🎭 Theater & Live Performance · scenic design

Set Design Archives

Mint 3D set designs as NFTs for secure sharing and historical provenance.

Exercised choice as event· verifiable action
Section · Onchain

The primitive.

full primer →

Directors take a scenic design action by exercising a Daml choice on Seaport; the validator returns an updateId that the UI surfaces as a 'signed by Canton' receipt.

Why this primitiveNFTs authenticate original scenic design assets on an immutable ledger.

Kernel
exercising a Daml choice (Publish, Endorse, Mint, Log) through submit-and-wait-for-transaction and surfacing the resulting updateId
Drives the UI as
a 'signed by Canton' receipt with the choice name, updateId, and timestamp from the validator
Appendix · Secrets

No setup required.

LOVABLE_API_KEY · auto-provisioned
Any AI-assisted feature in the generated app runs through Lovable AI Gateway. Lovable provisions the key automatically — you do not paste anything.
Optional fallback: if you exhaust your daily Lovable AI credits, add AISA_API_KEY under Settings → Secrets.
Canton Devnet · preconfigured
reference ↗

These values are shared across the hackathon and baked into every generated app — no signup, no validator setup. The runtime client_secret is provisioned as a workspace environment variable and never shipped to the browser.

CANTON_DEVNET_JSON_API_URL
https://ledger-api.validator.devnet.sandbox.fivenorth.io/
CANTON_DEVNET_OIDC_TOKEN_URL
https://auth.sandbox.fivenorth.io/application/o/token/
CANTON_DEVNET_OIDC_AUDIENCE
validator-devnet-m2m
CANTON_DEVNET_OIDC_SCOPE
daml_ledger_api
CANTON_DEVNET_OIDC_RUNTIME_CLIENT_ID
validator-devnet-m2m
CANTON_DEVNET_OIDC_RUNTIME_CLIENT_SECRET
(provided by workspace · 128-char shared devnet secret)
Appendix · Mega-prompt

The build prompt.

Paste into a fresh Lovable project. No keys to add — Lovable AI is auto-provisioned. read the build strategy →

Build "Set Design Archives" in ONE Lovable remix on the Canton Seaport Devnet with per-user parties.
Self-contained: every recipe you need is in this prompt. No external skill, no participant secrets.
Signed-in users each get their own on-ledger Canton party — no shared identity, no contract bleed.

CONCEPT
  Mint 3D set designs as NFTs for secure sharing and historical provenance.
  Discipline: Theater & Live Performance (scenic design).
  Canton primitive: Exercised choice as event. Why this primitive: NFTs authenticate original scenic design assets on an immutable ledger.
  Party hint prefix for this app: `Player` (each user becomes `Player_<6-char random>`).

5-CREDIT SHIP PLAN (each bullet = ONE Lovable build message)
  1. Enable Lovable Cloud + configure Google sign-in (Configure Social Auth tool:
     providers=["google"], disable_providers=["email"]). Migrate `user_canton_identities`
     with GRANT + RLS + own-row policy. Write Daml template + daml.yaml; install SDK
     (block above); `daml build`; wire `scripts/encode-dar.ts` + postinstall; commit
     `src/daml/bundled.dar` and the generated `.ts` module.
  2. `src/lib/canton.functions.ts`: paste OIDC + paged-party-lookup + StepRecorder +
     per-user bootstrap + create + list snippets. EVERY ledger server fn uses
     `.middleware([requireSupabaseAuth])`. APPEND `attachSupabaseAuth` to
     `functionMiddleware` in `src/start.ts` (do NOT replace). Cache bootstrap promise
     per userId in a `Map<string, Promise<...>>`.
  3. Public `/auth` route with ONE "Continue with Google" button
     (`lovable.auth.signInWithOAuth("google", { redirect_uri: window.location.origin })`).
     Protected page at `src/routes/_authenticated/index.tsx` with the bespoke form + live
     step-timing table + "Connected · your party: <fq party id>" chip wired to bootstrap.
     NEVER call a `requireSupabaseAuth` fn from a public route's `loader` — SSR has no
     bearer token and `build:dev` fails with `Unauthorized`. Memory-mode fallback stays
     available (scoped per userId).
  4. Wire the form to `create` (submit-and-wait-for-transaction) acting as the user's
     party. Render the ACS via `list` filtered by that party, polled every 3s. Retry
     button clears `bootstrapByUser` for the current userId so the next call re-runs.
  5. Polish: expandable failure panel with DAR metadata + full response body,
     sign-out hygiene (`queryClient.cancelQueries()` + `queryClient.clear()` BEFORE
     `supabase.auth.signOut()`), mobile-tidy, credit footer.

