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

  1. The browser makes a random code of 20 lowercase letters and digits (about 103 bits), with crypto.getRandomValues, when someone creates the shared thing.
  2. Every shared table stores that code and has an index on it.
  3. 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 code matches the code the caller sent.
  4. The page lives at a path that holds the code, such as /l/<code>. Sharing is sending that URL.
  5. Optional: a members table 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

See Auth for signed-in identities and Limits for what a shared app can store.