@elixpo/lixblogs-cli 1.3.3 → 1.4.4

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 (40) hide show
  1. package/README.md +9 -7
  2. package/dist/lixblogs.mjs +102 -0
  3. package/package.json +9 -10
  4. package/API.md +0 -104
  5. package/CHANGELOG.md +0 -10
  6. package/RELEASE.md +0 -30
  7. package/THREAT_MODEL.md +0 -91
  8. package/bin/lixblogs.mjs +0 -802
  9. package/src/api/AnalyticsClient.js +0 -40
  10. package/src/api/BlogClient.js +0 -140
  11. package/src/api/CollaborationClient.js +0 -73
  12. package/src/api/OrgClient.js +0 -158
  13. package/src/auth/AuthProvider.js +0 -90
  14. package/src/auth/AuthenticatedClient.js +0 -131
  15. package/src/auth/ElixpoAuthProvider.js +0 -281
  16. package/src/auth/MockAuthProvider.js +0 -170
  17. package/src/auth/productionGate.js +0 -44
  18. package/src/cli/contract.js +0 -46
  19. package/src/cli/ui.js +0 -54
  20. package/src/commands/analytics/index.js +0 -57
  21. package/src/commands/auth/login.js +0 -117
  22. package/src/commands/auth/logout.js +0 -21
  23. package/src/commands/auth/profileAlias.js +0 -29
  24. package/src/commands/auth/profiles.js +0 -27
  25. package/src/commands/auth/revoke.js +0 -45
  26. package/src/commands/auth/status.js +0 -33
  27. package/src/commands/blog/index.js +0 -85
  28. package/src/commands/blog/input.js +0 -59
  29. package/src/commands/collab/index.js +0 -53
  30. package/src/commands/org/index.js +0 -22
  31. package/src/commands/skill/index.js +0 -83
  32. package/src/config/CredentialStore.js +0 -142
  33. package/src/config/KeychainCredentialStore.js +0 -180
  34. package/src/config/ProfileRegistry.js +0 -105
  35. package/src/config/config.js +0 -60
  36. package/src/config/credentialStoreFactory.js +0 -63
  37. package/src/config/providerFactory.js +0 -42
  38. package/src/config/redact.js +0 -74
  39. package/src/content/markdown.js +0 -68
  40. package/src/content/validate.js +0 -45
