Compare commits

..
31 Commits
Author SHA1 Message Date
Worph a66ef51b86 CI: push to ipcr-push-85-17-246-67.ip.nsl.sh (the box's cert no longer covers nip.io)
Build and Push Docker Image / build (push) Successful in 6m9s
2026-11-04 12:00:49 +00:00
Worph f367ed037f Report inbox: /api/reports in its own bounded reports.db, dashboard Reports page (list, filter, triage, copy as text)
Build and Push Docker Image / build (push) Failing after 1m20s
2026-11-04 11:43:01 +00:00
Worph cef9dba59e migrate
Build and Push Docker Image / build (push) Failing after 2m28s
2026-11-04 11:55:00 +01:00
Worph 9ccaff90d8 CI: publish a Gitea release naming the IPCR image and digest on v* tags
Build and Push Docker Image / build (push) Successful in 1m7s
2026-10-25 19:02:36 +00:00
Worph 84af298792 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
2026-10-25 17:36:00 +00:00
Manielle b0adefb97b CI accepts four-part tags (v1.2.3.1) for fixes
Build and Push Docker Image / build (push) Successful in 4m20s
2026-10-25 14:22:01 +01:00
Worph 5369dc4b9c docs: search plan + counters for v1.2.0; tools/search-parity (parity + latency vs a brute-force reference)
Build and Push Docker Image / build (push) Successful in 2m50s
2026-10-19 21:59:36 +00:00
Worph fb4bdad61b v1.2.0: skip unchanged writes, transactional schema/root counters, snapshot replay for meta:events 0-0 (drops boot republish), collect-once search driver
Build and Push Docker Image / build (push) Successful in 7m38s
2026-10-19 21:41:55 +00:00
Worph dc5cbdfb9b replace Redis with an embedded SQLite string->string KV store (group commit, in-tx events, SQL search)
Build and Push Docker Image / build (push) Successful in 2m44s
2026-10-19 20:51:00 +00:00
Worph c53146e514 rank vectors: provider-file-tachiyomi
Build and Push Docker Image / build (push) Successful in 2m28s
2026-10-19 18:45:19 +00:00
Worph bb04a3f4dc release (first build on gitea-watch / IPCR)
Build and Push Docker Image / build (push) Successful in 1m0s
2026-10-13 11:02:23 +00:00
Worph 36eb3543b9 gitea workflow: push to IPCR's direct host (Cloudflare 100 MB cap) [skip ci] 2026-10-13 10:02:01 +00:00
Worph 155fd83392 gitea workflow: pull path via metadec.eth
Build and Push Docker Image / build (push) Successful in 56s
2026-10-12 18:30:11 +00:00
Worph cf63a8da68 gitea workflow: publish to IPCR (metadec.eth) instead of ghcr
Build and Push Docker Image / build (push) Successful in 55s
2026-10-12 18:26:08 +00:00
Worph 27ed45999b beacon v2 discovery: advertise metamesh.core + service resources on 239.255.99.1:9099, /api/neighbors ?cap= filter
Build and Push Docker Image / build (push) Failing after 14s
2026-10-04 11:51:03 +00:00
Worph 9254f043a4 README: sync with current code 2026-10-03 11:51:55 +00:00
Worph ebc894fa42 deleting a cids/ member also drops its reverse alias
Build and Push Docker Image / build (push) Failing after 14s
2026-09-16 17:10:00 +00:00
Worph 515ec04369 bugfix and dashboard improvement
Build and Push Docker Image / build (push) Failing after 13s
2026-09-14 19:04:23 +02:00
Worph 0403a6f3bc replace file discovery by udp discovery
Build and Push Docker Image / build (push) Failing after 13s
2026-09-14 11:24:46 +02:00
Worph 355c666183 chnage service discovery and identity 2026-09-13 19:48:52 +02:00
Worph 0cccceccd9 align with metadata standard
Build and Push Docker Image / build (push) Failing after 13s
2026-09-13 15:57:30 +02:00
Worph 40b553016c cid vector
Build and Push Docker Image / build (push) Failing after 13s
2026-09-13 14:36:50 +02:00
Worph 4894810fd4 new file tuple API
Build and Push Docker Image / build (push) Failing after 13s
2026-09-13 12:01:33 +02:00
Worph ac21315255 improved cid resolution
Build and Push Docker Image / build (push) Failing after 13s
2026-09-12 21:37:38 +02:00
Worph ec1b227631 improved search 2026-09-12 16:37:51 +02:00
Worph 8f4e08b6f1 rework domain field
Build and Push Docker Image / build (push) Failing after 13s
2026-09-02 00:07:02 +02:00
Worph 0c4e3a2227 align to metadata keys doc
Build and Push Docker Image / build (push) Failing after 13s
2026-08-30 19:52:36 +02:00
Worph 1f41ea0166 bugfix scroll
Build and Push Docker Image / build (push) Failing after 14s
2026-08-25 18:09:44 +02:00
Worph 5efdd2c07d improved identity management 2026-08-25 17:48:23 +02:00
Worph 6ae87a2cdb audio support widening 2026-08-21 18:23:20 +02:00
Worph 604e718cdc add identity management
Build and Push Docker Image / build (push) Failing after 13s
2026-08-06 11:03:50 +02:00
136 changed files with 17884 additions and 8439 deletions
+129
View File
@@ -0,0 +1,129 @@
# Gitea twin of .github/workflows/docker-publish.yml. Gitea runs only .gitea/workflows/ once this
# folder exists, and GitHub never reads it.
#
# Publishes to IPCR on watch.nsl.sh instead of ghcr.io:
# push ipcr-push-85-17-246-67.ip.nsl.sh/metadec.eth/<repo>:<tag> docker login as metadec.eth with the
# IPCR_PUSH_TOKEN secret (the metadec.eth namespace token from the IPCR dashboard)
# pull ipcr-watch.nsl.sh/ipns/metadec.eth/metadec.eth/<repo>:<tag> anonymous
# The push goes to ipcr-push-85-17-246-67.ip.nsl.sh, the box's own address, not ipcr-push-watch.nsl.sh: Cloudflare fronts
# *.watch.nsl.sh and refuses any request over 100 MB (413), which a large image layer is.
# That host's certificate is signed by the box's private Mesh Router CA, given to buildkit from
# the IPCR_CA_PEM repo variable. docker login would make the job's Docker daemon check the
# certificate without that CA, so the login is written straight into ~/.docker/config.json.
# The runner's job image (node:22-bookworm) has no docker CLI, so the static one is
# installed first; buildx installs itself. No `type=gha` cache: the runner has none.
name: Build and Push Docker Image
on:
push:
branches:
- main
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Tag for the image (optional, defaults to sha)'
required: false
type: string
env:
REGISTRY: ipcr-push-85-17-246-67.ip.nsl.sh
IMAGE_NAME: metadec.eth/${{ github.event.repository.name }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write # the release step below
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Docker CLI
run: curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-28.5.1.tgz | tar -xz -C /usr/local/bin --strip-components=1 docker/docker
- name: Trust the Mesh Router CA
run: printf '%s\n' "$IPCR_CA_PEM" > /tmp/ipcr-ca.pem
env:
IPCR_CA_PEM: ${{ vars.IPCR_CA_PEM }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
version: latest
buildkitd-config-inline: |
[registry."${{ env.REGISTRY }}"]
ca = ["/tmp/ipcr-ca.pem"]
- name: Log in to IPCR
env:
IPCR_PUSH_TOKEN: ${{ secrets.IPCR_PUSH_TOKEN }}
run: |
mkdir -p ~/.docker
printf '{"auths":{"%s":{"auth":"%s"}}}\n' "$REGISTRY" "$(printf 'metadec.eth:%s' "$IPCR_PUSH_TOKEN" | base64 -w0)" > ~/.docker/config.json
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
# A fix to release 1.2.3 is tagged v1.2.3.1: four numbers are not semver, so
# the pattern above makes no tag for it; this one does (image tag 1.2.3.1).
type=match,pattern=v(\d+\.\d+\.\d+\.\d+),group=1
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }}
- name: Build and push Docker image
id: build
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Gitea's Packages tab only lists images in Gitea's own registry, so a v* tag gets a
# release instead, naming the image it shipped. The digest line is the one to trust:
# ipcr 1.7.1 can drop some of the tags pushed together (see the tag list).
- name: Publish the Gitea release
if: startsWith(github.ref, 'refs/tags/v')
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ github.ref_name }}
SHA: ${{ github.sha }}
DIGEST: ${{ steps.build.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
PULL: ipcr-watch.nsl.sh/ipns/metadec.eth/${{ env.IMAGE_NAME }}
run: |
node -e '
const e = process.env, ver = e.TAG.replace(/^v/, "");
const tags = e.TAGS.split("\n").filter(Boolean).map(t => "`" + t.split(":").pop() + "`");
const body = [
"**Image** (anonymous pull):",
"```", `docker pull ${e.PULL}:${ver}`, "```",
"Pinned by digest:",
"```", `${e.PULL}@${e.DIGEST}`, "```",
`Tags pushed: ${tags.join(", ")} · commit ${e.SHA.slice(0, 7)}`,
].join("\n");
console.log(JSON.stringify({ tag_name: e.TAG, name: e.TAG, body }));
' > /tmp/release.json
auth="Authorization: token $TOKEN"
code=$(curl -s -o /tmp/existing.json -w '%{http_code}' -H "$auth" "$API/releases/tags/$TAG")
if [ "$code" = 200 ]; then
id=$(node -p 'require("/tmp/existing.json").id')
method=PATCH url="$API/releases/$id"
else
method=POST url="$API/releases"
fi
code=$(curl -s -o /tmp/out.json -w '%{http_code}' -X "$method" -H "$auth" \
-H 'Content-Type: application/json' --data @/tmp/release.json "$url")
case "$code" in 2*) echo "release $TAG: $method $code" ;; *) cat /tmp/out.json; exit 1 ;; esac
+3
View File
@@ -45,6 +45,9 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=semver,pattern={{version}} type=semver,pattern={{version}}
# A fix to release 1.2.3 is tagged v1.2.3.1: four numbers are not semver, so
# the pattern above makes no tag for it; this one does (image tag 1.2.3.1).
type=match,pattern=v(\d+\.\d+\.\d+\.\d+),group=1
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=sha,prefix= type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
+10 -9
View File
@@ -6,20 +6,21 @@
# - Bash-based leader election script (flock on shared volume) # - Bash-based leader election script (flock on shared volume)
# - meta-core Go binary (HTTP API, Mount API, Watcher) # - meta-core Go binary (HTTP API, Mount API, Watcher)
# - nginx (dashboard, WebDAV, API proxy) # - nginx (dashboard, WebDAV, API proxy)
# - Redis (managed by supervisord on leader) # - SQLite metadata store, embedded in the Go binary: truth /meta-core/db/meta-core.db
# (signed claims, backed up) + cache /meta-core/db-cache/cache.db (rebuildable)
# - rclone (single mount executor — SMB goes through rclone's :smb: backend) # - rclone (single mount executor — SMB goes through rclone's :smb: backend)
# - supervisord (process management - runs on leader only) # - supervisord (process management - runs on leader only)
# #
# Architecture: # Architecture:
# - leader-election.sh handles flock, starts supervisord on leader # - leader-election.sh handles flock, starts supervisord on leader
# - Followers remain dormant, retry flock every 5s # - Followers remain dormant, retry flock every 5s
# - Only leader runs Redis, meta-core, nginx, rclone, mount-watcher # - Only leader runs meta-core, nginx, rclone, mount-watcher
# ============================================================================= # =============================================================================
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Stage 1: Build Go binary # Stage 1: Build Go binary
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
FROM golang:1.21-alpine AS go-builder FROM golang:1.26-alpine AS go-builder
RUN apk add --no-cache git RUN apk add --no-cache git
@@ -74,7 +75,7 @@ ARG RCLONE_VERSION=1.73.5
# Install runtime dependencies. Native NFS/CIFS clients intentionally absent — # Install runtime dependencies. Native NFS/CIFS clients intentionally absent —
# all remote mounts go through rclone now. # all remote mounts go through rclone now.
RUN apk add --no-cache \ RUN apk add --no-cache \
redis \ sqlite \
nginx \ nginx \
nginx-mod-http-dav-ext \ nginx-mod-http-dav-ext \
supervisor \ supervisor \
@@ -104,7 +105,8 @@ RUN chmod +x /usr/local/bin/meta-core
# Create directories # Create directories
RUN mkdir -p \ RUN mkdir -p \
/meta-core/locks \ /meta-core/locks \
/meta-core/db/redis \ /meta-core/db \
/meta-core/db-cache \
/meta-core/services \ /meta-core/services \
/meta-core/mounts/errors \ /meta-core/mounts/errors \
/files \ /files \
@@ -140,13 +142,12 @@ ENV META_CORE_PATH=/meta-core \
SERVICE_VERSION=1.0.0 \ SERVICE_VERSION=1.0.0 \
META_CORE_HTTP_PORT=9000 \ META_CORE_HTTP_PORT=9000 \
META_CORE_HTTP_HOST=0.0.0.0 \ META_CORE_HTTP_HOST=0.0.0.0 \
REDIS_PORT=6379 \
WATCH_INTERVAL_MS=1000 WATCH_INTERVAL_MS=1000
# Expose ports # Expose ports
# 80: nginx (dashboard + API proxy + WebDAV) # 80: nginx (dashboard + API proxy + WebDAV). The metadata store has no port:
# 6379: Redis (for other services) # it is a file inside this container, reachable only through the HTTP API.
EXPOSE 80 6379 EXPOSE 80
# Health check # Health check
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
+1 -1
View File
@@ -2,7 +2,7 @@
# Variables # Variables
BINARY := meta-core BINARY := meta-core
VERSION ?= 1.0.0 VERSION ?= 2.0.0
BUILD_DIR := bin BUILD_DIR := bin
DOCKER_IMAGE := meta-core DOCKER_IMAGE := meta-core
+225 -113
View File
@@ -1,39 +1,51 @@
# meta-core # meta-core
Standalone Go service that owns Redis, the leader-info file, the HTTP+SSE Standalone Go service that owns the metadata store (an embedded SQLite file),
metadata surface, and a WebDAV file server for the MetaMesh dev/prod stack. the HTTP+SSE metadata surface, the
User Data Layer store and signing keystore, and a WebDAV file server for the
MetaMesh dev/prod stack.
## Overview ## Overview
meta-core runs as its own container (`metacore-app`). Other MetaMesh services meta-core runs as its own container (`metacore-app`). Other MetaMesh services
(meta-sort, meta-fuse, meta-stremio, meta-dup, meta-share) discover it by (meta-sort, meta-fuse, meta-stremio, meta-dup, meta-search, meta-share,
reading `/meta-core/locks/kv-leader.info` and then call its HTTP API. Redis meta-gateway, meta-watch, …) locate it over UDP multicast (beacon v2 —
is not exposed outside the container — all metadata I/O is mediated by the its `metamesh.core` advertise carries the full `/urls` payload) and then call its
HTTP API. HTTP API. `META_CORE_URL` on a client pins it instead. The store is embedded
in the Go binary (no database port at all) — all metadata I/O is mediated by the HTTP/SSE API
(see [`docs/api-mediated-access.md`](docs/api-mediated-access.md)).
### Key capabilities ### Key capabilities
| Feature | Description | | Feature | Description |
|---------|-------------| |---------|-------------|
| **Leader gate** | Bash `flock` loop in `docker/leader-election.sh` only `exec`s supervisord on the winner. The Go binary therefore only ever runs as leader. | | **DB-file mutex** | Bash `flock` loop in `docker/leader-election.sh` only `exec`s supervisord on the winner, so two containers sharing one `/meta-core` volume can never run two writers on the database file. It is a mutex, not discovery — nothing reads the lock. |
| **Redis owner** | Supervisord starts Redis with AOF + RDB persistence inside the container; meta-core connects to it on localhost. | | **Embedded SQLite store** | One schema-free `kv(k, v)` table in `/meta-core/db/meta-core.db` (pure-Go `modernc.org/sqlite`, WAL, `synchronous=NORMAL`). One writer goroutine with group commit; a record write (fields + root index + CID aliases + FTS row + events) is atomic. Replaced Redis in 2026-10 (Redis + the in-memory search copy needed ~6.6 GB on watch.nsl.sh's 169k records; the SQLite file is ~2.5 GB on disk with a bounded page cache). |
| **HTTP API** | Typed REST surface (gorilla/mux) for metadata, KV browsing, snapshots, schema inference, mounts, watchers, services, and file access by CID. | | **HTTP API** | Typed REST surface (gorilla/mux) for metadata, KV browsing, snapshots, schema inference, mounts, watchers, neighbours, identity, UDL, and file access by CID. |
| **SSE event streams** | `/api/events/files` and `/api/events/meta` mirror the underlying Redis Streams so external services never speak Redis. | | **Indexed search** | `POST /api/metadata/search` builds a superset of candidates in SQL — a partial expression index `kv_field_v` over (field name, value) of every record key, plus an FTS5 trigram table; the smallest part drives, the other filters are probed per candidate — then verifies each with the unchanged Go matchers. Fresh immediately, no in-memory copy (`internal/storage/search.go`, `internal/api/search_index.go`). |
| **SSE event streams** | `/api/events/files` and `/api/events/meta` mirror the store's `file:events` / `meta:events` streams so external services never touch the store. `meta:events` entries are written in the same transaction as the change. |
| **WebDAV server** | `/webdav/*` exposes the `/files` volume (read+write) for cross-service file access. nginx in front of meta-core handles caching. | | **WebDAV server** | `/webdav/*` exposes the `/files` volume (read+write) for cross-service file access. nginx in front of meta-core handles caching. |
| **Service discovery** | File-based registry under `/meta-core/services/*.json` with heartbeat + dead-service cleanup. | | **Service discovery** | beacon v2: UDP multicast advertise/listen on `239.255.99.1:9099`, neighbours served at `/neighbors`. The file-based registry under `/meta-core/services/*.json` it replaced is gone. |
| **File watcher / scanner** | Recursive scan + MidHash256 computation; emits events on `file:events`. | | **File watcher / scanner** | Recursive scan + MidHash256 computation; emits events on `file:events`. Watched roots are POSTed watcher configs (`/meta-core/watchers.json`), not env vars. |
| **Mount management** | rclone-only mount manager (SMB rendered into `:smb:`, plus pre-defined remotes). Read-only by construction. | | **Mount management** | rclone-only mount manager (SMB rendered into `:smb:`, plus pre-defined remotes). Read-only by construction. |
| **UUID-rooted storage** | Roots are UUIDv7 (Crockford Base32, ULID layout); CIDs are reverse-index aliases. Schema-version sentinel refuses to boot against stale data. | | **UUID-rooted storage** | Roots are UUIDv7 (Crockford Base32, ULID layout); CIDs are reverse-index aliases. Schema-version sentinel refuses to boot against stale data. |
| **Identity keystore** | Multi-account secp256k1 signing keys under `/meta-core/identity/accounts/`, created from the dashboard's Identity menu (never auto-generated). A private key is returned only by generate, or by reveal against a proof-of-possession signature (delete needs the same proof); `/api/identity/sign` refuses challenge-domain payloads. |
| **User Data Layer** | Version-gated store for per-user signed (and, for private-tier keys, encrypted) records — My List, Continue Watching, ratings — indexed by user and CID. meta-core never inspects the crypto. See [User Data Layer](../../docs/project-architecture/user-data-layer.md). |
| **Report inbox** | `/api/reports` stores the bug reports / feedback the client apps' 🐛 buttons file (forwarded server-to-server by meta-watch and meta-read), in its own bounded `db/reports.db`; the dashboard's **Reports** page lists, reads and triages them. See [architecture: Report inbox](docs/meta-core-architecture.md#report-inbox). |
| **Dashboard + editor** | nginx serves a React dashboard at `/` and the standalone metadata editor at `/editor/`. |
### Design characteristics ### Design characteristics
- **Single-leader** — flock on a shared volume; no external consensus. - **Single writer** — flock on the `/meta-core` volume; no external consensus.
- **HTTP-only externally** — Redis is bound to the container, never published. - **HTTP-only externally** — there is no database port; debug read-only with
`docker exec metacore-app sqlite3 -readonly /meta-core/db/meta-core.db …`
(dev stacks: `/meta-core-db/meta-core.db`).
- **Schema-gated boot** — `EnsureSchemaVersion` aborts startup when the - **Schema-gated boot** — `EnsureSchemaVersion` aborts startup when the
on-disk Redis layout predates the current build (alpha clean-wipe policy; on-disk store layout predates the current build (alpha clean-wipe policy;
see `docs/uuid-rooted-metadata.md`). see `docs/uuid-rooted-metadata.md`).
- **Static binary** — CGO disabled, single Go binary plus a thin Alpine - **Static binary** — CGO disabled (pure-Go SQLite), single Go binary plus a
image with `redis-server`, `rclone`, nginx, and supervisord. thin Alpine image with `rclone`, nginx, supervisord and the `sqlite` CLI
(for debugging).
## Architecture ## Architecture
@@ -41,34 +53,31 @@ HTTP API.
┌──────────────────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────────────────┐
│ metacore-app (standalone container) │ │ metacore-app (standalone container) │
│ │ │ │
│ leader-election.sh (flock /meta-core/locks/kv-leader.lock, │
│ then exec supervisord) │
│ supervisord │ │ supervisord │
│ ├─ leader-election.sh (flock /meta-core/locks/kv-leader.lock, │ │ ├─ nginx :80 (dashboard, /editor, API proxy, │
│ │ writes kv-leader.info, exec supervisord) │ │ │ proxy_cache for /webdav, rclone GUI /rclone) │
│ ├─ redis-server (AOF + RDB on /meta-core/db/redis) │ │ ├─ rclone rcd (mount daemon, RC API on :5572) │
│ ├─ nginx (TLS termination, proxy_cache for /webdav) │
│ ├─ rclone rcd (mount daemon, RC API) │
│ ├─ mount-watcher.sh (rclone mount lifecycle) │ │ ├─ mount-watcher.sh (rclone mount lifecycle) │
│ └─ meta-core (Go) ─► HTTP API + SSE + WebDAV on :9000 │ │ └─ meta-core (Go) ─► HTTP API + SSE + WebDAV on :9000 │
│ ─► embedded SQLite (db/meta-core.db) │
│ ─► UDP advertise/listen 239.255.99.1:9099 │
└──────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────┘
│ │
┌──────────────────────┴──────────────────────┐ ┌──────────────────────┴──────────────────────┐
│ /meta-core (shared volume) │ │ /meta-core (meta-core's own volume) │
│ locks/kv-leader.{lock,info} │ │ locks/kv-leader.lock │
│ db/redis/ │ │ db/meta-core.db (+ -wal, -shm) │
│ services/*.json │ │ identity/accounts/*.json │
│ mounts/{mounts.json, errors/} │ │ mounts/{mounts.json, errors/} │
│ watchers.json │ │ watchers.json │
│ cache/ (nginx proxy_cache) │ │ cache/ (nginx proxy_cache) │
└─────────────────────────────────────────────┘ └─────────────────────────────────────────────┘
▲
┌──────────────────────────────┼────────────────────────────────┐ meta-sort / meta-fuse / meta-stremio / meta-search / meta-share / …
│ │ │ │ 1. hear the metamesh.core advertise on UDP multicast (carries /urls)
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ └─ 2. HTTP + SSE (+ WebDAV) to metacore-app — never the store, never the volume
│ meta-sort │ │ meta-fuse │ │ meta-stremio│
│ │ │ │ │ │
│ reads │ HTTP+SSE │ reads │ HTTP+SSE │ reads │
│ leader.info├──────────────►│ leader.info├───────────────►│ leader.info│
└─────────────┘ └─────────────┘ └─────────────┘
``` ```
## Quick start ## Quick start
@@ -82,8 +91,9 @@ make test # run Go unit tests
``` ```
`make docker` tags the image as `:1.0.0`, `:latest`, **and** `:local`. The `make docker` tags the image as `:1.0.0`, `:latest`, **and** `:local`. The
`:local` tag is the one every sub-stack docker-compose.yml references `:local` tag is the one the sub-stack docker-compose files reference
(meta-share 10-peer federation, meta-gateway dev stacks); without it the (meta-share federation, meta-read and meta-listen dev stacks); the meta-gateway
dev stack (`docker-compose.gateway.yml`) uses `:latest`. Without these tags the
sub-stacks can't see a freshly-built image by name. sub-stacks can't see a freshly-built image by name.
> **Source-change → recreate.** `make docker` rebuilds and re-tags the > **Source-change → recreate.** `make docker` rebuilds and re-tags the
@@ -94,12 +104,14 @@ sub-stacks can't see a freshly-built image by name.
> >
> ```bash > ```bash
> make docker > make docker
> docker compose -f packages/meta-gateway/dev/docker-compose.yml \ > docker compose -f packages/meta-gateway/dev/docker-compose.gateway.yml \
> up -d --force-recreate metagateway-generic-core > up -d --force-recreate metagateway-hub-metacore
> docker compose -f packages/meta-gateway/dev/docker-compose.torznab.yml \
> up -d --force-recreate metagateway-torznab-core
> docker compose -f packages/meta-share/dev/docker-compose.yml \ > docker compose -f packages/meta-share/dev/docker-compose.yml \
> --profile share-10node up -d --force-recreate metashare-core metashare-core-test > --profile share-10node up -d --force-recreate metashare-core metashare-core-test
> docker compose -f packages/meta-read/dev/docker-compose.yml \
> up -d --force-recreate metaread-dev-core
> docker compose -f packages/meta-listen/dev/docker-compose.yml \
> up -d --force-recreate metalisten-dev-core
> ``` > ```
> >
> The symptom of a missed recreate is missing API fields on `/urls` > The symptom of a missed recreate is missing API fields on `/urls`
@@ -109,7 +121,7 @@ sub-stacks can't see a freshly-built image by name.
> >
> ```bash > ```bash
> docker inspect meta-core:local --format '{{.Id}}' # fresh SHA > docker inspect meta-core:local --format '{{.Id}}' # fresh SHA
> docker ps --filter ancestor=meta-core:local --format '{{.Names}}' > docker ps --filter ancestor=meta-core:local --format '{{.Names}}' # repeat for :latest
> # any container whose Image SHA doesn't match the fresh SHA needs --force-recreate. > # any container whose Image SHA doesn't match the fresh SHA needs --force-recreate.
> ``` > ```
@@ -126,36 +138,45 @@ HTTP port (no auth, bypasses Caddy) is `http://localhost:18083`.
## Configuration ## Configuration
Environment variables consumed by `internal/config/config.go`: Environment variables (from `internal/config/config.go` unless noted):
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `META_CORE_PATH` | `/meta-core` | Shared volume root (locks, db, services, mounts, cache). | | `META_CORE_PATH` | `/meta-core` | meta-core's own volume root (lock, SQLite DB, identity, mounts, watchers, cache). No other service mounts it. |
| `FILES_PATH` | `/files` | Files volume root. | | `FILES_PATH` | `/files` | Files volume root. |
| `SERVICE_NAME` | `meta-core` | Identity in the service registry. | | `SERVICE_NAME` | `meta-core` | Name in the beacon v2 advertise. |
| `SERVICE_VERSION` | `1.0.0` | Reported via `/status` and registry. | | `SERVICE_VERSION` | `1.0.0` | Reported via `/status`. |
| `API_PORT` | `8180` | External port baked into the leader-info `baseUrl`. | | `API_PORT` | `8180` | External port baked into the constructed `baseUrl` when `BASE_URL` is unset. |
| `BASE_URL` | _empty_ | Overrides the constructed `baseUrl` if set (used for HTTPS perimeter). | | `BASE_URL` | _empty_ | Overrides the constructed `baseUrl` (the Caddy/HTTPS perimeter URL). |
| `REDIS_PORT` | `6379` | Local Redis port. | | `META_CORE_PUBLIC_URL` | _empty_ | Browser-facing nav URL in the announce; wins over `BASE_URL` (e.g. a debug-direct port with no Caddy). |
| `META_CORE_DB_PATH` | `<META_CORE_PATH>/db/meta-core.db` | SQLite database file. Dev stacks set `/meta-core-db/meta-core.db` on a named volume (the `/meta-core` bind is 9p on Windows dev boxes and SQLite WAL is unsafe there). |
| `META_CORE_DB_CACHE_MB` | `64` | Page cache of the writer connection (`internal/storage/client.go`). |
| `META_CORE_DB_READER_CACHE_MB` | `16` | Page cache per reader connection. |
| `META_CORE_DB_READERS` | `8` | Reader connection pool size. |
| `META_CORE_REPORTS_DB_PATH` | `<META_CORE_PATH>/db/reports.db` | Report inbox database. |
| `META_CORE_REPORTS_MAX` / `META_CORE_REPORTS_MAX_MB` | `5000` / `256` | Inbox bounds; closed reports are evicted first, then the oldest. |
| `META_CORE_HTTP_PORT` | `9000` | Go HTTP+SSE+WebDAV port. | | `META_CORE_HTTP_PORT` | `9000` | Go HTTP+SSE+WebDAV port. |
| `META_CORE_HTTP_HOST` | `0.0.0.0` | HTTP bind. | | `META_CORE_HTTP_HOST` | `0.0.0.0` | HTTP bind. |
| `HEALTH_CHECK_INTERVAL_MS` | `5000` | Internal health-loop cadence. | | `ENABLE_FILE_WATCHER` | `true` | Disable to suppress the in-process watcher entirely (`POST /api/files/register` stays available). |
| `HEARTBEAT_INTERVAL_MS` | `30000` | Service registry heartbeat. | | `ENABLE_UDP_DISCOVERY` | `true` | beacon v2 advertise + listen. |
| `STALE_THRESHOLD_MS` | `60000` | Age past which a registry entry is marked stale. | | `BEACON_GROUP` / `BEACON_PORT` | `239.255.99.1` / `9099` | Multicast group and UDP port. |
| `CLEANUP_INTERVAL_MS` | `600000` | Dead-service cleaner cadence. | | `BEACON_INTERVAL_MS` | `10000` | Unsolicited advertise interval. |
| `DEAD_SERVICE_THRESHOLD_MS` | `600000` | Age past which a stale entry is deleted. | | `ELECTION_RETRY_SECS` | `5` | flock retry interval (read by `docker/leader-election.sh`, not the Go binary). |
| `WATCH_INTERVAL_MS` | `1000` | Watcher poll cadence. |
| `DEBOUNCE_MS` | `30000` | File-event debounce window. |
| `ENABLE_FILE_WATCHER` | `true` | Disable to suppress the in-process watcher entirely. |
Note: there is no `META_CORE_REDIS_PORT`, no `META_CORE_SERVICE_NAME`, and `HEALTH_CHECK_INTERVAL_MS`, `HEARTBEAT_INTERVAL_MS`, `STALE_THRESHOLD_MS`,
no `META_CORE_LEADER_TIMEOUT`. The Redis URL is discovered locally; service `CLEANUP_INTERVAL_MS`, `DEAD_SERVICE_THRESHOLD_MS`, `WATCH_INTERVAL_MS` and
identity uses `SERVICE_NAME`; leader timeout is governed by the bash flock `DEBOUNCE_MS` are still parsed by `internal/config/config.go` but nothing reads
retry interval (`ELECTION_RETRY_SECS`, default `5`). them (leftovers of the file-based registry); setting them has no effect. There
is no `REDIS_PORT` / `META_CORE_REDIS_PORT` / `META_CORE_INDEX_EXCLUDE_PREFIXES`
(gone with Redis and the in-memory search index; search results now include
`categories/newznab/*` like every record read) / `META_CORE_SERVICE_NAME` /
`META_CORE_LEADER_TIMEOUT`, and no `WATCH_PATHS` — watched folders are watcher
configs created via `/api/watchers`.
## API reference ## API reference
All endpoints below are served by `internal/api/server.go`. Examples assume All endpoints below are registered in `internal/api/server.go` (mounts,
watcher and watchers routes in their own packages' `handlers.go`). Examples assume
the in-container Go port `:9000`; from the host use the in-container Go port `:9000`; from the host use
`https://metacore-dev.localhost:8083` (Caddy) or `http://localhost:18083` `https://metacore-dev.localhost:8083` (Caddy) or `http://localhost:18083`
(debug-direct). (debug-direct).
@@ -163,16 +184,47 @@ the in-container Go port `:9000`; from the host use
### Health, leader, URLs ### Health, leader, URLs
```bash ```bash
GET /health # storage + role GET /health # storage + role (`redis: bool` kept for compatibility = store open)
GET /status # version, uptime, etc. GET /status # version, uptime, etc.
GET /leader # current leader info (hostname, PID, timestamps) GET /leader # current leader info (hostname, PID, timestamps)
GET /urls # baseUrl, apiUrl, webdavUrl, webdavUrlInternal (redisUrl is intentionally empty) GET /urls # baseUrl, apiUrl, webdavUrl, webdavUrlInternal (redisUrl is always "", kept for compatibility)
GET /services # registered services + heartbeats GET /neighbors # neighbours heard over UDP (beacon v2)
GET /api/services # alias for /services GET /api/neighbors # same, under /api
GET /services/{name} # one service GET /api/stats # dashboard counters (see below)
GET /services/cleanup/stats
``` ```
`/services`, `/api/services`, `/services/{name}` and `/services/cleanup/stats`
are **gone** — the registration-and-heartbeat registry they served was replaced
by beacon v2 (`/neighbors`). nginx answers an unknown non-`/api` path
with the dashboard's `index.html`, so a stale caller gets `200 text/html` rather
than a 404; that is what made the dashboard fail with
`SyntaxError: Unexpected token '<'` until 1.0.21.
#### `GET /api/stats`
Every headline counter in one request, split by cost:
```jsonc
{
"records": 88709, // count of idx:roots/* — metadata roots, file or not
"redisKeys": 3115447, // kv row count (name kept for compatibility; cached 60 s)
"redisMemory": "588.74M",// DB + WAL file size (name kept for compatibility)
"identities": 3, // signing accounts on disk
"files": { "count": 4864, "totalSize": 64587914424 }, // null until first sweep
"udlUsers": 5, // accounts holding User Data Layer records
"sweptAt": 1789402401970,
"sweeping": false
}
```
`records` and `identities` are live on every call; `redisKeys` is cached 60 s. `files` and
`udlUsers` need a keyspace walk (~0.5s and ~3s respectively on an 88k-record
box), so they are served from a single-flight cache refreshed in the background
with a 60s TTL — the walk is **never** on the request path, because the
dashboard polls and ten open tabs must not cost ten keyspace walks. Prefer this
over `/api/kv/info`, which does one `GET` per root (6s+) and sums `sizeByte`
across records that share a file.
### Metadata — primary surface ### Metadata — primary surface
These are the routes other services use. `{hash}` may be a UUID root or These are the routes other services use. `{hash}` may be a UUID root or
@@ -198,6 +250,8 @@ GET /api/meta/{cid} # resolve CID → metadata document
GET /api/file/{cid}/info # CID resolution metadata GET /api/file/{cid}/info # CID resolution metadata
GET /file/{cid} # serve file bytes (range-aware) GET /file/{cid} # serve file bytes (range-aware)
POST /file/cid # compute CID for an uploaded file POST /file/cid # compute CID for an uploaded file
POST /api/files/register # mint + alias one file under /files/plugin by midhash
# (the watcher's path for one file; works with the watcher OFF)
HEAD /data/{hash} # existence + size HEAD /data/{hash} # existence + size
GET /data/{hash}/path # absolute path on /files GET /data/{hash}/path # absolute path on /files
``` ```
@@ -209,6 +263,7 @@ the `/file/{cid}` reverse-lookup path.)
```bash ```bash
GET /api/metadata/hash-ids GET /api/metadata/hash-ids
GET /api/files/tuples
GET /api/metadata/list GET /api/metadata/list
POST /api/metadata/search POST /api/metadata/search
POST /api/metadata/batch POST /api/metadata/batch
@@ -221,10 +276,10 @@ PUT /api/metadata/{hashId}/property
GET /api/kv/info GET /api/kv/info
GET /api/kv/keys GET /api/kv/keys
GET /api/kv/tree GET /api/kv/tree # cursor is an opaque next-key ("0" when exhausted)
GET /api/kv/search GET /api/kv/search
GET /api/kv/find GET /api/kv/find
GET /api/kv/value GET /api/kv/value # type is always "string" for an existing key
PUT /api/kv/value PUT /api/kv/value
DELETE /api/kv/value DELETE /api/kv/value
GET /api/kv/key/{key...} GET /api/kv/key/{key...}
@@ -239,7 +294,8 @@ POST /api/snapshot/wipe
### SSE event streams ### SSE event streams
The HTTP-mirror of the Redis Streams. External services never read Redis The HTTP-mirror of the store's event streams (Redis Streams until 2026-10;
IDs keep the `<ms>-<seq>` form). External services never read the store
directly — they subscribe here with `Last-Event-ID` for resume. See directly — they subscribe here with `Last-Event-ID` for resume. See
[`docs/api-mediated-access.md`](docs/api-mediated-access.md) for the wire [`docs/api-mediated-access.md`](docs/api-mediated-access.md) for the wire
contract (one event per stream entry, opaque IDs, heartbeats, gap contract (one event per stream entry, opaque IDs, heartbeats, gap
@@ -248,6 +304,54 @@ signalling).
```bash ```bash
GET /api/events/files # file:events stream (watcher events) GET /api/events/files # file:events stream (watcher events)
GET /api/events/meta # meta:events stream (metadata mutations) GET /api/events/meta # meta:events stream (metadata mutations)
GET /api/events/poll # DEPRECATED long-poll over file:events — use SSE
```
### Identity (signing keystore)
Reached through meta-search's identity proxy (and directly by meta-gateway,
meta-read, meta-listen) for record signing, UDL writes and keypair sign-in.
```bash
GET /api/identity # status (never the private key)
GET /api/identity/accounts
POST /api/identity/generate # the only call that returns a new private key
POST /api/identity/import
POST /api/identity/challenge # one-shot challenge for proof-of-possession
POST /api/identity/reveal # needs proof-of-possession
DELETE /api/identity # needs proof-of-possession; reports purged UDL counts
POST /api/identity/sign
POST /api/identity/sign-batch
GET /api/identity/aead-key
```
### User Data Layer
Records arrive signed (private-tier ones encrypted) as opaque base64 CBOR;
writes are version-gated (stale → `409 stale_version`, or `accepted:false` per
cell in a batch). Storage model in `internal/storage/udl.go` (`udl:rec:<uid>/<cid>/<key>` → JSON `{record,version,ts,value?}`; index sets as one key per member).
```bash
GET /api/udl/record?uid&cid&key
PUT /api/udl/record
PUT /api/udl/records # batch
GET /api/udl/user/{uid}/key/{key}
GET /api/udl/user/{uid}/cid/{cid}
GET /api/udl/user/{uid}/all
GET /api/udl/user/{uid}/count
GET /api/udl/cid/{cid}/users
GET /api/udl/cid/{cid}/aggregate # meta-watch Aggregate wire shape
GET /api/udl/users/stats
```
### Reports (bug-report / feedback inbox)
```
POST /api/reports # {app, note?, page?, route?, cid?, title?, uid?, build?, filedAt?, log?, …blocks} → 201 {id}
GET /api/reports # ?app=&status=open|triaged|closed&q=&before=<id>&limit= → {reports, apps, counts, more}
GET /api/reports/{id} # full report incl. body
PATCH /api/reports/{id} # {status?, triage_note?}
DELETE /api/reports/{id}
``` ```
### Mounts ### Mounts
@@ -283,42 +387,43 @@ The legacy `/api/scan/trigger` and `/api/scan/status` still respond but
return a deprecation pointer at the `/api/watchers/*` routes return a deprecation pointer at the `/api/watchers/*` routes
(`internal/watcher/handlers.go:32-33`). (`internal/watcher/handlers.go:32-33`).
### Admin / WebDAV ### WebDAV
```bash ```bash
POST /api/admin/migrate-dual-roots # reunify stranded midhash-rooted entries
GET/PUT/DELETE /webdav/... # mounted on /files GET/PUT/DELETE /webdav/... # mounted on /files
``` ```
The one-shot Redis-era admin migrations (`/api/admin/migrate-dual-roots`,
`migrate-domain-screen`, `migrate-user-cids`, `sweep-literature-echoes`) were
removed with the 2026-10 SQLite switch; they had been run once on
watch.nsl.sh before the migration.
No `/metrics` endpoint is exposed; observability is via container logs and No `/metrics` endpoint is exposed; observability is via container logs and
the `/health` / `/status` JSON. the `/health` / `/status` JSON.
## Leader gate ## DB-file mutex
Leader election is a bash flock loop in `docker/leader-election.sh`, not a `docker/leader-election.sh` is a bash flock loop, not a Go-level state
Go-level state machine. When `flock` succeeds on `kv-leader.lock`, the machine. When `flock` succeeds on `/meta-core/locks/kv-leader.lock`, the
script writes the leader API URL to `kv-leader.info` and `exec`s script `exec`s supervisord; a second container mounting the same volume
supervisord. When the process tree dies, the kernel releases the lock and retries every `ELECTION_RETRY_SECS` and stays dormant. When the process tree
any standby instance can take over. There is no follower path inside the dies, the kernel releases the lock. There is no follower path inside the Go
Go binary — there is no `OnBecomeLeader` / `OnBecomeFollower` callback API. binary.
`kv-leader.info` is a single plaintext line containing the API base URL: The lock is a **mutex, not service discovery** — it only stops two
writers landing on one database file (`db/meta-core.db`). Nothing reads it, and the `kv-leader.info`
``` file it used to publish is gone: siblings locate meta-core over UDP
http://10.0.1.50:9000 (see [beacon-v2.md](../../docs/project-architecture/beacon-v2.md)).
``` `internal/leader` keeps its name for history, but only builds the `/urls`
payload (`baseUrl`, `apiUrl`, `webdavUrl`, `webdavUrlInternal`) that `/urls`
Other services read this file, then call `GET /urls` for full discovery. and the UDP announce share.
The dev compose runs a single `metacore-app`; the flock primitive is
retained as a correctness anchor (one process owns Redis and the
writable state under `/meta-core`), not as live failover.
## Metadata storage shape (one-line summary) ## Metadata storage shape (one-line summary)
Roots are opaque UUIDv7 strings; every property is a separate Redis Roots are opaque UUIDv7 strings; every property is a separate row
STRING at `file:<uuid>/<property>`; every known CID for a file is a of the `kv` table at `file:<uuid>/<property>`; every known CID is a key-set member
reverse-index entry `cid:<algorithm>:<value> → <uuid>`. Full details in `file:<uuid>/cids/<bareCid>` plus a reverse-index entry `cid:<bareCid> → <uuid>`
(the canonical CID is derived by rank on read, never stored). Full details in
[`docs/metadata-storage-structure.md`](docs/metadata-storage-structure.md) [`docs/metadata-storage-structure.md`](docs/metadata-storage-structure.md)
and design rationale in [`docs/uuid-rooted-metadata.md`](docs/uuid-rooted-metadata.md). and design rationale in [`docs/uuid-rooted-metadata.md`](docs/uuid-rooted-metadata.md).
The authoritative registry of field semantics and value formats lives in The authoritative registry of field semantics and value formats lives in
@@ -328,15 +433,17 @@ the repo-root [`METADATA_KEYS.md`](../../METADATA_KEYS.md).
| Package | Purpose | | Package | Purpose |
|---------|---------| |---------|---------|
| `cmd/meta-core` | Entry point; Redis connect + schema sentinel + service wiring. | | `cmd/meta-core` | Entry point; store open + schema sentinel + UDP discovery + service wiring. |
| `internal/config` | Env-driven configuration, path helpers. | | `internal/config` | Env-driven configuration, path helpers. |
| `internal/leader` | `LeaderInfoProvider` (builds the leader info from local hostname/IP/config; no election logic). | | `internal/leader` | `LeaderInfoProvider` — builds the `/urls` payload from hostname/IP/config; no election logic. |
| `internal/storage` | Redis wrapper (`client.go`), UUIDv7 minting (`uuid.go`), CID reverse index (`cid_resolution.go`), schema sentinel (`schema_version.go`), dual-root migration (`dual_root_migration.go`). | | `internal/meshdisco` | beacon v2 node (advertise, probe, neighbour map) — the Go port of the spec. |
| `internal/cid` | CID parsing + algorithm ranking for canonical-CID selection. | | `internal/kv` | Storage engine: SQLite `kv` table, single writer + group commit, reader pool, in-transaction change events (`kv.go`), event streams as rows (`stream.go`). |
| `internal/discovery` | Service registry + dead-service cleaner. | | `internal/storage` | Flat-key records (`client.go`), UUIDv7 minting, CID reverse index (`cid_resolution.go`), schema sentinel, search over `kv_field_v` + FTS (`search.go`), derived counters (`onChange`), UDL store (`udl.go`, `udl_admin.go`), stats. |
| `internal/events` | Keyspace-notification → `meta:events` stream publisher (`meta_publisher.go`). | | `internal/cid` | CID parsing + rank ladder for canonical-CID selection (`cid-rank-vectors.json`, guarded by the meta-root `scripts/check-cid-vectors.sh`). |
| `internal/api` | HTTP server + all handlers + SSE event endpoints. | | `internal/reports` | Report inbox: its own SQLite file, normalisation/bounds, list/filter/paging, prune (closed first). |
| `internal/schema` | Live per-field schema indexer (consumes `meta:events`). | | `internal/identity` | Multi-account secp256k1 keystore, challenge/proof-of-possession, signature verification. |
| `internal/api` | HTTP server + all handlers + SSE event endpoints + search matchers + `/api/stats`. |
| `internal/schema` | Per-field schema, folded from counters the write path keeps exact (`idx:schema/…`). |
| `internal/snapshot` | Snapshot export / import / wipe. | | `internal/snapshot` | Snapshot export / import / wipe. |
| `internal/watcher` | File system scanner, MidHash256 computation, dispatcher, state registry. | | `internal/watcher` | File system scanner, MidHash256 computation, dispatcher, state registry. |
| `internal/watchers` | Polling-based watcher configurations (manager + poller + handlers). | | `internal/watchers` | Polling-based watcher configurations (manager + poller + handlers). |
@@ -348,13 +455,17 @@ the repo-root [`METADATA_KEYS.md`](../../METADATA_KEYS.md).
``` ```
1. Load configuration from environment 1. Load configuration from environment
2. Build LeaderInfoProvider (no election — the bash gate already won) 2. Build LeaderInfoProvider (no election — the bash gate already won)
3. Connect to local Redis (retry up to 30× / 1s) 3. Open the SQLite store (META_CORE_DB_PATH or /meta-core/db/meta-core.db)
4. EnsureSchemaVersion — abort if the existing Redis layout is stale 4. EnsureSchemaVersion — abort if the existing store layout is stale
5. Start service discovery + dead-service cleaner 5. Start beacon v2 node (unless ENABLE_UDP_DISCOVERY=false)
6. Construct API server (initialises watcher, mounts, WebDAV) 6. Construct API server (identity keystore migration, watcher, watchers,
7. Start API server; on first connect, start MetaPublisher + schema Indexer mounts, WebDAV)
and republish existing metadata onto meta:events 7. EnsureCounters — one-time build of the derived counters (record count,
8. Wait for SIGINT/SIGTERM, shutdown in reverse order per-field schema) on a store that predates them; blocking, ~1 min at 169k
records, then never again
8. Start API server + pollers (no republish: consumers that want the whole
state ask /api/events/meta for `0-0` and get a snapshot)
9. Wait for SIGINT/SIGTERM, shutdown in reverse order
``` ```
## Dependencies ## Dependencies
@@ -362,13 +473,14 @@ the repo-root [`METADATA_KEYS.md`](../../METADATA_KEYS.md).
| Dependency | Purpose | | Dependency | Purpose |
|------------|---------| |------------|---------|
| `github.com/gorilla/mux` | HTTP router | | `github.com/gorilla/mux` | HTTP router |
| `github.com/redis/go-redis/v9` | Redis client | | `modernc.org/sqlite` | Embedded SQLite (pure Go, CGO off) |
| `github.com/google/uuid` | UUIDv7 minting | | `github.com/google/uuid` | UUIDv7 minting |
| `github.com/decred/dcrd/dcrec/secp256k1/v4`, `golang.org/x/crypto`, `github.com/mr-tron/base58` | Identity keys, signing, AEAD key derivation |
| `golang.org/x/net` | WebDAV support | | `golang.org/x/net` | WebDAV support |
| Go stdlib | context, net/http, os, sync, syscall |
Runtime requirements: Go 1.21+ at build time; the container ships Redis, Runtime requirements: Go 1.21+ at build time; the container ships
rclone, nginx, and supervisord alongside the binary. rclone, nginx, and supervisord alongside the binary, plus the React dashboard
and metadata editor (built from `dashboard/` and `editor/` in the Dockerfile).
## License ## License
+117 -41
View File
@@ -1,22 +1,25 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"log" "log"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"syscall" "syscall"
"time" "time"
"github.com/metazla/meta-core/internal/api" "github.com/metazla/meta-core/internal/api"
"github.com/metazla/meta-core/internal/config" "github.com/metazla/meta-core/internal/config"
"github.com/metazla/meta-core/internal/discovery"
"github.com/metazla/meta-core/internal/leader" "github.com/metazla/meta-core/internal/leader"
"github.com/metazla/meta-core/internal/meshdisco"
"github.com/metazla/meta-core/internal/reports"
"github.com/metazla/meta-core/internal/storage" "github.com/metazla/meta-core/internal/storage"
) )
// Version is set at build time // Version is set at build time
var Version = "1.0.0" var Version = "2.0.0"
func main() { func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile) log.SetFlags(log.LstdFlags | log.Lshortfile)
@@ -29,51 +32,106 @@ func main() {
// Create leader info provider (Go binary only runs as leader - followers loop in bash) // Create leader info provider (Go binary only runs as leader - followers loop in bash)
leaderProvider := leader.NewLeaderInfoProvider(cfg) leaderProvider := leader.NewLeaderInfoProvider(cfg)
// Connect to local Redis (supervisord starts Redis before meta-core) // Open the two embedded SQLite files: truth (signed claims, backed up)
// Retry connection since Redis may still be loading its dataset // and the cache (everything derived, rebuildable). No database process.
redisURL := fmt.Sprintf("redis://localhost:%d", cfg.RedisPort) if err := os.MkdirAll(filepath.Dir(cfg.CachePath()), 0o755); err != nil {
log.Printf("[meta-core] Connecting to Redis at %s", redisURL) log.Fatalf("[meta-core] Failed to create the cache folder: %v", err)
}
storageClient := storage.NewClient()
if err := storageClient.Connect(cfg.DBPath(), cfg.CachePath()); err != nil {
log.Fatalf("[meta-core] Failed to open the metadata store: %v", err)
}
storageClient := storage.NewClient("") // Schema-version gate: refuses a v1.x store (which the default truth path
maxRetries := 30 // still points at) and any unknown layout. No migration code here — see
retryDelay := 1 // seconds // tools/claims-migrate.
var err error
for i := 0; i < maxRetries; i++ {
err = storageClient.Connect(redisURL)
if err == nil {
break
}
log.Printf("[meta-core] Redis connection attempt %d/%d failed: %v", i+1, maxRetries, err)
time.Sleep(time.Duration(retryDelay) * time.Second)
}
if err != nil {
log.Fatalf("[meta-core] Failed to connect to Redis after %d attempts: %v", maxRetries, err)
}
log.Println("[meta-core] Connected to Redis")
// Schema-version sentinel: alpha clean-wipe gate. Refuses to boot when
// the existing Redis store predates the UUID-rooted schema, so legacy
// data can't silently leak into the new code paths. See
// docs/uuid-rooted-metadata.md.
if err := storageClient.EnsureSchemaVersion(storage.SchemaVersion); err != nil { if err := storageClient.EnsureSchemaVersion(storage.SchemaVersion); err != nil {
log.Fatalf("[meta-core] %v", err) log.Fatalf("[meta-core] %v", err)
} }
log.Printf("[meta-core] Redis schema version OK (v%d)", storage.SchemaVersion) log.Printf("[meta-core] Store schema version OK (v%d)", storage.SchemaVersion)
// Create and start service discovery // Bring the cache level with truth (replay after a crash, full rebuild
disc := discovery.NewService(cfg) // when the cache folder is empty), then start the apply loop.
if err := disc.Start(); err != nil { started := time.Now()
log.Fatalf("[meta-core] Failed to start service discovery: %v", err) if err := storageClient.Start(); err != nil {
log.Fatalf("[meta-core] %v", err)
}
log.Printf("[meta-core] Cache ready in %s", time.Since(started).Round(time.Millisecond))
// Derived counters (record count, per-field schema) are kept exact by the
// write path. A store that predates them gets a one-time build here,
// before anything else writes.
started = time.Now()
if built, err := storageClient.EnsureCounters(); err != nil {
log.Fatalf("[meta-core] Failed to build the store's derived counters: %v", err)
} else if built {
log.Printf("[meta-core] Built derived counters in %s", time.Since(started).Round(time.Millisecond))
} }
// Create and start dead service cleaner // Beacon v2 (docs/project-architecture/beacon-v2.md): advertise meta-core
cleaner := discovery.NewCleaner(cfg) // as metamesh.core — carrying the /urls block services locate us by — plus
if err := cleaner.Start(); err != nil { // its own metamesh.service/<name> nav entry. The payload is rebuilt from
log.Printf("[meta-core] Warning: failed to start service cleaner: %v", err) // leaderProvider on every tick, which keeps it and GET /urls from drifting.
var mesh *meshdisco.Node
if cfg.EnableUDPDiscovery {
hostname, _ := os.Hostname()
mesh = meshdisco.New(meshdisco.Config{
Name: cfg.ServiceName,
Instance: hostname,
Version: Version,
Group: cfg.DiscoveryGroup,
Port: cfg.DiscoveryPort,
Interval: time.Duration(cfg.DiscoveryIntervalMS) * time.Millisecond,
Payload: func() (string, []meshdisco.Resource) {
service := meshdisco.Resource{
ID: "service",
Caps: []string{meshdisco.CapServicePrefix + cfg.ServiceName},
Endpoints: map[string]string{"ui": coreBaseURL(cfg)},
}
info := leaderProvider.LeaderInfo()
if info == nil {
return "starting", []meshdisco.Resource{service}
}
data, _ := json.Marshal(map[string]meshdisco.URLs{"urls": {
Hostname: info.Hostname,
BaseUrl: info.BaseUrl,
ApiUrl: info.ApiUrl,
WebdavUrl: info.WebdavUrl,
WebdavUrlInternal: info.WebdavUrlInternal,
}})
core := meshdisco.Resource{
ID: "core",
Caps: []string{meshdisco.CapCore},
Endpoints: map[string]string{"http": info.ApiUrl},
Data: data,
}
return "running", []meshdisco.Resource{core, service}
},
})
if err := mesh.Start(); err != nil {
log.Printf("[meta-core] Warning: UDP discovery disabled: %v", err)
mesh = nil
}
} else {
log.Println("[meta-core] UDP discovery disabled by ENABLE_UDP_DISCOVERY")
}
// Report inbox: its own SQLite file beside truth. Not load-bearing — a
// failure to open it leaves /api/reports answering 503, never the core down.
reportStore, err := reports.Open(cfg.ReportsDBPath(), cfg.ReportsMax, int64(cfg.ReportsMaxMB)<<20)
if err != nil {
log.Printf("[meta-core] Warning: report inbox disabled: %v", err)
reportStore = nil
} else {
log.Printf("[meta-core] Report inbox %s (max %d reports, %d MiB)", reportStore.Path(), cfg.ReportsMax, cfg.ReportsMaxMB)
} }
// Create and start API server // Create and start API server
apiServer := api.NewServer(cfg, leaderProvider, disc, cleaner, storageClient) apiServer := api.NewServer(cfg, leaderProvider, storageClient)
apiServer.SetMeshNode(mesh)
if reportStore != nil {
apiServer.SetReports(reportStore)
}
if err := apiServer.Start(); err != nil { if err := apiServer.Start(); err != nil {
log.Fatalf("[meta-core] Failed to start API server: %v", err) log.Fatalf("[meta-core] Failed to start API server: %v", err)
} }
@@ -90,12 +148,16 @@ func main() {
log.Printf("[meta-core] Error stopping API server: %v", err) log.Printf("[meta-core] Error stopping API server: %v", err)
} }
if err := cleaner.Stop(); err != nil { if mesh != nil {
log.Printf("[meta-core] Error stopping service cleaner: %v", err) if err := mesh.Stop(); err != nil {
log.Printf("[meta-core] Error stopping UDP discovery: %v", err)
}
} }
if err := disc.Stop(); err != nil { if reportStore != nil {
log.Printf("[meta-core] Error stopping service discovery: %v", err) if err := reportStore.Close(); err != nil {
log.Printf("[meta-core] Error closing report inbox: %v", err)
}
} }
if err := storageClient.Close(); err != nil { if err := storageClient.Close(); err != nil {
@@ -112,3 +174,17 @@ func waitForShutdown() {
<-sigChan <-sigChan
} }
// coreBaseURL is the browser-facing URL announced for the nav menu. It keeps
// the three-way fallback the file registry used (discovery/service.go):
// META_CORE_PUBLIC_URL wins (the reachable URL when there is no Caddy
// perimeter, e.g. a debug-direct port), then BASE_URL (the Caddy URL), then
// the container IP.
func coreBaseURL(cfg *config.Config) string {
if cfg.PublicURL != "" {
return cfg.PublicURL
}
if cfg.BaseURL != "" {
return cfg.BaseURL
}
return fmt.Sprintf("http://%s:%d", meshdisco.LocalIPv4(), cfg.APIPort)
}
+1 -1
View File
@@ -21,7 +21,7 @@
min-height: 100vh; min-height: 100vh;
} }
</style> </style>
<script type="module" crossorigin src="/assets/index-Cbd1uH5A.js"></script> <script type="module" crossorigin src="/assets/index-Bgo6moAc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DtR_2V4s.css"> <link rel="stylesheet" crossorigin href="/assets/index-DtR_2V4s.css">
</head> </head>
<body> <body>
+10
View File
@@ -8,6 +8,7 @@
"name": "@meta-core/dashboard", "name": "@meta-core/dashboard",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@noble/secp256k1": "^3.1.0",
"iso-639-3": "^3.0.1", "iso-639-3": "^3.0.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@@ -744,6 +745,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@noble/secp256k1": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.1.0.tgz",
"integrity": "sha512-+F7iS7tUMaNGXcc9X3PjmjvuQnXEuSjCRNzVVA2xAcKXgCaP0dHYz4SFyt4FKNHef7sOP//xihowcySSS7PK9g==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@remix-run/router": { "node_modules/@remix-run/router": {
"version": "1.23.2", "version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
+3 -2
View File
@@ -9,10 +9,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@noble/secp256k1": "^3.1.0",
"iso-639-3": "^3.0.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.20.0", "react-router-dom": "^6.20.0"
"iso-639-3": "^3.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.38", "@types/react": "^18.2.38",
+8
View File
@@ -4,6 +4,7 @@ import Mounts from './pages/Mounts';
import Watchers from './pages/Watchers'; import Watchers from './pages/Watchers';
import Files from './pages/Files'; import Files from './pages/Files';
import Identity from './pages/Identity'; import Identity from './pages/Identity';
import Reports from './pages/Reports';
import ServiceNav from './components/ServiceNav'; import ServiceNav from './components/ServiceNav';
const navStyle: React.CSSProperties = { const navStyle: React.CSSProperties = {
@@ -81,6 +82,12 @@ export default function App() {
> >
Identity Identity
</NavLink> </NavLink>
<NavLink
to="/reports"
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
>
Reports
</NavLink>
<NavLink <NavLink
to="/editor" to="/editor"
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)} style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
@@ -97,6 +104,7 @@ export default function App() {
<Route path="/watchers" element={<Watchers />} /> <Route path="/watchers" element={<Watchers />} />
<Route path="/files" element={<Files />} /> <Route path="/files" element={<Files />} />
<Route path="/identity" element={<Identity />} /> <Route path="/identity" element={<Identity />} />
<Route path="/reports" element={<Reports />} />
<Route path="/editor" element={<EditorPage />} /> <Route path="/editor" element={<EditorPage />} />
</Routes> </Routes>
</div> </div>
+38 -140
View File
@@ -1,155 +1,53 @@
import { useState, useEffect } from 'react'; import { useEffect } from 'react';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore -- plain ES module, no .d.ts; imported for its side effect
// of defining the custom element.
import '../meta-service-menu.js';
interface ServiceInfo { /**
name: string; * ServiceNav — the neighbouring meta-* services, as a burger menu.
hostname: string; *
baseUrl: string; * Since meta-discovery v1 this is a thin wrapper around <meta-service-menu>,
status: string; * a framework-free custom element shared by every meta-* UI (see
lastHeartbeat: string; * scripts/check-mirrors.sh). The old implementation was ~165 lines of React
} * that existed in four near-identical copies and polled meta-core's
* /api/services; the element polls this service's own /api/neighbors instead,
* so the nav still renders when meta-core is down.
*
* The element lives in shadow DOM and cannot see this app's CSS variables, so
* the four --mm-nav-* tokens below map our palette onto it.
*/
interface ServicesResponse { declare global {
services: ServiceInfo[]; // eslint-disable-next-line @typescript-eslint/no-namespace
current: string; namespace JSX {
} interface IntrinsicElements {
'meta-service-menu': React.DetailedHTMLProps<
const serviceIcons: Record<string, string> = { React.HTMLAttributes<HTMLElement>,
'meta-core': '🗄️', HTMLElement
'meta-sort': '📁', > & { current?: string; endpoint?: string; label?: string };
'meta-fuse': '🗂️', }
'meta-stremio': '🎬', }
'meta-orbit': '🌐',
'default': '📦'
};
function formatServiceName(name: string): string {
return name.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ');
} }
function ServiceNav() { function ServiceNav() {
const [services, setServices] = useState<ServiceInfo[]>([]);
const [loading, setLoading] = useState(true);
const currentService = 'meta-core';
useEffect(() => { useEffect(() => {
const fetchServices = async () => { // Import above is for the side effect of defining the element; this
try { // keeps bundlers from tree-shaking it away in production builds.
const response = await fetch('/services');
if (response.ok) {
const data: ServicesResponse = await response.json();
setServices(data.services || []);
}
} catch (error) {
console.error('Failed to fetch services:', error);
} finally {
setLoading(false);
}
};
fetchServices();
const interval = setInterval(fetchServices, 30000);
return () => clearInterval(interval);
}, []); }, []);
if (loading) return null;
const sortedServices = [...services].sort((a, b) => a.name.localeCompare(b.name));
if (sortedServices.length === 0) return null;
return ( return (
<nav className="services-nav"> <>
<span className="services-nav-label">Services:</span> <meta-service-menu current="meta-core" />
<div className="services-nav-items">
{sortedServices.map(service => {
const icon = serviceIcons[service.name] || serviceIcons.default;
const isActive = service.name === currentService;
return (
<a
key={service.name}
href={isActive ? '#' : service.baseUrl}
className={`service-link${isActive ? ' active' : ''}`}
onClick={(e) => {
e.preventDefault();
if (!isActive) {
window.location.href = service.baseUrl;
}
}}
>
<span className="service-icon">{icon}</span>
<span>{formatServiceName(service.name)}</span>
<span className="service-status"></span>
</a>
);
})}
</div>
<style>{` <style>{`
.services-nav { meta-service-menu {
display: flex; --mm-nav-fg: var(--text-primary, #e0e0e0);
align-items: center; --mm-nav-bg: var(--bg-tertiary, var(--bg-secondary, #1a1a2e));
gap: 0.5rem; --mm-nav-border: var(--border-color, rgba(255,255,255,0.14));
} --mm-nav-accent: var(--accent-primary, #4ecdc4);
.services-nav-label {
color: #888;
font-size: 0.8rem;
white-space: nowrap;
}
.services-nav-items {
display: flex;
gap: 0.35rem;
}
.service-link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.6rem;
border-radius: 6px;
text-decoration: none;
font-size: 0.8rem;
font-weight: 500;
transition: all 0.2s;
color: #ccc;
background: #0f3460;
border: 1px solid #1a4a7a;
}
.service-link:hover {
background: #1a4a7a;
border-color: rgba(78, 205, 196, 0.5);
text-decoration: none;
color: #fff;
}
.service-link.active {
background: linear-gradient(135deg, rgba(78, 205, 196, 0.2), rgba(68, 160, 141, 0.2));
border-color: rgba(78, 205, 196, 0.5);
color: #fff;
}
.service-link .service-icon {
font-size: 0.9rem;
}
.service-link .service-status {
width: 5px;
height: 5px;
border-radius: 50%;
background: #4ecdc4;
}
@media (max-width: 800px) {
.services-nav-label {
display: none;
}
} }
`}</style> `}</style>
</nav> </>
); );
} }
+28
View File
@@ -0,0 +1,28 @@
// JSON fetch that fails with a sentence instead of a parser error.
//
// nginx serves the dashboard with `try_files $uri $uri/ /index.html`, so ANY
// path it does not have a proxy rule for answers 200 text/html with the SPA
// shell. A caller that does `res.ok && res.json()` on such a path gets
// `SyntaxError: Unexpected token '<', "<!doctype "... is not valid JSON` and
// no clue which request produced it — which is exactly how a route deleted
// from the Go router (`/services`, removed in favour of /api/neighbors) sat
// broken on the dashboard.
//
// So: check the content type, and name the URL in every error.
export async function getJSON<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
const ct = res.headers.get('content-type') || '';
if (!res.ok) {
throw new Error(`${url} → HTTP ${res.status}`);
}
if (!ct.includes('application/json')) {
// The SPA fallback is the overwhelmingly likely cause; say so.
throw new Error(
`${url} → ${res.status} ${ct || 'no content-type'} (not JSON — the route is ` +
`missing and nginx served the dashboard shell instead)`
);
}
return (await res.json()) as T;
}
+271
View File
@@ -0,0 +1,271 @@
// How a report reads at a glance (the inbox row headline) and in full (the
// copy-as-text dump pasted into the Deck board).
//
// meta-core stores a report's body verbatim and knows nothing about it, so
// the meaning lives here, per app. The meta-watch headline is ported from the
// /info page that used to own reports (meta-watch ui/info.html,
// reportHeadline) — its comments explain why each chip earned its place; the
// short version is that every chip there is a stall or desync that once read
// as "healthy" until it was measured. Anything an app adds that this file does
// not know still renders: in the dump, and as a JSON section in the detail.
/* eslint-disable @typescript-eslint/no-explicit-any */
export type Body = Record<string, any>;
export interface ReportSummary {
id: number;
ts: number;
received: number;
app: string;
build?: string;
uid?: string;
page?: string;
route?: string;
cid?: string;
title?: string;
note?: string;
status: 'open' | 'triaged' | 'closed';
triage_note?: string;
updated?: number;
log_lines: number;
bytes: number;
}
export interface Report extends ReportSummary {
body: Body;
}
/** One headline segment; `strong` ones are what a human wrote or a failure. */
export interface Bit {
text: string;
strong?: boolean;
}
export function fmtDur(ms: number): string {
if (ms == null || !isFinite(ms)) return '?';
if (ms < 1000) return `${Math.round(ms)}ms`;
const s = ms / 1000;
if (s < 60) return `${s.toFixed(s < 10 ? 1 : 0)}s`;
const m = Math.floor(s / 60);
const r = Math.round(s % 60);
if (m < 60) return `${m}m${r ? ` ${r}s` : ''}`;
return `${Math.floor(m / 60)}h ${m % 60}m`;
}
export function shortCid(cid: string): string {
return cid && cid.length > 16 ? `${cid.slice(0, 8)}…${cid.slice(-6)}` : cid || '';
}
const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
/** First line of the build blob, minus the app prefix: which build filed it. */
export function buildLabel(r: ReportSummary): string {
const first = (r.build || '').split('\n')[0];
return first.replace(/^meta-(watch|read|listen)( page| server)?\s+/, '').trim();
}
/** The device a report came from, in one line. */
export function deviceLine(d: any): string {
if (!d || typeof d !== 'object') return '';
const bits: string[] = [];
if (d.model) bits.push(d.model);
else if (d.platform) bits.push(d.platform);
if (d.isTv) bits.push(d.tvForced ? 'TV (forced)' : 'TV');
if (d.screen) bits.push(d.screen);
if (d.cores) bits.push(`${d.cores} cores`);
if (d.memoryGb) bits.push(`${d.memoryGb} GB`);
if (d.gpu) bits.push(d.gpu);
return bits.join(' · ');
}
/** What the row is about: a title, a page + route, or a cid. */
export function subject(r: ReportSummary, body?: Body): string {
const c = (body && body.context) || {};
if (r.page === 'search') return c.query ? `search “${c.query}”` : 'search';
if (r.title) return r.title;
if (r.page && r.page !== 'watch' && r.page !== 'read') return r.page;
if (r.cid) return shortCid(r.cid);
return r.page || '—';
}
// ── Headlines ─────────────────────────────────────────────────────────────
function failureBit(c: any): Bit | null {
const f = c && (c.failure || c.lastFailure);
if (!f) return null;
const who = c.lastFailure ? ` ${f.title || shortCid(f.cid || '')}` : '';
const tries = Array.isArray(f.attempts) && f.attempts.length > 1 ? ` · ${f.attempts.length} tries` : '';
return {
text: `FAILED${who}${f.elapsedMs != null ? ' after ' + fmtDur(f.elapsedMs) : ''}: ${clip(f.message || '', 90)}${tries}`,
strong: true,
};
}
function watchBits(r: ReportSummary, b: Body): string[] {
const bits: string[] = [];
const c = b.context || {};
if (r.page === 'search') {
if (c.state) bits.push(c.state === 'errored' ? 'STREAM ERRORED' : c.state);
if (c.anchored != null) {
bits.push(
`${c.anchored} anchored · ${c.raw || 0} raw` +
(c.shown != null && c.shown !== c.anchored ? ` · ${c.shown} after language filter` : '')
);
}
if (c.elapsedMs != null) bits.push(`stream ${fmtDur(c.elapsedMs)}`);
if (c.cacheSeeded) bits.push('painted from cache');
const ups = Array.isArray(c.upstreams) ? c.upstreams : [];
if (c.state !== 'idle' && c.state !== 'youtube-paste') {
if (ups.length) {
const recs = ups.reduce((n: number, u: any) => n + (u.records || 0), 0);
bits.push(`${ups.length} upstream${ups.length === 1 ? '' : 's'} (${recs} records)`);
} else bits.push('no upstream seen');
}
} else if (r.page === 'title') {
if (c.episodes != null) {
bits.push(`${c.episodes} release${c.episodes === 1 ? '' : 's'}` + (c.rawSources ? ` · ${c.rawSources} raw` : ''));
}
if (c.streamsOpen) bits.push(`${c.streamsOpen} stream${c.streamsOpen === 1 ? '' : 's'} open`);
else if (c.settled != null) bits.push(c.settled ? 'settled' : 'not settled');
if (c.placeholderCard) bits.push('placeholder hero');
}
if (c.lastPlayback) bits.push(`after playing ${c.lastPlayback.title || shortCid(c.lastPlayback.cid || '')}`);
const d = b.decode || {};
const p = b.pipeline || {};
if (d.underruns != null) bits.push(`${d.underruns} underrun${d.underruns === 1 ? '' : 's'}`);
if (d.minAhead != null) bits.push(`min ${(+d.minAhead).toFixed(1)}s ahead`);
if (d.decoded) {
const pct = (d.dropped / d.decoded) * 100;
bits.push(`${d.dropped} dropped (${pct.toFixed(pct >= 1 ? 1 : 2)}%)`);
}
if (d.capability) bits.push(d.capability);
if (p.pctDemuxCpu != null) bits.push(`demux CPU ${p.pctDemuxCpu}%`);
// Subtitle verdict: a stalled overlay is invisible in every other number.
const sub = b.subtitles;
if (sub && sub.up) {
const ago = sub.lastDrawAgoMs;
if (ago == null) bits.push('subs: NO DRAWS');
else if (ago > 4000) {
const why =
sub.paused === true ? ' while PAUSED'
: sub.advancing === true ? ' while playing'
: sub.paused === false ? ' (playing, not advancing)'
: '';
bits.push(`subs: STALLED ${fmtDur(ago)}${why}`);
} else bits.push(`subs ok (${sub.draws} draws)`);
if (sub.skipped > 0 && sub.draws > 0) {
const share = Math.round((100 * sub.skipped) / (sub.skipped + sub.draws));
if (share >= 15) bits.push(`subs: ${share}% of frames dropped`);
}
if (sub.commitMaxMs != null && sub.commitMaxMs > 250) {
bits.push(
`subs: commit ${sub.commitMs}/${sub.commitMaxMs}ms` +
(sub.pruned ? ` (${sub.lines}+${sub.pruned}p lines)` : ` (${sub.lines} lines)`)
);
}
if (sub.lastEventAgoMs != null && sub.lastEventAgoMs > 30000 && sub.advancing) {
bits.push(`subs: no packets for ${fmtDur(sub.lastEventAgoMs)}`);
}
if (sub.canvas && sub.canvas !== 'ok') bits.push(`subs canvas ${sub.canvas}`);
if (sub.videoW === 0 || sub.resizeDeferred) bits.push('subs: overlay resize owed (video has no size)');
else if (sub.resizesBlocked > 0) {
bits.push(`subs: ${sub.resizesBlocked} zero-size resize${sub.resizesBlocked > 1 ? 's' : ''} blocked`);
}
} else if (sub && sub.failed) {
const dd = sub.death;
if (!dd) bits.push('subs: fell back to WebVTT (build too old to say why)');
else {
bits.push(`subs: fell back to WebVTT at ${fmtDur(dd.mediaTime)} — ${dd.why}`);
if (dd.commitFailures) bits.push(`after ${dd.commitFailures} rejected commits`);
if (dd.strikes) bits.push(`after ${dd.strikes} stuck-loop strikes`);
bits.push(dd.draws ? `had drawn ${dd.draws} frames (${dd.lines}+${dd.pruned}p lines)` : 'never drew a frame');
if (dd.canvas && dd.canvas !== 'ok') bits.push(`canvas ${dd.canvas} at death`);
if (dd.resizesBlocked) bits.push(`${dd.resizesBlocked} zero-size resizes blocked`);
if (dd.videoW === 0) bits.push('video had no size at death');
}
}
// A/V sync: only the peak earns a chip.
const au = b.audio;
if (au && au.driftMaxMs != null && Math.abs(au.driftMaxMs) >= 40) {
const at = au.driftMaxAtSec != null ? ` @ ${fmtDur(au.driftMaxAtSec * 1000)}` : '';
bits.push(`A/V DESYNC ${au.driftMaxMs > 0 ? '+' : ''}${au.driftMaxMs}ms${at}`);
}
if (au && au.path === 'polyfill') bits.push(`audio ${au.codec || '?'} via polyfill`);
const rs = b.restart;
if (rs && rs.audioMinusVideoSec != null && rs.audioMinusVideoSec < -0.001) {
bits.push(`restart base from AUDIO (${rs.audioMinusVideoSec.toFixed(3)}s)`);
}
if (b.ttmvpMs != null) bits.push(`TTMVP ${fmtDur(b.ttmvpMs)}`);
if (b.capturedMsAgo != null && b.capturedMsAgo > 2000) bits.push(`measured ${fmtDur(b.capturedMsAgo)} before filing`);
return bits;
}
function readBits(r: ReportSummary, b: Body): string[] {
const bits: string[] = [];
const rd = b.reader;
if (rd && typeof rd === 'object') {
if (rd.page != null) bits.push(`page ${rd.page + 1}${rd.pages ? `/${rd.pages}` : ''}`);
if (rd.flow) bits.push(rd.flow);
if (rd.direction) bits.push(rd.direction);
if (rd.spread) bits.push('spread');
if (rd.strip && rd.strip.segments) bits.push(`strip ${rd.strip.active ?? '?'}/${rd.strip.segments}`);
if (rd.via) bits.push(`via ${rd.via}`);
if (rd.atEnd) bits.push('at end');
}
const c = b.context || {};
if (r.page === 'search') {
if (c.state) bits.push(c.state);
if (c.count != null) bits.push(`${c.count} shown`);
}
return bits;
}
/** The row headline: the note first (the only part a human wrote), then a
* failure if there was one, then the app's own measurements. */
export function headline(r: ReportSummary, body?: Body): Bit[] {
const out: Bit[] = [];
if (r.note) out.push({ text: `“${clip(r.note, 80)}”`, strong: true });
if (!body) return out;
const f = failureBit(body.context);
if (f) out.push(f);
const bits = r.app === 'meta-watch' ? watchBits(r, body) : r.app === 'meta-read' ? readBits(r, body) : [];
for (const t of bits) out.push({ text: t });
if (body.flags && typeof body.flags === 'object' && Object.keys(body.flags).length) {
out.push({ text: 'overrides: ' + Object.entries(body.flags).map(([k, v]) => `${k}=${v}`).join(' ') });
}
return out;
}
// ── Full text dump ────────────────────────────────────────────────────────
const LIFTED = new Set(['app', 'build', 'uid', 'page', 'route', 'cid', 'title', 'note', 'log']);
/** Every block of a report that is not one of the lifted fields or the log,
* in a stable order: context first, device second, then alphabetical. */
export function blocks(body: Body): [string, any][] {
const keys = Object.keys(body).filter((k) => !LIFTED.has(k));
const rank = (k: string) => (k === 'context' ? 0 : k === 'device' ? 1 : 2);
keys.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
return keys.map((k) => [k, body[k]]);
}
/** The whole report as text, for pasting into a ticket. */
export function dump(r: Report): string {
const parts: string[] = [];
parts.push(`REPORT #${r.id} · ${r.app} · ${new Date(r.ts).toISOString()} · ${r.status}` +
(r.uid ? `\nUID ${r.uid}` : ''));
if (r.note) parts.push('NOTE\n' + r.note);
if (r.page || r.route) parts.push('PAGE ' + (r.page || '—') + (r.route ? '\nROUTE ' + r.route : ''));
if (r.cid || r.title) parts.push(`CONTENT ${r.title || ''}${r.cid ? ` (${r.cid})` : ''}`.trim());
if (r.build) parts.push(r.build);
for (const [k, v] of blocks(r.body || {})) {
parts.push(k.toUpperCase() + '\n' + (typeof v === 'string' ? v : JSON.stringify(v, null, 2)));
}
const log: string[] = Array.isArray(r.body?.log) ? r.body.log : [];
if (log.length) parts.push(`LOG (${log.length} lines)\n` + log.join('\n'));
if (r.triage_note) parts.push('TRIAGE\n' + r.triage_note);
return parts.join('\n\n');
}
+127
View File
@@ -0,0 +1,127 @@
// Client-side proof of possession for account operations.
//
// meta-core stores every private key in plaintext, so it can sign as anybody —
// which means a signature it produced proves nothing about who asked. The only
// signature that carries authority is one made where the key actually is, and
// for a browser-driven admin console that is here. meta-core enforces the other
// half by refusing to sign anything in the challenge domain
// (identity.ChallengeDomain), so this path cannot be short-circuited.
//
// The secret key entered on this page is used for exactly one signature and is
// never sent anywhere: `/api/identity/challenge` receives only the uid, and the
// request that follows carries only the challenge and its signature.
//
// Same scheme, same curve and same encodings as meta-watch's sign-in
// (packages/meta-watch/ui/js/auth.js) — deliberately, so one implementation on
// the server verifies both.
import * as secp from '@noble/secp256k1';
export type IdentityAction = 'reveal' | 'delete';
function hexToBytes(hex: string): Uint8Array | null {
let h = hex.trim();
if (h.startsWith('0x') || h.startsWith('0X')) h = h.slice(2);
if (h.length !== 64 || /[^0-9a-fA-F]/.test(h)) return null;
const out = new Uint8Array(32);
for (let i = 0; i < 32; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
return out;
}
function bytesToBase64(bytes: Uint8Array): string {
let s = '';
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s);
}
const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
/// base58btc, the multibase "z" alphabet. Spelled out rather than pulled in:
/// the alternative is a second dependency for thirty lines of arithmetic.
function base58Encode(bytes: Uint8Array): string {
const digits: number[] = [0];
for (const byte of bytes) {
let carry = byte;
for (let i = 0; i < digits.length; i++) {
carry += digits[i] << 8;
digits[i] = carry % 58;
carry = (carry / 58) | 0;
}
while (carry) {
digits.push(carry % 58);
carry = (carry / 58) | 0;
}
}
// Leading zero bytes encode as leading '1's rather than being dropped.
let out = '';
for (let i = 0; i < bytes.length && bytes[i] === 0; i++) out += B58[0];
for (let i = digits.length - 1; i >= 0; i--) out += B58[digits[i]];
return out;
}
/// The uid a secret key belongs to: "z" + base58btc(33-byte compressed pubkey).
/// Derived locally so a mistyped or wrong-account key is caught before any
/// request goes out. Mirrors `identityFromPrivKey` in meta-core's identity.go.
export function uidFromSecretKey(secretKeyHex: string): string | null {
const raw = hexToBytes(secretKeyHex);
if (!raw) return null;
try {
return 'z' + base58Encode(secp.getPublicKey(raw, true));
} catch {
return null; // not a valid scalar
}
}
/// Whether this page can sign at all.
///
/// noble's signAsync takes its sha256 from crypto.subtle, which browsers expose
/// only in a secure context. The dashboard behind Caddy is https and fine; the
/// debug-direct port (18083) is plain http, where every signature would fail
/// with a cryptic error unless we say so first.
export function canSign(): boolean {
return Boolean(globalThis.crypto?.subtle);
}
export const INSECURE_CONTEXT_MESSAGE =
'This page cannot sign over a plain-http connection — browsers only expose the ' +
'needed crypto on https (or localhost). Reach the dashboard through Caddy ' +
'(https://metacore-dev.localhost:8083) rather than the debug-direct port.';
/// Ask meta-core for a challenge, sign it locally, and hand back the proof.
///
/// Throws with a message fit to show the user. The caller passes the proof
/// straight into the reveal/delete request body.
export async function proveOwnership(
uid: string,
action: IdentityAction,
secretKeyHex: string
): Promise<{ challenge: string; signature: string }> {
if (!canSign()) throw new Error(INSECURE_CONTEXT_MESSAGE);
const raw = hexToBytes(secretKeyHex);
if (!raw) {
throw new Error('That is not a valid secret key: expected 64 hex characters.');
}
const derived = uidFromSecretKey(secretKeyHex);
if (!derived) throw new Error('That is not a valid secp256k1 secret key.');
if (derived !== uid) {
// Caught here rather than by the server, so the message can say which
// account the key actually is — the useful half of the answer.
throw new Error(`That key belongs to a different account (${derived}), not this one.`);
}
const cRes = await fetch('/api/identity/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uid, action }),
});
const cBody = await cRes.json().catch(() => ({}));
if (!cRes.ok || !cBody.challenge) {
throw new Error(cBody.message ?? `Could not start verification (HTTP ${cRes.status}).`);
}
// signAsync prehashes with sha256 and returns a 64-byte compact (r‖s)
// signature — noble v3 dropped DER. meta-core's identity.Verify accepts both.
const sig = await secp.signAsync(new TextEncoder().encode(cBody.challenge), raw);
return { challenge: cBody.challenge, signature: bytesToBase64(sig) };
}
+308
View File
@@ -0,0 +1,308 @@
/**
* <meta-service-menu> — the neighbouring meta-* services, as a burger menu.
*
* Replaces the eight divergent "services bar" implementations (four copies of
* a React `ServiceNav.tsx`, two mirrored `services.js`, two bespoke inline
* ones) with a single framework-free custom element. It drops into the React
* dashboards as a plain tag — no React dependency — and into the vanilla UIs
* as an ES module.
*
* Data comes from the host service's own `GET /api/neighbors`, same-origin.
* Never cross-origin: the dev stack's self-signed certs and the OIDC redirects
* make a fetch to a neighbour a dead end. Links are plain navigations.
*
* ⚠ MIRRORED FILE. Byte-identical copies live in every meta-* UI tree; see
* `scripts/check-mirrors.sh`. Edit one → edit all.
*
* Shadow DOM is not optional here. The eight UIs use four incompatible token
* vocabularies, and two of them define *different colours under the same
* names* (`--bg-secondary` is #12121a in meta-sort, #16213e in meta-core,
* #161b22 in meta-dup). meta-search and meta-share are also the only
* light-mode-capable UIs, so a hardcoded dark palette would break them.
* The element therefore styles itself from four tokens with dark defaults:
*
* --mm-nav-fg, --mm-nav-bg, --mm-nav-border, --mm-nav-accent
*
* Each host maps its own variables onto those, e.g.
* meta-service-menu { --mm-nav-bg: var(--bg-secondary); }
*
* Attributes:
* current name of the host service, so it can be marked and not linked
* endpoint override the default "/api/neighbors"
* label button tooltip (default "Services")
*
* Property:
* rewriteUrl optional (name, url, neighbour) => url, for hosts that must
* translate internal hostnames into browser-reachable ones
* (meta-gateway does this for its dev/prod split).
*/
const ICONS = {
"meta-core": "⚙️",
"meta-sort": "📁",
"meta-fuse": "🗂️",
"meta-stremio": "🎬",
"meta-dup": "🔍",
"meta-search": "🔎",
"meta-share": "🔗",
"meta-gateway": "🌉",
"meta-watch": "📺",
"meta-listen": "🎧",
"meta-read": "📖",
};
const DEFAULT_ICON = "📦";
const POLL_MS = 30000;
function titleCase(name) {
return String(name || "")
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
const TEMPLATE = `
<style>
:host {
--_fg: var(--mm-nav-fg, #e8eaed);
--_bg: var(--mm-nav-bg, #1a1a2e);
--_border: var(--mm-nav-border, rgba(255, 255, 255, 0.14));
--_accent: var(--mm-nav-accent, #4ecdc4);
position: relative;
display: inline-block;
font-family: inherit;
font-size: 14px;
line-height: 1.4;
}
button.trigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
width: 2.1rem;
height: 2.1rem;
padding: 0;
cursor: pointer;
color: var(--_fg);
background: transparent;
border: 1px solid var(--_border);
border-radius: 8px;
font: inherit;
transition: border-color 0.15s, background 0.15s;
}
button.trigger:hover { border-color: var(--_accent); }
button.trigger:focus-visible {
outline: 2px solid var(--_accent);
outline-offset: 2px;
}
button.trigger[aria-expanded="true"] { border-color: var(--_accent); }
.bars { display: block; width: 15px; }
.bars span {
display: block;
height: 2px;
margin: 3px 0;
border-radius: 2px;
background: currentColor;
}
.panel {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 1000;
min-width: 13rem;
max-height: 70vh;
overflow-y: auto;
padding: 0.3rem;
background: var(--_bg);
border: 1px solid var(--_border);
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
}
.panel[hidden] { display: none; }
a.item, .item {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.6rem;
border-radius: 6px;
color: var(--_fg);
text-decoration: none;
white-space: nowrap;
}
a.item:hover { background: color-mix(in srgb, var(--_accent) 18%, transparent); }
.item.active { color: var(--_accent); font-weight: 600; cursor: default; }
.item.dead { opacity: 0.55; cursor: not-allowed; }
.icon { font-size: 1rem; line-height: 1; }
.dot {
width: 6px;
height: 6px;
margin-left: auto;
border-radius: 50%;
background: var(--_accent);
}
.empty {
padding: 0.6rem;
opacity: 0.7;
font-size: 0.85em;
white-space: nowrap;
}
/* Respect a host that opts out of motion. */
@media (prefers-reduced-motion: reduce) {
button.trigger { transition: none; }
}
</style>
<button class="trigger" type="button" aria-haspopup="menu" aria-expanded="false">
<span class="bars" aria-hidden="true"><span></span><span></span><span></span></span>
</button>
<div class="panel" role="menu" hidden></div>
`;
class MetaServiceMenu extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = TEMPLATE;
this._btn = this.shadowRoot.querySelector("button.trigger");
this._panel = this.shadowRoot.querySelector(".panel");
this._neighbors = [];
this._timer = null;
this._open = false;
this._onDocClick = (e) => {
if (!this._open) return;
if (e.composedPath().includes(this)) return;
this._setOpen(false);
};
this._onKey = (e) => {
if (e.key === "Escape" && this._open) {
this._setOpen(false);
this._btn.focus();
}
};
}
connectedCallback() {
this._btn.title = this.getAttribute("label") || "Services";
this._btn.setAttribute("aria-label", this._btn.title);
this._btn.addEventListener("click", () => this._setOpen(!this._open));
document.addEventListener("click", this._onDocClick);
document.addEventListener("keydown", this._onKey);
this.refresh();
this._timer = setInterval(() => this.refresh(), POLL_MS);
}
disconnectedCallback() {
if (this._timer) clearInterval(this._timer);
this._timer = null;
document.removeEventListener("click", this._onDocClick);
document.removeEventListener("keydown", this._onKey);
}
get endpoint() {
return this.getAttribute("endpoint") || "/api/neighbors";
}
get current() {
return this.getAttribute("current") || "";
}
async refresh() {
try {
const res = await fetch(this.endpoint, {
headers: { Accept: "application/json" },
});
if (!res.ok) throw new Error(String(res.status));
const data = await res.json();
// `services` is the legacy alias some backends still emit.
this._neighbors = data.neighbors || data.services || [];
} catch {
// Silent: the menu is navigation, not a health indicator. Keep whatever
// we last had rather than blanking the list on one failed poll.
}
this._render();
}
_setOpen(open) {
this._open = open;
this._panel.hidden = !open;
this._btn.setAttribute("aria-expanded", String(open));
if (open) this.refresh();
}
_render() {
const panel = this._panel;
panel.textContent = "";
const seen = new Set();
const rows = [];
for (const n of this._neighbors) {
const name = n.name || "";
if (!name || seen.has(name)) continue;
// Beacon v2 neighbours include plugins; the nav lists services only.
// (A row without `caps` comes from a pre-v2 backend — keep it.)
const isService = (c) => String(c).startsWith("metamesh.service/");
if (Array.isArray(n.caps) && !n.caps.some(isService)) continue;
seen.add(name);
rows.push(n);
}
// Always show the host itself, even before its own echo comes back.
if (this.current && !seen.has(this.current)) {
rows.push({ name: this.current, baseUrl: "" });
}
rows.sort((a, b) => String(a.name).localeCompare(String(b.name)));
if (rows.length === 0) {
const empty = document.createElement("div");
empty.className = "empty";
empty.textContent = "No services found";
panel.appendChild(empty);
return;
}
for (const n of rows) {
const isActive = n.name === this.current;
let url = n.baseUrl || "";
if (typeof this.rewriteUrl === "function") {
try {
url = this.rewriteUrl(n.name, url, n) || "";
} catch {
/* a bad hook must not blank the menu */
}
}
// A neighbour with no reachable URL is shown but not linked — better
// than a link that goes nowhere.
const el = document.createElement(isActive || !url ? "span" : "a");
el.className =
"item" + (isActive ? " active" : "") + (!isActive && !url ? " dead" : "");
if (el.tagName === "A") {
el.href = url;
el.setAttribute("role", "menuitem");
}
if (!isActive && !url) el.title = "No reachable URL advertised";
const icon = document.createElement("span");
icon.className = "icon";
icon.textContent = ICONS[n.name] || DEFAULT_ICON;
const label = document.createElement("span");
label.textContent = titleCase(n.name);
el.append(icon, label);
// Beacon v2 omits `status` when running.
if ((n.status || "running") === "running" || isActive) {
const dot = document.createElement("span");
dot.className = "dot";
dot.title = n.status || "running";
el.appendChild(dot);
}
panel.appendChild(el);
}
}
}
if (!customElements.get("meta-service-menu")) {
customElements.define("meta-service-menu", MetaServiceMenu);
}
export default MetaServiceMenu;
+171 -71
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { getJSON } from '../lib/api';
interface HealthStatus { interface HealthStatus {
status: string; status: string;
@@ -6,6 +7,22 @@ interface HealthStatus {
timestamp: string; timestamp: string;
} }
interface FileStats {
count: number;
totalSize: number;
}
interface Stats {
records: number;
redisKeys: number;
redisMemory: string;
identities: number;
files: FileStats | null;
udlUsers: number | null;
sweptAt: number;
sweeping: boolean;
}
interface WatcherStatus { interface WatcherStatus {
id: string; id: string;
path: string; path: string;
@@ -22,13 +39,6 @@ interface WatchersResponse {
count: number; count: number;
} }
interface ServiceInfo {
name: string;
api: string;
capabilities: string[];
timestamp: number;
}
const cardStyle: React.CSSProperties = { const cardStyle: React.CSSProperties = {
background: '#16213e', background: '#16213e',
borderRadius: '8px', borderRadius: '8px',
@@ -38,53 +48,110 @@ const cardStyle: React.CSSProperties = {
const gridStyle: React.CSSProperties = { const gridStyle: React.CSSProperties = {
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', // 200px, not 220: five tiles must fit one row inside the 1200px container
// (1136 content - 4x16 gap = 1072 / 5 = 214 each) before wrapping.
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '1rem', gap: '1rem',
marginBottom: '1rem',
}; };
const statStyle: React.CSSProperties = { const statStyle: React.CSSProperties = {
fontSize: '2rem', fontSize: '2rem',
fontWeight: 'bold', fontWeight: 'bold',
color: '#4ade80', color: '#4ade80',
lineHeight: 1.2,
}; };
const labelStyle: React.CSSProperties = {
marginBottom: '0.75rem',
color: '#888',
fontSize: '0.85rem',
textTransform: 'uppercase',
letterSpacing: '0.04em',
};
const noteStyle: React.CSSProperties = {
color: '#6b7a99',
fontSize: '0.8rem',
marginTop: '0.4rem',
};
const numberFmt = new Intl.NumberFormat();
function formatCount(n: number | null | undefined): string {
return n === null || n === undefined ? '—' : numberFmt.format(n);
}
// Binary units: this counts bytes on disk, and every other tool the operator
// compares against (du, docker, the mount page) reports GiB.
function formatBytes(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined) return '—';
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
return `${value.toFixed(value >= 100 || unit === 0 ? 0 : 1)} ${units[unit]}`;
}
function Tile({
label,
value,
note,
pending,
}: {
label: string;
value: string;
note?: string;
pending?: boolean;
}) {
return (
<div style={cardStyle}>
<div style={labelStyle}>{label}</div>
<div style={{ ...statStyle, color: pending ? '#6b7a99' : statStyle.color }}>
{value}
</div>
{note && <div style={noteStyle}>{note}</div>}
</div>
);
}
export default function Dashboard() { export default function Dashboard() {
const [health, setHealth] = useState<HealthStatus | null>(null); const [health, setHealth] = useState<HealthStatus | null>(null);
const [stats, setStats] = useState<Stats | null>(null);
const [watchersData, setWatchersData] = useState<WatchersResponse | null>(null); const [watchersData, setWatchersData] = useState<WatchersResponse | null>(null);
const [services, setServices] = useState<ServiceInfo[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false;
const fetchData = async () => { const fetchData = async () => {
try { // Settled, not all: one dead endpoint must not blank every tile.
// Fetch health const [healthRes, statsRes, watchersRes] = await Promise.allSettled([
const healthRes = await fetch('/health'); getJSON<HealthStatus>('/health'),
if (healthRes.ok) { getJSON<Stats>('/api/stats'),
setHealth(await healthRes.json()); getJSON<WatchersResponse>('/api/watchers'),
} ]);
if (cancelled) return;
// Fetch watchers status if (healthRes.status === 'fulfilled') setHealth(healthRes.value);
const watchersRes = await fetch('/api/watchers'); if (statsRes.status === 'fulfilled') setStats(statsRes.value);
if (watchersRes.ok) { if (watchersRes.status === 'fulfilled') setWatchersData(watchersRes.value);
setWatchersData(await watchersRes.json());
}
// Fetch services const failures = [healthRes, statsRes, watchersRes]
const servicesRes = await fetch('/services'); .filter((r): r is PromiseRejectedResult => r.status === 'rejected')
if (servicesRes.ok) { .map((r) => String(r.reason?.message ?? r.reason));
const data = await servicesRes.json(); setError(failures.length ? failures.join(' · ') : null);
setServices(data.services || []);
}
setError(null);
} catch (err) {
setError(String(err));
}
}; };
fetchData(); fetchData();
const interval = setInterval(fetchData, 5000); const interval = setInterval(fetchData, 5000);
return () => clearInterval(interval); return () => {
cancelled = true;
clearInterval(interval);
};
}, []); }, []);
const handleTriggerScan = async () => { const handleTriggerScan = async () => {
@@ -95,49 +162,102 @@ export default function Dashboard() {
} }
}; };
// Aggregate watcher stats const isScanning = watchersData?.watchers.some((w) => w.isScanning) || false;
const totalFiles = watchersData?.watchers.reduce((sum, w) => sum + w.fileCount, 0) || 0; const activeWatchers = watchersData?.watchers.filter((w) => w.active).length || 0;
const isScanning = watchersData?.watchers.some(w => w.isScanning) || false; const lastScan =
const activeWatchers = watchersData?.watchers.filter(w => w.active).length || 0; watchersData?.watchers.reduce((max, w) => Math.max(max, w.lastScan || 0), 0) || 0;
const lastScan = watchersData?.watchers.reduce((max, w) => Math.max(max, w.lastScan || 0), 0) || 0;
// Records we hold metadata for but have no local file: gateway/cid-rooted
// entries, plus anything whose file went away. The interesting number on a
// client box, where it should be most of them.
const fileless =
stats && stats.files ? Math.max(0, stats.records - stats.files.count) : null;
const sweepPending = !!stats && !stats.files;
const sweptNote = (() => {
if (!stats) return undefined;
if (sweepPending) return stats.sweeping ? 'scanning…' : 'not scanned yet';
if (stats.sweeping) return 'refreshing…';
if (stats.sweptAt) return `as of ${new Date(stats.sweptAt).toLocaleTimeString()}`;
return undefined;
})();
return ( return (
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}> <div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto', overflowY: 'auto', height: '100%' }}>
<h1 style={{ marginBottom: '2rem' }}>meta-core Dashboard</h1> <h1 style={{ marginBottom: '2rem' }}>meta-core Dashboard</h1>
{error && ( {error && (
<div style={{ ...cardStyle, background: '#4a1a1a', color: '#f87171' }}> <div style={{ ...cardStyle, background: '#4a1a1a', color: '#f87171' }}>
Error: {error} {error}
</div> </div>
)} )}
<div style={gridStyle}> <div style={gridStyle}>
{/* Health Card */} <Tile
label="Files discovered"
value={formatCount(stats?.files?.count)}
note={sweptNote}
pending={sweepPending}
/>
<Tile
label="Library size"
value={formatBytes(stats?.files?.totalSize)}
note={sweptNote}
pending={sweepPending}
/>
<Tile
label="Metadata entries"
value={formatCount(stats?.records)}
note={
fileless === null
? 'records in the index'
: `${formatCount(fileless)} with no local file`
}
/>
<Tile
label="Store keys"
value={formatCount(stats?.redisKeys)}
note={stats?.redisMemory ? `${stats.redisMemory} on disk` : undefined}
/>
<Tile
label="Identities"
value={formatCount(stats?.identities)}
note={
stats?.udlUsers !== null && stats?.udlUsers !== undefined
? `${formatCount(stats.udlUsers)} with user data`
: 'signing accounts'
}
/>
</div>
<div style={{ ...gridStyle, gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))' }}>
<div style={cardStyle}> <div style={cardStyle}>
<h3 style={{ marginBottom: '1rem', color: '#888' }}>Service Health</h3> <div style={labelStyle}>Service health</div>
{health ? ( {health ? (
<> <>
<div style={statStyle}> <div style={statStyle}>
{health.status === 'ok' ? 'Healthy' : health.status} {health.status === 'ok' ? 'Healthy' : health.status}
</div> </div>
<p>Redis: {health.redis ? 'Connected' : 'Disconnected'}</p> <p style={noteStyle}>
Metadata store: {health.redis ? 'Open' : 'Unavailable'}
</p>
</> </>
) : ( ) : (
<p>Loading...</p> <p style={noteStyle}>Loading…</p>
)} )}
</div> </div>
{/* Watchers Status Card */}
<div style={cardStyle}> <div style={cardStyle}>
<h3 style={{ marginBottom: '1rem', color: '#888' }}>File Watchers</h3> <div style={labelStyle}>File watchers</div>
{watchersData ? ( {watchersData ? (
<> <>
<div style={statStyle}>{totalFiles}</div> <div style={statStyle}>
<p>Files discovered</p> {activeWatchers} / {watchersData.count}
<p>Watchers: {activeWatchers} active / {watchersData.count} total</p> </div>
<p>Status: {isScanning ? 'Scanning...' : 'Idle'}</p> <p style={noteStyle}>active watchers</p>
<p style={noteStyle}>Status: {isScanning ? 'Scanning…' : 'Idle'}</p>
{lastScan > 0 && ( {lastScan > 0 && (
<p>Last scan: {new Date(lastScan).toLocaleString()}</p> <p style={noteStyle}>Last scan: {new Date(lastScan).toLocaleString()}</p>
)} )}
<button <button
onClick={handleTriggerScan} onClick={handleTriggerScan}
@@ -155,27 +275,7 @@ export default function Dashboard() {
</button> </button>
</> </>
) : ( ) : (
<p>Loading...</p> <p style={noteStyle}>Loading…</p>
)}
</div>
{/* Services Card */}
<div style={cardStyle}>
<h3 style={{ marginBottom: '1rem', color: '#888' }}>Connected Services</h3>
<div style={statStyle}>{services.length}</div>
<p>Services registered</p>
{services.length > 0 && (
<ul style={{ marginTop: '1rem', paddingLeft: '1.5rem' }}>
{services.map((svc) => (
<li key={svc.name} style={{ marginBottom: '0.5rem' }}>
<strong>{svc.name}</strong>
<br />
<span style={{ color: '#888', fontSize: '0.9rem' }}>
{svc.capabilities?.join(', ') || 'No capabilities'}
</span>
</li>
))}
</ul>
)} )}
</div> </div>
</div> </div>
+249 -51
View File
@@ -1,5 +1,13 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import {
INSECURE_CONTEXT_MESSAGE,
canSign,
proveOwnership,
uidFromSecretKey,
type IdentityAction,
} from '../lib/sign';
// Admin console for meta-core's account keystore. // Admin console for meta-core's account keystore.
// //
// meta-core stores one secp256k1 keypair per file under // meta-core stores one secp256k1 keypair per file under
@@ -10,6 +18,13 @@ import { useEffect, useRef, useState } from 'react';
// The private key is returned by exactly two calls: POST /api/identity/generate // 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 // (once, at creation) and POST /api/identity/reveal (on demand, for an account
// that already exists). Both land in the same exclusive SecretKeyPanel. // that already exists). Both land in the same exclusive SecretKeyPanel.
//
// Reveal and remove both require the account's own secret key, entered here and
// used to sign a one-shot challenge locally — the key itself is never sent. So
// this page can enumerate and create, but it holds no authority over an
// existing account that its operator does not already have the key for. There
// is deliberately no admin override: on this node the private key *is* the
// account.
interface Account { interface Account {
uid: string; uid: string;
@@ -17,6 +32,14 @@ interface Account {
createdAt: number; createdAt: number;
} }
// What one account holds in the User Data Layer. Absent from
// /api/udl/users/stats means zero, not unknown.
interface Stats {
records: number;
cids: number;
keys: number;
}
// A secret on screen. Carries no createdAt: /api/identity/reveal answers with // A secret on screen. Carries no createdAt: /api/identity/reveal answers with
// {uid, curve, privateKeyHex} only. // {uid, curve, privateKeyHex} only.
interface SecretKeyView { interface SecretKeyView {
@@ -32,6 +55,14 @@ interface Pending {
kind: 'remove' | 'reveal'; kind: 'remove' | 'reveal';
} }
// Both confirmable operations map to a server-side action name, and the
// challenge is bound to it: a challenge minted to reveal a key cannot delete
// the account. Keeping the mapping in one place stops the two drifting.
const ACTION_FOR: Record<Pending['kind'], IdentityAction> = {
reveal: 'reveal',
remove: 'delete',
};
// meta-watch's sentinel cell for profile records: display name and avatar are // 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", ...). // about the *user*, not a content file, so they live at (uid, "self", ...).
// meta-core stores them opaquely and knows nothing of the convention. // meta-core stores them opaquely and knows nothing of the convention.
@@ -148,11 +179,10 @@ async function fetchAccounts(): Promise<Account[]> {
// Resolve an account's display name from the User Data Layer, or null. // 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 // 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 // of the uid, and the account list must render identically whether the store is up
// (identity itself is file-backed, the UDL is not). The name is also *unsigned* // (identity itself is file-backed, the UDL is not). The name is the user's
// — meta-core stores the plaintext beside the signed record and verifies // signed MMUR2 claim (meta-core verified it on write); the uid stays on screen
// nothing — so the uid stays on screen as the real identity, and the length cap // as the real identity, and the length cap bounds the layout.
// bounds what a hostile writer can do to the layout.
async function fetchDisplayName(uid: string): Promise<string | null> { async function fetchDisplayName(uid: string): Promise<string | null> {
const url = const url =
`/api/udl/record?uid=${encodeURIComponent(uid)}` + `/api/udl/record?uid=${encodeURIComponent(uid)}` +
@@ -161,18 +191,106 @@ async function fetchDisplayName(uid: string): Promise<string | null> {
try { try {
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) return null; // 503 when storage is down, 500, … if (!res.ok) return null; // 503 when storage is down, 500, …
const data = (await res.json()) as { exists?: boolean; value?: unknown }; const data = (await res.json()) as { exists?: boolean; claim?: { value?: unknown } | null };
// Key off `value`, not `exists`: clearing a name writes a tombstone, which // A retraction (null) or ciphertext ({b64}) has nothing to show.
// leaves the cell existing with no plaintext. Private-tier cells look the const value = data.claim?.value;
// same, which is exactly right — there is nothing to show for either. if (typeof value !== 'string') return null;
if (typeof data.value !== 'string') return null; const name = value.trim();
const name = data.value.trim();
return name ? name.slice(0, 64) : null; return name ? name.slice(0, 64) : null;
} catch { } catch {
return null; return null;
} }
} }
// How much each account holds. One call for the whole page, not one per row:
// meta-core answers it from a keyspace SCAN (there is no per-user cid index),
// so per-row calls would walk the keyspace once per account.
//
// Never throws, for the same reason fetchDisplayName does not: the counts are
// context on top of the uid, and the list must render identically when the store is
// down — accounts are file-backed, their data is not.
//
// Returns null on failure rather than an empty map. The difference is
// load-bearing: an empty map renders as "no stored data" against every account,
// which inside a delete confirmation is a false statement about what is at
// stake. Unknown has to look like unknown.
async function fetchStats(): Promise<Record<string, Stats> | null> {
try {
const res = await fetch('/api/udl/users/stats');
if (!res.ok) return null;
const data = (await res.json()) as { users?: Record<string, Stats> };
return data.users ?? {};
} catch {
return null;
}
}
// The secret-key box shared by the reveal and remove confirmations.
//
// Separate from the surrounding panel so both operations ask the same way, and
// so the key lives in this component's state rather than the page's — it is
// discarded with the panel instead of lingering in a parent that outlives it.
function SecretKeyConfirm({
label,
actionLabel,
busy,
onConfirm,
onCancel,
}: {
label: string;
actionLabel: string;
busy: boolean;
onConfirm: (secretKeyHex: string) => void;
onCancel: () => void;
}) {
const [secret, setSecret] = useState('');
const signable = canSign();
// Derived locally, purely to tell the operator they have the wrong key before
// they hit the button. The server would refuse either way.
const derived = secret.trim() ? uidFromSecretKey(secret) : null;
const mismatch = Boolean(secret.trim() && derived && derived !== label);
return (
<form
onSubmit={(e) => {
e.preventDefault();
onConfirm(secret.trim());
}}
>
<p style={{ color: '#fecaca', margin: '0 0 0.5rem' }}>
Paste this account's secret key to prove it is yours. It is used to sign a one-time
challenge <em>in this browser</em> and is never sent to the server.
</p>
{!signable && <p style={errorTextStyle}>{INSECURE_CONTEXT_MESSAGE}</p>}
<input
style={inputStyle}
type="password"
autoComplete="off"
placeholder="64 hex characters"
value={secret}
onChange={(e) => setSecret(e.target.value)}
disabled={busy || !signable}
autoFocus
/>
{mismatch && (
<p style={{ ...errorTextStyle, marginTop: 0 }}>
That key belongs to a different account ({derived}).
</p>
)}
<button
type="submit"
style={dangerButtonStyle}
disabled={busy || !signable || !secret.trim() || mismatch}
>
{busy ? 'Verifying…' : actionLabel}
</button>
<button type="button" style={buttonStyle} onClick={onCancel} disabled={busy}>
Cancel
</button>
</form>
);
}
// The one place a private key is ever displayed. Rendered exclusively (the // 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 // 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 // freshly generated key is not competing with anything, and so the hardcoded
@@ -351,6 +469,7 @@ function NewAccountPanel({
function AccountCard({ function AccountCard({
account, account,
name, name,
stats,
busy, busy,
disabled, disabled,
pending, pending,
@@ -362,18 +481,24 @@ function AccountCard({
}: { }: {
account: Account; account: Account;
name?: string; name?: string;
// undefined means "counted, and it holds nothing"; null means "could not be
// counted". The two must not read the same on a confirmation screen.
stats?: Stats | null;
busy: boolean; busy: boolean;
disabled: boolean; disabled: boolean;
pending: Pending | null; pending: Pending | null;
error: string | null; error: string | null;
onAsk: (p: Pending) => void; onAsk: (p: Pending) => void;
onCancel: () => void; onCancel: () => void;
onReveal: (uid: string) => void; onReveal: (uid: string, secretKeyHex: string) => void;
onRemove: (uid: string) => void; onRemove: (uid: string, secretKeyHex: string) => void;
}) { }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const uidBoxId = `mm-uid-${account.uid}`; const uidBoxId = `mm-uid-${account.uid}`;
const label = name ?? account.uid; const label = name ?? account.uid;
const counted = stats !== null;
const records = stats?.records ?? 0;
const titles = stats?.cids ?? 0;
const copyUid = async () => { const copyUid = async () => {
await copyToClipboard(account.uid, uidBoxId); await copyToClipboard(account.uid, uidBoxId);
@@ -394,6 +519,16 @@ function AccountCard({
<p style={{ color: '#888', margin: '0.75rem 0 0', fontSize: '0.85rem' }}> <p style={{ color: '#888', margin: '0.75rem 0 0', fontSize: '0.85rem' }}>
Curve: {account.curve} · Created: {formatCreatedAt(account.createdAt)} Curve: {account.curve} · Created: {formatCreatedAt(account.createdAt)}
</p> </p>
<p style={{ color: '#888', margin: '0.25rem 0 0', fontSize: '0.85rem' }}>
{/* An account that has never been used legitimately holds nothing, so
zero is a real answer — but only once the count actually ran. */}
{!counted
? 'Stored data: could not be counted (storage unavailable)'
: records === 0
? 'No stored data'
: `${records.toLocaleString()} stored ${records === 1 ? 'record' : 'records'}` +
` across ${titles.toLocaleString()} ${titles === 1 ? 'title' : 'titles'}`}
</p>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', flexShrink: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', flexShrink: 0 }}>
<button style={{ ...buttonStyle, marginRight: 0 }} onClick={copyUid} disabled={disabled}> <button style={{ ...buttonStyle, marginRight: 0 }} onClick={copyUid} disabled={disabled}>
@@ -425,15 +560,17 @@ function AccountCard({
</p> </p>
<p style={{ color: '#fecaca', marginTop: 0 }}> <p style={{ color: '#fecaca', marginTop: 0 }}>
The key <em>is</em> the account. Anyone who copies it can sign records as this uid on 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 any machine, forever — there is no way to revoke or rotate it afterwards. Because
will hand it over without asking who you are; see the note at the top of this page. proving ownership already requires the key, this only confirms that the copy this node
holds matches yours.
</p> </p>
<button style={dangerButtonStyle} onClick={() => onReveal(account.uid)} disabled={busy}> <SecretKeyConfirm
{busy ? 'Revealing…' : 'Yes, show the private key'} label={account.uid}
</button> actionLabel="Show the private key"
<button style={buttonStyle} onClick={onCancel} disabled={busy}> busy={busy}
Cancel onConfirm={(secret) => onReveal(account.uid, secret)}
</button> onCancel={onCancel}
/>
</div> </div>
)} )}
@@ -454,21 +591,31 @@ function AccountCard({
verifying on every peer that replicated them. Deleting a key is not a recall. verifying on every peer that replicated them. Deleting a key is not a recall.
</li> </li>
<li> <li>
<strong>Its data stays, but goes read-only.</strong> The account's User Data Layer <strong>Its data is destroyed, not orphaned.</strong>{' '}
cells — likes, ratings, watch progress — survive in Redis, and no newer version of {!counted
any of them can ever be written. ? 'This account\u2019s records could not be counted because storage is unavailable — ' +
'so meta-core will refuse this delete rather than remove the key and strand them.'
: records === 0
? 'This account holds no User Data Layer records, so there is nothing to purge.'
: `All ${records.toLocaleString()} User Data Layer ${
records === 1 ? 'record' : 'records'
} — likes, ratings, watch progress across ${titles.toLocaleString()} ${
titles === 1 ? 'title' : 'titles'
} — are permanently deleted from the metadata store, along with every index that names this uid.`}{' '}
This cannot be undone and there is no backup.
</li> </li>
<li> <li>
<strong>This signs nobody out elsewhere.</strong> Any device that holds this key <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. still holds it, and can import it back here at any time.
</li> </li>
</ul> </ul>
<button style={dangerButtonStyle} onClick={() => onRemove(account.uid)} disabled={busy}> <SecretKeyConfirm
{busy ? 'Removing…' : 'Yes, remove this account'} label={account.uid}
</button> actionLabel="Remove this account and all its data"
<button style={buttonStyle} onClick={onCancel} disabled={busy}> busy={busy}
Cancel onConfirm={(secret) => onRemove(account.uid, secret)}
</button> onCancel={onCancel}
/>
</div> </div>
)} )}
</div> </div>
@@ -478,6 +625,8 @@ function AccountCard({
export default function Identity() { export default function Identity() {
const [accounts, setAccounts] = useState<Account[] | null>(null); const [accounts, setAccounts] = useState<Account[] | null>(null);
const [names, setNames] = useState<Record<string, string>>({}); const [names, setNames] = useState<Record<string, string>>({});
const [stats, setStats] = useState<Record<string, Stats> | null>({});
const [notice, setNotice] = useState<string | null>(null);
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 [secret, setSecret] = useState<SecretKeyView | null>(null);
@@ -504,34 +653,43 @@ export default function Identity() {
return; return;
} }
if (gen !== reqGen.current) return; if (gen !== reqGen.current) return;
// Render the list before resolving names, so a down Redis delays nothing. // Render the list before resolving names, so a down store delays nothing.
setAccounts(list); setAccounts(list);
setLoading(false); setLoading(false);
const settled = await Promise.allSettled(list.map((a) => fetchDisplayName(a.uid))); // Names and counts both come after the list is on screen, and neither can
// fail the page: a down store costs decoration, not the account list.
const [settled, counts] = await Promise.all([
Promise.allSettled(list.map((a) => fetchDisplayName(a.uid))),
fetchStats(),
]);
if (gen !== reqGen.current) return; if (gen !== reqGen.current) return;
const next: Record<string, string> = {}; const next: Record<string, string> = {};
settled.forEach((r, i) => { settled.forEach((r, i) => {
if (r.status === 'fulfilled' && r.value) next[list[i].uid] = r.value; 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. // Replace rather than merge, so a removed account's name and counts go with
// it instead of lingering against a uid that no longer exists.
setNames(next); setNames(next);
setStats(counts);
}; };
useEffect(() => { useEffect(() => {
refresh(); refresh();
}, []); }, []);
const reveal = async (uid: string) => { const reveal = async (uid: string, secretKeyHex: string) => {
setBusyUid(uid); setBusyUid(uid);
setRowError(null); setRowError(null);
try { try {
// POST with a body rather than ?uid=, so the target stays out of the const proof = await proveOwnership(uid, ACTION_FOR.reveal, secretKeyHex);
// proxy's access log. The handler accepts either. // POST with a body rather than ?uid=, so neither the target nor the
// signature reaches the proxy's access log. The handler accepts the uid
// either way; the proof must be in the body.
const res = await fetch('/api/identity/reveal', { const res = await fetch('/api/identity/reveal', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirm: true, uid }), body: JSON.stringify({ confirm: true, uid, ...proof }),
}); });
if (!res.ok) { if (!res.ok) {
setRowError({ uid, message: await errorMessage(res, 'Reveal failed') }); setRowError({ uid, message: await errorMessage(res, 'Reveal failed') });
@@ -547,19 +705,34 @@ export default function Identity() {
} }
}; };
const remove = async (uid: string) => { const remove = async (uid: string, secretKeyHex: string) => {
setBusyUid(uid); setBusyUid(uid);
setRowError(null); setRowError(null);
setNotice(null);
try { try {
// The uid is mandatory: with more than one account the backend refuses an const proof = await proveOwnership(uid, ACTION_FOR.remove, secretKeyHex);
// unqualified delete (400 uid_required) rather than guess. // The uid is mandatory: the signature authorises one specific account, so
const res = await fetch(`/api/identity?uid=${encodeURIComponent(uid)}&confirm=true`, { // the backend refuses an unqualified delete rather than guess a target.
const res = await fetch('/api/identity', {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirm: true, uid, ...proof }),
}); });
if (!res.ok) { if (!res.ok) {
setRowError({ uid, message: await errorMessage(res, 'Remove failed') }); setRowError({ uid, message: await errorMessage(res, 'Remove failed') });
return; return;
} }
// Report what the purge actually removed. "Account removed" on its own
// would not distinguish a clean delete from one that stranded the data.
const data = (await res.json().catch(() => ({}))) as { purged?: Stats };
const purged = data.purged;
setNotice(
purged && purged.records > 0
? `Account removed. Purged ${purged.records.toLocaleString()} User Data Layer ` +
`${purged.records === 1 ? 'record' : 'records'} across ` +
`${purged.cids.toLocaleString()} ${purged.cids === 1 ? 'title' : 'titles'}.`
: 'Account removed. It held no User Data Layer records.'
);
setPending(null); setPending(null);
await refresh(); await refresh();
} catch (e) { } catch (e) {
@@ -574,9 +747,24 @@ export default function Identity() {
refresh(); refresh();
}; };
const ask = (p: Pending) => {
setRowError(null);
setNotice(null);
setPending(p);
};
const showList = !loading && !fetchError && !secret; const showList = !loading && !fetchError && !secret;
return ( return (
// App.tsx's content area is `overflow: hidden`, so every page owns its own
// scrolling (Files.tsx does the same). Without this the account list is
// simply clipped at the viewport with no scrollbar — invisible on a node
// with two accounts, and a dead end on one with fifteen.
//
// The scroller is the outer element and the padding lives inside it, so the
// scrollbar sits at the edge of the content area rather than floating in
// 2rem of whitespace, and `margin: 0 auto` still centres the column.
<div style={{ height: '100%', overflowY: 'auto', minHeight: 0 }}>
<div style={{ padding: '2rem', maxWidth: '900px', margin: '0 auto' }}> <div style={{ padding: '2rem', maxWidth: '900px', margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1 style={{ marginBottom: 0 }}>Identity</h1> <h1 style={{ marginBottom: 0 }}>Identity</h1>
@@ -594,15 +782,26 @@ export default function Identity() {
<div style={{ ...dangerPanelStyle, marginTop: 0, marginBottom: '1rem' }}> <div style={{ ...dangerPanelStyle, marginTop: 0, marginBottom: '1rem' }}>
<p style={{ color: '#fecaca', margin: 0 }}> <p style={{ color: '#fecaca', margin: 0 }}>
<strong>Admin page — no authorisation happens here.</strong> <code>/api/identity/*</code>{' '} <strong>The key is the only authority.</strong> Revealing or removing an account requires
asks no questions: anything that can reach this API can list, create, reveal or delete{' '} that account's secret key, entered here and used to sign a one-time challenge in your
<em>any</em> account's private key. The only access control is the perimeter in front of browser — there is no admin override, and holding this page is not one. Listing and
this port. Client-facing screens (meta-watch) authorise reveal and delete against the creating accounts are still open to anything that can reach this API, so the perimeter in
caller's own session and expose no delete at all; this page has neither check. Treat front of this port is what keeps the roster private.
reaching it as equivalent to holding every key on this node. </p>
<p style={{ color: '#fecaca', margin: '0.5rem 0 0' }}>
What this does <em>not</em> defend against: anyone with filesystem access to{' '}
<code>{'{META_CORE_PATH}'}/identity/accounts/</code> reads every private key directly.
The keys are stored in plaintext, matching the trust model of the rest of{' '}
<code>/meta-core</code>.
</p> </p>
</div> </div>
{notice && (
<div style={{ ...cardStyle, border: '1px solid #0f3460' }}>
<p style={{ color: '#9ca3af', margin: 0 }}>{notice}</p>
</div>
)}
{loading && <div style={cardStyle}>Loading…</div>} {loading && <div style={cardStyle}>Loading…</div>}
{fetchError && ( {fetchError && (
@@ -639,14 +838,12 @@ export default function Identity() {
key={a.uid} key={a.uid}
account={a} account={a}
name={names[a.uid]} name={names[a.uid]}
stats={stats === null ? null : stats[a.uid]}
busy={busyUid === a.uid} busy={busyUid === a.uid}
disabled={busyUid !== null} disabled={busyUid !== null}
pending={pending} pending={pending}
error={rowError?.uid === a.uid ? rowError.message : null} error={rowError?.uid === a.uid ? rowError.message : null}
onAsk={(p) => { onAsk={ask}
setRowError(null);
setPending(p);
}}
onCancel={() => setPending(null)} onCancel={() => setPending(null)}
onReveal={reveal} onReveal={reveal}
onRemove={remove} onRemove={remove}
@@ -655,5 +852,6 @@ export default function Identity() {
</> </>
)} )}
</div> </div>
</div>
); );
} }
+406
View File
@@ -0,0 +1,406 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { getJSON } from '../lib/api';
import {
blocks,
buildLabel,
deviceLine,
dump,
headline,
type Report,
type ReportSummary,
shortCid,
subject,
} from '../lib/reportText';
// The bug-report / feedback inbox. Client apps (meta-watch, meta-read, …)
// forward what their 🐛 buttons file to POST /api/reports; this page lists,
// reads and triages them. See internal/reports for storage and bounds.
type Status = 'open' | 'triaged' | 'closed';
const STATUSES: Status[] = ['open', 'triaged', 'closed'];
interface ListPage {
reports: ReportSummary[];
apps: Record<string, number>;
counts: Record<string, number>;
more: boolean;
}
const card: React.CSSProperties = {
background: '#16213e',
borderRadius: '8px',
padding: '1rem',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
minHeight: 0,
};
const chip = (active: boolean): React.CSSProperties => ({
padding: '0.25rem 0.7rem',
borderRadius: '999px',
border: '1px solid #0f3460',
background: active ? '#0f3460' : 'transparent',
color: active ? '#fff' : '#aaa',
cursor: 'pointer',
fontSize: '0.85rem',
});
const button: React.CSSProperties = {
padding: '0.35rem 0.8rem',
borderRadius: '4px',
border: '1px solid #0f3460',
background: '#0f3460',
color: '#fff',
cursor: 'pointer',
fontSize: '0.85rem',
};
const mono: React.CSSProperties = { fontFamily: 'ui-monospace, Menlo, Consolas, monospace', fontSize: '0.8rem' };
const statusColor: Record<Status, string> = { open: '#b45309', triaged: '#1d4ed8', closed: '#374151' };
function StatusBadge({ s }: { s: Status }) {
return (
<span style={{ background: statusColor[s], color: '#fff', borderRadius: '3px', padding: '0.05rem 0.4rem', fontSize: '0.72rem' }}>
{s}
</span>
);
}
const fmtTime = (ms: number) => new Date(ms).toLocaleString();
export default function Reports() {
const [app, setApp] = useState('');
const [status, setStatus] = useState<Status | ''>('open');
const [q, setQ] = useState('');
const [page, setPage] = useState<ListPage | null>(null);
const [older, setOlder] = useState<ReportSummary[]>([]);
const [more, setMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<number | null>(null);
const query = useCallback(
(before?: number) => {
const p = new URLSearchParams();
if (app) p.set('app', app);
if (status) p.set('status', status);
if (q.trim()) p.set('q', q.trim());
if (before) p.set('before', String(before));
return `/api/reports?${p}`;
},
[app, status, q]
);
// The newest page is polled; pages loaded with "older" are kept beneath it
// until a filter changes, and then they own the "more" flag.
const olderCount = useRef(0);
olderCount.current = older.length;
const refresh = useCallback(async () => {
try {
const data = await getJSON<ListPage>(query());
setPage(data);
if (olderCount.current === 0) setMore(data.more);
setError(null);
} catch (e) {
setError(String((e as Error).message || e));
}
}, [query]);
useEffect(() => {
setOlder([]);
setMore(false);
}, [app, status, q]);
useEffect(() => {
refresh();
const t = setInterval(refresh, 10000);
return () => clearInterval(t);
}, [refresh]);
const loadOlder = async () => {
const all = [...(page?.reports || []), ...older];
const last = all[all.length - 1];
if (!last) return;
try {
const data = await getJSON<ListPage>(query(last.id));
setOlder((prev) => [...prev, ...data.reports]);
setMore(data.more);
} catch (e) {
setError(String((e as Error).message || e));
}
};
const rows = [...(page?.reports || []), ...older.filter((o) => !(page?.reports || []).some((r) => r.id === o.id))];
const apps = Object.entries(page?.apps || {}).sort((a, b) => a[0].localeCompare(b[0]));
const total = Object.values(page?.counts || {}).reduce((n, c) => n + c, 0);
return (
<div style={{ padding: '1rem', height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden', boxSizing: 'border-box' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '1rem', flexShrink: 0, flexWrap: 'wrap' }}>
<h1 style={{ margin: '0 0 0.75rem 0' }}>Reports</h1>
<span style={{ color: '#888', fontSize: '0.9rem' }}>bug reports and feedback filed from the apps' 🐛 buttons</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center', marginBottom: '0.75rem', flexShrink: 0 }}>
<span style={chip(status === '')} onClick={() => setStatus('')}>all {total}</span>
{STATUSES.map((s) => (
<span key={s} style={chip(status === s)} onClick={() => setStatus(s)}>
{s} {page?.counts?.[s] ?? 0}
</span>
))}
<span style={{ width: '1rem' }} />
<span style={chip(app === '')} onClick={() => setApp('')}>every app</span>
{apps.map(([a, n]) => (
<span key={a} style={chip(app === a)} onClick={() => setApp(a)}>
{a} {n}
</span>
))}
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="search note, title, cid, uid"
style={{ marginLeft: 'auto', padding: '0.35rem 0.6rem', borderRadius: '4px', border: '1px solid #0f3460', background: '#0b1630', color: '#eee', minWidth: '16rem' }}
/>
</div>
{error && <div style={{ color: '#f87171', marginBottom: '0.5rem', flexShrink: 0 }}>Could not load reports: {error}</div>}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(22rem, 2fr) 3fr', gap: '1rem', flex: 1, minHeight: 0 }}>
<div style={card}>
{rows.length === 0 ? (
<p style={{ color: '#888', margin: 0 }}>{page ? 'No reports match.' : 'Loading…'}</p>
) : (
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
{rows.map((r) => (
<div
key={r.id}
onClick={() => setSelected(r.id)}
style={{
padding: '0.55rem 0.5rem',
borderBottom: '1px solid #0f3460',
cursor: 'pointer',
background: selected === r.id ? '#0f3460' : 'transparent',
}}
>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', fontSize: '0.8rem', color: '#888' }}>
<span style={mono}>#{r.id}</span>
<StatusBadge s={r.status} />
<span>{r.app}</span>
<span style={{ marginLeft: 'auto' }}>{fmtTime(r.ts)}</span>
</div>
<div style={{ margin: '0.2rem 0', color: '#eee' }}>{subject(r)}</div>
<div style={{ fontSize: '0.85rem', color: r.note ? '#ddd' : '#666' }}>
{r.note ? `“${r.note.length > 120 ? r.note.slice(0, 119) + '…' : r.note}”` : 'no note'}
</div>
<div style={{ ...mono, color: '#666', marginTop: '0.15rem' }}>
{buildLabel(r) || 'build unknown'}
{r.uid ? ` · ${shortCid(r.uid)}` : ''}
{r.log_lines ? ` · ${r.log_lines} log lines` : ''}
</div>
</div>
))}
{more && (
<div style={{ padding: '0.75rem', textAlign: 'center' }}>
<button style={button} onClick={loadOlder}>Load older</button>
</div>
)}
</div>
)}
</div>
<div style={card}>
{selected == null ? (
<p style={{ color: '#888', margin: 0 }}>Pick a report to read it.</p>
) : (
<ReportDetail
key={selected}
id={selected}
onChanged={refresh}
onDeleted={() => {
setSelected(null);
setOlder((prev) => prev.filter((r) => r.id !== selected));
refresh();
}}
/>
)}
</div>
</div>
</div>
);
}
function ReportDetail({ id, onChanged, onDeleted }: { id: number; onChanged: () => void; onDeleted: () => void }) {
const [r, setR] = useState<Report | null>(null);
const [error, setError] = useState<string | null>(null);
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const copyTimer = useRef<number>();
useEffect(() => {
getJSON<Report>(`/api/reports/${id}`)
.then((rep) => {
setR(rep);
setNote(rep.triage_note || '');
})
.catch((e) => setError(String(e.message || e)));
return () => window.clearTimeout(copyTimer.current);
}, [id]);
const patch = async (change: { status?: Status; triage_note?: string }) => {
setBusy(true);
try {
const rep = await getJSON<Report>(`/api/reports/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change),
});
setR(rep);
setNote(rep.triage_note || '');
setError(null);
onChanged();
} catch (e) {
setError(String((e as Error).message || e));
} finally {
setBusy(false);
}
};
const remove = async () => {
if (!window.confirm(`Delete report #${id}? This cannot be undone.`)) return;
setBusy(true);
try {
const res = await fetch(`/api/reports/${id}`, { method: 'DELETE' });
if (!res.ok && res.status !== 404) throw new Error(`/api/reports/${id} → HTTP ${res.status}`);
onDeleted();
} catch (e) {
setError(String((e as Error).message || e));
setBusy(false);
}
};
const copy = async () => {
if (!r) return;
try {
await navigator.clipboard.writeText(dump(r));
setCopied(true);
copyTimer.current = window.setTimeout(() => setCopied(false), 1800);
} catch {
setError('Clipboard unavailable here — select the text below and copy it by hand.');
}
};
if (error && !r) return <p style={{ color: '#f87171', margin: 0 }}>Could not load report #{id}: {error}</p>;
if (!r) return <p style={{ color: '#888', margin: 0 }}>Loading #{id}…</p>;
const body = r.body || {};
const bits = headline(r, body);
const device = deviceLine(body.device);
const log: string[] = Array.isArray(body.log) ? body.log : [];
return (
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<h2 style={{ margin: 0, fontSize: '1.15rem' }}>#{r.id} · {subject(r, body)}</h2>
<StatusBadge s={r.status} />
<span style={{ marginLeft: 'auto', display: 'flex', gap: '0.4rem' }}>
{STATUSES.filter((s) => s !== r.status).map((s) => (
<button key={s} style={button} disabled={busy} onClick={() => patch({ status: s })}>
Mark {s}
</button>
))}
<button style={button} onClick={copy}>{copied ? 'Copied' : 'Copy as text'}</button>
<button style={{ ...button, background: '#7f1d1d', borderColor: '#7f1d1d' }} disabled={busy} onClick={remove}>
Delete
</button>
</span>
</div>
{error && <div style={{ color: '#f87171', marginTop: '0.5rem' }}>{error}</div>}
<div style={{ color: '#888', fontSize: '0.85rem', margin: '0.4rem 0 0.75rem' }}>
{r.app} · filed {fmtTime(r.ts)}
{r.received - r.ts > 60000 ? ` · delivered ${fmtTime(r.received)}` : ''}
{r.uid ? ` · uid ${r.uid}` : ''}
</div>
{bits.length > 0 && (
<div style={{ marginBottom: '0.75rem', lineHeight: 1.6 }}>
{bits.map((b, i) => (
<span key={i}>
{i > 0 && <span style={{ color: '#555' }}> · </span>}
{b.strong ? <b style={{ color: '#fff' }}>{b.text}</b> : <span style={{ color: '#ccc' }}>{b.text}</span>}
</span>
))}
</div>
)}
<Field label="Page">
{r.page || '—'}
{r.route && <span style={{ ...mono, color: '#888' }}> {r.route}</span>}
</Field>
{(r.title || r.cid) && (
<Field label="Content">
{r.title}
{r.cid && <span style={{ ...mono, color: '#888' }}> {r.cid}</span>}
</Field>
)}
{device && <Field label="Device">{device}</Field>}
{r.build && (
<Field label="Build">
<pre style={{ ...mono, margin: 0, whiteSpace: 'pre-wrap', color: '#ccc' }}>{r.build}</pre>
</Field>
)}
<Field label="Triage">
<div style={{ display: 'flex', gap: '0.5rem' }}>
<input
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="e.g. Deck #812, duplicate of #40, fixed in 1.0.95"
style={{ flex: 1, padding: '0.35rem 0.6rem', borderRadius: '4px', border: '1px solid #0f3460', background: '#0b1630', color: '#eee' }}
/>
<button style={button} disabled={busy || note === (r.triage_note || '')} onClick={() => patch({ triage_note: note })}>
Save
</button>
</div>
</Field>
{blocks(body)
.filter(([, v]) => v === null || typeof v !== 'object')
.map(([k, v]) => (
<Field key={k} label={k}>
<span style={mono}>{String(v)}</span>
</Field>
))}
{blocks(body).filter(([, v]) => v !== null && typeof v === 'object').map(([k, v]) => (
<details key={k} open={k === 'context' || k === 'reader'} style={{ marginTop: '0.6rem' }}>
<summary style={{ cursor: 'pointer', color: '#9ab' }}>{k}</summary>
<pre style={{ ...mono, background: '#0b1630', padding: '0.6rem', borderRadius: '4px', whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: '0.3rem 0 0' }}>
{typeof v === 'string' ? v : JSON.stringify(v, null, 2)}
</pre>
</details>
))}
{log.length > 0 && (
<details open style={{ marginTop: '0.6rem' }}>
<summary style={{ cursor: 'pointer', color: '#9ab' }}>log ({log.length} lines)</summary>
<pre style={{ ...mono, background: '#0b1630', padding: '0.6rem', borderRadius: '4px', whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: '0.3rem 0 0', maxHeight: '28rem', overflow: 'auto' }}>
{log.join('\n')}
</pre>
</details>
)}
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: 'grid', gridTemplateColumns: '5.5rem 1fr', gap: '0.5rem', margin: '0.3rem 0', alignItems: 'baseline' }}>
<span style={{ color: '#888', fontSize: '0.85rem' }}>{label}</span>
<div>{children}</div>
</div>
);
}
+2 -2
View File
@@ -9,8 +9,8 @@ set -e
# Create required directories # Create required directories
mkdir -p /meta-core/locks mkdir -p /meta-core/locks
mkdir -p /meta-core/db/redis mkdir -p /meta-core/db
mkdir -p /meta-core/services mkdir -p /meta-core/db-cache
mkdir -p /meta-core/mounts/errors mkdir -p /meta-core/mounts/errors
mkdir -p /files mkdir -p /files
mkdir -p /var/log/supervisor mkdir -p /var/log/supervisor
+8 -29
View File
@@ -2,9 +2,14 @@
# ============================================================================= # =============================================================================
# Leader Election Script for meta-core # Leader Election Script for meta-core
# #
# Uses flock for leader election. Only the leader runs the full application # The flock here is a MUTEX, not service discovery. It is the only thing
# stack (supervisord with Redis, meta-core, nginx, rclone, mount-watcher). # stopping two containers that mount the same /meta-core volume from putting
# Followers remain dormant, periodically retrying to acquire the lock. # two writers on one database file (/meta-core/db/meta-core.db). Sibling
# services no longer read anything from this directory — they locate meta-core
# over UDP (beacon v2, see docs/project-architecture/beacon-v2.md), so the
# kv-leader.info file
# this script used to publish is gone. Keep the lock; it costs nothing and the
# corruption it prevents is unrecoverable.
# #
# Environment variables: # Environment variables:
# META_CORE_PATH - Path to meta-core data directory (default: /meta-core) # META_CORE_PATH - Path to meta-core data directory (default: /meta-core)
@@ -20,7 +25,6 @@ META_CORE_HTTP_PORT="${META_CORE_HTTP_PORT:-9000}"
ELECTION_RETRY_SECS="${ELECTION_RETRY_SECS:-5}" ELECTION_RETRY_SECS="${ELECTION_RETRY_SECS:-5}"
LOCK_FILE="${META_CORE_PATH}/locks/kv-leader.lock" LOCK_FILE="${META_CORE_PATH}/locks/kv-leader.lock"
INFO_FILE="${META_CORE_PATH}/locks/kv-leader.info"
SUPERVISORD_CONF="/etc/supervisor/conf.d/supervisord.conf" SUPERVISORD_CONF="/etc/supervisor/conf.d/supervisord.conf"
# Get the container's IP address # Get the container's IP address
@@ -40,35 +44,13 @@ get_local_ip() {
echo "$ip" echo "$ip"
} }
# Write leader info file (plain text API URL)
write_leader_info() {
local ip=$(get_local_ip)
local api_url="http://${ip}:${META_CORE_HTTP_PORT}"
# Write atomically using temp file + rename
local temp_file="${INFO_FILE}.tmp"
echo "$api_url" > "$temp_file"
mv "$temp_file" "$INFO_FILE"
echo "[election] Wrote leader info: $api_url"
}
# Clean up leader info file on shutdown
cleanup() {
echo "[election] Cleaning up..."
rm -f "$INFO_FILE"
exit 0
}
# Set up signal handlers for graceful shutdown # Set up signal handlers for graceful shutdown
trap cleanup SIGTERM SIGINT
# Ensure lock directory exists # Ensure lock directory exists
mkdir -p "$(dirname "$LOCK_FILE")" mkdir -p "$(dirname "$LOCK_FILE")"
echo "[election] Starting leader election..." echo "[election] Starting leader election..."
echo "[election] Lock file: $LOCK_FILE" echo "[election] Lock file: $LOCK_FILE"
echo "[election] Info file: $INFO_FILE"
# Open lock file for flock (file descriptor 200) # Open lock file for flock (file descriptor 200)
exec 200>"$LOCK_FILE" exec 200>"$LOCK_FILE"
@@ -79,9 +61,6 @@ while true; do
if flock -n 200; then if flock -n 200; then
echo "[election] Acquired flock - transitioning to LEADER" echo "[election] Acquired flock - transitioning to LEADER"
# Write leader info before starting services
write_leader_info
# exec supervisord - replaces this process # exec supervisord - replaces this process
# When supervisord exits, the flock is released automatically # When supervisord exits, the flock is released automatically
echo "[election] Starting supervisord..." echo "[election] Starting supervisord..."
+4 -8
View File
@@ -123,14 +123,10 @@ http {
# ========================================================================= # =========================================================================
# Service discovery # Service discovery
# ========================================================================= # =========================================================================
location /services { # meta-discovery v1 neighbours (supersedes /services, which stays
proxy_pass http://meta-core-api/services; # while consumers migrate).
proxy_http_version 1.1; location /neighbors {
proxy_set_header Host $host; proxy_pass http://meta-core-api/neighbors;
}
location /services/ {
proxy_pass http://meta-core-api/services/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
} }
+3 -17
View File
@@ -16,26 +16,13 @@ serverurl=unix:///var/run/supervisor.sock
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
# ============================================================================= # =============================================================================
# Redis: Key-value store (managed by supervisord, started before meta-core) # meta-core: HTTP API, Mount API, File Watcher, metadata store (embedded SQLite)
# Previously spawned by Go code, now managed by bash-based leader election
# =============================================================================
[program:redis]
command=/usr/bin/redis-server --port %(ENV_REDIS_PORT)s --bind 0.0.0.0 --protected-mode no --dir /meta-core/db/redis --appendonly yes --appendfilename appendonly.aof --dbfilename dump.rdb --save 60 1 --loglevel warning --notify-keyspace-events Kg$
priority=1
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
startsecs=2
# =============================================================================
# meta-core: HTTP API, Mount API, File Watcher (connects to local Redis)
# ============================================================================= # =============================================================================
[program:meta-core] [program:meta-core]
command=/usr/local/bin/meta-core command=/usr/local/bin/meta-core
priority=10 priority=10
; Room to finish in-flight writes and checkpoint the WAL on shutdown.
stopwaitsecs=60
autostart=true autostart=true
autorestart=true autorestart=true
stdout_logfile=/dev/stdout stdout_logfile=/dev/stdout
@@ -50,7 +37,6 @@ environment=
SERVICE_VERSION="%(ENV_SERVICE_VERSION)s", SERVICE_VERSION="%(ENV_SERVICE_VERSION)s",
META_CORE_HTTP_PORT="%(ENV_META_CORE_HTTP_PORT)s", META_CORE_HTTP_PORT="%(ENV_META_CORE_HTTP_PORT)s",
META_CORE_HTTP_HOST="%(ENV_META_CORE_HTTP_HOST)s", META_CORE_HTTP_HOST="%(ENV_META_CORE_HTTP_HOST)s",
REDIS_PORT="%(ENV_REDIS_PORT)s",
WATCH_INTERVAL_MS="%(ENV_WATCH_INTERVAL_MS)s", WATCH_INTERVAL_MS="%(ENV_WATCH_INTERVAL_MS)s",
API_PORT="80", API_PORT="80",
BASE_URL="%(ENV_BASE_URL)s" BASE_URL="%(ENV_BASE_URL)s"
+263 -34
View File
@@ -1,4 +1,14 @@
# API-Mediated Access (Redis Lockdown) # API-Mediated Access (Redis Lockdown → Signed Claims)
> **meta-core v2.0.0 — the claims model.** The implementation basis is
> [`docs/implementation/metacore-claims-model.md`](../../../docs/implementation/metacore-claims-model.md)
> (meta-root). Where this file and that one disagree, that one wins. The
> *access* contract below still holds unchanged — HTTP only, events over
> SSE, no database reachable from outside `metacore-app`. The *write*
> contract changed: every metadata write is a **signed claim** posted to
> `POST /api/claims`, and the legacy unsigned write endpoints answer
> `410 Gone`. See ["Write contract — signed claims"](#write-contract--signed-claims-v200)
> below.
## Status ## Status
@@ -9,6 +19,16 @@ on [uuid-rooted-metadata.md](uuid-rooted-metadata.md), which assumes
meta-core is the single writer to the metadata keyspace — an assumption meta-core is the single writer to the metadata keyspace — an assumption
that's now actually enforced rather than aspirational. that's now actually enforced rather than aspirational.
> **2026-10: meta-core no longer uses Redis at all.** Its store is now an
> embedded SQLite file (`/meta-core/db/meta-core.db`, see
> [metadata-storage-structure.md](metadata-storage-structure.md)) and the
> `file:events` / `meta:events` streams are rows of that store. The lockdown
> below is now structural — there is no database port anywhere — and the
> SSE wire contract was kept byte-for-byte, so no consumer changed. The
> rest of this doc is the design record of the Redis-era lockdown; the
> sections that describe *current* behaviour (SSE retention, reconnect,
> error handling) have been updated in place.
See "Implementation outcome" at the bottom for the delta between this doc's See "Implementation outcome" at the bottom for the delta between this doc's
plan and what shipped (notably the write-path CID resolution and the plan and what shipped (notably the write-path CID resolution and the
dual-root migration sweep, which weren't in the original plan but turned dual-root migration sweep, which weren't in the original plan but turned
@@ -59,20 +79,23 @@ we don't fully control.
## The architecture in one picture ## The architecture in one picture
``` ```
meta-core (only Redis writer + reader) meta-core v2.0.0 (only store writer + reader)
┌──────────────────────────────────────┐ ┌──────────────────────────────────────┐
│ HTTP API surface: │ │ HTTP API surface: │
│ POST/PUT/PATCH /api/metadata/* │ ◄── writes │ POST /api/claims │ ◄── writes (signed claims only)
│ GET /api/meta/{cid} │ ◄── reads (by CID) │ POST /api/merge │ ◄── stateless merge, no write
│ GET /api/metadata/* │ ◄── reads (by id) │ GET /api/meta/{cid} │ ◄── reads (by CID, merged)
│ GET /api/meta/{cid}/claims │ ◄── reads (raw signed claims)
│ GET /api/metadata/* │ ◄── reads (by rid, merged)
│ GET /api/file/{cid} │ ◄── content │ GET /api/file/{cid} │ ◄── content
│ GET /api/events/files │ ◄── SSE stream │ GET /api/events/files │ ◄── SSE stream
│ GET /api/events/meta │ ◄── SSE stream │ GET /api/events/meta │ ◄── SSE stream (merged changes)
│ │ │ │ │ │
│ ▼ │ │ ▼ verify sig → truth → apply │
│ Redis (file:*, cid:*, streams) │ │ truth /meta-core/db/meta-core.db │
│ ◄── container-local, no exposed │ │ cache /meta-core/db-cache/cache.db │
│ port, no docker-network alias │ │ ◄── embedded SQLite, no port, │
│ no docker-network alias │
└──────────────────────────────────────┘ └──────────────────────────────────────┘
▲ ▲ ▲ ▲
│ HTTP │ HTTP SSE │ HTTP │ HTTP SSE
@@ -85,9 +108,18 @@ we don't fully control.
``` ```
**The single-writer rule.** meta-core has exclusive write authority over **The single-writer rule.** meta-core has exclusive write authority over
the `file:*`, `cid:*`, `file:__index__`, and `meta-core:*` keyspaces. No its store — in v2.0.0 the two SQLite databases (truth and cache); in the
other service can write — not because of an honoured convention, but Redis era the `file:*`, `cid:*`, `file:__index__` (later `idx:*`), and
because they can't reach Redis at all. `meta-core:*` keyspaces. No other service can write the store — not because
of an honoured convention, but because they can't reach it at all.
**What changed in v2.0.0 is who may *author* metadata.** meta-core is still
the only process that touches the database, but it no longer accepts
unsigned values: a writer submits signed votes (claims), meta-core verifies
each signature and stores the vote. meta-core **never signs a claim and
holds no writer key** — a store that could sign for everyone would prove
nothing. The signature is the write authentication; there are no
per-writer tokens.
**Stream reads also mediated.** Events go out through SSE endpoints on **Stream reads also mediated.** Events go out through SSE endpoints on
meta-core's HTTP surface. Consumers don't see the underlying Redis meta-core's HTTP surface. Consumers don't see the underlying Redis
@@ -98,6 +130,147 @@ without breaking every consumer.
**Redis is private.** No host port mapping, no docker-network alias, no **Redis is private.** No host port mapping, no docker-network alias, no
URL in `kv-leader.info`. Discovery returns only meta-core's HTTP base. URL in `kv-leader.info`. Discovery returns only meta-core's HTTP base.
(Since 2026-10 there is no Redis at all; the SQLite files are reachable only
from inside `metacore-app`.)
## Write contract — signed claims (v2.0.0)
Every metadata value is a **signed vote**: *signer `uid` says `key = value`
about content `cid`* — `claim(uid, cid, key) → value + ts + sig`. The
record a reader sees is the **merged view** that meta-core precomputes from
the votes; a reader's own votes always win in their view. Full model,
schema and merge rules: the
[implementation basis](../../../docs/implementation/metacore-claims-model.md)
§1–§7.
### Endpoint table
| | Endpoint | Behaviour |
|---|---|---|
| **Write** | `POST /api/claims` | A batch of signed claims (a JSON array, or `{"claims": [...]}`). **The only metadata write path.** |
| Removed | `PUT/PATCH/DELETE /meta/{hash}`, `PUT/DELETE /meta/{hash}/{key...}`, `POST /meta/{hash}/_add/{key...}`, `PUT/DELETE /api/metadata/{hashId}`, `PUT /api/metadata/{hashId}/property`, `POST /api/metadata/batch` | `410 Gone`, with a pointer to `POST /api/claims` |
| Kept | `PUT/GET/DELETE /api/kv/value` | Service scratch KV (`btmagnet:*`, `searchcov:*`), plain rows of the cache database's `kv` table (any key outside the reserved prefixes `file:`, `cid:`, `idx:`, `dup:`, `ev:`, `evhwm:`, `meta:`, `meta-core:`) — losing it only costs a refetch |
| Kept | `POST /api/files/register`, the file watcher | Fill `local_file` (box-local `filePath`, size, mtime, midhash — unsigned) and `cid_group` |
| UDL facade | `/api/udl/*` (same paths) | Writes go through the claims write path; reads come from the claims (per-signer index `claim_by_pid`). Records are MMUR2 claims. Shapes: see "UDL facade shapes" below |
| Read | `GET /api/meta/{cid}`, `GET /api/metadata/{rid}`, `GET /file/{cid}`, `GET /meta/{hash}`, `GET /api/metadata/hash-ids`, `GET /api/metadata/list`, `POST /api/metadata/search`, `GET /api/files/tuples`, `GET /api/schema`, `GET /api/stats` | Unchanged paths and response shapes, served from the merged view (+ `local_file`); optional `?as=<uid>`. They carry no `nsrc` |
| **New** | `GET /api/meta/{cid}/claims` | `{rid, cids, claims}`: the record's rid, the cids of its group and every live signed claim on them (all signers, tombstones excluded). `?key=` keeps one exact key, `?uid=` one signer. `404 unknown_cid` when no record holds the cid |
| **New** | `GET /api/metadata/{rid}/claims` | Same body and filters, addressed by rid (a cid is also accepted); `404` for an unknown record. What meta-search ingest and the gateway's corpus search use |
| **New** | `POST /api/merge` | Stateless: body `{claims, as?}`; verifies each claim (invalid ones dropped and counted, never a batch error), merges with the store's merge rule, overlays `as`'s own votes (in the request and held by this core for the same cids). Returns `{fields, nsrc, rejected}` — merged `key → value`, `key → agreeing signers`, rejected count. `413` above 10 000 claims. Writes nothing |
| Events | `GET /api/events/meta` | Unchanged `{type: set\|del, key, ts}` shape, now emitted for changes of the **merged** view, `key` still the literal `file:<rid>/<field>`; `GET /api/events/files` unchanged. Both streams are rows of the cache `kv` table (`ev:meta:events/…`, `ev:file:events/…`), ~100k entries each |
| Admin | `POST /api/metadata/clear` | Wipes both databases (truth and cache) |
| Removed | `/api/snapshot/export`, `/api/snapshot/import`, `/api/snapshot/wipe` | No snapshot format in v2.0.0 |
Endpoints the claims model does not touch keep their behaviour: discovery
(`/health`, `/status`, `/leader`, `/urls`, `/neighbors`), the account-key
identity service (`/api/identity/*` — its removal is a separate ticket;
v2.0.0 adds the `format` field to `sign` / `sign-batch` — `"compact"` or
`"der"`, default compact for `"MMUR2\n"` payloads and DER otherwise — and
makes `DELETE /api/identity` sign and store a retraction of every live
claim of the account before removing it),
`/api/file/{cid}/info`, `/file/cid`, `/data/{hash}*`, mounts, watchers and
WebDAV.
### The claim on the wire (JSON form)
```json
{ "cid": "bafy…", "uid": "zQ3s…", "key": "title",
"value": "The Matrix", // or {"b64": "…"} for bytes, or null
"ts": 1793001234567,
"sig": "base64(64 bytes)" }
```
| Field | Meaning |
|---|---|
| `cid` | The subject: a content CID as the writer knows it (bare CID string, as in `cids/*`). Never a rid — rids are local and never signed |
| `uid` | The signer: `"z" + base58btc(33-byte compressed secp256k1 pubkey)` |
| `key` | The field (`METADATA_KEYS.md`); a key-set member is inside the key (`genres/Drama`) |
| `value` | Text = public plaintext; `{"b64": …}` = bytes, a private (ciphertext) envelope; `null` = retraction |
| `ts` | Milliseconds since epoch. It **is** the version: only increases per `(uid, cid, key)` |
| `sig` | MMUR2: secp256k1 ECDSA, compact 64 bytes, over SHA-256 of `"MMUR2\n" ‖ canonical CBOR [cid, uid, key, value, ts]` |
A batch (the `POST /api/claims` body) is a JSON array of these (or
`{"claims": [...]}`). Kamilata and
the gateway wire carry the same five fields plus `sig`; the signed bytes
never change on any hop, and relays (meta-search, a client persisting a
gateway hit) forward claims verbatim — they never re-sign.
### Per-claim statuses
`POST /api/claims` answers with one status per claim:
| Status | Meaning |
|---|---|
| `accepted` | Verified and stored (or a retraction stored as a tombstone) |
| `unchanged` | The stored row for `(uid, cid, key)` already has this `ts` and `sig` — **or the same value under any `ts`** (a signer re-asserting what it holds): no verification, no write, and the stored row keeps its older `ts` |
| `stale` | The stored `ts` is ≥ the incoming `ts`; ignored (blocks replay of old votes) |
| `bad_signature` | The MMUR2 signature does not verify against the `uid`'s public key |
| `bad_request` | The claim is malformed |
The response is `200` with a JSON array of `{status, reason?}`, one entry
per claim, in input order, even when some are refused. A batch above 10 000
claims answers `413 too_many_claims`; a body that is not claims JSON
`400 invalid_json`. The
removed write routes answer `410 {"error":"gone","use":"POST /api/claims"}`.
`POST /api/claims`, `POST /api/merge` and the two claims read routes are
container-to-container routes, not routed through Caddy.
Rules: every claim is signed — ciphertext and retractions included; an
unsigned or badly signed claim is refused. A retraction (`value: null`) is
kept as a tombstone, so a peer cannot replay the vote it retracted. Values
are stored exactly as signed; normalisation (trim + case-fold, no NFC)
happens only inside the merge.
Accepted claims commit to the truth database in one group-committed
transaction; an apply loop then folds them, in commit order, into the
cache (the merged `file:<rid>/<field>` rows of its v1-layout `kv` table,
FTS, counters, events). `POST /api/claims` answers only once the cache has
applied the batch, so the writer reads its own write; another reader may
briefly see the previous merged value — the cache is behind, never wrong.
### `?as=<uid>` — the viewer's overlay
Every merged read accepts an optional `?as=<uid>`. The merge then puts that
signer's own live text vote first for each key (read-time overlay found
through the per-signer index, never stored in the shared merged view). Without
`?as=` the read returns the shared merge: most distinct signers → oldest
`ts` → byte-wise smallest value; ciphertext never merges.
### Raw claims and client-side merging
The claims read routes (`GET /api/metadata/{rid}/claims` for meta-search
ingest and the gateway's corpus hits, `GET /api/meta/{cid}/claims` by cid)
return the raw signed votes of every signer, not a merged map. A client that receives
claims from the swarm or a gateway displays them by calling its **local**
meta-core's `POST /api/merge` (one merge implementation, in Go), and
persists a hit by posting the claims **as received** to `POST /api/claims`.
### UDL facade shapes
Claims in the JSON form above (`internal/api/udl.go`):
| Route | Request | Response |
|---|---|---|
| `PUT /api/udl/record` | one claim | `200 {accepted: true, status}` for `accepted` / `unchanged`; `409 stale_version` for `stale`; `400` (status as slug) for `bad_signature` / `bad_request` |
| `PUT /api/udl/records` | `{records: [claim, …]}` | `200 {results: [{key, accepted, status, error?}, …]}`, input order; `accepted` = `accepted` or `unchanged` |
| `GET /api/udl/record?uid=&cid=&key=` | — | `{exists, claim}` — the stored claim, tombstone included; `exists` false when absent or a tombstone |
| `GET /api/udl/user/{uid}/key/{key}`, `/user/{uid}/cid/{cid}` | — | `{entries: [claim, …]}` |
| `GET /api/udl/user/{uid}/all?after=&limit=` | — | `{entries: [claim, …], nextCid}` |
| `GET /api/udl/user/{uid}/count` | — | `{cids}` |
| `GET /api/udl/cid/{cid}/users` | — | `{users: [uid, …]}` |
| `GET /api/udl/cid/{cid}/aggregate?keys=` | — | `{aggregates: [{key, count, avg, trues, sample_size}]}` |
| `GET /api/udl/users/stats` | — | `{users: {uid: {records, cids, keys}}}` for **every signer** with live claims — plugin, feeder, gateway and service keys included. `/api/stats` `udlUsers` is the size of this map |
### What a writer needs
Each writing container (every meta-sort plugin, every self-publishing
feeder, gateway core, meta-sort, meta-share, meta-search) signs with its
**own** key; users sign in the browser. meta-core holds none of these keys.
Key configuration per signer: implementation basis §9.
Box-local file facts (`filePath`, size, `mtimeNano`, midhash) are not
claims: the watcher and `POST /api/files/register` write them into the
cache's `local_file` table, they are joined into the record view unsigned,
and they never appear in `/api/meta/{cid}/claims` or on the swarm.
## Eventing — HTTP Server-Sent Events ## Eventing — HTTP Server-Sent Events
@@ -243,6 +416,36 @@ After PR A, the cursor contract is **"durable across meta-core restart
and bounded by `MAXLEN ~ N`"** — matching what consumers already assume and bounded by `MAXLEN ~ N`"** — matching what consumers already assume
when they persist a Last-Event-ID. when they persist a Last-Event-ID.
**Current backing store (since 2026-10).** The streams are rows of
meta-core's SQLite kv table — `ev:<stream>/<ms13>-<seq10>` → JSON of the
entry's fields, plus a high-water mark `evhwm:<stream>` that seeds the next
ID (so a `ClearStream` reset never reissues an ID an old cursor treats as
seen). The contract above holds unchanged:
- Entries persist across restart (they are in the database file).
- Retention is ~100k entries per stream, trimmed every 10k appends.
- IDs keep the Redis `<ms>-<seq>` form, so cursors persisted by meta-sort /
meta-share / meta-stremio before the switch still compare.
- `meta:events` entries (`{type: set|del, key, ts}`) describe changes of
the **merged** view (v2.0.0): the apply loop emits `set`/`del` (keyed by
rid + key) only when a claim actually changed a merged value, in the same
cache transaction as that change. A group merge (two cid groups linked by
a `cids/*` vote) emits `del` for every key of the losing rid and `set` for
the survivor. *History:* v1.x appended them in the same transaction as the
`file:` key change; before 2026-10 a Redis keyspace-notification hop
(PSUBSCRIBE in `MetaPublisher`) could drop events when it lagged.
- **Cache rebuild (v2.0.0).** Events live in the cache database. A cache
rebuild (missing cache folder, or catch-up after an unclean shutdown)
emits no per-key events; SSE consumers get `event: gap` and take a
snapshot, as they do for a trimmed cursor. Event IDs stay time-based, so
they keep rising across a rebuild.
- **`Last-Event-ID: 0` / `0-0` on `/api/events/meta` returns a snapshot**
(since v1.2.0): one `set` per existing record key, then the live tail from
the head captured before the snapshot. Only the last snapshot event carries
an `id:`. This replaced the boot-time republish of every field, which a
~100k-entry stream could never hold. A trimmed (non-zero) cursor still gets
the `gap` event and a replay of what is retained.
### Heartbeats ### Heartbeats
Server writes an SSE comment line (`:keep-alive\n\n`) every 30 seconds Server writes an SSE comment line (`:keep-alive\n\n`) every 30 seconds
@@ -254,10 +457,13 @@ per the SSE spec.
Per the SSE spec the client (or `EventSource`) auto-reconnects after a Per the SSE spec the client (or `EventSource`) auto-reconnects after a
network error, sending `Last-Event-ID`. The server resumes from the next network error, sending `Last-Event-ID`. The server resumes from the next
entry after that ID by translating to Redis Stream `XREAD … STREAMS file:events <id>`. entry after that ID (a range read of the stream's rows; under Redis this
was `XREAD … STREAMS file:events <id>`). With no `Last-Event-ID` the stream
starts at the head, resolved once at connect; `0` / `0-0` go through the
gap path and replay from the beginning.
**Gap handling.** If the requested ID has been trimmed out of the Redis **Gap handling.** If the requested ID is older than the oldest retained
Stream by retention, the server emits one synthetic event before resuming entry, the server emits one synthetic event before resuming
from the oldest available entry: from the oldest available entry:
``` ```
@@ -271,9 +477,10 @@ react. Consumers that don't care ignore it.
### Error handling ### Error handling
- **Redis unreachable on connect**: 503 with `Retry-After: 5`. - **Store unavailable on connect**: 503 with `Retry-After: 5`.
- **Redis fails mid-stream**: close the SSE connection. Client reconnects - **Store read fails mid-stream**: close the SSE connection. Client
with `Last-Event-ID`; the cycle resumes when Redis recovers. reconnects with `Last-Event-ID`; the cycle resumes when meta-core is back
(a meta-core restart is the common case — SSE clients just reconnect).
- **Client disconnects**: handler goroutine exits cleanly; no per-client - **Client disconnects**: handler goroutine exits cleanly; no per-client
state on meta-core. state on meta-core.
@@ -307,7 +514,8 @@ inside-only until a concrete external consumer appears.
### Implementation footprint ### Implementation footprint
The SSE handler itself is ~80 lines of Go in meta-core: one handler per The SSE handler itself is ~80 lines of Go in meta-core: one handler per
stream, each running an `XREAD … BLOCK 30s` loop and writing stream, each running a read-after-cursor loop (an `XREAD … BLOCK 30s` loop
in the Redis era) and writing
SSE-formatted lines to the response. Go's `net/http` natively supports SSE-formatted lines to the response. Go's `net/http` natively supports
streaming responses with `http.Flusher.Flush()`. Standard SSE clients streaming responses with `http.Flusher.Flush()`. Standard SSE clients
exist for every language we use: Node `eventsource`, Rust exist for every language we use: Node `eventsource`, Rust
@@ -484,6 +692,10 @@ durability across a meta-core restart).
### PR B — meta-sort writes via HTTP ### PR B — meta-sort writes via HTTP
> *History.* PR B moved writes onto the unsigned `/meta/{hash}` family.
> In v2.0.0 those routes answer `410 Gone`; writers sign claims and post
> them to `POST /api/claims` (see "Write contract — signed claims").
Swap `RedisClient` for an `HttpKVClient` implementation of the existing Swap `RedisClient` for an `HttpKVClient` implementation of the existing
`IKVClient` interface in `packages/meta-sort-core`. All writes `IKVClient` interface in `packages/meta-sort-core`. All writes
(`setMetadataFlat`, `setMetadataProperty`, batch updates) become HTTP (`setMetadataFlat`, `setMetadataProperty`, batch updates) become HTTP
@@ -562,17 +774,22 @@ metacore-app produces a connection refused. The lock is real.
| Reads from `file:*` direct? | Forbidden by network topology | Same lock, same reason | | Reads from `file:*` direct? | Forbidden by network topology | Same lock, same reason |
| Event streams direct? | No — mediated through SSE | Kubernetes-style. Costs one HTTP hop; buys schema evolution + threat-model fit | | Event streams direct? | No — mediated through SSE | Kubernetes-style. Costs one HTTP hop; buys schema evolution + threat-model fit |
| Enforcement mechanism? | Network isolation (primary) + docs + code review (secondary) | No ACLs, no proxy, no lint | | Enforcement mechanism? | Network isolation (primary) + docs + code review (secondary) | No ACLs, no proxy, no lint |
| Backing store for streams? | Redis Streams (unchanged) | SSE wraps it; could be swapped later without consumer impact | | Backing store for streams? | Redis Streams at the time; SQLite kv rows since 2026-10 | SSE wraps it; the 2026-10 swap proved it — no consumer changed |
| Consumer groups / horizontal scale? | Not supported via SSE | Not needed today; revisit if scale ever requires it | | Consumer groups / horizontal scale? | Not supported via SSE | Not needed today; revisit if scale ever requires it |
| Storage portability? | Not a goal but a free side-effect | Will not be exercised in alpha | | Storage portability? | Not a goal but a free side-effect | Will not be exercised in alpha |
| **Canonical write API path?** | **`/meta/{hash}` family** | Has GET/PUT/**PATCH**/DELETE plus `/_add/{key}` and per-property `{key:.*}` operations. `PATCH` calls `MergeMetadataFlat` — matches the partial-update semantics PR B needs. `/api/metadata/{hashId}` stays as the editor-internal surface (no PATCH, no per-property routes) but is not the migration target. | | **Canonical write API path?** | **`POST /api/claims` (v2.0.0)** — signed claims only; every unsigned write route answers `410 Gone` | *History (v1.x):* the **`/meta/{hash}` family** was the canonical write surface. It had GET/PUT/**PATCH**/DELETE plus `/_add/{key}` and per-property `{key:.*}` operations. `PATCH` calls `MergeMetadataFlat` — matches the partial-update semantics PR B needs. `/api/metadata/{hashId}` stays as the editor-internal surface (no PATCH, no per-property routes) but is not the migration target. |
| **SSE event-id contract?** | **Opaque to consumers** | Today it's the Redis Stream entry ID (`<ms>-<seq>`); consumers MUST treat as opaque so the backing store can be swapped without breaking the wire contract. Documented in the SSE endpoint comment and in this doc. | | **SSE event-id contract?** | **Opaque to consumers** | It is `<ms>-<seq>` (the Redis Stream entry form, kept after the 2026-10 SQLite switch); consumers MUST treat as opaque so the backing store can be swapped without breaking the wire contract. Documented in the SSE endpoint comment and in this doc. |
| **Typed error envelope?** | **`{error, message, retryable}` with stable `error` slug** | See "Error envelope" subsection below. | | **Typed error envelope?** | **`{error, message, retryable}` with stable `error` slug** | See "Error envelope" subsection below. |
| **Plugin direct-Redis risk?** | **Confirmed: no plugin uses Redis directly** | Grep across `packages/plugins/` for `ioredis` / `redis.createClient` / `new Redis()` returned empty. Plugins POST results back to meta-sort via `/api/plugins/callback`; meta-sort then writes. PR B picks up the plugin write path transparently. | | **Plugin direct-Redis risk?** | **Confirmed: no plugin uses Redis directly** | Grep across `packages/plugins/` for `ioredis` / `redis.createClient` / `new Redis()` returned empty. Plugins POST results back to meta-sort via `/api/plugins/callback`; meta-sort then writes. PR B picks up the plugin write path transparently. |
| **meta-sort non-`file:*` writes?** | **Confirmed: none in production** | Full audit of `kvClient.*` callers in `packages/meta-sort/packages/meta-sort-core/src/` shows only: `setMetadataFlat`, `deleteMetadataFlat`, `getMetadataFlat`, `getMetadata`, `getAllHashIds`, `health`, plus the `file:events` stream consumer (`initStreamConsumer` / `startStreamConsumer` / `processPendingEntries`). The generic `kv.set(key, value)` / `kv.setProperty(key, value)` methods on the interface exist for plugin sandboxes and tests but are not invoked by production code with non-`file:*` keys. PR B + PR C cover the full meta-sort surface. | | **meta-sort non-`file:*` writes?** | **Confirmed: none in production** | Full audit of `kvClient.*` callers in `packages/meta-sort/packages/meta-sort-core/src/` shows only: `setMetadataFlat`, `deleteMetadataFlat`, `getMetadataFlat`, `getMetadata`, `getAllHashIds`, `health`, plus the `file:events` stream consumer (`initStreamConsumer` / `startStreamConsumer` / `processPendingEntries`). The generic `kv.set(key, value)` / `kv.setProperty(key, value)` methods on the interface exist for plugin sandboxes and tests but are not invoked by production code with non-`file:*` keys. PR B + PR C cover the full meta-sort surface. |
## Error envelope ## Error envelope
(`410 Gone` from the removed v1.x write routes carries this envelope with a
message pointing to `POST /api/claims`. Per-claim outcomes of
`POST /api/claims` are statuses inside the response, not envelopes — see
"Per-claim statuses" above.)
Standard shape for all 4xx / 5xx responses from the metadata API: Standard shape for all 4xx / 5xx responses from the metadata API:
```json ```json
@@ -591,7 +808,11 @@ Stable `error` slugs the client should switch on:
| `unknown_root` | 404 | no | The hashId / UUID doesn't exist. Caller probably has a stale reference. | | `unknown_root` | 404 | no | The hashId / UUID doesn't exist. Caller probably has a stale reference. |
| `unknown_cid` | 404 | no | The CID isn't in the reverse index. No retry helps. | | `unknown_cid` | 404 | no | The CID isn't in the reverse index. No retry helps. |
| `schema_violation` | 400 | no | Body shape rejected (missing required field, invalid value). Caller bug. | | `schema_violation` | 400 | no | Body shape rejected (missing required field, invalid value). Caller bug. |
| `storage_unavailable` | 503 | yes | Redis transient. Caller should retry with backoff. | | `invalid_json` | 400 | no | `POST /api/claims` / `POST /api/merge` body is not claims JSON. Caller bug. |
| `too_many_claims` | 413 | no | More than 10 000 claims in one `POST /api/claims` or `POST /api/merge`. Split the batch. |
| `stale_version` | 409 | yes | `PUT /api/udl/record`: an equal or newer `ts` is stored for this signer, cid and key. Re-read the record, sign with a newer `ts`, retry. |
| `gone` | 410 | no | A removed unsigned write route. Body is `{"error":"gone","use":"POST /api/claims"}` (no `message` / `retryable`). |
| `storage_unavailable` | 503 | yes | Store transient (Redis at the time; the embedded SQLite store since 2026-10). Caller should retry with backoff. |
| `internal` | 500 | yes (cautiously) | Unhandled server-side error. Retry once; escalate if persistent. | | `internal` | 500 | yes (cautiously) | Unhandled server-side error. Retry once; escalate if persistent. |
**Implemented.** Every 4xx/5xx response across `internal/api`, **Implemented.** Every 4xx/5xx response across `internal/api`,
@@ -605,10 +826,9 @@ import cycle on the api package; consolidating it into a shared
Mostly answered post-implementation: Mostly answered post-implementation:
- **Deprecation timeline for `/api/metadata/{hashId}`.** Still open. The - **Deprecation timeline for `/api/metadata/{hashId}`.** Answered by
editor's React UI continues to call it; the surface itself was untouched v2.0.0: its `PUT`/`DELETE` (and `PUT …/property`) answer `410 Gone`
by this work. Worth a follow-up to consolidate the editor onto like every unsigned write; `GET` stays as a merged read.
`/meta/{hash}` and delete the parallel routes.
- **Initial bootstrap flood on `/api/events/files`.** Resolved in - **Initial bootstrap flood on `/api/events/files`.** Resolved in
practice: meta-fuse uses `initialCursor: '0-0'` to deliberately practice: meta-fuse uses `initialCursor: '0-0'` to deliberately
@@ -628,8 +848,9 @@ Mostly answered post-implementation:
- **Authentication between internal services.** The hash-lock OIDC - **Authentication between internal services.** The hash-lock OIDC
perimeter handles external clients; internal service-to-service perimeter handles external clients; internal service-to-service
trust is by network isolation (docker network for dev, equivalent trust is by network isolation (docker network for dev, equivalent
isolation in prod deployments). Adding service-to-service auth is a isolation in prod deployments). Since v2.0.0 metadata *writes* carry
separate concern. their own authentication — the claim signature — so meta-core needs no
per-writer tokens; transport-level service auth remains out of scope.
- **Replacing the public HTTP API surface.** This proposal exercises - **Replacing the public HTTP API surface.** This proposal exercises
what already exists; the only new endpoints are the two SSE streams. what already exists; the only new endpoints are the two SSE streams.
- **Introducing new transport, broker, proxy, or sidecar.** Everything - **Introducing new transport, broker, proxy, or sidecar.** Everything
@@ -678,6 +899,11 @@ What actually shipped vs. what this doc planned.
### Write-path CID resolution (`storage.ResolveRoot`) ### Write-path CID resolution (`storage.ResolveRoot`)
> *History (v1.x).* In v2.0.0 a claim names a cid, never a rid, and the
> hidden rid comes from `cid_group` (a `cids/<other>` vote links two
> groups; the older rid survives). The dual-root failure below cannot
> recur: there is no write that addresses a root.
Not in the original plan but load-bearing. Not in the original plan but load-bearing.
**The problem.** meta-sort's PATCH-via-HTTP migration (PR B) routed **The problem.** meta-sort's PATCH-via-HTTP migration (PR B) routed
@@ -709,7 +935,9 @@ contract violation."
One-shot fixer for already-stranded entries: walks every UUID's One-shot fixer for already-stranded entries: walks every UUID's
`midhash256` field to build the orphan index, merges each stranded `midhash256` field to build the orphan index, merges each stranded
`file:<midhash>/*` into the matching UUID via `MergeMetadataFlat`, `file:<midhash>/*` into the matching UUID via `MergeMetadataFlat`,
deletes the stranded root, and re-points the alias. deletes the stranded root, and re-points the alias. *Removed in
2026-10* with the SQLite switch, together with the other one-shot Redis
migrations; it had been run on watch.nsl.sh before the migration.
Verified on the dev stack: 48 stranded entries were reunited; the Verified on the dev stack: 48 stranded entries were reunited; the
editor now shows every file with its full plugin metadata under one editor now shows every file with its full plugin metadata under one
@@ -726,9 +954,10 @@ duplicated in each to avoid an import cycle on the `api` package.
- **meta-dup SSE + read migration.** Not deployed, no test harness. - **meta-dup SSE + read migration.** Not deployed, no test harness.
When revived, copy meta-fuse's pattern. When revived, copy meta-fuse's pattern.
- **Editor `/api/metadata/{hashId}` consolidation.** The duplicate - **Editor writes (resolved in v2.0.0).** `PUT /api/metadata/{hashId}`
editor-internal API surface is untouched; could be retired in a answers `410`. meta-sort-editor's manual edits go through meta-sort's
follow-up by porting the editor UI to `/meta/{hash}`. backend, which signs them as meta-sort (its own key) and posts them to
`POST /api/claims`. The editor's reads keep using `/api/metadata/{hashId}`.
- **Consolidated `internal/httperr` package** for the typed envelope - **Consolidated `internal/httperr` package** for the typed envelope
helpers (currently duplicated 4× to avoid the import cycle). Code helpers (currently duplicated 4× to avoid the import cycle). Code
hygiene; functionally a no-op. hygiene; functionally a no-op.
+396 -130
View File
@@ -1,16 +1,24 @@
# Meta-Core Architecture # Meta-Core Architecture
> **meta-core v2.0.0 — the claims model.** Implementation basis:
> [`docs/implementation/metacore-claims-model.md`](../../../docs/implementation/metacore-claims-model.md)
> (meta-root). Where this file and that one disagree, that one wins.
## Document Purpose ## Document Purpose
This document describes the architectural design of **meta-core**, the This document describes the architectural design of **meta-core**, the
standalone container that owns Redis, the leader-info file, the HTTP+SSE standalone container that owns the metadata store (two embedded SQLite
databases since v2.0.0 — *truth* and *cache*; a single SQLite file in
v1.x; Redis until 2026-10), the leader-info file, the HTTP+SSE
metadata surface, and a WebDAV file server. It covers the leader gate, metadata surface, and a WebDAV file server. It covers the leader gate,
storage data model, API design, the SSE event surface, and the integration the claims data model and its write/apply pipeline, API design, the SSE
contract with other MetaMesh services. event surface, and the integration contract with other MetaMesh services.
For the on-disk Redis layout, see For the engine design (SQLite, group commit, measurements), see
[metacore-storage-engine.md](../../../docs/project-architecture/metacore-storage-engine.md);
for the on-disk layout (tables of both databases), see
[metadata-storage-structure.md](metadata-storage-structure.md). For the [metadata-storage-structure.md](metadata-storage-structure.md). For the
external-access design (Redis lockdown, SSE wire contract, auth rules), external-access design (store lockdown, SSE wire contract, auth rules),
see [api-mediated-access.md](api-mediated-access.md). For the UUID-rooted see [api-mediated-access.md](api-mediated-access.md). For the UUID-rooted
storage rationale, see [uuid-rooted-metadata.md](uuid-rooted-metadata.md). storage rationale, see [uuid-rooted-metadata.md](uuid-rooted-metadata.md).
@@ -23,7 +31,7 @@ view of file metadata:
| Service | Language | Role | | Service | Language | Role |
|---|---|---| |---|---|---|
| **meta-sort** | TypeScript | Watches folders, runs plugins, writes metadata via meta-core's HTTP API. | | **meta-sort** | TypeScript | Watches folders, runs plugins; each plugin signs its own claims and posts them to meta-core. |
| **meta-fuse** | TypeScript / Rust | Serves the virtual filesystem from metadata. | | **meta-fuse** | TypeScript / Rust | Serves the virtual filesystem from metadata. |
| **meta-stremio** | Python | Stremio addon for HLS streaming. | | **meta-stremio** | Python | Stremio addon for HLS streaming. |
| **meta-dup** | TypeScript | Duplicate detection. | | **meta-dup** | TypeScript | Duplicate detection. |
@@ -31,18 +39,30 @@ view of file metadata:
meta-core sits below all of these as the single owner of: meta-core sits below all of these as the single owner of:
1. **Redis** — the metadata store and event source. 1. **The metadata store** — two embedded SQLite databases: **truth**
(`/meta-core/db/meta-core.db`, backed up) holding the signed claims, and
**cache** (`/meta-core/db-cache/cache.db`, never backed up) holding every
index and derived row — a v1-layout `kv` table (the merged view as
`file:<rid>/<field>` rows, lookup, counters, event streams, service
scratch KV) with its field index and FTS, plus the `claim_by_pid` and
`local_file` tables. (v2.0.0 is implemented, uncommitted and not yet
deployed as of 2026-10-25.)
2. **The leader-info file** — the one place services look to discover meta-core's URLs. 2. **The leader-info file** — the one place services look to discover meta-core's URLs.
3. **The HTTP+SSE surface** — the only way external services touch the data plane. 3. **The HTTP+SSE surface** — the only way external services touch the data plane.
4. **A WebDAV server** — file access for plugins and other services. 4. **A WebDAV server** — file access for plugins and other services.
### Core design principle ### Core design principle
> External services never speak Redis. Every read, write, and event flows > External services never touch the store. Every read, write, and event flows
> through meta-core's HTTP+SSE surface. > through meta-core's HTTP+SSE surface.
This is the contract that PR D of [api-mediated-access.md](api-mediated-access.md) This is the contract that PR D of [api-mediated-access.md](api-mediated-access.md)
landed and that the rest of this document assumes. landed and that the rest of this document assumes. v2.0.0 adds a second:
> Every metadata value is a **signed vote** — `claim(uid, cid, key) → value`
> + `ts` + `sig`. The only write is `POST /api/claims`. meta-core verifies
> every claim and **never signs one**: it holds no writer key, and the
> signature is the write authentication.
--- ---
@@ -53,8 +73,8 @@ landed and that the rest of this document assumes.
meta-core runs as a dedicated container (`metacore-app`). The dev compose meta-core runs as a dedicated container (`metacore-app`). The dev compose
runs a single instance; the legacy multi-instance topology (sibling runs a single instance; the legacy multi-instance topology (sibling
followers) was deprecated. The flock primitive is retained as the followers) was deprecated. The flock primitive is retained as the
correctness anchor — it guarantees that at most one process owns Redis and correctness anchor — it guarantees that at most one process writes the
the writable state under `/meta-core`. database file and the writable state under `/meta-core`.
``` ```
┌────────────────────────────────────────────────────────────────────────┐ ┌────────────────────────────────────────────────────────────────────────┐
@@ -62,17 +82,19 @@ the writable state under `/meta-core`.
│ │ │ │
│ supervisord │ │ supervisord │
│ ├─ leader-election.sh (flock → write kv-leader.info → exec self) │ │ ├─ leader-election.sh (flock → write kv-leader.info → exec self) │
│ ├─ redis-server (AOF + RDB on /meta-core/db/redis) │
│ ├─ nginx (TLS termination, proxy_cache for /webdav) │ │ ├─ nginx (TLS termination, proxy_cache for /webdav) │
│ ├─ rclone rcd (mount daemon) │ │ ├─ rclone rcd (mount daemon) │
│ ├─ mount-watcher.sh (rclone mount lifecycle) │ │ ├─ mount-watcher.sh (rclone mount lifecycle) │
│ └─ meta-core (Go) ──► HTTP + SSE + WebDAV on :9000 │ │ └─ meta-core (Go) ──► HTTP + SSE + WebDAV on :9000 │
│ + embedded SQLite: truth (db/meta-core.db) │
│ and cache (cache/cache.db) │
└────────────────────────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────────────────────────┘
│ │
┌────────────────────────────┴────────────────────────────┐ ┌────────────────────────────┴────────────────────────────┐
│ /meta-core (shared volume) │ │ /meta-core (shared volume) │
│ locks/kv-leader.{lock,info} │ │ locks/kv-leader.{lock,info} │
│ db/redis/ │ │ db/meta-core.db (+ -wal, -shm) truth, backed up │
│ cache/cache.db (+ -wal, -shm) cache, never backed │
│ services/*.json │ │ services/*.json │
│ mounts/{mounts.json, errors/} │ │ mounts/{mounts.json, errors/} │
│ watchers.json │ │ watchers.json │
@@ -152,7 +174,7 @@ Container startup
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ supervisord starts: │ │ supervisord starts: │
│ redis → nginx → rclone → │ │ nginx → rclone → │
│ meta-core Go binary │ │ meta-core Go binary │
└──────────────────────────────┘ └──────────────────────────────┘
``` ```
@@ -167,10 +189,11 @@ gate wins. Therefore:
local hostname + IP + config and returns it on demand. There are no local hostname + IP + config and returns it on demand. There are no
`OnBecomeLeader` / `OnBecomeFollower` / `OnLeaderLost` callbacks; those `OnBecomeLeader` / `OnBecomeFollower` / `OnLeaderLost` callbacks; those
states do not exist inside the Go process. states do not exist inside the Go process.
- `LeaderLockInfo.RedisUrl` is intentionally empty in the response from - `LeaderLockInfo.RedisUrl` is always empty in the response from
`/urls` — see `internal/leader/election.go:82`. PR D of `/urls` (kept for wire compatibility). PR D of
api-mediated-access.md removed direct Redis exposure; consumers route api-mediated-access.md removed direct Redis exposure, and since 2026-10
metadata I/O through meta-core's HTTP API and SSE event streams. there is no Redis at all; consumers route metadata I/O through
meta-core's HTTP API and SSE event streams.
### Lock-info file ### Lock-info file
@@ -192,75 +215,174 @@ Other services read this file, then call `GET /urls` for full discovery
`metacore-app` is provisioned, its bash loop will acquire on next tick. `metacore-app` is provisioned, its bash loop will acquire on next tick.
The dev compose runs a single instance; failover is "restart the The dev compose runs a single instance; failover is "restart the
container." container."
- **Redis crash inside the container** → supervisord restarts redis. The - **The store is in-process** — there is no separate database process to
Go binary's connect loop retries up to 30 times (1s delay) at startup; crash. A meta-core crash is a process restart; SQLite's WAL recovers the
at steady state, individual operations fail and clients retry. last committed transaction on reopen.
--- ---
## Schema-Version Gate ## Schema-Version Gate
Before serving any traffic, `cmd/meta-core/main.go:54-61` calls Before serving any traffic, `cmd/meta-core/main.go` opens both databases
`storage.EnsureSchemaVersion`. The sentinel lives in Redis at and calls `storage.EnsureSchemaVersion` (before the cache catch-up). In
`meta-core:schema-version` (`internal/storage/schema_version.go`). The v2.0.0 the sentinel is the `schema-version = "3"` row of the truth
gate refuses to boot when the existing Redis layout predates the database's `meta` table. The gate:
UUID-rooted schema, so legacy `file:midhash256:*` data can't leak into
the current code paths. In alpha there is no automated migration — - **explicitly refuses a v1.x store** — a truth file holding the v1 `kv`
operators wipe with `pnpm run clean:all` and restart. table (`meta-core:schema-version = "2"`), which the default path still
points at — with a message naming `tools/claims-migrate`;
- refuses a store with claims but no sentinel, or any other version;
- stamps a fresh store with `"3"`.
So no pre-claims data leaks into the current code paths. The meta-core
**service binary contains no migration code**: watch.nsl.sh is converted
once by the separate one-off tool `packages/meta-core/tools/claims-migrate`
(not built into the image; implementation basis §11); every other box and
dev stack starts empty (operators wipe with `pnpm run clean:all` and
restart).
--- ---
## Data Model ## Data Model
### Two concepts: metadata vs. file bytes ### The one concept: signed votes
``` ```
┌────────────────────────────────────────────────────────────────────┐ claim(uid, cid, key) → value + ts + sig
│ METADATA (Redis) │
│ │
│ Root: opaque UUIDv7 (Crockford Base32; ULID layout, 26 chars) │
│ Storage: flat string keys │
│ file:<uuid>/<property> → string value │
│ file:<uuid>/cids/<bareCid> → "true" (key-set) │
│ file:<uuid>/duplicates → SET of paths │
│ │
│ No stored canonical_cid — derived by rank on read. │
│ │
│ Aliases (reverse index, one Redis STRING each): │
│ cid:<bareCid> → <uuid> │
│ │
│ Index: file:__index__ → SET of all UUIDs │
│ │
│ Mintable without a local file (remote / deleted entries OK). │
│ Resolvable by any registered CID in O(1). │
└────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────┐
│ FILE BYTES │
│ │
│ Location: /files volume (shared across containers). │
│ Access: path resolution via metadata, served via /webdav/*, │
│ /file/{cid} (reverse-lookup), or /data/{hash}. │
│ Path stored as filePath in the metadata, relative to /files. │
└────────────────────────────────────────────────────────────────────┘
``` ```
### Why UUID-rooted? - `uid` — the signer (a secp256k1 public key, `z…` base58btc). One key per
writing container (each meta-sort plugin, each self-publishing feeder,
gateway core, meta-share, meta-search) and one per user.
- `cid` — the subject, a content CID; never a rid.
- `key` — the field (`METADATA_KEYS.md`); key-set members live inside the
key (`genres/Drama`, `cids/<cid>`).
- `value` — text (public), bytes (signed + encrypted envelope) or null
(retraction, kept as a tombstone).
- `ts` — milliseconds; it **is** the version and only increases per
`(uid, cid, key)`.
- `sig` — MMUR2: secp256k1 ECDSA (compact 64 bytes) over SHA-256 of
`"MMUR2\n" ‖ canonical CBOR [cid, uid, key, value, ts]`.
Roots were previously content-hash tokens (`file:midhash256:abc/...`), The old shared record (`file:<rid>/<field>`) and the old User Data Layer
which privileged one hash algorithm. UUID-rooted storage treats every CID store (`udl:rec:*`) both collapse into this shape. The store distinguishes
symmetrically as an alias, makes `GET /api/meta/<cid>` O(1) for any only plaintext (signed) from ciphertext (signed + encrypted); "personal vs
registered CID, and lets new digest types be added without rewriting root shared" is app logic.
keys. See [uuid-rooted-metadata.md](uuid-rooted-metadata.md) for the
design rationale and [metadata-storage-structure.md](metadata-storage-structure.md) ### Two databases
for the on-disk layout.
```
┌──────────────────────────────────────────────────────────────────────┐
│ TRUTH /meta-core/db/meta-core.db (backed up; PKs only) │
│ │
│ principal(pid, uid) uid ↔ local surrogate pid │
│ claim(cid, key, pid → value, ts, sig, seq) │
│ the votes; PK (cid, key, pid) │
│ cid_group(cid → rid) hidden local record identity │
│ meta(name → value) schema-version, last_seq │
└──────────────────────────────────────────────────────────────────────┘
│ apply loop (seq order)
▼
┌──────────────────────────────────────────────────────────────────────┐
│ CACHE /meta-core/db-cache/cache.db (never backed up; rebuildable)│
│ │
│ kv(k → v) the v1 layout, every row derived: │
│ file:<rid>/<field> the merged view every read uses │
│ cid:<cid> cid → rid lookup │
│ idx:roots/ idx:fts/ idx:count/ idx:schema/ roots, FTS rowid, │
│ counters (search totals, stats) │
│ ev:<stream>/…, evhwm:… /api/events/meta + /files backlog │
│ meta:applied_seq, meta:rebuilt_at apply-loop bookkeeping │
│ anything else /api/kv/value scratch KV │
│ kv_field_v (index), fts filter + free-text search │
│ claim_by_pid(pid, cid, key) "everything uid X wrote" (?as=, │
│ UDL facade, identity retraction) │
│ local_file(path → midhash, size, mtime) watcher facts, unsigned │
└──────────────────────────────────────────────────────────────────────┘
```
Disaster recovery is "delete the cache, replay truth". `META_CORE_DB_PATH`
names the truth file, `META_CORE_CACHE_PATH` the cache file. The cache keeps
the v1 `kv` layout so every v1 reader (record reads, field index, FTS,
counters, SSE + snapshot) runs unchanged on derived rows; `nsrc` is computed
by the merge (`POST /api/merge`), not stored. Full schema: implementation
basis §5.
### Records: cids, groups and the hidden rid
A vote never mentions a rid. A **rid** (UUIDv7, 26-char Crockford, as in
v1.x) is a local name for a group of cids known to be the same content,
held in `cid_group`:
- A new cid with no link gets its own group and a fresh rid.
- A `cids/<other>` vote on cid X links X's group and `<other>`'s group; the
group with the older rid survives, the losing rid gets a `del` event per
key and the survivor `set` events (no alias table). Retracting the link
does **not** split the group (phase 1); the record view synthesises
`cids/<cid> = "true"` for every cid of the group from `cid_group`.
- Reading record R = every vote on every cid of R's group, merged per key;
within a group one signer counts once per key.
- rids are never signed and never travel; consumers that hold them
(meta-sort, meta-fuse, meta-stremio) keep working because `cid_group`
lives in truth.
### Merge
Per `(rid, key)`, over the group's votes, each signer's newest vote counted
once (`internal/storage/merge.go`, shared by the apply loop, `?as=` and
`POST /api/merge`):
1. the viewer's own live text vote (`?as=<uid>`, applied at read time,
never stored);
2. retractions and ciphertext drop out — only text merges;
3. most distinct signers for the normalised value (**trim + case-fold
only**, no NFC) — `nsrc` is that count (computed, not stored);
4. tie → oldest `ts`;
5. tie → byte-wise smallest value.
The stored value is the winning vote's exact text. Key-sets need no special
code (each member is a key, present while one vote supports it). A
different kind of fact gets a different key, so a guesser (e.g.
filename-parser) never competes with a lookup on the same key.
### Box-local file facts
`filePath`, size, `mtimeNano` and midhash are **not claims** — meta-core
cannot sign, and the disk can always be rescanned. The file watcher and
`POST /api/files/register` write them into the cache's `local_file` table
(and the midhash into `cid_group`); they are joined into the record view
unsigned and never reach the claims API or the swarm. When several local
paths hold the same content, `filePath` is the lexicographically first one
(size and mtime from the same row); a signed `sizeByte` claim wins over the
on-disk size.
### File bytes
```
Location: /files volume (shared across containers).
Access: path resolution via local_file, served via /webdav/*,
/file/{cid} (reverse-lookup), or /data/{hash}.
Path: filePath (from local_file) in the record view, relative to /files.
```
### History: UUID-rooted flat keys (v1.x)
Until v2.0.0 a record was a set of flat keys under an opaque UUID root —
`file:<uuid>/<property>`, `file:<uuid>/cids/<bareCid>` key-sets,
`dup:<uuid>/<path>`, a reverse index `cid:<bareCid> → <uuid>` and a root
index `idx:roots/<uuid>`, all in one kv table. Roots had previously been
content-hash tokens (`file:midhash256:abc/...`); UUID roots treated every
CID symmetrically as an alias. The rid of v2.0.0 is that UUID, kept hidden
and local; migrated records kept their UUIDs. See
[uuid-rooted-metadata.md](uuid-rooted-metadata.md) (the *why*) and the
History note of [metadata-storage-structure.md](metadata-storage-structure.md).
### Path resolution ### Path resolution
`filePath` values stored in metadata are absolute paths under the `/files` `filePath` values are absolute paths under the `/files` volume (e.g.
volume (e.g. `/files/watch/movies/Movie.2024.mkv`). The `/files` prefix is `/files/watch/movies/Movie.2024.mkv`). The `/files` prefix is itself a
itself a per-host detail — moving the volume only requires re-mounting it per-host detail — moving the volume only requires re-mounting it at the
at the same path inside the container. same path inside the container.
--- ---
@@ -276,20 +398,19 @@ Bootstrap / discovery
GET /status GET /status
GET /leader GET /leader
GET /urls GET /urls
GET /services (alias: /api/services) GET /neighbors (alias: /api/neighbors)
GET /services/{name} GET /api/stats
GET /services/cleanup/stats
Metadata (primary surface for other services) Claims (the only metadata write path)
POST /api/claims signed claim batch → per-claim status
POST /api/merge {claims, as?} → {fields, nsrc, rejected}; no write
GET /api/meta/{cid}/claims {rid, cids, claims} of the cid's group (?key=, ?uid=; 404 unknown)
GET /api/metadata/{rid}/claims same, addressed by rid
Metadata reads (merged view + local_file; optional ?as=<uid>)
GET /meta GET /meta
GET /meta/{hash} GET /meta/{hash}
PUT /meta/{hash}
PATCH /meta/{hash}
DELETE /meta/{hash}
GET /meta/{hash}/{key...} GET /meta/{hash}/{key...}
PUT /meta/{hash}/{key...}
DELETE /meta/{hash}/{key...}
POST /meta/{hash}/_add/{key...}
CID-addressed (public, auth-bypassed at the perimeter) CID-addressed (public, auth-bypassed at the perimeter)
GET /api/meta/{cid} GET /api/meta/{cid}
@@ -299,25 +420,47 @@ CID-addressed (public, auth-bypassed at the perimeter)
HEAD /data/{hash} HEAD /data/{hash}
GET /data/{hash}/path GET /data/{hash}/path
Editor / KV / schema / snapshot Editor / KV / schema
GET /api/metadata/hash-ids GET /api/metadata/hash-ids
GET /api/files/tuples
POST /api/files/register (fills local_file + cid_group)
GET /api/metadata/list GET /api/metadata/list
POST /api/metadata/search POST /api/metadata/search
POST /api/metadata/batch POST /api/metadata/clear (wipes both databases)
POST /api/metadata/clear GET /api/metadata/{hashId} (hashId = rid)
{GET,PUT,DELETE} /api/metadata/{hashId} GET /api/metadata/{hashId}/property
{GET,PUT} /api/metadata/{hashId}/property GET /api/kv/{info,keys,tree,search,find} (read-only browser over the cache kv table)
GET /api/kv/{info,keys,tree,search,find,value} {GET,PUT,DELETE} /api/kv/value (service scratch keys only; reserved prefixes refused)
PUT /api/kv/value GET /api/kv/key/{key...} (read-only)
DELETE /api/kv/value
GET /api/kv/key/{key...}
GET /api/schema GET /api/schema
POST /api/schema/rescan POST /api/schema/rescan
GET /api/snapshot/export
POST /api/snapshot/import
POST /api/snapshot/wipe
SSE event streams (HTTP-mirror of Redis Streams) User Data Layer (facade over claims; records are MMUR2)
{GET,PUT} /api/udl/record
PUT /api/udl/records
GET /api/udl/user/{uid}/{key/{key},cid/{cid},all,count}
GET /api/udl/cid/{cid}/{users,aggregate}
GET /api/udl/users/stats (every signer with live claims, plugin keys included)
Account-key identity service (kept in v2.0.0; removal is a separate ticket)
{GET,DELETE} /api/identity (DELETE signs + stores a retraction of every live claim first)
GET /api/identity/{accounts,aead-key}
POST /api/identity/{challenge,generate,import,reveal,sign,sign-batch}
(sign/sign-batch `format`: compact | der; default
compact for "MMUR2\n" payloads, DER otherwise)
Removed in v2.0.0 — answer 410 Gone, pointing to POST /api/claims
PUT/PATCH/DELETE /meta/{hash}
PUT/DELETE /meta/{hash}/{key...}
POST /meta/{hash}/_add/{key...}
PUT/DELETE /api/metadata/{hashId}
PUT /api/metadata/{hashId}/property
POST /api/metadata/batch
Removed in v2.0.0 — no snapshot format
GET /api/snapshot/export, POST /api/snapshot/{import,wipe}
SSE event streams (HTTP-mirror of the store's event streams)
GET /api/events/files GET /api/events/files
GET /api/events/meta GET /api/events/meta
@@ -337,13 +480,51 @@ Watchers
POST /api/watchers/{scan-all,reset-all} POST /api/watchers/{scan-all,reset-all}
(deprecated: /api/scan/trigger, /api/scan/status — see watcher/handlers.go:32) (deprecated: /api/scan/trigger, /api/scan/status — see watcher/handlers.go:32)
Admin (The one-shot Redis-era admin migrations — `/api/admin/migrate-dual-roots`,
POST /api/admin/migrate-dual-roots `migrate-domain-screen`, `migrate-user-cids`, `sweep-literature-echoes` —
were removed with the 2026-10 SQLite switch; they had been run once on
watch.nsl.sh beforehand.)
Report inbox (internal/reports, internal/api/reports.go) — bug reports and
feedback the client apps' 🐛 buttons file; read on the dashboard's Reports page
POST /api/reports {app, note?, page?, route?, cid?, title?, uid?,
build?, filedAt?, log?, <any block>} → 201 {id}
GET /api/reports ?app=&status=open|triaged|closed&q=&before=<id>&limit=
→ {reports: [summary…], apps, counts, more}
GET /api/reports/{id} full report incl. body
PATCH /api/reports/{id} {status?, triage_note?}
DELETE /api/reports/{id}
WebDAV WebDAV
/webdav/... (GET, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND) /webdav/... (GET, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND)
``` ```
### Report inbox
Client apps (meta-watch, meta-read) build a report in the browser and their
server forwards it **server-to-server to the box's own core** — the uid is
stamped by the app from its bearer token, never taken from the body. Apps that
cannot reach the core spool the report locally and retry
(`report_outbox.rs` in each app), so `filedAt` (when it was filed) and
`received` (when meta-core stored it) can differ; the row's `ts` is `filedAt`
when it is plausible (≤ 30 days old, ≤ 5 min in the future).
- **Own file, `db/reports.db`.** Reports are diagnostics, not metadata: neither
the truth DB (signed claims only) nor the cache (thrown away on recovery).
`POST /api/metadata/clear` and a cache rebuild never touch it. A failure to
open it disables `/api/reports*` (503), never the core.
- **meta-core does not interpret a report.** `app`, `build`, `uid`, `page`,
`route`, `cid`, `title`, `note` are lifted into columns for filtering; every
other top-level key is stored verbatim in `body` and rendered generically
(app-specific headlines live in `dashboard/src/lib/reportText.ts`). Bounds:
512 KiB per POST, note 1000 chars, log tail 200 × 600 chars, any other block
over 32 KiB replaced by `{truncated, bytes}`.
- **Bounded.** `META_CORE_REPORTS_MAX` reports and `META_CORE_REPORTS_MAX_MB`
of bodies; on overflow closed reports are evicted first, then the oldest —
an open report is the last to go. Enforced on every insert and hourly.
- **Auth:** perimeter-gated like the rest of `/api`; do not add it to the
hash-lock bypass. A report is viewing/reading history (cids, titles, a uid).
No `/metrics` endpoint is exposed. Observability is via container logs + No `/metrics` endpoint is exposed. Observability is via container logs +
the `/health` and `/status` JSON. the `/health` and `/status` JSON.
@@ -361,17 +542,28 @@ peers and unauthenticated tooling can reach them):
- (`/api/events/files`, `/api/events/meta` are also auth-bypassed but - (`/api/events/files`, `/api/events/meta` are also auth-bypassed but
**should not** be exposed publicly via Caddy; they're inside-only.) **should not** be exposed publicly via Caddy; they're inside-only.)
Since v2.0.0 `/meta/*` serves reads only — its write verbs answer `410
Gone` — so the bypass no longer exposes an unsigned write. Writes go to
`POST /api/claims`, where the claim signature (not the perimeter) is the
write authentication. `POST /api/claims`, `POST /api/merge` and
`GET /api/meta/{cid}/claims` are container-to-container routes, not routed
through Caddy, like the v1.x write routes were.
See `docs/api-mediated-access.md` "Auth" for the rationale. See `docs/api-mediated-access.md` "Auth" for the rationale.
### SSE event streams — wire contract ### SSE event streams — wire contract
`/api/events/files` and `/api/events/meta` are SSE mirrors of the `/api/events/files` and `/api/events/meta` are SSE mirrors of the
`file:events` and `meta:events` Redis Streams. The contract `file:events` and `meta:events` streams, stored as `ev:<stream>/…` rows of
the kv table (`internal/kv/stream.go`). The contract
(`internal/api/sse_events.go`): (`internal/api/sse_events.go`):
- One SSE event per Redis Stream entry. - One SSE event per stream entry.
- `id:` is the opaque `<ms>-<seq>` Redis Stream entry ID — clients echo - `id:` is the opaque `<ms>-<seq>` entry ID (the Redis Stream form, kept so
it back via `Last-Event-ID` on reconnect. cursors persisted before the 2026-10 switch still compare) — clients echo
it back via `Last-Event-ID` on reconnect. Without `Last-Event-ID` the
stream starts at the head (resolved once at connect); `0` / `0-0` replay
from the beginning.
- `event:` is the `type` field of the underlying entry. - `event:` is the `type` field of the underlying entry.
- `data:` is the JSON of the rest of the entry. - `data:` is the JSON of the rest of the entry.
- Heartbeats: SSE comment `:keep-alive\n\n` every 30s of silence. - Heartbeats: SSE comment `:keep-alive\n\n` every 30s of silence.
@@ -380,9 +572,46 @@ See `docs/api-mediated-access.md` "Auth" for the rationale.
`event: gap` payload before resuming from the oldest available entry. `event: gap` payload before resuming from the oldest available entry.
External services use SSE with `Last-Event-ID` as the resume primitive. External services use SSE with `Last-Event-ID` as the resume primitive.
A Redis consumer-group (XREADGROUP / XACK) exists *internally* — the Since v2.0.0 `meta:events` describes changes of the **merged** view, same
`MetaPublisher` uses one to drain keyspace notifications — but it is not `{type: set|del, key, ts}` shape: the cache engine appends an
the external contract. `ev:meta:events/…` row for every change of a merged `file:` row, in the same
cache transaction as the change (both streams are v1 stream rows of the
cache `kv` table). A cache rebuild
emits no per-key events — consumers get `event: gap` and take a snapshot;
event IDs stay time-based and keep rising. *History:* v1.x appended them in
the same transaction as the `file:` key change; the Redis-era
keyspace-notification hop through `MetaPublisher` is long gone.
### Write path and apply loop
```
POST /api/claims ──► per claim, before the writer:
0. validate (required fields, reserved keys, ts range) → else bad_request
1. same ts + sig as stored? → unchanged (no verify)
2. same value as stored (any ts)? → unchanged; stored row + older ts kept
3. stored ts ≥ incoming ts? → stale
4. verify MMUR2 signature → else bad_signature
──► accepted claims, one group-committed truth transaction:
5. re-check 1–3 under the writer
6. uid → pid (principal; insert on first sight)
7. upsert claim (tombstone if null), seq = ++last_seq
8. cid_group: mint rid for unknown cid; text cids/* links groups
(a retraction never splits)
9. commit (last_seq in truth meta); answer once the cache applied it
──► apply loop, in seq order, into the cache:
sync cid:/cids/<cid>/idx:roots rows → claim_by_pid
→ re-merge (rid, key) from truth PK range reads
→ set/delete file:<rid>/<key> (+ local_file for filePath/sizeByte/mtimeNano)
⇒ meta:events row + counter delta in the same tx
→ FTS row on a haystack change → meta:applied_seq
```
The verifier lives in the write path (secp256k1 from `internal/identity`);
meta-core holds **no** writer key and never signs a claim. Response: one
status per claim — `accepted` · `unchanged` · `stale` · `bad_signature` ·
`bad_request`. Request/response shapes and the 410 contract for removed
routes: [api-mediated-access.md](api-mediated-access.md) "Write contract —
signed claims".
### WebDAV URLs ### WebDAV URLs
@@ -412,7 +641,8 @@ WebDAV caching is handled by nginx's `proxy_cache` (see
| Single binary | Static compilation | | Single binary | Static compilation |
| Low resource | ~10-20 MB resident | | Low resource | ~10-20 MB resident |
| HTTP server | stdlib `net/http` + gorilla/mux | | HTTP server | stdlib `net/http` + gorilla/mux |
| Redis client | go-redis | | Metadata store | embedded SQLite via `modernc.org/sqlite` (pure Go, CGO off) — truth + cache databases |
| Claim verification | secp256k1 ECDSA (`internal/identity`) |
| WebDAV | `golang.org/x/net/webdav` | | WebDAV | `golang.org/x/net/webdav` |
### Repo layout ### Repo layout
@@ -425,12 +655,12 @@ packages/meta-core/
│ ├── cid/ CID parsing + algorithm ranking │ ├── cid/ CID parsing + algorithm ranking
│ ├── config/ env-driven configuration │ ├── config/ env-driven configuration
│ ├── discovery/ service registry + dead-service cleaner │ ├── discovery/ service registry + dead-service cleaner
│ ├── events/ meta:events stream publisher │ ├── identity/ secp256k1 (claim verification; account-key service)
│ ├── kv/ SQLite engine (single writer, group commit) + event streams
│ ├── leader/ LeaderInfoProvider (no election logic) │ ├── leader/ LeaderInfoProvider (no election logic)
│ ├── mounts/ rclone mount manager + handlers + stats │ ├── mounts/ rclone mount manager + handlers + stats
│ ├── schema/ live per-field schema indexer │ ├── schema/ per-field schema from transactional counters
│ ├── snapshot/ snapshot export / import / wipe │ ├── storage/ claims write path, apply loop, merge, cid_group, search, schema gate
│ ├── storage/ Redis wrapper + UUID + CID reverse index + schema gate
│ ├── watcher/ scanner + dispatcher + midhash + state │ ├── watcher/ scanner + dispatcher + midhash + state
│ ├── watchers/ polling-based watcher configs │ ├── watchers/ polling-based watcher configs
│ └── webdav/ /webdav/* handler │ └── webdav/ /webdav/* handler
@@ -451,21 +681,29 @@ packages/meta-core/
``` ```
1. Load configuration from environment 1. Load configuration from environment
2. Build LeaderInfoProvider (no election; bash gate already won) 2. Build LeaderInfoProvider (no election; bash gate already won)
3. Connect to local Redis (retry up to 30× / 1s) 3. Create the cache folder; open the truth database (META_CORE_DB_PATH or
4. EnsureSchemaVersion (abort if Redis layout is stale) /meta-core/db/meta-core.db) and the cache (META_CORE_CACHE_PATH or
5. Start service discovery + dead-service cleaner /meta-core/db-cache/cache.db) (storage.Connect)
6. NewServer + Server.Start (boots watcher, mounts, WebDAV, API) 4. EnsureSchemaVersion (abort on a v1 kv store or an unknown layout)
7. On first connection: start MetaPublisher + schema Indexer, 5. storage.Start — cache catch-up (meta:applied_seq ≥ last_seq → nothing; behind →
republish existing metadata onto meta:events replay claims with seq > applied_seq, or a full
8. Wait for SIGINT/SIGTERM rebuild if a cids/* link is among them; no
9. Shutdown in reverse order cache → full rebuild), then the apply loop
6. Derived counters ready
7. Start service discovery + dead-service cleaner
8. NewServer + Server.Start (boots watcher, mounts, WebDAV, API)
9. Wait for SIGINT/SIGTERM
10. Shutdown in reverse order
(v1.x ran `EnsureCounters` and a background FTS rebuild here; in v2.0.0
counters and FTS are cache rows maintained with every merged-row change.)
``` ```
### Process management ### Process management
Redis is not spawned by the Go binary; supervisord starts it before There is no database process: the store is embedded in the Go binary
meta-core (`docker/supervisord.conf`). The Go binary connects to (`docker/supervisord.conf` runs meta-core, nginx and rclone). Until
`localhost:6379` and retries until Redis is reachable. 2026-10 supervisord also ran `redis-server` on `localhost:6379`.
--- ---
@@ -473,15 +711,31 @@ meta-core (`docker/supervisord.conf`). The Go binary connects to
### Container restart ### Container restart
Supervisord restarts crashed processes (meta-core, redis, nginx, rclone) Supervisord restarts crashed processes (meta-core, nginx, rclone)
individually. The bash flock is held by supervisord's process tree, so individually. The bash flock is held by supervisord's process tree, so
internal crashes don't trigger re-election. internal crashes don't trigger re-election.
### Redis crash ### Store durability
Supervisord restarts redis. The Go binary's connect retry handles the Both SQLite files run in WAL mode; truth with `synchronous=NORMAL` (every
window. Persistence is RDB + AOF on the `/meta-core` volume, so no data is committed transaction survives a meta-core process crash), the cache with
lost across restarts. `synchronous=OFF` (it is rebuildable). A claim batch
commits to truth in one transaction; the apply loop then commits the
derived cache changes. **A crash between the two commits leaves the cache
behind, never wrong**: at boot, if the cache's `applied_seq` is below
truth's `last_seq`, meta-core scans truth's `claim` table for
`seq > applied_seq` (a full scan — there is no seq index; it only happens
after an unclean shutdown) and applies them — or rebuilds the cache in full
if one of them is a `cids/*` link.
### Cache loss
The cache is never backed up and is fully derived (except `local_file`,
which a rescan of `/files` refills, and the service scratch keys, whose
loss costs a refetch). A missing cache folder (no `meta:applied_seq`) means
meta-core rebuilds every derived row from truth and stamps
`meta:rebuilt_at`. A rebuild emits no per-key events;
SSE consumers get `event: gap` and re-snapshot.
### Container crash ### Container crash
@@ -492,8 +746,9 @@ the container."
### Schema mismatch ### Schema mismatch
`EnsureSchemaVersion` refuses to boot. Operators wipe with `EnsureSchemaVersion` refuses to boot. Operators wipe with
`pnpm run clean:all` (or equivalent) and restart. This is an alpha-stage `pnpm run clean:all` (or equivalent) and restart, or — once, on
hard-fail; there is no automated migration path. watch.nsl.sh — convert a v1.x store with `tools/claims-migrate`. This is an
alpha-stage hard-fail; the service never migrates automatically.
### Shared volume unavailable ### Shared volume unavailable
@@ -514,7 +769,14 @@ via `/health`; metadata I/O and SSE event streams stop.
| `SERVICE_VERSION` | `1.0.0` | Reported in `/status`. | | `SERVICE_VERSION` | `1.0.0` | Reported in `/status`. |
| `API_PORT` | `8180` | External port baked into `baseUrl`. | | `API_PORT` | `8180` | External port baked into `baseUrl`. |
| `BASE_URL` | _empty_ | Overrides constructed `baseUrl` (HTTPS perimeter). | | `BASE_URL` | _empty_ | Overrides constructed `baseUrl` (HTTPS perimeter). |
| `REDIS_PORT` | `6379` | Local Redis port. | | `META_CORE_DB_PATH` | `<META_CORE_PATH>/db/meta-core.db` | Truth database file (backed up). Dev stacks set `/meta-core-db/meta-core.db` (named volume — SQLite WAL is unsafe on the 9p `/meta-core` bind of Windows dev boxes). |
| `META_CORE_CACHE_PATH` | `/meta-core/db-cache/cache.db` | Cache database file (never backed up; rebuilt from truth when missing). Dev stacks keep it on a named volume too; store apps put the cache folder in Maison's `backup.exclude`. |
| `META_CORE_DB_CACHE_MB` | `64` | Page cache of the writer connection. |
| `META_CORE_DB_READER_CACHE_MB` | `16` | Page cache per reader connection. |
| `META_CORE_DB_READERS` | `8` | Reader connection pool size. |
| `META_CORE_REPORTS_DB_PATH` | `<META_CORE_PATH>/db/reports.db` | Report inbox database (beside truth, so it is backed up with it). |
| `META_CORE_REPORTS_MAX` | `5000` | Reports kept; closed ones are evicted first, then the oldest. |
| `META_CORE_REPORTS_MAX_MB` | `256` | Total report body size kept, MiB. |
| `META_CORE_HTTP_PORT` | `9000` | Go HTTP+SSE+WebDAV port. | | `META_CORE_HTTP_PORT` | `9000` | Go HTTP+SSE+WebDAV port. |
| `META_CORE_HTTP_HOST` | `0.0.0.0` | HTTP bind address. | | `META_CORE_HTTP_HOST` | `0.0.0.0` | HTTP bind address. |
| `HEALTH_CHECK_INTERVAL_MS` | `5000` | Internal health-loop cadence. | | `HEALTH_CHECK_INTERVAL_MS` | `5000` | Internal health-loop cadence. |
@@ -533,7 +795,9 @@ via `/health`; metadata I/O and SSE event streams stop.
|---|---| |---|---|
| `locks/kv-leader.lock` | Flock target. | | `locks/kv-leader.lock` | Flock target. |
| `locks/kv-leader.info` | Leader API URL (plaintext). | | `locks/kv-leader.info` | Leader API URL (plaintext). |
| `db/redis/{dump.rdb, appendonly.aof}` | Redis persistence. | | `db/meta-core.db` (+ `-wal`, `-shm`) | Truth database (claims, principals, `cid_group`). Backed up. Replaced `db/redis/` in 2026-10; holds the claims schema since v2.0.0. |
| `db-cache/cache.db` (+ `-wal`, `-shm`) | Cache database (v1-layout `kv` table: merged view, lookup, counters, events, service scratch keys; field index, FTS, `claim_by_pid`, `local_file`). Never backed up. |
| `db/reports.db` (+ `-wal`, `-shm`) | Report inbox (bug reports / feedback from the client apps). Diagnostics, not metadata; bounded. |
| `services/*.json` | Service registry. | | `services/*.json` | Service registry. |
| `mounts/mounts.json` | rclone mount configuration. | | `mounts/mounts.json` | rclone mount configuration. |
| `mounts/errors/{id}.error` | Mount error logs. | | `mounts/errors/{id}.error` | Mount error logs. |
@@ -547,7 +811,9 @@ via `/health`; metadata I/O and SSE event streams stop.
meta-core consolidates leader gating, metadata storage, event mirroring, meta-core consolidates leader gating, metadata storage, event mirroring,
and file access for MetaMesh into one container with a typed HTTP+SSE and file access for MetaMesh into one container with a typed HTTP+SSE
surface. The bash flock gate guarantees single-leader semantics without surface. The bash flock gate guarantees single-leader semantics without
an external consensus system. UUID-rooted storage with CID aliasing an external consensus system. Metadata is a table of signed votes in a
treats all content hashes symmetrically and makes any-CID resolution backed-up truth database, merged by an apply loop into a rebuildable cache
O(1). API-mediated access ensures no other service ever needs to speak that serves every read; meta-core verifies every claim and signs none.
Redis. CID groups behind a hidden local rid treat all content hashes
symmetrically. API-mediated access ensures no other service ever touches
the store.
+259 -230
View File
@@ -1,180 +1,199 @@
# Redis Metadata Storage Structure # Metadata Storage Structure
How meta-core lays out file metadata in Redis. This document covers the How meta-core lays out metadata on disk since **v2.0.0 (claims model)**: the
on-disk shape (UUID-rooted flat string keys, CID reverse index, two SQLite databases, their tables, how a record is assembled from signed
per-root sets) and the schema-version gate. It does **not** enumerate votes, and the schema-version gate. Implementation basis:
field semantics — that's the job of the repo-root [`docs/implementation/metacore-claims-model.md`](../../../docs/implementation/metacore-claims-model.md).
[`METADATA_KEYS.md`](../../../METADATA_KEYS.md), the authoritative key This document does **not** enumerate field semantics — that is the job of the
registry for everyone who writes to or reads from metadata. repo-root [`METADATA_KEYS.md`](../../../METADATA_KEYS.md), the authoritative
key registry for everyone who writes or reads metadata.
For the design rationale (why UUIDs instead of content hashes, why a Related: why rids are UUIDs and hidden —
reverse index), see [`uuid-rooted-metadata.md`](uuid-rooted-metadata.md). [`uuid-rooted-metadata.md`](uuid-rooted-metadata.md); why the engine is
For the external-access contract (HTTP-only, SSE for events), see shaped this way, its guarantees and measurements —
[`metacore-storage-engine.md`](../../../docs/project-architecture/metacore-storage-engine.md);
the external-access contract (HTTP-only, SSE for events) —
[`api-mediated-access.md`](api-mediated-access.md). [`api-mediated-access.md`](api-mediated-access.md).
> **History.** Until 2026-10 metadata lived in Redis; v1.1.0–v1.2.x kept the
> same flat keys (`file:<rid>/<field>`, `cid:<cid>`, `idx:*`, `dup:*`,
> `udl:*`, `ev:*`) in one SQLite string→string `kv` table. v2.0.0 replaced
> that keyspace entirely; the old layout is summarised at the end
> ([Previous layout](#previous-layout-v11v12-kv-table)).
## Quick summary ## Quick summary
- **Roots are UUIDv7** encoded as 26-char Crockford Base32 (ULID layout), - **Every metadata value is a signed claim**: `claim(uid, cid, key) → value`
not content hashes. Examples in this doc use the placeholder + `ts` + `sig` (MMUR2, secp256k1). One row per signer per field. Writers
`01JKR8XW5T4QMV7AHNJ2DEFGHK`. sign; meta-core never signs.
- **Properties are flat Redis STRING keys**, one per leaf: - **Two databases.** *Truth* `/meta-core/db/meta-core.db`
`file:<uuid>/<property/path>` → string value. *Not* Redis Hashes. (`META_CORE_DB_PATH`, backed up, primary keys only): `principal`, `claim`,
- **CIDs are reverse-index aliases**: `cid:<bareCid>` → `<uuid>` `cid_group`, `meta`. *Cache* `/meta-core/db-cache/cache.db`
(a Redis STRING per known CID; no algorithm prefix — the multicodec (`META_CORE_CACHE_PATH`, never backed up, rebuildable): the v1 `kv` table
already carries it). (merged view as `file:<rid>/<field>` rows, lookup, counters, event streams,
- **Per-root sets**: `file:<uuid>/cids/<bareCid>` (a *key-set*: one STRING bookkeeping, service scratch keys) with its `kv_field_v` index and `fts`,
`"true"` per known CID, midhash included), `file:<uuid>/duplicates` plus two real tables, `claim_by_pid` and `local_file`.
(alternate paths with the same content), plus `file:__index__` (every UUID). - **Schema-free.** A field is a `key` string in a row; a new field never
There is **no stored `canonical_cid`** — it is derived by rank on read. needs DDL.
- **A schema-version sentinel** (`meta-core:schema-version`) refuses to - **Records are cid groups.** A vote names a cid, never a rid. `cid_group`
boot against legacy data. maps each cid to a **rid** — a hidden, locally minted UUIDv7 (26-char
Crockford) naming the group. Examples use `01JKR8XW5T4QMV7AHNJ2DEFGHK`.
- **What readers get is the merged view**: one precomputed value per
`(rid, key)`, stored as the cache row `file:<rid>/<key>`. `nsrc` (how many
signers support it) is computed by the merge and returned by
`POST /api/merge`, not stored. `canonical_cid` is still **derived on read**
by rank, never stored.
- **Watcher facts are not claims**: `filePath`, size, `mtimeNano`, midhash
live in `local_file` and are joined into the record view unsigned.
- **A schema-version sentinel** (`schema-version = "3"` in truth `meta`).
meta-core explicitly refuses a truth file holding the v1 `kv` table, and any
store with claims but no sentinel or another version.
Implementation lives in `internal/storage/client.go`, ## Truth database — `/meta-core/db/meta-core.db`
`internal/storage/cid_resolution.go`, `internal/storage/uuid.go`, and
`internal/storage/schema_version.go`.
## Key structure Backed up; only primary keys (plus `principal.uid`'s implicit UNIQUE index).
### Roots | Table | Columns (PK first) | Notes |
|---|---|---|
| `principal` | `pid` INTEGER PK, `uid` TEXT UNIQUE | `uid` = `"z" + base58btc(33-byte compressed pubkey)`. `pid` is a local surrogate: never signed, never on the wire |
| `claim` | `(cid, key, pid)` PK, `value` ANY, `ts`, `sig`, `seq` | `STRICT, WITHOUT ROWID`. `value`: TEXT = plaintext, BLOB = ciphertext envelope, NULL = retraction (tombstone, kept). `ts` (ms) is the version. `seq` = global change counter, not indexed |
| `cid_group` | `cid` PK, `rid` | the cid → rid map: the record identity layer |
| `meta` | `name` PK, `value` | `schema-version = "3"`, `last_seq` |
`claim` is written **only** by `POST /api/claims`. A claim replaces the
stored row for its `(cid, key, pid)` only if its `ts` is strictly greater;
identical `(ts, sig)` is `unchanged`; the **same value** from the same signer
(any `ts`) is also `unchanged` and keeps the stored row with its older `ts`;
otherwise older or equal is `stale`, and a bad signature is refused. Why `STRICT` with an `ANY` column: a non-STRICT `ANY`
column gets NUMERIC affinity and turns the text `"007"` into the integer 7;
STRICT keeps the written type, which is the cipher flag.
## Cache database — `/meta-core/db-cache/cache.db`
Never backed up (the store app lists the folder in `backup.exclude`).
Deleting it is the recovery procedure: meta-core rebuilds it from truth at
boot (no `meta:applied_seq` → full rebuild), and `local_file` refills from a
rescan.
It keeps the **v1 key/value layout** (`internal/storage/client.go`): one
`kv(k TEXT PRIMARY KEY, v TEXT NOT NULL) WITHOUT ROWID` table, whose rows are
all derived — no client writes `file:` rows any more.
| `kv` rows | Holds | Written by | Read by |
|---|---|---|---|
| `file:<rid>/<field>` | the **merged view**, one row per `(rid, key)` — the only rows under `file:`. Includes the synthesised `cids/<cid> = "true"` members (from `cid_group`) and the box-local `filePath` / `sizeByte` / `mtimeNano` (from `local_file`) | apply loop | every record read; search |
| `cid:<cid>` | cid → rid (copy of truth `cid_group`) | apply loop, watcher | every cid-addressed request |
| `idx:roots/<rid>` | the set of every record | apply loop | listing, counts |
| `idx:fts/<rid>` | rowid of the record's `fts` row | apply loop | FTS maintenance |
| `idx:count/*`, `idx:schema/*` | record count, per-field schema counters | the cache engine's change hook, from `file:` deltas | search `total`, `/api/stats`, `/api/schema` |
| `ev:<stream>/<ms13>-<seq10>`, `evhwm:<stream>` | `meta:events` (every `file:` change, same transaction) and `file:events` (watcher) | cache engine; watcher | `/api/events/meta`, `/api/events/files` (SSE) |
| `meta:applied_seq`, `meta:rebuilt_at` | how far the cache has applied truth; last rebuild (older SSE cursors get `gap`) | apply loop, rebuild | boot catch-up; SSE |
| anything else | service scratch KV (`btmagnet:*`, `searchcov:*`, `nzb:*`) | other services via `/api/kv/value` (reserved prefixes refused) | same |
| Index / table | Shape | Written by | Read by |
|---|---|---|---|
| `kv_field_v` (index) | `kv(lower(field parsed from k), lower(v), v)` over `file:` rows only | — | filter search |
| `fts` | FTS5 trigram `(rid UNINDEXED, hay)`, one row per record | apply loop, when a haystack field's merged value changes | free-text search |
| `claim_by_pid` | `(pid, cid, key)` PK | apply loop | `?as=<uid>` overlay, `/api/udl/*` facade, identity retraction, `/api/udl/users/stats` |
| `local_file` | `path` PK → `midhash`, `size`, `mtime`; index `(midhash, path)` | file watcher, `POST /api/files/register` | record view (`filePath`, `sizeByte`, `mtimeNano`) |
The cache is **behind, never wrong**: after an unclean shutdown, boot finds
`meta:applied_seq < last_seq` and re-applies every truth claim with `seq >
applied_seq` (or rebuilds in full when one of them is a `cids/*` link). Write
path, apply loop and durability are detailed in
[`metacore-storage-engine.md`](../../../docs/project-architecture/metacore-storage-engine.md) §4.
## Keys
The `key` column carries the same field paths METADATA_KEYS.md defines —
what used to follow `file:<rid>/`:
``` ```
file:<uuid>/<property/path> → Redis STRING (always) claim(cid=bagacbabaec…, key=title, uid=<tmdb plugin>) = "Inception"
claim(cid=bagacbabaec…, key=fileinfo/duration, uid=<ffmpeg plugin>) = "8878.5"
claim(cid=bagacbabaec…, key=fileinfo/streamdetails/video/0/codec, uid=<ffmpeg plugin>) = "h264"
claim(cid=bagacbabaec…, key=genres/Drama, uid=<tmdb plugin>) = "true"
claim(cid=bagacbabaec…, key=cids/bafybeigd…, uid=<full-hash plugin>) = "true"
``` ```
UUIDs are produced by `storage.NewUUID()` (`internal/storage/uuid.go`): - `/` is the path separator; nested objects are flattened on write and
UUIDv7 → 26-char Crockford Base32 (skips I/L/O/U; ULID-compatible). The reconstructed on read.
time-sortable prefix lets you `KEYS file:01JKR8*` to find roots minted - **Key-sets** put the member inside the key (`genres/<g>`, `cids/<cid>`,
in a given window, which restored some of the debug-ability lost when `titles/eng/<name>`, `anchoredBy/tmdb:123`): one claim per member, union by
midhash256 stopped being the root. the ordinary merge, removal by a signed retraction.
- Plugins keep writing under their prefixes (`{plugin-id}/<field>`,
`{plugin-id}/<group>/<n>/<field>`), each signing with its own key. Two
sources compete on one key only when they make the same kind of statement;
a different fact (e.g. a filename guess vs a TMDB lookup) gets a different
key.
Properties use `/` as the path separator. Nested objects in source code ## Records: `cid_group` and the hidden rid
are flattened to flat keys on write and reconstructed on read. Examples:
``` ```
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/filePath = "/files/watch/movies/Inception.2010.mkv" votes (truth, signed) cid_group (truth) cache kv rows (merged)
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/sizeByte = "15032385536" (A, M, title) = "The Matrix" M → R1 file:R1/title = "The Matrix"
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/mtimeNano = "1729872000000000000" (A, M, cids/Q) = "true" Q → R1 file:R1/cids/M = "true" (synthesised)
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/title = "Inception" (B, Q, title) = "Matrix, The" file:R1/cids/Q = "true" (synthesised)
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/fileinfo/duration = "8878.5" file:R1/sizeByte = "734003200"
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/fileinfo/streamdetails/video/0/codec = "h264" (B, Q, sizeByte) = "734003200"
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/tmdb/poster = "bafkreih..."
``` ```
### Reverse-index aliases (CIDs) - A text `cids/<other>` vote on cid X links X and `<other>` into one
group. Retracting it does **not** split the group (phase 1). In the merged
view, membership is synthesised from `cid_group` — `cids/<cid> = "true"` for
every cid of the group — and `cids/*` claims are never merged. Record R = every vote on every cid of R's group, merged per key,
each signer counted once per key.
- First-seen cid with no link → new group with a fresh rid
(`storage.NewUUID()`, UUIDv7 → 26-char Crockford Base32, ULID layout).
- Linking two groups → the **older rid survives**; the other group's cids
move to it; `/api/events/meta` emits `del` for each key of the losing rid
and `set` for the survivor. There is no rid alias table.
- `GET /api/meta/<cid>` is a `cid:<cid>` lookup in the cache, then a range
read `[file:<rid>/, file:<rid>0)` — any known cid, any hash algorithm, no
primary.
- Rids are local names: never signed, never on the swarm or gateway wire.
Local consumers (meta-sort, meta-fuse, meta-stremio) do hold them, which is
why `cid_group` lives in truth.
Every CID known for a file is registered as its own Redis STRING, keyed by There is **no stored `canonical_cid`**. `GetMetadataDocument` ranks the
the **bare CID** — no algorithm prefix. A CIDv1 already encodes its hash group's cids through `internal/cid/rank.go` (`cid.Better`) and returns the
function in the multicodec, so naming it again in the key would be winner as `canonical_cid` in the JSON response only.
redundant (and would let the same digest be indexed under two spellings):
``` ## Merge
cid:<bareCid> → <uuid>
```
Examples: The cache row `file:<rid>/<key>` holds the winner over the group's votes
(each signer's newest vote counts once; retractions and ciphertext drop out):
``` most distinct signers (values compared after **trim + case-fold only**, no
cid:bafkreih5kapbjzq... → 01JKR8XW5T4QMV7AHNJ2DEFGHK NFC) → oldest `ts` → byte-wise smallest. The stored value is the winner's
cid:bagacbabaec7v3... → 01JKR8XW5T4QMV7AHNJ2DEFGHK exact text; `nsrc` is not stored (`POST /api/merge` returns it). With
``` `?as=<uid>`, the viewer's own live text votes (found via `claim_by_pid`)
override at read time; they are never written to the cache. Box-local
The reverse index makes `GET /api/meta/<cid>` an O(1) lookup for any fields: when several local paths hold the same content, `filePath` is the
registered CID, independent of which hash algorithm — there is no primary. lexicographically first (size and mtime from the same `local_file` row); a
The auto-alias hook (`maybeAddAliasFromFieldLocked` in signed `sizeByte` claim wins over the on-disk size. Full rules:
`internal/storage/cid_resolution.go`, called from `SetProperty`, implementation basis §7 (`internal/storage/merge.go`).
`SetMetadataFlat` and `MergeMetadataFlat`) registers the alias for any
field whose name starts with `cids/`, so writers emit key-set members
without knowing the reverse index exists.
> ⚠ The hook matches **only** the `cids/` prefix. A legacy `cid_*` or
> `midhash256` *named* field written to a root is stored verbatim and is
> **never indexed** — the record becomes unresolvable by that CID. Writers
> must collapse such fields to `cids/<cid>` members before the write. See
> `METADATA_KEYS.md` §14.13.
### Per-root sets
```
file:<uuid>/cids/<bareCid> → STRING "true" — one key per known CID (a key-set, not a Redis SET)
file:<uuid>/duplicates → Redis SET of alternate paths with same content
```
`cids` is a **key-set**: one flat `file:<uuid>/cids/<cid>` STRING per member,
which keeps it conflict-free to merge across peers (a SET would not be).
The record's own address (the midhash) is just the `0x1000` member — it has
no separate field.
There is **no stored `canonical_cid`**. The canonical CID is *derived on
read*: `GetMetadataDocument` (`internal/storage/cid_resolution.go`) ranks the
key-set's members through `internal/cid/rank.go` (`cid.Better`) and returns
the winner as the `canonical_cid` field of the JSON response only. Nothing
reconciles or persists it — an earlier `reconcileCanonicalCIDLocked` did, and
was removed with the flat `cid_*` keys.
### Global index
```
file:__index__ → Redis SET of all UUIDs
```
`internal/storage/client.go::GetAllHashIDs` reads this set to enumerate
every file without scanning the keyspace.
```bash
SMEMBERS file:__index__ # all UUIDs
SCARD file:__index__ # total file count
SISMEMBER file:__index__ <uuid>
```
### Schema-version sentinel
```
meta-core:schema-version → STRING (currently "2")
```
Set at first boot, checked on every subsequent boot
(`internal/storage/schema_version.go`). If the sentinel is missing but
`file:__index__` is non-empty, the gate refuses to boot (legacy data).
If the sentinel is present but doesn't match `SchemaVersion`, the gate
also refuses to boot. Alpha-stage policy: operators wipe and restart;
there is no automated migration.
## Why flat STRING keys?
The store does not use Redis Hashes. The reasons are:
1. **Field-level keyspace notifications.** Redis publishes
`__keyspace@0__:<key>` on every SET/DEL of a STRING key. With Hashes
you'd only get one notification on the hash itself. meta-core's
`MetaPublisher` (`internal/events/meta_publisher.go`) consumes these
notifications and publishes structured events to the `meta:events`
Redis Stream.
2. **Selective updates without read-modify-write.** A plugin writing
`tmdb/poster` doesn't have to read the rest of the document.
3. **Compatibility with the CID alias hooks.** Field writes can trigger
alias-maintenance work in `SetProperty` without the plugin caring.
## Reads and writes ## Reads and writes
The Go API in `internal/storage/client.go`: | Operation | Endpoint | Store access |
|---|---|---|
| write metadata | `POST /api/claims` (batch of signed claims) | truth upsert, then the apply loop updates the cache |
| removed writes | `PUT/PATCH/DELETE /meta/*`, `POST /meta/{h}/_add/{k}`, `PUT/DELETE /api/metadata/{id}`, `PUT /api/metadata/{id}/property`, `POST /api/metadata/batch` | `410 Gone`, pointing at `POST /api/claims` |
| read a record | `GET /api/meta/{cid}`, `GET /api/metadata/{rid}`, `GET /file/{cid}`, `GET /meta/{h}` | `cid:` lookup → `file:<rid>/*` rows, optional `?as=` |
| list / search | `GET /api/metadata/hash-ids`, `/list`, `POST /api/metadata/search`, `GET /api/files/tuples`, `/api/schema`, `/api/stats` | `file:` rows, `kv_field_v`, `fts`, `idx:count/*` / `idx:schema/*` |
| raw votes | `GET /api/meta/{cid}/claims`, `GET /api/metadata/{rid}/claims` (`?key=`, `?uid=`) | `{rid, cids, claims}`: every live signed claim of the group, all signers, tombstones excluded; 404 for an unknown cid / rid |
| merge without storing | `POST /api/merge` `{claims, as?}` → `{fields, nsrc, rejected}` | none — verify + merge in memory (plus `as`'s votes this core holds) |
| watcher facts | the watcher, `POST /api/files/register` | `local_file` + `cid_group` |
| service scratch | `PUT/GET/DELETE /api/kv/value` | non-reserved cache `kv` rows |
| wipe | `POST /api/metadata/clear` | both databases |
| Method | Behaviour | Truth writes go through **one writer goroutine** with group commit; reads use
|---|---| a separate pool and see committed state (WAL). Read paths and shapes are
| `GetMetadataFlat(uuid)` | `SCAN` for `file:<uuid>/*`, `MGET` the values, return `map[string]string`. | unchanged from v1.x — they are now served from the derived `file:` rows.
| `SetMetadataFlat(uuid, m)` | Pipeline `SET` per field; `SADD file:__index__`; auto-register an alias for every `cids/<cid>` member. |
| `MergeMetadataFlat(uuid, m)` | Like `SetMetadataFlat` but doesn't pre-clear; used by `PATCH /meta/{hash}`. |
| `GetProperty(uuid, field)` | `GET file:<uuid>/<field>`. |
| `SetProperty(uuid, field, value)` | `SET file:<uuid>/<field>`; auto-register alias if the field is a CID. |
| `DeleteRoot(uuid)` | `SCAN` + `DEL` for `file:<uuid>/*`, unmap every reverse-index entry. |
| `Mint(filePath, size, mtimeNano)` | `NewUUID()` + write `filePath/sizeByte/mtimeNano` + add to index. |
| `AddAlias(uuid, "<bareCid>")` | Set `cid:<bareCid>` → `<uuid>` and the key-set member `file:<uuid>/cids/<bareCid>` = `"true"`. Nothing to reconcile — canonical is derived on read. |
| `ResolveRoot("<bareCid>")` | `GET cid:<bareCid>` → uuid (returns the input unchanged if it is already a UUID root). |
| `GetMetadataDocument(uuid)` | Flat fields + `cids` hoisted to an array + `canonical_cid` **derived** by `cid.Better` rank + `duplicates`. Backs `GET /api/meta/{cid}`. |
## Reconstruction (flat → nested) ## Reconstruction (flat → nested)
When the HTTP API returns a document, it reconstructs the flattened The HTTP API reconstructs the merged flat keys into a nested JSON object:
storage back into a nested JSON object:
``` ```
flat nested flat nested
@@ -186,112 +205,122 @@ fileinfo/streamdetails/ streamdetails: { video:
streams/0/language = "eng" streams: [ { language: "eng" }, ... ] streams/0/language = "eng" streams: [ { language: "eng" }, ... ]
``` ```
Rules: - Numeric path segments become array indices on read.
- All values are strings on the wire; type interpretation is the caller's
job (documented per field in `METADATA_KEYS.md`).
- Box-local fields from `local_file` are joined in unsigned; they never
appear in `GET /api/meta/{cid}/claims` or on the swarm.
- `/` is the path separator. ## Search structures
- Numeric path segments (`0`, `1`, ...) become array indices on read.
- All values are strings on the wire; type interpretation is the
caller's job (and is documented per-field in `METADATA_KEYS.md`).
- `null`/`undefined` round-trip as empty strings.
## Plugin contribution flow Both live in the cache and are maintained in the same transaction as the
merged-row change, so results are fresh as soon as a change is applied:
Plugins don't talk to Redis. They write metadata via meta-core's HTTP - **`kv_field_v`** — an expression index over the field name parsed out of
API (`PUT /meta/{hash}/{key...}` or `PATCH /meta/{hash}`). Each plugin the key, `lower(v)` and `v`, restricted to `file:` rows. Answers
writes under a prefix: "which records have field F with a value like V" for any field, including
ones invented tomorrow; covering for the substring test.
- **`fts`** — FTS5 trigram `(rid UNINDEXED, hay)`, one row per record: the
haystack of its searchable merged fields plus the rid. Every 3+ character substring is
indexable.
``` `POST /api/metadata/search` builds a candidate superset in SQL and verifies
{plugin-id}/<field> single value each hydrated merged record with the Go matchers (`recordMatchesFilter`,
{plugin-id}/<group>/<field> grouped `matchAllTokens`); results come in rid order. Plan, rejected shapes and the
{plugin-id}/<group>/<n>/<field> indexed (array) measurement status are in
``` [`metacore-storage-engine.md`](../../../docs/project-architecture/metacore-storage-engine.md) §8.
See [`METADATA_KEYS.md`](../../../METADATA_KEYS.md) for the full, ## Schema-version sentinel
authoritative list of fields each plugin writes (content identification,
file stats, video classification, anime detection, subtitle references, Truth `meta` row `schema-version` = `"3"`, set at first boot and checked on
Jellyfin metadata, FFmpeg stream details, etc.) and the value formats every boot (`internal/storage/schema_version.go`), after both files are
(`int-string`, `lang3`, `path-relfiles`, `csv-set`, ...). This doc opened and before the cache catch-up. A truth file holding the v1 `kv` table
deliberately does not duplicate that registry — it would drift. (v1.x, `meta-core:schema-version = "2"`) is **explicitly refused**, naming the
migration tool; claims without a sentinel, or another version, are refused
too; a fresh store is stamped. Alpha policy: no migration code in the
service binary; operators wipe and restart. watch.nsl.sh is converted once by
the separate one-off tool `packages/meta-core/tools/claims-migrate` (not in
the image; rids keep their UUIDs; legacy fields re-signed by a throwaway key;
UDL cells re-signed by their account keys — implementation basis §11).
## Event streams ## Event streams
Two Redis Streams are produced inside meta-core:
| Stream | Producer | Contents | | Stream | Producer | Contents |
|---|---|---| |---|---|---|
| `file:events` | `internal/watcher` dispatcher | `add` / `change` / `delete` / `rename` / `reset` events for files on the `/files` volume. | | `/api/events/meta` | the cache engine, as `ev:meta:events/…` rows of the cache `kv` table | `{type: set\|del, key, ts}` for every change of a **merged** value, in the same cache transaction as the change |
| `meta:events` | `internal/events.MetaPublisher` | Field-level metadata mutations derived from Redis keyspace notifications. | | `/api/events/files` | `internal/watcher` dispatcher (`ev:file:events/…` rows) | `add` / `change` / `delete` / `rename` / `reset` for files on `/files` (unchanged) |
External consumers do **not** read these streams directly. They subscribe - A vote that does not change the merged value emits nothing.
via SSE: - A group merge emits `del` for every key of the losing rid and `set` for the
survivor's keys.
- `GET /api/events/files` — mirror of `file:events` - IDs keep the `<ms>-<seq>` form and stay time-based, so persisted cursors
- `GET /api/events/meta` — mirror of `meta:events` keep comparing, including across a cache rebuild.
- `Last-Event-ID: 0` / `0-0` returns a snapshot (one `set` per merged key)
The SSE wire contract uses `Last-Event-ID` for resume (the opaque then the live tail; only the last snapshot event carries an `id:`.
`<ms>-<seq>` Redis Stream entry ID). Heartbeats are SSE comments - A trimmed cursor gets one `event: gap`. A cache rebuild emits no per-key
(`:keep-alive\n\n`) every 30s of silence. If retention has trimmed past events; consumers get `gap` and take a snapshot.
a client's cursor, the handler emits one `event: gap` payload before - Heartbeats are SSE comments (`:keep-alive\n\n`) every 30 s of silence.
resuming. See `internal/api/sse_events.go` and Contract: `internal/api/sse_events.go`, [`api-mediated-access.md`](api-mediated-access.md).
[`api-mediated-access.md`](api-mediated-access.md) for the full
contract.
Internally, meta-core uses a Redis consumer group (XREADGROUP / XACK)
inside `MetaPublisher` to drain keyspace notifications, but this is not
the external surface — external services use SSE.
## Debugging ## Debugging
Paths: truth `/meta-core/db/meta-core.db`, cache `/meta-core/db-cache/cache.db`
(image default and store app); dev stacks keep both on named volumes (the
`/meta-core` bind is 9p on Windows dev boxes, where SQLite WAL is unsafe).
Always open **read-only**; writes go through `POST /api/claims`.
```bash ```bash
# Roots T=/meta-core/db/meta-core.db; C=/meta-core/db-cache/cache.db # dev: the named-volume paths
docker exec metacore-app redis-cli SMEMBERS 'file:__index__' QT() { docker exec metacore-app sqlite3 -readonly "$T" "$1"; }
docker exec metacore-app redis-cli SCARD 'file:__index__' QC() { docker exec metacore-app sqlite3 -readonly "$C" "$1"; }
docker exec metacore-app redis-cli SRANDMEMBER 'file:__index__'
# Full document for one root (flat scan) QT "SELECT rid FROM cid_group WHERE cid = 'bafkreih...'" # cid → rid
docker exec metacore-app redis-cli --scan --pattern 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/*' QT "SELECT cid FROM cid_group WHERE rid = '01JKR8XW5T4QMV7AHNJ2DEFGHK'" # the group (full scan: no rid index)
QC "SELECT k, v FROM kv WHERE k >= 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/' AND k < 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK0'" # merged record
# One property QT "SELECT c.key, typeof(c.value), c.value, p.uid, c.ts, c.seq
docker exec metacore-app redis-cli GET 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/fileinfo/duration' FROM claim c JOIN principal p USING(pid) WHERE c.cid = 'bafkreih...'" # every vote on one cid
QT "SELECT name, value FROM meta"; QC "SELECT k, v FROM kv WHERE k LIKE 'meta:%'" # last_seq vs applied_seq
# Confirm keys are STRING (not HASH) QC "SELECT path, midhash FROM local_file WHERE path LIKE '/files/watch/%' LIMIT 10"
docker exec metacore-app redis-cli TYPE 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/filePath' QC "SELECT k, v FROM kv WHERE k >= 'ev:meta:events/' AND k < 'ev:meta:events0' ORDER BY k DESC LIMIT 10"
# string
# Reverse-index lookup (bare CID — no algorithm prefix)
docker exec metacore-app redis-cli GET 'cid:bafkreih...' # → uuid
# The cids key-set (one STRING per member, NOT a Redis SET — SMEMBERS won't work)
docker exec metacore-app redis-cli --scan --pattern 'file:01JKR8XW5T4QMV7AHNJ2DEFGHK/cids/*'
# canonical_cid is NOT stored. Derive it via the API instead:
curl -k https://metacore-dev.localhost:8083/api/meta/<cid> | jq '.canonical_cid, .cids'
# Schema-version sentinel
docker exec metacore-app redis-cli GET 'meta-core:schema-version'
# Streams (server-side; external consumers should use SSE)
docker exec metacore-app redis-cli XRANGE 'file:events' - + COUNT 10
docker exec metacore-app redis-cli XRANGE 'meta:events' - + COUNT 10
# HTTP equivalents — preferred # HTTP equivalents — preferred
curl -k 'https://metacore-dev.localhost:8083/api/meta/<cid>' # CID → document curl -k 'https://metacore-dev.localhost:8083/api/meta/<cid>' # merged document (+ canonical_cid)
curl -k 'https://metacore-dev.localhost:8083/meta' # list root IDs curl -k 'https://metacore-dev.localhost:8083/api/meta/<cid>/claims' # signed votes
curl -k 'https://metacore-dev.localhost:8083/meta/<uuid>' # document by UUID
curl -k 'https://metacore-dev.localhost:8083/api/events/meta' # live SSE curl -k 'https://metacore-dev.localhost:8083/api/events/meta' # live SSE
curl -k 'https://metacore-dev.localhost:8083/api/kv/info' # KV stats
``` ```
## Key implementation files ## Key implementation files
| Component | Path | | Component | Path |
|---|---| |---|---|
| Redis client + flat-key ops | `internal/storage/client.go` | | Single-writer, group-commit SQLite engine | `internal/kv/` |
| Truth, apply loop, merge, local files, counters | `internal/storage/{truth,apply,merge,localfile,client}.go` |
| Search (`kv_field_v` + FTS) | `internal/storage/search.go` |
| MMUR2 claim encoding + verification | `internal/claim/` (secp256k1 in `internal/identity/`) |
| UUIDv7 minting | `internal/storage/uuid.go` | | UUIDv7 minting | `internal/storage/uuid.go` |
| CID reverse index + aliases + canonical-CID reconciliation | `internal/storage/cid_resolution.go` | | Canonical-CID rank | `internal/cid/rank.go` |
| Schema-version gate | `internal/storage/schema_version.go` | | Schema-version gate | `internal/storage/schema_version.go` |
| Dual-root migration (legacy clean-up) | `internal/storage/dual_root_migration.go` | | SSE event mirror + snapshot | `internal/api/sse_events.go` |
| Keyspace-notification → `meta:events` publisher | `internal/events/meta_publisher.go` | | Watcher → `local_file`, `/api/events/files` | `internal/watcher/` |
| Watcher → `file:events` dispatcher | `internal/watcher/dispatcher.go` |
| SSE event mirror | `internal/api/sse_events.go` |
| Field semantics (authoritative registry) | [`/METADATA_KEYS.md`](../../../METADATA_KEYS.md) | | Field semantics (authoritative registry) | [`/METADATA_KEYS.md`](../../../METADATA_KEYS.md) |
## Previous layout (v1.1–v1.2 `kv` table)
Kept so old notes and scripts can be read. In v2.0.0 the **truth** database
has no `kv` table at all; the **cache** keeps the same key layout for derived
rows (see above), but nothing in it is written by clients.
| Key | Value | Replaced by |
|---|---|---|
| `file:<rid>/<field>` | one value per field, last writer wins | `claim` (one row per signer) → derived cache `file:` rows |
| `cid:<cid>` | `<rid>` | `cid_group` (truth), copied to cache `cid:` rows |
| `idx:roots/<rid>`, `idx:count/*`, `idx:schema/*`, `idx:fts/<rid>` | root set, counters, FTS rowid | same keys, derived, in the cache |
| `dup:<rid>/<path>` | duplicate path | `local_file` rows |
| `udl:rec:<uid>/<cid>/<key>`, `udl:idx:*` | User Data Layer cells (MMUR1, `version`) | `claim` (MMUR2, `ts`) + `claim_by_pid` |
| `ev:<stream>/<ms13>-<seq10>`, `evhwm:<stream>` | stream rows | same keys, in the cache |
| `btmagnet:*`, `searchcov:*`, `nzb:*` | service scratch | same keys, in the cache |
| `meta-core:schema-version` = `"2"` | gate | truth `meta` `schema-version` = `"3"` |
The flat-key idea survived — a field is still one string key, a key-set
member is still inside the key — but the record moved from "one value per
field" to "one value per signer per field".
+201 -287
View File
@@ -2,361 +2,275 @@
## Status ## Status
**Status:** Implemented (2026 alpha clean-wipe gate landed). Kept for design rationale. **Status:** Design record. Implemented in 2026 alpha (clean-wipe gate);
**reframed by meta-core v2.0.0 (claims model)** — see
[`docs/implementation/metacore-claims-model.md`](../../../docs/implementation/metacore-claims-model.md)
§4. This doc keeps the *why* behind UUID rids and CID symmetry; it is not
the schema reference. For what is stored, see
[metadata-storage-structure.md](./metadata-storage-structure.md) and
`METADATA_KEYS.md` §2/§14.13.
> ⚠ **Two parts of this proposal were superseded before shipping.** This doc What still holds, unchanged since the original proposal:
> is the design record; it is *not* the schema reference. For what is actually
> stored, see `METADATA_KEYS.md` §2/§14.13 and - **No CID is privileged.** Any known CID resolves to its record; all hash
> [metadata-storage-structure.md](./metadata-storage-structure.md). types are symmetric.
- **Rids are UUIDv7**, 26-char Crockford Base32, **minted locally** with no
central allocator.
- **`canonical_cid` is derived on read** by rank (`internal/cid/rank.go` →
`cid.Better`), never stored.
- **`cids` is a key-set of bare CIDs** (`cids/<bareCid>` = `"true"`), no
`<algo>:` prefix — a CIDv1's multicodec already names the hash function.
What changed in v2.0.0:
> 1. **The rid is a hidden local name for a group of cids.** Metadata is no
> longer stored under `file:<uuid>/<field>`. Every value is a signed claim
> about a **cid** (`claim(uid, cid, key) → value`); `cid_group(cid → rid)`
> says which cids form one record. The rid is **derived from cid groups,
> never signed and never on the wire** (swarm, gateway); each box mints its
> own.
> 2. **The `cid:<cid>` reverse index is gone**, replaced by the `cid_group`
> table (still a primary-key lookup, so still O(1)-class).
> 3. **meta-core no longer writes `cids/*` members.** They are signed votes
> from the writers that learn a cid (e.g. the full-hash plugin); a non-null
> `cids/<other>` vote links two groups. Only the watcher's midhash enters
> `cid_group` directly.
> 4. **`duplicates` / `dup:<uuid>/<path>` are gone.** Box-local paths live in
> the cache's `local_file(path → midhash, size, mtime)`; several paths with
> the same midhash are the duplicates.
> >
> 1. **`canonical_cid` is not a stored key.** The proposed reconciler > Earlier superseded points, kept for the record: the proposed
> (`reconcileCanonicalCIDLocked`) was never kept — a stored scalar isn't > `reconcileCanonicalCIDLocked` never shipped; `cids` never was a Redis SET
> conflict-free to merge across peers. The canonical CID is **derived on > of `<algo>:<value>` tokens; Redis itself went in v1.1.0 (2026-10). Text
> read** by ranking the `cids` members (`internal/cid/rank.go` → > below marked *(proposal)* or *(v1.x)* describes those shapes, not v2.0.0.
> `cid.Better`), and appears only in the `GET /api/meta/{cid}` response.
> 2. **`cids` is a key-set, not a Redis SET, and holds bare CIDs, not
> `<algo>:<value>` tokens.** One flat STRING per member:
> `file:<uuid>/cids/<bareCid>` = `"true"`. The reverse index is likewise
> `cid:<bareCid>` → uuid. A CIDv1's multicodec already names its hash
> function, so the `<algo>:` prefix was redundant.
>
> Text below that references either shape describes the *proposal*, not the
> code. The rank ordering and everything else did ship as written.
## Motivation ## Motivation (history)
Today, every metadata entry in Redis is keyed by a content hash, specifically Before UUID rids, every metadata entry in Redis was keyed by a content hash,
midhash256: specifically midhash256:
``` ```
file:midhash256:abc.../filePath file:midhash256:abc.../filePath
file:midhash256:abc.../title file:midhash256:abc.../title
file:midhash256:abc.../fileinfo/duration
file:midhash256:abc.../tmdb/poster file:midhash256:abc.../tmdb/poster
...
``` ```
Other digests (sha256, IPFS CIDs, BitTorrent infohashes) live as fields Other digests (sha256, IPFS CIDs, BitTorrent infohashes) lived as fields
*inside* that entry: *inside* that entry (`cid_sha256`, `cid_ipfs`). Two problems:
``` 1. **midhash256 was privileged by accident.** It *was* the key, while every
file:midhash256:abc.../cid_sha256 = sha256:def... other CID was a secondary field. Hash-algorithm changes, or new digest
file:midhash256:abc.../cid_ipfs = bafy... types added by plugins, forced rewrites of every root. The privilege
``` itself was the issue, not which hash held it.
2. **CID lookup was O(n).** "Given any CID, return the metadata" scanned
every entry (`storage/client.go::LookupPathByCID`). Unacceptable for a
public API, especially when peers query by any CID variant.
Two problems with this: The requirement: **any CID a client knows must resolve to that content's
metadata in O(1)**, and the storage layout must not bias toward any digest
1. **midhash256 is privileged by accident.** It IS the key, while every other type. The claims model adds a second one: **what is signed must name
CID is a secondary field. There's nothing intrinsically special about something every peer agrees on** — a rid minted at random on one box does
midhash256 — it's just the first hash computed. Hash-algorithm changes, or not qualify, a cid does.
new digest types added by plugins, force rewrites of every root. The
"promotion" workarounds people reach for (re-keying entries when a stronger
hash arrives) solve the wrong problem; the privilege itself is the issue.
2. **CID lookup is O(n).** Resolving "given any CID, return that file's
metadata" currently scans every indexed entry (see
`storage/client.go::LookupPathByCID`, which acknowledges this in a
comment). Acceptable for admin tooling, unacceptable for a public API
surface — especially when peers on meta-share may query by any CID variant.
The product requirement is clear: **any CID a client knows about must resolve
to that file's metadata in O(1).** IPFS-compatible CIDs are the externally
meaningful identity for content retrieval; the internal storage layout should
not bias toward any one digest type.
## Goal ## Goal
A single externally-meaningful contract:
``` ```
GET /api/meta/<cid> → full metadata document for that file GET /api/meta/<cid> → full (merged) metadata document for that content
``` ```
Where `<cid>` may be any supported type (midhash256, sha256, IPFS, btih, `<cid>` may be any supported type (midhash, sha256, IPFS, btih, sha3, future
sha3, future additions). All CID types are treated symmetrically. No CID type additions), all treated symmetrically.
is "the key."
## Design ## Design (v2.0.0)
### Two-tier key structure ### Votes name cids; a rid names a group
The root key becomes an opaque internal UUID; CIDs are aliases that point to
it.
``` ```
file:<uuid>/<property...> ← canonical storage (UUIDv7, never disclosed externally) votes (signed, travel) derived (local, persistent: truth cid_group)
cid:<cid> → <uuid> ← reverse index (one Redis key per known CID) (A, M, title) = "The Matrix" M → R1
(A, M, cids/Q) = "true" ← alias vote Q → R1
(B, Q, title) = "Matrix, The"
(B, Q, sizeByte)= "734003200" record R1 = every vote on M or Q, merged per key
``` ```
The metadata keyspace under `file:<uuid>/` remains open and flat, exactly as - **First-seen cid, no link** → new group, fresh UUIDv7 rid.
today — anything that wrote `file:<midhash>/fileinfo/duration` now writes - **`cids/<other>` vote on X** → X and `<other>` join one group.
`file:<uuid>/fileinfo/duration`. Only the root identifier changes. - **Link between two existing groups** → the group with the **older rid
survives**; the other's cids move to it; the losing rid gets a `del` event
for each of its keys, the survivor `set` events. No `rid_alias`: consumers
already handle deletes, and cids re-resolve.
- **Reading record R** = every vote on every cid of R's group, merged per key
(one signer counts once per key), precomputed into the cache's
`file:<rid>/<field>` rows (the v1 key layout, now derived).
- **Retracting a `cids/<other>` link does not split the group** (phase 1):
the tombstone drops out of the merge, `cid_group` keeps both cids under one
rid. In the record view, membership is synthesised from `cid_group`
(`cids/<cid> = "true"` for every cid of the group).
**Example layout:** Why keep a rid at all, if votes never mention it: local consumers (meta-sort,
meta-fuse, meta-stremio) and the merged view need one stable name per record,
and a group's cid set grows over time. Why `cid_group` lives in **truth**
although it could be recomputed from `cids/*` votes: the rid names are minted
at random and consumers hold them, so a recomputation would rename every
record.
``` ### Why UUIDv7 (still valid)
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/filePath = /watch/Inception.mkv
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/size = 4500000000
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/mtime = 1729872000000000000
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/cids/bagacbabaec… = "true" ← midhash (record address)
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/cids/bafybeigd… = "true" ← sha2-256
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/duplicates = SET{/watch/dup1.mkv, /watch/dup2.mkv}
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/title = "Inception"
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/fileinfo/duration = 7200.5
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/tmdb/poster = bafy...
file:01JKR8XW5T4QMV7AHNJ2DEFGHK/subtitle/eng = ...
cid:bagacbabaec… → 01JKR8XW5T4QMV7AHNJ2DEFGHK - **128 bits**: collision-free under distributed minting; no allocator. Each
cid:bafybeigd… → 01JKR8XW5T4QMV7AHNJ2DEFGHK box minting its own rids for the same content is now the normal case, not a
``` corner case — which is exactly why rids never travel.
- **Time-sortable prefix**: rids minted in a window sort together
(`k >= 'file:01JKR8' AND k < 'file:01JKR9'` on the cache's merged rows),
and "older rid
survives" on a group merge is a plain string comparison.
- **Monotonic-ish**: adjacent rids cluster in the cache's `file:<rid>/…`
key range.
*(As shipped — the proposal originally wrote `cids` as a Redis SET of Encoded as 26-char Crockford Base32 (ULID-compatible) for compactness and
`<algo>:<value>` tokens plus a `canonical_cid` scalar. See the Status note.)* case-insensitive matching. Migrated records kept their v1.x UUIDs as rids.
### UUID format: UUIDv7 ### The `cids` key-set
UUIDv7 chosen over UUIDv4 or 64-bit random: `cids/<bareCid>` = `"true"`, one claim per member, by whichever signer knows
the link. A key-set merges conflict-free: a member is present while at least
one vote supports it, and removal is a signed retraction. That is the reason
the proposal's Redis SET was reversed before shipping, and it is what lets
the claims model treat `cids` with no special merge code.
- **128 bits**: collision-free under distributed minting; no central allocator. Box-local file facts (`filePath`, size, `mtimeNano`, midhash) are **not**
- **Time-sortable prefix**: `KEYS file:01JKR8*` retrieves entries minted in a claims — meta-core cannot sign and the disk can always be rescanned. The
given window. Restores some of the debuggability lost by going opaque. watcher records them in `local_file` and puts the midhash into `cid_group`;
- **Monotonic-ish**: adjacent UUIDs cluster on disk; modestly better SCAN the record view joins them in unsigned. When several local paths hold the
locality. same content, `filePath` is the lexicographically first one.
Encoded as a 26-character Crockford Base32 string (ULID-compatible) for ### Canonical CID selection (unchanged)
compactness and case-insensitive matching.
### New root fields owned by meta-core The canonical CID is a label, not a key, so nothing external breaks when it
changes. It is computed on every read from the group's cids:
The metadata schema is open — any writer can put any key under
`file:<uuid>/`. This proposal does not change that. It adds three keys whose
sole writer is meta-core itself:
| Key | Type (as shipped) | Purpose |
|-----|------|---------|
| `cids/<bareCid>` | STRING `"true"` — a *key-set*, one key per member | All CIDs known for this file, midhash included. Mirror of the `cid:*` reverse index. Source of truth for delete-time GC. **Proposed as a Redis SET of `<algo>:<value>` tokens; shipped as a key-set of bare CIDs** — a key-set merges conflict-free across peers and the multicodec already names the algorithm. |
| `duplicates` | Redis SET | Additional file paths whose content matches this entry. One UUID = one set of metadata; multiple physical files may share that content. Consumed by meta-dup. |
| ~~`canonical_cid`~~ | *(not stored)* | **Dropped.** Was to hold the externally-presented CID. Now derived on read by ranking the `cids` members; surfaced only on `GET /api/meta/{cid}`. |
Existing meta-core-owned fields (`filePath`, `size`, `mtime`, `midhash256`)
keep their names and writers. Plugin-written fields (`tmdb/*`, `fileinfo/*`,
`subtitle/<lang>`, …) are unaffected.
~~Redis SET is a departure from the all-strings convention in the current
storage doc, but `file:__index__` is already a SET — so the precedent exists
for using sets where they're the right structure.~~ **Reversed for `cids`.**
The all-strings convention won: `cids` ships as flat `cids/<cid>` STRING keys
so that two peers merging the same record can never disagree about set
membership. `duplicates` remained a Redis SET (it is leader-local and never
merged across peers).
### Canonical CID selection
The canonical CID is a label, not a key — so nothing external breaks when it
changes. **As shipped it is not written at all**: it is computed on every read
from the `cids` members, which makes the "update" cost zero and removes the
reconciler entirely.
Selection rule, highest-wins (this part shipped as designed —
`internal/cid/rank.go`):
1. IPFS CID (any variant) 1. IPFS CID (any variant)
2. SHA-256 / SHA-3 family 2. SHA-256 / SHA-3 family
3. BitTorrent infohash (v2 preferred over v1) 3. BitTorrent infohash (v2 preferred over v1)
4. midhash256 4. midhash256
Ranked by **utility for external retrieval**, not by cryptographic strength. Ranked by **utility for external retrieval**, not cryptographic strength:
midhash256 and sha256 are both 256-bit, but only sha256/IPFS CIDs are midhash256 and sha256 are both 256-bit, but only sha256/IPFS CIDs are
externally meaningful for content addressing. btih ranks above midhash because externally meaningful; btih ranks above midhash because it enables swarm
it enables swarm interop even if it's weaker as a digest. interop. The full ladder (including the custom locator codecs) lives in
`internal/cid/rank.go`, mirrored in meta-search / meta-share
(`scripts/check-cid-vectors.sh`).
~~Reconciler runs on the leader, recomputes `canonical_cid` whenever ## Lifecycles (v2.0.0)
`AddAlias` registers a new CID for an entry.~~ **Not shipped** — there is no
reconciler. `GetMetadataDocument` folds the members through `cid.Better` on
each read.
## Internal operations ### File arrival
The meta-core storage interface adds five operations. None are exposed
externally — they're internal to meta-core. External callers see only the
CID-based API.
```go
// Mint creates a new root with no aliases. Called when the watcher
// computes a midhash for a previously-unseen file. Returns the new UUID.
Mint(filePath string, size int64, mtime int64) (uuid string, err error)
// AddAlias registers a CID as resolving to this UUID. Updates both the
// cid:<cid> reverse index and the file:<uuid>/cids/<cid> key-set member.
// (As shipped: no reconciliation step — canonical is derived on read.)
AddAlias(uuid string, cid string) error
// GetByCID resolves any known CID to its UUID. Returns ("", nil) if unknown.
GetByCID(cid string) (uuid string, err error)
// MergeIfAliasExists is called when the watcher mints a fresh UUID for a
// file path but, on hashing, discovers the resulting CID already belongs
// to a different UUID. Merges the new (mostly-empty) UUID into the
// existing one, transferring filePath into the duplicates set, and
// returns the canonical UUID.
MergeIfAliasExists(newUUID, cid string) (canonicalUUID string, merged bool, err error)
// DeleteRoot removes the entire entry, including every cid:* alias and
// every file:<uuid>/* key. Used for file deletion events.
DeleteRoot(uuid string) error
```
### Lifecycle: file arrival
``` ```
1. Watcher sees /watch/foo.mkv created. 1. Watcher sees /files/watch/foo.mkv, computes its midhash M.
2. Watcher computes midhash256 → "midhash256:abc...". 2. local_file: /files/watch/foo.mkv → (M, size, mtime).
3. Watcher calls GetByCID("midhash256:abc..."). 3. cid_group: M already grouped → nothing; otherwise M → fresh rid.
→ uuid_X (existing entry) or "" (new file). 4. Plugins process the file by cid M (the rid is never disclosed to them)
and sign claims about M with their own keys.
4a. If existing (uuid_X): 5. The full-hash plugin computes sha256 S and signs (plugin, M, cids/S) = "true".
- Add "/watch/foo.mkv" to file:<uuid_X>/duplicates. → S joins M's group (or the two groups merge, older rid surviving).
- No new root created. → The next read derives canonical_cid = S, because it outranks the midhash.
- meta-dup is notified via the existing keyspace event channel.
4b. If new:
- Mint("/watch/foo.mkv", size, mtime) → uuid_Y.
- AddAlias(uuid_Y, "midhash256:abc...").
- Plugins now process the file, referenced by midhash256:abc...
(UUID is never disclosed to them.)
5. Later, the full-hash plugin computes sha256.
- Plugin reports back: "for CID midhash256:abc..., sha256 is sha256:def...".
- meta-core resolves midhash256:abc → uuid_Y via GetByCID.
- meta-core calls AddAlias(uuid_Y, "sha256:def...").
- Nothing else to do: the next read of this entry derives a canonical CID
of sha2-256, because it outranks the midhash.
``` ```
### Lifecycle: file deletion A second path with the same midhash is just another `local_file` row: same
cid, same group, same record; the record's `filePath` is the
lexicographically first of the paths.
``` ### File deletion
1. Watcher sees /watch/foo.mkv deleted.
2. Resolve which entry owns this path: The watcher removes the path's `local_file` row; the record view loses that
a. If /watch/foo.mkv is in some entry's duplicates set: `filePath`. Claims are signed truth from their writers and are not deleted
- Remove from set. Done. by meta-core (the store is permanent and additive,
b. If /watch/foo.mkv is the filePath of some entry: `docs/project-architecture/metacore-as-permanent-store.md`).
- If duplicates is non-empty: promote one duplicate to filePath.
- If duplicates is empty: DeleteRoot(uuid). *(v1.x)* Deletion promoted a duplicate to `filePath`, or called `DeleteRoot`,
- Iterates the cids set, deletes each cid:<cid> reverse-index key. which removed every `cid:*` alias and every `file:<uuid>/*` key.
- Deletes every file:<uuid>/* key.
- Removes uuid from file:__index__.
```
## Public API ## Public API
``` ```
GET /api/meta/<cid> GET /api/meta/<cid> → 200 merged document { "filePath", "canonical_cid", "cids": [...], ... }
→ 200 { "filePath": "...", "canonical_cid": "...", (optional ?as=<uid>: the viewer's own votes win)
"cids": [...], "title": "...", "tmdb": {...}, ... } → 404 if no such cid
→ 404 if no such CID GET /api/meta/<cid>/claims → {rid, cids, claims}: every live signed claim of the cid's
group, all signers (?key=, ?uid=); 404 if no such cid
GET /api/file/<cid> (already exists — implementation now O(1)) GET /api/metadata/<rid>/claims → the same, addressed by rid (local consumers)
→ streams file content
``` ```
The UUID is **not** included in the response. Clients address everything by The rid is not part of the cid-addressed contract and never appears in a
CID; meta-core's choice of internal identifier remains a private claim. It is visible only to local consumers that list or address records by
implementation detail and may change. rid (`/api/metadata/{rid}`, `/api/metadata/{rid}/claims`, `hash-ids`, SSE event
keys; the claims routes also echo it as `rid`), and may change on a
group merge (delete event, then re-resolve by cid).
Auth: `/api/meta/<cid>` follows the same pattern as `/api/file/<cid>` and Auth: `/api/meta/<cid>` bypasses the perimeter like `/api/file/<cid>` — it is
bypasses the auth perimeter. It's the metadata twin of the content endpoint, the metadata twin of the content endpoint.
and meta-share calls it from federated search responses.
## Cross-service impact ## Cross-service impact (v2.0.0)
This is a meta-core schema change. Other services interact only through CIDs, - **Writers** (plugins, self-publishing feeders, gateway core, meta-share,
so the impact is contained but real. meta-search): address claims by cid only and sign them; they never resolve
a rid.
- **meta-fuse, meta-stremio**: unchanged — merged reads and SSE keep their
shape; a group merge reaches them as ordinary `del` + `set` events.
- **meta-search / meta-share**: announce the derived canonical cid (each
mirrors the Go rank) and relay raw claims; every cid of the group travels as
`cids/*` claims, so a peer querying by any digest gets a hit.
- **meta-sort**: writers must resolve plugin callbacks to a UUID before ## Migration (history)
writing. The existing `LeaderClient` gains a `getByCID` cache.
- **meta-fuse**: VFS rebuild already iterates `file:__index__`; only the
ID format changes.
- **meta-stremio**: queries by CID; needs no logic change beyond its storage
client.
- **meta-dup**: gains a cleaner data source — duplicates are now explicit in
the `duplicates` set, not implicit in colliding writes.
- **meta-search / meta-share**: announce the *derived* canonical CID rather
than the midhash — each mirrors the Go rank in `ingest::canonical_cid_from_metadata`
rather than reading a stored field. Federated search responses include all
CIDs from the `cids` key-set so any peer querying by any digest gets a hit.
(Written before the 2026-06 discovery/transport split; discovery is now
meta-search's job.)
- **Plugins**: no change. They speak CIDs only.
## Migration > **The v1.0 sweep below was never run.** The alpha shipped behind a
> clean-wipe gate. The v1.x → v2.0.0 conversion of watch.nsl.sh is a
> one-off tool outside the service image, `packages/meta-core/tools/claims-migrate`
> (canonical cid by rank per record; `cid:` aliases → `cid_group`, rids keep
> their UUIDs, and the `cid:` index wins when a record's `cids/*` member
> disagrees; fields re-signed by a throwaway legacy key); every other box
> starts empty. Implementation basis §11.
> **Historical — this sweep was never run.** The alpha shipped behind a *(proposal)* One-time sweep over `file:__index__`: mint a uuid, `AddAlias`
> clean-wipe gate (Redis was flushed and the library re-ingested), so no the old hashID and every `cid_*` field, `RENAME` every property under the new
> in-place migration code exists. Kept for the record. root, reconcile canonical, swap the index entry; crash-recoverable by
detecting which step completed.
One-time sweep, idempotent, runs on first boot after upgrade:
```
For each hashID in file:__index__ (current shape: midhash256:abc, sha256:def, …):
1. Mint a fresh uuid_Y.
2. AddAlias(uuid_Y, hashID). The existing primary becomes an alias.
3. For each cid_* field on the old entry, AddAlias(uuid_Y, that value).
4. RENAME file:<hashID>/<property> → file:<uuid_Y>/<property> for every
property (Lua script, one entry at a time; non-atomic across entries,
but each entry stays internally consistent).
5. Reconcile canonical_cid.
6. Remove hashID from file:__index__, add uuid_Y.
```
Crash-recoverable: re-running the sweep finds entries with both old and new
roots and resumes by detecting which step completed (presence of
`file:<uuid>/cids` indicates step 3 finished; absence of any
`file:<hashID>/*` keys indicates step 4 finished).
## Decisions recorded ## Decisions recorded
| Question | Decision | Notes | | Question | Decision | Notes |
|---|---|---| |---|---|---|
| When is the UUID minted? | At first-hash | Pragmatic for the initial migration. Long-term target: at discovery, fully decoupled from hashing. Tracked as an open question below. | | When is the rid minted? | When a cid is first seen with no link to a known group | v1.x: at first hash of a watched file. Now any first-seen cid (watcher midhash or a claim about a new cid) mints one |
| UUID format | UUIDv7, 26-char Crockford Base32 | Time-sortable, 128-bit, distributed-safe. | | Rid format | UUIDv7, 26-char Crockford Base32 | Time-sortable, 128-bit, distributed-safe |
| UUID visible externally? | No | Pure meta-core internal. Clients address by CID only. | | Rid visible externally? | No — never signed, never on the swarm/gateway wire | Local HTTP consumers may hold it |
| Merge semantics | One UUID per content; additional paths into `duplicates` | One file (logical) = one metadata set. Multiple physical paths supported via `duplicates`. | | What do signed votes name? | A cid | Every peer agrees on a cid; rids differ per box |
| Canonical CID rank | IPFS > sha-family > btih > midhash | Utility-weighted, not strength-weighted. | | Record identity | A group of cids (`cid_group`, in truth) | Linked by `cids/<other>` votes |
| ~~`cids` and `duplicates` field type~~ | ~~Redis SET~~ → **`cids` is a key-set of bare CIDs; `duplicates` stayed a SET** | Reversed before shipping. Flat `cids/<cid>` STRING keys merge conflict-free across peers; a SET does not. See §14.13 of `METADATA_KEYS.md`. | | Group merge | Older rid survives; losing rid → `del` events | No `rid_alias` (out of scope for phase 1) |
| ~~Stored `canonical_cid`~~ | **Dropped — derived on read** | A stored scalar is not conflict-free to merge. `GetMetadataDocument` ranks the `cids` members per request. | | Duplicate paths | `local_file` rows sharing a midhash (cache) | v1.x: `duplicates` SET, then `dup:<uuid>/<path>` |
| Reverse-index format | Flat `cid:<cid>` STRING keys | Simple, scan-friendly, no hot-key contention. | | Canonical CID rank | IPFS > sha-family > btih > midhash, derived on read | Utility-weighted; never stored |
| `cids` shape | Key-set of bare CIDs, one claim per member | Merges with the generic rule; a SET would not |
| Reverse index | `cid_group(cid PK → rid)` in truth, copied to cache `cid:<cid>` rows for reads | v1.x: flat `cid:<cid>` keys were the only copy |
| Unlinking | A retracted `cids/*` link does not split a group | Phase 1 |
## Open questions ## Open questions
- **Discovery-time UUID minting.** The cleaner long-term model: watcher mints - **Discovery-time rid minting.** Minting at discovery (before hashing)
a UUID at discovery (before hashing), then `AddAlias` runs when the midhash would give two same-content paths two rids until hashing links them. The
arrives. Requires an explicit "reconcile if alias already exists" step claims model already handles that case — it is an ordinary group merge —
because two paths with the same content would each get their own UUID until so this is now a policy choice, not a missing mechanism.
hashing reveals the collision. Worth doing eventually; not required for - **IPFS gateway endpoint.** Should meta-core expose `/ipfs/<cid>` for
this iteration. external IPFS clients? Adjacent, not part of this design.
- ~~**meta-share announce strategy.**~~ Settled: the canonical cid addresses
- ~~**meta-share announce strategy.**~~ **Settled.** The record carries *every* the record, every group cid travels as a `cids/*` claim.
member of `cids` on the wire (`Record.cids`) and addresses itself by the - ~~**Keyspace-notification consumers.**~~ Obsolete since Redis left.
single derived canonical (`Record.cid`), so a peer querying by any digest
gets a hit without the DHT footprint of announcing each one separately.
- **IPFS gateway endpoint.** Should meta-core expose `/ipfs/<cid>` as a
content-streaming endpoint for external IPFS clients? Adjacent to this
design but not part of it.
- **Keyspace-notification consumers.** Today's parsers (`schema/indexer.go`)
handle `file:<type>:<hash>/...` with a special split rule for the colon
inside `midhash256:abc`. With UUIDs (no colons), `parseFieldPath` simplifies
— but any external consumer subscribed by hashID pattern needs updating.
Inventory those before flipping the migration.
## Non-goals ## Non-goals
- **Not** a content-replication or IPFS-pinning system. Registering an IPFS - **Not** a content-replication or pinning system. A `cids/<ipfs-cid>` vote
CID alias does not pin the file anywhere — it only states "if somebody pins nothing; it states "hashing these bytes with IPFS's chunker gives this
hashes this file with IPFS's chunker, this is the CID they'd get." CID."
- **Not** a versioning scheme. Metadata is a snapshot of current state; - **Not** a history. `ts` versions each `(uid, cid, key)` and the newest vote
historical versions are out of scope. replaces the older one; only tombstones are kept, to block replay.
- **Not** a strong-consistency system. Reverse-index writes happen under the - **Not** strongly consistent across the two databases: the cache is applied
leader lock; reads from followers may briefly miss a freshly-added alias. after the truth commit. `POST /api/claims` waits for the apply before
answering, so the writer reads its own write; another reader may briefly
miss a just-accepted claim. It is behind, never wrong.
+135
View File
@@ -0,0 +1,135 @@
// Signed claims (meta-core v2.0.0). The editor no longer writes keys: an edit
// is a claim signed as the chosen identity account — through meta-core's
// account-key service (/api/identity/sign, compact low-S) — and posted to
// POST /api/claims. That vote wins in that account's view; the shared merged
// value only moves if enough signers agree.
//
// MMUR2 bytes (must match internal/claim and its golden vectors):
// "MMUR2\n" || canonical CBOR [cid, uid, key, value, ts]
export interface Claim {
cid: string;
uid: string;
key: string;
value: string | { b64: string } | null;
ts: number;
sig: string;
}
export interface ClaimsResponse {
rid: string;
cids: string[];
claims: Claim[];
}
export interface Account {
uid: string;
curve: string;
createdAt: number;
}
const SIGNER_LS_KEY = 'kv-editor.signer.v1';
async function jsonOrThrow<T>(res: Response): Promise<T> {
if (!res.ok) {
let msg = `HTTP ${res.status}`;
try {
const body = await res.json();
if (body?.message) msg = body.message;
else if (body?.error) msg = body.error;
} catch {}
throw new Error(msg);
}
return res.json();
}
// --- canonical CBOR for the five MMUR2 shapes -------------------------------
function head(out: number[], major: number, n: number) {
const m = major << 5;
if (n < 24) out.push(m | n);
else if (n <= 0xff) out.push(m | 24, n);
else if (n <= 0xffff) out.push(m | 25, n >> 8, n & 0xff);
else if (n <= 0xffffffff) out.push(m | 26, (n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff);
else {
const hi = Math.floor(n / 2 ** 32);
const lo = n >>> 0;
out.push(m | 27, (hi >>> 24) & 0xff, (hi >>> 16) & 0xff, (hi >>> 8) & 0xff, hi & 0xff,
(lo >>> 24) & 0xff, (lo >>> 16) & 0xff, (lo >>> 8) & 0xff, lo & 0xff);
}
}
function text(out: number[], s: string) {
const b = new TextEncoder().encode(s);
head(out, 3, b.length);
for (const x of b) out.push(x);
}
export function signedBytes(cid: string, uid: string, key: string, value: string | null, ts: number): Uint8Array {
const out: number[] = [...new TextEncoder().encode('MMUR2\n')];
head(out, 4, 5);
text(out, cid);
text(out, uid);
text(out, key);
if (value === null) out.push(0xf6);
else text(out, value);
head(out, 0, ts);
return new Uint8Array(out);
}
function b64(bytes: Uint8Array): string {
let s = '';
for (const x of bytes) s += String.fromCharCode(x);
return btoa(s);
}
// --- signer -----------------------------------------------------------------
export function getSigner(): string {
try { return localStorage.getItem(SIGNER_LS_KEY) ?? ''; } catch { return ''; }
}
export function setSigner(uid: string) {
try { localStorage.setItem(SIGNER_LS_KEY, uid); } catch {}
}
export const ClaimsAPI = {
async accounts(): Promise<Account[]> {
const r = await jsonOrThrow<{ accounts: Account[] }>(await fetch('/api/identity/accounts'));
return r.accounts ?? [];
},
async forRecord(rid: string, key?: string): Promise<ClaimsResponse> {
const q = key ? `?key=${encodeURIComponent(key)}` : '';
return jsonOrThrow(await fetch(`/api/metadata/${encodeURIComponent(rid)}/claims${q}`));
},
// vote signs value (null = retraction) for key on the record's subject cid
// as the chosen account and posts it.
async vote(rid: string, key: string, value: string | null): Promise<void> {
const uid = getSigner();
if (!uid) throw new Error('choose a signing account first (top right)');
const rec = await this.forRecord(rid);
const cid = rec.cids[0];
if (!cid) throw new Error(`record ${rid} has no cid`);
// ts must beat this account's previous vote on the key.
const prev = rec.claims.filter((c) => c.uid === uid && c.key === key).map((c) => c.ts);
const ts = Math.max(Date.now(), ...prev.map((t) => t + 1));
const bytes = signedBytes(cid, uid, key, value, ts);
const signed = await jsonOrThrow<{ sigB64: string }>(await fetch('/api/identity/sign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uid, bytesB64: b64(bytes), format: 'compact' }),
}));
const claim: Claim = { cid, uid, key, value, ts, sig: signed.sigB64 };
const res = await jsonOrThrow<{ status: string; reason?: string }[]>(await fetch('/api/claims', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([claim]),
}));
const st = res[0]?.status;
if (st !== 'accepted' && st !== 'unchanged') {
throw new Error(`claim ${st}${res[0]?.reason ? ': ' + res[0].reason : ''}`);
}
},
};
+135 -12
View File
@@ -1,8 +1,17 @@
// Client for the meta-core /api/kv endpoints. The keyspace is a slash-tree of // The editor's key tree. Since meta-core v2.0.0 the records are no longer raw
// string-valued keys; this module wraps the S3-style listing, search, and // keys: `file:<rid>/<field>` is the MERGED view, derived from signed claims.
// per-key get/put/delete operations. // 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 API_BASE = '/api/kv';
const RECORD_NS = 'file:';
export interface KVTreeResponse { export interface KVTreeResponse {
prefix: string; prefix: string;
@@ -61,6 +70,45 @@ async function jsonOrThrow<T>(res: Response): Promise<T> {
return res.json(); 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 = { export const KVAPI = {
async info(): Promise<KVInfoResponse> { async info(): Promise<KVInfoResponse> {
return jsonOrThrow(await fetch(`${API_BASE}/info`)); return jsonOrThrow(await fetch(`${API_BASE}/info`));
@@ -72,17 +120,34 @@ export const KVAPI = {
cursor?: string; cursor?: string;
max?: number; max?: number;
}): Promise<KVTreeResponse> { }): 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(); const q = new URLSearchParams();
if (params.prefix !== undefined) q.set('prefix', params.prefix); q.set('prefix', prefix);
if (params.delimiter !== undefined) q.set('delimiter', params.delimiter); q.set('delimiter', delimiter);
if (params.cursor) q.set('cursor', params.cursor); if (params.cursor) q.set('cursor', params.cursor);
if (params.max) q.set('max', String(params.max)); 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> { async treeAll(prefix: string, delimiter: string, softCap = 5000): Promise<KVTreeResponse> {
const seenLeaves = new Set<string>(); const seenLeaves = new Set<string>();
const seenBranches = 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> { 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) }); 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> { async find(contains: string, fields: string[], limit = 200): Promise<KVFindResponse> {
const q = new URLSearchParams({ contains, fields: fields.join(','), limit: String(limit) }); const matches: KVFindMatch[] = [];
return jsonOrThrow(await fetch(`${API_BASE}/find?${q}`)); 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> { 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 }); const q = new URLSearchParams({ key });
return jsonOrThrow(await fetch(`${API_BASE}/value?${q}`)); 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> { 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`, { const res = await fetch(`${API_BASE}/value`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -129,7 +245,14 @@ export const KVAPI = {
return jsonOrThrow(res); 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 }> { 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 }); const q = new URLSearchParams({ key });
return jsonOrThrow(await fetch(`${API_BASE}/value?${q}`, { method: 'DELETE' })); return jsonOrThrow(await fetch(`${API_BASE}/value?${q}`, { method: 'DELETE' }));
}, },
-96
View File
@@ -1,96 +0,0 @@
const API_BASE = '/api/snapshot';
export type ImportMode = 'replace' | 'merge';
export type ImportConflict = 'mine' | 'source';
export interface FileImportResult {
hashId: string;
added: number;
updated: number;
unchanged: number;
kept: number;
deleted: number;
error?: string;
}
export interface PluginOutputResult {
written: number;
skipped: number;
}
export interface ImportResult {
mode: ImportMode;
conflict?: ImportConflict;
dryRun: boolean;
totalFiles: number;
filesOk: number;
filesFailed: number;
files?: FileImportResult[];
pluginOutput?: PluginOutputResult;
}
export interface WipeResult {
metadataDeleted: number;
}
export class SnapshotAPI {
/**
* Trigger a download of a metadata snapshot ZIP.
* Browser handles the download via a synthetic <a> click.
*/
static downloadExport(includes: { pluginOutput?: boolean } = {}): void {
const parts: string[] = [];
if (includes.pluginOutput) parts.push('plugin-output');
const qs = parts.length ? `?include=${parts.join(',')}` : '';
const a = document.createElement('a');
a.href = `${API_BASE}/export${qs}`;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
/**
* Upload a snapshot ZIP and apply it.
* Mode replace deletes and rewrites each CID's properties.
* Mode merge preserves untouched fields; conflict picks who wins on overlap.
*/
static async runImport(
file: File,
opts: { mode: ImportMode; conflict?: ImportConflict; dryRun?: boolean }
): Promise<ImportResult> {
const params = new URLSearchParams();
params.set('mode', opts.mode);
if (opts.mode === 'merge') {
params.set('conflict', opts.conflict ?? 'source');
}
if (opts.dryRun) {
params.set('dry_run', 'true');
}
const fd = new FormData();
fd.append('snapshot', file);
const response = await fetch(`${API_BASE}/import?${params.toString()}`, {
method: 'POST',
body: fd,
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.message || err.error || 'Import failed');
}
return response.json();
}
/**
* Wipe Redis metadata. Pass scope=metadata.
*/
static async wipe(scope: 'metadata'): Promise<WipeResult> {
const response = await fetch(`${API_BASE}/wipe?scope=${scope}`, {
method: 'POST',
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.message || err.error || 'Wipe failed');
}
return response.json();
}
}
-209
View File
@@ -1,209 +0,0 @@
.snapshot-panel {
max-width: 920px;
margin: 0 auto;
padding: 1.5rem;
}
.snapshot-panel h2 {
margin: 0 0 0.5rem;
}
.snapshot-intro {
color: var(--text-muted, #888);
margin-bottom: 1.5rem;
}
.snapshot-section {
border: 1px solid var(--border, #e0e0e0);
border-radius: 6px;
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
}
.snapshot-section.danger {
border-color: #d04a4a;
background: rgba(208, 74, 74, 0.04);
}
.snapshot-section h3 {
margin: 0 0 0.5rem;
}
.snapshot-row {
display: flex;
gap: 1rem;
align-items: flex-start;
margin: 0.75rem 0;
flex-wrap: wrap;
}
.snapshot-file {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.snapshot-file span {
font-size: 0.85rem;
color: var(--text-muted, #888);
}
.snapshot-fieldset {
border: 1px solid var(--border, #e0e0e0);
border-radius: 4px;
padding: 0.5rem 0.75rem;
min-width: 280px;
}
.snapshot-fieldset[disabled] {
opacity: 0.5;
}
.snapshot-fieldset legend {
font-weight: 600;
font-size: 0.85rem;
padding: 0 0.25rem;
}
.snapshot-fieldset label {
display: block;
margin: 0.25rem 0;
font-size: 0.9rem;
cursor: pointer;
}
.snapshot-fieldset input[type='radio'] {
margin-right: 0.5rem;
}
.snapshot-btn {
background: var(--bg-button, #f0f0f0);
border: 1px solid var(--border, #c0c0c0);
color: var(--text, #222);
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.95rem;
}
.snapshot-btn:hover:not(:disabled) {
background: var(--bg-button-hover, #e0e0e0);
}
.snapshot-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.snapshot-btn.primary {
background: #2a6df4;
border-color: #2a6df4;
color: white;
}
.snapshot-btn.primary:hover:not(:disabled) {
background: #1a5de4;
}
.snapshot-btn.danger {
background: #d04a4a;
border-color: #d04a4a;
color: white;
}
.snapshot-btn.danger:hover:not(:disabled) {
background: #b83838;
}
.snapshot-error {
background: rgba(208, 74, 74, 0.1);
color: #b83838;
padding: 0.5rem 0.75rem;
border-radius: 4px;
margin-top: 0.75rem;
}
.snapshot-info {
background: rgba(42, 109, 244, 0.08);
color: #1a5de4;
padding: 0.5rem 0.75rem;
border-radius: 4px;
margin-top: 0.75rem;
}
.snapshot-result {
margin-top: 0.75rem;
border: 1px solid var(--border, #e0e0e0);
border-radius: 4px;
padding: 0.75rem;
}
.snapshot-result-summary {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.snapshot-result-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.snapshot-result-table th,
.snapshot-result-table td {
border: 1px solid var(--border, #e0e0e0);
padding: 0.35rem 0.5rem;
text-align: right;
}
.snapshot-result-table th {
background: var(--bg-subtle, #f5f5f5);
font-weight: 600;
}
.snapshot-result-errors {
margin-top: 0.5rem;
font-size: 0.85rem;
}
.snapshot-result-errors summary {
cursor: pointer;
color: #b83838;
}
.snapshot-result-errors ul {
margin: 0.5rem 0 0;
padding-left: 1.25rem;
}
.snapshot-result-errors code {
font-size: 0.8rem;
word-break: break-all;
}
.snapshot-checkbox {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin: 0.5rem 0 0.75rem;
cursor: pointer;
font-size: 0.95rem;
}
.snapshot-checkbox input {
margin-top: 0.2rem;
}
.snapshot-hint {
color: var(--text-muted, #888);
font-size: 0.9rem;
}
.snapshot-result-aside {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--text-muted, #555);
}
-271
View File
@@ -1,271 +0,0 @@
import { useState } from 'react';
import {
SnapshotAPI,
ImportMode,
ImportConflict,
ImportResult,
} from '../api/snapshotApi';
import './SnapshotPanel.css';
export function SnapshotPanel() {
const [exportPluginOutput, setExportPluginOutput] = useState(false);
const [importFile, setImportFile] = useState<File | null>(null);
const [importMode, setImportMode] = useState<ImportMode>('merge');
const [importConflict, setImportConflict] = useState<ImportConflict>('source');
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [importError, setImportError] = useState<string | null>(null);
const [wipeBusy, setWipeBusy] = useState(false);
const [wipeMessage, setWipeMessage] = useState<string | null>(null);
const runImport = async (dryRun: boolean) => {
if (!importFile) {
setImportError('Pick a snapshot ZIP first.');
return;
}
setImporting(true);
setImportError(null);
setImportResult(null);
try {
const result = await SnapshotAPI.runImport(importFile, {
mode: importMode,
conflict: importMode === 'merge' ? importConflict : undefined,
dryRun,
});
setImportResult(result);
} catch (e: any) {
setImportError(e.message || String(e));
} finally {
setImporting(false);
}
};
const runWipe = async () => {
const ok = window.confirm(
'This deletes ALL metadata in Redis. Files on disk are not touched, but every manual ' +
'edit you made via this editor will be lost. Continue?'
);
if (!ok) return;
setWipeBusy(true);
setWipeMessage(null);
try {
const r = await SnapshotAPI.wipe('metadata');
setWipeMessage(`Deleted ${r.metadataDeleted} file metadata entries.`);
} catch (e: any) {
setWipeMessage(`Failed: ${e.message || e}`);
} finally {
setWipeBusy(false);
}
};
return (
<div className="snapshot-panel">
<h2>Snapshot</h2>
<p className="snapshot-intro">
Export, import, or wipe the metadata KV store. Snapshots are portable ZIP
archives — one nested-JSON file per CID, plus a manifest.
</p>
<section className="snapshot-section">
<h3>Export</h3>
<p>Downloads every CID's metadata as a ZIP. Optional cache files can be added below.</p>
<label className="snapshot-checkbox">
<input
type="checkbox"
checked={exportPluginOutput}
onChange={(e) => setExportPluginOutput(e.target.checked)}
/>
<span>
Include plugin output
<span className="snapshot-hint">
{' '}— TMDB posters/backdrops, extracted subtitles. Adds ~120 MB on a populated stack.
</span>
</span>
</label>
<button
className="snapshot-btn primary"
onClick={() =>
SnapshotAPI.downloadExport({ pluginOutput: exportPluginOutput })
}
>
Download snapshot.zip
</button>
</section>
<section className="snapshot-section">
<h3>Import</h3>
<div className="snapshot-row">
<label className="snapshot-file">
<span>Snapshot ZIP</span>
<input
type="file"
accept=".zip,application/zip"
onChange={(e) => {
setImportFile(e.target.files?.[0] ?? null);
setImportResult(null);
setImportError(null);
}}
/>
</label>
</div>
<div className="snapshot-row">
<fieldset className="snapshot-fieldset">
<legend>Mode</legend>
<label>
<input
type="radio"
name="mode"
value="merge"
checked={importMode === 'merge'}
onChange={() => setImportMode('merge')}
/>
Merge — keep existing fields not in the snapshot
</label>
<label>
<input
type="radio"
name="mode"
value="replace"
checked={importMode === 'replace'}
onChange={() => setImportMode('replace')}
/>
Replace — wipe each imported CID and rewrite from snapshot
</label>
</fieldset>
<fieldset className="snapshot-fieldset" disabled={importMode !== 'merge'}>
<legend>Conflict</legend>
<label>
<input
type="radio"
name="conflict"
value="source"
checked={importConflict === 'source'}
onChange={() => setImportConflict('source')}
/>
Source wins (overwrite my edits)
</label>
<label>
<input
type="radio"
name="conflict"
value="mine"
checked={importConflict === 'mine'}
onChange={() => setImportConflict('mine')}
/>
Mine wins (keep my edits, fill in only missing fields)
</label>
</fieldset>
</div>
<div className="snapshot-row">
<button
className="snapshot-btn"
disabled={!importFile || importing}
onClick={() => runImport(true)}
>
Dry run
</button>
<button
className="snapshot-btn primary"
disabled={!importFile || importing}
onClick={() => runImport(false)}
>
{importing ? 'Importing…' : 'Apply import'}
</button>
</div>
{importError && <div className="snapshot-error">{importError}</div>}
{importResult && <ImportResultView result={importResult} />}
</section>
<section className="snapshot-section danger">
<h3>Wipe metadata</h3>
<p>
Deletes every <code>file:*</code> key plus <code>file:__index__</code>.
Files on disk are untouched.
</p>
<button
className="snapshot-btn danger"
disabled={wipeBusy}
onClick={runWipe}
>
{wipeBusy ? 'Wiping…' : 'Wipe all metadata'}
</button>
{wipeMessage && <div className="snapshot-info">{wipeMessage}</div>}
</section>
</div>
);
}
function ImportResultView({ result }: { result: ImportResult }) {
const totals = (result.files ?? []).reduce(
(acc, f) => {
acc.added += f.added;
acc.updated += f.updated;
acc.unchanged += f.unchanged;
acc.kept += f.kept;
acc.deleted += f.deleted;
return acc;
},
{ added: 0, updated: 0, unchanged: 0, kept: 0, deleted: 0 }
);
return (
<div className="snapshot-result">
<div className="snapshot-result-summary">
<strong>{result.dryRun ? 'Dry run preview' : 'Import applied'}</strong>
<span>
{result.filesOk} ok / {result.filesFailed} failed of {result.totalFiles} files
</span>
<span>
mode={result.mode}
{result.conflict ? `, conflict=${result.conflict}` : ''}
</span>
</div>
<table className="snapshot-result-table">
<thead>
<tr>
<th>added</th>
<th>updated</th>
<th>unchanged</th>
<th>kept (mine)</th>
<th>deleted (replace)</th>
</tr>
</thead>
<tbody>
<tr>
<td>{totals.added}</td>
<td>{totals.updated}</td>
<td>{totals.unchanged}</td>
<td>{totals.kept}</td>
<td>{totals.deleted}</td>
</tr>
</tbody>
</table>
{result.pluginOutput && (
<div className="snapshot-result-aside">
plugin-output: {result.pluginOutput.written} written
{result.pluginOutput.skipped > 0
? `, ${result.pluginOutput.skipped} skipped (existed)`
: ''}
</div>
)}
{result.files && result.files.some((f) => f.error) && (
<details className="snapshot-result-errors">
<summary>Per-file errors</summary>
<ul>
{result.files
.filter((f) => f.error)
.map((f) => (
<li key={f.hashId}>
<code>{f.hashId}</code>: {f.error}
</li>
))}
</ul>
</details>
)}
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useState } from 'react';
import { Claim, ClaimsAPI, getSigner } from '../api/claimsApi';
import { splitRecordKey } from '../api/kvApi';
// ClaimsPanel lists the signed claims behind one merged field: who said what,
// and when. The merged value is what most signers agree on.
export function ClaimsPanel({ recordKey, reloadToken }: { recordKey: string; reloadToken: number }) {
const [claims, setClaims] = useState<Claim[] | null>(null);
const [error, setError] = useState('');
const parts = splitRecordKey(recordKey);
useEffect(() => {
if (!parts || !parts.field) return;
let cancelled = false;
ClaimsAPI.forRecord(parts.rid, parts.field)
.then((r) => { if (!cancelled) setClaims(r.claims); })
.catch((e) => { if (!cancelled) setError(e.message); });
return () => { cancelled = true; };
}, [recordKey, reloadToken]); // eslint-disable-line react-hooks/exhaustive-deps
if (!parts || !parts.field) return null;
if (error) return <div className="kv-claims kv-pane-status">claims: {error}</div>;
if (!claims) return <div className="kv-claims kv-pane-status">loading claims…</div>;
const me = getSigner();
return (
<div className="kv-claims">
<div className="kv-leaf-label">Claims ({claims.length})</div>
{claims.length === 0 ? (
<div className="kv-pane-hint">No signed claim — a box-local fact (file path, size, mtime) or a group member.</div>
) : (
<table className="kv-claims-table">
<tbody>
{claims.map((c) => (
<tr key={c.uid + c.cid}>
<td title={c.uid}><code>{c.uid.slice(0, 10)}…</code>{c.uid === me ? ' (you)' : ''}</td>
<td>{c.value === null ? <em>retracted</em> : typeof c.value === 'string' ? c.value : <em>ciphertext</em>}</td>
<td title={new Date(c.ts).toISOString()}>{new Date(c.ts).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
+6
View File
@@ -894,3 +894,9 @@
.kv-modal-close:hover { .kv-modal-close:hover {
opacity: 1; opacity: 1;
} }
.kv-signer { font-size: 12px; display: inline-flex; align-items: center; gap: 4px; }
.kv-signer select { font-size: 12px; }
.kv-claims { margin-top: 16px; }
.kv-claims-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.kv-claims-table td { padding: 3px 6px; border-top: 1px solid rgba(127, 127, 127, 0.25); vertical-align: top; }
+2 -32
View File
@@ -10,7 +10,7 @@ const FILE_BRANCH_RE = /^file:[^/]+\/$/;
import { SearchFieldChips } from './SearchFieldChips'; import { SearchFieldChips } from './SearchFieldChips';
import { ToastStack, makeToast } from './Toast'; import { ToastStack, makeToast } from './Toast';
import { ToastEntry } from './types'; import { ToastEntry } from './types';
import { SnapshotPanel } from '../components/SnapshotPanel'; import { SignerPicker } from './SignerPicker';
import './KVApp.css'; import './KVApp.css';
const SEARCH_FIELDS_LS_KEY = 'kv-editor.searchFields.v1'; 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. // A sticky header carries the search bar, the breadcrumb, and basic stats.
export default function KVApp() { export default function KVApp() {
const [snapshotOpen, setSnapshotOpen] = useState(false);
// Selected node. branchPath is set when a branch is selected; selectedKey // Selected node. branchPath is set when a branch is selected; selectedKey
// when a leaf is. Mutually exclusive. // when a leaf is. Mutually exclusive.
const [selectedKey, setSelectedKey] = useState<string | null>(null); const [selectedKey, setSelectedKey] = useState<string | null>(null);
@@ -176,14 +175,7 @@ export default function KVApp() {
onClick={() => setReloadToken((n) => n + 1)} onClick={() => setReloadToken((n) => n + 1)}
title="refresh tree" title="refresh tree"
>Refresh</button> >Refresh</button>
<button <SignerPicker onError={(msg) => pushToast(makeToast('error', msg))} />
className="kv-btn-icon kv-app-gear"
onClick={() => setSnapshotOpen(true)}
title="Snapshot — export / import / wipe"
aria-label="Open snapshot settings"
>
⚙
</button>
</div> </div>
<SearchFieldChips fields={searchFields} onChange={setSearchFields} /> <SearchFieldChips fields={searchFields} onChange={setSearchFields} />
{breadcrumb && breadcrumb.length > 0 && ( {breadcrumb && breadcrumb.length > 0 && (
@@ -284,28 +276,6 @@ export default function KVApp() {
</main> </main>
</div> </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} /> <ToastStack toasts={toasts} onDismiss={dismissToast} />
</div> </div>
); );
+6 -3
View File
@@ -5,9 +5,10 @@ import { ALL_TYPES, describeType, detectType } from './typeHeuristics';
import { lookupSchemaForKey } from './schemaLookup'; import { lookupSchemaForKey } from './schemaLookup';
import { LeafValueEditor } from './leafRenderers'; import { LeafValueEditor } from './leafRenderers';
import { FieldType } from './types'; import { FieldType } from './types';
import { ClaimsPanel } from './ClaimsPanel';
interface Props { interface Props {
// Full Redis key path of the selected leaf. // Full key path of the selected leaf (file:<rid>/<field> = merged view).
selectedKey: string; selectedKey: string;
// Bumped after a delete or external change to force refetch. // Bumped after a delete or external change to force refetch.
reloadToken: number; reloadToken: number;
@@ -71,7 +72,7 @@ export function KVLeafEditor({ selectedKey, reloadToken, onSaved, onDeleted, onE
}; };
const del = async () => { 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); setSaving(true);
try { try {
await KVAPI.del(selectedKey); await KVAPI.del(selectedKey);
@@ -136,9 +137,11 @@ export function KVLeafEditor({ selectedKey, reloadToken, onSaved, onDeleted, onE
className="kv-btn kv-btn-danger" className="kv-btn kv-btn-danger"
onClick={del} onClick={del}
disabled={saving} disabled={saving}
>Delete</button> >Retract</button>
)} )}
</div> </div>
<ClaimsPanel recordKey={selectedKey} reloadToken={reloadToken} />
</div> </div>
); );
} }
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useState } from 'react';
import { Account, ClaimsAPI, getSigner, setSigner } from '../api/claimsApi';
// SignerPicker chooses the identity account whose key signs the editor's
// edits. Every save is that account's claim — it wins in that account's view.
export function SignerPicker({ onError }: { onError: (msg: string) => void }) {
const [accounts, setAccounts] = useState<Account[]>([]);
const [uid, setUid] = useState(getSigner());
useEffect(() => {
ClaimsAPI.accounts()
.then((list) => {
setAccounts(list);
if (!uid && list.length === 1) {
setSigner(list[0].uid);
setUid(list[0].uid);
}
})
.catch((e) => onError(`accounts: ${e.message}`));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<label className="kv-signer" title="Edits are claims signed by this account">
sign as{' '}
<select
value={uid}
onChange={(e) => { setSigner(e.target.value); setUid(e.target.value); }}
>
<option value="">— choose an account —</option>
{accounts.map((a) => (
<option key={a.uid} value={a.uid}>{a.uid.slice(0, 10)}…</option>
))}
</select>
</label>
);
}
+11 -7
View File
@@ -1,21 +1,25 @@
module github.com/metazla/meta-core module github.com/metazla/meta-core
go 1.21 go 1.26.0
require ( require (
github.com/alicebob/miniredis/v2 v2.31.1
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
github.com/mr-tron/base58 v1.3.0 github.com/mr-tron/base58 v1.3.0
github.com/redis/go-redis/v9 v9.4.0
golang.org/x/crypto v0.31.0 golang.org/x/crypto v0.31.0
golang.org/x/net v0.21.0 golang.org/x/net v0.21.0
modernc.org/sqlite v1.60.1
) )
require ( require (
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/yuin/gopher-lua v1.1.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
) )
require golang.org/x/sys v0.48.0
+48 -22
View File
@@ -1,36 +1,62 @@
github.com/DmitriyVTitov/size v1.5.0/go.mod h1:le6rNI4CoLQV1b9gzp1+3d7hMAD/uu2QcJ+aYbNgiU0=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
github.com/alicebob/miniredis/v2 v2.31.1 h1:7XAt0uUg3DtwEKW5ZAGa+K7FZV2DdKQo5K/6TTnfX8Y=
github.com/alicebob/miniredis/v2 v2.31.1/go.mod h1:UB/T2Uztp7MlFSDakaX1sTXUv5CASoprx0wulRT6HBg=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI= github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI=
github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8= github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8=
github.com/redis/go-redis/v9 v9.4.0 h1:Yzoz33UZw9I/mFhx4MNrB6Fk+XHO1VukNcCa1+lwyKk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/redis/go-redis/v9 v9.4.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/yuin/gopher-lua v1.1.0 h1:BojcDhfyDWgU2f2TOzYK/g5p2gxMrku8oupLDqlnSqE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/yuin/gopher-lua v1.1.0/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
-30
View File
@@ -1,30 +0,0 @@
package api
import (
"net/http"
)
// handleMigrateDualRoots triggers the one-shot sweep that re-unifies
// stranded midhash-rooted entries into their UUID roots. See
// storage.MigrateDualRoots for the data-flow detail and
// docs/api-mediated-access.md for the bug background.
//
// Returns {fixed: N} on success. Safe to call repeatedly — entries already
// migrated are skipped on the next pass.
func (s *Server) handleMigrateDualRoots(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
fixed, err := s.storage.MigrateDualRootsWithTimeout()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"fixed": fixed,
})
}
+6
View File
@@ -56,6 +56,12 @@ func (s *Server) handleGetMetaByCID(w http.ResponseWriter, r *http.Request) {
return return
} }
if as := r.URL.Query().Get("as"); as != "" {
if flat, err := s.storage.GetMetadataFlatAs(uuid, as); err == nil && flat != nil {
doc.Flat = flat
}
}
// Response envelope mirrors GetMetadataDocument JSON tags (metadata / // Response envelope mirrors GetMetadataDocument JSON tags (metadata /
// cids / canonical_cid / duplicates), with cid added so the caller can // cids / canonical_cid / duplicates), with cid added so the caller can
// correlate when they fan out N lookups. canonical_cid is derived by // correlate when they fan out N lookups. canonical_cid is derived by
+172
View File
@@ -0,0 +1,172 @@
package api
// Signed claims (meta-core v2.0.0, docs/implementation/metacore-claims-model.md).
//
// POST /api/claims the only metadata write path
// GET /api/meta/{cid}/claims every live claim of the cid's group
// GET /api/metadata/{rid}/claims the same, addressed by rid
// POST /api/merge stateless merge of claims the caller holds
//
// meta-core never signs a claim here: it verifies each one (MMUR2, low-S) and
// the signature is the write authentication.
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/metazla/meta-core/internal/claim"
"github.com/metazla/meta-core/internal/storage"
)
// GoneResponse is the body of every removed unsigned write route.
type GoneResponse struct {
Error string `json:"error"`
Use string `json:"use"`
}
// handleGone answers the unsigned write routes removed in v2.0.0.
func (s *Server) handleGone(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusGone, GoneResponse{Error: "gone", Use: "POST /api/claims"})
}
// decodeClaims accepts a JSON array of claims or {"claims": [...]}.
func decodeClaims(r *http.Request) ([]claim.Claim, error) {
var raw json.RawMessage
if err := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 64<<20)).Decode(&raw); err != nil {
return nil, err
}
var claims []claim.Claim
if len(raw) > 0 && raw[0] == '{' {
var env struct {
Claims []claim.Claim `json:"claims"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return nil, err
}
return env.Claims, nil
}
if err := json.Unmarshal(raw, &claims); err != nil {
return nil, err
}
return claims, nil
}
// handlePostClaims handles POST /api/claims: one status per claim, in input
// order. 200 even when some claims are refused — the statuses say which.
func (s *Server) handlePostClaims(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
claims, err := decodeClaims(r)
if err != nil {
writeErrorSlug(w, http.StatusBadRequest, "invalid_json", "body must be a JSON array of claims: "+err.Error(), false)
return
}
if len(claims) > storage.MaxClaimsPerBatch {
writeErrorSlug(w, http.StatusRequestEntityTooLarge, "too_many_claims", "at most 10000 claims per batch", false)
return
}
results, err := s.storage.WriteClaims(r.Context(), claims)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, results)
}
// ClaimsResponse is the body of the claims read routes: the record's rid, the
// cids of its group, and every live signed claim on them.
type ClaimsResponse struct {
RID string `json:"rid"`
CIDs []string `json:"cids"`
Claims []claim.Claim `json:"claims"`
}
// handleClaimsByCID handles GET /api/meta/{cid}/claims[?key=…].
func (s *Server) handleClaimsByCID(w http.ResponseWriter, r *http.Request) {
cid := mux.Vars(r)["cid"]
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
rid, err := s.storage.GetByCID(cid)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if rid == "" {
writeErrorSlug(w, http.StatusNotFound, ErrUnknownCID, "no record for this CID", false)
return
}
s.writeClaims(w, r, rid)
}
// handleClaimsByRID handles GET /api/metadata/{rid}/claims[?key=…]. The rid
// may also be a cid (resolved like every /api/metadata read).
func (s *Server) handleClaimsByRID(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
rid := s.storage.ResolveRoot(mux.Vars(r)["hashId"])
s.writeClaims(w, r, rid)
}
// writeClaims answers both claims routes: ?key= keeps one exact key, ?uid=
// one signer; 404 when the record does not exist.
func (s *Server) writeClaims(w http.ResponseWriter, r *http.Request, rid string) {
cids, claims, err := s.storage.RecordClaims(rid, r.URL.Query().Get("key"))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if len(cids) == 0 {
writeErrorSlug(w, http.StatusNotFound, ErrUnknownCID, "no record for this rid or cid", false)
return
}
out := make([]claim.Claim, 0, len(claims))
uid := r.URL.Query().Get("uid")
for _, cl := range claims {
if uid == "" || cl.UID == uid {
out = append(out, cl)
}
}
writeJSON(w, http.StatusOK, ClaimsResponse{RID: rid, CIDs: cids, Claims: out})
}
// MergeRequest is the body of POST /api/merge.
type MergeRequest struct {
Claims []claim.Claim `json:"claims"`
As string `json:"as,omitempty"`
}
// MergeResponse is the merged field map, the number of agreeing signers per
// key, and how many claims failed verification (dropped, never an error).
type MergeResponse struct {
Fields map[string]string `json:"fields"`
Nsrc map[string]int `json:"nsrc"`
Rejected int `json:"rejected"`
}
// handleMerge handles POST /api/merge: verify, merge per key with the same
// rule the cache uses, overlay `as`'s own votes held by this core. No write.
func (s *Server) handleMerge(w http.ResponseWriter, r *http.Request) {
var req MergeRequest
if err := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 64<<20)).Decode(&req); err != nil {
writeErrorSlug(w, http.StatusBadRequest, "invalid_json", err.Error(), false)
return
}
if len(req.Claims) > storage.MaxClaimsPerBatch {
writeErrorSlug(w, http.StatusRequestEntityTooLarge, "too_many_claims", "at most 10000 claims per merge", false)
return
}
fields, nsrc, rejected, err := s.storage.MergeClaims(req.Claims, req.As)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, MergeResponse{Fields: fields, Nsrc: nsrc, Rejected: rejected})
}
+182
View File
@@ -0,0 +1,182 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gorilla/mux"
"github.com/metazla/meta-core/internal/claim"
"github.com/metazla/meta-core/internal/storage"
)
func withVars(r *http.Request, vars map[string]string) *http.Request { return mux.SetURLVars(r, vars) }
// routedServer wires the real route table, so the test sees what clients see.
func routedServer(t *testing.T) *Server {
t.Helper()
s := &Server{storage: newTestStorage(t), router: mux.NewRouter()}
s.setupRoutes()
return s
}
func call(t *testing.T, s *Server, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
rr := httptest.NewRecorder()
s.router.ServeHTTP(rr, req)
return rr
}
// Every unsigned write route of v1.x answers 410 with a pointer to the claims.
func TestUnsignedWritesAreGone(t *testing.T) {
s := routedServer(t)
for _, c := range []struct{ method, path string }{
{"PUT", "/meta/bafy"}, {"PATCH", "/meta/bafy"}, {"DELETE", "/meta/bafy"},
{"POST", "/meta/bafy/_add/genres"}, {"PUT", "/meta/bafy/title"}, {"DELETE", "/meta/bafy/title"},
{"PUT", "/api/metadata/bafy"}, {"DELETE", "/api/metadata/bafy"}, {"PUT", "/api/metadata/bafy/property"},
{"POST", "/api/metadata/batch"},
} {
rr := call(t, s, c.method, c.path, `{"title":"x"}`)
if rr.Code != http.StatusGone || !strings.Contains(rr.Body.String(), `"use":"POST /api/claims"`) {
t.Errorf("%s %s = %d %s", c.method, c.path, rr.Code, rr.Body)
}
}
if rr := call(t, s, "PUT", "/api/kv/value", `{"key":"file:R/title","value":"x"}`); rr.Code != http.StatusConflict {
t.Errorf("raw record write via /api/kv/value = %d", rr.Code)
}
if rr := call(t, s, "PUT", "/api/kv/value", `{"key":"btmagnet:abc","value":"magnet:"}`); rr.Code != http.StatusOK {
t.Errorf("service key write = %d %s", rr.Code, rr.Body)
}
}
func TestPostClaimsAndReadBack(t *testing.T) {
s := routedServer(t)
a, b := testSigner(t, 1), testSigner(t, 2)
bad := signed(t, b, "bafyA", "year", "1999", 5)
bad.Value = claim.Text("2001")
body, _ := json.Marshal([]claim.Claim{
signed(t, a, "bafyA", "title", "Matrix", 5),
signed(t, b, "bafyA", "title", "Matrix", 6),
bad,
})
rr := call(t, s, "POST", "/api/claims", string(body))
if rr.Code != http.StatusOK {
t.Fatalf("POST /api/claims = %d %s", rr.Code, rr.Body)
}
var res []storage.ClaimResult
_ = json.Unmarshal(rr.Body.Bytes(), &res)
if len(res) != 3 || res[0].Status != "accepted" || res[1].Status != "accepted" || res[2].Status != "bad_signature" {
t.Fatalf("statuses %+v", res)
}
rr = call(t, s, "GET", "/api/meta/bafyA", "")
if !strings.Contains(rr.Body.String(), `"title":"Matrix"`) {
t.Fatalf("merged read = %s", rr.Body)
}
rr = call(t, s, "GET", "/api/meta/bafyA/claims", "")
var cr ClaimsResponse
_ = json.Unmarshal(rr.Body.Bytes(), &cr)
if rr.Code != http.StatusOK || len(cr.Claims) != 2 {
t.Fatalf("claims by cid = %d %s", rr.Code, rr.Body)
}
if cr.RID == "" || len(cr.CIDs) != 1 || cr.CIDs[0] != "bafyA" {
t.Fatalf("claims envelope rid/cids = %q %v", cr.RID, cr.CIDs)
}
rr = call(t, s, "GET", "/api/metadata/"+cr.RID+"/claims?key=title&uid="+a.UID, "")
var byRid ClaimsResponse
_ = json.Unmarshal(rr.Body.Bytes(), &byRid)
if rr.Code != http.StatusOK || len(byRid.Claims) != 1 || byRid.Claims[0].UID != a.UID || byRid.RID != cr.RID {
t.Fatalf("claims by rid with key+uid = %d %s", rr.Code, rr.Body)
}
if rr := call(t, s, "GET", "/api/meta/bafyA/claims?key=nope", ""); !strings.Contains(rr.Body.String(), `"claims":[]`) {
t.Fatalf("key filter = %s", rr.Body)
}
if rr := call(t, s, "GET", "/api/meta/bafyUnknown/claims", ""); rr.Code != http.StatusNotFound {
t.Fatalf("unknown cid claims = %d", rr.Code)
}
if rr := call(t, s, "GET", "/api/metadata/01NOSUCHRID/claims", ""); rr.Code != http.StatusNotFound {
t.Fatalf("unknown rid claims = %d", rr.Code)
}
// ?as= overlays the viewer's own vote.
me := testSigner(t, 9)
storeClaims(t, s.storage, signed(t, me, "bafyA", "title", "My title", 7))
if rr := call(t, s, "GET", "/api/meta/bafyA?as="+me.UID, ""); !strings.Contains(rr.Body.String(), `"title":"My title"`) {
t.Fatalf("?as overlay = %s", rr.Body)
}
if rr := call(t, s, "GET", "/api/meta/bafyA", ""); !strings.Contains(rr.Body.String(), `"title":"Matrix"`) {
t.Fatalf("shared view changed by a single viewer: %s", rr.Body)
}
}
func TestMergeEndpoint(t *testing.T) {
s := routedServer(t)
a, b, c := testSigner(t, 1), testSigner(t, 2), testSigner(t, 3)
bad := signed(t, c, "bafyX", "title", "Forged", 1)
bad.UID = a.UID
body, _ := json.Marshal(MergeRequest{Claims: []claim.Claim{
signed(t, a, "bafyX", "title", "Dune", 2),
signed(t, b, "bafyX", "title", "Dune", 3),
signed(t, c, "bafyX", "title", "Dune Part One", 1),
bad,
}})
rr := call(t, s, "POST", "/api/merge", string(body))
var mr MergeResponse
_ = json.Unmarshal(rr.Body.Bytes(), &mr)
if rr.Code != http.StatusOK || mr.Fields["title"] != "Dune" || mr.Nsrc["title"] != 2 || mr.Rejected != 1 {
t.Fatalf("merge = %d %s", rr.Code, rr.Body)
}
if !strings.Contains(rr.Body.String(), `"rejected":1`) || !strings.Contains(rr.Body.String(), `"nsrc":{"title":2}`) {
t.Fatalf("merge wire shape = %s", rr.Body)
}
// `as`: the viewer's own vote in the request wins…
me := testSigner(t, 9)
body, _ = json.Marshal(MergeRequest{As: me.UID, Claims: []claim.Claim{
signed(t, a, "bafyX", "title", "Dune", 2),
signed(t, b, "bafyX", "title", "Dune", 3),
signed(t, me, "bafyX", "title", "Dune (my cut)", 4),
}})
_ = json.Unmarshal(call(t, s, "POST", "/api/merge", string(body)).Body.Bytes(), &mr)
if mr.Fields["title"] != "Dune (my cut)" || mr.Nsrc["title"] != 1 {
t.Fatalf("request overlay = %+v", mr)
}
// …and so do its votes held by this core for the same cids.
storeClaims(t, s.storage, signed(t, me, "bafyX", "year", "2021", 5))
body, _ = json.Marshal(MergeRequest{As: me.UID, Claims: []claim.Claim{
signed(t, a, "bafyX", "title", "Dune", 2),
signed(t, a, "bafyX", "year", "1984", 2),
}})
_ = json.Unmarshal(call(t, s, "POST", "/api/merge", string(body)).Body.Bytes(), &mr)
if mr.Fields["year"] != "2021" || mr.Fields["title"] != "Dune" {
t.Fatalf("local overlay = %+v", mr)
}
// An all-invalid batch is still 200 with everything rejected.
bad2 := signed(t, a, "bafyX", "title", "x", 9)
bad2.Sig = bad2.Sig[:10]
body, _ = json.Marshal(MergeRequest{Claims: []claim.Claim{bad2}})
rr = call(t, s, "POST", "/api/merge", string(body))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `"rejected":1`) {
t.Fatalf("all-invalid merge = %d %s", rr.Code, rr.Body)
}
}
func TestTooManyClaimsIs413(t *testing.T) {
s := routedServer(t)
var sb strings.Builder
sb.WriteString("[")
for i := 0; i <= storage.MaxClaimsPerBatch; i++ {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(`{"cid":"c","uid":"u","key":"k","value":null,"ts":1,"sig":""}`)
}
sb.WriteString("]")
if rr := call(t, s, "POST", "/api/claims", sb.String()); rr.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized batch = %d", rr.Code)
}
}
-86
View File
@@ -1,86 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// The per-algorithm CID fields (`cid_sha2-256`, `cid_midhash256`, …) and the
// stored `canonical_cid` scalar were removed (METADATA_KEYS.md §14.13). They
// must be rejected at the write boundary rather than stored.
//
// Why a hard 400 and not a silent rewrite: the reverse-index hook
// (maybeAddAliasFromFieldLocked) only registers an alias for a field named
// `cids/<cid>`. A `cid_*` field was therefore written verbatim and never
// indexed — the record silently became unresolvable by that CID. A loud
// failure is strictly better than a record that looks correct and cannot be
// found.
func TestRejectDeprecatedField(t *testing.T) {
rejected := []string{
"cid_midhash256",
"cid_sha2-256",
"cid_btih_v2",
"cid_crc32",
"midhash256",
"canonical_cid",
}
for _, f := range rejected {
if !rejectDeprecatedField(f) {
t.Errorf("rejectDeprecatedField(%q) = false, want true (deprecated shape)", f)
}
}
accepted := []string{
"cids/bagacbabaec7v3fu2ygzh3e2sybg3fbzmisry2hbtpmck6vx3yftea6vzq35r4",
"filePath",
"title",
"fileinfo/duration",
"tmdb/poster",
// Near-misses that must NOT be caught: the prefix is `cid_`, with an
// underscore. A field merely starting with "cid" is fine.
"cids",
"cidrank",
}
for _, f := range accepted {
if rejectDeprecatedField(f) {
t.Errorf("rejectDeprecatedField(%q) = true, want false (legitimate field)", f)
}
}
}
func TestValidateFields_Rejects400WithSlug(t *testing.T) {
rr := httptest.NewRecorder()
blocked := validateFields(rr, map[string]string{
"title": "Inception",
"cid_midhash256": "bagacbabaec7v3fu2ygzh3e2sybg3fbzmisry2hbtpmck6vx3yftea6vzq35r4",
})
if !blocked {
t.Fatal("validateFields returned false for a payload carrying cid_midhash256; the write would have been stored unindexed")
}
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rr.Code, http.StatusBadRequest)
}
var body map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("response is not JSON: %v", err)
}
if got := body["error"]; got != ErrDeprecatedField {
t.Errorf("error slug = %v, want %q", got, ErrDeprecatedField)
}
}
func TestValidateFields_AllowsCidsKeyset(t *testing.T) {
rr := httptest.NewRecorder()
blocked := validateFields(rr, map[string]string{
"filePath": "/watch/Inception.mkv",
"cids/bagacbabaec7v3fu2ygzh3e2sybg3fbzmisry2hbtpmck6vx3yftea6vzq35r4": "true",
"cids/baejbeibku6ua2l6r5olnggx4pto2sii2s5jeeawgh4cbkhlbmp52gudbua": "true",
})
if blocked {
t.Fatalf("validateFields rejected a legitimate cids/ key-set write: %s", rr.Body.String())
}
}
+132
View File
@@ -0,0 +1,132 @@
package api
import (
"encoding/json"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"github.com/metazla/meta-core/internal/watcher"
)
// registerFileRequest is the body of POST /api/files/register.
type registerFileRequest struct {
// Path relative to FILES_PATH, e.g. "plugin/subtitles/ab12….eng.srt".
Path string `json:"path"`
}
type registerFileResponse struct {
Root string `json:"root"`
CID string `json:"cid"`
Created bool `json:"created"`
Path string `json:"path"`
Size int64 `json:"size"`
}
// pluginRelPath normalises a FILES_PATH-relative path and refuses anything
// outside plugin/. Plugin output is the only tree a service may register this
// way: /files/watch is the user's library and belongs to the watcher.
func pluginRelPath(raw string) (string, bool) {
p := strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(raw)), "/")
if p == "" {
return "", false
}
for _, seg := range strings.Split(p, "/") {
if seg == ".." {
return "", false
}
}
clean := path.Clean(p)
if !strings.HasPrefix(clean, "plugin/") || clean == "plugin/" {
return "", false
}
return clean, true
}
// handleRegisterFile handles POST /api/files/register.
//
// Does for ONE file what the watcher does for every file it scans: midhash256
// it, mint a root carrying filePath/sizeByte/mtimeNano, register the
// cid:<midhash> alias, and append a file:events entry so meta-share's
// ipfs_seed chunks, seeds and provides it.
//
// It exists for cores that run with ENABLE_FILE_WATCHER=false (a meta-watch
// client box). Without it, bytes written under /files/plugin over WebDAV are
// never resolvable by CID and never reach the swarm. Idempotent: the same
// content at the same path returns the existing root with created=false.
func (s *Server) handleRegisterFile(w http.ResponseWriter, r *http.Request) {
if s.storage == nil || !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
var req registerFileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
rel, ok := pluginRelPath(req.Path)
if !ok {
writeError(w, http.StatusBadRequest, "path must be a file under plugin/")
return
}
fullPath := filepath.Join(s.config.FilesPath, filepath.FromSlash(rel))
absFiles, _ := filepath.Abs(filepath.Join(s.config.FilesPath, "plugin"))
absFull, _ := filepath.Abs(fullPath)
if !strings.HasPrefix(absFull, absFiles+string(filepath.Separator)) {
writeError(w, http.StatusBadRequest, "path must be a file under plugin/")
return
}
info, err := os.Stat(fullPath)
if err != nil {
writeError(w, http.StatusNotFound, "file not found")
return
}
if info.IsDir() {
writeError(w, http.StatusBadRequest, "path is a directory, not a file")
return
}
midhash, err := watcher.ComputeMidHash256(fullPath, info.Size())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to hash file")
return
}
root, created, err := s.storage.RegisterFileTuple(fullPath, info.Size(), info.ModTime().UnixNano(), midhash)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if created {
ev := watcher.FileEvent{
Type: watcher.EventTypeAdd,
Path: rel,
Size: info.Size(),
MidHash256: midhash,
Timestamp: watcher.NowMS(),
}
if err := watcher.PublishEvent(s.storage, ev); err != nil {
// The record is written and resolvable; only seeding is late.
writeError(w, http.StatusInternalServerError, "registered, but file event not published: "+err.Error())
return
}
}
status := http.StatusOK
if created {
status = http.StatusCreated
}
writeJSON(w, status, registerFileResponse{
Root: root,
CID: midhash,
Created: created,
Path: rel,
Size: info.Size(),
})
}
+135
View File
@@ -0,0 +1,135 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/metazla/meta-core/internal/config"
)
// newRegisterTestServer wires handleRegisterFile to a temp store and a temp
// FILES_PATH. Built directly, not through NewServer, so no watcher runs —
// which is exactly the configuration this route exists for.
func newRegisterTestServer(t *testing.T) (*Server, string) {
t.Helper()
files := t.TempDir()
return &Server{storage: newTestStorage(t), config: &config.Config{FilesPath: files}}, files
}
func writeFixture(t *testing.T, files, rel, body string) string {
t.Helper()
full := filepath.Join(files, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return full
}
func postRegister(t *testing.T, s *Server, path string) (int, registerFileResponse) {
t.Helper()
body, _ := json.Marshal(registerFileRequest{Path: path})
req := httptest.NewRequest(http.MethodPost, "/api/files/register", strings.NewReader(string(body)))
rr := httptest.NewRecorder()
s.handleRegisterFile(rr, req)
var resp registerFileResponse
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
return rr.Code, resp
}
func streamLen(t *testing.T, s *Server) int {
t.Helper()
n, err := s.storage.GetStreamLength("file:events")
if err != nil {
t.Fatalf("stream length: %v", err)
}
return int(n)
}
// The whole point of the route: with the watcher off, a file written under
// plugin/ becomes a record resolvable by its midhash, and meta-share hears
// about it on file:events.
func TestRegisterFile_MintsAliasAndPublishesEvent(t *testing.T) {
s, files := newRegisterTestServer(t)
rel := "plugin/subtitles/ab12cd34.eng.srt"
full := writeFixture(t, files, rel, "1\n00:00:01,000 --> 00:00:02,000\nHello\n")
code, resp := postRegister(t, s, rel)
if code != http.StatusCreated || !resp.Created {
t.Fatalf("want 201 created, got %d %+v", code, resp)
}
if !strings.HasPrefix(resp.CID, "b") || resp.Root == "" {
t.Fatalf("want a multibase cid and a root, got %+v", resp)
}
root, err := s.storage.GetByCID(resp.CID)
if err != nil || root != resp.Root {
t.Fatalf("cid alias must resolve to the root: got %q err=%v, want %q", root, err, resp.Root)
}
if fp, _ := s.storage.GetProperty(resp.Root, "filePath"); fp != full {
t.Fatalf("filePath = %q, want %q", fp, full)
}
entries, err := s.storage.XRevRange("file:events", "+", "-", 0)
if err != nil || len(entries) != 1 {
t.Fatalf("want exactly one file:events entry, got %d err=%v", len(entries), err)
}
if vals := entries[0].Values; vals["midhash256"] != resp.CID || vals["path"] != rel {
t.Fatalf("event must carry the midhash and the relative path, got %v", vals)
}
}
// Re-uploading the same subtitle is a no-op: same root, no second event (which
// would make meta-share re-chunk and re-provide the file).
func TestRegisterFile_IsIdempotent(t *testing.T) {
s, files := newRegisterTestServer(t)
rel := "plugin/subtitles/same.eng.srt"
writeFixture(t, files, rel, "same bytes")
_, first := postRegister(t, s, rel)
code, second := postRegister(t, s, rel)
if code != http.StatusOK || second.Created {
t.Fatalf("second register must be 200 created=false, got %d %+v", code, second)
}
if second.Root != first.Root || second.CID != first.CID {
t.Fatalf("second register must return the same record: %+v vs %+v", second, first)
}
if n := streamLen(t, s); n != 1 {
t.Fatalf("an idempotent re-register must not publish again, stream has %d", n)
}
}
// Only plugin output may be registered here. /files/watch is the user's
// library and belongs to the watcher; traversal must not escape either.
func TestRegisterFile_RejectsPathsOutsidePlugin(t *testing.T) {
s, files := newRegisterTestServer(t)
writeFixture(t, files, "watch/movie.mkv", "x")
for _, p := range []string{
"", "plugin", "plugin/", "watch/movie.mkv", "/watch/movie.mkv",
"plugin/../watch/movie.mkv", "../etc/passwd", "plugin/../../etc/passwd",
} {
if code, _ := postRegister(t, s, p); code != http.StatusBadRequest {
t.Errorf("path %q: want 400, got %d", p, code)
}
}
}
func TestRegisterFile_MissingFileAndDirectory(t *testing.T) {
s, files := newRegisterTestServer(t)
if code, _ := postRegister(t, s, "plugin/subtitles/nope.srt"); code != http.StatusNotFound {
t.Errorf("missing file: want 404, got %d", code)
}
if err := os.MkdirAll(filepath.Join(files, "plugin", "subtitles", "dir"), 0o755); err != nil {
t.Fatal(err)
}
if code, _ := postRegister(t, s, "plugin/subtitles/dir"); code != http.StatusBadRequest {
t.Errorf("directory: want 400, got %d", code)
}
}
+74
View File
@@ -0,0 +1,74 @@
package api
import "net/http"
// fileTupleJSON is one file-backed root on the wire.
type fileTupleJSON struct {
HashID string `json:"hashId"`
FilePath string `json:"filePath"`
SizeByte int64 `json:"sizeByte"`
MtimeNano int64 `json:"mtimeNano"`
}
// FileTuplesResponse is the body of GET /api/files/tuples.
//
// Files is a pointer so the summary form can omit it while the full form
// always carries it — an empty library is `"files": []`, never an absent key,
// which a client could mistake for a meta-core that lacks the rows.
type FileTuplesResponse struct {
Files *[]fileTupleJSON `json:"files,omitempty"`
Count int `json:"count"`
TotalSize int64 `json:"totalSize"`
}
// handleGetFileTuples handles GET /api/files/tuples.
//
// Lists the FILE-BACKED roots — records carrying filePath, sizeByte and
// mtimeNano, i.e. what the watcher or /api/files/register minted — with their
// sizes, plus the count and total. Gateway / cid-rooted records carry no file
// and are not included; neither is a locator record that only merged a cache
// file's metadata (it has no mtimeNano).
//
// It exists so a client never walks every hashId to answer "how many files,
// how big": GetTuplesForAllFiles reads the three fields for every root in
// chunked MGETs server-side (~250 round-trips for 82k roots, no SCAN), where
// the client-side equivalent was one HTTP request per record.
//
// ?summary=1 omits the rows and returns only {count, totalSize}.
func (s *Server) handleGetFileTuples(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
tuples, err := s.storage.GetTuplesForAllFiles()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
summary := r.URL.Query().Get("summary")
withRows := summary == "" || summary == "0" || summary == "false"
resp := FileTuplesResponse{Count: len(tuples)}
var rows []fileTupleJSON
if withRows {
rows = make([]fileTupleJSON, 0, len(tuples))
}
for _, t := range tuples {
resp.TotalSize += t.Size
if withRows {
rows = append(rows, fileTupleJSON{
HashID: t.HashID,
FilePath: t.FilePath,
SizeByte: t.Size,
MtimeNano: t.MtimeNano,
})
}
}
if withRows {
resp.Files = &rows
}
writeJSON(w, http.StatusOK, resp)
}
+108
View File
@@ -0,0 +1,108 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sort"
"testing"
"github.com/metazla/meta-core/internal/storage"
)
func newFileTuplesTestServer(t *testing.T) (*Server, *storage.Client) {
t.Helper()
c := newTestStorage(t)
return &Server{storage: c}, c
}
func getFileTuples(t *testing.T, s *Server, query string) (int, FileTuplesResponse) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/files/tuples"+query, nil)
rr := httptest.NewRecorder()
s.handleGetFileTuples(rr, req)
var resp FileTuplesResponse
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
return rr.Code, resp
}
func mustMint(t *testing.T, c *storage.Client, path string, size, mtime int64) string {
t.Helper()
id, _, err := c.RegisterFileTuple(path, size, mtime, "bafkmid"+path)
if err != nil {
t.Fatalf("register %s: %v", path, err)
}
return id
}
// Only local files count: a gateway record — even one whose claims carry a
// filePath and size — is left out.
func TestFileTuples_OnlyFileBackedRoots(t *testing.T) {
s, c := newFileTuplesTestServer(t)
a := mustMint(t, c, "/files/watch/a.mkv", 1000, 1)
b := mustMint(t, c, "/files/watch/b.mkv", 2500, 2)
gw := testSigner(t, 1)
storeClaims(t, c,
signed(t, gw, "bagcsgatewayrecord", "title", "Some Release", 1),
signed(t, gw, "bafkpartial", "filePath", "/files/cache/x.mkv", 1),
signed(t, gw, "bafkpartial", "sizeByte", "999", 1),
)
code, resp := getFileTuples(t, s, "")
if code != http.StatusOK {
t.Fatalf("want 200, got %d", code)
}
if resp.Count != 2 || resp.TotalSize != 3500 {
t.Fatalf("want count 2 / totalSize 3500, got %d / %d", resp.Count, resp.TotalSize)
}
if resp.Files == nil || len(*resp.Files) != 2 {
t.Fatalf("want 2 rows, got %+v", resp.Files)
}
got := []string{(*resp.Files)[0].HashID, (*resp.Files)[1].HashID}
want := []string{a, b}
sort.Strings(got)
sort.Strings(want)
if got[0] != want[0] || got[1] != want[1] {
t.Fatalf("want roots %v, got %v", want, got)
}
}
func TestFileTuples_SummaryOmitsRows(t *testing.T) {
s, c := newFileTuplesTestServer(t)
mustMint(t, c, "/files/watch/a.mkv", 1000, 1)
mustMint(t, c, "/files/watch/b.mkv", 2500, 2)
code, resp := getFileTuples(t, s, "?summary=1")
if code != http.StatusOK {
t.Fatalf("want 200, got %d", code)
}
if resp.Files != nil {
t.Fatalf("summary must omit rows, got %d", len(*resp.Files))
}
if resp.Count != 2 || resp.TotalSize != 3500 {
t.Fatalf("want count 2 / totalSize 3500, got %d / %d", resp.Count, resp.TotalSize)
}
}
// An empty library still carries "files": [] — clients treat an absent key as
// "this meta-core cannot list files", so it must not disappear when empty.
func TestFileTuples_EmptyLibraryKeepsFilesKey(t *testing.T) {
s, _ := newFileTuplesTestServer(t)
code, resp := getFileTuples(t, s, "")
if code != http.StatusOK {
t.Fatalf("want 200, got %d", code)
}
if resp.Files == nil || len(*resp.Files) != 0 || resp.Count != 0 {
t.Fatalf("want an empty files array and count 0, got %+v", resp)
}
}
func TestFileTuples_StorageNotConnected(t *testing.T) {
s := &Server{storage: storage.NewClient()}
code, _ := getFileTuples(t, s, "")
if code != http.StatusServiceUnavailable {
t.Fatalf("want 503, got %d", code)
}
}
+36 -364
View File
@@ -4,7 +4,6 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/base32" "encoding/base32"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"net/http" "net/http"
"os" "os"
@@ -14,6 +13,7 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/metazla/meta-core/internal/leader" "github.com/metazla/meta-core/internal/leader"
"github.com/metazla/meta-core/internal/meshdisco"
) )
// contentTypeByExt maps file extensions to MIME types // contentTypeByExt maps file extensions to MIME types
@@ -88,42 +88,6 @@ const (
ErrDeprecatedField = "deprecated_field" ErrDeprecatedField = "deprecated_field"
) )
// rejectDeprecatedField reports whether a field name is one of the removed
// per-algorithm CID shapes (`cid_sha2-256`, `cid_midhash256`, `midhash256`, …).
//
// This is a tripwire, not a compatibility shim. Such a field used to be
// accepted and written verbatim — but the reverse-index hook
// (`maybeAddAliasFromFieldLocked`) only registers aliases for fields named
// `cids/<cid>`, so a `cid_*` field was stored and then NEVER INDEXED: the
// record silently became unresolvable by that CID. Failing the write loudly is
// strictly better than a record that looks fine and cannot be found.
//
// Writers must emit the bare-CID key-set instead: `cids/<bareCid>` = "true".
// See METADATA_KEYS.md §2/§14.13.
func rejectDeprecatedField(field string) bool {
return strings.HasPrefix(field, "cid_") ||
field == "midhash256" ||
field == "canonical_cid"
}
// validateFields rejects a write carrying any deprecated CID field. Returns
// true if the request was rejected (the handler must return immediately).
func validateFields(w http.ResponseWriter, metadata map[string]string) bool {
for field := range metadata {
if rejectDeprecatedField(field) {
writeErrorSlug(w, http.StatusBadRequest, ErrDeprecatedField,
fmt.Sprintf("field %q is a removed per-algorithm CID shape; "+
"write the bare-CID key-set instead: \"cids/<bareCid>\": \"true\" "+
"(a cid_* field would be stored but never indexed, leaving the "+
"record unresolvable by that CID). See METADATA_KEYS.md §2/§14.13.",
field),
false)
return true
}
}
return false
}
// defaultSlugFor maps an HTTP status code to the most likely error slug. // defaultSlugFor maps an HTTP status code to the most likely error slug.
// Handlers that want to override (e.g. /api/meta/{cid} → "unknown_cid" // Handlers that want to override (e.g. /api/meta/{cid} → "unknown_cid"
// instead of "unknown_root" on 404) call writeErrorSlug directly. // instead of "unknown_root" on 404) call writeErrorSlug directly.
@@ -249,7 +213,7 @@ func (s *Server) handleGetMeta(w http.ResponseWriter, r *http.Request) {
// the watcher minted the root, regardless of which CID the caller knows. // the watcher minted the root, regardless of which CID the caller knows.
hashID = s.storage.ResolveRoot(hashID) hashID = s.storage.ResolveRoot(hashID)
metadata, err := s.storage.GetMetadataFlat(hashID) metadata, err := s.storage.GetMetadataFlatAs(hashID, r.URL.Query().Get("as"))
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
@@ -268,76 +232,6 @@ func (s *Server) handleGetMeta(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, response) writeJSON(w, http.StatusOK, response)
} }
// handlePutMeta handles PUT /meta/{hash}
func (s *Server) handlePutMeta(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hash"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash is required")
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
// CID → UUID resolution so writes land on the canonical root, not on
// a parallel midhash-rooted entry the watcher's UUID never sees.
hashID = s.storage.ResolveRoot(hashID)
var metadata map[string]string
if err := json.NewDecoder(r.Body).Decode(&metadata); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if validateFields(w, metadata) {
return
}
if err := s.storage.SetMetadataFlat(hashID, metadata); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"hashId": hashID,
})
}
// handleDeleteMeta handles DELETE /meta/{hash}
func (s *Server) handleDeleteMeta(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hash"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash is required")
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
hashID = s.storage.ResolveRoot(hashID)
deleted, err := s.storage.DeleteMetadata(hashID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"hashId": hashID,
"deleted": deleted,
})
}
// handleListMeta handles GET /meta // handleListMeta handles GET /meta
func (s *Server) handleListMeta(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListMeta(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() { if !s.storage.IsConnected() {
@@ -547,62 +441,49 @@ func (s *Server) handleGetFileByCID(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, fileInfo.Name(), fileInfo.ModTime(), file) http.ServeContent(w, r, fileInfo.Name(), fileInfo.ModTime(), file)
} }
// handleListServices handles GET /services and GET /api/services // handleListNeighbors handles GET /neighbors and GET /api/neighbors.
// Supports optional ?current=<service-name> query parameter //
func (s *Server) handleListServices(w http.ResponseWriter, r *http.Request) { // Beacon v2 (docs/project-architecture/beacon-v2.md). The list is this node's
services, err := s.discovery.DiscoverAll() // in-memory view of the UDP group, so it works across stacks that share a
if err != nil { // network but no volume. Instances of one name collapse to the most recent
writeError(w, http.StatusInternalServerError, err.Error()) // (one row per service, the nav menu's shape); `?all=1` returns every
return // instance, `?cap=<pattern>` keeps only nodes with a matching capability.
} func (s *Server) handleListNeighbors(w http.ResponseWriter, r *http.Request) {
response := map[string]interface{}{ response := map[string]interface{}{
"services": services, "current": s.config.ServiceName,
"count": len(services), "neighbors": []interface{}{},
} "count": 0,
// Add 'current' field if query parameter is provided
current := r.URL.Query().Get("current")
if current != "" {
response["current"] = current
} }
if s.mesh == nil {
// Discovery off or failed to bind. Report empty rather than 503 —
// the nav is decoration and callers already treat [] as "hide".
response["enabled"] = false
writeJSON(w, http.StatusOK, response) writeJSON(w, http.StatusOK, response)
}
// handleGetService handles GET /services/{name}
func (s *Server) handleGetService(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
name := vars["name"]
if name == "" {
writeError(w, http.StatusBadRequest, "service name is required")
return return
} }
service, err := s.discovery.Discover(name) var list []meshdisco.Neighbor
if err != nil { if r.URL.Query().Get("all") != "" {
writeError(w, http.StatusInternalServerError, err.Error()) list = s.mesh.Neighbors()
return } else {
list = s.mesh.NeighborsByName()
}
if pattern := r.URL.Query().Get("cap"); pattern != "" {
kept := list[:0]
for _, nb := range list {
if nb.Matches(pattern) {
kept = append(kept, nb)
}
}
list = kept
} }
if service == nil { response["enabled"] = true
writeError(w, http.StatusNotFound, "service not found") response["neighbors"] = list
return response["count"] = len(list)
} response["self"] = s.mesh.Self()
writeJSON(w, http.StatusOK, response)
writeJSON(w, http.StatusOK, service)
}
// handleCleanupStats handles GET /services/cleanup/stats
func (s *Server) handleCleanupStats(w http.ResponseWriter, r *http.Request) {
if s.cleaner == nil {
writeError(w, http.StatusServiceUnavailable, "cleaner not initialized")
return
}
stats := s.cleaner.Stats()
writeJSON(w, http.StatusOK, stats)
} }
// writeJSON writes a JSON response // writeJSON writes a JSON response
@@ -635,50 +516,6 @@ func writeErrorSlug(w http.ResponseWriter, status int, slug, message string, ret
}) })
} }
// handlePatchMeta handles PATCH /meta/{hash}
// Merges the provided metadata into existing (does not delete missing keys)
func (s *Server) handlePatchMeta(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hash"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash is required")
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
// PATCH is the hot write path for meta-sort. Resolve so plugin output
// merges into the watcher's UUID root rather than minting a parallel
// midhash-rooted entry.
hashID = s.storage.ResolveRoot(hashID)
var metadata map[string]string
if err := json.NewDecoder(r.Body).Decode(&metadata); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if validateFields(w, metadata) {
return
}
updated, err := s.storage.MergeMetadataFlat(hashID, metadata)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"hashId": hashID,
"updated": updated,
})
}
// handleGetProperty handles GET /meta/{hash}/{key} // handleGetProperty handles GET /meta/{hash}/{key}
// Gets a single property value // Gets a single property value
func (s *Server) handleGetProperty(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetProperty(w http.ResponseWriter, r *http.Request) {
@@ -725,171 +562,6 @@ type PropertyBody struct {
Value string `json:"value"` Value string `json:"value"`
} }
// handlePutProperty handles PUT /meta/{hash}/{key}
// Sets a single property value
// Accepts JSON body with {"value": "..."} or plain text
func (s *Server) handlePutProperty(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hash"]
key := vars["key"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash is required")
return
}
if key == "" {
writeError(w, http.StatusBadRequest, "key is required")
return
}
if validateFields(w, map[string]string{key: ""}) {
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
hashID = s.storage.ResolveRoot(hashID)
// Read body
buf := make([]byte, 1024*1024) // 1MB max
n, err := r.Body.Read(buf)
if err != nil && err.Error() != "EOF" {
writeError(w, http.StatusBadRequest, "failed to read body")
return
}
body := buf[:n]
// Try to parse as JSON {"value": "..."} first
var propBody PropertyBody
var value string
if err := json.Unmarshal(body, &propBody); err == nil && propBody.Value != "" {
// JSON body with value field
value = propBody.Value
} else {
// Plain text body
value = string(body)
}
if err := s.storage.SetProperty(hashID, key, value); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"hashId": hashID,
"property": key,
})
}
// handleDeleteProperty handles DELETE /meta/{hash}/{key}
// Deletes a single property
func (s *Server) handleDeleteProperty(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hash"]
key := vars["key"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash is required")
return
}
if key == "" {
writeError(w, http.StatusBadRequest, "key is required")
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
hashID = s.storage.ResolveRoot(hashID)
if err := s.storage.DeleteProperty(hashID, key); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"hashId": hashID,
"property": key,
})
}
// handleAddToSet handles POST /meta/{hash}/_add/{key}
// Adds a value to a set-type field
// Accepts JSON body with {"value": "..."} or plain text
func (s *Server) handleAddToSet(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hash"]
key := vars["key"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash is required")
return
}
if key == "" {
writeError(w, http.StatusBadRequest, "key is required")
return
}
if validateFields(w, map[string]string{key: ""}) {
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
hashID = s.storage.ResolveRoot(hashID)
// Read body
buf := make([]byte, 1024*1024) // 1MB max
n, err := r.Body.Read(buf)
if err != nil && err.Error() != "EOF" {
writeError(w, http.StatusBadRequest, "failed to read body")
return
}
body := buf[:n]
// Try to parse as JSON {"value": "..."} first
var propBody PropertyBody
var value string
if err := json.Unmarshal(body, &propBody); err == nil && propBody.Value != "" {
// JSON body with value field
value = propBody.Value
} else {
// Plain text body
value = string(body)
}
if value == "" {
writeError(w, http.StatusBadRequest, "value is required")
return
}
added, err := s.storage.AddToSet(hashID, key, value)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"hashId": hashID,
"property": key,
"added": added,
})
}
// CIDRequest is the request body for POST /file/cid // CIDRequest is the request body for POST /file/cid
type CIDRequest struct { type CIDRequest struct {
Path string `json:"path"` Path string `json:"path"`
+340 -44
View File
@@ -3,10 +3,17 @@ package api
import ( import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt"
"log"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time"
"github.com/metazla/meta-core/internal/claim"
"github.com/metazla/meta-core/internal/identity" "github.com/metazla/meta-core/internal/identity"
"github.com/metazla/meta-core/internal/storage"
) )
// identityStatusResponse is the shape of GET /api/identity. The private key // identityStatusResponse is the shape of GET /api/identity. The private key
@@ -58,12 +65,40 @@ type identityImportResponse struct {
Created bool `json:"created"` Created bool `json:"created"`
} }
// identityRevealRequest asks for an existing account's private key back. The // identityChallengeRequest asks for a one-shot challenge to sign.
// key was shown once at generate; without a way to see it again, a client with type identityChallengeRequest struct {
// a real sign-out has no recovery path for a user who never saved it. UID string `json:"uid"`
Action string `json:"action"`
}
type identityChallengeResponse struct {
Challenge string `json:"challenge"`
ExpiresAt int64 `json:"expiresAt"`
}
// identityProof is the proof-of-possession carried by reveal and delete: the
// challenge text as issued, and a signature over it by the account's key.
//
// The signature is produced by whoever holds the key — the browser, locally.
// It is NOT obtainable from POST /api/identity/sign, which refuses any payload
// in the challenge domain; that refusal is what makes this proof mean
// something on a node that stores every private key in plaintext.
type identityProof struct {
Challenge string `json:"challenge"`
Signature string `json:"signature"`
}
// identityRevealRequest asks for an existing account's private key back.
//
// Gated on proof-of-possession, which makes it a "does this node hold the same
// key I do" check rather than the recovery path it once was. That is the
// deliberate consequence of the rule this file enforces: the private key, and
// nothing else, is authority over the account. An operator with root on the box
// can still read the file; an operator with only the API cannot.
type identityRevealRequest struct { type identityRevealRequest struct {
Confirm bool `json:"confirm"` Confirm bool `json:"confirm"`
UID string `json:"uid"` UID string `json:"uid"`
identityProof
} }
type identityRevealResponse struct { type identityRevealResponse struct {
@@ -75,10 +110,22 @@ type identityRevealResponse struct {
type identityDeleteRequest struct { type identityDeleteRequest struct {
Confirm bool `json:"confirm"` Confirm bool `json:"confirm"`
UID string `json:"uid"` UID string `json:"uid"`
identityProof
}
// identityDeleteResponse reports what the delete actually removed. The purge
// counts are the point: "account removed" alone leaves the caller unable to
// tell a clean removal from one that silently stranded a decade of ratings.
type identityDeleteResponse struct {
UID string `json:"uid"`
Purged storage.UDLUserStats `json:"purged"`
} }
type identitySignRequest struct { type identitySignRequest struct {
BytesB64 string `json:"bytesB64"` BytesB64 string `json:"bytesB64"`
// Format is "der" or "compact" (64-byte r||s, low-S). Default: compact for
// MMUR2 claim payloads ("MMUR2\n" prefix), DER otherwise (MMUR1 back-compat).
Format string `json:"format,omitempty"`
// UID selects which account signs. Optional — when omitted and exactly one // UID selects which account signs. Optional — when omitted and exactly one
// account exists, that account is used (single-identity back-compat). // account exists, that account is used (single-identity back-compat).
UID string `json:"uid,omitempty"` UID string `json:"uid,omitempty"`
@@ -88,6 +135,43 @@ 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"`
// Format: see identitySignRequest.Format; applies to every payload.
Format string `json:"format,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
// reservedPayloadMessage explains the one refusal the signing endpoints make.
//
// This is the load-bearing half of proof-of-possession on a node that holds
// every private key: without it, a caller wanting to delete someone else's
// account just asks meta-core to sign the challenge for them, and the gate on
// reveal/delete becomes a formality. See identity.ChallengeDomain.
const reservedPayloadMessage = "payload is in the reserved account-authorisation domain (" +
identity.ChallengeDomain + "…); meta-core will not sign one. " +
"Signing a challenge is what proves you hold the key, so it must be done where the key is."
type identityAEADKeyResponse struct { type identityAEADKeyResponse struct {
AEADKeyB64 string `json:"aeadKeyB64"` AEADKeyB64 string `json:"aeadKeyB64"`
} }
@@ -217,23 +301,105 @@ func (s *Server) handleIdentityImport(w http.ResponseWriter, r *http.Request) {
}) })
} }
// handleIdentityReveal returns an existing account's private key. Deliberately // handleIdentityChallenge issues the one-shot text a client signs to prove it
// POST + explicit confirm (never a bare GET) so it cannot be triggered by a // holds an account's private key.
// link or an image tag, and so it reads as the deliberate act it is. The //
// endpoint is auth-gated at the perimeter like the rest of /api/identity/*. // Unauthenticated by design: a challenge is useless without the key, and
func (s *Server) handleIdentityReveal(w http.ResponseWriter, r *http.Request) { // GET /api/identity/accounts already publishes every uid, so refusing to mint
var body identityRevealRequest // one for an unknown caller would protect nothing. Requiring the account to
_ = json.NewDecoder(r.Body).Decode(&body) // exist is for a clear error, not secrecy.
if !body.Confirm && r.URL.Query().Get("confirm") != "true" { func (s *Server) handleIdentityChallenge(w http.ResponseWriter, r *http.Request) {
writeErrorSlug(w, http.StatusBadRequest, "confirm_required", var body identityChallengeRequest
"revealing a private key requires {\"confirm\": true} or ?confirm=true", false) if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return return
} }
uid := strings.TrimSpace(body.UID) uid := strings.TrimSpace(body.UID)
if uid == "" {
writeErrorSlug(w, http.StatusBadRequest, "uid_required", "uid is required", false)
return
}
action := identity.Action(strings.TrimSpace(body.Action))
if !identity.ValidAction(action) {
writeErrorSlug(w, http.StatusBadRequest, "bad_action",
`action must be "reveal" or "delete"`, false)
return
}
if !identity.ExistsByUID(s.config.IdentityAccountsDir(), uid) {
writeErrorSlug(w, http.StatusNotFound, "no_identity", "no identity for the given uid", false)
return
}
text, expires, err := s.challenges.Issue(uid, action)
if err != nil {
if errors.Is(err, identity.ErrChallengeFull) {
writeErrorSlug(w, http.StatusServiceUnavailable, "challenges_full", err.Error(), true)
return
}
writeErrorSlug(w, http.StatusBadRequest, "bad_uid", err.Error(), false)
return
}
writeJSON(w, http.StatusOK, identityChallengeResponse{
Challenge: text,
ExpiresAt: expires.Unix(),
})
}
// requireProof resolves the target account and verifies the caller's
// proof-of-possession for `action`. status == 0 means the proof held.
//
// The uid is mandatory here, unlike elsewhere in this file: the old "sole
// account" fallback would have this pick a target the signature does not name,
// and a proof that authorises an unnamed account authorises the wrong one as
// soon as a second profile exists.
func (s *Server) requireProof(uid string, proof identityProof, action identity.Action) (
id *identity.Identity, status int, slug, msg string,
) {
uid = strings.TrimSpace(uid)
if uid == "" {
return nil, http.StatusBadRequest, "uid_required",
"uid is required: the signature authorises one specific account"
}
if proof.Challenge == "" || proof.Signature == "" {
return nil, http.StatusUnauthorized, "signature_required",
"this operation requires proof you hold the account's private key: " +
"POST /api/identity/challenge, sign the returned text, and resend it as {challenge, signature}"
}
sig, err := base64.StdEncoding.DecodeString(proof.Signature)
if err != nil {
return nil, http.StatusBadRequest, "bad_signature", "signature is not valid base64"
}
// Resolve before redeeming so a request naming a missing account does not
// burn a challenge it could not have used anyway.
id, status, slug, msg = s.resolveAccount(uid)
if status != 0 {
return nil, status, slug, msg
}
// Redeem first, verify second: the challenge is single-use either way, so a
// wrong signature costs the attacker a fresh round trip per guess rather
// than letting them grind one challenge offline against the same nonce.
if err := s.challenges.Redeem(proof.Challenge, uid, action); err != nil {
return nil, http.StatusUnauthorized, "bad_challenge", err.Error()
}
if err := identity.Verify(uid, []byte(proof.Challenge), sig); err != nil {
return nil, http.StatusUnauthorized, "bad_signature", err.Error()
}
return id, 0, "", ""
}
// handleIdentityReveal returns an existing account's private key to a caller
// that proves it already holds that key.
//
// Deliberately POST (never a bare GET) so it cannot be triggered by a link or
// an image tag. The proof is what actually guards it: before this gate existed,
// anything that could reach the port could read every private key on the node.
func (s *Server) handleIdentityReveal(w http.ResponseWriter, r *http.Request) {
var body identityRevealRequest
_ = json.NewDecoder(r.Body).Decode(&body)
uid := strings.TrimSpace(body.UID)
if uid == "" { if uid == "" {
uid = strings.TrimSpace(r.URL.Query().Get("uid")) uid = strings.TrimSpace(r.URL.Query().Get("uid"))
} }
id, status, slug, msg := s.resolveAccount(uid) id, status, slug, msg := s.requireProof(uid, body.identityProof, identity.ActionReveal)
if status != 0 { if status != 0 {
writeErrorSlug(w, status, slug, msg, false) writeErrorSlug(w, status, slug, msg, false)
return return
@@ -245,46 +411,62 @@ func (s *Server) handleIdentityReveal(w http.ResponseWriter, r *http.Request) {
}) })
} }
// handleIdentityDelete removes one account and everything it wrote.
//
// Two things happen, in this order and no other:
//
// 1. the account's User Data Layer rows are purged (cells plus every index
// that names the uid, including the cid-scoped ones shared with other
// accounts);
// 2. the keypair file is removed.
//
// Purge-then-delete so a failure never strands data: if the purge errors the
// key is still on disk, the account still exists, and the caller can retry.
// The reverse order would leave rows nobody can ever attribute, reach or
// rewrite — the orphaning this endpoint exists to prevent.
//
// What deletion still does NOT do, and cannot: records already signed under
// this uid and replicated to other peers keep verifying there. Removing a key
// is not a recall.
func (s *Server) handleIdentityDelete(w http.ResponseWriter, r *http.Request) { func (s *Server) handleIdentityDelete(w http.ResponseWriter, r *http.Request) {
var body identityDeleteRequest var body identityDeleteRequest
// Tolerate empty body — confirm flag in a query param is also acceptable // Tolerate an empty body so the uid may ride in the query — meta-watch's
// for shells where sending a JSON body with DELETE is awkward. // proxy puts it there. The proof itself must be in the body: a signature in
// a URL ends up in every access log between here and the browser.
_ = json.NewDecoder(r.Body).Decode(&body) _ = json.NewDecoder(r.Body).Decode(&body)
if !body.Confirm && r.URL.Query().Get("confirm") != "true" {
writeErrorSlug(w, http.StatusBadRequest, "confirm_required",
"identity deletion requires {\"confirm\": true} or ?confirm=true", false)
return
}
dir := s.config.IdentityAccountsDir()
uid := strings.TrimSpace(body.UID) uid := strings.TrimSpace(body.UID)
if uid == "" { if uid == "" {
uid = strings.TrimSpace(r.URL.Query().Get("uid")) uid = strings.TrimSpace(r.URL.Query().Get("uid"))
} }
if uid == "" { id, status, slug, msg := s.requireProof(uid, body.identityProof, identity.ActionDelete)
// Back-compat: with no uid, delete the sole account. Ambiguous (and so if status != 0 {
// refused) once more than one account exists. writeErrorSlug(w, status, slug, msg, false)
list, err := identity.List(dir) return
}
// A purge needs storage. Refusing here — rather than deleting the key and
// reporting the purge as "0 records" — is the difference between a retry
// and permanent orphaned data: once the key is gone the account cannot be
// named for a second attempt through this API.
if !s.storage.IsConnected() {
writeErrorSlug(w, http.StatusServiceUnavailable, "storage_unavailable",
"storage is not connected; refusing to delete the key while its data cannot be purged", true)
return
}
purged, err := s.retractAllClaims(r, id)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeErrorSlug(w, http.StatusInternalServerError, "purge_failed",
"could not retract this account's claims; the account was NOT deleted: "+err.Error(), true)
return return
} }
switch len(list) {
case 0: if err := identity.DeleteByUID(s.config.IdentityAccountsDir(), id.UID); err != nil {
w.WriteHeader(http.StatusNoContent) // idempotent writeErrorSlug(w, http.StatusInternalServerError, "delete_failed", err.Error(), false)
return
case 1:
uid = list[0].UID
default:
writeErrorSlug(w, http.StatusBadRequest, "uid_required",
"multiple accounts exist; specify uid to delete", false)
return return
} }
} log.Printf("[API] Deleted account %s after retracting %d claims across %d cids",
if err := identity.DeleteByUID(dir, uid); err != nil { id.UID, purged.Records, purged.Cids)
writeError(w, http.StatusInternalServerError, err.Error()) writeJSON(w, http.StatusOK, identityDeleteResponse{UID: id.UID, Purged: purged})
return
}
w.WriteHeader(http.StatusNoContent)
} }
func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) { func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) {
@@ -307,9 +489,13 @@ func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "bytesB64 is not valid base64") writeError(w, http.StatusBadRequest, "bytesB64 is not valid base64")
return return
} }
sig, err := identity.Sign(id, raw) if identity.IsChallengePayload(raw) {
writeErrorSlug(w, http.StatusForbidden, "reserved_payload", reservedPayloadMessage, false)
return
}
sig, err := signPayload(id, raw, body.Format)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
writeJSON(w, http.StatusOK, identitySignResponse{ writeJSON(w, http.StatusOK, identitySignResponse{
@@ -317,6 +503,58 @@ 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
}
if identity.IsChallengePayload(raw) {
writeErrorSlug(w, http.StatusForbidden, "reserved_payload",
"payloadsB64["+strconv.Itoa(i)+"]: "+reservedPayloadMessage, false)
return
}
sig, err := signPayload(id, raw, body.Format)
if err != nil {
writeError(w, http.StatusBadRequest, "payloadsB64["+strconv.Itoa(i)+"]: "+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 {
@@ -332,3 +570,61 @@ func (s *Server) handleIdentityAEADKey(w http.ResponseWriter, r *http.Request) {
AEADKeyB64: base64.StdEncoding.EncodeToString(key), AEADKeyB64: base64.StdEncoding.EncodeToString(key),
}) })
} }
// signPayload signs raw with the account key in the requested encoding.
func signPayload(id *identity.Identity, raw []byte, format string) ([]byte, error) {
switch format {
case "compact":
return identity.SignCompact(id, raw)
case "der":
return identity.Sign(id, raw)
case "":
if strings.HasPrefix(string(raw), claim.Domain) {
return identity.SignCompact(id, raw)
}
return identity.Sign(id, raw)
}
return nil, fmt.Errorf("format must be \"compact\" or \"der\"")
}
// retractAllClaims signs a retraction for every live claim the account holds
// and stores them (G11): the tombstones stay, so the votes cannot be replayed.
func (s *Server) retractAllClaims(r *http.Request, id *identity.Identity) (storage.UDLUserStats, error) {
refs, err := s.storage.LiveClaimsBySigner(id.UID)
if err != nil {
return storage.UDLUserStats{}, err
}
cids, keys := map[string]bool{}, map[string]bool{}
now := uint64(time.Now().UnixMilli())
batch := make([]claim.Claim, 0, len(refs))
for _, ref := range refs {
cur, ok, err := s.storage.UDLGetClaim(id.UID, ref[0], ref[1])
if err != nil {
return storage.UDLUserStats{}, err
}
ts := now
if ok && cur.TS >= ts {
ts = cur.TS + 1
}
tomb := claim.Claim{CID: ref[0], UID: id.UID, Key: ref[1], Value: claim.Null(), TS: ts}
if err := tomb.Sign(id); err != nil {
return storage.UDLUserStats{}, err
}
batch = append(batch, tomb)
cids[ref[0]], keys[ref[1]] = true, true
}
for start := 0; start < len(batch); start += storage.MaxClaimsPerBatch {
end := min(start+storage.MaxClaimsPerBatch, len(batch))
results, err := s.storage.WriteClaims(r.Context(), batch[start:end])
if err != nil {
return storage.UDLUserStats{}, err
}
for i, res := range results {
if res.Status != storage.StatusAccepted && res.Status != storage.StatusUnchanged {
return storage.UDLUserStats{}, fmt.Errorf("retraction of %s/%s refused: %s %s",
batch[start+i].CID, batch[start+i].Key, res.Status, res.Reason)
}
}
}
return storage.UDLUserStats{Records: len(batch), Cids: len(cids), Keys: len(keys)}, nil
}
+254
View File
@@ -0,0 +1,254 @@
package api
// End-to-end exercise of the account gate against a live router + a temp store:
// generate -> write UDL rows -> stats -> challenge -> sign -> delete -> verify
// both the key and every row are gone. Deliberately drives the real HTTP
// handlers rather than the packages beneath them, because the bugs that matter
// here (a challenge accepted for the wrong action, a purge that runs after the
// key is gone) live in the wiring.
import (
"bytes"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
"github.com/metazla/meta-core/internal/config"
"github.com/metazla/meta-core/internal/identity"
)
func newTestServer(t *testing.T) *Server {
t.Helper()
stor := newTestStorage(t)
s := &Server{
config: &config.Config{MetaCorePath: t.TempDir()},
storage: stor,
challenges: identity.NewChallengeStore(),
router: mux.NewRouter(),
}
s.setupIdentityTestRoutes()
return s
}
// Only the routes under test, so this does not depend on every other subsystem
// booting.
func (s *Server) setupIdentityTestRoutes() {
s.router.HandleFunc("/api/identity/accounts", s.handleIdentityAccounts).Methods("GET")
s.router.HandleFunc("/api/identity/challenge", s.handleIdentityChallenge).Methods("POST")
s.router.HandleFunc("/api/identity/generate", s.handleIdentityGenerate).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/sign", s.handleIdentitySign).Methods("POST")
s.router.HandleFunc("/api/udl/users/stats", s.handleUDLUserStats).Methods("GET")
}
func do(t *testing.T, s *Server, method, path string, body any) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
var rdr *bytes.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
} else {
rdr = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, rdr)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, req)
out := map[string]any{}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
return rec, out
}
// signAs produces the proof a browser would, from the raw key.
func signAs(t *testing.T, privHex, challenge string) map[string]any {
t.Helper()
raw, err := hex.DecodeString(privHex)
if err != nil {
t.Fatalf("decode key: %v", err)
}
id, err := identity.Import(hex.EncodeToString(raw))
if err != nil {
t.Fatalf("import: %v", err)
}
sig, err := identity.Sign(id, []byte(challenge))
if err != nil {
t.Fatalf("sign: %v", err)
}
return map[string]any{
"challenge": challenge,
"signature": base64.StdEncoding.EncodeToString(sig),
}
}
func TestE2E_DeleteRequiresProofAndPurgesEverything(t *testing.T) {
s := newTestServer(t)
rec, gen := do(t, s, "POST", "/api/identity/generate", nil)
if rec.Code != http.StatusCreated {
t.Fatalf("generate: %d %s", rec.Code, rec.Body)
}
uid := gen["uid"].(string)
priv := gen["privateKeyHex"].(string)
// Some data to lose: claims signed by the account.
acct, err := identity.Import(priv)
if err != nil {
t.Fatal(err)
}
storeClaims(t, s.storage,
signed(t, acct, "cidA", "like", "true", 100),
signed(t, acct, "cidA", "userRating", "5", 100),
signed(t, acct, "cidB", "seek", []byte("enc"), 100),
)
// The count the UI shows in the confirmation.
rec, stats := do(t, s, "GET", "/api/udl/users/stats", nil)
if rec.Code != http.StatusOK {
t.Fatalf("stats: %d %s", rec.Code, rec.Body)
}
users := stats["users"].(map[string]any)
mine := users[uid].(map[string]any)
if mine["records"].(float64) != 3 || mine["cids"].(float64) != 2 {
t.Fatalf("stats = %v, want 3 records / 2 cids", mine)
}
// Unsigned delete must be refused, and must not have touched anything.
rec, body := do(t, s, "DELETE", "/api/identity", map[string]any{"uid": uid, "confirm": true})
if rec.Code != http.StatusUnauthorized || body["error"] != "signature_required" {
t.Fatalf("unsigned delete = %d %v, want 401 signature_required", rec.Code, body)
}
if !identity.ExistsByUID(s.config.IdentityAccountsDir(), uid) {
t.Fatal("a refused delete removed the key anyway")
}
// meta-core must refuse to sign the caller's authorisation for them —
// without this the gate is decoration on a node holding every key.
ch, _, err := s.challenges.Issue(uid, identity.ActionDelete)
if err != nil {
t.Fatalf("issue: %v", err)
}
rec, body = do(t, s, "POST", "/api/identity/sign", map[string]any{
"uid": uid,
"bytesB64": base64.StdEncoding.EncodeToString([]byte(ch)),
})
if rec.Code != http.StatusForbidden || body["error"] != "reserved_payload" {
t.Fatalf("sign of a challenge = %d %v, want 403 reserved_payload", rec.Code, body)
}
// A challenge minted for reveal must not authorise a delete.
rec, chBody := do(t, s, "POST", "/api/identity/challenge", map[string]any{"uid": uid, "action": "reveal"})
if rec.Code != http.StatusOK {
t.Fatalf("challenge: %d %s", rec.Code, rec.Body)
}
proof := signAs(t, priv, chBody["challenge"].(string))
req := map[string]any{"uid": uid, "confirm": true}
for k, v := range proof {
req[k] = v
}
rec, body = do(t, s, "DELETE", "/api/identity", req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("reveal challenge used for delete = %d %v, want 401", rec.Code, body)
}
// The real thing.
rec, chBody = do(t, s, "POST", "/api/identity/challenge", map[string]any{"uid": uid, "action": "delete"})
if rec.Code != http.StatusOK {
t.Fatalf("challenge: %d %s", rec.Code, rec.Body)
}
proof = signAs(t, priv, chBody["challenge"].(string))
req = map[string]any{"uid": uid, "confirm": true}
for k, v := range proof {
req[k] = v
}
rec, body = do(t, s, "DELETE", "/api/identity", req)
if rec.Code != http.StatusOK {
t.Fatalf("signed delete = %d %s", rec.Code, rec.Body)
}
purged := body["purged"].(map[string]any)
if purged["records"].(float64) != 3 || purged["cids"].(float64) != 2 {
t.Fatalf("purged = %v, want 3 records / 2 cids", purged)
}
// Key gone.
if identity.ExistsByUID(s.config.IdentityAccountsDir(), uid) {
t.Fatal("the account file survived the delete")
}
// Every claim retracted (tombstones kept) — nothing left to orphan.
after, err := s.storage.UDLAllUserStats()
if err != nil {
t.Fatalf("stats after: %v", err)
}
if got, present := after[uid]; present {
t.Fatalf("deleted account still holds %+v", got)
}
for _, cid := range []string{"cidA", "cidB"} {
uids, err := s.storage.UDLCidUsers(cid)
if err != nil {
t.Fatalf("cid users: %v", err)
}
if len(uids) != 0 {
t.Fatalf("%s still names %v after the purge", cid, uids)
}
}
// The challenge is single-use, so replaying the same proof does nothing.
rec, _ = do(t, s, "DELETE", "/api/identity", req)
if rec.Code == http.StatusOK {
t.Fatal("a replayed proof must not succeed")
}
}
func TestE2E_RevealRequiresProof(t *testing.T) {
s := newTestServer(t)
_, gen := do(t, s, "POST", "/api/identity/generate", nil)
uid := gen["uid"].(string)
priv := gen["privateKeyHex"].(string)
rec, body := do(t, s, "POST", "/api/identity/reveal", map[string]any{"uid": uid, "confirm": true})
if rec.Code != http.StatusUnauthorized || body["error"] != "signature_required" {
t.Fatalf("unsigned reveal = %d %v, want 401 signature_required", rec.Code, body)
}
_, chBody := do(t, s, "POST", "/api/identity/challenge", map[string]any{"uid": uid, "action": "reveal"})
proof := signAs(t, priv, chBody["challenge"].(string))
req := map[string]any{"uid": uid, "confirm": true}
for k, v := range proof {
req[k] = v
}
rec, body = do(t, s, "POST", "/api/identity/reveal", req)
if rec.Code != http.StatusOK {
t.Fatalf("signed reveal = %d %s", rec.Code, rec.Body)
}
if body["privateKeyHex"] != priv {
t.Fatal("reveal returned a different key than generate did")
}
}
// Another account's key must not authorise this one, even with a challenge
// minted for the right uid and action.
func TestE2E_AnotherAccountsKeyIsRefused(t *testing.T) {
s := newTestServer(t)
_, alice := do(t, s, "POST", "/api/identity/generate", nil)
_, mallory := do(t, s, "POST", "/api/identity/generate", nil)
uid := alice["uid"].(string)
_, chBody := do(t, s, "POST", "/api/identity/challenge", map[string]any{"uid": uid, "action": "delete"})
proof := signAs(t, mallory["privateKeyHex"].(string), chBody["challenge"].(string))
req := map[string]any{"uid": uid, "confirm": true}
for k, v := range proof {
req[k] = v
}
rec, body := do(t, s, "DELETE", "/api/identity", req)
if rec.Code != http.StatusUnauthorized || body["error"] != "bad_signature" {
t.Fatalf("mallory deleting alice = %d %v, want 401 bad_signature", rec.Code, body)
}
if !identity.ExistsByUID(s.config.IdentityAccountsDir(), uid) {
t.Fatal("alice's account was deleted by mallory's signature")
}
}
+7 -38
View File
@@ -383,8 +383,9 @@ func (s *Server) handleKVValueGet(w http.ResponseWriter, r *http.Request) {
}) })
} }
// handleKVValuePut handles PUT /api/kv/value with body {key, value}. // handleKVValuePut handles PUT /api/kv/value with body {key, value}: service
// Maintains the file index when the key matches file:<hashId>/... // scratch KV only (btmagnet:*, searchcov:*), kept in the cache. Merged records
// and bookkeeping are refused — metadata is written as signed claims.
func (s *Server) handleKVValuePut(w http.ResponseWriter, r *http.Request) { func (s *Server) handleKVValuePut(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() { if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected") writeError(w, http.StatusServiceUnavailable, "storage not connected")
@@ -401,19 +402,11 @@ func (s *Server) handleKVValuePut(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.storage.SetRawValue(req.Key, req.Value); err != nil { if err := s.storage.SetServiceValue(req.Key, req.Value); err != nil {
writeError(w, http.StatusConflict, err.Error()) writeError(w, http.StatusConflict, err.Error())
return return
} }
if hashID := extractFileHashID(req.Key); hashID != "" {
if err := s.storage.IndexAdd(hashID); err != nil {
// Non-fatal: the value was written; index entry is best-effort.
// Surface in the log path of writeJSON via response header.
w.Header().Set("X-KV-Index-Warning", err.Error())
}
}
writeJSON(w, http.StatusOK, KVValueResponse{ writeJSON(w, http.StatusOK, KVValueResponse{
Key: req.Key, Key: req.Key,
Type: "string", Type: "string",
@@ -422,9 +415,7 @@ func (s *Server) handleKVValuePut(w http.ResponseWriter, r *http.Request) {
}) })
} }
// handleKVValueDelete handles DELETE /api/kv/value?key=... // handleKVValueDelete handles DELETE /api/kv/value?key=... (service keys only).
// When the key matches file:<hashId>/..., removes the file index entry if no
// sibling keys remain after the delete.
func (s *Server) handleKVValueDelete(w http.ResponseWriter, r *http.Request) { func (s *Server) handleKVValueDelete(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() { if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected") writeError(w, http.StatusServiceUnavailable, "storage not connected")
@@ -437,36 +428,14 @@ func (s *Server) handleKVValueDelete(w http.ResponseWriter, r *http.Request) {
return return
} }
deleted, err := s.storage.DeleteRawKey(key) deleted, err := s.storage.DeleteServiceValue(key)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusConflict, err.Error())
return return
} }
if deleted {
if hashID := extractFileHashID(key); hashID != "" {
if _, err := s.storage.IndexRemoveIfEmpty(hashID); err != nil {
w.Header().Set("X-KV-Index-Warning", err.Error())
}
}
}
writeJSON(w, http.StatusOK, KVValueDeleteResponse{ writeJSON(w, http.StatusOK, KVValueDeleteResponse{
Key: key, Key: key,
Deleted: deleted, Deleted: deleted,
}) })
} }
// extractFileHashID returns the hashID for a key shaped like file:<hashId>/...
// Returns "" when the key does not follow that convention.
func extractFileHashID(key string) string {
if !strings.HasPrefix(key, "file:") {
return ""
}
rest := strings.TrimPrefix(key, "file:")
slash := strings.Index(rest, "/")
if slash <= 0 {
return ""
}
return rest[:slash]
}
+48 -259
View File
@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@@ -39,6 +38,39 @@ type MetadataSearchRequest struct {
Property string `json:"property,omitempty"` Property string `json:"property,omitempty"`
PropertyValue string `json:"propertyValue,omitempty"` PropertyValue string `json:"propertyValue,omitempty"`
Limit int `json:"limit,omitempty"` Limit int `json:"limit,omitempty"`
// As is a viewer uid: the search sees that signer's own claims overlaid
// (and their records as candidates). Also accepted as ?as=.
As string `json:"as,omitempty"`
// Filters is the structured form: AND across entries, OR within an
// entry's Values. Narrowed in SQL through the field index alongside the
// free-text Query, then verified per record (see serveIndexedSearch), so an
// id-anchored lookup costs an index probe rather than a read per record.
//
// Property/PropertyValue is the single-filter legacy shape and folds into
// this list (see normalizedFilters) with Exact=false, preserving its
// substring semantics byte-for-byte.
Filters []MetadataFilter `json:"filters,omitempty"`
}
// MetadataFilter is one structured constraint on a record.
//
// A record satisfies it when ANY of Values matches, and a search result must
// satisfy EVERY filter (OR within, AND across). An entry with no usable value
// constrains nothing — same rule the gateway's `genres_filter_matches` uses, so
// a caller that lowers an empty filter doesn't accidentally empty the result.
type MetadataFilter struct {
// Property is the field name, WITHOUT a key-set suffix: `genres`, not
// `genres/Action`. Both storage shapes are probed — see recordMatchesFilter.
Property string `json:"property"`
// Values are OR'd. Compared case-insensitively.
Values []string `json:"values,omitempty"`
// Exact selects CSV-exact comparison (the semantics meta-gateway's
// `any_csv_value_matches` applies at its own Layer-B re-filter) over
// substring containment. Callers doing identity lookups want this: with a
// result limit, a substring match on `tmdbid=1405` also admits `21405` and
// can crowd the real matches out of the page.
Exact bool `json:"exact,omitempty"`
} }
// MetadataSearchResult is a single search result // MetadataSearchResult is a single search result
@@ -193,10 +225,10 @@ func (s *Server) handleGetMetadataByHashId(w http.ResponseWriter, r *http.Reques
return return
} }
// Same resolution as the PUT below: callers hold a CID, not the root. // Callers may hold a CID, not the root.
hashID = s.storage.ResolveRoot(hashID) hashID = s.storage.ResolveRoot(hashID)
metadata, err := s.storage.GetMetadataFlat(hashID) metadata, err := s.storage.GetMetadataFlatAs(hashID, r.URL.Query().Get("as"))
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
@@ -215,87 +247,6 @@ func (s *Server) handleGetMetadataByHashId(w http.ResponseWriter, r *http.Reques
writeJSON(w, http.StatusOK, response) writeJSON(w, http.StatusOK, response)
} }
// handleUpdateMetadataByHashId handles PUT /api/metadata/{hashId}
func (s *Server) handleUpdateMetadataByHashId(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hashId"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash ID is required")
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
// The {hashId} in the URL is whatever CID the caller knows the file by —
// the gateway's auto-store keys this PUT by the hash it just computed
// (dispatch.rs → store_metadata_only), which for a usenet→IPFS fetch is
// the dag-pb root, not the nzb-release cid the record was minted under.
// Without resolving, the write lands on file:<thatCid>/* and forks a
// second root for a file that already has one: a full duplicate record,
// cross-linked and separately indexed, which every downstream ingest then
// sees as two files. Unknown CIDs pass through unchanged, so a genuinely
// new gateway record still mints its root at the hash.
hashID = s.storage.ResolveRoot(hashID)
var metadata map[string]string
if err := json.NewDecoder(r.Body).Decode(&metadata); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
// Exclude internal processing status field
delete(metadata, "processingStatus")
if err := s.storage.SetMetadataFlat(hashID, metadata); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"hashId": hashID,
"message": "Metadata updated successfully",
})
}
// handleDeleteMetadataByHashId handles DELETE /api/metadata/{hashId}
func (s *Server) handleDeleteMetadataByHashId(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hashId"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash ID is required")
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
// meta-gateway retracts a dropped hit by whichever CID it holds
// (meta_core.rs `delete`, which documents this resolution as a
// precondition). Without it, deleting by a non-root CID is a silent
// no-op and the retracted record lingers.
hashID = s.storage.ResolveRoot(hashID)
deletedCount, err := s.storage.DeleteMetadata(hashID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"hashId": hashID,
"deletedKeys": deletedCount,
})
}
// handleMetadataGetProperty handles GET /api/metadata/{hashId}/property?property=X // handleMetadataGetProperty handles GET /api/metadata/{hashId}/property?property=X
func (s *Server) handleMetadataGetProperty(w http.ResponseWriter, r *http.Request) { func (s *Server) handleMetadataGetProperty(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
@@ -335,46 +286,6 @@ func (s *Server) handleMetadataGetProperty(w http.ResponseWriter, r *http.Reques
}) })
} }
// handleMetadataUpdateProperty handles PUT /api/metadata/{hashId}/property
func (s *Server) handleMetadataUpdateProperty(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hashID := vars["hashId"]
if hashID == "" {
writeError(w, http.StatusBadRequest, "hash ID is required")
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
var req PropertyUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if req.Property == "" {
writeError(w, http.StatusBadRequest, "property is required")
return
}
if err := s.storage.SetProperty(hashID, req.Property, req.Value); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"hashId": hashID,
"property": req.Property,
"value": req.Value,
"message": "Property updated successfully",
})
}
// handleSearchMetadata handles POST /api/metadata/search // handleSearchMetadata handles POST /api/metadata/search
func (s *Server) handleSearchMetadata(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSearchMetadata(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() { if !s.storage.IsConnected() {
@@ -391,6 +302,9 @@ func (s *Server) handleSearchMetadata(w http.ResponseWriter, r *http.Request) {
if req.Limit == 0 { if req.Limit == 0 {
req.Limit = 100 req.Limit = 100
} }
if req.As == "" {
req.As = r.URL.Query().Get("as")
}
// If hashId is provided, do exact match // If hashId is provided, do exact match
if req.HashID != "" { if req.HashID != "" {
@@ -415,142 +329,17 @@ func (s *Server) handleSearchMetadata(w http.ResponseWriter, r *http.Request) {
return return
} }
// Free-text query: served from the in-memory search index (search_index.go) // Everything else — free text, structured filters, both, or neither
// so we match in memory instead of doing a SCAN+MGET Redis round-trip per // (which returns the first `limit`) — is served from the store's derived
// record (that was 40s+ over ~72k records). Full metadata is fetched only // search indexes (search_index.go → storage.Search).
// for the <=limit matches. //
if req.Query != "" && req.Property == "" { // There is deliberately no second path. The property branch used to fall
// through to a `GetAllHashIDs` + per-record `GetMetadataFlat` scan, which
// is what made an id-anchored lookup 3.63s over ~20k records (vs 10ms for
// the free-text branch on the same corpus) and kept meta-core out of the
// gateway's anchored search path entirely. The index already holds every
// field of every record, so that scan was re-reading data it had in hand.
s.serveIndexedSearch(w, req) s.serveIndexedSearch(w, req)
return
}
// Property filter (rare) or empty query (returns the first `limit`): the
// legacy scan. Empty query only fetches `limit` records; property search is
// low-volume and not on the hot search path.
allHashIDs, err := s.storage.GetAllHashIDs()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
var results []MetadataSearchResult
total := len(allHashIDs)
for _, hashID := range allHashIDs {
if len(results) >= req.Limit {
break
}
metadata, err := s.storage.GetMetadataFlat(hashID)
if err != nil || metadata == nil {
continue
}
matches := false
// Property search
if req.Property != "" && req.PropertyValue != "" {
value, ok := metadata[req.Property]
if ok && strings.Contains(strings.ToLower(value), strings.ToLower(req.PropertyValue)) {
matches = true
}
}
// If no query or property filter, return all
if req.Query == "" && req.Property == "" {
matches = true
}
if matches {
results = append(results, MetadataSearchResult{
HashID: hashID,
Metadata: metadata,
})
}
}
writeJSON(w, http.StatusOK, MetadataSearchResponse{
Results: results,
Count: len(results),
Total: total,
})
}
// handleBatchUpdate handles POST /api/metadata/batch
func (s *Server) handleBatchUpdate(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
var req MetadataBatchUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if len(req.Updates) == 0 {
writeError(w, http.StatusBadRequest, "updates array is required")
return
}
var results []MetadataBatchResult
successCount := 0
errorCount := 0
for _, update := range req.Updates {
if update.HashID == "" {
results = append(results, MetadataBatchResult{
HashID: "unknown",
Status: "error",
Error: "Missing hash ID",
})
errorCount++
continue
}
var updateErr error
// Update complete metadata
if update.Metadata != nil {
// Exclude internal processing status
delete(update.Metadata, "processingStatus")
updateErr = s.storage.SetMetadataFlat(update.HashID, update.Metadata)
}
// Update individual properties
if updateErr == nil && update.Properties != nil {
for property, value := range update.Properties {
if err := s.storage.SetProperty(update.HashID, property, value); err != nil {
updateErr = err
break
}
}
}
if updateErr != nil {
results = append(results, MetadataBatchResult{
HashID: update.HashID,
Status: "error",
Error: updateErr.Error(),
})
errorCount++
} else {
results = append(results, MetadataBatchResult{
HashID: update.HashID,
Status: "ok",
})
successCount++
}
}
writeJSON(w, http.StatusOK, MetadataBatchResponse{
Status: "completed",
Total: len(results),
Success: successCount,
Errors: errorCount,
Results: results,
})
} }
// handleClearMetadata handles POST /api/metadata/clear // handleClearMetadata handles POST /api/metadata/clear
+204
View File
@@ -0,0 +1,204 @@
package api
// Report inbox (/api/reports): bug reports and feedback filed from the client
// apps. An app's browser builds the report, the app's server forwards it here
// server-to-server, and the dashboard's Reports page reads it back. Storage
// and bounds live in internal/reports.
//
// Auth: like the rest of /api, gated at the perimeter — do NOT add to the
// nginx-hash-lock unauth bypass. Apps reach meta-core on the internal network.
import (
"encoding/json"
"errors"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/metazla/meta-core/internal/reports"
)
// maxReportBody bounds one POST. A report is at most a few blocks of 32 KiB
// plus a 200-line log tail; anything larger is not a report.
const maxReportBody = 512 << 10
// SetReports attaches the report inbox. Separate from NewServer so its
// signature (and its tests) stay untouched; nil leaves the routes answering
// 503.
func (s *Server) SetReports(r *reports.Store) {
s.reports = r
}
func (s *Server) setupReportRoutes() {
s.router.HandleFunc("/api/reports", s.handlePostReport).Methods("POST")
s.router.HandleFunc("/api/reports", s.handleListReports).Methods("GET")
s.router.HandleFunc("/api/reports/{id:[0-9]+}", s.handleGetReport).Methods("GET")
s.router.HandleFunc("/api/reports/{id:[0-9]+}", s.handlePatchReport).Methods("PATCH")
s.router.HandleFunc("/api/reports/{id:[0-9]+}", s.handleDeleteReport).Methods("DELETE")
}
func (s *Server) reportsReady(w http.ResponseWriter) bool {
if s.reports == nil {
writeError(w, http.StatusServiceUnavailable, "report inbox not available")
return false
}
return true
}
func reportID(r *http.Request) int64 {
id, _ := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
return id
}
// handlePostReport handles POST /api/reports → 201 {id}.
func (s *Server) handlePostReport(w http.ResponseWriter, r *http.Request) {
if !s.reportsReady(w) {
return
}
var in map[string]json.RawMessage
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxReportBody)).Decode(&in); err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
writeError(w, http.StatusRequestEntityTooLarge, "report larger than 512 KiB")
return
}
writeError(w, http.StatusBadRequest, "body must be a JSON object")
return
}
rep, err := reports.Normalize(in, time.Now())
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
id, err := s.reports.Insert(rep)
if err != nil {
log.Printf("[reports] insert failed: %v", err)
writeError(w, http.StatusInternalServerError, "could not store the report")
return
}
log.Printf("[reports] #%d filed by %s (%s)", id, rep.App, firstLine(rep.Note))
writeJSON(w, http.StatusCreated, map[string]int64{"id": id})
}
// handleListReports handles GET /api/reports?app=&status=&q=&before=&limit=.
func (s *Server) handleListReports(w http.ResponseWriter, r *http.Request) {
if !s.reportsReady(w) {
return
}
q := r.URL.Query()
f := reports.Filter{App: q.Get("app"), Status: q.Get("status"), Q: q.Get("q")}
f.Before, _ = strconv.ParseInt(q.Get("before"), 10, 64)
f.Limit, _ = strconv.Atoi(q.Get("limit"))
if f.Status != "" && !reports.ValidStatus(f.Status) {
writeError(w, http.StatusBadRequest, "status must be open, triaged or closed")
return
}
page, err := s.reports.List(f)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, page)
}
// handleGetReport handles GET /api/reports/{id}.
func (s *Server) handleGetReport(w http.ResponseWriter, r *http.Request) {
if !s.reportsReady(w) {
return
}
rep, err := s.reports.Get(reportID(r))
if errors.Is(err, reports.ErrNotFound) {
writeError(w, http.StatusNotFound, "no such report")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, rep)
}
// handlePatchReport handles PATCH /api/reports/{id} {status?, triage_note?}.
func (s *Server) handlePatchReport(w http.ResponseWriter, r *http.Request) {
if !s.reportsReady(w) {
return
}
var in struct {
Status *string `json:"status"`
TriageNote *string `json:"triage_note"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "body must be a JSON object")
return
}
if in.Status == nil && in.TriageNote == nil {
writeError(w, http.StatusBadRequest, "nothing to change: send status and/or triage_note")
return
}
if in.Status != nil && !reports.ValidStatus(*in.Status) {
writeError(w, http.StatusBadRequest, "status must be open, triaged or closed")
return
}
rep, err := s.reports.Update(reportID(r), in.Status, in.TriageNote)
if errors.Is(err, reports.ErrNotFound) {
writeError(w, http.StatusNotFound, "no such report")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, rep)
}
// handleDeleteReport handles DELETE /api/reports/{id}.
func (s *Server) handleDeleteReport(w http.ResponseWriter, r *http.Request) {
if !s.reportsReady(w) {
return
}
err := s.reports.Delete(reportID(r))
if errors.Is(err, reports.ErrNotFound) {
writeError(w, http.StatusNotFound, "no such report")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// pruneReports re-applies the inbox bounds hourly. Insert already prunes, so
// this only matters when a bound was lowered between restarts.
func (s *Server) pruneReports() {
run := func() {
if n, err := s.reports.Prune(); err != nil {
log.Printf("[reports] prune failed: %v", err)
} else if n > 0 {
log.Printf("[reports] pruned %d report(s) over the inbox bounds", n)
}
}
run()
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for range ticker.C {
run()
}
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[:i]
}
if r := []rune(s); len(r) > 80 {
s = string(r[:80]) + "…"
}
if s == "" {
return "no note"
}
return strconv.Quote(s)
}
+121
View File
@@ -0,0 +1,121 @@
package api
import (
"bytes"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/gorilla/mux"
"github.com/metazla/meta-core/internal/reports"
)
func newReportServer(t *testing.T) *Server {
t.Helper()
store, err := reports.Open(filepath.Join(t.TempDir(), "reports.db"), 0, 0)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
s := &Server{router: mux.NewRouter(), reports: store}
s.setupReportRoutes()
return s
}
func TestReportsRoundTrip(t *testing.T) {
s := newReportServer(t)
rec, out := do(t, s, "POST", "/api/reports", map[string]any{
"app": "meta-read", "note": "blank page", "uid": "u1",
"reader": map[string]any{"page": 3}, "log": []string{"a", "b"},
})
if rec.Code != http.StatusCreated || out["id"] == nil {
t.Fatalf("post: %d %s", rec.Code, rec.Body)
}
id := int64(out["id"].(float64))
rec, out = do(t, s, "GET", "/api/reports", nil)
list, _ := out["reports"].([]any)
if rec.Code != 200 || len(list) != 1 {
t.Fatalf("list: %d %s", rec.Code, rec.Body)
}
first := list[0].(map[string]any)
if _, hasBody := first["body"]; hasBody || first["log_lines"].(float64) != 2 {
t.Fatalf("summary should carry log_lines, not the body: %v", first)
}
path := "/api/reports/" + itoa(id)
rec, out = do(t, s, "GET", path, nil)
body, _ := out["body"].(map[string]any)
if rec.Code != 200 || body["reader"] == nil || out["uid"] != "u1" {
t.Fatalf("get: %d %s", rec.Code, rec.Body)
}
rec, out = do(t, s, "PATCH", path, map[string]any{"status": "triaged", "triage_note": "Deck #1"})
if rec.Code != 200 || out["status"] != "triaged" || out["triage_note"] != "Deck #1" {
t.Fatalf("patch: %d %s", rec.Code, rec.Body)
}
rec, _ = do(t, s, "GET", "/api/reports?status=triaged&app=meta-read", nil)
if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"triaged":1`) {
t.Fatalf("filtered list: %d %s", rec.Code, rec.Body)
}
if rec, _ = do(t, s, "DELETE", path, nil); rec.Code != http.StatusNoContent {
t.Fatalf("delete: %d", rec.Code)
}
if rec, _ = do(t, s, "GET", path, nil); rec.Code != http.StatusNotFound {
t.Fatalf("get after delete: %d", rec.Code)
}
}
func TestReportsRejects(t *testing.T) {
s := newReportServer(t)
cases := []struct {
name, method, path string
body any
want int
}{
{"no app", "POST", "/api/reports", map[string]any{"note": "x"}, 400},
{"bad status filter", "GET", "/api/reports?status=done", nil, 400},
{"patch nothing", "PATCH", "/api/reports/1", map[string]any{}, 400},
{"patch bad status", "PATCH", "/api/reports/1", map[string]any{"status": "done"}, 400},
{"patch missing", "PATCH", "/api/reports/1", map[string]any{"status": "closed"}, 404},
{"delete missing", "DELETE", "/api/reports/1", nil, 404},
}
for _, c := range cases {
if rec, _ := do(t, s, c.method, c.path, c.body); rec.Code != c.want {
t.Errorf("%s: got %d want %d (%s)", c.name, rec.Code, c.want, rec.Body)
}
}
// Not a JSON object.
req := httptest.NewRequest("POST", "/api/reports", strings.NewReader(`[1,2]`))
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, req)
if rec.Code != 400 {
t.Errorf("array body: %d", rec.Code)
}
// Over the body cap.
big := bytes.Repeat([]byte("x"), maxReportBody+1)
req = httptest.NewRequest("POST", "/api/reports", strings.NewReader(`{"app":"a","note":"`+string(big)+`"}`))
rec = httptest.NewRecorder()
s.router.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("oversized body: %d", rec.Code)
}
}
func TestReportsUnavailableWithoutStore(t *testing.T) {
s := &Server{router: mux.NewRouter()}
s.setupReportRoutes()
if rec, _ := do(t, s, "GET", "/api/reports", nil); rec.Code != http.StatusServiceUnavailable {
t.Fatalf("no store: %d", rec.Code)
}
}
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
+17 -14
View File
@@ -3,43 +3,46 @@ package api
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"time"
) )
// handleSchemaGet returns the live per-field schema derived from meta:events. // handleSchemaGet returns the per-field schema, folded from counters the write
// path keeps exact (see schema.Indexer).
func (s *Server) handleSchemaGet(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSchemaGet(w http.ResponseWriter, r *http.Request) {
if s.schemaIndexer == nil { if s.schemaIndexer == nil {
http.Error(w, "schema indexer not running", http.StatusServiceUnavailable) http.Error(w, "schema indexer not running", http.StatusServiceUnavailable)
return return
} }
snap := s.schemaIndexer.Snapshot() snap, err := s.schemaIndexer.Snapshot(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(snap) _ = json.NewEncoder(w).Encode(snap)
} }
// handleSchemaRescan re-emits a set event for every existing property via the // handleSchemaRescan rebuilds the schema counters from a full read of the
// meta publisher, waits for the indexer to drain, and returns the refreshed // records and returns the result. The counters are exact by construction, so
// schema. Equivalent to a full SCAN-based rebuild without locking Redis. // this is a repair tool, not routine: it runs as one write transaction, and
// every other write waits for it (minutes on a large store).
func (s *Server) handleSchemaRescan(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSchemaRescan(w http.ResponseWriter, r *http.Request) {
if s.schemaIndexer == nil { if s.schemaIndexer == nil {
http.Error(w, "schema indexer not running", http.StatusServiceUnavailable) http.Error(w, "schema indexer not running", http.StatusServiceUnavailable)
return return
} }
if err := s.storage.RebuildCounters(); err != nil {
count, err := s.RepublishMetadata() http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
snap, err := s.schemaIndexer.Snapshot(r.Context())
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
s.schemaIndexer.WaitForDrain(10 * time.Second)
snap := s.schemaIndexer.Snapshot()
snap.Source = "rescan" snap.Source = "rescan"
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"published_events": count, "published_events": 0,
"schema": snap, "schema": snap,
}) })
} }
+211
View File
@@ -0,0 +1,211 @@
package api
import "testing"
// Tests for the structured-filter matcher that lets meta-core answer an
// id-anchored query from the in-memory search index (search_index.go).
//
// Why this matters beyond "filters work": meta-core is a *coarse pre-filter*
// for meta-gateway, which re-evaluates every record it emits through its own
// `query_eval::record_matches`. So the property that has to hold is that this
// matcher stays a SUPERSET of that one — it may admit records the gateway will
// drop, but it must never drop a record the gateway would have kept. Every
// case below is written from that angle:
//
// - key-set fields (`genres/Action`) must match a `genres:action` filter, or
// card-tier records vanish from a genre-scoped query;
// - a missing field fails CLOSED, matching record_matches;
// - an empty/unusable filter constrains NOTHING, so a caller that lowers a
// blank filter gets its results rather than an empty page.
func f(property string, exact bool, values ...string) MetadataFilter {
return MetadataFilter{Property: property, Values: values, Exact: exact}
}
func TestRecordMatchesFilter(t *testing.T) {
fields := map[string]string{
"fileType": "video",
"videoType": "tv,anime",
"tmdbid": "1405",
"genres/Action": "true",
"genres/Science Fiction": "true",
"source/gateway:nyaa.si": "true",
"categories/XXX": "true",
"retracted": "false",
}
cases := []struct {
name string
filter MetadataFilter
want bool
}{
// Flat scalar.
{"flat exact hit", f("fileType", true, "video"), true},
{"flat exact miss", f("fileType", true, "audio"), false},
{"flat exact is case-insensitive", f("fileType", true, "VIDEO"), true},
// csv-set field: each comma piece is compared independently.
{"csv member hit", f("videoType", true, "anime"), true},
{"csv member miss", f("videoType", true, "movie"), false},
// Exact vs contains — the reason Exact exists. A substring match on an
// identity field admits neighbours and, under a result limit, crowds
// the real matches off the page.
{"contains admits a superstring", f("tmdbid", false, "140"), true},
{"exact rejects a superstring", f("tmdbid", true, "140"), false},
{"exact accepts the whole value", f("tmdbid", true, "1405"), true},
// Key-set shape. The stored member is verbatim (`genres/Action`) while
// query values arrive lowercased, so the case-insensitive sweep is what
// makes a genre-scoped row work at all.
{"key-set exact casing", f("genres", true, "Action"), true},
{"key-set lowercased query", f("genres", true, "action"), true},
{"key-set with a space", f("genres", true, "science fiction"), true},
{"key-set miss", f("genres", true, "horror"), false},
{"key-set label containing a colon", f("source", true, "gateway:nyaa.si"), true},
// OR within one filter's values.
{"or hits on the second value", f("fileType", true, "audio", "video"), true},
{"or misses on all values", f("fileType", true, "audio", "image"), false},
// Fail-closed on an absent field — mirrors record_matches, and is what
// keeps a filtered query from returning unrelated records.
{"absent field fails closed", f("imdbid", true, "tt0903747"), false},
// A filter that constrains nothing must not empty the result.
{"no values constrains nothing", f("fileType", true), true},
{"blank value constrains nothing", f("fileType", true, " "), true},
{"no property constrains nothing", f("", true, "video"), true},
// A FLAT field whose value happens to be "false" is still a value, and
// an exact filter for it matches. Only the key-set arm treats "false"
// as non-membership (pinned in the test below).
{"flat field valued false still matches", f("retracted", true, "false"), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := recordMatchesFilter(fields, &tc.filter); got != tc.want {
t.Fatalf("recordMatchesFilter(%+v) = %v, want %v", tc.filter, got, tc.want)
}
})
}
}
// `retracted = "false"` is a flat field, not a key-set member, so the flat arm
// legitimately matches the literal string "false". The key-set arm is what must
// treat it as non-membership — pinned separately so the two arms don't get
// conflated if either is refactored.
func TestFalseyKeySetMemberIsNotMembership(t *testing.T) {
fields := map[string]string{"genres/Action": "false", "genres/Drama": "0"}
for _, v := range []string{"Action", "Drama"} {
filter := f("genres", true, v)
if recordMatchesFilter(fields, &filter) {
t.Fatalf("genres/%s with a falsey value must not assert membership", v)
}
}
}
func TestRecordMatchesFiltersIsAndAcrossEntries(t *testing.T) {
fields := map[string]string{"fileType": "video", "season": "2"}
all := []MetadataFilter{f("fileType", true, "video"), f("season", true, "2")}
if !recordMatchesFilters(fields, all) {
t.Fatal("a record satisfying every filter must match")
}
one := []MetadataFilter{f("fileType", true, "video"), f("season", true, "3")}
if recordMatchesFilters(fields, one) {
t.Fatal("filters are AND across entries — one miss must reject the record")
}
if !recordMatchesFilters(fields, nil) {
t.Fatal("no filters must match everything")
}
}
// The legacy Property/PropertyValue pair keeps its substring semantics: it is
// the shape existing callers already use, and tightening it silently would
// change results for them. New callers pass Filters and opt into Exact.
func TestNormalizedFiltersFoldsTheLegacyPair(t *testing.T) {
got := normalizedFilters(MetadataSearchRequest{Property: "tmdbid", PropertyValue: "1405"})
if len(got) != 1 {
t.Fatalf("legacy pair must fold into exactly one filter, got %d", len(got))
}
if got[0].Property != "tmdbid" || len(got[0].Values) != 1 || got[0].Values[0] != "1405" {
t.Fatalf("legacy pair folded incorrectly: %+v", got[0])
}
if got[0].Exact {
t.Fatal("the legacy pair must stay substring (Exact=false) for compatibility")
}
// A half-specified pair is not a filter — it must not become one that
// matches nothing and empties the response.
if n := len(normalizedFilters(MetadataSearchRequest{Property: "tmdbid"})); n != 0 {
t.Fatalf("a property with no value must not fold into a filter, got %d", n)
}
if n := len(normalizedFilters(MetadataSearchRequest{PropertyValue: "1405"})); n != 0 {
t.Fatalf("a value with no property must not fold into a filter, got %d", n)
}
both := normalizedFilters(MetadataSearchRequest{
Property: "tmdbid",
PropertyValue: "1405",
Filters: []MetadataFilter{f("fileType", true, "video")},
})
if len(both) != 2 {
t.Fatalf("the legacy pair must be appended to explicit filters, got %d", len(both))
}
}
// The acceptance bar for routing the anchored path through the index: a
// full-miss filtered sweep over a realistic corpus must stay in the same class
// as the free-text branch (measured 10 ms over 19 622 live records), not the
// legacy per-record Redis scan it replaces (measured 3.63 s over the same
// corpus). Full-miss is the worst case — the loop cannot short-circuit on the
// result limit, so every record is evaluated.
//
// Run: go test ./internal/api/ -bench BenchmarkFilterSweep -benchtime 10x
func BenchmarkFilterSweep(b *testing.B) {
const records = 20000
corpus := make([]map[string]string, records)
for i := range corpus {
corpus[i] = map[string]string{
"title": "Some Release",
"fileType": "video",
"videoType": "tv",
"tmdbid": "1405",
"season": "2",
"genres/Action": "true",
"genres/Drama": "true",
"source/gateway:nyaa.si": "true",
"categories/TV/Anime": "true",
"indexer": "nyaa.si",
"fileName": "some.release.s02e05.mkv",
"description/eng": "text",
"poster": "bafy",
"sizeByte": "123456789",
"contentKind": "episode",
"anchoredBy/tmdb:1405": "true",
}
}
// A miss on the identity field: the shape an id-anchored gateway query
// takes, and the one that must not degrade.
filters := []MetadataFilter{
f("tmdbid", true, "999999"),
f("fileType", true, "video"),
f("genres", true, "action"),
}
b.ResetTimer()
for n := 0; n < b.N; n++ {
hits := 0
for _, fields := range corpus {
if recordMatchesFilters(fields, filters) {
hits++
}
}
if hits != 0 {
b.Fatalf("expected a full miss, got %d hits", hits)
}
}
}
+149 -79
View File
@@ -1,100 +1,59 @@
package api package api
import ( import (
"log"
"net/http" "net/http"
"strings" "strings"
"sync"
"sync/atomic"
"time"
"github.com/metazla/meta-core/internal/storage" "github.com/metazla/meta-core/internal/storage"
) )
// searchIndexRefreshInterval is how often the in-memory search index is rebuilt // serveIndexedSearch answers a search from the database's derived indexes
// from Redis in the background. Records ingest continuously, so a newly stored // (storage.Search): SQL narrows to a superset of candidates through the field
// record becomes searchable within one interval; the rebuild is a single // expression index and the FTS5 trigram table, then every candidate is checked
// batched bulk read (storage.GetSearchIndexTuples), off the request path. // here with the exact matchers below — a token-AND match against the record's
const searchIndexRefreshInterval = 60 * time.Second // haystack (or a whole-query hashID substring, preserving the legacy
// behaviour) AND every structured filter.
// searchIndex is an in-memory snapshot of every record's search haystack //
// (hashID + lowercased searchable fields), rebuilt periodically from Redis. It // Until 2026-10 this ran against an in-memory copy of every record, rebuilt
// exists so POST /api/metadata/search matches in memory (string ops over a // from Redis; that copy cost ~30 KB of RAM per record (5.1 GB on watch.nsl.sh)
// slice) instead of doing a SCAN+MGET Redis round-trip per record — the latter // and is gone. The matchers did not change, so neither did the semantics.
// took 40s+ over ~72k records and made a live parallel search path unusable. //
type searchIndex struct { // This is the ONLY search path. Free text, filters, both, or neither all land
mu sync.RWMutex // here; only the `hashId` exact-match branch in handleSearchMetadata bypasses it.
tuples []storage.SearchTuple
ready atomic.Bool
}
// snapshot returns the current tuples slice and whether the index has built at
// least once. The refresh swaps in a fresh slice under the write lock (never
// mutates in place), so the returned slice is safe to read lock-free.
func (idx *searchIndex) snapshot() ([]storage.SearchTuple, bool) {
idx.mu.RLock()
defer idx.mu.RUnlock()
return idx.tuples, idx.ready.Load()
}
// refresh rebuilds the index from Redis once (a batched bulk read).
func (idx *searchIndex) refresh(stor *storage.Client) {
if stor == nil || !stor.IsConnected() {
return
}
tuples, err := stor.GetSearchIndexTuples()
if err != nil {
log.Printf("[API] search index refresh failed: %v", err)
return
}
idx.mu.Lock()
idx.tuples = tuples
idx.mu.Unlock()
idx.ready.Store(true)
}
// runRefreshLoop builds the index immediately, then rebuilds on a ticker for the
// process lifetime.
func (idx *searchIndex) runRefreshLoop(stor *storage.Client) {
idx.refresh(stor)
ticker := time.NewTicker(searchIndexRefreshInterval)
defer ticker.Stop()
for range ticker.C {
idx.refresh(stor)
}
}
// serveIndexedSearch answers a free-text (non-property) query from the in-memory
// index: token-AND match against each record's haystack (or a whole-query hashID
// substring, preserving the legacy behaviour), then fetch full metadata for only
// the <=limit matches. No Redis round-trip per scanned record.
func (s *Server) serveIndexedSearch(w http.ResponseWriter, req MetadataSearchRequest) { func (s *Server) serveIndexedSearch(w http.ResponseWriter, req MetadataSearchRequest) {
tuples, ready := s.search.snapshot()
if !ready {
// First call before the background build finished: warm inline with the
// efficient batched read (seconds), never the per-record SCAN fallback.
s.search.refresh(s.storage)
tuples, _ = s.search.snapshot()
}
tokens := strings.Fields(strings.ToLower(req.Query)) tokens := strings.Fields(strings.ToLower(req.Query))
queryLower := strings.ToLower(req.Query) queryLower := strings.ToLower(req.Query)
results := make([]MetadataSearchResult, 0, req.Limit) filters := normalizedFilters(req)
for _, t := range tuples {
if len(results) >= req.Limit { narrow := make([]storage.SearchFilter, 0, len(filters))
break for _, f := range filters {
narrow = append(narrow, storage.SearchFilter{Property: f.Property, Values: f.Values})
} }
if !matchAllTokens(t.Haystack, tokens) && hits, err := s.storage.SearchAs(req.Query, narrow, req.Limit, req.As, func(hashID string, fields map[string]string) bool {
!strings.Contains(strings.ToLower(t.HashID), queryLower) { if req.Query != "" &&
continue !matchAllTokens(storage.Haystack(fields), tokens) &&
!strings.Contains(strings.ToLower(hashID), queryLower) {
return false
} }
// Full metadata is cached in the index — return it straight from memory, return recordMatchesFilters(fields, filters)
// no Redis round-trip per result. })
results = append(results, MetadataSearchResult{HashID: t.HashID, Metadata: t.Fields}) if err != nil {
writeError(w, http.StatusInternalServerError, "search failed: "+err.Error())
return
}
total, err := s.storage.CountRoots()
if err != nil {
total = 0
}
results := make([]MetadataSearchResult, 0, len(hits))
for _, h := range hits {
results = append(results, MetadataSearchResult{HashID: h.HashID, Metadata: h.Fields})
} }
writeJSON(w, http.StatusOK, MetadataSearchResponse{ writeJSON(w, http.StatusOK, MetadataSearchResponse{
Results: results, Results: results,
Count: len(results), Count: len(results),
Total: len(tuples), Total: int(total),
}) })
} }
@@ -108,3 +67,114 @@ func matchAllTokens(haystack string, tokens []string) bool {
} }
return true return true
} }
// normalizedFilters returns the request's structured filters with the legacy
// Property/PropertyValue pair folded in as one more entry.
//
// The legacy pair keeps Exact=false so its long-standing substring semantics
// are unchanged for existing callers; new callers pass Filters and choose.
func normalizedFilters(req MetadataSearchRequest) []MetadataFilter {
if req.Property == "" || req.PropertyValue == "" {
return req.Filters
}
out := make([]MetadataFilter, 0, len(req.Filters)+1)
out = append(out, req.Filters...)
return append(out, MetadataFilter{
Property: req.Property,
Values: []string{req.PropertyValue},
})
}
// recordMatchesFilters reports whether fields satisfies every filter (AND).
func recordMatchesFilters(fields map[string]string, filters []MetadataFilter) bool {
for i := range filters {
if !recordMatchesFilter(fields, &filters[i]) {
return false
}
}
return true
}
// recordMatchesFilter reports whether fields satisfies one filter (OR within
// its values), probing BOTH storage shapes a field can take:
//
// - a flat scalar/csv-set field — `fileType = "video"`, `videoType = "tv,anime"`;
// - a key-set member — `genres/Action = "true"`, `source/gateway:nyaa.si = "true"`
// (METADATA_KEYS "How the store is shaped").
//
// Probing both is what lets one filter shape serve the whole vocabulary. The
// gateway needs a `genres:action` filter to find a record whose genre lives in
// the key `genres/Action`, and a `fileType:video` filter to find one whose value
// lives in the field `fileType` — without meta-core knowing which fields are
// key-sets. It also means this stays a SUPERSET of the gateway's own
// `record_matches`, which is the contract: meta-core pre-filters, the gateway
// re-filters authoritatively.
//
// A filter with no usable value constrains nothing (matches). A filter whose
// field is absent fails closed, matching `record_matches`.
func recordMatchesFilter(fields map[string]string, f *MetadataFilter) bool {
values := make([]string, 0, len(f.Values))
for _, v := range f.Values {
if v = strings.TrimSpace(v); v != "" {
values = append(values, v)
}
}
if f.Property == "" || len(values) == 0 {
return true
}
// Key-set shape: `<property>/<value>` present and truthy. Try the exact
// key first — that hits whenever the caller's casing matches what was
// written, which is the common case and costs one map probe.
for _, v := range values {
if isTruthyMember(fields[f.Property+"/"+v]) {
return true
}
}
// Members are written verbatim (`genres/Action`) while query values arrive
// lowercased, so fall back to one case-insensitive sweep. Single pass over
// the record's fields for ALL values, not one pass per value.
wanted := make(map[string]struct{}, len(values))
for _, v := range values {
wanted[strings.ToLower(v)] = struct{}{}
}
prefix := strings.ToLower(f.Property) + "/"
for k, val := range fields {
if len(k) <= len(prefix) || !strings.EqualFold(k[:len(prefix)], prefix) {
continue
}
if _, ok := wanted[strings.ToLower(k[len(prefix):])]; ok && isTruthyMember(val) {
return true
}
}
// Flat shape: the field's own value, split on `,` for csv-set fields.
fieldValue, ok := fields[f.Property]
if !ok {
return false
}
for _, piece := range strings.Split(fieldValue, ",") {
piece = strings.TrimSpace(piece)
if piece == "" {
continue
}
for _, v := range values {
if f.Exact {
if strings.EqualFold(piece, v) {
return true
}
} else if strings.Contains(strings.ToLower(piece), strings.ToLower(v)) {
return true
}
}
}
return false
}
// isTruthyMember reports whether a key-set member's value asserts membership.
// Mirrors the gateway's `is_truthy_member`: presence with a non-empty,
// non-"false"/"0" value.
func isTruthyMember(v string) bool {
v = strings.TrimSpace(strings.ToLower(v))
return v != "" && v != "false" && v != "0"
}
+97 -117
View File
@@ -9,11 +9,11 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/metazla/meta-core/internal/config" "github.com/metazla/meta-core/internal/config"
"github.com/metazla/meta-core/internal/discovery"
"github.com/metazla/meta-core/internal/events"
"github.com/metazla/meta-core/internal/identity" "github.com/metazla/meta-core/internal/identity"
"github.com/metazla/meta-core/internal/leader" "github.com/metazla/meta-core/internal/leader"
"github.com/metazla/meta-core/internal/meshdisco"
"github.com/metazla/meta-core/internal/mounts" "github.com/metazla/meta-core/internal/mounts"
"github.com/metazla/meta-core/internal/reports"
"github.com/metazla/meta-core/internal/schema" "github.com/metazla/meta-core/internal/schema"
"github.com/metazla/meta-core/internal/storage" "github.com/metazla/meta-core/internal/storage"
"github.com/metazla/meta-core/internal/watcher" "github.com/metazla/meta-core/internal/watcher"
@@ -25,8 +25,6 @@ import (
type Server struct { type Server struct {
config *config.Config config *config.Config
leaderProvider *leader.LeaderInfoProvider leaderProvider *leader.LeaderInfoProvider
discovery *discovery.Service
cleaner *discovery.Cleaner
storage *storage.Client storage *storage.Client
mountsManager *mounts.Manager mountsManager *mounts.Manager
mountsHandlers *mounts.Handlers mountsHandlers *mounts.Handlers
@@ -38,37 +36,50 @@ type Server struct {
watchersManager *watchers.Manager watchersManager *watchers.Manager
watchersPoller *watchers.Poller watchersPoller *watchers.Poller
watchersHandlers *watchers.Handlers watchersHandlers *watchers.Handlers
metaPublisher *events.MetaPublisher
schemaIndexer *schema.Indexer schemaIndexer *schema.Indexer
webdavHandler *webdav.Handler webdavHandler *webdav.Handler
search *searchIndex // challenges backs proof-of-possession on reveal/delete. In-process, so a
// restart invalidates the ones in flight — see identity/challenge.go.
challenges *identity.ChallengeStore
// mesh is the UDP discovery node (beacon v2). nil when discovery
// is disabled or failed to bind, in which case /api/neighbors reports an
// empty list rather than erroring — a dashboard nav is decoration and
// must not take the API down with it.
mesh *meshdisco.Node
router *mux.Router router *mux.Router
server *http.Server server *http.Server
// reports is the bug-report inbox (reports.go); nil = routes answer 503.
reports *reports.Store
// stats caches the keyspace-walk half of GET /api/stats so a polling
// dashboard never puts a multi-second store walk on the request path.
stats *statsCache
}
// SetMeshNode attaches the UDP discovery node. Separate from NewServer so the
// server's constructor signature (and its tests) stay untouched.
func (s *Server) SetMeshNode(n *meshdisco.Node) {
s.mesh = n
} }
// NewServer creates a new API server // NewServer creates a new API server
func NewServer( func NewServer(
cfg *config.Config, cfg *config.Config,
leaderProvider *leader.LeaderInfoProvider, leaderProvider *leader.LeaderInfoProvider,
disc *discovery.Service,
cleaner *discovery.Cleaner,
stor *storage.Client, stor *storage.Client,
) *Server { ) *Server {
s := &Server{ s := &Server{
config: cfg, config: cfg,
leaderProvider: leaderProvider, leaderProvider: leaderProvider,
discovery: disc,
cleaner: cleaner,
storage: stor, storage: stor,
search: &searchIndex{},
router: mux.NewRouter(), router: mux.NewRouter(),
stats: &statsCache{},
} }
// Initialize file watcher/scanner and dispatcher // Initialize file watcher/scanner and dispatcher
var scanFunc mounts.ScanFunc var scanFunc mounts.ScanFunc
if cfg.EnableFileWatcher { if cfg.EnableFileWatcher {
s.watcherDispatcher = watcher.NewDispatcher() s.watcherDispatcher = watcher.NewDispatcher()
// Wire storage client for Redis Stream publishing // Wire storage client for file:events publishing
if stor != nil { if stor != nil {
s.watcherDispatcher.SetStorageClient(stor) s.watcherDispatcher.SetStorageClient(stor)
} }
@@ -78,7 +89,7 @@ func NewServer(
} else { } else {
s.fileWatcher = fileWatcher s.fileWatcher = fileWatcher
// Wire storage so the watcher can persist (path,size,mtime,cid) // Wire storage so the watcher can persist (path,size,mtime,cid)
// tuples to Redis as it computes midhashes. // tuples to the store as it computes midhashes.
if stor != nil { if stor != nil {
fileWatcher.SetStorageClient(stor) fileWatcher.SetStorageClient(stor)
} }
@@ -97,10 +108,9 @@ func NewServer(
// Create watchers poller // Create watchers poller
if s.fileWatcher != nil { if s.fileWatcher != nil {
s.watchersPoller = watchers.NewPoller(watchersManager, s.fileWatcher, s.watcherDispatcher) s.watchersPoller = watchers.NewPoller(watchersManager, s.fileWatcher, s.watcherDispatcher)
// Pass republish callback to handlers (handles nil metaPublisher case) s.watchersHandlers = watchers.NewHandlers(watchersManager, s.watchersPoller)
s.watchersHandlers = watchers.NewHandlers(watchersManager, s.watchersPoller, s.RepublishMetadata)
// Hydrate the in-memory state registry from Redis tuples so // Hydrate the in-memory state registry from stored tuples so
// the first scan tick after boot can skip ComputeMidHash256 // the first scan tick after boot can skip ComputeMidHash256
// for files whose (size, mtime) match. // for files whose (size, mtime) match.
configs := watchersManager.List() configs := watchersManager.List()
@@ -111,7 +121,7 @@ func NewServer(
if loaded, skipped, err := s.fileWatcher.HydrateStateFromStorage(roots); err != nil { if loaded, skipped, err := s.fileWatcher.HydrateStateFromStorage(roots); err != nil {
log.Printf("[API] Warning: failed to hydrate watcher state: %v", err) log.Printf("[API] Warning: failed to hydrate watcher state: %v", err)
} else { } else {
log.Printf("[API] Hydrated watcher state from Redis: %d files loaded, %d skipped (no matching watcher root)", loaded, skipped) log.Printf("[API] Hydrated watcher state from the store: %d files loaded, %d skipped (no matching watcher root)", loaded, skipped)
} }
} }
} }
@@ -139,6 +149,8 @@ func NewServer(
// Initialize WebDAV handler (caching is handled by nginx proxy_cache) // Initialize WebDAV handler (caching is handled by nginx proxy_cache)
s.webdavHandler = webdav.NewHandler(cfg) s.webdavHandler = webdav.NewHandler(cfg)
s.challenges = identity.NewChallengeStore()
// One-time migration: fold a pre-multi-account identity.json into the // One-time migration: fold a pre-multi-account identity.json into the
// per-account keystore so existing single-identity hosts keep their uid. // per-account keystore so existing single-identity hosts keep their uid.
if migrated, err := identity.MigrateLegacy(cfg.IdentityFilePath(), cfg.IdentityAccountsDir()); err != nil { if migrated, err := identity.MigrateLegacy(cfg.IdentityFilePath(), cfg.IdentityAccountsDir()); err != nil {
@@ -159,33 +171,42 @@ func (s *Server) setupRoutes() {
s.router.HandleFunc("/leader", s.handleLeader).Methods("GET") s.router.HandleFunc("/leader", s.handleLeader).Methods("GET")
s.router.HandleFunc("/urls", s.handleURLs).Methods("GET") s.router.HandleFunc("/urls", s.handleURLs).Methods("GET")
// Dashboard headline counters in one request. Cheap half live, keyspace
// walks served from a background-refreshed cache — see stats.go.
s.router.HandleFunc("/api/stats", s.handleStats).Methods("GET")
// Metadata Editor API routes (must be before /meta/{hash} routes) // Metadata Editor API routes (must be before /meta/{hash} routes)
s.router.HandleFunc("/api/metadata/hash-ids", s.handleGetHashIds).Methods("GET") s.router.HandleFunc("/api/metadata/hash-ids", s.handleGetHashIds).Methods("GET")
s.router.HandleFunc("/api/metadata/list", s.handleListMetadata).Methods("GET") s.router.HandleFunc("/api/metadata/list", s.handleListMetadata).Methods("GET")
s.router.HandleFunc("/api/metadata/search", s.handleSearchMetadata).Methods("POST") s.router.HandleFunc("/api/metadata/search", s.handleSearchMetadata).Methods("POST")
s.router.HandleFunc("/api/metadata/batch", s.handleBatchUpdate).Methods("POST") s.router.HandleFunc("/api/metadata/batch", s.handleGone).Methods("POST")
s.router.HandleFunc("/api/metadata/clear", s.handleClearMetadata).Methods("POST") s.router.HandleFunc("/api/metadata/clear", s.handleClearMetadata).Methods("POST")
// User Data Layer signing identity (see internal/identity). Auth-gated // User Data Layer signing identity (see internal/identity). Auth-gated
// at the perimeter — do NOT add to nginx-hash-lock unauth bypass. // at the perimeter — do NOT add to nginx-hash-lock unauth bypass.
s.router.HandleFunc("/api/identity", s.handleIdentityGet).Methods("GET") s.router.HandleFunc("/api/identity", s.handleIdentityGet).Methods("GET")
s.router.HandleFunc("/api/identity/accounts", s.handleIdentityAccounts).Methods("GET") s.router.HandleFunc("/api/identity/accounts", s.handleIdentityAccounts).Methods("GET")
s.router.HandleFunc("/api/identity/challenge", s.handleIdentityChallenge).Methods("POST")
s.router.HandleFunc("/api/identity/generate", s.handleIdentityGenerate).Methods("POST") s.router.HandleFunc("/api/identity/generate", s.handleIdentityGenerate).Methods("POST")
s.router.HandleFunc("/api/identity/import", s.handleIdentityImport).Methods("POST") s.router.HandleFunc("/api/identity/import", s.handleIdentityImport).Methods("POST")
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) // Signed claims (v2.0.0): the only metadata write path, the raw claims
s.router.HandleFunc("/api/snapshot/export", s.handleSnapshotExport).Methods("GET") // behind a record, and the stateless merge clients use for search hits.
s.router.HandleFunc("/api/snapshot/import", s.handleSnapshotImport).Methods("POST") s.router.HandleFunc("/api/claims", s.handlePostClaims).Methods("POST")
s.router.HandleFunc("/api/snapshot/wipe", s.handleSnapshotWipe).Methods("POST") s.router.HandleFunc("/api/merge", s.handleMerge).Methods("POST")
s.router.HandleFunc("/api/meta/{cid}/claims", s.handleClaimsByCID).Methods("GET")
s.router.HandleFunc("/api/metadata/{hashId}/claims", s.handleClaimsByRID).Methods("GET")
s.router.HandleFunc("/api/metadata/{hashId}/property", s.handleMetadataGetProperty).Methods("GET") s.router.HandleFunc("/api/metadata/{hashId}/property", s.handleMetadataGetProperty).Methods("GET")
s.router.HandleFunc("/api/metadata/{hashId}/property", s.handleMetadataUpdateProperty).Methods("PUT") s.router.HandleFunc("/api/metadata/{hashId}/property", s.handleGone).Methods("PUT")
s.router.HandleFunc("/api/metadata/{hashId}", s.handleGetMetadataByHashId).Methods("GET") s.router.HandleFunc("/api/metadata/{hashId}", s.handleGetMetadataByHashId).Methods("GET")
s.router.HandleFunc("/api/metadata/{hashId}", s.handleUpdateMetadataByHashId).Methods("PUT") s.router.HandleFunc("/api/metadata/{hashId}", s.handleGone).Methods("PUT")
s.router.HandleFunc("/api/metadata/{hashId}", s.handleDeleteMetadataByHashId).Methods("DELETE") s.router.HandleFunc("/api/metadata/{hashId}", s.handleGone).Methods("DELETE")
// Schema inference // Schema inference
s.router.HandleFunc("/api/schema", s.handleSchemaGet).Methods("GET") s.router.HandleFunc("/api/schema", s.handleSchemaGet).Methods("GET")
@@ -206,25 +227,26 @@ 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/user/{uid}/all", s.handleUDLUserAll).Methods("GET")
s.router.HandleFunc("/api/udl/user/{uid}/count", s.handleUDLUserCount).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")
s.router.HandleFunc("/api/udl/cid/{cid}/aggregate", s.handleUDLAggregate).Methods("GET") s.router.HandleFunc("/api/udl/cid/{cid}/aggregate", s.handleUDLAggregate).Methods("GET")
s.router.HandleFunc("/api/udl/users/stats", s.handleUDLUserStats).Methods("GET")
// Metadata operations - base endpoints // Metadata operations - base endpoints
s.router.HandleFunc("/meta/{hash}", s.handleGetMeta).Methods("GET") s.router.HandleFunc("/meta/{hash}", s.handleGetMeta).Methods("GET")
s.router.HandleFunc("/meta/{hash}", s.handlePutMeta).Methods("PUT") s.router.HandleFunc("/meta/{hash}", s.handleGone).Methods("PUT", "PATCH", "DELETE")
s.router.HandleFunc("/meta/{hash}", s.handlePatchMeta).Methods("PATCH")
s.router.HandleFunc("/meta/{hash}", s.handleDeleteMeta).Methods("DELETE")
s.router.HandleFunc("/meta", s.handleListMeta).Methods("GET") s.router.HandleFunc("/meta", s.handleListMeta).Methods("GET")
// Metadata operations - set operations (must be before property routes) // Metadata operations - set operations (must be before property routes)
s.router.HandleFunc("/meta/{hash}/_add/{key:.*}", s.handleAddToSet).Methods("POST") s.router.HandleFunc("/meta/{hash}/_add/{key:.*}", s.handleGone).Methods("POST")
// Metadata operations - property-level (key can contain slashes) // Metadata operations - property-level (key can contain slashes)
s.router.HandleFunc("/meta/{hash}/{key:.*}", s.handleGetProperty).Methods("GET") s.router.HandleFunc("/meta/{hash}/{key:.*}", s.handleGetProperty).Methods("GET")
s.router.HandleFunc("/meta/{hash}/{key:.*}", s.handlePutProperty).Methods("PUT") s.router.HandleFunc("/meta/{hash}/{key:.*}", s.handleGone).Methods("PUT", "DELETE")
s.router.HandleFunc("/meta/{hash}/{key:.*}", s.handleDeleteProperty).Methods("DELETE")
// Data operations // Data operations
s.router.HandleFunc("/data/{hash}/path", s.handleGetDataPath).Methods("GET") s.router.HandleFunc("/data/{hash}/path", s.handleGetDataPath).Methods("GET")
@@ -234,30 +256,35 @@ func (s *Server) setupRoutes() {
s.router.HandleFunc("/api/file/{cid}/info", s.handleGetFileByCIDInfo).Methods("GET") s.router.HandleFunc("/api/file/{cid}/info", s.handleGetFileByCIDInfo).Methods("GET")
s.router.HandleFunc("/file/{cid}", s.handleGetFileByCID).Methods("GET") s.router.HandleFunc("/file/{cid}", s.handleGetFileByCID).Methods("GET")
s.router.HandleFunc("/file/cid", s.handleComputeFileCID).Methods("POST") s.router.HandleFunc("/file/cid", s.handleComputeFileCID).Methods("POST")
// Register one file under /files/plugin by midhash — the watcher's
// mint + alias + file:events path for a single file. Registered
// unconditionally: it exists for boxes whose watcher is OFF.
s.router.HandleFunc("/api/files/register", s.handleRegisterFile).Methods("POST")
// File-backed roots (filePath+sizeByte+mtimeNano) with sizes, batched
// server-side, so clients answer "how many files, how big" without one
// read per hashId. ?summary=1 returns only {count, totalSize}.
s.router.HandleFunc("/api/files/tuples", s.handleGetFileTuples).Methods("GET")
// Public metadata-by-CID resolver. Reverse-index lookup → full document. // Public metadata-by-CID resolver. Reverse-index lookup → full document.
// Auth-bypassed alongside /api/file/{cid} so meta-share peers can query // Auth-bypassed alongside /api/file/{cid} so meta-share peers can query
// without going through Authelia. // without going through Authelia.
s.router.HandleFunc("/api/meta/{cid}", s.handleGetMetaByCID).Methods("GET") s.router.HandleFunc("/api/meta/{cid}", s.handleGetMetaByCID).Methods("GET")
// Admin: reunify stranded midhash-rooted entries into their UUID roots // SSE event streams. The only way consumers see meta-core's change
// (the dual-root pattern caused by historical writes that bypassed CID // streams — they never touch the database. Auth-bypass
// resolution).
s.router.HandleFunc("/api/admin/migrate-dual-roots", s.handleMigrateDualRoots).Methods("POST")
// SSE event streams. Mediated mirror of the Redis Streams; consumers
// reach them over HTTP instead of touching Redis directly. Auth-bypass
// applies but exposure should be inside-only (do NOT proxy through // applies but exposure should be inside-only (do NOT proxy through
// Caddy publicly). See docs/api-mediated-access.md "Auth". // Caddy publicly). See docs/api-mediated-access.md "Auth".
s.router.HandleFunc("/api/events/files", s.handleEventsFiles).Methods("GET") s.router.HandleFunc("/api/events/files", s.handleEventsFiles).Methods("GET")
s.router.HandleFunc("/api/events/meta", s.handleEventsMeta).Methods("GET") s.router.HandleFunc("/api/events/meta", s.handleEventsMeta).Methods("GET")
// Service discovery // beacon v2: neighbours heard over UDP. Supersedes /api/services,
// Both /services and /api/services are supported for consistency with other services // which is still served above while consumers migrate.
s.router.HandleFunc("/services", s.handleListServices).Methods("GET") s.router.HandleFunc("/neighbors", s.handleListNeighbors).Methods("GET")
s.router.HandleFunc("/api/services", s.handleListServices).Methods("GET") s.router.HandleFunc("/api/neighbors", s.handleListNeighbors).Methods("GET")
s.router.HandleFunc("/services/cleanup/stats", s.handleCleanupStats).Methods("GET")
s.router.HandleFunc("/services/{name}", s.handleGetService).Methods("GET") // Report inbox: bug reports / feedback forwarded by the client apps,
// read by the dashboard's Reports page. Perimeter-gated (reports.go).
s.setupReportRoutes()
// Mount management routes (if manager initialized) // Mount management routes (if manager initialized)
if s.mountsHandlers != nil { if s.mountsHandlers != nil {
@@ -326,54 +353,25 @@ func (s *Server) Start() error {
s.mountStatsPoller.Start() s.mountStatsPoller.Start()
} }
// Build + periodically refresh the in-memory search index so // Schema: served from counters the write path keeps exact; nothing runs.
// POST /api/metadata/search matches in memory instead of a Redis // Change events need no publisher either — the storage engine appends them
// round-trip per record (see search_index.go). // in the same transaction as each change, and a consumer that wants the
if s.storage != nil { // whole state asks /api/events/meta for "0" and gets a snapshot (see
go s.search.runRefreshLoop(s.storage) // streamSnapshot). The boot republish of every field is gone with it.
}
// Start meta publisher (if storage is connected)
// Publishes keyspace notifications to meta:events stream
if s.storage != nil && s.storage.IsConnected() { if s.storage != nil && s.storage.IsConnected() {
s.metaPublisher = events.NewMetaPublisher(s.storage.GetRedisClient()) s.schemaIndexer = schema.NewIndexer(s.storage.Store())
if err := s.metaPublisher.Start(); err != nil {
log.Printf("[API] Warning: failed to start meta publisher: %v", err)
} else {
// Schema indexer consumes the same stream to derive a live
// per-field schema (type/hint/breakdown). Start it before the
// republish flood so it captures the bootstrap events.
s.schemaIndexer = schema.NewIndexer(s.storage.GetRedisClient(), s.storage.GetPrefix())
if err := s.schemaIndexer.Start(); err != nil {
log.Printf("[API] Warning: failed to start schema indexer: %v", err)
s.schemaIndexer = nil
}
// Republish events for all existing metadata
go func() { go func() {
// Build any missing per-record field indexes first, in ONE
// keyspace pass. The republish below reads every record, and
// an un-indexed record read costs a full-keyspace SCAN — on a
// real store that walk warmed at ~1.3 records/s (a ~2.6h tail,
// paid again on every restart). Warming first turns the whole
// republish into index-served reads. Idempotent, so a
// fully-indexed store just pays the single scan.
// See storage/field_index.go.
started := time.Now() started := time.Now()
if n, err := s.storage.WarmFieldIndexes(); err != nil { if n, err := s.storage.RebuildSearchIfEmpty(); err != nil {
log.Printf("[API] Warning: field index warm failed: %v (reads fall back to SCAN)", err) log.Printf("[API] Warning: search row rebuild failed: %v", err)
} else if n > 0 { } else if n > 0 {
log.Printf("[API] Warmed %d field indexes in %s", n, time.Since(started).Round(time.Millisecond)) log.Printf("[API] Rebuilt search rows for %d records in %s", n, time.Since(started).Round(time.Millisecond))
}
count, err := s.RepublishMetadata()
if err != nil {
log.Printf("[API] Warning: failed to republish metadata on startup: %v", err)
} else {
log.Printf("[API] Republished %d metadata events on startup", count)
} }
}() }()
go s.logWriteStats()
} }
if s.reports != nil {
go s.pruneReports()
} }
return nil return nil
@@ -400,20 +398,6 @@ func (s *Server) Stop() error {
s.mountStatsPoller.Stop() s.mountStatsPoller.Stop()
} }
// Stop schema indexer before the publisher so the consumer drains cleanly
if s.schemaIndexer != nil {
if err := s.schemaIndexer.Stop(); err != nil {
log.Printf("[API] Warning: failed to stop schema indexer: %v", err)
}
}
// Stop meta publisher
if s.metaPublisher != nil {
if err := s.metaPublisher.Stop(); err != nil {
log.Printf("[API] Warning: failed to stop meta publisher: %v", err)
}
}
if s.server == nil { if s.server == nil {
return nil return nil
} }
@@ -426,26 +410,22 @@ func (s *Server) Stop() error {
return s.server.Shutdown(ctx) return s.server.Shutdown(ctx)
} }
// RepublishMetadata republishes all existing metadata to the meta:events stream // logWriteStats reports, every 15 minutes, how many writes changed a value
// This is called on startup and after reset-all // and how many found it already in place (kv skips those). The ratio is what
func (s *Server) RepublishMetadata() (int, error) { // the no-op skip buys on a permanent store that re-persists repeated hits.
if s.metaPublisher == nil { func (s *Server) logWriteStats() {
return 0, fmt.Errorf("meta publisher not initialized") ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
for range ticker.C {
store := s.storage.Store()
if store == nil {
return
} }
if s.storage == nil || !s.storage.IsConnected() { changed, unchanged := store.WriteStats()
return 0, fmt.Errorf("storage not connected") if total := changed + unchanged; total > 0 {
log.Printf("[Storage] writes since start: %d changed, %d unchanged skipped (%.0f%% no-op)", changed, unchanged, 100*float64(unchanged)/float64(total))
} }
// Create the republish function that wraps storage methods
republishFunc := func() ([]string, func(string) (map[string]string, error), error) {
hashIDs, err := s.storage.GetAllHashIDs()
if err != nil {
return nil, nil, err
} }
return hashIDs, s.storage.GetMetadataFlat, nil
}
return s.metaPublisher.RepublishAllMetadata(republishFunc)
} }
// statusRecorder captures the response status so loggingMiddleware can stay // statusRecorder captures the response status so loggingMiddleware can stay
@@ -498,7 +478,7 @@ func loggingMiddleware(next http.Handler) http.Handler {
func corsMiddleware(next http.Handler) http.Handler { func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type") w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" { if r.Method == "OPTIONS" {
-167
View File
@@ -1,167 +0,0 @@
package api
import (
"bytes"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/metazla/meta-core/internal/snapshot"
)
// Bound the upload size so a malicious client can't OOM the server.
// 2 GiB is well above any realistic export.
const maxImportBytes = 2 << 30
// pluginOutputDir is where plugin-produced artifacts live on disk
// (subtitles, posters, etc). Mounted from the host into the meta-core
// container at $FILES_PATH/plugin.
func (s *Server) pluginOutputDir() string {
return filepath.Join(s.config.FilesPath, "plugin")
}
// handleSnapshotExport streams a ZIP snapshot of all metadata.
// GET /api/snapshot/export?include=hash-index,plugin-output
func (s *Server) handleSnapshotExport(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
// The server has a 30s WriteTimeout (server.go:222). Long exports
// blow past that, leaving a truncated ZIP without a central directory.
// Clear the deadline for this handler so the export can run as long as
// needed. ResponseController is the standard way to do this.
rc := http.NewResponseController(w)
_ = rc.SetWriteDeadline(time.Time{})
include := splitCSVParam(r.URL.Query().Get("include"))
opts := snapshot.ExportOptions{
IncludePluginOutput: include["plugin-output"],
PluginOutputDir: s.pluginOutputDir(),
}
filename := fmt.Sprintf("metamesh-snapshot-%s.zip", time.Now().UTC().Format("20060102-150405"))
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
// We stream — no Content-Length.
exporter := snapshot.NewExporter(s.storage)
if _, err := exporter.Export(w, opts); err != nil {
// Headers may already be sent; log and bail.
// Client will see a truncated zip, which is the best signal we can give.
fmt.Printf("[Snapshot] export failed: %v\n", err)
return
}
}
// handleSnapshotImport applies an uploaded ZIP snapshot.
// POST /api/snapshot/import?mode=replace|merge&conflict=mine|source&dry_run=true
// Body: application/zip (raw) OR multipart/form-data with field "snapshot"
func (s *Server) handleSnapshotImport(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
// Long imports can blow past the server's 30s ReadTimeout/WriteTimeout
// when the snapshot is large. Clear both for this handler.
rc := http.NewResponseController(w)
_ = rc.SetReadDeadline(time.Time{})
_ = rc.SetWriteDeadline(time.Time{})
q := r.URL.Query()
opts := snapshot.ImportOptions{
Mode: snapshot.Mode(q.Get("mode")),
Conflict: snapshot.Conflict(q.Get("conflict")),
DryRun: strings.EqualFold(q.Get("dry_run"), "true"),
PluginOutputDir: s.pluginOutputDir(),
}
if opts.Mode == "" {
opts.Mode = snapshot.ModeMerge
}
if opts.Mode == snapshot.ModeMerge && opts.Conflict == "" {
opts.Conflict = snapshot.ConflictSource
}
body, err := readImportBody(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
importer := snapshot.NewImporter(s.storage)
result, err := importer.Import(bytes.NewReader(body), int64(len(body)), opts)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
// handleSnapshotWipe clears Redis metadata.
// POST /api/snapshot/wipe?scope=metadata
func (s *Server) handleSnapshotWipe(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
scopes := splitCSVParam(r.URL.Query().Get("scope"))
if len(scopes) == 0 {
writeError(w, http.StatusBadRequest, "scope query param is required (e.g. scope=metadata)")
return
}
scope := snapshot.WipeScope{
Metadata: scopes["metadata"],
}
if !scope.Metadata {
writeError(w, http.StatusBadRequest, "no supported scope requested; scope=metadata is the only accepted value")
return
}
wiper := snapshot.NewWiper(s.storage)
res, err := wiper.Wipe(scope)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, res)
}
// readImportBody accepts either a raw application/zip body or a multipart
// upload with a field named "snapshot". Buffers up to maxImportBytes.
func readImportBody(r *http.Request) ([]byte, error) {
r.Body = http.MaxBytesReader(nil, r.Body, maxImportBytes)
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/form-data") {
if err := r.ParseMultipartForm(32 << 20); err != nil {
return nil, fmt.Errorf("parse multipart: %w", err)
}
f, _, err := r.FormFile("snapshot")
if err != nil {
return nil, fmt.Errorf("missing 'snapshot' file field: %w", err)
}
defer f.Close()
return io.ReadAll(f)
}
return io.ReadAll(r.Body)
}
// splitCSVParam parses "a,b,c" into a set. Empty values yield an empty set.
func splitCSVParam(v string) map[string]bool {
out := map[string]bool{}
for _, part := range strings.Split(v, ",") {
p := strings.TrimSpace(part)
if p != "" {
out[p] = true
}
}
return out
}
+153 -91
View File
@@ -3,7 +3,6 @@ package api
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
@@ -11,15 +10,18 @@ import (
"strings" "strings"
"time" "time"
"github.com/redis/go-redis/v9" "github.com/metazla/meta-core/internal/kv"
"github.com/metazla/meta-core/internal/storage"
) )
// SSE event stream endpoints for the file:events and meta:events Redis Streams. // SSE event stream endpoints for the file:events and meta:events streams.
// //
// Wire contract (see docs/api-mediated-access.md): // Wire contract (see docs/api-mediated-access.md):
// - One SSE event per Redis Stream entry. // - One SSE event per stream entry.
// - `id:` is the opaque Redis Stream entry ID (`<ms>-<seq>`); consumers // - `id:` is the opaque stream entry ID (`<ms>-<seq>`, the Redis Streams
// MUST treat it as a black-box string they echo back via Last-Event-ID. // form, kept across the move to SQLite so cursors consumers persisted to
// disk still compare correctly); consumers MUST treat it as a black-box
// string they echo back via Last-Event-ID.
// - `event:` is the `type` field from the underlying entry. // - `event:` is the `type` field from the underlying entry.
// - `data:` is the JSON-encoded rest of the entry (all fields except `type`). // - `data:` is the JSON-encoded rest of the entry (all fields except `type`).
// - Heartbeats are SSE comment lines (`:keep-alive\n\n`) every 30s of silence. // - Heartbeats are SSE comment lines (`:keep-alive\n\n`) every 30s of silence.
@@ -30,30 +32,30 @@ import (
const ( const (
sseHeartbeatInterval = 30 * time.Second sseHeartbeatInterval = 30 * time.Second
// sseReadBlock controls how long each XREAD blocks before returning. // sseReadBlock controls how long each read waits for new entries.
// Much shorter than sseHeartbeatInterval so the handler stays responsive // Much shorter than sseHeartbeatInterval so the handler stays responsive
// to client disconnects and the heartbeat check runs frequently enough // to client disconnects and the heartbeat check runs frequently enough
// that the keep-alive fires within a few seconds of its nominal cadence. // that the keep-alive fires within a few seconds of its nominal cadence.
sseReadBlock = 2 * time.Second sseReadBlock = 2 * time.Second
// sseReadCount caps how many entries one XREAD returns. Small enough // sseReadCount caps how many entries one read returns. Small enough
// to keep per-flush memory low, large enough to drain bursts cheaply. // to keep per-flush memory low, large enough to drain bursts cheaply.
sseReadCount = 100 sseReadCount = 100
) )
// handleEventsFiles streams the file:events Redis Stream as SSE. // handleEventsFiles streams the file:events stream as SSE.
func (s *Server) handleEventsFiles(w http.ResponseWriter, r *http.Request) { func (s *Server) handleEventsFiles(w http.ResponseWriter, r *http.Request) {
s.streamSSE(w, r, "file:events") s.streamSSE(w, r, "file:events")
} }
// handleEventsMeta streams the meta:events Redis Stream as SSE. // handleEventsMeta streams the meta:events stream as SSE.
func (s *Server) handleEventsMeta(w http.ResponseWriter, r *http.Request) { func (s *Server) handleEventsMeta(w http.ResponseWriter, r *http.Request) {
s.streamSSE(w, r, "meta:events") s.streamSSE(w, r, "meta:events")
} }
// streamSSE is the shared SSE pump shared by both endpoints. One handler per // streamSSE is the SSE pump shared by both endpoints. Each subscriber reads
// stream — there is no fan-out; each subscriber holds its own XREAD loop. // the stream itself and sleeps on the store's commit broadcast between reads —
// That's fine at our scale (≤20 subscribers; one per consumer service). // fine at our scale (≤20 subscribers; one per consumer service).
func (s *Server) streamSSE(w http.ResponseWriter, r *http.Request, stream string) { func (s *Server) streamSSE(w http.ResponseWriter, r *http.Request, stream string) {
if !s.storage.IsConnected() { if !s.storage.IsConnected() {
w.Header().Set("Retry-After", "5") w.Header().Set("Retry-After", "5")
@@ -89,14 +91,37 @@ func (s *Server) streamSSE(w http.ResponseWriter, r *http.Request, stream string
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
flusher.Flush() flusher.Flush()
client := s.storage.GetRedisClient() store := s.storage.Store()
if client == nil { if store == nil {
// Storage was just disconnected — close cleanly. // Storage was just disconnected — close cleanly.
return return
} }
ctx := r.Context() ctx := r.Context()
cursor := resolveStartCursor(ctx, client, stream, r.Header.Get("Last-Event-ID"), w, flusher) lastID := r.Header.Get("Last-Event-ID")
var cursor string
if stream == storage.MetaEventsStream && (lastID == "0" || lastID == "0-0") {
// "From the beginning" on meta:events means "the whole current
// state": stream it from the records themselves, then the live tail.
head, err := streamSnapshot(ctx, store, w, flusher)
if err != nil {
if ctx.Err() == nil {
log.Printf("[SSE %s] snapshot failed: %v", stream, err)
}
return
}
cursor = head
} else {
if rebuilt := s.storage.RebuiltAt(); stream == storage.MetaEventsStream && lastID != "" && rebuilt != "" &&
compareStreamIDs(lastID, rebuilt) < 0 {
// The cache was rebuilt after this cursor: the rebuild published no
// per-key events, so the consumer must take a snapshot.
writeGap(w, flusher, lastID, rebuilt, "rebuild")
cursor = "0"
} else {
cursor = resolveStartCursor(ctx, store, stream, lastID, w, flusher)
}
}
lastWrite := time.Now() lastWrite := time.Now()
for { for {
@@ -104,37 +129,34 @@ func (s *Server) streamSSE(w http.ResponseWriter, r *http.Request, stream string
return return
} }
// Bounded BLOCK lets us re-check ctx and emit heartbeats. The // Take the wake-up channel BEFORE reading, so an append landing
// context's deadline isn't propagated into XREAD's BLOCK arg — // between the read and the wait still wakes us.
// that's a poll-style timeout on Redis itself — so we keep BLOCK wake := store.Wait(stream)
// short. entries, err := store.ReadAfter(ctx, stream, cursor, sseReadCount)
readCtx, cancel := context.WithTimeout(ctx, sseReadBlock+2*time.Second)
result, err := client.XRead(readCtx, &redis.XReadArgs{
Streams: []string{stream, cursor},
Block: sseReadBlock,
Count: sseReadCount,
}).Result()
cancel()
if err != nil { if err != nil {
// redis.Nil = BLOCK timeout with no entries. Anything else if ctx.Err() != nil {
// while the client is still connected is a real failure.
if errors.Is(err, redis.Nil) || isContextTimeout(err) {
// fall through to heartbeat check
} else if errors.Is(err, context.Canceled) || ctx.Err() != nil {
return
} else {
log.Printf("[SSE %s] XREAD error: %v", stream, err)
return return
} }
log.Printf("[SSE %s] read error: %v", stream, err)
return
} }
for _, str := range result { for _, e := range entries {
for _, msg := range str.Messages { writeSSEEntry(w, flusher, e)
writeSSEEntry(w, flusher, msg) cursor = e.ID
cursor = msg.ID
lastWrite = time.Now() lastWrite = time.Now()
} }
if len(entries) == 0 {
timer := time.NewTimer(sseReadBlock)
select {
case <-ctx.Done():
timer.Stop()
return
case <-wake:
case <-timer.C:
}
timer.Stop()
} }
// Heartbeat if quiet for >= the interval. Comment line; SSE // Heartbeat if quiet for >= the interval. Comment line; SSE
@@ -149,87 +171,130 @@ func (s *Server) streamSSE(w http.ResponseWriter, r *http.Request, stream string
} }
} }
// streamSnapshot sends one `set` event per existing record key, then returns
// the stream position the live tail continues from.
//
// This is how a consumer that rebuilds its state from events (meta-fuse sends
// Last-Event-ID 0-0 on every start) gets ALL of it. The stream itself only
// retains ~100k entries, so replaying it never could; meta-core used to
// republish every field into it on boot instead, which took minutes and still
// lost everything past the retention window.
//
// The head is captured BEFORE the snapshot is read, so a change racing the
// snapshot appears in the tail too — re-applying a `set` is idempotent. Only
// the final snapshot event carries an `id:`, so a consumer that disconnects
// mid-snapshot still holds its old cursor and gets a fresh snapshot.
func streamSnapshot(ctx context.Context, store *kv.Store, w http.ResponseWriter, flusher http.Flusher) (string, error) {
head, err := store.LastID(ctx, storage.MetaEventsStream)
if err != nil {
return "", err
}
ts := strconv.FormatInt(time.Now().UnixMilli(), 10)
pending := ""
err = store.ScanPrefix(ctx, "file:", 5000, func(rows []kv.KV) error {
for _, row := range rows {
if pending != "" {
writeSnapshotEvent(w, pending, ts, "")
}
pending = row.K
}
flusher.Flush()
return ctx.Err()
})
if err != nil {
return "", err
}
if pending != "" {
writeSnapshotEvent(w, pending, ts, head)
flusher.Flush()
}
return head, nil
}
func writeSnapshotEvent(w http.ResponseWriter, key, ts, id string) {
payload, _ := json.Marshal(map[string]string{"key": key, "ts": ts})
if id != "" {
fmt.Fprintf(w, "id: %s\n", id)
}
fmt.Fprintf(w, "event: set\ndata: %s\n\n", payload)
}
// resolveStartCursor decides where to start reading. // resolveStartCursor decides where to start reading.
// - No Last-Event-ID → start at "$" (only new entries). // - No Last-Event-ID → the stream's last issued ID, resolved ONCE here (only
// entries appended from now on). Redis' "$" was re-resolved on every
// XREAD, which dropped anything appended between two reads.
// - Last-Event-ID provided AND still in stream → resume from it. // - Last-Event-ID provided AND still in stream → resume from it.
// - Last-Event-ID provided BUT trimmed → emit one synthetic `gap` event // - Last-Event-ID provided BUT trimmed → emit one synthetic `gap` event
// and resume from the oldest available entry's predecessor "0", so the // and resume from the oldest available entry's predecessor "0", so the
// next XREAD picks up everything still in the stream. // next read picks up everything still in the stream.
// //
// The "still in stream" check is cheap: one XRANGE for the first entry. // The "still in stream" check is cheap: one read of the first entry.
func resolveStartCursor(ctx context.Context, client *redis.Client, stream, lastID string, w http.ResponseWriter, flusher http.Flusher) string { func resolveStartCursor(ctx context.Context, store *kv.Store, stream, lastID string, w http.ResponseWriter, flusher http.Flusher) string {
if lastID == "" {
return "$"
}
// One-entry XRANGE to find the oldest ID currently in the stream.
xCtx, cancel := context.WithTimeout(ctx, 2*time.Second) xCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel() defer cancel()
entries, err := client.XRangeN(xCtx, stream, "-", "+", 1).Result()
if lastID == "" {
head, err := store.LastID(xCtx, stream)
if err != nil {
log.Printf("[SSE %s] head lookup failed: %v (starting from the beginning)", stream, err)
return "0"
}
return head
}
oldestEntry, ok, err := store.Oldest(xCtx, stream)
if err != nil { if err != nil {
// Treat any error as "stream not available right now" and resume // Treat any error as "stream not available right now" and resume
// from the cursor — XREAD will either find it or block until new // from the cursor — the read either finds it or waits for new entries.
// entries arrive. log.Printf("[SSE %s] oldest-entry probe failed: %v (resuming from cursor)", stream, err)
log.Printf("[SSE %s] XRANGE probe failed: %v (resuming from cursor)", stream, err)
return lastID return lastID
} }
if len(entries) == 0 { if !ok {
// Stream is empty. Cursor may be from a previous incarnation; safest // Stream is empty. Cursor may be from a previous incarnation; safest
// is to resume from the cursor — XREAD against an empty stream // is to resume from the cursor — reading an empty stream returns
// returns nothing and blocks until new entries arrive. // nothing and waits until new entries arrive.
return lastID return lastID
} }
oldest := entries[0].ID oldest := oldestEntry.ID
if compareStreamIDs(lastID, oldest) >= 0 { if compareStreamIDs(lastID, oldest) >= 0 {
// Cursor is at or past the oldest — fine to resume. // Cursor is at or past the oldest — fine to resume.
return lastID return lastID
} }
// Cursor was trimmed. Emit a gap event so consumers that care can react. // Cursor was trimmed. Emit a gap event so consumers that care can react.
gap := map[string]string{ writeGap(w, flusher, lastID, oldest, "retention")
"requested": lastID,
"resumeFrom": oldest,
"reason": "retention",
}
payload, _ := json.Marshal(gap)
fmt.Fprintf(w, "event: gap\ndata: %s\n\n", string(payload))
flusher.Flush()
// Return "0" so the next XREAD picks up entries starting from the // Return "0" so the next read picks up entries starting from the
// stream's true minimum — including `oldest` itself. // stream's true minimum — including `oldest` itself.
return "0" return "0"
} }
// writeSSEEntry serialises one Redis Stream entry as an SSE event. // writeSSEEntry serialises one stream entry as an SSE event.
// //
// `event:` comes from the entry's `type` field (no prefix munging). `data:` // `event:` comes from the entry's `type` field (no prefix munging). `data:`
// is a JSON object containing all OTHER fields. Field values from the Redis // is a JSON object containing all OTHER fields.
// stream are interface{} (typically strings); we encode whatever the stream func writeSSEEntry(w http.ResponseWriter, flusher http.Flusher, e kv.Entry) {
// holds.
func writeSSEEntry(w http.ResponseWriter, flusher http.Flusher, msg redis.XMessage) {
eventType := "message" eventType := "message"
data := make(map[string]interface{}, len(msg.Values)) data := make(map[string]string, len(e.Values))
for k, v := range msg.Values { for k, v := range e.Values {
if k == "type" { if k == "type" {
if s, ok := v.(string); ok { eventType = v
eventType = s
continue continue
} }
}
data[k] = v data[k] = v
} }
payload, err := json.Marshal(data) payload, err := json.Marshal(data)
if err != nil { if err != nil {
log.Printf("[SSE] failed to marshal entry %s: %v", msg.ID, err) log.Printf("[SSE] failed to marshal entry %s: %v", e.ID, err)
return return
} }
// Spec: each event ends with a blank line. id/event/data lines are // Spec: each event ends with a blank line. id/event/data lines are
// terminated with single \n. // terminated with single \n.
fmt.Fprintf(w, "id: %s\n", msg.ID) fmt.Fprintf(w, "id: %s\n", e.ID)
fmt.Fprintf(w, "event: %s\n", eventType) fmt.Fprintf(w, "event: %s\n", eventType)
fmt.Fprintf(w, "data: %s\n\n", string(payload)) fmt.Fprintf(w, "data: %s\n\n", string(payload))
flusher.Flush() flusher.Flush()
@@ -264,17 +329,14 @@ func parseStreamID(id string) (int64, int64) {
return ms, seq return ms, seq
} }
// isContextTimeout returns true for the context deadline error wrapped by // writeGap emits the synthetic `gap` event: the consumer's cursor cannot be
// go-redis when XREAD's read context fires. We treat it the same as // resumed losslessly and it should take a snapshot (Last-Event-ID: 0).
// redis.Nil — i.e. an empty poll cycle, not a fatal error. func writeGap(w http.ResponseWriter, flusher http.Flusher, requested, resumeFrom, reason string) {
func isContextTimeout(err error) bool { payload, _ := json.Marshal(map[string]string{
if err == nil { "requested": requested,
return false "resumeFrom": resumeFrom,
} "reason": reason,
if errors.Is(err, context.DeadlineExceeded) { })
return true fmt.Fprintf(w, "event: gap\ndata: %s\n\n", string(payload))
} flusher.Flush()
// go-redis sometimes wraps this in its own error type. String match is
// a safety net.
return strings.Contains(err.Error(), "context deadline exceeded")
} }
+118
View File
@@ -0,0 +1,118 @@
package api
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// syncRecorder is a ResponseRecorder safe to read while the handler writes.
type syncRecorder struct {
*httptest.ResponseRecorder
mu sync.Mutex
}
func (r *syncRecorder) Write(b []byte) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
return r.ResponseRecorder.Write(b)
}
func (r *syncRecorder) body() string {
r.mu.Lock()
defer r.mu.Unlock()
return r.Body.String()
}
// Last-Event-ID 0-0 on meta:events returns the whole current state — one set
// per record key, only the last carrying an id — then the live tail.
func TestMetaEventsSnapshotThenTail(t *testing.T) {
stor := newTestStorage(t)
s := &Server{storage: stor}
a := testSigner(t, 1)
for i, cid := range []string{"bafyR1", "bafyR2"} {
storeClaims(t, stor, signed(t, a, cid, "title", "T", 1), signed(t, a, cid, "n", string(rune('a'+i)), 1))
}
r1, r2 := stor.ResolveRoot("bafyR1"), stor.ResolveRoot("bafyR2")
ctx, cancel := context.WithCancel(context.Background())
req := httptest.NewRequest(http.MethodGet, "/api/events/meta", nil).WithContext(ctx)
req.Header.Set("Last-Event-ID", "0-0")
rec := &syncRecorder{ResponseRecorder: httptest.NewRecorder()}
done := make(chan struct{})
go func() { s.handleEventsMeta(rec, req); close(done) }()
waitFor := func(substr string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !strings.Contains(rec.body(), substr) {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %q in:\n%s", substr, rec.body())
}
time.Sleep(20 * time.Millisecond)
}
}
waitFor(`"key":"file:` + r2 + `/title"`)
storeClaims(t, stor, signed(t, a, "bafyR1", "title", "changed", 2))
waitFor(`event: set`)
time.Sleep(300 * time.Millisecond)
cancel()
<-done
body := rec.body()
keys := []string{"file:" + r1 + "/title", "file:" + r1 + "/n", "file:" + r2 + "/title", "file:" + r2 + "/n",
"file:" + r1 + "/cids/bafyR1", "file:" + r2 + "/cids/bafyR2"}
for _, k := range keys {
if !strings.Contains(body, `"key":"`+k+`"`) {
t.Errorf("snapshot is missing %s", k)
}
}
events := strings.Split(strings.TrimSpace(body), "\n\n")
if len(events) != 7 {
t.Fatalf("want 6 snapshot events + 1 live event, got %d:\n%s", len(events), body)
}
for i, e := range events[:5] {
if strings.Contains(e, "id: ") {
t.Errorf("snapshot event %d carries an id: %q", i, e)
}
}
if !strings.HasPrefix(events[5], "id: ") || !strings.HasPrefix(events[6], "id: ") {
t.Fatalf("the last snapshot event and the live event must carry ids:\n%s", body)
}
if !strings.Contains(events[6], `"key":"file:`+r1+`/title"`) {
t.Fatalf("live tail did not follow the snapshot: %q", events[6])
}
}
// A cursor older than the last cache rebuild gets `gap` (reason rebuild): the
// rebuild published no per-key events, so the consumer must re-snapshot.
func TestMetaEventsGapAfterRebuild(t *testing.T) {
stor := newTestStorage(t)
s := &Server{storage: stor}
storeClaims(t, stor, signed(t, testSigner(t, 1), "bafyA", "title", "T", 1))
if err := stor.Rebuild(); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
req := httptest.NewRequest(http.MethodGet, "/api/events/meta", nil).WithContext(ctx)
req.Header.Set("Last-Event-ID", "1000-0")
rec := &syncRecorder{ResponseRecorder: httptest.NewRecorder()}
done := make(chan struct{})
go func() { s.handleEventsMeta(rec, req); close(done) }()
deadline := time.Now().Add(5 * time.Second)
for !strings.Contains(rec.body(), "event: gap") {
if time.Now().After(deadline) {
t.Fatalf("no gap after rebuild:\n%s", rec.body())
}
time.Sleep(20 * time.Millisecond)
}
cancel()
<-done
if !strings.Contains(rec.body(), `"reason":"rebuild"`) {
t.Fatalf("gap reason: %s", rec.body())
}
}
+166
View File
@@ -0,0 +1,166 @@
package api
import (
"log"
"net/http"
"sync"
"time"
"github.com/metazla/meta-core/internal/identity"
)
// statsSweepTTL is how long a keyspace-walk result stays good. The walk is
// never on the request path (see statsCache), so this only controls how stale
// the library tiles may look, not how slow the endpoint is.
const statsSweepTTL = 60 * time.Second
// fileStatsJSON is the file-backed half of the library: the roots that carry
// filePath+sizeByte+mtimeNano. Gateway / cid-rooted records have no file on
// disk and are counted in Records, not here — the gap between the two numbers
// is exactly "metadata we hold for files we do not have".
type fileStatsJSON struct {
Count int `json:"count"`
TotalSize int64 `json:"totalSize"`
}
// StatsResponse is the body of GET /api/stats — every headline number the
// dashboard shows, in one request.
//
// Deliberately split by cost. Records/RedisKeys/RedisMemory/Identities are
// cheap (an index range count, a cached row count, a file stat) plus a
// directory listing, recomputed on every call. The `redis*` JSON names
// predate the move to SQLite and are kept for dashboard compatibility:
// redisKeys is the number of claim rows (truth), redisMemory both databases' on-disk size. Files and UDLUsers need a keyspace walk — ~0.5s for the file
// tuples and ~3s for the UDL SCAN on an 88k-record box — so they are served
// from a background-refreshed cache and are null until the first sweep lands.
type StatsResponse struct {
Records int64 `json:"records"`
RedisKeys int64 `json:"redisKeys"`
RedisMemory string `json:"redisMemory"`
Identities int `json:"identities"`
Files *fileStatsJSON `json:"files"`
UDLUsers *int `json:"udlUsers"`
// SweptAt is when the cached half was computed (unix ms), 0 if never.
SweptAt int64 `json:"sweptAt"`
// Sweeping reports that a refresh is in flight, so the UI can show the
// cached numbers as provisional instead of pretending they are live.
Sweeping bool `json:"sweeping"`
}
// statsCache holds the expensive half of /api/stats.
//
// Why a cache and not just "compute it": the dashboard polls. A multi-second store walk
// per tick, per open tab, is a self-inflicted outage on a box that is also
// serving playback — the same shape as meta-sort's /api/stats full sweep. So
// the handler NEVER waits on a walk: it returns whatever the last sweep
// produced and kicks off a new one if the old one is stale. `running` makes
// that single-flight, so ten tabs cost one walk.
type statsCache struct {
mu sync.Mutex
files *fileStatsJSON
udlUsers *int
sweptAt time.Time
running bool
}
// snapshot returns the cached values and whether a refresh is in flight.
func (c *statsCache) snapshot() (*fileStatsJSON, *int, time.Time, bool) {
c.mu.Lock()
defer c.mu.Unlock()
return c.files, c.udlUsers, c.sweptAt, c.running
}
// refreshIfStale starts a background sweep unless one is already running or
// the last result is still fresh. Returns true if it started one.
func (c *statsCache) refreshIfStale(sweep func() (*fileStatsJSON, *int)) bool {
c.mu.Lock()
if c.running || time.Since(c.sweptAt) < statsSweepTTL {
c.mu.Unlock()
return false
}
c.running = true
c.mu.Unlock()
go func() {
files, udl := sweep()
c.mu.Lock()
defer c.mu.Unlock()
c.running = false
// A failed sweep leaves the previous numbers in place rather than
// blanking the tiles; sweptAt still advances so a hard-failing walk
// (store down) does not spin once per poll.
c.sweptAt = time.Now()
if files != nil {
c.files = files
}
if udl != nil {
c.udlUsers = udl
}
}()
return true
}
// handleStats handles GET /api/stats.
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
resp := StatsResponse{}
if n, err := s.storage.CountRoots(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
} else {
resp.Records = n
}
if n, err := s.storage.CountClaims(); err == nil {
resp.RedisKeys = n
}
if mem, err := s.storage.GetMemoryInfo(); err == nil {
resp.RedisMemory = mem
} else {
resp.RedisMemory = "N/A"
}
if list, err := identity.List(s.config.IdentityAccountsDir()); err == nil {
resp.Identities = len(list)
}
s.stats.refreshIfStale(s.sweepStats)
files, udl, sweptAt, sweeping := s.stats.snapshot()
resp.Files = files
resp.UDLUsers = udl
resp.Sweeping = sweeping
if !sweptAt.IsZero() {
resp.SweptAt = sweptAt.UnixMilli()
}
writeJSON(w, http.StatusOK, resp)
}
// sweepStats runs the two keyspace walks behind the cached tiles. Called off
// the request path only. A failing half returns nil so the other half still
// lands.
func (s *Server) sweepStats() (*fileStatsJSON, *int) {
var files *fileStatsJSON
if tuples, err := s.storage.GetTuplesForAllFiles(); err != nil {
log.Printf("[API] stats: file tuple sweep failed: %v", err)
} else {
fs := fileStatsJSON{Count: len(tuples)}
for _, t := range tuples {
fs.TotalSize += t.Size
}
files = &fs
}
var udl *int
if stats, err := s.storage.UDLAllUserStats(); err != nil {
log.Printf("[API] stats: UDL user sweep failed: %v", err)
} else {
n := len(stats)
udl = &n
}
return files, udl
}
+68
View File
@@ -0,0 +1,68 @@
package api
import (
"context"
"fmt"
"path/filepath"
"testing"
"github.com/metazla/meta-core/internal/claim"
"github.com/metazla/meta-core/internal/identity"
"github.com/metazla/meta-core/internal/storage"
)
// newTestStorage opens (and starts) a fresh store in the test's temp dir.
func newTestStorage(t *testing.T) *storage.Client {
t.Helper()
dir := t.TempDir()
c := storage.NewClient()
if err := c.Connect(filepath.Join(dir, "meta-core.db"), filepath.Join(dir, "cache.db")); err != nil {
t.Fatalf("open store: %v", err)
}
if err := c.EnsureSchemaVersion(storage.SchemaVersion); err != nil {
t.Fatalf("schema: %v", err)
}
if err := c.Start(); err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
return c
}
func testSigner(t *testing.T, n byte) *identity.Identity {
t.Helper()
id, err := identity.Import(fmt.Sprintf("%02x%062x", n, 1))
if err != nil {
t.Fatal(err)
}
return id
}
// signed builds a claim signed by id; v is string, []byte or nil.
func signed(t *testing.T, id *identity.Identity, cid, key string, v any, ts uint64) claim.Claim {
t.Helper()
cl := claim.Claim{CID: cid, UID: id.UID, Key: key, TS: ts}
switch x := v.(type) {
case string:
cl.Value = claim.Text(x)
case []byte:
cl.Value = claim.Bytes(x)
}
if err := cl.Sign(id); err != nil {
t.Fatal(err)
}
return cl
}
func storeClaims(t *testing.T, c *storage.Client, claims ...claim.Claim) {
t.Helper()
res, err := c.WriteClaims(context.Background(), claims)
if err != nil {
t.Fatal(err)
}
for i, r := range res {
if r.Status != storage.StatusAccepted {
t.Fatalf("claim %d: %s %s", i, r.Status, r.Reason)
}
}
}
+206 -116
View File
@@ -1,70 +1,63 @@
package api package api
// User Data Layer (UDL) HTTP surface. // User Data Layer facade (/api/udl/*). Since meta-core v2.0.0 a user's data are
// ordinary MMUR2 claims signed by the user's key (plaintext for public-social
// keys, ciphertext for private ones); these routes keep their paths for the
// clients (meta-watch, meta-read, meta-listen, the gateway mirror) and speak
// claims in the §3.4 JSON form:
// //
// Backs meta-watch's per-user store (My List, Continue Watching, ratings, // {"cid","uid","key","value": "…" | {"b64": "…"} | null,"ts","sig"}
// profile) now that the authoritative store lives in meta-core instead of //
// meta-watch's local op-log. Records arrive already signed (and, for // A write is the same as POST /api/claims; newest ts wins per (uid, cid, key).
// private-tier keys, already encrypted) as opaque base64 CBOR blobs; meta-core
// version-gates and indexes them but never inspects the crypto. See
// internal/storage/udl.go for the Redis model.
import ( import (
"encoding/json" "encoding/json"
"math" "math"
"net/http" "net/http"
"strconv"
"strings" "strings"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/metazla/meta-core/internal/claim"
"github.com/metazla/meta-core/internal/storage"
) )
// UDLPutRequest is the body of PUT /api/udl/record. // UDLPutResponse answers PUT /api/udl/record.
type UDLPutRequest struct {
Uid string `json:"uid"`
Cid string `json:"cid"`
Key string `json:"key"`
Version int64 `json:"version"`
Ts int64 `json:"ts"`
Record string `json:"record"` // base64 CBOR, opaque + signed
Value *json.RawMessage `json:"value,omitempty"`
// Tombstone marks this write as a deletion of (uid, cid, key). The record
// itself is opaque here — meta-core never decodes the CBOR, and a private-
// tier value and a tombstone are equally "no plaintext value" — so the
// writer has to say. Without it the cid is never removed from
// udl:idx:user:<uid>:key:<key>, which then grows for the life of the
// account and is returned in full on every My List / Continue Watching
// read. Omitted by older writers, which keeps the previous behaviour.
Tombstone bool `json:"tombstone,omitempty"`
}
// UDLPutResponse is the response of PUT /api/udl/record.
type UDLPutResponse struct { type UDLPutResponse struct {
Accepted bool `json:"accepted"` Accepted bool `json:"accepted"`
Status storage.ClaimStatus `json:"status"`
} }
// UDLRecordResponse is the response of GET /api/udl/record. // UDLPutBatchRequest is PUT /api/udl/records.
type UDLPutBatchRequest struct {
Records []claim.Claim `json:"records"`
}
// UDLPutBatchResult is one entry of the batch response.
type UDLPutBatchResult struct {
Key string `json:"key"`
Accepted bool `json:"accepted"`
Status storage.ClaimStatus `json:"status"`
Error string `json:"error,omitempty"`
}
// UDLPutBatchResponse answers PUT /api/udl/records.
type UDLPutBatchResponse struct {
Results []UDLPutBatchResult `json:"results"`
}
const maxUDLBatch = 1000
// UDLRecordResponse answers GET /api/udl/record. Claim is the stored claim,
// tombstone included (its ts is what a newer write must exceed); Exists is
// false when it is absent or a tombstone.
type UDLRecordResponse struct { type UDLRecordResponse struct {
Record string `json:"record"`
Version int64 `json:"version"`
Ts int64 `json:"ts"`
Exists bool `json:"exists"` Exists bool `json:"exists"`
// Value is the plaintext JSON scalar stored beside the opaque record, and Claim *claim.Claim `json:"claim"`
// 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 is one key's public aggregate on a cid.
// field names; `avg` is null for boolean keys like `like`), so the meta-watch
// client deserializes it into the same struct it serializes to the UI.
type UDLAggregate struct { type UDLAggregate struct {
Key string `json:"key"` Key string `json:"key"`
Count int `json:"count"` Count int `json:"count"`
@@ -73,10 +66,17 @@ type UDLAggregate struct {
SampleSize int `json:"sample_size"` SampleSize int `json:"sample_size"`
} }
// handleUDLRecordGet handles GET /api/udl/record?uid&cid&key func (s *Server) udlReady(w http.ResponseWriter) bool {
func (s *Server) handleUDLRecordGet(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() { if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected") writeError(w, http.StatusServiceUnavailable, "storage not connected")
return false
}
return true
}
// handleUDLRecordGet handles GET /api/udl/record?uid=&cid=&key=.
func (s *Server) handleUDLRecordGet(w http.ResponseWriter, r *http.Request) {
if !s.udlReady(w) {
return return
} }
q := r.URL.Query() q := r.URL.Query()
@@ -85,111 +85,172 @@ func (s *Server) handleUDLRecordGet(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "uid, cid and key are required") writeError(w, http.StatusBadRequest, "uid, cid and key are required")
return return
} }
cl, ok, err := s.storage.UDLGetClaim(uid, cid, key)
cell, exists, err := s.storage.UDLGetCell(uid, cid, key)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
resp := UDLRecordResponse{ resp := UDLRecordResponse{}
Record: cell.Record, if ok {
Version: cell.Version, resp.Claim = &cl
Ts: cell.Ts, resp.Exists = !cl.Value.IsNull()
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) writeJSON(w, http.StatusOK, resp)
} }
// handleUDLRecordPut handles PUT /api/udl/record. Version-gated: a stale write // handleUDLRecordPut handles PUT /api/udl/record with one claim as the body.
// (version <= stored) returns 409 stale_version so the client can re-read and
// retry with a higher version.
func (s *Server) handleUDLRecordPut(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUDLRecordPut(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() { if !s.udlReady(w) {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return return
} }
var req UDLPutRequest var cl claim.Claim
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&cl); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body") writeError(w, http.StatusBadRequest, "body must be one claim: "+err.Error())
return return
} }
if req.Uid == "" || req.Cid == "" || req.Key == "" || req.Record == "" { results, err := s.storage.WriteClaims(r.Context(), []claim.Claim{cl})
writeError(w, http.StatusBadRequest, "uid, cid, key and record are required")
return
}
publicValue := ""
hasValue := false
if req.Value != nil {
publicValue = string(*req.Value)
hasValue = true
}
accepted, err := s.storage.UDLUpsertIfNewer(req.Uid, req.Cid, req.Key, req.Version, req.Ts, req.Record, publicValue, hasValue, req.Tombstone)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
if !accepted { res := results[0]
writeErrorSlug(w, http.StatusConflict, "stale_version", "a record with an equal or newer version already exists", true) switch res.Status {
return case storage.StatusAccepted, storage.StatusUnchanged:
writeJSON(w, http.StatusOK, UDLPutResponse{Accepted: true, Status: res.Status})
case storage.StatusStale:
writeErrorSlug(w, http.StatusConflict, "stale_version", "a claim with an equal or newer ts already exists", true)
default:
writeErrorSlug(w, http.StatusBadRequest, string(res.Status), res.Reason, false)
} }
writeJSON(w, http.StatusOK, UDLPutResponse{Accepted: true})
} }
// handleUDLUserKey handles GET /api/udl/user/{uid}/key/{key} // handleUDLRecordsPut handles PUT /api/udl/records {records: [claim, …]}.
// (active_for_user_key — My List, Continue Watching). func (s *Server) handleUDLRecordsPut(w http.ResponseWriter, r *http.Request) {
if !s.udlReady(w) {
return
}
var req UDLPutBatchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
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, err := s.storage.WriteClaims(r.Context(), req.Records)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]UDLPutBatchResult, len(results))
for i, res := range results {
out[i] = UDLPutBatchResult{
Key: req.Records[i].Key,
Status: res.Status,
Accepted: res.Status == storage.StatusAccepted || res.Status == storage.StatusUnchanged,
Error: res.Reason,
}
}
writeJSON(w, http.StatusOK, UDLPutBatchResponse{Results: out})
}
func (s *Server) writeUDLEntries(w http.ResponseWriter, uid, cid, key string) {
entries, err := s.storage.UDLClaimsForUser(uid, cid, key)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if entries == nil {
entries = []claim.Claim{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"entries": entries})
}
// handleUDLUserKey handles GET /api/udl/user/{uid}/key/{key}: every live claim
// of uid on 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) {
if !s.storage.IsConnected() { if !s.udlReady(w) {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return return
} }
vars := mux.Vars(r) vars := mux.Vars(r)
uid, key := vars["uid"], vars["key"] if vars["uid"] == "" || vars["key"] == "" {
if uid == "" || key == "" {
writeError(w, http.StatusBadRequest, "uid and key are required") writeError(w, http.StatusBadRequest, "uid and key are required")
return return
} }
entries, err := s.storage.UDLListUserKey(uid, key) s.writeUDLEntries(w, vars["uid"], "", vars["key"])
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"entries": entries})
} }
// handleUDLUserCid handles GET /api/udl/user/{uid}/cid/{cid} // handleUDLUserCid handles GET /api/udl/user/{uid}/cid/{cid}.
// (list_for_user_cid).
func (s *Server) handleUDLUserCid(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUDLUserCid(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() { if !s.udlReady(w) {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return return
} }
vars := mux.Vars(r) vars := mux.Vars(r)
uid, cid := vars["uid"], vars["cid"] if vars["uid"] == "" || vars["cid"] == "" {
if uid == "" || cid == "" {
writeError(w, http.StatusBadRequest, "uid and cid are required") writeError(w, http.StatusBadRequest, "uid and cid are required")
return return
} }
entries, err := s.storage.UDLListUserCid(uid, cid) s.writeUDLEntries(w, vars["uid"], vars["cid"], "")
}
// handleUDLUserAll handles GET /api/udl/user/{uid}/all?after=&limit= — the
// whole profile, paged on cid boundaries (recovery / export).
func (s *Server) handleUDLUserAll(w http.ResponseWriter, r *http.Request) {
if !s.udlReady(w) {
return
}
uid := mux.Vars(r)["uid"]
if uid == "" {
writeError(w, http.StatusBadRequest, "uid is required")
return
}
limit := maxUDLBatch
if v := r.URL.Query().Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
writeError(w, http.StatusBadRequest, "limit must be a positive integer")
return
}
limit = min(n, limit)
}
entries, nextCid, err := s.storage.UDLAllForUser(uid, r.URL.Query().Get("after"), limit)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
writeJSON(w, http.StatusOK, map[string]interface{}{"entries": entries}) if entries == nil {
entries = []claim.Claim{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"entries": entries, "nextCid": nextCid})
} }
// handleUDLCidUsers handles GET /api/udl/cid/{cid}/users (uids_for_cid). // handleUDLUserCount handles GET /api/udl/user/{uid}/count.
func (s *Server) handleUDLUserCount(w http.ResponseWriter, r *http.Request) {
if !s.udlReady(w) {
return
}
uid := mux.Vars(r)["uid"]
if uid == "" {
writeError(w, http.StatusBadRequest, "uid is required")
return
}
n, err := s.storage.UDLCountUserCids(uid)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"cids": n})
}
// handleUDLCidUsers handles GET /api/udl/cid/{cid}/users: signers holding live
// claims on the cid.
func (s *Server) handleUDLCidUsers(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUDLCidUsers(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() { if !s.udlReady(w) {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return return
} }
cid := mux.Vars(r)["cid"] cid := mux.Vars(r)["cid"]
@@ -205,12 +266,9 @@ func (s *Server) handleUDLCidUsers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{"users": users}) writeJSON(w, http.StatusOK, map[string]interface{}{"users": users})
} }
// handleUDLAggregate handles GET /api/udl/cid/{cid}/aggregate?keys=like,rating. // handleUDLAggregate handles GET /api/udl/cid/{cid}/aggregate?keys=like,userRating.
// Computes per-key population stats from the stored public value fields
// (absorbs meta-watch's aggregate.rs). Reflects only records this core holds.
func (s *Server) handleUDLAggregate(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUDLAggregate(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() { if !s.udlReady(w) {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return return
} }
cid := mux.Vars(r)["cid"] cid := mux.Vars(r)["cid"]
@@ -220,7 +278,7 @@ func (s *Server) handleUDLAggregate(w http.ResponseWriter, r *http.Request) {
} }
keysParam := r.URL.Query().Get("keys") keysParam := r.URL.Query().Get("keys")
if keysParam == "" { if keysParam == "" {
keysParam = "like,rating" keysParam = "like,userRating"
} }
aggregates := make([]UDLAggregate, 0) aggregates := make([]UDLAggregate, 0)
for _, key := range strings.Split(keysParam, ",") { for _, key := range strings.Split(keysParam, ",") {
@@ -265,3 +323,35 @@ func aggregatePublicValues(key string, values []string) UDLAggregate {
} }
return agg return agg
} }
// udlUserStatsResponse is the shape of GET /api/udl/users/stats.
type udlUserStatsResponse struct {
// Users maps uid -> counts. Accounts holding nothing are absent rather than
// present with zeroes; a caller rendering a list treats "missing" as 0.
Users map[string]storage.UDLUserStats `json:"users"`
}
// handleUDLUserStats handles GET /api/udl/users/stats — how much every account
// holds, in one keyspace walk.
//
// Bulk rather than per-uid because the only caller is the accounts page, which
// needs a number for every row: the counts come from a SCAN (there is no
// per-user cid index — see storage/udl_admin.go), and MATCH filters without
// indexing, so N per-uid calls would walk the whole keyspace N times.
//
// Deliberately not on the critical path of anything. It is an admin read whose
// cost grows with the keyspace, so callers should fetch it after rendering,
// never before.
func (s *Server) handleUDLUserStats(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeErrorSlug(w, http.StatusServiceUnavailable, "storage_unavailable",
"storage not connected", true)
return
}
stats, err := s.storage.UDLAllUserStats()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, udlUserStatsResponse{Users: stats})
}
+75 -93
View File
@@ -4,127 +4,109 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"github.com/alicebob/miniredis/v2" "github.com/metazla/meta-core/internal/claim"
"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 { func newUDLTestServer(t *testing.T) *Server {
t.Helper() t.Helper()
return &Server{storage: newTestStorage(t)}
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 func udlGet(t *testing.T, s *Server, uid, cid, key string) UDLRecordResponse {
// 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() t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/udl/record?uid="+uid+"&cid="+cid+"&key="+key, nil) req := httptest.NewRequest(http.MethodGet, "/api/udl/record?uid="+uid+"&cid="+cid+"&key="+key, nil)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
s.handleUDLRecordGet(rr, req) s.handleUDLRecordGet(rr, req)
if rr.Code != http.StatusOK {
var body map[string]json.RawMessage t.Fatalf("GET record = %d %s", rr.Code, rr.Body)
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response %q: %v", rr.Body.String(), err)
} }
return rr.Code, body var resp UDLRecordResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
return resp
} }
// The plaintext value of a PUBLIC-tier cell must come back over the wire, and func udlPutBatch(t *testing.T, s *Server, claims ...claim.Claim) UDLPutBatchResponse {
// come back *as it was written* — a JSON string stays a quoted string, a bool t.Helper()
// stays a bool. body, _ := json.Marshal(UDLPutBatchRequest{Records: claims})
// req := httptest.NewRequest(http.MethodPut, "/api/udl/records", strings.NewReader(string(body)))
// Both halves matter. Without the value at all, the meta-core dashboard can rr := httptest.NewRecorder()
// only ever show 44-character base58 uids: display names live in the User Data s.handleUDLRecordsPut(rr, req)
// Layer as the public cell (uid, "self", "profile:name"), and the plaintext if rr.Code != http.StatusOK {
// sits in Redis where the storage layer has always read it — the handler just t.Fatalf("PUT records = %d %s", rr.Code, rr.Body)
// 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` var resp UDLPutBatchResponse
// (number) while looking correct for names. if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
func TestUDLRecordGet_ReturnsPublicValue(t *testing.T) { t.Fatal(err)
}
return resp
}
// The facade stores and returns MMUR2 claims: public values in clear,
// private ones as ciphertext, tombstones with their ts.
func TestUDLFacadeRoundTrip(t *testing.T) {
s := newUDLTestServer(t) s := newUDLTestServer(t)
me := testSigner(t, 7)
const uid = "zAlice" res := udlPutBatch(t, s,
if ok, err := s.storage.UDLUpsertIfNewer(uid, "self", "profile:name", 1, 100, "cmVj", `"Ada Lovelace"`, true, false); err != nil || !ok { signed(t, me, "self", "profile:name", "Ada Lovelace", 100),
t.Fatalf("seed name: ok=%v err=%v", ok, err) signed(t, me, "bagcsaaa", "like", "true", 100),
signed(t, me, "bagcsaaa", "seek", []byte("ciphertext"), 100),
)
for _, r := range res.Results {
if !r.Accepted {
t.Fatalf("not accepted: %+v", r)
} }
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) got := udlGet(t, s, me.UID, "self", "profile:name")
if !got.Exists || got.Claim == nil || got.Claim.Value.Text != "Ada Lovelace" || got.Claim.Verify() != nil {
t.Fatalf("profile = %+v", got)
}
if seek := udlGet(t, s, me.UID, "bagcsaaa", "seek"); seek.Claim == nil || seek.Claim.Value.Kind != claim.KindBytes {
t.Fatalf("seek = %+v", seek)
}
if miss := udlGet(t, s, me.UID, "bagcsaaa", "nope"); miss.Exists || miss.Claim != nil {
t.Fatalf("missing cell = %+v", miss)
} }
code, body := getRecord(t, s, uid, "self", "profile:name") // Retraction: stored, returned with its ts, no longer "exists".
if code != http.StatusOK { udlPutBatch(t, s, signed(t, me, "bagcsaaa", "like", nil, 200))
t.Fatalf("status = %d, want 200", code) if tomb := udlGet(t, s, me.UID, "bagcsaaa", "like"); tomb.Exists || tomb.Claim == nil || tomb.Claim.TS != 200 {
} t.Fatalf("tombstone = %+v", tomb)
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. rr := httptest.NewRecorder()
if _, body = getRecord(t, s, uid, "bagcsaaa", "like"); string(body["value"]) != "true" { req := httptest.NewRequest(http.MethodGet, "/api/udl/cid/self/aggregate?keys=profile:name", nil)
t.Fatalf("like value = %s, want the bare JSON literal true", body["value"]) req = withVars(req, map[string]string{"cid": "self"})
s.handleUDLAggregate(rr, req)
if !strings.Contains(rr.Body.String(), `"sample_size":1`) {
t.Fatalf("aggregate = %s", rr.Body)
} }
} }
// A cell with no plaintext must omit `value` entirely rather than send null. // A stale claim in a batch is reported in its slot without failing the rest.
// func TestUDLRecordsPut_StaleSlotDoesNotFailTheBatch(t *testing.T) {
// 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) s := newUDLTestServer(t)
me := testSigner(t, 7)
const uid = "zBob" udlPutBatch(t, s, signed(t, me, "bagcsaaa", "seen:s01e01", "true", 400))
if ok, err := s.storage.UDLUpsertIfNewer(uid, "bagcsaaa", "seek", 1, 100, "ZW5jcnlwdGVk", "", false, false); err != nil || !ok { res := udlPutBatch(t, s,
t.Fatalf("seed private cell: ok=%v err=%v", ok, err) signed(t, me, "bagcsaaa", "seen:s01e01", "false", 300),
} signed(t, me, "bagcsaaa", "seen:s01e02", "true", 300),
)
code, body := getRecord(t, s, uid, "bagcsaaa", "seek") if res.Results[0].Accepted || res.Results[0].Status != "stale" || !res.Results[1].Accepted {
if code != http.StatusOK { t.Fatalf("results = %+v", res.Results)
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 — func TestUDLRecordsPut_RejectsEmptyBatch(t *testing.T) {
// 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) s := newUDLTestServer(t)
req := httptest.NewRequest(http.MethodPut, "/api/udl/records", strings.NewReader(`{"records":[]}`))
code, body := getRecord(t, s, "zNobody", "self", "profile:name") rr := httptest.NewRecorder()
if code != http.StatusOK { s.handleUDLRecordsPut(rr, req)
t.Fatalf("status = %d, want 200", code) if rr.Code != http.StatusBadRequest {
} t.Fatalf("empty batch = %d", rr.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"])
} }
} }
+92
View File
@@ -0,0 +1,92 @@
package cid
import (
"encoding/base32"
"encoding/binary"
"fmt"
"strings"
"unicode/utf8"
)
// DecodeCard recovers the (source, id) pair a card locator (CodeCard, 0x1007)
// wraps. Wire form:
//
// 0x01 ‖ varint(0x1007) ‖ 0x00 (identity mh) ‖ varint(len(digest)) ‖ digest
// digest = varint(len(source)) ‖ source ‖ id (both UTF-8)
//
// Decode stops at the multihash code because ranking needs nothing past it;
// reading *which work* a card addresses needs the digest too, so this is the
// one place in Go that walks the whole thing.
//
// ⚠ Mirror of meta-read `card_cid::decode` and of meta-feeder-sdk
// `compute_card_cid`. The vectors in card_test.go are pinned literals copied
// from meta-read, not round-trips, so a drift on either side fails a test
// instead of silently reading a different work out of the same CID.
//
// Identity multihash only: a 0x1007 CID carrying any other multihash is not
// something anyone mints, and reading its digest as (source, id) would be
// reading hash bytes as text. Bytes past the declared digest length are
// ignored, as meta-read does.
func DecodeCard(cidStr string) (source, id string, err error) {
if len(cidStr) < 2 || cidStr[0] != 'b' {
return "", "", fmt.Errorf("cid %q: not a multibase base32 CIDv1", cidStr)
}
raw, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(cidStr[1:]))
if err != nil {
return "", "", fmt.Errorf("cid %q: base32 decode: %w", cidStr, err)
}
next := func(buf []byte, what string) (uint64, []byte, error) {
v, n := binary.Uvarint(buf)
if n <= 0 {
return 0, nil, fmt.Errorf("cid %q: bad %s varint", cidStr, what)
}
return v, buf[n:], nil
}
version, raw, err := next(raw, "version")
if err != nil {
return "", "", err
}
if version != 1 {
return "", "", fmt.Errorf("cid %q: unsupported CID version %d", cidStr, version)
}
codec, raw, err := next(raw, "codec")
if err != nil {
return "", "", err
}
if codec != CodeCard {
return "", "", fmt.Errorf("cid %q: codec 0x%x is not a card locator", cidStr, codec)
}
mh, raw, err := next(raw, "multihash-code")
if err != nil {
return "", "", err
}
if mh != 0 {
return "", "", fmt.Errorf("cid %q: card locator with non-identity multihash 0x%x", cidStr, mh)
}
digestLen, raw, err := next(raw, "digest-length")
if err != nil {
return "", "", err
}
if digestLen > uint64(len(raw)) {
return "", "", fmt.Errorf("cid %q: digest truncated", cidStr)
}
digest := raw[:digestLen]
sourceLen, rest, err := next(digest, "source-length")
if err != nil {
return "", "", err
}
if sourceLen > uint64(len(rest)) {
return "", "", fmt.Errorf("cid %q: source truncated", cidStr)
}
source, id = string(rest[:sourceLen]), string(rest[sourceLen:])
if source == "" || id == "" {
return "", "", fmt.Errorf("cid %q: empty source or id", cidStr)
}
if !utf8.ValidString(source) || !utf8.ValidString(id) {
return "", "", fmt.Errorf("cid %q: source or id is not UTF-8", cidStr)
}
return source, id, nil
}
+71
View File
@@ -0,0 +1,71 @@
package cid
import (
"encoding/base32"
"encoding/binary"
"strings"
"testing"
)
// Pinned from meta-read `src/card_cid.rs` / meta-feeder-sdk `compute_card_cid`
// — literals, not round-trips (see DecodeCard).
const (
goldenAnilistCard = "bagdsaaaua5qw42lmnfzxi3lbnztwcorrga2tonzy"
goldenMangadexCard = "bagdsaabybrwwc3thmfsgk6bon5zgol3nmfxgoyjpmiygenzsgftgmlldgm4dqljugq4dmllbmeygmlldgjrdaytcgmzdcnjrgi"
)
// wireCID builds a CIDv1 from raw parts so the negative cases below can vary
// one field at a time.
func wireCID(codec, mh uint64, digest []byte) string {
buf := []byte{0x01}
buf = binary.AppendUvarint(buf, codec)
buf = binary.AppendUvarint(buf, mh)
buf = binary.AppendUvarint(buf, uint64(len(digest)))
buf = append(buf, digest...)
return "b" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf))
}
func cardDigest(source, id string) []byte {
d := binary.AppendUvarint(nil, uint64(len(source)))
d = append(d, source...)
return append(d, id...)
}
func TestDecodeCard_GoldenVectors(t *testing.T) {
for _, tc := range []struct {
cid, source, id string
}{
{goldenAnilistCard, "anilist", "manga:105778"},
{goldenMangadexCard, "mangadex.org", "/manga/b0b721ff-c388-4486-aa0f-c2b0bb321512"},
} {
source, id, err := DecodeCard(tc.cid)
if err != nil {
t.Fatalf("DecodeCard(%s): %v", tc.cid, err)
}
if source != tc.source || id != tc.id {
t.Errorf("DecodeCard(%s) = (%q, %q), want (%q, %q)", tc.cid, source, id, tc.source, tc.id)
}
// The test encoder must agree with the pinned literal, or the negative
// cases below are testing a different wire format.
if got := wireCID(CodeCard, 0, cardDigest(tc.source, tc.id)); got != tc.cid {
t.Errorf("wireCID(%q, %q) = %s, want %s", tc.source, tc.id, got, tc.cid)
}
}
}
func TestDecodeCard_RejectsEverythingElse(t *testing.T) {
for name, s := range map[string]string{
"url locator codec": wireCID(CodeURL, 0, cardDigest("anilist", "1")),
"non-identity multihash": wireCID(CodeCard, CodeSha256, cardDigest("anilist", "1")),
"empty id": wireCID(CodeCard, 0, cardDigest("anilist", "")),
"source past digest": wireCID(CodeCard, 0, []byte{0x20, 'a'}),
"not a CID": "title:berserk",
"CIDv0": "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG",
"truncated": goldenAnilistCard[:12],
"empty": "",
} {
if source, id, err := DecodeCard(s); err == nil {
t.Errorf("%s: DecodeCard(%q) = (%q, %q), want error", name, s, source, id)
}
}
}
+25 -1
View File
@@ -22,7 +22,7 @@
"25": "nzb-posting - content-derived (Message-ID set digest), but not itself fetchable", "25": "nzb-posting - content-derived (Message-ID set digest), but not itself fetchable",
"20": "BitTorrent infohashes - swarm interop", "20": "BitTorrent infohashes - swarm interop",
"10": "midhash256 - the record address", "10": "midhash256 - the record address",
"5": "opaque locators (nzb-release, url, card) - never outrank a real digest", "5": "opaque locators (nzb-release, url, card, yt-video, ext-play) - never outrank a real digest",
"0": "weak/unknown digests, unparseable input" "0": "weak/unknown digests, unparseable input"
}, },
"tieBreak": "lexicographically smaller cid wins", "tieBreak": "lexicographically smaller cid wins",
@@ -116,6 +116,30 @@
"rank": 5, "rank": 5,
"note": "codec 0x1007 + IDENTITY multihash 0x00, digest = varint(len(source)) || source || id — here (\"tmdb\",\"tv:95479\"). Same codec-slot rule as nzb-release/url: match on the CODEC, never the mh. The limit case of the family — the only CID in MetaMesh with NO bytes behind it anywhere, ever: it addresses a *work* (a series, a film), not a file, so the byte path 404s by design and bitswap never offers it (seeding is gated on sha2-256). Tier 5 is load-bearing for the planned cross-source merge: one card record may carry BOTH a TMDB and a MyAnimeList locator, and if a real card body is ever added (see card-tier-search.md §4.3) its sha2-256 must win the canonical election over both locators without any addressing change. Ranking it 0 instead would still lose to a digest, but would put the implementations back out of agreement — which is the exact failure this fixture exists to prevent." "note": "codec 0x1007 + IDENTITY multihash 0x00, digest = varint(len(source)) || source || id — here (\"tmdb\",\"tv:95479\"). Same codec-slot rule as nzb-release/url: match on the CODEC, never the mh. The limit case of the family — the only CID in MetaMesh with NO bytes behind it anywhere, ever: it addresses a *work* (a series, a film), not a file, so the byte path 404s by design and bitswap never offers it (seeding is gated on sha2-256). Tier 5 is load-bearing for the planned cross-source merge: one card record may carry BOTH a TMDB and a MyAnimeList locator, and if a real card body is ever added (see card-tier-search.md §4.3) its sha2-256 must win the canonical election over both locators without any addressing change. Ranking it 0 instead would still lose to a digest, but would put the implementations back out of agreement — which is the exact failure this fixture exists to prevent."
}, },
{
"name": "yt-video-locator",
"cid": "bagecaaarav3gszdfn42ein3vgvfumn2tka4a",
"rank": 5,
"note": "codec 0x1008 + IDENTITY multihash 0x00, digest = varint(len(kind)) || kind || id — here (\"video\",\"4D7u5KF7SP8\"). DELEGATED PLAYBACK: the limit case beyond card. A card addresses a work with no bytes anywhere; this addresses a *rendition* whose bytes exist but are permanently someone else's — an external player renders it, nothing is transported, seeded or content-addressed. Framing is byte-identical to card (0x1007) on purpose, so each implementation extends the parser it already wrote. Same codec-slot rule: match on the CODEC, never the mh (identity 0x00 would fall through and score 0). Tier 5 is load-bearing for the on-ramp: a delegated reference and a real archived copy can sit on ONE record, and that sha2-256 must win the canonical election over this locator with no addressing change. NOTE the byte path must REFUSE this (400), not 404 — see docs/cid-formats.md §7.4."
},
{
"name": "ext-play-locator",
"cid": "bagesaabenb2hi4dthixs6zlymfwxa3dffzrgc3temnqw24bomnxw2l3bnrrhk3jppa",
"rank": 5,
"note": "codec 0x1009 + IDENTITY multihash 0x00, digest = the verbatim UTF-8 URL. WIRE-IDENTICAL to url (0x1006) apart from the codec, and that difference is the entire contract: 0x1006 means 'fetch these bytes once and seed them', 0x1009 means 'open this in a browser, there are no bytes for us'. A decoder that matched on SHAPE rather than on the codec slot would see an identity multihash wrapping an http(s) URL, take the 0x1006 path, fetch a watch/Bandcamp page, chunk it, seed the HTML and link that content CID onto the record AS THE FILE — the MSR1 poisoning shape, except permanent and replicated. Same tier/reasoning as the rest of the locator family."
},
{
"name": "provider-file-locator",
"cid": "bagfcaaa2bvxxazloon2we5djorwgk43gnfwgkorxga3dcobtgq",
"rank": 5,
"note": "codec 0x100A + IDENTITY multihash 0x00, digest = varint(len(source)) || source || id - here (\"opensubtitles\",\"file:7061834\"). The GENERIC fetchable locator: one codec for every provider whose bytes are fetched the same way; the provider is inside the digest. Framing byte-identical to card (0x1007), but the byte path RESOLVES it (via source's fetch tier) instead of 404ing. Same codec-slot rule as the rest of the family: match on the CODEC, never the mh (identity 0x00 would fall through and score 0). Tier 5: once the fetched bytes' sha2-256 is linked onto the record it wins the canonical election with no addressing change."
},
{
"name": "provider-file-tachiyomi",
"cid": "bagfcaabebf2gcy3inf4w63ljnvwhu332gnvw423po42hg33ug4zdeytdonrtk4bsnq",
"rank": 5,
"note": "provider-file (0x100A) under the second registered source, (\"tachiyomi\", base32lower(sha256(\"https://www.mangakakalot.gg/manga/ragna-crimson/chapter-94\"))[..26] = \"mlzoz3knkow4sot722bcsc5p2l\"). A comic chapter, minted by meta-feeder-suwayomi from the chapter's address ON ITS SITE - never the Suwayomi host/port/row ids - so every box mints this same cid; redeemed into one CBZ. Same rank as any provider-file (rank ignores source); the vector proves the digest bytes (cid-formats.md section 8.3)."
},
{ {
"name": "sha1", "name": "sha1",
"cid": "baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra", "cid": "baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra",
+27 -1
View File
@@ -58,6 +58,31 @@ const (
// one record, and a real digest added later still wins the election. // one record, and a real digest added later still wins the election.
CodeCard = 0x1007 // custom — identity-multihash CID wrapping a card (work) id CodeCard = 0x1007 // custom — identity-multihash CID wrapping a card (work) id
// CodeYtVideo and CodeExtPlay are the DELEGATED-PLAYBACK locators — the
// limit case beyond CodeCard. A card addresses a work with no bytes
// anywhere; these address a *rendition* whose bytes exist but are
// permanently someone else's: an external player renders the media, nothing
// is transported, seeded or content-addressed.
//
// They rank on the CODEC like the rest of the family — their multihash is
// identity (0x00), so a rank that dispatched on the multihash would see 0x00,
// fall through and silently score 0. That is the exact drift that once made
// meta-share and meta-search elect different canonical CIDs for one record.
//
// Tier 5 is also what keeps the on-ramp safe: a delegated reference and a
// real CID can sit on one record, and if the media is ever archived to the
// mesh its sha2-256 wins the canonical election with no addressing change.
CodeYtVideo = 0x1008 // custom — identity-multihash CID wrapping a YouTube (kind, id)
CodeExtPlay = 0x1009 // custom — identity-multihash CID wrapping a URL to OPEN, never fetch
// CodeProviderFile is the FETCHABLE twin of CodeExtPlay: a provider-held
// file addressed as varint(len(source))‖source‖id — ("opensubtitles",
// "file:7061834"). Framing is byte-identical to CodeCard; only the byte
// path differs (it redeems the id through that source's feeder instead of
// 404ing). Same codec-slot rule and same tier: once the fetched bytes'
// sha2-256 is linked onto the record it wins with no addressing change.
CodeProviderFile = 0x100A // custom — identity-multihash CID wrapping a (source, id) provider file
// CodeNzbPosting is the INVERSE trap of the two above: its multihash is a // CodeNzbPosting is the INVERSE trap of the two above: its multihash is a
// REAL sha2-256 digest (over the normalised, sorted article Message-ID // REAL sha2-256 digest (over the normalised, sorted article Message-ID
// set of a Usenet posting we scanned ourselves — never a locator, no // set of a Usenet posting we scanned ourselves — never a locator, no
@@ -165,7 +190,8 @@ func Rank(cidStr string) int {
} }
// Opaque locators. Codec-only: their multihash is identity (0x00), so // Opaque locators. Codec-only: their multihash is identity (0x00), so
// there is nothing to match on the mh side. // there is nothing to match on the mh side.
if codec == CodeNzbRelease || codec == CodeURL || codec == CodeCard { if codec == CodeNzbRelease || codec == CodeURL || codec == CodeCard ||
codec == CodeYtVideo || codec == CodeExtPlay || codec == CodeProviderFile {
return RankLocator return RankLocator
} }
// nzb-posting: codec-only, checked BEFORE the mh switch below — its // nzb-posting: codec-only, checked BEFORE the mh switch below — its
+240
View File
@@ -0,0 +1,240 @@
// Package claim is the MMUR2 signed claim: signer uid says key = value about
// content cid, at ts (milliseconds, also the version).
//
// Wire spec (load-bearing for every implementation — Go here, Rust in
// meta-feeder-sdk's `claims` feature and the clients' record/ mirror, TS in
// the plugins' meta-core-client.ts; all pinned by testdata/claim-vectors.json):
//
// domain = "MMUR2\n"
// payload = canonical CBOR array [cid, uid, key, value, ts]
// bytes_to_sign = domain || payload
// sig = ECDSA-secp256k1(SHA-256(bytes_to_sign)), compact r||s, low-S
//
// value is CBOR text (public), bytes (private envelope) or null (retraction).
// Canonical = definite lengths, smallest integer heads — the encoder below is
// hand-rolled because those five shapes are all MMUR2 ever needs.
package claim
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/metazla/meta-core/internal/identity"
)
// Domain separates MMUR2 bytes from every other signed payload (MMUR1 records,
// identity challenges). Changing it invalidates every claim everywhere.
const Domain = "MMUR2\n"
// Kind of a claim value. The SQLite storage type mirrors it: TEXT, BLOB, NULL.
type Kind uint8
const (
KindNull Kind = iota
KindText
KindBytes
)
// Value is a claim's value. The zero Value is a retraction (null).
type Value struct {
Kind Kind
Text string
Bytes []byte
}
func Text(s string) Value { return Value{Kind: KindText, Text: s} }
func Bytes(b []byte) Value { return Value{Kind: KindBytes, Bytes: b} }
func Null() Value { return Value{} }
func (v Value) IsNull() bool { return v.Kind == KindNull }
// Equal reports byte-for-byte equality, kind included.
func (v Value) Equal(o Value) bool {
if v.Kind != o.Kind {
return false
}
switch v.Kind {
case KindText:
return v.Text == o.Text
case KindBytes:
return string(v.Bytes) == string(o.Bytes)
}
return true
}
// Claim is one signed vote.
type Claim struct {
CID string
UID string
Key string
Value Value
TS uint64
Sig []byte // 64-byte compact r||s
}
// SignedBytes returns the exact bytes the signature covers.
func SignedBytes(cid, uid, key string, v Value, ts uint64) []byte {
b := make([]byte, 0, len(Domain)+len(cid)+len(uid)+len(key)+len(v.Text)+len(v.Bytes)+32)
b = append(b, Domain...)
b = appendHead(b, 4, 5) // array(5)
b = appendText(b, cid)
b = appendText(b, uid)
b = appendText(b, key)
switch v.Kind {
case KindText:
b = appendText(b, v.Text)
case KindBytes:
b = appendHead(b, 2, uint64(len(v.Bytes)))
b = append(b, v.Bytes...)
default:
b = append(b, 0xf6) // null
}
return appendHead(b, 0, ts)
}
// SignedBytes of c.
func (c *Claim) SignedBytes() []byte {
return SignedBytes(c.CID, c.UID, c.Key, c.Value, c.TS)
}
func appendText(b []byte, s string) []byte {
b = appendHead(b, 3, uint64(len(s)))
return append(b, s...)
}
// appendHead writes a CBOR major type + argument in its smallest form.
func appendHead(b []byte, major byte, n uint64) []byte {
m := major << 5
switch {
case n < 24:
return append(b, m|byte(n))
case n <= 0xff:
return append(b, m|24, byte(n))
case n <= 0xffff:
return append(b, m|25, byte(n>>8), byte(n))
case n <= 0xffffffff:
return append(b, m|26, byte(n>>24), byte(n>>16), byte(n>>8), byte(n))
default:
return append(b, m|27, byte(n>>56), byte(n>>48), byte(n>>40), byte(n>>32),
byte(n>>24), byte(n>>16), byte(n>>8), byte(n))
}
}
// ErrBadSignature is returned for a signature that is malformed, high-S, or
// does not verify against the uid.
var ErrBadSignature = errors.New("bad signature")
// IsLowS reports whether a compact signature's S is in the lower half.
func IsLowS(sig []byte) bool {
if len(sig) != 64 {
return false
}
var s secp256k1.ModNScalar
if overflow := s.SetByteSlice(sig[32:]); overflow {
return false
}
return !s.IsOverHalfOrder()
}
// Verify checks the claim's shape and signature.
func (c *Claim) Verify() error {
if c.CID == "" || c.Key == "" || !identity.ValidUID(c.UID) {
return fmt.Errorf("%w: missing cid, key or valid uid", ErrBadSignature)
}
if len(c.Sig) != 64 || !IsLowS(c.Sig) {
return fmt.Errorf("%w: want a 64-byte low-S compact signature", ErrBadSignature)
}
if err := identity.Verify(c.UID, c.SignedBytes(), c.Sig); err != nil {
return fmt.Errorf("%w: %v", ErrBadSignature, err)
}
return nil
}
// Sign fills c.Sig with id's compact low-S signature. Used by the account-key
// service (users who sign through meta-core until its separate ticket removes
// it), tests and the one-off migration tool — never by the claims engine.
func (c *Claim) Sign(id *identity.Identity) error {
if id.UID != c.UID {
return fmt.Errorf("claim uid %s is not the signing identity %s", c.UID, id.UID)
}
sig, err := identity.SignCompact(id, c.SignedBytes())
if err != nil {
return err
}
c.Sig = sig
return nil
}
// jsonClaim is the §3.4 HTTP form.
type jsonClaim struct {
CID string `json:"cid"`
UID string `json:"uid"`
Key string `json:"key"`
Value json.RawMessage `json:"value"`
TS uint64 `json:"ts"`
Sig string `json:"sig"`
}
type jsonBytes struct {
B64 string `json:"b64"`
}
// MarshalJSON emits {"cid","uid","key","value","ts","sig"}; bytes values as
// {"b64": "..."}, retractions as null.
func (c Claim) MarshalJSON() ([]byte, error) {
var v json.RawMessage
var err error
switch c.Value.Kind {
case KindText:
v, err = json.Marshal(c.Value.Text)
case KindBytes:
v, err = json.Marshal(jsonBytes{B64: base64.StdEncoding.EncodeToString(c.Value.Bytes)})
default:
v = json.RawMessage("null")
}
if err != nil {
return nil, err
}
return json.Marshal(jsonClaim{
CID: c.CID, UID: c.UID, Key: c.Key, Value: v, TS: c.TS,
Sig: base64.StdEncoding.EncodeToString(c.Sig),
})
}
// UnmarshalJSON parses the §3.4 form.
func (c *Claim) UnmarshalJSON(data []byte) error {
var j jsonClaim
if err := json.Unmarshal(data, &j); err != nil {
return err
}
sig, err := base64.StdEncoding.DecodeString(j.Sig)
if err != nil {
return fmt.Errorf("sig: %w", err)
}
*c = Claim{CID: j.CID, UID: j.UID, Key: j.Key, TS: j.TS, Sig: sig}
raw := j.Value
if len(raw) == 0 || string(raw) == "null" {
return nil
}
if raw[0] == '"' {
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return err
}
c.Value = Text(s)
return nil
}
var b jsonBytes
if err := json.Unmarshal(raw, &b); err != nil || b.B64 == "" && string(raw) != `{"b64":""}` {
return fmt.Errorf("value must be a string, {\"b64\": ...} or null")
}
bs, err := base64.StdEncoding.DecodeString(b.B64)
if err != nil {
return fmt.Errorf("value.b64: %w", err)
}
c.Value = Bytes(bs)
return nil
}
+207
View File
@@ -0,0 +1,207 @@
package claim
import (
"encoding/hex"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/metazla/meta-core/internal/identity"
)
// The golden file every MMUR2 implementation is pinned to. Regenerate with
// CLAIM_VECTORS_UPDATE=1 go test ./internal/claim/ — then re-sync the vendored
// copies (scripts/check-claim-vectors.sh --fix). A change here is a wire break.
const vectorsPath = "testdata/claim-vectors.json"
// Fixed test key: the scalar 0x0101…01. Never used outside tests.
const testKeyHex = "0101010101010101010101010101010101010101010101010101010101010101"
type vector struct {
Name string `json:"name"`
CID string `json:"cid"`
UID string `json:"uid"`
Key string `json:"key"`
Value *string `json:"value"` // text value, or null
ValueB64 *string `json:"valueBytesHex,omitempty"` // bytes value as hex
TS uint64 `json:"ts"`
Signed string `json:"signedBytesHex"`
Sig string `json:"sigHex"` // deterministic RFC 6979, low-S
}
type vectorFile struct {
Comment string `json:"_comment"`
PrivateKeyHex string `json:"privateKeyHex"`
UID string `json:"uid"`
Vectors []vector `json:"vectors"`
HighS struct {
Note string `json:"note"`
Vector string `json:"vector"`
SigHex string `json:"sigHex"`
} `json:"highS"`
}
func (v vector) value(t *testing.T) Value {
t.Helper()
switch {
case v.ValueB64 != nil:
b, err := hex.DecodeString(*v.ValueB64)
if err != nil {
t.Fatal(err)
}
return Bytes(b)
case v.Value != nil:
return Text(*v.Value)
}
return Null()
}
func strp(s string) *string { return &s }
func testIdentity(t *testing.T) *identity.Identity {
t.Helper()
id, err := identity.Import(testKeyHex)
if err != nil {
t.Fatal(err)
}
return id
}
func cases(uid string) []vector {
return []vector{
{Name: "text", CID: "bafkreih5kapbjzq", UID: uid, Key: "title", Value: strp("The Matrix"), TS: 1793001234567},
{Name: "empty-text", CID: "bafkreih5kapbjzq", UID: uid, Key: "title", Value: strp(""), TS: 1},
{Name: "unicode", CID: "bafkreih5kapbjzq", UID: uid, Key: "titles/jpn/攻殻機動隊", Value: strp("true"), TS: 1793001234567},
{Name: "keyset-member", CID: "bagacbabaec7v3", UID: uid, Key: "cids/bafkreih5kapbjzq", Value: strp("true"), TS: 23},
{Name: "bytes", CID: "bafkreih5kapbjzq", UID: uid, Key: "seek", ValueB64: strp("00ff10ab"), TS: 24},
{Name: "retraction", CID: "bafkreih5kapbjzq", UID: uid, Key: "genres/Drama", TS: 255},
{Name: "long-value", CID: "bafkreih5kapbjzq", UID: uid, Key: "plot/eng", Value: strp(strings.Repeat("a", 300)), TS: 65536},
{Name: "ts-64bit", CID: "bafkreih5kapbjzq", UID: uid, Key: "year", Value: strp("1999"), TS: 1 << 40},
}
}
func TestVectors(t *testing.T) {
id := testIdentity(t)
if os.Getenv("CLAIM_VECTORS_UPDATE") == "1" {
writeVectors(t, id)
}
raw, err := os.ReadFile(vectorsPath)
if err != nil {
t.Fatalf("read vectors: %v (generate with CLAIM_VECTORS_UPDATE=1)", err)
}
var f vectorFile
if err := json.Unmarshal(raw, &f); err != nil {
t.Fatal(err)
}
if f.UID != id.UID {
t.Fatalf("vector uid %s != test key uid %s", f.UID, id.UID)
}
for _, v := range f.Vectors {
c := Claim{CID: v.CID, UID: v.UID, Key: v.Key, Value: v.value(t), TS: v.TS}
if got := hex.EncodeToString(c.SignedBytes()); got != v.Signed {
t.Errorf("%s: signed bytes\n got %s\nwant %s", v.Name, got, v.Signed)
}
if err := c.Sign(id); err != nil {
t.Fatal(err)
}
if got := hex.EncodeToString(c.Sig); got != v.Sig {
t.Errorf("%s: sig\n got %s\nwant %s", v.Name, got, v.Sig)
}
if err := c.Verify(); err != nil {
t.Errorf("%s: verify: %v", v.Name, err)
}
}
// The high-S twin of a valid signature must be refused.
for _, v := range f.Vectors {
if v.Name != f.HighS.Vector {
continue
}
sig, _ := hex.DecodeString(f.HighS.SigHex)
c := Claim{CID: v.CID, UID: v.UID, Key: v.Key, Value: v.value(t), TS: v.TS, Sig: sig}
if err := c.Verify(); !errors.Is(err, ErrBadSignature) {
t.Errorf("high-S signature accepted: %v", err)
}
}
}
func writeVectors(t *testing.T, id *identity.Identity) {
t.Helper()
f := vectorFile{
Comment: "MMUR2 golden vectors. Canonical copy: meta-core internal/claim/testdata. Do not edit by hand; see claim_test.go.",
PrivateKeyHex: testKeyHex,
UID: id.UID,
}
for _, v := range cases(id.UID) {
c := Claim{CID: v.CID, UID: v.UID, Key: v.Key, Value: v.value(t), TS: v.TS}
if err := c.Sign(id); err != nil {
t.Fatal(err)
}
v.Signed = hex.EncodeToString(c.SignedBytes())
v.Sig = hex.EncodeToString(c.Sig)
f.Vectors = append(f.Vectors, v)
}
first := f.Vectors[0]
sig, _ := hex.DecodeString(first.Sig)
f.HighS.Note = "same claim as `vector`, S replaced by n-S: valid ECDSA, but MMUR2 verifiers must refuse it"
f.HighS.Vector = first.Name
f.HighS.SigHex = hex.EncodeToString(highS(sig))
out, err := json.MarshalIndent(f, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll("testdata", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(vectorsPath, append(out, '\n'), 0o644); err != nil {
t.Fatal(err)
}
}
func highS(sig []byte) []byte {
var s secp256k1.ModNScalar
s.SetByteSlice(sig[32:])
s.Negate()
out := append([]byte{}, sig[:32]...)
b := s.Bytes()
return append(out, b[:]...)
}
func TestJSONRoundTrip(t *testing.T) {
id := testIdentity(t)
for _, v := range []Value{Text("x"), Bytes([]byte{0, 1, 2}), Null()} {
c := Claim{CID: "bafy", UID: id.UID, Key: "k", Value: v, TS: 42}
if err := c.Sign(id); err != nil {
t.Fatal(err)
}
b, err := json.Marshal(c)
if err != nil {
t.Fatal(err)
}
var back Claim
if err := json.Unmarshal(b, &back); err != nil {
t.Fatalf("%s: %v", b, err)
}
if !back.Value.Equal(v) || back.TS != 42 || string(back.Sig) != string(c.Sig) {
t.Errorf("round trip changed the claim: %s", b)
}
if err := back.Verify(); err != nil {
t.Errorf("%s: %v", b, err)
}
}
}
func TestTamperRejected(t *testing.T) {
id := testIdentity(t)
c := Claim{CID: "bafy", UID: id.UID, Key: "title", Value: Text("A"), TS: 1}
if err := c.Sign(id); err != nil {
t.Fatal(err)
}
c.Value = Text("B")
if err := c.Verify(); !errors.Is(err, ErrBadSignature) {
t.Fatalf("tampered value verified: %v", err)
}
}
+93
View File
@@ -0,0 +1,93 @@
{
"_comment": "MMUR2 golden vectors. Canonical copy: meta-core internal/claim/testdata. Do not edit by hand; see claim_test.go.",
"privateKeyHex": "0101010101010101010101010101010101010101010101010101010101010101",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"vectors": [
{
"name": "text",
"cid": "bafkreih5kapbjzq",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "title",
"value": "The Matrix",
"ts": 1793001234567,
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a657469746c656a546865204d61747269781b000001a17733a087",
"sigHex": "3ecac653f63cfc981564b5a5cc0db0459f2b4f19b810b6391d8f487216da0f3d2e560a2b61f4a81e320287cde753e1990d7949f4f518127f6505b8fb698ee8a8"
},
{
"name": "empty-text",
"cid": "bafkreih5kapbjzq",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "title",
"value": "",
"ts": 1,
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a657469746c656001",
"sigHex": "6eeb9ace8afe3987006c166eb0ec564b71e23d6a1c6211fdd55e8b4779c12dbb4f38c443faa3255c2ce7c6592e9d024e4ea57b5fa23446d1d4df647947e37642"
},
{
"name": "unicode",
"cid": "bafkreih5kapbjzq",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "titles/jpn/攻殻機動隊",
"value": "true",
"ts": 1793001234567,
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a781a7469746c65732f6a706e2fe694bbe6aebbe6a99fe58b95e99a8a64747275651b000001a17733a087",
"sigHex": "64ec6342c598782df278b49636a34da4f4c009423a584454a3284efad5426aa517d83873b09a4fdd3fd32a5e9a5fef0880126edd11db773045caeb6e634bc207"
},
{
"name": "keyset-member",
"cid": "bagacbabaec7v3",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "cids/bafkreih5kapbjzq",
"value": "true",
"ts": 23,
"signedBytesHex": "4d4d5552320a856e6261676163626162616563377633782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a75636964732f6261666b72656968356b6170626a7a71647472756517",
"sigHex": "8f707847895ccb522f1507dbab23330bd2c567d6fd1882df3bfae65bef6601024832f0b181146dcfb089aae91f5beac47da970373ab14370c4ac41410f3bf761"
},
{
"name": "bytes",
"cid": "bafkreih5kapbjzq",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "seek",
"value": null,
"valueBytesHex": "00ff10ab",
"ts": 24,
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a647365656b4400ff10ab1818",
"sigHex": "09671b27c03edd510029ec550b35e95e3900dd37a5d688ea04c3f0409979c7236bc4a1b5fa9d1b76265da85df24693a51f0ced528a6171c90a70b92db9f246e4"
},
{
"name": "retraction",
"cid": "bafkreih5kapbjzq",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "genres/Drama",
"value": null,
"ts": 255,
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a6c67656e7265732f4472616d61f618ff",
"sigHex": "d0c29d8edac4b78005eaf895cd9d534cb080b22ba9f7e79a5abf4d9bd906dc494464a2b388653452bca16e351132a9a92370953fa047e693459e55b7965449ac"
},
{
"name": "long-value",
"cid": "bafkreih5kapbjzq",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "plot/eng",
"value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"ts": 65536,
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a68706c6f742f656e6779012c6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161611a00010000",
"sigHex": "383b2e40f913eb5240e1eae2cfa392f967321d3701623c2b3df344984686d888198bce1c78847d1898b35f991ec3465c382c45e03b9a77f2ae7d1fd740391dd2"
},
{
"name": "ts-64bit",
"cid": "bafkreih5kapbjzq",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "year",
"value": "1999",
"ts": 1099511627776,
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a647965617264313939391b0000010000000000",
"sigHex": "2b9b42ef4ebd7fc8d3ca0a514679ca4104f2e627da3a84c7b9664b27ebbdf73946cc37a04e4ea15dbed23a0e48e8797c92fcba5598b012305a314bf776398e0d"
}
],
"highS": {
"note": "same claim as `vector`, S replaced by n-S: valid ECDSA, but MMUR2 verifiers must refuse it",
"vector": "text",
"sigHex": "3ecac653f63cfc981564b5a5cc0db0459f2b4f19b810b6391d8f487216da0f3dd1a9f5d49e0b57e1cdfd783218ac1e65ad3592f1ba308dbc5acca59166a75899"
}
}
+50 -13
View File
@@ -19,8 +19,14 @@ type Config struct {
BaseURL string // Base URL for stable service discovery BaseURL string // Base URL for stable service discovery
PublicURL string // Browser-facing URL for the service-bar nav; overrides BaseURL when set (e.g. a debug-direct port with no Caddy in front) PublicURL string // Browser-facing URL for the service-bar nav; overrides BaseURL when set (e.g. a debug-direct port with no Caddy in front)
// Redis configuration // Storage configuration
RedisPort int // Redis port (default: 6379) DBPathOverride string // META_CORE_DB_PATH; empty = <MetaCorePath>/db/meta-core.db (truth)
CachePathOverride string // META_CORE_CACHE_PATH; empty = <MetaCorePath>/db-cache/cache.db
// Report inbox (internal/reports): its own SQLite file, bounded.
ReportsPathOverride string // META_CORE_REPORTS_DB_PATH; empty = <MetaCorePath>/db/reports.db
ReportsMax int // META_CORE_REPORTS_MAX: reports kept (default 5000; closed evicted first)
ReportsMaxMB int // META_CORE_REPORTS_MAX_MB: total body size kept, MiB (default 256)
// HTTP API configuration // HTTP API configuration
HTTPPort int // HTTP API port (default: 9000) HTTPPort int // HTTP API port (default: 9000)
@@ -40,6 +46,14 @@ type Config struct {
// Mount configuration // Mount configuration
MountsDir string // Path to mounts configuration (default: /meta-core/mounts) MountsDir string // Path to mounts configuration (default: /meta-core/mounts)
// UDP discovery (beacon v2). Replaces the file-based registry that used
// to live under /meta-core/services; see
// docs/project-architecture/beacon-v2.md.
EnableUDPDiscovery bool // Advertise + listen on the multicast group (default: true)
DiscoveryGroup string // BEACON_GROUP: IPv4 multicast group (default: 239.255.99.1)
DiscoveryPort int // BEACON_PORT: UDP port (default: 9099)
DiscoveryIntervalMS int // BEACON_INTERVAL_MS: unsolicited advertise interval (default: 10000)
} }
// Load creates a Config from environment variables // Load creates a Config from environment variables
@@ -52,7 +66,11 @@ func Load() *Config {
APIPort: getEnvInt("API_PORT", 8180), APIPort: getEnvInt("API_PORT", 8180),
BaseURL: getEnv("BASE_URL", ""), BaseURL: getEnv("BASE_URL", ""),
PublicURL: getEnv("META_CORE_PUBLIC_URL", ""), PublicURL: getEnv("META_CORE_PUBLIC_URL", ""),
RedisPort: getEnvInt("REDIS_PORT", 6379), DBPathOverride: getEnv("META_CORE_DB_PATH", ""),
CachePathOverride: getEnv("META_CORE_CACHE_PATH", ""),
ReportsPathOverride: getEnv("META_CORE_REPORTS_DB_PATH", ""),
ReportsMax: getEnvInt("META_CORE_REPORTS_MAX", 5000),
ReportsMaxMB: getEnvInt("META_CORE_REPORTS_MAX_MB", 256),
HTTPPort: getEnvInt("META_CORE_HTTP_PORT", 9000), HTTPPort: getEnvInt("META_CORE_HTTP_PORT", 9000),
HTTPHost: getEnv("META_CORE_HTTP_HOST", "0.0.0.0"), HTTPHost: getEnv("META_CORE_HTTP_HOST", "0.0.0.0"),
HealthCheckIntervalMS: getEnvInt("HEALTH_CHECK_INTERVAL_MS", 5000), HealthCheckIntervalMS: getEnvInt("HEALTH_CHECK_INTERVAL_MS", 5000),
@@ -63,6 +81,10 @@ func Load() *Config {
WatchIntervalMS: getEnvInt("WATCH_INTERVAL_MS", 1000), WatchIntervalMS: getEnvInt("WATCH_INTERVAL_MS", 1000),
DebounceMS: getEnvInt("DEBOUNCE_MS", 30000), DebounceMS: getEnvInt("DEBOUNCE_MS", 30000),
EnableFileWatcher: getEnvBool("ENABLE_FILE_WATCHER", true), EnableFileWatcher: getEnvBool("ENABLE_FILE_WATCHER", true),
EnableUDPDiscovery: getEnvBool("ENABLE_UDP_DISCOVERY", true),
DiscoveryGroup: getEnv("BEACON_GROUP", "239.255.99.1"),
DiscoveryPort: getEnvInt("BEACON_PORT", 9099),
DiscoveryIntervalMS: getEnvInt("BEACON_INTERVAL_MS", 10000),
} }
// Set mounts directory // Set mounts directory
@@ -71,24 +93,39 @@ func Load() *Config {
return cfg return cfg
} }
// LockFilePath returns the path to the leader lock file // LockFilePath returns the path to the database-volume mutex (one meta-core
// per database file). Held by
// docker/leader-election.sh; nothing reads it, and it is NOT service
// discovery — see docs/project-architecture/beacon-v2.md.
func (c *Config) LockFilePath() string { func (c *Config) LockFilePath() string {
return c.MetaCorePath + "/locks/kv-leader.lock" return c.MetaCorePath + "/locks/kv-leader.lock"
} }
// InfoFilePath returns the path to the leader info file // DBPath returns the path of meta-core's truth database (backed up).
func (c *Config) InfoFilePath() string { func (c *Config) DBPath() string {
return c.MetaCorePath + "/locks/kv-leader.info" if c.DBPathOverride != "" {
return c.DBPathOverride
}
return c.MetaCorePath + "/db/meta-core.db"
} }
// RedisDataDir returns the path to Redis data directory // CachePath returns the path of meta-core's cache database: everything
func (c *Config) RedisDataDir() string { // derived from truth, never backed up, rebuilt when missing.
return c.MetaCorePath + "/db/redis" func (c *Config) CachePath() string {
if c.CachePathOverride != "" {
return c.CachePathOverride
}
return c.MetaCorePath + "/db-cache/cache.db"
} }
// ServicesDir returns the path to services directory // ReportsDBPath returns the path of the report inbox database. It sits beside
func (c *Config) ServicesDir() string { // the truth database (so it is backed up with it) but is a separate file:
return c.MetaCorePath + "/services" // reports are diagnostics, never metadata.
func (c *Config) ReportsDBPath() string {
if c.ReportsPathOverride != "" {
return c.ReportsPathOverride
}
return c.MetaCorePath + "/db/reports.db"
} }
// MountsFilePath returns the path to the mounts configuration file // MountsFilePath returns the path to the mounts configuration file
-196
View File
@@ -1,196 +0,0 @@
package discovery
import (
"encoding/json"
"log"
"os"
"path/filepath"
"sync"
"time"
"github.com/metazla/meta-core/internal/config"
)
// CleanupStats holds statistics about the cleanup process
type CleanupStats struct {
LastRunTime string `json:"lastRunTime,omitempty"`
ServicesRemoved int `json:"servicesRemoved"`
TotalRuns int `json:"totalRuns"`
Errors int `json:"errors"`
}
// Cleaner handles periodic cleanup of dead service registration files
type Cleaner struct {
config *config.Config
servicesDir string
ownFile string // Our own service file (never delete)
stats CleanupStats
stopChan chan struct{}
wg sync.WaitGroup
mu sync.RWMutex
}
// NewCleaner creates a new service cleaner
func NewCleaner(cfg *config.Config) *Cleaner {
hostname, _ := os.Hostname()
return &Cleaner{
config: cfg,
servicesDir: cfg.ServicesDir(),
ownFile: filepath.Join(cfg.ServicesDir(), cfg.ServiceName+"-"+hostname+".json"),
stopChan: make(chan struct{}),
}
}
// Start begins the cleanup loop
func (c *Cleaner) Start() error {
log.Printf("[Cleaner] Starting dead service cleanup (interval: %dms, threshold: %dms)",
c.config.CleanupIntervalMS, c.config.DeadServiceThresholdMS)
c.wg.Add(1)
go c.cleanupLoop()
return nil
}
// Stop stops the cleanup loop
func (c *Cleaner) Stop() error {
log.Println("[Cleaner] Stopping dead service cleanup...")
close(c.stopChan)
c.wg.Wait()
return nil
}
// cleanupLoop runs the periodic cleanup
func (c *Cleaner) cleanupLoop() {
defer c.wg.Done()
// Run an initial cleanup shortly after starting
initialDelay := time.NewTimer(30 * time.Second)
select {
case <-c.stopChan:
initialDelay.Stop()
return
case <-initialDelay.C:
c.runCleanup()
}
ticker := time.NewTicker(time.Duration(c.config.CleanupIntervalMS) * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-c.stopChan:
return
case <-ticker.C:
c.runCleanup()
}
}
}
// runCleanup scans the services directory and removes dead services
func (c *Cleaner) runCleanup() {
c.mu.Lock()
c.stats.TotalRuns++
c.stats.LastRunTime = time.Now().UTC().Format(time.RFC3339)
c.mu.Unlock()
entries, err := os.ReadDir(c.servicesDir)
if err != nil {
if !os.IsNotExist(err) {
log.Printf("[Cleaner] Failed to read services directory: %v", err)
c.mu.Lock()
c.stats.Errors++
c.mu.Unlock()
}
return
}
threshold := time.Duration(c.config.DeadServiceThresholdMS) * time.Millisecond
removedCount := 0
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
filePath := filepath.Join(c.servicesDir, entry.Name())
// Never delete our own service file
if filePath == c.ownFile {
continue
}
// Check if service is dead
isDead, serviceName, age := c.isServiceDead(filePath, threshold)
if isDead {
if err := os.Remove(filePath); err != nil {
log.Printf("[Cleaner] Failed to remove dead service file %s: %v", entry.Name(), err)
c.mu.Lock()
c.stats.Errors++
c.mu.Unlock()
} else {
ageMinutes := age.Minutes()
log.Printf("[Cleaner] Removed dead service: %s (last heartbeat: %.1f minutes ago)", serviceName, ageMinutes)
removedCount++
}
}
}
if removedCount > 0 {
c.mu.Lock()
c.stats.ServicesRemoved += removedCount
c.mu.Unlock()
log.Printf("[Cleaner] Cleanup complete: removed %d dead service(s)", removedCount)
}
}
// isServiceDead checks if a service file represents a dead service
// Returns: isDead, serviceName, age since last heartbeat
func (c *Cleaner) isServiceDead(filePath string, threshold time.Duration) (bool, string, time.Duration) {
data, err := os.ReadFile(filePath)
if err != nil {
// Can't read file - treat as dead to clean up corrupted files
log.Printf("[Cleaner] Cannot read service file %s: %v (treating as dead)", filepath.Base(filePath), err)
return true, filepath.Base(filePath), threshold
}
var info ServiceInfo
if err := json.Unmarshal(data, &info); err != nil {
// Corrupted JSON - skip rather than delete (might be recoverable)
log.Printf("[Cleaner] Skipping corrupted service file %s: %v", filepath.Base(filePath), err)
return false, "", 0
}
// Parse last heartbeat timestamp
if info.LastHeartbeat == "" {
// No heartbeat timestamp - treat as dead
return true, info.Name, threshold
}
lastHeartbeat, err := time.Parse(time.RFC3339, info.LastHeartbeat)
if err != nil {
// Invalid timestamp - treat as dead
log.Printf("[Cleaner] Invalid timestamp in service file %s: %v (treating as dead)", filepath.Base(filePath), err)
return true, info.Name, threshold
}
age := time.Since(lastHeartbeat)
if age > threshold {
return true, info.Name, age
}
return false, info.Name, age
}
// Stats returns a copy of the current cleanup statistics
func (c *Cleaner) Stats() CleanupStats {
c.mu.RLock()
defer c.mu.RUnlock()
return CleanupStats{
LastRunTime: c.stats.LastRunTime,
ServicesRemoved: c.stats.ServicesRemoved,
TotalRuns: c.stats.TotalRuns,
Errors: c.stats.Errors,
}
}
-286
View File
@@ -1,286 +0,0 @@
package discovery
import (
"encoding/json"
"fmt"
"log"
"net"
"os"
"path/filepath"
"sync"
"time"
"github.com/metazla/meta-core/internal/config"
)
// ServiceInfo contains simplified service registration data
// URLs are obtained via the /urls API endpoint
type ServiceInfo struct {
Name string `json:"name"`
Hostname string `json:"hostname"`
BaseUrl string `json:"baseUrl"`
Status string `json:"status"`
LastHeartbeat string `json:"lastHeartbeat"`
}
// Service handles service registration and discovery
type Service struct {
config *config.Config
servicesDir string
serviceFile string
info *ServiceInfo
mu sync.RWMutex
stopChan chan struct{}
wg sync.WaitGroup
}
// NewService creates a new service discovery instance
func NewService(cfg *config.Config) *Service {
hostname, _ := os.Hostname()
return &Service{
config: cfg,
servicesDir: cfg.ServicesDir(),
serviceFile: filepath.Join(cfg.ServicesDir(), cfg.ServiceName+"-"+hostname+".json"),
stopChan: make(chan struct{}),
}
}
// Start begins service registration and heartbeat
func (s *Service) Start() error {
log.Printf("[Discovery] Starting service discovery for %s", s.config.ServiceName)
// Ensure services directory exists
if err := os.MkdirAll(s.servicesDir, 0755); err != nil {
return fmt.Errorf("failed to create services directory: %w", err)
}
// Build and register service info
s.info = s.buildServiceInfo()
if err := s.register(); err != nil {
return fmt.Errorf("failed to register service: %w", err)
}
// Start heartbeat loop
s.wg.Add(1)
go s.heartbeatLoop()
return nil
}
// Stop stops service discovery and unregisters
func (s *Service) Stop() error {
log.Println("[Discovery] Stopping service discovery...")
close(s.stopChan)
s.wg.Wait()
// Unregister by removing service file
if err := os.Remove(s.serviceFile); err != nil && !os.IsNotExist(err) {
log.Printf("[Discovery] Failed to remove service file: %v", err)
}
return nil
}
// buildServiceInfo creates the service info for this instance
func (s *Service) buildServiceInfo() *ServiceInfo {
hostname, _ := os.Hostname()
ip := getLocalIP()
// Browser-facing nav URL: prefer META_CORE_PUBLIC_URL (the reachable URL
// when there's no Caddy perimeter in front — e.g. a debug-direct port),
// then BASE_URL (the Caddy perimeter URL), then the internal IP.
var baseUrl string
if s.config.PublicURL != "" {
baseUrl = s.config.PublicURL
} else if s.config.BaseURL != "" {
baseUrl = s.config.BaseURL
} else {
baseUrl = fmt.Sprintf("http://%s:%d", ip, s.config.APIPort)
}
info := &ServiceInfo{
Name: s.config.ServiceName,
Hostname: hostname,
BaseUrl: baseUrl,
Status: "running",
LastHeartbeat: time.Now().UTC().Format(time.RFC3339),
}
return info
}
// register writes service info to file
func (s *Service) register() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.writeServiceInfo(s.info)
}
// writeServiceInfo atomically writes service info to file
func (s *Service) writeServiceInfo(info *ServiceInfo) error {
tempPath := s.serviceFile + ".tmp"
data, err := json.MarshalIndent(info, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(tempPath, data, 0644); err != nil {
return err
}
return os.Rename(tempPath, s.serviceFile)
}
// heartbeatLoop periodically updates the last heartbeat
func (s *Service) heartbeatLoop() {
defer s.wg.Done()
ticker := time.NewTicker(time.Duration(s.config.HeartbeatIntervalMS) * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-s.stopChan:
return
case <-ticker.C:
s.heartbeat()
}
}
}
// heartbeat updates the last heartbeat timestamp
func (s *Service) heartbeat() {
s.mu.Lock()
defer s.mu.Unlock()
if s.info == nil {
return
}
s.info.LastHeartbeat = time.Now().UTC().Format(time.RFC3339)
if err := s.writeServiceInfo(s.info); err != nil {
log.Printf("[Discovery] Failed to update heartbeat: %v", err)
}
}
// UpdateStatus updates the service status
func (s *Service) UpdateStatus(status string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.info == nil {
return fmt.Errorf("service not registered")
}
s.info.Status = status
s.info.LastHeartbeat = time.Now().UTC().Format(time.RFC3339)
return s.writeServiceInfo(s.info)
}
// Discover finds a service by name
// Looks for files matching pattern: name-*.json (hostname-based naming)
func (s *Service) Discover(name string) (*ServiceInfo, error) {
// First try exact match for backward compatibility
exactPath := filepath.Join(s.servicesDir, name+".json")
if data, err := os.ReadFile(exactPath); err == nil {
var info ServiceInfo
if err := json.Unmarshal(data, &info); err == nil {
return s.checkStale(&info), nil
}
}
// Search for hostname-based files: name-*.json
pattern := filepath.Join(s.servicesDir, name+"-*.json")
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, err
}
// Return the first valid service found
for _, match := range matches {
data, err := os.ReadFile(match)
if err != nil {
continue
}
var info ServiceInfo
if err := json.Unmarshal(data, &info); err != nil {
continue
}
return s.checkStale(&info), nil
}
return nil, nil
}
// checkStale marks a service as stale if heartbeat is too old
func (s *Service) checkStale(info *ServiceInfo) *ServiceInfo {
lastHeartbeat, err := time.Parse(time.RFC3339, info.LastHeartbeat)
if err == nil {
staleThreshold := time.Duration(s.config.StaleThresholdMS) * time.Millisecond
if time.Since(lastHeartbeat) > staleThreshold {
info.Status = "stale"
}
}
return info
}
// DiscoverAll finds all registered services
func (s *Service) DiscoverAll() ([]*ServiceInfo, error) {
entries, err := os.ReadDir(s.servicesDir)
if err != nil {
if os.IsNotExist(err) {
return []*ServiceInfo{}, nil
}
return nil, err
}
var services []*ServiceInfo
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
filePath := filepath.Join(s.servicesDir, entry.Name())
data, err := os.ReadFile(filePath)
if err != nil {
log.Printf("[Discovery] Failed to read service file %s: %v", entry.Name(), err)
continue
}
var info ServiceInfo
if err := json.Unmarshal(data, &info); err != nil {
log.Printf("[Discovery] Failed to parse service file %s: %v", entry.Name(), err)
continue
}
services = append(services, s.checkStale(&info))
}
return services, nil
}
// getLocalIP returns the local IP address
func getLocalIP() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
hostname, _ := os.Hostname()
return hostname
}
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
return ipnet.IP.String()
}
}
}
hostname, _ := os.Hostname()
return hostname
}
-222
View File
@@ -1,222 +0,0 @@
package events
import (
"context"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/redis/go-redis/v9"
"github.com/metazla/meta-core/internal/storage"
)
const (
// MetaEventsStream is the Redis stream name for metadata events
MetaEventsStream = "meta:events"
// MetaEventsMaxLen is the approximate cap on stream length. Bounded
// retention is a contract requirement for the SSE wire — see
// docs/api-mediated-access.md "Backing-store retention and lifecycle".
// Cheap (~) trim keeps growth bounded without serialising writes.
MetaEventsMaxLen = 100_000
)
// MetaPublisher subscribes to Redis keyspace notifications
// and publishes metadata change events to the meta:events stream
type MetaPublisher struct {
client *redis.Client
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
running bool
mu sync.RWMutex
}
// NewMetaPublisher creates a new MetaPublisher
func NewMetaPublisher(client *redis.Client) *MetaPublisher {
ctx, cancel := context.WithCancel(context.Background())
return &MetaPublisher{
client: client,
ctx: ctx,
cancel: cancel,
}
}
// Start begins subscribing to keyspace notifications.
//
// The stream is NOT truncated on startup — the SSE wire contract requires
// Last-Event-ID resumption to survive a meta-core restart, which means
// entries must persist across restart. Growth is bounded instead by
// MAXLEN ~ MetaEventsMaxLen on every XAdd (see publishEvent).
func (p *MetaPublisher) Start() error {
p.mu.Lock()
if p.running {
p.mu.Unlock()
return nil
}
p.running = true
p.mu.Unlock()
p.wg.Add(1)
go p.subscribeLoop()
log.Println("[MetaPublisher] Started - listening for keyspace notifications")
return nil
}
// Stop stops the publisher
func (p *MetaPublisher) Stop() error {
p.mu.Lock()
if !p.running {
p.mu.Unlock()
return nil
}
p.running = false
p.mu.Unlock()
p.cancel()
p.wg.Wait()
log.Println("[MetaPublisher] Stopped")
return nil
}
// IsRunning returns true if the publisher is running
func (p *MetaPublisher) IsRunning() bool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.running
}
// subscribeLoop subscribes to keyspace notifications and publishes events
func (p *MetaPublisher) subscribeLoop() {
defer p.wg.Done()
// Subscribe to all file:* keyspace events
// Pattern: __keyspace@0__:file:*
pattern := "__keyspace@0__:file:*"
pubsub := p.client.PSubscribe(p.ctx, pattern)
defer pubsub.Close()
log.Printf("[MetaPublisher] Subscribed to pattern: %s", pattern)
ch := pubsub.Channel()
for {
select {
case <-p.ctx.Done():
return
case msg := <-ch:
if msg == nil {
continue
}
// msg.Channel: __keyspace@0__:file:midhash256:abc123/tmdb
// msg.Payload: set, del, expire, etc.
p.publishEvent(msg.Channel, msg.Payload)
}
}
}
// publishEvent publishes a keyspace event to the meta:events stream
func (p *MetaPublisher) publishEvent(channel, operation string) {
// Extract key from channel: __keyspace@0__:file:xxx/field -> file:xxx/field
key := strings.TrimPrefix(channel, "__keyspace@0__:")
// Skip index key and non-file keys
if strings.Contains(key, "__index__") || !strings.HasPrefix(key, "file:") {
return
}
// Skip the per-record field index. It is bookkeeping (see
// storage.FieldsField), not a metadata field — publishing it would make
// every downstream consumer that parses `file:<id>/<field>` off this
// stream (meta-search, meta-dup, meta-fuse, meta-sort) see a phantom
// field change on a key they can never read.
if strings.HasSuffix(key, "/"+storage.FieldsField) {
return
}
// Build event fields
fields := map[string]interface{}{
"type": operation, // "set", "del", "expire"
"key": key, // "file:midhash256:abc123/tmdb"
"ts": fmt.Sprintf("%d", time.Now().UnixMilli()),
}
// Approximate MAXLEN trim keeps the stream bounded across restarts
// (see Start). Cap is intentionally generous; most consumers reconnect
// well within retention.
_, err := p.client.XAdd(p.ctx, &redis.XAddArgs{
Stream: MetaEventsStream,
MaxLen: MetaEventsMaxLen,
Approx: true,
Values: fields,
}).Result()
if err != nil {
log.Printf("[MetaPublisher] Error publishing event: %v", err)
}
}
// publishEventDirect publishes an event directly (not from keyspace notification)
func (p *MetaPublisher) publishEventDirect(operation, key string) {
fields := map[string]interface{}{
"type": operation,
"key": key,
"ts": fmt.Sprintf("%d", time.Now().UnixMilli()),
}
_, err := p.client.XAdd(p.ctx, &redis.XAddArgs{
Stream: MetaEventsStream,
MaxLen: MetaEventsMaxLen,
Approx: true,
Values: fields,
}).Result()
if err != nil {
log.Printf("[MetaPublisher] Error publishing direct event: %v", err)
}
}
// RepublishFunc is a function type for getting metadata that can be republished
type RepublishFunc func() (hashIDs []string, getMetadata func(hashID string) (map[string]string, error), err error)
// RepublishAllMetadata iterates over all existing file metadata and publishes events
// This is called on startup and can be triggered via API
func (p *MetaPublisher) RepublishAllMetadata(republishFunc RepublishFunc) (int, error) {
p.mu.RLock()
running := p.running
p.mu.RUnlock()
if !running {
return 0, fmt.Errorf("publisher not running")
}
// Get all hash IDs and metadata getter
hashIDs, getMetadata, err := republishFunc()
if err != nil {
return 0, fmt.Errorf("failed to get hash IDs: %w", err)
}
log.Printf("[MetaPublisher] Republishing events for %d files...", len(hashIDs))
publishedCount := 0
for _, hashID := range hashIDs {
// Get all properties for this file
metadata, err := getMetadata(hashID)
if err != nil || metadata == nil {
continue
}
// Publish a set event for each property
for property := range metadata {
key := fmt.Sprintf("file:%s/%s", hashID, property)
p.publishEventDirect("set", key)
publishedCount++
}
}
log.Printf("[MetaPublisher] Republished %d property events", publishedCount)
return publishedCount, nil
}
+126
View File
@@ -0,0 +1,126 @@
package identity
// In-memory, single-use challenge store backing proof-of-possession on the
// account operations that need it (reveal, delete).
//
// In memory on purpose: a challenge is worthless once redeemed and worthless
// once expired, so persisting it would only widen the window in which a leaked
// one is useful. A meta-core restart invalidates every challenge in flight,
// which costs a client one retry.
//
// Mirrors the sign-in challenge in meta-watch's src/auth.rs, down to the TTL —
// the browser signs immediately, so seconds is the right order and a narrow
// window bounds replay if one ever surfaces in a proxy log.
import (
"errors"
"sync"
"time"
)
// ChallengeTTL is how long an issued challenge stays answerable.
const ChallengeTTL = 120 * time.Second
// maxChallenges caps the store. An unauthenticated caller can mint challenges
// for any uid it can name, so the map is attacker-growable; without a ceiling
// that is a memory-exhaustion lever. At the cap, issuing fails rather than
// evicting: dropping someone else's live challenge on demand would let an
// attacker deny sign-outs, which is worse than making them retry.
const maxChallenges = 4096
var (
// ErrChallengeUnknown covers absent, already-redeemed and expired alike.
// One error for all three deliberately: distinguishing them tells a prober
// whether a challenge string was ever real.
ErrChallengeUnknown = errors.New("challenge is unknown, already used, or expired")
ErrChallengeFull = errors.New("too many challenges in flight; try again shortly")
)
type challengeEntry struct {
uid string
action Action
expires time.Time
}
// ChallengeStore issues and redeems challenges. Safe for concurrent use.
type ChallengeStore struct {
mu sync.Mutex
entries map[string]challengeEntry
ttl time.Duration
// now is swappable so the tests can age entries without sleeping.
now func() time.Time
}
func NewChallengeStore() *ChallengeStore {
return &ChallengeStore{
entries: make(map[string]challengeEntry),
ttl: ChallengeTTL,
now: time.Now,
}
}
// Issue mints a challenge for (uid, action) and remembers it until redeemed or
// expired. Returns the literal text the client signs and its expiry.
func (s *ChallengeStore) Issue(uid string, action Action) (string, time.Time, error) {
if !validUID(uid) {
return "", time.Time{}, errors.New("invalid uid")
}
if !ValidAction(action) {
return "", time.Time{}, errors.New("unknown action")
}
text, err := NewChallengeText(uid, action)
if err != nil {
return "", time.Time{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
s.sweepLocked()
if len(s.entries) >= maxChallenges {
return "", time.Time{}, ErrChallengeFull
}
expires := s.now().Add(s.ttl)
s.entries[text] = challengeEntry{uid: uid, action: action, expires: expires}
return text, expires, nil
}
// Redeem consumes a challenge, asserting it was issued for this exact uid and
// action. Single-use: the entry is dropped on any lookup that finds it, so a
// mismatched redemption burns the challenge rather than leaving it to be
// retried against a different account.
func (s *ChallengeStore) Redeem(text, uid string, action Action) error {
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.entries[text]
if !ok {
return ErrChallengeUnknown
}
delete(s.entries, text)
if s.now().After(entry.expires) {
return ErrChallengeUnknown
}
if entry.uid != uid || entry.action != action {
return ErrChallengeUnknown
}
// Re-read the signed bytes rather than trusting the map key alone: the
// signature covers `text`, so `text` is what has to name this uid+action.
if !ChallengeMatches(text, uid, action) {
return ErrChallengeUnknown
}
return nil
}
// sweepLocked drops expired entries. Caller holds the lock.
//
// Called only from Issue: with no background goroutine, expiry is enforced
// lazily on the one path that grows the map, which is exactly where the bound
// matters.
func (s *ChallengeStore) sweepLocked() {
now := s.now()
for text, e := range s.entries {
if now.After(e.expires) {
delete(s.entries, text)
}
}
}
+24
View File
@@ -307,6 +307,30 @@ func Sign(id *Identity, bytes []byte) ([]byte, error) {
return sig.Serialize(), nil return sig.Serialize(), nil
} }
// SignCompact is Sign with the 64-byte compact r||s encoding MMUR2 claims
// use. decred's RFC 6979 signer always emits low-S, which MMUR2 requires.
func SignCompact(id *Identity, bytes []byte) ([]byte, error) {
if id == nil {
return nil, errors.New("no identity loaded")
}
raw, err := hex.DecodeString(id.PrivateKeyHex)
if err != nil {
return nil, fmt.Errorf("decode private key: %w", err)
}
if len(raw) != 32 {
return nil, fmt.Errorf("private key length %d", len(raw))
}
priv := secp256k1.PrivKeyFromBytes(raw)
digest := sha256.Sum256(bytes)
sig := ecdsa.Sign(priv, digest[:])
r, s := sig.R(), sig.S()
out := make([]byte, 64)
rb, sb := r.Bytes(), s.Bytes()
copy(out[:32], rb[:])
copy(out[32:], sb[:])
return out, nil
}
// DeriveAEADKey derives the symmetric key used to encrypt private-tier // DeriveAEADKey derives the symmetric key used to encrypt private-tier
// User Data Layer records. The KDF parameters (salt, info, length) are // User Data Layer records. The KDF parameters (salt, info, length) are
// fixed for the v1 wire format. // fixed for the v1 wire format.
+43
View File
@@ -0,0 +1,43 @@
package identity
import (
"encoding/base64"
"testing"
)
// A real signature produced by @noble/secp256k1 v3 — the library the dashboard
// and meta-watch both sign with — pinned as a fixture and verified here.
//
// This is the one seam neither side's own tests can cover: Go signs DER, the
// browser signs 64-byte compact, and every unit test above verifies signatures
// this package produced itself. If the encodings ever disagree, the gate fails
// closed in production (nobody can delete their own account) while every other
// test stays green. Regenerate with:
//
// node -e 'import("@noble/secp256k1").then(async s => { … })'
//
// against the same fixed scalar (32 zero bytes with 42 in the last).
func TestVerify_AcceptsARealNobleV3Signature(t *testing.T) {
const (
uid = "ztbJ71eLvfmGXw2ydaSPHavfwjva5Bzadutw2tvYDVFVY"
challenge = "metamesh-identity-v1:delete:ztbJ71eLvfmGXw2ydaSPHavfwjva5Bzadutw2tvYDVFVY:deadbeef"
sigB64 = "lIiZGL0j9l/w2WXwzwe56cICnusuYdD3oaTuYfFTtNJBR9MolXFPk3014WQeAlvmbX4LfnW+TQz4asqoSwmSAQ=="
)
sig, err := base64.StdEncoding.DecodeString(sigB64)
if err != nil {
t.Fatalf("decode fixture: %v", err)
}
if len(sig) != 64 {
t.Fatalf("fixture is %d bytes, expected noble's 64-byte compact form", len(sig))
}
if err := Verify(uid, []byte(challenge), sig); err != nil {
t.Fatalf("a genuine browser signature must verify: %v", err)
}
// And the same signature must not verify over a different challenge — a
// verifier that ignored the payload would pass the line above too.
if err := Verify(uid, []byte(challenge+"x"), sig); err == nil {
t.Fatal("the fixture verified over the wrong payload")
}
}
+160
View File
@@ -0,0 +1,160 @@
package identity
// Signature verification against a uid, and the challenge vocabulary that
// makes a signature mean "I hold this account's private key" rather than
// "meta-core signed something for me".
//
// The asymmetry that motivates this file: meta-core *holds* every private key,
// so it can sign as anybody. A signature is therefore only proof of possession
// if the payload is one meta-core refuses to sign on request — hence
// ChallengeDomain, which POST /api/identity/sign rejects outright. Without that
// refusal an attacker mints their own authorisation in one extra call and the
// whole gate is decoration.
//
// Verification itself needs no stored state: a uid *is* the public key
// ("z" + base58btc(33-byte compressed sec1)), so the pubkey is recovered from
// the uid the caller names. Mirrors `verify_local` / `verify_local_compact` in
// meta-watch's src/record/sign.rs — same curve, same digest, both signature
// encodings.
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"strings"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
"github.com/mr-tron/base58"
)
// ChallengeDomain prefixes every byte string that authorises an account
// operation. POST /api/identity/sign and /sign-batch refuse payloads carrying
// it, so the only way to produce one is to hold the key yourself.
//
// Versioned because it is a wire commitment: changing it invalidates every
// challenge in flight, which is harmless, but changing it *silently* would let
// an old-domain payload keep working. Bump it deliberately.
const ChallengeDomain = "metamesh-identity-v1:"
// Action is the operation a challenge authorises. A challenge minted to reveal
// a key must not also delete the account, so the action is inside the signed
// bytes and is re-checked at redemption.
type Action string
const (
ActionReveal Action = "reveal"
ActionDelete Action = "delete"
)
// ValidAction reports whether a is an action a challenge may be issued for.
func ValidAction(a Action) bool {
return a == ActionReveal || a == ActionDelete
}
// ValidUID exposes the uid shape check to callers outside this package (the
// API layer, which must reject a hostile uid before it reaches a key-range read
// pattern or a file path).
func ValidUID(uid string) bool { return validUID(uid) }
// IsChallengePayload reports whether b is (or claims to be) an account
// authorisation payload. Used by the signing endpoints to refuse minting one.
//
// Deliberately a prefix test on raw bytes rather than a parse: anything that
// merely *looks* like a challenge is refused, so a malformed or future-version
// payload fails closed instead of being signed.
func IsChallengePayload(b []byte) bool {
return strings.HasPrefix(string(b), ChallengeDomain)
}
// PubkeyFromUID recovers the compressed public key a uid encodes.
func PubkeyFromUID(uid string) (*secp256k1.PublicKey, error) {
if !validUID(uid) {
return nil, fmt.Errorf("invalid uid: %q", uid)
}
if !strings.HasPrefix(uid, "z") {
return nil, errors.New("uid is not multibase base58btc (missing 'z' prefix)")
}
raw, err := base58.Decode(uid[1:])
if err != nil {
return nil, fmt.Errorf("uid is not valid base58: %w", err)
}
if len(raw) != 33 {
return nil, fmt.Errorf("uid decodes to %d bytes, want a 33-byte compressed pubkey", len(raw))
}
pub, err := secp256k1.ParsePubKey(raw)
if err != nil {
return nil, fmt.Errorf("uid is not a point on secp256k1: %w", err)
}
return pub, nil
}
// Verify checks sig over SHA-256(payload) against the public key inside uid.
//
// Accepts both encodings in circulation: DER (what Sign emits, and what User
// Data Layer records carry) and 64-byte compact r‖s (what the browser emits —
// noble-secp256k1 v3 dropped DER, and meta-watch's sign-in already sends
// compact). Rejecting either would break one of the two callers.
func Verify(uid string, payload, sig []byte) error {
pub, err := PubkeyFromUID(uid)
if err != nil {
return err
}
parsed, err := parseSignature(sig)
if err != nil {
return err
}
digest := sha256.Sum256(payload)
if !parsed.Verify(digest[:], pub) {
return errors.New("signature does not verify for this uid")
}
return nil
}
func parseSignature(sig []byte) (*ecdsa.Signature, error) {
if len(sig) == 64 {
var r, s secp256k1.ModNScalar
// SetByteSlice reports overflow (>= curve order), which is not a valid
// scalar. A zero r or s is likewise never produced by a real signer.
if r.SetByteSlice(sig[:32]) || r.IsZero() {
return nil, errors.New("compact signature has an invalid r")
}
if s.SetByteSlice(sig[32:]) || s.IsZero() {
return nil, errors.New("compact signature has an invalid s")
}
return ecdsa.NewSignature(&r, &s), nil
}
parsed, err := ecdsa.ParseDERSignature(sig)
if err != nil {
return nil, fmt.Errorf("signature is neither 64-byte compact nor valid DER: %w", err)
}
return parsed, nil
}
// NewChallengeText mints the literal string a client signs. The uid and action
// are inside it, so a signature cannot be lifted from one account or operation
// onto another even if the nonce leaks.
func NewChallengeText(uid string, action Action) (string, error) {
nonce := make([]byte, 32)
if _, err := rand.Read(nonce); err != nil {
return "", fmt.Errorf("read nonce: %w", err)
}
return ChallengeDomain + string(action) + ":" + uid + ":" + hex.EncodeToString(nonce), nil
}
// ChallengeMatches reports whether text is a well-formed challenge for exactly
// this uid and action. The store already keys on the full text, so this is a
// belt-and-braces re-read of the signed bytes themselves: it is the signed
// content, not the lookup key, that a verifier must agree with.
//
// Compared in constant time — the text is a bearer secret until redeemed.
func ChallengeMatches(text, uid string, action Action) bool {
want := ChallengeDomain + string(action) + ":" + uid + ":"
if len(text) <= len(want) {
return false
}
return subtle.ConstantTimeCompare([]byte(text[:len(want)]), []byte(want)) == 1
}
+278
View File
@@ -0,0 +1,278 @@
package identity
import (
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
"time"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
)
// The whole gate rests on a uid being the public key: verification consults no
// stored state, so a signature is checked against the account the caller names
// rather than against whatever key meta-core happens to hold.
func TestVerify_AcceptsDERFromOurOwnSigner(t *testing.T) {
id, err := Generate()
if err != nil {
t.Fatalf("generate: %v", err)
}
payload := []byte(ChallengeDomain + "delete:" + id.UID + ":abcdef")
sig, err := Sign(id, payload)
if err != nil {
t.Fatalf("sign: %v", err)
}
if err := Verify(id.UID, payload, sig); err != nil {
t.Fatalf("DER signature from Sign must verify: %v", err)
}
}
// The browser is the signer that matters here, and noble-secp256k1 v3 emits
// 64-byte compact r‖s — it dropped DER entirely. A verifier that took only DER
// would reject every real proof while passing this package's own tests.
func TestVerify_AcceptsCompactAsTheBrowserEmitsIt(t *testing.T) {
id, err := Generate()
if err != nil {
t.Fatalf("generate: %v", err)
}
payload := []byte("some challenge text")
raw, _ := hex.DecodeString(id.PrivateKeyHex)
priv := secp256k1.PrivKeyFromBytes(raw)
digest := sha256.Sum256(payload)
der := ecdsa.Sign(priv, digest[:])
// Re-encode the same signature compactly, the way the browser would.
parsed, err := ecdsa.ParseDERSignature(der.Serialize())
if err != nil {
t.Fatalf("parse own DER: %v", err)
}
r := parsed.R()
sc := parsed.S()
compact := make([]byte, 0, 64)
rb := r.Bytes()
sb := sc.Bytes()
compact = append(compact, rb[:]...)
compact = append(compact, sb[:]...)
if err := Verify(id.UID, payload, compact); err != nil {
t.Fatalf("compact signature must verify: %v", err)
}
}
func TestVerify_RejectsAnotherAccountsSignature(t *testing.T) {
alice, _ := Generate()
mallory, _ := Generate()
payload := []byte("challenge for alice")
sig, err := Sign(mallory, payload)
if err != nil {
t.Fatalf("sign: %v", err)
}
if err := Verify(alice.UID, payload, sig); err == nil {
t.Fatal("a signature by mallory must not verify as alice")
}
}
func TestVerify_RejectsTamperedPayload(t *testing.T) {
id, _ := Generate()
sig, _ := Sign(id, []byte("delete account A"))
if err := Verify(id.UID, []byte("delete account B"), sig); err == nil {
t.Fatal("a signature must not verify over different bytes")
}
}
func TestVerify_RejectsGarbageSignatureEncodings(t *testing.T) {
id, _ := Generate()
for name, sig := range map[string][]byte{
"empty": {},
"short": {1, 2, 3},
"zero compact": make([]byte, 64),
"not der": []byte("this is not a signature at all......."),
"63 bytes": make([]byte, 63),
"65 bytes non-der": make([]byte, 65),
} {
if err := Verify(id.UID, []byte("x"), sig); err == nil {
t.Errorf("%s: expected rejection", name)
}
}
}
func TestPubkeyFromUID_RejectsMalformedUIDs(t *testing.T) {
id, _ := Generate()
for name, uid := range map[string]string{
"empty": "",
"no z prefix": id.UID[1:],
"truncated": id.UID[:20],
"path traversal": "../../etc/passwd",
"with slash": "z/" + id.UID,
"not on curve": "z" + strings.Repeat("1", 45),
} {
if _, err := PubkeyFromUID(uid); err == nil {
t.Errorf("%s (%q): expected rejection", name, uid)
}
}
if _, err := PubkeyFromUID(id.UID); err != nil {
t.Fatalf("a real uid must parse: %v", err)
}
}
// The refusal that makes proof-of-possession mean anything on a node holding
// every private key. If this ever returns false for a challenge, an attacker
// asks /api/identity/sign to authorise their own delete.
func TestIsChallengePayload_CatchesAnythingInTheDomain(t *testing.T) {
yes := [][]byte{
[]byte(ChallengeDomain),
[]byte(ChallengeDomain + "delete:zAlice:beef"),
[]byte(ChallengeDomain + "some-future-action:whatever"),
[]byte("metamesh-identity-v1:"), // literal, in case the const drifts
}
for _, b := range yes {
if !IsChallengePayload(b) {
t.Errorf("must refuse to sign %q", b)
}
}
no := [][]byte{
{},
[]byte("metamesh-identity-v2:delete:zAlice"), // different domain, not ours to refuse
[]byte("a record payload"),
[]byte(" " + ChallengeDomain), // not a prefix
}
for _, b := range no {
if IsChallengePayload(b) {
t.Errorf("must still sign %q", b)
}
}
}
func TestChallengeMatches_TiesTheTextToUIDAndAction(t *testing.T) {
text, err := NewChallengeText("zAlice", ActionDelete)
if err != nil {
t.Fatalf("mint: %v", err)
}
if !ChallengeMatches(text, "zAlice", ActionDelete) {
t.Fatal("its own uid+action must match")
}
if ChallengeMatches(text, "zBob", ActionDelete) {
t.Fatal("another uid must not match")
}
if ChallengeMatches(text, "zAlice", ActionReveal) {
t.Fatal("another action must not match")
}
// A prefix-only uid must not slip through: "zAlic" is a prefix of "zAlice",
// and a naive HasPrefix on the uid alone would accept it.
if ChallengeMatches(text, "zAlic", ActionDelete) {
t.Fatal("a uid that is a prefix of the real one must not match")
}
}
func TestNewChallengeText_IsUnpredictable(t *testing.T) {
seen := make(map[string]struct{}, 64)
for i := 0; i < 64; i++ {
text, err := NewChallengeText("zAlice", ActionDelete)
if err != nil {
t.Fatalf("mint: %v", err)
}
if _, dup := seen[text]; dup {
t.Fatal("challenge repeated — the nonce is not random")
}
seen[text] = struct{}{}
}
}
// --- challenge store --------------------------------------------------------
func TestChallengeStore_RedeemIsSingleUse(t *testing.T) {
s := NewChallengeStore()
text, _, err := s.Issue("zAlice", ActionDelete)
if err != nil {
t.Fatalf("issue: %v", err)
}
if err := s.Redeem(text, "zAlice", ActionDelete); err != nil {
t.Fatalf("first redeem: %v", err)
}
if err := s.Redeem(text, "zAlice", ActionDelete); err == nil {
t.Fatal("a challenge must not be redeemable twice — that is replay")
}
}
// A challenge minted to *look at* a key must not delete the account, and one
// minted for Alice must not act on Bob. Both are inside the signed text, so a
// store that ignored them would authorise the wrong operation with a perfectly
// valid signature.
func TestChallengeStore_RedeemRefusesWrongUIDOrAction(t *testing.T) {
s := NewChallengeStore()
text, _, _ := s.Issue("zAlice", ActionReveal)
if err := s.Redeem(text, "zAlice", ActionDelete); err == nil {
t.Fatal("a reveal challenge must not authorise a delete")
}
text2, _, _ := s.Issue("zAlice", ActionDelete)
if err := s.Redeem(text2, "zBob", ActionDelete); err == nil {
t.Fatal("alice's challenge must not authorise deleting bob")
}
}
// A failed redemption still consumes the challenge, so a wrong guess costs a
// round trip rather than leaving the nonce up for another attempt.
func TestChallengeStore_FailedRedeemStillBurnsIt(t *testing.T) {
s := NewChallengeStore()
text, _, _ := s.Issue("zAlice", ActionDelete)
_ = s.Redeem(text, "zBob", ActionDelete)
if err := s.Redeem(text, "zAlice", ActionDelete); err == nil {
t.Fatal("a burnt challenge must stay burnt for its rightful owner too")
}
}
func TestChallengeStore_Expires(t *testing.T) {
s := NewChallengeStore()
now := time.Unix(1_700_000_000, 0)
s.now = func() time.Time { return now }
text, expires, _ := s.Issue("zAlice", ActionDelete)
if !expires.Equal(now.Add(ChallengeTTL)) {
t.Fatalf("expiry %v, want %v", expires, now.Add(ChallengeTTL))
}
now = now.Add(ChallengeTTL + time.Second)
if err := s.Redeem(text, "zAlice", ActionDelete); err == nil {
t.Fatal("an expired challenge must not redeem")
}
}
// Issuing is unauthenticated, so the map is attacker-growable. The cap is what
// stops that being a memory-exhaustion lever; expiry sweeping is what stops the
// cap being reached by honest traffic.
func TestChallengeStore_CapsInFlightAndSweepsExpired(t *testing.T) {
s := NewChallengeStore()
now := time.Unix(1_700_000_000, 0)
s.now = func() time.Time { return now }
for i := 0; i < maxChallenges; i++ {
if _, _, err := s.Issue("zAlice", ActionDelete); err != nil {
t.Fatalf("issue %d: %v", i, err)
}
}
if _, _, err := s.Issue("zAlice", ActionDelete); err == nil {
t.Fatal("expected the store to refuse once full")
}
// Once they age out, the next issue sweeps and succeeds again.
now = now.Add(ChallengeTTL + time.Second)
if _, _, err := s.Issue("zAlice", ActionDelete); err != nil {
t.Fatalf("after expiry the store must accept again: %v", err)
}
}
func TestChallengeStore_RefusesInvalidInputs(t *testing.T) {
s := NewChallengeStore()
if _, _, err := s.Issue("../etc", ActionDelete); err == nil {
t.Fatal("a uid that is not uid-shaped must be refused")
}
if _, _, err := s.Issue("zAlice", Action("sudo")); err == nil {
t.Fatal("an unknown action must be refused")
}
}
+869
View File
@@ -0,0 +1,869 @@
// Package kv is meta-core's storage engine: one string→string table in an
// embedded SQLite file.
//
// # Why a key/value table and not a schema
//
// meta-core's data model is schema-free on purpose — a record is a set of flat
// `file:<rid>/<field>` keys, and a new metadata field is just a new key
// (METADATA_KEYS.md). The engine keeps exactly that model:
//
// CREATE TABLE kv(k TEXT PRIMARY KEY, v TEXT NOT NULL) WITHOUT ROWID
//
// is the only table that holds data, and it never needs a migration. What
// Redis gave us that a bare KV would not — "list a record's fields", "find
// records by field value", free-text search — comes from the table being
// *ordered* (a record is a key range) and from two derived structures the
// storage package maintains on top: an expression index over the field name
// parsed out of the key, and an FTS5 table. Both are rebuildable from the kv
// rows and never authoritative.
//
// It replaced Redis on 2026-10: holding everything in RAM cost ~6.6 GB on a
// 169k-record corpus (Redis plus the API's in-memory copy needed to search
// it), with the OOM kills that implies. The same data is ~2.5 GB on disk here
// and the working set is a bounded page cache.
//
// # Concurrency
//
// SQLite allows one writer at a time, so every write goes through one writer
// goroutine owning one connection. Concurrent Update calls are GROUP-COMMITTED:
// the writer runs every request waiting at that moment inside one SQLite
// transaction, each under its own SAVEPOINT (a failing request rolls back
// alone), and commits once. A commit — writing the dirty pages of the table,
// the field index and the FTS shadow tables to the WAL — was ~40% of the cost
// of a record write, so sharing it is what makes concurrent ingest scale.
//
// Reads use a separate pool and see the last committed state (WAL). Inside
// Update, read through the *Tx — the reader pool cannot see uncommitted rows —
// and never call Update from inside an Update callback (the writer would wait
// for itself).
//
// # Events
//
// Redis keyspace notifications are replaced by the transaction itself: every
// Put/Delete of a key the Options.EventKey predicate selects is appended, in
// the same transaction, to Options.EventStream as {type: set|del, key, ts}.
// A change and its event therefore commit together or not at all.
package kv
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"net/url"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
_ "modernc.org/sqlite"
)
// Options configures a Store.
type Options struct {
// EventStream receives a set/del entry for every changed key EventKey
// selects. Empty disables change events.
EventStream string
// EventMaxLen is the approximate retention of EventStream.
EventMaxLen int64
// EventKey selects the keys whose changes are published.
EventKey func(key string) bool
// WriterCacheMB / ReaderCacheMB size SQLite's page cache per connection.
WriterCacheMB int
ReaderCacheMB int
// Readers is the size of the read connection pool.
Readers int
// Schema is extra DDL run at open (idempotent CREATE … IF NOT EXISTS),
// for derived indexes owned by the caller.
Schema string
// OnChange is called inside the transaction for every key whose value
// actually changed (created, modified or deleted), with the old and new
// values — the hook derived counters hang off (Tx.AddCounter). It must not
// write kv rows itself.
OnChange func(tx *Tx, key, old string, hadOld bool, new string, hasNew bool)
// NoKVTable opens the file without the kv table and its prepared
// statements: the caller owns every table through Schema and writes them
// with Tx.Exec / Tx.Query (meta-core's truth database). The kv methods
// (Get, Put, streams, counters) must not be used on such a store.
NoKVTable bool
// Synchronous is SQLite's synchronous pragma; default NORMAL. A store that
// is rebuildable from another (meta-core's cache) runs OFF.
Synchronous string
}
// Store is an open database.
type Store struct {
path string
opts Options
w *sql.DB // single connection: the one writer
r *sql.DB // read pool
streamMu sync.Mutex
streams map[string]*streamState
// Hot write statements, prepared once on the writer connection: parsing
// SQL on every Put was the single largest cost of a record write.
stPut, stPutIfAbsent, stDelete, stGet, stInsert *sql.Stmt
// Write accounting: how many Puts changed a value vs found it unchanged.
puts, noops atomic.Int64
writes chan *writeReq
writeMu sync.Mutex // held by the writer while a group runs
closeOnce sync.Once
stopped chan struct{}
}
// writeReq is one Update call waiting for the writer.
type writeReq struct {
fn func(tx *Tx) error
done chan error
}
// maxGroup caps how many requests share one commit, bounding how long the
// writer holds a transaction open.
const maxGroup = 128
// Open opens (creating if needed) the database file at path.
func Open(path string, opts Options) (*Store, error) {
if opts.Readers <= 0 {
opts.Readers = 8
}
if opts.WriterCacheMB <= 0 {
opts.WriterCacheMB = 64
}
if opts.ReaderCacheMB <= 0 {
opts.ReaderCacheMB = 16
}
if opts.EventMaxLen <= 0 {
opts.EventMaxLen = 100_000
}
if opts.Synchronous == "" {
opts.Synchronous = "NORMAL"
}
w, err := sql.Open("sqlite", dsn(path, opts.WriterCacheMB, false, opts.Synchronous))
if err != nil {
return nil, fmt.Errorf("open writer: %w", err)
}
w.SetMaxOpenConns(1)
w.SetMaxIdleConns(1)
w.SetConnMaxLifetime(0)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if !opts.NoKVTable {
if _, err := w.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS kv(k TEXT PRIMARY KEY, v TEXT NOT NULL) WITHOUT ROWID`); err != nil {
w.Close()
return nil, fmt.Errorf("create kv: %w", err)
}
}
if opts.Schema != "" {
// No deadline: on an existing store, the first open after a new index
// is added builds it over every row (minutes at millions of keys).
// Cancelling that just rolls it back and restarts it on the next boot.
started := time.Now()
if _, err := w.ExecContext(context.Background(), opts.Schema); err != nil {
w.Close()
return nil, fmt.Errorf("apply schema: %w", err)
}
if d := time.Since(started); d > 5*time.Second {
log.Printf("[kv] schema applied in %s (indexes built)", d.Round(time.Second))
}
}
r, err := sql.Open("sqlite", dsn(path, opts.ReaderCacheMB, true, opts.Synchronous))
if err != nil {
w.Close()
return nil, fmt.Errorf("open readers: %w", err)
}
r.SetMaxOpenConns(opts.Readers)
r.SetMaxIdleConns(opts.Readers)
st := &Store{path: path, opts: opts, w: w, r: r, streams: make(map[string]*streamState)}
// Fresh deadline: the schema step above may legitimately have taken minutes.
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
stmts := []struct {
dst **sql.Stmt
query string
}{
{&st.stPut, `INSERT INTO kv(k, v) VALUES(?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v`},
{&st.stPutIfAbsent, `INSERT INTO kv(k, v) VALUES(?, ?) ON CONFLICT(k) DO NOTHING`},
{&st.stDelete, `DELETE FROM kv WHERE k = ? RETURNING v`},
{&st.stGet, `SELECT v FROM kv WHERE k = ?`},
{&st.stInsert, `INSERT INTO kv(k, v) VALUES(?, ?)`},
}
if opts.NoKVTable {
stmts = nil
}
for _, p := range stmts {
if *p.dst, err = w.PrepareContext(ctx, p.query); err != nil {
r.Close()
w.Close()
return nil, fmt.Errorf("prepare: %w", err)
}
}
st.writes = make(chan *writeReq)
st.stopped = make(chan struct{})
go st.writer()
return st, nil
}
// dsn builds a modernc DSN. WAL + synchronous=NORMAL is the durability class
// Redis' AOF-everysec gave us: a power loss may drop the last transactions,
// never corrupt the file.
func dsn(path string, cacheMB int, readOnly bool, synchronous string) string {
q := url.Values{}
q.Add("_pragma", "busy_timeout(30000)")
q.Add("_pragma", "journal_mode(WAL)")
q.Add("_pragma", "synchronous("+synchronous+")")
q.Add("_pragma", fmt.Sprintf("cache_size(-%d)", cacheMB*1024))
q.Add("_pragma", "temp_store(MEMORY)")
q.Add("_pragma", "journal_size_limit(268435456)")
if readOnly {
q.Add("_pragma", "query_only(1)")
} else {
q.Add("_txlock", "immediate")
}
return "file:" + path + "?" + q.Encode()
}
// Close stops the writer and closes both pools, checkpointing the WAL into the
// main file.
func (s *Store) Close() error {
s.closeOnce.Do(func() { close(s.stopped) })
s.writeMu.Lock() // wait for an in-flight group to finish
defer s.writeMu.Unlock()
_, _ = s.w.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`)
return errors.Join(s.r.Close(), s.w.Close())
}
// Ping reports whether the database answers.
func (s *Store) Ping(ctx context.Context) error {
var one int
return s.r.QueryRowContext(ctx, `SELECT 1`).Scan(&one)
}
// Path is the database file path.
func (s *Store) Path() string { return s.path }
// DB exposes the read pool for callers that own derived indexes (search). It
// is read-only (query_only), so it cannot bypass Update.
func (s *Store) DB() *sql.DB { return s.r }
// FileSize returns the on-disk size of the database plus its WAL.
func (s *Store) FileSize() int64 {
var total int64
for _, suffix := range []string{"", "-wal"} {
if fi, err := os.Stat(s.path + suffix); err == nil {
total += fi.Size()
}
}
return total
}
// Checkpoint folds the WAL back into the main file. TRUNCATE takes the writer
// slot and waits for readers, so callers run it when idle.
func (s *Store) Checkpoint(ctx context.Context) error {
_, err := s.w.ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE)`)
return err
}
// KV is one key/value pair.
type KV struct {
K, V string
}
// Reader is the read surface shared by Store (committed state) and Tx (the
// transaction's own view). Storage helpers take a Reader so they work both
// inside and outside a write.
type Reader interface {
Get(ctx context.Context, k string) (string, bool, error)
MGet(ctx context.Context, keys []string) (map[string]string, error)
Range(ctx context.Context, prefix, after string, limit int) ([]KV, error)
Count(ctx context.Context, prefix string) (int64, error)
}
type queryer interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
// PrefixEnd returns the smallest string greater than every string with the
// given prefix, so [prefix, PrefixEnd(prefix)) is exactly the prefix's range.
// An empty result means "unbounded".
func PrefixEnd(prefix string) string {
b := []byte(prefix)
for i := len(b) - 1; i >= 0; i-- {
if b[i] < 0xff {
b[i]++
return string(b[:i+1])
}
}
return ""
}
func get(ctx context.Context, q queryer, k string) (string, bool, error) {
var v string
err := q.QueryRowContext(ctx, `SELECT v FROM kv WHERE k = ?`, k).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, err
}
return v, true, nil
}
// mgetBatch stays well under SQLite's default 32766 bound-parameter cap.
const mgetBatch = 500
func mget(ctx context.Context, q queryer, keys []string) (map[string]string, error) {
out := make(map[string]string, len(keys))
for start := 0; start < len(keys); start += mgetBatch {
end := min(start+mgetBatch, len(keys))
chunk := keys[start:end]
args := make([]any, len(chunk))
for i, k := range chunk {
args[i] = k
}
rows, err := q.QueryContext(ctx, `SELECT k, v FROM kv WHERE k IN (?`+strings.Repeat(",?", len(chunk)-1)+`)`, args...)
if err != nil {
return nil, err
}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
rows.Close()
return nil, err
}
out[k] = v
}
if err := rows.Close(); err != nil {
return nil, err
}
}
return out, nil
}
// rangeQuery returns keys with prefix, strictly after `after` (when non-empty),
// in key order, at most limit (<= 0 means all).
func rangeQuery(ctx context.Context, q queryer, prefix, after string, limit int) ([]KV, error) {
lo, loOp := prefix, ">="
if after != "" && after >= prefix {
lo, loOp = after, ">"
}
query := `SELECT k, v FROM kv WHERE k ` + loOp + ` ?`
args := []any{lo}
if end := PrefixEnd(prefix); end != "" {
query += ` AND k < ?`
args = append(args, end)
}
query += ` ORDER BY k`
if limit > 0 {
query += ` LIMIT ?`
args = append(args, limit)
}
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []KV
for rows.Next() {
var kv KV
if err := rows.Scan(&kv.K, &kv.V); err != nil {
return nil, err
}
out = append(out, kv)
}
return out, rows.Err()
}
func count(ctx context.Context, q queryer, prefix string) (int64, error) {
query := `SELECT count(*) FROM kv WHERE k >= ?`
args := []any{prefix}
if end := PrefixEnd(prefix); end != "" {
query += ` AND k < ?`
args = append(args, end)
}
var n int64
err := q.QueryRowContext(ctx, query, args...).Scan(&n)
return n, err
}
// Get returns the committed value of k.
func (s *Store) Get(ctx context.Context, k string) (string, bool, error) { return get(ctx, s.r, k) }
// MGet returns the committed values of the keys that exist.
func (s *Store) MGet(ctx context.Context, keys []string) (map[string]string, error) {
return mget(ctx, s.r, keys)
}
// Range returns committed keys under prefix after `after`, in key order.
func (s *Store) Range(ctx context.Context, prefix, after string, limit int) ([]KV, error) {
return rangeQuery(ctx, s.r, prefix, after, limit)
}
// Count returns how many committed keys sit under prefix.
func (s *Store) Count(ctx context.Context, prefix string) (int64, error) {
return count(ctx, s.r, prefix)
}
// ScanPrefix walks every committed key under prefix in pages of `page`, each
// page its own short read so a long walk never pins a WAL snapshot (which
// would stop checkpoints and let the WAL grow without bound).
func (s *Store) ScanPrefix(ctx context.Context, prefix string, page int, fn func([]KV) error) error {
if page <= 0 {
page = 5000
}
after := ""
for {
batch, err := rangeQuery(ctx, s.r, prefix, after, page)
if err != nil {
return err
}
if len(batch) == 0 {
return nil
}
if err := fn(batch); err != nil {
return err
}
if len(batch) < page {
return nil
}
after = batch[len(batch)-1].K
}
}
// CountAll counts every row. A full scan — callers cache it.
func (s *Store) CountAll(ctx context.Context) (int64, error) {
var n int64
err := s.r.QueryRowContext(ctx, `SELECT count(*) FROM kv`).Scan(&n)
return n, err
}
// ErrClosed is returned by Update on a closed store.
var ErrClosed = errors.New("kv: store closed")
// Update runs fn atomically: its writes (and the change events they publish)
// commit together or not at all. fn may share a SQLite transaction with other
// concurrent Updates (group commit) but is isolated from their failures by a
// savepoint. Never call Update from inside fn.
//
// ctx bounds only the wait for the writer. Once fn starts it runs to
// completion: interrupting a statement inside a shared transaction would roll
// back every request in the group.
func (s *Store) Update(ctx context.Context, fn func(tx *Tx) error) error {
req := &writeReq{fn: fn, done: make(chan error, 1)}
select {
case s.writes <- req:
case <-ctx.Done():
return ctx.Err()
case <-s.stopped:
return ErrClosed
}
return <-req.done
}
// writer is the single goroutine that executes writes.
func (s *Store) writer() {
for {
var first *writeReq
select {
case first = <-s.writes:
case <-s.stopped:
return
}
group := []*writeReq{first}
drain:
for len(group) < maxGroup {
select {
case r := <-s.writes:
group = append(group, r)
default:
break drain
}
}
s.writeMu.Lock()
s.runGroup(group)
s.writeMu.Unlock()
}
}
// group is the state shared by the requests of one commit.
type group struct {
tx *sql.Tx
pending map[string]*streamPending
stmts map[*sql.Stmt]*sql.Stmt
counters map[string]int64
}
func (g *group) snapshot() map[string]streamPending {
out := make(map[string]streamPending, len(g.pending))
for k, p := range g.pending {
out[k] = *p
}
return out
}
func (g *group) restore(snap map[string]streamPending) {
g.pending = make(map[string]*streamPending, len(snap))
for k, p := range snap {
p := p
g.pending[k] = &p
}
}
func (s *Store) runGroup(reqs []*writeReq) {
ctx := context.Background()
results := make([]error, len(reqs))
fail := func(err error) {
for i, r := range reqs {
if results[i] == nil {
results[i] = err
}
r.done <- results[i]
}
}
sqlTx, err := s.w.BeginTx(ctx, nil)
if err != nil {
fail(fmt.Errorf("begin: %w", err))
return
}
g := &group{tx: sqlTx}
var commitHooks []func()
for i, r := range reqs {
if _, err := sqlTx.ExecContext(ctx, `SAVEPOINT req`); err != nil {
_ = sqlTx.Rollback()
fail(fmt.Errorf("savepoint: %w", err))
return
}
snap := g.snapshot()
tx := &Tx{ctx: ctx, s: s, g: g}
err := r.fn(tx)
if err == nil {
err = tx.flushEvents()
}
if err == nil {
g.mergeCounters(tx.counters)
commitHooks = append(commitHooks, tx.onCommit...)
}
if err != nil {
results[i] = err
if _, rbErr := sqlTx.ExecContext(ctx, `ROLLBACK TO req`); rbErr != nil {
_ = sqlTx.Rollback()
fail(fmt.Errorf("rollback to savepoint: %w (after %v)", rbErr, err))
return
}
g.restore(snap)
}
if _, err := sqlTx.ExecContext(ctx, `RELEASE req`); err != nil {
_ = sqlTx.Rollback()
fail(fmt.Errorf("release savepoint: %w", err))
return
}
}
if err := (&Tx{ctx: ctx, s: s, g: g}).flushCounters(); err != nil {
_ = sqlTx.Rollback()
fail(err)
return
}
if err := (&Tx{ctx: ctx, s: s, g: g}).flushHWM(); err != nil {
_ = sqlTx.Rollback()
fail(err)
return
}
if err := sqlTx.Commit(); err != nil {
fail(fmt.Errorf("commit: %w", err))
return
}
s.afterCommit(g.pending)
// Hooks run in the writer goroutine, in commit order, before any request
// returns: a caller that orders work by commit (meta-core's apply queue)
// sees it in exactly that order.
for _, h := range commitHooks {
h()
}
for i, r := range reqs {
r.done <- results[i]
}
}
// Tx is one Update's view of the write transaction. Its reads see its own
// writes (and those of earlier requests in the same group).
type Tx struct {
ctx context.Context
s *Store
g *group
events []map[string]string
counters map[string]int64
onCommit []func()
quiet bool
}
// OnCommit registers fn to run after the transaction commits, only if this
// request succeeded. Hooks run in the writer goroutine in commit order and
// must not block or call Update.
func (t *Tx) OnCommit(fn func()) { t.onCommit = append(t.onCommit, fn) }
// Quiet stops this request's changes from publishing events (a bulk rebuild
// whose consumers are told to take a snapshot instead). Counters and the
// OnChange hook still run.
func (t *Tx) Quiet() { t.quiet = true }
// Query runs a raw multi-row query in the transaction.
func (t *Tx) Query(query string, args ...any) (*sql.Rows, error) {
return t.sqlTx().QueryContext(t.ctx, query, args...)
}
func (t *Tx) sqlTx() *sql.Tx { return t.g.tx }
// stmt binds one of the store's prepared statements to the transaction.
func (t *Tx) stmt(st *sql.Stmt) *sql.Stmt {
if t.g.stmts == nil {
t.g.stmts = make(map[*sql.Stmt]*sql.Stmt, 5)
}
bound := t.g.stmts[st]
if bound == nil {
bound = t.g.tx.StmtContext(t.ctx, st)
t.g.stmts[st] = bound
}
return bound
}
// Context is the context the transaction runs under.
func (t *Tx) Context() context.Context { return t.ctx }
func (t *Tx) Get(ctx context.Context, k string) (string, bool, error) {
var v string
err := t.stmt(t.s.stGet).QueryRowContext(ctx, k).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, err
}
return v, true, nil
}
func (t *Tx) MGet(ctx context.Context, keys []string) (map[string]string, error) {
return mget(ctx, t.sqlTx(), keys)
}
func (t *Tx) Range(ctx context.Context, prefix, after string, limit int) ([]KV, error) {
return rangeQuery(ctx, t.sqlTx(), prefix, after, limit)
}
func (t *Tx) Count(ctx context.Context, prefix string) (int64, error) {
return count(ctx, t.sqlTx(), prefix)
}
// Exec runs a raw statement in the transaction — for the caller's derived
// tables (FTS), never for kv rows (which would skip events).
func (t *Tx) Exec(query string, args ...any) (sql.Result, error) {
return t.sqlTx().ExecContext(t.ctx, query, args...)
}
// QueryRow runs a raw single-row query in the transaction.
func (t *Tx) QueryRow(query string, args ...any) *sql.Row {
return t.sqlTx().QueryRowContext(t.ctx, query, args...)
}
// Put sets k = v. A no-op — no row write, no index churn, no event — when k
// already holds v. (Redis keyspace notifications published `set` even for an
// unchanged value; with meta-core persisting the same gateway hits again on
// every repeated query, those no-op writes were most of the write load.)
func (t *Tx) Put(k, v string) error {
_, err := t.Set(k, v)
return err
}
// Set is Put that reports whether the value changed.
func (t *Tx) Set(k, v string) (bool, error) {
old, had, err := t.Get(t.ctx, k)
if err != nil {
return false, fmt.Errorf("put %q: %w", k, err)
}
if had && old == v {
t.s.noops.Add(1)
return false, nil
}
if _, err := t.stmt(t.s.stPut).ExecContext(t.ctx, k, v); err != nil {
return false, fmt.Errorf("put %q: %w", k, err)
}
t.s.puts.Add(1)
t.changed("set", k, old, had, v, true)
return true, nil
}
// PutIfAbsent sets k = v only when k does not exist. Reports whether it wrote.
func (t *Tx) PutIfAbsent(k, v string) (bool, error) {
res, err := t.stmt(t.s.stPutIfAbsent).ExecContext(t.ctx, k, v)
if err != nil {
return false, fmt.Errorf("put %q: %w", k, err)
}
n, _ := res.RowsAffected()
if n > 0 {
t.changed("set", k, "", false, v, true)
}
return n > 0, nil
}
// Delete removes k. Reports whether it existed; publishes `del` only then,
// as Redis did.
func (t *Tx) Delete(k string) (bool, error) {
var old string
err := t.stmt(t.s.stDelete).QueryRowContext(t.ctx, k).Scan(&old)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("delete %q: %w", k, err)
}
t.changed("del", k, old, true, "", false)
return true, nil
}
// DeletePrefix removes up to limit keys under prefix (limit <= 0: all) and
// returns how many went. Bulk callers loop over separate Updates with a limit
// so one transaction never grows to millions of rows.
func (t *Tx) DeletePrefix(prefix string, limit int) (int64, error) {
end := PrefixEnd(prefix)
sel := `SELECT k FROM kv WHERE k >= ?`
args := []any{prefix}
if end != "" {
sel += ` AND k < ?`
args = append(args, end)
}
if limit > 0 {
sel += ` ORDER BY k LIMIT ?`
args = append(args, limit)
}
rows, err := t.sqlTx().QueryContext(t.ctx, `DELETE FROM kv WHERE k IN (`+sel+`) RETURNING k, v`, args...)
if err != nil {
return 0, fmt.Errorf("delete prefix %q: %w", prefix, err)
}
var gone []KV
for rows.Next() {
var kv KV
if err := rows.Scan(&kv.K, &kv.V); err != nil {
rows.Close()
return 0, err
}
gone = append(gone, kv)
}
if err := rows.Close(); err != nil {
return 0, err
}
for _, kv := range gone {
t.changed("del", kv.K, kv.V, true, "", false)
}
return int64(len(gone)), nil
}
// changed records one real change: the event (when in scope) and the hook.
func (t *Tx) changed(op, k, old string, hadOld bool, v string, hasNew bool) {
t.event(op, k)
if t.s.opts.OnChange != nil {
t.s.opts.OnChange(t, k, old, hadOld, v, hasNew)
}
}
// AddCounter adds delta to the integer counter stored at key. Deltas are
// summed in memory and written once per commit (a hot counter touched by every
// record in a group costs one row write, not one per record); they are
// discarded with the request if it fails. A counter that reaches zero is
// deleted.
func (t *Tx) AddCounter(key string, delta int64) {
if delta == 0 {
return
}
if t.counters == nil {
t.counters = make(map[string]int64)
}
t.counters[key] += delta
}
func (g *group) mergeCounters(c map[string]int64) {
if len(c) == 0 {
return
}
if g.counters == nil {
g.counters = make(map[string]int64, len(c))
}
for k, d := range c {
g.counters[k] += d
}
}
// flushCounters applies the group's counter deltas, bypassing Put (counters
// are bookkeeping: no events, no hook).
func (t *Tx) flushCounters() error {
for k, d := range t.g.counters {
if d == 0 {
continue
}
cur, _, err := t.Get(t.ctx, k)
if err != nil {
return err
}
n, _ := strconv.ParseInt(cur, 10, 64)
n += d
if n <= 0 {
if _, err := t.sqlTx().ExecContext(t.ctx, `DELETE FROM kv WHERE k = ?`, k); err != nil {
return fmt.Errorf("counter %s: %w", k, err)
}
continue
}
if _, err := t.stmt(t.s.stPut).ExecContext(t.ctx, k, strconv.FormatInt(n, 10)); err != nil {
return fmt.Errorf("counter %s: %w", k, err)
}
}
return nil
}
// Counter reads a committed counter (0 when absent).
func (s *Store) Counter(ctx context.Context, key string) (int64, bool, error) {
v, ok, err := s.Get(ctx, key)
if err != nil || !ok {
return 0, ok, err
}
n, err := strconv.ParseInt(v, 10, 64)
return n, true, err
}
// WriteStats reports how many Puts changed a value and how many found it
// already in place, since open.
func (s *Store) WriteStats() (changed, unchanged int64) {
return s.puts.Load(), s.noops.Load()
}
func (t *Tx) event(op, k string) {
if t.quiet || t.s.opts.EventStream == "" || t.s.opts.EventKey == nil || !t.s.opts.EventKey(k) {
return
}
t.events = append(t.events, map[string]string{
"type": op,
"key": k,
"ts": fmt.Sprintf("%d", time.Now().UnixMilli()),
})
}
// flushEvents appends the queued change events to the event stream, inside
// the transaction, right before commit.
func (t *Tx) flushEvents() error {
if len(t.events) == 0 {
return nil
}
evs := t.events
t.events = nil
for _, e := range evs {
if _, err := t.Append(t.s.opts.EventStream, t.s.opts.EventMaxLen, e); err != nil {
return err
}
}
return nil
}
+362
View File
@@ -0,0 +1,362 @@
package kv
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"time"
)
func openTest(t *testing.T, path string) *Store {
t.Helper()
if path == "" {
path = filepath.Join(t.TempDir(), "kv.db")
}
s, err := Open(path, Options{
EventStream: "meta:events",
EventMaxLen: 100_000,
EventKey: func(k string) bool { return strings.HasPrefix(k, "file:") },
})
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func update(t *testing.T, s *Store, fn func(tx *Tx) error) {
t.Helper()
if err := s.Update(context.Background(), fn); err != nil {
t.Fatalf("update: %v", err)
}
}
func events(t *testing.T, s *Store) []Entry {
t.Helper()
es, err := s.ReadAfter(context.Background(), "meta:events", "0", 0)
if err != nil {
t.Fatalf("read events: %v", err)
}
return es
}
// The replacement for keyspace notifications: a change and its event commit
// together. `set` only for a real change (an unchanged value is a no-op: no
// write, no event), `del` only for a key that existed, nothing for keys outside
// the event scope.
func TestChangesPublishEventsWithRedisSemantics(t *testing.T) {
s := openTest(t, "")
update(t, s, func(tx *Tx) error {
_ = tx.Put("file:A/title", "x")
_ = tx.Put("file:A/title", "x") // unchanged: no write, no event
_ = tx.Put("cid:bafk", "A") // outside the event scope
_, _ = tx.Delete("file:A/missing")
_, _ = tx.Delete("file:A/title")
return nil
})
got := events(t, s)
want := []string{"set file:A/title", "del file:A/title"}
if len(got) != len(want) {
t.Fatalf("events = %+v, want %v", got, want)
}
for i, e := range got {
if e.Values["type"]+" "+e.Values["key"] != want[i] {
t.Errorf("event %d = %v, want %q", i, e.Values, want[i])
}
}
}
func TestRolledBackChangesPublishNothing(t *testing.T) {
s := openTest(t, "")
err := s.Update(context.Background(), func(tx *Tx) error {
_ = tx.Put("file:A/title", "x")
return errors.New("boom")
})
if err == nil {
t.Fatal("want the error back")
}
if _, ok, _ := s.Get(context.Background(), "file:A/title"); ok {
t.Fatal("rolled-back write is visible")
}
if es := events(t, s); len(es) != 0 {
t.Fatalf("rolled-back write published %+v", es)
}
}
func TestDeletePrefixPublishesOneDelPerKey(t *testing.T) {
s := openTest(t, "")
update(t, s, func(tx *Tx) error {
for _, k := range []string{"file:A/a", "file:A/b", "file:AB/c"} {
_ = tx.Put(k, "1")
}
return nil
})
var n int64
update(t, s, func(tx *Tx) error {
var err error
n, err = tx.DeletePrefix("file:A/", 0)
return err
})
if n != 2 {
t.Fatalf("deleted %d, want 2 (file:AB/ is not under file:A/)", n)
}
es := events(t, s)
if dels := es[3:]; len(dels) != 2 || dels[0].Values["type"] != "del" || dels[1].Values["type"] != "del" {
t.Fatalf("want two del events, got %+v", es)
}
}
// IDs are Redis-shaped and strictly increasing, within one millisecond and
// across a reopen — the SSE cursors consumers persist depend on both.
func TestStreamIDsAreMonotonicAcrossReopen(t *testing.T) {
path := filepath.Join(t.TempDir(), "kv.db")
s, err := Open(path, Options{})
if err != nil {
t.Fatal(err)
}
var ids []string
if err := s.Update(context.Background(), func(tx *Tx) error {
for i := 0; i < 50; i++ {
id, err := tx.Append("file:events", 0, map[string]string{"n": "x"})
if err != nil {
return err
}
ids = append(ids, id)
}
return nil
}); err != nil {
t.Fatal(err)
}
_ = s.Close()
s2 := openTest(t, path)
var after string
update(t, s2, func(tx *Tx) error {
var err error
after, err = tx.Append("file:events", 0, map[string]string{"n": "y"})
return err
})
ids = append(ids, after)
for i := 1; i < len(ids); i++ {
if !idLess(t, ids[i-1], ids[i]) {
t.Fatalf("id %d (%s) does not follow %s", i, ids[i], ids[i-1])
}
}
if !strings.Contains(ids[0], "-") || strings.HasPrefix(ids[0], "0") {
t.Fatalf("id %q is not the unpadded <ms>-<seq> form", ids[0])
}
}
func idLess(t *testing.T, a, b string) bool {
t.Helper()
am, as, err := ParseID(a)
if err != nil {
t.Fatal(err)
}
bm, bs, err := ParseID(b)
if err != nil {
t.Fatal(err)
}
return am < bm || (am == bm && as < bs)
}
// The watcher's reset clears file:events. The next ID must still exceed every
// ID issued before the clear, or a consumer holding an old cursor would skip
// the reset event.
func TestClearStreamKeepsTheHighWaterMark(t *testing.T) {
s := openTest(t, "")
var before, after string
update(t, s, func(tx *Tx) error {
var err error
for i := 0; i < 5; i++ {
if before, err = tx.Append("file:events", 0, map[string]string{"type": "add"}); err != nil {
return err
}
}
return nil
})
update(t, s, func(tx *Tx) error { return tx.ClearStream("file:events") })
if n, _ := s.Len(context.Background(), "file:events"); n != 0 {
t.Fatalf("len after clear = %d", n)
}
update(t, s, func(tx *Tx) error {
var err error
after, err = tx.Append("file:events", 0, map[string]string{"type": "reset"})
return err
})
if !idLess(t, before, after) {
t.Fatalf("post-clear id %s does not follow pre-clear %s", after, before)
}
got, err := s.ReadAfter(context.Background(), "file:events", before, 10)
if err != nil || len(got) != 1 || got[0].Values["type"] != "reset" {
t.Fatalf("an old cursor must see the reset: %+v err=%v", got, err)
}
}
// MAXLEN ~ semantics across many small transactions (the common case: one
// event per write).
func TestStreamTrimsAcrossTransactions(t *testing.T) {
defer func(old int64) { trimEvery = old }(trimEvery)
trimEvery = 100
s := openTest(t, "")
const maxLen = 50
for i := int64(0); i < 3*trimEvery; i++ {
update(t, s, func(tx *Tx) error {
_, err := tx.Append("x", maxLen, map[string]string{"i": "1"})
return err
})
}
n, err := s.Len(context.Background(), "x")
if err != nil {
t.Fatal(err)
}
if n > maxLen+trimEvery {
t.Fatalf("stream holds %d entries, want <= %d", n, maxLen+trimEvery)
}
oldest, ok, err := s.Oldest(context.Background(), "x")
if err != nil || !ok {
t.Fatal(err)
}
if last, _ := s.LastID(context.Background(), "x"); !idLess(t, oldest.ID, last) {
t.Fatalf("oldest %s should precede last %s", oldest.ID, last)
}
}
// A subscriber that took the wake channel before reading is woken by the next
// commit — the property the SSE pump relies on to not miss an append.
func TestWaitIsClosedByTheNextCommit(t *testing.T) {
s := openTest(t, "")
wake := s.Wait("meta:events")
update(t, s, func(tx *Tx) error { return tx.Put("file:A/x", "1") })
select {
case <-wake:
case <-time.After(2 * time.Second):
t.Fatal("commit did not wake the waiter")
}
}
func TestReadAfterZeroFormsReadFromTheStart(t *testing.T) {
s := openTest(t, "")
update(t, s, func(tx *Tx) error {
_, err := tx.Append("x", 0, map[string]string{"n": "1"})
return err
})
for _, id := range []string{"", "0", "0-0"} {
got, err := s.ReadAfter(context.Background(), "x", id, 10)
if err != nil || len(got) != 1 {
t.Errorf("ReadAfter(%q) = %d entries, err %v; want 1", id, len(got), err)
}
}
}
func TestRangeIsExactlyThePrefix(t *testing.T) {
s := openTest(t, "")
update(t, s, func(tx *Tx) error {
for _, k := range []string{"file:A/a", "file:A/b/c", "file:A0", "file:AB/x", "file:A"} {
_ = tx.Put(k, "v")
}
return nil
})
rows, err := s.Range(context.Background(), "file:A/", "", 0)
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 || rows[0].K != "file:A/a" || rows[1].K != "file:A/b/c" {
t.Fatalf("range = %+v", rows)
}
}
// Concurrent Updates share commits; a failing one must roll back alone and
// leave its neighbours' writes and events intact.
func TestGroupCommitIsolatesFailures(t *testing.T) {
s := openTest(t, "")
const n = 200
errs := make(chan error, n)
for i := 0; i < n; i++ {
go func(i int) {
errs <- s.Update(context.Background(), func(tx *Tx) error {
k := fmt.Sprintf("file:R%03d/title", i)
if err := tx.Put(k, "x"); err != nil {
return err
}
if i%10 == 0 {
return errors.New("fail")
}
return nil
})
}(i)
}
failed := 0
for i := 0; i < n; i++ {
if <-errs != nil {
failed++
}
}
if failed != n/10 {
t.Fatalf("failed = %d, want %d", failed, n/10)
}
rows, _ := s.Range(context.Background(), "file:", "", 0)
if len(rows) != n-n/10 {
t.Fatalf("committed %d rows, want %d", len(rows), n-n/10)
}
if es := events(t, s); len(es) != n-n/10 {
t.Fatalf("events = %d, want %d (one per committed write, none for failures)", len(es), n-n/10)
}
for _, r := range rows {
var i int
fmt.Sscanf(r.K, "file:R%03d/title", &i)
if i%10 == 0 {
t.Fatalf("failed request's write %s committed", r.K)
}
}
}
// The change hook sees real changes only, with old and new values, and
// counters follow the request's fate (a failed request's deltas are dropped).
func TestOnChangeAndCounters(t *testing.T) {
type change struct{ k, old, new string }
var seen []change
s, err := Open(filepath.Join(t.TempDir(), "kv.db"), Options{
OnChange: func(tx *Tx, k, old string, hadOld bool, v string, hasNew bool) {
seen = append(seen, change{k, old, v})
if hasNew {
tx.AddCounter("count/"+v, 1)
}
if hadOld {
tx.AddCounter("count/"+old, -1)
}
},
})
if err != nil {
t.Fatal(err)
}
defer s.Close()
update(t, s, func(tx *Tx) error {
_ = tx.Put("a", "x")
_ = tx.Put("b", "x")
_ = tx.Put("a", "x") // no-op
_ = tx.Put("a", "y")
return nil
})
_ = s.Update(context.Background(), func(tx *Tx) error {
_ = tx.Put("c", "x")
return errors.New("fail")
})
update(t, s, func(tx *Tx) error { _, err := tx.DeletePrefix("b", 0); return err })
if len(seen) != 5 { // a=x, b=x, a x→y, c=x (rolled back, hook still ran), del b; stats count attempts
t.Fatalf("changes = %+v", seen)
}
for k, want := range map[string]int64{"count/x": 0, "count/y": 1} {
got, _, err := s.Counter(context.Background(), k)
if err != nil || got != want {
t.Errorf("%s = %d (err %v), want %d", k, got, err, want)
}
}
if changed, unchanged := s.WriteStats(); changed != 4 || unchanged != 1 {
t.Errorf("write stats = %d changed / %d unchanged", changed, unchanged)
}
}
+356
View File
@@ -0,0 +1,356 @@
package kv
// Append-only event streams, as rows of the kv table.
//
// A stream entry is the row
//
// ev:<stream>/<ms:13>-<seq:10> → JSON object of the entry's string fields
//
// Zero padding makes key order equal ID order, so reading "after an ID" is a
// plain range read and trimming is a range delete. On the wire IDs are the
// unpadded Redis form (`1700000000000-0`): meta-sort, meta-share and
// meta-stremio persist SSE cursors to disk across the Redis → SQLite switch,
// and those compare as <ms>-<seq> pairs.
//
// The last issued ID is kept in `evhwm:<stream>` and updated in the same
// transaction as the append. That high-water mark, not the newest surviving
// row, seeds the next ID — so Clear (the watcher's `reset`) never reissues an
// ID an old cursor would treat as already seen.
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
// Entry is one stream entry.
type Entry struct {
ID string
Values map[string]string
}
type streamID struct{ ms, seq int64 }
func (id streamID) String() string {
return strconv.FormatInt(id.ms, 10) + "-" + strconv.FormatInt(id.seq, 10)
}
func (id streamID) padded() string { return fmt.Sprintf("%013d-%010d", id.ms, id.seq) }
func (id streamID) less(o streamID) bool {
return id.ms < o.ms || (id.ms == o.ms && id.seq < o.seq)
}
// ParseID parses a Redis-style stream ID. "", "0", "0-0", "-" all parse as the
// zero ID (before everything). A bare "<ms>" means "<ms>-0".
func ParseID(s string) (ms, seq int64, err error) {
if s == "" || s == "-" || s == "0" {
return 0, 0, nil
}
msPart, seqPart, hasSeq := strings.Cut(s, "-")
ms, err = strconv.ParseInt(msPart, 10, 64)
if err != nil || ms < 0 {
return 0, 0, fmt.Errorf("invalid stream id %q", s)
}
if hasSeq {
seq, err = strconv.ParseInt(seqPart, 10, 64)
if err != nil || seq < 0 {
return 0, 0, fmt.Errorf("invalid stream id %q", s)
}
}
return ms, seq, nil
}
func streamPrefix(stream string) string { return "ev:" + stream + "/" }
func hwmKey(stream string) string { return "evhwm:" + stream }
// streamState is the in-memory view of a stream's high-water mark plus the
// broadcast channel subscribers wait on. Updated only after a commit.
type streamState struct {
loaded bool
hwm streamID
sinceTrim int64 // committed appends since the last trim
notify chan struct{}
}
// streamPending is what a transaction did to one stream, applied to
// streamState on commit (and discarded on rollback).
type streamPending struct {
hwm streamID
appends int64
trimmed bool
lastTrimAt int64 // value of appends when this transaction last trimmed
}
func (s *Store) state(stream string) *streamState {
st := s.streams[stream]
if st == nil {
st = &streamState{notify: make(chan struct{})}
s.streams[stream] = st
}
return st
}
// Wait returns a channel closed by the next commit that appends to stream.
// Take it BEFORE reading, so an append between the read and the wait is not
// missed.
func (s *Store) Wait(stream string) <-chan struct{} {
s.streamMu.Lock()
defer s.streamMu.Unlock()
return s.state(stream).notify
}
func (s *Store) afterCommit(pending map[string]*streamPending) {
if len(pending) == 0 {
return
}
s.streamMu.Lock()
defer s.streamMu.Unlock()
for name, p := range pending {
st := s.state(name)
st.loaded = true
st.hwm = p.hwm
if p.trimmed {
st.sinceTrim = p.appends - p.lastTrimAt
} else {
st.sinceTrim += p.appends
}
close(st.notify)
st.notify = make(chan struct{})
}
}
// hwm returns the stream's last issued ID as seen by this transaction.
func (t *Tx) hwm(stream string) (streamID, error) {
if p := t.g.pending[stream]; p != nil {
return p.hwm, nil
}
t.s.streamMu.Lock()
st := t.s.state(stream)
loaded, hwm := st.loaded, st.hwm
t.s.streamMu.Unlock()
if loaded {
return hwm, nil
}
v, ok, err := t.Get(t.ctx, hwmKey(stream))
if err != nil {
return streamID{}, err
}
if !ok {
return streamID{}, nil
}
ms, seq, err := ParseID(v)
if err != nil {
return streamID{}, err
}
return streamID{ms, seq}, nil
}
// Append adds an entry to stream and returns its ID. The stream is trimmed to
// about maxLen entries (maxLen <= 0: unbounded).
func (t *Tx) Append(stream string, maxLen int64, fields map[string]string) (string, error) {
last, err := t.hwm(stream)
if err != nil {
return "", fmt.Errorf("stream %s hwm: %w", stream, err)
}
id := streamID{ms: time.Now().UnixMilli()}
if !last.less(id) {
id = streamID{ms: last.ms, seq: last.seq + 1}
}
body, err := json.Marshal(fields)
if err != nil {
return "", err
}
if _, err := t.stmt(t.s.stInsert).ExecContext(t.ctx, streamPrefix(stream)+id.padded(), string(body)); err != nil {
return "", fmt.Errorf("append %s: %w", stream, err)
}
if t.g.pending == nil {
t.g.pending = make(map[string]*streamPending)
}
p := t.g.pending[stream]
if p == nil {
p = &streamPending{}
t.g.pending[stream] = p
}
p.hwm = id
p.appends++
// Trim in steps, not per append: MAXLEN ~ semantics, like the XADD it
// replaces. The count spans transactions (most append one event each).
if maxLen > 0 {
t.s.streamMu.Lock()
since := t.s.state(stream).sinceTrim
t.s.streamMu.Unlock()
if !p.trimmed {
since += p.appends
} else {
since = p.appends - p.lastTrimAt
}
if since >= trimEvery {
if err := t.trim(stream, maxLen); err != nil {
return "", err
}
p.trimmed, p.lastTrimAt = true, p.appends
}
}
return id.String(), nil
}
// trimEvery is how many appends pass between trims. A trim locates its cut
// point by walking maxLen entries back from the newest, so it is kept rare;
// the cost is overshooting maxLen by at most trimEvery entries.
var trimEvery int64 = 10_000 // var so tests can lower it
func (t *Tx) trim(stream string, maxLen int64) error {
prefix := streamPrefix(stream)
var cut string
err := t.sqlTx().QueryRowContext(t.ctx,
`SELECT k FROM kv WHERE k >= ? AND k < ? ORDER BY k DESC LIMIT 1 OFFSET ?`,
prefix, PrefixEnd(prefix), maxLen).Scan(&cut)
if err != nil {
return nil // fewer than maxLen entries (sql.ErrNoRows): nothing to trim
}
_, err = t.sqlTx().ExecContext(t.ctx, `DELETE FROM kv WHERE k >= ? AND k <= ?`, prefix, cut)
return err
}
// flushHWM persists each touched stream's high-water mark — once per
// transaction, not per append.
func (t *Tx) flushHWM() error {
for stream, p := range t.g.pending {
if _, err := t.stmt(t.s.stPut).ExecContext(t.ctx, hwmKey(stream), p.hwm.String()); err != nil {
return fmt.Errorf("stream %s hwm: %w", stream, err)
}
}
return nil
}
// TrimAll trims stream to maxLen inside the transaction regardless of how many
// appends this transaction made. Used after an append burst split over many
// short transactions.
func (t *Tx) TrimAll(stream string, maxLen int64) error { return t.trim(stream, maxLen) }
// ClearStream deletes every entry of stream, keeping its high-water mark.
func (t *Tx) ClearStream(stream string) error {
prefix := streamPrefix(stream)
_, err := t.sqlTx().ExecContext(t.ctx, `DELETE FROM kv WHERE k >= ? AND k < ?`, prefix, PrefixEnd(prefix))
return err
}
func decodeEntry(stream, k, v string) (Entry, error) {
padded := strings.TrimPrefix(k, streamPrefix(stream))
ms, seq, err := ParseID(padded)
if err != nil {
return Entry{}, err
}
var values map[string]string
if err := json.Unmarshal([]byte(v), &values); err != nil {
return Entry{}, fmt.Errorf("decode %s: %w", k, err)
}
return Entry{ID: streamID{ms, seq}.String(), Values: values}, nil
}
func decodeEntries(stream string, rows []KV) ([]Entry, error) {
out := make([]Entry, 0, len(rows))
for _, kv := range rows {
e, err := decodeEntry(stream, kv.K, kv.V)
if err != nil {
return nil, err
}
out = append(out, e)
}
return out, nil
}
// ReadAfter returns up to n entries strictly after id, oldest first. "0",
// "0-0" and "" read from the beginning.
func (s *Store) ReadAfter(ctx context.Context, stream, id string, n int) ([]Entry, error) {
ms, seq, err := ParseID(id)
if err != nil {
return nil, err
}
prefix := streamPrefix(stream)
after := ""
if ms != 0 || seq != 0 {
after = prefix + streamID{ms, seq}.padded()
}
rows, err := rangeQuery(ctx, s.r, prefix, after, n)
if err != nil {
return nil, err
}
return decodeEntries(stream, rows)
}
// Oldest returns the oldest surviving entry, or ok=false for an empty stream.
func (s *Store) Oldest(ctx context.Context, stream string) (Entry, bool, error) {
rows, err := rangeQuery(ctx, s.r, streamPrefix(stream), "", 1)
if err != nil || len(rows) == 0 {
return Entry{}, false, err
}
e, err := decodeEntry(stream, rows[0].K, rows[0].V)
return e, err == nil, err
}
// LastID returns the last ID ever issued on stream ("0-0" if none) — the
// cursor meaning "only entries appended from now on".
func (s *Store) LastID(ctx context.Context, stream string) (string, error) {
s.streamMu.Lock()
st := s.state(stream)
loaded, hwm := st.loaded, st.hwm
s.streamMu.Unlock()
if loaded {
return hwm.String(), nil
}
v, ok, err := s.Get(ctx, hwmKey(stream))
if err != nil {
return "", err
}
if !ok {
return "0-0", nil
}
return v, nil
}
// Len is the number of surviving entries.
func (s *Store) Len(ctx context.Context, stream string) (int64, error) {
return count(ctx, s.r, streamPrefix(stream))
}
// RevRange returns up to n entries newest first, stopping at (excluding)
// entries at or before afterID when afterID is non-empty.
func (s *Store) RevRange(ctx context.Context, stream, afterID string, n int) ([]Entry, error) {
prefix := streamPrefix(stream)
query := `SELECT k, v FROM kv WHERE k > ? AND k < ? ORDER BY k DESC`
lo := prefix
if afterID != "" {
ms, seq, err := ParseID(afterID)
if err != nil {
return nil, err
}
lo = prefix + streamID{ms, seq}.padded()
}
args := []any{lo, PrefixEnd(prefix)}
if n > 0 {
query += ` LIMIT ?`
args = append(args, n)
}
rows, err := s.r.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var kvs []KV
for rows.Next() {
var kv KV
if err := rows.Scan(&kv.K, &kv.V); err != nil {
return nil, err
}
kvs = append(kvs, kv)
}
if err := rows.Err(); err != nil {
return nil, err
}
return decodeEntries(stream, kvs)
}
+439
View File
@@ -0,0 +1,439 @@
package meshdisco
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"os"
"sort"
"sync"
"syscall"
"time"
"golang.org/x/net/ipv4"
"golang.org/x/sys/unix"
)
// Config configures a Node. Payload is a function rather than a value so the
// advertise is rebuilt from live state on every tick — that is what keeps the
// advertised core URLs and GET /urls from drifting apart.
type Config struct {
Name string
Instance string
Version string
Group string
Port int
Interval time.Duration
// Payload returns this node's status ("starting" / "running") and its
// resources. The envelope and NodeInfo are filled in by the Node.
Payload func() (status string, resources []Resource)
}
func (c *Config) applyDefaults() {
if c.Group == "" {
c.Group = DefaultGroup
}
if c.Port == 0 {
c.Port = DefaultPort
}
if c.Interval <= 0 {
c.Interval = DefaultInterval
}
}
type seen struct {
node NodeInfo
resources []Resource
addr string
lastSeen time.Time
}
// Node is one beacon v2 participant: it advertises its resources, answers
// probes, and keeps a TTL map of every other node it has heard from.
type Node struct {
cfg Config
group net.IP
pc *ipv4.PacketConn
closer interface{ Close() error }
// ifaces are the multicast-capable interfaces we joined and send on.
// A container attached to two docker networks (metashare-app on `pcs`
// and `metamesh-mesh`, metawatch-* on `metawatch` and `metamesh-mesh`)
// has two entries here, and MUST be advertised on both — sending via the
// default route alone silently covers only one network.
ifaces []net.Interface
// sendMu serialises SetMulticastInterface + WriteTo, which are a
// two-step operation on one shared socket.
sendMu sync.Mutex
mu sync.RWMutex
nodes map[string]*seen
stopChan chan struct{}
stopOnce sync.Once
wg sync.WaitGroup
}
// New creates a Node. It does not touch the network until Start.
func New(cfg Config) *Node {
cfg.applyDefaults()
return &Node{
cfg: cfg,
nodes: make(map[string]*seen),
stopChan: make(chan struct{}),
}
}
// Start opens the multicast socket, joins the group on every eligible
// interface, and begins advertising. A join failure on one interface is logged
// and skipped rather than fatal — a host that blocks multicast degrades
// instead of failing to boot.
func (n *Node) Start() error {
n.group = net.ParseIP(n.cfg.Group)
if n.group == nil || n.group.To4() == nil {
return fmt.Errorf("beacon: invalid multicast group %q", n.cfg.Group)
}
lc := net.ListenConfig{Control: reusePort}
conn, err := lc.ListenPacket(context.Background(), "udp4", fmt.Sprintf(":%d", n.cfg.Port))
if err != nil {
return fmt.Errorf("beacon: listen on :%d: %w", n.cfg.Port, err)
}
n.closer = conn
pc := ipv4.NewPacketConn(conn)
// TTL 1: link-local only. Discovery never routes off the local segment;
// the two-host topology keeps two separate meshes by design.
if err := pc.SetMulticastTTL(1); err != nil {
log.Printf("[beacon] Warning: could not set multicast TTL: %v", err)
}
n.pc = pc
n.ifaces = eligibleInterfaces()
if len(n.ifaces) == 0 {
log.Println("[beacon] Warning: no multicast-capable interfaces found; discovery will be inert")
}
group := &net.UDPAddr{IP: n.group}
joined := 0
for i := range n.ifaces {
if err := pc.JoinGroup(&n.ifaces[i], group); err != nil {
log.Printf("[beacon] Warning: join %s on %s failed: %v", n.cfg.Group, n.ifaces[i].Name, err)
continue
}
joined++
}
log.Printf("[beacon] Listening on %s:%d as %s/%s (joined %d of %d interfaces)",
n.cfg.Group, n.cfg.Port, n.cfg.Name, n.cfg.Instance, joined, len(n.ifaces))
n.wg.Add(2)
go n.readLoop()
go n.advertiseLoop()
return nil
}
// Stop multicasts a bye — so neighbours drop us immediately instead of
// waiting out the staleness window — then closes the socket and waits for the
// loops to exit.
func (n *Node) Stop() error {
var err error
n.stopOnce.Do(func() {
n.broadcast(Message{Proto: Proto, V: Version, Type: TypeBye, Node: n.nodeInfo("")})
close(n.stopChan)
if n.closer != nil {
err = n.closer.Close()
}
n.wg.Wait()
})
return err
}
// Probe multicasts a probe on every interface. Every node owning a resource
// matching one of want (every node, when want is empty) replies immediately,
// so a caller does not have to wait out an interval.
func (n *Node) Probe(want ...string) {
n.broadcast(Message{Proto: Proto, V: Version, Type: TypeProbe, From: n.cfg.Instance, Want: want})
}
// Neighbors returns every node heard from within the staleness window,
// sorted by (name, instance). Expired entries are dropped as a side effect —
// expiry is a read-time concern, so there is no reaper goroutine.
func (n *Node) Neighbors() []Neighbor {
cutoff := time.Now().Add(-n.cfg.Interval * LivenessFactor)
n.mu.Lock()
out := make([]Neighbor, 0, len(n.nodes))
for k, s := range n.nodes {
if s.lastSeen.Before(cutoff) {
delete(n.nodes, k)
continue
}
out = append(out, newNeighbor(s.node, s.resources, s.addr, s.lastSeen))
}
n.mu.Unlock()
sort.Slice(out, func(i, j int) bool {
if out[i].Name != out[j].Name {
return out[i].Name < out[j].Name
}
return out[i].Instance < out[j].Instance
})
return out
}
// NeighborsByName collapses multiple instances of one name down to the most
// recently seen — the shape the nav menu wants.
func (n *Node) NeighborsByName() []Neighbor {
best := make(map[string]Neighbor)
for _, nb := range n.Neighbors() {
if cur, ok := best[nb.Name]; !ok || nb.LastSeen > cur.LastSeen {
best[nb.Name] = nb
}
}
out := make([]Neighbor, 0, len(best))
for _, nb := range best {
out = append(out, nb)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Self returns this node as a row, so callers can render themselves without
// waiting to hear their own multicast echo back.
func (n *Node) Self() Neighbor {
status, resources := n.payload()
return newNeighbor(*n.nodeInfo(status), resources, "", time.Now())
}
func (n *Node) payload() (string, []Resource) {
if n.cfg.Payload == nil {
return "running", nil
}
return n.cfg.Payload()
}
func (n *Node) nodeInfo(status string) *NodeInfo {
return &NodeInfo{Name: n.cfg.Name, Instance: n.cfg.Instance, Version: n.cfg.Version, Status: status}
}
func (n *Node) advertiseMsg() Message {
status, resources := n.payload()
return Message{Proto: Proto, V: Version, Type: TypeAdvertise, Node: n.nodeInfo(status), Resources: resources}
}
func (n *Node) readLoop() {
defer n.wg.Done()
buf := make([]byte, ReadBufferSize)
for {
select {
case <-n.stopChan:
return
default:
}
nRead, _, src, err := n.pc.ReadFrom(buf)
if err != nil {
select {
case <-n.stopChan:
return
default:
}
log.Printf("[beacon] Read error: %v", err)
continue
}
msg, ok := Parse(buf[:nRead])
if !ok {
continue // v1 or foreign traffic on the shared group
}
switch msg.Type {
case TypeProbe:
if msg.From == n.cfg.Instance {
continue
}
ad := n.advertiseMsg()
wanted := len(msg.Want) == 0
for _, r := range ad.Resources {
if r.MatchesAny(msg.Want) {
wanted = true
break
}
}
if wanted {
// Unicast straight back to the prober's source address; its
// ephemeral source port is where it is listening.
n.send(ad, src)
}
case TypeAdvertise, TypeBye:
// Drop our own multicast echo. Loopback stays enabled so a
// single-container test still sees traffic; we filter by identity.
if msg.Node.Instance == n.cfg.Instance {
continue
}
n.record(msg, src)
}
}
}
func (n *Node) record(msg Message, src net.Addr) {
addr := ""
if src != nil {
if udp, ok := src.(*net.UDPAddr); ok {
addr = udp.IP.String()
} else {
addr = src.String()
}
}
n.mu.Lock()
defer n.mu.Unlock()
if msg.Type == TypeBye {
delete(n.nodes, msg.Node.Instance)
return
}
n.nodes[msg.Node.Instance] = &seen{node: *msg.Node, resources: msg.Resources, addr: addr, lastSeen: time.Now()}
}
func (n *Node) advertiseLoop() {
defer n.wg.Done()
// Advertise immediately, then probe once so the map fills without
// waiting a full interval for everyone else's ticker.
n.broadcast(n.advertiseMsg())
n.Probe()
ticker := time.NewTicker(n.cfg.Interval)
defer ticker.Stop()
for {
select {
case <-n.stopChan:
return
case <-ticker.C:
n.broadcast(n.advertiseMsg())
}
}
}
func encode(msg Message) ([]byte, bool) {
body, err := json.Marshal(msg)
if err != nil {
return nil, false
}
if len(body) > MaxDatagram {
log.Printf("[beacon] Warning: %d-byte datagram exceeds the fragmentation-safe %d", len(body), MaxDatagram)
}
return body, true
}
// send unicasts msg to dst (a probe reply).
func (n *Node) send(msg Message, dst net.Addr) {
body, ok := encode(msg)
if !ok {
return
}
n.sendMu.Lock()
defer n.sendMu.Unlock()
if _, err := n.pc.WriteTo(body, nil, dst); err != nil {
log.Printf("[beacon] Reply to %s failed: %v", dst, err)
}
}
// broadcast writes one message to the group once per eligible interface.
// Writing once and letting the routing table choose would reach exactly one
// network, which is the failure mode this protocol exists to avoid.
func (n *Node) broadcast(msg Message) {
if n.pc == nil {
return
}
body, ok := encode(msg)
if !ok {
return
}
dst := &net.UDPAddr{IP: n.group, Port: n.cfg.Port}
n.sendMu.Lock()
defer n.sendMu.Unlock()
for i := range n.ifaces {
if err := n.pc.SetMulticastInterface(&n.ifaces[i]); err != nil {
log.Printf("[beacon] Warning: select interface %s failed: %v", n.ifaces[i].Name, err)
continue
}
if _, err := n.pc.WriteTo(body, nil, dst); err != nil {
log.Printf("[beacon] Warning: send on %s failed: %v", n.ifaces[i].Name, err)
}
}
}
// eligibleInterfaces returns every up, non-loopback, multicast-capable
// interface that has an IPv4 address.
func eligibleInterfaces() []net.Interface {
all, err := net.Interfaces()
if err != nil {
log.Printf("[beacon] Warning: could not enumerate interfaces: %v", err)
return nil
}
var out []net.Interface
for _, ifi := range all {
if ifi.Flags&net.FlagUp == 0 ||
ifi.Flags&net.FlagLoopback != 0 ||
ifi.Flags&net.FlagMulticast == 0 {
continue
}
addrs, err := ifi.Addrs()
if err != nil {
continue
}
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil {
out = append(out, ifi)
break
}
}
}
return out
}
// reusePort sets SO_REUSEADDR + SO_REUSEPORT so several listeners (a test, a
// sidecar, a beacon v1 responder in the same netns) can bind 9099 on one host.
func reusePort(_, _ string, c syscall.RawConn) error {
var serr error
err := c.Control(func(fd uintptr) {
serr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1)
if serr == nil {
serr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1)
}
})
if err != nil {
return err
}
return serr
}
// LocalIPv4 returns this host's first non-loopback IPv4 address, or the
// hostname when none can be determined. Same fallback the file-based registry
// used (discovery/service.go), kept identical so announced URLs don't shift.
func LocalIPv4() string {
addrs, err := net.InterfaceAddrs()
if err == nil {
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
return ipnet.IP.String()
}
}
}
}
host, _ := os.Hostname()
return host
}
+237
View File
@@ -0,0 +1,237 @@
// Package meshdisco implements beacon v2: local resource advertise / discover
// over UDP multicast (239.255.99.1:9099), the protocol every meta-* service
// and SDK-hosted plugin uses to find its neighbours.
//
// A node advertises Resources, each tagged with capability strings
// ("metamesh.core", "metamesh.service/meta-sort", "metamesh.transport/nzb@1");
// consumers keep a live view of everyone else's and filter by capability.
// meta-core advertises "metamesh.core" carrying its /urls block, which is how
// services locate it without a shared volume.
//
// The wire protocol is documented in docs/project-architecture/beacon-v2.md.
// Keep this package and that doc in step — the Rust (meta-feeder-sdk),
// TypeScript and Python implementations are written against the doc.
package meshdisco
import (
"encoding/json"
"strings"
"time"
)
const (
// Proto and Version form the envelope every v2 message carries. Anything
// else on the group (beacon v1, junk) is dropped silently.
Proto = "beacon"
Version = 2
// DefaultGroup / DefaultPort are beacon's endpoint, on purpose: one
// well-known place for every kind of local resource. v1 and v2 coexist
// because v2 never sends v1's "discovery" / "announce" types.
DefaultGroup = "239.255.99.1"
DefaultPort = 9099
// DefaultInterval is how often an unsolicited advertise is emitted.
DefaultInterval = 10 * time.Second
// LivenessFactor multiplies the interval to get the staleness window.
// Expiry is evaluated at read time; there is no reaper.
LivenessFactor = 3
// ReadBufferSize is the receive buffer; MaxDatagram is what a sender
// stays under so nothing fragments.
ReadBufferSize = 8192
MaxDatagram = 1400
TypeProbe = "probe"
TypeAdvertise = "advertise"
TypeBye = "bye"
// CapCore is meta-core's capability; its resource carries Data.urls.
CapCore = "metamesh.core"
// CapServicePrefix + name is every service's capability.
CapServicePrefix = "metamesh.service/"
CapAnyService = "metamesh.service/*"
)
// URLs mirrors meta-core's GET /urls response (api.URLsResponse) minus
// redisUrl. It travels as the metamesh.core resource's Data.urls, so a service
// locates meta-core from the advertise alone.
type URLs struct {
Hostname string `json:"hostname"`
BaseUrl string `json:"baseUrl"`
ApiUrl string `json:"apiUrl"`
WebdavUrl string `json:"webdavUrl"`
WebdavUrlInternal string `json:"webdavUrlInternal"`
}
// NodeInfo says who is advertising.
type NodeInfo struct {
Name string `json:"name"`
Instance string `json:"instance"`
Version string `json:"version,omitempty"`
// Status is "starting" | "running"; absent reads as running.
Status string `json:"status,omitempty"`
}
// Resource is one advertised thing.
type Resource struct {
ID string `json:"id"`
Caps []string `json:"caps"`
// Endpoints: well-known names http, ui, manifest, mcp. A value starting
// with "/" is relative to endpoints.http.
Endpoints map[string]string `json:"endpoints,omitempty"`
Rev string `json:"rev,omitempty"`
// Binds names the one consumer instance this resource belongs to.
Binds string `json:"binds,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
// Matches reports whether any cap matches pattern.
func (r Resource) Matches(pattern string) bool {
for _, c := range r.Caps {
if CapMatches(pattern, c) {
return true
}
}
return false
}
// MatchesAny reports whether any cap matches any pattern; an empty list matches.
func (r Resource) MatchesAny(patterns []string) bool {
if len(patterns) == 0 {
return true
}
for _, p := range patterns {
if r.Matches(p) {
return true
}
}
return false
}
// Endpoint returns the named endpoint as an absolute URL.
func (r Resource) Endpoint(name string) string {
v := r.Endpoints[name]
if strings.HasPrefix(v, "/") {
base := r.Endpoints["http"]
if base == "" {
return ""
}
return strings.TrimRight(base, "/") + v
}
return v
}
// Message is every v2 datagram.
type Message struct {
Proto string `json:"proto"`
V int `json:"v"`
Type string `json:"type"`
Node *NodeInfo `json:"node,omitempty"`
Resources []Resource `json:"resources,omitempty"`
// From and Want are probe-only.
From string `json:"from,omitempty"`
Want []string `json:"want,omitempty"`
}
// Parse decodes a datagram, returning ok=false for anything that is not a
// well-formed beacon v2 message (v1 traffic, other versions, unknown types,
// an advertise/bye without a node).
func Parse(b []byte) (Message, bool) {
var m Message
if err := json.Unmarshal(b, &m); err != nil {
return m, false
}
if m.Proto != Proto || m.V != Version {
return m, false
}
switch m.Type {
case TypeProbe:
return m, true
case TypeAdvertise, TypeBye:
if m.Node == nil || m.Node.Name == "" || m.Node.Instance == "" {
return m, false
}
return m, true
}
return m, false
}
func splitContract(s string) (string, string, bool) {
if i := strings.LastIndex(s, "@"); i >= 0 {
return s[:i], s[i+1:], true
}
return s, "", false
}
// CapMatches reports whether capability cap satisfies pattern:
// "*" matches everything; an "@N" on the pattern must equal the cap's
// contract (none on the pattern ignores it); "x/*" matches any variant of x;
// otherwise the bases must be equal.
func CapMatches(pattern, cap string) bool {
if pattern == "*" {
return true
}
pbase, pc, phas := splitContract(pattern)
cbase, cc, chas := splitContract(cap)
if phas && (!chas || pc != cc) {
return false
}
if prefix, ok := strings.CutSuffix(pbase, "/*"); ok {
rest, ok := strings.CutPrefix(cbase, prefix)
return ok && strings.HasPrefix(rest, "/") && len(rest) > 1
}
return pbase == cbase
}
// Neighbor is a node heard on the wire, as served by /api/neighbors.
type Neighbor struct {
NodeInfo
Resources []Resource `json:"resources"`
// Caps flattens every resource's caps, for consumers that just filter.
Caps []string `json:"caps"`
// BaseUrl is the metamesh.service/* resource's endpoints.ui — kept so
// v1-era readers (the nav menu) keep working.
BaseUrl string `json:"baseUrl,omitempty"`
// Addr is the packet's source address, never a payload claim.
Addr string `json:"addr"`
// LastSeen is unix seconds.
LastSeen float64 `json:"lastSeen"`
}
// Matches reports whether any resource matches pattern.
func (n Neighbor) Matches(pattern string) bool {
for _, r := range n.Resources {
if r.Matches(pattern) {
return true
}
}
return false
}
func newNeighbor(node NodeInfo, resources []Resource, addr string, seen time.Time) Neighbor {
nb := Neighbor{
NodeInfo: node,
Resources: resources,
Caps: []string{},
Addr: addr,
LastSeen: float64(seen.UnixNano()) / 1e9,
}
if nb.Resources == nil {
nb.Resources = []Resource{}
}
seenCap := map[string]bool{}
for _, r := range resources {
for _, c := range r.Caps {
if !seenCap[c] {
seenCap[c] = true
nb.Caps = append(nb.Caps, c)
}
}
if nb.BaseUrl == "" && r.Matches(CapAnyService) {
nb.BaseUrl = r.Endpoint("ui")
}
}
return nb
}
+140
View File
@@ -0,0 +1,140 @@
package meshdisco
import (
"encoding/json"
"net"
"testing"
"time"
)
func TestCapMatches(t *testing.T) {
cases := []struct {
pattern, cap string
want bool
}{
{"metamesh.transport/*", "metamesh.transport/nzb@1", true},
{"metamesh.transport/*", "metamesh.transport/ipfs", true},
{"metamesh.transport/*", "metamesh.transport", false},
{"metamesh.transport/*", "metamesh.transportx/a", false},
{"metamesh.transport/*", "metamesh.transport/", false},
{"metamesh.transport/nzb", "metamesh.transport/nzb@2", true},
{"metamesh.transport/nzb@2", "metamesh.transport/nzb@2", true},
{"metamesh.transport/nzb@2", "metamesh.transport/nzb@1", false},
{"metamesh.transport/nzb@2", "metamesh.transport/nzb", false},
{"metamesh.transport/*@1", "metamesh.transport/nzb@1", true},
{"metamesh.core", "metamesh.core", true},
{"metamesh.core", "metamesh.core2", false},
{"mcp", "mcp/x", false},
{"*", "anything@3", true},
}
for _, c := range cases {
if got := CapMatches(c.pattern, c.cap); got != c.want {
t.Errorf("CapMatches(%q, %q) = %v, want %v", c.pattern, c.cap, got, c.want)
}
}
}
func TestParseRejectsV1AndForeign(t *testing.T) {
for _, raw := range []string{
`{"type":"discovery"}`,
`{"type":"announce","name":"x","tools":[]}`,
`{"v":1,"type":"announce","name":"meta-sort"}`,
`{"proto":"beacon","v":3,"type":"probe"}`,
`{"proto":"beacon","v":2,"type":"announce"}`,
`{"proto":"beacon","v":2,"type":"advertise"}`,
"\x00garbage",
} {
if _, ok := Parse([]byte(raw)); ok {
t.Errorf("Parse(%q) accepted", raw)
}
}
if _, ok := Parse([]byte(`{"proto":"beacon","v":2,"type":"probe","want":["metamesh.core"]}`)); !ok {
t.Error("valid probe rejected")
}
}
func TestNeighborRowCarriesCapsAndBaseUrl(t *testing.T) {
nb := newNeighbor(
NodeInfo{Name: "meta-sort", Instance: "metasort-app"},
[]Resource{{ID: "service", Caps: []string{"metamesh.service/meta-sort"}, Endpoints: map[string]string{"ui": "https://x"}}},
"10.0.0.1", time.Now(),
)
if nb.BaseUrl != "https://x" || len(nb.Caps) != 1 || !nb.Matches(CapAnyService) {
t.Fatalf("unexpected row: %+v", nb)
}
}
// End to end over loopback: a probe with a non-matching want gets no reply;
// a matching one gets the advertise; advertise then bye updates the view.
func TestProbeAndRecord(t *testing.T) {
port := freePort(t)
n := New(Config{
Name: "meta-core", Instance: "core-1", Port: port, Interval: time.Hour,
Payload: func() (string, []Resource) {
return "running", []Resource{{ID: "core", Caps: []string{CapCore}}}
},
})
if err := n.Start(); err != nil {
t.Fatal(err)
}
defer n.Stop()
c, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatal(err)
}
defer c.Close()
dst := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port}
buf := make([]byte, ReadBufferSize)
send := func(m Message) {
b, _ := json.Marshal(m)
if _, err := c.WriteToUDP(b, dst); err != nil {
t.Fatal(err)
}
}
send(Message{Proto: Proto, V: Version, Type: TypeProbe, From: "cli", Want: []string{"metamesh.service/*"}})
c.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
if _, _, err := c.ReadFromUDP(buf); err == nil {
t.Fatal("non-matching probe got a reply")
}
send(Message{Proto: Proto, V: Version, Type: TypeProbe, From: "cli", Want: []string{CapCore}})
c.SetReadDeadline(time.Now().Add(2 * time.Second))
nr, _, err := c.ReadFromUDP(buf)
if err != nil {
t.Fatalf("matching probe got no reply: %v", err)
}
if m, ok := Parse(buf[:nr]); !ok || m.Type != TypeAdvertise || m.Node.Instance != "core-1" {
t.Fatalf("bad reply: %s", buf[:nr])
}
peer := &NodeInfo{Name: "meta-sort", Instance: "sort-1"}
send(Message{Proto: Proto, V: Version, Type: TypeAdvertise, Node: peer,
Resources: []Resource{{ID: "service", Caps: []string{"metamesh.service/meta-sort"}}}})
waitFor(t, func() bool { return len(n.Neighbors()) == 1 })
send(Message{Proto: Proto, V: Version, Type: TypeBye, Node: peer})
waitFor(t, func() bool { return len(n.Neighbors()) == 0 })
}
func freePort(t *testing.T) int {
c, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatal(err)
}
defer c.Close()
return c.LocalAddr().(*net.UDPAddr).Port
}
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("condition not reached")
}
+561
View File
@@ -0,0 +1,561 @@
// Package reports is meta-core's bug-report / feedback inbox.
//
// Client apps (meta-watch, meta-read, …) build a report in the browser — the
// line the person typed, the page they were on, the build, the device, the
// tail of the client log, and whatever app-specific state explains the moment
// (a player pipeline, a reader position) — and their server forwards it here,
// server-to-server, to the box's own core. meta-core stores it, lists it and
// shows it on the dashboard's Reports page, so triage reads one inbox instead
// of one per app.
//
// # Why a third SQLite file
//
// Reports are diagnostics, not metadata. They do not belong in the truth
// database (signed claims only) nor in the cache (unsynchronised, never backed
// up, deleted as the recovery procedure). They live in their own file,
// reports.db, so neither a cache rebuild nor POST /api/metadata/clear touches
// them, and a full report table can never slow a record write.
//
// # What meta-core knows about a report
//
// Almost nothing, on purpose. A handful of top-level fields are lifted into
// columns so the inbox can filter and headline them (app, build, uid, page,
// route, cid, title, note); everything else is kept verbatim in body as JSON
// and rendered generically by the dashboard. An app can add a new block of
// state without a meta-core release.
//
// ⚠ A report is a piece of viewing/reading history (cids, titles, a uid, a
// timestamp). It stays on the box, behind the same perimeter as the rest of
// meta-core's API; nothing here is federated.
package reports
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"unicode/utf8"
"github.com/metazla/meta-core/internal/kv"
)
const schema = `
CREATE TABLE IF NOT EXISTS report(
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL, -- when the report was filed (ms)
received INTEGER NOT NULL, -- when meta-core stored it (ms)
app TEXT NOT NULL,
build TEXT NOT NULL DEFAULT '',
uid TEXT NOT NULL DEFAULT '',
page TEXT NOT NULL DEFAULT '',
route TEXT NOT NULL DEFAULT '',
cid TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open',
triage_note TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0,
log_lines INTEGER NOT NULL DEFAULT 0,
bytes INTEGER NOT NULL,
body TEXT NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS report_app_status ON report(app, status, id DESC);
CREATE INDEX IF NOT EXISTS report_status ON report(status, id DESC);
`
// Statuses a report moves through. "open" is where every report starts.
const (
StatusOpen = "open"
StatusTriaged = "triaged"
StatusClosed = "closed"
)
// ValidStatus reports whether s is a status a report can be set to.
func ValidStatus(s string) bool {
return s == StatusOpen || s == StatusTriaged || s == StatusClosed
}
// Bounds on what one report may carry. The string caps match the ones
// meta-watch applied when it kept reports itself, so moving the inbox loses
// nothing a report used to hold.
const (
maxApp = 32
maxBuild = 4000
maxUID = 128
maxPage = 32
maxRoute = 512
maxCID = 128
maxTitle = 200
maxNote = 1000
maxTriageNote = 2000
// MaxLogLines / MaxLogLineChars bound the client log tail.
MaxLogLines = 200
MaxLogLineChars = 600
// MaxBlockBytes bounds every other top-level block (context, device,
// app-specific state). A larger block is replaced by a marker naming its
// size, never dropped silently.
MaxBlockBytes = 32 * 1024
// maxBlocks bounds the number of top-level keys.
maxBlocks = 64
// filed-at plausibility window: a report spooled by an app while the core
// was down arrives late but keeps the time it was filed.
maxFiledAge = 30 * 24 * time.Hour
maxFiledFuture = 5 * time.Minute
)
// ErrNotFound is returned for an id that does not exist.
var ErrNotFound = errors.New("report not found")
// Summary is a report without its body: what the inbox list shows.
type Summary struct {
ID int64 `json:"id"`
TS int64 `json:"ts"`
Received int64 `json:"received"`
App string `json:"app"`
Build string `json:"build,omitempty"`
UID string `json:"uid,omitempty"`
Page string `json:"page,omitempty"`
Route string `json:"route,omitempty"`
CID string `json:"cid,omitempty"`
Title string `json:"title,omitempty"`
Note string `json:"note,omitempty"`
Status string `json:"status"`
TriageNote string `json:"triage_note,omitempty"`
Updated int64 `json:"updated,omitempty"`
LogLines int `json:"log_lines"`
Bytes int64 `json:"bytes"`
}
// Report is a stored report with its full body.
type Report struct {
Summary
Body json.RawMessage `json:"body"`
}
// Filter selects reports for List.
type Filter struct {
App string
Status string
Q string // substring of note, title or cid
Before int64 // only ids below this (paging); 0 = from the newest
Limit int
}
// Page is one page of List.
type Page struct {
Reports []Summary `json:"reports"`
Apps map[string]int64 `json:"apps"` // every app with a report, and how many
Counts map[string]int64 `json:"counts"` // per status, within the App filter
More bool `json:"more"`
}
// Store is the open reports database.
type Store struct {
db *kv.Store
maxRows int
maxBytes int64
now func() time.Time
}
// Open opens (creating if needed) the reports database at path. maxRows and
// maxBytes bound what Prune keeps; <= 0 disables that bound.
func Open(path string, maxRows int, maxBytes int64) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("reports dir: %w", err)
}
db, err := kv.Open(path, kv.Options{
NoKVTable: true,
Schema: schema,
WriterCacheMB: 4,
ReaderCacheMB: 2,
Readers: 2,
})
if err != nil {
return nil, fmt.Errorf("open reports %s: %w", path, err)
}
return &Store{db: db, maxRows: maxRows, maxBytes: maxBytes, now: time.Now}, nil
}
// Close closes the database.
func (s *Store) Close() error { return s.db.Close() }
// Path is the database file.
func (s *Store) Path() string { return s.db.Path() }
func opCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 30*time.Second)
}
// ── Normalisation ─────────────────────────────────────────────────────────
// Normalize turns a posted envelope into a storable report: the indexed
// fields lifted and clamped, the log tail bounded, every other block size-
// checked. It never fails on content an app might plausibly send; the only
// error is a report that names no app.
func Normalize(in map[string]json.RawMessage, now time.Time) (Report, error) {
var r Report
str := func(key string, n int) string {
raw, ok := in[key]
if !ok {
return ""
}
var v string
if json.Unmarshal(raw, &v) != nil {
return ""
}
return clamp(strings.TrimSpace(v), n)
}
r.App = str("app", maxApp)
if r.App == "" {
return r, errors.New("app is required")
}
r.Build = str("build", maxBuild)
r.UID = str("uid", maxUID)
r.Page = str("page", maxPage)
r.Route = str("route", maxRoute)
r.CID = str("cid", maxCID)
r.Title = str("title", maxTitle)
r.Note = str("note", maxNote)
r.Status = StatusOpen
r.Received = now.UnixMilli()
r.TS = r.Received
if raw, ok := in["filedAt"]; ok {
var f float64
if json.Unmarshal(raw, &f) == nil {
t := time.UnixMilli(int64(f))
if t.After(now.Add(-maxFiledAge)) && t.Before(now.Add(maxFiledFuture)) {
r.TS = t.UnixMilli()
}
}
}
// The body repeats the lifted fields (clamped), so a report exported as
// JSON is complete on its own.
body := make(map[string]any, len(in))
for k, v := range map[string]string{
"app": r.App, "build": r.Build, "uid": r.UID, "page": r.Page,
"route": r.Route, "cid": r.CID, "title": r.Title, "note": r.Note,
} {
if v != "" {
body[k] = v
}
}
if raw, ok := in["log"]; ok {
var lines []string
if json.Unmarshal(raw, &lines) == nil {
if len(lines) > MaxLogLines {
lines = lines[len(lines)-MaxLogLines:]
}
for i, l := range lines {
lines[i] = clamp(l, MaxLogLineChars)
}
if len(lines) > 0 {
body["log"] = lines
r.LogLines = len(lines)
}
}
}
// Remaining blocks in key order, so which ones survive maxBlocks does not
// depend on map iteration.
keys := make([]string, 0, len(in))
for k := range in {
if k != "log" && k != "filedAt" && !isLifted(k) {
keys = append(keys, k)
}
}
sort.Strings(keys)
blocks := 0
for _, k := range keys {
raw := in[k]
if string(raw) == "null" {
continue
}
if blocks >= maxBlocks {
break
}
blocks++
key := clamp(k, 64)
if len(raw) > MaxBlockBytes {
body[key] = map[string]any{"truncated": true, "bytes": len(raw)}
continue
}
body[key] = raw
}
b, err := json.Marshal(body)
if err != nil {
return r, fmt.Errorf("encode body: %w", err)
}
r.Body = b
r.Bytes = int64(len(b))
return r, nil
}
func isLifted(k string) bool {
switch k {
case "app", "build", "uid", "page", "route", "cid", "title", "note":
return true
}
return false
}
// clamp keeps at most n characters (runes) of s, never splitting one.
func clamp(s string, n int) string {
if utf8.RuneCountInString(s) <= n {
return s
}
i := 0
for j := range s {
if i == n {
return s[:j]
}
i++
}
return s
}
// ── Writes ────────────────────────────────────────────────────────────────
// Insert stores a normalised report and returns its id. The store is pruned
// to its bounds in the same transaction, so it never sits over them.
func (s *Store) Insert(r Report) (int64, error) {
ctx, cancel := opCtx()
defer cancel()
var id int64
err := s.db.Update(ctx, func(tx *kv.Tx) error {
res, err := tx.Exec(`INSERT INTO report(ts, received, app, build, uid, page, route, cid, title, note,
status, log_lines, bytes, body) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
r.TS, r.Received, r.App, r.Build, r.UID, r.Page, r.Route, r.CID, r.Title, r.Note,
StatusOpen, r.LogLines, r.Bytes, string(r.Body))
if err != nil {
return err
}
if id, err = res.LastInsertId(); err != nil {
return err
}
_, err = s.prune(tx)
return err
})
return id, err
}
// Update sets a report's status and/or triage note. A nil argument leaves
// that field as it is.
func (s *Store) Update(id int64, status, triageNote *string) (Report, error) {
if status != nil && !ValidStatus(*status) {
return Report{}, fmt.Errorf("invalid status %q", *status)
}
ctx, cancel := opCtx()
defer cancel()
err := s.db.Update(ctx, func(tx *kv.Tx) error {
sets, args := []string{"updated = ?"}, []any{s.now().UnixMilli()}
if status != nil {
sets, args = append(sets, "status = ?"), append(args, *status)
}
if triageNote != nil {
sets, args = append(sets, "triage_note = ?"), append(args, clamp(strings.TrimSpace(*triageNote), maxTriageNote))
}
res, err := tx.Exec(`UPDATE report SET `+strings.Join(sets, ", ")+` WHERE id = ?`, append(args, id)...)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
})
if err != nil {
return Report{}, err
}
return s.Get(id)
}
// Delete removes a report.
func (s *Store) Delete(id int64) error {
ctx, cancel := opCtx()
defer cancel()
return s.db.Update(ctx, func(tx *kv.Tx) error {
res, err := tx.Exec(`DELETE FROM report WHERE id = ?`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
})
}
// Prune brings the store back within its bounds and returns how many reports
// it removed. Insert already prunes; this is for a bound lowered by config.
func (s *Store) Prune() (int, error) {
ctx, cancel := opCtx()
defer cancel()
var n int
err := s.db.Update(ctx, func(tx *kv.Tx) error {
var err error
n, err = s.prune(tx)
return err
})
return n, err
}
// prune evicts closed reports first, oldest first, then any report, oldest
// first, until both the row and the byte bound hold. An open report is the
// last thing to go: it is the one nobody has read yet.
func (s *Store) prune(tx *kv.Tx) (int, error) {
if s.maxRows <= 0 && s.maxBytes <= 0 {
return 0, nil
}
var rows, bytes int64
if err := tx.QueryRow(`SELECT count(*), coalesce(sum(bytes), 0) FROM report`).Scan(&rows, &bytes); err != nil {
return 0, err
}
over := func() bool {
return (s.maxRows > 0 && rows > int64(s.maxRows)) || (s.maxBytes > 0 && bytes > s.maxBytes)
}
if !over() {
return 0, nil
}
q, err := tx.Query(`SELECT id, bytes FROM report ORDER BY (status = 'closed') DESC, id ASC`)
if err != nil {
return 0, err
}
var victims []int64
for q.Next() && over() {
var id, b int64
if err := q.Scan(&id, &b); err != nil {
q.Close()
return 0, err
}
victims = append(victims, id)
rows--
bytes -= b
}
q.Close()
for _, id := range victims {
if _, err := tx.Exec(`DELETE FROM report WHERE id = ?`, id); err != nil {
return 0, err
}
}
return len(victims), nil
}
// ── Reads ─────────────────────────────────────────────────────────────────
const summaryCols = `id, ts, received, app, build, uid, page, route, cid, title, note, status, triage_note, updated, log_lines, bytes`
func scanSummary(sc interface{ Scan(...any) error }, extra ...any) (Summary, error) {
var r Summary
dst := append([]any{&r.ID, &r.TS, &r.Received, &r.App, &r.Build, &r.UID, &r.Page, &r.Route,
&r.CID, &r.Title, &r.Note, &r.Status, &r.TriageNote, &r.Updated, &r.LogLines, &r.Bytes}, extra...)
return r, sc.Scan(dst...)
}
// Get returns one report with its body.
func (s *Store) Get(id int64) (Report, error) {
ctx, cancel := opCtx()
defer cancel()
var body string
row := s.db.DB().QueryRowContext(ctx, `SELECT `+summaryCols+`, body FROM report WHERE id = ?`, id)
sum, err := scanSummary(row, &body)
if errors.Is(err, sql.ErrNoRows) {
return Report{}, ErrNotFound
}
if err != nil {
return Report{}, err
}
return Report{Summary: sum, Body: json.RawMessage(body)}, nil
}
// List returns one page of summaries, newest first, with the counts the
// inbox's filter bar needs.
func (s *Store) List(f Filter) (Page, error) {
if f.Limit <= 0 || f.Limit > 200 {
f.Limit = 50
}
ctx, cancel := opCtx()
defer cancel()
db := s.db.DB()
var where []string
var args []any
if f.App != "" {
where, args = append(where, "app = ?"), append(args, f.App)
}
appWhere, appArgs := append([]string(nil), where...), append([]any(nil), args...)
if f.Status != "" {
where, args = append(where, "status = ?"), append(args, f.Status)
}
if q := strings.TrimSpace(f.Q); q != "" {
like := "%" + escapeLike(q) + "%"
where = append(where, `(note LIKE ? ESCAPE '\' OR title LIKE ? ESCAPE '\' OR cid LIKE ? ESCAPE '\' OR uid LIKE ? ESCAPE '\')`)
args = append(args, like, like, like, like)
}
if f.Before > 0 {
where, args = append(where, "id < ?"), append(args, f.Before)
}
clause := ""
if len(where) > 0 {
clause = " WHERE " + strings.Join(where, " AND ")
}
page := Page{Reports: []Summary{}, Apps: map[string]int64{}, Counts: map[string]int64{}}
rows, err := db.QueryContext(ctx, `SELECT `+summaryCols+` FROM report`+clause+` ORDER BY id DESC LIMIT ?`,
append(args, f.Limit+1)...)
if err != nil {
return page, err
}
for rows.Next() {
sum, err := scanSummary(rows)
if err != nil {
rows.Close()
return page, err
}
page.Reports = append(page.Reports, sum)
}
rows.Close()
if len(page.Reports) > f.Limit {
page.Reports, page.More = page.Reports[:f.Limit], true
}
if err := groupCount(ctx, db, `SELECT app, count(*) FROM report GROUP BY app`, nil, page.Apps); err != nil {
return page, err
}
statusQ := `SELECT status, count(*) FROM report`
if len(appWhere) > 0 {
statusQ += " WHERE " + strings.Join(appWhere, " AND ")
}
if err := groupCount(ctx, db, statusQ+` GROUP BY status`, appArgs, page.Counts); err != nil {
return page, err
}
return page, nil
}
func groupCount(ctx context.Context, db *sql.DB, q string, args []any, into map[string]int64) error {
rows, err := db.QueryContext(ctx, q, args...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var k string
var n int64
if err := rows.Scan(&k, &n); err != nil {
return err
}
into[k] = n
}
return rows.Err()
}
func escapeLike(s string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
}
+227
View File
@@ -0,0 +1,227 @@
package reports
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"time"
)
func open(t *testing.T, maxRows int, maxBytes int64) *Store {
t.Helper()
s, err := Open(filepath.Join(t.TempDir(), "db", "reports.db"), maxRows, maxBytes)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func envelope(t *testing.T, v map[string]any) map[string]json.RawMessage {
t.Helper()
b, _ := json.Marshal(v)
var out map[string]json.RawMessage
if err := json.Unmarshal(b, &out); err != nil {
t.Fatal(err)
}
return out
}
func file(t *testing.T, s *Store, v map[string]any) int64 {
t.Helper()
r, err := Normalize(envelope(t, v), time.Now())
if err != nil {
t.Fatal(err)
}
id, err := s.Insert(r)
if err != nil {
t.Fatal(err)
}
return id
}
func TestNormalizeRequiresApp(t *testing.T) {
if _, err := Normalize(envelope(t, map[string]any{"note": "x"}), time.Now()); err == nil {
t.Fatal("a report without app must be refused")
}
}
func TestNormalizeClampsAndBounds(t *testing.T) {
log := make([]string, 250)
for i := range log {
log[i] = strings.Repeat("é", 700)
}
now := time.Now()
r, err := Normalize(envelope(t, map[string]any{
"app": "meta-read",
"note": strings.Repeat("n", 1500),
"title": strings.Repeat("t", 300),
"log": log,
"context": map[string]string{"big": strings.Repeat("x", MaxBlockBytes)},
"reader": map[string]int{"page": 4},
"device": nil,
"filedAt": now.Add(-time.Hour).UnixMilli(),
}), now)
if err != nil {
t.Fatal(err)
}
if len(r.Note) != maxNote || len(r.Title) != maxTitle {
t.Fatalf("note/title not clamped: %d %d", len(r.Note), len(r.Title))
}
if r.TS != now.Add(-time.Hour).UnixMilli() || r.Received != now.UnixMilli() {
t.Fatalf("filedAt should set ts, received stays now: %d %d", r.TS, r.Received)
}
var body map[string]json.RawMessage
_ = json.Unmarshal(r.Body, &body)
var lines []string
_ = json.Unmarshal(body["log"], &lines)
if len(lines) != MaxLogLines || r.LogLines != MaxLogLines {
t.Fatalf("log tail: %d lines (col %d)", len(lines), r.LogLines)
}
if n := len([]rune(lines[0])); n != MaxLogLineChars {
t.Fatalf("log line clamped by rune: %d", n)
}
if !strings.Contains(string(body["context"]), `"truncated":true`) {
t.Fatalf("oversized block not replaced by a marker: %.80s", body["context"])
}
if string(body["reader"]) != `{"page":4}` {
t.Fatalf("app block not kept verbatim: %s", body["reader"])
}
if _, ok := body["device"]; ok {
t.Fatal("null block should be dropped")
}
if _, ok := body["filedAt"]; ok {
t.Fatal("filedAt belongs in ts, not the body")
}
}
func TestNormalizeIgnoresImplausibleFiledAt(t *testing.T) {
now := time.Now()
for _, at := range []time.Time{now.Add(-60 * 24 * time.Hour), now.Add(time.Hour)} {
r, _ := Normalize(envelope(t, map[string]any{"app": "a", "filedAt": at.UnixMilli()}), now)
if r.TS != now.UnixMilli() {
t.Fatalf("filedAt %v should be ignored", at)
}
}
}
func TestInsertGetListFilters(t *testing.T) {
s := open(t, 0, 0)
a := file(t, s, map[string]any{"app": "meta-watch", "note": "stall at 3%", "cid": "bafyA"})
b := file(t, s, map[string]any{"app": "meta-read", "note": "blank page", "uid": "u1"})
c := file(t, s, map[string]any{"app": "meta-read", "note": "wrong cover 100%"})
got, err := s.Get(a)
if err != nil || got.App != "meta-watch" || got.CID != "bafyA" || got.Status != StatusOpen {
t.Fatalf("get: %+v %v", got.Summary, err)
}
if _, err := s.Get(999); err != ErrNotFound {
t.Fatalf("missing id: %v", err)
}
p, err := s.List(Filter{})
if err != nil || len(p.Reports) != 3 || p.Reports[0].ID != c {
t.Fatalf("list newest first: %+v %v", p.Reports, err)
}
if p.Apps["meta-read"] != 2 || p.Apps["meta-watch"] != 1 || p.Counts[StatusOpen] != 3 {
t.Fatalf("counts: %+v %+v", p.Apps, p.Counts)
}
p, _ = s.List(Filter{App: "meta-read"})
if len(p.Reports) != 2 || p.Counts[StatusOpen] != 2 || p.Apps["meta-watch"] != 1 {
t.Fatalf("app filter: %+v counts %+v apps %+v", p.Reports, p.Counts, p.Apps)
}
p, _ = s.List(Filter{Q: "100%"})
if len(p.Reports) != 1 || p.Reports[0].ID != c {
t.Fatalf("q is a literal substring (%% escaped): %+v", p.Reports)
}
p, _ = s.List(Filter{Q: "u1"})
if len(p.Reports) != 1 || p.Reports[0].ID != b {
t.Fatalf("q matches uid: %+v", p.Reports)
}
p, _ = s.List(Filter{Limit: 2})
if len(p.Reports) != 2 || !p.More {
t.Fatalf("page 1: %d more=%v", len(p.Reports), p.More)
}
p, _ = s.List(Filter{Limit: 2, Before: p.Reports[1].ID})
if len(p.Reports) != 1 || p.More || p.Reports[0].ID != a {
t.Fatalf("page 2: %+v more=%v", p.Reports, p.More)
}
}
func TestUpdateAndDelete(t *testing.T) {
s := open(t, 0, 0)
id := file(t, s, map[string]any{"app": "a"})
st, note := StatusTriaged, " Deck #900 "
r, err := s.Update(id, &st, &note)
if err != nil || r.Status != StatusTriaged || r.TriageNote != "Deck #900" || r.Updated == 0 {
t.Fatalf("update: %+v %v", r.Summary, err)
}
bad := "done"
if _, err := s.Update(id, &bad, nil); err == nil {
t.Fatal("invalid status accepted")
}
if _, err := s.Update(42, &st, nil); err != ErrNotFound {
t.Fatalf("update missing: %v", err)
}
p, _ := s.List(Filter{Status: StatusTriaged})
if len(p.Reports) != 1 {
t.Fatalf("status filter: %+v", p.Reports)
}
if err := s.Delete(id); err != nil {
t.Fatal(err)
}
if err := s.Delete(id); err != ErrNotFound {
t.Fatalf("second delete: %v", err)
}
}
func TestPruneEvictsClosedThenOldest(t *testing.T) {
s := open(t, 3, 0)
ids := []int64{
file(t, s, map[string]any{"app": "a", "note": "1"}),
file(t, s, map[string]any{"app": "a", "note": "2"}),
file(t, s, map[string]any{"app": "a", "note": "3"}),
}
closed := StatusClosed
if _, err := s.Update(ids[2], &closed, nil); err != nil {
t.Fatal(err)
}
// Fourth report: the closed one (newest of the three) goes first.
file(t, s, map[string]any{"app": "a", "note": "4"})
if _, err := s.Get(ids[2]); err != ErrNotFound {
t.Fatal("closed report should be evicted before older open ones")
}
// Fifth: no closed left, so the oldest open goes.
file(t, s, map[string]any{"app": "a", "note": "5"})
if _, err := s.Get(ids[0]); err != ErrNotFound {
t.Fatal("oldest open report should be evicted next")
}
p, _ := s.List(Filter{})
if len(p.Reports) != 3 {
t.Fatalf("store over its row bound: %d", len(p.Reports))
}
}
func TestPruneByBytes(t *testing.T) {
s := open(t, 0, 3000)
for i := 0; i < 5; i++ {
file(t, s, map[string]any{"app": "a", "note": strings.Repeat("x", 900)})
}
p, _ := s.List(Filter{})
var total int64
for _, r := range p.Reports {
total += r.Bytes
}
if total > 3000 || len(p.Reports) == 0 {
t.Fatalf("byte bound not held: %d bytes in %d reports", total, len(p.Reports))
}
}
func TestClampKeepsRunes(t *testing.T) {
if got := clamp("héllo", 2); got != "hé" {
t.Fatalf("clamp: %q", got)
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
// (crc32: "bafk3eaqerjhiwhy"). // (crc32: "bafk3eaqerjhiwhy").
var cidRegex = regexp.MustCompile(`^(ba[a-z2-7]{14,}|Qm[1-9A-HJ-NP-Za-km-z]{44})$`) var cidRegex = regexp.MustCompile(`^(ba[a-z2-7]{14,}|Qm[1-9A-HJ-NP-Za-km-z]{44})$`)
// ClassifyValue returns the (primitive, hint) for a Redis value. // ClassifyValue returns the (primitive, hint) for a stored value.
// exists=false (key missing) and JSON null literal both map to PrimUndefined. // exists=false (key missing) and JSON null literal both map to PrimUndefined.
// Empty string is a valid string. // Empty string is a valid string.
func ClassifyValue(value string, exists bool) (Primitive, Hint) { func ClassifyValue(value string, exists bool) (Primitive, Hint) {
+74 -232
View File
@@ -2,21 +2,35 @@ package schema
import ( import (
"context" "context"
"log"
"regexp" "regexp"
"strconv"
"strings" "strings"
"sync"
"sync/atomic"
"time" "time"
"github.com/metazla/meta-core/internal/events" "github.com/metazla/meta-core/internal/kv"
"github.com/redis/go-redis/v9"
) )
var langCodeRegex = regexp.MustCompile(`^[a-z]{2,3}$`) var langCodeRegex = regexp.MustCompile(`^[a-z]{2,3}$`)
// fieldState is the per-field accumulator. perKey lets us undo a previous // CounterPrefix is where the schema lives: one counter per (field, key hint,
// contribution when the same Redis key is re-SET or DELed. // primitive, hint), maintained by the storage write path in the same
// transaction as the change (storage's OnChange hook → CounterKey).
const CounterPrefix = "idx:schema/"
const counterSep = "\x1f"
// CounterKey is the counter a record key holding value contributes to, or
// ok=false for keys that are not record properties.
func CounterKey(recordKey, value string) (string, bool) {
fieldPath, keyHint, ok := parseFieldPath(recordKey)
if !ok {
return "", false
}
prim, hint := ClassifyValue(value, true)
return CounterPrefix + fieldPath + counterSep + string(keyHint) + counterSep + string(prim) + counterSep + string(hint), true
}
// fieldState is the per-field accumulator.
type fieldState struct { type fieldState struct {
counts map[string]int counts map[string]int
primitives map[Primitive]int primitives map[Primitive]int
@@ -24,12 +38,6 @@ type fieldState struct {
typedCount int typedCount int
undefinedCount int undefinedCount int
languageKeyed bool languageKeyed bool
perKey map[string]labelPair
}
type labelPair struct {
prim Primitive
hint Hint
} }
func newFieldState() *fieldState { func newFieldState() *fieldState {
@@ -37,160 +45,72 @@ func newFieldState() *fieldState {
counts: make(map[string]int), counts: make(map[string]int),
primitives: make(map[Primitive]int), primitives: make(map[Primitive]int),
hintCounts: make(map[Hint]int), hintCounts: make(map[Hint]int),
perKey: make(map[string]labelPair),
} }
} }
// Indexer maintains a live field-schema by consuming meta:events. // Indexer serves the per-field schema.
//
// The schema is a set of exact counters the write path keeps current in the
// same transaction as each change (see CounterKey), so serving it is one range
// read and nothing runs in the background. Two earlier shapes were dropped: a
// consumer of meta:events that remembered every record key it had seen (~2 GB
// at 6.3M keys), then a full rescan of the store every 10 minutes (~80 s of
// CPU each time, whether or not anyone looked).
type Indexer struct { type Indexer struct {
client *redis.Client store *kv.Store
keyPrefix string
mu sync.RWMutex
fields map[string]*fieldState
processed uint64 // total events processed since start
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
runMu sync.Mutex
running bool
} }
// NewIndexer creates an Indexer. keyPrefix should match storage.Client.GetPrefix() // NewIndexer creates an Indexer over store.
// so the indexer can build absolute Redis keys for GET calls. func NewIndexer(store *kv.Store) *Indexer {
func NewIndexer(client *redis.Client, keyPrefix string) *Indexer { return &Indexer{store: store}
ctx, cancel := context.WithCancel(context.Background())
return &Indexer{
client: client,
keyPrefix: keyPrefix,
fields: make(map[string]*fieldState),
ctx: ctx,
cancel: cancel,
}
} }
// Start begins consuming the meta:events stream from the earliest available ID. // Start and Stop exist for the server's lifecycle; there is nothing to run.
func (i *Indexer) Start() error { func (i *Indexer) Start() error { return nil }
i.runMu.Lock() func (i *Indexer) Stop() error { return nil }
if i.running {
i.runMu.Unlock()
return nil
}
i.running = true
i.runMu.Unlock()
i.wg.Add(1) // Snapshot reads the counters and folds them into the per-field schema.
go i.consumeLoop() func (i *Indexer) Snapshot(ctx context.Context) (*SchemaResponse, error) {
fields := make(map[string]*fieldState)
log.Println("[SchemaIndexer] Started - consuming meta:events stream") err := i.store.ScanPrefix(ctx, CounterPrefix, 5000, func(rows []kv.KV) error {
return nil for _, row := range rows {
} parts := strings.Split(row.K[len(CounterPrefix):], counterSep)
if len(parts) != 4 {
// Stop terminates the consumer goroutine.
func (i *Indexer) Stop() error {
i.runMu.Lock()
if !i.running {
i.runMu.Unlock()
return nil
}
i.running = false
i.runMu.Unlock()
i.cancel()
i.wg.Wait()
log.Println("[SchemaIndexer] Stopped")
return nil
}
// ProcessedCount returns the total number of stream events processed.
// Used by WaitForDrain to detect when a rescan flood has been absorbed.
func (i *Indexer) ProcessedCount() uint64 {
return atomic.LoadUint64(&i.processed)
}
// WaitForDrain blocks until ProcessedCount has been stable for `stableFor` or
// the timeout elapses. Suitable for use right after RepublishMetadata.
func (i *Indexer) WaitForDrain(timeout time.Duration) {
deadline := time.Now().Add(timeout)
last := i.ProcessedCount()
stableSince := time.Now()
for time.Now().Before(deadline) {
time.Sleep(100 * time.Millisecond)
now := i.ProcessedCount()
if now != last {
last = now
stableSince = time.Now()
continue continue
} }
if time.Since(stableSince) >= 300*time.Millisecond { n, err := strconv.Atoi(row.V)
return if err != nil || n <= 0 {
continue
} }
fs := fields[parts[0]]
if fs == nil {
fs = newFieldState()
fields[parts[0]] = fs
} }
} if parts[1] != "" {
fs.languageKeyed = true
func (i *Indexer) consumeLoop() {
defer i.wg.Done()
lastID := "0"
for {
select {
case <-i.ctx.Done():
return
default:
} }
incContribution(fs, labelPair{prim: Primitive(parts[2]), hint: Hint(parts[3])}, n)
res, err := i.client.XRead(i.ctx, &redis.XReadArgs{ }
Streams: []string{events.MetaEventsStream, lastID}, return nil
Block: 2 * time.Second, })
Count: 500,
}).Result()
if err != nil { if err != nil {
if err == redis.Nil || i.ctx.Err() != nil { return nil, err
continue
}
log.Printf("[SchemaIndexer] XRead error: %v", err)
time.Sleep(1 * time.Second)
continue
}
for _, stream := range res {
for _, msg := range stream.Messages {
lastID = msg.ID
i.processEvent(msg.Values)
atomic.AddUint64(&i.processed, 1)
} }
snap := &SchemaResponse{
Fields: make(map[string]*FieldSchema, len(fields)),
GeneratedAt: time.Now().UTC(),
Source: "live",
} }
for fp, fs := range fields {
snap.Fields[fp] = computeFieldSchema(fs)
} }
return snap, nil
} }
func (i *Indexer) processEvent(values map[string]any) { type labelPair struct {
op, _ := values["type"].(string) prim Primitive
key, _ := values["key"].(string) hint Hint
if key == "" {
return
}
fieldPath, keyHint, ok := parseFieldPath(key)
if !ok {
return
}
switch op {
case "set":
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
val, err := i.client.Get(ctx, i.keyPrefix+key).Result()
cancel()
exists := err != redis.Nil
if err != nil && err != redis.Nil {
// Treat transient errors as "value disappeared" — nothing to record.
return
}
prim, hint := ClassifyValue(val, exists)
i.upsert(fieldPath, key, keyHint, prim, hint)
case "del", "expire":
i.remove(fieldPath, key)
}
} }
// parseFieldPath turns "file:<rootID>/<propertyPath>" into the logical field // parseFieldPath turns "file:<rootID>/<propertyPath>" into the logical field
@@ -222,99 +142,21 @@ func parseFieldPath(key string) (string, KeyHint, bool) {
return property, "", true return property, "", true
} }
func (i *Indexer) upsert(fieldPath, redisKey string, keyHint KeyHint, prim Primitive, hint Hint) { // incContribution adds n values of one (primitive, hint) to a field.
i.mu.Lock() func incContribution(fs *fieldState, lp labelPair, n int) {
defer i.mu.Unlock()
fs, ok := i.fields[fieldPath]
if !ok {
fs = newFieldState()
i.fields[fieldPath] = fs
}
if keyHint != "" {
fs.languageKeyed = true
}
if old, ok := fs.perKey[redisKey]; ok {
decContribution(fs, old)
}
fs.perKey[redisKey] = labelPair{prim: prim, hint: hint}
incContribution(fs, labelPair{prim: prim, hint: hint})
}
func (i *Indexer) remove(fieldPath, redisKey string) {
i.mu.Lock()
defer i.mu.Unlock()
fs, ok := i.fields[fieldPath]
if !ok {
return
}
old, ok := fs.perKey[redisKey]
if !ok {
return
}
delete(fs.perKey, redisKey)
decContribution(fs, old)
if len(fs.perKey) == 0 {
delete(i.fields, fieldPath)
}
}
func incContribution(fs *fieldState, lp labelPair) {
label := breakdownLabel(lp.prim, lp.hint) label := breakdownLabel(lp.prim, lp.hint)
fs.counts[label]++ fs.counts[label] += n
if lp.prim == PrimUndefined { if lp.prim == PrimUndefined {
fs.undefinedCount++ fs.undefinedCount += n
return return
} }
fs.typedCount++ fs.typedCount += n
fs.primitives[lp.prim]++ fs.primitives[lp.prim] += n
if lp.hint != "" { if lp.hint != "" {
fs.hintCounts[lp.hint]++ fs.hintCounts[lp.hint] += n
} }
} }
func decContribution(fs *fieldState, lp labelPair) {
label := breakdownLabel(lp.prim, lp.hint)
fs.counts[label]--
if fs.counts[label] <= 0 {
delete(fs.counts, label)
}
if lp.prim == PrimUndefined {
fs.undefinedCount--
return
}
fs.typedCount--
fs.primitives[lp.prim]--
if fs.primitives[lp.prim] <= 0 {
delete(fs.primitives, lp.prim)
}
if lp.hint != "" {
fs.hintCounts[lp.hint]--
if fs.hintCounts[lp.hint] <= 0 {
delete(fs.hintCounts, lp.hint)
}
}
}
// Snapshot returns the current schema. Safe to call concurrently with consumption.
func (i *Indexer) Snapshot() *SchemaResponse {
i.mu.RLock()
defer i.mu.RUnlock()
out := &SchemaResponse{
Fields: make(map[string]*FieldSchema, len(i.fields)),
GeneratedAt: time.Now().UTC(),
Source: "live",
}
for fp, fs := range i.fields {
out.Fields[fp] = computeFieldSchema(fs)
}
return out
}
func computeFieldSchema(fs *fieldState) *FieldSchema { func computeFieldSchema(fs *fieldState) *FieldSchema {
out := &FieldSchema{ out := &FieldSchema{
Breakdown: make(map[string]int, len(fs.counts)), Breakdown: make(map[string]int, len(fs.counts)),
-143
View File
@@ -1,143 +0,0 @@
package snapshot
import (
"archive/zip"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
)
// PluginOutputArchivePrefix is where plugin-output files live inside the ZIP.
// Trailing slash because ZIP entry names are forward-slash relative paths.
const PluginOutputArchivePrefix = "cache/plugin-output/"
// addPluginOutput walks srcDir (typically $FILES_PATH/plugin) and copies every
// file under it into zw at PluginOutputArchivePrefix + relPath.
//
// Returns the number of files added. A missing srcDir is not an error: it
// just means no plugin has produced output yet, so the section stays empty.
func addPluginOutput(zw *zip.Writer, srcDir string) (int, error) {
info, err := os.Stat(srcDir)
if errors.Is(err, os.ErrNotExist) {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("stat %s: %w", srcDir, err)
}
if !info.IsDir() {
return 0, fmt.Errorf("%s is not a directory", srcDir)
}
count := 0
err = filepath.WalkDir(srcDir, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(srcDir, path)
if err != nil {
return err
}
entry := PluginOutputArchivePrefix + filepath.ToSlash(rel)
w, err := zw.Create(entry)
if err != nil {
return err
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
if _, err := io.Copy(w, f); err != nil {
return err
}
count++
return nil
})
if err != nil {
return count, fmt.Errorf("walk %s: %w", srcDir, err)
}
return count, nil
}
// extractPluginOutput pulls every entry under PluginOutputArchivePrefix from
// the ZIP and writes it under dstDir. Existing files are overwritten when
// overwrite=true; otherwise they're left alone (treats existing files as the
// "mine wins" choice — same toggle the metadata import uses).
//
// Path traversal is rejected: any entry whose relative path resolves outside
// dstDir is skipped with a warning.
func extractPluginOutput(zr *zip.Reader, dstDir string, overwrite bool) (written int, skipped int, err error) {
if dstDir == "" {
return 0, 0, fmt.Errorf("plugin-output destination dir is empty")
}
if err := os.MkdirAll(dstDir, 0o755); err != nil {
return 0, 0, fmt.Errorf("mkdir %s: %w", dstDir, err)
}
cleanRoot, err := filepath.Abs(dstDir)
if err != nil {
return 0, 0, fmt.Errorf("abs %s: %w", dstDir, err)
}
for _, f := range zr.File {
if !strings.HasPrefix(f.Name, PluginOutputArchivePrefix) {
continue
}
if strings.HasSuffix(f.Name, "/") {
continue
}
rel := strings.TrimPrefix(f.Name, PluginOutputArchivePrefix)
dst := filepath.Join(cleanRoot, filepath.FromSlash(rel))
// Reject anything that escapes the dst root.
if !strings.HasPrefix(dst, cleanRoot+string(os.PathSeparator)) && dst != cleanRoot {
log.Printf("[Snapshot] import: skipping path-escaping entry %q", f.Name)
skipped++
continue
}
if !overwrite {
if _, err := os.Stat(dst); err == nil {
skipped++
continue
}
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return written, skipped, fmt.Errorf("mkdir for %s: %w", dst, err)
}
if err := writeZipFile(f, dst); err != nil {
return written, skipped, err
}
written++
}
return written, skipped, nil
}
func writeZipFile(f *zip.File, dst string) error {
rc, err := f.Open()
if err != nil {
return fmt.Errorf("open zip entry %s: %w", f.Name, err)
}
defer rc.Close()
out, err := os.Create(dst)
if err != nil {
return fmt.Errorf("create %s: %w", dst, err)
}
defer out.Close()
if _, err := io.Copy(out, rc); err != nil {
return fmt.Errorf("write %s: %w", dst, err)
}
return nil
}
-132
View File
@@ -1,132 +0,0 @@
package snapshot
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"log"
"os"
"time"
"github.com/metazla/meta-core/internal/storage"
)
// ExportOptions controls what an export includes.
type ExportOptions struct {
IncludePluginOutput bool
// PluginOutputDir is the absolute path on disk that holds plugin-produced
// artifacts (typically $FILES_PATH/plugin). Required when
// IncludePluginOutput is true.
PluginOutputDir string
// SourceHost is recorded in the manifest so imports can attribute
// where the snapshot came from (defaults to OS hostname).
SourceHost string
}
// Exporter writes snapshot ZIPs from a live Redis store.
type Exporter struct {
storage *storage.Client
}
func NewExporter(stor *storage.Client) *Exporter {
return &Exporter{storage: stor}
}
// Export streams a ZIP archive to w. The archive layout is:
//
// manifest.json
// index.json
// metadata/{cid}.json (one per known CID)
//
// Optional sections (hash-index, plugin-output) are gated by ExportOptions
// but are not yet implemented — they live on filesystems meta-core does not
// own, and need cross-service collection. The flag is plumbed for forward
// compatibility but does nothing today.
func (e *Exporter) Export(w io.Writer, opts ExportOptions) (Manifest, error) {
if e.storage == nil || !e.storage.IsConnected() {
return Manifest{}, fmt.Errorf("storage not connected")
}
hashIDs, err := e.storage.GetAllHashIDs()
if err != nil {
return Manifest{}, fmt.Errorf("list hashIDs: %w", err)
}
zw := zip.NewWriter(w)
defer zw.Close()
// 1. manifest.json — written last after we know FileCount, but we need
// the entry order to put it first in the archive for readers. Solve
// by buffering in memory: it's tiny.
host := opts.SourceHost
if host == "" {
if h, err := os.Hostname(); err == nil {
host = h
}
}
manifest := Manifest{
SchemaVersion: ManifestSchemaVersion,
GeneratedAt: time.Now().UTC(),
SourceHost: host,
FileCount: len(hashIDs),
Includes: Includes{
Metadata: true,
PluginOutput: opts.IncludePluginOutput,
},
}
if err := writeJSON(zw, ManifestFile, manifest); err != nil {
return Manifest{}, fmt.Errorf("write manifest: %w", err)
}
// 2. index.json — flat list of CIDs. Redundant with metadata/ but
// cheap and useful for readers that only need the keyset.
if err := writeJSON(zw, IndexFile, IndexFileBody{HashIDs: hashIDs}); err != nil {
return Manifest{}, fmt.Errorf("write index: %w", err)
}
// 3. metadata/{cid}.json — one nested document per CID.
for _, hashID := range hashIDs {
flat, err := e.storage.GetMetadataFlat(hashID)
if err != nil {
log.Printf("[Snapshot] export: skipping %s: %v", hashID, err)
continue
}
if len(flat) == 0 {
continue
}
nested := Reconstruct(flat)
entry := MetadataDir + hashID + ".json"
if err := writeJSON(zw, entry, nested); err != nil {
return Manifest{}, fmt.Errorf("write %s: %w", entry, err)
}
}
// 4. cache/plugin-output/* — optional. Walks the on-disk plugin dir and
// streams each file into the ZIP. Heavy (~120 MB on a populated dev
// stack), so it's gated by the ExportOptions toggle.
if opts.IncludePluginOutput {
if opts.PluginOutputDir == "" {
return Manifest{}, fmt.Errorf("PluginOutputDir is required when IncludePluginOutput is set")
}
n, err := addPluginOutput(zw, opts.PluginOutputDir)
if err != nil {
return Manifest{}, fmt.Errorf("add plugin-output: %w", err)
}
log.Printf("[Snapshot] exported %d plugin-output files from %s", n, opts.PluginOutputDir)
}
log.Printf("[Snapshot] exported %d files (host=%s, pluginOutput=%v)",
len(hashIDs), host, opts.IncludePluginOutput)
return manifest, nil
}
func writeJSON(zw *zip.Writer, name string, body interface{}) error {
w, err := zw.Create(name)
if err != nil {
return err
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(body)
}
-195
View File
@@ -1,195 +0,0 @@
package snapshot
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
// Flat keys use "/" as the separator. Property paths inside Redis are
// stored as e.g. "fileinfo/streamdetails/video/0/codec".
const flatSep = "/"
// numericKey matches a path segment that is a non-negative decimal integer.
// Used to detect array slots (".../video/0/codec").
var numericKey = regexp.MustCompile(`^\d+$`)
// Reconstruct rebuilds a nested JSON-shaped value from a flat property map.
// Keys are property paths (no leading slash, no file:{cid}/ prefix).
//
// Sibling keys whose path segments are all sequential integers starting at 0
// are emitted as JSON arrays. Other groups become JSON objects. Leaf values
// are coerced from string to int/float/bool/null when they parse cleanly so
// the exported JSON is human-readable; on import we round-trip back to strings.
func Reconstruct(flat map[string]string) interface{} {
root := map[string]interface{}{}
for path, value := range flat {
parts := strings.Split(path, flatSep)
setPath(root, parts, parseValue(value))
}
return convertNumericMapsToArrays(root)
}
// Flatten is the inverse of Reconstruct: walks a nested value and produces a
// flat property map whose values are all strings (Redis-ready).
//
// Arrays become numeric path segments ("genre/0", "genre/1"). Nil/missing
// values are stored as "" to preserve the field's presence — this matches
// what flattenMetadata in the TS implementation does.
func Flatten(value interface{}) map[string]string {
out := map[string]string{}
flattenInto(value, "", out)
return out
}
func setPath(root map[string]interface{}, parts []string, leaf interface{}) {
if len(parts) == 0 {
return
}
cur := root
for i, part := range parts {
if i == len(parts)-1 {
cur[part] = leaf
return
}
next, ok := cur[part].(map[string]interface{})
if !ok {
next = map[string]interface{}{}
cur[part] = next
}
cur = next
}
}
func convertNumericMapsToArrays(v interface{}) interface{} {
switch t := v.(type) {
case map[string]interface{}:
// Recurse first so child arrays are detected before parent.
for k, child := range t {
t[k] = convertNumericMapsToArrays(child)
}
if !looksLikeArray(t) {
return t
}
// Sort numerically and emit as []interface{}.
idx := make([]int, 0, len(t))
for k := range t {
n, _ := strconv.Atoi(k)
idx = append(idx, n)
}
sort.Ints(idx)
arr := make([]interface{}, len(idx))
for i, n := range idx {
arr[i] = t[strconv.Itoa(n)]
}
return arr
case []interface{}:
for i := range t {
t[i] = convertNumericMapsToArrays(t[i])
}
return t
default:
return v
}
}
// looksLikeArray returns true when every key in m is a non-negative integer
// and the keys form a contiguous range starting at 0. Empty maps return false
// — we can't tell whether the original was {} or [], so prefer {}.
func looksLikeArray(m map[string]interface{}) bool {
if len(m) == 0 {
return false
}
seen := make(map[int]bool, len(m))
for k := range m {
if !numericKey.MatchString(k) {
return false
}
n, err := strconv.Atoi(k)
if err != nil || n < 0 {
return false
}
seen[n] = true
}
for i := 0; i < len(m); i++ {
if !seen[i] {
return false
}
}
return true
}
func flattenInto(v interface{}, path string, out map[string]string) {
switch t := v.(type) {
case nil:
out[path] = ""
case map[string]interface{}:
if len(t) == 0 && path != "" {
// Empty objects don't have a Redis representation; skip.
return
}
for k, child := range t {
next := k
if path != "" {
next = path + flatSep + k
}
flattenInto(child, next, out)
}
case []interface{}:
for i, child := range t {
next := strconv.Itoa(i)
if path != "" {
next = path + flatSep + next
}
flattenInto(child, next, out)
}
case string:
out[path] = t
case bool:
if t {
out[path] = "true"
} else {
out[path] = "false"
}
case float64:
// JSON numbers always decode to float64; emit ints without decimals.
if t == float64(int64(t)) {
out[path] = strconv.FormatInt(int64(t), 10)
} else {
out[path] = strconv.FormatFloat(t, 'f', -1, 64)
}
default:
out[path] = fmt.Sprintf("%v", t)
}
}
// parseValue mirrors the TS reconstruct logic: convert obvious primitives
// back to their typed form for nicer exported JSON, leave anything ambiguous
// as a string so we don't lose data (e.g. "5.1" channel layouts).
func parseValue(s string) interface{} {
if s == "" {
return nil
}
switch s {
case "true":
return true
case "false":
return false
case "null":
return nil
}
// Only treat as number when the string is a clean integer or decimal,
// no leading zeros / trailing junk. Matches the TS regex.
if isCleanNumber(s) {
if n, err := strconv.ParseFloat(s, 64); err == nil {
return n
}
}
return s
}
var cleanNumber = regexp.MustCompile(`^-?\d+(\.\d+)?$`)
func isCleanNumber(s string) bool { return cleanNumber.MatchString(s) }
-262
View File
@@ -1,262 +0,0 @@
package snapshot
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"log"
"strings"
"github.com/metazla/meta-core/internal/storage"
)
// Mode controls how an import reconciles existing CIDs.
type Mode string
const (
// ModeReplace deletes every existing property of an imported CID and
// writes the snapshot's values verbatim. Untouched CIDs are left alone.
ModeReplace Mode = "replace"
// ModeMerge keeps unmentioned existing properties and only touches the
// fields present in the snapshot. Conflict resolution within merge is
// controlled by Conflict.
ModeMerge Mode = "merge"
)
// Conflict picks a winner for fields present on both sides during merge.
type Conflict string
const (
// ConflictMine keeps the existing (target) value when both sides have one.
ConflictMine Conflict = "mine"
// ConflictSource overwrites the existing value with the snapshot's value.
ConflictSource Conflict = "source"
)
// ImportOptions controls how Import applies a snapshot.
type ImportOptions struct {
Mode Mode
Conflict Conflict
DryRun bool
// PluginOutputDir is the on-disk destination for cache/plugin-output/*
// entries. When empty, plugin-output is silently skipped on import.
PluginOutputDir string
}
// FileResult summarizes what happened (or would happen, in dry-run) for one CID.
type FileResult struct {
HashID string `json:"hashId"`
Added int `json:"added"` // new property keys written
Updated int `json:"updated"` // existing property keys whose value changed
Unchanged int `json:"unchanged"` // existing property keys whose value matched
Kept int `json:"kept"` // existing property keys preserved by ConflictMine
Deleted int `json:"deleted"` // existing property keys removed (replace mode)
Error string `json:"error,omitempty"`
}
// PluginOutputResult summarizes what happened to cache/plugin-output entries.
type PluginOutputResult struct {
Written int `json:"written"`
Skipped int `json:"skipped"`
}
// ImportResult is the response body for the import endpoint.
type ImportResult struct {
Mode Mode `json:"mode"`
Conflict Conflict `json:"conflict,omitempty"`
DryRun bool `json:"dryRun"`
TotalFiles int `json:"totalFiles"`
FilesOK int `json:"filesOk"`
FilesFailed int `json:"filesFailed"`
Files []FileResult `json:"files,omitempty"`
PluginOutput *PluginOutputResult `json:"pluginOutput,omitempty"`
}
// Importer applies snapshot ZIPs to a live Redis store.
type Importer struct {
storage *storage.Client
}
func NewImporter(stor *storage.Client) *Importer {
return &Importer{storage: stor}
}
// Import reads a ZIP from r (which must support io.ReaderAt; callers pass
// *bytes.Reader after buffering the upload) and applies it to Redis.
func (i *Importer) Import(r io.ReaderAt, size int64, opts ImportOptions) (*ImportResult, error) {
if i.storage == nil || !i.storage.IsConnected() {
return nil, fmt.Errorf("storage not connected")
}
if opts.Mode != ModeReplace && opts.Mode != ModeMerge {
return nil, fmt.Errorf("invalid mode %q (want replace|merge)", opts.Mode)
}
if opts.Mode == ModeMerge && opts.Conflict != ConflictMine && opts.Conflict != ConflictSource {
return nil, fmt.Errorf("invalid conflict %q (want mine|source)", opts.Conflict)
}
zr, err := zip.NewReader(r, size)
if err != nil {
return nil, fmt.Errorf("open zip: %w", err)
}
var manifest Manifest
if err := readJSON(zr, ManifestFile, &manifest); err != nil {
return nil, fmt.Errorf("read manifest: %w", err)
}
if manifest.SchemaVersion > ManifestSchemaVersion {
return nil, fmt.Errorf("snapshot schemaVersion %d exceeds supported %d",
manifest.SchemaVersion, ManifestSchemaVersion)
}
if manifest.SchemaVersion < ManifestSchemaVersion {
return nil, fmt.Errorf("snapshot schemaVersion %d is no longer supported (current: %d); v1 used content-hash root keys which are incompatible with the UUID-rooted schema",
manifest.SchemaVersion, ManifestSchemaVersion)
}
result := &ImportResult{
Mode: opts.Mode,
Conflict: opts.Conflict,
DryRun: opts.DryRun,
}
for _, f := range zr.File {
if !strings.HasPrefix(f.Name, MetadataDir) || !strings.HasSuffix(f.Name, ".json") {
continue
}
hashID := strings.TrimSuffix(strings.TrimPrefix(f.Name, MetadataDir), ".json")
if hashID == "" {
continue
}
fr := i.applyOne(f, hashID, opts)
result.TotalFiles++
if fr.Error != "" {
result.FilesFailed++
} else {
result.FilesOK++
}
result.Files = append(result.Files, fr)
}
// Plugin-output extraction. Only applied on real imports (not dry-run).
// If the snapshot doesn't contain a cache/plugin-output/ section, this is
// a no-op. If the destination is unset, we skip silently — the manifest's
// pluginOutput flag tells the caller it was on the source side either way.
if !opts.DryRun && opts.PluginOutputDir != "" && manifest.Includes.PluginOutput {
// Existing files are kept under ConflictMine / replace-doesn't-apply,
// overwritten under ConflictSource. Replace mode behaves like
// "overwrite" for cache files (it's the destructive choice for
// metadata; mirror that intent here).
overwrite := opts.Mode == ModeReplace || opts.Conflict == ConflictSource
written, skipped, err := extractPluginOutput(zr, opts.PluginOutputDir, overwrite)
if err != nil {
return result, fmt.Errorf("extract plugin-output: %w", err)
}
result.PluginOutput = &PluginOutputResult{Written: written, Skipped: skipped}
log.Printf("[Snapshot] import: plugin-output written=%d skipped=%d", written, skipped)
}
log.Printf("[Snapshot] import: mode=%s conflict=%s dryRun=%v files=%d ok=%d failed=%d",
opts.Mode, opts.Conflict, opts.DryRun, result.TotalFiles, result.FilesOK, result.FilesFailed)
return result, nil
}
func (i *Importer) applyOne(f *zip.File, hashID string, opts ImportOptions) FileResult {
fr := FileResult{HashID: hashID}
rc, err := f.Open()
if err != nil {
fr.Error = fmt.Sprintf("open entry: %v", err)
return fr
}
defer rc.Close()
var nested interface{}
if err := json.NewDecoder(rc).Decode(&nested); err != nil {
fr.Error = fmt.Sprintf("decode json: %v", err)
return fr
}
incoming := Flatten(nested)
existing, err := i.storage.GetMetadataFlat(hashID)
if err != nil {
fr.Error = fmt.Sprintf("read existing: %v", err)
return fr
}
if existing == nil {
existing = map[string]string{}
}
switch opts.Mode {
case ModeReplace:
// Diff for reporting.
toWrite := map[string]string{}
for k, v := range incoming {
toWrite[k] = v
if cur, ok := existing[k]; ok {
if cur == v {
fr.Unchanged++
} else {
fr.Updated++
}
} else {
fr.Added++
}
}
for k := range existing {
if _, ok := incoming[k]; !ok {
fr.Deleted++
}
}
if !opts.DryRun {
if _, err := i.storage.DeleteMetadata(hashID); err != nil {
fr.Error = fmt.Sprintf("delete existing: %v", err)
return fr
}
if err := i.storage.SetMetadataFlat(hashID, toWrite); err != nil {
fr.Error = fmt.Sprintf("write: %v", err)
return fr
}
}
case ModeMerge:
toWrite := map[string]string{}
for k, v := range incoming {
cur, present := existing[k]
switch {
case !present:
toWrite[k] = v
fr.Added++
case cur == v:
fr.Unchanged++
case opts.Conflict == ConflictSource:
toWrite[k] = v
fr.Updated++
case opts.Conflict == ConflictMine:
fr.Kept++
}
}
if !opts.DryRun && len(toWrite) > 0 {
if _, err := i.storage.MergeMetadataFlat(hashID, toWrite); err != nil {
fr.Error = fmt.Sprintf("merge: %v", err)
return fr
}
}
}
return fr
}
func readJSON(zr *zip.Reader, name string, out interface{}) error {
for _, f := range zr.File {
if f.Name != name {
continue
}
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
return json.NewDecoder(rc).Decode(out)
}
return fmt.Errorf("entry %q not found", name)
}
-55
View File
@@ -1,55 +0,0 @@
// Package snapshot implements export/import/wipe of MetaMesh metadata.
//
// The on-disk format is a ZIP archive containing one nested-JSON file per
// indexed root. Schema is defined by ManifestSchemaVersion; readers must
// reject archives whose schemaVersion exceeds what they know.
//
// v2 (current): root identifiers are UUIDv7 (Crockford Base32). Entries
// under metadata/<root>.json carry the flat string properties; the cid
// reverse-index and the duplicates SET are NOT serialized — they are rebuilt
// transparently on import because the cids/<cid> key-set members ARE flat
// properties, and SetMetadataFlat / MergeMetadataFlat auto-register a
// reverse-index alias for every one they see (see
// internal/storage/cid_resolution.go: cidFromKeysetField). The legacy flat
// cid_*/midhash256 named fields are not recognised.
//
// v1 (legacy): roots were content-hash-typed tokens like "midhash256:abc".
// v1 archives can no longer be imported — they would resurrect the
// privileged-hashID schema this layer was built to retire.
package snapshot
import "time"
// ManifestSchemaVersion is the on-disk format version. Bump when the layout
// of metadata/{root}.json or the manifest changes in a non-additive way.
const ManifestSchemaVersion = 2
// MetadataDir is the path inside the ZIP that holds per-CID JSON files.
const MetadataDir = "metadata/"
// ManifestFile is the path inside the ZIP that holds the manifest JSON.
const ManifestFile = "manifest.json"
// IndexFile is the path inside the ZIP that holds the file index (CID list).
const IndexFile = "index.json"
// Manifest is the metadata header stored at the root of every snapshot ZIP.
type Manifest struct {
SchemaVersion int `json:"schemaVersion"`
GeneratedAt time.Time `json:"generatedAt"`
SourceHost string `json:"sourceHost,omitempty"`
MetaCoreVer string `json:"metaCoreVersion,omitempty"`
FileCount int `json:"fileCount"`
Includes Includes `json:"includes"`
}
// Includes records which optional sections are present in a snapshot.
type Includes struct {
Metadata bool `json:"metadata"`
PluginOutput bool `json:"pluginOutput,omitempty"`
}
// IndexFileBody is the structure of index.json inside the ZIP.
type IndexFileBody struct {
HashIDs []string `json:"hashIds"`
}
-42
View File
@@ -1,42 +0,0 @@
package snapshot
import (
"fmt"
"github.com/metazla/meta-core/internal/storage"
)
// WipeScope picks which classes of data to clear.
type WipeScope struct {
Metadata bool // file:* keys + file:__index__
}
// WipeResult reports what was cleared.
type WipeResult struct {
MetadataDeleted int64 `json:"metadataDeleted"`
}
// Wiper clears metadata from Redis.
type Wiper struct {
storage *storage.Client
}
func NewWiper(stor *storage.Client) *Wiper {
return &Wiper{storage: stor}
}
// Wipe applies the requested scope. Returns the count of files removed.
func (w *Wiper) Wipe(scope WipeScope) (*WipeResult, error) {
if w.storage == nil || !w.storage.IsConnected() {
return nil, fmt.Errorf("storage not connected")
}
res := &WipeResult{}
if scope.Metadata {
n, err := w.storage.ClearAllMetadata()
if err != nil {
return nil, fmt.Errorf("clear metadata: %w", err)
}
res.MetadataDeleted = n
}
return res, nil
}
+612
View File
@@ -0,0 +1,612 @@
package storage
// The apply loop: folds committed truth changes into the cache.
//
// The cache's merged view keeps the v1 record layout — `file:<rid>/<field>`
// rows of the cache kv table — so every reader (record reads, search, the
// field index, FTS, counters, SSE and its snapshot) is unchanged; those rows
// are now derived, never written by a client. Each committed change re-merges
// only the (rid, key) it touched, from the truth votes of every cid in the
// group. Group membership is synthesised: every cid of a group appears as
// `file:<rid>/cids/<cid> = "true"` plus the `cid:<cid> → rid` lookup, whatever
// the cids/* claims say.
//
// Changes arrive in commit order (kv.Tx.OnCommit runs in the truth writer), so
// `meta:applied_seq` only ever covers a prefix of the truth log. A crash
// between the truth commit and the cache apply leaves the cache behind, never
// wrong; Connect catches up from truth.
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"strconv"
"strings"
"sync"
"time"
"github.com/metazla/meta-core/internal/kv"
)
const (
appliedSeqKey = "meta:applied_seq"
// rebuiltAtKey holds the stream id at which the cache was last rebuilt.
// An SSE cursor older than it gets `gap`: the rebuild emitted no
// per-key events, so the consumer must take a snapshot.
rebuiltAtKey = "meta:rebuilt_at"
)
var localFields = []string{"filePath", "sizeByte", "mtimeNano"}
type applier struct {
c *Client
mu sync.Mutex
queue []change
applied int64
notify chan struct{}
wake chan struct{}
stop chan struct{}
done chan struct{}
}
func newApplier(c *Client, applied int64) *applier {
a := &applier{
c: c, applied: applied,
notify: make(chan struct{}),
wake: make(chan struct{}, 1),
stop: make(chan struct{}),
done: make(chan struct{}),
}
go a.run()
return a
}
func (a *applier) enqueue(ch []change) {
a.mu.Lock()
a.queue = append(a.queue, ch...)
a.mu.Unlock()
select {
case a.wake <- struct{}{}:
default:
}
}
// wait blocks until the cache has applied every change up to seq.
func (a *applier) wait(ctx context.Context, seq int64) error {
for {
a.mu.Lock()
if a.applied >= seq {
a.mu.Unlock()
return nil
}
n := a.notify
a.mu.Unlock()
select {
case <-n:
case <-ctx.Done():
return ctx.Err()
case <-a.stop:
return errors.New("storage closed before the cache applied the write")
}
}
}
func (a *applier) close() {
close(a.stop)
<-a.done
}
const applyBatchMax = 2000
func (a *applier) run() {
defer close(a.done)
for {
select {
case <-a.wake:
case <-a.stop:
return
}
for {
a.mu.Lock()
n := min(len(a.queue), applyBatchMax)
batch := append([]change(nil), a.queue[:n]...)
a.mu.Unlock()
if len(batch) == 0 {
break
}
if err := a.c.applyBatch(batch); err != nil {
log.Printf("[Storage] apply of %d changes failed, retrying: %v", len(batch), err)
select {
case <-time.After(time.Second):
continue
case <-a.stop:
return
}
}
a.mu.Lock()
a.queue = a.queue[n:]
if s := batch[len(batch)-1].seq; s > a.applied {
a.applied = s
}
close(a.notify)
a.notify = make(chan struct{})
a.mu.Unlock()
}
}
}
// applyBatch folds committed changes into the cache in one transaction.
func (c *Client) applyBatch(batch []change) error {
s, err := c.kv()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
return s.Update(ctx, func(tx *kv.Tx) error {
return c.applyTx(tx, batch)
})
}
func (c *Client) applyTx(tx *kv.Tx, batch []change) error {
ctx := tx.Context()
marks := map[string]map[string]bool{}
mark := func(rid, key string) {
if marks[rid] == nil {
marks[rid] = map[string]bool{}
}
marks[rid][key] = true
}
all := map[string]bool{}
dirty := map[string]bool{}
var maxSeq int64
for _, it := range batch {
maxSeq = max(maxSeq, it.seq)
if it.regroup != nil {
loser, survivor := it.regroup[0], it.regroup[1]
if _, err := tx.DeletePrefix(recordPrefix(loser), 0); err != nil {
return err
}
if _, err := tx.Delete(rootKey(loser)); err != nil {
return err
}
if err := deleteSearchRowTx(tx, loser); err != nil {
return err
}
delete(marks, loser)
cids, err := c.truthMembers(ctx, survivor)
if err != nil {
return err
}
for _, cid := range cids {
if _, err := syncMember(tx, cid, survivor); err != nil {
return err
}
}
all[survivor], dirty[survivor] = true, true
continue
}
rid, err := c.truthRid(ctx, it.cid)
if err != nil {
return err
}
if rid == "" {
continue
}
newRoot, err := syncMember(tx, it.cid, rid)
if err != nil {
return err
}
if newRoot {
dirty[rid] = true
}
if it.key == "" {
continue
}
if it.live {
_, err = tx.Exec(`INSERT OR IGNORE INTO claim_by_pid(pid, cid, key) VALUES(?, ?, ?)`, it.pid, it.cid, it.key)
} else {
_, err = tx.Exec(`DELETE FROM claim_by_pid WHERE pid = ? AND cid = ? AND key = ?`, it.pid, it.cid, it.key)
}
if err != nil {
return err
}
mark(rid, it.key)
}
for rid := range all {
cids, err := cidsForRoot(ctx, tx, rid)
if err != nil {
return err
}
keys, err := c.claimKeys(ctx, cids)
if err != nil {
return err
}
for _, k := range append(keys, localFields...) {
mark(rid, k)
}
}
for rid, keys := range marks {
cids, err := cidsForRoot(ctx, tx, rid)
if err != nil {
return err
}
for key := range keys {
changed, err := c.remergeTx(tx, rid, cids, key)
if err != nil {
return err
}
if changed && affectsHaystack(key) {
dirty[rid] = true
}
}
}
for rid := range dirty {
if err := refreshSearchTx(tx, rid); err != nil {
return err
}
}
if maxSeq > 0 {
return tx.Put(appliedSeqKey, strconv.FormatInt(maxSeq, 10))
}
return nil
}
// syncMember makes cid a member of rid in the cache: the lookup row, the
// synthesised key-set member, and the root. Reports whether rid is new.
func syncMember(tx *kv.Tx, cid, rid string) (bool, error) {
cur, _, err := tx.Get(tx.Context(), CIDIndexPrefix+cid)
if err != nil {
return false, err
}
if cur != rid {
if cur != "" {
if _, err := tx.Delete(recordPrefix(cur) + CIDsKeyPrefix + cid); err != nil {
return false, err
}
}
if err := tx.Put(CIDIndexPrefix+cid, rid); err != nil {
return false, err
}
}
if err := tx.Put(recordPrefix(rid)+CIDsKeyPrefix+cid, "true"); err != nil {
return false, err
}
return tx.Set(rootKey(rid), "")
}
// remergeTx recomputes file:<rid>/<key> from the group's votes (plus the
// box-local facts for filePath / sizeByte / mtimeNano) and reports whether
// the merged row changed.
func (c *Client) remergeTx(tx *kv.Tx, rid string, cids []string, key string) (bool, error) {
if strings.HasPrefix(key, CIDsKeyPrefix) {
return false, nil // membership is synthesised, never merged
}
votes, err := c.votesFor(tx.Context(), cids, key)
if err != nil {
return false, err
}
res := Merge(votes, "")
val, ok := res.Value, res.OK
if isLocalField(key) {
lv, lok, err := localFact(tx, cids, key)
if err != nil {
return false, err
}
// The disk is the truth for where and when a file is; a signed
// sizeByte (a content fact) wins over the on-disk size.
if lok && (key != "sizeByte" || !ok) {
val, ok = lv, true
}
}
k := recordPrefix(rid) + key
if ok {
return tx.Set(k, val)
}
return tx.Delete(k)
}
// localFact reads a box-local field from local_file for the group's cids.
func localFact(tx *kv.Tx, cids []string, key string) (string, bool, error) {
if len(cids) == 0 {
return "", false, nil
}
args := make([]any, len(cids))
for i, c := range cids {
args[i] = c
}
var path string
var size, mtime int64
err := tx.QueryRow(`SELECT path, size, mtime FROM local_file WHERE midhash IN (?`+
strings.Repeat(",?", len(cids)-1)+`) ORDER BY path LIMIT 1`, args...).Scan(&path, &size, &mtime)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, err
}
switch key {
case "filePath":
return path, true, nil
case "sizeByte":
return strconv.FormatInt(size, 10), true, nil
default:
return strconv.FormatInt(mtime, 10), true, nil
}
}
// truthMembers lists every cid of rid's group (scans cid_group: truth has no
// rid index; only regroups and rebuilds call this).
func (c *Client) truthMembers(ctx context.Context, rid string) ([]string, error) {
rows, err := c.truth.DB().QueryContext(ctx, `SELECT cid FROM cid_group WHERE rid = ?`, rid)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var cid string
if err := rows.Scan(&cid); err != nil {
return nil, err
}
out = append(out, cid)
}
return out, rows.Err()
}
// truthLastSeq reads the newest committed seq.
func (c *Client) truthLastSeq(ctx context.Context) (int64, error) {
var v string
err := c.truth.DB().QueryRowContext(ctx, `SELECT value FROM meta WHERE name = ?`, lastSeqName).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, err
}
return strconv.ParseInt(v, 10, 64)
}
// catchUp brings the cache level with truth at boot: nothing to do, a replay
// of the changes after applied_seq (found by a full scan — there is no seq
// index, and this only runs after an unclean shutdown), or a full rebuild
// when the cache is missing or a group changed while it was behind.
func (c *Client) catchUp() (int64, error) {
s := c.store
ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
defer cancel()
last, err := c.truthLastSeq(ctx)
if err != nil {
return 0, err
}
appliedStr, has, err := s.Get(ctx, appliedSeqKey)
if err != nil {
return 0, err
}
applied, _ := strconv.ParseInt(appliedStr, 10, 64)
if has && applied >= last {
return applied, nil
}
if !has {
if last == 0 {
return 0, nil // fresh store
}
log.Printf("[Storage] cache is empty, rebuilding from truth (last_seq %d)", last)
return last, c.rebuild(ctx, last)
}
rows, err := c.truth.DB().QueryContext(ctx,
`SELECT cid, key, pid, value IS NOT NULL, seq FROM claim WHERE seq > ? ORDER BY seq`, applied)
if err != nil {
return 0, err
}
var missed []change
regroup := false
for rows.Next() {
var ch change
if err := rows.Scan(&ch.cid, &ch.key, &ch.pid, &ch.live, &ch.seq); err != nil {
rows.Close()
return 0, err
}
if strings.HasPrefix(ch.key, CIDsKeyPrefix) {
regroup = true
}
missed = append(missed, ch)
}
if err := rows.Close(); err != nil {
return 0, err
}
if regroup {
log.Printf("[Storage] cache behind by %d changes including a group link, rebuilding", len(missed))
return last, c.rebuild(ctx, last)
}
log.Printf("[Storage] cache behind (applied %d < last %d), replaying %d changes", applied, last, len(missed))
for start := 0; start < len(missed); start += applyBatchMax {
end := min(start+applyBatchMax, len(missed))
if err := c.applyBatch(missed[start:end]); err != nil {
return 0, err
}
}
if err := s.Update(ctx, func(tx *kv.Tx) error { return tx.Put(appliedSeqKey, strconv.FormatInt(last, 10)) }); err != nil {
return 0, err
}
return last, nil
}
// Rebuild drops every derived row of the cache and recomputes it from truth.
// Exposed for tests and operators; boot calls it when the cache is missing.
func (c *Client) Rebuild() error {
if _, err := c.truthStore(); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
defer cancel()
last, err := c.truthLastSeq(ctx)
if err != nil {
return err
}
return c.rebuild(ctx, last)
}
const rebuildBatch = 500
func (c *Client) rebuild(ctx context.Context, last int64) error {
s := c.store
started := time.Now()
for _, prefix := range []string{recordNS, CIDIndexPrefix, rootsNS, ftsNS} {
for {
var n int64
if err := s.Update(ctx, func(tx *kv.Tx) error {
tx.Quiet()
var err error
n, err = tx.DeletePrefix(prefix, bulkBatch)
return err
}); err != nil {
return fmt.Errorf("clear %s: %w", prefix, err)
}
if n < bulkBatch {
break
}
}
}
if err := s.Update(ctx, func(tx *kv.Tx) error {
if _, err := tx.Exec(`DELETE FROM fts`); err != nil {
return err
}
_, err := tx.Exec(`DELETE FROM claim_by_pid`)
return err
}); err != nil {
return err
}
groups := map[string][]string{}
var order []string
rows, err := c.truth.DB().QueryContext(ctx, `SELECT cid, rid FROM cid_group`)
if err != nil {
return err
}
for rows.Next() {
var cid, rid string
if err := rows.Scan(&cid, &rid); err != nil {
rows.Close()
return err
}
if _, ok := groups[rid]; !ok {
order = append(order, rid)
}
groups[rid] = append(groups[rid], cid)
}
if err := rows.Close(); err != nil {
return err
}
for start := 0; start < len(order); start += rebuildBatch {
end := min(start+rebuildBatch, len(order))
if err := s.Update(ctx, func(tx *kv.Tx) error {
tx.Quiet()
for _, rid := range order[start:end] {
cids := groups[rid]
for _, cid := range cids {
if _, err := syncMember(tx, cid, rid); err != nil {
return err
}
}
keys, err := c.claimKeys(ctx, cids)
if err != nil {
return err
}
for _, key := range append(keys, localFields...) {
if _, err := c.remergeTx(tx, rid, cids, key); err != nil {
return err
}
}
if err := refreshSearchTx(tx, rid); err != nil {
return err
}
}
return nil
}); err != nil {
return fmt.Errorf("rebuild records: %w", err)
}
}
if err := c.rebuildClaimIndex(ctx); err != nil {
return err
}
if err := c.RebuildCounters(); err != nil {
return err
}
rebuiltAt := strconv.FormatInt(time.Now().UnixMilli(), 10) + "-0"
if err := s.Update(ctx, func(tx *kv.Tx) error {
if err := tx.Put(rebuiltAtKey, rebuiltAt); err != nil {
return err
}
return tx.Put(appliedSeqKey, strconv.FormatInt(last, 10))
}); err != nil {
return err
}
log.Printf("[Storage] cache rebuilt: %d records in %s", len(order), time.Since(started).Round(time.Millisecond))
return nil
}
// rebuildClaimIndex refills claim_by_pid from truth, a page at a time.
func (c *Client) rebuildClaimIndex(ctx context.Context) error {
type row struct {
pid int64
cid, key string
}
var after row
first := true
for {
q := `SELECT pid, cid, key FROM claim WHERE value IS NOT NULL`
var args []any
if !first {
q += ` AND (cid, key, pid) > (?, ?, ?)`
args = append(args, after.cid, after.key, after.pid)
}
q += ` ORDER BY cid, key, pid LIMIT 5000`
rs, err := c.truth.DB().QueryContext(ctx, q, args...)
if err != nil {
return err
}
var page []row
for rs.Next() {
var r row
if err := rs.Scan(&r.pid, &r.cid, &r.key); err != nil {
rs.Close()
return err
}
page = append(page, r)
}
if err := rs.Close(); err != nil {
return err
}
if len(page) == 0 {
return nil
}
if err := c.store.Update(ctx, func(tx *kv.Tx) error {
for _, r := range page {
if _, err := tx.Exec(`INSERT OR IGNORE INTO claim_by_pid(pid, cid, key) VALUES(?, ?, ?)`, r.pid, r.cid, r.key); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
after, first = page[len(page)-1], false
}
}
// RebuiltAt is the stream id of the last cache rebuild ("" if never).
func (c *Client) RebuiltAt() string {
s, err := c.kv()
if err != nil {
return ""
}
ctx, cancel := opCtx()
defer cancel()
v, _, _ := s.Get(ctx, rebuiltAtKey)
return v
}
+45 -281
View File
@@ -3,38 +3,24 @@ package storage
import ( import (
"context" "context"
"fmt" "fmt"
"strconv"
"strings" "strings"
"time"
"github.com/metazla/meta-core/internal/cid" "github.com/metazla/meta-core/internal/cid"
"github.com/redis/go-redis/v9" "github.com/metazla/meta-core/internal/kv"
) )
// This file implements the CID-resolution layer that lives on top of the // The cid → record resolution layer, read side. A record (rid) is a group of
// flat metadata storage in client.go. The short version: // cids known to be the same content; the group lives in truth's cid_group and
// is mirrored into the cache as
// //
// - Roots are opaque UUIDv7s — file:<uuid>/<property> for every property. // cid:<bareCid> → <rid>
// The UUID is an internal implementation detail; the mental model is // file:<rid>/cids/<bareCid> = "true" (synthesised membership, one per cid)
// "N CIDs → one record of keys→metadata".
// - Every known CID for a file is registered as a reverse-index entry:
// cid:<bareCid> → <uuid> (Redis STRING)
// and as a member of the per-root bare-CID key-set:
// file:<uuid>/cids/<bareCid> = "true" (flat key, sentinel value)
// - A CID is self-describing: its algorithm is recoverable from the CIDv1
// multicodec (see internal/cid), so there is no per-algorithm field name
// and no <algo>: token prefix. The canonical externally-visible CID is
// *derived by rank* from the key-set on read (GetMetadataDocument), never
// stored.
// //
// Auto-alias hooks in SetProperty/SetMetadataFlat/MergeMetadataFlat catch // by the apply loop (apply.go). A CID is self-describing (its algorithm is in
// any write of a cids/<cid> field and register the reverse-index entry, so // the CIDv1 multicodec, see internal/cid); the canonical externally-visible
// writers that just emit the key-set members don't need to know about the // CID is derived by rank on read (GetMetadataDocument), never stored.
// reverse index at all. The hook helpers are *Locked — they assume c.mu is
// already held by the caller, avoiding nested RLock (which can deadlock
// against a waiting writer).
// CIDIndexPrefix is the Redis key prefix for the reverse index. Combined // CIDIndexPrefix is the key prefix for the reverse index. Combined
// with a bare CID, it forms the full key: "cid:bafk…". // with a bare CID, it forms the full key: "cid:bafk…".
const CIDIndexPrefix = "cid:" const CIDIndexPrefix = "cid:"
@@ -45,118 +31,6 @@ const CIDIndexPrefix = "cid:"
// reverse-index entries that need cleanup). // reverse-index entries that need cleanup).
const CIDsKeyPrefix = "cids/" const CIDsKeyPrefix = "cids/"
// DuplicatesField is the per-root SET key holding additional file paths
// whose content hashes to the same CIDs as this root. The watcher writes
// here when it sees a new path for content that's already indexed; meta-dup
// reads from it.
const DuplicatesField = "duplicates"
// buildCIDIndexKey returns the absolute Redis key for a reverse-index entry.
// cidStr is a bare multibase CIDv1 ("bafk…").
func (c *Client) buildCIDIndexKey(cidStr string) string {
return c.buildKey(CIDIndexPrefix + cidStr)
}
// Mint creates a new root with filePath/sizeByte/mtimeNano and registers it
// in the file index. Returns the new UUID. The caller is expected to follow
// up with AddAlias for at least one CID — a root without any alias is
// reachable only via the index, which works but defeats the point.
func (c *Client) Mint(filePath string, size, mtimeNano int64) (string, error) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.client == nil {
return "", fmt.Errorf("not connected")
}
uuid, err := NewUUID()
if err != nil {
return "", fmt.Errorf("uuid: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
prefix := c.buildKeyPrefix(uuid)
pipe := c.client.Pipeline()
pipe.Set(ctx, prefix+"filePath", filePath, 0)
pipe.Set(ctx, prefix+"sizeByte", strconv.FormatInt(size, 10), 0)
pipe.Set(ctx, prefix+"mtimeNano", strconv.FormatInt(mtimeNano, 10), 0)
pipe.SAdd(ctx, c.buildIndexKey(), uuid)
c.addFieldsToPipe(ctx, pipe, uuid, []string{"filePath", "sizeByte", "mtimeNano"})
if _, err := pipe.Exec(ctx); err != nil {
return "", fmt.Errorf("mint pipeline: %w", err)
}
return uuid, nil
}
// AddAlias registers cidStr as a sibling CID of uuid: it writes the reverse
// index cid:<cidStr> → uuid and the key-set member cids/<cidStr> = "true".
// Idempotent: registering the same CID twice is a no-op.
func (c *Client) AddAlias(uuid, cidStr string) error {
c.mu.RLock()
defer c.mu.RUnlock()
if c.client == nil {
return fmt.Errorf("not connected")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return c.addAliasLocked(ctx, uuid, cidStr)
}
// addAliasLocked is the lock-free body of AddAlias. The caller must already
// hold c.mu (read or write). Split out so the alias-maintenance hooks
// invoked from SetProperty / SetMetadataFlat / MergeMetadataFlat can reuse
// the work without re-entering the lock — nested RLock can deadlock against
// a waiting writer.
func (c *Client) addAliasLocked(ctx context.Context, uuid, cidStr string) error {
if uuid == "" || cidStr == "" {
return fmt.Errorf("uuid and cid are required")
}
keysetField := CIDsKeyPrefix + cidStr
keysetKey := c.buildKeyPrefix(uuid) + keysetField
// Every branch below writes the key-set member, so index the field name
// once here — including the two guard branches that skip the alias itself.
// Missing it would leave `cids/<cid>` invisible to a record read.
defer c.addFieldLocked(ctx, uuid, keysetField)
// Self-pointing alias guard. If the caller is trying to register
// cid:<cid> → <cid> (i.e. the root IS the cid value), that's the
// dual-root failure mode in disguise — it means somebody wrote
// /meta/<cid> without first resolving via ResolveRoot. Refuse the alias
// write; leave any existing alias alone. Still record the key-set
// membership so the field-level write succeeds.
if uuid == cidStr {
return c.client.Set(ctx, keysetKey, "true", 0).Err()
}
// Guard against the dual-root pattern: if cid:<cid> already points at a
// DIFFERENT uuid, don't silently overwrite. Leave the existing alias and
// just record the key-set membership so it's still discoverable via the
// root → cids enumeration.
indexKey := c.buildCIDIndexKey(cidStr)
existing, err := c.client.Get(ctx, indexKey).Result()
if err != nil && err != redis.Nil {
return fmt.Errorf("read existing alias: %w", err)
}
if existing != "" && existing != uuid {
return c.client.Set(ctx, keysetKey, "true", 0).Err()
}
pipe := c.client.Pipeline()
pipe.Set(ctx, indexKey, uuid, 0)
pipe.Set(ctx, keysetKey, "true", 0)
if _, err := pipe.Exec(ctx); err != nil {
return fmt.Errorf("add alias pipeline: %w", err)
}
return nil
}
// ResolveRoot maps a hash supplied by a caller (a meta-sort write, an editor // ResolveRoot maps a hash supplied by a caller (a meta-sort write, an editor
// lookup, an SSE consumer) to the actual storage root key. // lookup, an SSE consumer) to the actual storage root key.
// //
@@ -174,8 +48,8 @@ func (c *Client) addAliasLocked(ctx context.Context, uuid, cidStr string) error
// - hash matches a reverse-index entry → return UUID // - hash matches a reverse-index entry → return UUID
// - otherwise return hash unchanged (legacy / explicit-UUID writes) // - otherwise return hash unchanged (legacy / explicit-UUID writes)
// //
// Errors are converted to "no resolution" — if Redis is down or the lookup // Errors are converted to "no resolution" — if the store is down or the
// fails we let the caller proceed with the bare hash and surface the Redis // lookup fails we let the caller proceed with the bare hash and surface the
// failure on the actual operation. // failure on the actual operation.
func (c *Client) ResolveRoot(hash string) string { func (c *Client) ResolveRoot(hash string) string {
if hash == "" { if hash == "" {
@@ -192,151 +66,35 @@ func (c *Client) ResolveRoot(hash string) string {
// when the CID is unknown; the empty string is the "not found" signal rather // when the CID is unknown; the empty string is the "not found" signal rather
// than a typed error so callers can branch without errors.As. // than a typed error so callers can branch without errors.As.
func (c *Client) GetByCID(cidStr string) (string, error) { func (c *Client) GetByCID(cidStr string) (string, error) {
c.mu.RLock() s, err := c.kv()
defer c.mu.RUnlock() if err != nil {
return "", err
if c.client == nil {
return "", fmt.Errorf("not connected")
} }
if cidStr == "" { if cidStr == "" {
return "", nil return "", nil
} }
ctx, cancel := opCtx()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
uuid, _, err := s.Get(ctx, CIDIndexPrefix+cidStr)
uuid, err := c.client.Get(ctx, c.buildCIDIndexKey(cidStr)).Result()
if err == redis.Nil {
return "", nil
}
if err != nil { if err != nil {
return "", fmt.Errorf("get cid index: %w", err) return "", fmt.Errorf("get cid index: %w", err)
} }
return uuid, nil return uuid, nil
} }
// AddDuplicatePath records that path is another physical location whose // cidsForRoot returns a root's bare-CID key-set members (cids/ prefix
// content matches uuid. Returns true if path was newly added (not already // stripped).
// in the set). The watcher calls this when it sees a file whose midhash func cidsForRoot(ctx context.Context, r kv.Reader, uuid string) ([]string, error) {
// matches an existing root but at a different filePath. prefix := recordPrefix(uuid) + CIDsKeyPrefix
func (c *Client) AddDuplicatePath(uuid, path string) (bool, error) { rows, err := r.Range(ctx, prefix, "", 0)
c.mu.RLock()
defer c.mu.RUnlock()
if c.client == nil {
return false, fmt.Errorf("not connected")
}
if uuid == "" || path == "" {
return false, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
added, err := c.client.SAdd(ctx, c.buildKeyPrefix(uuid)+DuplicatesField, path).Result()
if err != nil { if err != nil {
return false, fmt.Errorf("sadd duplicates: %w", err) return nil, err
} }
return added > 0, nil out := make([]string, 0, len(rows))
} for _, row := range rows {
out = append(out, row.K[len(prefix):])
// cidsForRootLocked returns a root's bare-CID key-set members (with the cids/
// prefix stripped). Caller must hold c.mu.
//
// Reads the field index and filters by prefix; only an un-indexed root falls
// back to a SCAN, and then over the narrower cids/ glob. See field_index.go.
func (c *Client) cidsForRootLocked(ctx context.Context, uuid string) ([]string, error) {
return c.fieldsWithPrefixLocked(ctx, uuid, CIDsKeyPrefix)
}
// DeleteRoot removes everything keyed off uuid: every file:<uuid>/* property
// (including the cids/<cid> key-set), every reverse-index entry for those
// CIDs, and the index membership. Idempotent — a uuid with no remaining keys
// is a successful no-op.
func (c *Client) DeleteRoot(uuid string) error {
c.mu.RLock()
defer c.mu.RUnlock()
if c.client == nil {
return fmt.Errorf("not connected")
}
if uuid == "" {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
prefix := c.buildKeyPrefix(uuid)
// 1. Read the cids key-set first — once we delete file:<uuid>/* we lose
// the list of reverse-index entries to clean up.
cids, err := c.cidsForRootLocked(ctx, uuid)
if err != nil {
return err
}
// 2. Delete every reverse-index entry in one shot.
if len(cids) > 0 {
keys := make([]string, 0, len(cids))
for _, cidStr := range cids {
keys = append(keys, c.buildCIDIndexKey(cidStr))
}
if err := c.client.Del(ctx, keys...).Err(); err != nil {
return fmt.Errorf("del cid index: %w", err)
}
}
// 3. Scan and delete every file:<uuid>/* key (cids/<cid> keys included).
var cursor uint64
for {
keys, next, err := c.client.Scan(ctx, cursor, prefix+"*", 1000).Result()
if err != nil {
return fmt.Errorf("scan root keys: %w", err)
}
if len(keys) > 0 {
if err := c.client.Del(ctx, keys...).Err(); err != nil {
return fmt.Errorf("del root keys: %w", err)
}
}
cursor = next
if cursor == 0 {
break
}
}
// 4. Remove from the file index set.
if err := c.client.SRem(ctx, c.buildIndexKey(), uuid).Err(); err != nil {
return fmt.Errorf("srem index: %w", err)
}
return nil
}
// maybeAddAliasFromFieldLocked inspects a (field, value) pair written into a
// root. If it's a key-set member — field name starts with "cids/" — it
// registers the implied CID in the reverse index. Writers emit cids/<cid>
// members without knowing about the reverse index; this hook keeps it in
// sync transparently.
//
// Caller must hold c.mu. Errors are intentionally swallowed at the call site
// (logged, not returned): an out-of-sync reverse index is recoverable via a
// sweep, while a failed property write is not.
func (c *Client) maybeAddAliasFromFieldLocked(ctx context.Context, uuid, field, value string) error {
cidStr := cidFromKeysetField(field)
if cidStr == "" {
return nil
}
return c.addAliasLocked(ctx, uuid, cidStr)
}
// maybeAddAliasesFromMetadataLocked bulk-applies maybeAddAliasFromFieldLocked
// over every entry in a metadata map. Used by SetMetadataFlat /
// MergeMetadataFlat after the underlying pipeline write succeeds. Caller must
// hold c.mu.
func (c *Client) maybeAddAliasesFromMetadataLocked(ctx context.Context, uuid string, metadata map[string]string) {
for k := range metadata {
_ = c.maybeAddAliasFromFieldLocked(ctx, uuid, k, metadata[k])
} }
return out, nil
} }
// cidFromKeysetField returns the bare CID encoded in a cids/<cid> field name, // cidFromKeysetField returns the bare CID encoded in a cids/<cid> field name,
@@ -366,24 +124,23 @@ type MetadataDocument struct {
// the duplicates SET. Returns nil if the root has no keys at all (deleted or // the duplicates SET. Returns nil if the root has no keys at all (deleted or
// never existed). // never existed).
func (c *Client) GetMetadataDocument(uuid string) (*MetadataDocument, error) { func (c *Client) GetMetadataDocument(uuid string) (*MetadataDocument, error) {
c.mu.RLock() s, err := c.kv()
defer c.mu.RUnlock() if err != nil {
return nil, err
if c.client == nil {
return nil, fmt.Errorf("not connected")
} }
if uuid == "" { if uuid == "" {
return nil, nil return nil, nil
} }
ctx, cancel := opCtx()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
flat, err := c.getMetadataFlatInternal(ctx, uuid) flat, err := readRecord(ctx, s, uuid)
if err != nil { if err != nil {
return nil, err return nil, err
} }
prefix := c.buildKeyPrefix(uuid) if flat == nil {
flat = make(map[string]string)
}
// Hoist the cids/<cid> key-set members into a typed array and derive the // Hoist the cids/<cid> key-set members into a typed array and derive the
// canonical CID by rank. The members remain in Flat too — that's how // canonical CID by rank. The members remain in Flat too — that's how
@@ -403,9 +160,16 @@ func (c *Client) GetMetadataDocument(uuid string) (*MetadataDocument, error) {
} }
} }
dups, err := c.client.SMembers(ctx, prefix+DuplicatesField).Result() // Duplicates: every other local path holding the same content.
if err != nil && err != redis.Nil { paths, err := localPaths(ctx, s.DB(), cids)
return nil, fmt.Errorf("smembers duplicates: %w", err) if err != nil {
return nil, fmt.Errorf("read duplicates: %w", err)
}
dups := make([]string, 0, len(paths))
for _, p := range paths {
if p != flat["filePath"] {
dups = append(dups, p)
}
} }
if len(flat) == 0 && len(cids) == 0 && len(dups) == 0 { if len(flat) == 0 && len(cids) == 0 && len(dups) == 0 {
+419
View File
@@ -0,0 +1,419 @@
package storage
import (
"context"
"database/sql"
"os"
"path/filepath"
"reflect"
"sort"
"strconv"
"strings"
"testing"
"github.com/metazla/meta-core/internal/claim"
"github.com/metazla/meta-core/internal/kv"
)
func TestWriteClaimStatuses(t *testing.T) {
c := newTestClient(t)
a := signer(t, 1)
good := vote(t, a, "bafyA", "title", "The Matrix", 100)
tampered := vote(t, a, "bafyA", "year", "1999", 100)
tampered.Value = claim.Text("2000")
highS := vote(t, a, "bafyA", "studio", "WB", 100)
highS.Sig = flipS(highS.Sig)
got := write(t, c,
good,
tampered,
highS,
claim.Claim{CID: "bafyA", UID: a.UID, TS: 1}, // no key
vote(t, a, "bafyA", "cid_sha256", "x", 100), // removed CID shape
)
want := []ClaimStatus{StatusAccepted, StatusBadSignature, StatusBadSignature, StatusBadRequest, StatusBadRequest}
if !reflect.DeepEqual(got, want) {
t.Fatalf("statuses %v, want %v", got, want)
}
if st := write(t, c, good); st[0] != StatusUnchanged {
t.Fatalf("identical re-send: %s, want unchanged", st[0])
}
if st := write(t, c, vote(t, a, "bafyA", "title", "Older", 50)); st[0] != StatusStale {
t.Fatalf("older ts: %s, want stale", st[0])
}
if st := write(t, c, vote(t, a, "bafyA", "title", "Same ts other value", 100)); st[0] != StatusStale {
t.Fatalf("equal ts, different claim: %s, want stale", st[0])
}
if got := record(t, c, "bafyA")["title"]; got != "The Matrix" {
t.Fatalf("title = %q", got)
}
// A re-persist of the same value with a newer ts keeps the stored row (and
// its older ts): it is "unchanged", not a new version.
if st := write(t, c, vote(t, a, "bafyA", "title", "The Matrix", 200)); st[0] != StatusUnchanged {
t.Fatalf("same value, newer ts: %s, want unchanged", st[0])
}
// Proof the stored ts stayed 100: a new value at 150 (< 200) is accepted.
if st := write(t, c, vote(t, a, "bafyA", "title", "Updated", 150)); st[0] != StatusAccepted {
t.Fatalf("new value at ts 150 after a same-value re-send at 200: %s, want accepted", st[0])
}
}
func flipS(sig []byte) []byte {
// n - s for secp256k1, computed via the vectors' helper in internal/claim
// would be cleaner; here big-endian subtraction on bytes is enough.
n := []byte{
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe,
0xba, 0xae, 0xdc, 0xe6, 0xaf, 0x48, 0xa0, 0x3b, 0xbf, 0xd2, 0x5e, 0x8c, 0xd0, 0x36, 0x41, 0x41,
}
out := append([]byte{}, sig...)
borrow := 0
for i := 31; i >= 0; i-- {
d := int(n[i]) - int(sig[32+i]) - borrow
borrow = 0
if d < 0 {
d += 256
borrow = 1
}
out[32+i] = byte(d)
}
return out
}
// A retraction is kept as a tombstone, so the vote it retracted cannot be
// replayed — by its signer's old copy or by a peer that still holds it.
func TestRetractionKeepsTombstoneAndBlocksReplay(t *testing.T) {
c := newTestClient(t)
a := signer(t, 1)
old := vote(t, a, "bafyA", "genres/Drama", "true", 10)
mustAccept(t, c, old, vote(t, a, "bafyA", "title", "X", 10))
mustAccept(t, c, vote(t, a, "bafyA", "genres/Drama", nil, 20))
if _, ok := record(t, c, "bafyA")["genres/Drama"]; ok {
t.Fatal("retracted member still merged")
}
if st := write(t, c, old); st[0] != StatusStale {
t.Fatalf("replay of the retracted vote: %s, want stale", st[0])
}
if n, _ := c.CountClaims(); n != 2 {
t.Fatalf("claims = %d, want 2 (tombstone kept)", n)
}
live, _ := c.ClaimsForCIDs([]string{"bafyA"}, "")
if len(live) != 1 || live[0].Key != "title" {
t.Fatalf("live claims = %+v, want only title", live)
}
}
func TestMergeRule(t *testing.T) {
tx := func(s string) claim.Value { return claim.Text(s) }
cases := []struct {
name string
votes []Vote
viewer string
want string
nsrc int
ok bool
}{
{"support wins", []Vote{{Uid: "a", Value: tx("X"), TS: 9}, {Uid: "b", Value: tx("x "), TS: 9}, {Uid: "c", Value: tx("Y"), TS: 1}}, "", "X", 2, true},
{"tie: oldest wins", []Vote{{Uid: "a", Value: tx("New"), TS: 9}, {Uid: "b", Value: tx("Old"), TS: 1}}, "", "Old", 1, true},
{"tie: smallest wins", []Vote{{Uid: "a", Value: tx("b"), TS: 5}, {Uid: "b", Value: tx("a"), TS: 5}}, "", "a", 1, true},
{"signer counts once, newest", []Vote{{Uid: "a", Value: tx("X"), TS: 1}, {Uid: "a", Value: tx("Y"), TS: 2}, {Uid: "b", Value: tx("Y"), TS: 3}}, "", "Y", 2, true},
{"retraction drops", []Vote{{Uid: "a", Value: tx("X"), TS: 1}, {Uid: "a", Value: claim.Null(), TS: 2}}, "", "", 0, false},
{"ciphertext never merges", []Vote{{Uid: "a", Value: claim.Bytes([]byte{1}), TS: 1}}, "", "", 0, false},
{"viewer wins", []Vote{{Uid: "a", Value: tx("X"), TS: 1}, {Uid: "b", Value: tx("X"), TS: 1}, {Uid: "v", Value: tx("Mine"), TS: 9}}, "v", "Mine", 1, true},
{"viewer retraction does not hide", []Vote{{Uid: "a", Value: tx("X"), TS: 1}, {Uid: "v", Value: claim.Null(), TS: 9}}, "v", "X", 1, true},
}
for _, tc := range cases {
got := Merge(tc.votes, tc.viewer)
if got.OK != tc.ok || got.Value != tc.want || (tc.ok && got.Nsrc != tc.nsrc) {
t.Errorf("%s: got %+v, want %q nsrc %d ok %v", tc.name, got, tc.want, tc.nsrc, tc.ok)
}
}
}
func TestMergedViewAndClaims(t *testing.T) {
c := newTestClient(t)
gwA, gwB, gwC := signer(t, 1), signer(t, 2), signer(t, 3)
mustAccept(t, c,
vote(t, gwA, "bafyA", "title", "Matrix", 10),
vote(t, gwB, "bafyA", "title", "Matrix", 20),
vote(t, gwC, "bafyA", "title", "The Matrix (1999)", 5),
)
rec := record(t, c, "bafyA")
if rec["title"] != "Matrix" || rec["cids/bafyA"] != "true" {
t.Fatalf("merged %v", rec)
}
claims, err := c.ClaimsForRID(rid(t, c, "bafyA"), "title")
if err != nil || len(claims) != 3 {
t.Fatalf("claims %d (err %v), want 3", len(claims), err)
}
for _, cl := range claims {
if err := cl.Verify(); err != nil {
t.Fatalf("returned claim does not verify: %v", err)
}
}
}
// A cids/* link merges two groups: the older rid survives, the loser's rows
// go with `del` events, and the survivor carries both groups' votes.
func TestGroupLinkMergesRecords(t *testing.T) {
c := newTestClient(t)
a, b := signer(t, 1), signer(t, 2)
mustAccept(t, c, vote(t, a, "bafyM", "title", "Local copy", 10))
older := rid(t, c, "bafyM")
mustAccept(t, c, vote(t, b, "bafyQ", "year", "1999", 10))
newer := rid(t, c, "bafyQ")
if older == newer || older > newer {
t.Fatalf("expected two groups, older first: %s %s", older, newer)
}
before, _ := c.Store().LastID(context.Background(), MetaEventsStream)
mustAccept(t, c, vote(t, a, "bafyM", "cids/bafyQ", "true", 11))
if got := rid(t, c, "bafyQ"); got != older {
t.Fatalf("bafyQ → %s, want the older rid %s", got, older)
}
rec := record(t, c, "bafyM")
if rec["title"] != "Local copy" || rec["year"] != "1999" || rec["cids/bafyQ"] != "true" || rec["cids/bafyM"] != "true" {
t.Fatalf("survivor %v", rec)
}
if f, _ := c.GetMetadataFlat(newer); f != nil {
t.Fatalf("loser still has rows: %v", f)
}
entries, _ := c.Store().ReadAfter(context.Background(), MetaEventsStream, before, 1000)
sawDel := false
for _, e := range entries {
if e.Values["type"] == "del" && strings.HasPrefix(e.Values["key"], "file:"+newer+"/") {
sawDel = true
}
}
if !sawDel {
t.Fatal("no del event for the losing rid")
}
if n, _ := c.CountRoots(); n != 1 {
t.Fatalf("roots = %d, want 1", n)
}
}
func TestViewerOverlay(t *testing.T) {
c := newTestClient(t)
a, b, me := signer(t, 1), signer(t, 2), signer(t, 9)
mustAccept(t, c,
vote(t, a, "bafyA", "title", "Shared", 1),
vote(t, b, "bafyA", "title", "Shared", 1),
vote(t, me, "bafyA", "title", "Mine", 2),
vote(t, me, "bafyA", "seek", []byte{1, 2, 3}, 2), // ciphertext: never shown
)
r := rid(t, c, "bafyA")
shared, _ := c.GetMetadataFlat(r)
mine, _ := c.GetMetadataFlatAs(r, me.UID)
if shared["title"] != "Shared" || mine["title"] != "Mine" {
t.Fatalf("shared %q, mine %q", shared["title"], mine["title"])
}
if _, ok := mine["seek"]; ok {
t.Fatal("ciphertext leaked into the view")
}
match := func(_ string, f map[string]string) bool { return strings.Contains(strings.ToLower(f["title"]), "mine") }
if hits, _ := c.Search("", nil, 10, match); len(hits) != 0 {
t.Fatalf("shared search found the viewer's value: %v", hits)
}
hits, err := c.SearchAs("", nil, 10, me.UID, match)
if err != nil || len(hits) != 1 {
t.Fatalf("viewer search hits %v (err %v), want 1", hits, err)
}
merged, _, dropped, err := c.MergeClaims([]claim.Claim{
vote(t, a, "bafyA", "title", "Shared", 1),
vote(t, b, "bafyZ", "title", "Other", 1),
}, me.UID)
if err != nil || dropped != 0 || merged["title"] != "Mine" {
t.Fatalf("stateless merge as viewer = %v dropped %d err %v", merged, dropped, err)
}
}
// A crash between the truth commit and the cache apply leaves the cache
// behind; the next boot replays the missed changes from truth.
func TestCrashBetweenCommitsIsCaughtUp(t *testing.T) {
dir := t.TempDir()
c := openClient(t, dir)
a := signer(t, 1)
mustAccept(t, c, vote(t, a, "bafyA", "title", "Before", 1))
// Commit straight to truth, as WriteClaims does, but never apply.
lost := vote(t, a, "bafyA", "title", "After", 2)
if err := c.truth.Update(context.Background(), func(tx *kv.Tx) error {
seq, err := readLastSeq(tx)
if err != nil {
return err
}
pid, err := ensurePrincipal(tx, lost.UID)
if err != nil {
return err
}
seq++
if _, err := tx.Exec(`UPDATE claim SET value = ?, ts = ?, sig = ?, seq = ? WHERE cid = ? AND key = ? AND pid = ?`,
"After", int64(lost.TS), lost.Sig, seq, lost.CID, lost.Key, pid); err != nil {
return err
}
return writeLastSeq(tx, seq)
}); err != nil {
t.Fatal(err)
}
if got := record(t, c, "bafyA")["title"]; got != "Before" {
t.Fatalf("cache applied a change it was never given: %q", got)
}
c.Close()
c = openClient(t, dir)
defer c.Close()
if got := record(t, c, "bafyA")["title"]; got != "After" {
t.Fatalf("after catch-up title = %q, want After", got)
}
}
// The cache is a pure function of truth: deleting it and rebuilding yields
// exactly what incremental application produced.
func TestRebuildEqualsIncremental(t *testing.T) {
dir := t.TempDir()
c := openClient(t, dir)
a, b, u := signer(t, 1), signer(t, 2), signer(t, 3)
mustAccept(t, c,
vote(t, a, "bafy1", "title", "One", 1), vote(t, a, "bafy1", "genres/Action", "true", 1),
vote(t, b, "bafy1", "title", "Uno", 2), vote(t, b, "bafy2", "title", "Two", 2),
vote(t, a, "bafy2", "sizeByte", "123", 1), vote(t, u, "bafy2", "like", "true", 3),
vote(t, u, "bafy2", "seek", []byte{9}, 3),
)
mustAccept(t, c,
vote(t, a, "bafy1", "genres/Action", nil, 4), // retraction
vote(t, b, "bafy1", "cids/bafy2", "true", 5), // group merge
vote(t, a, "bafy3", "title", "Three", 5),
)
incremental := cacheState(t, c)
c.Close()
for _, f := range []string{"cache.db", "cache.db-wal", "cache.db-shm"} {
_ = os.Remove(filepath.Join(dir, f))
}
c = openClient(t, dir)
defer c.Close()
if rebuilt := cacheState(t, c); !reflect.DeepEqual(rebuilt, incremental) {
t.Fatalf("rebuild differs from incremental:\n incremental %v\n rebuilt %v", incremental, rebuilt)
}
if c.RebuiltAt() == "" {
t.Fatal("rebuild did not record rebuilt_at (SSE consumers would not get a gap)")
}
}
// cacheState is every derived row that must be a function of truth.
func cacheState(t *testing.T, c *Client) []string {
t.Helper()
var out []string
for _, k := range allKeys(t, c) {
if strings.HasPrefix(k, "ev:") || strings.HasPrefix(k, "evhwm:") || strings.HasPrefix(k, "meta:") ||
strings.HasPrefix(k, ftsNS) || k == countersReady {
continue
}
v, _, _ := c.Store().Get(context.Background(), k)
out = append(out, k+"="+v)
}
rows, err := c.Store().DB().Query(`SELECT pid, cid, key FROM claim_by_pid`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var pid int64
var cid, key string
_ = rows.Scan(&pid, &cid, &key)
out = append(out, "byp:"+strconv.FormatInt(pid, 10)+"/"+cid+"/"+key)
}
var hays []string
hr, _ := c.Store().DB().Query(`SELECT rid, hay FROM fts`)
for hr.Next() {
var r, h string
_ = hr.Scan(&r, &h)
hays = append(hays, "fts:"+r+"="+h)
}
hr.Close()
out = append(out, hays...)
sort.Strings(out)
return out
}
func TestLocalFileFactsAndEvents(t *testing.T) {
c := newTestClient(t)
before, _ := c.Store().LastID(context.Background(), MetaEventsStream)
r, created, err := c.RegisterFileTuple("/files/watch/a.mkv", 1000, 42, "bafkmid")
if err != nil || !created {
t.Fatalf("register: %v created=%v", err, created)
}
rec, _ := c.GetMetadataFlat(r)
if rec["filePath"] != "/files/watch/a.mkv" || rec["sizeByte"] != "1000" || rec["mtimeNano"] != "42" || rec["cids/bafkmid"] != "true" {
t.Fatalf("record %v", rec)
}
entries, _ := c.Store().ReadAfter(context.Background(), MetaEventsStream, before, 100)
sawPath := false
for _, e := range entries {
if e.Values["type"] == "set" && e.Values["key"] == "file:"+r+"/filePath" {
sawPath = true
}
}
if !sawPath {
t.Fatal("no set event for file:<rid>/filePath (meta-fuse builds its tree from it)")
}
// A signed sizeByte (content fact) wins over the on-disk size.
mustAccept(t, c, vote(t, signer(t, 1), "bafkmid", "sizeByte", "999", 1))
if got, _ := c.GetProperty(r, "sizeByte"); got != "999" {
t.Fatalf("sizeByte = %q, want the signed 999", got)
}
// A second path of the same content is a duplicate.
if _, _, err := c.RegisterFileTuple("/files/watch/copy.mkv", 1000, 43, "bafkmid"); err != nil {
t.Fatal(err)
}
doc, _ := c.GetMetadataDocument(r)
if len(doc.Duplicates) != 1 || doc.Duplicates[0] != "/files/watch/copy.mkv" {
t.Fatalf("duplicates %v", doc.Duplicates)
}
tuples, _ := c.GetTuplesForAllFiles()
if len(tuples) != 2 || tuples[0].HashID != r {
t.Fatalf("tuples %+v", tuples)
}
// Deleting the files drops the local facts, keeps the claims.
mark, _ := c.Store().LastID(context.Background(), MetaEventsStream)
for _, p := range []string{"/files/watch/a.mkv", "/files/watch/copy.mkv"} {
if err := c.RemoveFileTuple(p); err != nil {
t.Fatal(err)
}
}
rec, _ = c.GetMetadataFlat(r)
if _, ok := rec["filePath"]; ok || rec["sizeByte"] != "999" {
t.Fatalf("after delete %v", rec)
}
entries, _ = c.Store().ReadAfter(context.Background(), MetaEventsStream, mark, 100)
sawDel := false
for _, e := range entries {
if e.Values["type"] == "del" && e.Values["key"] == "file:"+r+"/filePath" {
sawDel = true
}
}
if !sawDel {
t.Fatal("no del event for filePath")
}
}
func TestSchemaGateRefusesV1Store(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "meta-core.db")
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`CREATE TABLE kv(k TEXT PRIMARY KEY, v TEXT NOT NULL) WITHOUT ROWID;
INSERT INTO kv VALUES('meta-core:schema-version','2')`); err != nil {
t.Fatal(err)
}
db.Close()
c := NewClient()
if err := c.Connect(path, filepath.Join(dir, "cache.db")); err != nil {
t.Fatal(err)
}
defer c.Close()
if err := c.EnsureSchemaVersion(SchemaVersion); err == nil || !strings.Contains(err.Error(), "v1.x") {
t.Fatalf("v1 store accepted: %v", err)
}
}

Some files were not shown because too many files have changed in this diff Show More