agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
132 lines • 6.51 kB
TypeScript
/**
* githubDeviceSignIn — let the REPORTER sign in, so the issue is filed as them.
*
* import { githubDeviceSignIn } from 'agentfootprint/observe';
*
* const signIn = await githubDeviceSignIn({ clientId: 'Iv1.0123456789abcdef' });
* show(`Go to ${signIn.verificationUri} and enter ${signIn.userCode}`);
* const { token, login } = await signIn.completed; // resolves on authorize
*
* GitHub's OAuth **device flow**, spoken as three plain `fetch` calls and
* nothing else: request a code, show it to the human, poll until they approve.
* Zero dependencies, and it runs in a browser as well as on a server — there is
* no client secret in this flow, which is exactly why it is the one that works
* from a page.
*
* ## Why this exists beside a server token
*
* A server-side PAT files every report as the application. That is right for an
* automated "Report a problem" button: the app owns the repo, the app owns the
* token. It is wrong when the value is ATTRIBUTION — a field tester filing
* upstream should appear as themselves, so a maintainer can ask them a
* follow-up question and so their report counts as theirs.
*
* | | server PAT | device sign-in |
* |---|---|---|
* | Who the issue is from | the application | the reporter |
* | Where the token lives | server environment | the reporter's session, in memory |
* | Human steps | none | one: enter a code, approve |
* | Right for | in-app "report a problem" | field testers filing upstream |
*
* The token this returns is handed to {@link githubBugReporter} as `token`,
* with no special-casing anywhere: a token is a token.
*
* ## Keep it in memory, for the session only
*
* **Never `localStorage`, never a cookie, never a log line.** A device-flow
* token is a live credential for the account that approved it; persisting it in
* a browser turns one XSS into a lasting account compromise. Hold it in a
* variable, use it, drop it when the tab closes.
*
* ## Scopes are coarse here, and that is GitHub's design
*
* The device flow issues a CLASSIC OAuth token, and classic scopes are coarse:
* `public_repo` (the default here) grants write across every public repository
* the account can reach, and `repo` grants it across private ones too. There is
* no fine-grained equivalent in this flow. That trade buys attribution — the
* issue is really from that person — and it is the reason the default stops at
* `public_repo`: filing an issue needs no more, and the token disappears with
* the session. Where least privilege matters more than attribution, use a
* fine-grained PAT on a server instead (see {@link githubBugReporter}).
*
* ## The collaborator caveat, stated rather than discovered
*
* A reporter who signs in as themselves can file an issue on a public repo, and
* can commit evidence ONLY to a repository they can write to. Pointing
* `evidenceRepo` at a private repo the reporter is not a collaborator on will
* fail with a 404 (GitHub hides private repos from tokens that cannot see
* them). Either add the reporter as a collaborator, or let them attach the zip
* to the issue by hand — `exportBugReport` gives them the file either way.
*
* ## Secrecy
*
* The token appears in no message this module can produce. The three failure
* shapes of the flow — the human denied it, the code expired, the poll was
* aborted — are named plainly, with GitHub's `error_description` only, never a
* response body or a request.
*/
export interface GithubDeviceSignInOptions {
/**
* The OAuth App's client id. **Public by design** — the device flow has no
* client secret, so this belongs in your front-end code. Create the app once
* under the organisation, tick "Enable Device Flow", and copy the id.
*/
readonly clientId: string;
/**
* Classic OAuth scopes. Default `['public_repo']` — enough to file an issue
* and commit to a public evidence repo, and no more. `['repo']` is what a
* private evidence repo needs, and it is a much larger grant; ask for it only
* when the flow really commits there.
*/
readonly scopes?: readonly string[];
/** Cancel the polling (a closed dialog, an unmounted component). */
readonly signal?: AbortSignal;
/** GitHub's web origin. GHES: `https://github.your-company.com`. */
readonly authBase?: string;
/** GitHub's API root. GHES: `https://github.your-company.com/api/v3`. */
readonly apiBase?: string;
/** Test seam — inject `fetch`. Bypasses the network entirely. */
readonly _fetch?: typeof fetch;
/** Test seam — inject the wait between polls. */
readonly _sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
}
/** Who signed in, and with what. */
export interface GithubDeviceIdentity {
/** The access token. Memory-only; see the module docs. */
readonly token: string;
/** Usually `bearer`. */
readonly tokenType: string;
/** The scopes GitHub actually granted — not necessarily the ones asked for. */
readonly scopes: readonly string[];
/** The GitHub login the issue will be filed as. `undefined` if `/user` refused. */
readonly login?: string;
}
/** The code to show a human, and the promise that resolves when they approve. */
export interface GithubDeviceSignIn {
/** The code the human types — show it verbatim, it is case-sensitive. */
readonly userCode: string;
/** The page they type it into. */
readonly verificationUri: string;
/** Seconds until `userCode` stops working. */
readonly expiresIn: number;
/** Seconds GitHub asked us to wait between polls. */
readonly interval: number;
/**
* Resolves when the human approves, rejects when they deny it, when the code
* expires, or when `signal` aborts. Polling starts immediately — awaiting
* this later does not miss an approval.
*/
readonly completed: Promise<GithubDeviceIdentity>;
}
/**
* Start a device-flow sign-in.
*
* Resolves as soon as GitHub hands back a code — that is the point, because
* the human cannot approve a code they have not been shown. The waiting happens
* on the returned `completed` promise.
*
* @throws TypeError when `clientId` is missing (naming where it comes from).
* @throws Error naming the status when GitHub refuses to issue a code.
*/
export declare function githubDeviceSignIn(options: GithubDeviceSignInOptions): Promise<GithubDeviceSignIn>;
//# sourceMappingURL=githubDeviceSignIn.d.ts.map