cursedbelt-server 4.46.0 → 4.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,42 @@
1
+ /**
2
+ * What binary-server holds under MANY keys — `POST /internal/ops/meta`, one request per
3
+ * {@link BINARY_META_KEYS_MAX} keys (forge task 3385).
4
+ *
5
+ * 🔴 One client, because two hand-rolled copies drifted into being: collections' media-cipher
6
+ * sweep (forge task 2634 — ~880 per-key `/meta` asks a run against its 8/s bulk ceiling, ~2,300
7
+ * refusals an hour) and music's nightly missing-bytes check (forge task 3382) each carried the
8
+ * loop and a copied `1_000`. A change to the route's cap or shape is now one edit here, and
9
+ * binary-server takes its own cap from {@link BINARY_META_KEYS_MAX}, so the two cannot disagree.
10
+ *
11
+ * The route is internal-secret gated and loopback-only (`/internal/ops/*` 404s on the public
12
+ * hostname), so `baseUrl` is binary-server's INTERNAL origin, never the tunnel's.
13
+ */
14
+ /** Keys per `POST /internal/ops/meta` — binary-server's `META_KEYS_MAX` is this constant. */
15
+ export declare const BINARY_META_KEYS_MAX = 1000;
16
+ /** One key's answer: an indexed, active row whose bytes exist. */
17
+ export interface StoredMeta {
18
+ size: number;
19
+ mime: string | null;
20
+ checksum: string | null;
21
+ }
22
+ export interface MetaManyTarget {
23
+ /** binary-server's internal origin (`BINARY_SERVER_INTERNAL_URL`). */
24
+ baseUrl: string | undefined;
25
+ internalSecret: string | undefined;
26
+ /** The tenant the keys belong to. */
27
+ app: string;
28
+ }
29
+ export interface MetaManyOptions {
30
+ fetchImpl?: (url: string, init: RequestInit) => Promise<Response>;
31
+ /** Per batch. Default {@link BINARY_META_BATCH_TIMEOUT_MS}. */
32
+ timeoutMs?: number;
33
+ }
34
+ /**
35
+ * Every asked key → its {@link StoredMeta}, or `null` when the store holds nothing live there.
36
+ *
37
+ * The whole answer is `null` on ANY failure — no origin or secret, a refusal (an older
38
+ * binary-server without the route answers 404; a batch over its cap, 400), a timeout, a body of
39
+ * another shape. Callers then ask key by key, as before: slower, never wrong. A partial map is
40
+ * never returned, so "absent from the map" cannot be mistaken for "not held".
41
+ */
42
+ export declare function metaMany(to: MetaManyTarget, keys: readonly string[], opts?: MetaManyOptions): Promise<Map<string, StoredMeta | null> | null>;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * What binary-server holds under MANY keys — `POST /internal/ops/meta`, one request per
3
+ * {@link BINARY_META_KEYS_MAX} keys (forge task 3385).
4
+ *
5
+ * 🔴 One client, because two hand-rolled copies drifted into being: collections' media-cipher
6
+ * sweep (forge task 2634 — ~880 per-key `/meta` asks a run against its 8/s bulk ceiling, ~2,300
7
+ * refusals an hour) and music's nightly missing-bytes check (forge task 3382) each carried the
8
+ * loop and a copied `1_000`. A change to the route's cap or shape is now one edit here, and
9
+ * binary-server takes its own cap from {@link BINARY_META_KEYS_MAX}, so the two cannot disagree.
10
+ *
11
+ * The route is internal-secret gated and loopback-only (`/internal/ops/*` 404s on the public
12
+ * hostname), so `baseUrl` is binary-server's INTERNAL origin, never the tunnel's.
13
+ */
14
+ /** Keys per `POST /internal/ops/meta` — binary-server's `META_KEYS_MAX` is this constant. */
15
+ export const BINARY_META_KEYS_MAX = 1_000;
16
+ /** How long one batch may take: a batch of keys the store's memo lacks is HEADed eight at a time. */
17
+ const BINARY_META_BATCH_TIMEOUT_MS = 180_000;
18
+ /**
19
+ * Every asked key → its {@link StoredMeta}, or `null` when the store holds nothing live there.
20
+ *
21
+ * The whole answer is `null` on ANY failure — no origin or secret, a refusal (an older
22
+ * binary-server without the route answers 404; a batch over its cap, 400), a timeout, a body of
23
+ * another shape. Callers then ask key by key, as before: slower, never wrong. A partial map is
24
+ * never returned, so "absent from the map" cannot be mistaken for "not held".
25
+ */
26
+ export async function metaMany(to, keys, opts = {}) {
27
+ if (!to.baseUrl || !to.internalSecret)
28
+ return null;
29
+ const doFetch = opts.fetchImpl ?? fetch;
30
+ const url = `${to.baseUrl.replace(/\/+$/, '')}/internal/ops/meta`;
31
+ const out = new Map();
32
+ try {
33
+ for (let i = 0; i < keys.length; i += BINARY_META_KEYS_MAX) {
34
+ const batch = keys.slice(i, i + BINARY_META_KEYS_MAX);
35
+ const res = await doFetch(url, {
36
+ method: 'POST',
37
+ headers: { 'content-type': 'application/json', 'x-internal-secret': to.internalSecret },
38
+ body: JSON.stringify({ app: to.app, keys: batch }),
39
+ signal: AbortSignal.timeout(opts.timeoutMs ?? BINARY_META_BATCH_TIMEOUT_MS),
40
+ });
41
+ if (!res.ok)
42
+ return null;
43
+ const body = (await res.json());
44
+ if (!body?.metas || typeof body.metas !== 'object')
45
+ return null;
46
+ const metas = body.metas;
47
+ for (const key of batch) {
48
+ const m = metas[key];
49
+ out.set(key, m && typeof m.size === 'number' ? { size: m.size, mime: m.mime ?? null, checksum: m.checksum ?? null } : null);
50
+ }
51
+ }
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ return out;
57
+ }
@@ -6,6 +6,8 @@ import type { FileTokenClaims } from './types.js';
6
6
  * this module read unchanged.
7
7
  */
8
8
  export type { FileTokenClaims };
9
+ /** The batched `/internal/ops/meta` client — its own module so the store's graph stays the store's. */
10
+ export { BINARY_META_KEYS_MAX, metaMany, type StoredMeta } from './binaryServerMeta.js';
9
11
  export interface BinaryStoreConfig {
10
12
  /** e.g. `http://127.0.0.1:3099` or `https://binary-server.cursedalchemy.com`. */
11
13
  baseUrl: string;
@@ -41,6 +41,8 @@
41
41
  * behavioural difference in any of the three, so nothing was dropped to land the union.
42
42
  */
43
43
  import { createHash, createPrivateKey, createPublicKey, sign as cryptoSign } from 'node:crypto';
44
+ /** The batched `/internal/ops/meta` client — its own module so the store's graph stays the store's. */
45
+ export { BINARY_META_KEYS_MAX, metaMany } from './binaryServerMeta.js';
44
46
  /*
45
47
  * ── The process-wide repeat register (§ the repeat-key counter) ───────────────
46
48
  *
@@ -417,13 +419,16 @@ export function createBinaryStore(cfg) {
417
419
  * caller asked for `paced` and the budget allows, otherwise thrown as a typed error that
418
420
  * keeps the `[binary-store] <op> <key>: 429` message batch callers already match.
419
421
  */
420
- const sendRead = async (op, key, url, init, opts) => {
422
+ const sendRead = async (op, key,
423
+ /** Signs the URL. Called for EVERY attempt: a read token lives 60 s and a paced read may wait
424
+ * 120 s, so a URL signed once went out dead after the first long retry-after (forge task 3302). */
425
+ mint, init, opts) => {
421
426
  const budget = opts?.paced
422
427
  ? (typeof opts.paced === 'object' ? opts.paced.maxWaitMs : undefined) ?? BINARY_PACED_MAX_WAIT_MS
423
428
  : 0;
424
429
  let waited = 0;
425
430
  for (let attempt = 0;; attempt++) {
426
- const res = await send(url, init);
431
+ const res = await send(await mint(), init);
427
432
  if (res.status !== 429)
428
433
  return res;
429
434
  const retryAfterSec = retryAfterSeconds(res.headers.get('retry-after'), attempt);
@@ -548,7 +553,7 @@ export function createBinaryStore(cfg) {
548
553
  if (!opts?.fresh && knownAbsent(key))
549
554
  return null;
550
555
  countWireRead(key);
551
- const res = await sendRead('get', key, await internalMediaUrl(key), undefined, opts);
556
+ const res = await sendRead('get', key, () => internalMediaUrl(key), undefined, opts);
552
557
  if (res.status === 404) {
553
558
  rememberAbsent(key);
554
559
  return null;
@@ -624,7 +629,7 @@ export function createBinaryStore(cfg) {
624
629
  return null;
625
630
  }
626
631
  countWireRead(key);
627
- const res = await sendRead('meta', key, await metaUrl(key), undefined, opts);
632
+ const res = await sendRead('meta', key, () => metaUrl(key), undefined, opts);
628
633
  if (res.status === 404) {
629
634
  rememberAbsent(key);
630
635
  return null;
@@ -671,7 +676,7 @@ export function createBinaryStore(cfg) {
671
676
  throw why;
672
677
  }
673
678
  countWireRead(key);
674
- const res = await sendRead('stat', key, await internalMediaUrl(key), { headers: { Range: 'bytes=0-0' } }, opts);
679
+ const res = await sendRead('stat', key, () => internalMediaUrl(key), { headers: { Range: 'bytes=0-0' } }, opts);
675
680
  if (res.status === 404) {
676
681
  rememberAbsent(key);
677
682
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.46.0",
3
+ "version": "4.47.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
@@ -144,8 +144,10 @@ const LEAVES = [
144
144
  * in `src/server/storage/` beside the file-catalogue and transcode graph, which is
145
145
  * exactly the barrel this must never be reached through: `./storage`'s index is heavy
146
146
  * and this module's whole point is that it costs `node:crypto` and nothing else.
147
+ * Its batched `/internal/ops/meta` client (4.47.0) is a sibling that imports nothing.
147
148
  */
148
149
  evaluates: 'createBinaryStore',
150
+ siblings: ['src/server/storage/binaryServerMeta.ts'],
149
151
  },
150
152
  {
151
153
  subpath: './binary-store/testing',
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { BINARY_META_KEYS_MAX, metaMany } from './binaryStore.js';
3
+
4
+ type Call = { url: string; init: RequestInit };
5
+ const target = { baseUrl: 'http://127.0.0.1:3099/', internalSecret: 's3cret', app: 'music' };
6
+
7
+ /** A store that holds every key whose name starts with `held/`, answering in the route's shape. */
8
+ function store(calls: Call[], answer?: (keys: string[]) => Response) {
9
+ return async (url: string, init: RequestInit) => {
10
+ calls.push({ url, init });
11
+ const { keys } = JSON.parse(String(init.body)) as { keys: string[] };
12
+ if (answer) return answer(keys);
13
+ const metas = Object.fromEntries(keys.map((k) => [k, k.startsWith('held/') ? { size: 7, mime: 'audio/mp4', checksum: 'abc' } : null]));
14
+ return Response.json({ app: 'music', metas });
15
+ };
16
+ }
17
+
18
+ describe('metaMany', () => {
19
+ it('asks one request per BINARY_META_KEYS_MAX keys, and answers every key', async () => {
20
+ const calls: Call[] = [];
21
+ const keys = Array.from({ length: BINARY_META_KEYS_MAX * 2 + 1 }, (_, i) => (i % 2 ? `held/${i}` : `gone/${i}`));
22
+ const got = await metaMany(target, keys, { fetchImpl: store(calls) });
23
+ expect(calls.map((c) => (JSON.parse(String(c.init.body)) as { keys: string[] }).keys.length)).toEqual([BINARY_META_KEYS_MAX, BINARY_META_KEYS_MAX, 1]);
24
+ expect(calls[0]?.url).toBe('http://127.0.0.1:3099/internal/ops/meta');
25
+ expect(calls[0]?.init.headers).toMatchObject({ 'x-internal-secret': 's3cret' });
26
+ expect(got?.size).toBe(keys.length);
27
+ expect(got?.get('held/1')).toEqual({ size: 7, mime: 'audio/mp4', checksum: 'abc' });
28
+ expect(got?.get('gone/0')).toBeNull();
29
+ });
30
+
31
+ it('is null — never a partial map — when any batch fails', async () => {
32
+ let n = 0;
33
+ const keys = Array.from({ length: BINARY_META_KEYS_MAX + 1 }, (_, i) => `held/${i}`);
34
+ const second404 = store([], (asked) => (n++ === 0 ? Response.json({ metas: Object.fromEntries(asked.map((k) => [k, null])) }) : new Response('nope', { status: 404 })));
35
+ expect(await metaMany(target, keys, { fetchImpl: second404 })).toBeNull();
36
+ });
37
+
38
+ it('is null on a body of another shape, a throw, or no origin/secret — and asks nothing then', async () => {
39
+ expect(await metaMany(target, ['k'], { fetchImpl: store([], () => Response.json({ ok: true })) })).toBeNull();
40
+ expect(await metaMany(target, ['k'], { fetchImpl: async () => { throw new Error('ECONNREFUSED'); } })).toBeNull();
41
+ const calls: Call[] = [];
42
+ expect(await metaMany({ ...target, internalSecret: undefined }, ['k'], { fetchImpl: store(calls) })).toBeNull();
43
+ expect(await metaMany({ ...target, baseUrl: '' }, ['k'], { fetchImpl: store(calls) })).toBeNull();
44
+ expect(calls).toHaveLength(0);
45
+ });
46
+
47
+ it('reads a key the store left out of its answer as not held', async () => {
48
+ const got = await metaMany(target, ['held/a', 'held/b'], { fetchImpl: store([], () => Response.json({ metas: { 'held/a': { size: 1 } } })) });
49
+ expect(got?.get('held/a')).toEqual({ size: 1, mime: null, checksum: null });
50
+ expect(got?.get('held/b')).toBeNull();
51
+ });
52
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * What binary-server holds under MANY keys — `POST /internal/ops/meta`, one request per
3
+ * {@link BINARY_META_KEYS_MAX} keys (forge task 3385).
4
+ *
5
+ * 🔴 One client, because two hand-rolled copies drifted into being: collections' media-cipher
6
+ * sweep (forge task 2634 — ~880 per-key `/meta` asks a run against its 8/s bulk ceiling, ~2,300
7
+ * refusals an hour) and music's nightly missing-bytes check (forge task 3382) each carried the
8
+ * loop and a copied `1_000`. A change to the route's cap or shape is now one edit here, and
9
+ * binary-server takes its own cap from {@link BINARY_META_KEYS_MAX}, so the two cannot disagree.
10
+ *
11
+ * The route is internal-secret gated and loopback-only (`/internal/ops/*` 404s on the public
12
+ * hostname), so `baseUrl` is binary-server's INTERNAL origin, never the tunnel's.
13
+ */
14
+
15
+ /** Keys per `POST /internal/ops/meta` — binary-server's `META_KEYS_MAX` is this constant. */
16
+ export const BINARY_META_KEYS_MAX = 1_000;
17
+
18
+ /** How long one batch may take: a batch of keys the store's memo lacks is HEADed eight at a time. */
19
+ const BINARY_META_BATCH_TIMEOUT_MS = 180_000;
20
+
21
+ /** One key's answer: an indexed, active row whose bytes exist. */
22
+ export interface StoredMeta {
23
+ size: number;
24
+ mime: string | null;
25
+ checksum: string | null;
26
+ }
27
+
28
+ export interface MetaManyTarget {
29
+ /** binary-server's internal origin (`BINARY_SERVER_INTERNAL_URL`). */
30
+ baseUrl: string | undefined;
31
+ internalSecret: string | undefined;
32
+ /** The tenant the keys belong to. */
33
+ app: string;
34
+ }
35
+
36
+ export interface MetaManyOptions {
37
+ fetchImpl?: (url: string, init: RequestInit) => Promise<Response>;
38
+ /** Per batch. Default {@link BINARY_META_BATCH_TIMEOUT_MS}. */
39
+ timeoutMs?: number;
40
+ }
41
+
42
+ /**
43
+ * Every asked key → its {@link StoredMeta}, or `null` when the store holds nothing live there.
44
+ *
45
+ * The whole answer is `null` on ANY failure — no origin or secret, a refusal (an older
46
+ * binary-server without the route answers 404; a batch over its cap, 400), a timeout, a body of
47
+ * another shape. Callers then ask key by key, as before: slower, never wrong. A partial map is
48
+ * never returned, so "absent from the map" cannot be mistaken for "not held".
49
+ */
50
+ export async function metaMany(
51
+ to: MetaManyTarget,
52
+ keys: readonly string[],
53
+ opts: MetaManyOptions = {},
54
+ ): Promise<Map<string, StoredMeta | null> | null> {
55
+ if (!to.baseUrl || !to.internalSecret) return null;
56
+ const doFetch = opts.fetchImpl ?? fetch;
57
+ const url = `${to.baseUrl.replace(/\/+$/, '')}/internal/ops/meta`;
58
+ const out = new Map<string, StoredMeta | null>();
59
+ try {
60
+ for (let i = 0; i < keys.length; i += BINARY_META_KEYS_MAX) {
61
+ const batch = keys.slice(i, i + BINARY_META_KEYS_MAX);
62
+ const res = await doFetch(url, {
63
+ method: 'POST',
64
+ headers: { 'content-type': 'application/json', 'x-internal-secret': to.internalSecret },
65
+ body: JSON.stringify({ app: to.app, keys: batch }),
66
+ signal: AbortSignal.timeout(opts.timeoutMs ?? BINARY_META_BATCH_TIMEOUT_MS),
67
+ });
68
+ if (!res.ok) return null;
69
+ const body = (await res.json()) as { metas?: unknown } | null;
70
+ if (!body?.metas || typeof body.metas !== 'object') return null;
71
+ const metas = body.metas as Record<string, Partial<StoredMeta> | null | undefined>;
72
+ for (const key of batch) {
73
+ const m = metas[key];
74
+ out.set(key, m && typeof m.size === 'number' ? { size: m.size, mime: m.mime ?? null, checksum: m.checksum ?? null } : null);
75
+ }
76
+ }
77
+ } catch {
78
+ return null;
79
+ }
80
+ return out;
81
+ }
@@ -26,7 +26,7 @@
26
26
  * server; the RSA keypair is generated in-process and the signature is verified with
27
27
  * `node:crypto`, which is what makes the token assertions real rather than shape-deep.
28
28
  */
29
- import { afterAll, afterEach, beforeEach, describe, expect, it } from 'bun:test';
29
+ import { afterAll, afterEach, beforeEach, describe, expect, it, setSystemTime } from 'bun:test';
30
30
  import { generateKeyPairSync, verify as cryptoVerify } from 'node:crypto';
31
31
  import {
32
32
  AUTO_CHUNK_THRESHOLD_BYTES,
@@ -1056,6 +1056,29 @@ describe('🔴 a read the store PACES is waited out when asked, typed when not (
1056
1056
  expect(calls).toHaveLength(4);
1057
1057
  });
1058
1058
 
1059
+ it('🔴 every attempt goes with a token signed for IT — a 60 s token outlives no 120 s wait (forge task 3302)', async () => {
1060
+ // The URL was signed once, before the first attempt, so a paced read that waited out 30 s
1061
+ // refusals sent a dead token on its third try, and binary-server answered 403 — not 429.
1062
+ try {
1063
+ let n = 0;
1064
+ reply = () => (n++ < 3 ? limited('30') : ok(new Uint8Array([7, 8])));
1065
+ const store = makeStore({
1066
+ sleep: (ms) => {
1067
+ setSystemTime(new Date(Date.now() + ms));
1068
+ return Promise.resolve();
1069
+ },
1070
+ });
1071
+ await store.get('parts/0', { paced: true });
1072
+ expect(calls).toHaveLength(4);
1073
+ // The last attempt went 90 s after the first: its token must still have time left.
1074
+ const token = new URL(String(calls.at(-1)?.url)).searchParams.get('token') ?? '';
1075
+ const { exp } = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString('utf8')) as { exp: number };
1076
+ expect(exp * 1000).toBeGreaterThan(Date.now());
1077
+ } finally {
1078
+ setSystemTime();
1079
+ }
1080
+ });
1081
+
1059
1082
  it('without paced, a 429 is the typed error carrying retryAfterSec — and the old message', async () => {
1060
1083
  const { store, slept } = pacedStore(1, '7');
1061
1084
  const error = await store.get('parts/0').catch((e: unknown) => e);
@@ -51,6 +51,9 @@ import type { FileTokenClaims } from './types.js';
51
51
  */
52
52
  export type { FileTokenClaims };
53
53
 
54
+ /** The batched `/internal/ops/meta` client — its own module so the store's graph stays the store's. */
55
+ export { BINARY_META_KEYS_MAX, metaMany, type StoredMeta } from './binaryServerMeta.js';
56
+
54
57
  export interface BinaryStoreConfig {
55
58
  /** e.g. `http://127.0.0.1:3099` or `https://binary-server.cursedalchemy.com`. */
56
59
  baseUrl: string;
@@ -728,7 +731,9 @@ export function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore {
728
731
  const sendRead = async (
729
732
  op: string,
730
733
  key: string,
731
- url: string,
734
+ /** Signs the URL. Called for EVERY attempt: a read token lives 60 s and a paced read may wait
735
+ * 120 s, so a URL signed once went out dead after the first long retry-after (forge task 3302). */
736
+ mint: () => Promise<string>,
732
737
  init: RequestInit | undefined,
733
738
  opts: ReadOptions | undefined,
734
739
  ): Promise<Response> => {
@@ -737,7 +742,7 @@ export function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore {
737
742
  : 0;
738
743
  let waited = 0;
739
744
  for (let attempt = 0; ; attempt++) {
740
- const res = await send(url, init);
745
+ const res = await send(await mint(), init);
741
746
  if (res.status !== 429) return res;
742
747
  const retryAfterSec = retryAfterSeconds(res.headers.get('retry-after'), attempt);
743
748
  const ms = retryAfterSec * 1000;
@@ -875,7 +880,7 @@ export function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore {
875
880
  // short-circuit here — we want the bytes, and only the store has them.
876
881
  if (!opts?.fresh && knownAbsent(key)) return null;
877
882
  countWireRead(key);
878
- const res = await sendRead('get', key, await internalMediaUrl(key), undefined, opts);
883
+ const res = await sendRead('get', key, () => internalMediaUrl(key), undefined, opts);
879
884
  if (res.status === 404) {
880
885
  rememberAbsent(key);
881
886
  return null;
@@ -952,7 +957,7 @@ export function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore {
952
957
  if (knownAbsent(key)) return null;
953
958
  }
954
959
  countWireRead(key);
955
- const res = await sendRead('meta', key, await metaUrl(key), undefined, opts);
960
+ const res = await sendRead('meta', key, () => metaUrl(key), undefined, opts);
956
961
  if (res.status === 404) {
957
962
  rememberAbsent(key);
958
963
  return null;
@@ -995,7 +1000,7 @@ export function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore {
995
1000
  if (why instanceof BinaryStoreRateLimitedError) throw why;
996
1001
  }
997
1002
  countWireRead(key);
998
- const res = await sendRead('stat', key, await internalMediaUrl(key), { headers: { Range: 'bytes=0-0' } }, opts);
1003
+ const res = await sendRead('stat', key, () => internalMediaUrl(key), { headers: { Range: 'bytes=0-0' } }, opts);
999
1004
  if (res.status === 404) {
1000
1005
  rememberAbsent(key);
1001
1006
  return null;