@ban-sky/dsh-github 0.0.0-stage → 0.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.
@@ -0,0 +1,390 @@
1
+ /**
2
+ * Thin GitHub REST client: device-flow sign-in and the repository calls the
3
+ * plugin needs.
4
+ *
5
+ * Security properties:
6
+ * - the token travels only in the `Authorization` header, never in a URL,
7
+ * query string, or log line;
8
+ * - redirects are refused, so a 30x cannot move the token to another host;
9
+ * - every call carries a hard timeout and a caller-supplied abort signal.
10
+ *
11
+ * @module dsh-github/lib/github-api
12
+ */
13
+
14
+ import { GithubError } from './errors.js'
15
+
16
+ const API_ROOT = 'https://api.github.com'
17
+ const DEVICE_CODE_URL = 'https://github.com/login/device/code'
18
+ const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token'
19
+ const USER_AGENT = 'dsh-github/0.4.2'
20
+ const DEFAULT_TIMEOUT_MS = 20000
21
+
22
+ /**
23
+ * OAuth scopes requested at sign-in.
24
+ *
25
+ * `repo` covers private repositories. `workflow` is what GitHub demands before
26
+ * it lets a token create or update `.github/workflows/*` files: with a
27
+ * `repo`-only token `git push` fails with "refusing to allow an OAuth App to
28
+ * create or update workflow ... without `workflow` scope". One Device Flow
29
+ * authorization grants both, so asking together costs nothing extra.
30
+ */
31
+ const REQUESTED_SCOPE = 'repo workflow'
32
+
33
+ /** Scopes the plugin relies on, used to flag tokens that need re-authorization. */
34
+ export const REQUIRED_SCOPES = Object.freeze(['repo', 'workflow'])
35
+
36
+ /**
37
+ * Scopes from {@link REQUIRED_SCOPES} that a granted scope string does not cover.
38
+ *
39
+ * GitHub reports granted scopes comma separated ("repo, workflow"); older
40
+ * tokens can report a space separated or even empty string.
41
+ *
42
+ * @param {string} granted - Scope string stored with the token.
43
+ * @returns {string[]} Missing scopes, in REQUIRED_SCOPES order.
44
+ */
45
+ export function missingScopes(granted) {
46
+ const held = new Set(String(granted ?? '').split(/[,\s]+/).filter(Boolean))
47
+ return REQUIRED_SCOPES.filter((scope) => !held.has(scope))
48
+ }
49
+
50
+ /**
51
+ * @typedef {object} DeviceCode
52
+ * @property {string} deviceCode
53
+ * @property {string} userCode
54
+ * @property {string} verificationUri
55
+ * @property {number} expiresIn - Seconds until the code expires.
56
+ * @property {number} interval - Minimum polling interval in seconds.
57
+ */
58
+
59
+ /**
60
+ * @typedef {object} Repository
61
+ * @property {number} id
62
+ * @property {string} fullName
63
+ * @property {string} name
64
+ * @property {string} owner
65
+ * @property {boolean} private
66
+ * @property {string} description
67
+ * @property {string} defaultBranch
68
+ * @property {string} updatedAt
69
+ * @property {string} htmlUrl
70
+ * @property {boolean} canPush
71
+ */
72
+
73
+ /**
74
+ * Sleep that rejects when the signal aborts.
75
+ *
76
+ * @param {number} ms - Delay.
77
+ * @param {AbortSignal | undefined} signal - Cancellation.
78
+ * @returns {Promise<void>}
79
+ */
80
+ export function sleep(ms, signal) {
81
+ return new Promise((resolve, reject) => {
82
+ if (signal?.aborted === true) {
83
+ reject(new GithubError('cancelled', 'the operation was cancelled'))
84
+ return
85
+ }
86
+ const timer = setTimeout(() => {
87
+ signal?.removeEventListener('abort', onAbort)
88
+ resolve()
89
+ }, ms)
90
+ const onAbort = () => {
91
+ clearTimeout(timer)
92
+ reject(new GithubError('cancelled', 'the operation was cancelled'))
93
+ }
94
+ signal?.addEventListener('abort', onAbort, { once: true })
95
+ })
96
+ }
97
+
98
+ /**
99
+ * One HTTP request against GitHub, JSON in and JSON out.
100
+ *
101
+ * @param {string} url - Absolute URL.
102
+ * @param {object} [options] - Request options.
103
+ * @param {'GET' | 'POST'} [options.method] - HTTP method.
104
+ * @param {string} [options.token] - Access token (sent as a Bearer header).
105
+ * @param {unknown} [options.body] - JSON body.
106
+ * @param {AbortSignal} [options.signal] - Cancellation.
107
+ * @param {number} [options.timeoutMs] - Timeout in milliseconds.
108
+ * @param {string} [options.accept] - Accept header.
109
+ * @returns {Promise<any>}
110
+ */
111
+ async function request(url, options = {}) {
112
+ const {
113
+ method = 'GET',
114
+ token,
115
+ body,
116
+ signal,
117
+ timeoutMs = DEFAULT_TIMEOUT_MS,
118
+ accept = 'application/vnd.github+json',
119
+ } = options
120
+ const signals = [AbortSignal.timeout(timeoutMs)]
121
+ if (signal !== undefined) signals.push(signal)
122
+ /** @type {Record<string, string>} */
123
+ const headers = { accept, 'user-agent': USER_AGENT }
124
+ if (typeof token === 'string' && token !== '') headers.authorization = `Bearer ${token}`
125
+ if (body !== undefined) headers['content-type'] = 'application/json'
126
+
127
+ let response
128
+ try {
129
+ response = await fetch(url, {
130
+ method,
131
+ headers,
132
+ body: body === undefined ? undefined : JSON.stringify(body),
133
+ redirect: 'error',
134
+ cache: 'no-store',
135
+ signal: AbortSignal.any(signals),
136
+ })
137
+ } catch (error) {
138
+ const aborted = AbortSignal.any(signals).aborted
139
+ if (aborted) {
140
+ throw new GithubError('network-error', 'the GitHub request timed out or was cancelled.', {}, { cause: error })
141
+ }
142
+ throw new GithubError(
143
+ 'network-error',
144
+ `could not reach GitHub: ${error instanceof Error ? error.message : String(error)}`,
145
+ {},
146
+ { cause: error },
147
+ )
148
+ }
149
+
150
+ const text = await response.text().catch(() => '')
151
+ let payload = null
152
+ if (text !== '') {
153
+ try {
154
+ payload = JSON.parse(text)
155
+ } catch {
156
+ payload = null
157
+ }
158
+ }
159
+
160
+ if (!response.ok) throw httpFailure(response, payload)
161
+ if (payload === null || typeof payload !== 'object') {
162
+ throw new GithubError('github-bad-response', `GitHub returned an unreadable response (HTTP ${response.status}).`)
163
+ }
164
+ return payload
165
+ }
166
+
167
+ /**
168
+ * Map a non-2xx GitHub response to a GithubError.
169
+ *
170
+ * @param {Response} response - Failed response.
171
+ * @param {any} payload - Parsed body, if any.
172
+ * @returns {GithubError}
173
+ */
174
+ function httpFailure(response, payload) {
175
+ const status = response.status
176
+ const message = typeof payload?.message === 'string' ? payload.message : ''
177
+ if (status === 401) {
178
+ return new GithubError('token-invalid', 'GitHub rejected the stored token. Sign in again.')
179
+ }
180
+ if (status === 403 || status === 429) {
181
+ if (response.headers.get('x-ratelimit-remaining') === '0' || status === 429) {
182
+ const reset = response.headers.get('x-ratelimit-reset')
183
+ const retryAfter = response.headers.get('retry-after')
184
+ const hint = reset !== null ? ` Resets at ${new Date(Number(reset) * 1000).toISOString()}.` : ''
185
+ const wait = retryAfter !== null ? ` Retry after ${retryAfter}s.` : ''
186
+ return new GithubError('rate-limited', `GitHub API rate limit exceeded.${wait}${hint}`)
187
+ }
188
+ return new GithubError('forbidden', `GitHub refused the request (HTTP 403)${message !== '' ? `: ${message}` : ''}.`)
189
+ }
190
+ if (status === 404) {
191
+ return new GithubError('not-found', 'GitHub returned 404: the repository does not exist or the token lacks access.')
192
+ }
193
+ if (status === 422) {
194
+ const first = Array.isArray(payload?.errors) && payload.errors.length > 0 ? payload.errors[0] : undefined
195
+ const detail = typeof first?.message === 'string' ? first.message : message
196
+ return new GithubError('invalid-request', `GitHub rejected the request: ${detail || 'HTTP 422'}`)
197
+ }
198
+ return new GithubError(
199
+ 'github-error',
200
+ `GitHub request failed (HTTP ${status})${message !== '' ? `: ${message}` : ''}.`,
201
+ )
202
+ }
203
+
204
+ /**
205
+ * Map a device-flow error code to a GithubError.
206
+ *
207
+ * @param {string} code - GitHub error code.
208
+ * @param {string} [description] - GitHub error description.
209
+ * @returns {GithubError}
210
+ */
211
+ function deviceFailure(code, description) {
212
+ const detail = typeof description === 'string' && description !== '' ? ` ${description}` : ''
213
+ switch (code) {
214
+ case 'device_flow_disabled':
215
+ return new GithubError('device-flow-disabled', 'This OAuth App has Device Flow disabled. Enable it in the App settings.')
216
+ case 'unauthorized_client':
217
+ case 'incorrect_client_credentials':
218
+ case 'invalid_client':
219
+ return new GithubError('client-id-invalid', `GitHub did not accept the Client ID.${detail}`)
220
+ case 'access_denied':
221
+ return new GithubError('login-denied', 'The authorization was denied on GitHub.')
222
+ case 'expired_token':
223
+ return new GithubError('login-expired', 'The device code expired before it was authorized. Start over.')
224
+ default:
225
+ return new GithubError('login-failed', `The device authorization failed: ${code}.${detail}`)
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Request a device code from GitHub (step 1 of the official device flow).
231
+ *
232
+ * @param {string} clientId - OAuth App Client ID.
233
+ * @param {AbortSignal | undefined} signal - Cancellation.
234
+ * @returns {Promise<DeviceCode>}
235
+ */
236
+ export async function requestDeviceCode(clientId, signal) {
237
+ const payload = await request(DEVICE_CODE_URL, {
238
+ method: 'POST',
239
+ body: { client_id: clientId, scope: REQUESTED_SCOPE },
240
+ signal,
241
+ accept: 'application/json',
242
+ })
243
+ if (typeof payload.error === 'string' && payload.error !== '') {
244
+ throw deviceFailure(payload.error, payload.error_description)
245
+ }
246
+ const deviceCode = payload.device_code
247
+ const userCode = payload.user_code
248
+ const verificationUri = payload.verification_uri
249
+ if (typeof deviceCode !== 'string' || deviceCode === '' || typeof userCode !== 'string' || userCode === '') {
250
+ throw new GithubError('github-bad-response', 'GitHub did not return a usable device code.')
251
+ }
252
+ const expiresIn = Number(payload.expires_in)
253
+ const interval = Number(payload.interval)
254
+ return {
255
+ deviceCode,
256
+ userCode,
257
+ verificationUri: typeof verificationUri === 'string' ? verificationUri : 'https://github.com/login/device',
258
+ expiresIn: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 900,
259
+ interval: Number.isFinite(interval) && interval > 0 ? interval : 5,
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Poll GitHub until the user authorizes (step 2 of the device flow).
265
+ *
266
+ * @param {string} clientId - OAuth App Client ID.
267
+ * @param {DeviceCode} device - Code from {@link requestDeviceCode}.
268
+ * @param {AbortSignal | undefined} signal - Cancellation.
269
+ * @returns {Promise<{accessToken: string, scope: string, tokenType: string}>}
270
+ */
271
+ export async function pollAccessToken(clientId, device, signal) {
272
+ const deadline = Date.now() + Math.min(device.expiresIn, 15 * 60) * 1000
273
+ let intervalMs = Math.max(5, device.interval) * 1000
274
+ while (Date.now() < deadline) {
275
+ await sleep(intervalMs, signal)
276
+ const payload = await request(ACCESS_TOKEN_URL, {
277
+ method: 'POST',
278
+ body: {
279
+ client_id: clientId,
280
+ device_code: device.deviceCode,
281
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
282
+ },
283
+ signal,
284
+ accept: 'application/json',
285
+ })
286
+ if (typeof payload.access_token === 'string' && payload.access_token !== '') {
287
+ return {
288
+ accessToken: payload.access_token,
289
+ scope: typeof payload.scope === 'string' ? payload.scope : '',
290
+ tokenType: typeof payload.token_type === 'string' ? payload.token_type : 'bearer',
291
+ }
292
+ }
293
+ const code = typeof payload.error === 'string' ? payload.error : ''
294
+ if (code === 'authorization_pending') continue
295
+ if (code === 'slow_down') {
296
+ intervalMs += 5000
297
+ continue
298
+ }
299
+ if (code === 'expired_token') throw new GithubError('login-expired', 'The device code expired. Start over.')
300
+ if (code === 'access_denied') throw new GithubError('login-denied', 'The authorization was denied on GitHub.')
301
+ throw deviceFailure(code === '' ? 'unknown' : code, payload.error_description)
302
+ }
303
+ throw new GithubError('login-expired', 'The device code expired before it was authorized. Start over.')
304
+ }
305
+
306
+ /**
307
+ * Read the signed-in account.
308
+ *
309
+ * @param {string} token - Access token.
310
+ * @param {AbortSignal | undefined} [signal] - Cancellation.
311
+ * @returns {Promise<{login: string, name: string, avatarUrl: string, htmlUrl: string}>}
312
+ */
313
+ export async function getAuthenticatedUser(token, signal) {
314
+ const payload = await request(`${API_ROOT}/user`, { token, signal })
315
+ return {
316
+ login: typeof payload.login === 'string' ? payload.login : '',
317
+ name: typeof payload.name === 'string' ? payload.name : '',
318
+ avatarUrl: typeof payload.avatar_url === 'string' ? payload.avatar_url : '',
319
+ htmlUrl: typeof payload.html_url === 'string' ? payload.html_url : '',
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Shape one repository API object for the UI.
325
+ *
326
+ * @param {any} raw - Repository JSON from GitHub.
327
+ * @returns {Repository}
328
+ */
329
+ function toRepository(raw) {
330
+ return {
331
+ id: Number(raw.id) || 0,
332
+ fullName: String(raw.full_name ?? ''),
333
+ name: String(raw.name ?? ''),
334
+ owner: String(raw.owner?.login ?? ''),
335
+ private: raw.private === true,
336
+ description: typeof raw.description === 'string' ? raw.description : '',
337
+ defaultBranch: String(raw.default_branch ?? ''),
338
+ updatedAt: String(raw.updated_at ?? ''),
339
+ htmlUrl: String(raw.html_url ?? ''),
340
+ canPush: raw.permissions?.push === true,
341
+ }
342
+ }
343
+
344
+ /**
345
+ * List repositories the signed-in account can push to.
346
+ *
347
+ * @param {string} token - Access token.
348
+ * @param {AbortSignal | undefined} signal - Cancellation.
349
+ * @returns {Promise<Repository[]>}
350
+ */
351
+ export async function listRepositories(token, signal) {
352
+ const payload = await request(`${API_ROOT}/user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member`, {
353
+ token,
354
+ signal,
355
+ })
356
+ if (!Array.isArray(payload)) {
357
+ throw new GithubError('github-bad-response', 'GitHub returned an unexpected repository list.')
358
+ }
359
+ return payload.map(toRepository)
360
+ }
361
+
362
+ /**
363
+ * Create one repository for the signed-in account.
364
+ *
365
+ * @param {string} token - Access token.
366
+ * @param {object} input - Repository fields.
367
+ * @param {string} input.name - Repository name (validated by the caller).
368
+ * @param {string} input.description - Description (may be empty).
369
+ * @param {boolean} input.private - Private repository.
370
+ * @param {boolean} input.autoInit - Initialize with a README.
371
+ * @param {AbortSignal | undefined} signal - Cancellation.
372
+ * @returns {Promise<Repository>}
373
+ */
374
+ export async function createRepository(token, input, signal) {
375
+ const payload = await request(`${API_ROOT}/user/repos`, {
376
+ method: 'POST',
377
+ token,
378
+ body: {
379
+ name: input.name,
380
+ description: input.description,
381
+ private: input.private,
382
+ auto_init: input.autoInit,
383
+ has_issues: true,
384
+ has_projects: false,
385
+ has_wiki: false,
386
+ },
387
+ signal,
388
+ })
389
+ return toRepository(payload)
390
+ }
package/lib/http.js ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The authenticated HTTP route shared by the client UI.
3
+ *
4
+ * Registered through `ctx.connection.fetch`, which only admits requests that
5
+ * passed the Connection's own authentication: a page that is not signed in to
6
+ * this DSH instance never reaches the handler. On top of that the handler
7
+ * requires a JSON content type (so a cross-site HTML form POST cannot reach
8
+ * it), bounds the body size, and returns business failures in an `ok:false`
9
+ * envelope with redacted messages.
10
+ *
11
+ * @module dsh-github/lib/http
12
+ */
13
+
14
+ import { GithubError, asGithubError } from './errors.js'
15
+
16
+ /** Exact route below the shared `/api` channel; must match the Client half. */
17
+ export const ROUTE_PATH = '/api/github'
18
+
19
+ /** Maximum accepted request body size (bytes). */
20
+ const MAX_BODY_BYTES = 1 << 20
21
+
22
+ /**
23
+ * One JSON response.
24
+ *
25
+ * @param {number} status - HTTP status.
26
+ * @param {unknown} body - JSON body.
27
+ * @returns {Response}
28
+ */
29
+ export function jsonResponse(status, body) {
30
+ return new Response(JSON.stringify(body), {
31
+ status,
32
+ headers: {
33
+ 'content-type': 'application/json',
34
+ 'cache-control': 'no-store',
35
+ 'x-content-type-options': 'nosniff',
36
+ },
37
+ })
38
+ }
39
+
40
+ /**
41
+ * Failure envelope.
42
+ *
43
+ * @param {string} code - Machine-readable code.
44
+ * @param {string} message - Redacted message.
45
+ * @returns {{ok: false, error: {code: string, message: string, details: Record<string, unknown>}}}
46
+ */
47
+ function failure(code, message) {
48
+ return { ok: false, error: { code, message, details: {} } }
49
+ }
50
+
51
+ /**
52
+ * Build the route handler.
53
+ *
54
+ * @param {ReturnType<import('./service.js').createService>} service - Plugin service.
55
+ * @returns {(request: Request) => Promise<Response>}
56
+ */
57
+ export function createHandler(service) {
58
+ return async (request) => {
59
+ const contentType = request.headers.get('content-type') ?? ''
60
+ if (!/^application\/json\b/i.test(contentType)) {
61
+ return jsonResponse(415, failure('unsupported-media-type', 'send a JSON body with content-type application/json'))
62
+ }
63
+ const declared = Number(request.headers.get('content-length') ?? '0')
64
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
65
+ return jsonResponse(413, failure('body-too-large', 'the request body is too large'))
66
+ }
67
+
68
+ let text
69
+ try {
70
+ text = await request.text()
71
+ } catch {
72
+ return jsonResponse(400, failure('bad-request', 'the request body could not be read'))
73
+ }
74
+ if (text.length > MAX_BODY_BYTES) {
75
+ return jsonResponse(413, failure('body-too-large', 'the request body is too large'))
76
+ }
77
+
78
+ /** @type {unknown} */
79
+ let payload
80
+ try {
81
+ payload = text.trim() === '' ? {} : JSON.parse(text)
82
+ } catch {
83
+ return jsonResponse(400, failure('bad-request', 'the request body is not JSON'))
84
+ }
85
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
86
+ return jsonResponse(400, failure('bad-request', 'the request body must be a JSON object'))
87
+ }
88
+
89
+ const action = typeof /** @type {Record<string, unknown>} */ (payload).action === 'string'
90
+ ? /** @type {{action: string}} */ (payload).action
91
+ : ''
92
+ try {
93
+ const value = await service.dispatch(action, payload, request.signal)
94
+ return jsonResponse(200, { ok: true, value })
95
+ } catch (error) {
96
+ const normalized = asGithubError(error)
97
+ // `cancelled` is a normal end to a request, not a defect.
98
+ if (normalized.code === 'cancelled') return jsonResponse(499, failure('cancelled', 'the request was cancelled'))
99
+ return jsonResponse(200, { ok: false, error: service.failure(error) })
100
+ }
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Build one error-shaped response for an unexpected handler failure.
106
+ *
107
+ * @param {unknown} error - Thrown value.
108
+ * @returns {Response}
109
+ */
110
+ export function unexpectedResponse(error) {
111
+ const normalized = error instanceof GithubError ? error : asGithubError(error)
112
+ return jsonResponse(500, failure(normalized.code, normalized.message))
113
+ }