cursedbelt-server 4.33.1 → 4.35.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,16 @@
1
+ import { ChunkStore } from 'cursedbelt-core/ctgr/chunk-store';
2
+ import type { Hono } from 'hono';
3
+ import type { CursedbeltEnv } from './context.js';
4
+ export interface CtgrTunnelOptions {
5
+ /**
6
+ * The accumulator for multi-chunk payloads. Share ONE across an app (the default is
7
+ * a fresh singleton); pass your own to tune the DoS caps ({@link ChunkStore}).
8
+ */
9
+ store?: ChunkStore;
10
+ }
11
+ /**
12
+ * Mount the GET-only tunnel on `app`. Call once, on the Hono app whose real routes sit
13
+ * at the paths the twins mirror (a twin path is `/ctgr<xx>` + the real path). Returns
14
+ * the {@link ChunkStore} in use, for tests and metrics.
15
+ */
16
+ export declare function mountCtgrTunnel<E extends CursedbeltEnv = CursedbeltEnv>(app: Hono<E>, options?: CtgrTunnelOptions): ChunkStore;
@@ -0,0 +1,91 @@
1
+ /*
2
+ * The one-line server adapter for the GET-only tunnel: `mountCtgrTunnel(app)`.
3
+ *
4
+ * Where {@link unGETify} + {@link buildRoutes} ask each route to register a twin and
5
+ * read its body from `c.get('ctgrDecoded')`, this mounts a single middleware that
6
+ * DECODES a `/ctgr<xx>` GET twin and RE-DISPATCHES it into the same app at the real
7
+ * method + path — so every existing `app.post(...)` / `app.delete(...)` handler is
8
+ * reached unchanged, reading `await c.req.json()` / `c.req.formData()` as it always
9
+ * did. That is what makes the tunnel droppable onto any Hono app in one call.
10
+ *
11
+ * A single-chunk twin decodes inline; a multi-chunk payload accumulates in the shared
12
+ * {@link ChunkStore} (an intermediate chunk answers 202). Legacy `_cv`-absent twins
13
+ * fall through the v0 decoder. The middleware decodes and re-dispatches only; whatever
14
+ * gate stands in front of the app (Access, a session cookie, a getOnly claim) is
15
+ * unchanged and still runs on the real handler.
16
+ */
17
+ import { ChunkStore } from 'cursedbelt-core/ctgr/chunk-store';
18
+ import { ABBREV_TO_METHOD, } from 'cursedbelt-core/ctgr/types';
19
+ import { V0ChunkStore } from 'cursedbelt-core/ctgr/v0compat';
20
+ import { CTGR_ROUTE_PATTERN } from './ctgr.js';
21
+ /** Query keys the codec adds to a twin URL — dropped when rebuilding the real request. */
22
+ const isCtgrParam = (key) => key === '_d' || key === '_sha' || key.startsWith('_c');
23
+ /**
24
+ * Mount the GET-only tunnel on `app`. Call once, on the Hono app whose real routes sit
25
+ * at the paths the twins mirror (a twin path is `/ctgr<xx>` + the real path). Returns
26
+ * the {@link ChunkStore} in use, for tests and metrics.
27
+ */
28
+ export function mountCtgrTunnel(app, options = {}) {
29
+ const store = options.store ?? new ChunkStore();
30
+ const legacy = new V0ChunkStore();
31
+ app.use(CTGR_ROUTE_PATTERN, async (c) => {
32
+ const url = new URL(c.req.url);
33
+ const params = Object.fromEntries(url.searchParams);
34
+ // The twin path is `/ctgr<xx>...`; the abbreviation is the two chars after `ctgr`.
35
+ const pathMethod = ABBREV_TO_METHOD[c.req.path.slice(5, 7)];
36
+ if (!pathMethod) {
37
+ return c.json({ error: 'not found', code: 'NOT_FOUND' }, 404);
38
+ }
39
+ const result = params._cv === undefined ? legacy.add(params, pathMethod) : await store.add(params);
40
+ if (result === false)
41
+ return c.json({ acknowledged: true }, 202);
42
+ if (result instanceof Error) {
43
+ return c.json({ error: result.message, code: 'BAD_REQUEST' }, 400);
44
+ }
45
+ const realReq = rebuildRequest(c.req.raw, c.req.path, url, result);
46
+ // Re-enter the app at the real route. The real path never starts with `/ctgr`, so
47
+ // this middleware does not re-match — no recursion.
48
+ let executionCtx;
49
+ try {
50
+ executionCtx = c.executionCtx;
51
+ }
52
+ catch {
53
+ executionCtx = undefined;
54
+ }
55
+ return app.fetch(realReq, c.env, executionCtx);
56
+ });
57
+ return store;
58
+ }
59
+ /** Build the real Request the decoded twin stands in for. */
60
+ function rebuildRequest(raw, twinPath, url, decoded) {
61
+ // Strip the `/ctgr<xx>` prefix and the codec's own query params; keep the rest.
62
+ const realPath = twinPath.replace(/^\/ctgr../, '') || '/';
63
+ const kept = new URLSearchParams();
64
+ for (const [key, value] of url.searchParams) {
65
+ if (!isCtgrParam(key))
66
+ kept.set(key, value);
67
+ }
68
+ const search = kept.toString();
69
+ const realUrl = `${url.origin}${realPath}${search ? `?${search}` : ''}`;
70
+ const headers = new Headers(raw.headers);
71
+ headers.delete('content-length');
72
+ let body;
73
+ if (decoded.files && decoded.files.length > 0) {
74
+ const form = new FormData();
75
+ const fields = (decoded.body ?? {});
76
+ for (const [key, value] of Object.entries(fields)) {
77
+ form.append(key, typeof value === 'string' ? value : JSON.stringify(value));
78
+ }
79
+ for (const file of decoded.files) {
80
+ form.append(file.fieldname, new File([new Uint8Array(file.buffer)], file.originalname, { type: file.mimetype }));
81
+ }
82
+ body = form;
83
+ // Let the runtime set the multipart content-type + boundary.
84
+ headers.delete('content-type');
85
+ }
86
+ else if (decoded.body !== undefined) {
87
+ body = JSON.stringify(decoded.body);
88
+ headers.set('content-type', 'application/json');
89
+ }
90
+ return new Request(realUrl, { method: decoded.method, headers, body });
91
+ }
@@ -3,6 +3,7 @@ export { type CreateAppOpts, createApp, type ServeBunOpts, serveBun } from './ap
3
3
  export { type AppJwtPayload, AUTH_COOKIE_NAME, type AuthEnv, type AuthUser, type AuthVariables, type CreateSessionInput, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, type CsrfOptions, clearAuthCookie, createAuthMiddleware, createCsrfMiddleware, clientKeyOf, clientKeyOfContext, createLoginThrottle, createSessionStore, createRs256FileTokenSigner, DEFAULT_TOTP_ISSUER, decryptVaultItem, deriveVaultKey, encryptVaultItem, generateRecoveryCodes, generateTotpSecret, getOnlyEnforcement, getTotpUri, hashPassword, isGetOnlyViolation, type LoginThrottle, type LoginThrottleOptions, type ThrottleVerdict, needsRehash, type Rs256FileTokenSignerOptions, type Session, type SessionStore, type SessionStoreOptions, signToken, type TokenPurpose, verifyPassword, verifyPurposeToken, verifyRecoveryCode, verifyToken, verifyTotpCode, } from './auth/index.js';
