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
85 changes: 85 additions & 0 deletions app/api/v1/bookmarks/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { apiError, json, requireApiKey } from "@/lib/docs/api";
import { docUrl, listBookmarks, type BookmarkDocRow } from "@/lib/docs/store";
import { resolveAccess } from "@/lib/docs/grants";
import { safeEqualStr } from "@/lib/auth/tokens";
import { resolveBookmarkView } from "@/lib/docs/bookmarks-view";

export const dynamic = "force-dynamic";

const DEFAULT_LIST_LIMIT = 100;
const MAX_LIST_LIMIT = 500;

// One bookmark's API view: re-resolve the caller's access (no session — the key
// acts as its authenticated email), then shape the item. A revoked bookmark
// carries no url and the bookmark-time title snapshot (the caller can no longer
// see the doc's current title).
async function itemView(doc: BookmarkDocRow, principalEmail: string, principalUserId: number) {
const access = await resolveAccess(doc, principalEmail, principalUserId);
const grantRole =
access.kind === "email_grant" || access.kind === "domain_grant" ? access.role : null;
const view = resolveBookmarkView({
deleted: doc.deleted_at != null,
isOwner: access.kind === "owner",
grantRole,
isPublic: doc.is_public,
tokenValid: doc.bookmark_token != null && safeEqualStr(doc.bookmark_token, doc.view_token),
});

const url = view.revoked
? null
: view.usesToken && doc.bookmark_token
? `${docUrl(doc.slug)}?viewtoken=${encodeURIComponent(doc.bookmark_token)}`
: docUrl(doc.slug);

return {
slug: doc.slug,
url,
title: view.revoked ? doc.bookmark_title : doc.title,
access: view.access,
revoked: view.revoked,
public: doc.is_public,
bookmarked_at: doc.bookmarked_at,
};
}

// GET /api/v1/bookmarks — list the caller's bookmarks, newest first. Scope:
// docs.read.
//
// ?scope=all (default) | owned | shared:
// - owned : bookmarked docs the key's user owns.
// - shared: bookmarked docs shared with (not owned by) the caller.
// - all : both. The web equivalent for a signed-in human is /bookmarks.
export async function GET(req: Request): Promise<Response> {
const auth = await requireApiKey(req, "docs.read", "read");
if ("response" in auth) return auth.response;
const { principal } = auth;

const url = new URL(req.url);
let limit = DEFAULT_LIST_LIMIT;
const limitParam = url.searchParams.get("limit");
if (limitParam != null) {
const n = Number(limitParam);
if (!Number.isInteger(n) || n < 1) {
return apiError(400, "invalid_request", "Query 'limit' must be a positive integer.");
}
limit = Math.min(n, MAX_LIST_LIMIT);
}

const scope = (url.searchParams.get("scope") ?? "all") as "owned" | "shared" | "all";
if (scope !== "owned" && scope !== "shared" && scope !== "all") {
return apiError(400, "invalid_request", "Query 'scope' must be one of: owned, shared, all.");
}

// Filter by scope in SQL so `limit` bounds the returned scope, not the full
// bookmark set (an owned/shared request must not underfill because out-of-scope
// rows consumed the limit first).
const rows = await listBookmarks(principal.email, limit, {
scope,
ownerId: principal.userId,
});
const bookmarks = [];
for (const doc of rows) {
bookmarks.push(await itemView(doc, principal.email, principal.userId));
}
Comment thread
cursor[bot] marked this conversation as resolved.
return json({ bookmarks });
}
60 changes: 60 additions & 0 deletions app/api/v1/docs/[slug]/bookmark/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { json, notFoundDoc, requireApiKey } from "@/lib/docs/api";
import { findBySlug, removeBookmarkBySlug, saveBookmark } from "@/lib/docs/store";
import { resolveAccess } from "@/lib/docs/grants";
import { canView } from "@/lib/docs/access";
import { safeEqualStr } from "@/lib/auth/tokens";

export const dynamic = "force-dynamic";

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

