@mutmutco/installer-gate 0.1.0 → 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1
4
+
5
+ - **Fixed: `/user` is a REST call — split the GitHub hosts (#6783).** The gate used one base for
6
+ three endpoints. `https://github.com/user` answers `406`, which the handler rethrew as
7
+ `502 upstream_error`, so **every gated sign-in failed right after the user approved the device
8
+ code**. The device-flow endpoints (`/login/device/code`, `/login/oauth/access_token`) stay on the
9
+ new `githubWebBase` (default `https://github.com`); `/user` now uses `githubApiBase` (default
10
+ `https://api.github.com`). Both are normalized by one function and overridable independently.
11
+ - **Consumer action:** bump to `^0.1.1` and redeploy the gate. If your config explicitly set
12
+ `githubApiBase` to `https://github.com` (its old meaning), remove it or move that value to
13
+ `githubWebBase` — `githubApiBase` now names the REST host.
14
+ - `GITHUB_WEB_BASE_DEFAULT` / `GITHUB_API_BASE_DEFAULT` and `normalizeGitHubBase(base, fallback)`
15
+ are exported. `normalizeGitHubApiBase` remains as a deprecated shim and now defaults to the REST
16
+ host.
17
+
18
+ ## 0.1.0
19
+
20
+ - Initial release: GitHub device flow, allowlists, and Ed25519-signed release manifests.
package/README.md CHANGED
@@ -51,7 +51,8 @@ returns `false` and touches nothing, so the adopter's router continues.
51
51
  | `kind` | `'github' \| 'google'` | Selects the identity lane. |
52
52
  | `githubClientId` | `string` | github kind. The gate rejects a device-flow `client_id` that does not match it. |
53
53
  | `githubClientSecret` | `string` | github kind. Passed in by the adopter — **never committed here**. |
54
- | `githubApiBase` | `string?` | github kind. Default `https://github.com`; override in tests. |
54
+ | `githubWebBase` | `string?` | github kind. GitHub **web** origin for the device flow — `POST /login/device/code` and `POST /login/oauth/access_token`. Default `https://github.com`; override for GHES or tests. Independent of `githubApiBase`. |
55
+ | `githubApiBase` | `string?` | github kind. GitHub **REST** origin for identity lookups (`GET /user`). Default `https://api.github.com`; override for GHES or tests. Independent of `githubWebBase`. |
55
56
  | `verifyBearer` | `(token) => Promise<{sub}\|null>` | google kind. The product's own OAuth server. |
56
57
  | `allowlist` | `{ source: 'roster' \| 'logins', getLogins?: () => Promise<string[]> }` | Allowed identities. |
57
58
  | `release` | `{ manifest, sign, readFile }` | How release artifacts are described, signed and read. |
@@ -63,6 +64,18 @@ returns `false` and touches nothing, so the adopter's router continues.
63
64
  `release.manifest()` returns `{ version, files: [{ path, sha256, size }] }`. The gate stamps
64
65
  `created` (ISO 8601) and signs; `release.readFile(path)` throws when a file is absent.
65
66
 
67
+ ### GitHub hosts (#6783)
68
+
69
+ GitHub serves the device-flow endpoints and the REST API from **different hosts**, so the gate has
70
+ one base per host. `githubWebBase` (default `https://github.com`) carries
71
+ `/login/device/code` and `/login/oauth/access_token`; `githubApiBase` (default
72
+ `https://api.github.com`) carries `/user`. Both go through one normalizer (http(s) scheme check +
73
+ trailing-slash strip), and an override of either never moves the other. Calling `/user` on
74
+ `github.com` answers `406`, which the handler turns into `502 upstream_error`; that is exactly the
75
+ sign-in failure #6783 fixed. **Consumer action:** if you previously set `githubApiBase` to
76
+ `https://github.com` (its old meaning), drop it or move that value to `githubWebBase` — as of
77
+ `0.1.1` `githubApiBase` means the REST host.
78
+
66
79
  ## Wire contract v1
67
80
 
68
81
  | Method | Path | Body / headers | Response |
package/dist/github.d.ts CHANGED
@@ -1,10 +1,28 @@
1
1
  /**
2
2
  * GitHub device flow (OAuth 2.0 Device Authorization Grant) client, plus the identity lookup.
3
3
  *
4
- * Everything talks to `githubApiBase` (default `https://github.com`), which tests point at a local
5
- * fake server so no real network is touched. The gate never hardcodes credentials: the client id
4
+ * TWO hosts this is the #6783 fix. The device-code and token endpoints exist only on the GitHub
5
+ * WEB host (`githubWebBase`, default `https://github.com`); `/user` is a REST call on the API host
6
+ * (`githubApiBase`, default `https://api.github.com`). Calling `/user` on github.com answers 406,
7
+ * which surfaced as `502 upstream_error` on every gated sign-in. Tests point both hosts at local
8
+ * fake servers so no real network is touched. The gate never hardcodes credentials: the client id
6
9
  * and secret arrive in config.
7
10
  */
11
+ /** GitHub web host: the device authorization and token endpoints exist only here. */
12
+ export declare const GITHUB_WEB_BASE_DEFAULT = "https://github.com";
13
+ /** GitHub REST API host: `/user` and every other REST call, never on the web host. */
14
+ export declare const GITHUB_API_BASE_DEFAULT = "https://api.github.com";
15
+ /**
16
+ * The ONE normalizer both hosts pass through: an http(s) scheme check plus a trailing-slash strip.
17
+ * `fallback` is the per-host default, so an override of one base can never move the other.
18
+ */
19
+ export declare function normalizeGitHubBase(base: string | undefined, fallback: string): string;
20
+ /**
21
+ * @deprecated kept for source compatibility. It now normalizes the REST host, matching the config
22
+ * field it is named after. Use `normalizeGitHubBase(base, GITHUB_API_BASE_DEFAULT)` for REST and
23
+ * `normalizeGitHubBase(base, GITHUB_WEB_BASE_DEFAULT)` for the device flow.
24
+ */
25
+ export declare function normalizeGitHubApiBase(base?: string): string;
8
26
  export interface GitHubDeviceCodeResponse {
9
27
  device_code: string;
10
28
  user_code: string;
@@ -20,12 +38,11 @@ export type GitHubDeviceTokenResult = {
20
38
  };
21
39
  /** The four poll errors the wire contract exposes. */
22
40
  export type GateDeviceError = 'authorization_pending' | 'slow_down' | 'expired_token' | 'denied';
23
- export declare function normalizeGitHubApiBase(base?: string): string;
24
41
  /** Starts a device authorization. `POST /gate/device/code` proxies this. */
25
- export declare function requestDeviceCode(apiBase: string, clientId: string): Promise<GitHubDeviceCodeResponse>;
42
+ export declare function requestDeviceCode(webBase: string, clientId: string): Promise<GitHubDeviceCodeResponse>;
26
43
  /** Exchanges a device code for a GitHub access token. Returns the raw result, error included. */
27
- export declare function pollDeviceToken(apiBase: string, clientId: string, clientSecret: string, deviceCode: string): Promise<GitHubDeviceTokenResult>;
44
+ export declare function pollDeviceToken(webBase: string, clientId: string, clientSecret: string, deviceCode: string): Promise<GitHubDeviceTokenResult>;
28
45
  /** Maps a GitHub poll error onto the wire contract's four-value error set. */
29
46
  export declare function mapDeviceError(error: string): GateDeviceError;
30
- /** Resolves the authenticated GitHub login for a token. */
47
+ /** Resolves the authenticated GitHub login for a token. This is a REST call: API host only. */
31
48
  export declare function fetchGitHubLogin(apiBase: string, accessToken: string): Promise<string>;
package/dist/index.d.ts CHANGED
@@ -15,7 +15,7 @@ export { canonicalJson } from './canonical.js';
15
15
  export { isIdentityAllowed } from './allowlist.js';
16
16
  export { mintToken, verifyToken } from './tokens.js';
17
17
  export type { GateTokenPayload } from './tokens.js';
18
- export { fetchGitHubLogin, mapDeviceError, normalizeGitHubApiBase, pollDeviceToken, requestDeviceCode, } from './github.js';
18
+ export { fetchGitHubLogin, GITHUB_API_BASE_DEFAULT, GITHUB_WEB_BASE_DEFAULT, mapDeviceError, normalizeGitHubApiBase, normalizeGitHubBase, pollDeviceToken, requestDeviceCode, } from './github.js';
19
19
  export type { GateDeviceError, GitHubDeviceCodeResponse, GitHubDeviceTokenResult, } from './github.js';
20
20
  export { resolveGoogleIdentity } from './google.js';
21
21
  export type { BearerVerifier } from './google.js';
package/dist/index.js CHANGED
@@ -24,8 +24,23 @@ async function isIdentityAllowed(allowlist, revoke, identity) {
24
24
  }
25
25
 
26
26
  // src/github.ts
27
+ var GITHUB_WEB_BASE_DEFAULT = "https://github.com";
28
+ var GITHUB_API_BASE_DEFAULT = "https://api.github.com";
29
+ function normalizeGitHubBase(base, fallback) {
30
+ const value = (base ?? fallback).trim();
31
+ let parsed;
32
+ try {
33
+ parsed = new URL(value);
34
+ } catch {
35
+ throw new Error(`github base is not a valid URL: ${value}`);
36
+ }
37
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
38
+ throw new Error(`github base must be an http(s) URL: ${value}`);
39
+ }
40
+ return value.replace(/\/+$/, "");
41
+ }
27
42
  function normalizeGitHubApiBase(base) {
28
- return (base ?? "https://github.com").replace(/\/+$/, "");
43
+ return normalizeGitHubBase(base, GITHUB_API_BASE_DEFAULT);
29
44
  }
30
45
  async function formPost(url, params) {
31
46
  const response = await fetch(url, {
@@ -44,8 +59,8 @@ async function formPost(url, params) {
44
59
  if (typeof data !== "object" || data === null) throw new Error("github returned a non-object body");
45
60
  return data;
46
61
  }
47
- async function requestDeviceCode(apiBase, clientId) {
48
- const data = await formPost(`${apiBase}/login/device/code`, { client_id: clientId });
62
+ async function requestDeviceCode(webBase, clientId) {
63
+ const data = await formPost(`${webBase}/login/device/code`, { client_id: clientId });
49
64
  const deviceCode = data.device_code;
50
65
  const userCode = data.user_code;
51
66
  const verificationUri = data.verification_uri;
@@ -66,8 +81,8 @@ async function requestDeviceCode(apiBase, clientId) {
66
81
  }
67
82
  return result;
68
83
  }
69
- async function pollDeviceToken(apiBase, clientId, clientSecret, deviceCode) {
70
- const data = await formPost(`${apiBase}/login/oauth/access_token`, {
84
+ async function pollDeviceToken(webBase, clientId, clientSecret, deviceCode) {
85
+ const data = await formPost(`${webBase}/login/oauth/access_token`, {
71
86
  client_id: clientId,
72
87
  client_secret: clientSecret,
73
88
  device_code: deviceCode,
@@ -323,7 +338,8 @@ function createGateHandler(config) {
323
338
  const now = config.now ?? (() => Date.now());
324
339
  const ttlSeconds = resolveTtl(config);
325
340
  const secret = resolveSecret(config);
326
- const githubApiBase = config.kind === "github" ? normalizeGitHubApiBase(config.githubApiBase) : "";
341
+ const githubWebBase = config.kind === "github" ? normalizeGitHubBase(config.githubWebBase, GITHUB_WEB_BASE_DEFAULT) : "";
342
+ const githubApiBase = config.kind === "github" ? normalizeGitHubBase(config.githubApiBase, GITHUB_API_BASE_DEFAULT) : "";
327
343
  return function gateHandler(req, res) {
328
344
  const pathname = requestPath(req);
329
345
  if (!pathname.startsWith("/gate/") && !pathname.startsWith("/release/")) {
@@ -361,6 +377,11 @@ function createGateHandler(config) {
361
377
  await refresh(req, res);
362
378
  return;
363
379
  }
380
+ if (pathname === "/release/version") {
381
+ requireMethod(method, "GET");
382
+ await releaseVersion(res);
383
+ return;
384
+ }
364
385
  if (pathname === "/release/manifest") {
365
386
  requireMethod(method, "GET");
366
387
  await releaseManifest(req, res);
@@ -389,7 +410,7 @@ function createGateHandler(config) {
389
410
  requireClientId(body);
390
411
  let issued;
391
412
  try {
392
- issued = await requestDeviceCode(githubApiBase, config.githubClientId);
413
+ issued = await requestDeviceCode(githubWebBase, config.githubClientId);
393
414
  } catch {
394
415
  throw new GateHttpError(502, "upstream_error");
395
416
  }
@@ -404,7 +425,7 @@ function createGateHandler(config) {
404
425
  let result;
405
426
  try {
406
427
  result = await pollDeviceToken(
407
- githubApiBase,
428
+ githubWebBase,
408
429
  config.githubClientId,
409
430
  config.githubClientSecret,
410
431
  deviceCodeValue
@@ -476,6 +497,20 @@ function createGateHandler(config) {
476
497
  }
477
498
  return identity;
478
499
  }
500
+ async function releaseVersion(res) {
501
+ const read = config.release.currentVersion;
502
+ if (!read) {
503
+ sendJson(res, 404, { error: "not_found" });
504
+ return;
505
+ }
506
+ let version;
507
+ try {
508
+ version = await read();
509
+ } catch {
510
+ throw new GateHttpError(500, "internal_error");
511
+ }
512
+ sendJson(res, 200, { version });
513
+ }
479
514
  async function releaseManifest(req, res) {
480
515
  if (await authenticate(req, res) === null) return;
481
516
  let input;
@@ -511,6 +546,8 @@ function createGateHandler(config) {
511
546
  }
512
547
  }
513
548
  export {
549
+ GITHUB_API_BASE_DEFAULT,
550
+ GITHUB_WEB_BASE_DEFAULT,
514
551
  buildCanonicalManifest,
515
552
  canonicalJson,
516
553
  createGateHandler,
@@ -519,6 +556,7 @@ export {
519
556
  mapDeviceError,
520
557
  mintToken,
521
558
  normalizeGitHubApiBase,
559
+ normalizeGitHubBase,
522
560
  pollDeviceToken,
523
561
  requestDeviceCode,
524
562
  resolveGoogleIdentity,
package/dist/types.d.ts CHANGED
@@ -36,6 +36,11 @@ export interface ReleaseConfig {
36
36
  sign: (canonicalBytes: Buffer) => Buffer;
37
37
  /** Reads one release file by manifest-relative path. Throws when absent. */
38
38
  readFile: (path: string) => Promise<Buffer>;
39
+ /** #6864: the current release version ALONE, read straight from the pointer — no file hashing.
40
+ * Backs the unauthenticated `GET /release/version` probe, so it must stay cheap: `manifest()`
41
+ * re-hashes every payload file on each call and must never sit behind a public route. Optional:
42
+ * a store that does not provide it simply has no version probe (the route answers 404). */
43
+ currentVersion?: () => Promise<string>;
39
44
  }
40
45
  interface GateConfigBase {
41
46
  allowlist: AllowlistConfig;
@@ -56,7 +61,11 @@ export interface GitHubGateConfig extends GateConfigBase {
56
61
  githubClientId: string;
57
62
  /** Passed in by the adopter — never hardcoded in this library. */
58
63
  githubClientSecret: string;
59
- /** GitHub origin for the device flow. Default `https://github.com`; override for tests. */
64
+ /** GitHub WEB origin for the device flow (`/login/device/code`, `/login/oauth/access_token`).
65
+ * Default `https://github.com`; override for GHES or tests. Independent of `githubApiBase`. */
66
+ githubWebBase?: string;
67
+ /** GitHub REST API origin for identity lookups (`/user`). Default `https://api.github.com`;
68
+ * override for GHES or tests. Independent of `githubWebBase`. */
60
69
  githubApiBase?: string;
61
70
  }
62
71
  export interface GoogleGateConfig extends GateConfigBase {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/installer-gate",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Installer gate server library — GitHub device flow, allowlists, and signed release manifests. Mounted by a product's own server.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -17,7 +17,8 @@
17
17
  "types": "./dist/index.d.ts",
18
18
  "files": [
19
19
  "dist",
20
- "README.md"
20
+ "README.md",
21
+ "CHANGELOG.md"
21
22
  ],
22
23
  "publishConfig": {
23
24
  "access": "public"