@mutmutco/installer-gate
v1.2.0
Published
Installer gate server library — GitHub device flow, allowlists, and signed release manifests. Mounted by a product's own server.
Readme
@mutmutco/installer-gate
A small, framework-agnostic node:http server library a product mounts to run its installer gate: it drives the GitHub device flow (or verifies a Google bearer), enforces an allowlist, and serves an Ed25519-signed release manifest plus the release files it describes.
- Node >= 22, ESM, zero runtime dependencies (node: builtins only).
- The library never hardcodes a credential: the GitHub client id/secret and every product-specific piece (manifest, signer, file reader, bearer verifier) are injected by the adopter.
- The published package ships a built ESM bundle (
dist/index.js) plus declarations (dist/*.d.ts);npm run buildproduces both — esbuild bundles the entry andtsc -p tsconfig.build.jsonemits types only. The adopter imports@mutmutco/installer-gatedirectly; no bundler step is required.
Mounting
import http from 'node:http';
import { sign as cryptoSign } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { createGateHandler } from '@mutmutco/installer-gate';
const gate = createGateHandler({
kind: 'github',
githubClientId: process.env.GITHUB_CLIENT_ID!,
githubClientSecret: process.env.GITHUB_CLIENT_SECRET!,
allowlist: { source: 'roster', getLogins: async () => rosterLogins() },
release: {
manifest: async () => ({
version: currentVersion(),
files: releaseFiles(), // [{ path, sha256, size }]
}),
sign: (canonicalBytes) => cryptoSign(null, canonicalBytes, privateKey), // Ed25519, detached
readFile: async (path) => readFile(join(releaseRoot, path)),
},
});
http.createServer((req, res) => {
if (!gate(req, res)) myOwnRouter(req, res); // false = not a /gate or /release path
}).listen(8787);createGateHandler(config) returns (req, res) => boolean. It owns /gate/* and
/release/*; for those it handles the request and returns true. For every other path it
returns false and touches nothing, so the adopter's router continues.
Config reference
| Field | Type | Notes |
| --- | --- | --- |
| kind | 'github' \| 'google' | Selects the identity lane. |
| githubClientId | string | github kind. The gate rejects a device-flow client_id that does not match it. |
| githubClientSecret | string | github kind. Passed in by the adopter — never committed here. |
| 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. |
| 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. |
| verifyBearer | (token) => Promise<{sub}\|null> | google kind. The product's own OAuth server. |
| allowlist | { source: 'roster' \| 'logins', getLogins?: () => Promise<string[]> } | Allowed identities. |
| release | { manifest, sign, readFile, currentVersion?, launcher? } | How release artifacts are described, signed and read. |
| tokenTtlSeconds | number? | Default 3600; values above 3600 are clamped to 3600. |
| revoke | string[] \| (() => Promise<string[]>)? | Explicit revocations, re-read every call. |
| tokenSecret | string? | HMAC key for the gate's own tokens. github derives one from the client secret; google mints no tokens. |
| now | () => number? | Injectable clock (epoch ms) for tests. |
release.manifest() returns { version, files: [{ path, sha256, size }] }. The gate stamps
created (ISO 8601) and signs; release.readFile(path) throws when a file is absent.
release.launcher() (optional, #7070) returns the same { version, files } shape for the launcher
binaries: version is the launcher version and each path is a platform key (win-x64,
darwin-arm64, …). It is served signed like the manifest at the public GET /release/launcher,
so keep it cheap — never hash binaries per request. Absent or throwing → 404 { error: "not_found" }.
GitHub hosts (#6783)
GitHub serves the device-flow endpoints and the REST API from different hosts, so the gate has
one base per host. githubWebBase (default https://github.com) carries
/login/device/code and /login/oauth/access_token; githubApiBase (default
https://api.github.com) carries /user. Both go through one normalizer (http(s) scheme check +
trailing-slash strip), and an override of either never moves the other. Calling /user on
github.com answers 406, which the handler turns into 502 upstream_error; that is exactly the
sign-in failure #6783 fixed. Consumer action: if you previously set githubApiBase to
https://github.com (its old meaning), drop it or move that value to githubWebBase — as of
0.1.1 githubApiBase means the REST host.
Wire contract v1
| Method | Path | Body / headers | Response |
| --- | --- | --- | --- |
| POST | /gate/device/code | { client_id } | 200 { device_code, user_code, verification_uri, verification_uri_complete?, expires_in, interval } |
| POST | /gate/device/token | { client_id, device_code } | 200 { access_token, refresh_token, expires_in } or 400 { error } |
| POST | /gate/refresh | { refresh_token } | 200 { access_token, expires_in } or 403 { error } |
| GET | /release/launcher | none (public) | 200 { version, created, files, signature } — the signed launcher record, or 404 { error } |
| GET | /release/manifest | Authorization: Bearer <access_token> | 200 { version, created, files, signature } |
| GET | /release/<path> | Authorization: Bearer <access_token> | raw bytes, or 404 { error } |
Device-flow 400 errors are exactly authorization_pending, slow_down, expired_token,
denied (GitHub's access_denied maps to denied; any other GitHub error is terminal → denied).
Refusals: 401 { error: "unauthorized" } for a missing/invalid/expired bearer;
403 { error: "forbidden" } for an allowlist miss or a revoked identity.
Token format
The gate mints its own opaque bearer credentials after the identity is proven:
<base64url(JSON payload)> . <base64url(HMAC-SHA256(payloadB64, tokenSecret))>
payload = { sub, exp, jti, typ }sub is the identity (GitHub login or Google sub), exp is epoch seconds, jti a random id,
and typ is 'access' or 'refresh'. HMAC-SHA256 is the chosen scheme: the token only ever
travels back to the gate that minted it, so a symmetric MAC is sufficient and needs no keypair.
Tokens are opaque to the client; mintToken/verifyToken are exported for adopters that need to
inspect one. expires_in is always <= 3600.
Allowlists
'logins'— an explicit list served bygetLogins().'roster'— the same interface; the adopter wires a roster adapter later. The gate treats both sources identically.- Matching is case-insensitive and trims whitespace (GitHub logins are case-insensitive).
- Fail closed: a missing
getLogins, an empty list, or a throw allows nobody. - The allowlist is checked at token mint and on every gated read, and
revoke(a list or a function) is re-read on every call — so an allowlist edit or a revocation refuses the very next refresh and release read. Refresh mints a new access token only if the identity still passes.
Release signing
GET /release/manifest returns a base64 detached Ed25519 signature over exactly the UTF-8
bytes of the canonical JSON:
{"created":<created>,"files":[{"path":<path>,"sha256":<sha256>,"size":<size>}],"version":<version>}Keys sorted lexicographically, arrays in given order, no whitespace. The launcher side verifies with the exported helpers:
import { verifySignedManifest, verifyReleaseFile } from '@mutmutco/installer-gate';
verifySignedManifest(manifest, publicKey); // signature over the canonical bytes
verifyReleaseFile(manifest.files[i], fileBytes); // size + SHA-256 of a downloaded filebuildCanonicalManifest(manifest) returns the exact bytes if you need them; canonicalJson(value)
is the underlying serializer. GET /release/<path> refuses any path that escapes the release root
(.., empty/. segments, backslashes, absolute paths) with 404 { error: "not_found" }.
The google kind
kind: 'google' does not implement OAuth. verifyBearer(token) is the product's own OAuth
server; the library verifies the returned sub against the allowlist on every gated read. There is
no device flow and no gate-issued refresh token for this kind, so /gate/device/* returns
404 { error: "not_found" }.
Contract-compliance notes (decisions taken)
- Allowlist miss at token mint is
403 { error: "forbidden" }, matching the contract's global refusal rule; the device-token400set is reserved for GitHub's poll errors. createdis an ISO 8601 UTC string (new Date(now).toISOString()), so it is JSON-quoted in the canonical form.- Token secret is an added config field. github derives it from
githubClientSecretwhen absent; google (which mints nothing) generates an ephemeral one. /release/<path>traversal is refused rather than delegated to the adopter'sreadFile.- No secrets are committed; the client secret and signer key are injected at mount time.