4
4
  export type { CursedbeltEnv, CursedbeltVariables } from './context.js';
5
5
  export { buildRoutes, CTGR_ROUTE_PATTERN, getCtgrDecoded, unGETify } from './ctgr.js';
6
+ export { type CtgrTunnelOptions, mountCtgrTunnel } from './ctgrTunnel.js';
6
7
  export { createKysely, type PlumbingDb } from './db/kysely.js';
7
8
  export type { AppConfigRow, AuthSessionRow, EmailFailureRow, EventLogRow, JobRunRow, JobStateRow, MigrationRow, PlumbingSchema, RequestMetricRow, SystemSampleRow, } from './db/plumbingTypes.js';
8
9
  export { ApiError, type ApiErrorOptions, type ErrorEnvelope, isApiError, toErrorEnvelope, toValidationIssues, VALIDATION_ERROR_CODE, type ValidationIssue, validationEnvelope, } from './errors.js';
@@ -12,6 +12,7 @@ export { createApp, serveBun } from './app.js';
12
12
  // login throttle, argon2id passwords, TOTP, vault crypto, the composed middleware.
13
13
  export { AUTH_COOKIE_NAME, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, clearAuthCookie, createAuthMiddleware, createCsrfMiddleware, clientKeyOf, clientKeyOfContext, createLoginThrottle, createSessionStore, createRs256FileTokenSigner, DEFAULT_TOTP_ISSUER, decryptVaultItem, deriveVaultKey, encryptVaultItem, generateRecoveryCodes, generateTotpSecret, getOnlyEnforcement, getTotpUri, hashPassword, isGetOnlyViolation, needsRehash, signToken, verifyPassword, verifyPurposeToken, verifyRecoveryCode, verifyToken, verifyTotpCode, } from './auth/index.js';
