DocuGate

Why the access check lives on the server, and GitHub is the gate

How DocuGate decides who may read a docs space, why that decision never runs in the browser, and why GitHub answers it for repository access.

By ·

The short answer

Put the access decision on the server, before any content is read, and send a refused reader only what the locked screen needs. Where an upstream system already knows who may read something, such as GitHub for a repository, read with the reader's own credential and let that system refuse, rather than copying its permissions into your own table.

The browser is the wrong place, even when it looks right

DocuGate's front end is a single-page app. Early on, the whole product ran in the browser against an in-memory mock, and the gate worked: a locked space drew a locked screen. It was also not a gate at all. A single-page app ships its code to the reader, so any check it performs is a check the reader can edit, skip or read around in the network tab.

So the rule we wrote down was simple: restricted markdown never leaves the server unless the server has decided this session may read it. The browser decides which screen to draw from the answer the server gives. It never decides whether there is content to draw.

One decision, three credentials

Every space has exactly one visibility: public, repository access, or an allowlist. The interesting part is not who gets in, it is whose credential does the reading. That is what decideAccess in server/access.ts returns, shortened here:

export async function decideAccess(space: SpaceDoc, session: Session | null): Promise<Decision> {
  if (space.visibility === 'public') {
    if (!space.private) return { ok: true, token: env.serverToken }
    // A private repo published openly: read it with the owner's stored token.
    // ...
  }

  if (!session) return { ok: false, reason: 'signin-required' }

  // The owner always gets in, with their own credentials.
  if (session.login && session.login === space.ownerLogin && session.token) {
    return { ok: true, token: session.token }
  }

  if (space.visibility === 'repo-access') {
    if (!session.token) return { ok: false, reason: 'github-required' }
    return { ok: true, token: session.token }
  }

  if (!onAllowlist(space, session)) return { ok: false, reason: 'not-allowed' }
  // ... look up the owner's stored GitHub token
  if (!credential) return { ok: false, reason: 'owner-disconnected' }
  return { ok: true, token: credential.token }
}

Public spaces read with a server token. Allowlist spaces read with the owner's token, which is what lets somebody without a GitHub account read them after signing in with Google: the list is ours, so the reader never has to be known to GitHub. Repository-access spaces read with the reader's own token, and that one is different in kind.

Letting GitHub be the gate

For repository access, decideAccess does not actually decide. It hands back the reader's token and the answer arrives when the read with it succeeds or fails. We wrote that into the type itself: repo-denied is a denial reason that decideAccess can never return, with a comment saying the question is delegated to GitHub and only answered by the read.

The alternative was a table: sync the repository's collaborators into our database and check against it. We did not build it, for two reasons. A copy of a permission list starts drifting the moment it is made, and somebody removed from the repository on Friday would still read the docs until the next sync. And a table we maintain is a table that can say yes when GitHub would say no. With the reader's token, DocuGate cannot grant access GitHub would refuse, because GitHub is the one serving the bytes.

The reader-facing route is short as a result:

const readable = merged.sources.filter((source) => source.ok)
if (!readable.length) {
  // For a repo-access space the read *is* the permission check.
  if (space.visibility === 'repo-access') {
    return c.json({ error: 'locked', reason: 'repo-denied', space: publicFacing(space) }, 403)
  }
  return c.json({ error: 'unreadable' }, 404)
}

A space can merge several repositories, so there is one more line after that: if the primary source refused, the answer is no even when other sources came back. Otherwise a gated space would leak the moment somebody added a second, public repository to it.

404, 403, and what a refused reader can observe

GitHub answers a private repository you cannot see with 404, not 403. It will not confirm the repository exists. Our wrapper keeps that property instead of trying to be clever about it:

/** Null means "GitHub said no" — missing and forbidden are deliberately the same. */
export async function getRepo(owner: string, repo: string, token: string): Promise<Repo | null> {
  const res = await gh(`/repos/${owner}/${repo}`, token)
  if (!res.ok) return null
  return (await res.json()) as Repo
}

