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:
parent
3fdbdc9fcf
commit
5ad47a917d
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
117
kez-chat/web/src/lib/sigchain-service.ts
Normal file
117
kez-chat/web/src/lib/sigchain-service.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user