Skip to content
Closed
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
84 changes: 68 additions & 16 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,11 @@ Auth:
- API 401s carry `WWW-Authenticate: Bearer resource_metadata="…"`.

Documents (`/api/v1`, `Authorization: Bearer jh_live_…`): `docs` CRUD,
`/edits` (deterministic patches), `/rotate-token`, `/versions`, `/grants`.
`/edits` additionally accepts a signed-in session — it backs the viewer's inline
edit mode (see below); every other document endpoint stays API-key-only.
`/edits` (deterministic text patches), `/ops` (structural edits),
`/rotate-token`, `/versions`, `/versions/:n/restore`, `/grants`. `/edits`,
`/ops` and `/versions/:n/restore` additionally accept a signed-in session — they
back the viewer's inline edit mode and the history page's restore button (see
below); every other document endpoint stays API-key-only.
`GET /api/v1/docs` items carry `access` and `comment_count`. Creating an
**email** grant sends the grantee a share-notification email — one single-use,
7-day login link (`kind='share'` on `login_tokens`) with `next=/d/:slug` that
Expand Down Expand Up @@ -221,19 +223,69 @@ owner / editor or commenter grant / view-token holder with identity / any identi
on a public doc. React: anyone who can view, with identity. Anonymous never writes.

Inline editing (`/d/:slug`, owner or editor grant): the bar's pencil enters edit
mode; clicking a block makes THAT BLOCK contentEditable, and blur / ⌘-Enter saves.
The overlay never serializes the DOM back to HTML — a round-trip would rewrite the
whole document (attribute order, entity forms, script-rendered subtrees) and
byte-exact `/raw` is the product. Instead it diffs the block's TEXT NODES and
reports `{before, after}` pairs; `lib/docs/inline-edit.ts` turns those into the
same `{oldText,newText}` patch an agent posts to `/edits`, so versioning,
re-anchoring, quotas and the 409/422 outcomes are all the existing paths. A text
node's content is verbatim in the stored HTML, except where the author used
entities — hence two payloads (literal, then entity-escaped on a `not_found`), and
`newText` is always escaped so typing `<b>` lands as text. Structural edits are
not expressible as text patches: Enter is suppressed, paste is flattened, and a
node split/merge/removal restores the block and reports "structure changed" rather
than guessing. Those go through the agent API.
mode; clicking a block makes THAT BLOCK contentEditable. There are two write
paths, chosen by what changed.

**Typing** diffs the block's TEXT NODES on blur / ⌘-Enter and posts
`{oldText,newText}` to `/edits` — the overlay never serializes the DOM back to
HTML, because a round-trip would rewrite the whole document (attribute order,
entity forms, script-rendered subtrees) and byte-exact `/raw` is the product.
Nothing reloads and comment anchors ride the patch's offset map. A text node is
verbatim in the stored HTML except where the author used entities, hence two
payloads (literal, then entity-escaped on `not_found`); `newText` is always
escaped so typing `<b>` lands as text. If the text still can't be placed — or
appears more than once — the shell retries POSITIONALLY as a `setRuns` op, which
names the exact text node instead of searching for its content.

**Formatting and structure** posts to `/ops`. Each op names an element by the
`data-jh-src` id written into the start tags of the overlay copy only
(`lib/docs/html-source.ts`; direct `/raw` stays byte-pristine) and describes what
it should become; `lib/docs/doc-ops.ts` turns that into ONE SPLICE of that
element's byte range, so everything outside it is untouched down to the byte,
exactly as with a text patch. Ops carry INTENT, never markup — every tag comes
from a literal in `lib/docs/block-render.ts` and author text is always escaped —
so there is no html to sanitize. Ids are indexes into the bytes they were served
with, so `base_version` is effectively mandatory; a mismatch is a 409 and the
shell replays once (safe because text ops carry the text they expect to replace).

After an ops write the iframe RELOADS against the stored bytes and the caret and
scroll are restored, rather than the overlay re-rendering the change locally: a
second markup renderer in the sandbox would have to agree with the server's
forever, and the moment it drifted the rendered document would stop being the
stored one. Typing does not reload.

