Privacy and security

Private by construction, not by setting

There is no privacy toggle in Partmov because there is nothing public to turn off. Rooms are unlisted, links expire, media URLs live for two minutes, and the analytics measure the player rather than the people.

Layers

Six layers of access control

LayerMechanismDetail
AccountMagic link + signed session cookieNo password to phish or reuse. Tokens are single-use, 15-minute, and bound to the requesting email. Sessions are HttpOnly, Secure, SameSite=Lax, revocable server-side.
RoomUnlisted by constructionNo listing endpoint exists, room ids are UUIDv4, and enumeration returns 404 for anything the caller may not know about.
InvitationHashed token + expiry + use limitThe plaintext token is shown to the host once. The database stores only SHA-256. Default expiry 24 hours, default max_uses 1, optional argon2id passphrase, and revocation takes effect on live sockets.
SocketShort-lived ticketThe WebSocket is authenticated by a 60-second JWT scoped to one room and one role, so a leaked URL is not a leaked session.
MediaHMAC-signed, 120-second URLsSignature covers the object key, room id, session id, expiry, and client IP prefix. Object storage is never publicly reachable.
StoragePer-owner prefixes + SSEKeys are u/<user_id>/… so cross-tenant reads require a forged signature, not just a guessed id. Server-side encryption is on.
Signed delivery

How a segment request is authorised

Every media byte passes a gate that knows which room and which session asked for it. Storage credentials never leave the server side.

HMAC signing and verification
# minting (API, on GET /api/rooms/:id/playback-urls)
exp  = now + 120s
msg  = f"{object_key}|{room_id}|{session_id}|{exp}|{ip_prefix}"
sig  = base64url(hmac_sha256(MEDIA_SIGNING_KEY, msg))
url  = f"https://partmov.example/media/{object_key}?e={exp}&s={session_id}&r={room_id}&sig={sig}"

# verifying (media gate, per segment request)
1. exp has not passed                     → else 403 expired
2. sig matches recomputed HMAC            → else 403 bad_signature
3. session is an active participant of room → else 403 not_in_room
4. room.status = 'active' and asset not taken down → else 403 unavailable
5. ip_prefix matches /24 (v4) or /48 (v6) → else 403 moved
6. proxy the byte range from MinIO; never redirect to a storage URL

# rotation: two signing keys are live at once (current + previous) so key
# rotation never invalidates a film that is already playing.
  • Why not presigned S3 URLs? A presigned MinIO URL is valid for its whole TTL to anyone who holds it and cannot be revoked. A gate can check room membership, takedown status, and revocation on every single request.
  • Stopping casual downloads. Segment URLs are short-lived and IP-prefix bound, and the master playlist is signed separately. Someone determined can still reassemble segments they are authorised to watch — that is true of every non-DRM player on the web, and the design says so rather than pretending otherwise.
  • Where DRM would fit.If licensed catalogue content ever requires it, the path is Widevine or PlayReady with an open-source licence server such as Shaka Packager plus an EME integration. It costs a per-title packaging step, browser-specific failure modes, and — decisively — the loss of fine-grained playback control on some platforms, which is the product’s core promise. So DRM stays out until a contract requires it, and never applies to user uploads.
Isolation and deletion

Content stays with its owner, and leaves when told

  • Structural isolation. Object keys embed the owner id, every asset query is scoped by owner_id, and the media gate re-derives ownership from the room rather than trusting the request.
  • Deletion is a pipeline, not a flag. DELETE /api/assets/:id marks the asset deleting, closes dependent rooms with reason: asset_removed, revokes their invitations, then a purge worker deletes the originals prefix, the renditions prefix, subtitle objects, poster, and sprites — verifying with a list call that zero objects remain before writing asset.purged to the audit log.
  • Backups respect deletion. Media backups are prefix-synchronised with mc mirror --remove, so a purge propagates. Database backups older than the purge are the only place a title reference survives, and they age out on a 30-day schedule.
  • Account deletion. Cascades to assets, rooms, invitations, and sessions; enqueues purge jobs for every prefix; and leaves behind only anonymised audit rows with a null actor.

The verification step matters. A purge that does not confirm an empty prefix is a promise, not a deletion — so the job fails loudly and retries rather than reporting success.

Abuse prevention

Rejecting bad input before it becomes a problem

VectorControl
Upload validationffprobe must find a decodable video stream; container must be in an allowlist (mkv, mp4, mov, webm, avi, ts); declared size and SHA-256 must match the received bytes; anything else is deleted, not quarantined.
Dangerous inputFFmpeg runs in a container with no network, a read-only root filesystem, a dropped capability set, a memory ceiling, and a wall-clock timeout. Protocol whitelisting is enabled so a crafted file cannot make FFmpeg fetch a remote URL.
Archive and script tricksContent type is decided by probing, never by extension or client-supplied MIME. Filenames are regenerated as UUIDs, so path traversal and double-extension tricks have no surface.
Storage abusePer-account quota and per-file size cap, enforced before the tus endpoint is issued and re-checked on completion.
Link brute forceTokens are 132 bits of entropy. Join attempts are limited to 5 per IP per minute and 20 per room per hour, and a room locks joins for 15 minutes after 10 failures.
Traffic abusePer-session segment request ceiling based on the film's real bitrate: a client that requests far more than realtime is scraping, and gets throttled then blocked.
Chat abuseLength caps, per-participant rate limits, and no link unfurling. In a two-person room the honest remedy is that either person can close the room instantly.
Analytics

Measure the playback, not the person

Every metric below describes the system's behaviour. None of them describe what someone watched, when they watched it, or with whom.

MetricTypeTarget
partmov_startup_mshistogramp95 under 2.5 s from room open to first frame
partmov_rebuffer_ratiogaugeunder 0.5 percent of watch time
partmov_sync_drift_mshistogramp95 under 120 ms, p99 under 400 ms
partmov_room_join_successcounter pairover 99 percent of valid invites join on first attempt
partmov_rate_nudge_secondscounterhow long clients spend correcting — a proxy for network health
partmov_hard_seek_totalcountershould stay near zero; every one is a visible artefact
partmov_transcode_duration_mshistogramper rung, to size worker capacity
partmov_ws_reconnects_totalcounterby reason, to catch proxy timeout regressions
  • No third-party scripts, no advertising or analytics SDKs, no fingerprinting, no cross-site cookies.
  • Access logs store an IP prefix rather than a full address, and are dropped after 14 days.
  • Metric labels are bounded: room and user ids never become label values, so cardinality stays flat and dashboards cannot become a viewing history.
  • Client telemetry is aggregate-first: the browser reports buffer and drift numbers, never a list of titles.

Threat model, stated plainly. Partmov defends against link leakage, room enumeration, cross-tenant reads, direct storage access, casual scraping, and operator over-collection. It does not defend against a participant recording their own screen, and it does not claim to.