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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Pluck is a fast, open-source URL saver with an MCP connector. There is no AI ins

- **No model calls on the server.** No LLM SDKs, no embeddings, no vector stores, no "smart" anything. AI features are MCP prompts in `packages/mcp` that a connected client runs. A PR that adds a model call is declined.
- **Only `packages/db` touches MongoDB.** Every query is scoped by `userId`. Every new query gets a two-user test.
- **Public reads use one projection.** `packages/db/public.ts` is the only function that serves public list data. It never joins `contents`, `tags`, `ticked` or `status`. Its field list is pinned by a test.
- **Public reads use one projection.** `packages/db/src/public.ts` is the only function that serves public list data. It never joins `contents`, `tags`, `ticked` or `status`. Its field list is pinned by a test.
- **Nothing is deleted by expiry or by MCP.** Expiry and "Archive it all" set `status: archived`. MCP has no delete tool outside the `library:delete` scope, which the consent screen does not request.
- **Every type starts as a Zod schema** in `packages/shared-schemas`; the API parses requests with it.
- **MCP is the only public API.** The REST API serves our clients and the MCP layer. Do not add other integrations.
Expand Down
2 changes: 1 addition & 1 deletion apps/ios/modules/app-icon/ios/AppIcon.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ Pod::Spec.new do |s|
s.name = 'AppIcon'
s.version = '1.0.0'
s.summary = 'Alternate app icons for Pluck'
s.license = 'MIT'
s.license = 'Apache-2.0'
s.author = 'Pluck'
s.homepage = 'https://getpluck.io'
s.platforms = { :ios => '15.1' }
Expand Down
3 changes: 2 additions & 1 deletion apps/web/app/(site)/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { pageMeta } from "@/lib/site";
import { localePath } from "@/lib/siteUrls";
import { SitePage } from "../../SiteChrome";
import styles from "../../site.module.css";
import { ORIGIN } from "@/lib/origin";

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

Expand All @@ -28,7 +29,7 @@ export async function generateMetadata(props: Params): Promise<Metadata> {
};
}

const base = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
const base = ORIGIN;

// One post, in the language of its URL: the reader's type (Source Serif at the reader's sizes) under the site's chrome.
export default async function BlogPost(props: Params) {
Expand Down
5 changes: 3 additions & 2 deletions apps/web/app/(site)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ import { PasteBox } from "./PasteBox";
import { Reveal } from "./Reveal";
import { SitePage } from "./SiteChrome";
import styles from "./site.module.css";
import { ORIGIN } from "@/lib/origin";

export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("site.home.meta");
return { title: t("title"), description: t("description"), ...(await pageMeta("/")) };
}

const base = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
const base = ORIGIN;

