Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,15 @@ Enter `owner/repository`, a GitHub URL, or a clone URL. Everything documented
below — loaded ranges, tree reconstruction, caching, rate limits, milestones —
applies to this mode. The built-in demo is served locally and bypasses all of it.

Unknown `demo/*` references are not special: they go down the ordinary GitHub path
and produce the ordinary not-found result.
`demo` is a reserved internal namespace, not a GitHub account. Exactly one
reference in it resolves — the built-in demo — and every other `demo/*` reference
is refused locally with the ordinary 404, before any GitHub request is built. It
is never silently swapped for the demo that does exist.

Because no request goes out, those responses carry `rateLimit: null` and
`rateLimitAgeMs: null` rather than the snapshot left by some earlier live request.
Owners that merely resemble the reserved one — `demos`, `my-demo` — are real
accounts and go down the ordinary GitHub path.

## What "loaded history" means

Expand Down Expand Up @@ -161,6 +168,18 @@ Every response carries `meta.dataSource`, which is `builtin` or `github`. The
interface switches on that field rather than on any visible label, so it can never
be wrong about which mode it is in.

The reserved-namespace rule is applied where a request's parameters become a
reference (`readRepoRef` in `src/lib/api/route-helpers.ts`, which all six routes
call) and again where the service decides between the fixture and GitHub
(`servedFromBuiltin` in `src/lib/github/service.ts`). One predicate, enforced at
both boundaries, so no endpoint can be given it separately and no direct API
request escapes it.

A failure decided locally reports no GitHub quota. The rate-limit store holds
whatever the last real response said, and attributing those numbers to a call that
never happened would imply quota was spent and date the reading to now. Genuine
GitHub failures still report theirs.

| Route | GitHub endpoint | Cache |
| --- | --- | --- |
| `/api/gh/repo` | `/repos/{o}/{r}` | 300s |
Expand Down
Binary file modified e2e/api.spec.ts
Binary file not shown.
11 changes: 10 additions & 1 deletion e2e/shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,9 +230,18 @@ test.describe('states', () => {
});

test('treats an unknown demo reference as an ordinary missing repository', async ({ page }) => {
const githubCalls: string[] = [];
page.on('request', (request) => {
if (new URL(request.url()).hostname.includes('github')) githubCalls.push(request.url());
});

await page.goto(repoUrl(REPOS.unknownDemo));
await expect(appAlert(page)).toContainText(/could not be found/);
await expect(appAlert(page)).toContainText(/not a repository/);
await expect(page.getByRole('slider', { name: /Commit position/ })).toHaveCount(0);
// It offers the demo as a choice rather than silently becoming it.
await expect(page.getByRole('button', { name: 'Open the built-in demo' })).toBeVisible();
await expect(builtinBadge(page)).toHaveCount(0);
expect(githubCalls).toEqual([]);
});

