feat(kez-chat/web): mirror local claims to chain-service sigchain

When the user adds a claim, append an `add` event to their sigchain
on the chain service (rust-sig-server); when they remove a claim,
append a `revoke`. Implements SPEC.md §8 — the sigchain is now the
canonical, verifiable record of what the user currently claims, not
a per-claim field.

  • lib/sigchain-service.ts: new module that fetches the current chain
    to compute the next seq + prev hash, signs the event locally (the
    chain service never sees the seed), and POSTs. Returns a typed
    SigchainSyncResult so the caller can record the seq + status.
  • lib/kez.ts: sigchain event types + helpers (nextChainCursor,
    sigchainEventHash, signSigchainEvent, SignedSigchainEvent /
    SigchainOp types). Mirrors the Rust + Node + Python core surface.
  • lib/api.ts: getSigchain (GET full chain) + postSigchainEvent
    (POST one signed event) wrappers against the chain service.
  • lib/claims-store: StoredClaim gains chain_service, sigchain_seq,
    sigchain_status ("synced" | "error"), sigchain_error fields +
    setSigchainSync helper.
  • routes/AddClaim: on successful claim creation, fires an `add` to
    the chain service in the background; surfaces sync errors with a
    "Retry sync" button.
  • routes/Claims: a `revoke` is posted to the chain service first
    when the user removes a claim. Best-effort — if the service is
    unreachable, asks before dropping the local copy so the chain
    doesn't silently drift. Per-row "Sync to chain" button retries
    failed adds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-06-05 22:43:16 -06:00
parent 3fdbdc9fcf
commit 5ad47a917d
6 changed files with 465 additions and 9 deletions

View File

@ -3,7 +3,7 @@
import { ed25519 } from "@noble/curves/ed25519"; import { ed25519 } from "@noble/curves/ed25519";
import { bytesToHex } from "@noble/hashes/utils"; import { bytesToHex } from "@noble/hashes/utils";
import type { SignedRegistration } from "./kez.js"; import type { SignedRegistration, SignedSigchainEvent } from "./kez.js";
export interface HandleResponse { export interface HandleResponse {
handle: string; handle: string;
@ -118,3 +118,53 @@ export async function register(
}); });
return unwrap(resp); return unwrap(resp);
} }
// ─────────────────────────────────────────────────────────────────────────────
// Chain service (sigchain storage server)
//
// `sigchainUrl` is the per-user base URL the chat server hands back in a
// handle lookup (`HandleResponse.sigchain_url`), e.g.
// `https://sig.kez.lat/v1/sigchains/ed25519/<hex>`. It's a different origin
// than the chat server, so these talk to it directly (it sends permissive
// CORS). The signatures are the source of truth — the chain service just
// stores them.
// ─────────────────────────────────────────────────────────────────────────────
/**
* Fetch the full sigchain for a user as an ordered list of signed events.
* The chain service returns `application/jsonl` (one envelope per line);
* an unknown/empty chain yields an empty list.
*/
export async function getSigchain(
sigchainUrl: string,
): Promise<SignedSigchainEvent[]> {
const resp = await fetch(sigchainUrl);
if (resp.status === 404) return [];
if (!resp.ok) {
throw new ApiError(resp.status, `getSigchain → HTTP ${resp.status}`);
}
const text = await resp.text();
return text
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line) as SignedSigchainEvent);
}
/**
* Append one signed event to the user's sigchain on the chain service.
* The server re-runs the full integrity check (tag, primary, seq, prev,
* signature) and returns the recorded `{ seq, hash }` on success.
*/
export async function postSigchainEvent(
sigchainUrl: string,
event: SignedSigchainEvent,
): Promise<{ seq: number; hash: string }> {
const base = sigchainUrl.replace(/\/$/, "");
const resp = await fetch(`${base}/events`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(event),
});
return unwrap(resp);
}

View File

