cursedbelt-server 3.0.1 → 4.0.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.
- package/dist/server/auth/jwt.d.ts +1 -1
- package/dist/server/auth/jwt.js +2 -2
- package/dist/server/master-lock/accountsPage.d.ts +39 -0
- package/dist/server/master-lock/accountsPage.js +455 -0
- package/dist/server/master-lock/guard.js +39 -1
- package/dist/server/master-lock/index.d.ts +10 -2
- package/dist/server/master-lock/index.js +10 -2
- package/dist/server/master-lock/lockPage.d.ts +9 -0
- package/dist/server/master-lock/lockPage.js +44 -36
- package/dist/server/media-bun/streaming.d.ts +0 -1
- package/dist/server/media-bun/streaming.js +1 -1
- package/dist/server/storage/files/catalogue.d.ts +2 -2
- package/dist/server/storage/files/catalogue.js +18 -208
- package/dist/server/storage/files/fileRoutes.d.ts +2 -20
- package/dist/server/storage/files/fileRoutes.js +1 -74
- package/dist/server/storage/files/index.d.ts +1 -1
- package/dist/server/storage/files/index.js +1 -1
- package/dist/server/storage/files/migrations.js +9 -6
- package/dist/server/storage/files/stack.d.ts +6 -31
- package/dist/server/storage/files/stack.js +0 -27
- package/dist/server/storage/files/types.d.ts +10 -60
- package/dist/server/storage/files/types.js +9 -0
- package/dist/server/storage/index.d.ts +2 -2
- package/dist/server/storage/index.js +1 -1
- package/dist/server/storage/mountMediaRoutes.d.ts +1 -4
- package/dist/server/storage/types.d.ts +0 -9
- package/dist/server/storage/types.js +2 -9
- package/package.json +2 -2
- package/src/leafSubpathsImportNothing.spec.ts +6 -4
- package/src/server/auth/jwt.ts +2 -2
- package/src/server/master-lock/accountsPage.spec.ts +231 -0
- package/src/server/master-lock/accountsPage.ts +465 -0
- package/src/server/master-lock/guard.ts +44 -1
- package/src/server/master-lock/index.ts +22 -2
- package/src/server/master-lock/lockPage.spec.ts +20 -16
- package/src/server/master-lock/lockPage.ts +45 -36
- package/src/server/media-bun/streaming.ts +0 -6
- package/src/server/storage/fileUploadClient.integration.spec.ts +2 -21
- package/src/server/storage/files/adapter.photo.display.spec.ts +2 -2
- package/src/server/storage/files/adapter.photo.spec.ts +2 -2
- package/src/server/storage/files/adapter.spec.ts +0 -1
- package/src/server/storage/files/catalogue.media-host.spec.ts +6 -71
- package/src/server/storage/files/catalogue.recover.spec.ts +14 -23
- package/src/server/storage/files/catalogue.ts +19 -240
- package/src/server/storage/files/fileRoutes.replace.spec.ts +2 -2
- package/src/server/storage/files/fileRoutes.spec.ts +3 -97
- package/src/server/storage/files/fileRoutes.ts +2 -96
- package/src/server/storage/files/index.ts +1 -4
- package/src/server/storage/files/migrations.ts +9 -7
- package/src/server/storage/files/stack.spec.ts +11 -111
- package/src/server/storage/files/stack.ts +9 -61
- package/src/server/storage/files/types.ts +20 -68
- package/src/server/storage/index.ts +0 -4
- package/src/server/storage/mountMediaRoutes.ts +1 -7
- package/src/server/storage/types.ts +2 -11
- package/src/shippedFilesAreTracked.spec.ts +3 -2
- package/dist/server/private-media/crypto.d.ts +0 -37
- package/dist/server/private-media/crypto.js +0 -111
- package/dist/server/private-media/index-store.d.ts +0 -31
- package/dist/server/private-media/index-store.js +0 -36
- package/dist/server/private-media/mountPrivateRoutes.d.ts +0 -26
- package/dist/server/private-media/mountPrivateRoutes.js +0 -219
- package/dist/server/private-media/pipeline.d.ts +0 -37
- package/dist/server/private-media/pipeline.js +0 -97
- package/dist/server/private-media/unlock.d.ts +0 -52
- package/dist/server/private-media/unlock.js +0 -55
- package/src/server/private-media/blobRoute.spec.ts +0 -165
- package/src/server/private-media/crypto.spec.ts +0 -81
- package/src/server/private-media/crypto.ts +0 -155
- package/src/server/private-media/gatePolicy.spec.ts +0 -98
- package/src/server/private-media/index-store.ts +0 -83
- package/src/server/private-media/mountPrivateRoutes.ts +0 -265
- package/src/server/private-media/pipeline.spec.ts +0 -111
- package/src/server/private-media/pipeline.ts +0 -170
- package/src/server/private-media/routes.spec.ts +0 -265
- package/src/server/private-media/unlock.ts +0 -112
- package/src/server/storage/files/catalogue.private.spec.ts +0 -106
|
@@ -6,4 +6,4 @@ export { createFileUrls, type FileUrls, type MountFileRoutesOptions, mountFileRo
|
|
|
6
6
|
export { FILE_CATALOGUE_MIGRATIONS } from './migrations';
|
|
7
7
|
export { createFilesStack, type FilesStack, type FilesStackMountAuth, type FilesStackOptions, } from './stack';
|
|
8
8
|
export { createLedgeredDeletes, createStorageOpsLedger, createStorageReconciler, type StorageAction, type StorageOpRow, type StorageOpsLedger, type StorageReconciler, } from './storageOps';
|
|
9
|
-
export { type CollectionKind, type CollectionVisibility, classifyFileType, FILE_SORTS, type FileCatalogue, type FileCollection, type FilePage, type FileRecord, type FileSort, type FileToPlayable, type FileType, type ListFilesOptions, type
|
|
9
|
+
export { type CollectionKind, type CollectionVisibility, classifyFileType, FILE_SORTS, type FileCatalogue, type FileCollection, type FilePage, type FileRecord, type FileSort, type FileToPlayable, type FileType, type ListFilesOptions, type TagFacet, } from './types';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// cursedbelt-server/storage FileCatalogue — a SQLite-backed tagged flat file system over the
|
|
2
|
-
// MediaStore + media pipeline
|
|
2
|
+
// MediaStore + media pipeline. One source of truth for a user's binaries; the
|
|
3
3
|
// video gallery is a video-filtered view over it.
|
|
4
4
|
export { createCatalogueMediaSource, createCataloguePhotoSource, createCatalogueVideoSource, createFileToPlayable, } from './adapter';
|
|
5
5
|
export { catalogueUploadKey, createSqliteFileCatalogue, fileBinaryKey, } from './catalogue';
|
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
// (not re-derived per list), tags live in an indexed junction (fast facets, not a JSON scan),
|
|
4
4
|
// processing state is typed columns (not an opaque blob), and a storage-operations ledger backs
|
|
5
5
|
// durable cross-boundary (binary-server/S3) ops. Flat — no parent_id tree.
|
|
6
|
+
//
|
|
7
|
+
// 🔴 `0001` is edited IN PLACE rather than followed by a drop-column migration (2026-09-17,
|
|
8
|
+
// removing private media). It is `CREATE TABLE IF NOT EXISTS` and is recorded in `_migrations`,
|
|
9
|
+
// so an existing database never re-runs it and simply keeps three vestigial columns
|
|
10
|
+
// (`is_private`, `enc_name`, `enc_dek`, `enc_key_type`, `file_tags.is_private`) that nothing
|
|
11
|
+
// reads; every one of them had `NOT NULL DEFAULT`, so the leaner INSERTs below still satisfy
|
|
12
|
+
// them. A fresh database gets the lean shape. That is safe here only because no app in the
|
|
13
|
+
// fleet mounts `createFilesStack` — if one ever does, removing a column becomes a real
|
|
14
|
+
// migration, not an edit.
|
|
6
15
|
export const FILE_CATALOGUE_MIGRATIONS = [
|
|
7
16
|
{
|
|
8
17
|
name: '0001_file_catalogue',
|
|
@@ -15,7 +24,6 @@ export const FILE_CATALOGUE_MIGRATIONS = [
|
|
|
15
24
|
mime TEXT NOT NULL DEFAULT '',
|
|
16
25
|
size INTEGER NOT NULL DEFAULT 0,
|
|
17
26
|
file_type TEXT NOT NULL DEFAULT 'other',
|
|
18
|
-
is_private INTEGER NOT NULL DEFAULT 0,
|
|
19
27
|
has_thumbnail INTEGER NOT NULL DEFAULT 0,
|
|
20
28
|
has_hls INTEGER NOT NULL DEFAULT 0,
|
|
21
29
|
has_sprites INTEGER NOT NULL DEFAULT 0,
|
|
@@ -23,9 +31,6 @@ export const FILE_CATALOGUE_MIGRATIONS = [
|
|
|
23
31
|
processing_status TEXT NOT NULL DEFAULT 'ready',
|
|
24
32
|
processing_progress INTEGER NOT NULL DEFAULT 100,
|
|
25
33
|
storage_backend TEXT NOT NULL DEFAULT 'disk',
|
|
26
|
-
enc_name TEXT NOT NULL DEFAULT '',
|
|
27
|
-
enc_dek TEXT NOT NULL DEFAULT '',
|
|
28
|
-
enc_key_type TEXT NOT NULL DEFAULT '',
|
|
29
34
|
created_at INTEGER NOT NULL,
|
|
30
35
|
updated_at INTEGER NOT NULL
|
|
31
36
|
)
|
|
@@ -33,7 +38,6 @@ export const FILE_CATALOGUE_MIGRATIONS = [
|
|
|
33
38
|
// Listing is cursor-keyed on (created_at, id); these cover the flat feed + smart-view filter.
|
|
34
39
|
db.run(`CREATE INDEX IF NOT EXISTS idx_files_owner_created ON files(owner, created_at DESC, id DESC)`);
|
|
35
40
|
db.run(`CREATE INDEX IF NOT EXISTS idx_files_owner_type_created ON files(owner, file_type, created_at DESC, id DESC)`);
|
|
36
|
-
db.run(`CREATE INDEX IF NOT EXISTS idx_files_owner_private ON files(owner, is_private)`);
|
|
37
41
|
// Tags as a junction (indexed) — single-tag filter + facet counts are index lookups, not a
|
|
38
42
|
// full-table JSON parse (ca's weakness). `owner` denormalized so facet queries stay covering.
|
|
39
43
|
db.run(`
|
|
@@ -41,7 +45,6 @@ export const FILE_CATALOGUE_MIGRATIONS = [
|
|
|
41
45
|
file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
|
42
46
|
tag TEXT NOT NULL,
|
|
43
47
|
owner TEXT NOT NULL,
|
|
44
|
-
is_private INTEGER NOT NULL DEFAULT 0,
|
|
45
48
|
PRIMARY KEY (file_id, tag)
|
|
46
49
|
)
|
|
47
50
|
`);
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import type { Database } from 'bun:sqlite';
|
|
2
2
|
import type { Context, Hono } from 'hono';
|
|
3
|
-
import { type UnlockPasswordPolicy, type UnlockService } from '../../private-media/unlock';
|
|
4
3
|
import { type MediaUrls, SourceRegistry } from '../sources';
|
|
5
4
|
import { type StreamTokenService } from '../streamToken';
|
|
6
5
|
import type { MediaCtx, MediaGallerySource, MediaSource, MediaStore, PhotoSource } from '../types';
|
|
7
6
|
import { type SqliteFileCatalogueOptions } from './catalogue';
|
|
8
7
|
import { type ChunkedUploadService, type MintChunkUpload } from './chunkedUpload';
|
|
9
8
|
import { type FileUrls } from './fileRoutes';
|
|
10
|
-
import type { FileCatalogue
|
|
9
|
+
import type { FileCatalogue } from './types';
|
|
11
10
|
export interface FilesStackOptions {
|
|
12
11
|
/** The catalogue database. The stack runs FILE_CATALOGUE_MIGRATIONS on it itself. */
|
|
13
12
|
db: Database;
|
|
@@ -17,9 +16,9 @@ export interface FilesStackOptions {
|
|
|
17
16
|
workDir: string;
|
|
18
17
|
/** Secret for the stream-token service (path-embedded byte-route auth). */
|
|
19
18
|
streamSecret: string;
|
|
20
|
-
/** Owner keyspace segment. Defaults to the userId (files
|
|
21
|
-
*
|
|
22
|
-
* agrees
|
|
19
|
+
/** Owner keyspace segment. Defaults to the userId (files are personal) — note the bare
|
|
20
|
+
* primitives default to tenantId; the stack picks the personal axis so every URL and route
|
|
21
|
+
* agrees on one owner. */
|
|
23
22
|
owner?: (ctx: MediaCtx) => string;
|
|
24
23
|
/** Registered video-source id (also the media-token sourceId). Default 'files'. */
|
|
25
24
|
sourceId?: string;
|
|
@@ -40,8 +39,6 @@ export interface FilesStackOptions {
|
|
|
40
39
|
mediaPublicBase?: string;
|
|
41
40
|
/** PUBLIC base for browser file URLs (split-origin counterpart of `filesBasePath`). */
|
|
42
41
|
filesPublicBase?: string;
|
|
43
|
-
/** Private (encrypted) route mount. Default '/_private'. */
|
|
44
|
-
privateBasePath?: string;
|
|
45
42
|
/** HLS ladder heights for video ingest (createSqliteFileCatalogue passthrough). */
|
|
46
43
|
ladderHeights?: SqliteFileCatalogueOptions['ladderHeights'];
|
|
47
44
|
/** Cap on transcode kicks per file before it's marked failed (createSqliteFileCatalogue
|
|
@@ -56,15 +53,6 @@ export interface FilesStackOptions {
|
|
|
56
53
|
/** Image thumbnail/display derivative implementation (createSqliteFileCatalogue passthrough).
|
|
57
54
|
* Default: local sharp with ffmpeg fallback. */
|
|
58
55
|
imageDerivatives?: SqliteFileCatalogueOptions['imageDerivatives'];
|
|
59
|
-
/** Enables the private (encrypted) stack: unlock service + /_private routes + private uploads.
|
|
60
|
-
* Omit ⇒ `unlock` is null and mount() wires only the public routes. `passwordPolicy` sets what
|
|
61
|
-
* a supplied password MEANS (derive = deniable sets, the default; gate = verify against the
|
|
62
|
-
* app's accounts + recoverable server-key sets — see UnlockPasswordPolicy). */
|
|
63
|
-
privateMedia?: {
|
|
64
|
-
serverSecret: string;
|
|
65
|
-
unlockSecret: string;
|
|
66
|
-
passwordPolicy?: UnlockPasswordPolicy;
|
|
67
|
-
};
|
|
68
56
|
/** Re-kick transcodes a restart left stuck (catalogue.recoverProcessing) at build time.
|
|
69
57
|
* Default true. */
|
|
70
58
|
recoverProcessing?: boolean;
|
|
@@ -90,18 +78,8 @@ export interface FilesStackMountAuth {
|
|
|
90
78
|
/** Session auth for uploads + listings (same guard mountMediaRoutes/mountFileRoutes take).
|
|
91
79
|
* Return a MediaCtx to allow, null to reject. */
|
|
92
80
|
authorizeUpload: (c: Context) => Promise<MediaCtx | null> | MediaCtx | null;
|
|
93
|
-
/** Session auth for the private layer → {userId, userSalt}. REQUIRED when `privateMedia` is
|
|
94
|
-
* configured (mount() throws otherwise); ignored when it isn't. */
|
|
95
|
-
authorizePrivate?: (c: Context) => Promise<{
|
|
96
|
-
userId: string;
|
|
97
|
-
userSalt: string;
|
|
98
|
-
} | null>;
|
|
99
81
|
/** Wire large-file chunked uploads (mountFileRoutes' `chunked` passthrough). */
|
|
100
82
|
mintChunkUpload?: ChunkedUploadService;
|
|
101
|
-
/** Private-upload password header (default 'x-private-password'). */
|
|
102
|
-
passwordHeader?: string;
|
|
103
|
-
/** Private-upload unlock-token header (default 'x-unlock-token'). */
|
|
104
|
-
tokenHeader?: string;
|
|
105
83
|
}
|
|
106
84
|
/**
|
|
107
85
|
* The assembled stack. `catalogue` / `fileUrls` / `mediaUrls` / `videoSource` are intentionally
|
|
@@ -110,7 +88,7 @@ export interface FilesStackMountAuth {
|
|
|
110
88
|
* not, layering) import cursedshell.
|
|
111
89
|
*/
|
|
112
90
|
export interface FilesStack {
|
|
113
|
-
catalogue: FileCatalogue
|
|
91
|
+
catalogue: FileCatalogue;
|
|
114
92
|
token: StreamTokenService;
|
|
115
93
|
mediaUrls: MediaUrls;
|
|
116
94
|
fileUrls: FileUrls;
|
|
@@ -120,15 +98,12 @@ export interface FilesStack {
|
|
|
120
98
|
/** The MIXED view — photos + videos in one feed (media gallery data source). */
|
|
121
99
|
mediaSource: MediaGallerySource;
|
|
122
100
|
registry: SourceRegistry;
|
|
123
|
-
/** Null unless `privateMedia` was configured. */
|
|
124
|
-
unlock: UnlockService | null;
|
|
125
101
|
/** The large-file chunked-upload service, or null unless `chunkedUpload` was configured. mount()
|
|
126
102
|
* auto-wires it into the file routes; exposed for observability (pending()) + tests. */
|
|
127
103
|
chunked: ChunkedUploadService | null;
|
|
128
104
|
/**
|
|
129
105
|
* Mount every byte/upload route the stack owns onto a Hono app: mountMediaRoutes
|
|
130
|
-
* (mediaBasePath) + mountFileRoutes (filesBasePath
|
|
131
|
-
* mountPrivateRoutes (privateBasePath, when unlock exists). Auth stays host-injected.
|
|
106
|
+
* (mediaBasePath) + mountFileRoutes (filesBasePath). Auth stays host-injected.
|
|
132
107
|
*/
|
|
133
108
|
mount(app: Hono, auth: FilesStackMountAuth): void;
|
|
134
109
|
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { runMigrations } from '../../migration/runner';
|
|
2
|
-
import { mountPrivateRoutes } from '../../private-media/mountPrivateRoutes';
|
|
3
|
-
import { createUnlockService, } from '../../private-media/unlock';
|
|
4
2
|
import { mountMediaRoutes } from '../mountMediaRoutes';
|
|
5
3
|
import { createMediaUrls, SourceRegistry } from '../sources';
|
|
6
4
|
import { createStreamTokenService } from '../streamToken';
|
|
@@ -65,7 +63,6 @@ export function createFilesStack(opts) {
|
|
|
65
63
|
videoSourceId: sourceId,
|
|
66
64
|
});
|
|
67
65
|
const registry = new SourceRegistry().register(videoSource);
|
|
68
|
-
const unlock = opts.privateMedia ? createUnlockService(opts.privateMedia) : null;
|
|
69
66
|
const chunked = opts.chunkedUpload
|
|
70
67
|
? createChunkedUploadService({
|
|
71
68
|
catalogue,
|
|
@@ -85,13 +82,8 @@ export function createFilesStack(opts) {
|
|
|
85
82
|
photoSource,
|
|
86
83
|
mediaSource,
|
|
87
84
|
registry,
|
|
88
|
-
unlock,
|
|
89
85
|
chunked,
|
|
90
86
|
mount(app, auth) {
|
|
91
|
-
if (unlock && !auth.authorizePrivate) {
|
|
92
|
-
throw new Error('createFilesStack: privateMedia is configured but mount() got no authorizePrivate — ' +
|
|
93
|
-
'the private routes need a session→{userId, userSalt} resolver.');
|
|
94
|
-
}
|
|
95
87
|
mountMediaRoutes(app, {
|
|
96
88
|
store,
|
|
97
89
|
token,
|
|
@@ -107,26 +99,7 @@ export function createFilesStack(opts) {
|
|
|
107
99
|
authorizeUpload: auth.authorizeUpload,
|
|
108
100
|
// Prefer a mount-time override, else the service the stack built from `chunkedUpload` opts.
|
|
109
101
|
chunked: auth.mintChunkUpload ?? chunked ?? undefined,
|
|
110
|
-
privateUpload: unlock && auth.authorizePrivate
|
|
111
|
-
? {
|
|
112
|
-
unlock,
|
|
113
|
-
authorize: auth.authorizePrivate,
|
|
114
|
-
passwordHeader: auth.passwordHeader,
|
|
115
|
-
tokenHeader: auth.tokenHeader,
|
|
116
|
-
passwordPolicy: opts.privateMedia?.passwordPolicy,
|
|
117
|
-
}
|
|
118
|
-
: undefined,
|
|
119
102
|
});
|
|
120
|
-
if (unlock && auth.authorizePrivate) {
|
|
121
|
-
mountPrivateRoutes(app, {
|
|
122
|
-
store,
|
|
123
|
-
index: catalogue.asPrivateIndex(),
|
|
124
|
-
unlock,
|
|
125
|
-
basePath: opts.privateBasePath,
|
|
126
|
-
authorize: auth.authorizePrivate,
|
|
127
|
-
passwordPolicy: opts.privateMedia?.passwordPolicy,
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
103
|
},
|
|
131
104
|
};
|
|
132
105
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { PlayableVideo, ProcessingStatus } from 'cursedbelt-core/media';
|
|
2
|
-
import type { PrivateIndex } from '../../private-media/index-store';
|
|
3
2
|
import type { MediaCtx } from '../types';
|
|
4
3
|
export type FileType = 'image' | 'video' | 'audio' | 'doc' | 'code' | 'other';
|
|
5
4
|
/** User-defined groupings (Apple-Photos-style). `favorites` is a system singleton per owner;
|
|
@@ -26,7 +25,6 @@ export type FileRecord = {
|
|
|
26
25
|
size: number;
|
|
27
26
|
fileType: FileType;
|
|
28
27
|
tags: string[];
|
|
29
|
-
isPrivate: boolean;
|
|
30
28
|
hasThumbnail: boolean;
|
|
31
29
|
hasHls: boolean;
|
|
32
30
|
hasSprites: boolean;
|
|
@@ -67,11 +65,8 @@ export type ListFilesOptions = {
|
|
|
67
65
|
type?: FileType;
|
|
68
66
|
/** Single-tag filter (indexed via the join table). */
|
|
69
67
|
tag?: string;
|
|
70
|
-
/** Name substring
|
|
68
|
+
/** Name substring. */
|
|
71
69
|
query?: string;
|
|
72
|
-
/** Include the caller's private files (requires an unlocked set — the caller resolves which
|
|
73
|
-
* ones actually open). Omitted ⇒ public only, matching ca's default listing. */
|
|
74
|
-
includePrivate?: boolean;
|
|
75
70
|
/** Filter to members of a specific collection. An unlock-gated collection yields empty unless
|
|
76
71
|
* `includeUnlockable` is also true. */
|
|
77
72
|
collectionId?: string;
|
|
@@ -93,14 +88,11 @@ export declare function classifyFileType(mime: string, isText?: boolean): FileTy
|
|
|
93
88
|
/** The catalogue contract. A SQLite implementation is the production default; the interface lets
|
|
94
89
|
* an app swap it. All calls are owner-scoped via {@link MediaCtx} (one tenant never sees another). */
|
|
95
90
|
/** Cheap owner-scoped aggregates (COUNT/SUM over the files table) — the admin
|
|
96
|
-
* surface's "site stats" source.
|
|
97
|
-
* (names/kinds stay sealed). */
|
|
91
|
+
* surface's "site stats" source. */
|
|
98
92
|
export type CatalogueStats = {
|
|
99
93
|
files: number;
|
|
100
94
|
bytes: number;
|
|
101
95
|
byType: Partial<Record<FileType, number>>;
|
|
102
|
-
privateItems: number;
|
|
103
|
-
privateBytes: number;
|
|
104
96
|
processing: number;
|
|
105
97
|
failed: number;
|
|
106
98
|
};
|
|
@@ -109,9 +101,7 @@ export interface FileCatalogue {
|
|
|
109
101
|
get(ctx: MediaCtx, id: string): Promise<FileRecord | null>;
|
|
110
102
|
/** Owner-scoped aggregates for admin/stats surfaces. */
|
|
111
103
|
stats(ctx: MediaCtx): Promise<CatalogueStats>;
|
|
112
|
-
tagFacets(ctx: MediaCtx
|
|
113
|
-
includePrivate?: boolean;
|
|
114
|
-
}): Promise<TagFacet[]>;
|
|
104
|
+
tagFacets(ctx: MediaCtx): Promise<TagFacet[]>;
|
|
115
105
|
/** Ingest a public upload: persist bytes, classify, kick off processing (thumbnail/HLS). */
|
|
116
106
|
ingest(ctx: MediaCtx, upload: {
|
|
117
107
|
name: string;
|
|
@@ -137,9 +127,8 @@ export interface FileCatalogue {
|
|
|
137
127
|
}): Promise<FileRecord | null>;
|
|
138
128
|
/** Overwrite a file's BYTES in place — the identity (id, tags, collections, favorites, view
|
|
139
129
|
* history) survives while mime/size/fileType re-derive, stale derivatives are wiped, and
|
|
140
|
-
* processing re-kicks (video transcode / image thumbnail).
|
|
141
|
-
*
|
|
142
|
-
* or null if the id is unknown/other-owner. */
|
|
130
|
+
* processing re-kicks (video transcode / image thumbnail). Returns the refreshed record, or
|
|
131
|
+
* null if the id is unknown/other-owner. */
|
|
143
132
|
replaceContent(ctx: MediaCtx, id: string, upload: {
|
|
144
133
|
mime: string;
|
|
145
134
|
body: ReadableStream<Uint8Array>;
|
|
@@ -171,20 +160,20 @@ export interface FileCatalogue {
|
|
|
171
160
|
reason: string;
|
|
172
161
|
}[];
|
|
173
162
|
}>;
|
|
174
|
-
/** Recover from a restart that lost in-flight transcodes: re-kick
|
|
175
|
-
* pending/processing (their source persists), and mark
|
|
176
|
-
* app startup. Returns how many were re-queued vs. failed. */
|
|
163
|
+
/** Recover from a restart that lost in-flight transcodes: re-kick video rows still marked
|
|
164
|
+
* pending/processing (their source persists), and mark rows that have exhausted their kick
|
|
165
|
+
* budget failed. Call once at app startup. Returns how many were re-queued vs. failed. */
|
|
177
166
|
recoverProcessing(): {
|
|
178
167
|
requeued: number;
|
|
179
168
|
failed: number;
|
|
180
169
|
};
|
|
181
|
-
/** Backfill grid thumbnails for finished
|
|
170
|
+
/** Backfill grid thumbnails for finished video rows that have no poster (landed while
|
|
182
171
|
* transcoding was off, or failed before the poster step). Re-kicks the poster pipeline for each.
|
|
183
172
|
* Returns how many were re-queued. */
|
|
184
173
|
backfillVideoPosters(): {
|
|
185
174
|
requeued: number;
|
|
186
175
|
};
|
|
187
|
-
/** Backfill grid thumbnails for
|
|
176
|
+
/** Backfill grid thumbnails for image rows with no poster (e.g. HEIC photos whose sharp
|
|
188
177
|
* thumbnail failed before the ffmpeg fallback). Re-kicks the image pipeline. */
|
|
189
178
|
backfillImageThumbnails(): {
|
|
190
179
|
requeued: number;
|
|
@@ -220,42 +209,3 @@ export interface FileCatalogue {
|
|
|
220
209
|
}
|
|
221
210
|
/** Resolve a video FileRecord to a PlayableVideo (URLs baked server-side). Non-video ⇒ null. */
|
|
222
211
|
export type FileToPlayable = (ctx: MediaCtx, file: FileRecord) => Promise<PlayableVideo | null>;
|
|
223
|
-
/** How a private item's content key is wrapped: `standard` (server-recoverable) vs `deniable`
|
|
224
|
-
* (password-derived; zero-knowledge). Mirrors private-media `KeyMode`. */
|
|
225
|
-
export type PrivateKeyMode = 'standard' | 'deniable';
|
|
226
|
-
/** The OPAQUE locked-index row — only non-sensitive metadata (so a user can prune media whose
|
|
227
|
-
* password is lost). NEVER name/tags/thumbnail: those are sealed. No set identifier is exposed. */
|
|
228
|
-
export type PrivateFileItem = {
|
|
229
|
-
id: string;
|
|
230
|
-
size: number;
|
|
231
|
-
createdAt: number;
|
|
232
|
-
processingStatus: ProcessingStatus;
|
|
233
|
-
processingProgress: number;
|
|
234
|
-
hasHls: boolean;
|
|
235
|
-
};
|
|
236
|
-
/** Private-media capability of a catalogue. `createSqliteFileCatalogue` implements this alongside
|
|
237
|
-
* {@link FileCatalogue}, storing private items as sealed rows in the SAME table (one source of
|
|
238
|
-
* truth). Membership is by decrypt-success — no stored password/set hash.
|
|
239
|
-
*
|
|
240
|
-
* Deliberately small: the catalogue owns only INGEST (the new private-upload path). Everything
|
|
241
|
-
* else — the locked index, unlock, try-a-password reveal, the gated content-key route, and the
|
|
242
|
-
* encrypted-HLS byte serving — is the existing `mountPrivateRoutes`, which operates through a
|
|
243
|
-
* {@link PrivateIndex}. `asPrivateIndex()` is the view that lets those routes read/write the
|
|
244
|
-
* catalogue's private rows, so there is exactly one store and one set of routes. */
|
|
245
|
-
export interface PrivateFileCatalogue {
|
|
246
|
-
/** Ingest an encrypted video: stage plaintext (never persisted), transcode to AES-128 HLS, seal
|
|
247
|
-
* the descriptor + wrap the content key under `setKey`, and insert a private row. */
|
|
248
|
-
ingestPrivate(ctx: MediaCtx, upload: {
|
|
249
|
-
name: string;
|
|
250
|
-
mime: string;
|
|
251
|
-
body: ReadableStream<Uint8Array>;
|
|
252
|
-
size?: number;
|
|
253
|
-
tags?: string[];
|
|
254
|
-
}, opts: {
|
|
255
|
-
setKey: Buffer;
|
|
256
|
-
keyMode: PrivateKeyMode;
|
|
257
|
-
}): Promise<PrivateFileItem>;
|
|
258
|
-
/** A {@link PrivateIndex} view over this catalogue's private rows — pass to `mountPrivateRoutes`
|
|
259
|
-
* so unlock/index/reveal/key/HLS all operate on the catalogue. `remove` also drops the bytes. */
|
|
260
|
-
asPrivateIndex(): PrivateIndex;
|
|
261
|
-
}
|
|
@@ -20,3 +20,12 @@ export function classifyFileType(mime, isText = false) {
|
|
|
20
20
|
return 'doc';
|
|
21
21
|
return 'other';
|
|
22
22
|
}
|
|
23
|
+
// ── There is no private half, deliberately (removed 2026-09-17) ───────────────────
|
|
24
|
+
// A `PrivateFileCatalogue` used to sit here: sealed descriptors, wrapped content keys, a
|
|
25
|
+
// `KeyMode: 'standard' | 'deniable'` and membership "by decrypt-success — no stored password".
|
|
26
|
+
// It was 1,505 lines that no app ever mounted, over a byte store that encrypts nothing
|
|
27
|
+
// (binary-server's `exportFiles.ts`: *"Objects are stored unencrypted"*), so there was never a
|
|
28
|
+
// ciphertext behind it — and the owner chose to delete the whole module rather than carry a
|
|
29
|
+
// second, unexercised confidentiality story alongside the master lock, which is the one that
|
|
30
|
+
// actually guards these apps. This paragraph is the record; it is not an invitation to
|
|
31
|
+
// reintroduce it. The wall that IS real is `../../master-lock`.
|
|
@@ -4,12 +4,12 @@ export { createBinaryServerStore } from './binaryServerStore';
|
|
|
4
4
|
export { type BinaryServerProcessingConfig, createBinaryServerProcessing, } from './binaryServerProcessing';
|
|
5
5
|
export { createTranscodeJobClient, type TranscodeJobClient, type TranscodeJobClientConfig, type TranscodeJobResult, type TranscodeJobStatus, type TranscodeJobStatusBody, } from './transcodeJobs';
|
|
6
6
|
export { createDiskStore } from './diskStore';
|
|
7
|
-
export { type CatalogueStats, type ChunkedInit, type ChunkedUploadService, catalogueUploadKey, classifyFileType, createCatalogueMediaSource, createCataloguePhotoSource, createCatalogueVideoSource, createChunkedUploadService, createFilesStack, createFileToPlayable, createFileUrls, createLedgeredDeletes, createSqliteFileCatalogue, createStorageOpsLedger, createStorageReconciler, FILE_CATALOGUE_MIGRATIONS, type FileCatalogue, type FilePage, type FileRecord, type FilesStack, type FilesStackMountAuth, type FilesStackOptions, type FileToPlayable, type FileType, type FileUrls, fileBinaryKey, type ImageDerivatives, type ImageDerivativesOptions, type ListFilesOptions, type MintChunkUpload, type MountFileRoutesOptions, mountFileRoutes, type
|
|
7
|
+
export { type CatalogueStats, type ChunkedInit, type ChunkedUploadService, catalogueUploadKey, classifyFileType, createCatalogueMediaSource, createCataloguePhotoSource, createCatalogueVideoSource, createChunkedUploadService, createFilesStack, createFileToPlayable, createFileUrls, createLedgeredDeletes, createSqliteFileCatalogue, createStorageOpsLedger, createStorageReconciler, FILE_CATALOGUE_MIGRATIONS, type FileCatalogue, type FilePage, type FileRecord, type FilesStack, type FilesStackMountAuth, type FilesStackOptions, type FileToPlayable, type FileType, type FileUrls, fileBinaryKey, type ImageDerivatives, type ImageDerivativesOptions, type ListFilesOptions, type MintChunkUpload, type MountFileRoutesOptions, mountFileRoutes, type SqliteFileCatalogueOptions, type StorageAction, type StorageOpRow, type StorageOpsLedger, type StorageReconciler, type VideoProcessor, } from './files';
|
|
8
8
|
export { type CorsResolver, type MountMediaOptions, mountMediaRoutes } from './mountMediaRoutes';
|
|
9
9
|
export { createDiskSource, createMediaUrls, type DiskSource, type MediaUrls, SourceRegistry, } from './sources';
|
|
10
10
|
export { createS3ObjectStore, type S3ClientLike, type S3ObjectHandle, type S3ObjectStoreOptions, type S3ObjectWriter, } from './s3ObjectStore';
|
|
11
11
|
export { decodeRange, type RangeDecision, type ServeRangeOpts, serveRange } from './streamRange';
|
|
12
12
|
export { createStreamTokenService, type StreamGrant, type StreamTokenService } from './streamToken';
|
|
13
|
-
export { fsHlsFileKey, fsHlsMasterKey, fsHlsPrefix, fsMp4Key, fsPosterKey, fsSourceKey, fsSpriteKey, fsVttKey, type MediaCtx, type MediaGallerySource, type MediaSource, type MediaStore, type PhotoSource,
|
|
13
|
+
export { fsHlsFileKey, fsHlsMasterKey, fsHlsPrefix, fsMp4Key, fsPosterKey, fsSourceKey, fsSpriteKey, fsVttKey, type MediaCtx, type MediaGallerySource, type MediaSource, type MediaStore, type PhotoSource, } from './types';
|
|
14
14
|
export { type ProcessOptions, type ProcessResult, processVideo, } from './videoProcessor';
|
|
15
15
|
export { objectStoreAsMediaStore } from './objectStoreMediaStore';
|
|
@@ -22,6 +22,6 @@ export { createDiskSource, createMediaUrls, SourceRegistry, } from './sources';
|
|
|
22
22
|
export { createS3ObjectStore, } from './s3ObjectStore';
|
|
23
23
|
export { decodeRange, serveRange } from './streamRange';
|
|
24
24
|
export { createStreamTokenService } from './streamToken';
|
|
25
|
-
export { fsHlsFileKey, fsHlsMasterKey, fsHlsPrefix, fsMp4Key, fsPosterKey, fsSourceKey, fsSpriteKey, fsVttKey,
|
|
25
|
+
export { fsHlsFileKey, fsHlsMasterKey, fsHlsPrefix, fsMp4Key, fsPosterKey, fsSourceKey, fsSpriteKey, fsVttKey, } from './types';
|
|
26
26
|
export { processVideo, } from './videoProcessor';
|
|
27
27
|
export { objectStoreAsMediaStore } from './objectStoreMediaStore';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Context, Hono } from 'hono';
|
|
2
2
|
import type { SourceRegistry } from './sources';
|
|
3
|
-
import type {
|
|
3
|
+
import type { StreamTokenService } from './streamToken';
|
|
4
4
|
import { type MediaCtx, type MediaStore } from './types';
|
|
5
5
|
/** Resolves the Access-Control-Allow-Origin for a request origin: a fixed origin, an allowlist
|
|
6
6
|
* (echoes the request origin when allowed), or a custom resolver (return null to deny). */
|
|
@@ -21,8 +21,5 @@ export type MountMediaOptions = {
|
|
|
21
21
|
/** Authorizes an upload request (it CAN send the Bearer, unlike a media element). Return a
|
|
22
22
|
* MediaCtx to allow, or null to reject. Omit ⇒ uploads are disabled on this mount. */
|
|
23
23
|
authorizeUpload?: (c: Context) => Promise<MediaCtx | null> | MediaCtx | null;
|
|
24
|
-
/** Optional hook to decrypt bytes for the private (pfs/…) keyspace before serving. Wired by
|
|
25
|
-
* the private-media layer; omitted ⇒ all bytes served as stored. */
|
|
26
|
-
decryptStream?: (stream: ReadableStream<Uint8Array>, grant: StreamGrant) => ReadableStream<Uint8Array>;
|
|
27
24
|
};
|
|
28
25
|
export declare function mountMediaRoutes(app: Hono, opts: MountMediaOptions): void;
|
|
@@ -182,12 +182,3 @@ export declare const fsPosterKey: (owner: string, id: string) => string;
|
|
|
182
182
|
export declare const fsDisplayKey: (owner: string, id: string) => string;
|
|
183
183
|
export declare const fsSpriteKey: (owner: string, id: string) => string;
|
|
184
184
|
export declare const fsVttKey: (owner: string, id: string) => string;
|
|
185
|
-
/** Private (encrypted) namespace — the private-media layer stores ciphertext here. The HLS
|
|
186
|
-
* segments under it are AES-128-encrypted at rest (the player fetches the content key via the
|
|
187
|
-
* gated key route and decrypts); a distinct keyspace from public `hls/` so the two never collide. */
|
|
188
|
-
export declare const privateFsKey: (owner: string, id: string) => string;
|
|
189
|
-
export declare const privateHlsPrefix: (owner: string, id: string) => string;
|
|
190
|
-
export declare const privateHlsMasterKey: (owner: string, id: string) => string;
|
|
191
|
-
export declare const privateHlsFileKey: (owner: string, id: string, rel: string) => string;
|
|
192
|
-
export declare const privatePosterKey: (owner: string, id: string) => string;
|
|
193
|
-
export declare const privateSourceKey: (owner: string, id: string) => string;
|
|
@@ -30,12 +30,5 @@ export const fsPosterKey = (owner, id) => `fs/${owner}/${id}-poster.jpg`;
|
|
|
30
30
|
export const fsDisplayKey = (owner, id) => `fs/${owner}/${id}-display.jpg`;
|
|
31
31
|
export const fsSpriteKey = (owner, id) => `fs/${owner}/${id}-sprites.jpg`;
|
|
32
32
|
export const fsVttKey = (owner, id) => `fs/${owner}/${id}-sprites.vtt`;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
* gated key route and decrypts); a distinct keyspace from public `hls/` so the two never collide. */
|
|
36
|
-
export const privateFsKey = (owner, id) => `pfs/${owner}/${id}`;
|
|
37
|
-
export const privateHlsPrefix = (owner, id) => `pfs/${owner}/${id}/hls`;
|
|
38
|
-
export const privateHlsMasterKey = (owner, id) => `pfs/${owner}/${id}/hls/master.m3u8`;
|
|
39
|
-
export const privateHlsFileKey = (owner, id, rel) => `pfs/${owner}/${id}/hls/${rel}`;
|
|
40
|
-
export const privatePosterKey = (owner, id) => `pfs/${owner}/${id}/poster.enc`;
|
|
41
|
-
export const privateSourceKey = (owner, id) => `pfs/${owner}/${id}/source.enc`;
|
|
33
|
+
// The `pfs/` (ciphertext) keyspace was deleted with private media on 2026-09-17 — nothing ever
|
|
34
|
+
// wrote a byte to it on this fleet, and binary-server stores objects unencrypted regardless.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
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.",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
|
21
21
|
"test": "NODE_ENV=development bun test src",
|
|
22
22
|
"build": "bun run clean && tsc -p tsconfig.build.json",
|
|
23
|
-
"paths": "bun
|
|
23
|
+
"paths": "bun run scripts/paths.ts",
|
|
24
24
|
"verify": "bun run paths && bun run typecheck && bun run build && bun run test",
|
|
25
25
|
"prepublishOnly": "bun run verify",
|
|
26
26
|
"test:pg": "bun run scripts/testPg.ts"
|
|
@@ -85,8 +85,9 @@ import pkg from '../package.json';
|
|
|
85
85
|
* fixture stopped demonstrating that peers are unreachable
|
|
86
86
|
* → red on the control itself: "the fixture resolved 'otplib' … Every assertion below
|
|
87
87
|
* is vacuous until that is fixed". The vacuity guard fires.
|
|
88
|
-
* · `src/server/private-media/unlock.ts` put back to
|
|
89
|
-
* second, two-hop edge, which grep for `../auth` inside
|
|
88
|
+
* · `src/server/private-media/unlock.ts` (deleted 2026-09-17) put back to
|
|
89
|
+
* `import … from '../auth'` — the second, two-hop edge, which grep for `../auth` inside
|
|
90
|
+
* `src/server/storage` cannot see
|
|
90
91
|
* → red: "importing cursedbelt/server/storage statically pulls these OPTIONAL peers
|
|
91
92
|
* … + otplib". This is the branch that found that edge in the first place.
|
|
92
93
|
*/
|
|
@@ -276,8 +277,9 @@ console.log(JSON.stringify(out));
|
|
|
276
277
|
const NO_STATIC_OPTIONAL_PEERS = [
|
|
277
278
|
{
|
|
278
279
|
subpath: './storage',
|
|
279
|
-
/** Its `streamToken.ts` and (two hops out) `private-media/unlock.ts`
|
|
280
|
-
* `../auth` index for two jose functions, so `otplib` was static in
|
|
280
|
+
/** Its `streamToken.ts` and (two hops out) the since-deleted `private-media/unlock.ts`
|
|
281
|
+
* both imported the `../auth` index for two jose functions, so `otplib` was static in
|
|
282
|
+
* here until 2.6.0. The edge this guards against is the import, not the file. */
|
|
281
283
|
entry: 'src/server/storage/index.ts',
|
|
282
284
|
},
|
|
283
285
|
] as const;
|
package/src/server/auth/jwt.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { AppJwtPayload } from './types';
|
|
|
4
4
|
/**
|
|
5
5
|
* JWT sign/verify on `jose` (Web Crypto HMAC — native on Bun + Node, no native
|
|
6
6
|
* deps; replaces `jsonwebtoken`). Every token system in the apps — the access
|
|
7
|
-
* token plus the short-lived vault/
|
|
7
|
+
* token plus the short-lived vault/files/yt-oauth unlock tokens —
|
|
8
8
|
* goes through these, so there is exactly ONE jose integration to reason about.
|
|
9
9
|
*
|
|
10
10
|
* jose verifies any standard HS256 JWT regardless of which library signed it, so a
|
|
@@ -57,7 +57,7 @@ export async function verifyToken<T extends JWTPayload = AppJwtPayload>(
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
* Verify a single-purpose token (vault/
|
|
60
|
+
* Verify a single-purpose token (vault/files/yt-oauth) AND assert its
|
|
61
61
|
* `purpose` claim matches — so an access token (or a token for a *different*
|
|
62
62
|
* feature) can never be replayed as, say, a vault-unlock token. Throws on a bad
|
|
63
63
|
* signature, expiry, or a purpose mismatch.
|