@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,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
|
-
}
|
package/src/api/BlogClient.js
DELETED
|
@@ -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
|
-
}
|
package/src/api/OrgClient.js
DELETED
|
@@ -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
|
-
}
|
package/src/auth/AuthProvider.js
DELETED
|
@@ -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
|
-
}
|