CLAUDE.md · git:20261017.cde10f9 · 2026-10-17 · sha256 4c9c31c850aed7b5

CLAUDE.md git:20261017.cde10f9A

Immutable. This exact content never changes and is served at /api/v1/blob/4c9c31c850aed7b5.

# OpenArtifacts

Push a local md/html file, get a public HTML page on the internet. v0 scope: HTML
upload → public page, upload API private and programmatic (Obsidian Copilot only).

## Development

```bash
npm install
npm run dev        # wrangler dev
npm run typecheck
npm test
npm run deploy     # wrangler deploy
```

`docs/development.md` carries the details these commands need — including the
license-server workaround a local push needs — and `docs/deploying.md` has the
provisioning sequence a first deploy requires. `docs/hosting.md` explains the
Cloudflare model itself. `scripts/smoke.sh` checks a *deployed* worker end to end and is run by
hand — it needs a real license key, so it is deliberately not in CI.

Tests run inside workerd against real R2 and D1 (Miniflare), never against test
doubles for them. A binding double asserts our idea of Cloudflare rather than
Cloudflare, so the only ones in the suite are deliberately broken bindings used
to test failure paths. The one other exception is the `EMAIL` (Email Service)
binding: its only observable is the outgoing message, which Miniflare offers no
way to read from inside workerd, so tests hand the Worker a recording `send`.
`npm run test:browser` drives the Share popover in Chrome against the same
Worker bundle on Miniflare's real D1 and R2; it is the one test that needs a
browser (see `docs/development.md`).

## The HTTP contract is frozen

`docs/http-api.md` is the contract Obsidian Copilot is written against, and it
is the source of truth — not this repo's source. Nothing in it changes
without a client release: not a status code, not an error `code`, not a field
name. Adding to it is fine; changing what is there is a cross-repo decision.

Two conventions in it are security properties rather than style, and both are
easy to undo by accident:

- **404, never 403.** A doc belonging to another publisher must be
  indistinguishable from one that never existed, on every endpoint. That covers
  `/open`, `/share`, the request route and the label hosts as well as `/api/v1`.
- **404 vs 410.** A deleted doc keeps its D1 row forever so its url answers 410.
  Delete destroys the R2 objects; it must never delete the row.

## Product context

`docs/positioning.md` and `docs/cost-at-scale.md` are the source of truth for what
this product is and how it is allowed to be built. Read them before designing
anything non-trivial — especially §6 of the cost doc (architecture) and the wedge
in the positioning doc.

## Non-negotiable design constraints

These are day-one decisions, not retrofits:

- User content is served from a **sacrificial domain** (`openartifacts.site`),
  separate from the brand domain. Reputation damage must not land on
  `openartifacts.ai`. `docs/serving-domain.md` is why, and why a subdomain does not
  substitute. There is no migration path once links are in the wild.
- Shared docs are **`noindex` + `nofollow` by default**.
- **Publisher-gated, reader-open**: publishing requires a key; reading a public
  document requires nothing. New pages from the CLI start invited-only when the
  plan allows it (`--public` opts out). An invited-only document is the exception, and it
  is never served from `openartifacts.site`: **no origin that runs more than one
  publisher's scripts may ever hold a credential**, and a label host serves one
  publisher only (`docs/private-sharing.md`).
- **Documents belong to an account, not to the credential that published them.**
  `docs.owner` is an app-sites `User.id`, and it is always *derived* — from a
  validated license key, later from a signed session — and **never accepted as
  input**. An endpoint that took an owner id as a parameter would turn every id
  into a password, and would not look like a security change when written.
  `docs/identity.md` is the whole model.
- **R2 is the system of record**, and the stored format is HTML. D1 holds pointer
  rows only, never content, and the document rows are rebuildable from R2: each
  doc's `manifest.json` carries its state, and the version objects carry the version
  log; see "Document state in R2" below for what is and is not covered.
  Comment bodies are the one exception to "D1 holds pointer rows": they live in the
  per-document Durable Object `DocComments`, snapshotted to `docs/{id}/threads.json`
  in R2 after every change, and never in D1.
- Every push mints an **immutable version** served with `cache-control: immutable`.
  R2 stores the publisher's own bytes; OpenArtifacts' additions (the robots meta,
  the favicon, the social card, the top bar with its page-script tag, and the
  bylines) are injected on the way out by `renderServedHtml`, so a byline change —
  or a plan that removes one — reaches documents already published. An
  invited-only version is the exception to `immutable`: it is served
  `private, no-store`, never cached.
  Nothing else is composed per read: the additions are a pure function of the
  stored version, so the page stays cacheable.
- **No per-MAU or per-seat priced dependency** anywhere in the serving path.
- Quotas and per-key rate limits ship with the feature they protect, not later.