// PUT /api/v1/docs/:slug/bookmark — idempotently bookmark a doc the caller can
// view. Keyed by the key's email, so it unifies with the account's signed-in
// web bookmarks. Requires view access (owner / grant via identity, a public
// doc, or a matching ?viewtoken=); an inaccessible doc 404s (no existence
// oracle). Scope: docs.read — bookmarking only needs to read the doc, and it
// writes the caller's own personal state, not the document.
export async function PUT(req: Request, ctx: Ctx): Promise<Response> {
const auth = await requireApiKey(req, "docs.read", "write");
if ("response" in auth) return auth.response;
const { principal } = auth;

const { slug } = await ctx.params;
const viewtoken = new URL(req.url).searchParams.get("viewtoken");

const doc = await findBySlug(slug);
if (!doc) return notFoundDoc();

// Owner / email / domain grant via the authenticated email, or public / a
// matching view token (the viewer-route path an API key may still present).
const access = await resolveAccess(doc, principal.email, principal.userId);
if (access.kind === "none" && !canView(doc, viewtoken)) return notFoundDoc();

// Persist the token only when it is the actual basis for access — a matching
// token on an otherwise private doc reached with no grant. Access via
// ownership / a grant / public makes the submitted token irrelevant, and
// storing a non-matching token would gate the later re-check on a token that
// was never the basis for access. Mirrors the web bookmark POST.
const tokenToStore =
access.kind === "none" &&
!doc.is_public &&
viewtoken &&
safeEqualStr(viewtoken, doc.view_token)
? viewtoken
: null;
await saveBookmark(principal.email, doc.id, tokenToStore, doc.title);
return json({ bookmarked: true });
}

// DELETE /api/v1/docs/:slug/bookmark — idempotently remove the caller's
// bookmark. Keyed by slug through the doc row so it still drops a bookmark for
// a revoked/deleted doc; a no-op when nothing was bookmarked. Scope: docs.read.
export async function DELETE(req: Request, ctx: Ctx): Promise<Response> {
const auth = await requireApiKey(req, "docs.read", "write");
if ("response" in auth) return auth.response;
const { principal } = auth;

const { slug } = await ctx.params;
await removeBookmarkBySlug(principal.email, slug);
return json({ removed: true });
}
46 changes: 45 additions & 1 deletion lib/docs/bookmarks-view.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { bookmarkRow, fmtDate } from "@/lib/docs/bookmarks-view";
import { bookmarkRow, fmtDate, resolveBookmarkView } from "@/lib/docs/bookmarks-view";

const base = {
docId: 42,
Expand Down Expand Up @@ -69,3 +69,47 @@ describe("fmtDate", () => {
expect(fmtDate("not-a-date")).toBe("not-a-date");
});
});

describe("resolveBookmarkView", () => {
const live = { deleted: false, isOwner: false, grantRole: null, isPublic: false, tokenValid: false };

it("a deleted doc is revoked, ahead of any live access", () => {
expect(resolveBookmarkView({ ...live, deleted: true, isOwner: true })).toEqual({
access: "revoked",
revoked: true,
usesToken: false,
});
});

it("owner beats a grant", () => {
expect(resolveBookmarkView({ ...live, isOwner: true, grantRole: "viewer" })).toEqual({
access: "owner",
revoked: false,
usesToken: false,
});
});

it("reports the identity grant role", () => {
expect(resolveBookmarkView({ ...live, grantRole: "commenter" }).access).toBe("commenter");
});

it("a public doc with no grant reads public", () => {
expect(resolveBookmarkView({ ...live, isPublic: true })).toEqual({
access: "public",
revoked: false,
usesToken: false,
});
});

it("private + no grant + a valid token reads link and uses the token", () => {
expect(resolveBookmarkView({ ...live, tokenValid: true })).toEqual({
access: "link",
revoked: false,
usesToken: true,
});
});

it("private + no grant + no valid token is revoked (access withdrawn)", () => {
expect(resolveBookmarkView(live)).toEqual({ access: "revoked", revoked: true, usesToken: false });
});
});
39 changes: 39 additions & 0 deletions lib/docs/bookmarks-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,42 @@ export function bookmarkRow(m: RowModel): string {
const vis = m.isPublic ? "public" : "private";
return `<div class="row"><pre><a class="title" href="${esc(href)}">${esc(label)}</a> <span class="tail">${esc(m.access)} ${vis} · ${esc(fmtDate(m.bookmarkedAt))}</span></pre>${removeForm(m.docId)}</div>`;
}

// Inputs for re-resolving a bookmarked doc's access at read time, reduced to
// primitives so the mapping is pure (no DB, no session). The API list handler
// (GET /api/v1/bookmarks) derives these from resolveAccess + the doc row.
export type BookmarkAccessInput = {
// The doc has been (soft-)deleted.
deleted: boolean;
// The caller owns the doc.
isOwner: boolean;
// The caller's resolved identity grant role, or null when there is no grant.
grantRole: "editor" | "commenter" | "viewer" | null;
// The doc is public.
isPublic: boolean;
// The view token the doc was bookmarked through still matches the doc.
tokenValid: boolean;
};

export type BookmarkAccessView = {
// owner | editor | commenter | viewer | public | link | revoked.
access: string;
revoked: boolean;
// The row should link through the stored view token (access === "link").
usesToken: boolean;
};