@ -17,8 +17,25 @@ export interface StoredClaim {
notes?: string; notes?: string;
/** Latest verification result, if we've checked. */ /** Latest verification result, if we've checked. */
last_verify?: VerifyResult; last_verify?: VerifyResult;
// ── Chain-service mirror (sigchain) ──
/** Chain-service base URL this claim was mirrored to (its sigchain URL). */
chain_service?: string;
/** Sequence number of the sigchain `add` event for this claim. */
sigchain_seq?: number;
/** Sync state of the sigchain mirror. */
sigchain_status?: "synced" | "error";
/** Error detail when sigchain_status === "error". */
sigchain_error?: string;
} }
/** Fields the caller may patch after a chain-service sync attempt. */
export type SigchainSyncPatch = Partial<
Pick<
StoredClaim,
"chain_service" | "sigchain_seq" | "sigchain_status" | "sigchain_error"
>
>;
export async function listClaims(): Promise<StoredClaim[]> { export async function listClaims(): Promise<StoredClaim[]> {
return (await get<StoredClaim[]>(KEY)) ?? []; return (await get<StoredClaim[]>(KEY)) ?? [];
} }
@ -50,6 +67,18 @@ export async function setVerifyResult(
} }
} }
/** Record the outcome of a chain-service (sigchain) sync for a claim. */
export async function setSigchainSync(
id: string,
patch: SigchainSyncPatch,
): Promise<void> {
const existing = await listClaims();
const target = existing.find((c) => c.id === id);
if (!target) return;
Object.assign(target, patch);
await set(KEY, existing);
}
export async function removeClaim(id: string): Promise<void> { export async function removeClaim(id: string): Promise<void> {
const existing = await listClaims(); const existing = await listClaims();
await set( await set(

View File

@ -7,7 +7,7 @@
// reconsider depending on the Node port. // reconsider depending on the Node port.
import { ed25519 } from "@noble/curves/ed25519"; import { ed25519 } from "@noble/curves/ed25519";
import { sha512 } from "@noble/hashes/sha2"; import { sha256, sha512 } from "@noble/hashes/sha2";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
import canonicalize from "canonicalize"; import canonicalize from "canonicalize";
@ -19,6 +19,8 @@ export const CLAIM_TYPE = "kez.claim";
export const REGISTRATION_TYPE = "kez.chat.handle_registration"; export const REGISTRATION_TYPE = "kez.chat.handle_registration";
export const REGISTRATION_ENVELOPE = "handle_registration"; export const REGISTRATION_ENVELOPE = "handle_registration";
export const CLAIM_ENVELOPE = "claim"; export const CLAIM_ENVELOPE = "claim";
export const SIGCHAIN_EVENT_TYPE = "kez.sigchain.event";
export const SIGCHAIN_ENVELOPE = "sigchain_event";
export const ED25519_SHA512_ALG = "ed25519-sha512-jcs"; export const ED25519_SHA512_ALG = "ed25519-sha512-jcs";
export const FORMAT_VERSION = 1; export const FORMAT_VERSION = 1;
export const COMPACT_PROOF_PREFIX = "kez:z1:"; export const COMPACT_PROOF_PREFIX = "kez:z1:";
@ -70,6 +72,33 @@ export interface SignedRegistration {
signature: SignatureBlock; signature: SignatureBlock;
} }
export type SigchainOp = "add" | "revoke";
export interface SigchainEventPayload {
type: typeof SIGCHAIN_EVENT_TYPE;
version: number;
primary: Identity;
seq: number;
/** `sha256:<hex>` of the prior envelope's JCS bytes. Omitted iff seq === 0. */
prev?: string;
created_at: string;
op: SigchainOp;
/** Op-specific fields, e.g. `{ subject, proof_url? }`. */
payload: Record<string, unknown>;
}
export interface SignedSigchainEvent {
kez: typeof SIGCHAIN_ENVELOPE;
payload: SigchainEventPayload;
signature: SignatureBlock;
}
/** Where the next event sits in the chain: its `seq` and `prev` hash. */
export interface ChainCursor {
seq: number;
prev?: string;
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Key generation + restoration // Key generation + restoration
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@ -137,6 +166,22 @@ function signWith(
}; };
} }
/**
* RFC 3339 UTC timestamp at SECOND precision (no fractional part), e.g.
* `2026-05-19T18:00:00Z` matching the SPEC.md examples.
*
* Why drop the milliseconds `toISOString()` emits: the Rust kez-core
* verifier (used by the chat server and the chain service) deserializes
* `created_at` into a `chrono::DateTime<Utc>` and re-serializes it to
* re-canonicalize for signature checking. Chrono's `AutoSi` seconds format
* drops a zero fractional part, so a browser-signed `…:00.000Z` would
* re-serialize as `…:00Z` and fail verification ~1 in 1000. Emitting whole
* seconds round-trips byte-stably through chrono for every timestamp.
*/
export function rfc3339Utc(date: Date = new Date()): string {
return date.toISOString().replace(/\.\d{3}Z$/, "Z");
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Verification // Verification
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@ -211,7 +256,7 @@ export function signClaim(
version: FORMAT_VERSION, version: FORMAT_VERSION,
subject, subject,
primary: signer.identity, primary: signer.identity,
created_at: createdAt.toISOString(), created_at: rfc3339Utc(createdAt),
}; };
return { return {
kez: CLAIM_ENVELOPE, kez: CLAIM_ENVELOPE,
@ -233,7 +278,7 @@ export function signRegistration(
handle, handle,
primary: signer.identity, primary: signer.identity,
server, server,
created_at: createdAt.toISOString(), created_at: rfc3339Utc(createdAt),
}; };
return { return {
kez: REGISTRATION_ENVELOPE, kez: REGISTRATION_ENVELOPE,
@ -242,6 +287,59 @@ export function signRegistration(
}; };
} }
// ─────────────────────────────────────────────────────────────────────────────
// Sigchain events (Spec §8)
// ─────────────────────────────────────────────────────────────────────────────
/**
* `sha256:<hex>` of the JCS-canonicalized bytes of the WHOLE signed envelope
* (not just the payload). This is what the next event's `prev` points at.
*/
export function sigchainEventHash(event: SignedSigchainEvent): string {
return `sha256:${bytesToHex(sha256(canonicalBytes(event)))}`;
}
/**
* Given the current (validated, ordered) chain, return where the next event
* goes: `seq` one past the head, and `prev` = the head's envelope hash.
* An empty chain yields `{ seq: 0 }` with no `prev` (per spec, seq 0 has none).
*/
export function nextChainCursor(events: SignedSigchainEvent[]): ChainCursor {
if (events.length === 0) return { seq: 0 };
const head = events[events.length - 1];
return { seq: head.payload.seq + 1, prev: sigchainEventHash(head) };
}
/**
* Build + sign a sigchain event (add/revoke a subject) at the given cursor.
* Insertion order matches the Rust/Node/Python impls (type, version, primary,
* seq, prev?, created_at, op, payload) so the JSONL form lines up; JCS sorts
* keys for signing regardless.
*/
export function signSigchainEvent(
signer: Ed25519Identity,
op: SigchainOp,
opPayload: Record<string, unknown>,
cursor: ChainCursor,
createdAt: Date = new Date(),
): SignedSigchainEvent {
const payload: SigchainEventPayload = {
type: SIGCHAIN_EVENT_TYPE,
version: FORMAT_VERSION,
primary: signer.identity,
seq: cursor.seq,
...(cursor.prev !== undefined ? { prev: cursor.prev } : {}),
created_at: rfc3339Utc(createdAt),
op,
payload: opPayload,
};
return {
kez: SIGCHAIN_ENVELOPE,
payload,
signature: signWith(payload, signer),
};
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Encodings — pretty JSON, compact (kez:z1:), markdown fence // Encodings — pretty JSON, compact (kez:z1:), markdown fence
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────

View File

@ -0,0 +1,117 @@
// Mirrors the user's claims into their sigchain on the chain service.
//
// The chain service (the rust-sig-server) is where a user's append-only,
// signed sigchain lives. When the user adds a claim we append an `add`
// event for that subject; when they remove a claim we append a `revoke`.
// This keeps the sigchain an accurate, verifiable record of what the user
// currently claims — the spec's mechanism (SPEC.md §8), not a per-claim
// field.
//
// We read the current chain to compute the next `seq` + `prev` hash so
// appends stay monotonic even across devices, then sign locally (the
// chain service never sees the seed) and POST the event.
import {
identityFromSeed,
nextChainCursor,
sigchainEventHash,
signSigchainEvent,
type Identity,
type SignedSigchainEvent,
type SigchainOp,
} from "./kez.js";
import { getSigchain, lookup, postSigchainEvent } from "./api.js";
export interface SigchainSyncResult {
/** The chain-service base URL the event was posted to. */
chainService: string;
/** Sequence number of the event (or the existing add, when noop). */
seq: number;
/** `sha256:<hex>` head hash of the chain. */
hash: string;
/**
* True when the chain already reflected the desired state, so no new
* event was posted (e.g. re-syncing a claim already on the chain, or
* removing a claim that was never added). Makes sync idempotent safe
* to run repeatedly and safe for claims created before sigchain support.
*/
noop: boolean;
}
/**
* Walk the chain and return the currently-active subjects (added and not
* later revoked), mapped to the `seq` of the `add` that activated each.
* This is how we keep appends idempotent.
*/
function activeSubjects(events: SignedSigchainEvent[]): Map<string, number> {
const active = new Map<string, number>();
for (const event of events) {
const subject = (event.payload.payload as { subject?: unknown })?.subject;
if (typeof subject !== "string") continue;
if (event.payload.op === "add") active.set(subject, event.payload.seq);
else if (event.payload.op === "revoke") active.delete(subject);
}
return active;
}
/**
* Resolve the user's chain-service URL (their sigchain base URL) from their
* handle. The chat server constructs this from the configured sig-server and
* the user's primary key.
*/
export async function resolveChainService(handle: string): Promise<string> {
const record = await lookup(handle);
if (!record.sigchain_url) {
throw new Error("server did not return a sigchain URL for this handle");
}
return record.sigchain_url;
}
/**
* Append an add/revoke event for `subject` to the user's sigchain on the
* chain service. Reads the current chain to compute the next cursor, signs
* the event with the user's seed, and POSTs it.
*/
export async function appendSubjectEvent(opts: {
seed: Uint8Array;
chainService: string;
subject: Identity;
op: SigchainOp;
/** Optional URL where the channel proof is published (spec: add.proof_url). */
proofUrl?: string;
}): Promise<SigchainSyncResult> {
const signer = identityFromSeed(opts.seed);
const events = await getSigchain(opts.chainService);
const active = activeSubjects(events);
const head = events.length > 0 ? events[events.length - 1] : null;
const headHash = head ? sigchainEventHash(head) : "";
// Idempotency: don't duplicate state the chain already has. Re-adding an
// already-active subject, or revoking one that isn't active, is a no-op.
if (opts.op === "add" && active.has(opts.subject)) {
return {
chainService: opts.chainService,
seq: active.get(opts.subject)!,
hash: headHash,
noop: true,
};
}
if (opts.op === "revoke" && !active.has(opts.subject)) {
return {
chainService: opts.chainService,
seq: head ? head.payload.seq : -1,
hash: headHash,
noop: true,
};
}
const opPayload: Record<string, unknown> = { subject: opts.subject };
if (opts.op === "add" && opts.proofUrl) {
opPayload.proof_url = opts.proofUrl;
}
const cursor = nextChainCursor(events);
const event = signSigchainEvent(signer, opts.op, opPayload, cursor);
const { seq, hash } = await postSigchainEvent(opts.chainService, event);
return { chainService: opts.chainService, seq, hash, noop: false };
}

View File

@ -9,7 +9,11 @@
toCompact, toCompact,
type SignedClaimEnvelope, type SignedClaimEnvelope,
} from "../lib/kez.js"; } from "../lib/kez.js";
import { addClaim } from "../lib/claims-store.js"; import { addClaim, setSigchainSync } from "../lib/claims-store.js";
import {
appendSubjectEvent,
resolveChainService,
} from "../lib/sigchain-service.js";
import { session } from "../lib/store.svelte.js"; import { session } from "../lib/store.svelte.js";
import { import {
hasNip07, hasNip07,
@ -154,6 +158,14 @@
| { status: "error"; message: string } | { status: "error"; message: string }
>({ status: "idle" }); >({ status: "idle" });
/** Outcome of mirroring this claim into the user's sigchain on the chain service. */
let sigchainSync = $state<
| { status: "idle" }
| { status: "pending" }
| { status: "ok"; seq: number; noop: boolean }
| { status: "error"; message: string }
>({ status: "idle" });
/** Re-evaluated each render; cheap (just a typeof check on window.nostr). */ /** Re-evaluated each render; cheap (just a typeof check on window.nostr). */
const nip07Available = $derived(hasNip07()); const nip07Available = $derived(hasNip07());
@ -226,13 +238,15 @@
} }
async function saveAndDone() { async function saveAndDone() {
if (!envelope || !selected) return; if (!envelope || !selected || !session.unlocked) return;
const id = crypto.randomUUID();
const subject = envelope.payload.subject;
try { try {
// $state wraps `envelope` in a deep Proxy; structuredClone (used // $state wraps `envelope` in a deep Proxy; structuredClone (used
// by idb-keyval) can't clone proxies and throws DataCloneError. // by idb-keyval) can't clone proxies and throws DataCloneError.
// $state.snapshot returns a plain, cloneable object. // $state.snapshot returns a plain, cloneable object.
await addClaim({ await addClaim({
id: crypto.randomUUID(), id,
envelope: $state.snapshot(envelope) as SignedClaimEnvelope, envelope: $state.snapshot(envelope) as SignedClaimEnvelope,
channel: selected.key, channel: selected.key,
}); });
@ -240,6 +254,36 @@
} catch (e) { } catch (e) {
console.error("saveAndDone failed", e); console.error("saveAndDone failed", e);
alert(`Failed to save claim: ${(e as Error).message}`); alert(`Failed to save claim: ${(e as Error).message}`);
return;
}
// Mirror the claim into the user's sigchain on the chain service: append
// a signed `add` event for this subject. Best-effort — the claim is
// already saved locally; if the chain service is unreachable we record
// the error and the user can retry from the Claims page later.
sigchainSync = { status: "pending" };
try {
const chainService = await resolveChainService(session.unlocked.handle);
const proofUrl =
nostrPublish.status === "ok" ? nostrPublish.result.evidence_url : undefined;
const result = await appendSubjectEvent({
seed: session.unlocked.seed,
chainService,
subject,
op: "add",
proofUrl,
});
await setSigchainSync(id, {
chain_service: result.chainService,
sigchain_seq: result.seq,
sigchain_status: "synced",
});
sigchainSync = { status: "ok", seq: result.seq, noop: result.noop };
} catch (e) {
await setSigchainSync(id, {
sigchain_status: "error",
sigchain_error: (e as Error).message,
});
sigchainSync = { status: "error", message: (e as Error).message };
} }
} }
</script> </script>
@ -452,6 +496,28 @@
Once you've published the proof on that channel, come back to the Once you've published the proof on that channel, come back to the
Claims page and mark it published. Claims page and mark it published.
</p> </p>
<!-- Chain-service (sigchain) mirror status -->
<div class="mt-3 text-sm">
{#if sigchainSync.status === "pending"}
<p class="text-text-secondary">⏳ Updating your sigchain on the chain service…</p>
{:else if sigchainSync.status === "ok"}
<p class="text-verified">
{#if sigchainSync.noop}
⛓ Already on your sigchain (seq {sigchainSync.seq}) — nothing to add.
{:else}
⛓ Sigchain updated — added at seq {sigchainSync.seq} on the chain service.
{/if}
</p>
{:else if sigchainSync.status === "error"}
<p class="text-warning">
⚠ Couldn't update your sigchain on the chain service:
{sigchainSync.message}. The claim is saved locally — retry the
sigchain sync from the Claims page.
</p>
{/if}
</div>
<div class="mt-4 flex gap-2"> <div class="mt-4 flex gap-2">
<a <a
href="#/claims" href="#/claims"
@ -466,6 +532,8 @@
selected = null; selected = null;
identifierInput = ""; identifierInput = "";
envelope = null; envelope = null;
nostrPublish = { status: "idle" };
sigchainSync = { status: "idle" };
}} }}
> >
Add another Add another

