@cdr-kit/forms/Overview

@cdr-kit/forms

Encrypted form submissions on Story Aeneid. Pick one of six CdrStorageProvider adapters, wire it once in your server route, and respondents never hold a wallet.
import { CdrForm, CdrField, CdrSubmitButton } from "@cdr-kit/forms"

Drop <CdrForm> + a few <CdrField>s into any page; each submission gets its own CDR vault on Aeneid. Only the form creator can decrypt. Respondents never connect a wallet — gas + signature + storage upload are the server's burden (the platform-wallet pattern, extracted from Confide).

Storage adapter selection is a developer concern: you wire one of the six CdrStorageProvider factories from @cdr-kit/core into your server route once, with credentials from env vars. The respondent UI never sees a "pick a storage backend" choice — that would be a UX smell.

Live preview

mock kit
<CdrForm onEncrypt={(fields) => fetch("/api/respond", { method: "POST", body: JSON.stringify({ fields }) }).then(r => r.json()).then(d => d.vaultId)}>
<CdrField name="mood" label="Mood (1–10)" type="number" required />
<CdrField name="highlight" label="Highlight of the week" required />
<CdrField name="notes" label="Anything else?" type="textarea" />
<CdrSubmitButton>Submit securely</CdrSubmitButton>
</CdrForm>

This preview runs the real @cdr-kit/forms components against an in-memory mock onEncrypt that returns a fake vault uuid after a short delay — same UX as the live network round-trip, no wallet needed to see it.

Install

pnpm add @cdr-kit/forms @cdr-kit/agent @cdr-kit/core

Client quickstart

"use client";

import { CdrForm, CdrField, CdrSubmitButton } from "@cdr-kit/forms";
import "@cdr-kit/forms/styles.css";

export function Survey() {
return (
  <CdrForm
    onEncrypt={async (fields) => {
      const r = await fetch("/api/respond", {
        method: "POST",
        body: JSON.stringify({ fields }),
      });
      const { vaultId } = await r.json();
      return vaultId;
    }}
    onSuccess={(uuid) => console.log("stored at vault", uuid)}
  >
    <CdrField name="mood" label="Mood (1–10)" type="number" required />
    <CdrField name="highlight" label="Highlight of the week" required />
    <CdrField name="notes" label="Anything else?" type="textarea" />
    <CdrSubmitButton />
  </CdrForm>
);
}

<CdrForm> serializes every <CdrField> (via FormData) and calls your onEncrypt with the resulting object. It expects you to return the CDR vault UUID — that's what gets persisted in your DB so you can decrypt later.

Server quickstart

The server route is where you wire your storage adapter. Here it's Pinata:

// app/api/respond/route.ts
import { NextResponse } from "next/server";
import { createPinataStorage } from "@cdr-kit/core";
import { storeFormSubmission } from "@cdr-kit/forms/server";

export async function POST(req: Request) {
const { fields } = await req.json();

const storage = createPinataStorage({
  jwt: process.env.PINATA_JWT!,
  gatewayUrl: process.env.PINATA_GATEWAY_URL ?? "https://gateway.pinata.cloud",
});

const { vaultId, cid } = await storeFormSubmission(fields, {
  privateKey: process.env.WALLET_PRIVATE_KEY as `0x${string}`,
  storage,
  rpcUrl: "https://aeneid.storyrpc.io",
});

return NextResponse.json({ vaultId, cid });
}

To read the submission back, use the same storage adapter:

import { readFormSubmission } from "@cdr-kit/forms/server";

const { fields, submittedAt } = await readFormSubmission(vaultId, {
privateKey: process.env.WALLET_PRIVATE_KEY as `0x${string}`,
storage,    // same adapter as the write
});

The six storage adapters

@cdr-kit/core ships six CdrStorageProvider factories. Pick the one that matches your existing infra; swap by changing the factory call in your server route — no other code changes required.

PropTypeDefaultDescription
createPinataStorage(opts: { jwt, gatewayUrl? }) => CdrStorageProviderHosted IPFS pinning. Free signup at pinata.cloud. Simplest setup — one JWT env var.
createSupabaseStorage(opts: { supabaseUrl, key, bucket, pathPrefix?, bucketIsPublic? }) => CdrStorageProviderPostgres-backed object storage. Best if you already use Supabase Auth/DB. Service-role or anon key.
createIpfsStorage(opts: { addUrl, gatewayUrl, headers? }) => CdrStorageProviderAny IPFS-compatible HTTP API (kubo, your own gateway, etc.).
createS3Storage(opts: { bucket, region, accessKeyId, secretAccessKey, endpoint?, pathPrefix?, forcePathStyle? }) => CdrStorageProviderAWS S3, Cloudflare R2, Backblaze B2 — anything S3-compatible. Lazy-loads @aws-sdk/client-s3.
createStorachaStorage(opts: { key, spaceDid, proof, gatewayUrl? }) => CdrStorageProviderStoracha / web3.storage UCAN-pinning. key + proof via w3 CLI. Lazy-loads @storacha/client.
createHeliaStorage(opts?: { helia? }) => CdrStorageProviderSelf-hosted in-process Helia node. Good for local dev, no third-party deps.

