Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
c1a1d1a
Update data model, including previous data model changes in datamodel…
ivanslabbert Aug 4, 2026
8391a50
feat(media): integrate Luminary Media Convert for audio encoding
johan-bell Aug 14, 2026
8c5ff23
feat(audio): implement audio track selection logic and update related…
johan-bell Aug 14, 2026
5c86f6f
test(api): move media validation tests to the HLS model
johan-bell Aug 14, 2026
73ce3a4
feat(api): delete a media collection from storage when the user asks
johan-bell Aug 17, 2026
44db417
feat(cms): offer to delete the media files when deleting a post or tag
johan-bell Aug 17, 2026
52192f7
Merge main into api-cms-delete-and-migrate-media-storage
johan-bell Aug 17, 2026
f3b0a7e
feat(api): move a media collection when its bucket changes (#1896)
johan-bell Aug 18, 2026
c17822c
Merge remote-tracking branch 'origin/main' into 1878-cms-encoder-inte…
johan-bell Aug 18, 2026
61c14db
feat: store media URLs relative to their bucket, and warn before repl…
johan-bell Aug 18, 2026
6b8afdc
build(app): consume the encoder's player packages as a submodule (#1911)
johan-bell Aug 18, 2026
5629140
feat: play video through the encoder's player, and deliver the key it…
johan-bell Aug 19, 2026
3391f1e
feat(cms): preview a video before publishing it (#1914)
johan-bell Aug 19, 2026
818dbac
chore(app): remove the code the player swap replaced
johan-bell Aug 19, 2026
e04c554
feat(cms): add tests for handling YouTube links in VideoPreview compo…
johan-bell Aug 19, 2026
6384666
test(cms): exercise the preview against a real API, not a stub
johan-bell Aug 19, 2026
ebbfb14
ci: give the workflows a way to clone the encoder submodule (#1918)
johan-bell Aug 19, 2026
7434fe6
ci: fall back to the built-in token when the submodule secret is unse…
johan-bell Aug 19, 2026
d772263
Add server-side sidecar documents for HLS encryption keys
MrDirkelz Aug 18, 2026
8bcbd8a
feat(api): add HLS key masking with shared test vector (step 1)
MrDirkelz Aug 18, 2026
58b734e
feat(api): store HLS keys as masked sidecars, retire the crypto path …
MrDirkelz Aug 18, 2026
4e02bf2
feat(api): delete sidecars on parent delete and on key removal (step 4)
MrDirkelz Aug 19, 2026
1a8eef6
feat(api): add GET /sidecar endpoint (step 5)
MrDirkelz Aug 19, 2026
76464fe
feat(api): rate-limit GET /sidecar, extract shared limiter (step 5 fo…
MrDirkelz Aug 19, 2026
9768e95
fix(api): reject malformed hlsKey at validation instead of masking it…
MrDirkelz Aug 19, 2026
0d78503
docs: retire docs/sidecar/ proposal notes now that ADR 0018 supersede…
MrDirkelz Aug 19, 2026
3f6c6a3
fix(cms): drop stale backend-internals reference from hlsKey JSDoc
MrDirkelz Aug 19, 2026
5d8143e
Add per-worktree dev scripts and port config for local dev servers
MrDirkelz Aug 19, 2026
ac2a8aa
feat(api): add v20 schema upgrade to move legacy video field onto parent
MrDirkelz Aug 19, 2026
8ceb3bc
test(api): fix processPostTagDto media specs left stale by the sideca…
MrDirkelz Aug 19, 2026
ba08e18
fix(api): address PR #1915 review feedback on the sidecar endpoint
MrDirkelz Aug 19, 2026
8438e5c
docs(adr): renumber HLS sidecar ADR to 0019 to resolve collision with…
MrDirkelz Aug 19, 2026
96eda01
test(app): stub the real player in SingleContent specs left stale by …
MrDirkelz Aug 19, 2026
d97631d
ci: the encoder submodule is public, drop the token workaround (#1920)
johan-bell Aug 19, 2026
c5f6c34
ci: build the encoder's libraries before running app and cms tests (#…
johan-bell Aug 19, 2026
c1ffe13
build: re-pin the encoder — a failing load surfaces through state, no…
johan-bell Aug 19, 2026
52e8e49
Merge branch '1878-api-cms-hls-media-data-model' into 1901-encryption…
johan-bell Aug 19, 2026
025e51c
test(cms): wait for the unmasked key, and drop the live spec for the …
johan-bell Aug 19, 2026
0992285
test(app): wait for the language options instead of a single tick (#1…
johan-bell Aug 19, 2026
fff34ec
feat(cms): request encryption again, and retire the workarounds it fo…
johan-bell Aug 19, 2026
5925ddd
build: rename the submodule directory to luminary-media-convert (#1924)
johan-bell Aug 19, 2026
bebb202
CMS: one Media section — an encode that survives a reload, and a prev…
johan-bell Sep 1, 2026
211b1ee
build: update submodule to latest commit (#1980)
johan-bell Sep 1, 2026
163dfaa
Merge main into the epic: two v20 upgrades, and the player changes ma…
johan-bell Sep 1, 2026
45f5d70
Record main as merged into the epic
johan-bell Sep 1, 2026
4791b8c
test(api): mock v21 in the upgrade chain spec
johan-bell Sep 1, 2026
b8c7b30
feat: add initial package.json for Electron app configuration
johan-bell Sep 1, 2026
89fb480
fix(cms): ask for the encoder to be opened before blaming the browser
johan-bell Sep 2, 2026
dd03773
fix(api): only require a media bucket for media in our own storage (#…
johan-bell Sep 2, 2026
98cfb7d
fix(cms): do not report the API's own parent mirror as a remote chang…
johan-bell Sep 2, 2026
0d5e044
feat: bucket-owned encode settings, and an encoder update check (#1989)
johan-bell Sep 2, 2026
a74892f
fix(cms): give the bucket-form toggles room at the modal's edge (#1990)
johan-bell Sep 2, 2026
1da4dfd
fix: drop the stray root package.json, advance the submodule past the…
johan-bell Sep 2, 2026
86d2177
Merge main into the epic: NestJS 11, slug-change rendering — and make…
johan-bell Sep 2, 2026
c18d0b9
Record main as merged into the epic
johan-bell Sep 2, 2026
ff98ab9
build: point the submodule at the encoder's main (#2000)
johan-bell Sep 3, 2026
0257f73
build: update submodule to latest commit for luminary-media-convert
johan-bell Sep 3, 2026
8f617ef
build: update submodule to latest commit for luminary-media-convert
johan-bell Sep 3, 2026
d9f1369
build: pin the submodule to a commit that exists on the remote (#2001)
johan-bell Sep 3, 2026
dba6f73
feat(cms): let an editor download the encoder from where they need it…
johan-bell Sep 4, 2026
5979002
Keep the encoder's availability true, and stop printing raw floats at…
johan-bell Sep 4, 2026
990d8bc
ci: drop the submodule token, the encoder repository is public (#2005)
johan-bell Sep 4, 2026
da7b3d5
Second review pass over #1910: races, duplication, dead code (#2007)
johan-bell Sep 4, 2026
2825c6c
Take trailing slashes off by index, not by a backtracking regex (#2009)
johan-bell Sep 4, 2026
7665808
Merge main into the epic
johan-bell Sep 4, 2026
0077ae1
Say "Uploading" while the encoder is uploading (#2012)
johan-bell Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# The build context is the repository root, and every Dockerfile here installs
# its own dependencies. Shipping the working tree's node_modules would send
# well over a gigabyte to the daemon and then be overwritten by `npm ci` anyway.
**/node_modules
**/dist
**/.git

# Test fixtures and desktop build output from the encoder submodule: large,
# and nothing in an image needs them.
luminary-media-convert/test-media
luminary-media-convert/app-electron/bin
luminary-media-convert/app-electron/release
luminary-media-convert/api/work

# NOT .env: the deploy workflow writes app/.env immediately before `docker build`,
# and the PWA plugin reads its icon paths from it. Excluding it here would fail
# the staging build with an undefined icon path.
**/*.log
2 changes: 2 additions & 0 deletions .github/workflows/app-deploy-staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
submodules: true

- name: Create .env file
run: |
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/app-unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
submodules: true

- name: Setup Node.js
uses: actions/setup-node@v6
Expand All @@ -31,6 +33,14 @@ jobs:
cache: "npm"
cache-dependency-path: app/package-lock.json

- name: Build the encoder's player libraries
# The submodule's packages ship only dist/, which is gitignored — a fresh
# checkout has sources and no entry points, and Vite fails with "Failed to
# resolve entry for package". ci:libs installs the five library workspaces
# without electron; build:libs builds them. Same as both Dockerfiles.
run: npm run ci:libs; npm run build:libs;
working-directory: luminary-media-convert

- name: Build shared dependencies
run: npm ci; npm run build;
working-directory: shared
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/cms-deploy-staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
submodules: true

- name: Create .env file
run: |
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/cms-unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
submodules: true

- name: Setup Node.js
uses: actions/setup-node@v6
Expand All @@ -31,6 +33,14 @@ jobs:
cache: "npm"
cache-dependency-path: cms/package-lock.json

- name: Build the encoder's player libraries
# The submodule's packages ship only dist/, which is gitignored — a fresh
# checkout has sources and no entry points, and Vite fails with "Failed to
# resolve entry for package". ci:libs installs the five library workspaces
# without electron; build:libs builds them. Same as both Dockerfiles.
run: npm run ci:libs; npm run build:libs;
working-directory: luminary-media-convert

- name: Build shared dependencies
run: npm ci; npm run build;
working-directory: shared
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/e2e-local-stack.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
submodules: true

- name: Setup Node.js
uses: actions/setup-node@v6
Expand All @@ -49,6 +51,13 @@ jobs:
cms/package-lock.json
playwright-tests/package-lock.json

- name: Build the encoder's player libraries
# The submodule's packages ship only dist/, which is gitignored — a fresh
# checkout has sources and no entry points, and Vite fails with "Failed to
# resolve entry for package". Same as the unit-test workflows.
run: npm run ci:libs; npm run build:libs;
working-directory: luminary-media-convert

- name: Start CouchDB
run: |
./scripts/start-couchdb-in-ci.sh
Expand Down
4 changes: 4 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[submodule "luminary-media-convert"]
path = luminary-media-convert
url = https://github.com/bccsa/luminary-media-convert.git
branch = main
19 changes: 17 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,24 @@ These are the seams that bite when you change one side and forget the other:

## Comment style

Comments explain **why** code is added, in ~1–3 small sentences — neat and quick to read. They must not describe domain-specific / Luminary-internal concepts in depth, describe a problem or bug, narrate how logic previously worked ("previously", "used to", "the old approach", "now"), reference issue/ticket/spec/phase numbers, or run into multi-paragraph rationale (that belongs in PR descriptions, ADRs, or commit messages). Apply this to inline `//`, block `/* */`, JSDoc `/** */`, and `<!-- -->` in `.vue`. Don't rewrite pre-existing comments unless asked; only apply it to comments you're adding or already changing.
Comments capture *why* code exists or why it's written a certain way — not *what* it does (the code already says that) and not architecture or design rationale. A comment that reads like a paragraph of prose, enumerates everywhere a concept is "excluded," or reproduces a doc is a signal it belongs in an ADR (`docs/adr/`), the package docs, or the datamodel — not inline.

Comments should stay relevant to the repo they're in — don't explain how something outside this repo (a separate packaging/deploy pipeline, another service, an external tool) consumes or wraps the code. This repo has one source of truth for its own behavior; what happens to the build afterward belongs in that other project's docs, not here.
- **No comment when the *why* is obvious or the code is self-explanatory.** Comments don't have to be everywhere.
- **Keep a comment to a tldr — one or two short lines of *why*.** If more is needed, the explanation goes in docs and the comment shrinks to a one-line pointer.
- **Don't trim past clarity.** Brevity is not the goal; a readable *why* is. Keep the subject/referent — a dangling fragment like `// Never replicated` is useless because *replicated to what?* Say `// Sidecars are never replicated to clients`. If dropping a word loses what the comment is about, keep the word.
- **Never reproduce documentation in a comment.** A pointer is fine; re-explaining the contents is not.
- **Never point a code comment at a `temp_` doc** (see Development docs below) — those are scaffolding, not a source of truth the code should depend on. If the *why* needs to live in code, write it as JSDoc, not as a link to a temp doc.
- **JSDoc on exported APIs:** a one-line *why/what-it-is*. Reserve longer treatment for the docs.
- Don't rewrite pre-existing comments unless asked; match the surrounding file's convention.

## Development docs

Working/proposal docs that exist only to develop a feature — not to document the final product — are **temporary scaffolding**. Treat them as such:

- **Prefix their filenames with `temp_`** (e.g. `docs/temp_sidecar-...md`) so they're trivially findable and removable once the feature lands.
- **Never reference a `temp_` doc from code or tests.** Code comments and JSDoc must stand on their own; a `temp_` doc will be deleted, so linking to it rots immediately.
- When the feature ships, either delete the `temp_` docs (if the substance now lives in code/ADRs) or promote the durable parts into a permanent doc/ADR and drop the `temp_` prefix.
- Anything that genuinely needs to be documented *in code* uses **JSDoc** (`/** */`), neatly — not `//` prose paragraphs.

## When changes span multiple packages

Expand Down
6 changes: 6 additions & 0 deletions api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ import { QueryController } from "./endpoints/query.controller";
import { FtsSearchService } from "./endpoints/ftsSearch.service";
import { FtsSearchController } from "./endpoints/ftsSearch.controller";
import { StorageStatusController } from "./endpoints/storageStatus.controller";
import { EncoderConfigController } from "./endpoints/encoderConfig.controller";
import { SidecarController } from "./endpoints/sidecar.controller";
import { AuthIdentityService } from "./auth/authIdentity.service";
import { QueryRateLimiterService } from "./ratelimit/queryRateLimiter.service";
import { SidecarRateLimiterService } from "./ratelimit/sidecarRateLimiter.service";

let winstonTransport: winston.transport;
if (!process.env.NODE_ENV || process.env.NODE_ENV === "development") {
Expand Down Expand Up @@ -57,13 +60,16 @@ if (!process.env.NODE_ENV || process.env.NODE_ENV === "development") {
QueryController,
FtsSearchController,
StorageStatusController,
EncoderConfigController,
SidecarController,
],
providers: [
DbService,
Socketio,
S3Service,
QueryService,
QueryRateLimiterService,
SidecarRateLimiterService,
FtsSearchService,
ChangeRequestService,
AuthIdentityService,
Expand Down
9 changes: 9 additions & 0 deletions api/src/changeRequests/aclValidation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,13 @@ describe("validateAcl", () => {

expect(result).toHaveLength(0);
});

it("should reject an ACL entry for DocType.Sidecar (never replicable, never grantable)", () => {
// Sidecar is absent from availablePermissionsPerDocType — the load-bearing
// non-replication guarantee. An entry is stripped.
const acl = [createEntry(DocType.Sidecar, "g1", [AclPermission.View, AclPermission.CmsView])];
const result = validateAcl(acl);

expect(result).toHaveLength(0);
});
});
4 changes: 3 additions & 1 deletion api/src/changeRequests/aclValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ const availablePermissionsPerDocType = {
],
};

// Valid DocTypes that can be used for ACL assignments
// Derived from the map keys, NOT the DocType enum — so omitting Crypto/Sidecar
// here is load-bearing: no ACL can grant view on them, so no client joins a
// crypto-*/sidecar-* room.
const validDocTypes = Object.keys(availablePermissionsPerDocType) as DocType[];

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { resolveCollectionPrefix } from "./deleteMediaCollection";

/** A real collection URL: MinIO, where the bucket name is part of the public path. */
const PUBLIC = "http://localhost:9000/media";
const SESSION = "c5829f07-4ba8-42ed-a449-80d83e6c0b53";
const HLS = `${PUBLIC}/${SESSION}/master.m3u8`;

const prefixOf = (r: ReturnType<typeof resolveCollectionPrefix>) =>
"prefix" in r ? r.prefix : undefined;
const refusalOf = (r: ReturnType<typeof resolveCollectionPrefix>) =>
"refusal" in r ? r.refusal : undefined;

describe("resolveCollectionPrefix", () => {
describe("a URL stored relative to the bucket", () => {
it("needs no public URL at all — the path is already the key", () => {
const r = resolveCollectionPrefix(
"/c5829f07-4ba8-42ed-a449-80d83e6c0b53/master.m3u8",
undefined,
);
expect("prefix" in r && r.prefix).toBe(
"c5829f07-4ba8-42ed-a449-80d83e6c0b53",
);
});

it("still refuses a folder the encoder did not write", () => {
const r = resolveCollectionPrefix("/shared-folder/master.m3u8", undefined);
expect("refusal" in r && r.refusal).toContain("not a session id");
});

it("still refuses the bucket root", () => {
const r = resolveCollectionPrefix("/master.m3u8", undefined);
expect("refusal" in r && r.refusal).toContain("bucket root");
});
});

describe("resolves a collection this API wrote", () => {
it("strips the bucket's public base and the master filename", () => {
expect(prefixOf(resolveCollectionPrefix(HLS, PUBLIC))).toBe(SESSION);
});

it("tolerates a trailing slash on the configured public URL", () => {
expect(prefixOf(resolveCollectionPrefix(HLS, `${PUBLIC}/`))).toBe(SESSION);
expect(prefixOf(resolveCollectionPrefix(HLS, `${PUBLIC}///`))).toBe(SESSION);
});

it("keeps a nested path prefix intact", () => {
// pathPrefix on the session puts the collection in a subfolder.
const url = `${PUBLIC}/tenant-a/videos/${SESSION}/master.m3u8`;
expect(prefixOf(resolveCollectionPrefix(url, PUBLIC))).toBe(
`tenant-a/videos/${SESSION}`,
);
});

it("ignores a query string or fragment", () => {
expect(prefixOf(resolveCollectionPrefix(`${HLS}?v=2`, PUBLIC))).toBe(SESSION);
expect(prefixOf(resolveCollectionPrefix(`${HLS}#top`, PUBLIC))).toBe(SESSION);
});

it("handles a bucket published at a bare host", () => {
const base = "https://cdn.example.com";
expect(
prefixOf(resolveCollectionPrefix(`${base}/${SESSION}/master.m3u8`, base)),
).toBe(SESSION);
});
});

describe("refuses anything it cannot prove it wrote", () => {
it("refuses a URL in a different bucket", () => {
const other = "https://someone-elses-cdn.example.com/media";
expect(refusalOf(resolveCollectionPrefix(`${other}/${SESSION}/master.m3u8`, PUBLIC)))
.toMatch(/not in this bucket/);
});

it("refuses a bucket whose name merely prefixes another", () => {
// The separator is part of the match, or `…/media` would claim
// `…/media-archive/<session>/master.m3u8`.
const url = `${PUBLIC}-archive/${SESSION}/master.m3u8`;
expect(refusalOf(resolveCollectionPrefix(url, PUBLIC))).toMatch(/not in this bucket/);
});

it("refuses a URL that is not a master playlist", () => {
expect(refusalOf(resolveCollectionPrefix(`${PUBLIC}/${SESSION}/`, PUBLIC)))
.toMatch(/master playlist/);
expect(
refusalOf(
resolveCollectionPrefix(`${PUBLIC}/${SESSION}/stream/playlist.m3u8`, PUBLIC),
),
).toMatch(/master playlist/);
});

it("refuses the bucket root", () => {
expect(refusalOf(resolveCollectionPrefix(`${PUBLIC}/master.m3u8`, PUBLIC)))
.toMatch(/bucket root/);
});

it("refuses a path that tries to climb out", () => {
const url = `${PUBLIC}/../other-tenant/${SESSION}/master.m3u8`;
expect(refusalOf(resolveCollectionPrefix(url, PUBLIC))).toMatch(/suspicious path/);
});

it("refuses a folder that is not a session id", () => {
// The case the tick box makes possible: hlsUrl is editable, so someone
// can paste a URL naming a folder shared with other content.
const url = `${PUBLIC}/shared-videos/master.m3u8`;
expect(refusalOf(resolveCollectionPrefix(url, PUBLIC))).toMatch(/not a session id/);
});

it("refuses when either side is missing", () => {
expect(refusalOf(resolveCollectionPrefix(undefined, PUBLIC))).toMatch(/no media URL/);
expect(refusalOf(resolveCollectionPrefix(HLS, undefined))).toMatch(/no public URL/);
expect(refusalOf(resolveCollectionPrefix("", PUBLIC))).toMatch(/no media URL/);
});
});

it("never returns a prefix with a leading or trailing slash", () => {
// The caller appends '/' to scope the listing; a stray slash would widen it.
const prefix = prefixOf(resolveCollectionPrefix(HLS, PUBLIC))!;
expect(prefix.startsWith("/")).toBe(false);
expect(prefix.endsWith("/")).toBe(false);
});
});
Loading
Loading