Skip to content
Merged
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
17 changes: 17 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ Auth:

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.
`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 All @@ -218,6 +220,21 @@ the chip/highlight appear optimistically the instant you react, no reload. Comme
owner / editor or commenter grant / view-token holder with identity / any identity
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.

Viewing: `/d/:slug` (shell + sandboxed iframe; the google-docs-style comment
rail appears once a doc has comments/reactions or the viewer can interact —
zero comments + non-interacting viewer = zero chrome), `/d/:slug/raw`
Expand Down
50 changes: 44 additions & 6 deletions app/api/v1/docs/[slug]/edits/route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import {
forbiddenScope,
hasScope,
json,
notFoundDoc,
parseJsonObject,
payloadTooLarge,
quotaExceeded,
requireApiKey,
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 { EditsBody, editsBadRequest } from "@/lib/docs/schemas";
import { MAX_HTML_BYTES } from "@/lib/docs/config";
import { MAX_HTML_BYTES, RL_WRITES_PER_MIN } from "@/lib/docs/config";
import { applyPatch, findBySlug, granteeView, ownerView } from "@/lib/docs/store";
import { EditApplyError, type Edit } from "@/lib/docs/edit-diff";
import { accessRoleLabel, canEdit, resolveAccess } from "@/lib/docs/grants";
Expand All @@ -20,11 +26,43 @@ type Ctx = { params: Promise<{ slug: string }> };

// POST /api/v1/docs/:slug/edits — apply deterministic patches (birthday.md
// "Editing"). Body: { edits: [{ oldText, newText }, ...], base_version? }.
// Scope: docs.write. Owner OR editor grant (birthday.md "Permissions model").
//
// Auth: API key (agents, scope docs.write) OR a signed-in browser session — the
// same dual identity the comment routes accept, because the viewer's inline edit
// mode posts here from the shell with a cookie, not a jh_live_ key. What each
// identity may do is UNCHANGED and enforced below by canEdit: owner or editor
// grant, nothing else. A view-token holder or a public-doc reader resolves to no
// access and gets the same 404 they always did — read links stay read-only.
export async function POST(req: Request, ctx: Ctx): Promise<Response> {
const auth = await requireApiKey(req, "docs.write", "write");
if ("response" in auth) return auth.response;
const { principal } = auth;
const apiPrincipal = await authenticate(req);
if (apiPrincipal && !hasScope(apiPrincipal, "docs.write")) return forbiddenScope("docs.write");
const session = apiPrincipal ? null : await getSession(req);
// Neither identity → the Bearer 401 an agent can bootstrap from (a session
// always resolves to a principal, so this is the truly-anonymous case).
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) }
);
}
}

// Size cap by Content-Length before parse (the produced html is re-checked
// under the write lock inside applyPatch). This precheck runs before the doc
Expand Down
133 changes: 131 additions & 2 deletions app/d/[slug]/CommentsShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { buildChromePalette, type ThemeSample, type ChromePalette } from "@/lib/
import CommentMarkdown from "@/lib/docs/comments/CommentMarkdown";
import type { Section } from "@/lib/docs/sections";
import { fragmentFor, parseHash } from "@/lib/docs/deeplink";
import { buildInlineEdits, type TextChange } from "@/lib/docs/inline-edit";

// CommentsShell — the THIRD React surface (birthday.md "Production
// architecture", "CHOSEN: variant B"). The google-docs-style comment rail. The
Expand Down Expand Up @@ -78,6 +79,10 @@ type Props = {
viewtoken: string | null;
canComment: boolean;
canReact: boolean;
// Inline edit mode is offered only to the owner or an editor grantee (resolved
// server-side by canEdit). Everyone else — including view-token holders who may
// comment — never sees the affordance, and the API would refuse them anyway.
canEdit: boolean;
signedIn: boolean;
docId: number;
bookmarked: boolean;
Expand Down Expand Up @@ -135,6 +140,20 @@ function legacyCopy(text: string): boolean {
}
}

/**
* Turn an /edits failure into something a person can act on. The engine's reason
* codes are precise but written for an agent retrying with more context; a human
* needs to know whether to reload, retype, or hand the change to their agent.
*/
function editErrorMessage(status: number, body: { reason?: string; message?: string } | null): string {
if (status === 409) return "edited elsewhere — reload for the latest";
if (status === 429) return "too many edits — wait a moment";
if (status === 401 || status === 403 || status === 404) return "no edit access";
if (status === 422 && body?.reason === "multiple_matches") return "text repeats — edit via your agent";
if (status === 422 && body?.reason === "not_found") return "couldn’t place that text — edit via your agent";
return body?.message || "save failed";
}

export default function CommentsShell(props: Props) {
const {
slug,
Expand All @@ -143,6 +162,7 @@ export default function CommentsShell(props: Props) {
viewtoken,
canComment,
canReact,
canEdit,
signedIn,
docId,
me,
Expand Down Expand Up @@ -235,6 +255,20 @@ export default function CommentsShell(props: Props) {
);
const isDark = palette !== null;

// Inline edit mode. versionRef tracks the version every patch is based on, so a
// concurrent write 409s instead of silently overwriting; the server hands back
// the new version on each save.
const [editing, setEditing] = useState(false);
const [editStatus, setEditStatus] = useState<string | null>(null);
const versionRef = useRef(props.version);
const saveInlineEditRef = useRef<(changes: TextChange[]) => void>(() => {});
const statusTimer = useRef<number | null>(null);
const showEditStatus = useCallback((msg: string | null, clearAfterMs?: number) => {
setEditStatus(msg);
if (statusTimer.current) window.clearTimeout(statusTimer.current);
if (msg && clearAfterMs) statusTimer.current = window.setTimeout(() => setEditStatus(null), clearAfterMs);
}, []);

// Selection state (from the overlay) → the floating toolbar + a pending draft.
const [selection, setSelection] = useState<{ anchor: NonNullable<Anchor>; top: number; viewTop: number } | null>(null);
const [draft, setDraft] = useState<{ anchor: NonNullable<Anchor>; top: number } | null>(null);
Expand Down Expand Up @@ -350,6 +384,13 @@ export default function CommentsShell(props: Props) {
postToOverlay({ type: "jh:reactions", groups: paintReactionGroups, me, avatars });
}, [overlayReady, paintReactionGroups, me, avatars, postToOverlay]);

// Sync edit mode to the overlay. Keyed on overlayReadyNonce as well as
// overlayReady so an iframe reload re-enters edit mode rather than silently
// dropping it (the fresh overlay starts with editing off).
useEffect(() => {
if (overlayReady) postToOverlay({ type: "jh:editMode", on: editing });
}, [overlayReady, overlayReadyNonce, editing, postToOverlay]);

// Send sections likewise — on change or when the overlay (re)becomes ready — so
// heading ids + gutter icons don't go stale if initialSections changes in place.
useEffect(() => {
Expand Down Expand Up @@ -463,6 +504,14 @@ export default function CommentsShell(props: Props) {
// A chip was clicked inside the iframe → optimistic toggle (add/remove).
if (canReact && d.anchor && d.anchor.exact) reactAnchoredRef.current(d.emoji, d.anchor);
break;
case "jh:edit":
// A block left edit mode with changed text. canEdit is re-checked
// server-side on every patch; this is only the UI gate.
if (canEdit && Array.isArray(d.changes)) saveInlineEditRef.current(d.changes);
break;
case "jh:editRejected":
showEditStatus("structure changed — use your agent for that", 6000);
break;
case "jh:copyLink":
// The section link icon (inside the iframe) was clicked. Clipboard is
// unreliable from the opaque-origin sandbox, so the copy happens here on
Expand All @@ -473,7 +522,7 @@ export default function CommentsShell(props: Props) {
}
window.addEventListener("message", onMsg);
return () => window.removeEventListener("message", onMsg);
}, [paintAnchors, paintReactionGroups, me, avatars, postToOverlay, canComment, canReact, initialSections, copyLink]);
}, [paintAnchors, paintReactionGroups, me, avatars, postToOverlay, canComment, canReact, canEdit, initialSections, copyLink, showEditStatus]);

const reload = useCallback(async () => {
const r = await fetch(`${apiBase}/comments${tokenQuery}`, { credentials: "same-origin" });
Expand Down Expand Up @@ -637,6 +686,50 @@ export default function CommentsShell(props: Props) {
);
reactAnchoredRef.current = reactAnchored;

// Save one block's inline edit. The overlay reports TEXT-NODE before/after pairs
// (never HTML — see lib/docs/inline-edit.ts), which become the same deterministic
// {oldText,newText} patch an agent posts, so the stored bytes change only where
// the text actually changed and everything around it stays byte-for-byte.
const saveInlineEdit = useCallback(
async (changes: TextChange[]) => {
const payloads = buildInlineEdits(changes);
if (!payloads) return;
Comment thread
sjmiller609 marked this conversation as resolved.
showEditStatus("saving…");

const post = (edits: { oldText: string; newText: string }[]) =>
fetch(`${apiBase}/edits${tokenQuery}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ edits, base_version: versionRef.current }),
});

