feat: public shopping list links can allow editing
Owner opts in per-list via a new "Allow editing" toggle next to the existing public-link switch. Anonymous writes are scoped to that one list only — the link id is the sole credential, enforced in getShoppingListAccess and the item routes (no session required there now), with an IP rate limit on genuinely anonymous requests. Turning off the public link also revokes editing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,23 @@ describe("getShoppingListAccess", () => {
|
||||
mockMemberFindFirst.mockResolvedValue(undefined);
|
||||
expect(await getShoppingListAccess("list-1", "user-2")).toBeNull();
|
||||
});
|
||||
|
||||
it("grants anonymous editor access via a public-editable link", async () => {
|
||||
mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-owner", isPublic: true, publicEditable: true });
|
||||
const access = await getShoppingListAccess("list-1", null);
|
||||
expect(access?.role).toBe("editor");
|
||||
});
|
||||
|
||||
it("grants anonymous viewer access via a public read-only link", async () => {
|
||||
mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-owner", isPublic: true, publicEditable: false });
|
||||
const access = await getShoppingListAccess("list-1", null);
|
||||
expect(access?.role).toBe("viewer");
|
||||
});
|
||||
|
||||
it("denies anonymous access when the list isn't public", async () => {
|
||||
mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-owner", isPublic: false, publicEditable: false });
|
||||
expect(await getShoppingListAccess("list-1", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("canWriteShoppingList", () => {
|
||||
|
||||
@@ -13,6 +13,12 @@ export async function requireSession() {
|
||||
return { session, response: null };
|
||||
}
|
||||
|
||||
/** Like requireSession, but never 401s — for endpoints that also accept anonymous
|
||||
* access via a resource-scoped capability (e.g. a public-editable share link). */
|
||||
export async function getOptionalSession() {
|
||||
return auth.api.getSession({ headers: await headers() });
|
||||
}
|
||||
|
||||
export async function requireAdmin() {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return { session: null, response };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||
export const APP_VERSION = "0.11.0";
|
||||
export const APP_VERSION = "0.12.0";
|
||||
|
||||
export type ChangelogEntry = {
|
||||
version: string;
|
||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
||||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.12.0",
|
||||
date: "2026-07-13 12:52",
|
||||
added: [
|
||||
"**Public shopping list links can now allow editing**: turn on \"Allow editing\" alongside the public link so anyone who has it can check off, add, and reorder items — no account needed. Off by default, and turning off the public link revokes editing too.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.11.0",
|
||||
date: "2026-07-13 12:27",
|
||||
|
||||
@@ -7,21 +7,32 @@ export type ShoppingListAccess = {
|
||||
role: ShoppingListRole;
|
||||
};
|
||||
|
||||
/** Resolves a user's access to a shopping list — owner, or member with their assigned role. Null if no access. */
|
||||
/**
|
||||
* Resolves access to a shopping list — owner, member with their assigned role,
|
||||
* or (when `userId` is null, i.e. no session) an anonymous visitor via the
|
||||
* public link: "editor" if the owner enabled public editing, "viewer" if the
|
||||
* list is merely public, else no access. The link's id is the only credential
|
||||
* an anonymous visitor has, so this must never fall through to a real role
|
||||
* for a non-public list.
|
||||
*/
|
||||
export async function getShoppingListAccess(
|
||||
listId: string,
|
||||
userId: string
|
||||
userId: string | null
|
||||
): Promise<ShoppingListAccess | null> {
|
||||
const list = await db.query.shoppingLists.findFirst({ where: eq(shoppingLists.id, listId) });
|
||||
if (!list) return null;
|
||||
if (list.userId === userId) return { list, role: "owner" };
|
||||
|
||||
const member = await db.query.shoppingListMembers.findFirst({
|
||||
where: and(eq(shoppingListMembers.listId, listId), eq(shoppingListMembers.userId, userId)),
|
||||
});
|
||||
if (!member) return null;
|
||||
if (userId) {
|
||||
if (list.userId === userId) return { list, role: "owner" };
|
||||
|
||||
return { list, role: member.role };
|
||||
const member = await db.query.shoppingListMembers.findFirst({
|
||||
where: and(eq(shoppingListMembers.listId, listId), eq(shoppingListMembers.userId, userId)),
|
||||
});
|
||||
if (member) return { list, role: member.role };
|
||||
}
|
||||
|
||||
if (!list.isPublic) return null;
|
||||
return { list, role: list.publicEditable ? "editor" : "viewer" };
|
||||
}
|
||||
|
||||
export function canWriteShoppingList(role: ShoppingListRole): boolean {
|
||||
|
||||
@@ -44,7 +44,10 @@ async function getOtherRecipients(listId: string, actorId: string) {
|
||||
export async function notifyShoppingListMembers(
|
||||
listId: string,
|
||||
actorId: string,
|
||||
actorName: string,
|
||||
// null actorName = an anonymous edit via a public-editable share link — no
|
||||
// per-visitor identity exists, so this is rendered as a localized generic
|
||||
// label (per recipient's own locale) rather than a real name.
|
||||
actorName: string | null,
|
||||
event: { type: "checked" | "itemsAdded"; itemName?: string; count?: number; listName: string }
|
||||
): Promise<void> {
|
||||
const recipients = await getOtherRecipients(listId, actorId);
|
||||
@@ -57,16 +60,17 @@ export async function notifyShoppingListMembers(
|
||||
if (!(await isNotificationCategoryEnabled(recipient.id, "shoppingList"))) return;
|
||||
|
||||
const messages = getMessages(recipient.locale);
|
||||
const displayName = actorName ?? messages.publicShoppingList.anonymousEditor;
|
||||
const title = messages.notifications.pushTitle.shoppingListUpdate;
|
||||
const body =
|
||||
event.type === "checked"
|
||||
? formatMessage(messages.notifications.detail.shoppingListChecked, {
|
||||
name: actorName,
|
||||
name: displayName,
|
||||
item: event.itemName ?? "",
|
||||
list: event.listName,
|
||||
})
|
||||
: formatMessage(messages.notifications.detail.shoppingListItemsAdded, {
|
||||
name: actorName,
|
||||
name: displayName,
|
||||
countLabel: itemsCountLabel(event.count ?? 1, recipient.locale),
|
||||
list: event.listName,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user