# 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:

```ts
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`:

```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`:

```tsx
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 `votes` or `slots` table 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. Use `paginate()` from the [database guide](/fold/database/) for more.

See [Auth](/fold/auth/) for signed-in identities and [Limits](/fold/limits/) for what a shared app can store.