test('explains an empty repository', async ({ page }) => {
Expand Down
57 changes: 48 additions & 9 deletions src/lib/api/route-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import 'server-only';

import type { ApiFailure, ApiSuccess } from '@/lib/api/contract';
import { BUILTIN_DEMO_SLUG } from '@/lib/builtin/learning-platform';
import { isUnknownBuiltinRef } from '@/lib/builtin/provider';
import { GitHubError, isGitHubError } from '@/lib/github/errors';
import { hasToken } from '@/lib/github/client';
import { readRateLimit } from '@/lib/github/rate-limit-store';
Expand All @@ -12,42 +14,69 @@ const MAX_PROBE_PATH = 200;
/** Anything outside this set has no business in a repository-relative path. */
const SAFE_PATH_PATTERN = /^[A-Za-z0-9._\-/+@ ()[\]]+$/;

/**
* The single place a request's owner/repository becomes a reference.
*
* Every route goes through here, which is what makes the reserved-namespace rule
* below impossible for one route to forget. Two things can go wrong:
*
* - the input is not a repository reference at all, which is a bad request;
* - it is syntactically fine but names the internal `demo` namespace without
* being the built-in demo, which is a repository that cannot exist. That is
* refused here rather than asked of GitHub, because the answer is already
* known and asking would spend quota to learn it.
*/
export function readRepoRef(params: URLSearchParams): RepoRef {
const owner = params.get('owner') ?? '';
const repo = params.get('repo') ?? '';
const parsed = parseRepoRef(`${owner}/${repo}`);
if (!parsed.ok) throw new GitHubError('invalid-input', parsed.message);
if (!parsed.ok) throw new GitHubError('invalid-input', parsed.message, { origin: 'local' });

if (isUnknownBuiltinRef(parsed.value)) {
// Deliberately the ordinary not-found result, and deliberately not silently
// swapped for the demo that does exist.
throw new GitHubError(
'not-found',
`"${parsed.value.slug}" is not a repository. The "demo" namespace is built into this application and holds only ${BUILTIN_DEMO_SLUG}.`,
{ origin: 'local' },
);
}

return parsed.value;
}

export function readBranch(params: URLSearchParams, fallback = 'HEAD'): string {
const branch = params.get('branch');
if (!branch) return fallback;
if (!isValidRefName(branch)) throw new GitHubError('invalid-input', 'That branch name is not valid.');
if (!isValidRefName(branch)) {
throw new GitHubError('invalid-input', 'That branch name is not valid.', { origin: 'local' });
}
return branch;
}

export function readSha(params: URLSearchParams): string {
const sha = (params.get('sha') ?? '').toLowerCase();
if (!isValidSha(sha)) throw new GitHubError('invalid-input', 'A commit sha is required.');
if (!isValidSha(sha)) throw new GitHubError('invalid-input', 'A commit sha is required.', { origin: 'local' });
return sha;
}

export function readPage(params: URLSearchParams): number {
const raw = Number(params.get('page') ?? '1');
if (!Number.isFinite(raw) || raw < 1) throw new GitHubError('invalid-input', 'Page must be a positive integer.');
if (!Number.isFinite(raw) || raw < 1) {
throw new GitHubError('invalid-input', 'Page must be a positive integer.', { origin: 'local' });
}
return Math.floor(raw);
}

export function readProbePath(params: URLSearchParams): string {
const value = params.get('path') ?? '';
if (!value || value.length > MAX_PROBE_PATH) {
throw new GitHubError('invalid-input', 'A repository-relative file path is required.');
throw new GitHubError('invalid-input', 'A repository-relative file path is required.', { origin: 'local' });
}
// Only paths that could plausibly exist inside a repository, so nothing can
// escape the `?path=` query parameter into another API surface.
if (value.startsWith('/') || value.includes('..') || !SAFE_PATH_PATTERN.test(value)) {
throw new GitHubError('invalid-input', 'That file path is not valid.');
throw new GitHubError('invalid-input', 'That file path is not valid.', { origin: 'local' });
}
return value;
}
Expand All @@ -71,15 +100,25 @@ export function fail(error: unknown): Response {
const githubError = isGitHubError(error)
? error
: new GitHubError('unknown', 'Something went wrong while talking to GitHub.');
const { snapshot, ageMs } = readRateLimit();

/*
* A failure decided here, without a request leaving the server, must not carry
* a rate-limit snapshot. The store holds whatever the last real GitHub response
* reported, and attributing those numbers to a call that never happened would
* be wrong in both directions: it implies quota was spent, and it dates the
* reading to now.
*/
const observed = githubError.isLocal ? { snapshot: null, ageMs: null } : readRateLimit();
const rateLimit = githubError.rateLimit ?? observed.snapshot;

const body: ApiFailure = {
error: {
code: githubError.code,
message: githubError.message,
retryAt: githubError.retryAt,
},
rateLimit: githubError.rateLimit ?? snapshot,
rateLimitAgeMs: githubError.rateLimit ? 0 : ageMs,
rateLimit,
rateLimitAgeMs: githubError.rateLimit ? 0 : observed.ageMs,
};
return Response.json(body, {
status: githubError.status,
Expand Down
19 changes: 19 additions & 0 deletions src/lib/builtin/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@ export function isBuiltinSlug(slug: string): boolean {
return slug.trim().toLowerCase() === BUILTIN_DEMO_SLUG;
}

/**
* `demo` is an internal namespace, not a GitHub account.
*
* Exactly one reference in it resolves — the built-in demo. Anything else is a
* reference to a repository that cannot exist, so asking GitHub about it would
* spend quota to be told what we already know.
*/
export function isReservedDemoOwner(owner: string): boolean {
return owner.trim().toLowerCase() === BUILTIN_DEMO_OWNER;
}

/**
* True when a reference lives in the reserved namespace but is not the built-in
* demo, i.e. it must be refused locally.
*/
export function isUnknownBuiltinRef(ref: Pick<RepoRef, 'owner' | 'slug'>): boolean {
return isReservedDemoOwner(ref.owner) && !isBuiltinRef(ref);
}

export const BUILTIN_DEMO_REF: RepoRef = {
owner: BUILTIN_DEMO_OWNER,
repo: BUILTIN_DEMO_REPO,
Expand Down
24 changes: 23 additions & 1 deletion src/lib/github/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ export type GitHubErrorCode =
| 'too-large'
| 'unknown';

/**
* Where a failure was decided.
*
* `local` means no request left this server, so the response must not report a
* GitHub quota: doing so would attribute an unrelated earlier request's numbers
* to a call that never happened.
*/
export type GitHubErrorOrigin = 'github' | 'local';

/**
* A failure that is safe to show to a visitor. `message` never contains the
* token, request headers, or anything else from the server environment.
Expand All @@ -22,18 +31,31 @@ export class GitHubError extends Error {
readonly rateLimit: RateLimitSnapshot | null;
/** Unix seconds when the rate limit window resets, for `rate-limited` only. */
readonly retryAt: number | null;
readonly origin: GitHubErrorOrigin;

constructor(
code: GitHubErrorCode,
message: string,
options: { status?: number; rateLimit?: RateLimitSnapshot | null; retryAt?: number | null } = {},
options: {
status?: number;
rateLimit?: RateLimitSnapshot | null;
retryAt?: number | null;
origin?: GitHubErrorOrigin;
} = {},
) {
super(message);
this.name = 'GitHubError';
this.code = code;
this.status = options.status ?? statusForCode(code);
this.rateLimit = options.rateLimit ?? null;
this.retryAt = options.retryAt ?? null;
// Errors built from a real response are the default; local decisions opt in.
this.origin = options.origin ?? 'github';
}

/** True when no request left this server to produce this failure. */
get isLocal(): boolean {
return this.origin === 'local';
}

toJSON() {
Expand Down
8 changes: 5 additions & 3 deletions src/lib/github/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export async function loadFixtures(ref: RepoRef): Promise<FixtureBundle> {
throw new GitHubError(
'not-found',
'That repository could not be found. This deployment is running in fixture mode and only serves recorded repositories.',
{ origin: 'local' },
);
}
// Recorded failure states, so the error UI can be exercised end to end.
Expand All @@ -53,7 +54,7 @@ export async function loadFixtures(ref: RepoRef): Promise<FixtureBundle> {
// `directory` comes from our own index file, never from user input.
const base = path.join(FIXTURE_ROOT, directory);
const bundle = await readJson<FixtureBundle>(path.join(base, 'bundle.json'));
if (!bundle) throw new GitHubError('upstream', 'Fixture bundle is missing or unreadable.');
if (!bundle) throw new GitHubError('upstream', 'Fixture bundle is missing or unreadable.', { origin: 'local' });
if (bundle.commits.length === 0) {
// Recorded empty repository: metadata exists, history does not.
return { ...bundle, commitDetail: {}, tree: {}, tags: [] };
Expand Down Expand Up @@ -81,11 +82,12 @@ function errorFixture(code: string): GitHubError {
return new GitHubError(
'not-found',
'That repository could not be found. It may be private, renamed, or misspelled \u2014 Repo Time Machine only reads public repositories.',
{ origin: 'local' },
);
case 'timeout':
return new GitHubError('timeout', 'GitHub did not respond in time. Try again in a moment.');
return new GitHubError('timeout', 'GitHub did not respond in time. Try again in a moment.', { origin: 'local' });
default:
return new GitHubError('upstream', 'GitHub is having trouble right now. Try again shortly.');
return new GitHubError('upstream', 'GitHub is having trouble right now. Try again shortly.', { origin: 'local' });
}
}

Expand Down
48 changes: 38 additions & 10 deletions src/lib/github/service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'server-only';

import { BUILTIN_DEMO_SLUG } from '@/lib/builtin/learning-platform';
import {
builtinCommitDetail,
builtinCommitPage,
Expand All @@ -8,6 +9,7 @@ import {
builtinTags,
builtinTree,
isBuiltinRef,
isUnknownBuiltinRef,
} from '@/lib/builtin/provider';
import type { Commit, CommitDetail, RepoMeta, RepoTree, Tag } from '@/lib/domain/types';
import type { RepoRef } from '@/lib/repo-ref';
Expand All @@ -33,6 +35,26 @@ export const COMMITS_PER_PAGE = 100;
/** Hard ceiling on how far back a visitor can page, so one visitor cannot drain the quota. */
export const MAX_COMMIT_PAGES = 10;

/**
* Decides whether a reference is served from the built-in fixture.
*
* Returning `true` means "answer locally". Throwing means the reference names the
* reserved `demo` namespace but is not the built-in demo, so it is a repository
* that cannot exist and no request should be made for it. Every function below
* asks this one question first, so neither branch can be skipped for one endpoint.
*/
function servedFromBuiltin(ref: RepoRef): boolean {
if (isBuiltinRef(ref)) return true;
if (isUnknownBuiltinRef(ref)) {
throw new GitHubError(
'not-found',
`"${ref.slug}" is not a repository. The "demo" namespace is built into this application and holds only ${BUILTIN_DEMO_SLUG}.`,
{ origin: 'local' },
);
}
return false;
}

async function request<T>(args: Parameters<typeof githubFetch<T>>[0]) {
const response = await githubFetch<T>(args);
recordRateLimit(response.rateLimit);
Expand All @@ -42,7 +64,7 @@ async function request<T>(args: Parameters<typeof githubFetch<T>>[0]) {
export async function fetchRepoMeta(ref: RepoRef): Promise<RepoMeta> {
// The built-in demo is resolved before anything else, in every environment,
// so it can never reach github.com/ghapi or be sent a token.
if (isBuiltinRef(ref)) return builtinRepoMeta();
if (servedFromBuiltin(ref)) return builtinRepoMeta();
if (fixtureModeEnabled()) {
const fixtures = await loadFixtures(ref);
return toRepoMeta(fixtures.repo, fixtures.commits.length === 0);
Expand Down Expand Up @@ -76,7 +98,7 @@ export async function fetchCommitPage(
): Promise<CommitsResult> {
const safePage = Math.min(Math.max(1, Math.floor(page)), MAX_COMMIT_PAGES);

if (isBuiltinRef(ref)) return builtinCommitPage(safePage, COMMITS_PER_PAGE);
if (servedFromBuiltin(ref)) return builtinCommitPage(safePage, COMMITS_PER_PAGE);

if (fixtureModeEnabled()) {
const fixtures = await loadFixtures(ref);
Expand Down Expand Up @@ -123,15 +145,17 @@ async function countCommits(ref: RepoRef, branch: string): Promise<number | null
}

export async function fetchCommitDetail(ref: RepoRef, sha: string): Promise<CommitDetail> {
if (isBuiltinRef(ref)) {
if (servedFromBuiltin(ref)) {
const detail = builtinCommitDetail(sha);
if (!detail) throw new GitHubError('not-found', 'That commit is not part of the built-in demo.');
if (!detail) {
throw new GitHubError('not-found', 'That commit is not part of the built-in demo.', { origin: 'local' });
}
return detail;
}
if (fixtureModeEnabled()) {
const fixtures = await loadFixtures(ref);
const raw = fixtureCommitDetail(fixtures, sha);
if (!raw) throw new GitHubError('not-found', 'No fixture recorded for that commit.');
if (!raw) throw new GitHubError('not-found', 'No fixture recorded for that commit.', { origin: 'local' });
return toCommitDetail(raw);
}
const { data } = await request<RawCommitDetail>({
Expand All @@ -143,15 +167,19 @@ export async function fetchCommitDetail(ref: RepoRef, sha: string): Promise<Comm
}

export async function fetchTree(ref: RepoRef, sha: string): Promise<RepoTree> {
if (isBuiltinRef(ref)) {
if (servedFromBuiltin(ref)) {
const tree = builtinTree(sha);
if (!tree) throw new GitHubError('not-found', 'That commit is not part of the built-in demo.');
if (!tree) {
throw new GitHubError('not-found', 'That commit is not part of the built-in demo.', { origin: 'local' });
}
return tree;
}
if (fixtureModeEnabled()) {
const fixtures = await loadFixtures(ref);
const raw = fixtureTree(fixtures, sha);
if (!raw) throw new GitHubError('not-found', 'No tree fixture recorded for that commit.');
if (!raw) {
throw new GitHubError('not-found', 'No tree fixture recorded for that commit.', { origin: 'local' });
}
return toRepoTree(raw, sha);
}
const { data } = await request<RawTree>({
Expand All @@ -164,7 +192,7 @@ export async function fetchTree(ref: RepoRef, sha: string): Promise<RepoTree> {
}

export async function fetchTags(ref: RepoRef): Promise<Tag[]> {
if (isBuiltinRef(ref)) return builtinTags();
if (servedFromBuiltin(ref)) return builtinTags();
if (fixtureModeEnabled()) {
const fixtures = await loadFixtures(ref);
return toTags(fixtures.tags);
Expand Down Expand Up @@ -193,7 +221,7 @@ export async function probeFirstCommitForPath(
branch: string,
path: string,
): Promise<{ firstSha: string | null; incomplete: boolean }> {
if (isBuiltinRef(ref)) return { firstSha: builtinFirstCommitForPath(path), incomplete: false };
if (servedFromBuiltin(ref)) return { firstSha: builtinFirstCommitForPath(path), incomplete: false };
if (fixtureModeEnabled()) {
const fixtures = await loadFixtures(ref);
const touching = fixtures.commits.filter((commit) => {
Expand Down
Loading
Loading