/**
* Re-resolve a bookmarked doc's access, mirroring the web /bookmarks page:
* a deleted doc or one the caller can no longer reach is "revoked"; otherwise
* the live identity role, or "public"/"link" when reachable only through the
* doc being public / the stored view token. Order matches the access ladder.
*/
export function resolveBookmarkView(i: BookmarkAccessInput): BookmarkAccessView {
if (i.deleted) return { access: "revoked", revoked: true, usesToken: false };
if (i.isOwner) return { access: "owner", revoked: false, usesToken: false };
if (i.grantRole) return { access: i.grantRole, revoked: false, usesToken: false };
if (i.isPublic) return { access: "public", revoked: false, usesToken: false };
if (i.tokenValid) return { access: "link", revoked: false, usesToken: true };
return { access: "revoked", revoked: true, usesToken: false };
}
95 changes: 95 additions & 0 deletions lib/docs/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
import { registry, z } from "@/lib/openapi/registry";
import {
ApiError,
BookmarkListResponse,
BookmarkRemovedResponse,
BookmarkSavedResponse,
CommentCreatedResponse,
CommentDeletedResponse,
CommentUpdatedResponse,
Expand Down Expand Up @@ -596,3 +599,95 @@ registry.registerPath({
},
},
});

// =========================================================================
// Bookmarks. A personal saved-doc list keyed by the caller's email (unifies
// with the signed-in web /bookmarks). Save/remove need only view access to the
// doc; the list re-resolves access per item so a withdrawn doc reads revoked.
// =========================================================================

// PUT /api/v1/docs/{slug}/bookmark — idempotently bookmark a viewable doc
registry.registerPath({
method: "put",
path: "/api/v1/docs/{slug}/bookmark",
tags: ["bookmarks"],
summary: "Bookmark a document (idempotent)",
description:
"Saves the doc to the caller's bookmarks. Requires view access (owner, a grant, a public doc, or a matching ?viewtoken=); an inaccessible doc returns 404 (no existence oracle). Keyed by the key's email, so it unifies with the account's signed-in web bookmarks.",
operationId: "addBookmark",
security,
request: { params: z.object({ slug: slugParam }), query: viewtokenQuery },
responses: {
200: {
description: "Bookmarked",
content: { "application/json": { schema: BookmarkSavedResponse } },
},
401: { description: "Missing/invalid credential", content: jsonError },
404: {
description: "No such document (also returned for inaccessible docs; no existence oracle)",
content: jsonError,
},
429: { description: "Rate limit exceeded", content: jsonError },
},
});

// DELETE /api/v1/docs/{slug}/bookmark — idempotently remove the caller's bookmark
registry.registerPath({
method: "delete",
path: "/api/v1/docs/{slug}/bookmark",
tags: ["bookmarks"],
summary: "Remove a bookmark (idempotent)",
description:
"Removes the caller's bookmark for this doc. Idempotent (succeeds when none existed) and works on a revoked or deleted doc.",
operationId: "removeBookmark",
security,
request: { params: z.object({ slug: slugParam }) },
responses: {
200: {
description: "Removed",
content: { "application/json": { schema: BookmarkRemovedResponse } },
},
401: { description: "Missing/invalid credential", content: jsonError },
429: { description: "Rate limit exceeded", content: jsonError },
},
});

// GET /api/v1/bookmarks — list the caller's bookmarks (owned, shared, or both)
registry.registerPath({
method: "get",
path: "/api/v1/bookmarks",
tags: ["bookmarks"],
summary: "List bookmarked documents (owned, shared, or both)",
description:
"The caller's bookmarks, newest first, each with access re-resolved at read time (owner|editor|commenter|viewer|public|link|revoked). The signed-in web equivalent is https://justhtml.sh/bookmarks.",
operationId: "listBookmarks",
security,
request: {
query: z.object({
scope: z
.enum(["owned", "shared", "all"])
.default("all")
.openapi({
param: { name: "scope", in: "query" },
description:
"owned: bookmarked docs the caller owns. shared: bookmarked docs shared with the caller. all (default): both.",
}),
limit: z
.number()
.int()
.min(1)
.max(500)
.default(100)
.openapi({ param: { name: "limit", in: "query" } }),
}),
},
responses: {
200: {
description: "The caller's bookmarks",
content: { "application/json": { schema: BookmarkListResponse } },
},
400: { description: "Invalid request body or parameters", content: jsonError },
401: { description: "Missing/invalid credential", content: jsonError },
429: { description: "Rate limit exceeded", content: jsonError },
},
});
Loading
Loading