diff --git a/dashboard/src/pages/Identity.tsx b/dashboard/src/pages/Identity.tsx index 20b7723..57d48a4 100644 --- a/dashboard/src/pages/Identity.tsx +++ b/dashboard/src/pages/Identity.tsx @@ -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 — -// it is shown exactly once at generation time (see GeneratedKeyPanel). -interface IdentityStatus { - hasIdentity: boolean; - uid?: string; - curve?: string; - createdAt?: number; -} +// Admin console for meta-core's account keystore. +// +// meta-core stores one secp256k1 keypair per file under +// {META_CORE_PATH}/identity/accounts/.json and has NO notion of a current +// user — which account is "you" is decided by whichever client signs in +// (meta-watch). So this page lists accounts; it never speaks of an active one. +// +// 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; curve: string; 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 = { background: '#16213e', borderRadius: '8px', @@ -59,6 +81,21 @@ const monoStyle: React.CSSProperties = { 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 { if (!unixSec) return ''; try { @@ -79,25 +116,76 @@ async function errorMessage(res: Response, fallback: string): Promise { return `${fallback} (HTTP ${res.status})`; } -function GeneratedKeyPanel({ data, onDismiss }: { data: GeneratedKey; onDismiss: () => void }) { - const [copied, setCopied] = useState(false); - const copy = async () => { - try { - await navigator.clipboard.writeText(data.privateKeyHex); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - // Clipboard API blocked (non-HTTPS context, etc.) — fall back to selection. - const sel = window.getSelection(); +// Copy text, falling back to selecting the element that displays it. +// +// 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 { + try { + await navigator.clipboard.writeText(text); + } catch { + const sel = window.getSelection(); + const el = document.getElementById(fallbackElementId); + if (sel && el) { const range = document.createRange(); - const el = document.getElementById('mm-privkey-box'); - if (sel && el) { - range.selectNodeContents(el); - sel.removeAllRanges(); - sel.addRange(range); - } + range.selectNodeContents(el); + sel.removeAllRanges(); + sel.addRange(range); } + } +} + +async function fetchAccounts(): Promise { + 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 { + 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 (
- This private key is shown ONCE. Save it now in a password manager. - It will never be displayed again — there is no recovery path. Losing it means losing access - to every record signed under this identity. + {data.kind === 'generated' ? ( + <> + This private key is shown ONCE. Save it now in a password manager. + You can ask for it again later with Reveal private key, but only for as long + as this account exists on this node — delete it and the key is gone for good. + + ) : ( + <> + This is the live private key for an existing account. 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. + + )}

uid (public):