<CdrForm> props

PropTypeDefaultDescription
onEncryptrequired(fields: CdrFormFields) => Promise<number>Called with the serialized field map on submit. Returns the CDR vault uuid. Server-side encryption happens inside this callback (typically a POST to /api/respond).
onSuccess(vaultId: number) => voidFired after onEncrypt resolves. Receives the same vault uuid the callback returned.
onError(err: Error) => voidFired if onEncrypt rejects. Pair with form-level error UI.
childrenrequiredReactNodeCdrFields, CdrSubmitButton, and any layout you want around them.

<CdrField> props

PropTypeDefaultDescription
namerequiredstringFormData key. Becomes a top-level property in the encrypted JSON payload.
labelstringVisible label above the input.
typeCdrFieldType"text"One of: text · textarea · email · number · select · radio · checkbox.
placeholderstringPlaceholder for text-like inputs.
requiredbooleanfalseHTML5 required attribute.
options{ value: string; label: string }[]Required for select / radio / checkbox; ignored otherwise.

<StorageProviderPicker>

Admin-side tile-grid for picking which CdrStorageProvider your platform routes form submissions through. Not for respondent UIs — render this in your setup screen, then wire the picked id to the matching factory on the server.

tsx
import { StorageProviderPicker } from "@cdr-kit/forms";
import { useState } from "react";

const [provider, setProvider] = useState("pinata");
return <StorageProviderPicker value={provider} onChange={setProvider} />;
PropTypeDefaultDescription
valueStorageProviderId | undefinedCurrently-selected provider id. Pass undefined for 'none chosen'.
onChangerequired(id: StorageProviderId) => voidFires when the user clicks a tile.
includereadonly StorageProviderId[]Restrict to a subset of providers. Order is respected. Default: all 8.
headingReactNode | null"Storage backend"Visible heading above the tiles. Pass null to hide.
classNamestringForwarded to the root container.
styleCSSPropertiesForwarded inline style.

The StorageProviderId union covers every adapter shipped by @cdr-kit/core: "pinata", "supabase", "storacha", "ipfs", "s3", "helia", "gateway", "memory". Each tile renders a brand-colored cdr-kit-authored glyph (not the provider's official mark — keeps licensing clean) and a one-line description.

<CdrField> type values

PropTypeDefaultDescription
textCdrFieldTypeSingle-line input.
textareaCdrFieldTypeMulti-line textarea.
emailCdrFieldTypeEmail input with HTML5 validation.
numberCdrFieldTypeNumber input.
selectCdrFieldTypeDropdown — pass `options={[{value, label}]}`.
radioCdrFieldTypeRadio group — pass `options={[{value, label}]}`.
checkboxCdrFieldTypeCheckbox group — pass `options={[{value, label}]}`.

<CdrSubmitButton> props

PropTypeDefaultDescription
childrenReactNode"Submit securely"Button label while idle. Auto-swapped to “Encrypting…” during submit and “Submitted ✓” after success.

useCdrSubmit() (low-level)

If you don't want the <CdrForm> wrapper, you can call useCdrSubmit({ onEncrypt }) directly to drive your own form layout. It returns { submit, isLoading, error, vaultId, reset }.

import { useCdrSubmit } from "@cdr-kit/forms";

function MyForm() {
const { submit, isLoading, vaultId } = useCdrSubmit({
  onEncrypt: async (fields) => {
    const r = await fetch("/api/respond", { method: "POST", body: JSON.stringify(fields) });
    return (await r.json()).vaultId;
  },
});

return (
  <form onSubmit={(e) => {
    e.preventDefault();
    const data = Object.fromEntries(new FormData(e.currentTarget));
    submit(data);
  }}>
    {/* ...your own inputs... */}
    <button disabled={isLoading}>{vaultId ? "Submitted" : "Submit"}</button>
  </form>
);
}

Scaffolder

A working end-to-end forms app — Pinata wired, /api/respond + /api/results routes, dark premium layout — is one command away:

pnpm create cdr-kit-app my-forms --template forms

The generated app's lib/storage.ts is the single source of truth for which adapter is wired; comments inline show how to swap to any of the other five. See the scaffolder docs for other templates.