# 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 must be rebuildable by scanning R2 manifests.
  **This does not hold yet — see below. Do not rely on it.**
  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.

## Owed before there is much data

**D1 is currently the only record of who owns a doc, what it is called, and
whether it was deleted.** R2 holds `docs/{id}/v{n}.html` and nothing else, so the
rebuildability the constraint above claims is not true today:

- publisher, title and timestamps exist only in D1;
- a deleted doc leaves an empty R2 prefix, so a rebuild could not tell a
  withdrawn url from one that never existed, and it would answer 404 where it
  promised 410;
- `link_access`, the invite list and the pending access requests exist only in D1.
  A rebuild from R2 alone would turn every private document public, so they belong
  in the manifest snapshot too.

The fix is the per-doc `manifest.json` from `docs/cost-at-scale.md` §6: written
after each push as a snapshot of the doc's D1 state, and left behind as a
tombstone on delete rather than swept with the version objects. Snapshot it, do
not read-modify-write it.

**Not a deploy blocker.** Backfilling manifests needs the D1 they insure
against, which sounds urgent but isn't while a rebuild would be a handful of
rows — and D1 has a 30-day Time Travel window underneath. Do it well before
there is enough data that reconstructing it by hand would hurt. Until then,
treat D1 as a system of record, not as a cache.

## 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 refuses any rollback across it, so it is the
effective floor once shipped, and the pre-floor emergency rollback above can no
longer be carried out (`docs/deploying.md`).

## 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).