View File

@ -6,8 +6,13 @@
markPublished, markPublished,
removeClaim, removeClaim,
setVerifyResult, setVerifyResult,
setSigchainSync,
type StoredClaim, type StoredClaim,
} from "../lib/claims-store.js"; } from "../lib/claims-store.js";
import {
appendSubjectEvent,
resolveChainService,
} from "../lib/sigchain-service.js";
import { verifyClaim } from "../lib/verify.js"; import { verifyClaim } from "../lib/verify.js";
import { session } from "../lib/store.svelte.js"; import { session } from "../lib/store.svelte.js";
@ -15,6 +20,8 @@
let loading = $state(true); let loading = $state(true);
/** ids currently mid-verify, so we can disable the button + show a spinner. */ /** ids currently mid-verify, so we can disable the button + show a spinner. */
let verifying = $state<Set<string>>(new Set()); let verifying = $state<Set<string>>(new Set());
/** ids currently mid chain-service sync (add retry or revoke-on-delete). */
let syncing = $state<Set<string>>(new Set());
/** Which claims have their details panel expanded. */ /** Which claims have their details panel expanded. */
let expanded = $state<Set<string>>(new Set()); let expanded = $state<Set<string>>(new Set());
@ -33,11 +40,74 @@
} }
async function deleteClaim(c: StoredClaim) { async function deleteClaim(c: StoredClaim) {
if (!confirm(`Remove the local copy of claim for ${c.envelope.payload.subject}?`)) return; if (!confirm(`Remove the claim for ${c.envelope.payload.subject}?`)) return;
// Revoke on the chain service first so the user's sigchain reflects the
// removal (SPEC.md §8: a revoke event withdraws a previously-added
// subject). Best-effort — if the chain service is unreachable, ask
// before dropping the local copy so the sigchain doesn't silently drift.
if (session.unlocked) {
syncing = new Set(syncing).add(c.id);
try {
const chainService =
c.chain_service ?? (await resolveChainService(session.unlocked.handle));
await appendSubjectEvent({
seed: session.unlocked.seed,
chainService,
subject: c.envelope.payload.subject,
op: "revoke",
});
} catch (e) {
const proceed = confirm(
`Couldn't post a revoke to the chain service (${(e as Error).message}). ` +
`Remove the local copy anyway? Your sigchain will still list this subject.`,
);
if (!proceed) {
const next = new Set(syncing);
next.delete(c.id);
syncing = next;
return;
}
} finally {
const next = new Set(syncing);
next.delete(c.id);
syncing = next;
}
}
await removeClaim(c.id); await removeClaim(c.id);
claims = await listClaims(); claims = await listClaims();
} }
/** (Re)mirror a claim into the sigchain on the chain service as an `add`. */
async function syncToChain(c: StoredClaim) {
if (!session.unlocked) return;
syncing = new Set(syncing).add(c.id);
try {
const chainService = await resolveChainService(session.unlocked.handle);
const result = await appendSubjectEvent({
seed: session.unlocked.seed,
chainService,
subject: c.envelope.payload.subject,
op: "add",
});
await setSigchainSync(c.id, {
chain_service: result.chainService,
sigchain_seq: result.seq,
sigchain_status: "synced",
sigchain_error: undefined,
});
} catch (e) {
await setSigchainSync(c.id, {
sigchain_status: "error",
sigchain_error: (e as Error).message,
});
} finally {
const next = new Set(syncing);
next.delete(c.id);
syncing = next;
claims = await listClaims();
}
}
async function runVerify(c: StoredClaim) { async function runVerify(c: StoredClaim) {
verifying = new Set(verifying).add(c.id); verifying = new Set(verifying).add(c.id);
try { try {
@ -149,6 +219,19 @@
Channel: <span class="font-mono">{c.channel}</span> · Channel: <span class="font-mono">{c.channel}</span> ·
Signed: <span class="font-mono">{c.envelope.payload.created_at}</span> Signed: <span class="font-mono">{c.envelope.payload.created_at}</span>
</p> </p>
{#if syncing.has(c.id)}
<p class="mt-1 text-xs text-text-secondary">⏳ Syncing with chain service…</p>
{:else if c.sigchain_status === "synced"}
<p class="mt-1 text-xs text-verified">
⛓ On your sigchain{#if c.sigchain_seq !== undefined} · seq {c.sigchain_seq}{/if}
</p>
{:else if c.sigchain_status === "error"}
<p class="mt-1 text-xs text-warning">
⚠ Not on your sigchain{#if c.sigchain_error} ({c.sigchain_error}){/if}
</p>
{:else}
<p class="mt-1 text-xs text-text-muted">⛓ Not yet on your sigchain</p>
{/if}
{#if c.last_verify} {#if c.last_verify}
<p class="mt-1 text-xs text-text-secondary"> <p class="mt-1 text-xs text-text-secondary">
{c.last_verify.summary} {c.last_verify.summary}
@ -208,9 +291,20 @@
Mark published Mark published
</button> </button>
{/if} {/if}
{#if c.sigchain_status !== "synced"}
<button
class="text-xs px-3 py-1 border border-border rounded-md text-text-secondary hover:bg-elevated disabled:opacity-50"
onclick={() => syncToChain(c)}
disabled={syncing.has(c.id)}
title="Append an `add` event for this subject to your sigchain on the chain service."
>
{syncing.has(c.id) ? "Syncing…" : "Sync to chain"}
</button>
{/if}
<button <button
class="text-xs px-3 py-1 border border-border rounded-md text-text-secondary hover:bg-danger/10 hover:border-danger" class="text-xs px-3 py-1 border border-border rounded-md text-text-secondary hover:bg-danger/10 hover:border-danger disabled:opacity-50"
onclick={() => deleteClaim(c)} onclick={() => deleteClaim(c)}
disabled={syncing.has(c.id)}
> >
Remove Remove
</button> </button>