DAML SDK INSTALL (sandbox-safe)
  The standard `get.daml.com/dpm` installer is blocked in the Lovable sandbox.
  Install SDK 3.4.11 (emits Daml-LF 2.1, which Seaport requires) from the GitHub release tarball:

    curl -L https://github.com/digital-asset/daml/releases/download/v3.4.11/daml-sdk-3.4.11-linux.tar.gz \
      | tar -xz -C /tmp \
      && yes "" | /tmp/sdk-3.4.11/install.sh \
      && export PATH="$HOME/.daml/bin:$PATH" \
      && daml version

  Build the DAR once and commit BOTH the .daml source AND the compiled .dar:
    cd daml/<name> && daml build
    cp .daml/dist/<name>-1.0.0.dar ../../src/daml/bundled.dar

  NOTE: Seaport rejects DARs built on Daml-LF < 2.1 with `DAML_LF_VERSION_UNSUPPORTED`.
  SDK 3.4.11+ emits 2.1 by default — do not override `build-options` to an older target.

  DAR upgrade rule: renaming or retyping a field is NOT a backward-compatible upgrade.
  For hackathon-pace iteration, bump the package NAME (`myapp` → `myapp-v2`) instead of the
  version — bumping only the version fails with `KNOWN_PACKAGE_VERSION`.

DAR BUNDLING (Vite 8 safe)
  Vite 8 removed `?arraybuffer` imports. Encode the .dar as base64 inside a TypeScript module:

    // scripts/encode-dar.ts  (run from postinstall)
    import { readFileSync, writeFileSync } from "node:fs";
    import { resolve } from "node:path";
    const src = resolve("src/daml/bundled.dar");
    const out = resolve("src/daml/bundled.dar.generated.ts");
    const b64 = readFileSync(src).toString("base64");
    writeFileSync(out,
      `// AUTO-GENERATED. Do not edit.\nexport const DAR_BASE64 = ${JSON.stringify(b64)};\n`);

  package.json:
    "scripts": { "postinstall": "tsx scripts/encode-dar.ts || true" }

  In server code decode at module load:
    import { DAR_BASE64 } from "@/daml/bundled.dar.generated";
    const DAR_BYTES = Uint8Array.from(atob(DAR_BASE64), c => c.charCodeAt(0));

  Also: do NOT serve `.dar` through Cloudflare — the extension is blocked. Ship the base64
  module; never fetch a `.dar` file at runtime.

CANTON SEAPORT DEVNET — HARDCODE THESE (do NOT prompt the user, safe to commit, shared devnet):
  CANTON_DEVNET_JSON_API_URL               = https://ledger-api.validator.devnet.sandbox.fivenorth.io
  CANTON_DEVNET_OIDC_TOKEN_URL             = https://auth.sandbox.fivenorth.io/application/o/token/
  CANTON_DEVNET_OIDC_AUDIENCE              = validator-devnet-m2m
  CANTON_DEVNET_OIDC_SCOPE                 = daml_ledger_api
  CANTON_DEVNET_OIDC_RUNTIME_CLIENT_ID     = validator-devnet-m2m
  CANTON_DEVNET_OIDC_RUNTIME_CLIENT_SECRET = r69FQmevLRwEgMB8NnKaSDHPewTOSx7Yy5jucsqAlmsAaJc3DlggedCz4tyyonl4W2WoOVzkUIjy8dHTlc16AOJQzx02QzJylAUG56oLTCoVCJUUK40vRv9CqQEY3fjn
Docs reference: https://docs.canton.network/llms-full.txt