@@ -1,40 +0,0 @@
1
- import { BlogApiError } from './BlogClient.js';
2
-
3
- async function parseResponse(response) {
4
- let payload;
5
- try { payload = await response.json(); } catch { payload = null; }
6
- if (!response.ok || payload?.error) {
7
- throw new BlogApiError(
8
- payload?.error?.code || `http_${response.status}`,
9
- payload?.error?.message || `LixBlogs returned HTTP ${response.status}.`,
10
- { status: response.status, requestId: payload?.error?.requestId || response.headers.get('x-request-id'), details: payload?.error?.details },
11
- );
12
- }
13
- return payload;
14
- }
15
-
16
- export class AnalyticsClient {
17
- constructor(authenticatedClient) {
18
- this.http = authenticatedClient;
19
- }
20
-
21
- async query(options = {}) {
22
- const scope = options.scope || 'personal';
23
- await this.http.requireScopes([
24
- 'lixblogs:analytics:read',
25
- ...(scope.startsWith('org:') ? ['lixblogs:organizations:read'] : []),
26
- ]);
27
- const query = new URLSearchParams({
28
- scope,
29
- range: options.range || (options.from || options.to ? 'custom' : '30d'),
30
- dimension: options.dimension || 'overview',
31
- limit: String(options.limit || 20),
32
- });
33
- if (options.from) query.set('from', options.from);
34
- if (options.to) query.set('to', options.to);
35
- if (options.cursor) query.set('cursor', options.cursor);
36
- return parseResponse(await this.http.request(`/api/v1/analytics?${query}`, {
37
- headers: { accept: 'application/json' },
38
- }));
39
- }
40
- }
@@ -1,140 +0,0 @@
1
- import { randomUUID } from 'node:crypto';
2
-
3
- export class BlogApiError extends Error {
4
- constructor(code, message, { status, requestId, details } = {}) {
5
- super(message);
6
- this.name = 'BlogApiError';
7
- this.code = code || 'api_error';
8
- this.status = status || 0;
9
- this.requestId = requestId || null;
10
- this.details = details || null;
11
- }
12
- }
13
-
14
- async function parseResponse(response) {
15
- let payload;
16
- try { payload = await response.json(); } catch { payload = null; }
17
- if (!response.ok || payload?.error) {
18
- throw new BlogApiError(
19
- payload?.error?.code || `http_${response.status}`,
20
- payload?.error?.message || `LixBlogs returned HTTP ${response.status}.`,
21
- {
22
- status: response.status,
23
- requestId: payload?.error?.requestId || response.headers.get('x-request-id'),
24
- details: payload?.error?.details,
25
- },
26
- );
27
- }
28
- return { payload, etag: response.headers.get('etag') };
29
- }
30
-
31
- export class BlogClient {
32
- constructor(authenticatedClient, { sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)) } = {}) {
33
- this.http = authenticatedClient;
34
- this.sleep = sleep;
35
- }
36
-
37
- async request(path, options = {}) {
38
- const requestOptions = {
39
- ...options,
40
- headers: {
41
- accept: 'application/json',
42
- ...(options.body ? { 'content-type': 'application/json' } : {}),
43
- ...options.headers,
44
- },
45
- };
46
- const method = requestOptions.method || 'GET';
47
- const retryable = method === 'GET' || Boolean(requestOptions.headers['idempotency-key']);
48
- for (let attempt = 0; attempt < 2; attempt += 1) {
49
- try {
50
- const response = await this.http.request(path, requestOptions);
51
- if (retryable && attempt === 0 && (response.status === 429 || response.status >= 500)) {
52
- const seconds = Math.min(2, Number.parseInt(response.headers.get('retry-after') || '1', 10) || 1);
53
- await this.sleep(seconds * 1000);
54
- continue;
55
- }
56
- return parseResponse(response);
57
- } catch (error) {
58
- if (!retryable || attempt > 0 || error instanceof BlogApiError || error?.code) throw error;
59
- await this.sleep(250);
60
- }
61
- }
62
- throw new BlogApiError('request_failed', 'The LixBlogs request failed after retrying.');
63
- }
64
-
65
- async requireScopes(scopes) {
66
- if (typeof this.http.requireScopes === 'function') await this.http.requireScopes(scopes);
67
- }
68
-
69
- async whoami() {
70
- await this.requireScopes(['lixblogs:profile:read']);
71
- return (await this.request('/api/v1/me')).payload.data;
72
- }
73
-
74
- async list({ status = 'all', limit = 20, cursor } = {}) {
75
- await this.requireScopes(['lixblogs:blog:read']);
76
- const query = new URLSearchParams({ status, limit: String(limit) });
77
- if (cursor) query.set('cursor', cursor);
78
- return (await this.request(`/api/v1/blogs?${query}`)).payload;
79
- }
80
-
81
- async get(id) {
82
- await this.requireScopes(['lixblogs:blog:read']);
83
- const result = await this.request(`/api/v1/blogs/${encodeURIComponent(id)}`);
84
- return { ...result.payload.data, etag: result.etag || result.payload.data.etag };
85
- }
86
-
87
- async create(input, { idempotencyKey = randomUUID() } = {}) {
88
- await this.requireScopes(['lixblogs:blog:write']);
89
- return (await this.request('/api/v1/blogs', {
90
- method: 'POST',
91
- headers: { 'idempotency-key': idempotencyKey },
92
- body: JSON.stringify(input),
93
- })).payload.data;
94
- }
95
-
96
- async update(id, input, { etag }) {
97
- await this.requireScopes(['lixblogs:blog:write']);
98
- return (await this.request(`/api/v1/blogs/${encodeURIComponent(id)}`, {
99
- method: 'PATCH',
100
- headers: { 'if-match': etag },
101
- body: JSON.stringify(input),
102
- })).payload.data;
103
- }
104
-
105
- async publish(id, { etag, idempotencyKey = randomUUID() }) {
106
- await this.requireScopes(['lixblogs:blog:publish']);
107
- return (await this.request(`/api/v1/blogs/${encodeURIComponent(id)}/publish`, {
108
- method: 'POST',
109
- headers: { 'if-match': etag, 'idempotency-key': idempotencyKey },
110
- })).payload.data;
111
- }
112
-
113
- async unpublish(id, { etag }) {
114
- await this.requireScopes(['lixblogs:blog:publish']);
115
- return (await this.request(`/api/v1/blogs/${encodeURIComponent(id)}/unpublish`, {
116
- method: 'POST', headers: { 'if-match': etag },
117
- })).payload.data;
118
- }
119
-
120
- async delete(id, { etag, permanent = false }) {
121
- await this.requireScopes([
122
- 'lixblogs:blog:delete',
123
- ...(permanent ? ['lixblogs:blog:delete:permanent'] : []),
124
- ]);
125
- return (await this.request(`/api/v1/blogs/${encodeURIComponent(id)}${permanent ? '?permanent=true' : ''}`, {
126
- method: 'DELETE',
127
- headers: {
128
- 'if-match': etag,
129
- ...(permanent ? { 'x-confirm-permanent-delete': id } : {}),
130
- },
131
- })).payload.data;
132
- }
133
-
134
- async restore(id, { etag }) {
135
- await this.requireScopes(['lixblogs:blog:delete']);
136
- return (await this.request(`/api/v1/blogs/${encodeURIComponent(id)}/restore`, {
137
- method: 'POST', headers: { 'if-match': etag },
138
- })).payload.data;
139
- }
140
- }
@@ -1,73 +0,0 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { BlogApiError } from './BlogClient.js';
3
-
4
- async function parseResponse(response) {
5
- let payload;
6
- try { payload = await response.json(); } catch { payload = null; }
7
- if (!response.ok || payload?.error) {
8
- throw new BlogApiError(payload?.error?.code || `http_${response.status}`, payload?.error?.message || `LixBlogs returned HTTP ${response.status}.`, {
9
- status: response.status,
10
- requestId: payload?.error?.requestId || response.headers.get('x-request-id'),
11
- details: payload?.error?.details,
12
- });
13
- }
14
- return payload.data;
15
- }
16
-
17
- export class CollaborationClient {
18
- constructor(authenticatedClient) {
19
- this.http = authenticatedClient;
20
- }
21
-
22
- async request(path, options = {}) {
23
- const response = await this.http.request(path, {
24
- ...options,
25
- headers: {
26
- accept: 'application/json',
27
- ...(options.body ? { 'content-type': 'application/json' } : {}),
28
- ...options.headers,
29
- },
30
- });
31
- return parseResponse(response);
32
- }
33
-
34
- async list(blogId) {
35
- await this.http.requireScopes(['lixblogs:collaboration:read']);
36
- return this.request(`/api/v1/blogs/${encodeURIComponent(blogId)}/collaborators`);
37
- }
38
-
39
- async invitations() {
40
- await this.http.requireScopes(['lixblogs:collaboration:read']);
41
- return this.request('/api/v1/collaboration/invitations');
42
- }
43
-
44
- async invite(blogId, { user, role, idempotencyKey = randomUUID() }) {
45
- await this.http.requireScopes(['lixblogs:collaboration:write']);
46
- return this.request(`/api/v1/blogs/${encodeURIComponent(blogId)}/collaborators`, {
47
- method: 'POST', headers: { 'idempotency-key': idempotencyKey }, body: JSON.stringify({ user, role }),
48
- });
49
- }
50
-
51
- async role(blogId, { user, role, idempotencyKey = randomUUID() }) {
52
- await this.http.requireScopes(['lixblogs:collaboration:write']);
53
- return this.request(`/api/v1/blogs/${encodeURIComponent(blogId)}/collaborators`, {
54
- method: 'PATCH', headers: { 'idempotency-key': idempotencyKey }, body: JSON.stringify({ user, role }),
55
- });
56
- }
57
-
58
- async remove(blogId, { user, idempotencyKey = randomUUID() } = {}) {
59
- await this.http.requireScopes(['lixblogs:collaboration:write']);
60
- return this.request(`/api/v1/blogs/${encodeURIComponent(blogId)}/collaborators`, {
61
- method: 'DELETE', headers: { 'idempotency-key': idempotencyKey }, body: JSON.stringify({ ...(user ? { user } : {}) }),
62
- });
63
- }
64
-
65
- async resolveInvitation(blogId, { action, showOnProfile = true, idempotencyKey = randomUUID() }) {
66
- await this.http.requireScopes(['lixblogs:collaboration:write']);
67
- return this.request('/api/v1/collaboration/invitations', {
68
- method: 'POST',
69
- headers: { 'idempotency-key': idempotencyKey },
70
- body: JSON.stringify({ blogId, action, showOnProfile }),
71
- });
72
- }
73
- }
@@ -1,158 +0,0 @@
1
- import { BlogApiError } from "./BlogClient.js";
2
-
3
- async function parseResponse(response) {
4
- let payload;
5
- try {
6
- payload = await response.json();
7
- } catch {
8
- payload = null;
9
- }
10
- if (!response.ok || payload?.error) {
11
- throw new BlogApiError(
12
- payload?.error?.code || `http_${response.status}`,
13
- payload?.error?.message ||
14
- `LixBlogs returned HTTP ${response.status}.`,
15
- {
16
- status: response.status,
17
- requestId:
18
- payload?.error?.requestId ||
19
- response.headers.get("x-request-id"),
20
- details: payload?.error?.details,
21
- },
22
- );
23
- }
24
- return { payload, etag: response.headers.get("etag") };
25
- }
26
-
27
- export class OrgClient {
28
- constructor(
29
- authenticatedClient,
30
- {
31
- sleep = (milliseconds) =>
32
- new Promise((resolve) => setTimeout(resolve, milliseconds)),
33
- } = {},
34
- ) {
35
- this.http = authenticatedClient;
36
- this.sleep = sleep;
37
- }
38
-
39
- async request(path, options = {}) {
40
- const requestOptions = {
41
- ...options,
42
- headers: {
43
- accept: "application/json",
44
- ...(options.body ? { "content-type": "application/json" } : {}),
45
- ...options.headers,
46
- },
47
- };
48
- const method = requestOptions.method || "GET";
49
- const retryable = method === "GET";
50
- for (let attempt = 0; attempt < 2; attempt += 1) {
51
- try {
52
- const response = await this.http.request(path, requestOptions);
53
- if (
54
- retryable &&
55
- attempt === 0 &&
56
- (response.status === 429 || response.status >= 500)
57
- ) {
58
- const seconds = Math.min(
59
- 2,
60
- Number.parseInt(
61
- response.headers.get("retry-after") || "1",
62
- 10,
63
- ) || 1,
64
- );
65
- await this.sleep(seconds * 1000);
66
- continue;
67
- }
68
- return parseResponse(response);
69
- } catch (error) {
70
- if (
71
- !retryable ||
72
- attempt > 0 ||
73
- error instanceof BlogApiError ||
74
- error?.code
75
- )
76
- throw error;
77
- await this.sleep(250);
78
- }
79
- }
80
- throw new BlogApiError(
81
- "request_failed",
82
- "The LixBlogs request failed after retrying.",
83
- );
84
- }
85
-
86
- async requireScopes(scopes) {
87
- if (typeof this.http.requireScopes === "function")
88
- await this.http.requireScopes(scopes);
89
- }
90
-
91
- async list() {
92
- await this.requireScopes(["lixblogs:organizations:read"]);
93
- return (await this.request("/api/v1/orgs")).payload;
94
- }
95
-
96
- async get(id) {
97
- if (!id) throw new Error("An organization ID or handle is required.");
98
- await this.requireScopes(["lixblogs:organizations:read"]);
99
- return (await this.request(`/api/v1/orgs/${encodeURIComponent(id)}`))
100
- .payload.data;
101
- }
102
-
103
- async collections(id) {
104
- if (!id) throw new Error("An organization ID or handle is required.");
105
- await this.requireScopes(["lixblogs:organizations:read"]);
106
- return (
107
- await this.request(
108
- `/api/v1/orgs/${encodeURIComponent(id)}/collections`,
109
- )
110
- ).payload.data;
111
- }
112
-
113
- async members(id) {
114
- if (!id) throw new Error("An organization ID or handle is required.");
115
- await this.requireScopes(["lixblogs:organizations:read"]);
116
- return (
117
- await this.request(`/api/v1/orgs/${encodeURIComponent(id)}/members`)
118
- ).payload.data;
119
- }
120
-
121
- async targets() {
122
- await this.requireScopes(["lixblogs:organizations:read"]);
123
- const orgsList = await this.list();
124
- const orgs = orgsList?.data || [];
125
- const writableOrgs = orgs.filter((org) => org.canWrite);
126
-
127
- const orgTargets = await Promise.all(
128
- writableOrgs.map(async (org) => {
129
- let cols = [];
130
- try {
131
- cols = await this.collections(org.id);
132
- } catch {
133
- cols = [];
134
- }
135
- return {
136
- target: `org:${org.id}`,
137
- orgId: org.id,
138
- slug: org.slug,
139
- name: org.name,
140
- role: org.role,
141
- collections: cols.map((col) => ({
142
- id: col.id,
143
- slug: col.slug,
144
- name: col.name,
145
- })),
146
- };
147
- }),
148
- );
149
-
150
- return {
151
- personal: {
152
- target: "personal",
153
- name: "Personal Blog",
154
- },
155
- organizations: orgTargets,
156
- };
157
- }
158
- }
@@ -1,90 +0,0 @@
1
- /**
2
- * AuthProvider — the single interface every auth implementation must satisfy.
3
- *
4
- * No CLI or API code should ever call a provider-specific method directly.
5
- * Everything goes through this interface, so swapping MockAuthProvider for
6
- * the real ElixpoAuthProvider (once accounts.elixpo.com confirms device-flow
7
- * support) requires no changes to calling code.
8
- *
9
- * See: elixpo/blogs.elixpo#137 for the full requirements this interface
10
- * exists to satisfy.
11
- */
12
-
13
- /**
14
- * @typedef {Object} DeviceCodeResponse
15
- * @property {string} deviceCode - Opaque code the client polls with.
16
- * @property {string} userCode - Short code the user enters at verificationUri.
17
- * @property {string} verificationUri - URL the user visits to approve login.
18
- * @property {string} [verificationUriComplete] - Approval URL with user code pre-filled.
19
- * @property {number} expiresInSeconds - How long the device code is valid for.
20
- * @property {number} pollIntervalSeconds - Minimum seconds between poll attempts.
21
- */
22
-
23
- /**
24
- * @typedef {Object} TokenResponse
25
- * @property {string} accessToken
26
- * @property {string} refreshToken
27
- * @property {number} expiresInSeconds
28
- * @property {string[]} scopes - Scopes actually granted (may be a subset of requested).
29
- */
30
-
31
- /**
32
- * @typedef {"pending"|"approved"|"denied"|"expired"|"slow_down"} PollStatus
33
- */
34
-
35
- /**
36
- * @typedef {Object} PollResult
37
- * @property {PollStatus} status
38
- * @property {TokenResponse} [token] - Present only when status === "approved".
39
- * @property {number} [pollIntervalIncreaseSeconds] - Present only when
40
- * status === "slow_down". Caller must add this to its current polling
41
- * interval before the next poll (RFC 8628 §3.5 behavior).
42
- */
43
-
44
- export class AuthProvider {
45
- /**
46
- * Identifies which implementation this is. Used by the production safety
47
- * gate to refuse anything but the approved provider in production.
48
- * @returns {string} e.g. "mock" | "elixpo"
49
- */
50
- get providerId() {
51
- throw new Error("AuthProvider.providerId must be implemented by subclass");
52
- }
53
-
54
- /**
55
- * Start a device authorization request.
56
- * @param {{ scopes: string[] }} params
57
- * @returns {Promise<DeviceCodeResponse>}
58
- */
59
- async requestDeviceCode(_params) {
60
- throw new Error("AuthProvider.requestDeviceCode must be implemented by subclass");
61
- }
62
-
63
- /**
64
- * Poll for whether the user has approved/denied the device code yet.
65
- * Callers are responsible for respecting pollIntervalSeconds between calls.
66
- * @param {{ deviceCode: string }} params
67
- * @returns {Promise<PollResult>}
68
- */
69
- async pollDeviceCode(_params) {
70
- throw new Error("AuthProvider.pollDeviceCode must be implemented by subclass");
71
- }
72
-
73
- /**
74
- * Exchange a refresh token for a new access token.
75
- * @param {{ refreshToken: string, scopes?: string[] }} params
76
- * @returns {Promise<TokenResponse>}
77
- */
78
- async refresh(_params) {
79
- throw new Error("AuthProvider.refresh must be implemented by subclass");
80
- }
81
-
82
- /**
83
- * Revoke a token (access or refresh). Must not throw if already revoked.
84
- * @param {{ token: string }} params
85
- * @returns {Promise<void>}
86
- */
87
- async revoke(_params) {
88
- throw new Error("AuthProvider.revoke must be implemented by subclass");
89
- }
90
- }
@@ -1,131 +0,0 @@
1
- import { AuthProviderError } from "./ElixpoAuthProvider.js";
2
-
3
- const DEFAULT_REFRESH_SKEW_MS = 60_000;
4
- const storeLocks = new WeakMap();
5
-
6
- function profileLocks(credentialStore) {
7
- let locks = storeLocks.get(credentialStore);
8
- if (!locks) {
9
- locks = new Map();
10
- storeLocks.set(credentialStore, locks);
11
- }
12
- return locks;
13
- }
14
-
15
- export class LoginRequiredError extends Error {
16
- constructor(profileId) {
17
- super(`Profile "${profileId}" needs to log in again.`);
18
- this.name = "LoginRequiredError";
19
- this.code = "login_required";
20
- }
21
- }
22
-
23
- export class ApiContractUnavailableError extends Error {
24
- constructor(status, contentType) {
25
- super("The configured LixBlogs origin is not serving the API v1 JSON contract.");
26
- this.name = "ApiContractUnavailableError";
27
- this.code = "api_contract_unavailable";
28
- this.status = status;
29
- this.details = { contentType: contentType || "unknown" };
30
- this.hint = "Deploy the LixBlogs API v1 stack, or select an origin that exposes /api/v1.";
31
- }
32
- }
33
-
34
- export class AuthenticatedClient {
35
- constructor({
36
- provider,
37
- credentialStore,
38
- profileId,
39
- apiBaseUrl = "https://blogs.elixpo.com",
40
- fetchImpl = globalThis.fetch,
41
- refreshSkewMs = DEFAULT_REFRESH_SKEW_MS,
42
- }) {
43
- this.provider = provider;
44
- this.credentialStore = credentialStore;
45
- this.profileId = profileId;
46
- this.apiBaseUrl = new URL(apiBaseUrl);
47
- this.fetchImpl = fetchImpl;
48
- this.refreshSkewMs = refreshSkewMs;
49
- }
50
-
51
- async _refresh(credentials, { force = false } = {}) {
52
- const locks = profileLocks(this.credentialStore);
53
- const existing = locks.get(this.profileId);
54
- if (existing) return existing;
55
-
56
- const operation = (async () => {
57
- const latest = (await this.credentialStore.get(this.profileId)) || credentials;
58
- if (!force && latest.expiresAt - Date.now() > this.refreshSkewMs) return latest;
59
- try {
60
- const token = await this.provider.refresh({
61
- refreshToken: latest.refreshToken,
62
- scopes: latest.scopes,
63
- });
64
- const rotated = {
65
- accessToken: token.accessToken,
66
- refreshToken: token.refreshToken,
67
- expiresAt: Date.now() + token.expiresInSeconds * 1000,
68
- scopes: token.scopes,
69
- };
70
- await this.credentialStore.set(this.profileId, rotated);
71
- return rotated;
72
- } catch (error) {
73
- if (error instanceof AuthProviderError && error.requiresLogin) {
74
- await this.credentialStore.delete(this.profileId);
75
- throw new LoginRequiredError(this.profileId);
76
- }
77
- throw error;
78
- }
79
- })();
80
-
81
- locks.set(this.profileId, operation);
82
- try {
83
- return await operation;
84
- } finally {
85
- if (locks.get(this.profileId) === operation) locks.delete(this.profileId);
86
- }
87
- }
88
-
89
- async credentials({ forceRefresh = false } = {}) {
90
- const stored = await this.credentialStore.get(this.profileId);
91
- if (!stored) throw new LoginRequiredError(this.profileId);
92
- if (forceRefresh || stored.expiresAt - Date.now() <= this.refreshSkewMs) {
93
- return this._refresh(stored, { force: forceRefresh });
94
- }
95
- return stored;
96
- }
97
-
98
- async request(url, options = {}) {
99
- const target = new URL(url, this.apiBaseUrl);
100
- if (target.origin !== this.apiBaseUrl.origin || !target.pathname.startsWith("/api/v1/")) {
101
- throw new Error("Authenticated CLI requests are restricted to the configured LixBlogs /api/v1 resource server.");
102
- }
103
- let credentials = await this.credentials();
104
- const send = () => this.fetchImpl(target.toString(), {
105
- ...options,
106
- headers: { ...options.headers, authorization: `Bearer ${credentials.accessToken}` },
107
- });
108
- let response = await send();
109
- if (response.status === 401) {
110
- credentials = await this.credentials({ forceRefresh: true });
111
- response = await send();
112
- }
113
- const contentType = response.headers.get("content-type") || "";
114
- if (!contentType.toLowerCase().includes("application/json")) {
115
- throw new ApiContractUnavailableError(response.status, contentType);
116
- }
117
- return response;
118
- }
119
-
120
- async requireScopes(required) {
121
- const credentials = await this.credentials();
122
- const missing = required.filter((scope) => !credentials.scopes.includes(scope));
123
- if (missing.length) {
124
- const error = new Error(`Login again with the required scope${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`);
125
- error.name = 'InsufficientScopeError';
126
- error.code = 'insufficient_scope';
127
- error.missingScopes = missing;
128
- throw error;
129
- }
130
- }
131
- }