v2.0.0: claims model - signed MMUR2 votes in a truth DB, merged view in a rebuildable cache DB; POST /api/claims, claims and merge routes, unsigned writes answer 410
Build and Push Docker Image / build (push) Successful in 5m1s

This commit is contained in:
Worph
2026-10-25 17:36:00 +00:00
parent b0adefb97b
commit 84af298792
78 changed files with 7203 additions and 5617 deletions
+135
View File
@@ -0,0 +1,135 @@
// Signed claims (meta-core v2.0.0). The editor no longer writes keys: an edit
// is a claim signed as the chosen identity account — through meta-core's
// account-key service (/api/identity/sign, compact low-S) — and posted to
// POST /api/claims. That vote wins in that account's view; the shared merged
// value only moves if enough signers agree.
//
// MMUR2 bytes (must match internal/claim and its golden vectors):
// "MMUR2\n" || canonical CBOR [cid, uid, key, value, ts]
export interface Claim {
cid: string;
uid: string;
key: string;
value: string | { b64: string } | null;
ts: number;
sig: string;
}
export interface ClaimsResponse {
rid: string;
cids: string[];
claims: Claim[];
}
export interface Account {
uid: string;
curve: string;
createdAt: number;
}
const SIGNER_LS_KEY = 'kv-editor.signer.v1';
async function jsonOrThrow<T>(res: Response): Promise<T> {
if (!res.ok) {
let msg = `HTTP ${res.status}`;
try {
const body = await res.json();
if (body?.message) msg = body.message;
else if (body?.error) msg = body.error;
} catch {}
throw new Error(msg);
}
return res.json();
}
// --- canonical CBOR for the five MMUR2 shapes -------------------------------
function head(out: number[], major: number, n: number) {
const m = major << 5;
if (n < 24) out.push(m | n);
else if (n <= 0xff) out.push(m | 24, n);
else if (n <= 0xffff) out.push(m | 25, n >> 8, n & 0xff);
else if (n <= 0xffffffff) out.push(m | 26, (n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff);
else {
const hi = Math.floor(n / 2 ** 32);
const lo = n >>> 0;
out.push(m | 27, (hi >>> 24) & 0xff, (hi >>> 16) & 0xff, (hi >>> 8) & 0xff, hi & 0xff,
(lo >>> 24) & 0xff, (lo >>> 16) & 0xff, (lo >>> 8) & 0xff, lo & 0xff);
}
}
function text(out: number[], s: string) {
const b = new TextEncoder().encode(s);
head(out, 3, b.length);
for (const x of b) out.push(x);
}
export function signedBytes(cid: string, uid: string, key: string, value: string | null, ts: number): Uint8Array {
const out: number[] = [...new TextEncoder().encode('MMUR2\n')];
head(out, 4, 5);
text(out, cid);
text(out, uid);
text(out, key);
if (value === null) out.push(0xf6);
else text(out, value);
head(out, 0, ts);
return new Uint8Array(out);
}
function b64(bytes: Uint8Array): string {
let s = '';
for (const x of bytes) s += String.fromCharCode(x);
return btoa(s);
}
// --- signer -----------------------------------------------------------------
export function getSigner(): string {
try { return localStorage.getItem(SIGNER_LS_KEY) ?? ''; } catch { return ''; }
}
export function setSigner(uid: string) {
try { localStorage.setItem(SIGNER_LS_KEY, uid); } catch {}
}
export const ClaimsAPI = {
async accounts(): Promise<Account[]> {
const r = await jsonOrThrow<{ accounts: Account[] }>(await fetch('/api/identity/accounts'));
return r.accounts ?? [];
},
async forRecord(rid: string, key?: string): Promise<ClaimsResponse> {
const q = key ? `?key=${encodeURIComponent(key)}` : '';
return jsonOrThrow(await fetch(`/api/metadata/${encodeURIComponent(rid)}/claims${q}`));
},
// vote signs value (null = retraction) for key on the record's subject cid
// as the chosen account and posts it.
async vote(rid: string, key: string, value: string | null): Promise<void> {
const uid = getSigner();
if (!uid) throw new Error('choose a signing account first (top right)');
const rec = await this.forRecord(rid);
const cid = rec.cids[0];
if (!cid) throw new Error(`record ${rid} has no cid`);
// ts must beat this account's previous vote on the key.
const prev = rec.claims.filter((c) => c.uid === uid && c.key === key).map((c) => c.ts);
const ts = Math.max(Date.now(), ...prev.map((t) => t + 1));
const bytes = signedBytes(cid, uid, key, value, ts);
const signed = await jsonOrThrow<{ sigB64: string }>(await fetch('/api/identity/sign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uid, bytesB64: b64(bytes), format: 'compact' }),
}));
const claim: Claim = { cid, uid, key, value, ts, sig: signed.sigB64 };
const res = await jsonOrThrow<{ status: string; reason?: string }[]>(await fetch('/api/claims', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([claim]),
}));
const st = res[0]?.status;
if (st !== 'accepted' && st !== 'unchanged') {
throw new Error(`claim ${st}${res[0]?.reason ? ': ' + res[0].reason : ''}`);
}
},
};
+135 -12
View File
@@ -1,8 +1,17 @@
// Client for the meta-core /api/kv endpoints. The keyspace is a slash-tree of
// string-valued keys; this module wraps the S3-style listing, search, and
// per-key get/put/delete operations.
// The editor's key tree. Since meta-core v2.0.0 the records are no longer raw
// keys: `file:<rid>/<field>` is the MERGED view, derived from signed claims.
// This module keeps the slash-tree the components were built on, as a view:
//
// - reads of `file:` keys come from the merged record API
// (/api/metadata/hash-ids, /api/metadata/{rid});
// - writes of `file:` keys are signed claims (claimsApi.ts): put = a vote,
// del = a retraction of the chosen account's vote;
// - every other key is service scratch KV, through /api/kv/*.
import { ClaimsAPI } from './claimsApi';
const API_BASE = '/api/kv';
const RECORD_NS = 'file:';
export interface KVTreeResponse {
prefix: string;
@@ -61,6 +70,45 @@ async function jsonOrThrow<T>(res: Response): Promise<T> {
return res.json();
}
// splitRecordKey decomposes file:<rid>/<field> (field may be empty).
export function splitRecordKey(key: string): { rid: string; field: string } | null {
if (!key.startsWith(RECORD_NS)) return null;
const rest = key.slice(RECORD_NS.length);
const slash = rest.indexOf('/');
if (slash <= 0) return null;
return { rid: rest.slice(0, slash), field: rest.slice(slash + 1) };
}
let hashIdsCache: { at: number; ids: string[] } | null = null;
async function hashIds(): Promise<string[]> {
if (hashIdsCache && Date.now() - hashIdsCache.at < 5000) return hashIdsCache.ids;
const r = await jsonOrThrow<{ hashIds: string[] }>(await fetch('/api/metadata/hash-ids'));
hashIdsCache = { at: Date.now(), ids: (r.hashIds ?? []).slice().sort() };
return hashIdsCache.ids;
}
async function recordFields(rid: string): Promise<Record<string, string>> {
const res = await fetch(`/api/metadata/${encodeURIComponent(rid)}`);
if (res.status === 404) return {};
const r = await jsonOrThrow<{ metadata: Record<string, string> }>(res);
return r.metadata ?? {};
}
// listUnder lists the children of prefix among keys, S3-style.
function listUnder(prefix: string, delimiter: string, keys: string[]) {
const branches = new Set<string>();
const leaves: string[] = [];
for (const k of keys) {
if (!k.startsWith(prefix)) continue;
const rest = k.slice(prefix.length);
const i = delimiter ? rest.indexOf(delimiter) : -1;
if (i >= 0) branches.add(prefix + rest.slice(0, i + delimiter.length));
else leaves.push(k);
}
return { branches: [...branches].sort(), leaves: leaves.sort() };
}
export const KVAPI = {
async info(): Promise<KVInfoResponse> {
return jsonOrThrow(await fetch(`${API_BASE}/info`));
@@ -72,17 +120,34 @@ export const KVAPI = {
cursor?: string;
max?: number;
}): Promise<KVTreeResponse> {
const prefix = params.prefix ?? '';
const delimiter = params.delimiter ?? '';
const done = (branches: string[], leaves: string[]): KVTreeResponse =>
({ prefix, delimiter, branches, leaves, cursor: '0', hasMore: false });
if (prefix.startsWith(RECORD_NS)) {
const parts = splitRecordKey(prefix);
if (!parts) {
const ids = await hashIds();
return done(ids.map((id) => `${RECORD_NS}${id}/`), []);
}
const fields = await recordFields(parts.rid);
const keys = Object.keys(fields).map((f) => `${RECORD_NS}${parts.rid}/${f}`);
const l = listUnder(prefix, delimiter, keys);
return done(l.branches, l.leaves);
}
const q = new URLSearchParams();
if (params.prefix !== undefined) q.set('prefix', params.prefix);
if (params.delimiter !== undefined) q.set('delimiter', params.delimiter);
q.set('prefix', prefix);
q.set('delimiter', delimiter);
if (params.cursor) q.set('cursor', params.cursor);
if (params.max) q.set('max', String(params.max));
return jsonOrThrow(await fetch(`${API_BASE}/tree?${q}`));
const svc = await jsonOrThrow<KVTreeResponse>(await fetch(`${API_BASE}/tree?${q}`));
if (prefix === '' && delimiter) {
svc.branches = [RECORD_NS, ...svc.branches.filter((b) => b !== RECORD_NS)];
}
return svc;
},
// Walk multiple SCAN cycles until the keyspace is exhausted or a soft cap
// is reached. Useful for branches with up to a few thousand children where
// pagination UX would just be a worse spinner.
async treeAll(prefix: string, delimiter: string, softCap = 5000): Promise<KVTreeResponse> {
const seenLeaves = new Set<string>();
const seenBranches = new Set<string>();
@@ -105,22 +170,73 @@ export const KVAPI = {
};
},
// search finds keys whose path contains the substring: records matched by
// free text (each matching field key), plus service keys.
async search(contains: string, limit = 500): Promise<KVSearchResponse> {
const r = await jsonOrThrow<{ results: { hashId: string; metadata: Record<string, string> }[] }>(
await fetch('/api/metadata/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: contains, limit: 100 }),
}));
const needle = contains.toLowerCase();
const keys: string[] = [];
for (const hit of r.results ?? []) {
for (const [f, v] of Object.entries(hit.metadata)) {
const k = `${RECORD_NS}${hit.hashId}/${f}`;
if (k.toLowerCase().includes(needle) || v.toLowerCase().includes(needle)) keys.push(k);
}
}
const q = new URLSearchParams({ contains, limit: String(limit) });
return jsonOrThrow(await fetch(`${API_BASE}/search?${q}`));
const svc = await jsonOrThrow<KVSearchResponse>(await fetch(`${API_BASE}/search?${q}`));
const all = [...keys, ...svc.keys].sort();
return { contains, keys: all.slice(0, limit), truncated: all.length > limit || svc.truncated };
},
// find returns record fields (one of `fields`) whose value contains the
// substring, via the merged-record search.
async find(contains: string, fields: string[], limit = 200): Promise<KVFindResponse> {
const q = new URLSearchParams({ contains, fields: fields.join(','), limit: String(limit) });
return jsonOrThrow(await fetch(`${API_BASE}/find?${q}`));
const matches: KVFindMatch[] = [];
let truncated = false;
for (const field of fields) {
const r = await jsonOrThrow<{ results: { hashId: string; metadata: Record<string, string> }[] }>(
await fetch('/api/metadata/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filters: [{ property: field, values: [contains] }], limit }),
}));
for (const hit of r.results ?? []) {
const v = hit.metadata[field];
if (v === undefined) continue;
const entryPath = `${RECORD_NS}${hit.hashId}/`;
matches.push({ key: entryPath + field, value: v, entryPath, field });
if (matches.length >= limit) { truncated = true; break; }
}
if (truncated) break;
}
matches.sort((a, b) => a.key.localeCompare(b.key));
return { contains, fields, matches, truncated };
},
async get(key: string): Promise<KVValueResponse> {
const parts = splitRecordKey(key);
if (parts) {
const fields = await recordFields(parts.rid);
const value = fields[parts.field];
return { key, type: 'string', value: value ?? '', exists: value !== undefined };
}
const q = new URLSearchParams({ key });
return jsonOrThrow(await fetch(`${API_BASE}/value?${q}`));
},
// put on a record key is the chosen account's signed vote.
async put(key: string, value: string): Promise<KVValueResponse> {
const parts = splitRecordKey(key);
if (parts) {
await ClaimsAPI.vote(parts.rid, parts.field, value);
hashIdsCache = null;
return { key, type: 'string', value, exists: true };
}
const res = await fetch(`${API_BASE}/value`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -129,7 +245,14 @@ export const KVAPI = {
return jsonOrThrow(res);
},
// del on a record key retracts the chosen account's vote; the merged value
// stays while other signers still support it.
async del(key: string): Promise<{ key: string; deleted: boolean }> {
const parts = splitRecordKey(key);
if (parts) {
await ClaimsAPI.vote(parts.rid, parts.field, null);
return { key, deleted: true };
}
const q = new URLSearchParams({ key });
return jsonOrThrow(await fetch(`${API_BASE}/value?${q}`, { method: 'DELETE' }));
},
-96
View File
@@ -1,96 +0,0 @@
const API_BASE = '/api/snapshot';
export type ImportMode = 'replace' | 'merge';
export type ImportConflict = 'mine' | 'source';
export interface FileImportResult {
hashId: string;
added: number;
updated: number;
unchanged: number;
kept: number;
deleted: number;
error?: string;
}
export interface PluginOutputResult {
written: number;
skipped: number;
}
export interface ImportResult {
mode: ImportMode;
conflict?: ImportConflict;
dryRun: boolean;
totalFiles: number;
filesOk: number;
filesFailed: number;
files?: FileImportResult[];
pluginOutput?: PluginOutputResult;
}
export interface WipeResult {
metadataDeleted: number;
}
export class SnapshotAPI {
/**
* Trigger a download of a metadata snapshot ZIP.
* Browser handles the download via a synthetic <a> click.
*/
static downloadExport(includes: { pluginOutput?: boolean } = {}): void {
const parts: string[] = [];
if (includes.pluginOutput) parts.push('plugin-output');
const qs = parts.length ? `?include=${parts.join(',')}` : '';
const a = document.createElement('a');
a.href = `${API_BASE}/export${qs}`;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
/**
* Upload a snapshot ZIP and apply it.
* Mode replace deletes and rewrites each CID's properties.
* Mode merge preserves untouched fields; conflict picks who wins on overlap.
*/
static async runImport(
file: File,
opts: { mode: ImportMode; conflict?: ImportConflict; dryRun?: boolean }
): Promise<ImportResult> {
const params = new URLSearchParams();
params.set('mode', opts.mode);
if (opts.mode === 'merge') {
params.set('conflict', opts.conflict ?? 'source');
}
if (opts.dryRun) {
params.set('dry_run', 'true');
}
const fd = new FormData();
fd.append('snapshot', file);
const response = await fetch(`${API_BASE}/import?${params.toString()}`, {
method: 'POST',
body: fd,
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.message || err.error || 'Import failed');
}
return response.json();
}
/**
* Wipe all stored metadata. Pass scope=metadata.
*/
static async wipe(scope: 'metadata'): Promise<WipeResult> {
const response = await fetch(`${API_BASE}/wipe?scope=${scope}`, {
method: 'POST',
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.message || err.error || 'Wipe failed');
}
return response.json();
}
}
-209
View File
@@ -1,209 +0,0 @@
.snapshot-panel {
max-width: 920px;
margin: 0 auto;
padding: 1.5rem;
}
.snapshot-panel h2 {
margin: 0 0 0.5rem;
}
.snapshot-intro {
color: var(--text-muted, #888);
margin-bottom: 1.5rem;
}
.snapshot-section {
border: 1px solid var(--border, #e0e0e0);
border-radius: 6px;
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
}
.snapshot-section.danger {
border-color: #d04a4a;
background: rgba(208, 74, 74, 0.04);
}
.snapshot-section h3 {
margin: 0 0 0.5rem;
}
.snapshot-row {
display: flex;
gap: 1rem;
align-items: flex-start;
margin: 0.75rem 0;
flex-wrap: wrap;
}
.snapshot-file {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.snapshot-file span {
font-size: 0.85rem;
color: var(--text-muted, #888);
}
.snapshot-fieldset {
border: 1px solid var(--border, #e0e0e0);
border-radius: 4px;
padding: 0.5rem 0.75rem;
min-width: 280px;
}
.snapshot-fieldset[disabled] {
opacity: 0.5;
}
.snapshot-fieldset legend {
font-weight: 600;
font-size: 0.85rem;
padding: 0 0.25rem;
}
.snapshot-fieldset label {
display: block;
margin: 0.25rem 0;
font-size: 0.9rem;
cursor: pointer;
}
.snapshot-fieldset input[type='radio'] {
margin-right: 0.5rem;
}
.snapshot-btn {
background: var(--bg-button, #f0f0f0);
border: 1px solid var(--border, #c0c0c0);
color: var(--text, #222);
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.95rem;
}
.snapshot-btn:hover:not(:disabled) {
background: var(--bg-button-hover, #e0e0e0);
}
.snapshot-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.snapshot-btn.primary {
background: #2a6df4;
border-color: #2a6df4;
color: white;
}
.snapshot-btn.primary:hover:not(:disabled) {
background: #1a5de4;
}
.snapshot-btn.danger {
background: #d04a4a;
border-color: #d04a4a;
color: white;
}
.snapshot-btn.danger:hover:not(:disabled) {
background: #b83838;
}
.snapshot-error {
background: rgba(208, 74, 74, 0.1);
color: #b83838;
padding: 0.5rem 0.75rem;
border-radius: 4px;
margin-top: 0.75rem;
}
.snapshot-info {
background: rgba(42, 109, 244, 0.08);
color: #1a5de4;
padding: 0.5rem 0.75rem;
border-radius: 4px;
margin-top: 0.75rem;
}
.snapshot-result {
margin-top: 0.75rem;
border: 1px solid var(--border, #e0e0e0);
border-radius: 4px;
padding: 0.75rem;
}
.snapshot-result-summary {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.snapshot-result-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.snapshot-result-table th,
.snapshot-result-table td {
border: 1px solid var(--border, #e0e0e0);
padding: 0.35rem 0.5rem;
text-align: right;
}
.snapshot-result-table th {
background: var(--bg-subtle, #f5f5f5);
font-weight: 600;
}
.snapshot-result-errors {
margin-top: 0.5rem;
font-size: 0.85rem;
}
.snapshot-result-errors summary {
cursor: pointer;
color: #b83838;
}
.snapshot-result-errors ul {
margin: 0.5rem 0 0;
padding-left: 1.25rem;
}
.snapshot-result-errors code {
font-size: 0.8rem;
word-break: break-all;
}
.snapshot-checkbox {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin: 0.5rem 0 0.75rem;
cursor: pointer;
font-size: 0.95rem;
}
.snapshot-checkbox input {
margin-top: 0.2rem;
}
.snapshot-hint {
color: var(--text-muted, #888);
font-size: 0.9rem;
}
.snapshot-result-aside {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--text-muted, #555);
}
-271
View File
@@ -1,271 +0,0 @@
import { useState } from 'react';
import {
SnapshotAPI,
ImportMode,
ImportConflict,
ImportResult,
} from '../api/snapshotApi';
import './SnapshotPanel.css';
export function SnapshotPanel() {
const [exportPluginOutput, setExportPluginOutput] = useState(false);
const [importFile, setImportFile] = useState<File | null>(null);
const [importMode, setImportMode] = useState<ImportMode>('merge');
const [importConflict, setImportConflict] = useState<ImportConflict>('source');
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [importError, setImportError] = useState<string | null>(null);
const [wipeBusy, setWipeBusy] = useState(false);
const [wipeMessage, setWipeMessage] = useState<string | null>(null);
const runImport = async (dryRun: boolean) => {
if (!importFile) {
setImportError('Pick a snapshot ZIP first.');
return;
}
setImporting(true);
setImportError(null);
setImportResult(null);
try {
const result = await SnapshotAPI.runImport(importFile, {
mode: importMode,
conflict: importMode === 'merge' ? importConflict : undefined,
dryRun,
});
setImportResult(result);
} catch (e: any) {
setImportError(e.message || String(e));
} finally {
setImporting(false);
}
};
const runWipe = async () => {
const ok = window.confirm(
'This deletes ALL stored metadata. Files on disk are not touched, but every manual ' +
'edit you made via this editor will be lost. Continue?'
);
if (!ok) return;
setWipeBusy(true);
setWipeMessage(null);
try {
const r = await SnapshotAPI.wipe('metadata');
setWipeMessage(`Deleted ${r.metadataDeleted} file metadata entries.`);
} catch (e: any) {
setWipeMessage(`Failed: ${e.message || e}`);
} finally {
setWipeBusy(false);
}
};
return (
<div className="snapshot-panel">
<h2>Snapshot</h2>
<p className="snapshot-intro">
Export, import, or wipe the metadata KV store. Snapshots are portable ZIP
archives — one nested-JSON file per CID, plus a manifest.
</p>
<section className="snapshot-section">
<h3>Export</h3>
<p>Downloads every CID's metadata as a ZIP. Optional cache files can be added below.</p>
<label className="snapshot-checkbox">
<input
type="checkbox"
checked={exportPluginOutput}
onChange={(e) => setExportPluginOutput(e.target.checked)}
/>
<span>
Include plugin output
<span className="snapshot-hint">
{' '}— TMDB posters/backdrops, extracted subtitles. Adds ~120 MB on a populated stack.
</span>
</span>
</label>
<button
className="snapshot-btn primary"
onClick={() =>
SnapshotAPI.downloadExport({ pluginOutput: exportPluginOutput })
}
>
Download snapshot.zip
</button>
</section>
<section className="snapshot-section">
<h3>Import</h3>
<div className="snapshot-row">
<label className="snapshot-file">
<span>Snapshot ZIP</span>
<input
type="file"
accept=".zip,application/zip"
onChange={(e) => {
setImportFile(e.target.files?.[0] ?? null);
setImportResult(null);
setImportError(null);
}}
/>
</label>
</div>
<div className="snapshot-row">
<fieldset className="snapshot-fieldset">
<legend>Mode</legend>
<label>
<input
type="radio"
name="mode"
value="merge"
checked={importMode === 'merge'}
onChange={() => setImportMode('merge')}
/>
Merge — keep existing fields not in the snapshot
</label>
<label>
<input
type="radio"
name="mode"
value="replace"
checked={importMode === 'replace'}
onChange={() => setImportMode('replace')}
/>
Replace — wipe each imported CID and rewrite from snapshot
</label>
</fieldset>
<fieldset className="snapshot-fieldset" disabled={importMode !== 'merge'}>
<legend>Conflict</legend>
<label>
<input
type="radio"
name="conflict"
value="source"
checked={importConflict === 'source'}
onChange={() => setImportConflict('source')}
/>
Source wins (overwrite my edits)
</label>
<label>
<input
type="radio"
name="conflict"
value="mine"
checked={importConflict === 'mine'}
onChange={() => setImportConflict('mine')}
/>
Mine wins (keep my edits, fill in only missing fields)
</label>
</fieldset>
</div>
<div className="snapshot-row">
<button
className="snapshot-btn"
disabled={!importFile || importing}
onClick={() => runImport(true)}
>
Dry run
</button>
<button
className="snapshot-btn primary"
disabled={!importFile || importing}
onClick={() => runImport(false)}
>
{importing ? 'Importing…' : 'Apply import'}
</button>
</div>
{importError && <div className="snapshot-error">{importError}</div>}
{importResult && <ImportResultView result={importResult} />}
</section>
<section className="snapshot-section danger">
<h3>Wipe metadata</h3>
<p>
Deletes every <code>file:*</code> key plus <code>file:__index__</code>.
Files on disk are untouched.
</p>
<button
className="snapshot-btn danger"
disabled={wipeBusy}
onClick={runWipe}
>
{wipeBusy ? 'Wiping…' : 'Wipe all metadata'}
</button>
{wipeMessage && <div className="snapshot-info">{wipeMessage}</div>}
</section>
</div>
);
}
function ImportResultView({ result }: { result: ImportResult }) {
const totals = (result.files ?? []).reduce(
(acc, f) => {
acc.added += f.added;
acc.updated += f.updated;
acc.unchanged += f.unchanged;
acc.kept += f.kept;
acc.deleted += f.deleted;
return acc;
},
{ added: 0, updated: 0, unchanged: 0, kept: 0, deleted: 0 }
);
return (
<div className="snapshot-result">
<div className="snapshot-result-summary">
<strong>{result.dryRun ? 'Dry run preview' : 'Import applied'}</strong>
<span>
{result.filesOk} ok / {result.filesFailed} failed of {result.totalFiles} files
</span>
<span>
mode={result.mode}
{result.conflict ? `, conflict=${result.conflict}` : ''}
</span>
</div>
<table className="snapshot-result-table">
<thead>
<tr>
<th>added</th>
<th>updated</th>
<th>unchanged</th>
<th>kept (mine)</th>
<th>deleted (replace)</th>
</tr>
</thead>
<tbody>
<tr>
<td>{totals.added}</td>
<td>{totals.updated}</td>
<td>{totals.unchanged}</td>
<td>{totals.kept}</td>
<td>{totals.deleted}</td>
</tr>
</tbody>
</table>
{result.pluginOutput && (
<div className="snapshot-result-aside">
plugin-output: {result.pluginOutput.written} written
{result.pluginOutput.skipped > 0
? `, ${result.pluginOutput.skipped} skipped (existed)`
: ''}
</div>
)}
{result.files && result.files.some((f) => f.error) && (
<details className="snapshot-result-errors">
<summary>Per-file errors</summary>
<ul>
{result.files
.filter((f) => f.error)
.map((f) => (
<li key={f.hashId}>
<code>{f.hashId}</code>: {f.error}
</li>
))}
</ul>
</details>
)}
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useState } from 'react';
import { Claim, ClaimsAPI, getSigner } from '../api/claimsApi';
import { splitRecordKey } from '../api/kvApi';
// ClaimsPanel lists the signed claims behind one merged field: who said what,
// and when. The merged value is what most signers agree on.
export function ClaimsPanel({ recordKey, reloadToken }: { recordKey: string; reloadToken: number }) {
const [claims, setClaims] = useState<Claim[] | null>(null);
const [error, setError] = useState('');
const parts = splitRecordKey(recordKey);
useEffect(() => {
if (!parts || !parts.field) return;
let cancelled = false;
ClaimsAPI.forRecord(parts.rid, parts.field)
.then((r) => { if (!cancelled) setClaims(r.claims); })
.catch((e) => { if (!cancelled) setError(e.message); });
return () => { cancelled = true; };
}, [recordKey, reloadToken]); // eslint-disable-line react-hooks/exhaustive-deps
if (!parts || !parts.field) return null;
if (error) return <div className="kv-claims kv-pane-status">claims: {error}</div>;
if (!claims) return <div className="kv-claims kv-pane-status">loading claims…</div>;
const me = getSigner();
return (
<div className="kv-claims">
<div className="kv-leaf-label">Claims ({claims.length})</div>
{claims.length === 0 ? (
<div className="kv-pane-hint">No signed claim — a box-local fact (file path, size, mtime) or a group member.</div>
) : (
<table className="kv-claims-table">
<tbody>
{claims.map((c) => (
<tr key={c.uid + c.cid}>
<td title={c.uid}><code>{c.uid.slice(0, 10)}…</code>{c.uid === me ? ' (you)' : ''}</td>
<td>{c.value === null ? <em>retracted</em> : typeof c.value === 'string' ? c.value : <em>ciphertext</em>}</td>
<td title={new Date(c.ts).toISOString()}>{new Date(c.ts).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
+6
View File
@@ -894,3 +894,9 @@
.kv-modal-close:hover {
opacity: 1;
}
.kv-signer { font-size: 12px; display: inline-flex; align-items: center; gap: 4px; }
.kv-signer select { font-size: 12px; }
.kv-claims { margin-top: 16px; }
.kv-claims-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.kv-claims-table td { padding: 3px 6px; border-top: 1px solid rgba(127, 127, 127, 0.25); vertical-align: top; }
+2 -32
View File
@@ -10,7 +10,7 @@ const FILE_BRANCH_RE = /^file:[^/]+\/$/;
import { SearchFieldChips } from './SearchFieldChips';
import { ToastStack, makeToast } from './Toast';
import { ToastEntry } from './types';
import { SnapshotPanel } from '../components/SnapshotPanel';
import { SignerPicker } from './SignerPicker';
import './KVApp.css';
const SEARCH_FIELDS_LS_KEY = 'kv-editor.searchFields.v1';
@@ -32,7 +32,6 @@ function loadSearchFields(): string[] {
//
// A sticky header carries the search bar, the breadcrumb, and basic stats.
export default function KVApp() {
const [snapshotOpen, setSnapshotOpen] = useState(false);
// Selected node. branchPath is set when a branch is selected; selectedKey
// when a leaf is. Mutually exclusive.
const [selectedKey, setSelectedKey] = useState<string | null>(null);
@@ -176,14 +175,7 @@ export default function KVApp() {
onClick={() => setReloadToken((n) => n + 1)}
title="refresh tree"
>Refresh</button>
<button
className="kv-btn-icon kv-app-gear"
onClick={() => setSnapshotOpen(true)}
title="Snapshot — export / import / wipe"
aria-label="Open snapshot settings"
>
⚙
</button>
<SignerPicker onError={(msg) => pushToast(makeToast('error', msg))} />
</div>
<SearchFieldChips fields={searchFields} onChange={setSearchFields} />
{breadcrumb && breadcrumb.length > 0 && (
@@ -284,28 +276,6 @@ export default function KVApp() {
</main>
</div>
{snapshotOpen && (
<div
className="kv-modal-backdrop"
onClick={() => setSnapshotOpen(false)}
>
<div
className="kv-modal"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<button
className="kv-modal-close"
onClick={() => setSnapshotOpen(false)}
aria-label="Close"
title="Close"
>×</button>
<SnapshotPanel />
</div>
</div>
)}
<ToastStack toasts={toasts} onDismiss={dismissToast} />
</div>
);
+6 -3
View File
@@ -5,9 +5,10 @@ import { ALL_TYPES, describeType, detectType } from './typeHeuristics';
import { lookupSchemaForKey } from './schemaLookup';
import { LeafValueEditor } from './leafRenderers';
import { FieldType } from './types';
import { ClaimsPanel } from './ClaimsPanel';
interface Props {
// Full Redis key path of the selected leaf.
// Full key path of the selected leaf (file:<rid>/<field> = merged view).
selectedKey: string;
// Bumped after a delete or external change to force refetch.
reloadToken: number;
@@ -71,7 +72,7 @@ export function KVLeafEditor({ selectedKey, reloadToken, onSaved, onDeleted, onE
};
const del = async () => {
if (!confirm(`Delete ${selectedKey}?`)) return;
if (!confirm(`Retract your vote on ${selectedKey}? Other signers' claims keep the merged value.`)) return;
setSaving(true);
try {
await KVAPI.del(selectedKey);
@@ -136,9 +137,11 @@ export function KVLeafEditor({ selectedKey, reloadToken, onSaved, onDeleted, onE
className="kv-btn kv-btn-danger"
onClick={del}
disabled={saving}
>Delete</button>
>Retract</button>
)}
</div>
<ClaimsPanel recordKey={selectedKey} reloadToken={reloadToken} />
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useState } from 'react';
import { Account, ClaimsAPI, getSigner, setSigner } from '../api/claimsApi';
// SignerPicker chooses the identity account whose key signs the editor's
// edits. Every save is that account's claim — it wins in that account's view.
export function SignerPicker({ onError }: { onError: (msg: string) => void }) {
const [accounts, setAccounts] = useState<Account[]>([]);
const [uid, setUid] = useState(getSigner());
useEffect(() => {
ClaimsAPI.accounts()
.then((list) => {
setAccounts(list);
if (!uid && list.length === 1) {
setSigner(list[0].uid);
setUid(list[0].uid);
}
})
.catch((e) => onError(`accounts: ${e.message}`));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<label className="kv-signer" title="Edits are claims signed by this account">
sign as{' '}
<select
value={uid}
onChange={(e) => { setSigner(e.target.value); setUid(e.target.value); }}
>
<option value="">— choose an account —</option>
{accounts.map((a) => (
<option key={a.uid} value={a.uid}>{a.uid.slice(0, 10)}…</option>
))}
</select>
</label>
);
}