## Document state in R2

D1 is the only record of who owns a doc, what it is called, whether it was
deleted and whether it is private. Each doc therefore keeps
`docs/{id}/manifest.json` (`src/manifest.ts`): `owner`, `title`, `linkAccess`,
`commentsEnabled`, timestamps, `latestVersion`, and the invite list with roles.
Pending access requests are not in it.

It is a snapshot of the D1 rows, never a read-modify-write, written and awaited
after every doc-state write. A failed put fails the access, invite and comments
requests, so their retry repairs it; create and update log instead, since a
missing manifest rebuilds closed. Delete leaves the manifest as the tombstone,
and removes it with the bytes if the tombstone put fails.

`src/rebuild.ts` turns manifests plus the listed `v{n}.html` objects back into
`docs`, `doc_invites`, `versions` and `storage_usage` rows, and fails closed
(rules and limits in `docs/deploying.md`).

**This covers document state only.** Accounts, sessions, owner links, the
publishers cache, access requests and comments (which snapshot to `threads.json`)
are not in R2; D1 Time Travel (30 days) is what covers them, so D1 stays a
system of record for those.

## Owed now that the serving domain has landed

Before the cutover, the serving split uses the old hosts: documents on
`symposium.site`, the API on `api.symposium.md`. The cutover makes
`openartifacts.site` and `api.openartifacts.ai` canonical while retaining the
old document host as a GET/HEAD redirect. The old API custom domain remains
attached but every method and path returns `410 Gone` before authentication.
`workers_dev` stays `false` — an unconfigured router would otherwise
serve `/d/*` on a `workers.dev` hostname, and that domain is on the Public
Suffix List, so a listing against it would take every Worker on the account's
subdomain. Two things came due the moment user content moved to a real serving
domain, and both are easy to miss because nothing fails loudly without them:

- **Caching needs a Cache Rule, not just a domain.** Cloudflare does not cache
  HTML by default — eligibility is decided by file extension, not MIME type and
  not the `Cache-Control` the worker sends, and `/d/{docId}` has no extension.
  Without an explicit Cache Rule on the serving zone (or the Cache API inside the
  worker), attaching a domain changes nothing and every read still hits D1 and R2.
  The rule matches `hostname eq openartifacts.site` only: label hosts
  (`{label}.openartifacts.site`) serve private bytes and are never cache-rule
  targets.
- **Then delete must purge that cache.** Pinned `/v{n}` urls are served
  `immutable` for a year, so the moment there is an edge cache, an unshared doc
  would keep being served from it. Wire a purge into `deleteDoc` in the same
  change that turns caching on — the two belong together, and shipping the first
  without the second is how a withdrawn doc stays readable. The private-cache
  copies readers already hold are unrecallable by any design, and the README says
  so. Making a document invited-only must purge the cache exactly as delete does,
  in the same change: otherwise a document made private stays readable from the
  edge.

All four canonical, legacy, and retired host vars are set in `wrangler.jsonc`,
which makes the router resolve by an explicit host allowlist (those four hosts
plus `{26-char label}.{SERVING_HOST}`, the publisher origins) and fail closed on
everything else. Leave them set together. Path-prefix fallback exists only for
local development when every host var is empty.

The first four-domain cutover is one production deployment. Record that Worker
version id as the hard rollback floor. Never roll back below it while either new
domain or `api.symposium.md` remains attached: older versions route unknown
hosts by path and make the retired API live again. Only while no document is
invited-only, a pre-floor emergency rollback first detaches those three domains
and accepts an API outage; see `docs/deploying.md`.

Private sharing adds a second hard floor: the first Worker version that contains
it. Once any document is invited-only, an older version serves that document
publicly at its canonical url, and detaching domains does not help, so there is no
safe pre-floor emergency rollback: roll forward. Record the version id beside the
cutover's, and treat the newer of the two as the floor (`docs/deploying.md`).

The comments deploy adds a third: the first version carrying the `DocComments`
Durable Object migration. Cloudflare enforces it for every rollback path
(`wrangler rollback`, `wrangler versions deploy`, the dashboard), so it is the
effective floor, and the pre-floor emergency rollback above can no longer be
carried out (`docs/deploying.md`). Its version id is
`11148574-004d-4692-bdb2-f2a2be94f65d`.

## Git hygiene

Stage explicit paths (`git add src/foo.ts`), never `git add -A` / `git add .`.
Before committing, review the staged set with `git status` **and**
`git diff --cached`, plus `git status --porcelain --untracked-files=all` to catch
untracked files.

All changes ship through a pull request. Never push directly to `main`.

For npm release preparation, follow the [release rules in AGENTS.md](AGENTS.md#npm-releases).