cursedbelt-server 1.0.1 → 1.0.2

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.
@@ -1,4 +1,5 @@
1
1
  export * from './argon2Binding';
2
+ export * from './passwordVerifier';
2
3
  export * from './verifier';
3
4
  export * from './revocationStore';
4
5
  export * from './sessionSigner';
@@ -1,6 +1,7 @@
1
1
  // The GuardEngine server wiring (02 §4): the argon2id binding adapter that arms
2
2
  // cwip's KDF seam, and the Ed25519 session signer that mints and revokes.
3
3
  export * from './argon2Binding';
4
+ export * from './passwordVerifier';
4
5
  export * from './verifier';
5
6
  export * from './revocationStore';
6
7
  export * from './sessionSigner';
@@ -23,3 +23,4 @@ export type { Migration } from './migration/types';
23
23
  export { AUDIT_LOG_MIGRATIONS, auditStateAt, auditStateAtVersion, auditStep, createSqliteAuditSink, } from './audit';
24
24
  export { applyFtsSchema, type BackupInfo, buildFtsSchema, clampLimit, clampOffset, collectSqliteStats, createFts5Search, createSqliteBackupService, createSqliteHealthChecks, createSqliteViewerMiddleware, type DbStats, exportSqliteSnapshot, exportSqliteTable, type Fts5Search, type FtsHighlightOpts, type FtsResult, type FtsSearchOpts, type FtsSnippetOpts, type FtsTableSpec, type ImportMode, type ImportResult, importRowsIntoSqliteTable, isKnownTable, isReadOnlySql, listSqliteTables, NotReadOnlyError, quoteIdent, type RestoreResult, readSqliteTable, restoreSqliteSnapshot, runReadonlySqlite, type SnapshotExport, type SqliteBackupOpts, type SqliteBackupS3Opts, type SqliteBackupService, type SqliteHealthCheck, type SqliteHealthOpts, type SqliteHealthResult, type SqliteHealthStatus, type SqliteTableInfo, type SqliteTablePage, sanitizeFtsQuery, snapshotSqlite, type TableExport, type TableExportFormat, type TableStat, UnknownTableError, } from './sqlite';
25
25
  export { computePresence, createEventBus, createWsHub, type EventBus, type EventBusData, joinRoom, leaveRoom, type PresenceEntry, type PresenceState, ROOM_META_KEY, roomOf, type WsChannel, type WsClient, type WsData, type WsHandshake, type WsHub, type WsHubOpts, type WsMessage, } from './ws';
26
+ export { resolveAppDataHome } from './paths/appDataHome';
@@ -51,3 +51,7 @@ export { applyFtsSchema, buildFtsSchema, clampLimit, clampOffset, collectSqliteS
51
51
  // ── WebSocket hub (task 28): Bun-native upgrade, channel registry, heartbeat, ──
52
52
  // presence-as-a-view, room helpers, and the push-only event-bus subset (ru).
53
53
  export { computePresence, createEventBus, createWsHub, joinRoom, leaveRoom, ROOM_META_KEY, roomOf, } from './ws';
54
+ // The app-data root resolver. Exported at the root (2026-09-15, task 148) because
55
+ // `cursedbelt-cc`'s localHost needs it and it answers a question about the HOST, not
56
+ // about any one subsystem — so no subpath barrel is its natural home.
57
+ export { resolveAppDataHome } from './paths/appDataHome';
@@ -1,5 +1,6 @@
1
1
  export { createFireQueue } from './fireQueue';
2
2
  export { defaultJobLogger } from './logger';
3
+ export { createPlainjobConnection } from './plainjobAdapter';
3
4
  export { createPollingJob } from './polling';
4
5
  export { createJobRunner } from './runner';
5
6
  export { JobTimeoutError } from './timeout';
@@ -4,6 +4,7 @@
4
4
  // *definitions* stay business and consume these primitives.
5
5
  export { createFireQueue } from './fireQueue';
6
6
  export { defaultJobLogger } from './logger';
7
+ export { createPlainjobConnection } from './plainjobAdapter';
7
8
  export { createPollingJob } from './polling';
8
9
  export { createJobRunner } from './runner';
9
10
  export { JobTimeoutError } from './timeout';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The app-facing Bun/Hono server tier of the cursedbelt split \u2014 storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
@@ -22,7 +22,8 @@
22
22
  "build": "bun run clean && tsc -p tsconfig.build.json",
23
23
  "paths": "bun ../../tools/check-paths.ts .",
24
24
  "verify": "bun run paths && bun run typecheck && bun run build && bun run test",
25
- "prepublishOnly": "bun run verify"
25
+ "prepublishOnly": "bun run verify",
26
+ "test:pg": "bun run scripts/testPg.ts"
26
27
  },