Where we made a different choice is one level up. For some time, a repository-access space whose repository refused the reader said "Space not found". That was faithful to GitHub and wrong for the reader: there is a space there, they simply cannot read it, and "not found" sent them looking for a typo instead of asking the owner. We fixed it on 2026-09-09. Now the space answers 403 with a locked screen and a button to ask the owner for access.

That means DocuGate discloses something GitHub does not: that a space exists at that address. We decided that was acceptable because of what the 403 carries, which is exactly this and nothing more:

export function publicFacing(space: SpaceDoc) {
  return {
    owner: space.ownerLogin,
    slug: space.slug,
    name: space.name,
    visibility: space.visibility,
  }
}

No markdown, no page titles, no page count, no repository name, no allowlist. A space that does not exist at all still answers a plain 404. The allowlist in particular is never sent to anyone but the owner. On 2026-09-11 we found the shared response shape sending it with every space read; it now only goes out through a separate function used on the owner's routes.

One door for every reader-facing route

The check is only as good as the number of routes that skip it. Every route that reads a repository on a reader's behalf starts with the same function, openSpace, which checks that the space exists, that the owner's plan still covers it, and that decideAccess says yes. The page content and the OpenAPI reference both go through it, so the API reference is exactly as gated as the pages.

We learned that rule the hard way. Two older routes, /api/repos/:owner/:repo/tree and /page, read any repository the server token could see with none of these checks. They bypassed a public repository's allowlist and let anyone spend the shared GitHub rate limit. They were removed on 2026-09-22, and server/app.ts carries a comment that says not to bring them back.

There is one more case worth naming. Issuing an API key acts on the access answer without reading anything, so for a repository-access space it would have taken "signed in with GitHub" for "allowed to read this repository". readToken closes that by asking GitHub for the repository with the reader's token before saying yes.

The trade-off

Delegating to GitHub is not free.

  • Every page view of a private repository is a live call to GitHub. Content read with a reader's token is never put in a shared cache, so there is nothing to serve it from. The comment in server/db.ts puts it plainly: the billing line and the cost line are the same line.
  • "Can open the repository" is GitHub's definition, not ours. On a public repository, repository access admits anybody with a GitHub account, because GitHub would show them the repository too. The mode only means something on a private repository.
  • Readers without GitHub cannot use this mode. A Google sign-in cannot be vouched for by GitHub, so a repository-access space asks them to connect GitHub. Allowlists exist partly for that reason.
  • Tokens expire. GitHub App user tokens last eight hours, so the server renews them silently on read. When renewal fails, the session keeps its identity and loses the token, and the reader sees the GitHub prompt again.

What we would do differently

  • Key the owner on an id, not a login. "The owner always gets in" compares the session's GitHub login with the login stored on the space. A renamed GitHub account misses that check. It is listed as an open question in our notes, not fixed.
  • Start with openSpace. The two unguarded routes existed because the gate was added to the routes that needed it rather than being the only way in. If we started again, the shared function would come first and the routes after.

Questions people also ask

Can a single-page app enforce who reads a page?

No. The app's code runs on the reader's machine, so any check it makes is one the reader controls. It can decide which screen to draw; the decision about whether content is sent at all has to be made by the server.

Why does a private repository I cannot see come back as 404 rather than 403?

That is GitHub's choice: it does not confirm that a private repository exists to someone who cannot read it. DocuGate treats missing and forbidden as the same answer when it talks to GitHub, and only turns it into a locked screen for spaces whose access mode is repository access.

What does a refused reader receive from DocuGate?

A 403 with a reason and four fields about the space: owner, slug, name and visibility. No markdown, no page list, no repository name and no allowlist.

Why use the reader's own GitHub token instead of a permissions table?

Because GitHub already knows who can open the repository, and keeps knowing it as people join and leave. A copy of that list in our database would drift, and could grant access GitHub itself would refuse.

Read next

Get started with DocuGate, free.