{data.uid}
@@ -124,36 +222,40 @@ function GeneratedKeyPanel({ data, onDismiss }: { data: GeneratedKey; onDismiss: {copied ? 'Copied' : 'Copy private key'}
); } -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, onImported, }: { - onGenerated: (g: GeneratedKey) => void; + onGenerated: (s: SecretKeyView) => void; onImported: () => void; }) { const [busy, setBusy] = useState(false); const [importing, setImporting] = useState(false); const [importHex, setImportHex] = useState(''); const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); const generate = async () => { setBusy(true); setError(null); + setNotice(null); try { const res = await fetch('/api/identity/generate', { method: 'POST' }); if (!res.ok) { setError(await errorMessage(res, 'Generate failed')); return; } - const data = (await res.json()) as GeneratedKey; - onGenerated(data); + const data = (await res.json()) as { uid: string; privateKeyHex: string }; + onGenerated({ uid: data.uid, privateKeyHex: data.privateKeyHex, kind: 'generated' }); } catch (e) { setError(`Generate failed: ${e instanceof Error ? e.message : String(e)}`); } finally { @@ -165,6 +267,7 @@ function NoIdentityPanel({ e.preventDefault(); setBusy(true); setError(null); + setNotice(null); try { const res = await fetch('/api/identity/import', { method: 'POST', @@ -175,6 +278,15 @@ function NoIdentityPanel({ setError(await errorMessage(res, 'Import failed')); 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(''); setImporting(false); onImported(); @@ -187,15 +299,16 @@ function NoIdentityPanel({ return (
-

No identity yet

-

- The User Data Layer needs a signing key for your ratings, watch progress, and "liked" flags - to be attributed to you across devices. Nothing happens automatically — generate one below, - or import a key you already have. +

Add an account

+

+ Generating mints a fresh secp256k1 keypair here. Importing adds a key created elsewhere — + it is also how an existing account is moved onto this node, so importing a key the + keystore already holds succeeds and changes nothing.

- {error && ( -

- {error} + {error &&

{error}

} + {notice && ( +

+ {notice}

)}
@@ -235,71 +348,125 @@ function NoIdentityPanel({ ); } -function HasIdentityPanel({ status, onReplace }: { status: IdentityStatus; onReplace: () => void }) { - const [confirming, setConfirming] = useState(false); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); +function AccountCard({ + account, + name, + 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 () => { - setBusy(true); - setError(null); - try { - 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); - } + const copyUid = async () => { + await copyToClipboard(account.uid, uidBoxId); + setCopied(true); + setTimeout(() => setCopied(false), 2000); }; return (
-

Active identity

-

uid:

-
{status.uid}
-

- Curve: {status.curve} · Created: {formatCreatedAt(status.createdAt)} -

-

- The private key lives at /meta-core/identity/identity.json on this volume and - is never returned through the API after generation. Other services on this stack (e.g. - meta-watch) sign records by calling POST /api/identity/sign. -

- {error && ( -

- {error} -

- )} - {!confirming ? ( - - ) : ( -
-

- 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. Make sure you have backed up the key - first. +

+
+

+ {name ?? 'Unnamed account'} +

+
+ {account.uid} +
+

+ Curve: {account.curve} · Created: {formatCreatedAt(account.createdAt)}

-
+
+ - + +
+
+ + {error &&

{error}

} + + {pending?.uid === account.uid && pending.kind === 'reveal' && ( +
+

+ Show the private key for {label}? +

+

+ The key is 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. +

+ + +
+ )} + + {pending?.uid === account.uid && pending.kind === 'remove' && ( +
+

+ Remove {label} from this node? +

+
    +
  • + The key is gone for good. The uid is 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. +
  • +
  • + Nothing is retracted. Records already signed under this uid keep + verifying on every peer that replicated them. Deleting a key is not a recall. +
  • +
  • + Its data stays, but goes read-only. 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. +
  • +
  • + This signs nobody out elsewhere. Any device that holds this key + still holds it, and can import it back here at any time. +
  • +
+ +
@@ -309,66 +476,183 @@ function HasIdentityPanel({ status, onReplace }: { status: IdentityStatus; onRep } export default function Identity() { - const [status, setStatus] = useState(null); - const [generated, setGenerated] = useState(null); + const [accounts, setAccounts] = useState(null); + const [names, setNames] = useState>({}); const [loading, setLoading] = useState(true); const [fetchError, setFetchError] = useState(null); + const [secret, setSecret] = useState(null); + const [pending, setPending] = useState(null); + const [busyUid, setBusyUid] = useState(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 gen = ++reqGen.current; setLoading(true); setFetchError(null); + let list: Account[]; try { - const res = await fetch('/api/identity'); - if (!res.ok) { - setFetchError(await errorMessage(res, 'Failed to load identity status')); - return; - } - setStatus((await res.json()) as IdentityStatus); + list = await fetchAccounts(); } catch (e) { - setFetchError(`Failed to load identity status: ${e instanceof Error ? e.message : String(e)}`); - } finally { + if (gen !== reqGen.current) return; + setFetchError(e instanceof Error ? e.message : String(e)); setLoading(false); + return; } + if (gen !== reqGen.current) return; + // Render the list before resolving names, so a down Redis delays nothing. + setAccounts(list); + setLoading(false); + + const settled = await Promise.allSettled(list.map((a) => fetchDisplayName(a.uid))); + if (gen !== reqGen.current) return; + const next: Record = {}; + 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(() => { 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 (
-

Identity

+
+

Identity

+ +

- Signing key for the User Data Layer (per-user ratings, progress, "liked" flags). One - identity per node. The same key on another device makes that device count as "you". + Every signing key in this node's keystore. meta-core has no "current user" — it stores + accounts, and which one is you is decided by whichever client signs in (e.g. + meta-watch). Keys live one JSON file per account under{' '} + {'{META_CORE_PATH}'}/identity/accounts/<uid>.json, and are used to sign + User Data Layer records (ratings, watch progress, "liked" flags).

+
+

+ Admin page — no authorisation happens here. /api/identity/*{' '} + asks no questions: anything that can reach this API can list, create, reveal or delete{' '} + any 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. +

+
+ {loading &&
Loading…
} + {fetchError && (
-

{fetchError}

+

{fetchError}

)} - {!loading && !fetchError && generated && ( - { - setGenerated(null); - refresh(); - }} - /> - )} + {!loading && !fetchError && secret && } - {!loading && !fetchError && !generated && status && !status.hasIdentity && ( - setGenerated(g)} onImported={refresh} /> - )} + {showList && ( + <> + setSecret(s)} onImported={refresh} /> - {!loading && !fetchError && !generated && status && status.hasIdentity && ( - +

+ Accounts ({accounts?.length ?? 0}) +

+ + {accounts && accounts.length === 0 && ( +
+

+ No accounts in this keystore. Accounts are normally created by a client when + someone signs up; use Add an account above if you are bootstrapping this + node. +

+
+ )} + + {accounts?.map((a) => ( + { + setRowError(null); + setPending(p); + }} + onCancel={() => setPending(null)} + onReveal={reveal} + onRemove={remove} + /> + ))} + )}
); diff --git a/internal/api/udl.go b/internal/api/udl.go index 7232ab9..ae66d8b 100644 --- a/internal/api/udl.go +++ b/internal/api/udl.go @@ -48,6 +48,18 @@ type UDLRecordResponse struct { Version int64 `json:"version"` Ts int64 `json:"ts"` 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 @@ -79,12 +91,20 @@ func (s *Server) handleUDLRecordGet(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err.Error()) return } - writeJSON(w, http.StatusOK, UDLRecordResponse{ + resp := UDLRecordResponse{ Record: cell.Record, Version: cell.Version, Ts: cell.Ts, 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 diff --git a/internal/api/udl_test.go b/internal/api/udl_test.go new file mode 100644 index 0000000..a4645d9 --- /dev/null +++ b/internal/api/udl_test.go @@ -0,0 +1,130 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "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"]) + } +}