27
28
  "files": [
28
29
  "dist",
@@ -221,6 +222,7 @@
221
222
  "@node-rs/argon2": "^2.0.2",
222
223
  "@types/bun": "^1.3.14",
223
224
  "@types/node": "^24",
225
+ "cursedbelt": "^3.0.0",
224
226
  "hono": "4.12.28",
225
227
  "kysely": "^0.28.17",
226
228
  "kysely-bun-sqlite": "^0.4.0",
@@ -1,6 +1,7 @@
1
1
  // The GuardEngine server wiring (02 §4): the argon2id binding adapter that arms
2
2
  // cwip's KDF seam, and the Ed25519 session signer that mints and revokes.
3
3
  export * from './argon2Binding';
4
+ export * from './passwordVerifier';
4
5
  export * from './verifier';
5
6
  export * from './revocationStore';
6
7
  export * from './sessionSigner';
@@ -246,3 +246,8 @@ export {
246
246
  type WsHubOpts,
247
247
  type WsMessage,
248
248
  } from './ws';
249
+
250
+ // The app-data root resolver. Exported at the root (2026-09-15, task 148) because
251
+ // `cursedbelt-cc`'s localHost needs it and it answers a question about the HOST, not
252
+ // about any one subsystem — so no subpath barrel is its natural home.
253
+ export { resolveAppDataHome } from './paths/appDataHome';
@@ -5,6 +5,7 @@
5
5
 
6
6
  export { createFireQueue } from './fireQueue';
7
7
  export { defaultJobLogger } from './logger';
8
+ export { createPlainjobConnection } from './plainjobAdapter';
8
9
  export { createPollingJob } from './polling';
9
10
  export { createJobRunner } from './runner';
10
11
  export { JobTimeoutError } from './timeout';
@@ -0,0 +1,183 @@
1
+ /*
2
+ * At the 2026-09-15 split this spec stays with the server internals it drives
3
+ * (files/fileRoutes, streamToken, binaryServerStore — none of them exported) and
4
+ * takes the react-side client from the PUBLISHED `cursedbelt` instead, same as
5
+ * fileUploadClient.integration.spec.ts beside it. Dev-only; runtime deps stay acyclic.
6
+ */
7
+ import { Database } from 'bun:sqlite';
8
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
9
+ import { subtle } from 'node:crypto';
10
+ import { existsSync } from 'node:fs';
11
+ import { mkdtemp, rm } from 'node:fs/promises';
12
+ import { tmpdir } from 'node:os';
13
+ import path from 'node:path';
14
+ import { pickFreePort, startTestServer, type TestServer } from 'cwip/testing';
15
+ import { Hono } from 'hono';
16
+ import { createFileUploadClient } from 'cursedbelt/react/media/upload-client';
17
+ import { runMigrations } from '../migration/runner';
18
+ import { createBinaryServerStore } from './binaryServerStore';
19
+ import {
20
+ createChunkedUploadService,
21
+ createSqliteFileCatalogue,
22
+ createStorageOpsLedger,
23
+ FILE_CATALOGUE_MIGRATIONS,
24
+ } from './files';
25
+ import { mountFileRoutes } from './files/fileRoutes';
26
+ import { createStreamTokenService } from './streamToken';
27
+ import type { MediaCtx } from './types';
28
+
29
+ // genapp-100: the missing half of the chunked-upload proof. `chunked.integration.spec.ts`
30
+ // drives the SERVICE against a real binary-server; THIS spec drives the BROWSER CLIENT
31
+ // (`fileUploadClient.uploadAuto`) through the real `mountFileRoutes` edge — the exact wire a
32
+ // generated app uses in production — and pins the load-bearing property: the app origin sees
33
+ // ONLY the small init/complete JSON calls, while every chunk PUTs DIRECT to binary-server.
34
+ // Skips cleanly when the local binary-server checkout isn't present (CI / other machines).
35
+ const BS_DIR = process.env.BINARY_SERVER_DIR ?? '';
36
+ const BS_PRESENT = BS_DIR !== '' && existsSync(path.join(BS_DIR, 'src', 'index.ts'));
37
+ const ISSUER = 'https://demo.local';
38
+ const APP_ID = 'demo';
39
+ const CTX: MediaCtx = { userId: 'user-stub', tenantId: 'user-stub' };
40
+
41
+ const b64url = (b: ArrayBuffer | Uint8Array) =>
42
+ Buffer.from(b instanceof Uint8Array ? b : new Uint8Array(b)).toString('base64url');
43
+ const jsonSeg = (o: unknown) => b64url(new TextEncoder().encode(JSON.stringify(o)));
44
+ const pem = (der: ArrayBuffer, label: string) =>
45
+ `-----BEGIN ${label}-----\n${(
46
+ Buffer.from(der)
47
+ .toString('base64')
48
+ .match(/.{1,64}/g) ?? []
49
+ ).join('\n')}\n-----END ${label}-----\n`;
50
+
51
+ // SKIPPED until a real binary-server checkout is present: point BINARY_SERVER_DIR at one.
52
+ // There is deliberately no default — a published package must not go looking in one machine's
53
+ // directory layout — and `binary-server` has not been re-homed here, so this skips on every host today.
54
+ describe.skipIf(!BS_PRESENT)('fileUploadClient chunked path → real binary-server (integration)', () => {
55
+ let server: TestServer | null = null;
56
+ let tmp = '';
57
+ let store: ReturnType<typeof createBinaryServerStore>;
58
+ const app = new Hono();
59
+ /** Every request the APP ORIGIN handled: [method+path, request body bytes]. */
60
+ const originRequests: Array<{ path: string; bodyBytes: number }> = [];
61
+ /** How many chunk PUTs bypassed the origin and hit binary-server directly. */
62
+ let directChunkPuts = 0;
63
+
64
+ beforeAll(async () => {
65
+ const kp = await subtle.generateKey(
66
+ {
67
+ name: 'RSASSA-PKCS1-v1_5',
68
+ modulusLength: 2048,
69
+ publicExponent: new Uint8Array([1, 0, 1]),
70
+ hash: 'SHA-256',
71
+ },
72
+ true,
73
+ ['sign', 'verify'],
74
+ );
75
+ const spki = pem(await subtle.exportKey('spki', kp.publicKey), 'PUBLIC KEY');
76
+ const signJwt = async (claims: Record<string, unknown>) => {
77
+ const now = Math.floor(Date.now() / 1000);
78
+ const input = `${jsonSeg({ alg: 'RS256', typ: 'JWT' })}.${jsonSeg({ iss: ISSUER, iat: now, exp: now + 3600, ...claims })}`;
79
+ const sig = await subtle.sign('RSASSA-PKCS1-v1_5', kp.privateKey, new TextEncoder().encode(input));
80
+ return `${input}.${b64url(new Uint8Array(sig))}`;
81
+ };
82
+
83
+ tmp = await mkdtemp(path.join(tmpdir(), 'bs-client-int-'));
84
+ const port = await pickFreePort();
85
+ server = await startTestServer({
86
+ cwd: BS_DIR,
87
+ cmd: ['bun', path.join(BS_DIR, 'src', 'index.ts')],
88
+ port,
89
+ env: {
90
+ PORT: String(port),
91
+ HOST: '127.0.0.1',
92
+ STORAGE_ROOT: path.join(tmp, 'binaries'),
93
+ DB_PATH: path.join(tmp, 'bs.sqlite'),
94
+ FILE_TOKEN_PUBLIC_KEY: spki,
95
+ FILE_TOKEN_ISSUER: ISSUER,
96
+ CA_APP_ID: APP_ID,
97
+ INTERNAL_SECRET: 'demo-internal',
98
+ },
99
+ healthPath: '/healthz',
100
+ timeoutMs: 90_000,
101
+ });
102
+ const base = server.baseUrl;
103
+
104
+ store = createBinaryServerStore({
105
+ baseUrl: base,
106
+ appId: APP_ID,
107
+ signFileToken: (c) => signJwt(c),
108
+ internalSecret: 'demo-internal',
109
+ });
110
+ const db = new Database(':memory:');
111
+ runMigrations(db, FILE_CATALOGUE_MIGRATIONS);
112
+ const catalogue = createSqliteFileCatalogue({
113
+ db,
114
+ store,
115
+ workDir: path.join(tmp, 'work'),
116
+ owner: (c) => c.userId,
117
+ });
118
+ const svc = createChunkedUploadService({
119
+ catalogue,
120
+ ledger: createStorageOpsLedger(db),
121
+ backend: 'binary',
122
+ chunkSize: 64 * 1024,
123
+ owner: (c) => c.userId,
124
+ mintChunkUpload: async ({ key, sid, ci, tc, owner }) => {
125
+ const token = await signJwt({ k: `${APP_ID}/${key}`, uid: owner, sid, ci, tc });
126
+ return `${base}/upload-chunk/${APP_ID}/${key}?token=${encodeURIComponent(token)}`;
127
+ },
128
+ });
129
+ mountFileRoutes(app, {
130
+ store,
131
+ token: createStreamTokenService({ secret: 's' }),
132
+ catalogue,
133
+ authorizeUpload: () => CTX,
134
+ chunked: svc,
135
+ });
136
+ }, 90_000);
137
+
138
+ afterAll(async () => {
139
+ await server?.stop();
140
+ await rm(tmp, { recursive: true, force: true });
141
+ });
142
+
143
+ test('uploadAuto splits a large file into direct-to-bs chunk PUTs; the origin sees only init/complete', async () => {
144
+ // The client's fetch seam is where prod's routing lives: same-origin paths go
145
+ // to the app, absolute (minted) URLs go straight out to binary-server.
146
+ const client = createFileUploadClient({
147
+ authHeaders: () => ({ Authorization: 'Bearer test' }),
148
+ chunkThreshold: 128 * 1024,
149
+ fetchImpl: (async (input: RequestInfo | URL, init?: RequestInit) => {
150
+ const url = String(input);
151
+ if (url.startsWith('http')) {
152
+ directChunkPuts++;
153
+ return fetch(input as string, init);
154
+ }
155
+ const bodyBytes =
156
+ init?.body instanceof Blob ? init.body.size : typeof init?.body === 'string' ? init.body.length : 0;
157
+ originRequests.push({ path: url, bodyBytes });
158
+ return app.request(url, init);
159
+ }) as typeof fetch,
160
+ });
161
+
162
+ // ~1 MiB of deterministic bytes → 16 × 64 KiB chunks, well over the 128 KiB threshold.
163
+ const bytes = new Uint8Array(1024 * 1024).map((_, i) => (i * 31 + 7) % 256);
164
+ const file = new File([bytes], 'big-clip.bin', { type: 'application/octet-stream' });
165
+
166
+ const result = await client.uploadAuto(file, { tags: ['chunked-proof'] });
167
+ expect(result.ok).toBe(true);
168
+ const fileId = result.ok ? (result.file?.id ?? '') : '';
169
+ expect(fileId.length).toBeGreaterThan(0);
170
+
171
+ // Every chunk bypassed the origin…
172
+ expect(directChunkPuts).toBe(16);
173
+ // …and the origin handled ONLY small JSON control calls (init + complete):
174
+ expect(originRequests.map((r) => r.path)).toEqual(['/_files/upload-init', '/_files/upload-complete']);
175
+ for (const r of originRequests) expect(r.bodyBytes).toBeLessThan(2_048);
176
+
177
+ // The assembled object on binary-server is byte-identical to the source (bs Range GET).
178
+ const back = await store.get(`fs/${CTX.userId}/${fileId}`);
179
+ expect(back).not.toBeNull();
180
+ expect(back?.length).toBe(bytes.byteLength);
181
+ expect(Buffer.from(back as Uint8Array).equals(Buffer.from(bytes))).toBe(true);
182
+ });
183
+ });
@@ -0,0 +1,139 @@
1
+ /*
2
+ * Moved from cursedbelt's src/react/media at the 2026-09-15 split: this spec
3
+ * drives the published react-side upload client against this package's storage
4
+ * INTERNALS (diskStore, streamToken, private-media), which are deliberately not
5
+ * exported — so the spec lives beside them and takes the client half from the
6
+ * published `cursedbelt` instead. Runtime deps stay acyclic; this is dev-only.
7
+ */
8
+ import { Database } from 'bun:sqlite';
9
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
10
+ import { mkdtemp, rm } from 'node:fs/promises';
11
+ import { tmpdir } from 'node:os';
12
+ import path from 'node:path';
13
+ import { Hono } from 'hono';
14
+ import { MAX_SINGLE_UPLOAD_BYTES } from 'cursedbelt-core/media/upload-limits';
15
+ import { runMigrations } from '../migration/runner';
16
+ import { generateUserSalt } from '../private-media/crypto';
17
+ import { createUnlockService } from '../private-media/unlock';
18
+ import { createDiskStore } from './diskStore';
19
+ import { createSqliteFileCatalogue } from './files/catalogue';
20
+ import { mountFileRoutes, SINGLE_UPLOAD_TOO_LARGE } from './files/fileRoutes';
21
+ import { FILE_CATALOGUE_MIGRATIONS } from './files/migrations';
22
+ import { createStreamTokenService } from './streamToken';
23
+ import type { MediaCtx } from './types';
24
+ import { createFileUploadClient } from 'cursedbelt/react/media/upload-client';
25
+
26
+ // Drives the REAL mountFileRoutes edge — the client and the routes share one header
27
+ // protocol (X-Filename/X-Tags/X-Unlock-Token), so the round-trip is the contract.
28
+
29
+ const CTX: MediaCtx = { userId: 'u1', tenantId: 't1' };
30
+ const unlock = createUnlockService({ serverSecret: 'srv', unlockSecret: 'unl' });
31
+ const salt = generateUserSalt();
32
+
33
+ describe('createFileUploadClient ⇄ mountFileRoutes', () => {
34
+ let db: Database;
35
+ let root: string;
36
+ const app = new Hono();
37
+ const client = createFileUploadClient({
38
+ authHeaders: () => ({ Authorization: 'Bearer test' }),
39
+ fetchImpl: ((input: RequestInfo | URL, init?: RequestInit) =>
40
+ app.request(input as string, init)) as typeof fetch,
41
+ });
42
+
43
+ beforeAll(async () => {
44
+ db = new Database(':memory:');
45
+ runMigrations(db, FILE_CATALOGUE_MIGRATIONS);
46
+ root = await mkdtemp(path.join(tmpdir(), 'fuc-'));
47
+ const catalogue = createSqliteFileCatalogue({
48
+ db,
49
+ store: createDiskStore(root),
50
+ workDir: path.join(root, 'w'),
51
+ owner: (c) => c.userId,
52
+ });
53
+ mountFileRoutes(app, {
54
+ store: createDiskStore(root),
55
+ token: createStreamTokenService({ secret: 's' }),
56
+ catalogue,
57
+ authorizeUpload: () => CTX,
58
+ privateUpload: {
59
+ unlock,
60
+ authorize: async () => ({ userId: CTX.userId, userSalt: salt }),
61
+ },
62
+ });
63
+ });
64
+ afterAll(async () => {
65
+ db.close();
66
+ await rm(root, { recursive: true, force: true });
67
+ });
68
+
69
+ let uploadedId = '';
70
+
71
+ test('upload carries filename + tags through to the catalogue row', async () => {
72
+ const res = await client.upload(new File(['hello'], 'notes.txt', { type: 'text/plain' }), {
73
+ tags: ['a', 'b'],
74
+ });
75
+ expect(res.ok).toBe(true);
76
+ if (!res.ok) return;
77
+ expect(res.file?.name).toBe('notes.txt');
78
+ expect(res.file?.tags).toEqual(['a', 'b']);
79
+ uploadedId = res.file?.id ?? '';
80
+ });
81
+
82
+ test('replace overwrites bytes under the same id', async () => {
83
+ const res = await client.replace(uploadedId, new Blob(['replaced'], { type: 'text/plain' }), {
84
+ name: 'notes-v2.txt',
85
+ });
86
+ expect(res.ok).toBe(true);
87
+ if (!res.ok) return;
88
+ expect(res.file?.id).toBe(uploadedId);
89
+ expect(res.file?.name).toBe('notes-v2.txt');
90
+ expect(res.file?.size).toBe('replaced'.length);
91
+ });
92
+
93
+ test('uploadPrivate with an unlock token lands an encrypted item', async () => {
94
+ const { token } = await unlock.unlock({ userId: CTX.userId, userSalt: salt, password: 'pw' });
95
+ const res = await client.uploadPrivate(
96
+ new File(['secret-bytes'], 'secret.jpg', { type: 'image/jpeg' }),
97
+ { unlockToken: token, tags: ['hidden'] },
98
+ );
99
+ expect(res.ok).toBe(true);
100
+ if (!res.ok) return;
101
+ expect(res.file?.processingStatus).toBe('ready'); // image blob path — no ffmpeg
102
+ });
103
+
104
+ test('uploadAuto falls back to the streamed PUT when chunked is unwired (404 init)', async () => {
105
+ const big = new File([new Uint8Array(9 * 1024 * 1024)], 'big.bin', {
106
+ type: 'application/octet-stream',
107
+ });
108
+ const res = await client.uploadAuto(big);
109
+ expect(res.ok).toBe(true);
110
+ if (!res.ok) return;
111
+ expect(res.file?.size).toBe(big.size);
112
+ });
113
+
114
+ test('uploadAuto surfaces a single-request-cap 413 as a hard ok:false error', async () => {
115
+ // Chunked is unwired (404 init) so uploadAuto falls back to the single PUT; a file over
116
+ // MAX_SINGLE_UPLOAD_BYTES must come back ok:false with the server's clear message — not a
117
+ // silent failure. (The guard fails fast on Content-Length, so no giant disk write.)
118
+ const tooBig = new File([new Uint8Array(MAX_SINGLE_UPLOAD_BYTES + 1024)], 'huge.bin', {
119
+ type: 'application/octet-stream',
120
+ });
121
+ const res = await client.uploadAuto(tooBig);
122
+ expect(res.ok).toBe(false);
123
+ if (res.ok) return;
124
+ expect(res.error).toBe(SINGLE_UPLOAD_TOO_LARGE);
125
+ });
126
+
127
+ test('an unauthenticated client is rejected', async () => {
128
+ const anon = createFileUploadClient({
129
+ authHeaders: () => ({}),
130
+ fetchImpl: ((_input: RequestInfo | URL, init?: RequestInit) => {
131
+ // Simulate the guard rejecting a missing session: the route's authorizeUpload above
132
+ // always allows, so drive a route that doesn't exist to prove errors surface cleanly.
133
+ return app.request('/_files/upload-nope', init);
134
+ }) as typeof fetch,
135
+ });
136
+ const res = await anon.upload(new File(['x'], 'x.txt', { type: 'text/plain' }));
137
+ expect(res.ok).toBe(false);
138
+ });
139
+ });