Compare commits

...
30 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
136 changed files with 17780 additions and 8590 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>
+37 -139
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.
*/
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace JSX {
interface IntrinsicElements {
'meta-service-menu': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
> & { current?: string; endpoint?: string; label?: string };
} }
interface ServicesResponse {
services: ServiceInfo[];
current: string;
} }
const serviceIcons: Record<string, string> = {
'meta-core': '🗄️',
'meta-sort': '📁',
'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) {