Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
604e718cdc | ||
|
|
0bbc656263 |
+403
-119
@@ -1,21 +1,43 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
// Status returned by GET /api/identity. Private key is never present here —
|
// Admin console for meta-core's account keystore.
|
||||||
// it is shown exactly once at generation time (see GeneratedKeyPanel).
|
//
|
||||||
interface IdentityStatus {
|
// meta-core stores one secp256k1 keypair per file under
|
||||||
hasIdentity: boolean;
|
// {META_CORE_PATH}/identity/accounts/<uid>.json and has NO notion of a current
|
||||||
uid?: string;
|
// user — which account is "you" is decided by whichever client signs in
|
||||||
curve?: string;
|
// (meta-watch). So this page lists accounts; it never speaks of an active one.
|
||||||
createdAt?: number;
|
//
|
||||||
}
|
// The private key is returned by exactly two calls: POST /api/identity/generate
|
||||||
|
// (once, at creation) and POST /api/identity/reveal (on demand, for an account
|
||||||
|
// that already exists). Both land in the same exclusive SecretKeyPanel.
|
||||||
|
|
||||||
interface GeneratedKey {
|
interface Account {
|
||||||
uid: string;
|
uid: string;
|
||||||
curve: string;
|
curve: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
privateKeyHex: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A secret on screen. Carries no createdAt: /api/identity/reveal answers with
|
||||||
|
// {uid, curve, privateKeyHex} only.
|
||||||
|
interface SecretKeyView {
|
||||||
|
uid: string;
|
||||||
|
privateKeyHex: string;
|
||||||
|
kind: 'generated' | 'revealed';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which row has an open confirmation, and for what. One discriminated value
|
||||||
|
// rather than a pair of ids, so at most one confirm panel can ever be open.
|
||||||
|
interface Pending {
|
||||||
|
uid: string;
|
||||||
|
kind: 'remove' | 'reveal';
|
||||||
|
}
|
||||||
|
|
||||||
|
// meta-watch's sentinel cell for profile records: display name and avatar are
|
||||||
|
// about the *user*, not a content file, so they live at (uid, "self", ...).
|
||||||
|
// meta-core stores them opaquely and knows nothing of the convention.
|
||||||
|
const PROFILE_CID = 'self';
|
||||||
|
const PROFILE_NAME_KEY = 'profile:name';
|
||||||
|
|
||||||
const cardStyle: React.CSSProperties = {
|
const cardStyle: React.CSSProperties = {
|
||||||
background: '#16213e',
|
background: '#16213e',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
@@ -59,6 +81,21 @@ const monoStyle: React.CSSProperties = {
|
|||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dangerPanelStyle: React.CSSProperties = {
|
||||||
|
background: '#1f0a0a',
|
||||||
|
border: '1px solid #7f1d1d',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
borderRadius: '4px',
|
||||||
|
marginTop: '0.75rem',
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorTextStyle: React.CSSProperties = {
|
||||||
|
color: '#f87171',
|
||||||
|
background: '#1f0a0a',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
borderRadius: '4px',
|
||||||
|
};
|
||||||
|
|
||||||
function formatCreatedAt(unixSec?: number): string {
|
function formatCreatedAt(unixSec?: number): string {
|
||||||
if (!unixSec) return '';
|
if (!unixSec) return '';
|
||||||
try {
|
try {
|
||||||
@@ -79,25 +116,76 @@ async function errorMessage(res: Response, fallback: string): Promise<string> {
|
|||||||
return `${fallback} (HTTP ${res.status})`;
|
return `${fallback} (HTTP ${res.status})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function GeneratedKeyPanel({ data, onDismiss }: { data: GeneratedKey; onDismiss: () => void }) {
|
// Copy text, falling back to selecting the element that displays it.
|
||||||
const [copied, setCopied] = useState(false);
|
//
|
||||||
const copy = async () => {
|
// The fallback is load-bearing, not politeness: the dashboard is served over
|
||||||
|
// plain HTTP, where navigator.clipboard is undefined — without it the copy
|
||||||
|
// buttons do nothing at all on a real deployment.
|
||||||
|
async function copyToClipboard(text: string, fallbackElementId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(data.privateKeyHex);
|
await navigator.clipboard.writeText(text);
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
} catch {
|
} catch {
|
||||||
// Clipboard API blocked (non-HTTPS context, etc.) — fall back to selection.
|
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
const range = document.createRange();
|
const el = document.getElementById(fallbackElementId);
|
||||||
const el = document.getElementById('mm-privkey-box');
|
|
||||||
if (sel && el) {
|
if (sel && el) {
|
||||||
|
const range = document.createRange();
|
||||||
range.selectNodeContents(el);
|
range.selectNodeContents(el);
|
||||||
sel.removeAllRanges();
|
sel.removeAllRanges();
|
||||||
sel.addRange(range);
|
sel.addRange(range);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAccounts(): Promise<Account[]> {
|
||||||
|
const res = await fetch('/api/identity/accounts');
|
||||||
|
if (!res.ok) throw new Error(await errorMessage(res, 'Failed to load accounts'));
|
||||||
|
const data = (await res.json()) as { accounts?: Account[] };
|
||||||
|
// Backend order is CreatedAt ascending then uid (identity.List) — stable, so
|
||||||
|
// render it as given rather than re-sorting here.
|
||||||
|
return data.accounts ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve an account's display name from the User Data Layer, or null.
|
||||||
|
//
|
||||||
|
// Never throws and never reports an error upward: a name is decoration on top
|
||||||
|
// of the uid, and the account list must render identically whether Redis is up
|
||||||
|
// (identity itself is file-backed, the UDL is not). The name is also *unsigned*
|
||||||
|
// — meta-core stores the plaintext beside the signed record and verifies
|
||||||
|
// nothing — so the uid stays on screen as the real identity, and the length cap
|
||||||
|
// bounds what a hostile writer can do to the layout.
|
||||||
|
async function fetchDisplayName(uid: string): Promise<string | null> {
|
||||||
|
const url =
|
||||||
|
`/api/udl/record?uid=${encodeURIComponent(uid)}` +
|
||||||
|
`&cid=${encodeURIComponent(PROFILE_CID)}` +
|
||||||
|
`&key=${encodeURIComponent(PROFILE_NAME_KEY)}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return null; // 503 when storage is down, 500, …
|
||||||
|
const data = (await res.json()) as { exists?: boolean; value?: unknown };
|
||||||
|
// Key off `value`, not `exists`: clearing a name writes a tombstone, which
|
||||||
|
// leaves the cell existing with no plaintext. Private-tier cells look the
|
||||||
|
// same, which is exactly right — there is nothing to show for either.
|
||||||
|
if (typeof data.value !== 'string') return null;
|
||||||
|
const name = data.value.trim();
|
||||||
|
return name ? name.slice(0, 64) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one place a private key is ever displayed. Rendered exclusively (the
|
||||||
|
// account list is hidden while it is open) so the "save it now" contract of a
|
||||||
|
// freshly generated key is not competing with anything, and so the hardcoded
|
||||||
|
// element id below stays unique.
|
||||||
|
function SecretKeyPanel({ data, onDismiss }: { data: SecretKeyView; onDismiss: () => void }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
await copyToClipboard(data.privateKeyHex, 'mm-privkey-box');
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ ...cardStyle, border: '2px solid #dc2626' }}>
|
<div style={{ ...cardStyle, border: '2px solid #dc2626' }}>
|
||||||
<div
|
<div
|
||||||
@@ -109,9 +197,19 @@ function GeneratedKeyPanel({ data, onDismiss }: { data: GeneratedKey; onDismiss:
|
|||||||
marginBottom: '1rem',
|
marginBottom: '1rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{data.kind === 'generated' ? (
|
||||||
|
<>
|
||||||
<strong>This private key is shown ONCE.</strong> Save it now in a password manager.
|
<strong>This private key is shown ONCE.</strong> Save it now in a password manager.
|
||||||
It will never be displayed again — there is no recovery path. Losing it means losing access
|
You can ask for it again later with <em>Reveal private key</em>, but only for as long
|
||||||
to every record signed under this identity.
|
as this account exists on this node — delete it and the key is gone for good.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<strong>This is the live private key for an existing account.</strong> Anyone holding
|
||||||
|
it can sign as this uid forever, on any machine. There is no revocation and no
|
||||||
|
rotation. Do not paste it into chat, tickets or screenshots.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ color: '#9ca3af', margin: '0 0 0.5rem' }}>uid (public):</p>
|
<p style={{ color: '#9ca3af', margin: '0 0 0.5rem' }}>uid (public):</p>
|
||||||
<div style={monoStyle}>{data.uid}</div>
|
<div style={monoStyle}>{data.uid}</div>
|
||||||
@@ -124,36 +222,40 @@ function GeneratedKeyPanel({ data, onDismiss }: { data: GeneratedKey; onDismiss:
|
|||||||
{copied ? 'Copied' : 'Copy private key'}
|
{copied ? 'Copied' : 'Copy private key'}
|
||||||
</button>
|
</button>
|
||||||
<button style={dangerButtonStyle} onClick={onDismiss}>
|
<button style={dangerButtonStyle} onClick={onDismiss}>
|
||||||
I have saved it
|
{data.kind === 'generated' ? 'I have saved it' : 'Done — hide it'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoIdentityPanel({
|
// Create / import. Always available, not just on an empty keystore: this is an
|
||||||
|
// admin console, so adding an account to a populated node has to be possible.
|
||||||
|
function NewAccountPanel({
|
||||||
onGenerated,
|
onGenerated,
|
||||||
onImported,
|
onImported,
|
||||||
}: {
|
}: {
|
||||||
onGenerated: (g: GeneratedKey) => void;
|
onGenerated: (s: SecretKeyView) => void;
|
||||||
onImported: () => void;
|
onImported: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const [importHex, setImportHex] = useState('');
|
const [importHex, setImportHex] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
const generate = async () => {
|
const generate = async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/identity/generate', { method: 'POST' });
|
const res = await fetch('/api/identity/generate', { method: 'POST' });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError(await errorMessage(res, 'Generate failed'));
|
setError(await errorMessage(res, 'Generate failed'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = (await res.json()) as GeneratedKey;
|
const data = (await res.json()) as { uid: string; privateKeyHex: string };
|
||||||
onGenerated(data);
|
onGenerated({ uid: data.uid, privateKeyHex: data.privateKeyHex, kind: 'generated' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(`Generate failed: ${e instanceof Error ? e.message : String(e)}`);
|
setError(`Generate failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -165,6 +267,7 @@ function NoIdentityPanel({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/identity/import', {
|
const res = await fetch('/api/identity/import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -175,6 +278,15 @@ function NoIdentityPanel({
|
|||||||
setError(await errorMessage(res, 'Import failed'));
|
setError(await errorMessage(res, 'Import failed'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Import is idempotent — presenting the key IS the proof of ownership, so
|
||||||
|
// a key this node already holds is a success, not a conflict. `created`
|
||||||
|
// only decides the wording.
|
||||||
|
const data = (await res.json()) as { created?: boolean };
|
||||||
|
setNotice(
|
||||||
|
data.created
|
||||||
|
? 'Imported — a new account was added to this keystore.'
|
||||||
|
: 'That key was already in this keystore; nothing changed.'
|
||||||
|
);
|
||||||
setImportHex('');
|
setImportHex('');
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
onImported();
|
onImported();
|
||||||
@@ -187,15 +299,16 @@ function NoIdentityPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={cardStyle}>
|
<div style={cardStyle}>
|
||||||
<h3 style={{ marginTop: 0 }}>No identity yet</h3>
|
<h3 style={{ marginTop: 0 }}>Add an account</h3>
|
||||||
<p style={{ color: '#9ca3af' }}>
|
<p style={{ color: '#9ca3af', marginTop: 0 }}>
|
||||||
The User Data Layer needs a signing key for your ratings, watch progress, and "liked" flags
|
Generating mints a fresh secp256k1 keypair here. Importing adds a key created elsewhere —
|
||||||
to be attributed to you across devices. Nothing happens automatically — generate one below,
|
it is also how an existing account is moved onto this node, so importing a key the
|
||||||
or import a key you already have.
|
keystore already holds succeeds and changes nothing.
|
||||||
</p>
|
</p>
|
||||||
{error && (
|
{error && <p style={errorTextStyle}>{error}</p>}
|
||||||
<p style={{ color: '#f87171', background: '#1f0a0a', padding: '0.5rem 0.75rem', borderRadius: '4px' }}>
|
{notice && (
|
||||||
{error}
|
<p style={{ color: '#9ca3af', background: '#0a0a18', padding: '0.5rem 0.75rem', borderRadius: '4px' }}>
|
||||||
|
{notice}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
@@ -235,71 +348,125 @@ function NoIdentityPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HasIdentityPanel({ status, onReplace }: { status: IdentityStatus; onReplace: () => void }) {
|
function AccountCard({
|
||||||
const [confirming, setConfirming] = useState(false);
|
account,
|
||||||
const [busy, setBusy] = useState(false);
|
name,
|
||||||
const [error, setError] = useState<string | null>(null);
|
busy,
|
||||||
|
disabled,
|
||||||
|
pending,
|
||||||
|
error,
|
||||||
|
onAsk,
|
||||||
|
onCancel,
|
||||||
|
onReveal,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
account: Account;
|
||||||
|
name?: string;
|
||||||
|
busy: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
pending: Pending | null;
|
||||||
|
error: string | null;
|
||||||
|
onAsk: (p: Pending) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onReveal: (uid: string) => void;
|
||||||
|
onRemove: (uid: string) => void;
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const uidBoxId = `mm-uid-${account.uid}`;
|
||||||
|
const label = name ?? account.uid;
|
||||||
|
|
||||||
const remove = async () => {
|
const copyUid = async () => {
|
||||||
setBusy(true);
|
await copyToClipboard(account.uid, uidBoxId);
|
||||||
setError(null);
|
setCopied(true);
|
||||||
try {
|
setTimeout(() => setCopied(false), 2000);
|
||||||
const res = await fetch('/api/identity?confirm=true', { method: 'DELETE' });
|
|
||||||
if (!res.ok && res.status !== 204) {
|
|
||||||
setError(await errorMessage(res, 'Delete failed'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onReplace();
|
|
||||||
} catch (e) {
|
|
||||||
setError(`Delete failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
setConfirming(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={cardStyle}>
|
<div style={cardStyle}>
|
||||||
<h3 style={{ marginTop: 0 }}>Active identity</h3>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem' }}>
|
||||||
<p style={{ color: '#9ca3af', margin: '0 0 0.5rem' }}>uid:</p>
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
<div style={monoStyle}>{status.uid}</div>
|
<h3 style={{ margin: '0 0 0.5rem', color: name ? '#fff' : '#888' }}>
|
||||||
<p style={{ color: '#888', marginTop: '1rem' }}>
|
{name ?? 'Unnamed account'}
|
||||||
Curve: {status.curve} · Created: {formatCreatedAt(status.createdAt)}
|
</h3>
|
||||||
|
<div id={uidBoxId} style={monoStyle}>
|
||||||
|
{account.uid}
|
||||||
|
</div>
|
||||||
|
<p style={{ color: '#888', margin: '0.75rem 0 0', fontSize: '0.85rem' }}>
|
||||||
|
Curve: {account.curve} · Created: {formatCreatedAt(account.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
<p style={{ color: '#9ca3af', fontSize: '0.85rem' }}>
|
</div>
|
||||||
The private key lives at <code>/meta-core/identity/identity.json</code> on this volume and
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', flexShrink: 0 }}>
|
||||||
is never returned through the API after generation. Other services on this stack (e.g.
|
<button style={{ ...buttonStyle, marginRight: 0 }} onClick={copyUid} disabled={disabled}>
|
||||||
meta-watch) sign records by calling <code>POST /api/identity/sign</code>.
|
{copied ? 'Copied' : 'Copy uid'}
|
||||||
</p>
|
|
||||||
{error && (
|
|
||||||
<p style={{ color: '#f87171', background: '#1f0a0a', padding: '0.5rem 0.75rem', borderRadius: '4px' }}>
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{!confirming ? (
|
|
||||||
<button style={dangerButtonStyle} onClick={() => setConfirming(true)} disabled={busy}>
|
|
||||||
Replace identity
|
|
||||||
</button>
|
</button>
|
||||||
) : (
|
<button
|
||||||
<div
|
style={{ ...dangerButtonStyle, marginRight: 0 }}
|
||||||
style={{
|
onClick={() => onAsk({ uid: account.uid, kind: 'reveal' })}
|
||||||
background: '#1f0a0a',
|
disabled={disabled}
|
||||||
border: '1px solid #7f1d1d',
|
|
||||||
padding: '0.75rem 1rem',
|
|
||||||
borderRadius: '4px',
|
|
||||||
marginTop: '0.5rem',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<p style={{ color: '#fecaca', marginTop: 0 }}>
|
Reveal private key
|
||||||
Replacing the identity deletes the current keypair from disk. Records signed by the
|
|
||||||
current uid will keep verifying for anyone who has them cached, but this node will no
|
|
||||||
longer be able to write under that uid. <strong>Make sure you have backed up the key
|
|
||||||
first.</strong>
|
|
||||||
</p>
|
|
||||||
<button style={dangerButtonStyle} onClick={remove} disabled={busy}>
|
|
||||||
{busy ? 'Deleting…' : 'Yes, delete identity'}
|
|
||||||
</button>
|
</button>
|
||||||
<button style={buttonStyle} onClick={() => setConfirming(false)} disabled={busy}>
|
<button
|
||||||
|
style={{ ...dangerButtonStyle, marginRight: 0 }}
|
||||||
|
onClick={() => onAsk({ uid: account.uid, kind: 'remove' })}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p style={{ ...errorTextStyle, marginBottom: 0 }}>{error}</p>}
|
||||||
|
|
||||||
|
{pending?.uid === account.uid && pending.kind === 'reveal' && (
|
||||||
|
<div style={dangerPanelStyle}>
|
||||||
|
<p style={{ color: '#fecaca', marginTop: 0 }}>
|
||||||
|
Show the private key for <strong>{label}</strong>?
|
||||||
|
</p>
|
||||||
|
<p style={{ color: '#fecaca', marginTop: 0 }}>
|
||||||
|
The key <em>is</em> the account. Anyone who copies it can sign records as this uid on
|
||||||
|
any machine, forever — there is no way to revoke or rotate it afterwards. meta-core
|
||||||
|
will hand it over without asking who you are; see the note at the top of this page.
|
||||||
|
</p>
|
||||||
|
<button style={dangerButtonStyle} onClick={() => onReveal(account.uid)} disabled={busy}>
|
||||||
|
{busy ? 'Revealing…' : 'Yes, show the private key'}
|
||||||
|
</button>
|
||||||
|
<button style={buttonStyle} onClick={onCancel} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pending?.uid === account.uid && pending.kind === 'remove' && (
|
||||||
|
<div style={dangerPanelStyle}>
|
||||||
|
<p style={{ color: '#fecaca', marginTop: 0 }}>
|
||||||
|
Remove <strong>{label}</strong> from this node?
|
||||||
|
</p>
|
||||||
|
<ul style={{ color: '#fecaca', paddingLeft: '1.2rem', margin: '0 0 0.75rem' }}>
|
||||||
|
<li>
|
||||||
|
<strong>The key is gone for good.</strong> The uid <em>is</em> the public key, and
|
||||||
|
the private half exists only in this node's keystore. If nobody saved it, nothing —
|
||||||
|
not you, not meta-core — can ever sign as this uid again. Reveal and copy it first
|
||||||
|
if there is any doubt.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Nothing is retracted.</strong> Records already signed under this uid keep
|
||||||
|
verifying on every peer that replicated them. Deleting a key is not a recall.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Its data stays, but goes read-only.</strong> The account's User Data Layer
|
||||||
|
cells — likes, ratings, watch progress — survive in Redis, and no newer version of
|
||||||
|
any of them can ever be written.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>This signs nobody out elsewhere.</strong> Any device that holds this key
|
||||||
|
still holds it, and can import it back here at any time.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<button style={dangerButtonStyle} onClick={() => onRemove(account.uid)} disabled={busy}>
|
||||||
|
{busy ? 'Removing…' : 'Yes, remove this account'}
|
||||||
|
</button>
|
||||||
|
<button style={buttonStyle} onClick={onCancel} disabled={busy}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,66 +476,183 @@ function HasIdentityPanel({ status, onReplace }: { status: IdentityStatus; onRep
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Identity() {
|
export default function Identity() {
|
||||||
const [status, setStatus] = useState<IdentityStatus | null>(null);
|
const [accounts, setAccounts] = useState<Account[] | null>(null);
|
||||||
const [generated, setGenerated] = useState<GeneratedKey | null>(null);
|
const [names, setNames] = useState<Record<string, string>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
const [secret, setSecret] = useState<SecretKeyView | null>(null);
|
||||||
|
const [pending, setPending] = useState<Pending | null>(null);
|
||||||
|
const [busyUid, setBusyUid] = useState<string | null>(null);
|
||||||
|
const [rowError, setRowError] = useState<{ uid: string; message: string } | null>(null);
|
||||||
|
|
||||||
|
// Bumped per refresh so a slow response from a superseded load cannot
|
||||||
|
// overwrite a newer one. The page does not poll (unlike the rest of the
|
||||||
|
// dashboard) — a key list has no business changing under the cursor.
|
||||||
|
const reqGen = useRef(0);
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
|
const gen = ++reqGen.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setFetchError(null);
|
setFetchError(null);
|
||||||
|
let list: Account[];
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/identity');
|
list = await fetchAccounts();
|
||||||
if (!res.ok) {
|
} catch (e) {
|
||||||
setFetchError(await errorMessage(res, 'Failed to load identity status'));
|
if (gen !== reqGen.current) return;
|
||||||
|
setFetchError(e instanceof Error ? e.message : String(e));
|
||||||
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus((await res.json()) as IdentityStatus);
|
if (gen !== reqGen.current) return;
|
||||||
} catch (e) {
|
// Render the list before resolving names, so a down Redis delays nothing.
|
||||||
setFetchError(`Failed to load identity status: ${e instanceof Error ? e.message : String(e)}`);
|
setAccounts(list);
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
|
||||||
|
const settled = await Promise.allSettled(list.map((a) => fetchDisplayName(a.uid)));
|
||||||
|
if (gen !== reqGen.current) return;
|
||||||
|
const next: Record<string, string> = {};
|
||||||
|
settled.forEach((r, i) => {
|
||||||
|
if (r.status === 'fulfilled' && r.value) next[list[i].uid] = r.value;
|
||||||
|
});
|
||||||
|
// Replace rather than merge, so a removed account's name goes with it.
|
||||||
|
setNames(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const reveal = async (uid: string) => {
|
||||||
|
setBusyUid(uid);
|
||||||
|
setRowError(null);
|
||||||
|
try {
|
||||||
|
// POST with a body rather than ?uid=, so the target stays out of the
|
||||||
|
// proxy's access log. The handler accepts either.
|
||||||
|
const res = await fetch('/api/identity/reveal', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ confirm: true, uid }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
setRowError({ uid, message: await errorMessage(res, 'Reveal failed') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { uid: string; privateKeyHex: string };
|
||||||
|
setPending(null);
|
||||||
|
setSecret({ uid: data.uid, privateKeyHex: data.privateKeyHex, kind: 'revealed' });
|
||||||
|
} catch (e) {
|
||||||
|
setRowError({ uid, message: `Reveal failed: ${e instanceof Error ? e.message : String(e)}` });
|
||||||
|
} finally {
|
||||||
|
setBusyUid(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (uid: string) => {
|
||||||
|
setBusyUid(uid);
|
||||||
|
setRowError(null);
|
||||||
|
try {
|
||||||
|
// The uid is mandatory: with more than one account the backend refuses an
|
||||||
|
// unqualified delete (400 uid_required) rather than guess.
|
||||||
|
const res = await fetch(`/api/identity?uid=${encodeURIComponent(uid)}&confirm=true`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
setRowError({ uid, message: await errorMessage(res, 'Remove failed') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(null);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setRowError({ uid, message: `Remove failed: ${e instanceof Error ? e.message : String(e)}` });
|
||||||
|
} finally {
|
||||||
|
setBusyUid(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissSecret = () => {
|
||||||
|
setSecret(null);
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const showList = !loading && !fetchError && !secret;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem', maxWidth: '900px', margin: '0 auto' }}>
|
<div style={{ padding: '2rem', maxWidth: '900px', margin: '0 auto' }}>
|
||||||
<h1>Identity</h1>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h1 style={{ marginBottom: 0 }}>Identity</h1>
|
||||||
|
<button style={{ ...buttonStyle, marginRight: 0 }} onClick={refresh} disabled={loading}>
|
||||||
|
{loading ? 'Loading…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p style={{ color: '#9ca3af' }}>
|
<p style={{ color: '#9ca3af' }}>
|
||||||
Signing key for the User Data Layer (per-user ratings, progress, "liked" flags). One
|
Every signing key in this node's keystore. meta-core has no "current user" — it stores
|
||||||
identity per node. The same key on another device makes that device count as "you".
|
accounts, and which one is <em>you</em> is decided by whichever client signs in (e.g.
|
||||||
|
meta-watch). Keys live one JSON file per account under{' '}
|
||||||
|
<code>{'{META_CORE_PATH}'}/identity/accounts/<uid>.json</code>, and are used to sign
|
||||||
|
User Data Layer records (ratings, watch progress, "liked" flags).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div style={{ ...dangerPanelStyle, marginTop: 0, marginBottom: '1rem' }}>
|
||||||
|
<p style={{ color: '#fecaca', margin: 0 }}>
|
||||||
|
<strong>Admin page — no authorisation happens here.</strong> <code>/api/identity/*</code>{' '}
|
||||||
|
asks no questions: anything that can reach this API can list, create, reveal or delete{' '}
|
||||||
|
<em>any</em> account's private key. The only access control is the perimeter in front of
|
||||||
|
this port. Client-facing screens (meta-watch) authorise reveal and delete against the
|
||||||
|
caller's own session and expose no delete at all; this page has neither check. Treat
|
||||||
|
reaching it as equivalent to holding every key on this node.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading && <div style={cardStyle}>Loading…</div>}
|
{loading && <div style={cardStyle}>Loading…</div>}
|
||||||
|
|
||||||
{fetchError && (
|
{fetchError && (
|
||||||
<div style={{ ...cardStyle, border: '1px solid #7f1d1d' }}>
|
<div style={{ ...cardStyle, border: '1px solid #7f1d1d' }}>
|
||||||
<p style={{ color: '#f87171', margin: 0 }}>{fetchError}</p>
|
<p style={{ color: '#f87171', margin: '0 0 1rem' }}>{fetchError}</p>
|
||||||
<button style={buttonStyle} onClick={refresh}>
|
<button style={buttonStyle} onClick={refresh}>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !fetchError && generated && (
|
{!loading && !fetchError && secret && <SecretKeyPanel data={secret} onDismiss={dismissSecret} />}
|
||||||
<GeneratedKeyPanel
|
|
||||||
data={generated}
|
{showList && (
|
||||||
onDismiss={() => {
|
<>
|
||||||
setGenerated(null);
|
<NewAccountPanel onGenerated={(s) => setSecret(s)} onImported={refresh} />
|
||||||
refresh();
|
|
||||||
|
<h2 style={{ fontSize: '1.1rem', color: '#9ca3af', margin: '1.5rem 0 1rem' }}>
|
||||||
|
Accounts ({accounts?.length ?? 0})
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{accounts && accounts.length === 0 && (
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<p style={{ color: '#9ca3af', margin: 0 }}>
|
||||||
|
No accounts in this keystore. Accounts are normally created by a client when
|
||||||
|
someone signs up; use <em>Add an account</em> above if you are bootstrapping this
|
||||||
|
node.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{accounts?.map((a) => (
|
||||||
|
<AccountCard
|
||||||
|
key={a.uid}
|
||||||
|
account={a}
|
||||||
|
name={names[a.uid]}
|
||||||
|
busy={busyUid === a.uid}
|
||||||
|
disabled={busyUid !== null}
|
||||||
|
pending={pending}
|
||||||
|
error={rowError?.uid === a.uid ? rowError.message : null}
|
||||||
|
onAsk={(p) => {
|
||||||
|
setRowError(null);
|
||||||
|
setPending(p);
|
||||||
}}
|
}}
|
||||||
|
onCancel={() => setPending(null)}
|
||||||
|
onReveal={reveal}
|
||||||
|
onRemove={remove}
|
||||||
/>
|
/>
|
||||||
)}
|
))}
|
||||||
|
</>
|
||||||
{!loading && !fetchError && !generated && status && !status.hasIdentity && (
|
|
||||||
<NoIdentityPanel onGenerated={(g) => setGenerated(g)} onImported={refresh} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !fetchError && !generated && status && status.hasIdentity && (
|
|
||||||
<HasIdentityPanel status={status} onReplace={refresh} />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/metazla/meta-core/internal/identity"
|
"github.com/metazla/meta-core/internal/identity"
|
||||||
@@ -88,6 +89,31 @@ type identitySignResponse struct {
|
|||||||
SigB64 string `json:"sigB64"`
|
SigB64 string `json:"sigB64"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// identitySignBatchRequest signs many payloads under one account in one call.
|
||||||
|
//
|
||||||
|
// Exists because a User Data Layer write signs exactly one record, and the bulk
|
||||||
|
// verbs above it ("mark this whole series seen") produce one record per episode:
|
||||||
|
// a 300-episode show is 300 sequential sign round trips through
|
||||||
|
// meta-watch -> meta-search -> here, which dominates the operation. The private
|
||||||
|
// key still never leaves this process; only the transport is batched.
|
||||||
|
type identitySignBatchRequest struct {
|
||||||
|
// PayloadsB64 are the canonical byte strings to sign, in order. The
|
||||||
|
// response's signatures line up index-for-index.
|
||||||
|
PayloadsB64 []string `json:"payloadsB64"`
|
||||||
|
// UID selects which account signs — same optional single-identity
|
||||||
|
// back-compat as identitySignRequest.
|
||||||
|
UID string `json:"uid,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type identitySignBatchResponse struct {
|
||||||
|
SigsB64 []string `json:"sigsB64"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxSignBatch bounds one batch. A signature is cheap but not free, and the
|
||||||
|
// request holds the account lock for its duration; a caller with more than this
|
||||||
|
// should page. Sized well above the longest real series (Supernatural, 327).
|
||||||
|
const maxSignBatch = 1000
|
||||||
|
|
||||||
type identityAEADKeyResponse struct {
|
type identityAEADKeyResponse struct {
|
||||||
AEADKeyB64 string `json:"aeadKeyB64"`
|
AEADKeyB64 string `json:"aeadKeyB64"`
|
||||||
}
|
}
|
||||||
@@ -317,6 +343,53 @@ func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleIdentitySignBatch signs every payload in the request under one account.
|
||||||
|
//
|
||||||
|
// All-or-nothing: a single bad base64 or signing failure fails the whole
|
||||||
|
// request rather than returning a short array. The caller pairs signatures with
|
||||||
|
// records by index, so a partial response would silently misalign them — and a
|
||||||
|
// misaligned signature verifies against the wrong record, which is worse than
|
||||||
|
// an error.
|
||||||
|
func (s *Server) handleIdentitySignBatch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body identitySignBatchRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := body.UID
|
||||||
|
if strings.TrimSpace(uid) == "" {
|
||||||
|
uid = r.URL.Query().Get("uid")
|
||||||
|
}
|
||||||
|
if len(body.PayloadsB64) == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "payloadsB64 is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.PayloadsB64) > maxSignBatch {
|
||||||
|
writeError(w, http.StatusBadRequest, "too many payloads in one batch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, status, slug, msg := s.resolveAccount(uid)
|
||||||
|
if status != 0 {
|
||||||
|
writeErrorSlug(w, status, slug, msg, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sigs := make([]string, 0, len(body.PayloadsB64))
|
||||||
|
for i, p := range body.PayloadsB64 {
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(p)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "payloadsB64["+strconv.Itoa(i)+"] is not valid base64")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sig, err := identity.Sign(id, raw)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sigs = append(sigs, base64.StdEncoding.EncodeToString(sig))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, identitySignBatchResponse{SigsB64: sigs})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleIdentityAEADKey(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleIdentityAEADKey(w http.ResponseWriter, r *http.Request) {
|
||||||
id, status, slug, msg := s.resolveAccount(r.URL.Query().Get("uid"))
|
id, status, slug, msg := s.resolveAccount(r.URL.Query().Get("uid"))
|
||||||
if status != 0 {
|
if status != 0 {
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ func (s *Server) setupRoutes() {
|
|||||||
s.router.HandleFunc("/api/identity/reveal", s.handleIdentityReveal).Methods("POST")
|
s.router.HandleFunc("/api/identity/reveal", s.handleIdentityReveal).Methods("POST")
|
||||||
s.router.HandleFunc("/api/identity", s.handleIdentityDelete).Methods("DELETE")
|
s.router.HandleFunc("/api/identity", s.handleIdentityDelete).Methods("DELETE")
|
||||||
s.router.HandleFunc("/api/identity/sign", s.handleIdentitySign).Methods("POST")
|
s.router.HandleFunc("/api/identity/sign", s.handleIdentitySign).Methods("POST")
|
||||||
|
s.router.HandleFunc("/api/identity/sign-batch", s.handleIdentitySignBatch).Methods("POST")
|
||||||
s.router.HandleFunc("/api/identity/aead-key", s.handleIdentityAEADKey).Methods("GET")
|
s.router.HandleFunc("/api/identity/aead-key", s.handleIdentityAEADKey).Methods("GET")
|
||||||
|
|
||||||
// Snapshot (export / import / wipe)
|
// Snapshot (export / import / wipe)
|
||||||
@@ -206,6 +207,7 @@ func (s *Server) setupRoutes() {
|
|||||||
// Continue Watching, ratings, profile). See internal/api/udl.go.
|
// Continue Watching, ratings, profile). See internal/api/udl.go.
|
||||||
s.router.HandleFunc("/api/udl/record", s.handleUDLRecordGet).Methods("GET")
|
s.router.HandleFunc("/api/udl/record", s.handleUDLRecordGet).Methods("GET")
|
||||||
s.router.HandleFunc("/api/udl/record", s.handleUDLRecordPut).Methods("PUT")
|
s.router.HandleFunc("/api/udl/record", s.handleUDLRecordPut).Methods("PUT")
|
||||||
|
s.router.HandleFunc("/api/udl/records", s.handleUDLRecordsPut).Methods("PUT")
|
||||||
s.router.HandleFunc("/api/udl/user/{uid}/key/{key}", s.handleUDLUserKey).Methods("GET")
|
s.router.HandleFunc("/api/udl/user/{uid}/key/{key}", s.handleUDLUserKey).Methods("GET")
|
||||||
s.router.HandleFunc("/api/udl/user/{uid}/cid/{cid}", s.handleUDLUserCid).Methods("GET")
|
s.router.HandleFunc("/api/udl/user/{uid}/cid/{cid}", s.handleUDLUserCid).Methods("GET")
|
||||||
s.router.HandleFunc("/api/udl/cid/{cid}/users", s.handleUDLCidUsers).Methods("GET")
|
s.router.HandleFunc("/api/udl/cid/{cid}/users", s.handleUDLCidUsers).Methods("GET")
|
||||||
|
|||||||
+106
-2
@@ -42,12 +42,50 @@ type UDLPutResponse struct {
|
|||||||
Accepted bool `json:"accepted"`
|
Accepted bool `json:"accepted"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UDLPutBatchRequest is the body of PUT /api/udl/records — many cells, one
|
||||||
|
// request. Each entry is exactly a UDLPutRequest, so a client can build the
|
||||||
|
// same signed record for either endpoint.
|
||||||
|
type UDLPutBatchRequest struct {
|
||||||
|
Records []UDLPutRequest `json:"records"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDLPutBatchResult is one cell's outcome. Accepted=false with an empty Error
|
||||||
|
// means the write was stale (the single-record endpoint's 409), which in a
|
||||||
|
// batch is an ordinary outcome rather than a failure.
|
||||||
|
type UDLPutBatchResult struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Accepted bool `json:"accepted"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDLPutBatchResponse mirrors the request order index-for-index.
|
||||||
|
type UDLPutBatchResponse struct {
|
||||||
|
Results []UDLPutBatchResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxUDLBatch bounds one batch. Each record is a Redis round trip while the
|
||||||
|
// request is held open; sized to match the sign batch on the other side of the
|
||||||
|
// same operation (see maxSignBatch).
|
||||||
|
const maxUDLBatch = 1000
|
||||||
|
|
||||||
// UDLRecordResponse is the response of GET /api/udl/record.
|
// UDLRecordResponse is the response of GET /api/udl/record.
|
||||||
type UDLRecordResponse struct {
|
type UDLRecordResponse struct {
|
||||||
Record string `json:"record"`
|
Record string `json:"record"`
|
||||||
Version int64 `json:"version"`
|
Version int64 `json:"version"`
|
||||||
Ts int64 `json:"ts"`
|
Ts int64 `json:"ts"`
|
||||||
Exists bool `json:"exists"`
|
Exists bool `json:"exists"`
|
||||||
|
// Value is the plaintext JSON scalar stored beside the opaque record, and
|
||||||
|
// is present only for PUBLIC-tier keys (like, rating, profile:name,
|
||||||
|
// profile:avatar). json.RawMessage rather than string because the field is
|
||||||
|
// generic over keys — a bool for `like`, a number for `rating`, a string
|
||||||
|
// for a profile name (see aggregatePublicValues) — and because it is the
|
||||||
|
// exact type UDLPutRequest.Value uses, so a value written through PUT reads
|
||||||
|
// back byte-identical.
|
||||||
|
//
|
||||||
|
// Absent, never null, when there is no plaintext: private-tier cells,
|
||||||
|
// tombstones and never-written cells must not read as an empty public
|
||||||
|
// value. Readers key off presence, not `exists`.
|
||||||
|
Value *json.RawMessage `json:"value,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UDLAggregate mirrors meta-watch's Aggregate wire shape exactly (snake_case
|
// UDLAggregate mirrors meta-watch's Aggregate wire shape exactly (snake_case
|
||||||
@@ -79,12 +117,20 @@ func (s *Server) handleUDLRecordGet(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, UDLRecordResponse{
|
resp := UDLRecordResponse{
|
||||||
Record: cell.Record,
|
Record: cell.Record,
|
||||||
Version: cell.Version,
|
Version: cell.Version,
|
||||||
Ts: cell.Ts,
|
Ts: cell.Ts,
|
||||||
Exists: exists,
|
Exists: exists,
|
||||||
})
|
}
|
||||||
|
// json.Valid guards the encoder: a malformed `value` hash field (hand-edited
|
||||||
|
// Redis, a future writer bug) would otherwise fail mid-encode and emit a
|
||||||
|
// truncated body instead of a readable error.
|
||||||
|
if exists && cell.HasValue && json.Valid([]byte(cell.Value)) {
|
||||||
|
raw := json.RawMessage(cell.Value)
|
||||||
|
resp.Value = &raw
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUDLRecordPut handles PUT /api/udl/record. Version-gated: a stale write
|
// handleUDLRecordPut handles PUT /api/udl/record. Version-gated: a stale write
|
||||||
@@ -124,6 +170,64 @@ func (s *Server) handleUDLRecordPut(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, UDLPutResponse{Accepted: true})
|
writeJSON(w, http.StatusOK, UDLPutResponse{Accepted: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleUDLRecordsPut handles PUT /api/udl/records — the batch form of
|
||||||
|
// handleUDLRecordPut.
|
||||||
|
//
|
||||||
|
// Exists for the bulk verbs above this layer ("mark this whole series seen"),
|
||||||
|
// where one user action produces one cell per episode. Each cell still goes
|
||||||
|
// through the same version-gated Lua upsert, so the CRDT semantics are
|
||||||
|
// identical — this batches the transport only.
|
||||||
|
//
|
||||||
|
// **Not transactional, and deliberately not 409.** A per-record `accepted` flag
|
||||||
|
// replaces the single-record conflict status: in a batch, one stale cell is a
|
||||||
|
// normal outcome (another device already marked that episode) and must not fail
|
||||||
|
// the 299 cells beside it. Callers read the array, not the status code.
|
||||||
|
func (s *Server) handleUDLRecordsPut(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req UDLPutBatchRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Records) == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "records is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Records) > maxUDLBatch {
|
||||||
|
writeError(w, http.StatusBadRequest, "too many records in one batch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results := make([]UDLPutBatchResult, 0, len(req.Records))
|
||||||
|
for _, rec := range req.Records {
|
||||||
|
if rec.Uid == "" || rec.Cid == "" || rec.Key == "" || rec.Record == "" {
|
||||||
|
results = append(results, UDLPutBatchResult{
|
||||||
|
Key: rec.Key,
|
||||||
|
Error: "uid, cid, key and record are required",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
publicValue := ""
|
||||||
|
hasValue := false
|
||||||
|
if rec.Value != nil {
|
||||||
|
publicValue = string(*rec.Value)
|
||||||
|
hasValue = true
|
||||||
|
}
|
||||||
|
accepted, err := s.storage.UDLUpsertIfNewer(rec.Uid, rec.Cid, rec.Key, rec.Version, rec.Ts, rec.Record, publicValue, hasValue, rec.Tombstone)
|
||||||
|
if err != nil {
|
||||||
|
// A storage error is per-record here rather than fatal for the whole
|
||||||
|
// batch: the cells already written stay written, and reporting which
|
||||||
|
// ones failed lets the caller retry exactly those.
|
||||||
|
results = append(results, UDLPutBatchResult{Key: rec.Key, Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, UDLPutBatchResult{Key: rec.Key, Accepted: accepted})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, UDLPutBatchResponse{Results: results})
|
||||||
|
}
|
||||||
|
|
||||||
// handleUDLUserKey handles GET /api/udl/user/{uid}/key/{key}
|
// handleUDLUserKey handles GET /api/udl/user/{uid}/key/{key}
|
||||||
// (active_for_user_key — My List, Continue Watching).
|
// (active_for_user_key — My List, Continue Watching).
|
||||||
func (s *Server) handleUDLUserKey(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUDLUserKey(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/alicebob/miniredis/v2"
|
||||||
|
"github.com/metazla/meta-core/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newUDLTestServer returns a Server wired to an in-memory Redis.
|
||||||
|
//
|
||||||
|
// The struct is built directly rather than through NewServer: NewServer boots
|
||||||
|
// the file watcher, mounts manager, watchers poller and WebDAV handler, none of
|
||||||
|
// which the UDL handlers touch — they use s.storage and nothing else.
|
||||||
|
func newUDLTestServer(t *testing.T) *Server {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
mr := miniredis.RunT(t)
|
||||||
|
c := storage.NewClient("")
|
||||||
|
if err := c.Connect("redis://" + mr.Addr()); err != nil {
|
||||||
|
t.Fatalf("connect to miniredis: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = c.Close() })
|
||||||
|
return &Server{storage: c}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRecord drives handleUDLRecordGet and decodes the body into raw JSON
|
||||||
|
// fields, so a test can assert on the *encoding* of `value` (is it a quoted
|
||||||
|
// string? is the key there at all?) rather than on what a typed struct would
|
||||||
|
// have coerced it into.
|
||||||
|
func getRecord(t *testing.T, s *Server, uid, cid, key string) (int, map[string]json.RawMessage) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/udl/record?uid="+uid+"&cid="+cid+"&key="+key, nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
s.handleUDLRecordGet(rr, req)
|
||||||
|
|
||||||
|
var body map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("decode response %q: %v", rr.Body.String(), err)
|
||||||
|
}
|
||||||
|
return rr.Code, body
|
||||||
|
}
|
||||||
|
|
||||||
|
// The plaintext value of a PUBLIC-tier cell must come back over the wire, and
|
||||||
|
// come back *as it was written* — a JSON string stays a quoted string, a bool
|
||||||
|
// stays a bool.
|
||||||
|
//
|
||||||
|
// Both halves matter. Without the value at all, the meta-core dashboard can
|
||||||
|
// only ever show 44-character base58 uids: display names live in the User Data
|
||||||
|
// Layer as the public cell (uid, "self", "profile:name"), and the plaintext
|
||||||
|
// sits in Redis where the storage layer has always read it — the handler just
|
||||||
|
// used to drop it. And the type has to stay json.RawMessage: this endpoint is
|
||||||
|
// generic over keys, so a `*string` would mangle `like` (bool) and `rating`
|
||||||
|
// (number) while looking correct for names.
|
||||||
|
func TestUDLRecordGet_ReturnsPublicValue(t *testing.T) {
|
||||||
|
s := newUDLTestServer(t)
|
||||||
|
|
||||||
|
const uid = "zAlice"
|
||||||
|
if ok, err := s.storage.UDLUpsertIfNewer(uid, "self", "profile:name", 1, 100, "cmVj", `"Ada Lovelace"`, true, false); err != nil || !ok {
|
||||||
|
t.Fatalf("seed name: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if ok, err := s.storage.UDLUpsertIfNewer(uid, "bagcsaaa", "like", 1, 100, "cmVj", `true`, true, false); err != nil || !ok {
|
||||||
|
t.Fatalf("seed like: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, body := getRecord(t, s, uid, "self", "profile:name")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if got := string(body["value"]); got != `"Ada Lovelace"` {
|
||||||
|
t.Fatalf("value = %s, want a quoted JSON string", got)
|
||||||
|
}
|
||||||
|
var name string
|
||||||
|
if err := json.Unmarshal(body["value"], &name); err != nil || name != "Ada Lovelace" {
|
||||||
|
t.Fatalf("value should unmarshal to the plain name, got %q err=%v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A boolean key survives unquoted — the assertion a *string field fails.
|
||||||
|
if _, body = getRecord(t, s, uid, "bagcsaaa", "like"); string(body["value"]) != "true" {
|
||||||
|
t.Fatalf("like value = %s, want the bare JSON literal true", body["value"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cell with no plaintext must omit `value` entirely rather than send null.
|
||||||
|
//
|
||||||
|
// That is the shape of every private-tier key (seek, watched, card, localpath —
|
||||||
|
// their real value rides encrypted inside the opaque record) and of every
|
||||||
|
// tombstone. A reader keys off presence: "there is no plaintext here" and "the
|
||||||
|
// plaintext is null" are different facts, and a name cleared by its owner
|
||||||
|
// leaves the cell existing with no value, which must not read as a name.
|
||||||
|
func TestUDLRecordGet_OmitsValueForPrivateCell(t *testing.T) {
|
||||||
|
s := newUDLTestServer(t)
|
||||||
|
|
||||||
|
const uid = "zBob"
|
||||||
|
if ok, err := s.storage.UDLUpsertIfNewer(uid, "bagcsaaa", "seek", 1, 100, "ZW5jcnlwdGVk", "", false, false); err != nil || !ok {
|
||||||
|
t.Fatalf("seed private cell: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, body := getRecord(t, s, uid, "bagcsaaa", "seek")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if _, ok := body["value"]; ok {
|
||||||
|
t.Fatalf("private cell must omit `value`, got %s", body["value"])
|
||||||
|
}
|
||||||
|
if string(body["exists"]) != "true" {
|
||||||
|
t.Fatalf("exists = %s, want true", body["exists"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cell that was never written answers 200 with exists:false and no value —
|
||||||
|
// not a 404, and not an empty-string value that would render as a blank name.
|
||||||
|
func TestUDLRecordGet_MissingCellHasNoValue(t *testing.T) {
|
||||||
|
s := newUDLTestServer(t)
|
||||||
|
|
||||||
|
code, body := getRecord(t, s, "zNobody", "self", "profile:name")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if string(body["exists"]) != "false" {
|
||||||
|
t.Fatalf("exists = %s, want false", body["exists"])
|
||||||
|
}
|
||||||
|
if _, ok := body["value"]; ok {
|
||||||
|
t.Fatalf("missing cell must omit `value`, got %s", body["value"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// putRecords drives handleUDLRecordsPut with a raw JSON body and decodes the
|
||||||
|
// per-record results.
|
||||||
|
func putRecords(t *testing.T, s *Server, body string) (int, UDLPutBatchResponse) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/udl/records", strings.NewReader(body))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
s.handleUDLRecordsPut(rr, req)
|
||||||
|
|
||||||
|
var out UDLPutBatchResponse
|
||||||
|
if rr.Code == http.StatusOK {
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("decode response %q: %v", rr.Body.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rr.Code, out
|
||||||
|
}
|
||||||
|
|
||||||
|
// **The load-bearing property of the batch endpoint: one stale cell does not
|
||||||
|
// fail the batch.**
|
||||||
|
//
|
||||||
|
// The single-record PUT answers a stale version with 409, which is right when
|
||||||
|
// the caller wrote one thing. In a batch it would be wrong: bulk verbs ("mark
|
||||||
|
// this whole series seen") routinely include episodes another device already
|
||||||
|
// marked, and 409-ing there would abandon every cell after it. So staleness is
|
||||||
|
// reported per record as accepted:false, with 200 on the request itself.
|
||||||
|
func TestUDLRecordsPut_StaleCellDoesNotFailTheBatch(t *testing.T) {
|
||||||
|
s := newUDLTestServer(t)
|
||||||
|
|
||||||
|
const uid = "zCarol"
|
||||||
|
const cid = "bagdsaaaseries"
|
||||||
|
// Seed s01e01 at version 4 — the batch below tries to write it at 2.
|
||||||
|
if ok, err := s.storage.UDLUpsertIfNewer(uid, cid, "seen:s01e01", 4, 100, "b2xk", "", false, false); err != nil || !ok {
|
||||||
|
t.Fatalf("seed: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, out := putRecords(t, s, `{"records":[
|
||||||
|
{"uid":"`+uid+`","cid":"`+cid+`","key":"seen:s01e01","version":2,"ts":200,"record":"c3RhbGU"},
|
||||||
|
{"uid":"`+uid+`","cid":"`+cid+`","key":"seen:s01e02","version":1,"ts":200,"record":"ZnJlc2g"}
|
||||||
|
]}`)
|
||||||
|
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 — a stale cell is an outcome, not an error", code)
|
||||||
|
}
|
||||||
|
if len(out.Results) != 2 {
|
||||||
|
t.Fatalf("results = %d, want 2 (one per record, in order)", len(out.Results))
|
||||||
|
}
|
||||||
|
if out.Results[0].Accepted || out.Results[0].Error != "" {
|
||||||
|
t.Fatalf("stale cell = %+v, want accepted:false with no error", out.Results[0])
|
||||||
|
}
|
||||||
|
if !out.Results[1].Accepted {
|
||||||
|
t.Fatalf("fresh cell = %+v, want accepted:true", out.Results[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fresh cell really landed, and the stale one did not overwrite.
|
||||||
|
if _, body := getRecord(t, s, uid, cid, "seen:s01e02"); string(body["exists"]) != "true" {
|
||||||
|
t.Fatalf("s01e02 was not written")
|
||||||
|
}
|
||||||
|
if _, body := getRecord(t, s, uid, cid, "seen:s01e01"); string(body["version"]) != "4" {
|
||||||
|
t.Fatalf("s01e01 version = %s, want 4 (the stale write must not apply)", body["version"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A malformed record is reported in its own slot rather than rejecting the
|
||||||
|
// request, for the same reason: the caller retries that cell, not the batch.
|
||||||
|
func TestUDLRecordsPut_ReportsBadRecordPerSlot(t *testing.T) {
|
||||||
|
s := newUDLTestServer(t)
|
||||||
|
|
||||||
|
code, out := putRecords(t, s, `{"records":[
|
||||||
|
{"uid":"zDan","cid":"bagdsaaa","key":"","version":1,"ts":1,"record":"eA"},
|
||||||
|
{"uid":"zDan","cid":"bagdsaaa","key":"seen:s01e01","version":1,"ts":1,"record":"eA"}
|
||||||
|
]}`)
|
||||||
|
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", code)
|
||||||
|
}
|
||||||
|
if out.Results[0].Error == "" {
|
||||||
|
t.Fatalf("record with no key must report an error, got %+v", out.Results[0])
|
||||||
|
}
|
||||||
|
if !out.Results[1].Accepted {
|
||||||
|
t.Fatalf("the valid record beside it must still land, got %+v", out.Results[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty batch is a client bug, not a no-op: it almost always means the
|
||||||
|
// caller built its record list wrong, and answering 200 with an empty array
|
||||||
|
// would hide that.
|
||||||
|
func TestUDLRecordsPut_RejectsEmptyBatch(t *testing.T) {
|
||||||
|
s := newUDLTestServer(t)
|
||||||
|
|
||||||
|
if code, _ := putRecords(t, s, `{"records":[]}`); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user