Sharing data by link
How to build data that several people share, like a flat's grocery list, a team poll or a trip budget, opened with an unguessable link.
Guests get a private identity in each browser, so data filtered by userId is visible only to its owner. Shared data needs its own key instead: a share code. Whoever has the link can read and edit; nobody can find the data without it.
The pattern
- The browser makes a random code of 20 lowercase letters and digits (about 103 bits), with
crypto.getRandomValues, when someone creates the shared thing. - Every shared table stores that
codeand has an index on it. - Every query and mutation takes the code, checks its shape, and loads rows only through that index. Never look up shared rows by a bare row ID: always check that the row's
codematches the code the caller sent. - The page lives at a path that holds the code, such as
/l/<code>. Sharing is sending that URL. - Optional: a
memberstable remembers which lists a person opened, so the home page can show "your lists".
Anyone with the link can change the data. To lock someone out, create a new list with a new code and share only that.
Shared code
shared/list.ts is used by both sides:
export function isShareCode(value: unknown): value is string {
return typeof value === "string" && /^[a-z0-9]{20}$/.test(value);
}
export function cleanText(value: string, max: number): string {
return value.replace(/\s+/g, " ").trim().slice(0, max);
}
Server
server/index.ts:
import { boolean, fold, mutation, query, string, table, userId } from "@aozumi/fold/server";
import { cleanText, isShareCode } from "../shared/list";
function requireCode(code: string): string {
if (!isShareCode(code)) throw new Error("That link is not valid.");
return code;
}
export default fold({
name: "shared-list",
schema: {
lists: table({ code: string(), title: string(), createdBy: userId() }).index("by_code", ["code"]),
members: table({ code: string(), memberId: userId() })
.index("by_member", ["memberId"])
.index("by_code_member", ["code", "memberId"]),
items: table({ code: string(), text: string(), done: boolean().default(false) }).index("by_code", ["code"])
},
queries: {
list: query(async (ctx, args: { code: string }) => {
ctx.auth.requireIdentity();
const code = requireCode(args.code);
const list = await ctx.db.lists.withIndex("by_code", (q) => q.eq("code", code)).first();
if (!list) return null;
const items = await ctx.db.items.withIndex("by_code", (q) => q.eq("code", code)).take(500);
return { title: list.title, items };
}),
myLists: query(async (ctx) => {
const { userId } = ctx.auth.requireIdentity();
const memberships = await ctx.db.members.withIndex("by_member", (q) => q.eq("memberId", userId)).order("desc").take(20);
const lists = [];
for (const membership of memberships) {
const list = await ctx.db.lists.withIndex("by_code", (q) => q.eq("code", membership.code)).first();
if (list) lists.push({ code: list.code, title: list.title });
}
return lists;
})
},
mutations: {
createList: mutation(async (ctx, code: string, title: string) => {
const { userId } = ctx.auth.requireIdentity();
requireCode(code);
if (await ctx.db.lists.withIndex("by_code", (q) => q.eq("code", code)).first()) throw new Error("Try again.");
await ctx.db.lists.insert({ code, title: cleanText(title, 60) || "Our list", createdBy: userId });
await ctx.db.members.insert({ code, memberId: userId });
}),
joinList: mutation(async (ctx, code: string) => {
const { userId } = ctx.auth.requireIdentity();
requireCode(code);
if (!(await ctx.db.lists.withIndex("by_code", (q) => q.eq("code", code)).first())) throw new Error("List not found.");
const joined = await ctx.db.members.withIndex("by_code_member", (q) => q.eq("code", code).eq("memberId", userId)).first();
if (!joined) await ctx.db.members.insert({ code, memberId: userId });
}),
addItem: mutation(async (ctx, code: string, text: string) => {
ctx.auth.requireIdentity();
requireCode(code);
if (!(await ctx.db.lists.withIndex("by_code", (q) => q.eq("code", code)).first())) throw new Error("List not found.");
const clean = cleanText(text, 80);
if (!clean) throw new Error("Type something first.");
await ctx.db.items.insert({ code, text: clean });
}),
toggleItem: mutation(async (ctx, code: string, itemId: string) => {
ctx.auth.requireIdentity();
requireCode(code);
const item = await ctx.db.items.get(itemId);
// The code check stops a leaked row ID from editing another list.
if (!item || item.code !== code) throw new Error("Item not found.");
await ctx.db.items.update(itemId, { done: !item.done });
})
}
});
Queries and mutations call ctx.auth.requireIdentity() so every caller has a guest or signed-in session, even though access is decided by the code. myLists is the only place that filters by userId, because it is about the person, not the list.
Client
client/index.tsx, with the router from @aozumi/fold/client:
import { createClient, Route, Router, Routes, useNavigate, useParams } from "@aozumi/fold/client";
import { useEffect, useState } from "preact/hooks";
import type app from "../server/index";
const client = createClient<typeof app>();
function newShareCode(): string {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
const bytes = new Uint8Array(20);
crypto.getRandomValues(bytes);
return Array.from(bytes, (byte) => alphabet[byte % alphabet.length]).join("");
}
function Home() {
const lists = client.useQuery("myLists") ?? [];
const createList = client.useMutation("createList");
const navigate = useNavigate();
const create = async () => {
const code = newShareCode();
await createList(code, "Our list");
navigate(`/l/${code}`);
};
return (
<main className="mx-auto max-w-md p-6">
<button className="rounded bg-black px-4 py-2 text-white" type="button" onClick={() => void create()}>New list</button>
<ul className="mt-4">{lists.map((list) => <li key={list.code}><a href={`/l/${list.code}`}>{list.title}</a></li>)}</ul>
</main>
);
}
function ListPage() {
const { code } = useParams<{ code: string }>();
const data = client.useQuery("list", { code });
const joinList = client.useMutation("joinList");
const addItem = client.useMutation("addItem");
const toggleItem = client.useMutation("toggleItem");
const [text, setText] = useState("");
const exists = Boolean(data);
// Remember the list for this person once it has loaded.
useEffect(() => {
if (exists) void joinList(code);
}, [exists, code]);
const share = () => void navigator.clipboard.writeText(location.href);
if (data === undefined) return <p className="p-6">Loading…</p>;
if (data === null) return <p className="p-6">This list does not exist.</p>;
return (
<main className="mx-auto max-w-md p-6">
<h1 className="text-xl font-semibold">{data.title}</h1>
<button className="mt-2 text-sm underline" type="button" onClick={share}>Copy share link</button>
<form className="mt-4 flex gap-2" onSubmit={(event) => { event.preventDefault(); void addItem(code, text); setText(""); }}>
<input className="flex-1 rounded border px-2" value={text} onInput={(event) => setText(event.currentTarget.value)} />
<button className="rounded border px-3" type="submit">Add</button>
</form>
<ul className="mt-4">
{data.items.map((item) => (
<li key={item.id}>
<label><input checked={item.done} type="checkbox" onChange={() => void toggleItem(code, item.id)} /> {item.text}</label>
</li>
))}
</ul>
</main>
);
}
export function App() {
return (
<Router>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/l/:code" element={<ListPage />} />
</Routes>
</Router>
);
}
Every open page updates live when anyone changes the list. Test sharing locally with two identities: open the list normally and again with ?fold_guest=flatmate added to the URL.
Variations
- Poll or sign-up sheet: the same code-keyed tables, with a
votesorslotstable indexed by["code", "memberId"]so each person answers once. - Read-only link: make two codes, an edit code and a view code, and check which one a mutation received.
- Bigger lists:
take(500)caps what one query reads. Usepaginate()from the database guide for more.
See Auth for signed-in identities and Limits for what a shared app can store.