5 Commits
Author SHA1 Message Date
Worph 8139652bfd sign every write as an MMUR2 claim (POST /api/claims)
Build and Push Docker Image / build (push) Successful in 57s
2026-10-25 17:36:54 +00:00
Manielle bda96c7825 CI accepts four-part tags (v1.2.3.1) for fixes
Build and Push Docker Image / build (push) Successful in 53s
2026-10-25 14:21:52 +01:00
Worph 953fcc9ff7 release (first build on gitea-watch / IPCR)
Build and Push Docker Image / build (push) Successful in 1m2s
2026-10-13 11:03:51 +00:00
Worph 3d54813cf9 gitea workflow: push to IPCR's direct host (Cloudflare 100 MB cap) [skip ci] 2026-10-13 10:03:09 +00:00
Worph 4b4755a9e0 gitea workflow: publish to IPCR (metadec.eth) instead of ghcr
Build and Push Docker Image / build (push) Successful in 1m3s
2026-10-12 18:31:27 +00:00
4 changed files with 459 additions and 48 deletions
+85
View File
@@ -0,0 +1,85 @@
# Gitea twin of .github/workflows/docker.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.nip.io/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.nip.io, 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, master]
tags: ['v*']
pull_request:
branches: [main, master]
env:
REGISTRY: ipcr-push-85-17-246-67.nip.io
IMAGE_NAME: metadec.eth/${{ github.event.repository.name }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
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
if: github.event_name != 'pull_request'
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
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
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
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+3
View File
@@ -42,6 +42,9 @@ jobs:
type=ref,event=branch type=ref,event=branch
type=ref,event=pr type=ref,event=pr
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 type=sha
+356 -41
View File
@@ -1,65 +1,380 @@
/** /**
* meta-core API Client for writing metadata * meta-core client: signed claim writes (MMUR2) + plain reads.
* Fails gracefully if meta-core is unavailable (returns without throwing) *
* MIRRORED byte-for-byte across every `metamesh-plugin-*` TypeScript plugin and
* meta-sort-core (`src/kv/meta-core-client.ts`) — guarded by
* `scripts/check-mirrors.sh`. Edit one copy, then copy it everywhere.
*
* Every write is a vote signed by THIS container's key
* (docs/implementation/metacore-claims-model.md §3, §9):
*
* bytes_to_sign = "MMUR2\n" || canonical CBOR [cid, uid, key, value, ts]
* sig = ECDSA-secp256k1(SHA-256(bytes)), compact r||s, low-S
*
* and is posted to `POST {baseUrl}/api/claims` — meta-core directly, or
* meta-sort's pass-through relay when the plugin runs under meta-sort. The
* encoding is pinned by meta-core's `internal/claim/testdata/claim-vectors.json`
* (vendored as `test/claim-vectors.json`).
*
* Key: `METAMESH_SIGNING_KEY` (64 hex chars) or `METAMESH_SIGNING_KEY_FILE`
* (read; generated and written 0600 if missing). With neither, writes are
* refused (logged, return false) — no silent throwaway identities.
*
* Fails gracefully if meta-core is unavailable: logs and returns, never throws.
*/ */
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
// ---------------------------------------------------------------- encoding --
const DOMAIN = Buffer.from('MMUR2\n', 'utf8');
const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141');
const HALF_N = N >> 1n;
/** A claim value: text (public), bytes (private envelope) or null (retraction). */
export type ClaimValue = string | Uint8Array | null;
export interface SignedClaim {
cid: string;
uid: string;
key: string;
value: ClaimValue;
ts: number;
sig: Uint8Array;
}
function head(major: number, n: number | bigint): Buffer {
const m = major << 5;
const v = BigInt(n);
if (v < 24n) return Buffer.from([m | Number(v)]);
if (v <= 0xffn) return Buffer.from([m | 24, Number(v)]);
if (v <= 0xffffn) {
const b = Buffer.alloc(3);
b[0] = m | 25;
b.writeUInt16BE(Number(v), 1);
return b;
}
if (v <= 0xffffffffn) {
const b = Buffer.alloc(5);
b[0] = m | 26;
b.writeUInt32BE(Number(v), 1);
return b;
}
const b = Buffer.alloc(9);
b[0] = m | 27;
b.writeBigUInt64BE(v, 1);
return b;
}
function text(s: string): Buffer {
const raw = Buffer.from(s, 'utf8');
return Buffer.concat([head(3, raw.length), raw]);
}
/** The exact bytes an MMUR2 signature covers. */
export function signedBytes(cid: string, uid: string, key: string, value: ClaimValue, ts: number): Buffer {
let v: Buffer;
if (value === null) v = Buffer.from([0xf6]);
else if (typeof value === 'string') v = text(value);
else v = Buffer.concat([head(2, value.length), Buffer.from(value)]);
return Buffer.concat([DOMAIN, head(4, 5), text(cid), text(uid), text(key), v, head(0, ts)]);
}
function base58Encode(bytes: Uint8Array): string {
let n = BigInt('0x' + (Buffer.from(bytes).toString('hex') || '0'));
let out = '';
while (n > 0n) {
out = B58[Number(n % 58n)] + out;
n /= 58n;
}
for (const b of bytes) {
if (b !== 0) break;
out = '1' + out;
}
return out;
}
function base58Decode(s: string): Buffer {
let n = 0n;
for (const ch of s) {
const i = B58.indexOf(ch);
if (i < 0) throw new Error(`invalid base58 character ${ch}`);
n = n * 58n + BigInt(i);
}
let hex = n === 0n ? '' : n.toString(16);
if (hex.length % 2) hex = '0' + hex;
let zeros = 0;
while (zeros < s.length && s[zeros] === '1') zeros++;
return Buffer.concat([Buffer.alloc(zeros), Buffer.from(hex, 'hex')]);
}
function b64url(b: Buffer): string {
return b.toString('base64url');
}
function publicKeyFromCompressed(compressed: Buffer): crypto.KeyObject {
const full = crypto.ECDH.convertKey(compressed, 'secp256k1', undefined, undefined, 'uncompressed') as Buffer;
return crypto.createPublicKey({
key: { kty: 'EC', crv: 'secp256k1', x: b64url(full.subarray(1, 33)), y: b64url(full.subarray(33, 65)) },
format: 'jwk',
});
}
/** Rewrite a compact signature to its low-S form (MMUR2 refuses high-S). */
function lowS(sig: Buffer): Buffer {
const s = BigInt('0x' + sig.subarray(32).toString('hex'));
if (s <= HALF_N) return sig;
const fixed = Buffer.from((N - s).toString(16).padStart(64, '0'), 'hex');
return Buffer.concat([sig.subarray(0, 32), fixed]);
}
/** True when the claim's signature is a valid low-S signature by its uid. */
export function verifyClaim(c: SignedClaim): boolean {
try {
const sig = Buffer.from(c.sig);
if (sig.length !== 64) return false;
if (BigInt('0x' + sig.subarray(32).toString('hex')) > HALF_N) return false;
if (!c.uid.startsWith('z')) return false;
const pub = base58Decode(c.uid.slice(1));
if (pub.length !== 33) return false;
return crypto.verify(
'sha256',
signedBytes(c.cid, c.uid, c.key, c.value, c.ts),
{ key: publicKeyFromCompressed(pub), dsaEncoding: 'ieee-p1363' },
sig,
);
} catch {
return false;
}
}
// ----------------------------------------------------------------- signer --
export class ClaimSigner {
readonly uid: string;
private readonly key: crypto.KeyObject;
private lastTs = 0;
constructor(privateKeyHex: string) {
const hex = privateKeyHex.trim().replace(/^0x/i, '');
if (!/^[0-9a-fA-F]{64}$/.test(hex)) throw new Error('signing key must be 64 hex characters');
const d = Buffer.from(hex, 'hex');
const ecdh = crypto.createECDH('secp256k1');
ecdh.setPrivateKey(d);
const full = ecdh.getPublicKey(null, 'uncompressed');
this.uid = 'z' + base58Encode(ecdh.getPublicKey(null, 'compressed'));
this.key = crypto.createPrivateKey({
key: {
kty: 'EC', crv: 'secp256k1', d: b64url(d),
x: b64url(full.subarray(1, 33)), y: b64url(full.subarray(33, 65)),
},
format: 'jwk',
});
}
/** Milliseconds, strictly increasing within this process (ts is the version). */
nextTs(): number {
this.lastTs = Math.max(Date.now(), this.lastTs + 1);
return this.lastTs;
}
sign(cid: string, key: string, value: ClaimValue, ts: number = this.nextTs()): SignedClaim {
const raw = crypto.sign('sha256', signedBytes(cid, this.uid, key, value, ts), {
key: this.key,
dsaEncoding: 'ieee-p1363',
});
return { cid, uid: this.uid, key, value, ts, sig: lowS(raw) };
}
}
let cachedSigner: ClaimSigner | null | undefined;
/**
* The process signer from the environment, or null when no key is configured.
* Read lazily on first write; `resetSignerForTests()` forgets it.
*/
export function loadSigner(): ClaimSigner | null {
if (cachedSigner !== undefined) return cachedSigner;
const inline = process.env.METAMESH_SIGNING_KEY;
const file = process.env.METAMESH_SIGNING_KEY_FILE;
try {
if (inline) {
cachedSigner = new ClaimSigner(inline);
} else if (file) {
if (!fs.existsSync(file)) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, crypto.randomBytes(32).toString('hex') + '\n', { mode: 0o600 });
}
cachedSigner = new ClaimSigner(fs.readFileSync(file, 'utf8'));
} else {
cachedSigner = null;
}
} catch (error) {
console.warn(`[MetaCoreClient] Signing key unusable: ${error instanceof Error ? error.message : String(error)}`);
cachedSigner = null;
}
if (cachedSigner) console.log(`[MetaCoreClient] Signing claims as ${cachedSigner.uid}`);
else console.warn('[MetaCoreClient] No METAMESH_SIGNING_KEY / METAMESH_SIGNING_KEY_FILE — metadata writes are refused');
return cachedSigner;
}
export function resetSignerForTests(): void {
cachedSigner = undefined;
}
/** The §3.4 JSON form. */
export function claimToJSON(c: SignedClaim): Record<string, unknown> {
let value: unknown = null;
if (typeof c.value === 'string') value = c.value;
else if (c.value) value = { b64: Buffer.from(c.value).toString('base64') };
return { cid: c.cid, uid: c.uid, key: c.key, value, ts: c.ts, sig: Buffer.from(c.sig).toString('base64') };
}
// ----------------------------------------------------------------- client --
export class MetaCoreClient { export class MetaCoreClient {
constructor(private baseUrl: string) {} constructor(private baseUrl: string) {}
private async safeFetch(url: string, options: RequestInit): Promise<Response | null> { private async safeFetch(url: string, options: RequestInit): Promise<Response | null> {
try { try {
return await fetch(url, { ...options, signal: AbortSignal.timeout(5000) }); return await fetch(url, { ...options, signal: AbortSignal.timeout(10000) });
} catch { } catch (error) {
// meta-core unavailable - fail silently (expected in standalone/test mode) console.warn(`[MetaCoreClient] Warning: meta-core unavailable at ${this.baseUrl}: ${error instanceof Error ? error.message : String(error)}`);
return null; return null;
} }
} }
async setProperty(hashId: string, key: string, value: string): Promise<void> { /**
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/${key}`, { * Sign and post claims about `cid`. True when every claim was accepted (or
method: 'PUT', * was already stored: `unchanged` / `stale` are not failures).
*/
async writeClaims(cid: string, values: Array<[string, ClaimValue]>): Promise<boolean> {
if (values.length === 0) return true;
const signer = loadSigner();
if (!signer) return false;
const claims = values.map(([key, value]) => claimToJSON(signer.sign(cid, key, value)));
const response = await this.safeFetch(`${this.baseUrl}/api/claims`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }), body: JSON.stringify(claims),
}); });
// Silently ignore errors (expected in standalone/test mode) if (!response) return false;
if (!response.ok) {
console.warn(`[MetaCoreClient] Claims for ${cid} rejected: ${response.status}`);
return false;
}
const statuses = await response.json().catch(() => null) as Array<{ status?: string; reason?: string }> | null;
const bad = (statuses ?? []).filter((s) => s.status === 'bad_signature' || s.status === 'bad_request');
if (bad.length > 0) {
console.warn(`[MetaCoreClient] ${bad.length} claim(s) for ${cid} refused: ${bad[0].status} ${bad[0].reason ?? ''}`);
return false;
}
return true;
}
/** One text claim. */
async setProperty(hashId: string, key: string, value: string): Promise<boolean> {
return this.writeClaims(hashId, [[key, String(value)]]);
}
/** One text claim per entry, in a single batch. */
async mergeMetadata(hashId: string, metadata: Record<string, string>): Promise<boolean> {
return this.writeClaims(hashId, Object.entries(metadata).map(([k, v]) => [k, String(v)]));
}
/**
* Retract this signer's own vote for `key`. Other signers' votes for the
* same key are untouched (there are no negative votes).
*/
async deleteProperty(hashId: string, key: string): Promise<boolean> {
return this.writeClaims(hashId, [[key, null]]);
}
/**
* Append a member to a legacy csv-set field (`genres`, `studio`, `tags`,
* `announceList`, …) — the comma-joined shape readers still parse (the
* METADATA_KEYS §14.12 key-set migration is out of scope for phase 1).
*
* Under claims a signer owns only its OWN vote, so this reads this
* signer's current claim for (cid, key) from
* `GET /api/meta/{cid}/claims?key=<key>` (other signers' claims are
* ignored), adds the member if absent and signs the new full value as one
* text claim. Members are kept raw (no escaping), same as the old
* meta-sort `_add` proxy. A member already present writes nothing.
*/
async addToSet(hashId: string, key: string, value: string): Promise<boolean> {
const member = String(value);
if (!member) return true;
const signer = loadSigner();
if (!signer) return false;
const current = await this.ownClaimValue(hashId, key, signer.uid);
if (current === undefined) return false; // could not read: never clobber our own set
const members = current ? current.split(',').map((v) => v.trim()).filter(Boolean) : [];
if (members.includes(member)) return true;
return this.writeClaims(hashId, [[key, [...members, member].join(',')]]);
}
/**
* This signer's newest text claim for (cid, key): the value, `null` when it
* has none, `undefined` when the claims could not be read.
*/
private async ownClaimValue(cid: string, key: string, uid: string): Promise<string | null | undefined> {
const url = `${this.baseUrl}/api/meta/${encodeURIComponent(cid)}/claims?key=${encodeURIComponent(key)}`;
const response = await this.safeFetch(url, { method: 'GET' });
if (!response) return undefined;
if (response.status === 404) return null;
if (!response.ok) {
console.warn(`[MetaCoreClient] Failed to read claims for ${cid}/${key}: ${response.status}`);
return undefined;
}
const body = await response.json().catch(() => null) as unknown;
const list = (Array.isArray(body) ? body : (body as { claims?: unknown })?.claims) as
Array<{ uid?: string; key?: string; value?: unknown; ts?: number }> | undefined;
let best: { value: string; ts: number } | null = null;
for (const c of Array.isArray(list) ? list : []) {
if (c.uid !== uid || c.key !== key || typeof c.value !== 'string') continue;
const ts = Number(c.ts) || 0;
if (!best || ts > best.ts) best = { value: c.value, ts };
}
return best ? best.value : null;
} }
async getProperty(hashId: string, key: string): Promise<string | null> { async getProperty(hashId: string, key: string): Promise<string | null> {
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/${key}`, { method: 'GET' }); const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/${key}`, { method: 'GET' });
if (!response || response.status === 404) return null; if (!response || !response.ok) return null;
if (!response.ok) return null; const data = await response.json() as { value?: unknown };
const data = await response.json() as { value?: string }; return data.value === undefined || data.value === null ? null : String(data.value);
return data.value ?? null;
} }
async mergeMetadata(hashId: string, metadata: Record<string, string>): Promise<void> { /**
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}`, { * Whole merged record, in whatever form the endpoint serves (meta-sort's
method: 'PATCH', * /meta is nested, meta-core's is flat) — callers flatten. `null` when the
headers: { 'Content-Type': 'application/json' }, * call itself failed, `{}` when the record does not exist.
body: JSON.stringify(metadata), */
}); async getMetadata(hashId: string): Promise<Record<string, unknown> | null> {
// Silently ignore errors (expected in standalone/test mode)
}
async deleteProperty(hashId: string, key: string): Promise<void> {
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/${key}`, { method: 'DELETE' });
// Silently ignore errors (expected in standalone/test mode)
}
async addToSet(hashId: string, key: string, value: string): Promise<void> {
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/_add/${key}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
});
// Silently ignore errors (expected in standalone/test mode)
}
async getMetadata(hashId: string): Promise<Record<string, string>> {
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}`, { method: 'GET' }); const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}`, { method: 'GET' });
if (!response || response.status === 404) return {}; if (!response) return null;
if (!response.ok) return {}; if (response.status === 404) return {};
const data = await response.json() as { metadata?: Record<string, string> }; if (!response.ok) return null;
const data = await response.json() as { metadata?: Record<string, unknown> };
return data.metadata ?? {}; return data.metadata ?? {};
} }
/**
* Compute the CID of a file under FILES_PATH via meta-core's `POST /file/cid`.
* @param filePath - path relative to FILES_PATH (e.g. "poster/Movie (2024)_poster.jpg")
*/
async computeFileCID(filePath: string): Promise<string | null> {
const response = await this.safeFetch(`${this.baseUrl}/file/cid`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: filePath }),
});
if (!response || !response.ok) return null;
const data = await response.json().catch(() => null) as { cid?: string } | null;
return data?.cid ?? null;
}
} }
+15 -7
View File
@@ -20,13 +20,17 @@ import * as http from 'http';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
import { verifyClaim } from '../src/meta-core-client.js';
// Fixed test signing key (scalar 0x0101…01) — the plugin signs as this.
process.env.METAMESH_SIGNING_KEY ??= '01'.repeat(32);
let processFile: typeof import('../src/plugin.js').process; let processFile: typeof import('../src/plugin.js').process;
let configure: typeof import('../src/plugin.js').configure; let configure: typeof import('../src/plugin.js').configure;
/** Properties the stand-in meta-core currently holds, per cid. */ /** Properties the stand-in meta-core currently holds, per cid. */
let record: Record<string, string>; let record: Record<string, string>;
/** Every `PUT /meta/{cid}/{key}` the plugin made, in order. */ /** Every claim the plugin posted (`POST /api/claims`), in order. */
let writes: Array<{ key: string; value: string }>; let writes: Array<{ key: string; value: string }>;
let server: http.Server; let server: http.Server;
@@ -69,7 +73,7 @@ beforeAll(async () => {
// Stand-in meta-core: just enough of the property API for this plugin. // Stand-in meta-core: just enough of the property API for this plugin.
server = http.createServer((req, res) => { server = http.createServer((req, res) => {
const url = new URL(req.url ?? '/', 'http://localhost'); const url = new URL(req.url ?? '/', 'http://localhost');
const parts = url.pathname.split('/').filter(Boolean); // ['meta', cid, key?] const parts = url.pathname.split('/').filter(Boolean); // ['meta', cid, key?] or ['api', 'claims']
const key = parts[2]; const key = parts[2];
if (req.method === 'GET' && key) { if (req.method === 'GET' && key) {
const value = record[key]; const value = record[key];
@@ -80,14 +84,18 @@ beforeAll(async () => {
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ value })); res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ value }));
return; return;
} }
if (req.method === 'PUT' && key) { if (req.method === 'POST' && url.pathname === '/api/claims') {
let body = ''; let body = '';
req.on('data', (c) => (body += c)); req.on('data', (c) => (body += c));
req.on('end', () => { req.on('end', () => {
const value = String(JSON.parse(body || '{}').value ?? ''); const claims = JSON.parse(body || '[]') as Array<{ cid: string; uid: string; key: string; value: string; ts: number; sig: string }>;
writes.push({ key, value }); const statuses = claims.map((c) => {
record[key] = value; if (!verifyClaim({ ...c, sig: Buffer.from(c.sig, 'base64') })) return { status: 'bad_signature' };
res.writeHead(200, { 'Content-Type': 'application/json' }).end('{}'); writes.push({ key: c.key, value: String(c.value) });
record[c.key] = String(c.value);
return { status: 'accepted' };
});
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(statuses));
}); });
return; return;
} }