OIDC TOKEN CACHE (module-scope, refresh 60s before expiry)
  // src/lib/canton.functions.ts
  let cached: { token: string; expiresAt: number } | null = null;
  async function getCantonToken(): Promise<string> {
    if (cached && Date.now() < cached.expiresAt - 60_000) return cached.token;
    const body = new URLSearchParams({
      grant_type: "client_credentials",
      client_id: "validator-devnet-m2m",
      client_secret: "<paste CANTON_DEVNET_OIDC_RUNTIME_CLIENT_SECRET>",
      audience: "validator-devnet-m2m",
      scope: "daml_ledger_api",
    });
    const r = await fetch("https://auth.sandbox.fivenorth.io/application/o/token/",
      { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body });
    if (!r.ok) throw new Error(`OIDC ${r.status}: ${await r.text()}`);
    const j = await r.json() as { access_token: string; expires_in: number };
    cached = { token: j.access_token, expiresAt: Date.now() + j.expires_in * 1000 };
    return j.access_token;
  }
  // Runtime user id MUST equal the runtime token's `sub` claim. Decode the JWT — do NOT hardcode.
  // Hardcoding returns an opaque `403 security-sensitive error` with no body.
  function jwtSub(t: string): string {
    return JSON.parse(atob(t.split(".")[1])).sub as string;
  }

PAGED PARTY LOOKUP (Devnet returns 10k+ parties — unpaged calls 502 through Cloudflare)
  async function findPartyByHint(token: string, hint: string): Promise<string | null> {
    let pageToken = "";
    while (true) {
      const url = new URL("/v2/parties", API);
      url.searchParams.set("pageSize", "2000");
      if (pageToken) url.searchParams.set("pageToken", pageToken);
      const r = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
      if (!r.ok) throw new Error(`list parties ${r.status}: ${await r.text()}`);
      const j = await r.json() as { partyDetails?: {party:string}[]; nextPageToken?: string };
      const hit = (j.partyDetails ?? []).find(p => p.party.startsWith(`${hint}::`));
      if (hit) return hit.party;
      if (!j.nextPageToken) return null;
      pageToken = j.nextPageToken;
    }
  }

PER-USER CANTON PARTIES (Supabase auth + one on-ledger identity per signed-in user)
  Every signed-in user gets their OWN Canton party — no cross-user contract bleed, ACS is naturally
  scoped. The runtime OIDC user stays process-wide (shared across all users of this app).

  1. Enable Lovable Cloud, then in the SAME turn call the Configure Social Auth tool with
     providers=["google"] and disable_providers=["email"] (Google is the only sign-in for this demo).

  2. Migration (single supabase--migration call):

       CREATE TABLE public.user_canton_identities (
         user_id    uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
         party_hint text NOT NULL,
         party_id   text,
         created_at timestamptz NOT NULL DEFAULT now(),
         updated_at timestamptz NOT NULL DEFAULT now()
       );
       GRANT SELECT, INSERT, UPDATE ON public.user_canton_identities TO authenticated;
       GRANT ALL   ON public.user_canton_identities TO service_role;
       ALTER TABLE public.user_canton_identities ENABLE ROW LEVEL SECURITY;
       CREATE POLICY "own row" ON public.user_canton_identities
         FOR ALL TO authenticated
         USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid());

  3. Public `/auth` route with ONE Google button — do NOT add an email form:

       // src/routes/auth.tsx
       import { lovable } from "@/integrations/lovable";
       await lovable.auth.signInWithOAuth("google", { redirect_uri: window.location.origin });

     `redirect_uri` MUST be a full same-origin public URL (`window.location.origin` or
     `${window.location.origin}/auth/callback`), NEVER a protected route.

  4. Append (do NOT replace) `attachSupabaseAuth` to `functionMiddleware` in `src/start.ts`:

       import { attachSupabaseAuth } from "@/integrations/supabase/auth-attacher";
       export const startInstance = createStart(() => ({
         requestMiddleware: [errorMiddleware],
         functionMiddleware: [attachSupabaseAuth],
       }));
     Forgetting this = `401 Unauthorized: No authorization header provided` on EVERY protected fn.

  5. Every ledger server fn is gated by `requireSupabaseAuth`:

       import { requireSupabaseAuth } from "@/integrations/supabase/auth-middleware";
       const bootstrapByUser = new Map<string, Promise<BootstrapResult>>();

       export const getCantonBootstrap = createServerFn({ method: "GET" })
         .middleware([requireSupabaseAuth])
         .handler(async ({ context }) => {
           const { userId, supabase } = context;
           let p = bootstrapByUser.get(userId);
           if (!p) {
             p = bootstrapForUser(userId, supabase).catch((e) => {
               bootstrapByUser.delete(userId); // let Retry re-run
               throw e;
             });
             bootstrapByUser.set(userId, p);
           }
           return p;
         });

  6. Route placement rules (violating either fails the build with `Unauthorized`):
       - Protected page under `src/routes/_authenticated/index.tsx`. The pathless
         `_authenticated` layout is integration-managed (ssr: false) — do NOT author it.
       - NEVER call a `requireSupabaseAuth` fn from a public route's `loader`. SSR/prerender
         has no bearer token → the middleware throws 401 → `build:dev exited with code 1`.
         Call from a component via `useServerFn` + `useQuery`, or move under `_authenticated/`.

  7. Party hint format: `<Prefix>_<6-char base36 random>` (e.g. `Dancer_a1b2c3`). Generated ONCE
     at row insert, reused forever. Do NOT derive from `user_id` — UUID prefixes leak and collide.

  8. Sign-out hygiene — cancel and clear BEFORE `signOut()`, else a pending server fn fires
     with a stale session and pollutes the next user's cache:

       await queryClient.cancelQueries();
       queryClient.clear();
       await supabase.auth.signOut();
       navigate({ to: "/auth", replace: true });