// Who and what Pluck is, for search engines and AI answer engines. Facts only: each one is on the page or in the repo.
const structured = (description: string) => ({
Expand All @@ -31,7 +32,7 @@ const structured = (description: string) => ({
operatingSystem: "Web, iOS, Chrome",
isAccessibleForFree: true,
offers: { "@type": "Offer", price: "0", priceCurrency: "EUR" },
license: "https://www.gnu.org/licenses/agpl-3.0.html",
license: "https://www.apache.org/licenses/LICENSE-2.0",
sameAs: [REPO_URL],
publisher: { "@id": `${base}/#org` },
featureList: [
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/d/[token]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { runTap } from "@/lib/library";

// Signed digest links land here as a POST: from the /d page (one tap, or a confirm for Archive it all),
// or from a mail client's one-click unsubscribe. A link opened by a scanner (GET) never acts.
export const POST = publicRoute<{ token: string }>(30, async ({ req, params }) => {
export const POST = publicRoute<{ token: string }>("digest-tap", 30, async ({ req, params }) => {
const form = await req.formData().catch(() => null);
const confirm = form?.get("confirm") === "1";
const res = await runTap(params.token, { confirm });
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/v1/highlights/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const PATCH = route<{ id: string }>({ kind: "write" }, async ({ req, call
json({ highlight: await noteHighlight(caller.userId, params.id, (await parseBody(req, UpdateHighlightBody)).note) }),
);

export const DELETE = route<{ id: string }>({ kind: "write" }, async ({ caller, params }) => {
export const DELETE = route<{ id: string }>({ kind: "write", scope: "library:delete" }, async ({ caller, params }) => {
await removeHighlight(caller.userId, params.id);
return new Response(null, { status: 204 });
});
21 changes: 21 additions & 0 deletions apps/web/app/api/v1/lists/[id]/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { twoUsers } from "@pluck/db/testing";
import { expect, test } from "vitest";
import { newKey, newList } from "@/lib/library";
import { DELETE as deleteHighlight } from "../../highlights/[id]/route";
import { DELETE as deleteTag } from "../../tags/[id]/route";
import { DELETE as deleteList } from "./route";

test("deleting a List, a Tag or a highlight needs library:delete, like deleting an Item", async () => {
const { a } = await twoUsers();
const { key } = await newKey(a, "script"); // read and write, the default
const { key: deleter } = await newKey(a, "cleanup", ["library:read", "library:write", "library:delete"]);
const list = await newList(a, { name: "Keep me" });
const call = (handler: typeof deleteList, url: string, id: string, bearer: string) =>
handler(new Request(url, { method: "DELETE", headers: { authorization: `Bearer ${bearer}` } }), { params: Promise.resolve({ id }) });

const id = "0".repeat(24); // refused before anything is looked up
for (const [handler, path] of [[deleteList, "lists"], [deleteTag, "tags"], [deleteHighlight, "highlights"]] as const)
expect((await call(handler, `http://x/api/v1/${path}/${id}`, id, key)).status).toBe(403);
expect((await call(deleteList, `http://x/api/v1/lists/${list.id}`, list.id, key)).status).toBe(403);
expect((await call(deleteList, `http://x/api/v1/lists/${list.id}`, list.id, deleter)).status).toBe(204);
});
2 changes: 1 addition & 1 deletion apps/web/app/api/v1/lists/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const PATCH = route<{ id: string }>({ kind: "write" }, async ({ req, call
});

// The list goes; its items stay in the library.
export const DELETE = route<{ id: string }>({ kind: "write" }, async ({ caller, params }) => {
export const DELETE = route<{ id: string }>({ kind: "write", scope: "library:delete" }, async ({ caller, params }) => {
await removeList(caller.userId, params.id);
return new Response(null, { status: 204 });
});
2 changes: 1 addition & 1 deletion apps/web/app/api/v1/p/[handle]/[slug]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ApiError } from "@/lib/errors";
import { publicPage } from "@/lib/library";

// Public list data, no auth, cached 60 s at the edge. The page at /@handle/slug counts views; this does not.
export const GET = publicRoute<{ handle: string; slug: string }>(300, async ({ params }) => {
export const GET = publicRoute<{ handle: string; slug: string }>("public-list", 300, async ({ params }) => {
const page = await publicPage(params.handle, params.slug, { count: false }).catch(() => null);
if (!page) throw new ApiError("not_found", "No such list.", 404);
return json(page, 200, { "cache-control": "public, s-maxage=60, stale-while-revalidate=60" });
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/v1/p/cover/[listId]/[itemId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ import { publicRoute } from "@/lib/api";
import { publicCover } from "@/lib/library";

// A cover on a public page: only for an item on a live public list.
export const GET = publicRoute<{ listId: string; itemId: string }>(600, async ({ params }) =>
export const GET = publicRoute<{ listId: string; itemId: string }>("public-cover", 600, async ({ params }) =>
new Response(null, { status: 302, headers: { location: await publicCover(params.listId, params.itemId), "cache-control": "public, max-age=300" } }),
);
2 changes: 1 addition & 1 deletion apps/web/app/api/v1/reports/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { clientIp, parseBody, publicRoute } from "@/lib/api";
import { report } from "@/lib/library";

// Report a public list. No auth; a few a minute per address.
export const POST = publicRoute(5, async ({ req }) => {
export const POST = publicRoute("reports", 5, async ({ req }) => {
const b = await parseBody(req, ReportBody);
await report(b.listId, clientIp(req), b.reason, b.details);
return new Response(null, { status: 204 });
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/v1/signin/code/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { appRedirectFromCode } from "@/lib/signin-code";

// The iPhone app's sign-in, step two: the code from the email (or App Review's password) for the app's own OAuth
// request (lib/signin-code.ts).
export const POST = publicRoute(30, async ({ req }) => {
export const POST = publicRoute("signin-code", 30, async ({ req }) => {
const body = await parseBody(req, z.union([
z.object({ email: z.string().trim().email().max(320), code: z.string().regex(/^\d{6}$/), authorize: z.string().max(4096) }),
z.object({ email: z.string().trim().email().max(320), password: z.string().min(1).max(256), authorize: z.string().max(4096) }),
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/v1/signin/send-code/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { sendAppCode } from "@/lib/signin-code";
import { localeFrom } from "@/i18n/request";

// The iPhone app's sign-in, step one: email a 6-digit code (lib/signin-code.ts).
export const POST = publicRoute(30, async ({ req }) => {
export const POST = publicRoute("signin-send-code", 30, async ({ req }) => {
const { email } = await parseBody(req, z.object({ email: z.string().trim().email().max(320) }));
return json(await sendAppCode(email, clientIp(req), localeFrom(req.headers)));
});
2 changes: 1 addition & 1 deletion apps/web/app/api/v1/tags/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const PATCH = route<{ id: string }>({ kind: "write" }, async ({ req, call
);

// DELETE needs ?reassignTo=<tagId> (merge) or ?removeFromItems=true.
export const DELETE = route<{ id: string }>({ kind: "write" }, async ({ req, caller, params }) => {
export const DELETE = route<{ id: string }>({ kind: "write", scope: "library:delete" }, async ({ req, caller, params }) => {
const q = DeleteTagQuery.safeParse(searchParamsObject(req));
if (!q.success) throw new ApiError("bad_request", "Say where its items go: reassignTo=<tag id>, or removeFromItems=true.", 400);
await removeTag(caller.userId, params.id, "reassignTo" in q.data ? { reassignTo: q.data.reassignTo } : { removeFromItems: true });
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/v1/try/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { holdPaste, pluckAny } from "@/lib/library";
const Body = z.object({ text: z.string().trim().min(1).max(5000) });

// The home page paste box. Signed in: a real pluck. A visitor: held for a day against a cookie until they sign up.
export const POST = publicRoute(10, async ({ req }) => {
export const POST = publicRoute("try", 10, async ({ req }) => {
const { text } = await parseBody(req, Body);
const caller = await authenticate(req);
if (caller) {
Expand Down
3 changes: 2 additions & 1 deletion apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import { LOCALE_HEADER } from "@/i18n/request";
import { localePath } from "@/lib/siteUrls";
import { fredoka, geist, geistMono, sourceSerif } from "./fonts";
import "./globals.css";
import { ORIGIN } from "@/lib/origin";

export const generateMetadata = async (): Promise<Metadata> => ({
// Absolute URLs for OG images and canonical links: the app's public origin. A site page in another language goes
// under its prefix, so its preview card (/de/connect/opengraph-image-…) is fetched in that language (proxy.ts).
metadataBase: new URL((process.env.BETTER_AUTH_URL ?? "http://localhost:3000") + localePath(urlLocale(await headers()), "/")),
metadataBase: new URL(ORIGIN + localePath(urlLocale(await headers()), "/")),
title: "Pluck",
description: (await getTranslations("site.meta"))("description"),
icons: {
Expand Down
7 changes: 5 additions & 2 deletions apps/web/app/llms.txt/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import { LOCALE_NAMES, LOCALES } from "@pluck/i18n";
import { postsIn } from "@/lib/blog";
import { localePath } from "@/lib/siteUrls";
import { CONNECTOR_URL, LICENCE, REPO_URL } from "@/lib/site";
import { ORIGIN } from "@/lib/origin";

const base = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
const base = ORIGIN;
// Built per request: a Docker image is built with a placeholder address, and this names the one it runs at.
export const dynamic = "force-dynamic";

// llms.txt (llmstxt.org): what Pluck is, in plain Markdown, for AI tools that answer questions about it.
export async function GET() {
Expand All @@ -15,7 +18,7 @@ Key facts:

- Free to use at ${base}. Open source (${LICENCE}). Self-host with Docker in one command.
- No AI runs inside Pluck. Your own AI tool (Claude, ChatGPT, Cursor or any MCP client) connects through Pluck's MCP connector at ${CONNECTOR_URL} and reads, searches and sorts your library when you ask.
- Your AI cannot delete anything or make a list public. Everything it adds is marked "by AI".
- Your AI cannot delete anything unless you allow it, and cannot make a list public. Everything it adds is marked "by AI".
- Kinds of item: Read, Watch, PDF, Image, Note, Todo, Thing. Status: Inbox, Later, Archived, Done.
- Items you never touch move to the Archive after 30 days (you pick the number, or turn it off). Nothing is deleted.
- Lists: ordered, with a note and a tick per item. Private by default, public at /@handle/slug with one switch.
Expand Down
4 changes: 2 additions & 2 deletions apps/web/app/oauth/consent/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth, currentUser, FIRST_PARTY_REDIRECTS } from "@/lib/auth";
import { auth, currentUser, FIRST_PARTY_REDIRECTS, ORIGIN } from "@/lib/auth";
import { ConsentButtons } from "./ConsentButtons";
import { BigMark, Dot, Standalone, standaloneStyles as s } from "@/components/Standalone";

Expand All @@ -25,7 +25,7 @@ export default async function Consent({ searchParams }: { searchParams: Promise<
const valid = await verifyOAuthQueryParams(query.toString(), secret);

const clientId = query.get("client_id") ?? "";
const res = await auth.handler(new Request(`${process.env.BETTER_AUTH_URL}/api/auth/oauth2/public-client?client_id=${encodeURIComponent(clientId)}`, { headers: await headers() }));
const res = await auth.handler(new Request(`${ORIGIN}/api/auth/oauth2/public-client?client_id=${encodeURIComponent(clientId)}`, { headers: await headers() }));
const client = res.ok ? ((await res.json()) as { client_name?: string; client_uri?: string }) : null;
// The authorize step already matched redirect_uri to the client's own list, and the query is signed.
const redirectUri = query.get("redirect_uri") ?? "";
Expand Down
5 changes: 4 additions & 1 deletion apps/web/app/robots.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { MetadataRoute } from "next";
import { ORIGIN } from "@/lib/origin";

const base = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
const base = ORIGIN;
// Built per request: a Docker image is built with a placeholder address, and this names the one it runs at.
export const dynamic = "force-dynamic";

// The marketing pages and public Lists are for search engines; a signed-in library, sign-in and the API are not.
export default function robots(): MetadataRoute.Robots {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/signin/link/go/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { verifyUrl } from "@/lib/signin-link";
import { ORIGIN } from "@/lib/origin";

const ORIGIN = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";

// The tap on /signin/link. 303 turns the POST into the GET that Better Auth's verify expects.
export async function POST(req: Request) {
Expand Down
5 changes: 4 additions & 1 deletion apps/web/app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import { LOCALES } from "@pluck/i18n";
import type { MetadataRoute } from "next";
import { POSTS } from "@/lib/blog";
import { localePath, SITE_PAGES } from "@/lib/siteUrls";
import { ORIGIN } from "@/lib/origin";

const base = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
const base = ORIGIN;
// Built per request: a Docker image is built with a placeholder address, and this names the one it runs at.
export const dynamic = "force-dynamic";

// The marketing pages and the blog, each in every language, with each URL naming its other languages (hreflang).
// Public Lists are left to links: they are people's pages, not ours to list.
Expand Down
7 changes: 3 additions & 4 deletions apps/web/app/try/claim/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import { after, NextResponse } from "next/server";
import { currentUser } from "@/lib/auth";
import { currentUser, ORIGIN } from "@/lib/auth";
import { claimPaste } from "@/lib/library";
import { TRY_COOKIE, tryToken } from "@/lib/api";

// Where the sign-in link lands when the visitor pasted links on the home page first: they become theirs.
export async function GET(req: Request) {
const origin = process.env.BETTER_AUTH_URL ?? new URL(req.url).origin; // the public origin, where the session cookie lives
const user = await currentUser();
if (!user) return NextResponse.redirect(`${origin}/signin`);
if (!user) return NextResponse.redirect(`${ORIGIN}/signin`);
const token = tryToken(req.headers.get("cookie"));
if (token) await claimPaste(user.id, token, { defer: after }); // the pages are read after the redirect
const res = NextResponse.redirect(`${origin}/inbox`);
const res = NextResponse.redirect(`${ORIGIN}/inbox`);
res.cookies.delete(TRY_COOKIE);
return res;
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Pluck behält den Seitentext, wenn du pflückst, also ist deine Kopie noch da, w

Wenn der Code öffentlich ist, kann die App die Firma überleben. Jemand kann sie forken, und du kannst nachlesen, was sie mit deinen Daten macht.

Pluck ist Open Source: AGPL-3.0 für den Server, MIT für die Apps.
Pluck ist Open Source unter Apache-2.0, der Server wie die Apps.

Öffentlicher Code ist allerdings nicht die ganze Antwort. Omnivore war auch Open Source.

Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/blog/de/run-your-own-pluck.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,6 @@ Die Backups machst du. Sichere die Volumes `mongo` und `minio`. Das ist deine ga
git pull && docker compose up -d --build
```

Der Server steht unter AGPL-3.0, die Apps unter MIT.
Server und Apps stehen unter Apache-2.0.

Hören wir je auf, machst du einfach weiter.
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Pluck keeps the page text when you pluck it, so your copy is still there when th

If the code is public, the app can outlive the company. Someone can fork it, and you can read what it does with your data.

Pluck is open source: AGPL-3.0 for the server, MIT for the apps.
Pluck is open source under Apache-2.0, the server and the apps alike.

Public code isn't the whole answer, mind. Omnivore was open source too.

Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/blog/en/run-your-own-pluck.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,6 @@ You run the backups. Back up the `mongo` and `minio` volumes. That's your whole
git pull && docker compose up -d --build
```

The server is AGPL-3.0 and the apps are MIT.
The server and the apps are Apache-2.0.

If we ever stop, you don't.
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Pluck conserva el texto de la página cuando la recoges, así que tu copia sigue

Si el código es público, la app puede sobrevivir a la empresa. Alguien puede hacer un fork, y tú puedes leer qué hace con tus datos.

Pluck es de código abierto: AGPL-3.0 para el servidor, MIT para las apps.
Pluck es de código abierto bajo Apache-2.0, tanto el servidor como las apps.

Ojo, el código público no lo resuelve todo. Omnivore también era de código abierto.

Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/blog/es/run-your-own-pluck.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,6 @@ Las copias de seguridad las haces tú. Haz copia de los volúmenes `mongo` y `mi
git pull && docker compose up -d --build
```

El servidor es AGPL-3.0 y las apps son MIT.
El servidor y las apps son Apache-2.0.

Si algún día paramos, tú no.
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Pluck garde le texte de la page quand vous la cueillez, alors votre copie est to

Si le code est public, l’app peut survivre à l’entreprise. Quelqu’un peut la forker, et vous pouvez lire ce qu’elle fait de vos données.

Pluck est open source : AGPL-3.0 pour le serveur, MIT pour les apps.
Pluck est open source sous Apache-2.0, le serveur comme les apps.

Cela dit, le code public ne règle pas tout. Omnivore aussi était open source.

Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/blog/fr/run-your-own-pluck.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,6 @@ Les sauvegardes, c’est vous. Sauvegardez les volumes `mongo` et `minio`. C’e
git pull && docker compose up -d --build
```

Le serveur est sous AGPL-3.0 et les apps sous MIT.
Le serveur et les apps sont sous Apache-2.0.

Si un jour nous arrêtons, vous, vous continuez.
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Pluck conserva il testo della pagina quando la prendi, così la tua copia c'è a

Se il codice è pubblico, l'app può sopravvivere all'azienda. Qualcuno può farne un fork, e tu puoi leggere cosa fa con i tuoi dati.

Pluck è open source: AGPL-3.0 per il server, MIT per le app.
Pluck è open source con licenza Apache-2.0, sia il server sia le app.

Il codice pubblico, intendiamoci, non è tutta la risposta. Anche Omnivore era open source.

Expand Down
Loading
Loading