14
14
  export { buildRoutes, CTGR_ROUTE_PATTERN, getCtgrDecoded, unGETify } from './ctgr.js';
15
+ export { mountCtgrTunnel } from './ctgrTunnel.js';
15
16
  // ── Kysely-typed plumbing tables ────────────────────────────────────────────
16
17
  export { createKysely } from './db/kysely.js';
17
18
  // ── Errors ──────────────────────────────────────────────────────────────────
@@ -405,6 +405,29 @@ export declare function binaryStoreTenant(appId: string, env?: Record<string, st
405
405
  * apps and that is precisely why the difference goes unnoticed when it is not.
406
406
  */
407
407
  export declare function resolvedMediaTenant(appId: string, env?: Record<string, string | undefined>): string | null;
408
+ /**
409
+ * binary-server's origin did not ANSWER a write — as opposed to answering "no".
410
+ *
411
+ * binary-server itself never answers 502, 503 or 504; those, and Cloudflare's 52x family (530 is a
412
+ * tunnel with no connector), are the edge speaking for an origin it could not reach — the owner's
413
+ * Mac off, asleep, or restarting. A thrown `fetch` is the same fact. Since 2026-09-26 the edge
414
+ * Worker takes an R2-backed tenant's whole upload itself in that case
415
+ * (`apps/binary-server/edge-worker/src/upload.ts`), so for desk and family this is what is left
416
+ * when BOTH halves failed; for a tenant whose bytes rest on the Mac it is simply "the Mac is off".
417
+ */
418
+ export declare function storeUnreachableStatus(status: number): boolean;
419
+ /**
420
+ * The write could not reach the store. Carries `getResponse()`, so a Hono app that lets it escape
421
+ * a route answers a 503 that names the file store — Hono's default error handler returns
422
+ * `err.getResponse()` for any error that has one — instead of a bare `500 Internal Server Error`.
423
+ * The message keeps the `[binary-store] <op> <key>:` head every batch caller already prints.
424
+ */
425
+ export declare class BinaryStoreUnreachableError extends Error {
426
+ readonly status = 503;
427
+ readonly code = "BYTE_STORE_UNREACHABLE";
428
+ constructor(op: string, key: string, detail: string);
429
+ getResponse(): Response;
430
+ }
408
431
  /**
409
432
  * The standard env surface every tenant's secrets file carries
410
433
  * (`$FORGE_STATE/secrets/<app>.env`, written at bs registration):
@@ -430,7 +430,11 @@ export function createBinaryStore(cfg) {
430
430
  method: 'PUT',
431
431
  headers: mime ? { 'content-type': mime } : undefined,
432
432
  body: bytes,
433
+ }).catch((cause) => {
434
+ throw new BinaryStoreUnreachableError('put', key, cause instanceof Error ? cause.message : String(cause));
433
435
  });
436
+ if (storeUnreachableStatus(res.status))
437
+ throw new BinaryStoreUnreachableError('put', key, String(res.status));
434
438
  if (!res.ok) {
435
439
  // A 413 that reaches here despite the threshold above means the edge's
436
440
  // real cap is lower than we think, or the caller disabled the
@@ -499,7 +503,12 @@ export function createBinaryStore(cfg) {
499
503
  method: 'PUT',
500
504
  headers: mime ? { 'content-type': mime } : undefined,
501
505
  body: slice,
506
+ }).catch((cause) => {
507
+ throw new BinaryStoreUnreachableError('putLarge', key, cause instanceof Error ? cause.message : String(cause));
502
508
  });
509
+ if (storeUnreachableStatus(res.status)) {
510
+ throw new BinaryStoreUnreachableError('putLarge', key, `chunk ${ci + 1}/${total}: ${res.status}`);
511
+ }
503
512
  if (!res.ok) {
504
513
  throw new Error(`[binary-store] putLarge ${key} chunk ${ci + 1}/${total}: ${res.status}`);
505
514
  }
@@ -752,6 +761,40 @@ export function binaryStoreTenant(appId, env = process.env) {
752
761
  export function resolvedMediaTenant(appId, env = process.env) {
753
762
  return readBinaryStoreEnv(appId, env)?.appId ?? null;
754
763
  }
764
+ /**
765
+ * binary-server's origin did not ANSWER a write — as opposed to answering "no".
766
+ *
767
+ * binary-server itself never answers 502, 503 or 504; those, and Cloudflare's 52x family (530 is a
768
+ * tunnel with no connector), are the edge speaking for an origin it could not reach — the owner's
769
+ * Mac off, asleep, or restarting. A thrown `fetch` is the same fact. Since 2026-09-26 the edge
770
+ * Worker takes an R2-backed tenant's whole upload itself in that case
771
+ * (`apps/binary-server/edge-worker/src/upload.ts`), so for desk and family this is what is left
772
+ * when BOTH halves failed; for a tenant whose bytes rest on the Mac it is simply "the Mac is off".
773
+ */
774
+ export function storeUnreachableStatus(status) {
775
+ return status === 502 || status === 503 || status === 504 || (status >= 520 && status <= 530);
776
+ }
777
+ /**
778
+ * The write could not reach the store. Carries `getResponse()`, so a Hono app that lets it escape
779
+ * a route answers a 503 that names the file store — Hono's default error handler returns
780
+ * `err.getResponse()` for any error that has one — instead of a bare `500 Internal Server Error`.
781
+ * The message keeps the `[binary-store] <op> <key>:` head every batch caller already prints.
782
+ */
783
+ export class BinaryStoreUnreachableError extends Error {
784
+ status = 503;
785
+ code = 'BYTE_STORE_UNREACHABLE';
786
+ constructor(op, key, detail) {
787
+ super(`[binary-store] ${op} ${key}: the file store did not answer (${detail}) — nothing was saved; ` +
788
+ 'everything already stored still opens. Try again when the home server is on.');
789
+ this.name = 'BinaryStoreUnreachableError';
790
+ }
791
+ getResponse() {
792
+ return new Response(JSON.stringify({
793
+ error: this.code,
794
+ message: 'The file store did not answer, so nothing was saved. Everything already stored still opens — try again in a moment.',
795
+ }), { status: 503, headers: { 'content-type': 'application/json; charset=utf-8', 'retry-after': '30' } });
796
+ }
797
+ }
755
798
  /**
756
799
  * The standard env surface every tenant's secrets file carries
757
800
  * (`$FORGE_STATE/secrets/<app>.env`, written at bs registration):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.33.1",
3
+ "version": "4.35.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.",
@@ -0,0 +1,110 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { encodeRequest } from 'cursedbelt-core/ctgr';
3
+ import { Hono } from 'hono';
4
+ import type { CursedbeltEnv } from './context.js';
5
+ import { mountCtgrTunnel } from './ctgrTunnel.js';
6
+
7
+ const ORIGIN = 'http://localhost';
8
+
9
+ /**
10
+ * An ordinary Hono app with ordinary handlers — NONE of them ctgr-aware. The tunnel
11
+ * is dropped on with one call; the handlers read `c.req.json()` / `c.req.formData()`
12
+ * exactly as they would for a real POST.
13
+ */
14
+ function makeApp() {
15
+ const app = new Hono<CursedbeltEnv>();
16
+ const store = mountCtgrTunnel(app, { store: undefined });
17
+
18
+ app.post('/api/v1/notes', async (c) => {
19
+ const body = await c.req.json();
20
+ return c.json({ ok: true, method: c.req.method, body });
21
+ });
22
+ app.patch('/api/v1/notes/:id', async (c) => {
23
+ const body = await c.req.json();
24
+ return c.json({ ok: true, id: c.req.param('id'), method: c.req.method, body });
25
+ });
26
+ app.delete('/api/v1/notes/:id', (c) =>
27
+ c.json({ ok: true, id: c.req.param('id'), method: c.req.method, permanent: c.req.query('permanent') ?? null }),
28
+ );
29
+ app.post('/api/v1/media', async (c) => {
30
+ const form = await c.req.formData();
31
+ const file = form.get('file') as File;
32
+ return c.json({
33
+ ok: true,
34
+ name: form.get('name'),
35
+ file: { name: file.name, type: file.type, size: file.size },
36
+ });
37
+ });
38
+ app.get('/api/v1/notes', (c) => c.json({ list: true }));
39
+
40
+ return { app, store };
41
+ }
42
+
43
+ const fetchTwin = (app: Hono<CursedbeltEnv>, url: string) =>
44
+ app.fetch(new Request(ORIGIN + url));
45
+
46
+ describe('mountCtgrTunnel — re-dispatch, zero handler changes', () => {
47
+ test('a single-chunk POST twin reaches the real handler with the decoded body', async () => {
48
+ const { app } = makeApp();
49
+ const { chunks } = await encodeRequest('POST', '/api/v1/notes', { title: 'hi', n: 1 });
50
+ const res = await fetchTwin(app, chunks[0].url);
51
+ expect(res.status).toBe(200);
52
+ expect(await res.json()).toEqual({ ok: true, method: 'POST', body: { title: 'hi', n: 1 } });
53
+ });
54
+
55
+ test('a PATCH twin routes with its path param intact', async () => {
56
+ const { app } = makeApp();
57
+ const { chunks } = await encodeRequest('PATCH', '/api/v1/notes/abc', { done: true });
58
+ const res = await fetchTwin(app, chunks[0].url);
59
+ expect(await res.json()).toEqual({ ok: true, id: 'abc', method: 'PATCH', body: { done: true } });
60
+ });
61
+
62
+ test('a DELETE twin preserves the app query string and drops the codec params', async () => {
63
+ const { app } = makeApp();
64
+ const { chunks } = await encodeRequest('DELETE', '/api/v1/notes/z?permanent=1', undefined);
65
+ const res = await fetchTwin(app, chunks[0].url);
66
+ expect(await res.json()).toEqual({ ok: true, id: 'z', method: 'DELETE', permanent: '1' });
67
+ });
68
+
69
+ test('a FormData upload rebuilds as multipart the handler reads unchanged', async () => {
70
+ const { app } = makeApp();
71
+ // Encode the __files body shape the client converter produces.
72
+ const body = {
73
+ __files: [{ field: 'file', name: 'p.png', type: 'image/png', dataBase64: btoa('abc') }],
74
+ name: 'pic',
75
+ };
76
+ const { chunks } = await encodeRequest('POST', '/api/v1/media', body);
77
+ const res = await fetchTwin(app, chunks[0].url);
78
+ expect(await res.json()).toEqual({
79
+ ok: true,
80
+ name: 'pic',
81
+ file: { name: 'p.png', type: 'image/png', size: 3 },
82
+ });
83
+ });
84
+
85
+ test('a multi-chunk payload answers 202 until the last chunk, then runs once', async () => {
86
+ const { app } = makeApp();
87
+ // Incompressible-ish and gzip disabled, so it really splits into many chunks.
88
+ const big = { blob: Array.from({ length: 4000 }, (_, i) => String.fromCharCode(33 + (i % 90))).join('') };
89
+ const { chunks } = await encodeRequest('POST', '/api/v1/notes', big, {
90
+ chunkSizeBytes: 800,
91
+ gzipThresholdBytes: 10 * 1024 * 1024,
92
+ });
93
+ expect(chunks.length).toBeGreaterThan(1);
94
+ for (const chunk of chunks.slice(0, -1)) {
95
+ const res = await fetchTwin(app, chunk.url);
96
+ expect(res.status).toBe(202);
97
+ }
98
+ const final = await fetchTwin(app, chunks[chunks.length - 1].url);
99
+ expect(final.status).toBe(200);
100
+ expect(await final.json()).toEqual({ ok: true, method: 'POST', body: big });
101
+ });
102
+
103
+ test('an unknown method twin is a 404, and a real GET route is untouched', async () => {
104
+ const { app } = makeApp();
105
+ const bad = await fetchTwin(app, '/ctgrzz/api/v1/notes?_cv=1');
106
+ expect(bad.status).toBe(404);
107
+ const get = await fetchTwin(app, '/api/v1/notes');
108
+ expect(await get.json()).toEqual({ list: true });
109
+ });
110
+ });
@@ -0,0 +1,120 @@
1
+ /*
2
+ * The one-line server adapter for the GET-only tunnel: `mountCtgrTunnel(app)`.
3
+ *
4
+ * Where {@link unGETify} + {@link buildRoutes} ask each route to register a twin and
5
+ * read its body from `c.get('ctgrDecoded')`, this mounts a single middleware that
6
+ * DECODES a `/ctgr<xx>` GET twin and RE-DISPATCHES it into the same app at the real
7
+ * method + path — so every existing `app.post(...)` / `app.delete(...)` handler is
8
+ * reached unchanged, reading `await c.req.json()` / `c.req.formData()` as it always
9
+ * did. That is what makes the tunnel droppable onto any Hono app in one call.
10
+ *
11
+ * A single-chunk twin decodes inline; a multi-chunk payload accumulates in the shared
12
+ * {@link ChunkStore} (an intermediate chunk answers 202). Legacy `_cv`-absent twins
13
+ * fall through the v0 decoder. The middleware decodes and re-dispatches only; whatever
14
+ * gate stands in front of the app (Access, a session cookie, a getOnly claim) is
15
+ * unchanged and still runs on the real handler.
16
+ */
17
+ import { ChunkStore } from 'cursedbelt-core/ctgr/chunk-store';
18
+ import {
19
+ ABBREV_TO_METHOD,
20
+ type CtgrAbbrev,
21
+ type CtgrDecoded,
22
+ } from 'cursedbelt-core/ctgr/types';
23
+ import { V0ChunkStore } from 'cursedbelt-core/ctgr/v0compat';
24
+ import type { ExecutionContext, Hono } from 'hono';
25
+ import type { CursedbeltEnv } from './context.js';
26
+ import { CTGR_ROUTE_PATTERN } from './ctgr.js';
27
+
28
+ /** Query keys the codec adds to a twin URL — dropped when rebuilding the real request. */
29
+ const isCtgrParam = (key: string): boolean =>
30
+ key === '_d' || key === '_sha' || key.startsWith('_c');
31
+
32
+ export interface CtgrTunnelOptions {
33
+ /**
34
+ * The accumulator for multi-chunk payloads. Share ONE across an app (the default is
35
+ * a fresh singleton); pass your own to tune the DoS caps ({@link ChunkStore}).
36
+ */
37
+ store?: ChunkStore;
38
+ }
39
+
40
+ /**
41
+ * Mount the GET-only tunnel on `app`. Call once, on the Hono app whose real routes sit
42
+ * at the paths the twins mirror (a twin path is `/ctgr<xx>` + the real path). Returns
43
+ * the {@link ChunkStore} in use, for tests and metrics.
44
+ */
45
+ export function mountCtgrTunnel<E extends CursedbeltEnv = CursedbeltEnv>(
46
+ app: Hono<E>,
47
+ options: CtgrTunnelOptions = {},
48
+ ): ChunkStore {
49
+ const store = options.store ?? new ChunkStore();
50
+ const legacy = new V0ChunkStore();
51
+
52
+ app.use(CTGR_ROUTE_PATTERN, async (c) => {
53
+ const url = new URL(c.req.url);
54
+ const params = Object.fromEntries(url.searchParams);
55
+ // The twin path is `/ctgr<xx>...`; the abbreviation is the two chars after `ctgr`.
56
+ const pathMethod = ABBREV_TO_METHOD[c.req.path.slice(5, 7) as CtgrAbbrev];
57
+ if (!pathMethod) {
58
+ return c.json({ error: 'not found', code: 'NOT_FOUND' }, 404);
59
+ }
60
+
61
+ const result =
62
+ params._cv === undefined ? legacy.add(params, pathMethod) : await store.add(params);
63
+
64
+ if (result === false) return c.json({ acknowledged: true }, 202);
65
+ if (result instanceof Error) {
66
+ return c.json({ error: result.message, code: 'BAD_REQUEST' }, 400);
67
+ }
68
+
69
+ const realReq = rebuildRequest(c.req.raw, c.req.path, url, result);
70
+ // Re-enter the app at the real route. The real path never starts with `/ctgr`, so
71
+ // this middleware does not re-match — no recursion.
72
+ let executionCtx: ExecutionContext | undefined;
73
+ try {
74
+ executionCtx = c.executionCtx as ExecutionContext;
75
+ } catch {
76
+ executionCtx = undefined;
77
+ }
78
+ return app.fetch(realReq, c.env as E, executionCtx);
79
+ });
80
+
81
+ return store;
82
+ }
83
+
84
+ /** Build the real Request the decoded twin stands in for. */
85
+ function rebuildRequest(raw: Request, twinPath: string, url: URL, decoded: CtgrDecoded): Request {
86
+ // Strip the `/ctgr<xx>` prefix and the codec's own query params; keep the rest.
87
+ const realPath = twinPath.replace(/^\/ctgr../, '') || '/';
88
+ const kept = new URLSearchParams();
89
+ for (const [key, value] of url.searchParams) {
90
+ if (!isCtgrParam(key)) kept.set(key, value);
91
+ }
92
+ const search = kept.toString();
93
+ const realUrl = `${url.origin}${realPath}${search ? `?${search}` : ''}`;
94
+
95
+ const headers = new Headers(raw.headers);
96
+ headers.delete('content-length');
97
+
98
+ let body: BodyInit | undefined;
99
+ if (decoded.files && decoded.files.length > 0) {
100
+ const form = new FormData();
101
+ const fields = (decoded.body ?? {}) as Record<string, unknown>;
102
+ for (const [key, value] of Object.entries(fields)) {
103
+ form.append(key, typeof value === 'string' ? value : JSON.stringify(value));
104
+ }
105
+ for (const file of decoded.files) {
106
+ form.append(
107
+ file.fieldname,
108
+ new File([new Uint8Array(file.buffer)], file.originalname, { type: file.mimetype }),
109
+ );
110
+ }
111
+ body = form;
112
+ // Let the runtime set the multipart content-type + boundary.
113
+ headers.delete('content-type');
114
+ } else if (decoded.body !== undefined) {
115
+ body = JSON.stringify(decoded.body);
116
+ headers.set('content-type', 'application/json');
117
+ }
118
+
119
+ return new Request(realUrl, { method: decoded.method, headers, body });
120
+ }
@@ -68,6 +68,7 @@ export {
68
68
  } from './auth/index.js';
69
69
  export type { CursedbeltEnv, CursedbeltVariables } from './context.js';
70
70
  export { buildRoutes, CTGR_ROUTE_PATTERN, getCtgrDecoded, unGETify } from './ctgr.js';
71
+ export { type CtgrTunnelOptions, mountCtgrTunnel } from './ctgrTunnel.js';
71
72
  // ── Kysely-typed plumbing tables ────────────────────────────────────────────
72
73
  export { createKysely, type PlumbingDb } from './db/kysely.js';
73
74
  export type {
@@ -34,6 +34,7 @@ import {
34
34
  BINARY_REPEAT_THRESHOLD,
35
35
  BINARY_REPEAT_WINDOW_MS,
36
36
  type BinaryStoreConfig,
37
+ BinaryStoreUnreachableError,
37
38
  binaryStoreTenant,
38
39
  createBinaryStore,
39
40
  normalizePrivateKeyPem,
@@ -41,6 +42,7 @@ import {
41
42
  repeatedBinaryKeys,
42
43
  resetRepeatedBinaryKeys,
43
44
  resolvedMediaTenant,
45
+ storeUnreachableStatus,
44
46
  wantsFresh,
45
47
  } from './binaryStore.js';
46
48
  import { binaryStoreFakeDefaults } from './binaryStoreFake.js';
@@ -962,3 +964,58 @@ describe('binaryStoreFakeDefaults', () => {
962
964
  expect(await fake.signToken({ k: 'c/a' }, 60)).toBe('fake-token');
963
965
  });
964
966
  });
967
+
968
+ describe('🔴 a write the store could not ANSWER is a 503 that names it (2026-09-26)', () => {
969
+ it('502-504 and 52x/530 are the origin unreachable; everything else is its own answer', () => {
970
+ for (const status of [502, 503, 504, 520, 522, 524, 530]) expect(storeUnreachableStatus(status)).toBe(true);
971
+ for (const status of [400, 403, 404, 413, 422, 500, 507]) expect(storeUnreachableStatus(status)).toBe(false);
972
+ });
973
+
974
+ it('put on a 530 throws the typed error, keeping the [binary-store] head batch callers print', async () => {
975
+ reply = () => new Response('tunnel gone', { status: 530 });
976
+ const error = await makeStore().put('art/a.png', new Uint8Array([1])).catch((e: unknown) => e);
977
+ expect(error).toBeInstanceOf(BinaryStoreUnreachableError);
978
+ expect(String((error as Error).message)).toStartWith('[binary-store] put art/a.png: the file store did not answer (530)');
979
+ expect((error as BinaryStoreUnreachableError).status).toBe(503);
980
+ });
981
+
982
+ it('a fetch that THROWS is the same fact', async () => {
983
+ const store = makeStore({ fetch: () => Promise.reject(new Error('connection refused')) });
984
+ const error = await store.put('k', new Uint8Array([1])).catch((e: unknown) => e);
985
+ expect(error).toBeInstanceOf(BinaryStoreUnreachableError);
986
+ expect(String((error as Error).message)).toContain('connection refused');
987
+ });
988
+
989
+ it('putLarge says which chunk found the store gone', async () => {
990
+ reply = () => new Response('', { status: 502 });
991
+ const error = await makeStore()
992
+ .putLarge('v.mp4', new Uint8Array(10), 'video/mp4', { chunkBytes: 4 })
993
+ .catch((e: unknown) => e);
994
+ expect(error).toBeInstanceOf(BinaryStoreUnreachableError);
995
+ expect(String((error as Error).message)).toContain('chunk 1/3: 502');
996
+ });
997
+
998
+ it('🔴 getResponse() is the 503 a Hono app answers with when the error escapes a route', async () => {
999
+ const res = new BinaryStoreUnreachableError('put', 'k', '530').getResponse();
1000
+ expect(res.status).toBe(503);
1001
+ expect(res.headers.get('retry-after')).toBe('30');
1002
+ expect(((await res.json()) as { error: string }).error).toBe('BYTE_STORE_UNREACHABLE');
1003
+ });
1004
+
1005
+ it('…and a real Hono app DOES answer it, with no onError of its own', async () => {
1006
+ const { Hono } = await import('hono');
1007
+ const app = new Hono().post('/upload', () => {
1008
+ throw new BinaryStoreUnreachableError('put', 'k', '530');
1009
+ });
1010
+ const res = await app.request('/upload', { method: 'POST' });
1011
+ expect(res.status).toBe(503);
1012
+ expect(((await res.json()) as { error: string }).error).toBe('BYTE_STORE_UNREACHABLE');
1013
+ });
1014
+
1015
+ it("the origin's own refusals are unchanged — a 403 is still the plain status error", async () => {
1016
+ reply = () => new Response('', { status: 403 });
1017
+ const error = await makeStore().put('k', new Uint8Array([1])).catch((e: unknown) => e);
1018
+ expect(error).not.toBeInstanceOf(BinaryStoreUnreachableError);
1019
+ expect(String((error as Error).message)).toBe('[binary-store] put k: 403');
1020
+ });
1021
+ });
@@ -725,7 +725,10 @@ export function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore {
725
725
  headers: mime ? { 'content-type': mime } : undefined,
726
726
  body: bytes as unknown as BodyInit,
727
727
  },
728
- );
728
+ ).catch((cause: unknown) => {
729
+ throw new BinaryStoreUnreachableError('put', key, cause instanceof Error ? cause.message : String(cause));
730
+ });
731
+ if (storeUnreachableStatus(res.status)) throw new BinaryStoreUnreachableError('put', key, String(res.status));
729
732
  if (!res.ok) {
730
733
  // A 413 that reaches here despite the threshold above means the edge's
731
734
  // real cap is lower than we think, or the caller disabled the
@@ -805,7 +808,12 @@ export function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore {
805
808
  headers: mime ? { 'content-type': mime } : undefined,
806
809
  body: slice,
807
810
  },
808
- );
811
+ ).catch((cause: unknown) => {
812
+ throw new BinaryStoreUnreachableError('putLarge', key, cause instanceof Error ? cause.message : String(cause));
813
+ });
814
+ if (storeUnreachableStatus(res.status)) {
815
+ throw new BinaryStoreUnreachableError('putLarge', key, `chunk ${ci + 1}/${total}: ${res.status}`);
816
+ }
809
817
  if (!res.ok) {
810
818
  throw new Error(`[binary-store] putLarge ${key} chunk ${ci + 1}/${total}: ${res.status}`);
811
819
  }