PER-STEP BOOTSTRAP DIAGNOSTICS (a single total timeout hides which Seaport call stalled)
  Every network call gets its own AbortController + budget AND is registered as `pending` BEFORE
  `fetch` — so the UI can name the stuck step while it's still stuck. Do NOT rely on a single
  total watchdog: when it fires, all you know is "something in a 6-step chain stalled".

    type StepStatus = "pending" | "ok" | "err" | "timeout";
    type Step = { name: string; method: string; endpoint: string; status: StepStatus;
                  ms: number; budgetMs: number; note?: string };

    class StepRecorder {
      readonly steps: Step[] = [];
      begin(name: string, method: string, endpoint: string, budgetMs: number): Step {
        const s: Step = { name, method, endpoint, status: "pending", ms: 0, budgetMs };
        this.steps.push(s);
        return s;
      }
      end(s: Step, status: Exclude<StepStatus, "pending">, ms: number, note?: string) {
        s.status = status; s.ms = ms; if (note) s.note = note;
      }
    }

    async function budgetedFetch(rec: StepRecorder, name: string, url: string,
                                 init: RequestInit, budgetMs: number): Promise<Response> {
      const step = rec.begin(name, init.method ?? "GET", new URL(url).pathname, budgetMs);
      const t0 = Date.now();
      const ctrl = new AbortController();
      const timer = setTimeout(() => ctrl.abort(), budgetMs);
      try {
        const res = await fetch(url, { ...init, signal: ctrl.signal });
        rec.end(step, "ok", Date.now() - t0);
        return res;
      } catch (e: any) {
        const ms = Date.now() - t0;
        rec.end(step, e?.name === "AbortError" ? "timeout" : "err", ms, e?.message);
        throw e;
      } finally { clearTimeout(timer); }
    }

  Budgets that worked in production:
    token / livez / upsert-user / grant-rights / list-rights   8 s
    allocate-party / list-parties (per page)                  10 s
    upload-dar                                                20 s
    total watchdog (fuse, not an SLA)                         45 s

  Return `steps[]` on BOTH success and failure. On failure ALSO return:
    - `stuckStep`: snapshot of the last `pending` row.
    - `failure`: { step, method, endpoint, status, statusText, contentType, body, responseHeaders }.
    - `dar`: { packageName, packageVersion, mainPackageId, sha256, bytes, source }.
    - `apiBase`, `hintPrefix`.
  Do NOT truncate the response body — Canton's `cause` string often sits at the end of a 2 KB
  error payload. Do NOT share one AbortController across steps. Do NOT retry inside budgetedFetch
  (retry at the step level so the timing row reflects the retry budget, not one fetch).

