cursedbelt-server 3.0.0 → 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/activity/index.d.ts +1 -1
- package/dist/server/activity/index.js +1 -1
- 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 +57 -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/activity/index.ts +1 -1
- 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.spec.ts +25 -0
- package/src/server/master-lock/guard.ts +65 -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
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
import type { Context, Hono } from 'hono';
|
|
2
2
|
import { MAX_SINGLE_UPLOAD_BYTES } from 'cursedbelt-core/media/upload-limits';
|
|
3
|
-
import type { UnlockPasswordPolicy, UnlockService } from '../../private-media/unlock';
|
|
4
3
|
import { serveRange } from '../streamRange';
|
|
5
4
|
import type { StreamTokenService } from '../streamToken';
|
|
6
5
|
import type { MediaCtx, MediaStore } from '../types';
|
|
7
6
|
import { fileBinaryKey } from './catalogue';
|
|
8
7
|
import type { ChunkedUploadService } from './chunkedUpload';
|
|
9
|
-
import {
|
|
10
|
-
FILE_SORTS,
|
|
11
|
-
type FileCatalogue,
|
|
12
|
-
type ListFilesOptions,
|
|
13
|
-
type PrivateFileCatalogue,
|
|
14
|
-
} from './types';
|
|
8
|
+
import { FILE_SORTS, type FileCatalogue, type ListFilesOptions } from './types';
|
|
15
9
|
|
|
16
10
|
// ── File binary + upload routes ─────────────────────────────────────────────────
|
|
17
11
|
// The FileCatalogue's byte edge for GENERAL files (images/docs/audio/any) — the piece
|
|
@@ -106,21 +100,7 @@ export type MountFileRoutesOptions = {
|
|
|
106
100
|
/** Session auth for the upload PUTs (a fetch CAN send the Bearer). Return a ctx or null. */
|
|
107
101
|
authorizeUpload?: (c: Context) => Promise<MediaCtx | null> | MediaCtx | null;
|
|
108
102
|
/** The catalogue uploads land in. Required for the upload routes to be mounted. */
|
|
109
|
-
catalogue?: FileCatalogue
|
|
110
|
-
/** Wire private uploads: derive a per-user set key from the request (password header ⇒ deniable,
|
|
111
|
-
* absent ⇒ standard server key). Omit ⇒ the private-upload route is disabled. */
|
|
112
|
-
privateUpload?: {
|
|
113
|
-
unlock: UnlockService;
|
|
114
|
-
authorize: (c: Context) => Promise<{ userId: string; userSalt: string } | null>;
|
|
115
|
-
/** Header carrying the plaintext password for a deniable set (default 'x-private-password'). */
|
|
116
|
-
passwordHeader?: string;
|
|
117
|
-
/** Header carrying an UNLOCK TOKEN (an already-unlocked session) — takes precedence over the
|
|
118
|
-
* password header; setKey + keyMode come from the verified grant (default 'x-unlock-token'). */
|
|
119
|
-
tokenHeader?: string;
|
|
120
|
-
/** What a supplied password means (default derive — deniable set). `gate` verifies it
|
|
121
|
-
* against the app's accounts and wraps under the recoverable server key instead. */
|
|
122
|
-
passwordPolicy?: UnlockPasswordPolicy;
|
|
123
|
-
};
|
|
103
|
+
catalogue?: FileCatalogue;
|
|
124
104
|
/** Wire large-file chunked uploads (direct-to-backend). Omit ⇒ init/complete routes are absent
|
|
125
105
|
* and clients use the streamed `PUT /upload`. Uses the same `authorizeUpload` guard. */
|
|
126
106
|
chunked?: ChunkedUploadService;
|
|
@@ -197,80 +177,6 @@ export function mountFileRoutes(app: Hono, opts: MountFileRoutesOptions): void {
|
|
|
197
177
|
});
|
|
198
178
|
}
|
|
199
179
|
|
|
200
|
-
// PUT a PRIVATE upload → catalogue.ingestPrivate under a set key derived from the request:
|
|
201
|
-
// either an UNLOCK TOKEN (an already-unlocked session — same mechanism the content-key route
|
|
202
|
-
// uses) or a password header (deniable) / neither (standard server key).
|
|
203
|
-
const pv = opts.privateUpload;
|
|
204
|
-
if (pv && catalogue) {
|
|
205
|
-
const passwordHeader = pv.passwordHeader ?? 'x-private-password';
|
|
206
|
-
const tokenHeader = pv.tokenHeader ?? 'x-unlock-token';
|
|
207
|
-
app.put(`${base}/upload-private`, async (c) => {
|
|
208
|
-
const who = await pv.authorize(c);
|
|
209
|
-
if (!who) return c.json({ error: 'unauthorized' }, 401);
|
|
210
|
-
const raw = c.req.raw.body;
|
|
211
|
-
if (!raw) return c.json({ error: 'empty body' }, 400);
|
|
212
|
-
const body = guardSingleUpload(c, raw);
|
|
213
|
-
if (body instanceof Response) return body;
|
|
214
|
-
|
|
215
|
-
let setKey: Buffer;
|
|
216
|
-
let keyMode: 'standard' | 'deniable';
|
|
217
|
-
const unlockToken = c.req.header(tokenHeader) || undefined;
|
|
218
|
-
if (unlockToken) {
|
|
219
|
-
// Token path: recover the sealed set key + mode from the grant. The token must belong
|
|
220
|
-
// to the session caller.
|
|
221
|
-
const grant = await pv.unlock.verify(unlockToken);
|
|
222
|
-
if (!grant || grant.userId !== who.userId) {
|
|
223
|
-
return c.json({ error: 'invalid unlock token' }, 401);
|
|
224
|
-
}
|
|
225
|
-
setKey = grant.setKey;
|
|
226
|
-
keyMode = grant.mode;
|
|
227
|
-
} else {
|
|
228
|
-
// Password path: derive fresh from the header (deniable) or the server key. Under the
|
|
229
|
-
// gate policy the password is an access check only — verify, then wrap standard.
|
|
230
|
-
const password = c.req.header(passwordHeader) || undefined;
|
|
231
|
-
const policy = pv.passwordPolicy ?? { mode: 'derive' as const };
|
|
232
|
-
let derivePassword = password;
|
|
233
|
-
if (policy.mode === 'gate' && password) {
|
|
234
|
-
if (!(await policy.verifyPassword(who, password))) {
|
|
235
|
-
return c.json({ error: 'invalid password' }, 401);
|
|
236
|
-
}
|
|
237
|
-
derivePassword = undefined;
|
|
238
|
-
}
|
|
239
|
-
setKey = pv.unlock.deriveSetKey({
|
|
240
|
-
userId: who.userId,
|
|
241
|
-
userSalt: who.userSalt,
|
|
242
|
-
password: derivePassword,
|
|
243
|
-
});
|
|
244
|
-
keyMode = derivePassword ? 'deniable' : 'standard';
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// The private owner axis is the userId (deniability is personal), matching mountPrivateRoutes.
|
|
248
|
-
const ctx: MediaCtx = { userId: who.userId, tenantId: who.userId };
|
|
249
|
-
const tags = c.req.header('x-tags');
|
|
250
|
-
try {
|
|
251
|
-
const item = await catalogue.ingestPrivate(
|
|
252
|
-
ctx,
|
|
253
|
-
{
|
|
254
|
-
name: c.req.header('x-filename') ?? 'upload',
|
|
255
|
-
mime: c.req.header('content-type') ?? 'application/octet-stream',
|
|
256
|
-
body,
|
|
257
|
-
tags: tags
|
|
258
|
-
? tags
|
|
259
|
-
.split(',')
|
|
260
|
-
.map((t) => t.trim())
|
|
261
|
-
.filter(Boolean)
|
|
262
|
-
: undefined,
|
|
263
|
-
},
|
|
264
|
-
{ setKey, keyMode },
|
|
265
|
-
);
|
|
266
|
-
return c.json({ item });
|
|
267
|
-
} catch (e) {
|
|
268
|
-
if (isUploadTooLarge(e)) return c.json({ error: SINGLE_UPLOAD_TOO_LARGE }, 413);
|
|
269
|
-
throw e;
|
|
270
|
-
}
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
|
|
274
180
|
// Chunked upload (large files, direct-to-backend). init → the client PUTs each chunk straight to
|
|
275
181
|
// the backend (URLs the app minted) → complete registers the row over the assembled bytes. The
|
|
276
182
|
// bytes never traverse this origin.
|
|
@@ -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 {
|
|
5
5
|
createCatalogueMediaSource,
|
|
@@ -59,8 +59,5 @@ export {
|
|
|
59
59
|
type FileToPlayable,
|
|
60
60
|
type FileType,
|
|
61
61
|
type ListFilesOptions,
|
|
62
|
-
type PrivateFileCatalogue,
|
|
63
|
-
type PrivateFileItem,
|
|
64
|
-
type PrivateKeyMode,
|
|
65
62
|
type TagFacet,
|
|
66
63
|
} from './types';
|
|
@@ -6,6 +6,15 @@ import type { Migration } from '../../migration/types';
|
|
|
6
6
|
// (not re-derived per list), tags live in an indexed junction (fast facets, not a JSON scan),
|
|
7
7
|
// processing state is typed columns (not an opaque blob), and a storage-operations ledger backs
|
|
8
8
|
// durable cross-boundary (binary-server/S3) ops. Flat — no parent_id tree.
|
|
9
|
+
//
|
|
10
|
+
// 🔴 `0001` is edited IN PLACE rather than followed by a drop-column migration (2026-09-17,
|
|
11
|
+
// removing private media). It is `CREATE TABLE IF NOT EXISTS` and is recorded in `_migrations`,
|
|
12
|
+
// so an existing database never re-runs it and simply keeps three vestigial columns
|
|
13
|
+
// (`is_private`, `enc_name`, `enc_dek`, `enc_key_type`, `file_tags.is_private`) that nothing
|
|
14
|
+
// reads; every one of them had `NOT NULL DEFAULT`, so the leaner INSERTs below still satisfy
|
|
15
|
+
// them. A fresh database gets the lean shape. That is safe here only because no app in the
|
|
16
|
+
// fleet mounts `createFilesStack` — if one ever does, removing a column becomes a real
|
|
17
|
+
// migration, not an edit.
|
|
9
18
|
|
|
10
19
|
export const FILE_CATALOGUE_MIGRATIONS: readonly Migration[] = [
|
|
11
20
|
{
|
|
@@ -19,7 +28,6 @@ export const FILE_CATALOGUE_MIGRATIONS: readonly Migration[] = [
|
|
|
19
28
|
mime TEXT NOT NULL DEFAULT '',
|
|
20
29
|
size INTEGER NOT NULL DEFAULT 0,
|
|
21
30
|
file_type TEXT NOT NULL DEFAULT 'other',
|
|
22
|
-
is_private INTEGER NOT NULL DEFAULT 0,
|
|
23
31
|
has_thumbnail INTEGER NOT NULL DEFAULT 0,
|
|
24
32
|
has_hls INTEGER NOT NULL DEFAULT 0,
|
|
25
33
|
has_sprites INTEGER NOT NULL DEFAULT 0,
|
|
@@ -27,9 +35,6 @@ export const FILE_CATALOGUE_MIGRATIONS: readonly Migration[] = [
|
|
|
27
35
|
processing_status TEXT NOT NULL DEFAULT 'ready',
|
|
28
36
|
processing_progress INTEGER NOT NULL DEFAULT 100,
|
|
29
37
|
storage_backend TEXT NOT NULL DEFAULT 'disk',
|
|
30
|
-
enc_name TEXT NOT NULL DEFAULT '',
|
|
31
|
-
enc_dek TEXT NOT NULL DEFAULT '',
|
|
32
|
-
enc_key_type TEXT NOT NULL DEFAULT '',
|
|
33
38
|
created_at INTEGER NOT NULL,
|
|
34
39
|
updated_at INTEGER NOT NULL
|
|
35
40
|
)
|
|
@@ -41,8 +46,6 @@ export const FILE_CATALOGUE_MIGRATIONS: readonly Migration[] = [
|
|
|
41
46
|
db.run(
|
|
42
47
|
`CREATE INDEX IF NOT EXISTS idx_files_owner_type_created ON files(owner, file_type, created_at DESC, id DESC)`,
|
|
43
48
|
);
|
|
44
|
-
db.run(`CREATE INDEX IF NOT EXISTS idx_files_owner_private ON files(owner, is_private)`);
|
|
45
|
-
|
|
46
49
|
// Tags as a junction (indexed) — single-tag filter + facet counts are index lookups, not a
|
|
47
50
|
// full-table JSON parse (ca's weakness). `owner` denormalized so facet queries stay covering.
|
|
48
51
|
db.run(`
|
|
@@ -50,7 +53,6 @@ export const FILE_CATALOGUE_MIGRATIONS: readonly Migration[] = [
|
|
|
50
53
|
file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
|
51
54
|
tag TEXT NOT NULL,
|
|
52
55
|
owner TEXT NOT NULL,
|
|
53
|
-
is_private INTEGER NOT NULL DEFAULT 0,
|
|
54
56
|
PRIMARY KEY (file_id, tag)
|
|
55
57
|
)
|
|
56
58
|
`);
|
|
@@ -5,23 +5,23 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { Hono } from 'hono';
|
|
7
7
|
import { runMigrations } from '../../migration/runner';
|
|
8
|
-
import { generateUserSalt } from '../../private-media/crypto';
|
|
9
8
|
import { createDiskStore } from '../diskStore';
|
|
10
9
|
import type { MediaCtx } from '../types';
|
|
11
10
|
import { FILE_CATALOGUE_MIGRATIONS } from './migrations';
|
|
12
11
|
import { createFilesStack, type FilesStack } from './stack';
|
|
13
12
|
|
|
14
13
|
const CTX: MediaCtx = { userId: 'u1', tenantId: 't1' };
|
|
15
|
-
const salt = generateUserSalt();
|
|
16
14
|
|
|
17
|
-
// Seed a row recoverProcessing acts on WITHOUT ffmpeg: a
|
|
18
|
-
// is marked failed synchronously
|
|
19
|
-
|
|
15
|
+
// Seed a row recoverProcessing acts on WITHOUT ffmpeg: a video stuck in `processing` that has
|
|
16
|
+
// already burned its kick budget is marked failed synchronously, no re-kick. (Before 2026-09-17
|
|
17
|
+
// this seeded a stuck PRIVATE row, which took the same branch for a different reason; the
|
|
18
|
+
// attempts cap is the branch that still exists, and the one the 2026-07-04 OOM outage bought.)
|
|
19
|
+
function seedExhaustedVideoRow(db: Database, id: string): void {
|
|
20
20
|
runMigrations(db, FILE_CATALOGUE_MIGRATIONS); // idempotent — the stack re-runs it harmlessly
|
|
21
21
|
db.run(
|
|
22
|
-
`INSERT INTO files (id, owner, name, mime, size, file_type,
|
|
23
|
-
processing_progress, storage_backend,
|
|
24
|
-
VALUES (?, 'u1', '', '', 1, 'video',
|
|
22
|
+
`INSERT INTO files (id, owner, name, mime, size, file_type, processing_status,
|
|
23
|
+
processing_progress, storage_backend, processing_attempts, created_at, updated_at)
|
|
24
|
+
VALUES (?, 'u1', 'v.mp4', 'video/mp4', 1, 'video', 'processing', 40, 'disk', 3, 1, 1)`,
|
|
25
25
|
[id],
|
|
26
26
|
);
|
|
27
27
|
}
|
|
@@ -60,10 +60,6 @@ describe('createFilesStack — public stack', () => {
|
|
|
60
60
|
expect(page.items).toEqual([]);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
test('no private layer configured ⇒ unlock is null', () => {
|
|
64
|
-
expect(stack.unlock).toBeNull();
|
|
65
|
-
});
|
|
66
|
-
|
|
67
63
|
test('URL builders are wired to the default bases', async () => {
|
|
68
64
|
const f = await stack.fileUrls.build(CTX, 'fid', 'image/png');
|
|
69
65
|
expect(f.url).toStartWith('/_files/s/');
|
|
@@ -144,9 +140,9 @@ describe('createFilesStack — recoverProcessing', () => {
|
|
|
144
140
|
recoverProcessing,
|
|
145
141
|
});
|
|
146
142
|
|
|
147
|
-
test('runs by default — a stuck
|
|
143
|
+
test('runs by default — a stuck row past its kick budget is marked failed at build', () => {
|
|
148
144
|
const db = new Database(':memory:');
|
|
149
|
-
|
|
145
|
+
seedExhaustedVideoRow(db, 'stuck1');
|
|
150
146
|
build(db);
|
|
151
147
|
expect(statusOf(db, 'stuck1')).toBe('failed');
|
|
152
148
|
db.close();
|
|
@@ -154,105 +150,9 @@ describe('createFilesStack — recoverProcessing', () => {
|
|
|
154
150
|
|
|
155
151
|
test('recoverProcessing: false skips it', () => {
|
|
156
152
|
const db = new Database(':memory:');
|
|
157
|
-
|
|
153
|
+
seedExhaustedVideoRow(db, 'stuck2');
|
|
158
154
|
build(db, false);
|
|
159
155
|
expect(statusOf(db, 'stuck2')).toBe('processing');
|
|
160
156
|
db.close();
|
|
161
157
|
});
|
|
162
158
|
});
|
|
163
|
-
|
|
164
|
-
describe('createFilesStack — private stack', () => {
|
|
165
|
-
let db: Database;
|
|
166
|
-
let root: string;
|
|
167
|
-
let stack: FilesStack;
|
|
168
|
-
const app = new Hono();
|
|
169
|
-
|
|
170
|
-
beforeAll(async () => {
|
|
171
|
-
db = new Database(':memory:');
|
|
172
|
-
root = await mkdtemp(path.join(tmpdir(), 'stack-priv-'));
|
|
173
|
-
stack = createFilesStack({
|
|
174
|
-
db,
|
|
175
|
-
store: createDiskStore(root),
|
|
176
|
-
workDir: path.join(root, 'work'),
|
|
177
|
-
streamSecret: 'stream-secret',
|
|
178
|
-
privateMedia: { serverSecret: 'srv', unlockSecret: 'unl' },
|
|
179
|
-
});
|
|
180
|
-
stack.mount(app, {
|
|
181
|
-
authorizeUpload: () => CTX,
|
|
182
|
-
authorizePrivate: async () => ({ userId: CTX.userId, userSalt: salt }),
|
|
183
|
-
});
|
|
184
|
-
});
|
|
185
|
-
afterAll(async () => {
|
|
186
|
-
db.close();
|
|
187
|
-
await rm(root, { recursive: true, force: true });
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test('unlock service exists and its tokens verify', async () => {
|
|
191
|
-
expect(stack.unlock).not.toBeNull();
|
|
192
|
-
const { token, mode } = await stack.unlock!.unlock({
|
|
193
|
-
userId: CTX.userId,
|
|
194
|
-
userSalt: salt,
|
|
195
|
-
password: 'pw',
|
|
196
|
-
});
|
|
197
|
-
expect(mode).toBe('deniable');
|
|
198
|
-
expect((await stack.unlock!.verify(token))?.userId).toBe(CTX.userId);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
test('POST /_private/unlock with a password mints a deniable token', async () => {
|
|
202
|
-
const res = await app.request('/_private/unlock', {
|
|
203
|
-
method: 'POST',
|
|
204
|
-
headers: { 'content-type': 'application/json' },
|
|
205
|
-
body: JSON.stringify({ password: 'hunter2' }),
|
|
206
|
-
});
|
|
207
|
-
expect(res.status).toBe(200);
|
|
208
|
-
const body = (await res.json()) as { token: string; mode: string };
|
|
209
|
-
expect(body.mode).toBe('deniable');
|
|
210
|
-
expect((await stack.unlock!.verify(body.token))?.mode).toBe('deniable');
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
test('reveal route is mounted (password reveal over an empty set)', async () => {
|
|
214
|
-
const res = await app.request('/_private/reveal', {
|
|
215
|
-
method: 'POST',
|
|
216
|
-
headers: { 'content-type': 'application/json' },
|
|
217
|
-
body: JSON.stringify({ password: 'hunter2' }),
|
|
218
|
-
});
|
|
219
|
-
expect(res.status).toBe(200);
|
|
220
|
-
expect((await res.json()) as { items: unknown[] }).toEqual({ items: [] });
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
test('private upload route is mounted and session-guarded', async () => {
|
|
224
|
-
const denied = new Hono();
|
|
225
|
-
stack.mount(denied, {
|
|
226
|
-
authorizeUpload: () => CTX,
|
|
227
|
-
authorizePrivate: async () => null,
|
|
228
|
-
});
|
|
229
|
-
const res = await denied.request('/_files/upload-private', { method: 'PUT', body: 'x' });
|
|
230
|
-
expect(res.status).toBe(401); // mounted (not 404) but rejected by the host auth
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
test('mount() throws without authorizePrivate when privateMedia is configured', () => {
|
|
234
|
-
expect(() => stack.mount(new Hono(), { authorizeUpload: () => CTX })).toThrow(
|
|
235
|
-
/authorizePrivate/,
|
|
236
|
-
);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
test('privateBasePath flows through to the private mount', async () => {
|
|
240
|
-
const db2 = new Database(':memory:');
|
|
241
|
-
const s = createFilesStack({
|
|
242
|
-
db: db2,
|
|
243
|
-
store: createDiskStore(root),
|
|
244
|
-
workDir: path.join(root, 'work2'),
|
|
245
|
-
streamSecret: 's',
|
|
246
|
-
privateBasePath: '/vault',
|
|
247
|
-
privateMedia: { serverSecret: 'srv', unlockSecret: 'unl' },
|
|
248
|
-
});
|
|
249
|
-
const a = new Hono();
|
|
250
|
-
s.mount(a, {
|
|
251
|
-
authorizeUpload: () => CTX,
|
|
252
|
-
authorizePrivate: async () => ({ userId: CTX.userId, userSalt: salt }),
|
|
253
|
-
});
|
|
254
|
-
const res = await a.request('/vault/unlock', { method: 'POST', body: '{}' });
|
|
255
|
-
expect(res.status).toBe(200);
|
|
256
|
-
db2.close();
|
|
257
|
-
});
|
|
258
|
-
});
|
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import type { Database } from 'bun:sqlite';
|
|
2
2
|
import type { Context, Hono } from 'hono';
|
|
3
3
|
import { runMigrations } from '../../migration/runner';
|
|
4
|
-
import { mountPrivateRoutes } from '../../private-media/mountPrivateRoutes';
|
|
5
|
-
import {
|
|
6
|
-
createUnlockService,
|
|
7
|
-
type UnlockPasswordPolicy,
|
|
8
|
-
type UnlockService,
|
|
9
|
-
} from '../../private-media/unlock';
|
|
10
4
|
import { mountMediaRoutes } from '../mountMediaRoutes';
|
|
11
5
|
import { createMediaUrls, type MediaUrls, SourceRegistry } from '../sources';
|
|
12
6
|
import { createStreamTokenService, type StreamTokenService } from '../streamToken';
|
|
@@ -25,14 +19,14 @@ import {
|
|
|
25
19
|
import { createFileUrls, type FileUrls, mountFileRoutes } from './fileRoutes';
|
|
26
20
|
import { FILE_CATALOGUE_MIGRATIONS } from './migrations';
|
|
27
21
|
import { createStorageOpsLedger } from './storageOps';
|
|
28
|
-
import type { FileCatalogue
|
|
22
|
+
import type { FileCatalogue } from './types';
|
|
29
23
|
|
|
30
24
|
// ── Files stack — the host recipe ───────────────────────────────────────────────
|
|
31
25
|
// One call that assembles the whole files/media server stack a host would otherwise hand-wire:
|
|
32
26
|
// migrations + SQLite catalogue over a MediaStore, the stream-token service, the media/file URL
|
|
33
|
-
// builders, the catalogue-as-video-source registered in a SourceRegistry,
|
|
34
|
-
//
|
|
35
|
-
//
|
|
27
|
+
// builders, the catalogue-as-video-source registered in a SourceRegistry, and a `mount()` that
|
|
28
|
+
// wires every byte/upload route onto a Hono app. The auth seam (session → MediaCtx) stays
|
|
29
|
+
// host-injected — the stack owns no auth.
|
|
36
30
|
|
|
37
31
|
export interface FilesStackOptions {
|
|
38
32
|
/** The catalogue database. The stack runs FILE_CATALOGUE_MIGRATIONS on it itself. */
|
|
@@ -43,9 +37,9 @@ export interface FilesStackOptions {
|
|
|
43
37
|
workDir: string;
|
|
44
38
|
/** Secret for the stream-token service (path-embedded byte-route auth). */
|
|
45
39
|
streamSecret: string;
|
|
46
|
-
/** Owner keyspace segment. Defaults to the userId (files
|
|
47
|
-
*
|
|
48
|
-
* agrees
|
|
40
|
+
/** Owner keyspace segment. Defaults to the userId (files are personal) — note the bare
|
|
41
|
+
* primitives default to tenantId; the stack picks the personal axis so every URL and route
|
|
42
|
+
* agrees on one owner. */
|
|
49
43
|
owner?: (ctx: MediaCtx) => string;
|
|
50
44
|
/** Registered video-source id (also the media-token sourceId). Default 'files'. */
|
|
51
45
|
sourceId?: string;
|
|
@@ -66,8 +60,6 @@ export interface FilesStackOptions {
|
|
|
66
60
|
mediaPublicBase?: string;
|
|
67
61
|
/** PUBLIC base for browser file URLs (split-origin counterpart of `filesBasePath`). */
|
|
68
62
|
filesPublicBase?: string;
|
|
69
|
-
/** Private (encrypted) route mount. Default '/_private'. */
|
|
70
|
-
privateBasePath?: string;
|
|
71
63
|
/** HLS ladder heights for video ingest (createSqliteFileCatalogue passthrough). */
|
|
72
64
|
ladderHeights?: SqliteFileCatalogueOptions['ladderHeights'];
|
|
73
65
|
/** Cap on transcode kicks per file before it's marked failed (createSqliteFileCatalogue
|
|
@@ -82,11 +74,6 @@ export interface FilesStackOptions {
|
|
|
82
74
|
/** Image thumbnail/display derivative implementation (createSqliteFileCatalogue passthrough).
|
|
83
75
|
* Default: local sharp with ffmpeg fallback. */
|
|
84
76
|
imageDerivatives?: SqliteFileCatalogueOptions['imageDerivatives'];
|
|
85
|
-
/** Enables the private (encrypted) stack: unlock service + /_private routes + private uploads.
|
|
86
|
-
* Omit ⇒ `unlock` is null and mount() wires only the public routes. `passwordPolicy` sets what
|
|
87
|
-
* a supplied password MEANS (derive = deniable sets, the default; gate = verify against the
|
|
88
|
-
* app's accounts + recoverable server-key sets — see UnlockPasswordPolicy). */
|
|
89
|
-
privateMedia?: { serverSecret: string; unlockSecret: string; passwordPolicy?: UnlockPasswordPolicy };
|
|
90
77
|
/** Re-kick transcodes a restart left stuck (catalogue.recoverProcessing) at build time.
|
|
91
78
|
* Default true. */
|
|
92
79
|
recoverProcessing?: boolean;
|
|
@@ -113,15 +100,8 @@ export interface FilesStackMountAuth {
|
|
|
113
100
|
/** Session auth for uploads + listings (same guard mountMediaRoutes/mountFileRoutes take).
|
|
114
101
|
* Return a MediaCtx to allow, null to reject. */
|
|
115
102
|
authorizeUpload: (c: Context) => Promise<MediaCtx | null> | MediaCtx | null;
|
|
116
|
-
/** Session auth for the private layer → {userId, userSalt}. REQUIRED when `privateMedia` is
|
|
117
|
-
* configured (mount() throws otherwise); ignored when it isn't. */
|
|
118
|
-
authorizePrivate?: (c: Context) => Promise<{ userId: string; userSalt: string } | null>;
|
|
119
103
|
/** Wire large-file chunked uploads (mountFileRoutes' `chunked` passthrough). */
|
|
120
104
|
mintChunkUpload?: ChunkedUploadService;
|
|
121
|
-
/** Private-upload password header (default 'x-private-password'). */
|
|
122
|
-
passwordHeader?: string;
|
|
123
|
-
/** Private-upload unlock-token header (default 'x-unlock-token'). */
|
|
124
|
-
tokenHeader?: string;
|
|
125
105
|
}
|
|
126
106
|
|
|
127
107
|
/**
|
|
@@ -131,7 +111,7 @@ export interface FilesStackMountAuth {
|
|
|
131
111
|
* not, layering) import cursedshell.
|
|
132
112
|
*/
|
|
133
113
|
export interface FilesStack {
|
|
134
|
-
catalogue: FileCatalogue
|
|
114
|
+
catalogue: FileCatalogue;
|
|
135
115
|
token: StreamTokenService;
|
|
136
116
|
mediaUrls: MediaUrls;
|
|
137
117
|
fileUrls: FileUrls;
|
|
@@ -141,15 +121,12 @@ export interface FilesStack {
|
|
|
141
121
|
/** The MIXED view — photos + videos in one feed (media gallery data source). */
|
|
142
122
|
mediaSource: MediaGallerySource;
|
|
143
123
|
registry: SourceRegistry;
|
|
144
|
-
/** Null unless `privateMedia` was configured. */
|
|
145
|
-
unlock: UnlockService | null;
|
|
146
124
|
/** The large-file chunked-upload service, or null unless `chunkedUpload` was configured. mount()
|
|
147
125
|
* auto-wires it into the file routes; exposed for observability (pending()) + tests. */
|
|
148
126
|
chunked: ChunkedUploadService | null;
|
|
149
127
|
/**
|
|
150
128
|
* Mount every byte/upload route the stack owns onto a Hono app: mountMediaRoutes
|
|
151
|
-
* (mediaBasePath) + mountFileRoutes (filesBasePath
|
|
152
|
-
* mountPrivateRoutes (privateBasePath, when unlock exists). Auth stays host-injected.
|
|
129
|
+
* (mediaBasePath) + mountFileRoutes (filesBasePath). Auth stays host-injected.
|
|
153
130
|
*/
|
|
154
131
|
mount(app: Hono, auth: FilesStackMountAuth): void;
|
|
155
132
|
}
|
|
@@ -212,8 +189,6 @@ export function createFilesStack(opts: FilesStackOptions): FilesStack {
|
|
|
212
189
|
});
|
|
213
190
|
const registry = new SourceRegistry().register(videoSource);
|
|
214
191
|
|
|
215
|
-
const unlock = opts.privateMedia ? createUnlockService(opts.privateMedia) : null;
|
|
216
|
-
|
|
217
192
|
const chunked = opts.chunkedUpload
|
|
218
193
|
? createChunkedUploadService({
|
|
219
194
|
catalogue,
|
|
@@ -234,15 +209,8 @@ export function createFilesStack(opts: FilesStackOptions): FilesStack {
|
|
|
234
209
|
photoSource,
|
|
235
210
|
mediaSource,
|
|
236
211
|
registry,
|
|
237
|
-
unlock,
|
|
238
212
|
chunked,
|
|
239
213
|
mount(app, auth) {
|
|
240
|
-
if (unlock && !auth.authorizePrivate) {
|
|
241
|
-
throw new Error(
|
|
242
|
-
'createFilesStack: privateMedia is configured but mount() got no authorizePrivate — ' +
|
|
243
|
-
'the private routes need a session→{userId, userSalt} resolver.',
|
|
244
|
-
);
|
|
245
|
-
}
|
|
246
214
|
mountMediaRoutes(app, {
|
|
247
215
|
store,
|
|
248
216
|
token,
|
|
@@ -258,27 +226,7 @@ export function createFilesStack(opts: FilesStackOptions): FilesStack {
|
|
|
258
226
|
authorizeUpload: auth.authorizeUpload,
|
|
259
227
|
// Prefer a mount-time override, else the service the stack built from `chunkedUpload` opts.
|
|
260
228
|
chunked: auth.mintChunkUpload ?? chunked ?? undefined,
|
|
261
|
-
privateUpload:
|
|
262
|
-
unlock && auth.authorizePrivate
|
|
263
|
-
? {
|
|
264
|
-
unlock,
|
|
265
|
-
authorize: auth.authorizePrivate,
|
|
266
|
-
passwordHeader: auth.passwordHeader,
|
|
267
|
-
tokenHeader: auth.tokenHeader,
|
|
268
|
-
passwordPolicy: opts.privateMedia?.passwordPolicy,
|
|
269
|
-
}
|
|
270
|
-
: undefined,
|
|
271
229
|
});
|
|
272
|
-
if (unlock && auth.authorizePrivate) {
|
|
273
|
-
mountPrivateRoutes(app, {
|
|
274
|
-
store,
|
|
275
|
-
index: catalogue.asPrivateIndex(),
|
|
276
|
-
unlock,
|
|
277
|
-
basePath: opts.privateBasePath,
|
|
278
|
-
authorize: auth.authorizePrivate,
|
|
279
|
-
passwordPolicy: opts.privateMedia?.passwordPolicy,
|
|
280
|
-
});
|
|
281
|
-
}
|
|
282
230
|
},
|
|
283
231
|
};
|
|
284
232
|
}
|
|
@@ -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
|
|
|
5
4
|
// ── FileCatalogue: a tagged flat file system over the storage layer ──────────────
|
|
@@ -8,7 +7,7 @@ import type { MediaCtx } from '../types';
|
|
|
8
7
|
// becomes a video-filtered view over it. Improves on ca's fs_nodes: FLAT (no folder tree), the
|
|
9
8
|
// smart-view `fileType` is STORED at write (not re-derived per request), tags are a JOIN table
|
|
10
9
|
// (indexed facets, not a JSON scan), listing is CURSOR-paginated, and processing state is typed
|
|
11
|
-
// columns — not an opaque JSON blob. Reuses MediaStore
|
|
10
|
+
// columns — not an opaque JSON blob. Reuses MediaStore and the media pipeline.
|
|
12
11
|
|
|
13
12
|
export type FileType = 'image' | 'video' | 'audio' | 'doc' | 'code' | 'other';
|
|
14
13
|
|
|
@@ -39,7 +38,6 @@ export type FileRecord = {
|
|
|
39
38
|
size: number;
|
|
40
39
|
fileType: FileType;
|
|
41
40
|
tags: string[];
|
|
42
|
-
isPrivate: boolean;
|
|
43
41
|
hasThumbnail: boolean;
|
|
44
42
|
hasHls: boolean;
|
|
45
43
|
hasSprites: boolean;
|
|
@@ -79,11 +77,8 @@ export type ListFilesOptions = {
|
|
|
79
77
|
type?: FileType;
|
|
80
78
|
/** Single-tag filter (indexed via the join table). */
|
|
81
79
|
tag?: string;
|
|
82
|
-
/** Name substring
|
|
80
|
+
/** Name substring. */
|
|
83
81
|
query?: string;
|
|
84
|
-
/** Include the caller's private files (requires an unlocked set — the caller resolves which
|
|
85
|
-
* ones actually open). Omitted ⇒ public only, matching ca's default listing. */
|
|
86
|
-
includePrivate?: boolean;
|
|
87
82
|
/** Filter to members of a specific collection. An unlock-gated collection yields empty unless
|
|
88
83
|
* `includeUnlockable` is also true. */
|
|
89
84
|
collectionId?: string;
|
|
@@ -116,14 +111,11 @@ export function classifyFileType(mime: string, isText = false): FileType {
|
|
|
116
111
|
/** The catalogue contract. A SQLite implementation is the production default; the interface lets
|
|
117
112
|
* an app swap it. All calls are owner-scoped via {@link MediaCtx} (one tenant never sees another). */
|
|
118
113
|
/** Cheap owner-scoped aggregates (COUNT/SUM over the files table) — the admin
|
|
119
|
-
* surface's "site stats" source.
|
|
120
|
-
* (names/kinds stay sealed). */
|
|
114
|
+
* surface's "site stats" source. */
|
|
121
115
|
export type CatalogueStats = {
|
|
122
116
|
files: number;
|
|
123
117
|
bytes: number;
|
|
124
118
|
byType: Partial<Record<FileType, number>>;
|
|
125
|
-
privateItems: number;
|
|
126
|
-
privateBytes: number;
|
|
127
119
|
processing: number;
|
|
128
120
|
failed: number;
|
|
129
121
|
};
|
|
@@ -133,7 +125,7 @@ export interface FileCatalogue {
|
|
|
133
125
|
get(ctx: MediaCtx, id: string): Promise<FileRecord | null>;
|
|
134
126
|
/** Owner-scoped aggregates for admin/stats surfaces. */
|
|
135
127
|
stats(ctx: MediaCtx): Promise<CatalogueStats>;
|
|
136
|
-
tagFacets(ctx: MediaCtx
|
|
128
|
+
tagFacets(ctx: MediaCtx): Promise<TagFacet[]>;
|
|
137
129
|
/** Ingest a public upload: persist bytes, classify, kick off processing (thumbnail/HLS). */
|
|
138
130
|
ingest(
|
|
139
131
|
ctx: MediaCtx,
|
|
@@ -160,9 +152,8 @@ export interface FileCatalogue {
|
|
|
160
152
|
): Promise<FileRecord | null>;
|
|
161
153
|
/** Overwrite a file's BYTES in place — the identity (id, tags, collections, favorites, view
|
|
162
154
|
* history) survives while mime/size/fileType re-derive, stale derivatives are wiped, and
|
|
163
|
-
* processing re-kicks (video transcode / image thumbnail).
|
|
164
|
-
*
|
|
165
|
-
* or null if the id is unknown/other-owner. */
|
|
155
|
+
* processing re-kicks (video transcode / image thumbnail). Returns the refreshed record, or
|
|
156
|
+
* null if the id is unknown/other-owner. */
|
|
166
157
|
replaceContent(
|
|
167
158
|
ctx: MediaCtx,
|
|
168
159
|
id: string,
|
|
@@ -189,15 +180,15 @@ export interface FileCatalogue {
|
|
|
189
180
|
absPath: string,
|
|
190
181
|
opts?: { tags?: string[] },
|
|
191
182
|
): Promise<{ imported: FileRecord[]; skipped: { path: string; reason: string }[] }>;
|
|
192
|
-
/** Recover from a restart that lost in-flight transcodes: re-kick
|
|
193
|
-
* pending/processing (their source persists), and mark
|
|
194
|
-
* app startup. Returns how many were re-queued vs. failed. */
|
|
183
|
+
/** Recover from a restart that lost in-flight transcodes: re-kick video rows still marked
|
|
184
|
+
* pending/processing (their source persists), and mark rows that have exhausted their kick
|
|
185
|
+
* budget failed. Call once at app startup. Returns how many were re-queued vs. failed. */
|
|
195
186
|
recoverProcessing(): { requeued: number; failed: number };
|
|
196
|
-
/** Backfill grid thumbnails for finished
|
|
187
|
+
/** Backfill grid thumbnails for finished video rows that have no poster (landed while
|
|
197
188
|
* transcoding was off, or failed before the poster step). Re-kicks the poster pipeline for each.
|
|
198
189
|
* Returns how many were re-queued. */
|
|
199
190
|
backfillVideoPosters(): { requeued: number };
|
|
200
|
-
/** Backfill grid thumbnails for
|
|
191
|
+
/** Backfill grid thumbnails for image rows with no poster (e.g. HEIC photos whose sharp
|
|
201
192
|
* thumbnail failed before the ffmpeg fallback). Re-kicks the image pipeline. */
|
|
202
193
|
backfillImageThumbnails(): { requeued: number };
|
|
203
194
|
|
|
@@ -235,51 +226,12 @@ export interface FileCatalogue {
|
|
|
235
226
|
/** Resolve a video FileRecord to a PlayableVideo (URLs baked server-side). Non-video ⇒ null. */
|
|
236
227
|
export type FileToPlayable = (ctx: MediaCtx, file: FileRecord) => Promise<PlayableVideo | null>;
|
|
237
228
|
|
|
238
|
-
// ──
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
/** The OPAQUE locked-index row — only non-sensitive metadata (so a user can prune media whose
|
|
249
|
-
* password is lost). NEVER name/tags/thumbnail: those are sealed. No set identifier is exposed. */
|
|
250
|
-
export type PrivateFileItem = {
|
|
251
|
-
id: string;
|
|
252
|
-
size: number;
|
|
253
|
-
createdAt: number;
|
|
254
|
-
processingStatus: ProcessingStatus;
|
|
255
|
-
processingProgress: number;
|
|
256
|
-
hasHls: boolean;
|
|
257
|
-
};
|
|
258
|
-
|
|
259
|
-
/** Private-media capability of a catalogue. `createSqliteFileCatalogue` implements this alongside
|
|
260
|
-
* {@link FileCatalogue}, storing private items as sealed rows in the SAME table (one source of
|
|
261
|
-
* truth). Membership is by decrypt-success — no stored password/set hash.
|
|
262
|
-
*
|
|
263
|
-
* Deliberately small: the catalogue owns only INGEST (the new private-upload path). Everything
|
|
264
|
-
* else — the locked index, unlock, try-a-password reveal, the gated content-key route, and the
|
|
265
|
-
* encrypted-HLS byte serving — is the existing `mountPrivateRoutes`, which operates through a
|
|
266
|
-
* {@link PrivateIndex}. `asPrivateIndex()` is the view that lets those routes read/write the
|
|
267
|
-
* catalogue's private rows, so there is exactly one store and one set of routes. */
|
|
268
|
-
export interface PrivateFileCatalogue {
|
|
269
|
-
/** Ingest an encrypted video: stage plaintext (never persisted), transcode to AES-128 HLS, seal
|
|
270
|
-
* the descriptor + wrap the content key under `setKey`, and insert a private row. */
|
|
271
|
-
ingestPrivate(
|
|
272
|
-
ctx: MediaCtx,
|
|
273
|
-
upload: {
|
|
274
|
-
name: string;
|
|
275
|
-
mime: string;
|
|
276
|
-
body: ReadableStream<Uint8Array>;
|
|
277
|
-
size?: number;
|
|
278
|
-
tags?: string[];
|
|
279
|
-
},
|
|
280
|
-
opts: { setKey: Buffer; keyMode: PrivateKeyMode },
|
|
281
|
-
): Promise<PrivateFileItem>;
|
|
282
|
-
/** A {@link PrivateIndex} view over this catalogue's private rows — pass to `mountPrivateRoutes`
|
|
283
|
-
* so unlock/index/reveal/key/HLS all operate on the catalogue. `remove` also drops the bytes. */
|
|
284
|
-
asPrivateIndex(): PrivateIndex;
|
|
285
|
-
}
|
|
229
|
+
// ── There is no private half, deliberately (removed 2026-09-17) ───────────────────
|
|
230
|
+
// A `PrivateFileCatalogue` used to sit here: sealed descriptors, wrapped content keys, a
|
|
231
|
+
// `KeyMode: 'standard' | 'deniable'` and membership "by decrypt-success — no stored password".
|
|
232
|
+
// It was 1,505 lines that no app ever mounted, over a byte store that encrypts nothing
|
|
233
|
+
// (binary-server's `exportFiles.ts`: *"Objects are stored unencrypted"*), so there was never a
|
|
234
|
+
// ciphertext behind it — and the owner chose to delete the whole module rather than carry a
|
|
235
|
+
// second, unexercised confidentiality story alongside the master lock, which is the one that
|
|
236
|
+
// actually guards these apps. This paragraph is the record; it is not an invitation to
|
|
237
|
+
// reintroduce it. The wall that IS real is `../../master-lock`.
|