@@ -1071,6 +1079,48 @@ export function resolvedMediaTenant(
1071
1079
  return readBinaryStoreEnv(appId, env)?.appId ?? null;
1072
1080
  }
1073
1081
 
1082
+ /**
1083
+ * binary-server's origin did not ANSWER a write — as opposed to answering "no".
1084
+ *
1085
+ * binary-server itself never answers 502, 503 or 504; those, and Cloudflare's 52x family (530 is a
1086
+ * tunnel with no connector), are the edge speaking for an origin it could not reach — the owner's
1087
+ * Mac off, asleep, or restarting. A thrown `fetch` is the same fact. Since 2026-09-26 the edge
1088
+ * Worker takes an R2-backed tenant's whole upload itself in that case
1089
+ * (`apps/binary-server/edge-worker/src/upload.ts`), so for desk and family this is what is left
1090
+ * when BOTH halves failed; for a tenant whose bytes rest on the Mac it is simply "the Mac is off".
1091
+ */
1092
+ export function storeUnreachableStatus(status: number): boolean {
1093
+ return status === 502 || status === 503 || status === 504 || (status >= 520 && status <= 530);
1094
+ }
1095
+
1096
+ /**
1097
+ * The write could not reach the store. Carries `getResponse()`, so a Hono app that lets it escape
1098
+ * a route answers a 503 that names the file store — Hono's default error handler returns
1099
+ * `err.getResponse()` for any error that has one — instead of a bare `500 Internal Server Error`.
1100
+ * The message keeps the `[binary-store] <op> <key>:` head every batch caller already prints.
1101
+ */
1102
+ export class BinaryStoreUnreachableError extends Error {
1103
+ readonly status = 503;
1104
+ readonly code = 'BYTE_STORE_UNREACHABLE';
1105
+ constructor(op: string, key: string, detail: string) {
1106
+ super(
1107
+ `[binary-store] ${op} ${key}: the file store did not answer (${detail}) — nothing was saved; ` +
1108
+ 'everything already stored still opens. Try again when the home server is on.',
1109
+ );
1110
+ this.name = 'BinaryStoreUnreachableError';
1111
+ }
1112
+ getResponse(): Response {
1113
+ return new Response(
1114
+ JSON.stringify({
1115
+ error: this.code,
1116
+ message:
1117
+ 'The file store did not answer, so nothing was saved. Everything already stored still opens — try again in a moment.',
1118
+ }),
1119
+ { status: 503, headers: { 'content-type': 'application/json; charset=utf-8', 'retry-after': '30' } },
1120
+ );
1121
+ }
1122
+ }
1123
+
1074
1124
  /**
1075
1125
  * The standard env surface every tenant's secrets file carries
1076
1126
  * (`$FORGE_STATE/secrets/<app>.env`, written at bs registration):