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.
Files changed (81) hide show
  1. package/dist/server/activity/index.d.ts +1 -1
  2. package/dist/server/activity/index.js +1 -1
  3. package/dist/server/auth/jwt.d.ts +1 -1
  4. package/dist/server/auth/jwt.js +2 -2
  5. package/dist/server/master-lock/accountsPage.d.ts +39 -0
  6. package/dist/server/master-lock/accountsPage.js +455 -0
  7. package/dist/server/master-lock/guard.js +57 -1
  8. package/dist/server/master-lock/index.d.ts +10 -2
  9. package/dist/server/master-lock/index.js +10 -2
  10. package/dist/server/master-lock/lockPage.d.ts +9 -0
  11. package/dist/server/master-lock/lockPage.js +44 -36
  12. package/dist/server/media-bun/streaming.d.ts +0 -1
  13. package/dist/server/media-bun/streaming.js +1 -1
  14. package/dist/server/storage/files/catalogue.d.ts +2 -2
  15. package/dist/server/storage/files/catalogue.js +18 -208
  16. package/dist/server/storage/files/fileRoutes.d.ts +2 -20
  17. package/dist/server/storage/files/fileRoutes.js +1 -74
  18. package/dist/server/storage/files/index.d.ts +1 -1
  19. package/dist/server/storage/files/index.js +1 -1
  20. package/dist/server/storage/files/migrations.js +9 -6
  21. package/dist/server/storage/files/stack.d.ts +6 -31
  22. package/dist/server/storage/files/stack.js +0 -27
  23. package/dist/server/storage/files/types.d.ts +10 -60
  24. package/dist/server/storage/files/types.js +9 -0
  25. package/dist/server/storage/index.d.ts +2 -2
  26. package/dist/server/storage/index.js +1 -1
  27. package/dist/server/storage/mountMediaRoutes.d.ts +1 -4
  28. package/dist/server/storage/types.d.ts +0 -9
  29. package/dist/server/storage/types.js +2 -9
  30. package/package.json +2 -2
  31. package/src/leafSubpathsImportNothing.spec.ts +6 -4
  32. package/src/server/activity/index.ts +1 -1
  33. package/src/server/auth/jwt.ts +2 -2
  34. package/src/server/master-lock/accountsPage.spec.ts +231 -0
  35. package/src/server/master-lock/accountsPage.ts +465 -0
  36. package/src/server/master-lock/guard.spec.ts +25 -0
  37. package/src/server/master-lock/guard.ts +65 -1
  38. package/src/server/master-lock/index.ts +22 -2
  39. package/src/server/master-lock/lockPage.spec.ts +20 -16
  40. package/src/server/master-lock/lockPage.ts +45 -36
  41. package/src/server/media-bun/streaming.ts +0 -6
  42. package/src/server/storage/fileUploadClient.integration.spec.ts +2 -21
  43. package/src/server/storage/files/adapter.photo.display.spec.ts +2 -2
  44. package/src/server/storage/files/adapter.photo.spec.ts +2 -2
  45. package/src/server/storage/files/adapter.spec.ts +0 -1
  46. package/src/server/storage/files/catalogue.media-host.spec.ts +6 -71
  47. package/src/server/storage/files/catalogue.recover.spec.ts +14 -23
  48. package/src/server/storage/files/catalogue.ts +19 -240
  49. package/src/server/storage/files/fileRoutes.replace.spec.ts +2 -2
  50. package/src/server/storage/files/fileRoutes.spec.ts +3 -97
  51. package/src/server/storage/files/fileRoutes.ts +2 -96
  52. package/src/server/storage/files/index.ts +1 -4
  53. package/src/server/storage/files/migrations.ts +9 -7
  54. package/src/server/storage/files/stack.spec.ts +11 -111
  55. package/src/server/storage/files/stack.ts +9 -61
  56. package/src/server/storage/files/types.ts +20 -68
  57. package/src/server/storage/index.ts +0 -4
  58. package/src/server/storage/mountMediaRoutes.ts +1 -7
  59. package/src/server/storage/types.ts +2 -11
  60. package/src/shippedFilesAreTracked.spec.ts +3 -2
  61. package/dist/server/private-media/crypto.d.ts +0 -37
  62. package/dist/server/private-media/crypto.js +0 -111
  63. package/dist/server/private-media/index-store.d.ts +0 -31
  64. package/dist/server/private-media/index-store.js +0 -36
  65. package/dist/server/private-media/mountPrivateRoutes.d.ts +0 -26
  66. package/dist/server/private-media/mountPrivateRoutes.js +0 -219
  67. package/dist/server/private-media/pipeline.d.ts +0 -37
  68. package/dist/server/private-media/pipeline.js +0 -97
  69. package/dist/server/private-media/unlock.d.ts +0 -52
  70. package/dist/server/private-media/unlock.js +0 -55
  71. package/src/server/private-media/blobRoute.spec.ts +0 -165
  72. package/src/server/private-media/crypto.spec.ts +0 -81
  73. package/src/server/private-media/crypto.ts +0 -155
  74. package/src/server/private-media/gatePolicy.spec.ts +0 -98
  75. package/src/server/private-media/index-store.ts +0 -83
  76. package/src/server/private-media/mountPrivateRoutes.ts +0 -265
  77. package/src/server/private-media/pipeline.spec.ts +0 -111
  78. package/src/server/private-media/pipeline.ts +0 -170
  79. package/src/server/private-media/routes.spec.ts +0 -265
  80. package/src/server/private-media/unlock.ts +0 -112
  81. package/src/server/storage/files/catalogue.private.spec.ts +0 -106
