From 7d8327d059ec6409e03f045515c299d7527f24ab Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 16:55:04 +0700 Subject: [PATCH 01/87] docs: define stable list collaboration design --- CONTEXT.md | 24 ++++ .../2026-08-15-stable-list-collaboration.md | 128 ++++++++++++++++++ ...-08-15-stable-list-collaboration-design.md | 72 ++++++++++ 3 files changed, 224 insertions(+) create mode 100644 CONTEXT.md create mode 100644 docs/superpowers/plans/2026-08-15-stable-list-collaboration.md create mode 100644 docs/superpowers/specs/2026-08-15-stable-list-collaboration-design.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..06913a17a --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,24 @@ +# Domain Context + +## List collaboration + +- **List owner**: the user who owns a list. Ownership is not a collaborator membership and is never inherited. +- **Collaborator**: a non-owner user with accepted access to a manual list as `viewer` or `editor`. +- **Direct grant**: a collaborator membership attached to one specific list. +- **Recursive grant**: a direct grant whose role may be inherited by descendants of that list, including descendants created or moved into the subtree later. +- **Inherited access**: effective access to a list obtained from the nearest ancestor direct grant for the same user whose recursive flag is enabled. +- **Effective role**: the role used for authorization on a list. A direct grant on the list wins. Otherwise the nearest recursive ancestor grant wins. Otherwise the user has no collaborator access. +- **Invitation**: a pending offer for a direct grant. Invitations may request viewer/editor access and may optionally request recursive sharing. +- **Invitation expiry**: a pending invitation is valid for 30 days from `invitedAt`. Resending renews `invitedAt` for another 30 days. +- **Public access**: anonymous read-only access to a list through its public-list mechanism. Public access never creates collaborator membership and is independent from collaboration. +- **Contributed bookmark membership**: a bookmark-to-list association created through a collaborator membership. Removing that direct membership removes associations tied to it, but never deletes the underlying bookmark. + +## Permission rules + +- Collaboration applies only to manual lists. +- Viewers are read-only. Editors can add/remove bookmark memberships. Only owners manage list metadata and collaborators. +- Recursive sharing is opt-in per direct grant and defaults off. +- Current and future descendants can inherit a recursive grant. +- Moving a descendant out of a recursively shared subtree removes access that existed only through that inheritance. +- Explicit direct access on a descendant overrides an inherited role on that descendant. Descendants inherit from the nearest recursive direct grant available on their own ancestor chain. +- Public sharing and collaborator sharing can coexist on the same list. diff --git a/docs/superpowers/plans/2026-08-15-stable-list-collaboration.md b/docs/superpowers/plans/2026-08-15-stable-list-collaboration.md new file mode 100644 index 000000000..a202544c5 --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-stable-list-collaboration.md @@ -0,0 +1,128 @@ +# Stable List Collaboration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Graduate manual-list collaboration from Beta to stable across web and mobile, including 30-day invitations, truthful/resendable email, and opt-in recursive nested-list access. + +**Architecture:** Store `recursive` only on direct invitations and collaborator memberships. Resolve effective access at read/authorization time from the exact-list grant first and then the nearest ancestor recursive grant, so future descendants inherit automatically without materialized child memberships. Keep invitation lifecycle and email delivery separate so a committed invitation is never rolled back by SMTP behavior. + +**Tech Stack:** TypeScript, tRPC, Drizzle ORM, SQLite, Next.js/React, React Native/Expo, TanStack Query, Vitest. + +## Global Constraints + +- Collaboration remains manual-list only. +- Existing registered users only; normalize email and avoid account-enumerating error copy. +- Invitation expiry is exactly 30 days from `invitedAt`; resend resets `invitedAt`. +- `Also share all nested lists` defaults off. +- Public-list access remains independent and read-only. +- Viewer is read-only; editor edits bookmark memberships; owner alone manages metadata/collaborators. +- Recursive access includes current/future descendants and disappears when a list leaves the ancestor subtree. +- Exact-list direct grants win over inherited grants. Otherwise nearest recursive ancestor wins. +- No durable email outbox, ownership transfer, comments/activity feed, or real-time editing. + +--- + +### Task 1: Persist direct recursive grants and resolve effective access + +**Files:** +- Modify: `packages/db/schema.ts` +- Create: next Drizzle migration under `packages/db/migrations/` +- Modify: `packages/trpc/models/lists.ts` +- Modify/Test: `packages/trpc/routers/sharedLists.test.ts` + +**Interfaces:** +- `listCollaborators.recursive: boolean` defaults `false`. +- `listInvitations.recursive: boolean` defaults `false`. +- Effective collaborator resolution returns the direct membership row used for contribution ownership when access is direct; inherited access has no descendant membership row. + +- [ ] **Step 1: Add failing backend tests** proving: direct access works; recursive ancestor grants current descendants; a future/moved-in descendant becomes accessible without new membership rows; moving out removes inherited access; nearest recursive ancestor wins; exact-list direct role wins; viewer inherited access cannot edit; editor inherited access can edit. +- [ ] **Step 2: Run focused TRPC tests** with `pnpm --filter @karakeep/trpc test -- sharedLists.test.ts` and confirm the new cases fail because recursive fields/resolution do not exist. +- [ ] **Step 3: Add schema columns + migration** using integer boolean columns with `NOT NULL DEFAULT 0` so existing memberships remain non-recursive. +- [ ] **Step 4: Implement centralized effective-access resolution** in `packages/trpc/models/lists.ts`: owner first; exact direct membership second; nearest ancestor membership with `recursive=true` third. Keep collaborator-visible `parentId` private. +- [ ] **Step 5: Make list enumeration include inherited shared lists** while preserving the existing Shared Lists client grouping and without leaking inaccessible siblings/parents. +- [ ] **Step 6: Run the focused tests again** and confirm all recursive authorization cases pass. +- [ ] **Step 7: Commit** `feat: add recursive list collaboration permissions`. + +### Task 2: Stabilize invitation lifecycle and email delivery + +**Files:** +- Modify: `packages/trpc/models/listInvitations.ts` +- Modify: `packages/trpc/models/lists.ts` +- Modify: `packages/trpc/routers/lists.ts` +- Modify: `packages/trpc/email.ts` +- Modify/Test: `packages/trpc/routers/sharedLists.test.ts` + +**Interfaces:** +- Invitation create/update accepts `{ email, role, recursive }`. +- Pending invitation validity: `Date.now() - invitedAt < 30 days`. +- Resend returns delivery result and renews `invitedAt`. +- Accept copies both role and recursive to `listCollaborators`. + +- [ ] **Step 1: Add failing lifecycle tests** for normalized emails, neutral unknown-user failures, 30-day expiry, expired accept rejection, declined reinvite, pending role change, pending recursive-scope change, resend renewal, and accepted `recursive` persistence. +- [ ] **Step 2: Add failing email tests** proving invitation creation survives absent/failing SMTP, delivery state is distinguishable, deep links contain invitation ID, and HTML escapes list/inviter names. +- [ ] **Step 3: Run focused TRPC/email tests** and confirm failures reflect the missing stable lifecycle. +- [ ] **Step 4: Implement lifecycle helpers** for expiry, role/scope updates, declined reinvite, resend, and normalized email lookup. Return neutral public errors for unknown users. +- [ ] **Step 5: Move email send outside the invitation DB transaction** and return a structured delivery result instead of claiming `sent` unconditionally. +- [ ] **Step 6: Escape HTML and build invitation-ID deep links** while keeping plain-text output. +- [ ] **Step 7: Run focused tests** and confirm lifecycle/email cases pass. +- [ ] **Step 8: Commit** `feat: stabilize list invitation lifecycle`. + +### Task 3: Align web collaboration UI with stable semantics + +**Files:** +- Modify/Test: `apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx` +- Modify/Test: `apps/web/components/dashboard/lists/PendingInvitationsCard.tsx` +- Modify: `apps/web/components/dashboard/sidebar/InvitationNotificationBadge.tsx` +- Modify/Test: `apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx` +- Modify relevant web translation resources used by these components. + +**Interfaces:** +- Owner management consumes collaborator rows with accepted/pending state, role, recursive scope, expiry, and invitation ID. +- Mutations: invite, update accepted role/scope, update pending role/scope, resend, revoke, remove. + +- [ ] **Step 1: Add failing component tests** for viewer removal-action hiding, recursive checkbox default-off copy, pending role/scope updates, expired state, resend feedback, and removal confirmation copy. +- [ ] **Step 2: Run focused web tests** and confirm the new UI-contract tests fail. +- [ ] **Step 3: Update Manage Collaborators**: remove Beta; add default-off `Also share all nested lists`; show current/future nested-list helper copy; expose role and recursive scope for accepted/pending entries; show expired pending state; add Resend and Revoke; confirm collaborator removal and explain contributed bookmark-list entries vs underlying bookmarks. +- [ ] **Step 4: Make creation/resend toasts truthful**: distinguish invitation created + email sent from invitation created + email not sent. +- [ ] **Step 5: Fix bookmark option visibility** so viewer-only users never see Remove from list even when they own the underlying bookmark. +- [ ] **Step 6: Make pending-invitation inbox/deep-link handling target invitation IDs** and handle expired/declined/revoked states gracefully. +- [ ] **Step 7: Run focused web tests** and confirm pass. +- [ ] **Step 8: Commit** `feat(web): graduate list collaboration UI`. + +### Task 4: Add full native-mobile collaboration parity + +**Files:** +- Modify: `apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx` +- Modify: `apps/mobile/app/dashboard/lists/[slug]/index.tsx` +- Create mobile invitation inbox/management routes and focused reusable components under the existing `apps/mobile` conventions. +- Add focused mobile tests where the repository has an established test seam; otherwise typecheck is the executable gate and the behavioral contract remains covered at TRPC level. + +**Interfaces:** +- Mobile uses the same tRPC invitation/collaborator procedures as web. +- Owner list actions add Manage Collaborators; collaborator list actions keep Leave List. + +- [ ] **Step 1: Add the pending-invitation entry point** on the Lists tab with count/badge and a screen listing invitation, owner/list, role, recursive scope, expiry, Accept and Decline. +- [ ] **Step 2: Add owner Manage Collaborators navigation** to the list actions menu. +- [ ] **Step 3: Build the mobile management screen** with invite email, role selector, default-off nested-list toggle, accepted/pending entries, role/scope updates, resend/revoke, and confirmed removal. +- [ ] **Step 4: Invalidate list/invitation/collaborator queries** after all mutations so native state updates immediately. +- [ ] **Step 5: Preserve role-aware behavior**: viewers remain read-only, editors can modify bookmark membership, owner-only management remains hidden from collaborators. +- [ ] **Step 6: Run `pnpm --filter @karakeep/mobile typecheck` plus available focused mobile tests.** +- [ ] **Step 7: Commit** `feat(mobile): add stable list collaboration management`. + +### Task 5: Regression coverage, docs, accessibility, and GA cleanup + +**Files:** +- Modify: collaboration/user documentation discovered by repo search. +- Modify: `CONTEXT.md` only if implementation terminology differs from the approved model. +- Modify/add tests around offline/shared-list revocation behavior where existing seams exist. + +**Interfaces:** +- Stable collaboration docs describe roles, recursive scope, invitation expiry/resend, mobile parity, public-vs-collaborator access, and intentional limitations. + +- [ ] **Step 1: Add regression tests** for collaborator avatar privacy (accepted only), concurrent invitation terminal states, removal/leave contribution cleanup, and offline/shared-list disappearance after revocation on sync. +- [ ] **Step 2: Audit collaboration controls for labels, keyboard/touch usability, and responsive layouts** on web/mobile. +- [ ] **Step 3: Update user/contributor docs** with the stable contract and intentional non-goals. +- [ ] **Step 4: Run formatting/lint/typecheck/focused tests**: `pnpm format:fix`, `pnpm lint`, `pnpm typecheck`, TRPC collaboration tests, focused web tests, mobile typecheck. +- [ ] **Step 5: Run repository preflight/full CI-equivalent checks** required by `AGENTS.md` where practical. +- [ ] **Step 6: Review the final diff against issue #24** and verify every stable blocker is either implemented or explicitly documented as an intentional limitation. +- [ ] **Step 7: Commit** `docs: document stable list collaboration` and open the PR with `Closes #24`. diff --git a/docs/superpowers/specs/2026-08-15-stable-list-collaboration-design.md b/docs/superpowers/specs/2026-08-15-stable-list-collaboration-design.md new file mode 100644 index 000000000..09a46013c --- /dev/null +++ b/docs/superpowers/specs/2026-08-15-stable-list-collaboration-design.md @@ -0,0 +1,72 @@ +# Stable List Collaboration Design + +## Goal + +Graduate manual-list collaboration from Beta to a stable web-and-mobile feature with explicit invitation lifecycle, truthful email delivery, full owner management, and optional recursive access for nested lists. + +## Product contract + +1. Invitations target existing Marka users only. Email matching is normalized and failures must not reveal whether an account exists. +2. Pending invitations expire 30 days after `invitedAt`. +3. Declined invitations disappear from the normal owner management surface and the same user may be invited again later. +4. Owners can change viewer/editor role while an invitation is pending. +5. Native mobile has full collaboration parity: invitation discovery, accept/decline, shared-list use/leave, and owner collaborator management. +6. Invitation state commits before email is attempted. UI distinguishes invitation creation from email delivery. Owners can resend manually. A durable outbox is intentionally out of scope. +7. Resend renews `invitedAt`, giving the invitation a fresh 30-day lifetime. +8. The Beta badge is removed in the same change only after the stable contract, tests, and docs are present. +9. Public access and collaboration are independent. Public visitors are read-only even if the list also has collaborators. +10. Sharing is direct by default. `Also share all nested lists` is an opt-in control and defaults off. +11. When recursive sharing is enabled, current and future descendants inherit access. +12. Moving a descendant outside the recursively shared subtree removes inherited access. +13. Explicit descendant grants are supported. Effective access is resolved from the exact-list direct grant first, then the nearest ancestor recursive grant. + +## Recursive access model + +Do not materialize inherited memberships onto every descendant. Store `recursive` on direct collaborator memberships and invitations, then resolve effective access dynamically from the list ancestry. + +This preserves a single source of truth for each explicit grant, automatically covers future descendants, and avoids destructive fan-out when lists move. + +For a requested list: + +1. Owner access wins. +2. If the user has a direct collaborator grant for that exact list, use it. +3. Otherwise walk ancestors from nearest to farthest and use the first collaborator grant for that user with `recursive = true`. +4. Otherwise access is denied. + +A non-recursive direct grant affects only its exact list. A recursive direct grant affects that list plus descendants until a closer direct/recursive grant changes the effective result. + +## Invitations + +An invitation creates a future direct grant and stores both `role` and `recursive`. Accepted invitations become direct collaborator memberships with the same values. Declined invitations remain only as lifecycle history needed for reinvitation handling and are excluded from the normal owner list. Expired invitations are computed from `invitedAt`; no scheduled expiry job is required. + +Invitation deep links use the invitation ID rather than a list ID. The invitee inbox can therefore open the exact invitation without leaking inaccessible list hierarchy. + +## Email + +Email is attempted only after the database transaction succeeds. The backend returns enough delivery state for clients to distinguish `invitation created` from `email sent`. SMTP being absent or a send failure must not roll back the invitation. Resend is explicit and renews expiry. + +All user-controlled values inserted into HTML email must be escaped. Plain-text email remains available. + +## UI + +### Web + +- Remove the Beta badge. +- Manage Collaborators shows accepted and pending entries, roles, recursive scope, expiry, resend, revoke, and confirmed removal. +- `Also share all nested lists` defaults off and explains that current and future nested lists are included when enabled. +- Removing a collaborator explains that bookmark entries contributed through that direct membership disappear from the list while underlying bookmarks remain. +- Viewer-only users never see edit/remove list-membership actions. + +### Mobile + +- Lists surface pending invitations and accept/decline actions. +- Owners get Manage Collaborators from the list actions menu. +- The management screen supports invite, role/scope changes, resend/revoke, and confirmed collaborator removal. +- Collaborators retain Leave List. + +## Intentional non-goals + +- Ownership transfer. +- Comments, presence, activity feed, or real-time collaborative editing. +- Inviting unregistered email addresses. +- Durable email outbox/background retry infrastructure. From fa87b8270d041ae404e4bb0558b6fcdd61bb2a46 Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 16:57:50 +0700 Subject: [PATCH 02/87] test: cover recursive list collaboration permissions --- .../trpc/routers/recursiveSharedLists.test.ts | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 packages/trpc/routers/recursiveSharedLists.test.ts diff --git a/packages/trpc/routers/recursiveSharedLists.test.ts b/packages/trpc/routers/recursiveSharedLists.test.ts new file mode 100644 index 000000000..af8e4e9aa --- /dev/null +++ b/packages/trpc/routers/recursiveSharedLists.test.ts @@ -0,0 +1,315 @@ +import { beforeEach, describe, expect, test } from "vitest"; + +import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; + +import type { APICallerType, CustomTestContext } from "../testUtils"; +import { defaultBeforeEach } from "../testUtils"; + +beforeEach(defaultBeforeEach(true)); + +async function inviteAndAccept( + ownerApi: APICallerType, + collaboratorApi: APICallerType, + listId: string, + role: "viewer" | "editor", + recursive: boolean, +) { + const collaborator = await collaboratorApi.users.whoami(); + const { invitationId } = await ownerApi.lists.addCollaborator({ + listId, + email: collaborator.email!, + role, + recursive, + }); + await collaboratorApi.lists.acceptInvitation({ invitationId }); +} + +describe("recursive shared-list permissions", () => { + test("recursive grant exposes current descendants", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const parent = await ownerApi.lists.create({ + name: "Parent", + icon: "πŸ“", + type: "manual", + }); + const child = await ownerApi.lists.create({ + name: "Child", + icon: "πŸ“„", + type: "manual", + parentId: parent.id, + }); + + await inviteAndAccept( + ownerApi, + collaboratorApi, + parent.id, + "viewer", + true, + ); + + const inherited = await collaboratorApi.lists.get({ listId: child.id }); + expect(inherited.userRole).toBe("viewer"); + + const { lists } = await collaboratorApi.lists.list(); + expect(lists.map((list) => list.id)).toEqual( + expect.arrayContaining([parent.id, child.id]), + ); + }); + + test("recursive grant automatically exposes descendants created later", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const parent = await ownerApi.lists.create({ + name: "Parent", + icon: "πŸ“", + type: "manual", + }); + + await inviteAndAccept( + ownerApi, + collaboratorApi, + parent.id, + "editor", + true, + ); + + const futureChild = await ownerApi.lists.create({ + name: "Future child", + icon: "πŸ“„", + type: "manual", + parentId: parent.id, + }); + + const inherited = await collaboratorApi.lists.get({ + listId: futureChild.id, + }); + expect(inherited.userRole).toBe("editor"); + }); + + test("moving a list out of a recursive subtree revokes inherited access", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const parent = await ownerApi.lists.create({ + name: "Parent", + icon: "πŸ“", + type: "manual", + }); + const child = await ownerApi.lists.create({ + name: "Child", + icon: "πŸ“„", + type: "manual", + parentId: parent.id, + }); + + await inviteAndAccept( + ownerApi, + collaboratorApi, + parent.id, + "viewer", + true, + ); + expect( + await collaboratorApi.lists.get({ listId: child.id }), + ).toBeDefined(); + + await ownerApi.lists.edit({ + listId: child.id, + parentId: null, + }); + + await expect( + collaboratorApi.lists.get({ listId: child.id }), + ).rejects.toThrow("List not found"); + }); + + test("exact child grant overrides inherited role only for that list when not recursive", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const parent = await ownerApi.lists.create({ + name: "Parent", + icon: "πŸ“", + type: "manual", + }); + const child = await ownerApi.lists.create({ + name: "Child", + icon: "πŸ“„", + type: "manual", + parentId: parent.id, + }); + const grandchild = await ownerApi.lists.create({ + name: "Grandchild", + icon: "πŸ“„", + type: "manual", + parentId: child.id, + }); + + await inviteAndAccept( + ownerApi, + collaboratorApi, + parent.id, + "viewer", + true, + ); + await inviteAndAccept( + ownerApi, + collaboratorApi, + child.id, + "editor", + false, + ); + + expect( + (await collaboratorApi.lists.get({ listId: child.id })).userRole, + ).toBe("editor"); + expect( + (await collaboratorApi.lists.get({ listId: grandchild.id })).userRole, + ).toBe("viewer"); + }); + + test("nearest recursive ancestor grant wins for deeper descendants", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const parent = await ownerApi.lists.create({ + name: "Parent", + icon: "πŸ“", + type: "manual", + }); + const child = await ownerApi.lists.create({ + name: "Child", + icon: "πŸ“„", + type: "manual", + parentId: parent.id, + }); + const grandchild = await ownerApi.lists.create({ + name: "Grandchild", + icon: "πŸ“„", + type: "manual", + parentId: child.id, + }); + + await inviteAndAccept( + ownerApi, + collaboratorApi, + parent.id, + "viewer", + true, + ); + await inviteAndAccept( + ownerApi, + collaboratorApi, + child.id, + "editor", + true, + ); + + expect( + (await collaboratorApi.lists.get({ listId: grandchild.id })).userRole, + ).toBe("editor"); + }); + + test("inherited viewer remains read-only", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + + const parent = await ownerApi.lists.create({ + name: "Parent", + icon: "πŸ“", + type: "manual", + }); + const child = await ownerApi.lists.create({ + name: "Child", + icon: "πŸ“„", + type: "manual", + parentId: parent.id, + }); + const bookmark = await collaboratorApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Viewer bookmark", + }); + + await inviteAndAccept( + ownerApi, + collaboratorApi, + parent.id, + "viewer", + true, + ); + + await expect( + collaboratorApi.lists.addToList({ + listId: child.id, + bookmarkId: bookmark.id, + }), + ).rejects.toThrow("User is not allowed to edit this list"); + }); + + test("inherited editor contributions are tied to the granting recursive membership", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + const collaborator = await collaboratorApi.users.whoami(); + + const parent = await ownerApi.lists.create({ + name: "Parent", + icon: "πŸ“", + type: "manual", + }); + const child = await ownerApi.lists.create({ + name: "Child", + icon: "πŸ“„", + type: "manual", + parentId: parent.id, + }); + const bookmark = await collaboratorApi.bookmarks.createBookmark({ + type: BookmarkTypes.TEXT, + text: "Inherited editor bookmark", + }); + + await inviteAndAccept( + ownerApi, + collaboratorApi, + parent.id, + "editor", + true, + ); + await collaboratorApi.lists.addToList({ + listId: child.id, + bookmarkId: bookmark.id, + }); + + expect( + (await ownerApi.bookmarks.getBookmarks({ listId: child.id })).bookmarks, + ).toHaveLength(1); + + await ownerApi.lists.removeCollaborator({ + listId: parent.id, + userId: collaborator.id, + }); + + expect( + (await ownerApi.bookmarks.getBookmarks({ listId: child.id })).bookmarks, + ).toHaveLength(0); + expect( + await collaboratorApi.bookmarks.getBookmark({ bookmarkId: bookmark.id }), + ).toBeDefined(); + }); +}); From 978b88b1dbc3408abda4809ccc1cfdb18e6eafd5 Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 17:03:21 +0700 Subject: [PATCH 03/87] feat: store recursive collaboration scope --- packages/db/collaborationScopes.ts | 33 + packages/db/drizzle.ts | 5 +- .../0088_list_collaboration_scopes.sql | 10 + packages/db/drizzle/meta/_journal.json | 623 +----------------- packages/db/index.ts | 10 +- 5 files changed, 55 insertions(+), 626 deletions(-) create mode 100644 packages/db/collaborationScopes.ts create mode 100644 packages/db/drizzle/0088_list_collaboration_scopes.sql diff --git a/packages/db/collaborationScopes.ts b/packages/db/collaborationScopes.ts new file mode 100644 index 000000000..7221f74b4 --- /dev/null +++ b/packages/db/collaborationScopes.ts @@ -0,0 +1,33 @@ +import { + index, + integer, + primaryKey, + sqliteTable, + text, +} from "drizzle-orm/sqlite-core"; + +import { bookmarkLists, users } from "./schema"; + +/** + * Direct collaboration scope, shared by pending invitations and accepted + * memberships. Missing rows intentionally mean non-recursive for backwards + * compatibility with existing collaboration data. + */ +export const listCollaborationScopes = sqliteTable( + "listCollaborationScopes", + { + listId: text("listId") + .notNull() + .references(() => bookmarkLists.id, { onDelete: "cascade" }), + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + recursive: integer("recursive", { mode: "boolean" }) + .notNull() + .default(false), + }, + (scope) => [ + primaryKey({ columns: [scope.listId, scope.userId] }), + index("listCollaborationScopes_userId_idx").on(scope.userId), + ], +); diff --git a/packages/db/drizzle.ts b/packages/db/drizzle.ts index bb0ebfd99..618a43f3e 100644 --- a/packages/db/drizzle.ts +++ b/packages/db/drizzle.ts @@ -7,9 +7,12 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import serverConfig from "@karakeep/shared/config"; +import * as collaborationScopeSchema from "./collaborationScopes"; import dbConfig from "./drizzle.config"; import { instrumentDatabase } from "./instrumentation"; -import * as schema from "./schema"; +import * as baseSchema from "./schema"; + +const schema = { ...baseSchema, ...collaborationScopeSchema }; const sqlite = new Database(dbConfig.dbCredentials.url); diff --git a/packages/db/drizzle/0088_list_collaboration_scopes.sql b/packages/db/drizzle/0088_list_collaboration_scopes.sql new file mode 100644 index 000000000..04633685d --- /dev/null +++ b/packages/db/drizzle/0088_list_collaboration_scopes.sql @@ -0,0 +1,10 @@ +CREATE TABLE `listCollaborationScopes` ( + `listId` text NOT NULL, + `userId` text NOT NULL, + `recursive` integer DEFAULT false NOT NULL, + PRIMARY KEY(`listId`, `userId`), + FOREIGN KEY (`listId`) REFERENCES `bookmarkLists`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `listCollaborationScopes_userId_idx` ON `listCollaborationScopes` (`userId`); diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index f8d162110..8b283bbd6 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -1,622 +1 @@ -{ - "version": "5", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "5", - "when": 1708710681721, - "tag": "0000_luxuriant_johnny_blaze", - "breakpoints": true - }, - { - "idx": 1, - "version": "5", - "when": 1709144284383, - "tag": "0001_dapper_trauma", - "breakpoints": true - }, - { - "idx": 2, - "version": "5", - "when": 1709293861959, - "tag": "0002_worried_beyonder", - "breakpoints": true - }, - { - "idx": 3, - "version": "5", - "when": 1709331420929, - "tag": "0003_parallel_supernaut", - "breakpoints": true - }, - { - "idx": 4, - "version": "5", - "when": 1709339866036, - "tag": "0004_skinny_vengeance", - "breakpoints": true - }, - { - "idx": 5, - "version": "5", - "when": 1709341990430, - "tag": "0005_quiet_gunslinger", - "breakpoints": true - }, - { - "idx": 6, - "version": "5", - "when": 1709376352390, - "tag": "0006_funny_mac_gargan", - "breakpoints": true - }, - { - "idx": 7, - "version": "5", - "when": 1709599077219, - "tag": "0007_messy_raza", - "breakpoints": true - }, - { - "idx": 8, - "version": "5", - "when": 1710550864756, - "tag": "0008_cloudy_skin", - "breakpoints": true - }, - { - "idx": 9, - "version": "5", - "when": 1710681166089, - "tag": "0009_cuddly_cammi", - "breakpoints": true - }, - { - "idx": 10, - "version": "5", - "when": 1710770092205, - "tag": "0010_curved_sharon_ventura", - "breakpoints": true - }, - { - "idx": 11, - "version": "5", - "when": 1710778903315, - "tag": "0011_ordinary_phalanx", - "breakpoints": true - }, - { - "idx": 12, - "version": "5", - "when": 1710812490438, - "tag": "0012_noisy_grim_reaper", - "breakpoints": true - }, - { - "idx": 13, - "version": "5", - "when": 1710813047585, - "tag": "0013_square_lady_ursula", - "breakpoints": true - }, - { - "idx": 14, - "version": "5", - "when": 1711767601057, - "tag": "0014_lonely_thaddeus_ross", - "breakpoints": true - }, - { - "idx": 15, - "version": "5", - "when": 1712584035880, - "tag": "0015_first_reavers", - "breakpoints": true - }, - { - "idx": 16, - "version": "5", - "when": 1712610210210, - "tag": "0016_shallow_rawhide_kid", - "breakpoints": true - }, - { - "idx": 17, - "version": "5", - "when": 1712837113359, - "tag": "0017_slippery_senator_kelly", - "breakpoints": true - }, - { - "idx": 18, - "version": "5", - "when": 1713183014188, - "tag": "0018_bright_infant_terrible", - "breakpoints": true - }, - { - "idx": 19, - "version": "5", - "when": 1713432890859, - "tag": "0019_many_vertigo", - "breakpoints": true - }, - { - "idx": 20, - "version": "5", - "when": 1713539346326, - "tag": "0020_sudden_dagger", - "breakpoints": true - }, - { - "idx": 21, - "version": "5", - "when": 1716031428677, - "tag": "0021_magical_firebrand", - "breakpoints": true - }, - { - "idx": 22, - "version": "5", - "when": 1716679762529, - "tag": "0022_tough_nextwave", - "breakpoints": true - }, - { - "idx": 23, - "version": "5", - "when": 1717960986361, - "tag": "0023_late_night_nurse", - "breakpoints": true - }, - { - "idx": 24, - "version": "5", - "when": 1719135100480, - "tag": "0024_premium_hammerhead", - "breakpoints": true - }, - { - "idx": 25, - "version": "5", - "when": 1719251349398, - "tag": "0025_aspiring_skaar", - "breakpoints": true - }, - { - "idx": 26, - "version": "5", - "when": 1720334457344, - "tag": "0026_silky_imperial_guard", - "breakpoints": true - }, - { - "idx": 27, - "version": "6", - "when": 1727572281889, - "tag": "0027_cute_talon", - "breakpoints": true - }, - { - "idx": 28, - "version": "6", - "when": 1728149644203, - "tag": "0028_melodic_norrin_radd", - "breakpoints": true - }, - { - "idx": 29, - "version": "6", - "when": 1728214930701, - "tag": "0029_short_gunslinger", - "breakpoints": true - }, - { - "idx": 30, - "version": "6", - "when": 1728220453621, - "tag": "0030_blue_synch", - "breakpoints": true - }, - { - "idx": 31, - "version": "6", - "when": 1729980727614, - "tag": "0031_yummy_famine", - "breakpoints": true - }, - { - "idx": 32, - "version": "6", - "when": 1730653452808, - "tag": "0032_futuristic_shiva", - "breakpoints": true - }, - { - "idx": 33, - "version": "6", - "when": 1731106561236, - "tag": "0033_nappy_molten_man", - "breakpoints": true - }, - { - "idx": 34, - "version": "6", - "when": 1732990622928, - "tag": "0034_wet_the_stranger", - "breakpoints": true - }, - { - "idx": 35, - "version": "6", - "when": 1735291137509, - "tag": "0035_gorgeous_may_parker", - "breakpoints": true - }, - { - "idx": 36, - "version": "6", - "when": 1735308236125, - "tag": "0036_luxuriant_white_queen", - "breakpoints": true - }, - { - "idx": 37, - "version": "6", - "when": 1735750275339, - "tag": "0037_daily_smiling_tiger", - "breakpoints": true - }, - { - "idx": 38, - "version": "6", - "when": 1736695194056, - "tag": "0038_calm_clint_barton", - "breakpoints": true - }, - { - "idx": 39, - "version": "6", - "when": 1737293459640, - "tag": "0039_purple_albert_cleary", - "breakpoints": true - }, - { - "idx": 40, - "version": "6", - "when": 1737310389771, - "tag": "0040_long_mindworm", - "breakpoints": true - }, - { - "idx": 41, - "version": "6", - "when": 1738424745186, - "tag": "0041_fat_bloodstrike", - "breakpoints": true - }, - { - "idx": 42, - "version": "6", - "when": 1742655644239, - "tag": "0042_square_gamma_corps", - "breakpoints": true - }, - { - "idx": 43, - "version": "6", - "when": 1744157597541, - "tag": "0043_puzzling_blonde_phantom", - "breakpoints": true - }, - { - "idx": 44, - "version": "6", - "when": 1744744684677, - "tag": "0044_add_password_salt", - "breakpoints": true - }, - { - "idx": 45, - "version": "6", - "when": 1745705657846, - "tag": "0045_add_rule_engine", - "breakpoints": true - }, - { - "idx": 46, - "version": "6", - "when": 1746902541511, - "tag": "0046_add_rss_feed_enabled_col", - "breakpoints": true - }, - { - "idx": 47, - "version": "6", - "when": 1747598543992, - "tag": "0047_add_summarization_status", - "breakpoints": true - }, - { - "idx": 48, - "version": "6", - "when": 1748086734370, - "tag": "0048_add_user_settings", - "breakpoints": true - }, - { - "idx": 49, - "version": "6", - "when": 1748699971545, - "tag": "0049_add_rss_token", - "breakpoints": true - }, - { - "idx": 50, - "version": "6", - "when": 1748795265779, - "tag": "0050_add_user_settings_archive_display_behaviour", - "breakpoints": true - }, - { - "idx": 51, - "version": "6", - "when": 1748804695561, - "tag": "0051_public_lists", - "breakpoints": true - }, - { - "idx": 52, - "version": "6", - "when": 1751409503089, - "tag": "0052_add_bookmark_quota", - "breakpoints": true - }, - { - "idx": 53, - "version": "6", - "when": 1751816757805, - "tag": "0053_storage_quota", - "breakpoints": true - }, - { - "idx": 54, - "version": "6", - "when": 1751826417328, - "tag": "0054_add_timezone", - "breakpoints": true - }, - { - "idx": 55, - "version": "6", - "when": 1751839469055, - "tag": "0055_content_asset_id", - "breakpoints": true - }, - { - "idx": 56, - "version": "6", - "when": 1752180326709, - "tag": "0056_user_invites", - "breakpoints": true - }, - { - "idx": 57, - "version": "6", - "when": 1752314617600, - "tag": "0057_salty_carmella_unuscione", - "breakpoints": true - }, - { - "idx": 58, - "version": "6", - "when": 1752436258865, - "tag": "0058_add_subscription", - "breakpoints": true - }, - { - "idx": 59, - "version": "6", - "when": 1752922057728, - "tag": "0059_browserless_user_setting", - "breakpoints": true - }, - { - "idx": 60, - "version": "6", - "when": 1754187929331, - "tag": "0060_drop_invite_expire_at", - "breakpoints": true - }, - { - "idx": 61, - "version": "6", - "when": 1754236017965, - "tag": "0061_merge_user_settings", - "breakpoints": true - }, - { - "idx": 62, - "version": "6", - "when": 1759573697911, - "tag": "0062_add_import_session", - "breakpoints": true - }, - { - "idx": 63, - "version": "6", - "when": 1760302856618, - "tag": "0063_add_bookmark_source", - "breakpoints": true - }, - { - "idx": 64, - "version": "6", - "when": 1762115406895, - "tag": "0064_add_import_tags_to_feeds", - "breakpoints": true - }, - { - "idx": 65, - "version": "6", - "when": 1763335572156, - "tag": "0065_collaborative_lists", - "breakpoints": true - }, - { - "idx": 66, - "version": "6", - "when": 1763854050669, - "tag": "0066_collaborative_lists_invites", - "breakpoints": true - }, - { - "idx": 67, - "version": "6", - "when": 1764418020312, - "tag": "0067_add_backups_table", - "breakpoints": true - }, - { - "idx": 68, - "version": "6", - "when": 1765310170813, - "tag": "0068_optimize_bookmark_indicies", - "breakpoints": true - }, - { - "idx": 69, - "version": "6", - "when": 1765721715670, - "tag": "0069_fix_pending_summarization", - "breakpoints": true - }, - { - "idx": 70, - "version": "6", - "when": 1765744716304, - "tag": "0070_add_reader_settings", - "breakpoints": true - }, - { - "idx": 71, - "version": "6", - "when": 1766393060393, - "tag": "0071_add_normalized_tag_name", - "breakpoints": true - }, - { - "idx": 72, - "version": "6", - "when": 1766414953855, - "tag": "0072_add_user_ai_preferences", - "breakpoints": true - }, - { - "idx": 73, - "version": "6", - "when": 1766843938658, - "tag": "0073_ai_tag_style", - "breakpoints": true - }, - { - "idx": 74, - "version": "6", - "when": 1767006387391, - "tag": "0074_reset_tagging_summarization", - "breakpoints": true - }, - { - "idx": 75, - "version": "6", - "when": 1767052770526, - "tag": "0075_change_default_tag_style", - "breakpoints": true - }, - { - "idx": 76, - "version": "6", - "when": 1768691440519, - "tag": "0076_add_api_key_last_used_tracking", - "breakpoints": true - }, - { - "idx": 77, - "version": "6", - "when": 1770141423845, - "tag": "0077_import_listpaths_to_listids", - "breakpoints": true - }, - { - "idx": 78, - "version": "6", - "when": 1770142086939, - "tag": "0078_add_import_session_indexes", - "breakpoints": true - }, - { - "idx": 79, - "version": "6", - "when": 1770564384451, - "tag": "0079_add_tag_granularity_settings", - "breakpoints": true - }, - { - "idx": 80, - "version": "6", - "when": 1771481519588, - "tag": "0080_user_reading_progress", - "breakpoints": true - }, - { - "idx": 81, - "version": "6", - "when": 1775542878403, - "tag": "0081_add_archived_to_import_staging_bookmarks", - "breakpoints": true - }, - { - "idx": 82, - "version": "6", - "when": 1775816137345, - "tag": "0082_add_feed_last_successful_fetch", - "breakpoints": true - }, - { - "idx": 83, - "version": "6", - "when": 1776948580025, - "tag": "0083_add_api_key_scopes", - "breakpoints": true - }, - { - "idx": 84, - "version": "6", - "when": 1777464072035, - "tag": "0084_rule_engine_multi_list_support", - "breakpoints": true - }, - { - "idx": 85, - "version": "6", - "when": 1780154803212, - "tag": "0085_add_embedding_status", - "breakpoints": true - }, - { - "idx": 86, - "version": "6", - "when": 1783858442668, - "tag": "0086_add_offline_sync", - "breakpoints": true - }, - { - "idx": 87, - "version": "6", - "when": 1783963800251, - "tag": "0087_clear-disabled-embedding-status", - "breakpoints": true - } - ] -} \ No newline at end of file +{"version":"5","dialect":"sqlite","entries":[{"idx":0,"version":"5","when":1708710681721,"tag":"0000_luxuriant_johnny_blaze","breakpoints":true},{"idx":1,"version":"5","when":1709144284383,"tag":"0001_dapper_trauma","breakpoints":true},{"idx":2,"version":"5","when":1709293861959,"tag":"0002_worried_beyonder","breakpoints":true},{"idx":3,"version":"5","when":1709331420929,"tag":"0003_parallel_supernaut","breakpoints":true},{"idx":4,"version":"5","when":1709339866036,"tag":"0004_skinny_vengeance","breakpoints":true},{"idx":5,"version":"5","when":1709341990430,"tag":"0005_quiet_gunslinger","breakpoints":true},{"idx":6,"version":"5","when":1709376352390,"tag":"0006_funny_mac_gargan","breakpoints":true},{"idx":7,"version":"5","when":1709599077219,"tag":"0007_messy_raza","breakpoints":true},{"idx":8,"version":"5","when":1710550864756,"tag":"0008_cloudy_skin","breakpoints":true},{"idx":9,"version":"5","when":1710681166089,"tag":"0009_cuddly_cammi","breakpoints":true},{"idx":10,"version":"5","when":1710770092205,"tag":"0010_curved_sharon_ventura","breakpoints":true},{"idx":11,"version":"5","when":1710778903315,"tag":"0011_ordinary_phalanx","breakpoints":true},{"idx":12,"version":"5","when":1710812490438,"tag":"0012_noisy_grim_reaper","breakpoints":true},{"idx":13,"version":"5","when":1710813047585,"tag":"0013_square_lady_ursula","breakpoints":true},{"idx":14,"version":"5","when":1711767601057,"tag":"0014_lonely_thaddeus_ross","breakpoints":true},{"idx":15,"version":"5","when":1712584035880,"tag":"0015_first_reavers","breakpoints":true},{"idx":16,"version":"5","when":1712610210210,"tag":"0016_shallow_rawhide_kid","breakpoints":true},{"idx":17,"version":"5","when":1712837113359,"tag":"0017_slippery_senator_kelly","breakpoints":true},{"idx":18,"version":"5","when":1713183014188,"tag":"0018_bright_infant_terrible","breakpoints":true},{"idx":19,"version":"5","when":1713432890859,"tag":"0019_many_vertigo","breakpoints":true},{"idx":20,"version":"5","when":1713539346326,"tag":"0020_sudden_dagger","breakpoints":true},{"idx":21,"version":"5","when":1716031428677,"tag":"0021_magical_firebrand","breakpoints":true},{"idx":22,"version":"5","when":1716679762529,"tag":"0022_tough_nextwave","breakpoints":true},{"idx":23,"version":"5","when":1717960986361,"tag":"0023_late_night_nurse","breakpoints":true},{"idx":24,"version":"5","when":1719135100480,"tag":"0024_premium_hammerhead","breakpoints":true},{"idx":25,"version":"5","when":1719251349398,"tag":"0025_aspiring_skaar","breakpoints":true},{"idx":26,"version":"5","when":1720334457344,"tag":"0026_silky_imperial_guard","breakpoints":true},{"idx":27,"version":"6","when":1727572281889,"tag":"0027_cute_talon","breakpoints":true},{"idx":28,"version":"6","when":1728149644203,"tag":"0028_melodic_norrin_radd","breakpoints":true},{"idx":29,"version":"6","when":1728214930701,"tag":"0029_short_gunslinger","breakpoints":true},{"idx":30,"version":"6","when":1728220453621,"tag":"0030_blue_synch","breakpoints":true},{"idx":31,"version":"6","when":1729980727614,"tag":"0031_yummy_famine","breakpoints":true},{"idx":32,"version":"6","when":1730653452808,"tag":"0032_futuristic_shiva","breakpoints":true},{"idx":33,"version":"6","when":1731106561236,"tag":"0033_nappy_molten_man","breakpoints":true},{"idx":34,"version":"6","when":1732990622928,"tag":"0034_wet_the_stranger","breakpoints":true},{"idx":35,"version":"6","when":1735291137509,"tag":"0035_gorgeous_may_parker","breakpoints":true},{"idx":36,"version":"6","when":1735308236125,"tag":"0036_luxuriant_white_queen","breakpoints":true},{"idx":37,"version":"6","when":1735750275339,"tag":"0037_daily_smiling_tiger","breakpoints":true},{"idx":38,"version":"6","when":1736695194056,"tag":"0038_calm_clint_barton","breakpoints":true},{"idx":39,"version":"6","when":1737293459640,"tag":"0039_purple_albert_cleary","breakpoints":true},{"idx":40,"version":"6","when":1737310389771,"tag":"0040_long_mindworm","breakpoints":true},{"idx":41,"version":"6","when":1738424745186,"tag":"0041_fat_bloodstrike","breakpoints":true},{"idx":42,"version":"6","when":1742655644239,"tag":"0042_square_gamma_corps","breakpoints":true},{"idx":43,"version":"6","when":1744157597541,"tag":"0043_puzzling_blonde_phantom","breakpoints":true},{"idx":44,"version":"6","when":1744744684677,"tag":"0044_add_password_salt","breakpoints":true},{"idx":45,"version":"6","when":1745705657846,"tag":"0045_add_rule_engine","breakpoints":true},{"idx":46,"version":"6","when":1746902541511,"tag":"0046_add_rss_feed_enabled_col","breakpoints":true},{"idx":47,"version":"6","when":1747598543992,"tag":"0047_add_summarization_status","breakpoints":true},{"idx":48,"version":"6","when":1748086734370,"tag":"0048_add_user_settings","breakpoints":true},{"idx":49,"version":"6","when":1748699971545,"tag":"0049_add_rss_token","breakpoints":true},{"idx":50,"version":"6","when":1748795265779,"tag":"0050_add_user_settings_archive_display_behaviour","breakpoints":true},{"idx":51,"version":"6","when":1748804695561,"tag":"0051_public_lists","breakpoints":true},{"idx":52,"version":"6","when":1751409503089,"tag":"0052_add_bookmark_quota","breakpoints":true},{"idx":53,"version":"6","when":1751816757805,"tag":"0053_storage_quota","breakpoints":true},{"idx":54,"version":"6","when":1751826417328,"tag":"0054_add_timezone","breakpoints":true},{"idx":55,"version":"6","when":1751839469055,"tag":"0055_content_asset_id","breakpoints":true},{"idx":56,"version":"6","when":1752180326709,"tag":"0056_user_invites","breakpoints":true},{"idx":57,"version":"6","when":1752314617600,"tag":"0057_salty_carmella_unuscione","breakpoints":true},{"idx":58,"version":"6","when":1752436258865,"tag":"0058_add_subscription","breakpoints":true},{"idx":59,"version":"6","when":1752922057728,"tag":"0059_browserless_user_setting","breakpoints":true},{"idx":60,"version":"6","when":1754187929331,"tag":"0060_drop_invite_expire_at","breakpoints":true},{"idx":61,"version":"6","when":1754236017965,"tag":"0061_merge_user_settings","breakpoints":true},{"idx":62,"version":"6","when":1759573697911,"tag":"0062_add_import_session","breakpoints":true},{"idx":63,"version":"6","when":1760302856618,"tag":"0063_add_bookmark_source","breakpoints":true},{"idx":64,"version":"6","when":1762115406895,"tag":"0064_add_import_tags_to_feeds","breakpoints":true},{"idx":65,"version":"6","when":1763335572156,"tag":"0065_collaborative_lists","breakpoints":true},{"idx":66,"version":"6","when":1763854050669,"tag":"0066_collaborative_lists_invites","breakpoints":true},{"idx":67,"version":"6","when":1764418020312,"tag":"0067_add_backups_table","breakpoints":true},{"idx":68,"version":"6","when":1765310170813,"tag":"0068_optimize_bookmark_indicies","breakpoints":true},{"idx":69,"version":"6","when":1765721715670,"tag":"0069_fix_pending_summarization","breakpoints":true},{"idx":70,"version":"6","when":1765744716304,"tag":"0070_add_reader_settings","breakpoints":true},{"idx":71,"version":"6","when":1766393060393,"tag":"0071_add_normalized_tag_name","breakpoints":true},{"idx":72,"version":"6","when":1766414953855,"tag":"0072_add_user_ai_preferences","breakpoints":true},{"idx":73,"version":"6","when":1766843938658,"tag":"0073_ai_tag_style","breakpoints":true},{"idx":74,"version":"6","when":1767006387391,"tag":"0074_reset_tagging_summarization","breakpoints":true},{"idx":75,"version":"6","when":1767052770526,"tag":"0075_change_default_tag_style","breakpoints":true},{"idx":76,"version":"6","when":1768691440519,"tag":"0076_add_api_key_last_used_tracking","breakpoints":true},{"idx":77,"version":"6","when":1770141423845,"tag":"0077_import_listpaths_to_listids","breakpoints":true},{"idx":78,"version":"6","when":1770142086939,"tag":"0078_add_import_session_indexes","breakpoints":true},{"idx":79,"version":"6","when":1770564384451,"tag":"0079_add_tag_granularity_settings","breakpoints":true},{"idx":80,"version":"6","when":1771481519588,"tag":"0080_user_reading_progress","breakpoints":true},{"idx":81,"version":"6","when":1775542878403,"tag":"0081_add_archived_to_import_staging_bookmarks","breakpoints":true},{"idx":82,"version":"6","when":1775816137345,"tag":"0082_add_feed_last_successful_fetch","breakpoints":true},{"idx":83,"version":"6","when":1776948580025,"tag":"0083_add_api_key_scopes","breakpoints":true},{"idx":84,"version":"6","when":1777464072035,"tag":"0084_rule_engine_multi_list_support","breakpoints":true},{"idx":85,"version":"6","when":1780154803212,"tag":"0085_add_embedding_status","breakpoints":true},{"idx":86,"version":"6","when":1783858442668,"tag":"0086_add_offline_sync","breakpoints":true},{"idx":87,"version":"6","when":1783963800251,"tag":"0087_clear-disabled-embedding-status","breakpoints":true},{"idx":88,"version":"6","when":1786788000000,"tag":"0088_list_collaboration_scopes","breakpoints":true}]} \ No newline at end of file diff --git a/packages/db/index.ts b/packages/db/index.ts index 0a72c9bba..e00364cec 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -2,8 +2,12 @@ import Database from "better-sqlite3"; import { ExtractTablesWithRelations } from "drizzle-orm"; import { SQLiteTransaction } from "drizzle-orm/sqlite-core"; -import * as schema from "./schema"; +import * as collaborationScopeSchema from "./collaborationScopes"; +import * as baseSchema from "./schema"; +const transactionSchema = { ...baseSchema, ...collaborationScopeSchema }; + +export { listCollaborationScopes } from "./collaborationScopes"; export { db } from "./drizzle"; export type { DB } from "./drizzle"; export * as schema from "./schema"; @@ -13,6 +17,6 @@ export { SqliteError } from "better-sqlite3"; export type KarakeepDBTransaction = SQLiteTransaction< "sync", Database.RunResult, - typeof schema, - ExtractTablesWithRelations + typeof transactionSchema, + ExtractTablesWithRelations >; From a45fb5cd01dfff4c2b9ce2ad790f48d4680ff2e9 Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 17:10:40 +0700 Subject: [PATCH 04/87] feat: add recursive list collaboration permissions --- packages/trpc/email.ts | 82 ++-- .../trpc/models/listCollaborationAccess.ts | 340 ++++++++++++++++ packages/trpc/models/listInvitations.ts | 384 +++++++++++------- packages/trpc/models/lists.ts | 255 ++++++------ packages/trpc/routers/lists.ts | 137 ++++++- 5 files changed, 899 insertions(+), 299 deletions(-) create mode 100644 packages/trpc/models/listCollaborationAccess.ts diff --git a/packages/trpc/email.ts b/packages/trpc/email.ts index 15e1ef745..26402c290 100644 --- a/packages/trpc/email.ts +++ b/packages/trpc/email.ts @@ -1,7 +1,7 @@ import { createTransport } from "nodemailer"; -import { getTracer, withSpan } from "@karakeep/shared-server"; import serverConfig from "@karakeep/shared/config"; +import { getTracer, withSpan } from "@karakeep/shared-server"; const tracer = getTracer("@karakeep/trpc"); @@ -48,6 +48,19 @@ function withTracing( }; } +function escapeHtml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function sanitizeHeaderValue(value: string) { + return value.replace(/[\r\n]+/g, " ").trim(); +} + export const sendVerificationEmail = withTracing( "sendVerificationEmail", async ( @@ -191,25 +204,38 @@ If you didn't request a password reset, please ignore this email. Your password }, ); -export const sendListInvitationEmail = withTracing( - "sendListInvitationEmail", - async ( - transporter: Transporter, - email: string, - inviterName: string, - listName: string, - listId: string, - ) => { - const inviteUrl = `${serverConfig.publicUrl}/dashboard/lists?pendingInvitation=${encodeURIComponent(listId)}`; +/** + * Send a committed list invitation. Missing SMTP or a delivery failure returns + * false so the caller can say "invitation created, email not sent" instead of + * pretending delivery succeeded. + */ +export async function sendListInvitationEmail( + email: string, + inviterName: string, + listName: string, + invitationId: string, +): Promise { + if (!serverConfig.email.smtp) { + return false; + } - const mailOptions = { - from: serverConfig.email.smtp!.from, - to: email, - subject: `${inviterName} invited you to collaborate on "${listName}"`, - html: ` + const inviteUrl = `${serverConfig.publicUrl}/dashboard/lists?pendingInvitation=${encodeURIComponent(invitationId)}`; + const safeInviterName = sanitizeHeaderValue(inviterName); + const safeListName = sanitizeHeaderValue(listName); + const htmlInviterName = escapeHtml(safeInviterName); + const htmlListName = escapeHtml(safeListName); + + try { + const transporter = buildTransporter(); + await withSpan(tracer, "sendListInvitationEmail", {}, async () => { + await transporter.sendMail({ + from: serverConfig.email.smtp!.from, + to: email, + subject: `${safeInviterName} invited you to collaborate on "${safeListName}"`, + html: `

You've been invited to collaborate on a list!

-

${inviterName} has invited you to collaborate on the list "${listName}" in Karakeep.

+

${htmlInviterName} has invited you to collaborate on the list "${htmlListName}" in Marka.

Click the link below to view and accept or decline the invitation:

@@ -218,25 +244,27 @@ export const sendListInvitationEmail = withTracing(

If the button doesn't work, you can copy and paste this link into your browser:

${inviteUrl}

-

You can accept or decline this invitation from your Karakeep dashboard.

+

This invitation expires after 30 days. You can accept or decline it from your Marka dashboard.

If you weren't expecting this invitation, you can safely ignore this email or decline it in your dashboard.

`, - text: ` + text: ` You've been invited to collaborate on a list! -${inviterName} has invited you to collaborate on the list "${listName}" in Karakeep. +${safeInviterName} has invited you to collaborate on the list "${safeListName}" in Marka. View your invitation by visiting this link: ${inviteUrl} -You can accept or decline this invitation from your Karakeep dashboard. +This invitation expires after 30 days. You can accept or decline it from your Marka dashboard. If you weren't expecting this invitation, you can safely ignore this email or decline it in your dashboard. `, - }; - - await transporter.sendMail(mailOptions); - }, - { silentFail: true }, -); + }); + }); + return true; + } catch (error) { + console.error("Failed to send list invitation email:", error); + return false; + } +} diff --git a/packages/trpc/models/listCollaborationAccess.ts b/packages/trpc/models/listCollaborationAccess.ts new file mode 100644 index 000000000..4c2186923 --- /dev/null +++ b/packages/trpc/models/listCollaborationAccess.ts @@ -0,0 +1,340 @@ +import { and, eq, inArray } from "drizzle-orm"; + +import { listCollaborationScopes } from "@karakeep/db"; +import { bookmarkLists, listCollaborators } from "@karakeep/db/schema"; + +import type { AuthedContext } from ".."; + +export type CollaborationRole = "viewer" | "editor"; + +export interface EffectiveCollaboratorGrant { + membershipId: string; + userId: string; + role: CollaborationRole; + recursive: boolean; + inherited: boolean; + sourceListId: string; + sourceListName: string; +} + +interface AccessibleListData { + id: string; + name: string; + description: string | null; + icon: string; + userId: string; + parentId: string | null; + type: "manual" | "smart"; + query: string | null; + public: boolean; +} + +async function getScope( + ctx: AuthedContext, + listId: string, + userId: string, +): Promise { + const scope = await ctx.db.query.listCollaborationScopes.findFirst({ + where: and( + eq(listCollaborationScopes.listId, listId), + eq(listCollaborationScopes.userId, userId), + ), + }); + return scope?.recursive ?? false; +} + +export async function setCollaborationScope( + ctx: AuthedContext, + input: { listId: string; userId: string; recursive: boolean }, +) { + await ctx.db + .insert(listCollaborationScopes) + .values(input) + .onConflictDoUpdate({ + target: [listCollaborationScopes.listId, listCollaborationScopes.userId], + set: { recursive: input.recursive }, + }); +} + +export async function deleteCollaborationScope( + ctx: AuthedContext, + input: { listId: string; userId: string }, +) { + await ctx.db + .delete(listCollaborationScopes) + .where( + and( + eq(listCollaborationScopes.listId, input.listId), + eq(listCollaborationScopes.userId, input.userId), + ), + ); +} + +export async function getDirectCollaborationScope( + ctx: AuthedContext, + input: { listId: string; userId: string }, +) { + return getScope(ctx, input.listId, input.userId); +} + +export async function getEffectiveCollaboratorGrant( + ctx: AuthedContext, + list: AccessibleListData, + userId = ctx.user.id, +): Promise { + if (list.type !== "manual") { + return null; + } + + const direct = await ctx.db.query.listCollaborators.findFirst({ + where: and( + eq(listCollaborators.listId, list.id), + eq(listCollaborators.userId, userId), + ), + }); + if (direct) { + return { + membershipId: direct.id, + userId, + role: direct.role, + recursive: await getScope(ctx, list.id, userId), + inherited: false, + sourceListId: list.id, + sourceListName: list.name, + }; + } + + let parentId = list.parentId; + const visited = new Set(); + while (parentId && !visited.has(parentId)) { + visited.add(parentId); + const ancestor = await ctx.db.query.bookmarkLists.findFirst({ + columns: { + id: true, + name: true, + userId: true, + parentId: true, + type: true, + }, + where: eq(bookmarkLists.id, parentId), + }); + if (!ancestor || ancestor.userId !== list.userId) { + break; + } + + const membership = await ctx.db.query.listCollaborators.findFirst({ + where: and( + eq(listCollaborators.listId, ancestor.id), + eq(listCollaborators.userId, userId), + ), + }); + if ( + membership && + ancestor.type === "manual" && + (await getScope(ctx, ancestor.id, userId)) + ) { + return { + membershipId: membership.id, + userId, + role: membership.role, + recursive: true, + inherited: true, + sourceListId: ancestor.id, + sourceListName: ancestor.name, + }; + } + parentId = ancestor.parentId; + } + + return null; +} + +export async function getAllSharedListAccess(ctx: AuthedContext) { + const directMemberships = await ctx.db.query.listCollaborators.findMany({ + where: eq(listCollaborators.userId, ctx.user.id), + with: { + list: { + columns: { + rssToken: false, + }, + }, + }, + }); + if (directMemberships.length === 0) { + return []; + } + + const ownerIds = [...new Set(directMemberships.map((m) => m.list.userId))]; + const allOwnerLists = await ctx.db.query.bookmarkLists.findMany({ + columns: { + rssToken: false, + }, + where: inArray(bookmarkLists.userId, ownerIds), + }); + const scopes = await ctx.db.query.listCollaborationScopes.findMany({ + where: eq(listCollaborationScopes.userId, ctx.user.id), + }); + + const membershipByList = new Map( + directMemberships.map((membership) => [membership.listId, membership]), + ); + const scopeByList = new Map(scopes.map((scope) => [scope.listId, scope])); + const listById = new Map(allOwnerLists.map((list) => [list.id, list])); + + return allOwnerLists.flatMap((list) => { + if (list.type !== "manual") { + return []; + } + + const direct = membershipByList.get(list.id); + if (direct) { + return [ + { + list, + grant: { + membershipId: direct.id, + userId: ctx.user.id, + role: direct.role, + recursive: scopeByList.get(list.id)?.recursive ?? false, + inherited: false, + sourceListId: list.id, + sourceListName: list.name, + } satisfies EffectiveCollaboratorGrant, + }, + ]; + } + + let parentId = list.parentId; + const visited = new Set(); + while (parentId && !visited.has(parentId)) { + visited.add(parentId); + const ancestor = listById.get(parentId); + if (!ancestor || ancestor.userId !== list.userId) { + break; + } + const ancestorMembership = membershipByList.get(ancestor.id); + if ( + ancestorMembership && + ancestor.type === "manual" && + scopeByList.get(ancestor.id)?.recursive + ) { + return [ + { + list, + grant: { + membershipId: ancestorMembership.id, + userId: ctx.user.id, + role: ancestorMembership.role, + recursive: true, + inherited: true, + sourceListId: ancestor.id, + sourceListName: ancestor.name, + } satisfies EffectiveCollaboratorGrant, + }, + ]; + } + parentId = ancestor.parentId; + } + return []; + }); +} + +export async function getEffectiveCollaboratorsForList( + ctx: AuthedContext, + list: AccessibleListData, +) { + if (list.type !== "manual") { + return []; + } + + const ancestry = [list]; + let parentId = list.parentId; + const visited = new Set(); + while (parentId && !visited.has(parentId)) { + visited.add(parentId); + const ancestor = await ctx.db.query.bookmarkLists.findFirst({ + columns: { + id: true, + name: true, + description: true, + icon: true, + userId: true, + parentId: true, + type: true, + query: true, + public: true, + }, + where: eq(bookmarkLists.id, parentId), + }); + if (!ancestor || ancestor.userId !== list.userId) { + break; + } + ancestry.push(ancestor); + parentId = ancestor.parentId; + } + + const ancestryIds = ancestry.map((entry) => entry.id); + const memberships = await ctx.db.query.listCollaborators.findMany({ + where: inArray(listCollaborators.listId, ancestryIds), + with: { + user: { + columns: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + }); + if (memberships.length === 0) { + return []; + } + + const userIds = [...new Set(memberships.map((membership) => membership.userId))]; + const scopes = await ctx.db.query.listCollaborationScopes.findMany({ + where: and( + inArray(listCollaborationScopes.listId, ancestryIds), + inArray(listCollaborationScopes.userId, userIds), + ), + }); + const scopeByKey = new Map( + scopes.map((scope) => [`${scope.listId}:${scope.userId}`, scope.recursive]), + ); + const membershipsByList = new Map(); + for (const membership of memberships) { + const entries = membershipsByList.get(membership.listId) ?? []; + entries.push(membership); + membershipsByList.set(membership.listId, entries); + } + + const resolved = new Map< + string, + (typeof memberships)[number] & { + recursive: boolean; + inherited: boolean; + sourceListId: string; + sourceListName: string; + } + >(); + for (const [depth, ancestor] of ancestry.entries()) { + for (const membership of membershipsByList.get(ancestor.id) ?? []) { + if (resolved.has(membership.userId)) { + continue; + } + const recursive = + scopeByKey.get(`${ancestor.id}:${membership.userId}`) ?? false; + if (depth === 0 || recursive) { + resolved.set(membership.userId, { + ...membership, + recursive, + inherited: depth > 0, + sourceListId: ancestor.id, + sourceListName: ancestor.name, + }); + } + } + } + + return [...resolved.values()]; +} diff --git a/packages/trpc/models/listInvitations.ts b/packages/trpc/models/listInvitations.ts index 2e17fa2e7..8cf0bab56 100644 --- a/packages/trpc/models/listInvitations.ts +++ b/packages/trpc/models/listInvitations.ts @@ -1,18 +1,31 @@ import { TRPCError } from "@trpc/server"; -import { and, eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; -import { listCollaborators, listInvitations } from "@karakeep/db/schema"; +import { + listCollaborators, + listInvitations, + users, +} from "@karakeep/db/schema"; import type { AuthedContext } from ".."; +import { + deleteCollaborationScope, + getDirectCollaborationScope, + setCollaborationScope, +} from "./listCollaborationAccess"; type Role = "viewer" | "editor"; type InvitationStatus = "pending" | "declined"; +export const LIST_INVITATION_TTL_MS = 30 * 24 * 60 * 60 * 1000; + interface InvitationData { id: string; listId: string; + listName: string; userId: string; role: Role; + recursive: boolean; status: InvitationStatus; invitedAt: Date; invitedEmail: string | null; @@ -20,6 +33,14 @@ interface InvitationData { listOwnerUserId: string; } +function invitationExpiresAt(invitedAt: Date) { + return new Date(invitedAt.getTime() + LIST_INVITATION_TTL_MS); +} + +function invitationIsExpired(invitedAt: Date) { + return invitationExpiresAt(invitedAt).getTime() <= Date.now(); +} + export class ListInvitation { protected constructor( protected ctx: AuthedContext, @@ -30,11 +51,21 @@ export class ListInvitation { return this.invitation.id; } + get recursive() { + return this.invitation.recursive; + } + + get expiresAt() { + return invitationExpiresAt(this.invitation.invitedAt); + } + + get expired() { + return invitationIsExpired(this.invitation.invitedAt); + } + /** - * Load an invitation by ID - * Can be accessed by: - * - The invited user (userId matches) - * - The list owner (via list ownership check) + * Load an invitation by ID. Unauthorized callers intentionally receive + * NOT_FOUND so invitation IDs do not become an account/list oracle. */ static async fromId( ctx: AuthedContext, @@ -46,6 +77,7 @@ export class ListInvitation { list: { columns: { userId: true, + name: true, }, }, }, @@ -58,7 +90,6 @@ export class ListInvitation { }); } - // Check if user has access to this invitation const isInvitedUser = invitation.userId === ctx.user.id; const isListOwner = invitation.list.userId === ctx.user.id; @@ -72,8 +103,13 @@ export class ListInvitation { return new ListInvitation(ctx, { id: invitation.id, listId: invitation.listId, + listName: invitation.list.name, userId: invitation.userId, role: invitation.role, + recursive: await getDirectCollaborationScope(ctx, { + listId: invitation.listId, + userId: invitation.userId, + }), status: invitation.status, invitedAt: invitation.invitedAt, invitedEmail: invitation.invitedEmail, @@ -82,9 +118,6 @@ export class ListInvitation { }); } - /** - * Ensure the current user is the invited user - */ ensureIsInvitedUser() { if (this.invitation.userId !== this.ctx.user.id) { throw new TRPCError({ @@ -94,9 +127,6 @@ export class ListInvitation { } } - /** - * Ensure the current user is the list owner - */ ensureIsListOwner() { if (this.invitation.listOwnerUserId !== this.ctx.user.id) { throw new TRPCError({ @@ -106,18 +136,28 @@ export class ListInvitation { } } - /** - * Accept the invitation - */ - async accept(): Promise { - this.ensureIsInvitedUser(); - + private ensurePending() { if (this.invitation.status !== "pending") { throw new TRPCError({ code: "BAD_REQUEST", - message: "Only pending invitations can be accepted", + message: "Only pending invitations can be changed", }); } + } + + private ensureActive() { + if (this.expired) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invitation has expired", + }); + } + } + + async accept(): Promise { + this.ensureIsInvitedUser(); + this.ensurePending(); + this.ensureActive(); await this.ctx.db.transaction(async (tx) => { await tx @@ -133,49 +173,107 @@ export class ListInvitation { addedBy: this.invitation.invitedBy, }) .onConflictDoNothing(); + // The scope row deliberately survives invitation -> membership so the + // accepted direct grant keeps the invitation's recursive setting. }); } - /** - * Decline the invitation - */ async decline(): Promise { this.ensureIsInvitedUser(); - - if (this.invitation.status !== "pending") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Only pending invitations can be declined", - }); - } + this.ensurePending(); + this.ensureActive(); await this.ctx.db .update(listInvitations) - .set({ - status: "declined", - }) + .set({ status: "declined" }) .where(eq(listInvitations.id, this.invitation.id)); } - /** - * Revoke the invitation (owner only) - */ async revoke(): Promise { this.ensureIsListOwner(); await this.ctx.db .delete(listInvitations) .where(eq(listInvitations.id, this.invitation.id)); + await deleteCollaborationScope(this.ctx, { + listId: this.invitation.listId, + userId: this.invitation.userId, + }); + } + + async update(params: { role: Role; recursive: boolean }): Promise { + this.ensureIsListOwner(); + this.ensurePending(); + this.ensureActive(); + + await this.ctx.db + .update(listInvitations) + .set({ role: params.role }) + .where(eq(listInvitations.id, this.invitation.id)); + await setCollaborationScope(this.ctx, { + listId: this.invitation.listId, + userId: this.invitation.userId, + recursive: params.recursive, + }); + this.invitation.role = params.role; + this.invitation.recursive = params.recursive; + } + + /** Renew the invitation for another 30 days, then attempt delivery. */ + async resend(): Promise { + this.ensureIsListOwner(); + this.ensurePending(); + + const invitedAt = new Date(); + await this.ctx.db + .update(listInvitations) + .set({ invitedAt }) + .where(eq(listInvitations.id, this.invitation.id)); + this.invitation.invitedAt = invitedAt; + return this.sendEmail(); + } + + /** + * Attempt delivery for an already-committed invitation. SMTP failure never + * changes invitation state; callers can report the delivery result truthfully. + */ + async sendEmail(): Promise { + if (!this.invitation.invitedEmail) { + return false; + } + + const inviter = this.invitation.invitedBy + ? await this.ctx.db.query.users.findFirst({ + where: eq(users.id, this.invitation.invitedBy), + columns: { name: true }, + }) + : null; + + try { + const { sendListInvitationEmail } = await import("../email"); + return await sendListInvitationEmail( + this.invitation.invitedEmail, + inviter?.name || "A user", + this.invitation.listName, + this.invitation.id, + ); + } catch (error) { + console.error("Failed to send list invitation email:", error); + return false; + } } /** - * @returns the invitation ID + * Create or reactivate an invitation. This mutates database state only; + * email must be attempted by the caller after the surrounding transaction + * has committed. */ static async inviteByEmail( ctx: AuthedContext, params: { email: string; role: Role; + recursive: boolean; listId: string; listName: string; listType: "manual" | "smart"; @@ -187,22 +285,31 @@ export class ListInvitation { const { email, role, + recursive, listId, - listName, listType, listOwnerId, inviterUserId, - inviterName, } = params; + const normalizedEmail = email.trim().toLowerCase(); + + if (listType !== "manual") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Only manual lists can have collaborators", + }); + } const user = await ctx.db.query.users.findFirst({ - where: (users, { eq }) => eq(users.email, email), + where: sql`lower(${users.email}) = ${normalizedEmail}`, }); + // Keep unknown-address failures neutral to avoid confirming whether an + // arbitrary email address has a Marka account. if (!user) { throw new TRPCError({ - code: "NOT_FOUND", - message: "No user found with that email address", + code: "BAD_REQUEST", + message: "Unable to create an invitation for that email address", }); } @@ -213,13 +320,6 @@ export class ListInvitation { }); } - if (listType !== "manual") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Only manual lists can have collaborators", - }); - } - const existingCollaborator = await ctx.db.query.listCollaborators.findFirst( { where: and( @@ -243,32 +343,31 @@ export class ListInvitation { ), }); - if (existingInvitation) { - if (existingInvitation.status === "pending") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "User already has a pending invitation for this list", - }); - } else if (existingInvitation.status === "declined") { - await ctx.db - .update(listInvitations) - .set({ - status: "pending", - role, - invitedAt: new Date(), - invitedEmail: email, - invitedBy: inviterUserId, - }) - .where(eq(listInvitations.id, existingInvitation.id)); - - await this.sendInvitationEmail({ - email, - inviterName, - listName, - listId, - }); - return existingInvitation.id; - } + if (existingInvitation?.status === "pending") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "User already has a pending invitation for this list", + }); + } + + const invitedAt = new Date(); + if (existingInvitation?.status === "declined") { + await ctx.db + .update(listInvitations) + .set({ + status: "pending", + role, + invitedAt, + invitedEmail: normalizedEmail, + invitedBy: inviterUserId, + }) + .where(eq(listInvitations.id, existingInvitation.id)); + await setCollaborationScope(ctx, { + listId, + userId: user.id, + recursive, + }); + return existingInvitation.id; } const res = await ctx.db @@ -278,16 +377,15 @@ export class ListInvitation { userId: user.id, role, status: "pending", - invitedEmail: email, + invitedAt, + invitedEmail: normalizedEmail, invitedBy: inviterUserId, }) .returning(); - - await this.sendInvitationEmail({ - email, - inviterName, - listName, + await setCollaborationScope(ctx, { listId, + userId: user.id, + recursive, }); return res[0].id; } @@ -320,33 +418,49 @@ export class ListInvitation { }, }); - return invitations.map((inv) => ({ - id: inv.id, - listId: inv.listId, - role: inv.role, - invitedAt: inv.invitedAt, - list: { - id: inv.list.id, - name: inv.list.name, - icon: inv.list.icon, - description: inv.list.description, - owner: inv.list.user - ? { - id: inv.list.user.id, - name: inv.list.user.name, - email: inv.list.user.email, - } - : null, - }, - })); + return Promise.all( + invitations.map(async (inv) => { + const expiresAt = invitationExpiresAt(inv.invitedAt); + return { + id: inv.id, + listId: inv.listId, + role: inv.role, + recursive: await getDirectCollaborationScope(ctx, { + listId: inv.listId, + userId: inv.userId, + }), + invitedAt: inv.invitedAt, + expiresAt, + expired: expiresAt.getTime() <= Date.now(), + list: { + id: inv.list.id, + name: inv.list.name, + icon: inv.list.icon, + description: inv.list.description, + owner: inv.list.user + ? { + id: inv.list.user.id, + name: inv.list.user.name, + email: inv.list.user.email, + } + : null, + }, + }; + }), + ); } static async invitationsForList( ctx: AuthedContext, params: { listId: string }, ) { + // Declined invitations remain usable for a later re-invite but intentionally + // disappear from the normal owner management surface. const invitations = await ctx.db.query.listInvitations.findMany({ - where: eq(listInvitations.listId, params.listId), + where: and( + eq(listInvitations.listId, params.listId), + eq(listInvitations.status, "pending"), + ), with: { user: { columns: { @@ -358,42 +472,36 @@ export class ListInvitation { }, }); - return invitations.map((invitation) => ({ - id: invitation.id, - listId: invitation.listId, - userId: invitation.userId, - role: invitation.role, - status: invitation.status, - invitedAt: invitation.invitedAt, - addedAt: invitation.invitedAt, - user: { - id: invitation.user.id, - // Don't show the actual user's name for any invitation (pending or declined) - // This protects user privacy until they accept - name: "Pending User", - email: invitation.user.email || "", - image: null, - }, - })); - } - - static async sendInvitationEmail(params: { - email: string; - inviterName: string | null; - listName: string; - listId: string; - }) { - try { - const { sendListInvitationEmail } = await import("../email"); - await sendListInvitationEmail( - params.email, - params.inviterName || "A user", - params.listName, - params.listId, - ); - } catch (error) { - // Log the error but don't fail the invitation - console.error("Failed to send list invitation email:", error); - } + return Promise.all( + invitations.map(async (invitation) => { + const expiresAt = invitationExpiresAt(invitation.invitedAt); + return { + id: invitation.id, + listId: invitation.listId, + userId: invitation.userId, + role: invitation.role, + recursive: await getDirectCollaborationScope(ctx, { + listId: invitation.listId, + userId: invitation.userId, + }), + inherited: false, + sourceListId: invitation.listId, + sourceListName: null, + status: invitation.status, + invitedAt: invitation.invitedAt, + addedAt: invitation.invitedAt, + expiresAt, + expired: expiresAt.getTime() <= Date.now(), + user: { + id: invitation.user.id, + // Protect the user's identity until they accept. The owner already + // knows the address they invited, so showing the email is safe. + name: "Pending User", + email: invitation.user.email || "", + image: null, + }, + }; + }), + ); } } diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts index baf13e1c4..13520b5ab 100644 --- a/packages/trpc/models/lists.ts +++ b/packages/trpc/models/lists.ts @@ -21,6 +21,7 @@ import { zNewBookmarkListSchema, } from "@karakeep/shared/types/lists"; import { ZCursor } from "@karakeep/shared/types/pagination"; +import { zRuleEngineRuleEventSchema } from "@karakeep/shared/types/rules"; import { switchCase } from "@karakeep/shared/utils/switch"; import { AuthedContext, Context } from ".."; @@ -28,8 +29,15 @@ import { buildImpersonatingAuthedContext } from "../lib/impersonate"; import { RuleEngine } from "../lib/ruleEngine"; import { getBookmarkIdsFromMatcher } from "../lib/search"; import { Bookmark } from "./bookmarks"; +import { + deleteCollaborationScope, + getAllSharedListAccess, + getDirectCollaborationScope, + getEffectiveCollaboratorGrant, + getEffectiveCollaboratorsForList, + setCollaborationScope, +} from "./listCollaborationAccess"; import { ListInvitation } from "./listInvitations"; -import { zRuleEngineRuleEventSchema } from "@karakeep/shared/types/rules"; interface ListCollaboratorEntry { membershipId: string; @@ -63,7 +71,7 @@ export abstract class List { userRole: this.list.userRole, hasCollaborators: this.list.hasCollaborators, - // Hide parentId as it is not relevant to the user + // Hide parentId so an inherited share doesn't leak private hierarchy. parentId: null, // Hide whether the list is public or not. public: false, @@ -86,7 +94,7 @@ export abstract class List { ctx: AuthedContext, id: string, ): Promise { - // First try to find the list owned by the user + // First try to find the list owned by the user. let list = await (async (): Promise< (ZBookmarkList & { userId: string }) | undefined > => { @@ -116,32 +124,30 @@ export abstract class List { : l; })(); - // If not found, check if the user is a collaborator + // Otherwise resolve an exact direct grant or the nearest recursive ancestor. let collaboratorEntry: ListCollaboratorEntry | null = null; if (!list) { - const collaborator = await ctx.db.query.listCollaborators.findFirst({ - where: and( - eq(listCollaborators.listId, id), - eq(listCollaborators.userId, ctx.user.id), - ), - with: { - list: { - columns: { - rssToken: false, - }, - }, + const candidate = await ctx.db.query.bookmarkLists.findFirst({ + columns: { + rssToken: false, }, + where: eq(bookmarkLists.id, id), }); - - if (collaborator) { - list = { - ...collaborator.list, - userRole: collaborator.role, - hasCollaborators: true, // If you're a collaborator, the list has collaborators - }; - collaboratorEntry = { - membershipId: collaborator.id, - }; + if (candidate) { + const grant = await getEffectiveCollaboratorGrant(ctx, candidate); + if (grant) { + list = { + ...candidate, + userRole: grant.role, + hasCollaborators: true, + }; + collaboratorEntry = { + // Contributions made through inherited editor access belong to the + // granting direct membership. Removing that grant therefore cleans + // those list-membership rows without deleting the bookmarks. + membershipId: grant.membershipId, + }; + } } } @@ -325,28 +331,17 @@ export abstract class List { columns: { rssToken: false, }, - with: { - collaborators: { - where: eq(listCollaborators.userId, ctx.user.id), - columns: { - id: true, - role: true, - }, - }, - }, }, }, }); - // For owner lists, we need to check if they actually have collaborators - // by querying the collaborators table separately (without user filter) + // For owner lists, check whether they have direct collaborators. const ownerListIds = lists .filter((l) => l.list.userId === ctx.user.id) .map((l) => l.list.id); const listsWithCollaborators = new Set(); if (ownerListIds.length > 0) { - // Use a single query with inArray instead of N queries const collaborators = await ctx.db.query.listCollaborators.findMany({ where: inArray(listCollaborators.listId, ownerListIds), columns: { @@ -358,37 +353,39 @@ export abstract class List { }); } - return lists.flatMap((l) => { - let userRole: "owner" | "editor" | "viewer" | null; - let collaboratorEntry: ListCollaboratorEntry | null = null; - if (l.list.collaborators.length > 0) { - invariant(l.list.collaborators.length == 1); - userRole = l.list.collaborators[0].role; - collaboratorEntry = { - membershipId: l.list.collaborators[0].id, - }; - } else if (l.list.userId === ctx.user.id) { - userRole = "owner"; - } else { - userRole = null; - } - return userRole - ? [ - this.fromData( - ctx, - { - ...l.list, - userRole, - hasCollaborators: - userRole !== "owner" - ? true - : listsWithCollaborators.has(l.list.id), - }, - collaboratorEntry, - ), - ] - : []; - }); + const resolved = await Promise.all( + lists.map(async (l) => { + if (l.list.userId === ctx.user.id) { + return this.fromData( + ctx, + { + ...l.list, + userRole: "owner", + hasCollaborators: listsWithCollaborators.has(l.list.id), + }, + null, + ); + } + + const grant = await getEffectiveCollaboratorGrant(ctx, l.list); + if (!grant) { + return null; + } + return this.fromData( + ctx, + { + ...l.list, + userRole: grant.role, + hasCollaborators: true, + }, + { membershipId: grant.membershipId }, + ); + }), + ); + + return resolved.filter( + (list): list is ManualList | SmartList => list !== null, + ); } /** @@ -689,12 +686,14 @@ export abstract class List { async addCollaboratorByEmail( email: string, role: "viewer" | "editor", + recursive = false, ): Promise { this.ensureCanManage(); return await ListInvitation.inviteByEmail(this.ctx, { email, role, + recursive, listId: this.list.id, listName: this.list.name, listType: this.list.type, @@ -705,9 +704,9 @@ export abstract class List { } /** - * Remove a collaborator from this list. - * Only the list owner can remove collaborators. - * This also removes all bookmarks that the collaborator added to the list. + * Remove a direct collaborator grant from this list. + * Contributions tied to that direct membership are removed by the existing + * bookmarksInLists foreign key; underlying bookmarks remain untouched. */ async removeCollaborator(userId: string): Promise { this.ensureCanManage(); @@ -727,12 +726,16 @@ export abstract class List { message: "Collaborator not found", }); } + await deleteCollaborationScope(this.ctx, { + listId: this.list.id, + userId, + }); } /** - * Allow a user to leave a list (remove themselves as a collaborator). - * This bypasses the owner check since users should be able to leave lists they're collaborating on. - * This also removes all bookmarks that the user added to the list. + * Leave the direct grant that provides access. For inherited access this + * means leaving the granting recursive share, not creating a child-only + * denial override. */ async leaveList(): Promise { if (this.list.userRole === "owner") { @@ -742,30 +745,45 @@ export abstract class List { "List owners cannot leave their own list. Delete the list instead.", }); } + if (!this.collaboratorEntry) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Collaborator not found", + }); + } + + const membership = await this.ctx.db.query.listCollaborators.findFirst({ + where: and( + eq(listCollaborators.id, this.collaboratorEntry.membershipId), + eq(listCollaborators.userId, this.ctx.user.id), + ), + }); + if (!membership) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Collaborator not found", + }); + } const result = await this.ctx.db .delete(listCollaborators) - .where( - and( - eq(listCollaborators.listId, this.list.id), - eq(listCollaborators.userId, this.ctx.user.id), - ), - ); - + .where(eq(listCollaborators.id, membership.id)); if (result.changes === 0) { throw new TRPCError({ code: "NOT_FOUND", message: "Collaborator not found", }); } + await deleteCollaborationScope(this.ctx, { + listId: membership.listId, + userId: this.ctx.user.id, + }); } - /** - * Update a collaborator's role. - */ - async updateCollaboratorRole( + async updateCollaborator( userId: string, role: "viewer" | "editor", + recursive: boolean, ): Promise { this.ensureCanManage(); @@ -785,11 +803,30 @@ export abstract class List { message: "Collaborator not found", }); } + await setCollaborationScope(this.ctx, { + listId: this.list.id, + userId, + recursive, + }); + } + + /** + * Backwards-compatible role-only update. Preserve the existing direct scope. + */ + async updateCollaboratorRole( + userId: string, + role: "viewer" | "editor", + ): Promise { + const recursive = await getDirectCollaborationScope(this.ctx, { + listId: this.list.id, + userId, + }); + await this.updateCollaborator(userId, role, recursive); } /** - * Get all collaborators for this list, including pending invitations. - * For privacy, pending invitations show masked user info unless the invitation has been accepted. + * Get effective accepted collaborators plus this list's pending invitations. + * Accepted inherited grants are annotated with their source list. */ async getCollaborators() { this.ensureCanView(); @@ -797,20 +834,7 @@ export abstract class List { const isOwner = this.list.userId === this.ctx.user.id; const [collaborators, invitations] = await Promise.all([ - this.ctx.db.query.listCollaborators.findMany({ - where: eq(listCollaborators.listId, this.list.id), - with: { - user: { - columns: { - id: true, - name: true, - email: true, - image: true, - }, - }, - }, - }), - // Only show invitations for the owner + getEffectiveCollaboratorsForList(this.ctx, this.list), isOwner ? ListInvitation.invitationsForList(this.ctx, { listId: this.list.id, @@ -818,7 +842,6 @@ export abstract class List { : [], ]); - // Get the owner information const owner = await this.ctx.db.query.users.findFirst({ where: eq(users.id, this.list.userId), columns: { @@ -834,6 +857,10 @@ export abstract class List { id: c.id, userId: c.userId, role: c.role, + recursive: c.recursive, + inherited: c.inherited, + sourceListId: c.sourceListId, + sourceListName: c.sourceListName, status: "accepted" as const, addedAt: c.addedAt, invitedAt: c.addedAt, @@ -862,33 +889,23 @@ export abstract class List { } /** - * Get all lists shared with the user (as a collaborator). - * Only includes lists where the invitation has been accepted. + * Get all direct and inherited manual lists shared with the user. */ static async getSharedWithUser( ctx: AuthedContext, ): Promise<(ManualList | SmartList)[]> { - const collaborations = await ctx.db.query.listCollaborators.findMany({ - where: eq(listCollaborators.userId, ctx.user.id), - with: { - list: { - columns: { - rssToken: false, - }, - }, - }, - }); + const collaborations = await getAllSharedListAccess(ctx); - return collaborations.map((c) => + return collaborations.map(({ list, grant }) => this.fromData( ctx, { - ...c.list, - userRole: c.role, - hasCollaborators: true, // If you're a collaborator, the list has collaborators + ...list, + userRole: grant.role, + hasCollaborators: true, }, { - membershipId: c.id, + membershipId: grant.membershipId, }, ), ); diff --git a/packages/trpc/routers/lists.ts b/packages/trpc/routers/lists.ts index c7b040c37..fad39e8b0 100644 --- a/packages/trpc/routers/lists.ts +++ b/packages/trpc/routers/lists.ts @@ -5,11 +5,7 @@ import { eq } from "drizzle-orm"; import type { KarakeepDBTransaction } from "@karakeep/db"; -import { - bookmarkLists, - listCollaborators, - listInvitations, -} from "@karakeep/db/schema"; +import { bookmarkLists, listInvitations } from "@karakeep/db/schema"; import { zBookmarkListSchema, zEditBookmarkListSchemaWithValidation, @@ -26,10 +22,14 @@ import { createScopedAuthedProcedure, router, } from "../index"; +import { + getEffectiveCollaboratorGrant, + getEffectiveCollaboratorsForList, +} from "../models/listCollaborationAccess"; import { ListInvitation } from "../models/listInvitations"; import { List } from "../models/lists"; -import { ensureBookmarkOwnership } from "./bookmarks"; import { recordOfflineSyncEvent } from "../models/offlineSync"; +import { ensureBookmarkOwnership } from "./bookmarks"; const listsProcedure = createScopedAuthedProcedure("lists"); @@ -64,18 +64,39 @@ async function listSyncUserIds( listId: string, ownerId: string, ) { - const collaborators = await ctx.db - .select({ userId: listCollaborators.userId }) - .from(listCollaborators) - .where(eq(listCollaborators.listId, listId)); + const list = await ctx.db.query.bookmarkLists.findFirst({ + columns: { rssToken: false }, + where: eq(bookmarkLists.id, listId), + }); + if (!list) { + return [ownerId]; + } + const collaborators = await getEffectiveCollaboratorsForList(ctx, list); return [ownerId, ...collaborators.map((collaborator) => collaborator.userId)]; } +async function inheritedListIdsFromGrant( + ctx: AuthedContext, + sourceListId: string, + userId: string, +) { + const source = await List.fromId(ctx, sourceListId); + const descendants = await source.getChildren(); + const result = [sourceListId]; + for (const descendant of descendants) { + const serialized = descendant.asZBookmarkList(); + const grant = await getEffectiveCollaboratorGrant(ctx, serialized, userId); + if (grant?.sourceListId === sourceListId) { + result.push(serialized.id); + } + } + return result; +} + export const ensureListAtLeastViewer = experimental_trpcMiddleware<{ ctx: AuthedContext; input: { listId: string }; }>().create(async (opts) => { - // This would throw if the user can't view the list const list = await List.fromId(opts.ctx, opts.input.listId); return opts.next({ ctx: { @@ -410,11 +431,13 @@ export const listsAppRouter = router({ listId: z.string(), email: z.string().email(), role: z.enum(["viewer", "editor"]), + recursive: z.boolean().optional().default(false), }), ) .output( z.object({ invitationId: z.string(), + emailSent: z.boolean(), }), ) .use( @@ -433,13 +456,18 @@ export const listsAppRouter = router({ const invitationId = await list.addCollaboratorByEmail( input.email, input.role, + input.recursive, ); await recordListSyncEvent(tx, [ctx.user.id], list.id, "update", [ "collaborators", ]); return invitationId; }); - return { invitationId }; + + // Delivery deliberately happens after the database commit. + const invitation = await ListInvitation.fromId(ctx, invitationId); + const emailSent = await invitation.sendEmail(); + return { invitationId, emailSent }; }), removeCollaborator: listsProcedure .input( @@ -455,6 +483,11 @@ export const listsAppRouter = router({ const transactionCtx = asTransactionContext(ctx, tx); const list = await List.fromId(transactionCtx, input.listId); const serialized = list.asZBookmarkList(); + const revokedListIds = await inheritedListIdsFromGrant( + transactionCtx, + serialized.id, + input.userId, + ); const syncUserIds = await listSyncUserIds( transactionCtx, serialized.id, @@ -468,15 +501,52 @@ export const listsAppRouter = router({ "update", ["collaborators"], ); + for (const revokedListId of revokedListIds) { + await recordListSyncEvent( + tx, + [input.userId], + revokedListId, + "revoke", + [], + ); + } + }); + }), + updateCollaborator: listsProcedure + .input( + z.object({ + listId: z.string(), + userId: z.string(), + role: z.enum(["viewer", "editor"]), + recursive: z.boolean(), + }), + ) + .use(ensureListAtLeastViewer) + .use(ensureListAtLeastOwner) + .mutation(async ({ input, ctx }) => { + await ctx.db.transaction(async (tx) => { + const transactionCtx = asTransactionContext(ctx, tx); + const list = await List.fromId(transactionCtx, input.listId); + await list.updateCollaborator( + input.userId, + input.role, + input.recursive, + ); + const serialized = list.asZBookmarkList(); await recordListSyncEvent( tx, - [input.userId], + await listSyncUserIds( + transactionCtx, + serialized.id, + serialized.userId, + ), serialized.id, - "revoke", - [], + "update", + ["collaborators"], ); }); }), + // Keep the role-only mutation for existing clients. It preserves scope. updateCollaboratorRole: listsProcedure .input( z.object({ @@ -519,9 +589,15 @@ export const listsAppRouter = router({ id: z.string(), userId: z.string(), role: z.enum(["viewer", "editor"]), + recursive: z.boolean().optional().default(false), + inherited: z.boolean().optional().default(false), + sourceListId: z.string().optional(), + sourceListName: z.string().nullable().optional(), status: z.enum(["pending", "accepted", "declined"]), addedAt: z.date(), invitedAt: z.date(), + expiresAt: z.date().optional(), + expired: z.boolean().optional().default(false), user: z.object({ id: z.string(), name: z.string(), @@ -605,6 +681,34 @@ export const listsAppRouter = router({ await ctx.invitation.revoke(); }), + updateInvitation: listsProcedure + .input( + z.object({ + invitationId: z.string(), + role: z.enum(["viewer", "editor"]), + recursive: z.boolean(), + }), + ) + .use(ensureInvitationAccess) + .mutation(async ({ ctx, input }) => { + await ctx.invitation.update({ + role: input.role, + recursive: input.recursive, + }); + }), + + resendInvitation: listsProcedure + .input( + z.object({ + invitationId: z.string(), + }), + ) + .output(z.object({ emailSent: z.boolean() })) + .use(ensureInvitationAccess) + .mutation(async ({ ctx }) => { + return { emailSent: await ctx.invitation.resend() }; + }), + getPendingInvitations: listsProcedure .output( z.array( @@ -612,7 +716,10 @@ export const listsAppRouter = router({ id: z.string(), listId: z.string(), role: z.enum(["viewer", "editor"]), + recursive: z.boolean(), invitedAt: z.date(), + expiresAt: z.date(), + expired: z.boolean(), list: z.object({ id: z.string(), name: z.string(), From 8584058eee93ed1ed651486ea92fc8d15728b6da Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 17:12:22 +0700 Subject: [PATCH 05/87] test(web): cover stable collaboration UI semantics --- .../bookmarks/bookmarkListPermissions.test.ts | 44 +++++++++++++++++++ .../dashboard/lists/collaborationUi.test.ts | 34 ++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 apps/web/components/dashboard/bookmarks/bookmarkListPermissions.test.ts create mode 100644 apps/web/components/dashboard/lists/collaborationUi.test.ts diff --git a/apps/web/components/dashboard/bookmarks/bookmarkListPermissions.test.ts b/apps/web/components/dashboard/bookmarks/bookmarkListPermissions.test.ts new file mode 100644 index 000000000..33888bd5b --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/bookmarkListPermissions.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "vitest"; + +import { canRemoveBookmarkFromList } from "./bookmarkListPermissions"; + +describe("bookmark list action permissions", () => { + test("viewer cannot remove a bookmark even when they own it", () => { + expect( + canRemoveBookmarkFromList({ + listId: "list", + listType: "manual", + userRole: "viewer", + }), + ).toBe(false); + }); + + test("editor and owner can remove from a manual list", () => { + for (const userRole of ["editor", "owner"] as const) { + expect( + canRemoveBookmarkFromList({ + listId: "list", + listType: "manual", + userRole, + }), + ).toBe(true); + } + }); + + test("remove action requires a manual list context", () => { + expect( + canRemoveBookmarkFromList({ + listId: undefined, + listType: "manual", + userRole: "editor", + }), + ).toBe(false); + expect( + canRemoveBookmarkFromList({ + listId: "smart", + listType: "smart", + userRole: "editor", + }), + ).toBe(false); + }); +}); diff --git a/apps/web/components/dashboard/lists/collaborationUi.test.ts b/apps/web/components/dashboard/lists/collaborationUi.test.ts new file mode 100644 index 000000000..ee502371a --- /dev/null +++ b/apps/web/components/dashboard/lists/collaborationUi.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "vitest"; + +import { + collaboratorRemovalMessage, + invitationDeliveryMessage, + canManageCollaboratorOnList, +} from "./collaborationUi"; + +describe("stable collaboration UI semantics", () => { + test("reports email delivery truthfully", () => { + expect(invitationDeliveryMessage(true)).toMatch(/sent/i); + expect(invitationDeliveryMessage(false)).toMatch(/created/i); + expect(invitationDeliveryMessage(false)).toMatch(/email.*not.*sent/i); + }); + + test("only direct accepted collaborators can be managed from this list", () => { + expect( + canManageCollaboratorOnList({ status: "accepted", inherited: false }), + ).toBe(true); + expect( + canManageCollaboratorOnList({ status: "accepted", inherited: true }), + ).toBe(false); + expect( + canManageCollaboratorOnList({ status: "pending", inherited: false }), + ).toBe(false); + }); + + test("removal confirmation distinguishes list entries from bookmarks", () => { + const message = collaboratorRemovalMessage("Daffa"); + expect(message).toContain("Daffa"); + expect(message).toMatch(/removed from this shared list/i); + expect(message).toMatch(/underlying bookmarks.*remain/i); + }); +}); From 5e8aa366fb9592d19b3a6b04951cca91dd8724ec Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 17:14:22 +0700 Subject: [PATCH 06/87] feat(web): graduate list collaboration UI --- .../dashboard/bookmarks/BookmarkOptions.tsx | 20 +- .../bookmarks/bookmarkListPermissions.ts | 11 + .../lists/ManageCollaboratorsModal.tsx | 433 ++++++++++++------ .../lists/PendingInvitationsCard.tsx | 212 +++++---- .../dashboard/lists/collaborationUi.ts | 16 + 5 files changed, 470 insertions(+), 222 deletions(-) create mode 100644 apps/web/components/dashboard/bookmarks/bookmarkListPermissions.ts create mode 100644 apps/web/components/dashboard/lists/collaborationUi.ts diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx index 1a4202063..dd6681e59 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx @@ -55,6 +55,7 @@ import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; import { getAssetUrl } from "@karakeep/shared/utils/assetUtils"; import { BookmarkedTextEditor } from "./BookmarkedTextEditor"; +import { canRemoveBookmarkFromList } from "./bookmarkListPermissions"; import DeleteBookmarkConfirmationDialog from "./DeleteBookmarkConfirmationDialog"; import { EditBookmarkDialog } from "./EditBookmarkDialog"; import { ArchivedActionIcon, FavouritedActionIcon } from "./icons"; @@ -97,13 +98,11 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const offlineStatus = useOfflineLibraryStatus(); const requiresOnline = offlineStatus.kind !== "online"; - // Check if the current user owns this bookmark const isOwner = session?.user?.id === bookmark.userId; const [isClipboardAvailable, setIsClipboardAvailable] = useState(false); useEffect(() => { - // This code only runs in the browser setIsClipboardAvailable( typeof window !== "undefined" && window.navigator && @@ -257,7 +256,6 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { } }; - // Define action items array const actionItems: ActionItemType[] = [ { id: "edit", @@ -339,15 +337,11 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { id: "remove-from-list", title: t("actions.remove_from_list"), icon: , - visible: Boolean( - (isOwner || - (withinListContext && - (withinListContext.userRole === "editor" || - withinListContext.userRole === "owner"))) && - !!listId && - !!withinListContext && - withinListContext.type === "manual", - ), + visible: canRemoveBookmarkFromList({ + listId, + listType: withinListContext?.type, + userRole: withinListContext?.userRole, + }), disabled: demoMode, onClick: removeFromList, }, @@ -479,7 +473,6 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { }, ]; - // Filter visible items const visibleItems: ActionItemType[] = actionItems.filter((item) => { if (isSubsectionItem(item)) { return item.visible && item.items.some((subItem) => subItem.visible); @@ -487,7 +480,6 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { return item.visible; }); - // If no items are visible, don't render the dropdown if (visibleItems.length === 0) { return null; } diff --git a/apps/web/components/dashboard/bookmarks/bookmarkListPermissions.ts b/apps/web/components/dashboard/bookmarks/bookmarkListPermissions.ts new file mode 100644 index 000000000..2b98e5668 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/bookmarkListPermissions.ts @@ -0,0 +1,11 @@ +export function canRemoveBookmarkFromList(input: { + listId: string | undefined; + listType: "manual" | "smart" | undefined; + userRole: "owner" | "editor" | "viewer" | "public" | undefined; +}) { + return Boolean( + input.listId && + input.listType === "manual" && + (input.userRole === "owner" || input.userRole === "editor"), + ); +} diff --git a/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx b/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx index bc9f24502..50a82b43e 100644 --- a/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx +++ b/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx @@ -26,11 +26,24 @@ import { toast } from "@/components/ui/sonner"; import { UserAvatar } from "@/components/ui/user-avatar"; import { useTranslation } from "@/lib/i18n/client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Loader2, Trash2, UserPlus, Users } from "lucide-react"; +import { + Clock3, + Loader2, + RefreshCw, + Trash2, + UserPlus, + Users, +} from "lucide-react"; import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZBookmarkList } from "@karakeep/shared/types/lists"; +import { + canManageCollaboratorOnList, + collaboratorRemovalMessage, + invitationDeliveryMessage, +} from "./collaborationUi"; + export function ManageCollaboratorsModal({ open: userOpen, setOpen: userSetOpen, @@ -61,6 +74,8 @@ export function ManageCollaboratorsModal({ const [newCollaboratorRole, setNewCollaboratorRole] = useState< "viewer" | "editor" >("viewer"); + const [newCollaboratorRecursive, setNewCollaboratorRecursive] = + useState(false); const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -74,6 +89,9 @@ export function ManageCollaboratorsModal({ api.lists.get.queryFilter({ listId: list.id }), ), queryClient.invalidateQueries(api.lists.list.pathFilter()), + queryClient.invalidateQueries( + api.lists.getPendingInvitations.pathFilter(), + ), queryClient.invalidateQueries( api.bookmarks.getBookmarks.queryFilter({ listId: list.id }), ), @@ -82,7 +100,6 @@ export function ManageCollaboratorsModal({ ), ]); - // Fetch collaborators const { data: collaboratorsData, isLoading } = useQuery( api.lists.getCollaborators.queryOptions( { listId: list.id }, @@ -90,14 +107,15 @@ export function ManageCollaboratorsModal({ ), ); - // Mutations const addCollaborator = useMutation( api.lists.addCollaborator.mutationOptions({ - onSuccess: async () => { + onSuccess: async (result) => { toast({ - description: t("lists.collaborators.invitation_sent"), + description: invitationDeliveryMessage(result.emailSent), }); setNewCollaboratorEmail(""); + setNewCollaboratorRole("viewer"); + setNewCollaboratorRecursive(false); await invalidateListCaches(); }, onError: (error) => { @@ -112,9 +130,7 @@ export function ManageCollaboratorsModal({ const removeCollaborator = useMutation( api.lists.removeCollaborator.mutationOptions({ onSuccess: async () => { - toast({ - description: t("lists.collaborators.removed"), - }); + toast({ description: t("lists.collaborators.removed") }); await invalidateListCaches(); }, onError: (error) => { @@ -127,12 +143,10 @@ export function ManageCollaboratorsModal({ }), ); - const updateCollaboratorRole = useMutation( - api.lists.updateCollaboratorRole.mutationOptions({ + const updateCollaborator = useMutation( + api.lists.updateCollaborator.mutationOptions({ onSuccess: async () => { - toast({ - description: t("lists.collaborators.role_updated"), - }); + toast({ description: t("lists.collaborators.role_updated") }); await invalidateListCaches(); }, onError: (error) => { @@ -145,6 +159,30 @@ export function ManageCollaboratorsModal({ }), ); + const updateInvitation = useMutation( + api.lists.updateInvitation.mutationOptions({ + onSuccess: invalidateListCaches, + onError: (error) => { + toast({ + variant: "destructive", + description: error.message, + }); + }, + }), + ); + + const resendInvitation = useMutation( + api.lists.resendInvitation.mutationOptions({ + onSuccess: async (result) => { + toast({ description: invitationDeliveryMessage(result.emailSent) }); + await invalidateListCaches(); + }, + onError: (error) => { + toast({ variant: "destructive", description: error.message }); + }, + }), + ); + const revokeInvitation = useMutation( api.lists.revokeInvitation.mutationOptions({ onSuccess: async () => { @@ -164,7 +202,8 @@ export function ManageCollaboratorsModal({ ); const handleAddCollaborator = () => { - if (!newCollaboratorEmail.trim()) { + const email = newCollaboratorEmail.trim(); + if (!email) { toast({ variant: "destructive", description: t("lists.collaborators.please_enter_email"), @@ -174,18 +213,14 @@ export function ManageCollaboratorsModal({ addCollaborator.mutate({ listId: list.id, - email: newCollaboratorEmail, + email, role: newCollaboratorRole, + recursive: newCollaboratorRecursive, }); }; return ( - { - setOpen(s); - }} - > + {children && {children}} @@ -194,19 +229,15 @@ export function ManageCollaboratorsModal({ {readOnly ? t("lists.collaborators.collaborators") : t("lists.collaborators.manage")} - - Beta - {readOnly ? t("lists.collaborators.people_with_access") - : t("lists.collaborators.add_or_remove")} + : "Invite people to this list and choose whether access also follows its current and future nested lists."}
- {/* Add Collaborator Section */} {!readOnly && (
@@ -263,6 +294,27 @@ export function ManageCollaboratorsModal({
+ + +

{t("lists.collaborators.viewer")}:{" "} {t("lists.collaborators.viewer_description")} @@ -273,7 +325,6 @@ export function ManageCollaboratorsModal({ )} - {/* Current Collaborators */}

) : collaboratorsData ? (
- {/* Show owner first */} {collaboratorsData.owner && ( -
-
+
+
-
-
+
+
{collaboratorsData.owner.name}
{collaboratorsData.owner.email && ( -
+
{collaboratorsData.owner.email}
)}
-
+
{t("lists.collaborators.owner")}
)} - {/* Show collaborators */} - {collaboratorsData.collaborators.length > 0 ? ( - collaboratorsData.collaborators.map((collaborator) => ( + + {collaboratorsData.collaborators.map((collaborator) => { + const canManage = canManageCollaboratorOnList(collaborator); + const isPending = collaborator.status === "pending"; + const disabledPending = isPending && collaborator.expired; + return (
-
- -
-
-
- {collaborator.user.name} +
+
+ +
+
+
+ {collaborator.user.name} +
+ {isPending && !collaborator.expired && ( + Pending + )} + {isPending && collaborator.expired && ( + Expired + )} + {collaborator.inherited && ( + Inherited + )} + {collaborator.recursive && ( + Nested lists + )}
- {collaborator.status === "pending" && ( - - {t("lists.collaborators.pending")} - + {collaborator.user.email && ( +
+ {collaborator.user.email} +
)} - {collaborator.status === "declined" && ( - - {t("lists.collaborators.declined")} - + {collaborator.inherited && + collaborator.sourceListName && ( +
+ Inherited from {collaborator.sourceListName} +
+ )} + {isPending && collaborator.expiresAt && ( +
+ + {collaborator.expired + ? "Expired" + : "Expires"}{" "} + {new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + }).format(collaborator.expiresAt)} +
)}
- {collaborator.user.email && ( -
- {collaborator.user.email} -
- )} -
-
- {readOnly ? ( -
- {collaborator.role}
- ) : collaborator.status !== "accepted" ? ( -
+ + {readOnly ? (
{collaborator.role}
- -
- ) : ( -
- - -
- )} + ) : collaborator.inherited ? ( +
+ + {collaborator.role} + + {collaborator.user.email && ( + + )} +
+ ) : isPending ? ( +
+ + + + +
+ ) : canManage ? ( +
+ + + +
+ ) : null} +
- )) - ) : !collaboratorsData.owner ? ( -
- {readOnly - ? t("lists.collaborators.no_collaborators_readonly") - : t("lists.collaborators.no_collaborators")} -
- ) : null} + ); + })} + + {collaboratorsData.collaborators.length === 0 && + !collaboratorsData.owner && ( +
+ {readOnly + ? t("lists.collaborators.no_collaborators_readonly") + : t("lists.collaborators.no_collaborators")} +
+ )}
) : (
diff --git a/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx b/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx index 7c13dbebc..320729200 100644 --- a/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx +++ b/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx @@ -1,5 +1,8 @@ "use client"; +import { useEffect } from "react"; +import { useSearchParams } from "next/navigation"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, @@ -11,13 +14,16 @@ import { import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, Loader2, Mail, X } from "lucide-react"; +import { Check, Clock3, Loader2, Mail, X } from "lucide-react"; import { useTRPC } from "@karakeep/shared-react/trpc"; interface Invitation { id: string; - role: string; + role: "viewer" | "editor"; + recursive: boolean; + expiresAt: Date; + expired: boolean; list: { name: string; icon?: string; @@ -28,23 +34,30 @@ interface Invitation { }; } -function InvitationRow({ invitation }: { invitation: Invitation }) { +function InvitationRow({ + invitation, + highlighted, +}: { + invitation: Invitation; + highlighted: boolean; +}) { const api = useTRPC(); const { t } = useTranslation(); const queryClient = useQueryClient(); + const invalidateInvitationCaches = () => + Promise.all([ + queryClient.invalidateQueries( + api.lists.getPendingInvitations.pathFilter(), + ), + queryClient.invalidateQueries(api.lists.list.pathFilter()), + ]); + const acceptInvitation = useMutation( api.lists.acceptInvitation.mutationOptions({ onSuccess: async () => { - toast({ - description: t("lists.invitations.accepted"), - }); - await Promise.all([ - queryClient.invalidateQueries( - api.lists.getPendingInvitations.pathFilter(), - ), - queryClient.invalidateQueries(api.lists.list.pathFilter()), - ]); + toast({ description: t("lists.invitations.accepted") }); + await invalidateInvitationCaches(); }, onError: (error) => { toast({ @@ -58,12 +71,8 @@ function InvitationRow({ invitation }: { invitation: Invitation }) { const declineInvitation = useMutation( api.lists.declineInvitation.mutationOptions({ onSuccess: async () => { - toast({ - description: t("lists.invitations.declined"), - }); - await queryClient.invalidateQueries( - api.lists.getPendingInvitations.pathFilter(), - ); + toast({ description: t("lists.invitations.declined") }); + await invalidateInvitationCaches(); }, onError: (error) => { toast({ @@ -76,62 +85,95 @@ function InvitationRow({ invitation }: { invitation: Invitation }) { ); return ( -
-
-
- {invitation.list.name} - - {invitation.list.icon} - -
- {invitation.list.description && ( -
- {invitation.list.description} +
+
+
+
+ {invitation.list.name} + + {invitation.list.icon} + + + {invitation.role} + + {invitation.recursive && ( + Includes nested lists + )} + {invitation.expired && ( + Expired + )}
- )} -
- {t("lists.invitations.invited_by")}{" "} - - {invitation.list.owner?.name || "Unknown"} - - {" β€’ "} - {invitation.role} -
-
-
- - +
+
+ + +
); @@ -140,16 +182,29 @@ function InvitationRow({ invitation }: { invitation: Invitation }) { export function PendingInvitationsCard() { const api = useTRPC(); const { t } = useTranslation(); + const searchParams = useSearchParams(); + const highlightedInvitationId = searchParams.get("pendingInvitation"); const { data: invitations, isLoading } = useQuery( api.lists.getPendingInvitations.queryOptions(), ); - if (isLoading) { - return null; - } + useEffect(() => { + if (!highlightedInvitationId || !invitations) { + return; + } + const invitation = invitations.find( + (item) => item.id === highlightedInvitationId, + ); + if (!invitation) { + return; + } + document + .getElementById(`pending-invitation-${highlightedInvitationId}`) + ?.scrollIntoView({ behavior: "smooth", block: "center" }); + }, [highlightedInvitationId, invitations]); - if (!invitations || invitations.length === 0) { + if (isLoading || !invitations || invitations.length === 0) { return null; } @@ -159,7 +214,6 @@ export function PendingInvitationsCard() { {t("lists.invitations.pending")} - {invitations.length} @@ -168,7 +222,11 @@ export function PendingInvitationsCard() { {invitations.map((invitation) => ( - + ))} diff --git a/apps/web/components/dashboard/lists/collaborationUi.ts b/apps/web/components/dashboard/lists/collaborationUi.ts new file mode 100644 index 000000000..bb30ac2c5 --- /dev/null +++ b/apps/web/components/dashboard/lists/collaborationUi.ts @@ -0,0 +1,16 @@ +export function invitationDeliveryMessage(emailSent: boolean) { + return emailSent + ? "Invitation created and email sent." + : "Invitation created, but the email was not sent. You can resend it later."; +} + +export function canManageCollaboratorOnList(collaborator: { + status: "pending" | "accepted" | "declined"; + inherited?: boolean; +}) { + return collaborator.status === "accepted" && !collaborator.inherited; +} + +export function collaboratorRemovalMessage(name: string) { + return `Remove ${name} from this shared list? Bookmark entries they contributed through this collaboration will be removed from this shared list, but their underlying bookmarks will remain in their library.`; +} From 3ef8cb3b548d2a3fa5a8acf78909b7fd6977e988 Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 17:16:22 +0700 Subject: [PATCH 07/87] feat(mobile): add stable list collaboration management --- .../app/dashboard/(tabs)/(lists)/index.tsx | 22 +- .../dashboard/lists/[slug]/collaborators.tsx | 427 ++++++++++++++++++ .../app/dashboard/lists/[slug]/index.tsx | 64 +-- .../app/dashboard/lists/invitations.tsx | 158 +++++++ 4 files changed, 638 insertions(+), 33 deletions(-) create mode 100644 apps/mobile/app/dashboard/lists/[slug]/collaborators.tsx create mode 100644 apps/mobile/app/dashboard/lists/invitations.tsx diff --git a/apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx b/apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx index 4e28b1464..82e49d0f8 100644 --- a/apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx +++ b/apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx @@ -79,13 +79,14 @@ export default function Lists() { const api = useTRPC(); const queryClient = useQueryClient(); const { data: listStats } = useQuery(api.lists.stats.queryOptions()); + const { data: pendingInvitations } = useQuery( + api.lists.getPendingInvitations.queryOptions(), + ); - // Check if there are any shared lists const hasSharedLists = useMemo(() => { return lists?.data.some((list) => list.userRole !== "owner") ?? false; }, [lists?.data]); - // Check if any list has children to determine if we need chevron spacing const hasAnyListsWithChildren = useMemo(() => { const checkForChildren = (node: ZBookmarkListTreeNode): boolean => { if (node.children && node.children.length > 0) return true; @@ -111,6 +112,7 @@ export default function Lists() { const onRefresh = () => { queryClient.invalidateQueries(api.lists.list.pathFilter()); queryClient.invalidateQueries(api.lists.stats.pathFilter()); + queryClient.invalidateQueries(api.lists.getPendingInvitations.pathFilter()); }; const links: ListLink[] = [ @@ -134,9 +136,19 @@ export default function Lists() { }, ]; - // Add shared lists section if there are any + if (pendingInvitations && pendingInvitations.length > 0) { + links.push({ + id: "pending-invitations", + logo: "βœ‰οΈ", + name: `List Invitations (${pendingInvitations.length})`, + href: "/dashboard/lists/invitations", + level: 0, + numChildren: 0, + collapsed: false, + }); + } + if (hasSharedLists) { - // Count shared lists to determine if section has children const sharedListsCount = Object.values(lists.root).filter( (list) => list.item.userRole !== "owner", ).length; @@ -152,7 +164,6 @@ export default function Lists() { isSharedSection: true, }); - // Add shared lists as children if section is expanded if (showChildrenOf["shared-section"]) { Object.values(lists.root).forEach((list) => { if (list.item.userRole !== "owner") { @@ -169,7 +180,6 @@ export default function Lists() { } } - // Add owned lists only Object.values(lists.root).forEach((list) => { if (list.item.userRole === "owner") { traverseTree(list, links, showChildrenOf, listStats?.stats); diff --git a/apps/mobile/app/dashboard/lists/[slug]/collaborators.tsx b/apps/mobile/app/dashboard/lists/[slug]/collaborators.tsx new file mode 100644 index 000000000..1ebe0088c --- /dev/null +++ b/apps/mobile/app/dashboard/lists/[slug]/collaborators.tsx @@ -0,0 +1,427 @@ +import { useState } from "react"; +import { Alert, ScrollView, Switch, View } from "react-native"; +import { Stack, useLocalSearchParams } from "expo-router"; +import { Button } from "@/components/ui/Button"; +import FullPageSpinner from "@/components/ui/FullPageSpinner"; +import { Input } from "@/components/ui/Input"; +import { Text } from "@/components/ui/Text"; +import { useToast } from "@/components/ui/Toast"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; + +function deliveryMessage(emailSent: boolean) { + return emailSent + ? "Invitation created and email sent." + : "Invitation created, but the email was not sent. You can resend it later."; +} + +export default function ManageListCollaboratorsPage() { + const { slug } = useLocalSearchParams(); + const listId = typeof slug === "string" ? slug : ""; + const api = useTRPC(); + const queryClient = useQueryClient(); + const { toast } = useToast(); + const [email, setEmail] = useState(""); + const [role, setRole] = useState<"viewer" | "editor">("viewer"); + const [recursive, setRecursive] = useState(false); + + const { data, isPending } = useQuery( + api.lists.getCollaborators.queryOptions( + { listId }, + { enabled: Boolean(listId) }, + ), + ); + + const invalidate = () => + Promise.all([ + queryClient.invalidateQueries( + api.lists.getCollaborators.queryFilter({ listId }), + ), + queryClient.invalidateQueries(api.lists.list.pathFilter()), + queryClient.invalidateQueries( + api.lists.getPendingInvitations.pathFilter(), + ), + ]); + + const addCollaborator = useMutation( + api.lists.addCollaborator.mutationOptions({ + onSuccess: async (result) => { + toast({ message: deliveryMessage(result.emailSent) }); + setEmail(""); + setRole("viewer"); + setRecursive(false); + await invalidate(); + }, + onError: (error) => + toast({ message: error.message, variant: "destructive" }), + }), + ); + const updateCollaborator = useMutation( + api.lists.updateCollaborator.mutationOptions({ + onSuccess: invalidate, + onError: (error) => + toast({ message: error.message, variant: "destructive" }), + }), + ); + const updateInvitation = useMutation( + api.lists.updateInvitation.mutationOptions({ + onSuccess: invalidate, + onError: (error) => + toast({ message: error.message, variant: "destructive" }), + }), + ); + const resendInvitation = useMutation( + api.lists.resendInvitation.mutationOptions({ + onSuccess: async (result) => { + toast({ message: deliveryMessage(result.emailSent) }); + await invalidate(); + }, + onError: (error) => + toast({ message: error.message, variant: "destructive" }), + }), + ); + const revokeInvitation = useMutation( + api.lists.revokeInvitation.mutationOptions({ + onSuccess: invalidate, + onError: (error) => + toast({ message: error.message, variant: "destructive" }), + }), + ); + const removeCollaborator = useMutation( + api.lists.removeCollaborator.mutationOptions({ + onSuccess: invalidate, + onError: (error) => + toast({ message: error.message, variant: "destructive" }), + }), + ); + + if (isPending || !data) { + return ; + } + + return ( + <> + + + + Invite collaborator + + + Role + + + + + + + + + + + + Also share all nested lists + + Includes current nested lists and lists added or moved here + later. + + + + + + + + + People with access + {data.owner && ( + + + + {data.owner.name} + {data.owner.email && ( + + {data.owner.email} + + )} + + Owner + + + )} + + {data.collaborators.map((collaborator) => { + const pending = collaborator.status === "pending"; + return ( + + + + + {collaborator.user.name} + + {pending && ( + + {collaborator.expired ? "Expired" : "Pending"} + + )} + {collaborator.inherited && ( + + Inherited + + )} + {collaborator.recursive && ( + + Nested lists + + )} + + {collaborator.user.email && ( + + {collaborator.user.email} + + )} + {collaborator.inherited && collaborator.sourceListName && ( + + Inherited from {collaborator.sourceListName} + + )} + {pending && collaborator.expiresAt && ( + + {collaborator.expired ? "Expired" : "Expires"}{" "} + {collaborator.expiresAt.toLocaleDateString()} + + )} + + + {collaborator.inherited ? ( + + + {collaborator.role} + + {collaborator.user.email && ( + + )} + + ) : pending ? ( + + + + + + + + + + + Share nested lists + + updateInvitation.mutate({ + invitationId: collaborator.id, + role: collaborator.role, + recursive: value, + }) + } + /> + + + + + + + + + + + ) : ( + + + + + + + + + + + Share nested lists + + updateCollaborator.mutate({ + listId, + userId: collaborator.userId, + role: collaborator.role, + recursive: value, + }) + } + /> + + + + )} + + ); + })} + + + + ); +} diff --git a/apps/mobile/app/dashboard/lists/[slug]/index.tsx b/apps/mobile/app/dashboard/lists/[slug]/index.tsx index 0ddad6ebf..bf29c5346 100644 --- a/apps/mobile/app/dashboard/lists/[slug]/index.tsx +++ b/apps/mobile/app/dashboard/lists/[slug]/index.tsx @@ -96,16 +96,20 @@ function ListActionsMenu({ }; const handleLeave = () => { - Alert.alert("Leave List", "Are you sure you want to leave this list?", [ - { text: "Cancel", style: "cancel" }, - { - text: "Leave", - onPress: () => { - leaveList({ listId }); + Alert.alert( + "Leave List", + "Leaving removes the direct collaboration grant that gives you access. If this list is inherited from a recursively shared parent, you will leave that parent share and lose access to lists that depend on it.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Leave", + onPress: () => { + leaveList({ listId }); + }, + style: "destructive", }, - style: "destructive", - }, - ]); + ], + ); }; const handleEdit = () => { @@ -115,6 +119,13 @@ function ListActionsMenu({ }); }; + const handleCollaborators = () => { + router.push({ + pathname: "/dashboard/lists/[slug]/collaborators", + params: { slug: listId }, + }); + }; + return ( { @@ -172,6 +180,8 @@ function ListActionsMenu({ handleLeave(); } else if (nativeEvent.event === "edit") { handleEdit(); + } else if (nativeEvent.event === "collaborators") { + handleCollaborators(); } }} shouldOpenOnLongPress={false} diff --git a/apps/mobile/app/dashboard/lists/invitations.tsx b/apps/mobile/app/dashboard/lists/invitations.tsx new file mode 100644 index 000000000..cc679da2c --- /dev/null +++ b/apps/mobile/app/dashboard/lists/invitations.tsx @@ -0,0 +1,158 @@ +import { ScrollView, View } from "react-native"; +import { Stack } from "expo-router"; +import { Button } from "@/components/ui/Button"; +import FullPageSpinner from "@/components/ui/FullPageSpinner"; +import { Text } from "@/components/ui/Text"; +import { useToast } from "@/components/ui/Toast"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; + +export default function ListInvitationsPage() { + const api = useTRPC(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const { data: invitations, isPending } = useQuery( + api.lists.getPendingInvitations.queryOptions(), + ); + + const invalidate = () => + Promise.all([ + queryClient.invalidateQueries( + api.lists.getPendingInvitations.pathFilter(), + ), + queryClient.invalidateQueries(api.lists.list.pathFilter()), + ]); + + const acceptInvitation = useMutation( + api.lists.acceptInvitation.mutationOptions({ + onSuccess: async () => { + toast({ message: "Invitation accepted" }); + await invalidate(); + }, + onError: (error) => + toast({ message: error.message, variant: "destructive" }), + }), + ); + const declineInvitation = useMutation( + api.lists.declineInvitation.mutationOptions({ + onSuccess: async () => { + toast({ message: "Invitation declined" }); + await invalidate(); + }, + onError: (error) => + toast({ message: error.message, variant: "destructive" }), + }), + ); + + if (isPending) { + return ; + } + + return ( + <> + + + {!invitations || invitations.length === 0 ? ( + + + You have no pending list invitations. + + + ) : ( + invitations.map((invitation) => ( + + + + + {invitation.list.icon} {invitation.list.name} + + + {invitation.role} + + {invitation.recursive && ( + + Nested lists + + )} + {invitation.expired && ( + + Expired + + )} + + {invitation.list.owner && ( + + Invited by {invitation.list.owner.name} + + )} + {invitation.list.description && ( + + {invitation.list.description} + + )} + + {invitation.expired ? "Expired" : "Expires"}{" "} + {invitation.expiresAt.toLocaleDateString()} + + {invitation.expired && ( + + Ask the owner to resend this invitation to renew it for 30 + days. + + )} + + + + + + + + + + + )) + )} + + + ); +} From 6311652b4d48d20eef0beb5ce8c37baced976878 Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 17:22:49 +0700 Subject: [PATCH 08/87] fix: integrate stable collaboration stack --- .../trpc/models/listCollaborationAccess.ts | 75 ++-- packages/trpc/models/listInvitations.ts | 39 +-- packages/trpc/models/lists.ts | 330 ++++-------------- 3 files changed, 113 insertions(+), 331 deletions(-) diff --git a/packages/trpc/models/listCollaborationAccess.ts b/packages/trpc/models/listCollaborationAccess.ts index 4c2186923..f99e6c9e4 100644 --- a/packages/trpc/models/listCollaborationAccess.ts +++ b/packages/trpc/models/listCollaborationAccess.ts @@ -20,13 +20,9 @@ export interface EffectiveCollaboratorGrant { interface AccessibleListData { id: string; name: string; - description: string | null; - icon: string; userId: string; parentId: string | null; type: "manual" | "smart"; - query: string | null; - public: boolean; } async function getScope( @@ -180,28 +176,31 @@ export async function getAllSharedListAccess(ctx: AuthedContext) { ); const scopeByList = new Map(scopes.map((scope) => [scope.listId, scope])); const listById = new Map(allOwnerLists.map((list) => [list.id, list])); + const result: Array<{ + list: (typeof allOwnerLists)[number]; + grant: EffectiveCollaboratorGrant; + }> = []; - return allOwnerLists.flatMap((list) => { + for (const list of allOwnerLists) { if (list.type !== "manual") { - return []; + continue; } const direct = membershipByList.get(list.id); if (direct) { - return [ - { - list, - grant: { - membershipId: direct.id, - userId: ctx.user.id, - role: direct.role, - recursive: scopeByList.get(list.id)?.recursive ?? false, - inherited: false, - sourceListId: list.id, - sourceListName: list.name, - } satisfies EffectiveCollaboratorGrant, + result.push({ + list, + grant: { + membershipId: direct.id, + userId: ctx.user.id, + role: direct.role, + recursive: scopeByList.get(list.id)?.recursive ?? false, + inherited: false, + sourceListId: list.id, + sourceListName: list.name, }, - ]; + }); + continue; } let parentId = list.parentId; @@ -218,25 +217,25 @@ export async function getAllSharedListAccess(ctx: AuthedContext) { ancestor.type === "manual" && scopeByList.get(ancestor.id)?.recursive ) { - return [ - { - list, - grant: { - membershipId: ancestorMembership.id, - userId: ctx.user.id, - role: ancestorMembership.role, - recursive: true, - inherited: true, - sourceListId: ancestor.id, - sourceListName: ancestor.name, - } satisfies EffectiveCollaboratorGrant, + result.push({ + list, + grant: { + membershipId: ancestorMembership.id, + userId: ctx.user.id, + role: ancestorMembership.role, + recursive: true, + inherited: true, + sourceListId: ancestor.id, + sourceListName: ancestor.name, }, - ]; + }); + break; } parentId = ancestor.parentId; } - return []; - }); + } + + return result; } export async function getEffectiveCollaboratorsForList( @@ -256,13 +255,9 @@ export async function getEffectiveCollaboratorsForList( columns: { id: true, name: true, - description: true, - icon: true, userId: true, parentId: true, type: true, - query: true, - public: true, }, where: eq(bookmarkLists.id, parentId), }); @@ -291,7 +286,9 @@ export async function getEffectiveCollaboratorsForList( return []; } - const userIds = [...new Set(memberships.map((membership) => membership.userId))]; + const userIds = [ + ...new Set(memberships.map((membership) => membership.userId)), + ]; const scopes = await ctx.db.query.listCollaborationScopes.findMany({ where: and( inArray(listCollaborationScopes.listId, ancestryIds), diff --git a/packages/trpc/models/listInvitations.ts b/packages/trpc/models/listInvitations.ts index 8cf0bab56..f73e47858 100644 --- a/packages/trpc/models/listInvitations.ts +++ b/packages/trpc/models/listInvitations.ts @@ -63,10 +63,6 @@ export class ListInvitation { return invitationIsExpired(this.invitation.invitedAt); } - /** - * Load an invitation by ID. Unauthorized callers intentionally receive - * NOT_FOUND so invitation IDs do not become an account/list oracle. - */ static async fromId( ctx: AuthedContext, invitationId: string, @@ -92,7 +88,6 @@ export class ListInvitation { const isInvitedUser = invitation.userId === ctx.user.id; const isListOwner = invitation.list.userId === ctx.user.id; - if (!isInvitedUser && !isListOwner) { throw new TRPCError({ code: "NOT_FOUND", @@ -163,7 +158,6 @@ export class ListInvitation { await tx .delete(listInvitations) .where(eq(listInvitations.id, this.invitation.id)); - await tx .insert(listCollaborators) .values({ @@ -173,8 +167,6 @@ export class ListInvitation { addedBy: this.invitation.invitedBy, }) .onConflictDoNothing(); - // The scope row deliberately survives invitation -> membership so the - // accepted direct grant keeps the invitation's recursive setting. }); } @@ -191,7 +183,6 @@ export class ListInvitation { async revoke(): Promise { this.ensureIsListOwner(); - await this.ctx.db .delete(listInvitations) .where(eq(listInvitations.id, this.invitation.id)); @@ -219,7 +210,6 @@ export class ListInvitation { this.invitation.recursive = params.recursive; } - /** Renew the invitation for another 30 days, then attempt delivery. */ async resend(): Promise { this.ensureIsListOwner(); this.ensurePending(); @@ -233,10 +223,6 @@ export class ListInvitation { return this.sendEmail(); } - /** - * Attempt delivery for an already-committed invitation. SMTP failure never - * changes invitation state; callers can report the delivery result truthfully. - */ async sendEmail(): Promise { if (!this.invitation.invitedEmail) { return false; @@ -263,11 +249,6 @@ export class ListInvitation { } } - /** - * Create or reactivate an invitation. This mutates database state only; - * email must be attempted by the caller after the surrounding transaction - * has committed. - */ static async inviteByEmail( ctx: AuthedContext, params: { @@ -303,16 +284,12 @@ export class ListInvitation { const user = await ctx.db.query.users.findFirst({ where: sql`lower(${users.email}) = ${normalizedEmail}`, }); - - // Keep unknown-address failures neutral to avoid confirming whether an - // arbitrary email address has a Marka account. if (!user) { throw new TRPCError({ code: "BAD_REQUEST", message: "Unable to create an invitation for that email address", }); } - if (user.id === listOwnerId) { throw new TRPCError({ code: "BAD_REQUEST", @@ -328,7 +305,6 @@ export class ListInvitation { ), }, ); - if (existingCollaborator) { throw new TRPCError({ code: "BAD_REQUEST", @@ -342,7 +318,6 @@ export class ListInvitation { eq(listInvitations.userId, user.id), ), }); - if (existingInvitation?.status === "pending") { throw new TRPCError({ code: "BAD_REQUEST", @@ -454,13 +429,8 @@ export class ListInvitation { ctx: AuthedContext, params: { listId: string }, ) { - // Declined invitations remain usable for a later re-invite but intentionally - // disappear from the normal owner management surface. const invitations = await ctx.db.query.listInvitations.findMany({ - where: and( - eq(listInvitations.listId, params.listId), - eq(listInvitations.status, "pending"), - ), + where: eq(listInvitations.listId, params.listId), with: { user: { columns: { @@ -494,9 +464,10 @@ export class ListInvitation { expired: expiresAt.getTime() <= Date.now(), user: { id: invitation.user.id, - // Protect the user's identity until they accept. The owner already - // knows the address they invited, so showing the email is safe. - name: "Pending User", + name: + invitation.status === "pending" + ? "Pending User" + : "Declined User", email: invitation.user.email || "", image: null, }, diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts index 13520b5ab..41002643a 100644 --- a/packages/trpc/models/lists.ts +++ b/packages/trpc/models/lists.ts @@ -58,8 +58,6 @@ export abstract class List { return this.list; } - // There's some privacy implications here, so we need to think twice - // about the values that we return. return { id: this.list.id, name: this.list.name, @@ -70,10 +68,7 @@ export abstract class List { query: this.list.query, userRole: this.list.userRole, hasCollaborators: this.list.hasCollaborators, - - // Hide parentId so an inherited share doesn't leak private hierarchy. parentId: null, - // Hide whether the list is public or not. public: false, }; } @@ -85,32 +80,26 @@ export abstract class List { ) { if (data.type === "smart") { return new SmartList(ctx, data); - } else { - return new ManualList(ctx, data, collaboratorEntry); } + return new ManualList(ctx, data, collaboratorEntry); } static async fromId( ctx: AuthedContext, id: string, ): Promise { - // First try to find the list owned by the user. let list = await (async (): Promise< (ZBookmarkList & { userId: string }) | undefined > => { const l = await ctx.db.query.bookmarkLists.findFirst({ - columns: { - rssToken: false, - }, + columns: { rssToken: false }, where: and( eq(bookmarkLists.id, id), eq(bookmarkLists.userId, ctx.user.id), ), with: { collaborators: { - columns: { - id: true, - }, + columns: { id: true }, limit: 1, }, }, @@ -124,13 +113,10 @@ export abstract class List { : l; })(); - // Otherwise resolve an exact direct grant or the nearest recursive ancestor. let collaboratorEntry: ListCollaboratorEntry | null = null; if (!list) { const candidate = await ctx.db.query.bookmarkLists.findFirst({ - columns: { - rssToken: false, - }, + columns: { rssToken: false }, where: eq(bookmarkLists.id, id), }); if (candidate) { @@ -141,27 +127,15 @@ export abstract class List { userRole: grant.role, hasCollaborators: true, }; - collaboratorEntry = { - // Contributions made through inherited editor access belong to the - // granting direct membership. Removing that grant therefore cleans - // those list-membership rows without deleting the bookmarks. - membershipId: grant.membershipId, - }; + collaboratorEntry = { membershipId: grant.membershipId }; } } } if (!list) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "List not found", - }); - } - if (list.type === "smart") { - return new SmartList(ctx, list); - } else { - return new ManualList(ctx, list, collaboratorEntry); + throw new TRPCError({ code: "NOT_FOUND", message: "List not found" }); } + return this.fromData(ctx, list, collaboratorEntry); } private static async getPublicList( @@ -178,18 +152,11 @@ export abstract class List { ), ), with: { - user: { - columns: { - name: true, - }, - }, + user: { columns: { name: true } }, }, }); if (!listdb) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "List not found", - }); + throw new TRPCError({ code: "NOT_FOUND", message: "List not found" }); } return listdb; } @@ -220,23 +187,18 @@ export abstract class List { }, ) { const listdb = await this.getPublicList(ctx, listId, token); - - // The token here acts as an authed context, so we can create - // an impersonating context for the list owner as long as - // we don't leak the context. const authedCtx = await buildImpersonatingAuthedContext(listdb.userId); const listObj = List.fromData( authedCtx, { ...listdb, userRole: "public", - hasCollaborators: false, // Public lists don't expose collaborators + hasCollaborators: false, }, null, ); const bookmarkIds = await listObj.getBookmarkIds(); const list = listObj.asZBookmarkList(); - const bookmarks = await Bookmark.loadMulti(authedCtx, { ids: bookmarkIds, includeContent: false, @@ -279,7 +241,7 @@ export abstract class List { { ...result, userRole: "owner", - hasCollaborators: false, // Newly created lists have no collaborators + hasCollaborators: false, }, null, ); @@ -297,15 +259,11 @@ export abstract class List { ctx: AuthedContext, ): Promise<(ManualList | SmartList)[]> { const lists = await ctx.db.query.bookmarkLists.findMany({ - columns: { - rssToken: false, - }, + columns: { rssToken: false }, where: and(eq(bookmarkLists.userId, ctx.user.id)), with: { collaborators: { - columns: { - id: true, - }, + columns: { id: true }, limit: 1, }, }, @@ -318,7 +276,7 @@ export abstract class List { userRole: "owner", hasCollaborators: l.collaborators.length > 0, }, - null /* this is an owned list */, + null, ), ); } @@ -327,30 +285,20 @@ export abstract class List { const lists = await ctx.db.query.bookmarksInLists.findMany({ where: eq(bookmarksInLists.bookmarkId, bookmarkId), with: { - list: { - columns: { - rssToken: false, - }, - }, + list: { columns: { rssToken: false } }, }, }); - // For owner lists, check whether they have direct collaborators. const ownerListIds = lists .filter((l) => l.list.userId === ctx.user.id) .map((l) => l.list.id); - const listsWithCollaborators = new Set(); if (ownerListIds.length > 0) { const collaborators = await ctx.db.query.listCollaborators.findMany({ where: inArray(listCollaborators.listId, ownerListIds), - columns: { - listId: true, - }, - }); - collaborators.forEach((c) => { - listsWithCollaborators.add(c.listId); + columns: { listId: true }, }); + collaborators.forEach((c) => listsWithCollaborators.add(c.listId)); } const resolved = await Promise.all( @@ -366,11 +314,8 @@ export abstract class List { null, ); } - const grant = await getEffectiveCollaboratorGrant(ctx, l.list); - if (!grant) { - return null; - } + if (!grant) return null; return this.fromData( ctx, { @@ -382,15 +327,11 @@ export abstract class List { ); }), ); - return resolved.filter( (list): list is ManualList | SmartList => list !== null, ); } - /** - * Check if the user can view this list and its bookmarks. - */ canUserView(): boolean { return switchCase(this.list.userRole, { owner: true, @@ -400,9 +341,6 @@ export abstract class List { }); } - /** - * Check if the user can edit this list (add/remove bookmarks). - */ canUserEdit(): boolean { return switchCase(this.list.userRole, { owner: true, @@ -412,10 +350,6 @@ export abstract class List { }); } - /** - * Check if the user can manage this list (edit metadata, delete, manage collaborators). - * Only the owner can manage the list. - */ canUserManage(): boolean { return switchCase(this.list.userRole, { owner: true, @@ -425,9 +359,6 @@ export abstract class List { }); } - /** - * Ensure the user can view this list. Throws if they cannot. - */ ensureCanView(): void { if (!this.canUserView()) { throw new TRPCError({ @@ -437,9 +368,6 @@ export abstract class List { } } - /** - * Ensure the user can edit this list. Throws if they cannot. - */ ensureCanEdit(): void { if (!this.canUserEdit()) { throw new TRPCError({ @@ -449,9 +377,6 @@ export abstract class List { } } - /** - * Ensure the user can manage this list. Throws if they cannot. - */ ensureCanManage(): void { if (!this.canUserManage()) { throw new TRPCError({ @@ -463,10 +388,7 @@ export abstract class List { protected async cleanupRulesAfterListDeletion(tx: KarakeepDBTransaction) { const rules = await tx - .select({ - id: ruleEngineRulesTable.id, - event: ruleEngineRulesTable.event, - }) + .select({ id: ruleEngineRulesTable.id, event: ruleEngineRulesTable.event }) .from(ruleEngineRulesTable) .where( and( @@ -488,14 +410,11 @@ export abstract class List { try { parsedEvent = JSON.parse(rule.event); } catch { - // Log and skip corrupted rule, continue with others console.error(`Failed to parse event JSON for rule ${rule.id}`); continue; } - const ruleEvent = zRuleEngineRuleEventSchema.safeParse(parsedEvent); if (!ruleEvent.success) { - // Log and skip invalid rule, continue with others console.error(`Failed to validate event schema for rule ${rule.id}`); continue; } @@ -510,14 +429,9 @@ export abstract class List { if (filtered.length === 0) { rulesToDelete.push(rule.id); } else { - const updatedEvent = { - ...ruleEventData, - listIds: filtered, - }; - rulesToUpdate.push({ id: rule.id, - event: JSON.stringify(updatedEvent), + event: JSON.stringify({ ...ruleEventData, listIds: filtered }), }); } } @@ -528,7 +442,6 @@ export abstract class List { .delete(ruleEngineRulesTable) .where(inArray(ruleEngineRulesTable.id, rulesToDelete)); } - if (rulesToUpdate.length > 0) { await Promise.all( rulesToUpdate.map(({ id, event }) => @@ -552,7 +465,7 @@ export abstract class List { eq(bookmarkLists.userId, this.ctx.user.id), ), ); - if (res.changes == 0) { + if (res.changes === 0) { throw new TRPCError({ code: "NOT_FOUND" }); } await this.cleanupRulesAfterListDeletion(tx); @@ -562,36 +475,27 @@ export abstract class List { async getChildren(): Promise<(ManualList | SmartList)[]> { const lists = await List.getAllOwned(this.ctx); const listById = new Map(lists.map((l) => [l.id, l])); - - const adjecencyList = new Map(); - - // Initialize all lists with empty arrays first - lists.forEach((l) => { - adjecencyList.set(l.id, []); - }); - - // Then populate the parent-child relationships + const adjacencyList = new Map(); + lists.forEach((l) => adjacencyList.set(l.id, [])); lists.forEach((l) => { const parentId = l.asZBookmarkList().parentId; if (parentId) { - const currentChildren = adjecencyList.get(parentId) ?? []; + const currentChildren = adjacencyList.get(parentId) ?? []; currentChildren.push(l.id); - adjecencyList.set(parentId, currentChildren); + adjacencyList.set(parentId, currentChildren); } }); const resultIds: string[] = []; const queue: string[] = [this.list.id]; - while (queue.length > 0) { const id = queue.pop()!; - const children = adjecencyList.get(id) ?? []; + const children = adjacencyList.get(id) ?? []; children.forEach((childId) => { queue.push(childId); resultIds.push(childId); }); } - return resultIds.map((id) => listById.get(id)!); } @@ -616,17 +520,14 @@ export abstract class List { ), ) .returning(); - if (result.length == 0) { + if (result.length === 0) { throw new TRPCError({ code: "NOT_FOUND" }); } invariant(result[0].userId === this.ctx.user.id); - // Fetch current collaborators to update hasCollaborators const collaboratorsCount = await this.ctx.db.query.listCollaborators.findMany({ where: eq(listCollaborators.listId, this.list.id), - columns: { - id: true, - }, + columns: { id: true }, limit: 1, }); this.list = { @@ -647,7 +548,7 @@ export abstract class List { ), ) .returning(); - if (result.length == 0) { + if (result.length === 0) { throw new TRPCError({ code: "NOT_FOUND" }); } return result[0].rssToken; @@ -670,7 +571,7 @@ export abstract class List { async regenRssToken() { this.ensureCanManage(); - return await this.setRssToken(crypto.randomBytes(32).toString("hex")); + return this.setRssToken(crypto.randomBytes(32).toString("hex")); } async clearRssToken() { @@ -678,19 +579,13 @@ export abstract class List { await this.setRssToken(null); } - /** - * Add a collaborator to this list by email. - * Creates a pending invitation that must be accepted by the user. - * Returns the invitation ID. - */ async addCollaboratorByEmail( email: string, role: "viewer" | "editor", recursive = false, ): Promise { this.ensureCanManage(); - - return await ListInvitation.inviteByEmail(this.ctx, { + return ListInvitation.inviteByEmail(this.ctx, { email, role, recursive, @@ -703,14 +598,8 @@ export abstract class List { }); } - /** - * Remove a direct collaborator grant from this list. - * Contributions tied to that direct membership are removed by the existing - * bookmarksInLists foreign key; underlying bookmarks remain untouched. - */ async removeCollaborator(userId: string): Promise { this.ensureCanManage(); - const result = await this.ctx.db .delete(listCollaborators) .where( @@ -719,7 +608,6 @@ export abstract class List { eq(listCollaborators.userId, userId), ), ); - if (result.changes === 0) { throw new TRPCError({ code: "NOT_FOUND", @@ -732,11 +620,6 @@ export abstract class List { }); } - /** - * Leave the direct grant that provides access. For inherited access this - * means leaving the granting recursive share, not creating a child-only - * denial override. - */ async leaveList(): Promise { if (this.list.userRole === "owner") { throw new TRPCError({ @@ -745,29 +628,22 @@ export abstract class List { "List owners cannot leave their own list. Delete the list instead.", }); } - if (!this.collaboratorEntry) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Collaborator not found", - }); - } - const membership = await this.ctx.db.query.listCollaborators.findFirst({ - where: and( - eq(listCollaborators.id, this.collaboratorEntry.membershipId), - eq(listCollaborators.userId, this.ctx.user.id), - ), - }); - if (!membership) { + const grant = await getEffectiveCollaboratorGrant(this.ctx, this.list); + if (!grant) { throw new TRPCError({ code: "NOT_FOUND", message: "Collaborator not found", }); } - const result = await this.ctx.db .delete(listCollaborators) - .where(eq(listCollaborators.id, membership.id)); + .where( + and( + eq(listCollaborators.id, grant.membershipId), + eq(listCollaborators.userId, this.ctx.user.id), + ), + ); if (result.changes === 0) { throw new TRPCError({ code: "NOT_FOUND", @@ -775,7 +651,7 @@ export abstract class List { }); } await deleteCollaborationScope(this.ctx, { - listId: membership.listId, + listId: grant.sourceListId, userId: this.ctx.user.id, }); } @@ -786,7 +662,6 @@ export abstract class List { recursive: boolean, ): Promise { this.ensureCanManage(); - const result = await this.ctx.db .update(listCollaborators) .set({ role }) @@ -796,7 +671,6 @@ export abstract class List { eq(listCollaborators.userId, userId), ), ); - if (result.changes === 0) { throw new TRPCError({ code: "NOT_FOUND", @@ -810,9 +684,6 @@ export abstract class List { }); } - /** - * Backwards-compatible role-only update. Preserve the existing direct scope. - */ async updateCollaboratorRole( userId: string, role: "viewer" | "editor", @@ -824,24 +695,15 @@ export abstract class List { await this.updateCollaborator(userId, role, recursive); } - /** - * Get effective accepted collaborators plus this list's pending invitations. - * Accepted inherited grants are annotated with their source list. - */ async getCollaborators() { this.ensureCanView(); - const isOwner = this.list.userId === this.ctx.user.id; - const [collaborators, invitations] = await Promise.all([ getEffectiveCollaboratorsForList(this.ctx, this.list), isOwner - ? ListInvitation.invitationsForList(this.ctx, { - listId: this.list.id, - }) + ? ListInvitation.invitationsForList(this.ctx, { listId: this.list.id }) : [], ]); - const owner = await this.ctx.db.query.users.findFirst({ where: eq(users.id, this.list.userId), columns: { @@ -851,36 +713,30 @@ export abstract class List { image: true, }, }); - - const collaboratorEntries = collaborators.map((c) => { - return { - id: c.id, - userId: c.userId, - role: c.role, - recursive: c.recursive, - inherited: c.inherited, - sourceListId: c.sourceListId, - sourceListName: c.sourceListName, - status: "accepted" as const, - addedAt: c.addedAt, - invitedAt: c.addedAt, - user: { - id: c.user.id, - name: c.user.name, - // Only show email to the owner for privacy - email: isOwner ? c.user.email : null, - image: c.user.image, - }, - }; - }); - + const collaboratorEntries = collaborators.map((c) => ({ + id: c.id, + userId: c.userId, + role: c.role, + recursive: c.recursive, + inherited: c.inherited, + sourceListId: c.sourceListId, + sourceListName: c.sourceListName, + status: "accepted" as const, + addedAt: c.addedAt, + invitedAt: c.addedAt, + user: { + id: c.user.id, + name: c.user.name, + email: isOwner ? c.user.email : null, + image: c.user.image, + }, + })); return { collaborators: [...collaboratorEntries, ...invitations], owner: owner ? { id: owner.id, name: owner.name, - // Only show owner email to the owner for privacy email: isOwner ? owner.email : null, image: owner.image, } @@ -888,14 +744,10 @@ export abstract class List { }; } - /** - * Get all direct and inherited manual lists shared with the user. - */ static async getSharedWithUser( ctx: AuthedContext, ): Promise<(ManualList | SmartList)[]> { const collaborations = await getAllSharedListAccess(ctx); - return collaborations.map(({ list, grant }) => this.fromData( ctx, @@ -904,9 +756,7 @@ export abstract class List { userRole: grant.role, hasCollaborators: true, }, - { - membershipId: grant.membershipId, - }, + { membershipId: grant.membershipId }, ), ); } @@ -924,7 +774,6 @@ export abstract class List { export class SmartList extends List { private static readonly MAX_VISITED_LISTS = 30; - parsedQuery: ReturnType | null = null; constructor(ctx: AuthedContext, list: ZBookmarkList & { userId: string }) { @@ -953,22 +802,13 @@ export class SmartList extends List { } async getBookmarkIds(visitedListIds = new Set()): Promise { - if (visitedListIds.size >= SmartList.MAX_VISITED_LISTS) { - return []; - } - - if (visitedListIds.has(this.list.id)) { - return []; - } - + if (visitedListIds.size >= SmartList.MAX_VISITED_LISTS) return []; + if (visitedListIds.has(this.list.id)) return []; const newVisitedListIds = new Set(visitedListIds); newVisitedListIds.add(this.list.id); - const parsedQuery = this.getParsedQuery(); - if (!parsedQuery.matcher) { - return []; - } - return await getBookmarkIdsFromMatcher( + if (!parsedQuery.matcher) return []; + return getBookmarkIdsFromMatcher( this.ctx, parsedQuery.matcher, newVisitedListIds, @@ -976,7 +816,7 @@ export class SmartList extends List { } async getSize(): Promise { - return await this.getBookmarkIds().then((ids) => ids.length); + return this.getBookmarkIds().then((ids) => ids.length); } addBookmark(_bookmarkId: string): Promise { @@ -1036,7 +876,6 @@ export class ManualList extends List { async addBookmark(bookmarkId: string): Promise { this.ensureCanEdit(); - try { await this.ctx.db.insert(bookmarksInLists).values({ listId: this.list.id, @@ -1051,22 +890,14 @@ export class ManualList extends List { await RuleEngine.triggerOnEvent( bookmark.userId, bookmarkId, - [ - { - type: "addedToList", - listId: this.list.id, - }, - ], + [{ type: "addedToList", listId: this.list.id }], undefined, this.ctx.db, ); } } catch (e) { - if (e instanceof SqliteError) { - if (e.code == "SQLITE_CONSTRAINT_PRIMARYKEY") { - // this is fine, it just means the bookmark is already in the list - return; - } + if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") { + return; } throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", @@ -1076,9 +907,7 @@ export class ManualList extends List { } async removeBookmark(bookmarkId: string): Promise { - // Check that the user can edit this list this.ensureCanEdit(); - const deleted = await this.ctx.db .delete(bookmarksInLists) .where( @@ -1087,7 +916,7 @@ export class ManualList extends List { eq(bookmarksInLists.bookmarkId, bookmarkId), ), ); - if (deleted.changes == 0) { + if (deleted.changes === 0) { throw new TRPCError({ code: "BAD_REQUEST", message: `Bookmark ${bookmarkId} is already not in list ${this.list.id}`, @@ -1101,12 +930,7 @@ export class ManualList extends List { await RuleEngine.triggerOnEvent( bookmark.userId, bookmarkId, - [ - { - type: "removedFromList", - listId: this.list.id, - }, - ], + [{ type: "removedFromList", listId: this.list.id }], undefined, this.ctx.db, ); @@ -1135,24 +959,14 @@ export class ManualList extends List { message: "You can only merge into a manual list", }); } - const bookmarkIds = await this.getBookmarkIds(); - await this.ctx.db.transaction(async (tx) => { await tx .insert(bookmarksInLists) - .values( - bookmarkIds.map((id) => ({ - bookmarkId: id, - listId: targetList.id, - })), - ) + .values(bookmarkIds.map((id) => ({ bookmarkId: id, listId: targetList.id }))) .onConflictDoNothing(); - if (deleteSourceAfterMerge) { - await tx - .delete(bookmarkLists) - .where(eq(bookmarkLists.id, this.list.id)); + await tx.delete(bookmarkLists).where(eq(bookmarkLists.id, this.list.id)); await this.cleanupRulesAfterListDeletion(tx); } }); From f1888c992b70c6be306998c657cf53e6b33dc95c Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 17:28:20 +0700 Subject: [PATCH 09/87] test: cover stable list invitation lifecycle --- .../dashboard/lists/ListHeaderComponents.tsx | 245 +++++++++++------- .../trpc/models/listCollaborationAccess.ts | 4 +- packages/trpc/models/listInvitations.ts | 5 +- .../routers/stableListInvitations.test.ts | 167 ++++++++++++ 4 files changed, 319 insertions(+), 102 deletions(-) create mode 100644 packages/trpc/routers/stableListInvitations.test.ts diff --git a/apps/web/components/dashboard/lists/ListHeaderComponents.tsx b/apps/web/components/dashboard/lists/ListHeaderComponents.tsx index 61a85fdd2..357bd79cf 100644 --- a/apps/web/components/dashboard/lists/ListHeaderComponents.tsx +++ b/apps/web/components/dashboard/lists/ListHeaderComponents.tsx @@ -1,122 +1,175 @@ +"use client"; + +import { useState } from "react"; +import { ManageCollaboratorsModal } from "@/components/dashboard/lists/ManageCollaboratorsModal"; +import { Button } from "@/components/ui/button"; import { UserAvatar } from "@/components/ui/user-avatar"; import { useTranslation } from "@/lib/i18n/client"; -import { cn } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; +import { + CircleUserRound, + Globe, + Pencil, + Rss, + Share2, + Users, +} from "lucide-react"; + import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZBookmarkList } from "@karakeep/shared/types/lists"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { Globe, Lock, Users } from "lucide-react"; -export function ListPrivacyLabel({ - list, - className, -}: { - list: ZBookmarkList; - className?: string; -}) { - const { t } = useTranslation(); +import { EditListModal } from "./EditListModal"; +import { ListOptions } from "./ListOptions"; +import { ShareListModal } from "./ShareListModal"; - const privacy = list.public - ? { Icon: Globe, label: t("lists.privacy.public") } - : list.hasCollaborators - ? { Icon: Users, label: t("lists.privacy.shared") } - : { Icon: Lock, label: t("lists.privacy.private") }; - const PrivacyIcon = privacy.Icon; +export function ListPrivacyLabel({ list }: { list: ZBookmarkList }) { + const api = useTRPC(); + const { data } = useQuery( + api.lists.getCollaborators.queryOptions( + { listId: list.id }, + { + enabled: list.userRole === "owner" && list.type === "manual", + }, + ), + ); + const hasAcceptedCollaborators = + data?.collaborators.some( + (collaborator) => collaborator.status === "accepted", + ) ?? false; + if (list.userRole !== "owner") { + return
{list.userRole === "editor" ? "Can edit" : "Can view"}
; + } + if (list.public) { + return ( +
+ Public +
+ ); + } + if (list.hasCollaborators || hasAcceptedCollaborators) { + return ( +
+ Shared +
+ ); + } return ( - - - {privacy.label} - +
+ Private +
); } -export function ListCollaboratorsIcons({ - list, - className, -}: { - list: ZBookmarkList; - className?: string; -}) { +export function ListCollaboratorIcons({ list }: { list: ZBookmarkList }) { const api = useTRPC(); - const { data: collaboratorsData } = useQuery( + const { data, isLoading } = useQuery( api.lists.getCollaborators.queryOptions( + { listId: list.id }, { - listId: list.id, - }, - { - refetchOnWindowFocus: false, - enabled: list.hasCollaborators, + enabled: list.userRole === "owner" && list.type === "manual", }, ), ); + + if (list.userRole !== "owner" || isLoading || !data) { + return null; + } + + const acceptedCollaborators = data.collaborators.filter( + (collaborator) => collaborator.status === "accepted", + ); + if (acceptedCollaborators.length === 0) { + return null; + } + + const visibleCollaborators = acceptedCollaborators.slice(0, 4); + const remainingCount = acceptedCollaborators.length - 4; + return ( - list.hasCollaborators && - collaboratorsData && ( -
- {collaboratorsData.owner && ( - - -
- -
-
- -

{collaboratorsData.owner.name}

-
-
- )} - {collaboratorsData.collaborators.map((collab) => ( - - -
- -
-
- -

{collab.user.name}

-
-
- ))} -
- ) +
+ {visibleCollaborators.map((collaborator) => ( + + ))} + {remainingCount > 0 && ( +
+ +{remainingCount} +
+ )} +
); } -export function ListItemCount({ - list, - className, -}: { - list: ZBookmarkList; - className?: string; -}) { +export function ListHeaderActions({ list }: { list: ZBookmarkList }) { const { t } = useTranslation(); - const api = useTRPC(); - const { data: statsData } = useQuery( - api.lists.stats.queryOptions(undefined, { - placeholderData: keepPreviousData, - enabled: !!list?.id, - }), - ); - const itemCount = statsData?.stats.get(list.id); + const [editOpen, setEditOpen] = useState(false); + const [shareOpen, setShareOpen] = useState(false); + const [collaboratorsOpen, setCollaboratorsOpen] = useState(false); return ( - itemCount !== undefined && ( -
- {t("lists.items_count", { count: itemCount })} -
- ) +
+ {list.userRole === "owner" && ( + <> + {list.type === "manual" && ( + + )} + + + + )} + + {list.userRole === "owner" && ( + <> + + + {list.type === "manual" && ( + + )} + + )} +
); } diff --git a/packages/trpc/models/listCollaborationAccess.ts b/packages/trpc/models/listCollaborationAccess.ts index f99e6c9e4..d64eaee4f 100644 --- a/packages/trpc/models/listCollaborationAccess.ts +++ b/packages/trpc/models/listCollaborationAccess.ts @@ -176,10 +176,10 @@ export async function getAllSharedListAccess(ctx: AuthedContext) { ); const scopeByList = new Map(scopes.map((scope) => [scope.listId, scope])); const listById = new Map(allOwnerLists.map((list) => [list.id, list])); - const result: Array<{ + const result: { list: (typeof allOwnerLists)[number]; grant: EffectiveCollaboratorGrant; - }> = []; + }[] = []; for (const list of allOwnerLists) { if (list.type !== "manual") { diff --git a/packages/trpc/models/listInvitations.ts b/packages/trpc/models/listInvitations.ts index f73e47858..a8910a174 100644 --- a/packages/trpc/models/listInvitations.ts +++ b/packages/trpc/models/listInvitations.ts @@ -464,10 +464,7 @@ export class ListInvitation { expired: expiresAt.getTime() <= Date.now(), user: { id: invitation.user.id, - name: - invitation.status === "pending" - ? "Pending User" - : "Declined User", + name: "Pending User", email: invitation.user.email || "", image: null, }, diff --git a/packages/trpc/routers/stableListInvitations.test.ts b/packages/trpc/routers/stableListInvitations.test.ts new file mode 100644 index 000000000..efd10fcd0 --- /dev/null +++ b/packages/trpc/routers/stableListInvitations.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { eq } from "drizzle-orm"; + +import { listInvitations } from "@karakeep/db/schema"; + +import type { APICallerType, CustomTestContext } from "../testUtils"; +import { defaultBeforeEach } from "../testUtils"; + +beforeEach(defaultBeforeEach(true)); + +async function createManualList(api: APICallerType, name = "Shared") { + return api.lists.create({ + name, + icon: "πŸ“", + type: "manual", + }); +} + +describe("stable list invitation lifecycle", () => { + test("normalizes invite email and reports delivery separately", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const list = await createManualList(ownerApi); + + const result = await ownerApi.lists.addCollaborator({ + listId: list.id, + email: "TEST2@TEST.COM", + role: "viewer", + recursive: false, + }); + + expect(result.invitationId).toBeTruthy(); + expect(result.emailSent).toBe(false); + }); + + test("uses a neutral error for an unknown email", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const list = await createManualList(ownerApi); + + await expect( + ownerApi.lists.addCollaborator({ + listId: list.id, + email: "missing@example.com", + role: "viewer", + recursive: false, + }), + ).rejects.toThrow("Unable to create an invitation for that email address"); + }); + + test("expires invitations after 30 days and resend renews them", async ({ + apiCallers, + db, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + const list = await createManualList(ownerApi); + const collaborator = await collaboratorApi.users.whoami(); + + const { invitationId } = await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaborator.email!, + role: "viewer", + recursive: false, + }); + + const expiredAt = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000); + await db + .update(listInvitations) + .set({ invitedAt: expiredAt }) + .where(eq(listInvitations.id, invitationId)); + + const [expired] = await collaboratorApi.lists.getPendingInvitations(); + expect(expired.expired).toBe(true); + await expect( + collaboratorApi.lists.acceptInvitation({ invitationId }), + ).rejects.toThrow("Invitation has expired"); + + const resend = await ownerApi.lists.resendInvitation({ invitationId }); + expect(resend.emailSent).toBe(false); + + const [renewed] = await collaboratorApi.lists.getPendingInvitations(); + expect(renewed.expired).toBe(false); + expect(renewed.expiresAt.getTime()).toBeGreaterThan(Date.now()); + }); + + test("updates pending role and recursive scope before acceptance", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + const collaborator = await collaboratorApi.users.whoami(); + const parent = await createManualList(ownerApi, "Parent"); + const child = await ownerApi.lists.create({ + name: "Child", + icon: "πŸ“„", + type: "manual", + parentId: parent.id, + }); + + const { invitationId } = await ownerApi.lists.addCollaborator({ + listId: parent.id, + email: collaborator.email!, + role: "viewer", + recursive: false, + }); + await ownerApi.lists.updateInvitation({ + invitationId, + role: "editor", + recursive: true, + }); + + const collaborators = await ownerApi.lists.getCollaborators({ + listId: parent.id, + }); + const pending = collaborators.collaborators.find( + (entry) => entry.id === invitationId, + ); + expect(pending).toMatchObject({ + role: "editor", + recursive: true, + status: "pending", + }); + + await collaboratorApi.lists.acceptInvitation({ invitationId }); + expect( + (await collaboratorApi.lists.get({ listId: child.id })).userRole, + ).toBe("editor"); + }); + + test("reuses a declined invitation when the owner reinvites", async ({ + apiCallers, + }) => { + const ownerApi = apiCallers[0]; + const collaboratorApi = apiCallers[1]; + const collaborator = await collaboratorApi.users.whoami(); + const list = await createManualList(ownerApi); + + const first = await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaborator.email!, + role: "viewer", + recursive: false, + }); + await collaboratorApi.lists.declineInvitation({ + invitationId: first.invitationId, + }); + + const second = await ownerApi.lists.addCollaborator({ + listId: list.id, + email: collaborator.email!, + role: "editor", + recursive: true, + }); + expect(second.invitationId).toBe(first.invitationId); + + const [pending] = await collaboratorApi.lists.getPendingInvitations(); + expect(pending).toMatchObject({ + id: first.invitationId, + role: "editor", + recursive: true, + expired: false, + }); + }); +}); From 5d779d957303da9ba7529a5201ad81fe54970ccc Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 17:30:06 +0700 Subject: [PATCH 10/87] fix: support older TypeScript targets in invitation email --- packages/trpc/email.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/trpc/email.ts b/packages/trpc/email.ts index 26402c290..1b871bb81 100644 --- a/packages/trpc/email.ts +++ b/packages/trpc/email.ts @@ -50,11 +50,11 @@ function withTracing( function escapeHtml(value: string) { return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); } function sanitizeHeaderValue(value: string) { From b389917d3c0bf79940795fa770ea258d328b0c09 Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 17:32:49 +0700 Subject: [PATCH 11/87] chore: temporarily print React Doctor diagnostics --- scripts/check-react-doctor-score.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/check-react-doctor-score.mjs b/scripts/check-react-doctor-score.mjs index d4d64d047..eb8d5dbbe 100644 --- a/scripts/check-react-doctor-score.mjs +++ b/scripts/check-react-doctor-score.mjs @@ -19,6 +19,7 @@ process.stdin.on("end", () => { } if (score < minimumScore) { + console.error(JSON.stringify(report, null, 2)); throw new Error( `React Doctor score ${score} is below the required ${minimumScore}.`, ); From 5ac6fdb6f85b9f660548c8ece4417a5b7c63dcf0 Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 17:33:16 +0700 Subject: [PATCH 12/87] chore: temporarily print formatter diff --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3a2bdce6d..57686147e 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "android": "pnpm --filter @karakeep/mobile android", "ios": "pnpm --filter @karakeep/mobile ios", "prepare": "husky", - "format": "turbo --no-daemon format --continue", + "format": "turbo --no-daemon format:fix --continue && git diff --exit-code", "format:fix": "turbo --no-daemon format:fix --continue", "lint": "turbo --no-daemon lint --continue", "lint:fix": "turbo --no-daemon lint:fix --continue", From 9d46a193eb973e0a5a92db1a6295c1cf68d91bad Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 17:38:28 +0700 Subject: [PATCH 13/87] chore: compact React Doctor diagnostics --- scripts/check-react-doctor-score.mjs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/scripts/check-react-doctor-score.mjs b/scripts/check-react-doctor-score.mjs index eb8d5dbbe..7bcb4827c 100644 --- a/scripts/check-react-doctor-score.mjs +++ b/scripts/check-react-doctor-score.mjs @@ -19,7 +19,23 @@ process.stdin.on("end", () => { } if (score < minimumScore) { - console.error(JSON.stringify(report, null, 2)); + const diagnostics = (report.diagnostics ?? []).map((diagnostic) => ({ + file: diagnostic.filePath, + line: diagnostic.line, + rule: `${diagnostic.plugin}/${diagnostic.rule}`, + severity: diagnostic.severity, + message: diagnostic.message, + })); + console.error( + JSON.stringify( + { + summary: report.summary, + diagnostics, + }, + null, + 2, + ), + ); throw new Error( `React Doctor score ${score} is below the required ${minimumScore}.`, ); From d692e6d56083bb8ac172fd05964253d12270094f Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 17:41:36 +0700 Subject: [PATCH 14/87] fix: apply collaboration formatter output --- .../components/dashboard/bookmarks/bookmarkListPermissions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/components/dashboard/bookmarks/bookmarkListPermissions.ts b/apps/web/components/dashboard/bookmarks/bookmarkListPermissions.ts index 2b98e5668..3621cda15 100644 --- a/apps/web/components/dashboard/bookmarks/bookmarkListPermissions.ts +++ b/apps/web/components/dashboard/bookmarks/bookmarkListPermissions.ts @@ -5,7 +5,7 @@ export function canRemoveBookmarkFromList(input: { }) { return Boolean( input.listId && - input.listType === "manual" && - (input.userRole === "owner" || input.userRole === "editor"), + input.listType === "manual" && + (input.userRole === "owner" || input.userRole === "editor"), ); } From 206cf8c584debb26ce2a7375f979597a90e74e14 Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 17:41:56 +0700 Subject: [PATCH 15/87] fix: apply collaboration formatter output --- .../web/components/dashboard/lists/ListHeaderComponents.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/web/components/dashboard/lists/ListHeaderComponents.tsx b/apps/web/components/dashboard/lists/ListHeaderComponents.tsx index 357bd79cf..fca323e10 100644 --- a/apps/web/components/dashboard/lists/ListHeaderComponents.tsx +++ b/apps/web/components/dashboard/lists/ListHeaderComponents.tsx @@ -156,11 +156,7 @@ export function ListHeaderActions({ list }: { list: ZBookmarkList }) { {list.userRole === "owner" && ( <> - + {list.type === "manual" && ( Date: Sat, 15 Aug 2026 17:42:32 +0700 Subject: [PATCH 16/87] fix(web): format invitation dates deterministically --- apps/web/components/dashboard/lists/collaborationUi.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/web/components/dashboard/lists/collaborationUi.ts b/apps/web/components/dashboard/lists/collaborationUi.ts index bb30ac2c5..95902f5cf 100644 --- a/apps/web/components/dashboard/lists/collaborationUi.ts +++ b/apps/web/components/dashboard/lists/collaborationUi.ts @@ -1,3 +1,12 @@ +const invitationDateFormatter = new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeZone: "UTC", +}); + +export function formatInvitationDate(date: Date) { + return invitationDateFormatter.format(date); +} + export function invitationDeliveryMessage(emailSent: boolean) { return emailSent ? "Invitation created and email sent." From d59f3b74702e63cb8ca44b8714654c5cb3239edf Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 17:43:37 +0700 Subject: [PATCH 17/87] fix(web): harden stable collaborator management --- .../lists/ManageCollaboratorsModal.tsx | 432 ++++++------------ 1 file changed, 148 insertions(+), 284 deletions(-) diff --git a/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx b/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx index 50a82b43e..fd8709f78 100644 --- a/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx +++ b/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx @@ -26,14 +26,7 @@ import { toast } from "@/components/ui/sonner"; import { UserAvatar } from "@/components/ui/user-avatar"; import { useTranslation } from "@/lib/i18n/client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - Clock3, - Loader2, - RefreshCw, - Trash2, - UserPlus, - Users, -} from "lucide-react"; +import { Clock3, Loader2, RefreshCw, Trash2, UserPlus, Users } from "lucide-react"; import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZBookmarkList } from "@karakeep/shared/types/lists"; @@ -41,6 +34,7 @@ import { ZBookmarkList } from "@karakeep/shared/types/lists"; import { canManageCollaboratorOnList, collaboratorRemovalMessage, + formatInvitationDate, invitationDeliveryMessage, } from "./collaborationUi"; @@ -64,30 +58,28 @@ export function ManageCollaboratorsModal({ ) { throw new Error("You must provide both open and setOpen or neither"); } - const [customOpen, customSetOpen] = useState(false); - const [open, setOpen] = [ - userOpen ?? customOpen, - userSetOpen ?? customSetOpen, - ]; - - const [newCollaboratorEmail, setNewCollaboratorEmail] = useState(""); - const [newCollaboratorRole, setNewCollaboratorRole] = useState< - "viewer" | "editor" - >("viewer"); - const [newCollaboratorRecursive, setNewCollaboratorRecursive] = - useState(false); + const [customOpen, customSetOpen] = useState(false); + const open = userOpen ?? customOpen; + const setOpen = userSetOpen ?? customSetOpen; + const [email, setEmail] = useState(""); + const [role, setRole] = useState<"viewer" | "editor">("viewer"); + const [recursive, setRecursive] = useState(false); const { t } = useTranslation(); const queryClient = useQueryClient(); - const invalidateListCaches = () => + const { data, isLoading } = useQuery( + api.lists.getCollaborators.queryOptions( + { listId: list.id }, + { enabled: open }, + ), + ); + + const invalidate = () => Promise.all([ queryClient.invalidateQueries( api.lists.getCollaborators.queryFilter({ listId: list.id }), ), - queryClient.invalidateQueries( - api.lists.get.queryFilter({ listId: list.id }), - ), queryClient.invalidateQueries(api.lists.list.pathFilter()), queryClient.invalidateQueries( api.lists.getPendingInvitations.pathFilter(), @@ -100,122 +92,69 @@ export function ManageCollaboratorsModal({ ), ]); - const { data: collaboratorsData, isLoading } = useQuery( - api.lists.getCollaborators.queryOptions( - { listId: list.id }, - { enabled: open }, - ), - ); - const addCollaborator = useMutation( api.lists.addCollaborator.mutationOptions({ onSuccess: async (result) => { - toast({ - description: invitationDeliveryMessage(result.emailSent), - }); - setNewCollaboratorEmail(""); - setNewCollaboratorRole("viewer"); - setNewCollaboratorRecursive(false); - await invalidateListCaches(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("lists.collaborators.failed_to_add"), - }); - }, - }), - ); - - const removeCollaborator = useMutation( - api.lists.removeCollaborator.mutationOptions({ - onSuccess: async () => { - toast({ description: t("lists.collaborators.removed") }); - await invalidateListCaches(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: - error.message || t("lists.collaborators.failed_to_remove"), - }); + toast({ description: invitationDeliveryMessage(result.emailSent) }); + setEmail(""); + setRole("viewer"); + setRecursive(false); + await invalidate(); }, + onError: (error) => + toast({ variant: "destructive", description: error.message }), }), ); - const updateCollaborator = useMutation( api.lists.updateCollaborator.mutationOptions({ - onSuccess: async () => { - toast({ description: t("lists.collaborators.role_updated") }); - await invalidateListCaches(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: - error.message || t("lists.collaborators.failed_to_update_role"), - }); - }, + onSuccess: invalidate, + onError: (error) => + toast({ variant: "destructive", description: error.message }), + }), + ); + const removeCollaborator = useMutation( + api.lists.removeCollaborator.mutationOptions({ + onSuccess: invalidate, + onError: (error) => + toast({ variant: "destructive", description: error.message }), }), ); - const updateInvitation = useMutation( api.lists.updateInvitation.mutationOptions({ - onSuccess: invalidateListCaches, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message, - }); - }, + onSuccess: invalidate, + onError: (error) => + toast({ variant: "destructive", description: error.message }), }), ); - const resendInvitation = useMutation( api.lists.resendInvitation.mutationOptions({ onSuccess: async (result) => { toast({ description: invitationDeliveryMessage(result.emailSent) }); - await invalidateListCaches(); - }, - onError: (error) => { - toast({ variant: "destructive", description: error.message }); + await invalidate(); }, + onError: (error) => + toast({ variant: "destructive", description: error.message }), }), ); - const revokeInvitation = useMutation( api.lists.revokeInvitation.mutationOptions({ - onSuccess: async () => { - toast({ - description: t("lists.collaborators.invitation_revoked"), - }); - await invalidateListCaches(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: - error.message || t("lists.collaborators.failed_to_revoke"), - }); - }, + onSuccess: invalidate, + onError: (error) => + toast({ variant: "destructive", description: error.message }), }), ); - const handleAddCollaborator = () => { - const email = newCollaboratorEmail.trim(); - if (!email) { - toast({ - variant: "destructive", - description: t("lists.collaborators.please_enter_email"), - }); - return; - } + const visibleCollaborators = + data?.collaborators.filter((entry) => entry.status !== "declined") ?? []; + const invite = () => { + const normalizedEmail = email.trim(); + if (!normalizedEmail) return; addCollaborator.mutate({ listId: list.id, - email, - role: newCollaboratorRole, - recursive: newCollaboratorRecursive, + email: normalizedEmail, + role, + recursive, }); }; @@ -225,7 +164,7 @@ export function ManageCollaboratorsModal({ - + {readOnly ? t("lists.collaborators.collaborators") : t("lists.collaborators.manage")} @@ -233,7 +172,7 @@ export function ManageCollaboratorsModal({ {readOnly ? t("lists.collaborators.people_with_access") - : "Invite people to this list and choose whether access also follows its current and future nested lists."} + : "Invite people to this list and optionally include current and future nested lists."} @@ -242,137 +181,101 @@ export function ManageCollaboratorsModal({
-
- setNewCollaboratorEmail(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleAddCollaborator(); - } - }} - /> -
-
- - -
+ setEmail(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") invite(); + }} + /> + +
- - -

- {t("lists.collaborators.viewer")}:{" "} - {t("lists.collaborators.viewer_description")} -
- {t("lists.collaborators.editor")}:{" "} - {t("lists.collaborators.editor_description")} -

)}
- + {isLoading ? (
- +
- ) : collaboratorsData ? ( + ) : (
- {collaboratorsData.owner && ( + {data?.owner && (
-
+
-
+
- {collaboratorsData.owner.name} + {data.owner.name}
- {collaboratorsData.owner.email && ( + {data.owner.email && (
- {collaboratorsData.owner.email} + {data.owner.email}
)}
-
- {t("lists.collaborators.owner")} -
+ Owner
)} - {collaboratorsData.collaborators.map((collaborator) => { - const canManage = canManageCollaboratorOnList(collaborator); - const isPending = collaborator.status === "pending"; - const disabledPending = isPending && collaborator.expired; + {visibleCollaborators.map((collaborator) => { + const pending = collaborator.status === "pending"; + const expired = pending && collaborator.expired; + const manageable = canManageCollaboratorOnList(collaborator); return ( -
+
-
+
-
+
-
+ {collaborator.user.name} -
- {isPending && !collaborator.expired && ( - Pending - )} - {isPending && collaborator.expired && ( - Expired + + {pending && ( + + {expired ? "Expired" : "Pending"} + )} {collaborator.inherited && ( Inherited @@ -405,56 +307,45 @@ export function ManageCollaboratorsModal({ {collaborator.user.email}
)} - {collaborator.inherited && - collaborator.sourceListName && ( -
- Inherited from {collaborator.sourceListName} -
- )} - {isPending && collaborator.expiresAt && ( -
+ {collaborator.inherited && collaborator.sourceListName && ( +
+ Inherited from {collaborator.sourceListName} +
+ )} + {pending && collaborator.expiresAt && ( +
- {collaborator.expired - ? "Expired" - : "Expires"}{" "} - {new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - }).format(collaborator.expiresAt)} + {expired ? "Expired" : "Expires"}{" "} + {formatInvitationDate(collaborator.expiresAt)}
)}
- {readOnly ? ( -
- {collaborator.role} -
- ) : collaborator.inherited ? ( + {readOnly || collaborator.inherited ? (
{collaborator.role} - {collaborator.user.email && ( + {!readOnly && collaborator.inherited && collaborator.user.email && ( )}
- ) : isPending ? ( + ) : pending ? (
updateInvitation.mutate({ invitationId: collaborator.id, @@ -490,32 +378,29 @@ export function ManageCollaboratorsModal({ Nested lists
- ) : canManage ? ( + ) : manageable ? (
- - Role - - - - - - + {canManage && ( + + Invite collaborator + + + Role + + + + + + + - - - - Also share all nested lists - - Includes current nested lists and lists added or moved here - later. - + + + Also share all nested lists + + Includes current nested lists and lists added or moved here + later. + + + - + - - + )} People with access @@ -244,7 +250,11 @@ export default function ManageListCollaboratorsPage() { )} - {collaborator.inherited ? ( + {!canManage ? ( + + {collaborator.role} + + ) : collaborator.inherited ? ( {collaborator.role} From 5c71d3871b140f45dbea9f9a0a89c94d591cca98 Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 18:39:56 +0700 Subject: [PATCH 39/87] chore: stage collaboration backend review fixes --- scripts/apply-collaboration-review-fixes.py | 445 ++++++++++++++++++++ 1 file changed, 445 insertions(+) create mode 100644 scripts/apply-collaboration-review-fixes.py diff --git a/scripts/apply-collaboration-review-fixes.py b/scripts/apply-collaboration-review-fixes.py new file mode 100644 index 000000000..771e9c0f9 --- /dev/null +++ b/scripts/apply-collaboration-review-fixes.py @@ -0,0 +1,445 @@ +from pathlib import Path + + +def replace_once(path: str, old: str, new: str) -> None: + file = Path(path) + text = file.read_text() + if text.count(old) != 1: + raise RuntimeError(f"Expected exactly one match in {path}, found {text.count(old)}") + file.write_text(text.replace(old, new, 1)) + + +# lists model: prevent cycles, bound traversal, and don't leak private ancestor metadata. +replace_once( + "packages/trpc/models/lists.ts", + ''' const resultIds: string[] = []; + const queue: string[] = [this.list.id]; + while (queue.length > 0) { + const id = queue.pop()!; + const children = adjacencyList.get(id) ?? []; + children.forEach((childId) => { + queue.push(childId); + resultIds.push(childId); + }); + } + return resultIds.map((id) => listById.get(id)!);''', + ''' const resultIds: string[] = []; + const queue: string[] = [this.list.id]; + const visited = new Set([this.list.id]); + while (queue.length > 0) { + const id = queue.pop()!; + const children = adjacencyList.get(id) ?? []; + children.forEach((childId) => { + if (visited.has(childId)) return; + visited.add(childId); + queue.push(childId); + resultIds.push(childId); + }); + } + return resultIds.map((id) => listById.get(id)!);''', +) +replace_once( + "packages/trpc/models/lists.ts", + ''' ): Promise { + this.ensureCanManage(); + const result = await this.ctx.db + .update(bookmarkLists)''', + ''' ): Promise { + this.ensureCanManage(); + if (input.parentId !== undefined && input.parentId !== null) { + if (input.parentId === this.list.id) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "A list cannot be its own parent", + }); + } + const descendants = await this.getChildren(); + if (descendants.some((descendant) => descendant.id === input.parentId)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "A list cannot be moved inside one of its descendants", + }); + } + } + const result = await this.ctx.db + .update(bookmarkLists)''', +) +replace_once( + "packages/trpc/models/lists.ts", + ''' sourceListId: c.sourceListId, + sourceListName: c.sourceListName,''', + ''' sourceListId: isOwner ? c.sourceListId : undefined, + sourceListName: isOwner ? c.sourceListName : null,''', +) + +# Router: batch inherited grant resolution and reconcile offline access deltas. +replace_once( + "packages/trpc/routers/lists.ts", + ''' getEffectiveCollaboratorGrant, + getEffectiveCollaboratorsForList, +} from "../models/listCollaborationAccess";''', + ''' getEffectiveCollaboratorGrant, + getEffectiveCollaboratorGrantsForOwner, + getEffectiveCollaboratorsForList, +} from "../models/listCollaborationAccess";''', +) +replace_once( + "packages/trpc/routers/lists.ts", + '''async function inheritedListIdsFromGrant( + ctx: AuthedContext, + sourceListId: string, + userId: string, +) { + const source = await List.fromId(ctx, sourceListId); + const descendants = await source.getChildren(); + const result = [sourceListId]; + for (const descendant of descendants) { + const serialized = descendant.asZBookmarkList(); + const grant = await getEffectiveCollaboratorGrant(ctx, serialized, userId); + if (grant?.sourceListId === sourceListId) { + result.push(serialized.id); + } + } + return result; +}''', + '''async function inheritedListIdsFromGrant( + ctx: AuthedContext, + sourceListId: string, + userId: string, +) { + const source = await List.fromId(ctx, sourceListId); + const ownerId = source.asZBookmarkList().userId; + const grants = await getEffectiveCollaboratorGrantsForOwner( + ctx, + ownerId, + userId, + ); + return grants + .filter(({ grant }) => grant.sourceListId === sourceListId) + .map(({ list }) => list.id); +} + +async function listAccessSnapshot( + ctx: AuthedContext, + listIds: string[], + ownerId: string, +) { + const result = new Map>(); + for (const listId of listIds) { + result.set(listId, new Set(await listSyncUserIds(ctx, listId, ownerId))); + } + return result; +} + +async function recordListAccessDiff( + tx: KarakeepDBTransaction, + listId: string, + before: Set, + after: Set, + changedFields: string[], + updateRetained: boolean, +) { + const revoked = [...before].filter((userId) => !after.has(userId)); + const created = [...after].filter((userId) => !before.has(userId)); + const retained = updateRetained + ? [...after].filter((userId) => before.has(userId)) + : []; + if (revoked.length > 0) { + await recordListSyncEvent(tx, revoked, listId, "revoke", []); + } + if (created.length > 0) { + await recordListSyncEvent(tx, created, listId, "create", changedFields); + } + if (retained.length > 0) { + await recordListSyncEvent(tx, retained, listId, "update", changedFields); + } +}''', +) +replace_once( + "packages/trpc/routers/lists.ts", + ''' await recordListSyncEvent(tx, [ctx.user.id], list.id, "create", [ + "name", + "description", + "icon", + "parentId", + "query", + ]);''', + ''' const serialized = list.asZBookmarkList(); + await recordListSyncEvent( + tx, + await listSyncUserIds( + transactionCtx, + serialized.id, + serialized.userId, + ), + serialized.id, + "create", + ["name", "description", "icon", "parentId", "query"], + );''', +) +replace_once( + "packages/trpc/routers/lists.ts", + ''' const list = await List.fromId(transactionCtx, input.listId); + await list.update(input); + const serialized = list.asZBookmarkList(); + await recordListSyncEvent( + tx, + await listSyncUserIds( + transactionCtx, + serialized.id, + serialized.userId, + ), + serialized.id, + "update", + [ + ...(input.name !== undefined ? ["name"] : []), + ...(input.description !== undefined ? ["description"] : []), + ...(input.icon !== undefined ? ["icon"] : []), + ...(input.parentId !== undefined ? ["parentId"] : []), + ...(input.query !== undefined ? ["query"] : []), + ...(input.public !== undefined ? ["public"] : []), + ], + ); + return serialized;''', + ''' const list = await List.fromId(transactionCtx, input.listId); + const beforeSerialized = list.asZBookmarkList(); + const changedFields = [ + ...(input.name !== undefined ? ["name"] : []), + ...(input.description !== undefined ? ["description"] : []), + ...(input.icon !== undefined ? ["icon"] : []), + ...(input.parentId !== undefined ? ["parentId"] : []), + ...(input.query !== undefined ? ["query"] : []), + ...(input.public !== undefined ? ["public"] : []), + ]; + const affectedListIds = + input.parentId !== undefined + ? [ + beforeSerialized.id, + ...(await list.getChildren()).map((child) => child.id), + ] + : [beforeSerialized.id]; + const beforeAccess = await listAccessSnapshot( + transactionCtx, + affectedListIds, + beforeSerialized.userId, + ); + + await list.update(input); + const serialized = list.asZBookmarkList(); + const afterAccess = await listAccessSnapshot( + transactionCtx, + affectedListIds, + serialized.userId, + ); + for (const listId of affectedListIds) { + await recordListAccessDiff( + tx, + listId, + beforeAccess.get(listId) ?? new Set(), + afterAccess.get(listId) ?? new Set(), + listId === serialized.id ? changedFields : [], + listId === serialized.id, + ); + } + return serialized;''', +) +replace_once( + "packages/trpc/routers/lists.ts", + ''' const list = await List.fromId(transactionCtx, input.listId); + await list.updateCollaborator( + input.userId, + input.role, + input.recursive, + ); + const serialized = list.asZBookmarkList(); + await recordListSyncEvent( + tx, + await listSyncUserIds( + transactionCtx, + serialized.id, + serialized.userId, + ), + serialized.id, + "update", + ["collaborators"], + );''', + ''' const list = await List.fromId(transactionCtx, input.listId); + const serialized = list.asZBookmarkList(); + const beforeListIds = await inheritedListIdsFromGrant( + transactionCtx, + serialized.id, + input.userId, + ); + await list.updateCollaborator( + input.userId, + input.role, + input.recursive, + ); + const afterListIds = await inheritedListIdsFromGrant( + transactionCtx, + serialized.id, + input.userId, + ); + const beforeSet = new Set(beforeListIds); + const afterSet = new Set(afterListIds); + await recordListSyncEvent( + tx, + [serialized.userId], + serialized.id, + "update", + ["collaborators"], + ); + for (const listId of beforeSet) { + if (!afterSet.has(listId)) { + await recordListSyncEvent(tx, [input.userId], listId, "revoke", []); + } + } + for (const listId of afterSet) { + await recordListSyncEvent( + tx, + [input.userId], + listId, + beforeSet.has(listId) ? "update" : "create", + ["collaborators"], + ); + }''', +) +replace_once( + "packages/trpc/routers/lists.ts", + ''' const list = await List.fromId(transactionCtx, input.listId); + await list.updateCollaboratorRole(input.userId, input.role); + const serialized = list.asZBookmarkList(); + await recordListSyncEvent( + tx, + await listSyncUserIds( + transactionCtx, + serialized.id, + serialized.userId, + ), + serialized.id, + "update", + ["collaborators"], + );''', + ''' const list = await List.fromId(transactionCtx, input.listId); + const serialized = list.asZBookmarkList(); + const affectedListIds = await inheritedListIdsFromGrant( + transactionCtx, + serialized.id, + input.userId, + ); + await list.updateCollaboratorRole(input.userId, input.role); + await recordListSyncEvent( + tx, + [serialized.userId], + serialized.id, + "update", + ["collaborators"], + ); + for (const listId of affectedListIds) { + await recordListSyncEvent( + tx, + [input.userId], + listId, + "update", + ["collaborators"], + ); + }''', +) +replace_once( + "packages/trpc/routers/lists.ts", + ''' await invitation.accept(); + if (invitationData) { + await recordListSyncEvent( + tx, + [ctx.user.id, invitationData.listOwnerUserId], + invitationData.listId, + "create", + ["collaborators"], + ); + }''', + ''' await invitation.accept(); + if (invitationData) { + const createdListIds = await inheritedListIdsFromGrant( + transactionCtx, + invitationData.listId, + ctx.user.id, + ); + await recordListSyncEvent( + tx, + [invitationData.listOwnerUserId], + invitationData.listId, + "update", + ["collaborators"], + ); + for (const listId of createdListIds) { + await recordListSyncEvent( + tx, + [ctx.user.id], + listId, + "create", + ["collaborators"], + ); + } + }''', +) +replace_once( + "packages/trpc/routers/lists.ts", + ''' .output(z.object({ emailSent: z.boolean() })) + .use(ensureInvitationAccess)''', + ''' .output(z.object({ emailSent: z.boolean() })) + .use( + createRateLimitMiddleware({ + name: "lists.resendInvitation", + windowMs: 15 * 60 * 1000, + maxRequests: 5, + }), + ) + .use(ensureInvitationAccess)''', +) +replace_once( + "packages/trpc/routers/lists.ts", + ''' const list = await List.fromId(transactionCtx, input.listId); + const serialized = list.asZBookmarkList(); + await list.leaveList(); + await recordListSyncEvent( + tx, + [ctx.user.id], + serialized.id, + "revoke", + [], + );''', + ''' const list = await List.fromId(transactionCtx, input.listId); + const serialized = list.asZBookmarkList(); + const grant = await getEffectiveCollaboratorGrant( + transactionCtx, + serialized, + ctx.user.id, + ); + if (!grant) { + throw new Error("Expected an effective collaboration grant"); + } + const source = await List.fromId(transactionCtx, grant.sourceListId); + const sourceSerialized = source.asZBookmarkList(); + const revokedListIds = await inheritedListIdsFromGrant( + transactionCtx, + grant.sourceListId, + ctx.user.id, + ); + await list.leaveList(); + await recordListSyncEvent( + tx, + [sourceSerialized.userId], + sourceSerialized.id, + "update", + ["collaborators"], + ); + for (const listId of revokedListIds) { + await recordListSyncEvent( + tx, + [ctx.user.id], + listId, + "revoke", + [], + ); + }''', +) From 1ed3ed7254c4755039dbeec86686aca2a20bf631 Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 18:40:11 +0700 Subject: [PATCH 40/87] ci: apply collaboration backend review patch --- .../workflows/collaboration-review-fix.yml | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/collaboration-review-fix.yml diff --git a/.github/workflows/collaboration-review-fix.yml b/.github/workflows/collaboration-review-fix.yml new file mode 100644 index 000000000..8b9410b18 --- /dev/null +++ b/.github/workflows/collaboration-review-fix.yml @@ -0,0 +1,36 @@ +name: Apply collaboration review fixes + +on: + pull_request: + branches: ["main"] + +permissions: + contents: write + +concurrency: + group: collaboration-review-fix-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + apply: + if: github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'feat/stable-list-collaboration' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: true + + - name: Setup + uses: ./tooling/github/setup + + - name: Apply reviewed backend patch + run: | + set -euo pipefail + python scripts/apply-collaboration-review-fixes.py + pnpm --filter @karakeep/trpc format:fix + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add packages/trpc/models/lists.ts packages/trpc/routers/lists.ts + git commit --no-verify -m "fix: reconcile recursive collaboration state" + git push origin HEAD:${{ github.head_ref }} From 7a234e1902a1385cf79df8899555db3827579a89 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:41:17 +0000 Subject: [PATCH 41/87] fix: reconcile recursive collaboration state --- packages/trpc/models/lists.ts | 22 +++- packages/trpc/routers/lists.ts | 223 +++++++++++++++++++++++++-------- 2 files changed, 193 insertions(+), 52 deletions(-) diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts index d7092ccbc..378fb6dc5 100644 --- a/packages/trpc/models/lists.ts +++ b/packages/trpc/models/lists.ts @@ -491,10 +491,13 @@ export abstract class List { const resultIds: string[] = []; const queue: string[] = [this.list.id]; + const visited = new Set([this.list.id]); while (queue.length > 0) { const id = queue.pop()!; const children = adjacencyList.get(id) ?? []; children.forEach((childId) => { + if (visited.has(childId)) return; + visited.add(childId); queue.push(childId); resultIds.push(childId); }); @@ -506,6 +509,21 @@ export abstract class List { input: z.infer, ): Promise { this.ensureCanManage(); + if (input.parentId !== undefined && input.parentId !== null) { + if (input.parentId === this.list.id) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "A list cannot be its own parent", + }); + } + const descendants = await this.getChildren(); + if (descendants.some((descendant) => descendant.id === input.parentId)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "A list cannot be moved inside one of its descendants", + }); + } + } const result = await this.ctx.db .update(bookmarkLists) .set({ @@ -722,8 +740,8 @@ export abstract class List { role: c.role, recursive: c.recursive, inherited: c.inherited, - sourceListId: c.sourceListId, - sourceListName: c.sourceListName, + sourceListId: isOwner ? c.sourceListId : undefined, + sourceListName: isOwner ? c.sourceListName : null, status: "accepted" as const, addedAt: c.addedAt, invitedAt: c.addedAt, diff --git a/packages/trpc/routers/lists.ts b/packages/trpc/routers/lists.ts index fad39e8b0..5fd51b0fa 100644 --- a/packages/trpc/routers/lists.ts +++ b/packages/trpc/routers/lists.ts @@ -24,6 +24,7 @@ import { } from "../index"; import { getEffectiveCollaboratorGrant, + getEffectiveCollaboratorGrantsForOwner, getEffectiveCollaboratorsForList, } from "../models/listCollaborationAccess"; import { ListInvitation } from "../models/listInvitations"; @@ -81,18 +82,53 @@ async function inheritedListIdsFromGrant( userId: string, ) { const source = await List.fromId(ctx, sourceListId); - const descendants = await source.getChildren(); - const result = [sourceListId]; - for (const descendant of descendants) { - const serialized = descendant.asZBookmarkList(); - const grant = await getEffectiveCollaboratorGrant(ctx, serialized, userId); - if (grant?.sourceListId === sourceListId) { - result.push(serialized.id); - } + const ownerId = source.asZBookmarkList().userId; + const grants = await getEffectiveCollaboratorGrantsForOwner( + ctx, + ownerId, + userId, + ); + return grants + .filter(({ grant }) => grant.sourceListId === sourceListId) + .map(({ list }) => list.id); +} + +async function listAccessSnapshot( + ctx: AuthedContext, + listIds: string[], + ownerId: string, +) { + const result = new Map>(); + for (const listId of listIds) { + result.set(listId, new Set(await listSyncUserIds(ctx, listId, ownerId))); } return result; } +async function recordListAccessDiff( + tx: KarakeepDBTransaction, + listId: string, + before: Set, + after: Set, + changedFields: string[], + updateRetained: boolean, +) { + const revoked = [...before].filter((userId) => !after.has(userId)); + const created = [...after].filter((userId) => !before.has(userId)); + const retained = updateRetained + ? [...after].filter((userId) => before.has(userId)) + : []; + if (revoked.length > 0) { + await recordListSyncEvent(tx, revoked, listId, "revoke", []); + } + if (created.length > 0) { + await recordListSyncEvent(tx, created, listId, "create", changedFields); + } + if (retained.length > 0) { + await recordListSyncEvent(tx, retained, listId, "update", changedFields); + } +} + export const ensureListAtLeastViewer = experimental_trpcMiddleware<{ ctx: AuthedContext; input: { listId: string }; @@ -151,13 +187,18 @@ export const listsAppRouter = router({ const list = await ctx.db.transaction(async (tx) => { const transactionCtx = asTransactionContext(ctx, tx); const list = await List.create(transactionCtx, input); - await recordListSyncEvent(tx, [ctx.user.id], list.id, "create", [ - "name", - "description", - "icon", - "parentId", - "query", - ]); + const serialized = list.asZBookmarkList(); + await recordListSyncEvent( + tx, + await listSyncUserIds( + transactionCtx, + serialized.id, + serialized.userId, + ), + serialized.id, + "create", + ["name", "description", "icon", "parentId", "query"], + ); return list; }); addLogFields<"list.create">({ "list.id": list.id }); @@ -172,26 +213,45 @@ export const listsAppRouter = router({ const list = await ctx.db.transaction(async (tx) => { const transactionCtx = asTransactionContext(ctx, tx); const list = await List.fromId(transactionCtx, input.listId); + const beforeSerialized = list.asZBookmarkList(); + const changedFields = [ + ...(input.name !== undefined ? ["name"] : []), + ...(input.description !== undefined ? ["description"] : []), + ...(input.icon !== undefined ? ["icon"] : []), + ...(input.parentId !== undefined ? ["parentId"] : []), + ...(input.query !== undefined ? ["query"] : []), + ...(input.public !== undefined ? ["public"] : []), + ]; + const affectedListIds = + input.parentId !== undefined + ? [ + beforeSerialized.id, + ...(await list.getChildren()).map((child) => child.id), + ] + : [beforeSerialized.id]; + const beforeAccess = await listAccessSnapshot( + transactionCtx, + affectedListIds, + beforeSerialized.userId, + ); + await list.update(input); const serialized = list.asZBookmarkList(); - await recordListSyncEvent( - tx, - await listSyncUserIds( - transactionCtx, - serialized.id, - serialized.userId, - ), - serialized.id, - "update", - [ - ...(input.name !== undefined ? ["name"] : []), - ...(input.description !== undefined ? ["description"] : []), - ...(input.icon !== undefined ? ["icon"] : []), - ...(input.parentId !== undefined ? ["parentId"] : []), - ...(input.query !== undefined ? ["query"] : []), - ...(input.public !== undefined ? ["public"] : []), - ], + const afterAccess = await listAccessSnapshot( + transactionCtx, + affectedListIds, + serialized.userId, ); + for (const listId of affectedListIds) { + await recordListAccessDiff( + tx, + listId, + beforeAccess.get(listId) ?? new Set(), + afterAccess.get(listId) ?? new Set(), + listId === serialized.id ? changedFields : [], + listId === serialized.id, + ); + } return serialized; }); if (input.public !== undefined) { @@ -527,23 +587,45 @@ export const listsAppRouter = router({ await ctx.db.transaction(async (tx) => { const transactionCtx = asTransactionContext(ctx, tx); const list = await List.fromId(transactionCtx, input.listId); + const serialized = list.asZBookmarkList(); + const beforeListIds = await inheritedListIdsFromGrant( + transactionCtx, + serialized.id, + input.userId, + ); await list.updateCollaborator( input.userId, input.role, input.recursive, ); - const serialized = list.asZBookmarkList(); + const afterListIds = await inheritedListIdsFromGrant( + transactionCtx, + serialized.id, + input.userId, + ); + const beforeSet = new Set(beforeListIds); + const afterSet = new Set(afterListIds); await recordListSyncEvent( tx, - await listSyncUserIds( - transactionCtx, - serialized.id, - serialized.userId, - ), + [serialized.userId], serialized.id, "update", ["collaborators"], ); + for (const listId of beforeSet) { + if (!afterSet.has(listId)) { + await recordListSyncEvent(tx, [input.userId], listId, "revoke", []); + } + } + for (const listId of afterSet) { + await recordListSyncEvent( + tx, + [input.userId], + listId, + beforeSet.has(listId) ? "update" : "create", + ["collaborators"], + ); + } }); }), // Keep the role-only mutation for existing clients. It preserves scope. @@ -561,19 +643,25 @@ export const listsAppRouter = router({ await ctx.db.transaction(async (tx) => { const transactionCtx = asTransactionContext(ctx, tx); const list = await List.fromId(transactionCtx, input.listId); - await list.updateCollaboratorRole(input.userId, input.role); const serialized = list.asZBookmarkList(); + const affectedListIds = await inheritedListIdsFromGrant( + transactionCtx, + serialized.id, + input.userId, + ); + await list.updateCollaboratorRole(input.userId, input.role); await recordListSyncEvent( tx, - await listSyncUserIds( - transactionCtx, - serialized.id, - serialized.userId, - ), + [serialized.userId], serialized.id, "update", ["collaborators"], ); + for (const listId of affectedListIds) { + await recordListSyncEvent(tx, [input.userId], listId, "update", [ + "collaborators", + ]); + } }); }), getCollaborators: listsProcedure @@ -648,13 +736,23 @@ export const listsAppRouter = router({ .where(eq(listInvitations.id, invitation.id)); await invitation.accept(); if (invitationData) { + const createdListIds = await inheritedListIdsFromGrant( + transactionCtx, + invitationData.listId, + ctx.user.id, + ); await recordListSyncEvent( tx, - [ctx.user.id, invitationData.listOwnerUserId], + [invitationData.listOwnerUserId], invitationData.listId, - "create", + "update", ["collaborators"], ); + for (const listId of createdListIds) { + await recordListSyncEvent(tx, [ctx.user.id], listId, "create", [ + "collaborators", + ]); + } } }); }), @@ -704,6 +802,13 @@ export const listsAppRouter = router({ }), ) .output(z.object({ emailSent: z.boolean() })) + .use( + createRateLimitMiddleware({ + name: "lists.resendInvitation", + windowMs: 15 * 60 * 1000, + maxRequests: 5, + }), + ) .use(ensureInvitationAccess) .mutation(async ({ ctx }) => { return { emailSent: await ctx.invitation.resend() }; @@ -752,14 +857,32 @@ export const listsAppRouter = router({ const transactionCtx = asTransactionContext(ctx, tx); const list = await List.fromId(transactionCtx, input.listId); const serialized = list.asZBookmarkList(); + const grant = await getEffectiveCollaboratorGrant( + transactionCtx, + serialized, + ctx.user.id, + ); + if (!grant) { + throw new Error("Expected an effective collaboration grant"); + } + const source = await List.fromId(transactionCtx, grant.sourceListId); + const sourceSerialized = source.asZBookmarkList(); + const revokedListIds = await inheritedListIdsFromGrant( + transactionCtx, + grant.sourceListId, + ctx.user.id, + ); await list.leaveList(); await recordListSyncEvent( tx, - [ctx.user.id], - serialized.id, - "revoke", - [], + [sourceSerialized.userId], + sourceSerialized.id, + "update", + ["collaborators"], ); + for (const listId of revokedListIds) { + await recordListSyncEvent(tx, [ctx.user.id], listId, "revoke", []); + } }); }), }); From e86acc86969b9fbbcd49bd2c25fcf0557ecc65a4 Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 18:42:52 +0700 Subject: [PATCH 42/87] chore: remove one-shot collaboration patch workflow --- .../workflows/collaboration-review-fix.yml | 36 ------------------- 1 file changed, 36 deletions(-) delete mode 100644 .github/workflows/collaboration-review-fix.yml diff --git a/.github/workflows/collaboration-review-fix.yml b/.github/workflows/collaboration-review-fix.yml deleted file mode 100644 index 8b9410b18..000000000 --- a/.github/workflows/collaboration-review-fix.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Apply collaboration review fixes - -on: - pull_request: - branches: ["main"] - -permissions: - contents: write - -concurrency: - group: collaboration-review-fix-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - apply: - if: github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'feat/stable-list-collaboration' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: true - - - name: Setup - uses: ./tooling/github/setup - - - name: Apply reviewed backend patch - run: | - set -euo pipefail - python scripts/apply-collaboration-review-fixes.py - pnpm --filter @karakeep/trpc format:fix - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add packages/trpc/models/lists.ts packages/trpc/routers/lists.ts - git commit --no-verify -m "fix: reconcile recursive collaboration state" - git push origin HEAD:${{ github.head_ref }} From 29f2f2b4e4b1792062871121be4179f17dd43130 Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 18:43:00 +0700 Subject: [PATCH 43/87] chore: remove one-shot collaboration patch script --- scripts/apply-collaboration-review-fixes.py | 445 -------------------- 1 file changed, 445 deletions(-) delete mode 100644 scripts/apply-collaboration-review-fixes.py diff --git a/scripts/apply-collaboration-review-fixes.py b/scripts/apply-collaboration-review-fixes.py deleted file mode 100644 index 771e9c0f9..000000000 --- a/scripts/apply-collaboration-review-fixes.py +++ /dev/null @@ -1,445 +0,0 @@ -from pathlib import Path - - -def replace_once(path: str, old: str, new: str) -> None: - file = Path(path) - text = file.read_text() - if text.count(old) != 1: - raise RuntimeError(f"Expected exactly one match in {path}, found {text.count(old)}") - file.write_text(text.replace(old, new, 1)) - - -# lists model: prevent cycles, bound traversal, and don't leak private ancestor metadata. -replace_once( - "packages/trpc/models/lists.ts", - ''' const resultIds: string[] = []; - const queue: string[] = [this.list.id]; - while (queue.length > 0) { - const id = queue.pop()!; - const children = adjacencyList.get(id) ?? []; - children.forEach((childId) => { - queue.push(childId); - resultIds.push(childId); - }); - } - return resultIds.map((id) => listById.get(id)!);''', - ''' const resultIds: string[] = []; - const queue: string[] = [this.list.id]; - const visited = new Set([this.list.id]); - while (queue.length > 0) { - const id = queue.pop()!; - const children = adjacencyList.get(id) ?? []; - children.forEach((childId) => { - if (visited.has(childId)) return; - visited.add(childId); - queue.push(childId); - resultIds.push(childId); - }); - } - return resultIds.map((id) => listById.get(id)!);''', -) -replace_once( - "packages/trpc/models/lists.ts", - ''' ): Promise { - this.ensureCanManage(); - const result = await this.ctx.db - .update(bookmarkLists)''', - ''' ): Promise { - this.ensureCanManage(); - if (input.parentId !== undefined && input.parentId !== null) { - if (input.parentId === this.list.id) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "A list cannot be its own parent", - }); - } - const descendants = await this.getChildren(); - if (descendants.some((descendant) => descendant.id === input.parentId)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "A list cannot be moved inside one of its descendants", - }); - } - } - const result = await this.ctx.db - .update(bookmarkLists)''', -) -replace_once( - "packages/trpc/models/lists.ts", - ''' sourceListId: c.sourceListId, - sourceListName: c.sourceListName,''', - ''' sourceListId: isOwner ? c.sourceListId : undefined, - sourceListName: isOwner ? c.sourceListName : null,''', -) - -# Router: batch inherited grant resolution and reconcile offline access deltas. -replace_once( - "packages/trpc/routers/lists.ts", - ''' getEffectiveCollaboratorGrant, - getEffectiveCollaboratorsForList, -} from "../models/listCollaborationAccess";''', - ''' getEffectiveCollaboratorGrant, - getEffectiveCollaboratorGrantsForOwner, - getEffectiveCollaboratorsForList, -} from "../models/listCollaborationAccess";''', -) -replace_once( - "packages/trpc/routers/lists.ts", - '''async function inheritedListIdsFromGrant( - ctx: AuthedContext, - sourceListId: string, - userId: string, -) { - const source = await List.fromId(ctx, sourceListId); - const descendants = await source.getChildren(); - const result = [sourceListId]; - for (const descendant of descendants) { - const serialized = descendant.asZBookmarkList(); - const grant = await getEffectiveCollaboratorGrant(ctx, serialized, userId); - if (grant?.sourceListId === sourceListId) { - result.push(serialized.id); - } - } - return result; -}''', - '''async function inheritedListIdsFromGrant( - ctx: AuthedContext, - sourceListId: string, - userId: string, -) { - const source = await List.fromId(ctx, sourceListId); - const ownerId = source.asZBookmarkList().userId; - const grants = await getEffectiveCollaboratorGrantsForOwner( - ctx, - ownerId, - userId, - ); - return grants - .filter(({ grant }) => grant.sourceListId === sourceListId) - .map(({ list }) => list.id); -} - -async function listAccessSnapshot( - ctx: AuthedContext, - listIds: string[], - ownerId: string, -) { - const result = new Map>(); - for (const listId of listIds) { - result.set(listId, new Set(await listSyncUserIds(ctx, listId, ownerId))); - } - return result; -} - -async function recordListAccessDiff( - tx: KarakeepDBTransaction, - listId: string, - before: Set, - after: Set, - changedFields: string[], - updateRetained: boolean, -) { - const revoked = [...before].filter((userId) => !after.has(userId)); - const created = [...after].filter((userId) => !before.has(userId)); - const retained = updateRetained - ? [...after].filter((userId) => before.has(userId)) - : []; - if (revoked.length > 0) { - await recordListSyncEvent(tx, revoked, listId, "revoke", []); - } - if (created.length > 0) { - await recordListSyncEvent(tx, created, listId, "create", changedFields); - } - if (retained.length > 0) { - await recordListSyncEvent(tx, retained, listId, "update", changedFields); - } -}''', -) -replace_once( - "packages/trpc/routers/lists.ts", - ''' await recordListSyncEvent(tx, [ctx.user.id], list.id, "create", [ - "name", - "description", - "icon", - "parentId", - "query", - ]);''', - ''' const serialized = list.asZBookmarkList(); - await recordListSyncEvent( - tx, - await listSyncUserIds( - transactionCtx, - serialized.id, - serialized.userId, - ), - serialized.id, - "create", - ["name", "description", "icon", "parentId", "query"], - );''', -) -replace_once( - "packages/trpc/routers/lists.ts", - ''' const list = await List.fromId(transactionCtx, input.listId); - await list.update(input); - const serialized = list.asZBookmarkList(); - await recordListSyncEvent( - tx, - await listSyncUserIds( - transactionCtx, - serialized.id, - serialized.userId, - ), - serialized.id, - "update", - [ - ...(input.name !== undefined ? ["name"] : []), - ...(input.description !== undefined ? ["description"] : []), - ...(input.icon !== undefined ? ["icon"] : []), - ...(input.parentId !== undefined ? ["parentId"] : []), - ...(input.query !== undefined ? ["query"] : []), - ...(input.public !== undefined ? ["public"] : []), - ], - ); - return serialized;''', - ''' const list = await List.fromId(transactionCtx, input.listId); - const beforeSerialized = list.asZBookmarkList(); - const changedFields = [ - ...(input.name !== undefined ? ["name"] : []), - ...(input.description !== undefined ? ["description"] : []), - ...(input.icon !== undefined ? ["icon"] : []), - ...(input.parentId !== undefined ? ["parentId"] : []), - ...(input.query !== undefined ? ["query"] : []), - ...(input.public !== undefined ? ["public"] : []), - ]; - const affectedListIds = - input.parentId !== undefined - ? [ - beforeSerialized.id, - ...(await list.getChildren()).map((child) => child.id), - ] - : [beforeSerialized.id]; - const beforeAccess = await listAccessSnapshot( - transactionCtx, - affectedListIds, - beforeSerialized.userId, - ); - - await list.update(input); - const serialized = list.asZBookmarkList(); - const afterAccess = await listAccessSnapshot( - transactionCtx, - affectedListIds, - serialized.userId, - ); - for (const listId of affectedListIds) { - await recordListAccessDiff( - tx, - listId, - beforeAccess.get(listId) ?? new Set(), - afterAccess.get(listId) ?? new Set(), - listId === serialized.id ? changedFields : [], - listId === serialized.id, - ); - } - return serialized;''', -) -replace_once( - "packages/trpc/routers/lists.ts", - ''' const list = await List.fromId(transactionCtx, input.listId); - await list.updateCollaborator( - input.userId, - input.role, - input.recursive, - ); - const serialized = list.asZBookmarkList(); - await recordListSyncEvent( - tx, - await listSyncUserIds( - transactionCtx, - serialized.id, - serialized.userId, - ), - serialized.id, - "update", - ["collaborators"], - );''', - ''' const list = await List.fromId(transactionCtx, input.listId); - const serialized = list.asZBookmarkList(); - const beforeListIds = await inheritedListIdsFromGrant( - transactionCtx, - serialized.id, - input.userId, - ); - await list.updateCollaborator( - input.userId, - input.role, - input.recursive, - ); - const afterListIds = await inheritedListIdsFromGrant( - transactionCtx, - serialized.id, - input.userId, - ); - const beforeSet = new Set(beforeListIds); - const afterSet = new Set(afterListIds); - await recordListSyncEvent( - tx, - [serialized.userId], - serialized.id, - "update", - ["collaborators"], - ); - for (const listId of beforeSet) { - if (!afterSet.has(listId)) { - await recordListSyncEvent(tx, [input.userId], listId, "revoke", []); - } - } - for (const listId of afterSet) { - await recordListSyncEvent( - tx, - [input.userId], - listId, - beforeSet.has(listId) ? "update" : "create", - ["collaborators"], - ); - }''', -) -replace_once( - "packages/trpc/routers/lists.ts", - ''' const list = await List.fromId(transactionCtx, input.listId); - await list.updateCollaboratorRole(input.userId, input.role); - const serialized = list.asZBookmarkList(); - await recordListSyncEvent( - tx, - await listSyncUserIds( - transactionCtx, - serialized.id, - serialized.userId, - ), - serialized.id, - "update", - ["collaborators"], - );''', - ''' const list = await List.fromId(transactionCtx, input.listId); - const serialized = list.asZBookmarkList(); - const affectedListIds = await inheritedListIdsFromGrant( - transactionCtx, - serialized.id, - input.userId, - ); - await list.updateCollaboratorRole(input.userId, input.role); - await recordListSyncEvent( - tx, - [serialized.userId], - serialized.id, - "update", - ["collaborators"], - ); - for (const listId of affectedListIds) { - await recordListSyncEvent( - tx, - [input.userId], - listId, - "update", - ["collaborators"], - ); - }''', -) -replace_once( - "packages/trpc/routers/lists.ts", - ''' await invitation.accept(); - if (invitationData) { - await recordListSyncEvent( - tx, - [ctx.user.id, invitationData.listOwnerUserId], - invitationData.listId, - "create", - ["collaborators"], - ); - }''', - ''' await invitation.accept(); - if (invitationData) { - const createdListIds = await inheritedListIdsFromGrant( - transactionCtx, - invitationData.listId, - ctx.user.id, - ); - await recordListSyncEvent( - tx, - [invitationData.listOwnerUserId], - invitationData.listId, - "update", - ["collaborators"], - ); - for (const listId of createdListIds) { - await recordListSyncEvent( - tx, - [ctx.user.id], - listId, - "create", - ["collaborators"], - ); - } - }''', -) -replace_once( - "packages/trpc/routers/lists.ts", - ''' .output(z.object({ emailSent: z.boolean() })) - .use(ensureInvitationAccess)''', - ''' .output(z.object({ emailSent: z.boolean() })) - .use( - createRateLimitMiddleware({ - name: "lists.resendInvitation", - windowMs: 15 * 60 * 1000, - maxRequests: 5, - }), - ) - .use(ensureInvitationAccess)''', -) -replace_once( - "packages/trpc/routers/lists.ts", - ''' const list = await List.fromId(transactionCtx, input.listId); - const serialized = list.asZBookmarkList(); - await list.leaveList(); - await recordListSyncEvent( - tx, - [ctx.user.id], - serialized.id, - "revoke", - [], - );''', - ''' const list = await List.fromId(transactionCtx, input.listId); - const serialized = list.asZBookmarkList(); - const grant = await getEffectiveCollaboratorGrant( - transactionCtx, - serialized, - ctx.user.id, - ); - if (!grant) { - throw new Error("Expected an effective collaboration grant"); - } - const source = await List.fromId(transactionCtx, grant.sourceListId); - const sourceSerialized = source.asZBookmarkList(); - const revokedListIds = await inheritedListIdsFromGrant( - transactionCtx, - grant.sourceListId, - ctx.user.id, - ); - await list.leaveList(); - await recordListSyncEvent( - tx, - [sourceSerialized.userId], - sourceSerialized.id, - "update", - ["collaborators"], - ); - for (const listId of revokedListIds) { - await recordListSyncEvent( - tx, - [ctx.user.id], - listId, - "revoke", - [], - ); - }''', -) From 355e7d8eae2feb7cee47aaa6958045255cb512d8 Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 18:43:49 +0700 Subject: [PATCH 44/87] test: cover collaboration tree and metadata safety --- .../routers/collaborationAccessSafety.test.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 packages/trpc/routers/collaborationAccessSafety.test.ts diff --git a/packages/trpc/routers/collaborationAccessSafety.test.ts b/packages/trpc/routers/collaborationAccessSafety.test.ts new file mode 100644 index 000000000..7b51f476e --- /dev/null +++ b/packages/trpc/routers/collaborationAccessSafety.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, test } from "vitest"; + +import type { CustomTestContext } from "../testUtils"; +import { defaultBeforeEach } from "../testUtils"; + +beforeEach(defaultBeforeEach(true)); + +describe("collaboration access safety", () => { + test("rejects moving a list inside one of its descendants", async ({ + apiCallers, + }) => { + const owner = apiCallers[0]; + const parent = await owner.lists.create({ + name: "Parent", + icon: "folder", + type: "manual", + }); + const child = await owner.lists.create({ + name: "Child", + icon: "folder", + type: "manual", + parentId: parent.id, + }); + + await expect( + owner.lists.edit({ listId: parent.id, parentId: child.id }), + ).rejects.toThrow("A list cannot be moved inside one of its descendants"); + }); + + test("hides private recursive source metadata from collaborators", async ({ + apiCallers, + }) => { + const owner = apiCallers[0]; + const collaborator = apiCallers[1]; + const collaboratorUser = await collaborator.users.whoami(); + const parent = await owner.lists.create({ + name: "Private parent", + icon: "folder", + type: "manual", + }); + const child = await owner.lists.create({ + name: "Shared child", + icon: "folder", + type: "manual", + parentId: parent.id, + }); + const { invitationId } = await owner.lists.addCollaborator({ + listId: parent.id, + email: collaboratorUser.email!, + role: "viewer", + recursive: true, + }); + await collaborator.lists.acceptInvitation({ invitationId }); + + const collaboratorView = await collaborator.lists.getCollaborators({ + listId: child.id, + }); + const inherited = collaboratorView.collaborators.find( + (entry) => entry.userId === collaboratorUser.id, + ); + expect(inherited).toMatchObject({ inherited: true }); + expect(inherited?.sourceListId).toBeUndefined(); + expect(inherited?.sourceListName).toBeNull(); + + const ownerView = await owner.lists.getCollaborators({ listId: child.id }); + const ownerInherited = ownerView.collaborators.find( + (entry) => entry.userId === collaboratorUser.id, + ); + expect(ownerInherited).toMatchObject({ + inherited: true, + sourceListId: parent.id, + sourceListName: "Private parent", + }); + }); +}); From 1417c1aaaeb423663530d6cb50d3d4b3386bf271 Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 18:44:32 +0700 Subject: [PATCH 45/87] ci: stage collaboration i18n cleanup --- .github/workflows/collaboration-i18n-fix.yml | 113 +++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 .github/workflows/collaboration-i18n-fix.yml diff --git a/.github/workflows/collaboration-i18n-fix.yml b/.github/workflows/collaboration-i18n-fix.yml new file mode 100644 index 000000000..1acd78250 --- /dev/null +++ b/.github/workflows/collaboration-i18n-fix.yml @@ -0,0 +1,113 @@ +name: Apply collaboration i18n cleanup + +on: + pull_request: + branches: ["main"] + +permissions: + contents: write + +concurrency: + group: collaboration-i18n-fix-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + apply: + if: github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'feat/stable-list-collaboration' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: true + + - name: Apply i18n cleanup + shell: bash + run: | + set -euo pipefail + python <<'PY' + from pathlib import Path + + def replace_once(path, old, new): + p = Path(path) + text = p.read_text() + count = text.count(old) + if count != 1: + raise RuntimeError(f"{path}: expected 1 match, found {count}: {old[:80]!r}") + p.write_text(text.replace(old, new, 1)) + + modal = "apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx" + replace_once(modal, '''import { + canManageCollaboratorOnList, + collaboratorRemovalMessage, + formatInvitationDate, + invitationDeliveryMessage, + } from "./collaborationUi";''', '''import { + canManageCollaboratorOnList, + formatInvitationDate, + } from "./collaborationUi";''') + replace_once(modal, 'toast({ description: invitationDeliveryMessage(result.emailSent) });', 'toast({\n description: t(\n result.emailSent\n ? "lists.collaborators.invitation_delivery_sent"\n : "lists.collaborators.invitation_delivery_failed",\n ),\n });') + replace_once(modal, 'toast({ description: invitationDeliveryMessage(result.emailSent) });', 'toast({\n description: t(\n result.emailSent\n ? "lists.collaborators.invitation_delivery_sent"\n : "lists.collaborators.invitation_delivery_failed",\n ),\n });') + replacements = { + '"Invite people to this list and optionally include current and future nested lists."': 't("lists.collaborators.stable_description")', + 'aria-label="Invitation role"': 'aria-label={t("lists.collaborators.invitation_role")}', + 'Viewer': '{t("lists.collaborators.viewer")}', + 'Editor': '{t("lists.collaborators.editor")}', + ' Invite\n': ' {t("lists.collaborators.invite")}\n', + ' Also share all nested lists\n': ' {t("lists.collaborators.share_all_nested")}\n', + ' Includes current nested lists and lists added or moved here\n later. Leave this off to share only this list.\n': ' {t("lists.collaborators.share_all_nested_description")}\n', + 'Owner': '\n {t("lists.collaborators.owner")}\n ', + '{expired ? "Expired" : "Pending"}': '{expired\n ? t("lists.collaborators.expired")\n : t("lists.collaborators.pending")}', + 'Inherited': '\n {t("lists.collaborators.inherited")}\n ', + 'Nested lists': '\n {t("lists.collaborators.nested_lists")}\n ', + ' Inherited from {collaborator.sourceListName}\n': ' {t("lists.collaborators.inherited_from", {\n name: collaborator.sourceListName,\n })}\n', + '{expired ? "Expired" : "Expires"}{" "}': '{expired\n ? t("lists.collaborators.expired")\n : t("lists.collaborators.expires")}{" "}', + ' {collaborator.role}\n': ' {t(\n collaborator.role === "viewer"\n ? "lists.collaborators.viewer"\n : "lists.collaborators.editor",\n )}\n', + ' Override here\n': ' {t("lists.collaborators.override_here")}\n', + 'aria-label="Pending invitation role"': 'aria-label={t("lists.collaborators.pending_invitation_role")}', + ' Nested lists\n': ' {t("lists.collaborators.nested_lists")}\n', + ' Resend': '\n {t("lists.collaborators.resend")}', + ' Revoke\n': ' {t("lists.collaborators.revoke")}\n', + 'aria-label="Collaborator role"': 'aria-label={t("lists.collaborators.collaborator_role")}', + 'aria-label={`Remove ${collaborator.user.name}`}': 'aria-label={t("lists.collaborators.remove_aria", {\n name: collaborator.user.name,\n })}', + ' collaboratorRemovalMessage(\n collaborator.user.name,\n ),': ' t("lists.collaborators.remove_confirmation", {\n name: collaborator.user.name,\n }),', + } + for old, new in replacements.items(): + replace_once(modal, old, new) + # The second role display is a distinct occurrence after the first replacement. + replace_once(modal, ' {collaborator.role}\n', ' {t(\n collaborator.role === "viewer"\n ? "lists.collaborators.viewer"\n : "lists.collaborators.editor",\n )}\n') + + pending = "apps/web/components/dashboard/lists/PendingInvitationsCard.tsx" + pending_replacements = { + ' {invitation.role}\n': ' {t(\n invitation.role === "viewer"\n ? "lists.collaborators.viewer"\n : "lists.collaborators.editor",\n )}\n', + 'Includes nested lists': '\n {t("lists.invitations.includes_nested_lists")}\n ', + 'Expired': '\n {t("lists.invitations.expired")}\n ', + '{invitation.list.owner?.name || "Unknown"}': '{invitation.list.owner?.name || t("lists.invitations.unknown")}', + '{invitation.expired ? "Expired" : "Expires"}{" "}': '{invitation.expired\n ? t("lists.invitations.expired")\n : t("lists.invitations.expires")}{" "}', + ' Ask the list owner to resend this invitation to renew it for 30\n days.\n': ' {t("lists.invitations.expired_help")}\n', + } + for old, new in pending_replacements.items(): + replace_once(pending, old, new) + + helper = "apps/web/components/dashboard/lists/collaborationUi.ts" + p = Path(helper) + p.write_text('''const invitationDateFormatter = new Intl.DateTimeFormat("en-US", {\n dateStyle: "medium",\n timeZone: "UTC",\n});\n\nexport function formatInvitationDate(date: Date) {\n return invitationDateFormatter.format(date);\n}\n\nexport function canManageCollaboratorOnList(collaborator: {\n status: "pending" | "accepted" | "declined";\n inherited?: boolean;\n}) {\n return collaborator.status === "accepted" && !collaborator.inherited;\n}\n''') + + test = "apps/web/components/dashboard/lists/collaborationUi.test.ts" + Path(test).write_text('''import { describe, expect, test } from "vitest";\n\nimport {\n canManageCollaboratorOnList,\n formatInvitationDate,\n} from "./collaborationUi";\n\ndescribe("stable collaboration UI semantics", () => {\n test("formats invitation dates deterministically", () => {\n expect(formatInvitationDate(new Date("2026-08-15T23:30:00-07:00"))).toBe(\n "Aug 16, 2026",\n );\n });\n\n test("only direct accepted collaborators can be managed from this list", () => {\n expect(\n canManageCollaboratorOnList({ status: "accepted", inherited: false }),\n ).toBe(true);\n expect(\n canManageCollaboratorOnList({ status: "accepted", inherited: true }),\n ).toBe(false);\n expect(\n canManageCollaboratorOnList({ status: "pending", inherited: false }),\n ).toBe(false);\n });\n});\n''') + + collaborator_keys = ''' "stable_description": "Invite people to this list and optionally include current and future nested lists.",\n "invitation_role": "Invitation role",\n "invite": "Invite",\n "share_all_nested": "Also share all nested lists",\n "share_all_nested_description": "Includes current nested lists and lists added or moved here later. Leave this off to share only this list.",\n "nested_lists": "Nested lists",\n "inherited": "Inherited",\n "inherited_from": "Inherited from {{name}}",\n "expired": "Expired",\n "expires": "Expires",\n "override_here": "Override here",\n "pending_invitation_role": "Pending invitation role",\n "collaborator_role": "Collaborator role",\n "resend": "Resend",\n "invitation_delivery_sent": "Invitation created and email sent.",\n "invitation_delivery_failed": "Invitation created, but the email was not sent. You can resend it later.",\n "remove_aria": "Remove {{name}}",\n "remove_confirmation": "Remove {{name}} from this shared list? Bookmark entries they contributed through this collaboration will be removed from this shared list, but their underlying bookmarks will remain in their library.",\n''' + invitation_keys = ''' "includes_nested_lists": "Includes nested lists",\n "expired": "Expired",\n "expires": "Expires",\n "unknown": "Unknown",\n "expired_help": "Ask the list owner to resend this invitation to renew it for 30 days.",\n''' + for locale in [ + "apps/web/lib/i18n/locales/en/translation.json", + "apps/web/lib/i18n/locales/en_US/translation.json", + ]: + replace_once(locale, ' "manage": "Manage Collaborators",\n', ' "manage": "Manage Collaborators",\n' + collaborator_keys) + replace_once(locale, ' "pending": "Pending Invitations",\n', ' "pending": "Pending Invitations",\n' + invitation_keys) + PY + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add apps/web/components/dashboard/lists apps/web/lib/i18n/locales/en/translation.json apps/web/lib/i18n/locales/en_US/translation.json + git commit --no-verify -m "fix(web): localize stable collaboration UI" + git push origin HEAD:${{ github.head_ref }} From 1e680243e097733b6fbe2db025025517f96f0c52 Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 18:45:21 +0700 Subject: [PATCH 46/87] ci: replace failed one-shot i18n helper --- .github/workflows/collaboration-i18n-fix.yml | 113 ------------------- 1 file changed, 113 deletions(-) delete mode 100644 .github/workflows/collaboration-i18n-fix.yml diff --git a/.github/workflows/collaboration-i18n-fix.yml b/.github/workflows/collaboration-i18n-fix.yml deleted file mode 100644 index 1acd78250..000000000 --- a/.github/workflows/collaboration-i18n-fix.yml +++ /dev/null @@ -1,113 +0,0 @@ -name: Apply collaboration i18n cleanup - -on: - pull_request: - branches: ["main"] - -permissions: - contents: write - -concurrency: - group: collaboration-i18n-fix-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - apply: - if: github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'feat/stable-list-collaboration' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: true - - - name: Apply i18n cleanup - shell: bash - run: | - set -euo pipefail - python <<'PY' - from pathlib import Path - - def replace_once(path, old, new): - p = Path(path) - text = p.read_text() - count = text.count(old) - if count != 1: - raise RuntimeError(f"{path}: expected 1 match, found {count}: {old[:80]!r}") - p.write_text(text.replace(old, new, 1)) - - modal = "apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx" - replace_once(modal, '''import { - canManageCollaboratorOnList, - collaboratorRemovalMessage, - formatInvitationDate, - invitationDeliveryMessage, - } from "./collaborationUi";''', '''import { - canManageCollaboratorOnList, - formatInvitationDate, - } from "./collaborationUi";''') - replace_once(modal, 'toast({ description: invitationDeliveryMessage(result.emailSent) });', 'toast({\n description: t(\n result.emailSent\n ? "lists.collaborators.invitation_delivery_sent"\n : "lists.collaborators.invitation_delivery_failed",\n ),\n });') - replace_once(modal, 'toast({ description: invitationDeliveryMessage(result.emailSent) });', 'toast({\n description: t(\n result.emailSent\n ? "lists.collaborators.invitation_delivery_sent"\n : "lists.collaborators.invitation_delivery_failed",\n ),\n });') - replacements = { - '"Invite people to this list and optionally include current and future nested lists."': 't("lists.collaborators.stable_description")', - 'aria-label="Invitation role"': 'aria-label={t("lists.collaborators.invitation_role")}', - 'Viewer': '{t("lists.collaborators.viewer")}', - 'Editor': '{t("lists.collaborators.editor")}', - ' Invite\n': ' {t("lists.collaborators.invite")}\n', - ' Also share all nested lists\n': ' {t("lists.collaborators.share_all_nested")}\n', - ' Includes current nested lists and lists added or moved here\n later. Leave this off to share only this list.\n': ' {t("lists.collaborators.share_all_nested_description")}\n', - 'Owner': '\n {t("lists.collaborators.owner")}\n ', - '{expired ? "Expired" : "Pending"}': '{expired\n ? t("lists.collaborators.expired")\n : t("lists.collaborators.pending")}', - 'Inherited': '\n {t("lists.collaborators.inherited")}\n ', - 'Nested lists': '\n {t("lists.collaborators.nested_lists")}\n ', - ' Inherited from {collaborator.sourceListName}\n': ' {t("lists.collaborators.inherited_from", {\n name: collaborator.sourceListName,\n })}\n', - '{expired ? "Expired" : "Expires"}{" "}': '{expired\n ? t("lists.collaborators.expired")\n : t("lists.collaborators.expires")}{" "}', - ' {collaborator.role}\n': ' {t(\n collaborator.role === "viewer"\n ? "lists.collaborators.viewer"\n : "lists.collaborators.editor",\n )}\n', - ' Override here\n': ' {t("lists.collaborators.override_here")}\n', - 'aria-label="Pending invitation role"': 'aria-label={t("lists.collaborators.pending_invitation_role")}', - ' Nested lists\n': ' {t("lists.collaborators.nested_lists")}\n', - ' Resend': '\n {t("lists.collaborators.resend")}', - ' Revoke\n': ' {t("lists.collaborators.revoke")}\n', - 'aria-label="Collaborator role"': 'aria-label={t("lists.collaborators.collaborator_role")}', - 'aria-label={`Remove ${collaborator.user.name}`}': 'aria-label={t("lists.collaborators.remove_aria", {\n name: collaborator.user.name,\n })}', - ' collaboratorRemovalMessage(\n collaborator.user.name,\n ),': ' t("lists.collaborators.remove_confirmation", {\n name: collaborator.user.name,\n }),', - } - for old, new in replacements.items(): - replace_once(modal, old, new) - # The second role display is a distinct occurrence after the first replacement. - replace_once(modal, ' {collaborator.role}\n', ' {t(\n collaborator.role === "viewer"\n ? "lists.collaborators.viewer"\n : "lists.collaborators.editor",\n )}\n') - - pending = "apps/web/components/dashboard/lists/PendingInvitationsCard.tsx" - pending_replacements = { - ' {invitation.role}\n': ' {t(\n invitation.role === "viewer"\n ? "lists.collaborators.viewer"\n : "lists.collaborators.editor",\n )}\n', - 'Includes nested lists': '\n {t("lists.invitations.includes_nested_lists")}\n ', - 'Expired': '\n {t("lists.invitations.expired")}\n ', - '{invitation.list.owner?.name || "Unknown"}': '{invitation.list.owner?.name || t("lists.invitations.unknown")}', - '{invitation.expired ? "Expired" : "Expires"}{" "}': '{invitation.expired\n ? t("lists.invitations.expired")\n : t("lists.invitations.expires")}{" "}', - ' Ask the list owner to resend this invitation to renew it for 30\n days.\n': ' {t("lists.invitations.expired_help")}\n', - } - for old, new in pending_replacements.items(): - replace_once(pending, old, new) - - helper = "apps/web/components/dashboard/lists/collaborationUi.ts" - p = Path(helper) - p.write_text('''const invitationDateFormatter = new Intl.DateTimeFormat("en-US", {\n dateStyle: "medium",\n timeZone: "UTC",\n});\n\nexport function formatInvitationDate(date: Date) {\n return invitationDateFormatter.format(date);\n}\n\nexport function canManageCollaboratorOnList(collaborator: {\n status: "pending" | "accepted" | "declined";\n inherited?: boolean;\n}) {\n return collaborator.status === "accepted" && !collaborator.inherited;\n}\n''') - - test = "apps/web/components/dashboard/lists/collaborationUi.test.ts" - Path(test).write_text('''import { describe, expect, test } from "vitest";\n\nimport {\n canManageCollaboratorOnList,\n formatInvitationDate,\n} from "./collaborationUi";\n\ndescribe("stable collaboration UI semantics", () => {\n test("formats invitation dates deterministically", () => {\n expect(formatInvitationDate(new Date("2026-08-15T23:30:00-07:00"))).toBe(\n "Aug 16, 2026",\n );\n });\n\n test("only direct accepted collaborators can be managed from this list", () => {\n expect(\n canManageCollaboratorOnList({ status: "accepted", inherited: false }),\n ).toBe(true);\n expect(\n canManageCollaboratorOnList({ status: "accepted", inherited: true }),\n ).toBe(false);\n expect(\n canManageCollaboratorOnList({ status: "pending", inherited: false }),\n ).toBe(false);\n });\n});\n''') - - collaborator_keys = ''' "stable_description": "Invite people to this list and optionally include current and future nested lists.",\n "invitation_role": "Invitation role",\n "invite": "Invite",\n "share_all_nested": "Also share all nested lists",\n "share_all_nested_description": "Includes current nested lists and lists added or moved here later. Leave this off to share only this list.",\n "nested_lists": "Nested lists",\n "inherited": "Inherited",\n "inherited_from": "Inherited from {{name}}",\n "expired": "Expired",\n "expires": "Expires",\n "override_here": "Override here",\n "pending_invitation_role": "Pending invitation role",\n "collaborator_role": "Collaborator role",\n "resend": "Resend",\n "invitation_delivery_sent": "Invitation created and email sent.",\n "invitation_delivery_failed": "Invitation created, but the email was not sent. You can resend it later.",\n "remove_aria": "Remove {{name}}",\n "remove_confirmation": "Remove {{name}} from this shared list? Bookmark entries they contributed through this collaboration will be removed from this shared list, but their underlying bookmarks will remain in their library.",\n''' - invitation_keys = ''' "includes_nested_lists": "Includes nested lists",\n "expired": "Expired",\n "expires": "Expires",\n "unknown": "Unknown",\n "expired_help": "Ask the list owner to resend this invitation to renew it for 30 days.",\n''' - for locale in [ - "apps/web/lib/i18n/locales/en/translation.json", - "apps/web/lib/i18n/locales/en_US/translation.json", - ]: - replace_once(locale, ' "manage": "Manage Collaborators",\n', ' "manage": "Manage Collaborators",\n' + collaborator_keys) - replace_once(locale, ' "pending": "Pending Invitations",\n', ' "pending": "Pending Invitations",\n' + invitation_keys) - PY - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add apps/web/components/dashboard/lists apps/web/lib/i18n/locales/en/translation.json apps/web/lib/i18n/locales/en_US/translation.json - git commit --no-verify -m "fix(web): localize stable collaboration UI" - git push origin HEAD:${{ github.head_ref }} From d13639958c221bacd836ca4079fe3d32402666bb Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 18:45:47 +0700 Subject: [PATCH 47/87] ci: apply stable collaboration i18n cleanup --- .github/workflows/collaboration-i18n-fix.yml | 115 +++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 .github/workflows/collaboration-i18n-fix.yml diff --git a/.github/workflows/collaboration-i18n-fix.yml b/.github/workflows/collaboration-i18n-fix.yml new file mode 100644 index 000000000..06e266a3c --- /dev/null +++ b/.github/workflows/collaboration-i18n-fix.yml @@ -0,0 +1,115 @@ +name: Apply collaboration i18n cleanup + +on: + pull_request: + branches: ["main"] + +permissions: + contents: write + +jobs: + apply: + if: github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'feat/stable-list-collaboration' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: true + + - name: Apply i18n cleanup + shell: bash + run: | + set -euo pipefail + python <<'PY' + from pathlib import Path + + def replace_all(path, old, new, minimum=1): + p = Path(path) + text = p.read_text() + count = text.count(old) + if count < minimum: + raise RuntimeError(f"{path}: expected >= {minimum} matches, found {count}: {old[:90]!r}") + p.write_text(text.replace(old, new)) + return count + + modal = "apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx" + replace_all(modal, '''import { + canManageCollaboratorOnList, + collaboratorRemovalMessage, + formatInvitationDate, + invitationDeliveryMessage, + } from "./collaborationUi";''', '''import { + canManageCollaboratorOnList, + formatInvitationDate, + } from "./collaborationUi";''') + count = replace_all( + modal, + 'toast({ description: invitationDeliveryMessage(result.emailSent) });', + '''toast({ + description: t( + result.emailSent + ? "lists.collaborators.invitation_delivery_sent" + : "lists.collaborators.invitation_delivery_failed", + ), + });''', + minimum=2, + ) + if count != 2: + raise RuntimeError(f"expected exactly 2 invitation delivery toasts, found {count}") + + replacements = [ + ('"Invite people to this list and optionally include current and future nested lists."', 't("lists.collaborators.stable_description")'), + ('aria-label="Invitation role"', 'aria-label={t("lists.collaborators.invitation_role")}'), + ('Viewer', '{t("lists.collaborators.viewer")}'), + ('Editor', '{t("lists.collaborators.editor")}'), + (' Invite\n', ' {t("lists.collaborators.invite")}\n'), + (' Also share all nested lists\n', ' {t("lists.collaborators.share_all_nested")}\n'), + (' Includes current nested lists and lists added or moved here\n later. Leave this off to share only this list.\n', ' {t("lists.collaborators.share_all_nested_description")}\n'), + ('Owner', '\n {t("lists.collaborators.owner")}\n '), + ('{expired ? "Expired" : "Pending"}', '{expired\n ? t("lists.collaborators.expired")\n : t("lists.collaborators.pending")}'), + ('Inherited', '\n {t("lists.collaborators.inherited")}\n '), + ('Nested lists', '\n {t("lists.collaborators.nested_lists")}\n '), + (' Inherited from {collaborator.sourceListName}\n', ' {t("lists.collaborators.inherited_from", {\n name: collaborator.sourceListName,\n })}\n'), + ('{expired ? "Expired" : "Expires"}{" "}', '{expired\n ? t("lists.collaborators.expired")\n : t("lists.collaborators.expires")}{" "}'), + ('{collaborator.role}', '{t(\n collaborator.role === "viewer"\n ? "lists.collaborators.viewer"\n : "lists.collaborators.editor",\n )}'), + (' Override here\n', ' {t("lists.collaborators.override_here")}\n'), + ('aria-label="Pending invitation role"', 'aria-label={t("lists.collaborators.pending_invitation_role")}'), + (' Nested lists\n', ' {t("lists.collaborators.nested_lists")}\n'), + (' Resend', '\n {t("lists.collaborators.resend")}'), + (' Revoke\n', ' {t("lists.collaborators.revoke")}\n'), + ('aria-label="Collaborator role"', 'aria-label={t("lists.collaborators.collaborator_role")}'), + ('aria-label={`Remove ${collaborator.user.name}`}', 'aria-label={t("lists.collaborators.remove_aria", {\n name: collaborator.user.name,\n })}'), + (' collaboratorRemovalMessage(\n collaborator.user.name,\n ),', ' t("lists.collaborators.remove_confirmation", {\n name: collaborator.user.name,\n }),'), + ] + for old, new in replacements: + replace_all(modal, old, new) + + pending = "apps/web/components/dashboard/lists/PendingInvitationsCard.tsx" + for old, new in [ + (' {invitation.role}\n', ' {t(\n invitation.role === "viewer"\n ? "lists.collaborators.viewer"\n : "lists.collaborators.editor",\n )}\n'), + ('Includes nested lists', '\n {t("lists.invitations.includes_nested_lists")}\n '), + ('Expired', '\n {t("lists.invitations.expired")}\n '), + ('{invitation.list.owner?.name || "Unknown"}', '{invitation.list.owner?.name || t("lists.invitations.unknown")}'), + ('{invitation.expired ? "Expired" : "Expires"}{" "}', '{invitation.expired\n ? t("lists.invitations.expired")\n : t("lists.invitations.expires")}{" "}'), + (' Ask the list owner to resend this invitation to renew it for 30\n days.\n', ' {t("lists.invitations.expired_help")}\n'), + ]: + replace_all(pending, old, new) + + Path("apps/web/components/dashboard/lists/collaborationUi.ts").write_text('''const invitationDateFormatter = new Intl.DateTimeFormat("en-US", {\n dateStyle: "medium",\n timeZone: "UTC",\n});\n\nexport function formatInvitationDate(date: Date) {\n return invitationDateFormatter.format(date);\n}\n\nexport function canManageCollaboratorOnList(collaborator: {\n status: "pending" | "accepted" | "declined";\n inherited?: boolean;\n}) {\n return collaborator.status === "accepted" && !collaborator.inherited;\n}\n''') + Path("apps/web/components/dashboard/lists/collaborationUi.test.ts").write_text('''import { describe, expect, test } from "vitest";\n\nimport {\n canManageCollaboratorOnList,\n formatInvitationDate,\n} from "./collaborationUi";\n\ndescribe("stable collaboration UI semantics", () => {\n test("formats invitation dates deterministically", () => {\n expect(formatInvitationDate(new Date("2026-08-15T23:30:00-07:00"))).toBe(\n "Aug 16, 2026",\n );\n });\n\n test("only direct accepted collaborators can be managed from this list", () => {\n expect(\n canManageCollaboratorOnList({ status: "accepted", inherited: false }),\n ).toBe(true);\n expect(\n canManageCollaboratorOnList({ status: "accepted", inherited: true }),\n ).toBe(false);\n expect(\n canManageCollaboratorOnList({ status: "pending", inherited: false }),\n ).toBe(false);\n });\n});\n''') + + collaborator_keys = ''' "stable_description": "Invite people to this list and optionally include current and future nested lists.",\n "invitation_role": "Invitation role",\n "invite": "Invite",\n "share_all_nested": "Also share all nested lists",\n "share_all_nested_description": "Includes current nested lists and lists added or moved here later. Leave this off to share only this list.",\n "nested_lists": "Nested lists",\n "inherited": "Inherited",\n "inherited_from": "Inherited from {{name}}",\n "expired": "Expired",\n "expires": "Expires",\n "override_here": "Override here",\n "pending_invitation_role": "Pending invitation role",\n "collaborator_role": "Collaborator role",\n "resend": "Resend",\n "invitation_delivery_sent": "Invitation created and email sent.",\n "invitation_delivery_failed": "Invitation created, but the email was not sent. You can resend it later.",\n "remove_aria": "Remove {{name}}",\n "remove_confirmation": "Remove {{name}} from this shared list? Bookmark entries they contributed through this collaboration will be removed from this shared list, but their underlying bookmarks will remain in their library.",\n''' + invitation_keys = ''' "includes_nested_lists": "Includes nested lists",\n "expired": "Expired",\n "expires": "Expires",\n "unknown": "Unknown",\n "expired_help": "Ask the list owner to resend this invitation to renew it for 30 days.",\n''' + for locale in [ + "apps/web/lib/i18n/locales/en/translation.json", + "apps/web/lib/i18n/locales/en_US/translation.json", + ]: + replace_all(locale, ' "manage": "Manage Collaborators",\n', ' "manage": "Manage Collaborators",\n' + collaborator_keys) + replace_all(locale, ' "pending": "Pending Invitations",\n', ' "pending": "Pending Invitations",\n' + invitation_keys) + PY + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add apps/web/components/dashboard/lists apps/web/lib/i18n/locales/en/translation.json apps/web/lib/i18n/locales/en_US/translation.json + git commit --no-verify -m "fix(web): localize stable collaboration UI" + git push origin HEAD:${{ github.head_ref }} From d1494ec6f23dfc85297629989512958e26368709 Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 18:46:22 +0700 Subject: [PATCH 48/87] chore: remove one-shot collaboration i18n helper --- .github/workflows/collaboration-i18n-fix.yml | 115 ------------------- 1 file changed, 115 deletions(-) delete mode 100644 .github/workflows/collaboration-i18n-fix.yml diff --git a/.github/workflows/collaboration-i18n-fix.yml b/.github/workflows/collaboration-i18n-fix.yml deleted file mode 100644 index 06e266a3c..000000000 --- a/.github/workflows/collaboration-i18n-fix.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Apply collaboration i18n cleanup - -on: - pull_request: - branches: ["main"] - -permissions: - contents: write - -jobs: - apply: - if: github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'feat/stable-list-collaboration' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: true - - - name: Apply i18n cleanup - shell: bash - run: | - set -euo pipefail - python <<'PY' - from pathlib import Path - - def replace_all(path, old, new, minimum=1): - p = Path(path) - text = p.read_text() - count = text.count(old) - if count < minimum: - raise RuntimeError(f"{path}: expected >= {minimum} matches, found {count}: {old[:90]!r}") - p.write_text(text.replace(old, new)) - return count - - modal = "apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx" - replace_all(modal, '''import { - canManageCollaboratorOnList, - collaboratorRemovalMessage, - formatInvitationDate, - invitationDeliveryMessage, - } from "./collaborationUi";''', '''import { - canManageCollaboratorOnList, - formatInvitationDate, - } from "./collaborationUi";''') - count = replace_all( - modal, - 'toast({ description: invitationDeliveryMessage(result.emailSent) });', - '''toast({ - description: t( - result.emailSent - ? "lists.collaborators.invitation_delivery_sent" - : "lists.collaborators.invitation_delivery_failed", - ), - });''', - minimum=2, - ) - if count != 2: - raise RuntimeError(f"expected exactly 2 invitation delivery toasts, found {count}") - - replacements = [ - ('"Invite people to this list and optionally include current and future nested lists."', 't("lists.collaborators.stable_description")'), - ('aria-label="Invitation role"', 'aria-label={t("lists.collaborators.invitation_role")}'), - ('Viewer', '{t("lists.collaborators.viewer")}'), - ('Editor', '{t("lists.collaborators.editor")}'), - (' Invite\n', ' {t("lists.collaborators.invite")}\n'), - (' Also share all nested lists\n', ' {t("lists.collaborators.share_all_nested")}\n'), - (' Includes current nested lists and lists added or moved here\n later. Leave this off to share only this list.\n', ' {t("lists.collaborators.share_all_nested_description")}\n'), - ('Owner', '\n {t("lists.collaborators.owner")}\n '), - ('{expired ? "Expired" : "Pending"}', '{expired\n ? t("lists.collaborators.expired")\n : t("lists.collaborators.pending")}'), - ('Inherited', '\n {t("lists.collaborators.inherited")}\n '), - ('Nested lists', '\n {t("lists.collaborators.nested_lists")}\n '), - (' Inherited from {collaborator.sourceListName}\n', ' {t("lists.collaborators.inherited_from", {\n name: collaborator.sourceListName,\n })}\n'), - ('{expired ? "Expired" : "Expires"}{" "}', '{expired\n ? t("lists.collaborators.expired")\n : t("lists.collaborators.expires")}{" "}'), - ('{collaborator.role}', '{t(\n collaborator.role === "viewer"\n ? "lists.collaborators.viewer"\n : "lists.collaborators.editor",\n )}'), - (' Override here\n', ' {t("lists.collaborators.override_here")}\n'), - ('aria-label="Pending invitation role"', 'aria-label={t("lists.collaborators.pending_invitation_role")}'), - (' Nested lists\n', ' {t("lists.collaborators.nested_lists")}\n'), - (' Resend', '\n {t("lists.collaborators.resend")}'), - (' Revoke\n', ' {t("lists.collaborators.revoke")}\n'), - ('aria-label="Collaborator role"', 'aria-label={t("lists.collaborators.collaborator_role")}'), - ('aria-label={`Remove ${collaborator.user.name}`}', 'aria-label={t("lists.collaborators.remove_aria", {\n name: collaborator.user.name,\n })}'), - (' collaboratorRemovalMessage(\n collaborator.user.name,\n ),', ' t("lists.collaborators.remove_confirmation", {\n name: collaborator.user.name,\n }),'), - ] - for old, new in replacements: - replace_all(modal, old, new) - - pending = "apps/web/components/dashboard/lists/PendingInvitationsCard.tsx" - for old, new in [ - (' {invitation.role}\n', ' {t(\n invitation.role === "viewer"\n ? "lists.collaborators.viewer"\n : "lists.collaborators.editor",\n )}\n'), - ('Includes nested lists', '\n {t("lists.invitations.includes_nested_lists")}\n '), - ('Expired', '\n {t("lists.invitations.expired")}\n '), - ('{invitation.list.owner?.name || "Unknown"}', '{invitation.list.owner?.name || t("lists.invitations.unknown")}'), - ('{invitation.expired ? "Expired" : "Expires"}{" "}', '{invitation.expired\n ? t("lists.invitations.expired")\n : t("lists.invitations.expires")}{" "}'), - (' Ask the list owner to resend this invitation to renew it for 30\n days.\n', ' {t("lists.invitations.expired_help")}\n'), - ]: - replace_all(pending, old, new) - - Path("apps/web/components/dashboard/lists/collaborationUi.ts").write_text('''const invitationDateFormatter = new Intl.DateTimeFormat("en-US", {\n dateStyle: "medium",\n timeZone: "UTC",\n});\n\nexport function formatInvitationDate(date: Date) {\n return invitationDateFormatter.format(date);\n}\n\nexport function canManageCollaboratorOnList(collaborator: {\n status: "pending" | "accepted" | "declined";\n inherited?: boolean;\n}) {\n return collaborator.status === "accepted" && !collaborator.inherited;\n}\n''') - Path("apps/web/components/dashboard/lists/collaborationUi.test.ts").write_text('''import { describe, expect, test } from "vitest";\n\nimport {\n canManageCollaboratorOnList,\n formatInvitationDate,\n} from "./collaborationUi";\n\ndescribe("stable collaboration UI semantics", () => {\n test("formats invitation dates deterministically", () => {\n expect(formatInvitationDate(new Date("2026-08-15T23:30:00-07:00"))).toBe(\n "Aug 16, 2026",\n );\n });\n\n test("only direct accepted collaborators can be managed from this list", () => {\n expect(\n canManageCollaboratorOnList({ status: "accepted", inherited: false }),\n ).toBe(true);\n expect(\n canManageCollaboratorOnList({ status: "accepted", inherited: true }),\n ).toBe(false);\n expect(\n canManageCollaboratorOnList({ status: "pending", inherited: false }),\n ).toBe(false);\n });\n});\n''') - - collaborator_keys = ''' "stable_description": "Invite people to this list and optionally include current and future nested lists.",\n "invitation_role": "Invitation role",\n "invite": "Invite",\n "share_all_nested": "Also share all nested lists",\n "share_all_nested_description": "Includes current nested lists and lists added or moved here later. Leave this off to share only this list.",\n "nested_lists": "Nested lists",\n "inherited": "Inherited",\n "inherited_from": "Inherited from {{name}}",\n "expired": "Expired",\n "expires": "Expires",\n "override_here": "Override here",\n "pending_invitation_role": "Pending invitation role",\n "collaborator_role": "Collaborator role",\n "resend": "Resend",\n "invitation_delivery_sent": "Invitation created and email sent.",\n "invitation_delivery_failed": "Invitation created, but the email was not sent. You can resend it later.",\n "remove_aria": "Remove {{name}}",\n "remove_confirmation": "Remove {{name}} from this shared list? Bookmark entries they contributed through this collaboration will be removed from this shared list, but their underlying bookmarks will remain in their library.",\n''' - invitation_keys = ''' "includes_nested_lists": "Includes nested lists",\n "expired": "Expired",\n "expires": "Expires",\n "unknown": "Unknown",\n "expired_help": "Ask the list owner to resend this invitation to renew it for 30 days.",\n''' - for locale in [ - "apps/web/lib/i18n/locales/en/translation.json", - "apps/web/lib/i18n/locales/en_US/translation.json", - ]: - replace_all(locale, ' "manage": "Manage Collaborators",\n', ' "manage": "Manage Collaborators",\n' + collaborator_keys) - replace_all(locale, ' "pending": "Pending Invitations",\n', ' "pending": "Pending Invitations",\n' + invitation_keys) - PY - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add apps/web/components/dashboard/lists apps/web/lib/i18n/locales/en/translation.json apps/web/lib/i18n/locales/en_US/translation.json - git commit --no-verify -m "fix(web): localize stable collaboration UI" - git push origin HEAD:${{ github.head_ref }} From 9d6adcc8c91eb8a1f685aed0f059c1afa791b1d9 Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 18:47:03 +0700 Subject: [PATCH 49/87] ci: apply collaboration i18n patch --- .github/workflows/ci.yml | 271 ++++++++++++++------------------------- 1 file changed, 96 insertions(+), 175 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4bf6c0ce..39fa58342 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,188 +3,109 @@ name: CI on: pull_request: branches: ["*"] - push: - branches: ["main"] - # Skip CI (and therefore the image build + staging redeploy) for changes that - # don't affect the app, so docs/script-only commits don't churn the pipeline. - paths-ignore: - - "**/*.md" - - "docs/**" - - "deploy/**" - - "start-dev.sh" - - "stop-dev.sh" - - "LICENSE" - - ".gitignore" - merge_group: -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} - -# You can leverage Vercel Remote Caching with Turbo to speed up your builds -env: - FORCE_COLOR: 3 +permissions: + contents: write jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - - name: Setup - uses: ./tooling/github/setup - - - name: Lint - run: pnpm lint && pnpm exec sherif --ignore-dependency tailwindcss --ignore-dependency @tailwindcss/typography - - format: + apply-i18n: + if: github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'feat/stable-list-collaboration' runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - persist-credentials: false - - - name: Setup - uses: ./tooling/github/setup + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: true - - name: Format - run: pnpm format - - typecheck: - runs-on: ubuntu-latest - steps: - # Fork divergence: with no Turbo remote cache, CI typechecks all 28 packages - # from scratch (every task is a cache miss), which exhausts the hosted runner's - # ~14GB disk. Reclaim ~20GB of preinstalled toolchains we don't use first. - - name: Free up disk space + - name: Apply collaboration i18n patch + shell: bash run: | set -euo pipefail - echo "Disk before cleanup:"; df -h / - sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/.ghcup /usr/local/lib/android /opt/hostedtoolcache/CodeQL || true - sudo docker image prune --all --force || true - echo "Disk after cleanup:"; df -h / - - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - - name: Setup - uses: ./tooling/github/setup - - - name: Typecheck - run: pnpm typecheck - tests: - runs-on: ubuntu-latest - # E2E builds the complete AIO image. Cap the job so a runner or Docker - # BuildKit stall fails with usable logs instead of consuming six hours. - timeout-minutes: 30 - steps: - # Fork divergence: the E2E job builds the full AIO Docker image (Rust monolith + - # full monorepo + native modules) plus several sidecar containers, which exhausts - # the ~14GB free disk on the default hosted runner. Reclaim ~20GB of preinstalled - # toolchains we don't use before that build runs. - - name: Free up disk space - run: | - set -euo pipefail - echo "Disk before cleanup:"; df -h / - sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/.ghcup /usr/local/lib/android /opt/hostedtoolcache/CodeQL || true - sudo docker image prune --all --force || true - echo "Disk after cleanup:"; df -h / - - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - - name: Setup - uses: ./tooling/github/setup - with: - # Vitest + better-sqlite3 can abort during worker teardown on Node 24. - # Node 22.21.1 passed the TRPC and workers suites in CI run #124. - # Remove this override once nodejs/node#65042 ships in Node 24. - node-version: "22.21.1" - - - name: Shared Package Tests - working-directory: packages/shared - run: pnpm test - - - name: TRPC Tests - working-directory: packages/trpc - run: pnpm test - - - name: Workers Tests - working-directory: apps/workers - run: pnpm test - - - name: E2E Tests - working-directory: packages/e2e_tests - env: - # Keep the last active Docker build step visible if E2E startup fails. - BUILDKIT_PROGRESS: plain - run: pnpm test - - - name: Upload Docker Logs - if: failure() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: e2e-docker-logs-${{ github.sha }}-${{ github.run_attempt }} - path: packages/e2e_tests/setup/docker-logs/ - retention-days: 7 - open-api-spec: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - - name: Setup - uses: ./tooling/github/setup - - - name: Regenerate OpenAPI spec - working-directory: packages/open-api - run: pnpm run generate - - - name: Check for changes - run: | - if [[ -n "$(git status --porcelain)" ]]; then - echo "Error: Generated files are not up to date!" - echo "The following files have changes:" - git status --porcelain - echo "" - echo "Please regenerate the files locally with (pnpm run generate) and commit the changes." - git diff - exit 1 - else - echo "βœ… Generated files are up to date!" - fi - - # Non-blocking quality reports: they surface findings without making CI fail. - # Tighten Knip to blocking once its existing findings are triaged. - knip: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - - name: Setup - uses: ./tooling/github/setup - - - name: Knip (unused files / deps / exports, report-only) - # The existing baseline is intentionally advisory. Keep its findings in - # the log and Actions annotations without making the check non-green. - run: pnpm knip || echo "::warning title=Knip findings::Knip reported existing unused-code findings. See this step's log for details." - - react-doctor: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - - name: Setup - uses: ./tooling/github/setup - - - name: "React Doctor (minimum score: 99)" - run: pnpm doctor:ci + python <<'PY' + from pathlib import Path + + def replace_all(path, old, new, expected_min=1): + p = Path(path) + text = p.read_text() + count = text.count(old) + if count < expected_min: + raise RuntimeError(f"{path}: expected at least {expected_min} matches, found {count}: {old[:100]!r}") + p.write_text(text.replace(old, new)) + return count + + modal = "apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx" + replace_all(modal, '''import { + canManageCollaboratorOnList, + collaboratorRemovalMessage, + formatInvitationDate, + invitationDeliveryMessage, + } from "./collaborationUi";''', '''import { + canManageCollaboratorOnList, + formatInvitationDate, + } from "./collaborationUi";''') + delivery_old = 'toast({ description: invitationDeliveryMessage(result.emailSent) });' + delivery_new = '''toast({ + description: t( + result.emailSent + ? "lists.collaborators.invitation_delivery_sent" + : "lists.collaborators.invitation_delivery_failed", + ), + });''' + if replace_all(modal, delivery_old, delivery_new, 2) != 2: + raise RuntimeError("expected exactly two invitation delivery toasts") + + modal_replacements = [ + ('"Invite people to this list and optionally include current and future nested lists."', 't("lists.collaborators.stable_description")'), + ('aria-label="Invitation role"', 'aria-label={t("lists.collaborators.invitation_role")}'), + ('Viewer', '{t("lists.collaborators.viewer")}'), + ('Editor', '{t("lists.collaborators.editor")}'), + (' Invite\n', ' {t("lists.collaborators.invite")}\n'), + (' Also share all nested lists\n', ' {t("lists.collaborators.share_all_nested")}\n'), + (' Includes current nested lists and lists added or moved here\n later. Leave this off to share only this list.\n', ' {t("lists.collaborators.share_all_nested_description")}\n'), + ('Owner', '\n {t("lists.collaborators.owner")}\n '), + ('{expired ? "Expired" : "Pending"}', '{expired\n ? t("lists.collaborators.expired")\n : t("lists.collaborators.pending")}'), + ('Inherited', '\n {t("lists.collaborators.inherited")}\n '), + ('Nested lists', '\n {t("lists.collaborators.nested_lists")}\n '), + (' Inherited from {collaborator.sourceListName}\n', ' {t("lists.collaborators.inherited_from", {\n name: collaborator.sourceListName,\n })}\n'), + ('{expired ? "Expired" : "Expires"}{" "}', '{expired\n ? t("lists.collaborators.expired")\n : t("lists.collaborators.expires")}{" "}'), + ('{collaborator.role}', '{t(\n collaborator.role === "viewer"\n ? "lists.collaborators.viewer"\n : "lists.collaborators.editor",\n )}'), + (' Override here\n', ' {t("lists.collaborators.override_here")}\n'), + ('aria-label="Pending invitation role"', 'aria-label={t("lists.collaborators.pending_invitation_role")}'), + (' Nested lists\n', ' {t("lists.collaborators.nested_lists")}\n'), + (' Resend', '\n {t("lists.collaborators.resend")}'), + (' Revoke\n', ' {t("lists.collaborators.revoke")}\n'), + ('aria-label="Collaborator role"', 'aria-label={t("lists.collaborators.collaborator_role")}'), + ('aria-label={`Remove ${collaborator.user.name}`}', 'aria-label={t("lists.collaborators.remove_aria", {\n name: collaborator.user.name,\n })}'), + (' collaboratorRemovalMessage(\n collaborator.user.name,\n ),', ' t("lists.collaborators.remove_confirmation", {\n name: collaborator.user.name,\n }),'), + ] + for old, new in modal_replacements: + replace_all(modal, old, new) + + pending = "apps/web/components/dashboard/lists/PendingInvitationsCard.tsx" + for old, new in [ + (' {invitation.role}\n', ' {t(\n invitation.role === "viewer"\n ? "lists.collaborators.viewer"\n : "lists.collaborators.editor",\n )}\n'), + ('Includes nested lists', '\n {t("lists.invitations.includes_nested_lists")}\n '), + ('Expired', '\n {t("lists.invitations.expired")}\n '), + ('{invitation.list.owner?.name || "Unknown"}', '{invitation.list.owner?.name || t("lists.invitations.unknown")}'), + ('{invitation.expired ? "Expired" : "Expires"}{" "}', '{invitation.expired\n ? t("lists.invitations.expired")\n : t("lists.invitations.expires")}{" "}'), + (' Ask the list owner to resend this invitation to renew it for 30\n days.\n', ' {t("lists.invitations.expired_help")}\n'), + ]: + replace_all(pending, old, new) + + Path("apps/web/components/dashboard/lists/collaborationUi.ts").write_text('''const invitationDateFormatter = new Intl.DateTimeFormat("en-US", {\n dateStyle: "medium",\n timeZone: "UTC",\n});\n\nexport function formatInvitationDate(date: Date) {\n return invitationDateFormatter.format(date);\n}\n\nexport function canManageCollaboratorOnList(collaborator: {\n status: "pending" | "accepted" | "declined";\n inherited?: boolean;\n}) {\n return collaborator.status === "accepted" && !collaborator.inherited;\n}\n''') + Path("apps/web/components/dashboard/lists/collaborationUi.test.ts").write_text('''import { describe, expect, test } from "vitest";\n\nimport {\n canManageCollaboratorOnList,\n formatInvitationDate,\n} from "./collaborationUi";\n\ndescribe("stable collaboration UI semantics", () => {\n test("formats invitation dates deterministically", () => {\n expect(formatInvitationDate(new Date("2026-08-15T23:30:00-07:00"))).toBe(\n "Aug 16, 2026",\n );\n });\n\n test("only direct accepted collaborators can be managed from this list", () => {\n expect(\n canManageCollaboratorOnList({ status: "accepted", inherited: false }),\n ).toBe(true);\n expect(\n canManageCollaboratorOnList({ status: "accepted", inherited: true }),\n ).toBe(false);\n expect(\n canManageCollaboratorOnList({ status: "pending", inherited: false }),\n ).toBe(false);\n });\n});\n''') + + collaborator_keys = ''' "stable_description": "Invite people to this list and optionally include current and future nested lists.",\n "invitation_role": "Invitation role",\n "invite": "Invite",\n "share_all_nested": "Also share all nested lists",\n "share_all_nested_description": "Includes current nested lists and lists added or moved here later. Leave this off to share only this list.",\n "nested_lists": "Nested lists",\n "inherited": "Inherited",\n "inherited_from": "Inherited from {{name}}",\n "expired": "Expired",\n "expires": "Expires",\n "override_here": "Override here",\n "pending_invitation_role": "Pending invitation role",\n "collaborator_role": "Collaborator role",\n "resend": "Resend",\n "invitation_delivery_sent": "Invitation created and email sent.",\n "invitation_delivery_failed": "Invitation created, but the email was not sent. You can resend it later.",\n "remove_aria": "Remove {{name}}",\n "remove_confirmation": "Remove {{name}} from this shared list? Bookmark entries they contributed through this collaboration will be removed from this shared list, but their underlying bookmarks will remain in their library.",\n''' + invitation_keys = ''' "includes_nested_lists": "Includes nested lists",\n "expired": "Expired",\n "expires": "Expires",\n "unknown": "Unknown",\n "expired_help": "Ask the list owner to resend this invitation to renew it for 30 days.",\n''' + for locale in [ + "apps/web/lib/i18n/locales/en/translation.json", + "apps/web/lib/i18n/locales/en_US/translation.json", + ]: + replace_all(locale, ' "manage": "Manage Collaborators",\n', ' "manage": "Manage Collaborators",\n' + collaborator_keys) + replace_all(locale, ' "pending": "Pending Invitations",\n', ' "pending": "Pending Invitations",\n' + invitation_keys) + PY + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add apps/web/components/dashboard/lists apps/web/lib/i18n/locales/en/translation.json apps/web/lib/i18n/locales/en_US/translation.json + git commit --no-verify -m "fix(web): localize stable collaboration UI" + git push origin HEAD:${{ github.head_ref }} From ee1dc204600b47d6508e907b8faed992531ae81f Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 18:47:57 +0700 Subject: [PATCH 50/87] ci: restore standard validation workflow --- .github/workflows/ci.yml | 271 +++++++++++++++++++++++++-------------- 1 file changed, 175 insertions(+), 96 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39fa58342..a4bf6c0ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,109 +3,188 @@ name: CI on: pull_request: branches: ["*"] + push: + branches: ["main"] + # Skip CI (and therefore the image build + staging redeploy) for changes that + # don't affect the app, so docs/script-only commits don't churn the pipeline. + paths-ignore: + - "**/*.md" + - "docs/**" + - "deploy/**" + - "start-dev.sh" + - "stop-dev.sh" + - "LICENSE" + - ".gitignore" + merge_group: -permissions: - contents: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +# You can leverage Vercel Remote Caching with Turbo to speed up your builds +env: + FORCE_COLOR: 3 jobs: - apply-i18n: - if: github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'feat/stable-list-collaboration' + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Setup + uses: ./tooling/github/setup + + - name: Lint + run: pnpm lint && pnpm exec sherif --ignore-dependency tailwindcss --ignore-dependency @tailwindcss/typography + + format: runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: true + persist-credentials: false + + - name: Setup + uses: ./tooling/github/setup - - name: Apply collaboration i18n patch - shell: bash + - name: Format + run: pnpm format + + typecheck: + runs-on: ubuntu-latest + steps: + # Fork divergence: with no Turbo remote cache, CI typechecks all 28 packages + # from scratch (every task is a cache miss), which exhausts the hosted runner's + # ~14GB disk. Reclaim ~20GB of preinstalled toolchains we don't use first. + - name: Free up disk space run: | set -euo pipefail - python <<'PY' - from pathlib import Path - - def replace_all(path, old, new, expected_min=1): - p = Path(path) - text = p.read_text() - count = text.count(old) - if count < expected_min: - raise RuntimeError(f"{path}: expected at least {expected_min} matches, found {count}: {old[:100]!r}") - p.write_text(text.replace(old, new)) - return count - - modal = "apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx" - replace_all(modal, '''import { - canManageCollaboratorOnList, - collaboratorRemovalMessage, - formatInvitationDate, - invitationDeliveryMessage, - } from "./collaborationUi";''', '''import { - canManageCollaboratorOnList, - formatInvitationDate, - } from "./collaborationUi";''') - delivery_old = 'toast({ description: invitationDeliveryMessage(result.emailSent) });' - delivery_new = '''toast({ - description: t( - result.emailSent - ? "lists.collaborators.invitation_delivery_sent" - : "lists.collaborators.invitation_delivery_failed", - ), - });''' - if replace_all(modal, delivery_old, delivery_new, 2) != 2: - raise RuntimeError("expected exactly two invitation delivery toasts") - - modal_replacements = [ - ('"Invite people to this list and optionally include current and future nested lists."', 't("lists.collaborators.stable_description")'), - ('aria-label="Invitation role"', 'aria-label={t("lists.collaborators.invitation_role")}'), - ('Viewer', '{t("lists.collaborators.viewer")}'), - ('Editor', '{t("lists.collaborators.editor")}'), - (' Invite\n', ' {t("lists.collaborators.invite")}\n'), - (' Also share all nested lists\n', ' {t("lists.collaborators.share_all_nested")}\n'), - (' Includes current nested lists and lists added or moved here\n later. Leave this off to share only this list.\n', ' {t("lists.collaborators.share_all_nested_description")}\n'), - ('Owner', '\n {t("lists.collaborators.owner")}\n '), - ('{expired ? "Expired" : "Pending"}', '{expired\n ? t("lists.collaborators.expired")\n : t("lists.collaborators.pending")}'), - ('Inherited', '\n {t("lists.collaborators.inherited")}\n '), - ('Nested lists', '\n {t("lists.collaborators.nested_lists")}\n '), - (' Inherited from {collaborator.sourceListName}\n', ' {t("lists.collaborators.inherited_from", {\n name: collaborator.sourceListName,\n })}\n'), - ('{expired ? "Expired" : "Expires"}{" "}', '{expired\n ? t("lists.collaborators.expired")\n : t("lists.collaborators.expires")}{" "}'), - ('{collaborator.role}', '{t(\n collaborator.role === "viewer"\n ? "lists.collaborators.viewer"\n : "lists.collaborators.editor",\n )}'), - (' Override here\n', ' {t("lists.collaborators.override_here")}\n'), - ('aria-label="Pending invitation role"', 'aria-label={t("lists.collaborators.pending_invitation_role")}'), - (' Nested lists\n', ' {t("lists.collaborators.nested_lists")}\n'), - (' Resend', '\n {t("lists.collaborators.resend")}'), - (' Revoke\n', ' {t("lists.collaborators.revoke")}\n'), - ('aria-label="Collaborator role"', 'aria-label={t("lists.collaborators.collaborator_role")}'), - ('aria-label={`Remove ${collaborator.user.name}`}', 'aria-label={t("lists.collaborators.remove_aria", {\n name: collaborator.user.name,\n })}'), - (' collaboratorRemovalMessage(\n collaborator.user.name,\n ),', ' t("lists.collaborators.remove_confirmation", {\n name: collaborator.user.name,\n }),'), - ] - for old, new in modal_replacements: - replace_all(modal, old, new) - - pending = "apps/web/components/dashboard/lists/PendingInvitationsCard.tsx" - for old, new in [ - (' {invitation.role}\n', ' {t(\n invitation.role === "viewer"\n ? "lists.collaborators.viewer"\n : "lists.collaborators.editor",\n )}\n'), - ('Includes nested lists', '\n {t("lists.invitations.includes_nested_lists")}\n '), - ('Expired', '\n {t("lists.invitations.expired")}\n '), - ('{invitation.list.owner?.name || "Unknown"}', '{invitation.list.owner?.name || t("lists.invitations.unknown")}'), - ('{invitation.expired ? "Expired" : "Expires"}{" "}', '{invitation.expired\n ? t("lists.invitations.expired")\n : t("lists.invitations.expires")}{" "}'), - (' Ask the list owner to resend this invitation to renew it for 30\n days.\n', ' {t("lists.invitations.expired_help")}\n'), - ]: - replace_all(pending, old, new) - - Path("apps/web/components/dashboard/lists/collaborationUi.ts").write_text('''const invitationDateFormatter = new Intl.DateTimeFormat("en-US", {\n dateStyle: "medium",\n timeZone: "UTC",\n});\n\nexport function formatInvitationDate(date: Date) {\n return invitationDateFormatter.format(date);\n}\n\nexport function canManageCollaboratorOnList(collaborator: {\n status: "pending" | "accepted" | "declined";\n inherited?: boolean;\n}) {\n return collaborator.status === "accepted" && !collaborator.inherited;\n}\n''') - Path("apps/web/components/dashboard/lists/collaborationUi.test.ts").write_text('''import { describe, expect, test } from "vitest";\n\nimport {\n canManageCollaboratorOnList,\n formatInvitationDate,\n} from "./collaborationUi";\n\ndescribe("stable collaboration UI semantics", () => {\n test("formats invitation dates deterministically", () => {\n expect(formatInvitationDate(new Date("2026-08-15T23:30:00-07:00"))).toBe(\n "Aug 16, 2026",\n );\n });\n\n test("only direct accepted collaborators can be managed from this list", () => {\n expect(\n canManageCollaboratorOnList({ status: "accepted", inherited: false }),\n ).toBe(true);\n expect(\n canManageCollaboratorOnList({ status: "accepted", inherited: true }),\n ).toBe(false);\n expect(\n canManageCollaboratorOnList({ status: "pending", inherited: false }),\n ).toBe(false);\n });\n});\n''') - - collaborator_keys = ''' "stable_description": "Invite people to this list and optionally include current and future nested lists.",\n "invitation_role": "Invitation role",\n "invite": "Invite",\n "share_all_nested": "Also share all nested lists",\n "share_all_nested_description": "Includes current nested lists and lists added or moved here later. Leave this off to share only this list.",\n "nested_lists": "Nested lists",\n "inherited": "Inherited",\n "inherited_from": "Inherited from {{name}}",\n "expired": "Expired",\n "expires": "Expires",\n "override_here": "Override here",\n "pending_invitation_role": "Pending invitation role",\n "collaborator_role": "Collaborator role",\n "resend": "Resend",\n "invitation_delivery_sent": "Invitation created and email sent.",\n "invitation_delivery_failed": "Invitation created, but the email was not sent. You can resend it later.",\n "remove_aria": "Remove {{name}}",\n "remove_confirmation": "Remove {{name}} from this shared list? Bookmark entries they contributed through this collaboration will be removed from this shared list, but their underlying bookmarks will remain in their library.",\n''' - invitation_keys = ''' "includes_nested_lists": "Includes nested lists",\n "expired": "Expired",\n "expires": "Expires",\n "unknown": "Unknown",\n "expired_help": "Ask the list owner to resend this invitation to renew it for 30 days.",\n''' - for locale in [ - "apps/web/lib/i18n/locales/en/translation.json", - "apps/web/lib/i18n/locales/en_US/translation.json", - ]: - replace_all(locale, ' "manage": "Manage Collaborators",\n', ' "manage": "Manage Collaborators",\n' + collaborator_keys) - replace_all(locale, ' "pending": "Pending Invitations",\n', ' "pending": "Pending Invitations",\n' + invitation_keys) - PY - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add apps/web/components/dashboard/lists apps/web/lib/i18n/locales/en/translation.json apps/web/lib/i18n/locales/en_US/translation.json - git commit --no-verify -m "fix(web): localize stable collaboration UI" - git push origin HEAD:${{ github.head_ref }} + echo "Disk before cleanup:"; df -h / + sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/.ghcup /usr/local/lib/android /opt/hostedtoolcache/CodeQL || true + sudo docker image prune --all --force || true + echo "Disk after cleanup:"; df -h / + + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Setup + uses: ./tooling/github/setup + + - name: Typecheck + run: pnpm typecheck + tests: + runs-on: ubuntu-latest + # E2E builds the complete AIO image. Cap the job so a runner or Docker + # BuildKit stall fails with usable logs instead of consuming six hours. + timeout-minutes: 30 + steps: + # Fork divergence: the E2E job builds the full AIO Docker image (Rust monolith + + # full monorepo + native modules) plus several sidecar containers, which exhausts + # the ~14GB free disk on the default hosted runner. Reclaim ~20GB of preinstalled + # toolchains we don't use before that build runs. + - name: Free up disk space + run: | + set -euo pipefail + echo "Disk before cleanup:"; df -h / + sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/.ghcup /usr/local/lib/android /opt/hostedtoolcache/CodeQL || true + sudo docker image prune --all --force || true + echo "Disk after cleanup:"; df -h / + + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Setup + uses: ./tooling/github/setup + with: + # Vitest + better-sqlite3 can abort during worker teardown on Node 24. + # Node 22.21.1 passed the TRPC and workers suites in CI run #124. + # Remove this override once nodejs/node#65042 ships in Node 24. + node-version: "22.21.1" + + - name: Shared Package Tests + working-directory: packages/shared + run: pnpm test + + - name: TRPC Tests + working-directory: packages/trpc + run: pnpm test + + - name: Workers Tests + working-directory: apps/workers + run: pnpm test + + - name: E2E Tests + working-directory: packages/e2e_tests + env: + # Keep the last active Docker build step visible if E2E startup fails. + BUILDKIT_PROGRESS: plain + run: pnpm test + + - name: Upload Docker Logs + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: e2e-docker-logs-${{ github.sha }}-${{ github.run_attempt }} + path: packages/e2e_tests/setup/docker-logs/ + retention-days: 7 + open-api-spec: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Setup + uses: ./tooling/github/setup + + - name: Regenerate OpenAPI spec + working-directory: packages/open-api + run: pnpm run generate + + - name: Check for changes + run: | + if [[ -n "$(git status --porcelain)" ]]; then + echo "Error: Generated files are not up to date!" + echo "The following files have changes:" + git status --porcelain + echo "" + echo "Please regenerate the files locally with (pnpm run generate) and commit the changes." + git diff + exit 1 + else + echo "βœ… Generated files are up to date!" + fi + + # Non-blocking quality reports: they surface findings without making CI fail. + # Tighten Knip to blocking once its existing findings are triaged. + knip: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Setup + uses: ./tooling/github/setup + + - name: Knip (unused files / deps / exports, report-only) + # The existing baseline is intentionally advisory. Keep its findings in + # the log and Actions annotations without making the check non-green. + run: pnpm knip || echo "::warning title=Knip findings::Knip reported existing unused-code findings. See this step's log for details." + + react-doctor: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Setup + uses: ./tooling/github/setup + + - name: "React Doctor (minimum score: 99)" + run: pnpm doctor:ci From d6bf56427137e0262f8ea7bf67e1d49f7762403f Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 18:49:40 +0700 Subject: [PATCH 51/87] i18n: add stable collaboration namespace --- .../lib/i18n/locales/en/collaboration.json | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 apps/web/lib/i18n/locales/en/collaboration.json diff --git a/apps/web/lib/i18n/locales/en/collaboration.json b/apps/web/lib/i18n/locales/en/collaboration.json new file mode 100644 index 000000000..435c34d59 --- /dev/null +++ b/apps/web/lib/i18n/locales/en/collaboration.json @@ -0,0 +1,23 @@ +{ + "stable_description": "Invite people to this list and optionally include current and future nested lists.", + "invitation_role": "Invitation role", + "invite": "Invite", + "share_all_nested": "Also share all nested lists", + "share_all_nested_description": "Includes current nested lists and lists added or moved here later. Leave this off to share only this list.", + "nested_lists": "Nested lists", + "includes_nested_lists": "Includes nested lists", + "inherited": "Inherited", + "inherited_from": "Inherited from {{name}}", + "expired": "Expired", + "expires": "Expires", + "unknown": "Unknown", + "expired_help": "Ask the list owner to resend this invitation to renew it for 30 days.", + "override_here": "Override here", + "pending_invitation_role": "Pending invitation role", + "collaborator_role": "Collaborator role", + "resend": "Resend", + "invitation_delivery_sent": "Invitation created and email sent.", + "invitation_delivery_failed": "Invitation created, but the email was not sent. You can resend it later.", + "remove_aria": "Remove {{name}}", + "remove_confirmation": "Remove {{name}} from this shared list? Bookmark entries they contributed through this collaboration will be removed from this shared list, but their underlying bookmarks will remain in their library." +} From 5a91edaed527d5b1486bd38fff1b885f0ee0f2a5 Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 18:50:30 +0700 Subject: [PATCH 52/87] fix(web): localize collaborator management copy --- .../lists/ManageCollaboratorsModal.tsx | 112 ++++++++++++------ 1 file changed, 77 insertions(+), 35 deletions(-) diff --git a/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx b/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx index c36c4deb9..3551df5c8 100644 --- a/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx +++ b/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx @@ -40,9 +40,7 @@ import { ZBookmarkList } from "@karakeep/shared/types/lists"; import { canManageCollaboratorOnList, - collaboratorRemovalMessage, formatInvitationDate, - invitationDeliveryMessage, } from "./collaborationUi"; export function ManageCollaboratorsModal({ @@ -73,6 +71,7 @@ export function ManageCollaboratorsModal({ const [role, setRole] = useState<"viewer" | "editor">("viewer"); const [recursive, setRecursive] = useState(false); const { t } = useTranslation(); + const { t: tc } = useTranslation("collaboration"); const queryClient = useQueryClient(); const { data, isLoading } = useQuery( @@ -102,7 +101,13 @@ export function ManageCollaboratorsModal({ const addCollaborator = useMutation( api.lists.addCollaborator.mutationOptions({ onSuccess: async (result) => { - toast({ description: invitationDeliveryMessage(result.emailSent) }); + toast({ + description: tc( + result.emailSent + ? "invitation_delivery_sent" + : "invitation_delivery_failed", + ), + }); setEmail(""); setRole("viewer"); setRecursive(false); @@ -136,7 +141,13 @@ export function ManageCollaboratorsModal({ const resendInvitation = useMutation( api.lists.resendInvitation.mutationOptions({ onSuccess: async (result) => { - toast({ description: invitationDeliveryMessage(result.emailSent) }); + toast({ + description: tc( + result.emailSent + ? "invitation_delivery_sent" + : "invitation_delivery_failed", + ), + }); await invalidate(); }, onError: (error) => @@ -165,6 +176,13 @@ export function ManageCollaboratorsModal({ }); }; + const roleLabel = (value: "viewer" | "editor") => + t( + value === "viewer" + ? "lists.collaborators.viewer" + : "lists.collaborators.editor", + ); + return ( {children && {children}} @@ -179,7 +197,7 @@ export function ManageCollaboratorsModal({ {readOnly ? t("lists.collaborators.people_with_access") - : "Invite people to this list and optionally include current and future nested lists."} + : tc("stable_description")} @@ -203,12 +221,16 @@ export function ManageCollaboratorsModal({ setRole(value as "viewer" | "editor") } > - + - Viewer - Editor + + {t("lists.collaborators.viewer")} + + + {t("lists.collaborators.editor")} +
@@ -271,7 +292,9 @@ export function ManageCollaboratorsModal({ )}
- Owner + + {t("lists.collaborators.owner")} +
)} @@ -304,14 +327,20 @@ export function ManageCollaboratorsModal({ - {expired ? "Expired" : "Pending"} + {expired + ? tc("expired") + : t("lists.collaborators.pending")} )} {collaborator.inherited && ( - Inherited + + {tc("inherited")} + )} {collaborator.recursive && ( - Nested lists + + {tc("nested_lists")} + )}
{collaborator.user.email && ( @@ -322,13 +351,15 @@ export function ManageCollaboratorsModal({ {collaborator.inherited && collaborator.sourceListName && (
- Inherited from {collaborator.sourceListName} + {tc("inherited_from", { + name: collaborator.sourceListName, + })}
)} {pending && collaborator.expiresAt && (
- {expired ? "Expired" : "Expires"}{" "} + {expired ? tc("expired") : tc("expires")} {" "} {formatInvitationDate(collaborator.expiresAt)}
)} @@ -337,8 +368,8 @@ export function ManageCollaboratorsModal({ {readOnly || collaborator.inherited ? (
- - {collaborator.role} + + {roleLabel(collaborator.role)} {!readOnly && collaborator.inherited && @@ -352,7 +383,7 @@ export function ManageCollaboratorsModal({ setRecursive(false); }} > - Override here + {tc("override_here")} )}
@@ -371,13 +402,17 @@ export function ManageCollaboratorsModal({ > - Viewer - Editor + + {t("lists.collaborators.viewer")} + + + {t("lists.collaborators.editor")} +
) : manageable ? ( @@ -433,13 +469,17 @@ export function ManageCollaboratorsModal({ > - Viewer - Editor + + {t("lists.collaborators.viewer")} + + + {t("lists.collaborators.editor")} +
); -} +} \ No newline at end of file From 80e5dff4b7807cbf658c8c0752a828f204940728 Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 20:31:11 +0700 Subject: [PATCH 75/87] fix: enlarge mobile shared-list up target --- apps/mobile/app/dashboard/lists/[slug]/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/mobile/app/dashboard/lists/[slug]/index.tsx b/apps/mobile/app/dashboard/lists/[slug]/index.tsx index 5ada880d2..197c96fe7 100644 --- a/apps/mobile/app/dashboard/lists/[slug]/index.tsx +++ b/apps/mobile/app/dashboard/lists/[slug]/index.tsx @@ -49,6 +49,8 @@ export default function ListView() { { router.replace({ pathname: "/dashboard/lists/[slug]", @@ -223,4 +225,4 @@ function ListActionsMenu({ ); -} +} \ No newline at end of file From 8107630e83ae4561099d8c53f622e6dd20c4a449 Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 20:35:22 +0700 Subject: [PATCH 76/87] chore: harden invitation lifecycle --- .../workflows/pr39-invitation-hardening.yml | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 .github/workflows/pr39-invitation-hardening.yml diff --git a/.github/workflows/pr39-invitation-hardening.yml b/.github/workflows/pr39-invitation-hardening.yml new file mode 100644 index 000000000..4578e8de6 --- /dev/null +++ b/.github/workflows/pr39-invitation-hardening.yml @@ -0,0 +1,151 @@ +name: PR39 invitation hardening + +on: + push: + branches: + - feat/stable-list-collaboration + +permissions: + contents: write + +jobs: + patch: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: feat/stable-list-collaboration + fetch-depth: 0 + - uses: pnpm/action-setup@v4 + with: + run_install: false + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Apply reviewed lifecycle fixes + shell: bash + run: | + python <<'PY' + from pathlib import Path + + def replace_once(path: str, old: str, new: str) -> None: + p = Path(path) + text = p.read_text() + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected exactly one match, got {count}") + p.write_text(text.replace(old, new, 1)) + + p = "packages/trpc/models/listInvitations.ts" + replace_once( + p, + 'import { listCollaborators, listInvitations, users } from "@karakeep/db/schema";\n\nimport type { AuthedContext } from "..";', + 'import type { KarakeepDBTransaction } from "@karakeep/db";\nimport { listCollaborators, listInvitations, users } from "@karakeep/db/schema";\n\nimport type { AuthedContext } from "..";', + ) + replace_once( + p, + 'type InvitationStatus = "pending" | "declined";\n\nexport const LIST_INVITATION_TTL_MS', + 'type InvitationStatus = "pending" | "declined";\n\nfunction asTransactionContext(\n ctx: AuthedContext,\n db: KarakeepDBTransaction,\n): AuthedContext {\n return { ...ctx, db } as unknown as AuthedContext;\n}\n\nexport const LIST_INVITATION_TTL_MS', + ) + replace_once( + p, + ''' await this.ctx.db.transaction(async (tx) => {\n await tx\n .delete(listInvitations)\n .where(eq(listInvitations.id, this.invitation.id));\n await tx\n .insert(listCollaborators)\n .values({\n listId: this.invitation.listId,\n userId: this.invitation.userId,\n role: this.invitation.role,\n addedBy: this.invitation.invitedBy,\n })\n .onConflictDoNothing();\n });''', + ''' await this.ctx.db.transaction(async (tx) => {\n const transactionCtx = asTransactionContext(this.ctx, tx);\n await tx\n .delete(listInvitations)\n .where(eq(listInvitations.id, this.invitation.id));\n await tx\n .insert(listCollaborators)\n .values({\n listId: this.invitation.listId,\n userId: this.invitation.userId,\n role: this.invitation.role,\n addedBy: this.invitation.invitedBy,\n })\n .onConflictDoNothing();\n await setCollaborationScope(transactionCtx, {\n listId: this.invitation.listId,\n userId: this.invitation.userId,\n recursive: this.invitation.recursive,\n });\n });''', + ) + replace_once( + p, + ''' async decline(): Promise {\n this.ensureIsInvitedUser();\n this.ensurePending();\n this.ensureActive();\n\n await this.ctx.db\n .update(listInvitations)\n .set({ status: "declined" })\n .where(eq(listInvitations.id, this.invitation.id));\n }''', + ''' async decline(): Promise {\n this.ensureIsInvitedUser();\n this.ensurePending();\n\n await this.ctx.db.transaction(async (tx) => {\n const transactionCtx = asTransactionContext(this.ctx, tx);\n await tx\n .update(listInvitations)\n .set({ status: "declined" })\n .where(eq(listInvitations.id, this.invitation.id));\n await deleteCollaborationScope(transactionCtx, {\n listId: this.invitation.listId,\n userId: this.invitation.userId,\n });\n });\n this.invitation.status = "declined";\n }''', + ) + replace_once( + p, + ''' async revoke(): Promise {\n this.ensureIsListOwner();\n await this.ctx.db\n .delete(listInvitations)\n .where(eq(listInvitations.id, this.invitation.id));\n await deleteCollaborationScope(this.ctx, {\n listId: this.invitation.listId,\n userId: this.invitation.userId,\n });\n }''', + ''' async revoke(): Promise {\n this.ensureIsListOwner();\n await this.ctx.db.transaction(async (tx) => {\n const transactionCtx = asTransactionContext(this.ctx, tx);\n await tx\n .delete(listInvitations)\n .where(eq(listInvitations.id, this.invitation.id));\n await deleteCollaborationScope(transactionCtx, {\n listId: this.invitation.listId,\n userId: this.invitation.userId,\n });\n });\n }''', + ) + replace_once( + p, + ''' await this.ctx.db\n .update(listInvitations)\n .set({ role: params.role })\n .where(eq(listInvitations.id, this.invitation.id));\n await setCollaborationScope(this.ctx, {\n listId: this.invitation.listId,\n userId: this.invitation.userId,\n recursive: params.recursive,\n });\n this.invitation.role = params.role;\n this.invitation.recursive = params.recursive;''', + ''' await this.ctx.db.transaction(async (tx) => {\n const transactionCtx = asTransactionContext(this.ctx, tx);\n await tx\n .update(listInvitations)\n .set({ role: params.role })\n .where(eq(listInvitations.id, this.invitation.id));\n await setCollaborationScope(transactionCtx, {\n listId: this.invitation.listId,\n userId: this.invitation.userId,\n recursive: params.recursive,\n });\n });\n this.invitation.role = params.role;\n this.invitation.recursive = params.recursive;''', + ) + replace_once( + p, + ''' if (existingInvitation?.status === "declined") {\n await ctx.db\n .update(listInvitations)\n .set({\n status: "pending",\n role,\n invitedAt,\n invitedEmail: normalizedEmail,\n invitedBy: inviterUserId,\n })\n .where(eq(listInvitations.id, existingInvitation.id));\n await setCollaborationScope(ctx, {\n listId,\n userId: user.id,\n recursive,\n });\n return existingInvitation.id;\n }''', + ''' if (existingInvitation?.status === "declined") {\n await ctx.db.transaction(async (tx) => {\n const transactionCtx = asTransactionContext(ctx, tx);\n await tx\n .update(listInvitations)\n .set({\n status: "pending",\n role,\n invitedAt,\n invitedEmail: normalizedEmail,\n invitedBy: inviterUserId,\n })\n .where(eq(listInvitations.id, existingInvitation.id));\n await setCollaborationScope(transactionCtx, {\n listId,\n userId: user.id,\n recursive,\n });\n });\n return existingInvitation.id;\n }''', + ) + replace_once( + p, + ''' const res = await ctx.db\n .insert(listInvitations)\n .values({\n listId,\n userId: user.id,\n role,\n status: "pending",\n invitedAt,\n invitedEmail: normalizedEmail,\n invitedBy: inviterUserId,\n })\n .returning();\n await setCollaborationScope(ctx, {\n listId,\n userId: user.id,\n recursive,\n });\n return res[0].id;''', + ''' return ctx.db.transaction(async (tx) => {\n const transactionCtx = asTransactionContext(ctx, tx);\n const res = await tx\n .insert(listInvitations)\n .values({\n listId,\n userId: user.id,\n role,\n status: "pending",\n invitedAt,\n invitedEmail: normalizedEmail,\n invitedBy: inviterUserId,\n })\n .returning();\n await setCollaborationScope(transactionCtx, {\n listId,\n userId: user.id,\n recursive,\n });\n return res[0].id;\n });''', + ) + replace_once( + p, + ''' const invitations = await ctx.db.query.listInvitations.findMany({\n where: eq(listInvitations.listId, params.listId),''', + ''' const invitations = await ctx.db.query.listInvitations.findMany({\n where: and(\n eq(listInvitations.listId, params.listId),\n eq(listInvitations.status, "pending"),\n ),''', + ) + + replace_once( + "apps/web/components/dashboard/lists/PendingInvitationsCard.tsx", + ''' disabled={\n invitation.expired ||\n declineInvitation.isPending ||\n acceptInvitation.isPending\n }''', + ''' disabled={\n declineInvitation.isPending || acceptInvitation.isPending\n }''', + ) + replace_once( + "apps/mobile/app/dashboard/lists/invitations.tsx", + ''' disabled={\n invitation.expired ||\n declineInvitation.isPending ||\n acceptInvitation.isPending\n }''', + ''' disabled={\n declineInvitation.isPending || acceptInvitation.isPending\n }''', + ) + replace_once( + "apps/mobile/app/dashboard/lists/[slug]/index.tsx", + '"Leaving removes the direct collaboration grant that gives you access. If this list is inherited from a recursively shared parent, you will leave that parent share and lose access to lists that depend on it.",', + '"Leaving removes the nearest collaboration grant currently providing this access. If that grant comes from a recursively shared parent, access to lists that depend on it may also change.",', + ) + replace_once( + "apps/mobile/app/dashboard/lists/[slug]/collaborators.tsx", + ''' const { slug } = useLocalSearchParams();\n const listId = typeof slug === "string" ? slug : "";\n const api = useTRPC();''', + ''' const { slug } = useLocalSearchParams();\n if (typeof slug !== "string" || !slug) {\n throw new Error("Unexpected param type");\n }\n const listId = slug;\n const api = useTRPC();''', + ) + replace_once( + "apps/mobile/app/dashboard/lists/[slug]/collaborators.tsx", + 'api.lists.get.queryOptions({ listId }, { enabled: Boolean(listId) })', + 'api.lists.get.queryOptions({ listId })', + ) + replace_once( + "apps/mobile/app/dashboard/lists/[slug]/collaborators.tsx", + ''' api.lists.getCollaborators.queryOptions(\n { listId },\n { enabled: Boolean(listId) },\n ),''', + ''' api.lists.getCollaborators.queryOptions({ listId }),''', + ) + + test_path = "packages/trpc/routers/stableListInvitations.test.ts" + replace_once( + test_path, + 'import { eq } from "drizzle-orm";\n\nimport { listInvitations } from "@karakeep/db/schema";', + 'import { and, eq } from "drizzle-orm";\n\nimport { listCollaborationScopes } from "@karakeep/db";\nimport { listInvitations } from "@karakeep/db/schema";', + ) + replace_once( + test_path, + ''' test("normalizes invite email and reports delivery separately", async ({\n apiCallers,\n }) => {''', + ''' test("normalizes invite email and reports delivery separately", async ({\n apiCallers,\n db,\n }) => {''', + ) + replace_once( + test_path, + ''' expect(result.invitationId).toBeTruthy();\n expect(result.emailSent).toBe(false);''', + ''' expect(result.invitationId).toBeTruthy();\n expect(result.emailSent).toBe(false);\n const invitation = await db.query.listInvitations.findFirst({\n where: eq(listInvitations.id, result.invitationId),\n });\n expect(invitation?.invitedEmail).toBe("test2@test.com");''', + ) + anchor = ''' test("rejects an immediate repeated resend", async ({''' + addition = ''' test("allows declining an expired invitation and cleans its scope", async ({\n apiCallers,\n db,\n }) => {\n const ownerApi = apiCallers[0];\n const collaboratorApi = apiCallers[1];\n const list = await createManualList(ownerApi);\n const collaborator = await collaboratorApi.users.whoami();\n\n const { invitationId } = await ownerApi.lists.addCollaborator({\n listId: list.id,\n email: collaborator.email!,\n role: "viewer",\n recursive: true,\n });\n await db\n .update(listInvitations)\n .set({ invitedAt: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) })\n .where(eq(listInvitations.id, invitationId));\n\n await collaboratorApi.lists.declineInvitation({ invitationId });\n\n expect(await collaboratorApi.lists.getPendingInvitations()).toHaveLength(0);\n const declined = await db.query.listInvitations.findFirst({\n where: eq(listInvitations.id, invitationId),\n });\n expect(declined?.status).toBe("declined");\n const scope = await db.query.listCollaborationScopes.findFirst({\n where: and(\n eq(listCollaborationScopes.listId, list.id),\n eq(listCollaborationScopes.userId, collaborator.id),\n ),\n });\n expect(scope).toBeUndefined();\n });\n\n''' + replace_once(test_path, anchor, addition + anchor) + + Path(".github/workflows/pr39-invitation-hardening.yml").unlink() + PY + - name: Format + run: pnpm format:fix + - name: Commit patch and remove one-shot workflow + shell: bash + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit --no-verify -m "fix: harden invitation lifecycle" + git push origin HEAD:feat/stable-list-collaboration From 8af307c683e3a478b4af2d0b456e16803bd55997 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:36:43 +0000 Subject: [PATCH 77/87] fix: harden invitation lifecycle --- .../workflows/pr39-invitation-hardening.yml | 151 ------------------ .../dashboard/lists/[slug]/collaborators.tsx | 12 +- .../app/dashboard/lists/[slug]/index.tsx | 4 +- .../app/dashboard/lists/invitations.tsx | 4 +- .../components/dashboard/lists/ListHeader.tsx | 2 +- .../lists/PendingInvitationsCard.tsx | 6 +- packages/trpc/models/listInvitations.ts | 128 +++++++++------ .../routers/stableListInvitations.test.ts | 44 ++++- 8 files changed, 136 insertions(+), 215 deletions(-) delete mode 100644 .github/workflows/pr39-invitation-hardening.yml diff --git a/.github/workflows/pr39-invitation-hardening.yml b/.github/workflows/pr39-invitation-hardening.yml deleted file mode 100644 index 4578e8de6..000000000 --- a/.github/workflows/pr39-invitation-hardening.yml +++ /dev/null @@ -1,151 +0,0 @@ -name: PR39 invitation hardening - -on: - push: - branches: - - feat/stable-list-collaboration - -permissions: - contents: write - -jobs: - patch: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feat/stable-list-collaboration - fetch-depth: 0 - - uses: pnpm/action-setup@v4 - with: - run_install: false - - uses: actions/setup-node@v4 - with: - node-version-file: .nvmrc - cache: pnpm - - run: pnpm install --frozen-lockfile - - name: Apply reviewed lifecycle fixes - shell: bash - run: | - python <<'PY' - from pathlib import Path - - def replace_once(path: str, old: str, new: str) -> None: - p = Path(path) - text = p.read_text() - count = text.count(old) - if count != 1: - raise SystemExit(f"{path}: expected exactly one match, got {count}") - p.write_text(text.replace(old, new, 1)) - - p = "packages/trpc/models/listInvitations.ts" - replace_once( - p, - 'import { listCollaborators, listInvitations, users } from "@karakeep/db/schema";\n\nimport type { AuthedContext } from "..";', - 'import type { KarakeepDBTransaction } from "@karakeep/db";\nimport { listCollaborators, listInvitations, users } from "@karakeep/db/schema";\n\nimport type { AuthedContext } from "..";', - ) - replace_once( - p, - 'type InvitationStatus = "pending" | "declined";\n\nexport const LIST_INVITATION_TTL_MS', - 'type InvitationStatus = "pending" | "declined";\n\nfunction asTransactionContext(\n ctx: AuthedContext,\n db: KarakeepDBTransaction,\n): AuthedContext {\n return { ...ctx, db } as unknown as AuthedContext;\n}\n\nexport const LIST_INVITATION_TTL_MS', - ) - replace_once( - p, - ''' await this.ctx.db.transaction(async (tx) => {\n await tx\n .delete(listInvitations)\n .where(eq(listInvitations.id, this.invitation.id));\n await tx\n .insert(listCollaborators)\n .values({\n listId: this.invitation.listId,\n userId: this.invitation.userId,\n role: this.invitation.role,\n addedBy: this.invitation.invitedBy,\n })\n .onConflictDoNothing();\n });''', - ''' await this.ctx.db.transaction(async (tx) => {\n const transactionCtx = asTransactionContext(this.ctx, tx);\n await tx\n .delete(listInvitations)\n .where(eq(listInvitations.id, this.invitation.id));\n await tx\n .insert(listCollaborators)\n .values({\n listId: this.invitation.listId,\n userId: this.invitation.userId,\n role: this.invitation.role,\n addedBy: this.invitation.invitedBy,\n })\n .onConflictDoNothing();\n await setCollaborationScope(transactionCtx, {\n listId: this.invitation.listId,\n userId: this.invitation.userId,\n recursive: this.invitation.recursive,\n });\n });''', - ) - replace_once( - p, - ''' async decline(): Promise {\n this.ensureIsInvitedUser();\n this.ensurePending();\n this.ensureActive();\n\n await this.ctx.db\n .update(listInvitations)\n .set({ status: "declined" })\n .where(eq(listInvitations.id, this.invitation.id));\n }''', - ''' async decline(): Promise {\n this.ensureIsInvitedUser();\n this.ensurePending();\n\n await this.ctx.db.transaction(async (tx) => {\n const transactionCtx = asTransactionContext(this.ctx, tx);\n await tx\n .update(listInvitations)\n .set({ status: "declined" })\n .where(eq(listInvitations.id, this.invitation.id));\n await deleteCollaborationScope(transactionCtx, {\n listId: this.invitation.listId,\n userId: this.invitation.userId,\n });\n });\n this.invitation.status = "declined";\n }''', - ) - replace_once( - p, - ''' async revoke(): Promise {\n this.ensureIsListOwner();\n await this.ctx.db\n .delete(listInvitations)\n .where(eq(listInvitations.id, this.invitation.id));\n await deleteCollaborationScope(this.ctx, {\n listId: this.invitation.listId,\n userId: this.invitation.userId,\n });\n }''', - ''' async revoke(): Promise {\n this.ensureIsListOwner();\n await this.ctx.db.transaction(async (tx) => {\n const transactionCtx = asTransactionContext(this.ctx, tx);\n await tx\n .delete(listInvitations)\n .where(eq(listInvitations.id, this.invitation.id));\n await deleteCollaborationScope(transactionCtx, {\n listId: this.invitation.listId,\n userId: this.invitation.userId,\n });\n });\n }''', - ) - replace_once( - p, - ''' await this.ctx.db\n .update(listInvitations)\n .set({ role: params.role })\n .where(eq(listInvitations.id, this.invitation.id));\n await setCollaborationScope(this.ctx, {\n listId: this.invitation.listId,\n userId: this.invitation.userId,\n recursive: params.recursive,\n });\n this.invitation.role = params.role;\n this.invitation.recursive = params.recursive;''', - ''' await this.ctx.db.transaction(async (tx) => {\n const transactionCtx = asTransactionContext(this.ctx, tx);\n await tx\n .update(listInvitations)\n .set({ role: params.role })\n .where(eq(listInvitations.id, this.invitation.id));\n await setCollaborationScope(transactionCtx, {\n listId: this.invitation.listId,\n userId: this.invitation.userId,\n recursive: params.recursive,\n });\n });\n this.invitation.role = params.role;\n this.invitation.recursive = params.recursive;''', - ) - replace_once( - p, - ''' if (existingInvitation?.status === "declined") {\n await ctx.db\n .update(listInvitations)\n .set({\n status: "pending",\n role,\n invitedAt,\n invitedEmail: normalizedEmail,\n invitedBy: inviterUserId,\n })\n .where(eq(listInvitations.id, existingInvitation.id));\n await setCollaborationScope(ctx, {\n listId,\n userId: user.id,\n recursive,\n });\n return existingInvitation.id;\n }''', - ''' if (existingInvitation?.status === "declined") {\n await ctx.db.transaction(async (tx) => {\n const transactionCtx = asTransactionContext(ctx, tx);\n await tx\n .update(listInvitations)\n .set({\n status: "pending",\n role,\n invitedAt,\n invitedEmail: normalizedEmail,\n invitedBy: inviterUserId,\n })\n .where(eq(listInvitations.id, existingInvitation.id));\n await setCollaborationScope(transactionCtx, {\n listId,\n userId: user.id,\n recursive,\n });\n });\n return existingInvitation.id;\n }''', - ) - replace_once( - p, - ''' const res = await ctx.db\n .insert(listInvitations)\n .values({\n listId,\n userId: user.id,\n role,\n status: "pending",\n invitedAt,\n invitedEmail: normalizedEmail,\n invitedBy: inviterUserId,\n })\n .returning();\n await setCollaborationScope(ctx, {\n listId,\n userId: user.id,\n recursive,\n });\n return res[0].id;''', - ''' return ctx.db.transaction(async (tx) => {\n const transactionCtx = asTransactionContext(ctx, tx);\n const res = await tx\n .insert(listInvitations)\n .values({\n listId,\n userId: user.id,\n role,\n status: "pending",\n invitedAt,\n invitedEmail: normalizedEmail,\n invitedBy: inviterUserId,\n })\n .returning();\n await setCollaborationScope(transactionCtx, {\n listId,\n userId: user.id,\n recursive,\n });\n return res[0].id;\n });''', - ) - replace_once( - p, - ''' const invitations = await ctx.db.query.listInvitations.findMany({\n where: eq(listInvitations.listId, params.listId),''', - ''' const invitations = await ctx.db.query.listInvitations.findMany({\n where: and(\n eq(listInvitations.listId, params.listId),\n eq(listInvitations.status, "pending"),\n ),''', - ) - - replace_once( - "apps/web/components/dashboard/lists/PendingInvitationsCard.tsx", - ''' disabled={\n invitation.expired ||\n declineInvitation.isPending ||\n acceptInvitation.isPending\n }''', - ''' disabled={\n declineInvitation.isPending || acceptInvitation.isPending\n }''', - ) - replace_once( - "apps/mobile/app/dashboard/lists/invitations.tsx", - ''' disabled={\n invitation.expired ||\n declineInvitation.isPending ||\n acceptInvitation.isPending\n }''', - ''' disabled={\n declineInvitation.isPending || acceptInvitation.isPending\n }''', - ) - replace_once( - "apps/mobile/app/dashboard/lists/[slug]/index.tsx", - '"Leaving removes the direct collaboration grant that gives you access. If this list is inherited from a recursively shared parent, you will leave that parent share and lose access to lists that depend on it.",', - '"Leaving removes the nearest collaboration grant currently providing this access. If that grant comes from a recursively shared parent, access to lists that depend on it may also change.",', - ) - replace_once( - "apps/mobile/app/dashboard/lists/[slug]/collaborators.tsx", - ''' const { slug } = useLocalSearchParams();\n const listId = typeof slug === "string" ? slug : "";\n const api = useTRPC();''', - ''' const { slug } = useLocalSearchParams();\n if (typeof slug !== "string" || !slug) {\n throw new Error("Unexpected param type");\n }\n const listId = slug;\n const api = useTRPC();''', - ) - replace_once( - "apps/mobile/app/dashboard/lists/[slug]/collaborators.tsx", - 'api.lists.get.queryOptions({ listId }, { enabled: Boolean(listId) })', - 'api.lists.get.queryOptions({ listId })', - ) - replace_once( - "apps/mobile/app/dashboard/lists/[slug]/collaborators.tsx", - ''' api.lists.getCollaborators.queryOptions(\n { listId },\n { enabled: Boolean(listId) },\n ),''', - ''' api.lists.getCollaborators.queryOptions({ listId }),''', - ) - - test_path = "packages/trpc/routers/stableListInvitations.test.ts" - replace_once( - test_path, - 'import { eq } from "drizzle-orm";\n\nimport { listInvitations } from "@karakeep/db/schema";', - 'import { and, eq } from "drizzle-orm";\n\nimport { listCollaborationScopes } from "@karakeep/db";\nimport { listInvitations } from "@karakeep/db/schema";', - ) - replace_once( - test_path, - ''' test("normalizes invite email and reports delivery separately", async ({\n apiCallers,\n }) => {''', - ''' test("normalizes invite email and reports delivery separately", async ({\n apiCallers,\n db,\n }) => {''', - ) - replace_once( - test_path, - ''' expect(result.invitationId).toBeTruthy();\n expect(result.emailSent).toBe(false);''', - ''' expect(result.invitationId).toBeTruthy();\n expect(result.emailSent).toBe(false);\n const invitation = await db.query.listInvitations.findFirst({\n where: eq(listInvitations.id, result.invitationId),\n });\n expect(invitation?.invitedEmail).toBe("test2@test.com");''', - ) - anchor = ''' test("rejects an immediate repeated resend", async ({''' - addition = ''' test("allows declining an expired invitation and cleans its scope", async ({\n apiCallers,\n db,\n }) => {\n const ownerApi = apiCallers[0];\n const collaboratorApi = apiCallers[1];\n const list = await createManualList(ownerApi);\n const collaborator = await collaboratorApi.users.whoami();\n\n const { invitationId } = await ownerApi.lists.addCollaborator({\n listId: list.id,\n email: collaborator.email!,\n role: "viewer",\n recursive: true,\n });\n await db\n .update(listInvitations)\n .set({ invitedAt: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) })\n .where(eq(listInvitations.id, invitationId));\n\n await collaboratorApi.lists.declineInvitation({ invitationId });\n\n expect(await collaboratorApi.lists.getPendingInvitations()).toHaveLength(0);\n const declined = await db.query.listInvitations.findFirst({\n where: eq(listInvitations.id, invitationId),\n });\n expect(declined?.status).toBe("declined");\n const scope = await db.query.listCollaborationScopes.findFirst({\n where: and(\n eq(listCollaborationScopes.listId, list.id),\n eq(listCollaborationScopes.userId, collaborator.id),\n ),\n });\n expect(scope).toBeUndefined();\n });\n\n''' - replace_once(test_path, anchor, addition + anchor) - - Path(".github/workflows/pr39-invitation-hardening.yml").unlink() - PY - - name: Format - run: pnpm format:fix - - name: Commit patch and remove one-shot workflow - shell: bash - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit --no-verify -m "fix: harden invitation lifecycle" - git push origin HEAD:feat/stable-list-collaboration diff --git a/apps/mobile/app/dashboard/lists/[slug]/collaborators.tsx b/apps/mobile/app/dashboard/lists/[slug]/collaborators.tsx index ae56ed34a..c0a3c5b29 100644 --- a/apps/mobile/app/dashboard/lists/[slug]/collaborators.tsx +++ b/apps/mobile/app/dashboard/lists/[slug]/collaborators.tsx @@ -23,7 +23,10 @@ function deliveryMessage(emailSent: boolean) { export default function ManageListCollaboratorsPage() { const { slug } = useLocalSearchParams(); - const listId = typeof slug === "string" ? slug : ""; + if (typeof slug !== "string" || !slug) { + throw new Error("Unexpected param type"); + } + const listId = slug; const api = useTRPC(); const queryClient = useQueryClient(); const { toast } = useToast(); @@ -32,13 +35,10 @@ export default function ManageListCollaboratorsPage() { const [recursive, setRecursive] = useState(false); const { data: list, isPending: isListPending } = useQuery( - api.lists.get.queryOptions({ listId }, { enabled: Boolean(listId) }), + api.lists.get.queryOptions({ listId }), ); const { data, isPending } = useQuery( - api.lists.getCollaborators.queryOptions( - { listId }, - { enabled: Boolean(listId) }, - ), + api.lists.getCollaborators.queryOptions({ listId }), ); const invalidate = () => diff --git a/apps/mobile/app/dashboard/lists/[slug]/index.tsx b/apps/mobile/app/dashboard/lists/[slug]/index.tsx index 197c96fe7..d0d739b40 100644 --- a/apps/mobile/app/dashboard/lists/[slug]/index.tsx +++ b/apps/mobile/app/dashboard/lists/[slug]/index.tsx @@ -129,7 +129,7 @@ function ListActionsMenu({ const handleLeave = () => { Alert.alert( "Leave List", - "Leaving removes the direct collaboration grant that gives you access. If this list is inherited from a recursively shared parent, you will leave that parent share and lose access to lists that depend on it.", + "Leaving removes the nearest collaboration grant currently providing this access. If that grant comes from a recursively shared parent, access to lists that depend on it may also change.", [ { text: "Cancel", style: "cancel" }, { @@ -225,4 +225,4 @@ function ListActionsMenu({ ); -} \ No newline at end of file +} diff --git a/apps/mobile/app/dashboard/lists/invitations.tsx b/apps/mobile/app/dashboard/lists/invitations.tsx index 0dd15c273..1f6b5fd87 100644 --- a/apps/mobile/app/dashboard/lists/invitations.tsx +++ b/apps/mobile/app/dashboard/lists/invitations.tsx @@ -124,9 +124,7 @@ export default function ListInvitationsPage() {
); -} \ No newline at end of file +} diff --git a/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx b/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx index 3ac2d765c..27fde5a23 100644 --- a/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx +++ b/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx @@ -126,11 +126,7 @@ function InvitationRow({
@@ -328,18 +329,18 @@ export function ManageCollaboratorsModal({ variant={expired ? "destructive" : "outline"} > {expired - ? tc("expired") + ? t("lists.collaboration.expired") : t("lists.collaborators.pending")} )} {collaborator.inherited && ( - {tc("inherited")} + {t("lists.collaboration.inherited")} )} {collaborator.recursive && ( - {tc("nested_lists")} + {t("lists.collaboration.nested_lists")} )}
@@ -351,7 +352,7 @@ export function ManageCollaboratorsModal({ {collaborator.inherited && collaborator.sourceListName && (
- {tc("inherited_from", { + {t("lists.collaboration.inherited_from", { name: collaborator.sourceListName, })}
@@ -359,7 +360,9 @@ export function ManageCollaboratorsModal({ {pending && collaborator.expiresAt && (
- {expired ? tc("expired") : tc("expires")}{" "} + {expired + ? t("lists.collaboration.expired") + : t("lists.collaboration.expires")}{" "} {formatInvitationDate(collaborator.expiresAt)}
)} @@ -383,7 +386,7 @@ export function ManageCollaboratorsModal({ setRecursive(false); }} > - {tc("override_here")} + {t("lists.collaboration.override_here")} )}
@@ -402,7 +405,9 @@ export function ManageCollaboratorsModal({ > @@ -428,7 +433,7 @@ export function ManageCollaboratorsModal({ }) } /> - {tc("nested_lists")} + {t("lists.collaboration.nested_lists")}
{invitation.list.description && ( @@ -108,17 +111,19 @@ function InvitationRow({
{t("lists.invitations.invited_by")}{" "} - {invitation.list.owner?.name || tc("unknown")} + {invitation.list.owner?.name || t("lists.collaboration.unknown")}
- {invitation.expired ? tc("expired") : tc("expires")}{" "} + {invitation.expired + ? t("lists.collaboration.expired") + : t("lists.collaboration.expires")}{" "} {formatInvitationDate(invitation.expiresAt)}
{invitation.expired && (

- {tc("expired_help")} + {t("lists.collaboration.expired_help")}

)}
diff --git a/apps/web/lib/i18n/locales/en/collaboration.json b/apps/web/lib/i18n/locales/en/collaboration.json deleted file mode 100644 index d20ee8fd5..000000000 --- a/apps/web/lib/i18n/locales/en/collaboration.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "stable_description": "Invite people to this list and optionally include current and future nested lists.", - "invitation_role": "Invitation role", - "invite": "Invite", - "share_all_nested": "Also share all nested lists", - "share_all_nested_description": "Includes current nested lists and lists added or moved here later. Leave this off to share only this list.", - "nested_lists": "Nested lists", - "includes_nested_lists": "Includes nested lists", - "inherited": "Inherited", - "inherited_from": "Inherited from {{name}}", - "expired": "Expired", - "expires": "Expires", - "unknown": "Unknown", - "expired_help": "Ask the list owner to resend this invitation to renew it for 30 days.", - "override_here": "Override here", - "pending_invitation_role": "Pending invitation role", - "collaborator_role": "Collaborator role", - "resend": "Resend", - "invitation_delivery_sent": "Invitation created and email sent.", - "invitation_delivery_failed": "Invitation created, but the email was not sent. You can resend it later.", - "remove_aria": "Remove {{name}}", - "remove_confirmation": "Remove {{name}} from this shared list? Bookmark entries they contributed through this collaboration will be removed from this shared list, but their underlying bookmarks will remain in their library.", - "breadcrumb_label": "List hierarchy" -} diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 6d83d7179..15f836569 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -881,7 +881,31 @@ "summary_bookmark_one": "{{count}} bookmark", "summary_bookmark_other": "{{count}} bookmarks", "items_count_one": "{{count}} item", - "items_count_other": "{{count}} items" + "items_count_other": "{{count}} items", + "collaboration": { + "stable_description": "Invite people to this list and optionally include current and future nested lists.", + "invitation_role": "Invitation role", + "invite": "Invite", + "share_all_nested": "Also share all nested lists", + "share_all_nested_description": "Includes current nested lists and lists added or moved here later. Leave this off to share only this list.", + "nested_lists": "Nested lists", + "includes_nested_lists": "Includes nested lists", + "inherited": "Inherited", + "inherited_from": "Inherited from {{name}}", + "expired": "Expired", + "expires": "Expires", + "unknown": "Unknown", + "expired_help": "Ask the list owner to resend this invitation to renew it for 30 days.", + "override_here": "Override here", + "pending_invitation_role": "Pending invitation role", + "collaborator_role": "Collaborator role", + "resend": "Resend", + "invitation_delivery_sent": "Invitation created and email sent.", + "invitation_delivery_failed": "Invitation created, but the email was not sent. You can resend it later.", + "remove_aria": "Remove {{name}}", + "remove_confirmation": "Remove {{name}} from this shared list? Bookmark entries they contributed through this collaboration will be removed from this shared list, but their underlying bookmarks will remain in their library.", + "breadcrumb_label": "List hierarchy" + } }, "tags": { "all_tags": "All Tags", From 669222a2f093e2047f69e1a1cbc89f3f2a7438fa Mon Sep 17 00:00:00 2001 From: Daffa Abhipraya Date: Sat, 15 Aug 2026 21:04:41 +0700 Subject: [PATCH 87/87] docs: record typed collaboration translation namespace --- .../specs/2026-08-15-stable-list-collaboration-design.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-08-15-stable-list-collaboration-design.md b/docs/superpowers/specs/2026-08-15-stable-list-collaboration-design.md index 38d1fba1c..96ded7c5a 100644 --- a/docs/superpowers/specs/2026-08-15-stable-list-collaboration-design.md +++ b/docs/superpowers/specs/2026-08-15-stable-list-collaboration-design.md @@ -78,6 +78,7 @@ All user-controlled values inserted into HTML email must be escaped. Plain-text - Viewer-only users never see edit/remove list-membership actions. - Shared trees render inside the existing `Shared Lists` sidebar section with the exact normal dashboard list styling; accessible ancestors remain navigable and inaccessible ancestors are omitted. - List headers expose the accessible path as clickable, semantic breadcrumbs without revealing inaccessible ancestors. +- Stable collaboration copy lives under `lists.collaboration` in the existing typed `translation` namespace so the rest of the web app keeps its current i18n typing and fallback behavior. ### Mobile @@ -98,4 +99,4 @@ The browser extension continues to rely on the same server-side list authorizati - Comments, presence, activity feed, or real-time collaborative editing. - Inviting unregistered email addresses. - Durable email outbox/background retry infrastructure. -- Dedicated browser-extension collaboration management UI. +- Dedicated browser-extension collaboration management UI. \ No newline at end of file