Illustration Rights Ledger
Pin illustration licenses and usage terms permanently for transparent rights management.
The primitive.
Painters watch their digital rights management feed update the instant a contract is created or archived on Seaport — it's a live websocket subscription to the Canton active contract set, not a polled API.
Why this primitiveCanton ACS’s immutable the Canton ledger pins secure rights documents for easy verification.
No setup required.
AISA_API_KEY under Settings → Secrets.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.
The build prompt.
Paste into a fresh Lovable project. No keys to add — Lovable AI is auto-provisioned. read the build strategy →
Build "Illustration Rights Ledger" 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
Pin illustration licenses and usage terms permanently for transparent rights management.
Discipline: Visual Art (digital rights management).
Canton primitive: Active contract set stream. Why this primitive: Canton ACS’s immutable the Canton ledger pins secure rights documents for easy verification.
Party hint prefix for this app: `Artist` (each user becomes `Artist_<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/illustrationrightsledger/daml/IllustrationRightsLedger.daml
```daml
module IllustrationRightsLedger where
-- Pin illustration licenses and usage terms permanently for transparent rights management.
-- Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
template IllustrationRightsLedgerEntry
with
author : Party
audience : [Party]
workTitle : Text
medium : Text
yearMade : Int
artist : Text
at : Time
where
signatory author
observer audience
```
daml.yaml — daml/illustrationrightsledger/daml.yaml
```yaml
sdk-version: 3.4.11 # Daml-LF 2.1 is the Seaport floor
name: illustrationrightsledger
version: 1.0.0
source: daml
dependencies:
- daml-prim
- daml-stdlib
```
TemplateId shorthand to use in CreateCommand: `#illustrationrightsledger:IllustrationRightsLedger:IllustrationRightsLedger`
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 `Artist_<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: `Artist_a1b2c3::1220…`" chip.
3. Each new digital rights management entry creates a `IllustrationRightsLedgerEntry` (workTitle, medium, yearMade, artist) with the user as author. The feed below re-polls the ACS every 3s (filtered by the user's party) and renders entries live as they appear.
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
Market sizing.
Indicative figures for hackathon pitches — refine with your own research before raising.