@yargote/artifacts-cli 0.0.0-stage → 0.1.0-beta.10

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 (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +309 -2
  3. package/package.json +42 -4
  4. package/skills/artifacts-cli/SKILL.md +78 -0
  5. package/skills/artifacts-cli/references/commands.md +240 -0
  6. package/skills/artifacts-cli/references/verify-retained.md +119 -0
  7. package/skills/artifacts-cli/scripts/prepare-retained.mjs +149 -0
  8. package/skills/artifacts-design/SKILL.md +92 -0
  9. package/skills/artifacts-design/THIRD-PARTY-NOTICES.md +155 -0
  10. package/skills/artifacts-design/fonts/OFL.txt +96 -0
  11. package/skills/artifacts-design/fonts/bricolage-grotesque.woff2 +0 -0
  12. package/skills/artifacts-design/fonts/jetbrains-mono.woff2 +0 -0
  13. package/skills/artifacts-design/fonts/newsreader-italic.woff2 +0 -0
  14. package/skills/artifacts-design/fonts/newsreader.woff2 +0 -0
  15. package/skills/artifacts-design/fonts/public-sans-italic.woff2 +0 -0
  16. package/skills/artifacts-design/fonts/public-sans.woff2 +0 -0
  17. package/skills/artifacts-design/references/canvas.md +100 -0
  18. package/skills/artifacts-design/references/catppuccin.md +38 -0
  19. package/skills/artifacts-design/references/interface-checks.md +60 -0
  20. package/skills/artifacts-design/references/local-checks.md +70 -0
  21. package/skills/artifacts-design/references/visual-explanations.md +13 -0
  22. package/skills/artifacts-design/scripts/embed-fonts.mjs +246 -0
  23. package/skills/artifacts-design/scripts/verify-local.mjs +333 -0
  24. package/skills/artifacts-design/templates/starter.html +170 -0
  25. package/src/auth.d.ts +21 -0
  26. package/src/auth.js +415 -0
  27. package/src/credentials.d.ts +39 -0
  28. package/src/credentials.js +116 -0
  29. package/src/index.d.ts +2 -0
  30. package/src/index.js +343 -0
  31. package/src/operations.d.ts +174 -0
  32. package/src/operations.js +98 -0
  33. package/src/skills.d.ts +9 -0
  34. package/src/skills.js +181 -0
@@ -0,0 +1,170 @@
1
+ <!doctype html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Artifact starter</title>
7
+ <style>
8
+ /* Layout: one centered column; summary before detail, then the dominant table, chart, or diagram. */
9
+ :root{
10
+ --page:#eff1f5; --surface:#ffffff; --sunken:#e6e9ef; --line:#ccd0da; --control:#8c8fa1;
11
+ --fg:#4c4f69; --muted:#5c5f77; --accent:#1e66f5; --on-accent:#ffffff; --accent-ink:#1957d0;
12
+ --ok:#40a02b; --ok-ink:#307820; --warn:#df8e1d; --warn-ink:#915c13; --bad:#d20f39; --bad-ink:#b20d30;
13
+ --neutral-soft:color-mix(in srgb,var(--fg) 9%,var(--surface));
14
+ --accent-soft:color-mix(in srgb,var(--accent) 14%,var(--surface));
15
+ --ok-soft:color-mix(in srgb,var(--ok) 14%,var(--surface));
16
+ --warn-soft:color-mix(in srgb,var(--warn) 14%,var(--surface));
17
+ --bad-soft:color-mix(in srgb,var(--bad) 14%,var(--surface));
18
+ --f-display:"Bricolage Grotesque","Avenir Next",system-ui,sans-serif;
19
+ --f-body:"Public Sans","Segoe UI",system-ui,sans-serif;
20
+ --f-mono:"JetBrains Mono",ui-monospace,"SF Mono",Menlo,Consolas,monospace;
21
+ --radius:10px; --width:1120px;
22
+ color-scheme:light;
23
+ }
24
+ @media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
25
+ --page:#181825; --surface:#1e1e2e; --sunken:#11111b; --line:#313244; --control:#6c7086;
26
+ --fg:#cdd6f4; --muted:#a6adc8; --accent:#89b4fa; --on-accent:#11111b; --accent-ink:#89b4fa;
27
+ --ok:#a6e3a1; --ok-ink:#a6e3a1; --warn:#f9e2af; --warn-ink:#f9e2af; --bad:#f38ba8; --bad-ink:#f38ba8;
28
+ color-scheme:dark}}
29
+ :root[data-theme="dark"]{
30
+ --page:#181825; --surface:#1e1e2e; --sunken:#11111b; --line:#313244; --control:#6c7086;
31
+ --fg:#cdd6f4; --muted:#a6adc8; --accent:#89b4fa; --on-accent:#11111b; --accent-ink:#89b4fa;
32
+ --ok:#a6e3a1; --ok-ink:#a6e3a1; --warn:#f9e2af; --warn-ink:#f9e2af; --bad:#f38ba8; --bad-ink:#f38ba8;
33
+ color-scheme:dark}
34
+
35
+ *{box-sizing:border-box}
36
+ html{background:var(--page)}
37
+ body{margin:0;background:var(--page);color:var(--fg);font:15px/1.55 var(--f-body);-webkit-font-smoothing:antialiased}
38
+ .wrap{max-width:var(--width);margin:0 auto;padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px));padding-block:32px 64px;display:grid;gap:24px}
39
+ .wrap>*{min-width:0}
40
+ h1,h2,h3{font-family:var(--f-display);margin:0;text-wrap:balance;letter-spacing:-.01em}
41
+ h1{font-size:clamp(28px,4vw,40px);line-height:1.05;font-weight:700}
42
+ h2{font-size:clamp(20px,2.4vw,24px);line-height:1.2;font-weight:700}
43
+ h3{font-size:17px;line-height:1.3;font-weight:600}
44
+ p{margin:0}
45
+ a{color:var(--accent-ink);text-underline-offset:3px}
46
+ code,kbd,pre{font-family:var(--f-mono);font-size:.86em}
47
+ pre{margin:0;padding:14px 16px;background:var(--sunken);border-radius:8px;overflow-x:auto;line-height:1.5}
48
+ :focus-visible{outline:2px solid var(--accent);outline-offset:2px}
49
+
50
+ .eyebrow,.label{font:600 12px/1.35 var(--f-body);color:var(--muted)}
51
+ header{display:grid;gap:8px}
52
+ .lede{color:var(--muted);max-width:68ch;font-size:16px}
53
+ .prose{display:grid;gap:12px;max-width:68ch}
54
+ section{display:grid;gap:14px}
55
+
56
+ .strip{display:flex;flex-wrap:wrap;gap:1px;background:var(--line);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
57
+ .stat{flex:1 1 150px;display:grid;gap:2px;padding:14px 16px;background:var(--surface)}
58
+ .stat b{font:700 26px/1.1 var(--f-display);font-variant-numeric:tabular-nums}
59
+ .stat span{font-size:13px;color:var(--muted)}
60
+
61
+ .pill{display:inline-flex;align-items:center;gap:6px;font:600 11.5px/1.25 var(--f-body);padding:3px 8px;border-radius:5px;background:var(--neutral-soft);color:var(--muted)}
62
+ .pills{display:flex;flex-wrap:wrap;gap:5px}
63
+ .pill.accent{background:var(--accent-soft);color:var(--accent-ink)}
64
+ .pill.ok{background:var(--ok-soft);color:var(--ok-ink)}
65
+ .pill.warn{background:var(--warn-soft);color:var(--warn-ink)}
66
+ .pill.bad{background:var(--bad-soft);color:var(--bad-ink)}
67
+
68
+ .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:16px 18px;display:grid;gap:10px}
69
+ .note{border-left:3px solid var(--accent);padding:2px 0 2px 14px;display:grid;gap:4px;max-width:72ch}
70
+ .note.warn{border-color:var(--warn)}
71
+
72
+ .table{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);overflow-x:auto}
73
+ table{width:100%;border-collapse:collapse;font-size:14px}
74
+ th{font:600 12px/1.35 var(--f-body);color:var(--muted);text-align:left;padding:10px 14px;background:var(--sunken);border-bottom:1px solid var(--line);white-space:nowrap}
75
+ td{padding:11px 14px;border-bottom:1px solid var(--line);vertical-align:top;font-variant-numeric:tabular-nums}
76
+ tr:last-child td{border-bottom:0}
77
+ td.num,th.num{text-align:right;white-space:nowrap}
78
+
79
+ .rows{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
80
+ .rows-head,.row>summary{display:grid;grid-template-columns:var(--cols,88px minmax(0,2fr) minmax(0,1fr) minmax(0,1.4fr));gap:16px;padding:12px 16px;align-items:start}
81
+ .rows-head{font:600 12px/1.35 var(--f-body);color:var(--muted);background:var(--sunken);border-bottom:1px solid var(--line)}
82
+ .row{border-bottom:1px solid var(--line)}
83
+ .row:last-child{border-bottom:0}
84
+ .row>summary{cursor:pointer;list-style:none}
85
+ .row>summary::-webkit-details-marker{display:none}
86
+ .row>summary>*{min-width:0}
87
+ .row>summary>:first-child::before{content:"▸";display:inline-block;margin-right:6px;color:var(--muted);font-size:11px;transition:transform .15s}
88
+ .row[open]>summary>:first-child::before{transform:rotate(90deg)}
89
+ .row-detail{padding:4px 16px 18px;display:grid;grid-template-columns:repeat(auto-fit,minmax(min(260px,100%),1fr));gap:14px 28px}
90
+ .row-detail dl{margin:0;display:grid;gap:10px;align-content:start}
91
+ .row-detail dd{margin:2px 0 0;font-size:13.5px;overflow-wrap:anywhere}
92
+ .row.closed>summary{color:var(--muted)}
93
+
94
+ .bar{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
95
+ input,select,button{font:inherit;font-size:14px;color:var(--fg);background:var(--surface);border:1px solid var(--control);border-radius:8px;padding:8px 10px;min-width:0}
96
+ button{cursor:pointer}
97
+ button.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent);font-weight:600}
98
+ .chip{border:1px solid var(--line);border-radius:999px;padding:5px 11px;font-size:13px;font-weight:500}
99
+ .chip[aria-pressed="true"]{background:var(--accent-soft);border-color:var(--accent);color:var(--accent-ink)}
100
+ .count{margin-left:auto;font:13px var(--f-mono);color:var(--muted)}
101
+
102
+ footer{font-size:12.5px;color:var(--muted);display:grid;gap:4px;border-top:1px solid var(--line);padding-top:16px}
103
+
104
+ @media (max-width:720px){.rows-head{display:none}.row>summary{grid-template-columns:minmax(0,1fr);gap:6px}}
105
+ @media (hover:hover) and (pointer:fine){tbody tr:hover td,.row>summary:hover{background:var(--sunken)}button:hover{border-color:var(--accent)}}
106
+ @media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
107
+ </style>
108
+ </head>
109
+ <body>
110
+ <main class="wrap">
111
+ <header>
112
+ <div class="eyebrow">Specimen · artifacts-design</div>
113
+ <h1>Starter primitives</h1>
114
+ <p class="lede">Replace this body with the artifact's own composition. Keep the tokens and the primitives the page needs, and delete the rest.</p>
115
+ </header>
116
+
117
+ <section class="strip" aria-label="Summary">
118
+ <div class="stat"><b>34</b><span>Records</span></div>
119
+ <div class="stat"><b>23</b><span>Open</span></div>
120
+ <div class="stat"><b>4</b><span>Need attention</span></div>
121
+ </section>
122
+
123
+ <section>
124
+ <h2>Status and data</h2>
125
+ <div class="bar">
126
+ <input id="q" type="search" placeholder="Search" aria-label="Search">
127
+ <button class="chip" aria-pressed="true">All</button>
128
+ <button class="chip" aria-pressed="false">Open</button>
129
+ <span class="count">3 of 34</span>
130
+ </div>
131
+ <div class="table">
132
+ <table>
133
+ <thead><tr><th>Item</th><th>State</th><th class="num">Value</th></tr></thead>
134
+ <tbody>
135
+ <tr><td>Accent</td><td><span class="pill accent">In review</span></td><td class="num">1,204</td></tr>
136
+ <tr><td>Success</td><td><span class="pill ok">Passed</span></td><td class="num">98.6 %</td></tr>
137
+ <tr><td>Warning and error</td><td><span class="pill warn">Pending</span> <span class="pill bad">Failed</span></td><td class="num">−12</td></tr>
138
+ </tbody>
139
+ </table>
140
+ </div>
141
+ </section>
142
+
143
+ <section>
144
+ <h2>Expandable records</h2>
145
+ <div class="rows">
146
+ <div class="rows-head" aria-hidden="true"><span>ID</span><span>Title</span><span>Group</span><span>State</span></div>
147
+ <details class="row">
148
+ <summary><b>#2088</b><span>Parallelize search and listing data fetching</span><span>Performance</span><span class="pills"><span class="pill">Draft</span><span class="pill ok">Build passed</span></span></summary>
149
+ <div class="row-detail">
150
+ <dl><dt class="label">Next step</dt><dd>Keep separate and wait for team review.</dd></dl>
151
+ <dl><dt class="label">Branch</dt><dd><code>refactor/search-plp-parallel-fetch</code></dd></dl>
152
+ </div>
153
+ </details>
154
+ <details class="row closed">
155
+ <summary><b>#2086</b><span>Temporary workspace configuration</span><span>Temporary</span><span class="pills"><span class="pill">Closed</span></span></summary>
156
+ <div class="row-detail"><dl><dt class="label">Reason</dt><dd>Closed without merge by the owner.</dd></dl></div>
157
+ </details>
158
+ </div>
159
+ </section>
160
+
161
+ <section class="prose">
162
+ <h2>Reading text</h2>
163
+ <p>Body copy stays near 68 characters per line. Use <code>code</code> for identifiers and the <span class="label">label</span> style for metadata.</p>
164
+ <div class="note"><b>Note.</b><p>A note marks one consequential statement. It is not a container for ordinary paragraphs.</p></div>
165
+ </section>
166
+
167
+ <footer><span>Source and date of the data go here.</span></footer>
168
+ </main>
169
+ </body>
170
+ </html>
package/src/auth.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { type Identity } from '@yargote/artifacts-contracts/http';
2
+ export declare function login(options: {
3
+ resource: string;
4
+ stateDir: string;
5
+ noBrowser: boolean;
6
+ onAuthorizationUrl: (url: string) => void;
7
+ }): Promise<{
8
+ resource: string;
9
+ identity: Identity;
10
+ }>;
11
+ export declare function accessToken(options: {
12
+ resource: string;
13
+ stateDir: string;
14
+ }): Promise<string>;
15
+ export declare function logout(options: {
16
+ resource: string;
17
+ stateDir: string;
18
+ }): Promise<{
19
+ signedOut: true;
20
+ remoteRevocation: 'confirmed' | 'unconfirmed' | 'not-needed';
21
+ }>;
package/src/auth.js ADDED
@@ -0,0 +1,415 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { createServer } from 'node:http';
4
+ import * as oidc from 'openid-client';
5
+ import { canonicalResource, httpErrorSchema, identitySchema, protectedResourceMetadataSchema, protectedResourceMetadataUrl, restBase } from '@yargote/artifacts-contracts/http';
6
+ import { HttpError } from '@yargote/artifacts-client';
7
+ import { CredentialStore } from "./credentials.js";
8
+ const networkTimeout = 10_000;
9
+ const callbackTimeout = 180_000;
10
+ const scopes = 'openid profile offline_access artifacts:read artifacts:write artifacts:publish';
11
+ function endpoint(value, issuer) {
12
+ const url = new URL(value);
13
+ const loopback = /^http:\/\/(?:127\.0\.0\.1|\[::1\]|localhost)(?::[0-9]+)?(?:\/|$)/.test(value);
14
+ if (value.trim() !== value || url.username || url.password || url.search || url.hash || (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)))
15
+ throw new Error('OAuth URLs require HTTPS or an exact loopback HTTP host, without credentials, query, or fragment');
16
+ if (issuer && url.origin !== new URL(issuer).origin)
17
+ throw new Error('OAuth endpoints must use the issuer origin');
18
+ return url;
19
+ }
20
+ class AuthTransportError extends Error {
21
+ }
22
+ function safeUrl(value) {
23
+ try {
24
+ const url = new URL(value);
25
+ return `${url.origin}${url.pathname}`;
26
+ }
27
+ catch {
28
+ return '[invalid endpoint]';
29
+ }
30
+ }
31
+ function authFailure(context, code, explanation, status) {
32
+ const message = `${context.phase} ${context.method} ${safeUrl(context.url)}${status === undefined ? '' : ` HTTP ${status}`}: ${explanation}`;
33
+ return status === undefined ? new AuthTransportError(message) : new HttpError(status, code, message);
34
+ }
35
+ async function boundedFetch(context, options = {}) {
36
+ const timeout = AbortSignal.timeout(networkTimeout);
37
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
38
+ let response;
39
+ try {
40
+ response = await fetch(context.url, { ...options, redirect: 'manual', signal });
41
+ }
42
+ catch {
43
+ throw authFailure(context, 'network_error', timeout.aborted || (options.signal?.aborted && options.signal.reason instanceof DOMException && options.signal.reason.name === 'TimeoutError') ? 'Request timed out' : options.signal?.aborted ? 'Request cancelled' : 'Network request failed');
44
+ }
45
+ if (response.status >= 300 && response.status < 400) {
46
+ await response.body?.cancel().catch(() => { });
47
+ throw authFailure(context, 'redirect_refused', 'Redirect refused', response.status);
48
+ }
49
+ const reader = response.body?.getReader();
50
+ const chunks = [];
51
+ let size = 0;
52
+ if (reader) {
53
+ try {
54
+ for (;;) {
55
+ const chunk = await reader.read();
56
+ if (chunk.done)
57
+ break;
58
+ size += chunk.value.byteLength;
59
+ if (size > 1024 * 1024)
60
+ throw new Error('too large');
61
+ chunks.push(chunk.value);
62
+ }
63
+ }
64
+ catch {
65
+ await reader.cancel().catch(() => { });
66
+ throw authFailure(context, 'invalid_response', timeout.aborted || (options.signal?.aborted && options.signal.reason instanceof DOMException && options.signal.reason.name === 'TimeoutError') ? 'Response timed out' : options.signal?.aborted ? 'Request cancelled' : 'Cannot read response', response.status);
67
+ }
68
+ finally {
69
+ reader.releaseLock();
70
+ }
71
+ }
72
+ return new Response(size ? Buffer.concat(chunks) : null, { status: response.status, statusText: response.statusText, headers: response.headers });
73
+ }
74
+ function jsonResponse(response, context) {
75
+ if (response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase() !== 'application/json')
76
+ throw authFailure(context, 'invalid_response', 'Expected a JSON response', response.status);
77
+ return response.json().catch(() => { throw authFailure(context, 'invalid_response', 'Invalid JSON response', response.status); });
78
+ }
79
+ async function discoverResource(resource, terminal = false) {
80
+ const context = { phase: 'resource_metadata', method: 'GET', url: protectedResourceMetadataUrl(resource) };
81
+ const response = await boundedFetch(context);
82
+ if (!response.ok)
83
+ throw authFailure(context, response.status >= 500 ? 'service_error' : 'incompatible_resource', response.status >= 500 ? 'Resource metadata service failed' : 'Cannot discover the protected resource. Confirm the configured REST URL', response.status);
84
+ const document = await jsonResponse(response, context);
85
+ const parsed = protectedResourceMetadataSchema.safeParse(document);
86
+ if (!parsed.success)
87
+ throw authFailure(context, 'invalid_response', 'Invalid protected resource metadata', response.status);
88
+ if (parsed.data.resource !== resource)
89
+ throw authFailure(context, 'invalid_response', 'Protected resource metadata does not match the exact server URL', response.status);
90
+ if (terminal && parsed.data.artifacts_rest_resource !== undefined && parsed.data.artifacts_rest_resource !== resource)
91
+ throw authFailure(context, 'invalid_response', 'REST resource metadata must not advertise another artifacts_rest_resource', response.status);
92
+ const issuer = parsed.data.authorization_servers[0];
93
+ try {
94
+ endpoint(issuer);
95
+ }
96
+ catch {
97
+ throw authFailure(context, 'invalid_response', 'Invalid authorization server URL in protected resource metadata', response.status);
98
+ }
99
+ return { issuer, ...(parsed.data.artifacts_rest_resource === undefined ? {} : { restResource: parsed.data.artifacts_rest_resource }) };
100
+ }
101
+ function compatibleChallenge(value) {
102
+ if (!value)
103
+ return false;
104
+ const match = /^Bearer\s+(.+)$/i.exec(value);
105
+ if (!match)
106
+ return false;
107
+ const parameters = new Map();
108
+ const input = match[1];
109
+ const parameter = /\s*([!#$%&'*+.^_`|~0-9A-Za-z-]+)\s*=\s*(?:"((?:[^"\\]|\\[\x20-\x7e])*)"|([!#$%&'*+.^_`|~0-9A-Za-z-]+))\s*(,|$)/gy;
110
+ let position = 0;
111
+ while (position < input.length) {
112
+ parameter.lastIndex = position;
113
+ const param = parameter.exec(input);
114
+ if (!param || (param[4] === ',' && parameter.lastIndex === input.length))
115
+ return false;
116
+ const key = param[1].toLowerCase();
117
+ if (parameters.has(key))
118
+ return false;
119
+ parameters.set(key, param[2] === undefined ? param[3] : param[2].replace(/\\([\x20-\x7e])/g, '$1'));
120
+ position = parameter.lastIndex;
121
+ }
122
+ return parameters.get('error') === 'invalid_token' && (parameters.get('scope')?.split(/\s+/).includes('artifacts:read') ?? false);
123
+ }
124
+ async function checkIdentity(resource, request) {
125
+ const context = { phase: request.kind === 'anonymous' ? 'rest_preflight' : 'authenticated_identity', method: 'GET', url: `${restBase(resource)}/identity` };
126
+ const response = await boundedFetch(context, request.kind === 'anonymous' ? {} : { signal: request.signal, headers: { authorization: `Bearer ${request.accessToken}` } });
127
+ if (request.kind === 'anonymous') {
128
+ if (response.status === 401) {
129
+ const body = httpErrorSchema.safeParse(await jsonResponse(response, context));
130
+ if (body.success && body.data.error.code === 'unauthenticated' && compatibleChallenge(response.headers.get('www-authenticate')))
131
+ return;
132
+ }
133
+ throw authFailure(context, response.status >= 500 ? 'service_error' : 'incompatible_resource', response.status >= 500 ? 'REST identity service failed' : 'REST identity route is incompatible. Use the exact REST URL or configure artifacts_rest_resource on the MCP server', response.status);
134
+ }
135
+ if (!response.ok)
136
+ throw authFailure(context, response.status >= 500 ? 'service_error' : response.status === 401 || response.status === 403 ? 'access_denied' : 'incompatible_resource', response.status >= 500 ? 'REST identity service failed' : response.status === 401 || response.status === 403 ? 'OAuth grant cannot access this Artifacts server' : 'REST identity route is unavailable. Confirm the configured REST URL', response.status);
137
+ const parsed = identitySchema.safeParse(await jsonResponse(response, context));
138
+ if (!parsed.success)
139
+ throw authFailure(context, 'invalid_response', 'Invalid REST identity response', response.status);
140
+ if (request.claims.sub !== parsed.data.userId || (request.claims.sid !== undefined && request.claims.sid !== parsed.data.sessionId))
141
+ throw authFailure(context, 'invalid_response', 'OAuth identity does not match the Artifacts session', response.status);
142
+ return parsed.data;
143
+ }
144
+ async function oauth(issuer, signal, operation) {
145
+ const observations = [];
146
+ try {
147
+ const result = await operation(discoveryOptions(issuer, signal, observations));
148
+ if (result instanceof oidc.Configuration) {
149
+ validateConfiguration(result, issuer, observations);
150
+ result[oidc.customFetch] = discoveryOptions(issuer, signal)[oidc.customFetch];
151
+ }
152
+ return result;
153
+ }
154
+ catch (error) {
155
+ let cause = error;
156
+ let observed;
157
+ for (let depth = 0; depth < 5; depth++) {
158
+ if (cause instanceof HttpError || cause instanceof AuthTransportError)
159
+ throw cause;
160
+ const response = cause instanceof Response ? cause : cause instanceof Error && 'response' in cause ? cause.response : undefined;
161
+ if (response instanceof Response)
162
+ observed = observations.find((item) => item.response === response);
163
+ if (observed || !(cause instanceof Error))
164
+ break;
165
+ cause = cause.cause;
166
+ }
167
+ observed ??= observations.at(-1);
168
+ if (observed)
169
+ throw authFailure(observed.context, 'invalid_response', 'OAuth protocol validation failed', observed.response.status);
170
+ throw new Error('OAuth protocol validation failed');
171
+ }
172
+ }
173
+ function discoveryOptions(issuer, signal, observations) {
174
+ endpoint(issuer);
175
+ return {
176
+ timeout: networkTimeout / 1000,
177
+ execute: [...(new URL(issuer).protocol === 'http:' ? [oidc.allowInsecureRequests] : []), oidc.enableNonRepudiationChecks],
178
+ [oidc.customFetch]: async (url, options) => {
179
+ const context = { phase: 'oauth', method: options.method ?? 'GET', url };
180
+ try {
181
+ endpoint(url, issuer);
182
+ }
183
+ catch {
184
+ throw authFailure({ ...context, url: issuer }, 'invalid_response', 'Invalid OAuth endpoint');
185
+ }
186
+ const response = await boundedFetch(context, { ...options, signal: signal && options.signal ? AbortSignal.any([signal, options.signal]) : signal ?? options.signal, body: options.body instanceof Uint8Array ? Uint8Array.from(options.body) : options.body });
187
+ if (!response.ok)
188
+ throw authFailure(context, response.status >= 500 ? 'service_error' : 'oauth_error', response.status >= 500 ? 'OAuth service failed' : 'OAuth request failed', response.status);
189
+ observations?.push({ context, response });
190
+ return response;
191
+ },
192
+ };
193
+ }
194
+ function validateConfiguration(config, issuer, observations) {
195
+ const discovery = observations.find((item) => item.context.method === 'GET');
196
+ const registration = observations.find((item) => item.context.method === 'POST');
197
+ if (!discovery)
198
+ throw new Error('OAuth discovery response was not observed');
199
+ const failDiscovery = (message) => authFailure(discovery.context, 'invalid_response', message, discovery.response.status);
200
+ const metadata = config.serverMetadata();
201
+ if (metadata.issuer !== issuer)
202
+ throw failDiscovery('OAuth issuer metadata does not match the configured issuer');
203
+ for (const [key, value] of Object.entries(metadata)) {
204
+ if ((key.endsWith('_endpoint') || key === 'jwks_uri') && typeof value === 'string') {
205
+ try {
206
+ endpoint(value, issuer);
207
+ }
208
+ catch (error) {
209
+ const message = error instanceof Error && error.message === 'OAuth endpoints must use the issuer origin' ? error.message : 'OAuth URLs require HTTPS or an exact loopback HTTP host, without credentials, query, or fragment';
210
+ throw failDiscovery(message);
211
+ }
212
+ }
213
+ }
214
+ if (!metadata.authorization_endpoint || !metadata.token_endpoint)
215
+ throw failDiscovery('OAuth metadata is missing authorization or token endpoints');
216
+ if (config.clientMetadata().client_secret || config.clientMetadata().token_endpoint_auth_method !== 'none') {
217
+ const message = 'The CLI requires a public OAuth client without a secret';
218
+ throw registration ? authFailure(registration.context, 'invalid_response', message, registration.response.status) : new Error(message);
219
+ }
220
+ }
221
+ async function configuration(registration, signal) {
222
+ return oauth(registration.issuer, signal, (options) => oidc.discovery(new URL(registration.issuer), registration.clientId, { token_endpoint_auth_method: 'none', id_token_signed_response_alg: 'EdDSA' }, oidc.None(), options));
223
+ }
224
+ function tokenRecord(tokens, previousRefresh) {
225
+ if (typeof tokens.expires_in !== 'number' || !Number.isFinite(tokens.expires_in) || tokens.expires_in <= 0)
226
+ throw new Error('OAuth token response is missing a valid expiration');
227
+ return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token ?? previousRefresh, expiresAt: Date.now() + tokens.expires_in * 1000 };
228
+ }
229
+ async function callback() {
230
+ const controller = new AbortController();
231
+ let accept;
232
+ let reject;
233
+ let expectedState;
234
+ let expectedIssuer;
235
+ let redirectUri = '';
236
+ let settled = false;
237
+ const result = new Promise((resolve, fail) => { accept = resolve; reject = fail; });
238
+ void result.catch(() => { });
239
+ const server = createServer((request, response) => {
240
+ response.setHeader('cache-control', 'no-store');
241
+ response.setHeader('content-type', 'text/plain; charset=utf-8');
242
+ if (request.method !== 'GET' || !request.url || request.url.length > 8192 || request.headers.host !== new URL(redirectUri).host || request.url.split('?')[0] !== '/oauth/callback') {
243
+ response.writeHead(404).end('Not found');
244
+ return;
245
+ }
246
+ const url = new URL(request.url, redirectUri);
247
+ if (settled || !expectedState || url.searchParams.getAll('state').length !== 1 || url.searchParams.get('state') !== expectedState || url.searchParams.getAll('iss').length !== 1 || url.searchParams.get('iss') !== expectedIssuer || url.searchParams.getAll('code').length + url.searchParams.getAll('error').length !== 1) {
248
+ response.writeHead(400).end('Invalid OAuth callback');
249
+ return;
250
+ }
251
+ settled = true;
252
+ response.end('Authorization received. Return to the terminal to check the result.');
253
+ accept(url);
254
+ });
255
+ await new Promise((resolve, fail) => {
256
+ server.once('error', fail);
257
+ server.listen(0, '127.0.0.1', () => { server.removeListener('error', fail); resolve(); });
258
+ });
259
+ const address = server.address();
260
+ if (!address || typeof address === 'string')
261
+ throw new Error('Cannot bind the OAuth loopback callback');
262
+ redirectUri = `http://127.0.0.1:${address.port}/oauth/callback`;
263
+ const fail = (error) => { controller.abort(error); reject(error); };
264
+ const timer = setTimeout(() => fail(new Error('Login timed out. Run artifacts login again')), callbackTimeout);
265
+ const cancel = () => fail(new Error('Login cancelled'));
266
+ process.once('SIGINT', cancel);
267
+ process.once('SIGTERM', cancel);
268
+ return {
269
+ redirectUri, signal: controller.signal,
270
+ wait(state, issuer) { expectedState = state; expectedIssuer = issuer; return result; },
271
+ async close() {
272
+ clearTimeout(timer);
273
+ process.removeListener('SIGINT', cancel);
274
+ process.removeListener('SIGTERM', cancel);
275
+ server.closeAllConnections();
276
+ await new Promise((resolve) => server.close(() => resolve()));
277
+ },
278
+ };
279
+ }
280
+ async function openBrowser(url) {
281
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open';
282
+ const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
283
+ await new Promise((resolve, reject) => {
284
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
285
+ child.once('error', () => reject(new Error('Cannot open a browser. Run artifacts login with --no-browser')));
286
+ child.once('spawn', () => { child.unref(); resolve(); });
287
+ });
288
+ }
289
+ export async function login(options) {
290
+ const source = canonicalResource(options.resource);
291
+ const metadata = await discoverResource(source);
292
+ const resource = metadata.restResource ?? source;
293
+ const { issuer } = resource === source ? metadata : await discoverResource(resource, true);
294
+ await checkIdentity(resource, { kind: 'anonymous' });
295
+ const store = await CredentialStore.open(options.stateDir);
296
+ let listener;
297
+ try {
298
+ const epoch = await store.exclusive(() => {
299
+ const profile = store.read(resource);
300
+ store.write(resource, { ...profile, epoch: profile.epoch + 1 });
301
+ return profile.epoch + 1;
302
+ });
303
+ listener = await callback();
304
+ const { redirectUri, signal } = listener;
305
+ const config = await store.exclusive(async () => {
306
+ const profile = store.read(resource);
307
+ if (profile.epoch !== epoch)
308
+ throw new Error('Login was superseded by another login or logout');
309
+ if (profile.registration?.issuer === issuer)
310
+ return configuration(profile.registration, signal);
311
+ const registered = await oauth(issuer, signal, (options) => oidc.dynamicClientRegistration(new URL(issuer), {
312
+ client_name: 'Artifacts CLI', application_type: 'native', token_endpoint_auth_method: 'none', id_token_signed_response_alg: 'EdDSA',
313
+ redirect_uris: [redirectUri], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], scope: scopes,
314
+ }, oidc.None(), options));
315
+ store.write(resource, { ...profile, registration: { issuer, clientId: registered.clientMetadata().client_id }, tokens: null, refreshAttempt: null });
316
+ return registered;
317
+ });
318
+ const verifier = oidc.randomPKCECodeVerifier();
319
+ const state = oidc.randomState();
320
+ const nonce = oidc.randomNonce();
321
+ const authorizationUrl = oidc.buildAuthorizationUrl(config, {
322
+ redirect_uri: redirectUri, response_type: 'code', scope: scopes, resource,
323
+ code_challenge: await oidc.calculatePKCECodeChallenge(verifier), code_challenge_method: 'S256', state, nonce,
324
+ }).href;
325
+ const authorization = listener.wait(state, issuer);
326
+ options.onAuthorizationUrl(authorizationUrl);
327
+ if (!options.noBrowser)
328
+ await openBrowser(authorizationUrl);
329
+ const currentUrl = await authorization;
330
+ if (currentUrl.searchParams.has('error'))
331
+ throw new Error(currentUrl.searchParams.get('error') === 'access_denied' ? 'Authorization denied' : 'Authorization failed');
332
+ return await store.exclusive(async () => {
333
+ const profile = store.read(resource);
334
+ if (profile.epoch !== epoch)
335
+ throw new Error('Login was superseded by another login or logout');
336
+ const tokens = await oauth(issuer, signal, (options) => {
337
+ config[oidc.customFetch] = options[oidc.customFetch];
338
+ return oidc.authorizationCodeGrant(config, currentUrl, { expectedState: state, expectedNonce: nonce, pkceCodeVerifier: verifier, idTokenExpected: true }, { resource });
339
+ });
340
+ const credentials = tokenRecord(tokens);
341
+ const identity = await checkIdentity(resource, { kind: 'authenticated', accessToken: credentials.accessToken, signal, claims: tokens.claims() });
342
+ signal.throwIfAborted();
343
+ store.commitLogin(resource, epoch, credentials);
344
+ return { resource, identity };
345
+ });
346
+ }
347
+ finally {
348
+ await listener?.close();
349
+ store.close();
350
+ }
351
+ }
352
+ export async function accessToken(options) {
353
+ const resource = canonicalResource(options.resource);
354
+ const automationToken = process.env.ARTIFACTS_ACCESS_TOKEN;
355
+ if (automationToken !== undefined) {
356
+ if (!/^[A-Za-z0-9._~+/-]+=*$/.test(automationToken))
357
+ throw new Error('ARTIFACTS_ACCESS_TOKEN must contain a nonempty bearer token');
358
+ return automationToken;
359
+ }
360
+ const store = await CredentialStore.open(options.stateDir);
361
+ try {
362
+ return await store.exclusive(async () => {
363
+ const profile = store.read(resource);
364
+ const required = `Run artifacts login --server ${resource}`;
365
+ if (!profile.tokens || !profile.registration)
366
+ throw new Error(`You are not signed in. ${required}`);
367
+ if (profile.refreshAttempt)
368
+ throw new Error(`The previous token refresh has an uncertain result. ${required}`);
369
+ if (profile.tokens.expiresAt > Date.now() + 30_000)
370
+ return profile.tokens.accessToken;
371
+ if (!profile.tokens.refreshToken)
372
+ throw new Error(`Your session expired. ${required}`);
373
+ const config = await configuration(profile.registration);
374
+ // The separate SQLite lock survives this commit and releases automatically on process death.
375
+ store.write(resource, { ...profile, refreshAttempt: randomUUID() });
376
+ try {
377
+ const tokens = await oidc.refreshTokenGrant(config, profile.tokens.refreshToken, { resource });
378
+ const credentials = tokenRecord(tokens, profile.tokens.refreshToken);
379
+ store.write(resource, { ...profile, tokens: credentials, refreshAttempt: null });
380
+ return credentials.accessToken;
381
+ }
382
+ catch {
383
+ throw new Error(`Token refresh failed and its result is uncertain. ${required}`);
384
+ }
385
+ });
386
+ }
387
+ finally {
388
+ store.close();
389
+ }
390
+ }
391
+ export async function logout(options) {
392
+ const resource = canonicalResource(options.resource);
393
+ const store = await CredentialStore.open(options.stateDir);
394
+ let previous;
395
+ try {
396
+ previous = await store.exclusive(() => {
397
+ const profile = store.read(resource);
398
+ store.write(resource, { ...profile, epoch: profile.epoch + 1, tokens: null, refreshAttempt: null });
399
+ return profile;
400
+ });
401
+ }
402
+ finally {
403
+ store.close();
404
+ }
405
+ if (!previous.tokens?.refreshToken || !previous.registration)
406
+ return { signedOut: true, remoteRevocation: 'not-needed' };
407
+ try {
408
+ const config = await configuration(previous.registration);
409
+ await oidc.tokenRevocation(config, previous.tokens.refreshToken, { token_type_hint: 'refresh_token' });
410
+ return { signedOut: true, remoteRevocation: previous.refreshAttempt ? 'unconfirmed' : 'confirmed' };
411
+ }
412
+ catch {
413
+ return { signedOut: true, remoteRevocation: 'unconfirmed' };
414
+ }
415
+ }
@@ -0,0 +1,39 @@
1
+ import { z } from 'zod';
2
+ declare const registrationSchema: z.ZodObject<{
3
+ issuer: z.ZodString;
4
+ clientId: z.ZodString;
5
+ }, z.core.$strict>;
6
+ declare const tokensSchema: z.ZodObject<{
7
+ accessToken: z.ZodString;
8
+ refreshToken: z.ZodOptional<z.ZodString>;
9
+ expiresAt: z.ZodNumber;
10
+ }, z.core.$strict>;
11
+ declare const profileSchema: z.ZodObject<{
12
+ epoch: z.ZodNumber;
13
+ registration: z.ZodNullable<z.ZodObject<{
14
+ issuer: z.ZodString;
15
+ clientId: z.ZodString;
16
+ }, z.core.$strict>>;
17
+ tokens: z.ZodNullable<z.ZodObject<{
18
+ accessToken: z.ZodString;
19
+ refreshToken: z.ZodOptional<z.ZodString>;
20
+ expiresAt: z.ZodNumber;
21
+ }, z.core.$strict>>;
22
+ refreshAttempt: z.ZodNullable<z.ZodString>;
23
+ }, z.core.$strict>;
24
+ export type Registration = z.infer<typeof registrationSchema>;
25
+ export type Tokens = z.infer<typeof tokensSchema>;
26
+ export type CredentialProfile = z.infer<typeof profileSchema>;
27
+ export declare class CredentialStore {
28
+ private readonly data;
29
+ private readonly lock;
30
+ private constructor();
31
+ static open(stateDir: string): Promise<CredentialStore>;
32
+ exclusive<T>(work: () => T | Promise<T>): Promise<T>;
33
+ read(resource: string): CredentialProfile;
34
+ write(resource: string, profile: CredentialProfile): void;
35
+ readSelectedResource(): string | undefined;
36
+ commitLogin(resource: string, epoch: number, tokens: Tokens): void;
37
+ close(): void;
38
+ }
39
+ export {};