sign every write as an MMUR2 claim (POST /api/claims)
Build and Push Docker Image / build (push) Successful in 53s
Build and Push Docker Image / build (push) Successful in 53s
This commit is contained in:
+356
-41
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user