Markdown is an INPUT METHOD, not a storage format (`lib/docs/markdown-input.ts`,
injected into the overlay): `**bold**`, `` `code` ``, `[t](u)`, bare URLs, `## `
/ `- ` / `> ` / ``` ``` ``` / `---` at a block start, and a multi-line paste all
parse to runs/blocks that the server renders. Nothing round-trips back to
asterisks. Keys: ⌘B / ⌘I / ⌘E / ⌘⇧X / ⌘K, ⌘⌥1-3 headings, ⌘⇧7 / ⌘⇧8 lists,
⌘⇧. quote, Tab / ⇧Tab list indent and table-cell movement, Backspace on an empty
block deletes it, `/` opens the block menu, Esc cancels.

**Enter always splits the block at the caret** — it is never a save (⌘Enter is),
and nothing is allowed to swallow it. It is one `splitAt` op: the caret's text
node is CUT at a byte offset, both halves keep their bytes exactly, and the
inline elements the caret sits inside are closed before the block ends and
reopened inside the new one. Because nothing is re-rendered, this works in a
block holding markup the run model could never describe — a `<span>`, a `<sup>` —
which the earlier describe-both-halves approach silently declined to split. At
either end of a block the same op leaves an empty half, so there are no special
cases. When the block has uncommitted typing, or markdown before the caret, the
op instead carries the two halves' new content, which is how a split saves what
is on screen and resolves markdown in the same write. The split is applied to the
DOM immediately and the reload lands on top of it, so Enter doesn't wait on a
round trip.
Blocks drag to reorder by the gutter grip. A block holding markup the run model
can't describe (an unknown inline element) is REFUSED rather than reformatted.

Because inline editing writes on every blur and every formatting command,
consecutive patches by one author inside `VERSION_COALESCE_MS` replace the
previous snapshot instead of adding one — the document's version still increments
on every write so stale-write detection is unaffected, but a session of writing yields
roughly one restorable point per five minutes instead of exhausting
`MAX_VERSIONS_PER_DOC`. `POST /versions/:n/restore` (and the button on
`/d/:slug/history`) writes an old version's content forward as a new one, so a
bad edit no longer needs database surgery to undo.

Viewing: `/d/:slug` (shell + sandboxed iframe; the google-docs-style comment
rail appears once a doc has comments/reactions or the viewer can interact —
Expand Down
113 changes: 113 additions & 0 deletions app/api/v1/docs/[slug]/ops/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import {
forbiddenScope,
hasScope,
json,
notFoundDoc,
parseJsonObject,
payloadTooLarge,
quotaExceeded,
rateLimit,
staleVersion,
unprocessableEdit,
} from "@/lib/docs/api";
import { authenticate, authFail } from "@/lib/auth/bearer";
import { getSession } from "@/lib/auth/session";
import { resolveCommentPrincipal } from "@/lib/docs/comments";
import { checkLimits } from "@/lib/auth/ratelimit";
import { OpsBody, opsBadRequest } from "@/lib/docs/schemas";
import { MAX_HTML_BYTES, RL_WRITES_PER_MIN } from "@/lib/docs/config";
import { applyDocOps, findBySlug, granteeView, ownerView } from "@/lib/docs/store";
import { OpApplyError, type Op } from "@/lib/docs/doc-ops";
import { accessRoleLabel, canEdit, resolveAccess } from "@/lib/docs/grants";

export const dynamic = "force-dynamic";

type Ctx = { params: Promise<{ slug: string }> };

// POST /api/v1/docs/:slug/ops — apply structural edits (lib/docs/doc-ops.ts).
// Body: { ops: [{ op, src, … }, …], base_version }.
//
// The sibling of /edits. /edits changes TEXT by matching it; this changes MARKUP
// by naming an element, which is what the viewer's formatting, block-type,
// insert/delete/move and list commands need. Auth, rate limiting, quota, size
// caps and the 409/422 contract are deliberately identical to /edits — the only
// additions are the op-specific 422 reasons and `focus`, the id of content this
// request created, which the viewer uses to put the caret in the right place
// once it has reloaded against the new bytes.
//
// base_version is effectively mandatory here even though the schema allows it to
// be absent: an element id only means something against the bytes it was served
// with, so an id dereferenced against a document that has moved is a silent
// mis-edit. Sending it turns that into a 409.
export async function POST(req: Request, ctx: Ctx): Promise<Response> {
const apiPrincipal = await authenticate(req);
if (apiPrincipal && !hasScope(apiPrincipal, "docs.write")) return forbiddenScope("docs.write");
const session = apiPrincipal ? null : await getSession(req);
const principal = await resolveCommentPrincipal(apiPrincipal, session);
if (!principal) return authFail(req);

// Rate limit per credential: the existing per-key bucket for agents, a
// per-session bucket at the same ceiling for the browser.
if (apiPrincipal) {
const limited = await rateLimit(req, apiPrincipal, "write");
if (limited) return limited;
} else {
const tripped = await checkLimits([
{ key: `docs:write:sess:${session!.id}`, limit: RL_WRITES_PER_MIN, window: "minute" },
]);
if (tripped) {
return json(
{
error: "rate_limited",
message: `Too many requests. Retry after ${tripped.retryAfter} seconds.`,
retry_after: tripped.retryAfter,
},
429,
{ "Retry-After": String(tripped.retryAfter) }
);
}
}

const contentLength = Number(req.headers.get("content-length") ?? "");
if (Number.isFinite(contentLength) && contentLength > MAX_HTML_BYTES) {
return payloadTooLarge(MAX_HTML_BYTES, contentLength);
}

const { slug } = await ctx.params;
const doc = await findBySlug(slug);
if (!doc) return notFoundDoc();
const access = await resolveAccess(doc, principal.email, principal.userId);
// No existence oracle: no edit access is the same 404 as no document.
if (!canEdit(access)) return notFoundDoc();

const parsed = await parseJsonObject(req);
if ("response" in parsed) return parsed.response;

const v = OpsBody.safeParse(parsed.obj);
if (!v.success) return opsBadRequest(v.error);

let result;
try {
result = await applyDocOps({
doc,
ops: v.data.ops as Op[],
baseVersion: v.data.base_version,
authorUserId: principal.userId,
});
} catch (e) {
if (e instanceof OpApplyError) return unprocessableEdit(e.reason, e.opIndex, e.message);
throw e;
}

if ("stale" in result) return staleVersion(result.stale.currentVersion);
if ("tooLarge" in result) return payloadTooLarge(MAX_HTML_BYTES, result.tooLarge.gotBytes);
if ("quota" in result) {
return quotaExceeded(result.quota.kind, result.quota.limit, result.quota.current);
}

const view =
access.kind === "owner"
? ownerView(result.doc, true)
: granteeView(result.doc, true, accessRoleLabel(access));
return json(result.focus === undefined ? view : { ...view, focus: result.focus });
}
89 changes: 89 additions & 0 deletions app/api/v1/docs/[slug]/versions/[n]/restore/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import {
apiError,
forbiddenScope,
hasScope,
json,
notFoundDoc,
parsePositiveIntParam,
quotaExceeded,
rateLimit,
} from "@/lib/docs/api";
import { authenticate, authFail } from "@/lib/auth/bearer";
import { getSession } from "@/lib/auth/session";
import { resolveCommentPrincipal } from "@/lib/docs/comments";
import { checkLimits } from "@/lib/auth/ratelimit";
import { RL_WRITES_PER_MIN } from "@/lib/docs/config";
import { findBySlug, findVersion, granteeView, ownerView, rewriteDoc } from "@/lib/docs/store";
import { accessRoleLabel, canEdit, resolveAccess } from "@/lib/docs/grants";

export const dynamic = "force-dynamic";

type Ctx = { params: Promise<{ slug: string; n: string }> };

// POST /api/v1/docs/:slug/versions/:n/restore — put an earlier version's content
// back as the current one. Owner or editor grant; API key OR signed-in session,
// so the history page's Restore button works from the browser.
//
// Restoring is a normal forward write, not a rewind: it takes version n's html
// and stores it as a NEW version with edit_kind 'rewrite'. Nothing is deleted, so
// restoring the wrong version is itself undoable, and the intervening versions
// stay in the history (subject to the usual retention cap).
//
// This is the recovery path for inline editing. Every other route can only move
// a document forward, which meant undoing a bad write required operating on the
// database directly.
export async function POST(req: Request, ctx: Ctx): Promise<Response> {
const apiPrincipal = await authenticate(req);
if (apiPrincipal && !hasScope(apiPrincipal, "docs.write")) return forbiddenScope("docs.write");
const session = apiPrincipal ? null : await getSession(req);
const principal = await resolveCommentPrincipal(apiPrincipal, session);
if (!principal) return authFail(req);

if (apiPrincipal) {
const limited = await rateLimit(req, apiPrincipal, "write");
if (limited) return limited;
} else {
const tripped = await checkLimits([
{ key: `docs:write:sess:${session!.id}`, limit: RL_WRITES_PER_MIN, window: "minute" },
]);
if (tripped) {
return json(
{
error: "rate_limited",
message: `Too many requests. Retry after ${tripped.retryAfter} seconds.`,
retry_after: tripped.retryAfter,
},
429,
{ "Retry-After": String(tripped.retryAfter) }
);
}
}

const { slug, n } = await ctx.params;
const versionResult = parsePositiveIntParam("Version", n);
if ("response" in versionResult) return versionResult.response;

const doc = await findBySlug(slug);
if (!doc) return notFoundDoc();
const access = await resolveAccess(doc, principal.email, principal.userId);
if (!canEdit(access)) return notFoundDoc();

const version = await findVersion(doc.id, versionResult.value);
if (!version) {
return apiError(404, "not_found", "No such version (it may have been pruned past the retention cap).");
}
if (version.html === doc.html) {
return apiError(422, "no_change", "That version's content is already what the document holds.");
}

const result = await rewriteDoc({ doc, html: version.html, authorUserId: principal.userId });
if ("quota" in result) {
return quotaExceeded(result.quota.kind, result.quota.limit, result.quota.current);
}

const view =
access.kind === "owner"
? ownerView(result.doc, true)
: granteeView(result.doc, true, accessRoleLabel(access));
return json({ ...view, restored_from: version.version });
}
Loading
Loading