let r = await post(payloads.edits);
let body = await r.json().catch(() => null);
// A miss here usually means the source spells the text with entities
// (`&amp;`, `&nbsp;`), so the DOM text isn't a substring of it. Retry once
// with the escaped form before telling the viewer we can't place it.
if (r.status === 422 && body?.reason === "not_found") {
r = await post(payloads.escaped);
body = await r.json().catch(() => null);
}

// Tell the overlay first: on a rejection it puts the author's text back, so
// the rendered document never disagrees with the stored bytes.
postToOverlay({ type: "jh:editResult", ok: r.ok });
if (!r.ok) {
showEditStatus(editErrorMessage(r.status, body), 6000);
return;
}
if (typeof body?.version === "number") versionRef.current = body.version;
showEditStatus("saved", 2000);
// The write re-anchored comments in the same transaction; pull the result.
await reload();
},
[apiBase, tokenQuery, postToOverlay, reload, showEditStatus]
);
saveInlineEditRef.current = (changes) => void saveInlineEdit(changes);

// Sync the active highlight to the overlay.
useEffect(() => {
if (overlayReady) postToOverlay({ type: "jh:active", id: activeId });
Expand Down Expand Up @@ -857,6 +950,42 @@ export default function CommentsShell(props: Props) {
</svg>
</button>
) : null}
{canEdit ? (
<>
{editStatus ? (
<span aria-live="polite" style={{ fontSize: "0.75rem", opacity: 0.85 }}>
{editStatus}
</span>
) : null}
<button
type="button"
aria-pressed={editing}
aria-label={editing ? "stop editing" : "edit this document"}
title={editing ? "Stop editing" : "Edit text inline — click a paragraph to type"}
onClick={() => {
setEditing((e) => !e);
showEditStatus(null);
}}
style={{ ...commentBtnStyle(editing), lineHeight: 1 }}
>
<svg
viewBox="0 0 24 24"
width="13"
height="13"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
style={{ display: "block" }}
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z" />
</svg>
</button>
</>
) : null}
<button
type="button"
className="jh-commentbtn"
Expand Down Expand Up @@ -886,7 +1015,7 @@ export default function CommentsShell(props: Props) {
selection's viewport-top within the iframe. Shows on selection when
the viewer can comment OR react (react-only viewers still get the
react affordance). */}
{selection && (canComment || canReact) ? (
{selection && !editing && (canComment || canReact) ? (
<SelectionToolbar
viewTop={selection.viewTop}
canComment={canComment}
Expand Down
11 changes: 9 additions & 2 deletions app/d/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { bookmarkExists, findBySlug } from "@/lib/docs/store";
import { canViewSession, canView } from "@/lib/docs/access";
import { canEdit } from "@/lib/docs/grants";
import { mintViewCap } from "@/lib/docs/viewcap";
import { getSession } from "@/lib/auth/session";
import { headers } from "next/headers";
Expand Down Expand Up @@ -81,15 +82,20 @@ export default async function ViewerPage({ params, searchParams }: Props) {
if (viewtoken) rawQuery = `?viewtoken=${encodeURIComponent(viewtoken)}`;
else if (!doc.is_public) rawQuery = `?cap=${encodeURIComponent(mintViewCap(slug))}`;

// Can this viewer comment and/or react? (Drives whether the rail is
// interactive and whether the selection toolbar offers comment/react.)
// Can this viewer comment, react, and/or edit? (Drives whether the rail is
// interactive, whether the selection toolbar offers comment/react, and whether
// the bar offers inline edit mode.) Editing is deliberately the narrowest of the
// three — owner or editor grant only, straight off the same resolved access, so
// a view-token holder who may comment still cannot type into the document.
const principal = await resolveCommentPrincipal(null, session);
let canComment = false;
let canReact = false;
let canEditDoc = false;
if (principal) {
const cap = await resolveCapability(doc, principal, canView(doc, viewtoken));
canComment = cap.canComment;
canReact = cap.canReact;
canEditDoc = canEdit(cap.access);
}

const threadData = await allThreads(doc);
Expand Down Expand Up @@ -123,6 +129,7 @@ export default async function ViewerPage({ params, searchParams }: Props) {
viewtoken={viewtoken}
canComment={canComment}
canReact={canReact}
canEdit={canEditDoc}
signedIn={session !== null}
docId={doc.id}
bookmarked={bookmarked}
Expand Down
Loading
Loading