build strategy · identus

Real identity, one prompt, one build.

Every entry in this catalog compiles down to the same shape: a TanStack server function calling a Hyperledger Identus Cloud Agent, plus one client surface. It's the only pattern that ships a working self-sovereign identity demo in one shot, inside the 5-credit budget.

Why Identus and not a login form?

Identus is identity infrastructure, not accounts. The issuer signs a fact once; the holder carries it in their own wallet; any verifier checks it cryptographically without calling the issuer. Nothing about it is a token, a coin, or a speculation.

Three modes, one codebase

Start simulated — an in-app mock with the agent's exact response shapes and zero setup. Swap in a local Docker stack or a hosted Fly.io deployment later by changing two environment variables. The UI never has to change.

src/lib/identus.functions.ts — publish a DID
// src/lib/identus.functions.ts — mint and publish a did:prism
// Built for the Hyperledger Identus Catalyst — StreetKode Fam · Indian Krump Festival 14
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";

const agent = () => ({
  base: process.env.AGENT_BASE_URL!.replace(/\/$/, ""),
  headers: { "content-type": "application/json", apikey: process.env.AGENT_API_KEY! },
});

export const mintDid = createServerFn({ method: "POST" })
  .inputValidator((d) => z.object({ label: z.string().min(1) }).parse(d))
  .handler(async () => {
    const { base, headers } = agent();
    const created = await fetch(`${base}/did-registrar/dids`, {
      method: "POST", headers,
      body: JSON.stringify({
        documentTemplate: {
          publicKeys: [
            { id: "auth-1", purpose: "authentication", curve: "secp256k1" },
            { id: "assert-1", purpose: "assertionMethod", curve: "secp256k1" },
          ],
          services: [],
        },
      }),
    }).then((r) => r.json());

    await fetch(`${base}/did-registrar/dids/${created.longFormDid}/publications`, {
      method: "POST", headers,
    });
    return { did: created.longFormDid };
  });
src/lib/identus.functions.ts — DIDComm invitation
// src/lib/identus.functions.ts — DIDComm invitation
export const createInvitation = createServerFn({ method: "POST" })
  .inputValidator((d) => z.object({ label: z.string().min(1) }).parse(d))
  .handler(async ({ data }) => {
    const { base, headers } = agent();
    const conn = await fetch(`${base}/connections`, {
      method: "POST", headers,
      body: JSON.stringify({ label: data.label, goalCode: "connect", goal: data.label }),
    }).then((r) => r.json());

    // Render conn.invitation.invitationUrl as a QR code; poll GET /connections/{id}
    // until state === "ConnectionResponseSent".
    return { connectionId: conn.connectionId, invitationUrl: conn.invitation.invitationUrl };
  });
src/lib/identus.functions.ts — issue a credential
// src/lib/identus.functions.ts — issue a JWT verifiable credential
export const offerCredential = createServerFn({ method: "POST" })
  .inputValidator((d) => z.object({
    issuingDID: z.string(),
    claims: z.record(z.string(), z.string()),
    connectionId: z.string().optional(),
  }).parse(d))
  .handler(async ({ data }) => {
    const { base, headers } = agent();
    const rec = await fetch(`${base}/issue-credentials/credential-offers`, {
      method: "POST", headers,
      body: JSON.stringify({
        claims: data.claims,
        issuingDID: data.issuingDID,       // must be PUBLISHED with assertionMethod
        credentialFormat: "JWT",
        automaticIssuance: true,
        ...(data.connectionId
          ? { connectionId: data.connectionId }
          : { goalCode: "issue-vc", goal: "Claim your credential" }),
      }),
    }).then((r) => r.json());

    // Poll GET /issue-credentials/records/{recordId} until "CredentialSent".
    return { recordId: rec.recordId, state: rec.protocolState };
  });
src/lib/identus.functions.ts — verify a proof
// src/lib/identus.functions.ts — request and verify a proof
export const requestProof = createServerFn({ method: "POST" })
  .inputValidator((d) => z.object({
    connectionId: z.string(),
    attributes: z.array(z.string()),
  }).parse(d))
  .handler(async ({ data }) => {
    const { base, headers } = agent();
    const rec = await fetch(`${base}/present-proof/presentations`, {
      method: "POST", headers,
      body: JSON.stringify({
        connectionId: data.connectionId,
        proofs: [],
        options: { challenge: crypto.randomUUID(), domain: "https://example.app" },
        claims: Object.fromEntries(data.attributes.map((a) => [a, {}])),
      }),
    }).then((r) => r.json());

    // Poll GET /present-proof/presentations/{id} until "PresentationVerified".
    return { presentationId: rec.presentationId, state: rec.status };
  });
environment
# Simulated mode — no secrets at all. Start here.

# Docker mode (local compose stack, APISIX gateway present):
AGENT_BASE_URL=http://localhost:8085/cloud-agent
AGENT_API_KEY=<DEFAULT_WALLET_AUTH_API_KEY>

# Fly.io mode (direct deploy — NO /cloud-agent suffix):
AGENT_BASE_URL=https://<app>.fly.dev
AGENT_API_KEY=<DEFAULT_WALLET_AUTH_API_KEY>

# The mega-prompt then:
#    - writes createServerFn wrappers around the Cloud Agent REST API
#    - polls protocolState instead of assuming the POST finished the job
#    - keeps the key on the server via process.env.AGENT_API_KEY

Rules of the build.