diff --git a/CLAUDE.md b/CLAUDE.md index b8a8782..b454639 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/apps/ios/modules/app-icon/ios/AppIcon.podspec b/apps/ios/modules/app-icon/ios/AppIcon.podspec index 4da5d9f..f6c2b72 100644 --- a/apps/ios/modules/app-icon/ios/AppIcon.podspec +++ b/apps/ios/modules/app-icon/ios/AppIcon.podspec @@ -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' } diff --git a/apps/web/app/(site)/blog/[slug]/page.tsx b/apps/web/app/(site)/blog/[slug]/page.tsx index 43a4f1a..166525e 100644 --- a/apps/web/app/(site)/blog/[slug]/page.tsx +++ b/apps/web/app/(site)/blog/[slug]/page.tsx @@ -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 }> }; @@ -28,7 +29,7 @@ export async function generateMetadata(props: Params): Promise { }; } -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) { diff --git a/apps/web/app/(site)/page.tsx b/apps/web/app/(site)/page.tsx index e196eda..8ccf03f 100644 --- a/apps/web/app/(site)/page.tsx +++ b/apps/web/app/(site)/page.tsx @@ -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 { 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) => ({ @@ -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: [ diff --git a/apps/web/app/api/d/[token]/route.ts b/apps/web/app/api/d/[token]/route.ts index f18d161..a0ece27 100644 --- a/apps/web/app/api/d/[token]/route.ts +++ b/apps/web/app/api/d/[token]/route.ts @@ -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 }); diff --git a/apps/web/app/api/v1/highlights/[id]/route.ts b/apps/web/app/api/v1/highlights/[id]/route.ts index b5f9c94..1694c42 100644 --- a/apps/web/app/api/v1/highlights/[id]/route.ts +++ b/apps/web/app/api/v1/highlights/[id]/route.ts @@ -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 }); }); diff --git a/apps/web/app/api/v1/lists/[id]/route.test.ts b/apps/web/app/api/v1/lists/[id]/route.test.ts new file mode 100644 index 0000000..e07705d --- /dev/null +++ b/apps/web/app/api/v1/lists/[id]/route.test.ts @@ -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); +}); diff --git a/apps/web/app/api/v1/lists/[id]/route.ts b/apps/web/app/api/v1/lists/[id]/route.ts index ebf23fb..5c9b6e8 100644 --- a/apps/web/app/api/v1/lists/[id]/route.ts +++ b/apps/web/app/api/v1/lists/[id]/route.ts @@ -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 }); }); diff --git a/apps/web/app/api/v1/p/[handle]/[slug]/route.ts b/apps/web/app/api/v1/p/[handle]/[slug]/route.ts index d43e587..572b526 100644 --- a/apps/web/app/api/v1/p/[handle]/[slug]/route.ts +++ b/apps/web/app/api/v1/p/[handle]/[slug]/route.ts @@ -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" }); diff --git a/apps/web/app/api/v1/p/cover/[listId]/[itemId]/route.ts b/apps/web/app/api/v1/p/cover/[listId]/[itemId]/route.ts index 726b42c..c584354 100644 --- a/apps/web/app/api/v1/p/cover/[listId]/[itemId]/route.ts +++ b/apps/web/app/api/v1/p/cover/[listId]/[itemId]/route.ts @@ -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" } }), ); diff --git a/apps/web/app/api/v1/reports/route.ts b/apps/web/app/api/v1/reports/route.ts index 4254dbb..9076c31 100644 --- a/apps/web/app/api/v1/reports/route.ts +++ b/apps/web/app/api/v1/reports/route.ts @@ -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 }); diff --git a/apps/web/app/api/v1/signin/code/route.ts b/apps/web/app/api/v1/signin/code/route.ts index 24c29f3..398b40c 100644 --- a/apps/web/app/api/v1/signin/code/route.ts +++ b/apps/web/app/api/v1/signin/code/route.ts @@ -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) }), diff --git a/apps/web/app/api/v1/signin/send-code/route.ts b/apps/web/app/api/v1/signin/send-code/route.ts index 1f248cc..50a8c87 100644 --- a/apps/web/app/api/v1/signin/send-code/route.ts +++ b/apps/web/app/api/v1/signin/send-code/route.ts @@ -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))); }); diff --git a/apps/web/app/api/v1/tags/[id]/route.ts b/apps/web/app/api/v1/tags/[id]/route.ts index d9a4dd5..e7a0a85 100644 --- a/apps/web/app/api/v1/tags/[id]/route.ts +++ b/apps/web/app/api/v1/tags/[id]/route.ts @@ -8,7 +8,7 @@ export const PATCH = route<{ id: string }>({ kind: "write" }, async ({ req, call ); // DELETE needs ?reassignTo= (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=, or removeFromItems=true.", 400); await removeTag(caller.userId, params.id, "reassignTo" in q.data ? { reassignTo: q.data.reassignTo } : { removeFromItems: true }); diff --git a/apps/web/app/api/v1/try/route.ts b/apps/web/app/api/v1/try/route.ts index 347378a..5816e02 100644 --- a/apps/web/app/api/v1/try/route.ts +++ b/apps/web/app/api/v1/try/route.ts @@ -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) { diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index c58bd39..311d20d 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -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 => ({ // 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: { diff --git a/apps/web/app/llms.txt/route.ts b/apps/web/app/llms.txt/route.ts index 150c581..9c91365 100644 --- a/apps/web/app/llms.txt/route.ts +++ b/apps/web/app/llms.txt/route.ts @@ -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() { @@ -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. diff --git a/apps/web/app/oauth/consent/page.tsx b/apps/web/app/oauth/consent/page.tsx index cd5eb9a..955d79e 100644 --- a/apps/web/app/oauth/consent/page.tsx +++ b/apps/web/app/oauth/consent/page.tsx @@ -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"; @@ -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") ?? ""; diff --git a/apps/web/app/robots.ts b/apps/web/app/robots.ts index 996e7c4..16fc5be 100644 --- a/apps/web/app/robots.ts +++ b/apps/web/app/robots.ts @@ -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 { diff --git a/apps/web/app/signin/link/go/route.ts b/apps/web/app/signin/link/go/route.ts index 92a4e8a..76fd751 100644 --- a/apps/web/app/signin/link/go/route.ts +++ b/apps/web/app/signin/link/go/route.ts @@ -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) { diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts index c3ae2a4..0161688 100644 --- a/apps/web/app/sitemap.ts +++ b/apps/web/app/sitemap.ts @@ -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. diff --git a/apps/web/app/try/claim/route.ts b/apps/web/app/try/claim/route.ts index 1054dad..313957f 100644 --- a/apps/web/app/try/claim/route.ts +++ b/apps/web/app/try/claim/route.ts @@ -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; } diff --git a/apps/web/content/blog/de/read-it-later-apps-keep-closing.md b/apps/web/content/blog/de/read-it-later-apps-keep-closing.md index e92f24a..138bd7d 100644 --- a/apps/web/content/blog/de/read-it-later-apps-keep-closing.md +++ b/apps/web/content/blog/de/read-it-later-apps-keep-closing.md @@ -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. diff --git a/apps/web/content/blog/de/run-your-own-pluck.md b/apps/web/content/blog/de/run-your-own-pluck.md index 9d2301c..c711cd2 100644 --- a/apps/web/content/blog/de/run-your-own-pluck.md +++ b/apps/web/content/blog/de/run-your-own-pluck.md @@ -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. diff --git a/apps/web/content/blog/en/read-it-later-apps-keep-closing.md b/apps/web/content/blog/en/read-it-later-apps-keep-closing.md index 0bdf9f3..226ecda 100644 --- a/apps/web/content/blog/en/read-it-later-apps-keep-closing.md +++ b/apps/web/content/blog/en/read-it-later-apps-keep-closing.md @@ -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. diff --git a/apps/web/content/blog/en/run-your-own-pluck.md b/apps/web/content/blog/en/run-your-own-pluck.md index e52ba31..ae19a39 100644 --- a/apps/web/content/blog/en/run-your-own-pluck.md +++ b/apps/web/content/blog/en/run-your-own-pluck.md @@ -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. diff --git a/apps/web/content/blog/es/read-it-later-apps-keep-closing.md b/apps/web/content/blog/es/read-it-later-apps-keep-closing.md index f5bcae2..f815d32 100644 --- a/apps/web/content/blog/es/read-it-later-apps-keep-closing.md +++ b/apps/web/content/blog/es/read-it-later-apps-keep-closing.md @@ -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. diff --git a/apps/web/content/blog/es/run-your-own-pluck.md b/apps/web/content/blog/es/run-your-own-pluck.md index bc0656d..3f09b51 100644 --- a/apps/web/content/blog/es/run-your-own-pluck.md +++ b/apps/web/content/blog/es/run-your-own-pluck.md @@ -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. diff --git a/apps/web/content/blog/fr/read-it-later-apps-keep-closing.md b/apps/web/content/blog/fr/read-it-later-apps-keep-closing.md index 0ac611d..29e30a0 100644 --- a/apps/web/content/blog/fr/read-it-later-apps-keep-closing.md +++ b/apps/web/content/blog/fr/read-it-later-apps-keep-closing.md @@ -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. diff --git a/apps/web/content/blog/fr/run-your-own-pluck.md b/apps/web/content/blog/fr/run-your-own-pluck.md index a9aaa70..e0faa0a 100644 --- a/apps/web/content/blog/fr/run-your-own-pluck.md +++ b/apps/web/content/blog/fr/run-your-own-pluck.md @@ -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. diff --git a/apps/web/content/blog/it/read-it-later-apps-keep-closing.md b/apps/web/content/blog/it/read-it-later-apps-keep-closing.md index 0e922a3..7cbf385 100644 --- a/apps/web/content/blog/it/read-it-later-apps-keep-closing.md +++ b/apps/web/content/blog/it/read-it-later-apps-keep-closing.md @@ -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. diff --git a/apps/web/content/blog/it/run-your-own-pluck.md b/apps/web/content/blog/it/run-your-own-pluck.md index 69e3b71..469f91a 100644 --- a/apps/web/content/blog/it/run-your-own-pluck.md +++ b/apps/web/content/blog/it/run-your-own-pluck.md @@ -58,6 +58,6 @@ I backup li fai tu. Fai il backup dei volumi `mongo` e `minio`. Lì c'è tutta l git pull && docker compose up -d --build ``` -Il server è AGPL-3.0 e le app sono MIT. +Il server e le app sono Apache-2.0. Se un giorno ci fermiamo noi, tu non ti fermi. diff --git a/apps/web/content/blog/ja/read-it-later-apps-keep-closing.md b/apps/web/content/blog/ja/read-it-later-apps-keep-closing.md index 5c52369..60d4a8e 100644 --- a/apps/web/content/blog/ja/read-it-later-apps-keep-closing.md +++ b/apps/web/content/blog/ja/read-it-later-apps-keep-closing.md @@ -28,7 +28,7 @@ PluckはPluckした時点でページの本文を残すので、サイトがな コードが公開されていれば、アプリは会社より長生きできます。誰かがフォークできますし、データがどう扱われているかを自分で読めます。 -Pluckはオープンソースです。サーバーはAGPL-3.0、アプリはMIT。 +Pluckはオープンソースです。サーバーもアプリもApache-2.0です。 とはいえ、コードの公開だけで答えになるわけではありません。Omnivoreもオープンソースでした。 diff --git a/apps/web/content/blog/ja/run-your-own-pluck.md b/apps/web/content/blog/ja/run-your-own-pluck.md index 4ae7998..8b3622c 100644 --- a/apps/web/content/blog/ja/run-your-own-pluck.md +++ b/apps/web/content/blog/ja/run-your-own-pluck.md @@ -58,6 +58,6 @@ HTTPS対応のリバースプロキシを前に置き、composeファイルの git pull && docker compose up -d --build ``` -サーバーはAGPL-3.0、アプリはMITです。 +サーバーもアプリもApache-2.0です。 もし私たちがやめても、あなたのPluckは止まりません。 diff --git a/apps/web/content/blog/ko/read-it-later-apps-keep-closing.md b/apps/web/content/blog/ko/read-it-later-apps-keep-closing.md index 60201e2..199103e 100644 --- a/apps/web/content/blog/ko/read-it-later-apps-keep-closing.md +++ b/apps/web/content/blog/ko/read-it-later-apps-keep-closing.md @@ -28,7 +28,7 @@ Pluck은 담는 순간 페이지 본문을 담아 둬요. 그래서 사이트가 코드가 공개되어 있으면 앱이 회사보다 오래 살 수 있어요. 누군가 포크할 수 있고, 내 데이터로 뭘 하는지 직접 읽어 볼 수도 있어요. -Pluck은 오픈소스예요. 서버는 AGPL-3.0, 앱은 MIT예요. +Pluck은 오픈소스예요. 서버와 앱 모두 Apache-2.0이에요. 물론 코드 공개가 답의 전부는 아니에요. Omnivore도 오픈소스였거든요. diff --git a/apps/web/content/blog/ko/run-your-own-pluck.md b/apps/web/content/blog/ko/run-your-own-pluck.md index d1ead21..0ce443b 100644 --- a/apps/web/content/blog/ko/run-your-own-pluck.md +++ b/apps/web/content/blog/ko/run-your-own-pluck.md @@ -58,6 +58,6 @@ Docker 이미지 하나를 두 번 띄워요. 하나는 앱을 제공해요. 다 git pull && docker compose up -d --build ``` -서버는 AGPL-3.0, 앱은 MIT예요. +서버와 앱 모두 Apache-2.0이에요. 저희가 멈춰도 내 Pluck은 멈추지 않아요. diff --git a/apps/web/content/blog/nl/read-it-later-apps-keep-closing.md b/apps/web/content/blog/nl/read-it-later-apps-keep-closing.md index 77f33db..22430ee 100644 --- a/apps/web/content/blog/nl/read-it-later-apps-keep-closing.md +++ b/apps/web/content/blog/nl/read-it-later-apps-keep-closing.md @@ -28,7 +28,7 @@ Pluck bewaart de paginatekst als je hem plukt, dus je kopie is er nog als de sit Is de code openbaar, dan kan de app het bedrijf overleven. Iemand kan hem forken, en jij kunt lezen wat hij met je data doet. -Pluck is open source: AGPL-3.0 voor de server, MIT voor de apps. +Pluck is open source onder Apache-2.0, de server en de apps allebei. Openbare code is niet het hele antwoord, hoor. Omnivore was ook open source. diff --git a/apps/web/content/blog/nl/run-your-own-pluck.md b/apps/web/content/blog/nl/run-your-own-pluck.md index 3551c0f..de37dde 100644 --- a/apps/web/content/blog/nl/run-your-own-pluck.md +++ b/apps/web/content/blog/nl/run-your-own-pluck.md @@ -58,6 +58,6 @@ Back-ups maak je zelf. Maak back-ups van de volumes `mongo` en `minio`. Dat is j git pull && docker compose up -d --build ``` -De server is AGPL-3.0 en de apps zijn MIT. +De server en de apps zijn Apache-2.0. Stoppen wij ooit, dan stop jij niet. diff --git a/apps/web/content/blog/pt-BR/read-it-later-apps-keep-closing.md b/apps/web/content/blog/pt-BR/read-it-later-apps-keep-closing.md index 8c8fe72..2592678 100644 --- a/apps/web/content/blog/pt-BR/read-it-later-apps-keep-closing.md +++ b/apps/web/content/blog/pt-BR/read-it-later-apps-keep-closing.md @@ -28,7 +28,7 @@ O Pluck guarda o texto da página quando você colhe, então a sua cópia contin Se o código é público, o app pode sobreviver à empresa. Alguém pode fazer um fork, e você pode ler o que ele faz com os seus dados. -O Pluck é de código aberto: AGPL-3.0 para o servidor, MIT para os apps. +O Pluck é de código aberto sob Apache-2.0, tanto o servidor quanto os apps. Código público não é a resposta toda, diga-se. O Omnivore também era de código aberto. diff --git a/apps/web/content/blog/pt-BR/run-your-own-pluck.md b/apps/web/content/blog/pt-BR/run-your-own-pluck.md index d7411df..057ef51 100644 --- a/apps/web/content/blog/pt-BR/run-your-own-pluck.md +++ b/apps/web/content/blog/pt-BR/run-your-own-pluck.md @@ -58,6 +58,6 @@ Os backups são com você. Faça backup dos volumes `mongo` e `minio`. Ali está git pull && docker compose up -d --build ``` -O servidor é AGPL-3.0 e os apps são MIT. +O servidor e os apps são Apache-2.0. Se um dia a gente parar, você não para. diff --git a/apps/web/content/blog/zh-Hans/read-it-later-apps-keep-closing.md b/apps/web/content/blog/zh-Hans/read-it-later-apps-keep-closing.md index e776b3c..332ed69 100644 --- a/apps/web/content/blog/zh-Hans/read-it-later-apps-keep-closing.md +++ b/apps/web/content/blog/zh-Hans/read-it-later-apps-keep-closing.md @@ -28,7 +28,7 @@ Pluck 在你摘下时就留下网页正文,所以网站没了,你的副本 如果代码公开,App 就能比公司活得久。有人可以 fork 它,你也能看清它拿你的数据做了什么。 -Pluck 是开源的:服务端用 AGPL-3.0,App 用 MIT。 +Pluck 是开源的:服务端和 App 都用 Apache-2.0。 不过,公开代码也不是全部答案。Omnivore 也是开源的。 diff --git a/apps/web/content/blog/zh-Hans/run-your-own-pluck.md b/apps/web/content/blog/zh-Hans/run-your-own-pluck.md index 6cd8db6..68be3d6 100644 --- a/apps/web/content/blog/zh-Hans/run-your-own-pluck.md +++ b/apps/web/content/blog/zh-Hans/run-your-own-pluck.md @@ -58,6 +58,6 @@ docker compose up -d git pull && docker compose up -d --build ``` -服务端是 AGPL-3.0,App 是 MIT。 +服务端和 App 都是 Apache-2.0。 就算我们哪天停了,你也不用停。 diff --git a/apps/web/instrumentation.ts b/apps/web/instrumentation.ts index 79bfc08..a382c21 100644 --- a/apps/web/instrumentation.ts +++ b/apps/web/instrumentation.ts @@ -1,10 +1,11 @@ +import { ORIGIN } from "./lib/origin"; + // Runs once as each server starts. The secrets in .env.example are public: a copy reachable from elsewhere would let // anyone sign sessions or run the cron, so it refuses to start with them. One machine may (the e2e server, a self-host // on localhost), as with the blob store's default in docker/entrypoint.sh, and so may `next build`, whose placeholder // secret never serves a request. export function register() { - const origin = process.env.BETTER_AUTH_URL ?? "http://localhost:3000"; - if (process.env.NODE_ENV !== "production" || process.env.NEXT_PHASE === "phase-production-build" || /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return; + if (process.env.NODE_ENV !== "production" || process.env.NEXT_PHASE === "phase-production-build" || /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(ORIGIN)) return; const secret = process.env.BETTER_AUTH_SECRET ?? ""; if (secret === "dev-only-secret-change-me-in-production-0123456789" || secret.length < 32) throw new Error("BETTER_AUTH_SECRET is the example one or shorter than 32 characters: set 32+ random bytes (openssl rand -base64 32)."); diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index b083e6b..59d78b8 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -99,10 +99,11 @@ export function route

