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
Build and Push Docker Image / build (push) Successful in 5m1s
This commit is contained in:
@@ -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
@@ -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' }));
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user