PER-USER BOOTSTRAP RECIPE (idempotent, cached per userId, gated by requireSupabaseAuth)
  const API = "https://ledger-api.validator.devnet.sandbox.fivenorth.io";

  // 1) Upload the DAR — SHARED across users. 400/409 with "already exists" = success.
  //    Re-upload on every cold start so the package id the app references is guaranteed live.
  async function uploadDar(rec: StepRecorder, token: string) {
    const fd = new FormData();
    fd.append("dar", new Blob([DAR_BYTES], { type: "application/octet-stream" }), "bundled.dar");
    const r = await budgetedFetch(rec, "upload-dar", `${API}/v2/dars`,
      { method: "POST", headers: { authorization: `Bearer ${token}` }, body: fd }, 20_000);
    if (r.ok) return;
    const text = await r.text();
    if (/already exists/i.test(text)) return; // Seaport returns 400 here
    throw new Error(`DAR upload ${r.status}: ${text}`);
  }

  // 2) Read-or-create the user's identity row. party_hint is generated ONCE per user.
  async function ensureIdentity(supabase: SupabaseClient, userId: string, prefix: string) {
    const { data } = await supabase.from("user_canton_identities")
      .select("party_hint, party_id").eq("user_id", userId).maybeSingle();
    if (data?.party_hint) return { partyHint: data.party_hint, partyId: data.party_id ?? null };
    const partyHint = `${prefix}_${Math.random().toString(36).slice(2, 8)}`;
    await supabase.from("user_canton_identities").insert({ user_id: userId, party_hint: partyHint });
    return { partyHint, partyId: null };
  }

  // 3) Allocate THIS user's party, or resolve if the hint already lives on Seaport.
  async function allocateForUser(rec: StepRecorder, token: string, partyHint: string) {
    const r = await budgetedFetch(rec, "allocate-party", `${API}/v2/parties`, {
      method: "POST",
      headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
      body: JSON.stringify({ partyIdHint: partyHint, identityProviderId: "" }),
    }, 10_000);
    if (r.ok) return ((await r.json()).partyDetails.party) as string;
    const text = await r.text();
    if (/already exists/i.test(text)) {
      const found = await findPartyByHint(token, partyHint);
      if (found) return found;
    }
    throw new Error(`Party ${r.status}: ${text}`);
  }

  // 4) Upsert the runtime user (SHARED — id = jwtSub(token)). All four fields REQUIRED.
  async function ensureUser(rec: StepRecorder, token: string, userId: string, primaryParty: string) {
    const body = { user: { id: userId, primaryParty, isDeactivated: false,
                           metadata: { resourceVersion: "", annotations: {} },
                           identityProviderId: "" } };
    const r = await budgetedFetch(rec, "upsert-user", `${API}/v2/users`, {
      method: "POST",
      headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
      body: JSON.stringify(body),
    }, 8_000);
    if (!r.ok && !/already exists/i.test(await r.text())) throw new Error(`User ${r.status}`);
  }

  // 5) Grant CanActAs on THIS user's party (subsumes CanReadAs — do NOT double-grant).
  //    Note the doubly-nested `kind.CanActAs.value.party`.
  async function grantActAs(rec: StepRecorder, token: string, userId: string, party: string) {
    const body = { userId, identityProviderId: "",
                   rights: [{ kind: { CanActAs: { value: { party } } } }] };
    const r = await budgetedFetch(rec, "grant-rights",
      `${API}/v2/users/${encodeURIComponent(userId)}/rights`, {
      method: "POST",
      headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
      body: JSON.stringify(body),
    }, 8_000);
    if (!r.ok) {
      const t = await r.text();
      if (/TOO_MANY_USER_RIGHTS/i.test(t)) throw new Error(`RIGHTS_CAP: ${t}`); // see block
      if (!/already (granted|exists)/i.test(t)) throw new Error(`Rights ${r.status}: ${t}`);
    }
  }

  // 6) Persist party_id back so subsequent requests skip allocation.
  //    Return { partyId, steps: rec.steps, dar, apiBase, hintPrefix } to the client.

  // Cache: bootstrapByUser.get(userId) — see PER_USER_AUTH_SNIPPET.