>(opts: Options, handler: Handler } // Routes anyone may call (public pages, reports): per-IP limit, same error shape, no caller. -export function publicRoute

>(perMinute: number, handler: PublicHandler

) { +// `name` keys the per-IP limit: one bucket per route, however many tokens or handles its paths carry. +export function publicRoute

>(name: string, perMinute: number, handler: PublicHandler

) { return async (req: Request, ctx: { params: Promise

}): Promise => { try { - const hit = await limited(`ip:${clientIp(req)}:${new URL(req.url).pathname.split("/").slice(0, 5).join("/")}`, perMinute); + const hit = await limited(`ip:${clientIp(req)}:${name}`, perMinute); if (hit) return errorResponse(hit, { "retry-after": String((hit.details as { retryAfter: number }).retryAfter) }); return await handler({ req, params: await ctx.params }); } catch (e) { diff --git a/apps/web/lib/auth.ts b/apps/web/lib/auth.ts index 832c3e9..9fba5e8 100644 --- a/apps/web/lib/auth.ts +++ b/apps/web/lib/auth.ts @@ -14,12 +14,11 @@ import { magicLinkEmail } from "./emails"; import { linkPageUrl } from "./signin-link"; import { localeFrom } from "../i18n/request"; import { clientIp } from "./ip"; +import { HOST, ORIGIN } from "./origin"; const DAY = 60 * 60 * 24; -// This copy's own address: getpluck.io hosted, a self-hoster's domain otherwise. HOST is how the UI shows it. -export const ORIGIN = process.env.BETTER_AUTH_URL ?? "http://localhost:3000"; -export const HOST = new URL(ORIGIN).host; +export { HOST, ORIGIN }; // Every OAuth token is bound (aud) to one protected resource: the connector for AI tools, // or the REST API for our own apps (the extension, iOS). diff --git a/apps/web/lib/library/account.ts b/apps/web/lib/library/account.ts index 022bae1..c58dd5b 100644 --- a/apps/web/lib/library/account.ts +++ b/apps/web/lib/library/account.ts @@ -10,6 +10,7 @@ import { sendEmail } from "../email"; import { exportEmail, welcomeEmail } from "../emails"; import { ApiError } from "../errors"; import { log } from "../log"; +import { ORIGIN } from "../origin"; // ---- Welcome ------------------------------------------------------------------------------------- @@ -27,7 +28,7 @@ export async function sendWelcome(userId: string): Promise { // Claimed before sending: a second copy of a personal note reads worse than a lost one. if (!from || !me || !(await claimOnce(`welcome:${userId}`))) return false; const counts = await Promise.all((["inbox", "later", "archived", "done"] as const).map((s) => countItems(userId, s))); - const connectUrl = `${process.env.BETTER_AUTH_URL ?? "http://localhost:3000"}/connect`; + const connectUrl = `${ORIGIN}/connect`; await sendEmail({ ...welcomeEmail(me.email, { plucked: counts.some(Boolean), expiryDays: me.settings.expiryDays, connectUrl }, me.settings.locale ?? "en"), from, replyTo: from }); return true; } diff --git a/apps/web/lib/library/digest.ts b/apps/web/lib/library/digest.ts index 4adb80f..13e1694 100644 --- a/apps/web/lib/library/digest.ts +++ b/apps/web/lib/library/digest.ts @@ -5,8 +5,8 @@ import { sendEmail } from "../email"; import { ApiError } from "../errors"; import { signTap, verifyTap, type Tap } from "../signed"; import { archiveItAll } from "./expiry"; +import { ORIGIN } from "../origin"; -const origin = () => process.env.BETTER_AUTH_URL ?? "http://localhost:3000"; export async function digest(userId: string, { limit = 5 }: { limit?: number } = {}): Promise { const me = await getMe(userId); @@ -20,15 +20,15 @@ export async function sendDigest(userId: string, { force = false }: { force?: bo if (!me) return false; const d = await digestFor(userId, me.settings); if (isEmptyDigest(d) && !force) return false; - const tap = (a: Tap["a"], i?: string) => `${origin()}/d/${signTap({ u: userId, a, i })}`; + const tap = (a: Tap["a"], i?: string) => `${ORIGIN}/d/${signTap({ u: userId, a, i })}`; await sendEmail({ ...digestEmail(me.email, me.name, d, { keep: (id) => tap("keep", id), letGo: (id) => tap("letgo", id), - open: (id) => `${origin()}/items/${id}`, - list: (id) => `${origin()}/lists/${id}`, + open: (id) => `${ORIGIN}/items/${id}`, + list: (id) => `${ORIGIN}/lists/${id}`, archiveAll: tap("archive-all"), - settings: `${origin()}/settings#expiry`, + settings: `${ORIGIN}/settings#expiry`, stop: tap("stop"), }, new Date(), { locale: me.settings.locale ?? "en", timeZone: me.settings.timezone }), from: process.env.DIGEST_FROM, // its own subdomain: a spam report on it never reaches sign-in links diff --git a/apps/web/lib/library/sharing.ts b/apps/web/lib/library/sharing.ts index 61a7954..3f44ab4 100644 --- a/apps/web/lib/library/sharing.ts +++ b/apps/web/lib/library/sharing.ts @@ -10,6 +10,7 @@ import { sharedListEmail } from "../emails"; import { ApiError } from "../errors"; import { log } from "../log"; import { queueExtraction } from "./items"; +import { ORIGIN } from "../origin"; type Defer = (task: () => Promise) => void; @@ -113,7 +114,6 @@ export async function report(listId: string, ip: string, reason: string, details // ---- Sharing a List with people, See only ---------------------------------------------------------------- -const origin = () => process.env.BETTER_AUTH_URL ?? "http://localhost:3000"; // Each new person is an email we send to an address someone typed: a few a day per account. const PEOPLE_PER_DAY = 10; @@ -131,7 +131,7 @@ export async function shareWith(userId: string, listId: string, email: string): if (!res.ok) throw SHARE_ERRORS[res.reason]; if (res.created) { const [owner, list] = await Promise.all([me(userId), getListMeta(userId, listId)]); - await sendEmail(sharedListEmail(res.share.email, { name: owner.name || null, handle: owner.handle ?? "" }, list?.name ?? null, `${origin()}/shared/${listId}`, res.recipientLocale ?? owner.settings.locale ?? "en")); + await sendEmail(sharedListEmail(res.share.email, { name: owner.name || null, handle: owner.handle ?? "" }, list?.name ?? null, `${ORIGIN}/shared/${listId}`, res.recipientLocale ?? owner.settings.locale ?? "en")); } return res.share; } diff --git a/apps/web/lib/og.tsx b/apps/web/lib/og.tsx index 1d64125..b0fb9bd 100644 --- a/apps/web/lib/og.tsx +++ b/apps/web/lib/og.tsx @@ -1,5 +1,6 @@ import { readFile } from "node:fs/promises"; import { ImageResponse } from "next/og"; +import { HOST } from "./origin"; // Link previews (Open Graph and X cards) for every page, in the brand: Fredoka for the wordmark and headline, // Geist for the rest, the forest ground of the App Store boards (or the light ground for a public List). @@ -35,7 +36,7 @@ async function cjk(text: string) { } // The foot defaults to this copy's own address, so a self-hosted copy's previews name it and not getpluck.io. -export async function og({ title, sub, foot = new URL(process.env.BETTER_AUTH_URL ?? "http://localhost:3000").host, tone = "forest" }: { title: string; sub?: string; foot?: string; tone?: "forest" | "light" }) { +export async function og({ title, sub, foot = HOST, tone = "forest" }: { title: string; sub?: string; foot?: string; tone?: "forest" | "light" }) { const c = C[tone]; const h = headline(title); const lines = h.text.split("\n"); diff --git a/apps/web/lib/origin.ts b/apps/web/lib/origin.ts new file mode 100644 index 0000000..e568db2 --- /dev/null +++ b/apps/web/lib/origin.ts @@ -0,0 +1,4 @@ +// This copy's own address: getpluck.io hosted, a self-hoster's domain otherwise. HOST is how the UI shows it. +// Its own file, with no imports, so the site, the share images and instrumentation can read it without loading auth. +export const ORIGIN = process.env.BETTER_AUTH_URL ?? "http://localhost:3000"; +export const HOST = new URL(ORIGIN).host; diff --git a/apps/web/lib/schedule.ts b/apps/web/lib/schedule.ts index 8f7a669..9b1e538 100644 --- a/apps/web/lib/schedule.ts +++ b/apps/web/lib/schedule.ts @@ -1,4 +1,5 @@ -import { claimOnce, runDueExpiry } from "@pluck/db"; +import { claimOnce, narrowAiGrants, runDueExpiry } from "@pluck/db"; +import { FIRST_PARTY_REDIRECTS } from "./auth"; import { sortBackfill } from "./capture"; import { pollInbound, purgeBinNow, queueDueDigests } from "./library"; import { log } from "./log"; @@ -6,6 +7,7 @@ import { log } from "./log"; // Time-based work, run from every cron tick and the worker loop. Each piece is claimed in the // database, so however many instances call this, it happens once. export async function runScheduled(now = new Date()): Promise<{ purged: number; expired: number; digests: number; inbound: number; sorted: number }> { + if (await claimOnce("narrow-ai-grants-2026-10")) log("info", "old AI tool grants narrowed", { grants: await narrowAiGrants(FIRST_PARTY_REDIRECTS) }); let purged = 0; if (await claimOnce(`purge-bin:${now.toISOString().slice(0, 10)}`)) { purged = await purgeBinNow(); diff --git a/apps/web/lib/signin-code.ts b/apps/web/lib/signin-code.ts index 5d44bd5..c35e1e6 100644 --- a/apps/web/lib/signin-code.ts +++ b/apps/web/lib/signin-code.ts @@ -3,8 +3,8 @@ import { ApiError } from "./api"; import { APP_GRANT, APP_REDIRECT, auth, limitSignInEmails, REVIEW, signInCode } from "./auth"; import { sendEmail } from "./email"; import { appCodeEmail } from "./emails"; +import { ORIGIN } from "./origin"; -const ORIGIN = process.env.BETTER_AUTH_URL ?? "http://localhost:3000"; // The iPhone app signs in with a 6-digit code, never a link. A link would end at io.getpluck.app:/oauth through the // operating system, and any app can claim that scheme; a code is typed in the app and the answer comes straight back diff --git a/apps/web/lib/site.ts b/apps/web/lib/site.ts index 8d03f7d..6b32e9f 100644 --- a/apps/web/lib/site.ts +++ b/apps/web/lib/site.ts @@ -2,6 +2,7 @@ import { fromIntlLocale } from "@pluck/i18n"; import { getLocale } from "next-intl/server"; import { MCP_RESOURCE } from "./auth"; import { siteAlternates } from "./siteUrls"; +import { ORIGIN } from "./origin"; // Links the marketing site needs that only exist at launch. Placeholders stay [LIKE THIS] until then. export const REPO_URL = "https://github.com/codu-code/pluck"; @@ -16,7 +17,6 @@ export const TESTFLIGHT_APP_URL = "https://apps.apple.com/app/testflight/id89924 export const LICENCE = "Apache-2.0"; export const CONNECTOR_URL = MCP_RESOURCE; -const ORIGIN = process.env.BETTER_AUTH_URL ?? "http://localhost:3000"; // A site page's canonical and hreflang links, absolute (the root layout's metadataBase carries the language's prefix, // for the preview card), for the language it's shown in. diff --git a/apps/web/load-env.ts b/apps/web/load-env.ts new file mode 100644 index 0000000..c82be2f --- /dev/null +++ b/apps/web/load-env.ts @@ -0,0 +1,7 @@ +import { fileURLToPath } from "node:url"; + +// One .env at the repo root, read before anything else is imported: modules like lib/origin.ts read env as they load. +// Absent in Docker and on Vercel, where env comes from the environment. +try { + process.loadEnvFile(fileURLToPath(new URL("../../.env", import.meta.url))); +} catch {} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index a419cbe..ce4e94a 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/dev/types/routes.d.ts"; -import "./.next/dev/types/root-params.d.ts"; +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index f12b419..32ebfeb 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -22,6 +22,16 @@ const config: NextConfig = { skipTrailingSlashRedirect: true, // PostHog's ingest paths end in a slash // Dev: .env sets BETTER_AUTH_URL to 127.0.0.1, so the dev server must serve its scripts there too. allowedDevOrigins: ["127.0.0.1"], + // Dev: sign-in cookies live on BETTER_AUTH_URL's host, so the other name for this machine (localhost for 127.0.0.1, or + // the reverse) is sent there instead of signing in and landing signed out. Never in a build: redirects are fixed into + // it, and a self-host image built with the placeholder URL would send a proxy's 127.0.0.1 traffic to localhost. + async redirects() { + if (process.env.NODE_ENV !== "development") return []; + const origin = process.env.BETTER_AUTH_URL; + const host = origin ? new URL(origin).hostname : ""; + const other = { "127.0.0.1": "localhost", localhost: "127.0.0.1" }[host]; + return other ? [{ source: "/:path*", has: [{ type: "host" as const, value: other }], destination: `${origin}/:path*`, permanent: false }] : []; + }, // Public lists live at /@handle/slug. A folder cannot start with "@" (that is a parallel-route slot), so the page is /p/. async rewrites() { return [ diff --git a/apps/web/worker.ts b/apps/web/worker.ts index dff5f46..a707620 100644 --- a/apps/web/worker.ts +++ b/apps/web/worker.ts @@ -1,14 +1,10 @@ // The worker entry: the same code as the web app, looping over the job queue. `pnpm worker` locally, // the `worker` service in Docker Compose for self-host. Hosted on Vercel, the cron tick does this instead. -import { fileURLToPath } from "node:url"; +import "./load-env"; import { runJobs } from "./lib/jobs"; import { log } from "./lib/log"; import { runScheduled } from "./lib/schedule"; -try { - process.loadEnvFile(fileURLToPath(new URL("../../.env", import.meta.url))); -} catch {} - let stopping = false; for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => (stopping = true)); diff --git a/docker-compose.yml b/docker-compose.yml index c5f13c2..84dcb82 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,10 @@ x-app: &app BETTER_AUTH_URL: ${PLUCK_URL:-http://localhost:3000} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-} EMAIL_FROM: ${EMAIL_FROM:-Pluck } + DIGEST_FROM: ${DIGEST_FROM:-} + EMAIL_REPLY_TO: ${EMAIL_REPLY_TO:-} + WELCOME_FROM: ${WELCOME_FROM:-} + DIGEST_SECRET: ${DIGEST_SECRET:-} # Mailpit (below) catches sign-in links at http://localhost:8025. Point this at your own SMTP server to send real mail. SMTP_URL: ${SMTP_URL:-smtp://mailpit:1025} AWS_REGION: ${AWS_REGION:-eu-west-1} @@ -30,6 +34,7 @@ x-app: &app POSTHOG_HOST: ${POSTHOG_HOST:-} REVIEW_EMAIL: ${REVIEW_EMAIL:-} REVIEW_PASSWORD: ${REVIEW_PASSWORD:-} + APPLE_TEAM_ID: ${APPLE_TEAM_ID:-} depends_on: mongo: { condition: service_healthy } minio-init: { condition: service_completed_successfully } diff --git a/docs/architecture.md b/docs/architecture.md index 79026e3..77f504e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,11 +26,12 @@ One Next.js app (`apps/web`) is the website, the signed-in app, the REST API for | `apps/web/lib/api.ts` | `route()` and `publicRoute()`: auth, scope, rate limits, body parsing and the error shape for every REST route. Most route files are a few lines. | | `apps/web/lib/library/*` | The library: the product rules over `packages/db` (URL normalisation, the hourly pluck budget, the Kind and text rules, unknown tags refused with the nearest ones, kicking extraction). REST routes and the connector both call it. | | `apps/web/lib/connector.ts` | Binds `PluckLibrary` (the interface in `packages/mcp/src/library.ts`) to the library for one person, with every write marked as made by AI. | -| `apps/web/lib/capture.ts`, `jobs.ts`, `schedule.ts`, `worker.ts` | Keeping a page, the job handlers, and the time-based work (expiry, the Monday email, emptying the bin, polling email-in). | +| `apps/web/lib/capture.ts`, `jobs.ts`, `schedule.ts`; `apps/web/worker.ts` | Keeping a page, the job handlers, and the time-based work (expiry, the Monday email, emptying the bin, polling email-in). `worker.ts` loops over them for self-host; on Vercel the cron route does. | | `apps/web/lib/auth.ts`, `signin-code.ts`, `signin-link.ts` | Better Auth: email magic link, the 6-digit code the iPhone app uses, and the OAuth provider for the extension, the iPhone app and AI tools. | | `apps/web/lib/email.ts`, `emails.ts`, `digest-email.ts` | `sendEmail` (SMTP to Mailpit locally, SES in production) and the email templates. | | `packages/db` | Every collection, index and query (`collections.ts` defines them all). Every query is scoped by `userId`; `public.ts` is the only reader for public Lists. | -| `packages/mcp` | The connector's tools, resources and prompts. It sees the library only through `PluckLibrary`. `packages/mcp-stdio` is the `npx @pluck/mcp` bridge. | +| `packages/mcp` | The connector's tools, resources and prompts (package `@pluck/mcp-server`). It sees the library only through `PluckLibrary`. | +| `packages/mcp-stdio` | The `npx @pluck/mcp` bridge (package `@pluck/mcp`, the one published to npm): stdio to the hosted connector with an API key. | | `packages/extract` | Guarded fetch (`fetch.ts`), page and PDF reading (`extract.ts`, Defuddle then Readability), email (`email.ts`) and imports. Its fixtures are served by `pnpm stack` so tests never touch the internet. | | `packages/blob` | S3 through the AWS SDK; MinIO locally and for self-hosting. | | `packages/shared-schemas` | Every type as a Zod schema, used by the server and every client. | @@ -61,7 +62,7 @@ One Next.js app (`apps/web`) is the website, the signed-in app, the REST API for - **Web:** an email with a magic link and a code; a Better Auth session cookie. Sessions have the scope "full". - **Extension:** OAuth 2.1 with PKCE and dynamic registration, in a normal tab that ends on `/extension/connected`. Tokens and the queue live in `browser.storage.local`. A personal API key (`pk_live_…`) works instead. - **iPhone:** the same OAuth request, but the person types a 6-digit code from the email and the server answers with the redirect over HTTPS (`lib/signin-code.ts`), so no URL scheme carries a code. Tokens are one Keychain item, shared with the share extension. -- **AI tools:** OAuth for the `/mcp` resource, with client ID metadata documents or dynamic registration, and scopes `library:read`, `library:write` and (never asked for by default) `library:delete`. API keys work too. +- **AI tools:** OAuth for the `/mcp` resource, with client ID metadata documents or dynamic registration, and scopes `library:read`, `library:write` and (never asked for by default) `library:delete`, which deleting an Item, a List, a Tag or a highlight needs. API keys work too, with read and write unless made with delete. ## Offline diff --git a/docs/handover/design/SiteOpen.dc.html b/docs/handover/design/SiteOpen.dc.html index 98a81c0..bd01035 100644 --- a/docs/handover/design/SiteOpen.dc.html +++ b/docs/handover/design/SiteOpen.dc.html @@ -67,7 +67,7 @@

-Licence: AGPL-3.0 (server) and MIT (apps). +Licence: Apache-2.0. HomeConnect your AISupportPrivacyTerms
diff --git a/docs/handover/design/SiteTerms.dc.html b/docs/handover/design/SiteTerms.dc.html index fc67ab1..a947ce2 100644 --- a/docs/handover/design/SiteTerms.dc.html +++ b/docs/handover/design/SiteTerms.dc.html @@ -57,7 +57,7 @@

pnpm --filter @pluck/extension build`, then `chrome://extensions`, Developer mode, Load unpacked, `apps/extension/.output/chrome-mv3`. It signs in through your server, or takes an API key (Settings, API keys). -- **iPhone:** open `pluck://signin?server=` on the phone (from a note or your server's Settings) and the sign-in screen opens on "Use your own server" with your address filled in. Store builds hide that choice otherwise; a build with `PLUCK_SHOW_SERVER=1` always shows it. +- **iPhone:** open `pluck://signin?server=` on the phone (from a note or your server's Settings) and the sign-in screen opens on "Use your own server" with your address filled in. Store builds hide that choice otherwise; a build with `PLUCK_SHOW_SERVER=1` always shows it. To build and sign the app yourself, change our identifiers to your own first, since Apple lets one team hold each: the bundle ID `io.getpluck.app` (and `.share`), the App Group `group.io.getpluck.app` and the Keychain group `io.getpluck.shared`, in `apps/ios/app.config.ts`, `apps/ios/targets/share/PluckClient.swift`, `apps/ios/lib/data.ts` and `apps/web/app/.well-known/apple-app-site-association/route.ts`. - **AI tools:** the connector is `/mcp`, with OAuth like the hosted service. For a tool that only speaks stdio, or a server without public HTTPS: `npx @pluck/mcp --server --key pk_live_…`. See `docs/mcp.md`. ## What runs where diff --git a/packages/db/src/ai-tools.test.ts b/packages/db/src/ai-tools.test.ts index a780716..16ace42 100644 --- a/packages/db/src/ai-tools.test.ts +++ b/packages/db/src/ai-tools.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "vitest"; -import { aiConnections, disconnectAi, isAiConnected } from "./ai-tools.ts"; +import { aiConnections, disconnectAi, isAiConnected, narrowAiGrants } from "./ai-tools.ts"; import { db, oid } from "./collections.ts"; import { twoUsers } from "./testing.ts"; @@ -29,3 +29,27 @@ test("connections are per person, and disconnecting removes the consent and the expect(await (await db()).collection("oauthRefreshToken").countDocuments({ userId: oid(a), clientId: "client-1" })).toBe(0); expect(await disconnectAi(a, "client-1")).toBe(false); }); + +test("old grants lose delete and the account, for every person, except our own apps'", async () => { + const { a, b } = await twoUsers(); + const d = await db(); + const ours = "http://ours.test/extension/connected"; + await d.collection("oauthClient").insertMany([ + { clientId: "narrow-ai", redirectUris: ["https://ai.example.test/cb"] }, + { clientId: "narrow-ext", redirectUris: [ours] }, + { clientId: "narrow-mixed", redirectUris: [ours, "https://ai.example.test/cb"] }, + ]); + const all = ["offline_access", "library:read", "library:write", "library:delete", "account"]; + for (const userId of [a, b]) + for (const clientId of ["narrow-ai", "narrow-ext", "narrow-mixed", "narrow-unknown"]) { + await d.collection("oauthConsent").insertOne({ clientId, userId: oid(userId), scopes: all, createdAt: new Date() }); + await d.collection("oauthRefreshToken").insertOne({ clientId, userId: oid(userId), token: `${clientId}-${userId}`, scopes: all }); + } + + expect(await narrowAiGrants([ours])).toBe(6); + for (const name of ["oauthConsent", "oauthRefreshToken"]) { + const rows = await d.collection(name).find({ clientId: /^narrow-/, userId: { $in: [oid(a), oid(b)] } }).toArray(); + for (const r of rows) expect(r.scopes).toEqual(r.clientId === "narrow-ext" ? all : ["offline_access", "library:read", "library:write"]); + } + expect(await narrowAiGrants([ours])).toBe(0); // safe to run again +}); diff --git a/packages/db/src/ai-tools.ts b/packages/db/src/ai-tools.ts index 8e0024f..264e0bc 100644 --- a/packages/db/src/ai-tools.ts +++ b/packages/db/src/ai-tools.ts @@ -32,3 +32,19 @@ export async function disconnectAi(userId: string, clientId: string): Promise 0; } + +// Until October 2026 an AI tool that named no scope was given every scope, delete and the account included. This takes +// those two back from every client that is not one of our apps; a tool that wants delete connects again and asks for it. +export async function narrowAiGrants(firstPartyRedirects: string[]): Promise { + const d = await db(); + const ours = await d.collection("oauthClient").distinct("clientId", { + "redirectUris.0": { $exists: true }, + redirectUris: { $not: { $elemMatch: { $nin: firstPartyRedirects } } }, + }); + const wide = ["library:delete", "account"]; + const filter = { clientId: { $nin: ours }, scopes: { $in: wide } }; + const narrow = { $pull: { scopes: { $in: wide } } }; + const grants = (c: string) => d.collection<{ clientId: string; scopes: string[] }>(c).updateMany(filter, narrow); + const [consents] = await Promise.all([grants("oauthConsent"), grants("oauthRefreshToken")]); + return consents.modifiedCount; +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index c283e6c..f1f5836 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -30,6 +30,6 @@ export { createApiKey, listApiKeys, revokeApiKey, verifyApiKey, DEFAULT_KEY_SCOP export { createImport, getImport, claimImportStart, updateImport, importLink, startExport, finishExport, latestExport, exportData, deleteAccountData, } from "./account.ts"; -export { aiConnections, disconnectAi, isAiConnected } from "./ai-tools.ts"; +export { aiConnections, disconnectAi, isAiConnected, narrowAiGrants } from "./ai-tools.ts"; export { holdTry, takeTry } from "./tries.ts"; export { nextUnsorted, type Unsorted } from "./backfill.ts"; diff --git a/packages/db/src/lists.ts b/packages/db/src/lists.ts index 770b37e..2a5f3b8 100644 --- a/packages/db/src/lists.ts +++ b/packages/db/src/lists.ts @@ -175,7 +175,7 @@ export async function addToList( } } await (await items()).updateMany({ _id: { $in: ordered }, userId: uid }, { $set: { lastTouchedAt: now } }); - await (await lists()).updateOne({ _id }, { $set: { lastAddedAt: now, lastActivityAt: now, updatedAt: now } }); + await (await lists()).updateOne({ _id, userId: uid }, { $set: { lastAddedAt: now, lastActivityAt: now, updatedAt: now } }); await recountList(_id); await syncListNames(uid, ordered); return added; @@ -186,7 +186,7 @@ export async function removeFromList(userId: string, listId: string, itemId: str const _id = oid(listId); const res = await (await listRows()).deleteOne({ listId: _id, userId: uid, itemId: oid(itemId) }); if (!res.deletedCount) return false; - await (await lists()).updateOne({ _id }, { $set: { lastActivityAt: new Date(), updatedAt: new Date() } }); + await (await lists()).updateOne({ _id, userId: uid }, { $set: { lastActivityAt: new Date(), updatedAt: new Date() } }); await recountList(_id); await syncListNames(uid, [oid(itemId)]); return true; @@ -211,7 +211,7 @@ export async function updateEntry(userId: string, listId: string, itemId: string if (!res.matchedCount) return false; // A mark is the owner's alone: activity, but not an update anyone else sees ("updated 10 min ago"). const seen = edit.note !== undefined || edit.position !== undefined; - await (await lists()).updateOne({ _id }, { $set: { lastActivityAt: new Date(), ...(seen ? { updatedAt: new Date() } : {}) } }); + await (await lists()).updateOne({ _id, userId: uid }, { $set: { lastActivityAt: new Date(), ...(seen ? { updatedAt: new Date() } : {}) } }); if (edit.ticked !== undefined) { await recountList(_id); await doneFollowsMark(uid, [oid(itemId)], edit.ticked); @@ -228,7 +228,7 @@ export async function markEntries(userId: string, listId: string, itemIds: strin const ids = itemIds.map(oid); const now = new Date(); const res = await (await listRows()).updateMany({ listId: _id, userId: uid, itemId: { $in: ids } }, { $set: { ticked, tickedAt: ticked ? now : null } }); - await (await lists()).updateOne({ _id }, { $set: { lastActivityAt: now } }); + await (await lists()).updateOne({ _id, userId: uid }, { $set: { lastActivityAt: now } }); await recountList(_id); await doneFollowsMark(uid, ids, ticked); return res.matchedCount; @@ -280,8 +280,8 @@ export async function reorderList(userId: string, listId: string, itemIds: strin const rows = await col.find({ listId: _id, userId: uid }, { projection: { itemId: 1 } }).toArray(); const have = new Set(rows.map((r) => r.itemId.toHexString())); if (have.size !== itemIds.length || itemIds.some((id) => !have.has(id))) return false; - await col.bulkWrite(itemIds.map((id, i) => ({ updateOne: { filter: { listId: _id, itemId: oid(id) }, update: { $set: { position: (i + 1) * GAP } } } }))); - await (await lists()).updateOne({ _id }, { $set: { lastActivityAt: new Date(), updatedAt: new Date() } }); + await col.bulkWrite(itemIds.map((id, i) => ({ updateOne: { filter: { listId: _id, userId: uid, itemId: oid(id) }, update: { $set: { position: (i + 1) * GAP } } } }))); + await (await lists()).updateOne({ _id, userId: uid }, { $set: { lastActivityAt: new Date(), updatedAt: new Date() } }); return true; } @@ -315,7 +315,7 @@ export async function setVisibility(userId: string, listId: string, visibility: } const now = new Date(); const doc = await col.findOneAndUpdate( - { _id }, + { _id, userId: uid }, { $set: { visibility, updatedAt: now, lastActivityAt: now, ...(visibility === "public" && !list.publishedAt ? { publishedAt: now } : {}) } }, { returnDocument: "after" }, ); diff --git a/packages/i18n/messages/app/de.json b/packages/i18n/messages/app/de.json index 1b0743a..55bff38 100644 --- a/packages/i18n/messages/app/de.json +++ b/packages/i18n/messages/app/de.json @@ -285,7 +285,7 @@ "scopes": { "read": "Deine Bibliothek lesen: Einträge, den Text, den Pluck behalten hat, Notizen, Tags und Listen", "write": "Pflücken und ordnen: Dinge hinzufügen, taggen, sortieren, Listen anlegen und bearbeiten", - "delete": "Einträge löschen. Die meisten KI-Tools brauchen das nie.", + "delete": "Einträge, Listen, Tags und Markierungen löschen. Die meisten KI-Tools brauchen das nie.", "account": "Dein Konto verwalten: verbundene KI-Tools, Exporte und das Löschen von allem. Nur Plucks eigene Apps fragen danach." }, "signedInAs": "Angemeldet als {email}. Löschen kann es nur, wenn das oben steht, und du kannst es jederzeit in den Einstellungen trennen." diff --git a/packages/i18n/messages/app/en.json b/packages/i18n/messages/app/en.json index 06b8dd7..43e34d2 100644 --- a/packages/i18n/messages/app/en.json +++ b/packages/i18n/messages/app/en.json @@ -285,7 +285,7 @@ "scopes": { "read": "Read your library: items, the text Pluck kept, notes, tags and lists", "write": "Pluck and organise: add things, tag, sort, make and edit lists", - "delete": "Delete items. Most AI tools never need this.", + "delete": "Delete items, lists, tags and highlights. Most AI tools never need this.", "account": "Manage your account: connected AI tools, exports, and deleting it all. Only Pluck's own apps ask for this." }, "signedInAs": "Signed in as {email}. It can never delete anything unless that is listed above, and you can disconnect it in Settings." diff --git a/packages/i18n/messages/app/es.json b/packages/i18n/messages/app/es.json index 16e3d13..ff795e1 100644 --- a/packages/i18n/messages/app/es.json +++ b/packages/i18n/messages/app/es.json @@ -285,7 +285,7 @@ "scopes": { "read": "Leer tu biblioteca: elementos, el texto que Pluck conservó, notas, etiquetas y listas", "write": "Recoger y organizar: añadir cosas, etiquetar, ordenar, crear y editar listas", - "delete": "Eliminar elementos. La mayoría de las herramientas de IA nunca lo necesitan.", + "delete": "Eliminar elementos, listas, etiquetas y destacados. La mayoría de las herramientas de IA nunca lo necesitan.", "account": "Gestionar tu cuenta: herramientas de IA conectadas, exportaciones y eliminarlo todo. Solo las apps de Pluck piden esto." }, "signedInAs": "Sesión iniciada como {email}. Nunca puede eliminar nada a menos que aparezca arriba, y puedes desconectarla en Ajustes." diff --git a/packages/i18n/messages/app/fr.json b/packages/i18n/messages/app/fr.json index 299571b..1e360e2 100644 --- a/packages/i18n/messages/app/fr.json +++ b/packages/i18n/messages/app/fr.json @@ -285,7 +285,7 @@ "scopes": { "read": "Lire votre bibliothèque : les éléments, le texte gardé par Pluck, les notes, les tags et les listes", "write": "Cueillir et ranger : ajouter des choses, taguer, trier, créer et modifier des listes", - "delete": "Supprimer des éléments. La plupart des outils d’IA n’en ont jamais besoin.", + "delete": "Supprimer des éléments, des listes, des tags et des surlignages. La plupart des outils d’IA n’en ont jamais besoin.", "account": "Gérer votre compte : outils d’IA connectés, exports et suppression de tout. Seules les apps de Pluck le demandent." }, "signedInAs": "Connecté en tant que {email}. Il ne peut jamais rien supprimer sauf si c’est indiqué ci-dessus, et vous pouvez le déconnecter dans les Réglages." diff --git a/packages/i18n/messages/app/it.json b/packages/i18n/messages/app/it.json index bb2f7ad..58a0840 100644 --- a/packages/i18n/messages/app/it.json +++ b/packages/i18n/messages/app/it.json @@ -285,7 +285,7 @@ "scopes": { "read": "Leggere la tua libreria: elementi, il testo conservato da Pluck, note, tag e liste", "write": "Prendere e organizzare: aggiungere cose, mettere tag, smistare, creare e modificare liste", - "delete": "Eliminare elementi. Quasi nessuno strumento di IA ne ha bisogno.", + "delete": "Eliminare elementi, liste, tag ed evidenziazioni. Quasi nessuno strumento di IA ne ha bisogno.", "account": "Gestire il tuo account: strumenti di IA collegati, esportazioni ed eliminazione di tutto. Lo chiedono solo le app di Pluck." }, "signedInAs": "Accesso effettuato come {email}. Non può mai eliminare niente, a meno che non sia elencato qui sopra, e puoi scollegarlo in Impostazioni." diff --git a/packages/i18n/messages/app/ja.json b/packages/i18n/messages/app/ja.json index 4bfa7fb..04dfe6c 100644 --- a/packages/i18n/messages/app/ja.json +++ b/packages/i18n/messages/app/ja.json @@ -285,7 +285,7 @@ "scopes": { "read": "ライブラリの読み取り:アイテム、Pluckが残した本文、メモ、タグ、リスト", "write": "Pluckと整理:追加、タグ付け、並べ替え、リストの作成と編集", - "delete": "アイテムの削除。ほとんどのAIツールには必要ありません。", + "delete": "アイテム、リスト、タグ、ハイライトの削除。ほとんどのAIツールには必要ありません。", "account": "アカウントの管理:接続中のAIツール、エクスポート、すべての削除。求めるのはPluck自身のアプリだけです。" }, "signedInAs": "{email}でサインイン中。上に書かれていない限り、何も削除できません。接続は設定でいつでも解除できます。" diff --git a/packages/i18n/messages/app/ko.json b/packages/i18n/messages/app/ko.json index 98e734b..0bc3c11 100644 --- a/packages/i18n/messages/app/ko.json +++ b/packages/i18n/messages/app/ko.json @@ -285,7 +285,7 @@ "scopes": { "read": "라이브러리 읽기: 항목, Pluck이 담아 둔 글, 메모, 태그, 리스트", "write": "담고 정리하기: 항목 추가, 태그, 분류, 리스트 만들기와 편집", - "delete": "항목 삭제. 대부분의 AI 도구에는 필요 없어요.", + "delete": "항목, 리스트, 태그, 하이라이트 삭제. 대부분의 AI 도구에는 필요 없어요.", "account": "계정 관리: 연결된 AI 도구, 내보내기, 전체 삭제. Pluck 자체 앱만 요청해요." }, "signedInAs": "{email}(으)로 로그인했어요. 위에 나와 있지 않으면 아무것도 삭제할 수 없고, 설정에서 언제든 연결을 해제할 수 있어요." diff --git a/packages/i18n/messages/app/nl.json b/packages/i18n/messages/app/nl.json index e2e5fd4..2fcce36 100644 --- a/packages/i18n/messages/app/nl.json +++ b/packages/i18n/messages/app/nl.json @@ -285,7 +285,7 @@ "scopes": { "read": "Je bibliotheek lezen: items, de tekst die Pluck bewaarde, notities, tags en lijsten", "write": "Plukken en ordenen: dingen toevoegen, taggen, sorteren, lijsten maken en bewerken", - "delete": "Items verwijderen. De meeste AI-tools hebben dit nooit nodig.", + "delete": "Items, lijsten, tags en markeringen verwijderen. De meeste AI-tools hebben dit nooit nodig.", "account": "Je account beheren: verbonden AI-tools, exports en alles verwijderen. Alleen de eigen apps van Pluck vragen hierom." }, "signedInAs": "Ingelogd als {email}. Hij kan nooit iets verwijderen, tenzij dat hierboven staat, en je kunt hem loskoppelen in Instellingen." diff --git a/packages/i18n/messages/app/pt-BR.json b/packages/i18n/messages/app/pt-BR.json index cad1509..47200f4 100644 --- a/packages/i18n/messages/app/pt-BR.json +++ b/packages/i18n/messages/app/pt-BR.json @@ -285,7 +285,7 @@ "scopes": { "read": "Ler sua biblioteca: itens, o texto que o Pluck guardou, notas, tags e listas", "write": "Colher e organizar: adicionar coisas, colocar tags, ordenar, criar e editar listas", - "delete": "Apagar itens. A maioria das ferramentas de IA nunca precisa disso.", + "delete": "Apagar itens, listas, tags e destaques. A maioria das ferramentas de IA nunca precisa disso.", "account": "Gerenciar sua conta: ferramentas de IA conectadas, exportações e apagar tudo. Só os apps do próprio Pluck pedem isso." }, "signedInAs": "Você entrou como {email}. Ela nunca pode apagar nada, a menos que isso esteja na lista acima, e você pode desconectá-la em Configurações." diff --git a/packages/i18n/messages/app/zh-Hans.json b/packages/i18n/messages/app/zh-Hans.json index 653cf17..34edec0 100644 --- a/packages/i18n/messages/app/zh-Hans.json +++ b/packages/i18n/messages/app/zh-Hans.json @@ -285,7 +285,7 @@ "scopes": { "read": "读取你的资料库:条目、Pluck 留下的文字、笔记、标签和清单", "write": "摘下和整理:添加内容、打标签、分类、新建和编辑清单", - "delete": "删除条目。大多数 AI 工具从来用不到。", + "delete": "删除条目、清单、标签和高亮。大多数 AI 工具从来用不到。", "account": "管理你的账号:已连接的 AI 工具、导出,以及删除全部数据。只有 Pluck 自己的 App 会请求这一项。" }, "signedInAs": "已登录为 {email}。除非上面列出了删除权限,否则它永远不能删除任何东西;你也可以在设置中断开它。" diff --git a/packages/i18n/messages/site/de.json b/packages/i18n/messages/site/de.json index dbd6e79..b019eb5 100644 --- a/packages/i18n/messages/site/de.json +++ b/packages/i18n/messages/site/de.json @@ -170,7 +170,7 @@ "title": "Open Source · Pluck", "description": "Pluck ist kostenlos und Open Source. Nutze getpluck.io, oder betreib deine eigene Instanz mit einem Befehl." }, - "licence": "Lizenz: AGPL-3.0 (Server) und MIT (Apps).", + "licence": "Lizenz: Apache-2.0.", "title": "Kostenlos.

Offen.

Deins.", "lede": "Später-lesen-Apps machen eine nach der anderen zu. Pluck kann das nicht, denn der Code ist öffentlich und die Daten gehören dir. Nutze unsere Instanz, oder betreib deine eigene mit einem Befehl.", "selfHostTitle": "Selbst hosten, mit einem Befehl.", diff --git a/packages/i18n/messages/site/en.json b/packages/i18n/messages/site/en.json index 30d2f9f..d0c013a 100644 --- a/packages/i18n/messages/site/en.json +++ b/packages/i18n/messages/site/en.json @@ -170,7 +170,7 @@ "title": "Open source · Pluck", "description": "Pluck is free and open source. Use getpluck.io, or run your own in one command." }, - "licence": "Licence: AGPL-3.0 (server) and MIT (apps).", + "licence": "Licence: Apache-2.0.", "title": "Free.

Open.

Yours.", "lede": "Read-it-later apps keep closing. Pluck can't, because the code is public and the data is yours. Use ours, or run your own in one command.", "selfHostTitle": "Self-host in one command.", diff --git a/packages/i18n/messages/site/es.json b/packages/i18n/messages/site/es.json index f982496..3a13b50 100644 --- a/packages/i18n/messages/site/es.json +++ b/packages/i18n/messages/site/es.json @@ -170,7 +170,7 @@ "title": "Código abierto · Pluck", "description": "Pluck es gratis y de código abierto. Usa getpluck.io, o ejecuta el tuyo con un solo comando." }, - "licence": "Licencia: AGPL-3.0 (servidor) y MIT (apps).", + "licence": "Licencia: Apache-2.0.", "title": "Gratis.

Abierto.

Tuyo.", "lede": "Las apps para leer más tarde no paran de cerrar. Pluck no puede, porque el código es público y los datos son tuyos. Usa el nuestro, o ejecuta el tuyo con un solo comando.", "selfHostTitle": "Alójalo tú con un solo comando.", diff --git a/packages/i18n/messages/site/fr.json b/packages/i18n/messages/site/fr.json index 234abd2..089b19c 100644 --- a/packages/i18n/messages/site/fr.json +++ b/packages/i18n/messages/site/fr.json @@ -170,7 +170,7 @@ "title": "Open source · Pluck", "description": "Pluck est gratuit et open source. Utilisez getpluck.io, ou faites tourner le vôtre en une commande." }, - "licence": "Licence : AGPL-3.0 (serveur) et MIT (apps).", + "licence": "Licence : Apache-2.0.", "title": "Gratuit.

Ouvert.

À vous.", "lede": "Les apps de lecture différée ferment les unes après les autres. Pas Pluck : le code est public et les données sont à vous. Utilisez le nôtre, ou faites tourner le vôtre en une commande.", "selfHostTitle": "Auto-hébergement en une commande.", diff --git a/packages/i18n/messages/site/it.json b/packages/i18n/messages/site/it.json index 83bebd2..309615e 100644 --- a/packages/i18n/messages/site/it.json +++ b/packages/i18n/messages/site/it.json @@ -170,7 +170,7 @@ "title": "Open source · Pluck", "description": "Pluck è gratuito e open source. Usa getpluck.io, o fai girare il tuo con un solo comando." }, - "licence": "Licenza: AGPL-3.0 (server) e MIT (app).", + "licence": "Licenza: Apache-2.0.", "title": "Gratis.

Aperto.

Tuo.", "lede": "Le app per leggere più tardi continuano a chiudere. Pluck non può, perché il codice è pubblico e i dati sono tuoi. Usa il nostro, o fai girare il tuo con un solo comando.", "selfHostTitle": "Self-hosting con un solo comando.", diff --git a/packages/i18n/messages/site/ja.json b/packages/i18n/messages/site/ja.json index c528d74..1aa867b 100644 --- a/packages/i18n/messages/site/ja.json +++ b/packages/i18n/messages/site/ja.json @@ -170,7 +170,7 @@ "title": "オープンソース · Pluck", "description": "Pluckは無料のオープンソースです。getpluck.ioを使うことも、コマンドひとつで自分のサーバーを動かすこともできます。" }, - "licence": "ライセンス:AGPL-3.0(サーバー)とMIT(アプリ)。", + "licence": "ライセンス:Apache-2.0。", "title": "無料。

オープン。

自分のもの。", "lede": "あとで読むアプリは、次々に終わっていきます。Pluckは終わりません。コードは公開されていて、データはあなたのものだからです。こちらのサービスを使うことも、コマンドひとつで自分で動かすこともできます。", "selfHostTitle": "コマンドひとつでセルフホスト。", diff --git a/packages/i18n/messages/site/ko.json b/packages/i18n/messages/site/ko.json index 3acbc5a..04f5414 100644 --- a/packages/i18n/messages/site/ko.json +++ b/packages/i18n/messages/site/ko.json @@ -170,7 +170,7 @@ "title": "오픈소스 · Pluck", "description": "Pluck은 무료 오픈소스예요. getpluck.io를 쓰거나, 명령어 하나로 직접 운영하세요." }, - "licence": "라이선스: AGPL-3.0(서버), MIT(앱).", + "licence": "라이선스: Apache-2.0.", "title": "무료.

공개.

내 것.", "lede": "나중에 읽기 앱들은 계속 문을 닫아요. Pluck은 그럴 수 없어요. 코드는 공개돼 있고, 데이터는 내 것이니까요. 저희 서비스를 쓰거나, 명령어 하나로 직접 운영하세요.", "selfHostTitle": "명령어 하나로 직접 호스팅.", diff --git a/packages/i18n/messages/site/nl.json b/packages/i18n/messages/site/nl.json index 510e183..7a50cce 100644 --- a/packages/i18n/messages/site/nl.json +++ b/packages/i18n/messages/site/nl.json @@ -170,7 +170,7 @@ "title": "Open source · Pluck", "description": "Pluck is gratis en open source. Gebruik getpluck.io, of draai je eigen versie met één commando." }, - "licence": "Licentie: AGPL-3.0 (server) en MIT (apps).", + "licence": "Licentie: Apache-2.0.", "title": "Gratis.

Open.

Van jou.", "lede": "Read-later-apps verdwijnen keer op keer. Pluck kan dat niet, want de code is openbaar en de data is van jou. Gebruik de onze, of draai je eigen versie met één commando.", "selfHostTitle": "Host het zelf met één commando.", diff --git a/packages/i18n/messages/site/pt-BR.json b/packages/i18n/messages/site/pt-BR.json index c91252e..7b535ec 100644 --- a/packages/i18n/messages/site/pt-BR.json +++ b/packages/i18n/messages/site/pt-BR.json @@ -170,7 +170,7 @@ "title": "Código aberto · Pluck", "description": "O Pluck é grátis e de código aberto. Use o getpluck.io, ou rode o seu com um comando." }, - "licence": "Licença: AGPL-3.0 (servidor) e MIT (apps).", + "licence": "Licença: Apache-2.0.", "title": "Grátis.

Aberto.

Seu.", "lede": "Os apps de ler depois vivem fechando. O Pluck não tem como, porque o código é público e os dados são seus. Use o nosso, ou rode o seu com um comando.", "selfHostTitle": "Hospede você mesmo com um comando.", diff --git a/packages/i18n/messages/site/zh-Hans.json b/packages/i18n/messages/site/zh-Hans.json index ce79a32..4268c8c 100644 --- a/packages/i18n/messages/site/zh-Hans.json +++ b/packages/i18n/messages/site/zh-Hans.json @@ -170,7 +170,7 @@ "title": "开源 · Pluck", "description": "Pluck 免费且开源。使用 getpluck.io,或者用一条命令运行你自己的。" }, - "licence": "许可证:AGPL-3.0(服务器)和 MIT(各个 App)。", + "licence": "许可证:Apache-2.0。", "title": "免费。

开放。

属于你。", "lede": "稍后读 App 一个接一个地关门。Pluck 不会,因为代码是公开的,数据是你的。用我们的,或者一条命令运行你自己的。", "selfHostTitle": "一条命令自托管。",