@@ -1,98 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { Hono } from 'hono';
3
- import { createDiskStore } from '../storage/diskStore';
4
- import { generateUserSalt, sealMeta } from './crypto';
5
- import { createMemoryPrivateIndex } from './index-store';
6
- import { mountPrivateRoutes } from './mountPrivateRoutes';
7
- import { createUnlockService } from './unlock';
8
-
9
- // The gate password policy: the password is an ACCESS CHECK against the app's own accounts
10
- // (e.g. the login store), not a key-derivation input. Correct password ⇒ a STANDARD
11
- // (recoverable, server-key) grant; wrong/absent password ⇒ 401; password changes never
12
- // orphan content because the set key never depended on the password.
13
-
14
- const unlock = createUnlockService({ serverSecret: 'srv', unlockSecret: 'unlock' });
15
- const salt = generateUserSalt();
16
-
17
- function gateApp(verify: (password: string) => boolean) {
18
- const app = new Hono();
19
- const index = createMemoryPrivateIndex();
20
- mountPrivateRoutes(app, {
21
- store: createDiskStore('/tmp/unused-pm-gate'),
22
- index,
23
- unlock,
24
- authorize: async () => ({ userId: 'u1', userSalt: salt }),
25
- passwordPolicy: {
26
- mode: 'gate',
27
- verifyPassword: async (_who, password) => verify(password),
28
- },
29
- });
30
- return { app, index };
31
- }
32
-
33
- const post = (app: Hono, path: string, body: unknown) =>
34
- app.request(path, {
35
- method: 'POST',
36
- headers: { 'content-type': 'application/json' },
37
- body: JSON.stringify(body),
38
- });
39
-
40
- describe('unlock gate policy', () => {
41
- test('correct password ⇒ standard grant (key never derived from the password)', async () => {
42
- const { app } = gateApp((pw) => pw === 'login-pw');
43
- const res = await post(app, '/_private/unlock', { password: 'login-pw' });
44
- expect(res.status).toBe(200);
45
- const { token, mode } = (await res.json()) as { token: string; mode: string };
46
- expect(mode).toBe('standard');
47
- const grant = await unlock.verify(token);
48
- expect(grant?.mode).toBe('standard');
49
- // The grant's key equals the server-derived one — NOT a password derivation.
50
- expect(grant?.setKey.equals(unlock.deriveSetKey({ userId: 'u1', userSalt: salt }))).toBe(true);
51
- });
52
-
53
- test('wrong password ⇒ 401, no token', async () => {
54
- const { app } = gateApp((pw) => pw === 'login-pw');
55
- const res = await post(app, '/_private/unlock', { password: 'wrong' });
56
- expect(res.status).toBe(401);
57
- });
58
-
59
- test('missing password ⇒ 401 (no silent session unlock under gate)', async () => {
60
- const { app } = gateApp(() => true);
61
- const res = await post(app, '/_private/unlock', {});
62
- expect(res.status).toBe(401);
63
- });
64
-
65
- test('reveal under gate verifies then reveals the STANDARD set', async () => {
66
- const { app, index } = gateApp((pw) => pw === 'login-pw');
67
- const standardKey = unlock.deriveSetKey({ userId: 'u1', userSalt: salt });
68
- await index.add({
69
- id: 'item1',
70
- owner: 'u1',
71
- sealedMeta: sealMeta(standardKey, { name: 'secret.jpg', mime: 'image/jpeg', tags: [] }),
72
- wrappedContentKey: 'unused',
73
- sizeBytes: 3,
74
- createdAt: Date.now(),
75
- status: 'ready',
76
- keyMode: 'standard',
77
- });
78
- const bad = await post(app, '/_private/reveal', { password: 'nope' });
79
- expect(bad.status).toBe(401);
80
- const good = await post(app, '/_private/reveal', { password: 'login-pw' });
81
- expect(good.status).toBe(200);
82
- const { items } = (await good.json()) as { items: Array<{ id: string }> };
83
- expect(items.map((i) => i.id)).toEqual(['item1']);
84
- });
85
-
86
- test('derive policy (default) still mints deniable sets from passwords', async () => {
87
- const app = new Hono();
88
- mountPrivateRoutes(app, {
89
- store: createDiskStore('/tmp/unused-pm-gate'),
90
- index: createMemoryPrivateIndex(),
91
- unlock,
92
- authorize: async () => ({ userId: 'u1', userSalt: salt }),
93
- });
94
- const res = await post(app, '/_private/unlock', { password: 'anything' });
95
- expect(res.status).toBe(200);
96
- expect(((await res.json()) as { mode: string }).mode).toBe('deniable');
97
- });
98
- });
@@ -1,83 +0,0 @@
1
- import type { ProcessingStatus } from 'cursedbelt-core/media';
2
- import type { KeyMode } from './crypto';
3
-
4
- // ── Private index ───────────────────────────────────────────────────────────────
5
- // Per-user rows for encrypted media. The row holds ONLY non-sensitive facts in the clear
6
- // (size, created-at, status) so a user can see "N locked files, X GB" and prune ones whose
7
- // password they've lost. The sensitive parts — title / thumbnail / notes / tags — live in the
8
- // AES-GCM `sealedMeta` envelope, and the AES-128 content key lives in `wrappedContentKey`; both
9
- // are opaque without the set key. The public projection (`toPublic`) is what the index endpoint
10
- // returns — it strips every secret, including the key MODE (so the clear index can't even hint
11
- // which rows are deniable). An app swaps the in-memory store for a SQLite-backed one (see
12
- // cursedbelt-server/sqlite) without touching the routes.
13
-
14
- export type PrivateItem = {
15
- id: string;
16
- owner: string;
17
- sizeBytes: number;
18
- createdAt: number;
19
- status: ProcessingStatus;
20
- keyMode: KeyMode;
21
- /** AES-128 content key + IV, wrapped under the item's set key. */
22
- wrappedContentKey: string;
23
- /** AES-GCM envelope of { title, notes, tags, durationSeconds, … } under the item's set key. */
24
- sealedMeta: string;
25
- };
26
-
27
- /** What the index endpoint exposes — clear, non-sensitive facts only. No title/thumb/notes/tags,
28
- * no key mode, no wrapped key. */
29
- export type PrivateIndexRow = {
30
- id: string;
31
- sizeBytes: number;
32
- createdAt: number;
33
- status: ProcessingStatus;
34
- };
35
-
36
- export const toPublic = (item: PrivateItem): PrivateIndexRow => ({
37
- id: item.id,
38
- sizeBytes: item.sizeBytes,
39
- createdAt: item.createdAt,
40
- status: item.status,
41
- });
42
-
43
- export interface PrivateIndex {
44
- add(item: PrivateItem): Promise<void>;
45
- get(owner: string, id: string): Promise<PrivateItem | null>;
46
- list(owner: string): Promise<PrivateItem[]>;
47
- update(
48
- owner: string,
49
- id: string,
50
- patch: Partial<Omit<PrivateItem, 'id' | 'owner'>>,
51
- ): Promise<void>;
52
- remove(owner: string, id: string): Promise<void>;
53
- }
54
-
55
- export function createMemoryPrivateIndex(): PrivateIndex {
56
- const byOwner = new Map<string, Map<string, PrivateItem>>();
57
- const bucket = (owner: string) => {
58
- let m = byOwner.get(owner);
59
- if (!m) {
60
- m = new Map();
61
- byOwner.set(owner, m);
62
- }
63
- return m;
64
- };
65
- return {
66
- async add(item) {
67
- bucket(item.owner).set(item.id, item);
68
- },
69
- async get(owner, id) {
70
- return bucket(owner).get(id) ?? null;
71
- },
72
- async list(owner) {
73
- return [...bucket(owner).values()].sort((a, b) => b.createdAt - a.createdAt);
74
- },
75
- async update(owner, id, patch) {
76
- const item = bucket(owner).get(id);
77
- if (item) bucket(owner).set(id, { ...item, ...patch });
78
- },
79
- async remove(owner, id) {
80
- bucket(owner).delete(id);
81
- },
82
- };
83
- }
@@ -1,265 +0,0 @@
1
- import type { Context, Hono } from 'hono';
2
- import { decodeRange, serveRange } from '../storage/streamRange';
3
- import { type MediaStore, privateHlsFileKey, privateSourceKey } from '../storage/types';
4
- import { cryptBlobRangeStream, cryptBlobStream, openMeta, unwrapContentKey } from './crypto';
5
- import type { PrivateIndex } from './index-store';
6
- import { toPublic } from './index-store';
7
- import type { UnlockPasswordPolicy, UnlockService } from './unlock';
8
-
9
- // Mounts the private-media routes. The unlock / index / reveal endpoints are session-guarded (a
10
- // fetch CAN send the app's Bearer). The byte routes under /u/<token>/ are UN-guarded (hls.js
11
- // can't send a Bearer) and authorized by the path-embedded unlock token instead — the same token
12
- // carries the sealed set key, so the content-key route can unwrap the item's AES-128 key. The
13
- // encrypted segments themselves are edge-cacheable ciphertext; only the tiny key is gated + no-store.
14
-
15
- /** Access-Control-Allow-Origin resolver for split-origin private streaming (media on another
16
- * origin than the SPA). Same shape as the storage layer's. */
17
- export type CorsResolver =
18
- | string
19
- | string[]
20
- | ((requestOrigin: string | undefined) => string | null | undefined);
21
-
22
- function resolveAcao(cors: CorsResolver | undefined, reqOrigin: string | undefined): string | null {
23
- if (!cors) return null;
24
- if (typeof cors === 'string') return cors;
25
- if (Array.isArray(cors)) return reqOrigin && cors.includes(reqOrigin) ? reqOrigin : null;
26
- return cors(reqOrigin) ?? null;
27
- }
28
-
29
- export type MountPrivateOptions = {
30
- store: MediaStore;
31
- index: PrivateIndex;
32
- unlock: UnlockService;
33
- /** Path prefix (default '/_private'). */
34
- basePath?: string;
35
- /** App session auth → the caller's id + their per-user salt (created lazily, stable). Return
36
- * null to reject. Gates unlock/index/reveal (the byte routes use the path token instead). */
37
- authorize: (c: Context) => Promise<{ userId: string; userSalt: string } | null>;
38
- /** What a supplied password means (default: derive — deniable sets). `gate` verifies it
39
- * against the app's accounts and mints the recoverable server-key grant instead. */
40
- passwordPolicy?: UnlockPasswordPolicy;
41
- /** Cross-origin access for split-origin deploys. */
42
- cors?: CorsResolver;
43
- };
44
-
45
- export function mountPrivateRoutes(app: Hono, opts: MountPrivateOptions): void {
46
- const base = opts.basePath ?? '/_private';
47
- const { store, index, unlock } = opts;
48
- const cors = (c: Context, res: Response): Response => {
49
- const acao = resolveAcao(opts.cors, c.req.header('origin'));
50
- if (acao) {
51
- res.headers.set('Access-Control-Allow-Origin', acao);
52
- res.headers.set(
53
- 'Access-Control-Expose-Headers',
54
- 'Content-Range, Content-Length, Accept-Ranges',
55
- );
56
- res.headers.append('Vary', 'Origin');
57
- }
58
- return res;
59
- };
60
-
61
- const policy = opts.passwordPolicy ?? { mode: 'derive' as const };
62
-
63
- /** Resolve what the supplied password contributes under the active policy:
64
- * derive ⇒ it feeds key derivation (may be undefined ⇒ standard set);
65
- * gate ⇒ it must be present AND verify against the app's accounts, then the
66
- * set key is the recoverable server one (password never feeds derivation).
67
- * Returns an error Response to short-circuit with, or the derivation input. */
68
- const resolvePassword = async (
69
- c: Context,
70
- who: { userId: string; userSalt: string },
71
- password: string | undefined,
72
- ): Promise<{ derivePassword?: string } | { error: Response }> => {
73
- if (policy.mode === 'derive') return { derivePassword: password };
74
- if (!password) return { error: c.json({ error: 'password required' }, 401) };
75
- if (!(await policy.verifyPassword(who, password))) {
76
- return { error: c.json({ error: 'invalid password' }, 401) };
77
- }
78
- return {}; // verified — standard (server-key) set
79
- };
80
-
81
- // POST /unlock { password? } → a token sealing the set key (deniable w/ password, else
82
- // standard; under the gate policy the password is verified and the set is always standard).
83
- app.post(`${base}/unlock`, async (c) => {
84
- const who = await opts.authorize(c);
85
- if (!who) return c.json({ error: 'unauthorized' }, 401);
86
- const body = (await c.req.json().catch(() => ({}))) as { password?: string };
87
- const resolved = await resolvePassword(c, who, body.password || undefined);
88
- if ('error' in resolved) return resolved.error;
89
- const { token, mode } = await unlock.unlock({
90
- userId: who.userId,
91
- userSalt: who.userSalt,
92
- password: resolved.derivePassword,
93
- });
94
- return c.json({ token, mode });
95
- });
96
-
97
- // GET /index → clear, non-sensitive rows only (size + createdAt + status). Never title/thumb/mode.
98
- app.get(`${base}/index`, async (c) => {
99
- const who = await opts.authorize(c);
100
- if (!who) return c.json({ error: 'unauthorized' }, 401);
101
- return c.json({ items: (await index.list(who.userId)).map(toPublic) });
102
- });
103
-
104
- // Decrypt every descriptor a set key opens, silently skipping the rest (decrypt-success IS the
105
- // membership test — deniability). Shared by the password and token reveal routes.
106
- const revealWithKey = async (userId: string, setKey: Buffer) => {
107
- const revealed: Array<{ id: string; meta: unknown }> = [];
108
- for (const item of await index.list(userId)) {
109
- const meta = openMeta(setKey, item.sealedMeta);
110
- if (meta !== null) revealed.push({ id: item.id, meta });
111
- }
112
- return revealed;
113
- };
114
-
115
- // POST /reveal { password } → which items that password unlocks (GCM decrypt-success), with
116
- // their now-decrypted metadata. Others stay opaque. This is the "try a password" cleanup UX.
117
- // Under the gate policy the password must verify first and the standard set is revealed.
118
- app.post(`${base}/reveal`, async (c) => {
119
- const who = await opts.authorize(c);
120
- if (!who) return c.json({ error: 'unauthorized' }, 401);
121
- const body = (await c.req.json().catch(() => ({}))) as { password?: string };
122
- const resolved = await resolvePassword(c, who, body.password || undefined);
123
- if ('error' in resolved) return resolved.error;
124
- const setKey = unlock.deriveSetKey({
125
- userId: who.userId,
126
- userSalt: who.userSalt,
127
- password: resolved.derivePassword,
128
- });
129
- return c.json({ items: await revealWithKey(who.userId, setKey) });
130
- });
131
-
132
- // POST /reveal-unlocked { token } → the same reveal, keyed by an UNLOCK TOKEN instead of the
133
- // password (the token seals the set key — same mechanism the content-key route uses). Lets an
134
- // already-unlocked session list its items without re-typing the password. The token must belong
135
- // to the session caller.
136
- app.post(`${base}/reveal-unlocked`, async (c) => {
137
- const who = await opts.authorize(c);
138
- if (!who) return c.json({ error: 'unauthorized' }, 401);
139
- const body = (await c.req.json().catch(() => ({}))) as { token?: string };
140
- if (!body.token) return c.json({ error: 'token required' }, 400);
141
- const grant = await unlock.verify(body.token);
142
- if (!grant || grant.userId !== who.userId) return c.json({ error: 'invalid token' }, 401);
143
- return c.json({ items: await revealWithKey(who.userId, grant.setKey) });
144
- });
145
-
146
- // GET /u/:token/hls/:source/:video/* → the encrypted HLS tree. The `enc.key` sub-path returns
147
- // the unwrapped AES-128 content key (gated, no-store); every other path serves the encrypted
148
- // segment/playlist (edge-cacheable — hls.js decrypts with the key).
149
- app.get(`${base}/u/:token/hls/:source/:video/*`, async (c) => {
150
- const raw = c.req.param('token');
151
- if (!raw) return c.body(null, 401);
152
- const grant = await unlock.verify(raw);
153
- if (!grant) return c.body(null, 401);
154
- const video = c.req.param('video');
155
- const prefix = `${base}/u/${raw}/hls/${c.req.param('source')}/${video}/`;
156
- const rel = c.req.path.slice(prefix.length);
157
- if (!rel || rel.includes('..')) return c.body(null, 400);
158
-
159
- if (rel.endsWith('enc.key')) {
160
- const item = await index.get(grant.userId, video);
161
- if (!item) return c.body(null, 404);
162
- const content = unwrapContentKey(grant.setKey, item.wrappedContentKey);
163
- // null ⇒ this token's set key doesn't own the item (wrong password / wrong mode).
164
- if (!content) return c.body(null, 403);
165
- // A fresh, tightly-sized ArrayBuffer copy of the 16-byte key — an unambiguous BodyInit
166
- // (wrapping the Node Buffer directly can mis-size the response).
167
- const keyBytes = Uint8Array.from(content.key);
168
- return cors(
169
- c,
170
- new Response(keyBytes.buffer, {
171
- status: 200,
172
- headers: { 'Content-Type': 'application/octet-stream', 'Cache-Control': 'no-store' },
173
- }),
174
- );
175
- }
176
-
177
- const mime = rel.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t';
178
- return cors(
179
- c,
180
- await serveRange(
181
- store,
182
- privateHlsFileKey(grant.userId, video, rel),
183
- mime,
184
- c.req.header('range'),
185
- ),
186
- );
187
- });
188
-
189
- // GET /u/:token/file/:id → a private file stored as one AES-128-CTR ciphertext blob at
190
- // privateSourceKey. Decrypted server-side per request under the token's set key; the mime
191
- // comes from the sealed descriptor, so a token whose set key can't open the item (wrong
192
- // password / wrong mode) gets a 403 and never any bytes.
193
- //
194
- // RANGE SUPPORT (genapp-100): CTR is positionally keyed, so a byte range decrypts from the
195
- // block-aligned ciphertext offset with the IV counter advanced (`cryptBlobRangeStream`). This
196
- // is what makes a big private VIDEO scrub on a remote-store host, where no local ffmpeg means
197
- // no HLS derivatives and the blob route is the only byte path. Plaintext is never stored.
198
- app.get(`${base}/u/:token/file/:id`, async (c) => {
199
- const raw = c.req.param('token');
200
- if (!raw) return c.body(null, 401);
201
- const grant = await unlock.verify(raw);
202
- if (!grant) return c.body(null, 401);
203
- const id = c.req.param('id');
204
- const item = await index.get(grant.userId, id);
205
- if (!item) return c.body(null, 404);
206
- const content = unwrapContentKey(grant.setKey, item.wrappedContentKey);
207
- if (!content) return c.body(null, 403);
208
- const meta = openMeta<{ mime?: string }>(grant.setKey, item.sealedMeta);
209
- const baseHeaders: Record<string, string> = {
210
- 'Content-Type': meta?.mime || 'application/octet-stream',
211
- 'Cache-Control': 'no-store',
212
- 'Accept-Ranges': 'bytes',
213
- };
214
-
215
- const key = privateSourceKey(grant.userId, id);
216
- const range = item.sizeBytes > 0 ? decodeRange(c.req.header('range'), item.sizeBytes) : { kind: 'full' as const };
217
- if (range.kind === 'unsatisfiable') {
218
- return cors(
219
- c,
220
- new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${item.sizeBytes}` } }),
221
- );
222
- }
223
- if (range.kind === 'partial') {
224
- // Fetch ciphertext from the BLOCK-ALIGNED start (CTR block = 16 bytes); the range
225
- // stream drops the intra-block lead-in so the client sees exactly [start, end].
226
- const alignedStart = range.start - (range.start % 16);
227
- const stored = await store.getRange(key, { start: alignedStart, end: range.end });
228
- if (!stored) return c.body(null, 404);
229
- return cors(
230
- c,
231
- new Response(cryptBlobRangeStream(content, stored.stream, range.start), {
232
- status: 206,
233
- headers: {
234
- ...baseHeaders,
235
- 'Content-Range': `bytes ${range.start}-${range.end}/${item.sizeBytes}`,
236
- 'Content-Length': String(range.end - range.start + 1),
237
- },
238
- }),
239
- );
240
- }
241
-
242
- const stored = await store.getRange(key);
243
- if (!stored) return c.body(null, 404);
244
- const headers = { ...baseHeaders };
245
- if (item.sizeBytes > 0) headers['Content-Length'] = String(item.sizeBytes);
246
- return cors(c, new Response(cryptBlobStream(content, stored.stream), { status: 200, headers }));
247
- });
248
-
249
- // Preflight for the cross-origin byte routes.
250
- if (opts.cors) {
251
- app.options(`${base}/u/*`, (c) => {
252
- const acao = resolveAcao(opts.cors, c.req.header('origin'));
253
- const headers: Record<string, string> = {
254
- 'Access-Control-Allow-Methods': 'GET, OPTIONS',
255
- 'Access-Control-Allow-Headers': 'Range',
256
- 'Access-Control-Max-Age': '86400',
257
- };
258
- if (acao) {
259
- headers['Access-Control-Allow-Origin'] = acao;
260
- headers.Vary = 'Origin';
261
- }
262
- return c.body(null, 204, headers);
263
- });
264
- }
265
- }
@@ -1,111 +0,0 @@
1
- import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
2
- import { createDecipheriv } from 'node:crypto';
3
- import { mkdtemp, rm } from 'node:fs/promises';
4
- import { tmpdir } from 'node:os';
5
- import path from 'node:path';
6
- import { isFfmpegAvailable } from '../media-bun';
7
- import { createDiskStore } from '../storage/diskStore';
8
- import { privateHlsFileKey, privateHlsMasterKey } from '../storage/types';
9
- import { deriveServerSetKey, unwrapContentKey } from './crypto';
10
- import { processPrivateVideo } from './pipeline';
11
-
12
- // Integration test — needs ffmpeg on PATH. When absent (CI image without it) the tests no-op so
13
- // the suite still passes; the pipeline itself returns 'failed' when ffmpeg is missing (private
14
- // media can't degrade to plaintext), which is the intended behavior.
15
- describe('processPrivateVideo (encrypted HLS)', () => {
16
- let root: string;
17
- let work: string;
18
- let clip: string;
19
- let hasFfmpeg = false;
20
-
21
- beforeAll(async () => {
22
- hasFfmpeg = await isFfmpegAvailable();
23
- root = await mkdtemp(path.join(tmpdir(), 'pm-store-'));
24
- work = await mkdtemp(path.join(tmpdir(), 'pm-work-'));
25
- if (!hasFfmpeg) return;
26
- clip = path.join(work, 'src.mp4');
27
- const proc = Bun.spawn(
28
- [
29
- 'ffmpeg',
30
- '-y',
31
- '-f',
32
- 'lavfi',
33
- '-i',
34
- 'testsrc=size=854x480:rate=15:duration=3',
35
- '-pix_fmt',
36
- 'yuv420p',
37
- '-c:v',
38
- 'libx264',
39
- '-preset',
40
- 'ultrafast',
41
- '-t',
42
- '3',
43
- clip,
44
- ],
45
- { stdout: 'ignore', stderr: 'ignore' },
46
- );
47
- await proc.exited;
48
- });
49
- afterAll(async () => {
50
- await rm(root, { recursive: true, force: true });
51
- await rm(work, { recursive: true, force: true });
52
- });
53
-
54
- test('encrypts HLS segments; the wrapped key unwraps and decrypts a segment to valid MPEG-TS', async () => {
55
- if (!hasFfmpeg) {
56
- console.warn('[pipeline.spec] ffmpeg absent — skipping encrypted-HLS integration test');
57
- return;
58
- }
59
- const store = createDiskStore(root);
60
- const setKey = deriveServerSetKey('server-secret', 'user-1');
61
-
62
- const res = await processPrivateVideo({
63
- store,
64
- workDir: work,
65
- owner: 't1',
66
- id: 'v1',
67
- sourcePath: clip,
68
- setKey,
69
- });
70
-
71
- expect(res.status).toBe('ready');
72
- expect(res.hasHls).toBe(true);
73
- expect(res.wrappedContentKey).not.toBeNull();
74
-
75
- // The master + a 480p variant playlist exist, and the playlist declares AES-128 encryption.
76
- expect(await store.stat(privateHlsMasterKey('t1', 'v1'))).not.toBeNull();
77
- const playlistBytes = await store.get(privateHlsFileKey('t1', 'v1', '480/playlist.m3u8'));
78
- expect(playlistBytes).not.toBeNull();
79
- const playlist = new TextDecoder().decode(playlistBytes as Uint8Array);
80
- expect(playlist).toContain('#EXT-X-KEY:METHOD=AES-128');
81
- expect(playlist).toContain('URI="enc.key"');
82
-
83
- // The raw content key never leaves wrapped — but unwrapping with the set key recovers it,
84
- // and it correctly AES-128-CBC-decrypts a real segment back to an MPEG-TS stream (0x47 sync).
85
- const content = unwrapContentKey(setKey, res.wrappedContentKey as string);
86
- expect(content).not.toBeNull();
87
- expect(content?.key.length).toBe(16);
88
-
89
- const seg = await store.get(privateHlsFileKey('t1', 'v1', '480/seg_00000.ts'));
90
- expect(seg).not.toBeNull();
91
- const d = createDecipheriv('aes-128-cbc', content?.key as Buffer, content?.iv as Buffer);
92
- const plain = Buffer.concat([d.update(Buffer.from(seg as Uint8Array)), d.final()]);
93
- expect(plain[0]).toBe(0x47); // MPEG-TS sync byte ⇒ decryption + key are correct
94
- }, 30_000);
95
-
96
- test('returns failed when ffmpeg is unavailable (private media cannot go plaintext)', async () => {
97
- // Documents the contract; only meaningfully exercised on a machine without ffmpeg.
98
- if (hasFfmpeg) return;
99
- const store = createDiskStore(root);
100
- const res = await processPrivateVideo({
101
- store,
102
- workDir: work,
103
- owner: 't',
104
- id: 'v',
105
- sourcePath: '/nonexistent',
106
- setKey: deriveServerSetKey('s', 'u'),
107
- });
108
- expect(res.status).toBe('failed');
109
- expect(res.wrappedContentKey).toBeNull();
110
- });
111
- });