Skip to main content

Auth and backends

Two interfaces, deliberately separate. Auth answers who is this and may they edit; the backend answers how do files get written.

interface AuthProvider {
authorizeUrl(input: { redirectUri: string; state: string }): string
exchange(input: { code: string; redirectUri: string }): Promise<{ token: string; user: CopyInkUser }>
getUser(token: string): Promise<CopyInkUser | null>
}

interface ContentBackend {
read(path: string): Promise<Uint8Array>
list(prefix: string): Promise<string[]>
headSha(): Promise<string | null>
commit(
changes: FileChange[],
baseSha: string | null,
message: string,
author?: CommitAuthor,
): Promise<CommitResult>
}

Splitting them buys three things: a local dev backend that writes straight to your working tree; room for other forges; and onboarding where any sign-in method can pair with a GitHub App committing on the client's behalf.

Making a non-technical client create a GitHub account, enable 2FA and accept a collaborator invite is a real obstacle — and repo scope on their personal account is a large blast radius for changing three words on a homepage.

So: identify the client via GitHub OAuth, check them against auth.allowlist, but commit using a GitHub App installation token scoped to contents: write on one repository. The client never needs repo write access.

Attribution moves into the commit message:

content: update about

Edited by Jane Client <jane@example.com> via copy-ink
Co-authored-by: Jane Client <jane@example.com>

Environment

# Identifies the client (OAuth App or GitHub App client credentials)
COPY_INK_GITHUB_CLIENT_ID=
COPY_INK_GITHUB_CLIENT_SECRET=

# Commits on their behalf (recommended)
COPY_INK_GITHUB_APP_ID=
COPY_INK_GITHUB_PRIVATE_KEY=
COPY_INK_GITHUB_INSTALLATION_ID=

# Or a personal access token, for the backend only
COPY_INK_GITHUB_TOKEN=

A PAT works as a ContentBackend but never as auth — a shared token cannot identify who made a change, and copy-ink doctor warns when one is in use.

See the environment variable reference for where each one comes from.

Setting up the GitHub side

  1. An OAuth App (or a GitHub App's client credentials) for sign-in. Callback URL: https://your-site.com/api/copy-ink/auth/callback. Its client id and secret become COPY_INK_GITHUB_CLIENT_ID / _SECRET.
  2. A GitHub App for committing. Repository permission: Contents: Read and write, nothing else. Install it on the one repository. Its app id, a generated private key and the installation id become the three COPY_INK_GITHUB_APP_* variables.
  3. The allowlist. auth.allowlist takes GitHub logins or email addresses. Anyone signing in who is not on it is rejected with NOT_AUTHORISED, at callback time and again on every request.

Installation ids are visible in the installation's settings URL. The private key is a PEM — keep the newlines when putting it in an environment variable, or store it base64-encoded and decode it in your host's config.

Token handling

The forge access token stays in memory server-side. The browser gets an httpOnly session cookie holding only a session id. The token never reaches localStorage and is never serialised into a response.

Sessions live in the server process by design. If you run several instances, or serverless functions that cold-start often, supply your own store:

copy-ink.setup.ts
export default setupCopyInk(config, { sessions: yourRedisBackedStore })

The store interface is four methods — create, get, destroy, size — so backing it with Redis or a database table is a small piece of work. See SessionStore.

Local development

backend: { type: 'local' }

Edits are written straight to your working tree — no GitHub round trip, no auth — and show up in git diff for you to commit yourself.

Outside production, the local backend also installs a local auth provider that signs you straight in, so next dev needs no GitHub App and no OAuth credentials at all. It is never used when NODE_ENV=production, whatever the config says, and copy-ink doctor tells you when it is active.

Writing your own

setServices replaces either half:

import { setServices } from 'copy-ink/server'

setServices({
auth: myAuthProvider, // any sign-in method
backend: myBackend, // any forge, or something else entirely
})

Implementing AuthProvider is how you pair a different identity source — an existing customer login, a magic link, an SSO tenant — with a GitHub App still doing the committing. That combination is the whole reason the two interfaces are separate.

GitLab, Gitea and Forgejo adapters are not in v1, but ContentBackend is the entire surface one would need.