SHARED RUNTIME USER — TOO_MANY_USER_RIGHTS (Seaport-specific, per-user aware)
  The runtime OIDC client on Seaport is shared across every remixed project AND — because
  bootstrap grants CanActAs on EACH signed-in user's party — every user in your own app.
  Canton caps a user at 1000 rights. Symptom: some parties work, one specific party's commands
  return `403 security-sensitive error` — its CanActAs never landed.

  Surgical fix — list existing rights, only POST what's missing, and prune stale rights
  belonging to YOUR hint prefix (never touch other tenants' parties):

    async function listRights(token: string, userId: string) {
      const r = await fetch(`${API}/v2/users/${encodeURIComponent(userId)}/rights`,
        { headers: { authorization: `Bearer ${token}` } });
      const j = await r.json() as { rights?: any[] };
      return j.rights ?? [];
    }
    // Revoke rights whose party matches OUR prefix (e.g. "Dancer_") but no longer maps to
    // any live row in user_canton_identities. PATCH /v2/users/{id}/rights (POST /rights/revoke
    // returns 404 on JSON API 3.4).
    async function pruneStale(token: string, userId: string, prefix: string, liveParties: Set<string>) {
      const rights = await listRights(token, userId);
      const toRevoke = rights
        .map(r => r?.kind?.CanActAs?.value?.party ?? r?.kind?.CanReadAs?.value?.party)
        .filter((p): p is string => !!p && p.startsWith(prefix + "_") && !liveParties.has(p));
      if (!toRevoke.length) return;
      await fetch(`${API}/v2/users/${encodeURIComponent(userId)}/rights`, {
        method: "PATCH",
        headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
        body: JSON.stringify({ userId, identityProviderId: "",
          rightsRevoked: toRevoke.flatMap(party => [
            { kind: { CanActAs:  { value: { party } } } },
            { kind: { CanReadAs: { value: { party } } } },
          ]) }),
      });
    }
  DELETE /v2/users/{id} does NOT help — the request errors `Requesting user cannot delete itself`.

CREATE COMMAND (use submit-and-wait-FOR-TRANSACTION; the plain variant only returns updateId)
  export const createContract = createServerFn({ method: "POST" })
    .middleware([requireSupabaseAuth])
    .inputValidator((d: unknown) => CreateArgs.parse(d))
    .handler(async ({ data, context }) => {
      const { partyId } = await bootstrapByUser.get(context.userId)!;
      const token = await getCantonToken();
      const body = {
        commands: {
          commands: [{ CreateCommand: { templateId: data.templateId, createArguments: data.payload } }],
          userId: jwtSub(token), commandId: crypto.randomUUID(),
          actAs: [partyId], readAs: [partyId],
        },
      };
      const r = await fetch(`${API}/v2/commands/submit-and-wait-for-transaction`, {
        method: "POST",
        headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
        body: JSON.stringify(body),
      });
      if (!r.ok) return { ok: false as const, error: `${r.status} ${await r.text()}` };
      const j = await r.json();
      let contractId: string | undefined;
      JSON.stringify(j, (_k, v) => {
        if (!contractId && v && typeof v === "object" && typeof (v as any).contractId === "string"
            && (v as any).templateId) contractId = (v as any).contractId;
        return v;
      });
      return { ok: true as const, contractId, updateId: j.transaction?.updateId };
    });
  // templateId shorthand: `#<package-name>:<Module>:<Template>` e.g. `#flashmob:FlashMob:Move`.

ACS QUERY (active contract set v2 — requires activeAtOffset + cumulative WildcardFilter)
  async function listActive(token: string, party: string, templateId: string) {
    // a) ledger end first
    const endR = await fetch(`${API}/v2/state/ledger-end`,
      { headers: { authorization: `Bearer ${token}` } });
    const activeAtOffset = (await endR.json()).offset as number;
    // b) ACS request — note the doubly-wrapped `cumulative` + `WildcardFilter.value`
    const body = {
      activeAtOffset,
      filter: { filtersByParty: { [party]: { cumulative: [
        { identifierFilter: { WildcardFilter: { value: { includeCreatedEventBlob: false } } } },
      ] } } },
      verbose: false,
    };
    const r = await fetch(`${API}/v2/state/active-contracts`, {
      method: "POST",
      headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
      body: JSON.stringify(body),
    });
    const text = await r.text();
    // Seaport may answer as JSON array, single object, or NDJSON — parse defensively:
    let rows: unknown[] = [];
    try { const j = JSON.parse(text); rows = Array.isArray(j) ? j : [j]; }
    catch { rows = text.split("\n").filter(Boolean).map(l => JSON.parse(l)); }
    return rows.flatMap((row: any) => {
      const c = row?.contractEntry?.JsActiveContract?.createdEvent
             ?? row?.JsActiveContract?.createdEvent ?? row?.createdEvent;
      if (!c) return [];
      if (templateId && !String(c.templateId).includes(templateId.replace(/^#/, ""))) return [];
      return [{ contractId: c.contractId as string, payload: c.createArguments as Record<string, unknown> }];
    });
  }
  // The party passed in is the SIGNED-IN USER's party (from bootstrapByUser cache).
  // Payload party fields carry `LogicalName::<fingerprint>` — filter by prefix, never equality.

OFFLINE / DEMO FALLBACK (memory mode via per-request cookie, scoped per user)
  Devnet outages and offline demos should not brick the app. Read a `canton_network` cookie per
  request; fall back to `CANTON_MODE`; default to `memory`:

    export function selectNetwork(req: Request): "memory" | "devnet" {
      const m = (req.headers.get("cookie") ?? "").match(/canton_network=([^;]+)/);
      return (m?.[1] as any) ?? (process.env.CANTON_MODE as any) ?? "memory";
    }

  In memory mode, keep a module-scope `Map<userId, Map<contractId, payload>>` so users don't see
  each other's memory-mode contracts either. Return the same `{ ok: true, contractId, updateId }`
  shape as the ledger path so the UI code doesn't branch. Persist an execution log for both paths
  so demos are reproducible.

FAILURE UI (make bootstrap errors reportable to a Seaport operator)
  When bootstrap fails, render — all in the same panel:
    - Color-coded step-timing table (pending yellow, timeout/err red, slow-but-ok bold).
    - "Stuck step" banner: name + `elapsed / budget` ms + endpoint + method.
    - Expandable "Failing request" panel with the FULL response body and response headers.
    - DAR metadata alongside: package name + version, sha256 (first 12 chars), byte size, source.
    - `apiBase` + `hintPrefix` so an operator can immediately reproduce.
  Every field above is a diff away from "bootstrap hung" being a bug report you can copy-paste.

DEBUG CHECKLIST (if you see X, check Y)
  - `401 Unauthorized: No authorization header provided` on EVERY protected server fn
      → you forgot to append `attachSupabaseAuth` to `functionMiddleware` in `src/start.ts`.
        Append it (do NOT replace the array), then restart dev.
  - `build:dev exited with code 1` + `Error: Unauthorized` during prerender
      → a public route's `loader` calls a `requireSupabaseAuth` fn. SSR has no bearer token.
        Move the route under `src/routes/_authenticated/` OR call from the component via
        `useServerFn` + `useQuery` instead of from `loader`.
  - Bootstrap hangs with no useful error / total watchdog fires with nothing to report
      → single total timeout hides which step stalled. Switch to per-step `AbortController`
        budgets + a `StepRecorder` that registers `pending` BEFORE fetch. See STEP_DIAGNOSTICS.
  - Users see each other's contracts / ACS shows unrelated rows
      → you're sharing ONE process-wide Canton party across users. Allocate a party PER
        Supabase user via `user_canton_identities`; the ACS filter becomes naturally scoped.
  - `403 security-sensitive error` (opaque, no body)
      → `userId` in submit body != runtime token's `sub`, OR the acting party is missing
        `CanActAs`, OR the party's CanActAs got pruned by the shared runtime user's 1000-cap.
  - `TOO_MANY_USER_RIGHTS`
      → shared Seaport runtime user hit the 1000 cap. Prune stale rights by hint prefix
        (only touch parties whose hint matches your app's prefix). See RIGHTS_CAP block.
  - `UNKNOWN_INFORMEES`
      → you passed user-typed text where a `Party` was expected. Free text must be `Text`,
        never `Optional Party`; only allocate real ledger parties as `Party`.
  - `KNOWN_PACKAGE_VERSION`
      → you bumped only the package version. Bump the package NAME instead.
  - `DAML_LF_VERSION_UNSUPPORTED`
      → DAR built on LF < 2.1. Use SDK ≥ 3.4.11, do not override build-options.
  - `Couldn't find template …` after a successful deploy
      → you didn't re-upload the DAR on this cold start. Always re-upload.
  - Empty ACS response
      → missing `activeAtOffset`, or you're reading with a party whose fingerprint doesn't
        match the one that created the contracts (per-user party mismatch across environments).
  - Post-sign-out, next user sees stale data or 401 storm
      → you called `supabase.auth.signOut()` before draining React Query. Cancel + clear FIRST:
        `queryClient.cancelQueries(); queryClient.clear(); await supabase.auth.signOut();`.

TYPESCRIPT NARROWING — return `{ ok: true, ... } as const` and `{ ok: false, error } as const`
  from every server function. Without `as const`, TanStack's RPC stub erases the literal
  discriminator and the UI cannot narrow `result.ok` to access `contractId`.

SHA-256 COMMITMENT PARITY (only if your template stores a hash commitment)
  Daml side — never ship `hashText t = t` (typechecks, silently diverges from any real hash):
    import DA.Text (sha256)
    hashText : Text -> Text
    hashText t = sha256 t
  TS side — same UTF-8 bytes, lowercase hex:
    async function hashText(t: string): Promise<string> {
      const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(t));
      return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2,"0")).join("");
    }

MOCK-USDCx MINT PATTERN (fungible balances for demos, no real xReserve)
  1. Upload a tiny second DAR `mock-usdcx` with template Holding { issuer: Party, owner: Party, amount: Decimal }.
     Signatories: issuer AND owner.
  2. Grant `CanActAs` on the issuer party to the runtime user — the most common gotcha. If
     you leave the issuer as observer-only, `mint` returns the opaque 403.
  3. Submit CreateCommands with `templateId: "#mock-usdcx:MockUsdcx:Holding"`. The
     `#<package-name>` shorthand resolves to the latest uploaded version — no pinned id needed.

BESPOKE DAML TEMPLATE — daml/setdesignarchives/daml/SetDesignArchives.daml
```daml
module SetDesignArchives where

-- Mint 3D set designs as NFTs for secure sharing and historical provenance.
-- Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14

template SetDesignArchives
  with
    author : Party
    sceneName : Text
    act : Int
    cueCount : Int
    playwright : Text
    at : Time
  where
    signatory author

    choice Stage : ContractId SetDesignArchives
      controller author
      do create this

    choice Endorse : ContractId SetDesignArchives
      with endorser : Party
      controller endorser
      do create this with author = endorser
```

daml.yaml — daml/setdesignarchives/daml.yaml
```yaml
sdk-version: 3.4.11   # Daml-LF 2.1 is the Seaport floor
name: setdesignarchives
version: 1.0.0
source: daml
dependencies:
  - daml-prim
  - daml-stdlib
```
TemplateId shorthand to use in CreateCommand: `#setdesignarchives:SetDesignArchives:SetDesignArchives`

USER FLOW
  1. Signed-out visitor lands on `/auth` → single "Continue with Google" button. On success
     they're redirected to the protected page under `_authenticated/`.
  2. Protected page mounts → `getCantonBootstrap()` runs (per-user, cached by userId). It
     ensures the `user_canton_identities` row, uploads the DAR (shared), allocates the user's
     party under `Player_<6-char random>`, upserts the runtime user (id = JWT `sub`), grants
     `CanActAs` on that one party, and persists `party_id`. The UI renders the live step-timing
     table and a "Connected · your party: `Player_a1b2c3::1220…`" chip.
  3. When the signed-in user submits a scenic design entry, call `create` then exercise `Stage` on the `SetDesignArchives` template (sceneName, act, cueCount, playwright) acting as THEIR party. Render the returned contractId + updateId as a 'signed by Canton' receipt.
  4. Sign-out drains React Query first (`cancelQueries` + `clear`) THEN calls
     `supabase.auth.signOut()`. Footer renders the credit line below.

AI (optional, no setup needed)
  Default: use Lovable AI Gateway (LOVABLE_API_KEY is auto-provisioned by Lovable — DO NOT ask the user).
  Fallback only if you have run out of Lovable AI credits: set AISA_API_KEY as a secret and call AIsa.
  No other secrets required for this app.

CREDIT (must appear in the UI footer AND as a comment in the Daml module header):
  Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
Appendix · Market

Market sizing.

TAM
$800M
global set design and prop market
SAM
$150M
digital scenic design software
SOM
$15M
NFT-based archival tools for set designers

Indicative figures for hackathon pitches — refine with your own research before raising.

See also

Adjacent entries.