Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 3 additions & 7 deletions .dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,6 @@ GOOGLE_CLIENT_SECRET=<secret from the shared vault; never commit>
GOOGLE_REDIRECT_URI=http://localhost:5173/api/auth/callback
ALLOWED_EMAIL_DOMAIN="sentry.io"

# The local adapter issues direct-upload fixtures and protected-playback contracts.
# It does not transcode, generate HLS, or move video bytes.
STREAM_MODE="fake"
STREAM_ALLOWED_ORIGIN="localhost"
STREAM_DELIVERY_HOST="customer-fake.cloudflarestream.com"
STREAM_WEBHOOK_SECRET="replace-with-a-local-signing-secret"
VIDEO_SERVICE_TOKEN="replace-with-a-local-job-token"
# R2 uploads use the local Workflow and pinned FFmpeg Container declared in
# wrangler.jsonc. No Cloudflare video credentials or remote resources are needed.
# Ready derivatives play through the authenticated same-origin MP4 endpoint.
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
*
!Dockerfile.video-processor
!processor/video-processor.mjs
4 changes: 2 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ jobs:
run: |
test -n "$CLOUDFLARE_API_TOKEN" || { echo 'Missing CLOUDFLARE_API_TOKEN'; exit 1; }
test "$CLOUDFLARE_ACCOUNT_ID" = '773afa1f62ff86c80db4f24f7ff1e9c8' || { echo 'Unexpected Cloudflare account'; exit 1; }
node -e "const c=require('./wrangler.production.json'); if (c.account_id !== '773afa1f62ff86c80db4f24f7ff1e9c8' || c.vars.STREAM_MODE !== 'disabled') process.exit(1); for (const value of [c.d1_databases[0].database_id,c.r2_buckets[0].bucket_name,c.vars.APP_ORIGIN,c.vars.GOOGLE_REDIRECT_URI,c.vars.GOOGLE_CLIENT_ID]) if (!value || /replace.me/i.test(value) || value === '00000000-0000-0000-0000-000000000000') process.exit(1)"
node -e "const c=require('./wrangler.production.json'); const videos=c.r2_buckets.find(x=>x.binding==='VIDEOS'); const workflow=c.workflows.find(x=>x.binding==='VIDEO_PROCESSING_WORKFLOW'); const container=c.containers.find(x=>x.class_name==='VideoProcessorContainer'); if (c.account_id !== '773afa1f62ff86c80db4f24f7ff1e9c8' || videos?.bucket_name !== 'hackweek-video-media-production' || workflow?.name !== 'hackweek-video-processing-production' || container?.name !== 'hackweek-video-processor-production' || container?.max_instances !== 2 || c.vars.VIDEO_PROCESSOR_CONCURRENCY !== '2') process.exit(1); for (const value of [c.d1_databases[0].database_id,c.r2_buckets[0].bucket_name,c.vars.APP_ORIGIN,c.vars.GOOGLE_REDIRECT_URI,c.vars.GOOGLE_CLIENT_ID]) if (!value || /replace.me/i.test(value) || value === '00000000-0000-0000-0000-000000000000') process.exit(1)"
- run: npm run build
- name: Apply reviewed D1 migrations
- name: Apply expand-compatible D1 migrations
run: npx wrangler d1 migrations apply hackweek-db --remote --config wrangler.production.json --yes
env: &cloudflare
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
Expand Down
34 changes: 0 additions & 34 deletions .github/workflows/video-archive.yml

This file was deleted.

26 changes: 0 additions & 26 deletions .github/workflows/video-measure.yml

This file was deleted.

13 changes: 13 additions & 0 deletions Dockerfile.video-processor
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
FROM mwader/static-ffmpeg:8.0.1@sha256:252705ff88532fa338e7065c21792756552f8fe7c212f84bc503d3c340689594 AS ffmpeg

FROM node:24.11.0-bookworm-slim@sha256:76d0ed0ed93bed4f4376211e9d8fddac4d8b3fbdb54cc45955696001a3c91152
COPY --from=ffmpeg /ffmpeg /usr/local/bin/ffmpeg
COPY --from=ffmpeg /ffprobe /usr/local/bin/ffprobe
COPY processor/video-processor.mjs /app/video-processor.mjs
RUN useradd --create-home --uid 10001 processor \
&& chmod 0555 /usr/local/bin/ffmpeg /usr/local/bin/ffprobe /app/video-processor.mjs
USER processor
WORKDIR /app
ENV PORT=8080
EXPOSE 8080
ENTRYPOINT ["node", "/app/video-processor.mjs"]
43 changes: 31 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,47 +1,66 @@
# Sentry Hackweek

Hackweek is an internal React + TypeScript application served by one Hono Cloudflare Worker. Application-owned Google OAuth authenticates users, D1 owns sessions/data/roles, and private R2 stores attachments. The core production rollout serves the SPA with Cloudflare Static Assets at `https://hackweek.getsentry.workers.dev` and keeps `STREAM_MODE=disabled`; Stream, video screening, and archive operations are dormant until a separately approved rollout. The UI preserves the Sentry `#HACKWEEK` identity and archive hierarchy.
Hackweek is an internal React + TypeScript application served by one Hono Cloudflare Worker. Application-owned Google OAuth authenticates users, D1 owns sessions/data/roles, and private R2 stores attachments plus immutable video originals and canonical MP4 derivatives. Project videos are processed by a Cloudflare Workflow using the pinned FFmpeg Container in `Dockerfile.video-processor`; ready media is served only through authenticated same-origin range endpoints.

## Requirements

- Node.js 24.11 or newer (Volta and CI pin 24.19)
- npm 11 or newer
- Docker with a running Linux engine (Docker Desktop or OrbStack)
- `ffmpeg` and `ffprobe` 8.x on the host for generated local fixtures

## Deterministic local start
No Cloudflare video resource or credential is required for local development.

## Local video environment

Complete the one-time setup without replacing an existing `.dev.vars`:

```bash
npm ci
cp .dev.vars.example .dev.vars
rm -rf .wrangler/state
[ -f .dev.vars ] || cp .dev.vars.example .dev.vars
npm run db:migrate:local
npm run migrate:local -- \
--database test/fixtures/firebase/database.json \
--storage-manifest test/fixtures/firebase/storage-manifest.json \
--storage-root test/fixtures/firebase/storage
npm run dev
```

Before starting the app, configure a Google OAuth Web application to allow the JavaScript origin `http://localhost:5173` and redirect URI `http://localhost:5173/api/auth/callback`. Replace the placeholders in `.dev.vars` with its client ID and the client secret from the shared vault; never commit `.dev.vars`.
Configure the Google OAuth Web application in `.dev.vars` for JavaScript origin `http://localhost:5173` and redirect URI `http://localhost:5173/api/auth/callback`. Use the shared-vault client secret; never commit `.dev.vars`.

Then one command starts the application, local D1/R2, local Workflow, and the real pinned FFmpeg Container:

```bash
npm run dev:video
```

Open `http://localhost:5173`, sign in, and use a current project’s video panel. Uploading a video performs real multipart local-R2 upload and Workflow/Container processing. When the status becomes ready, verify project playback, then save the project in the admin screening order and open the year reel. Originals and derivatives remain private and are retained after video retirement.

Open `http://localhost:5173` and sign in with Google. D1 remains the sole role authority. To promote your local user after signing in once, replace the email below and run:
To promote a local user after signing in once, replace the email below and run:

```bash
npx wrangler d1 execute hackweek-db --local --command \
"UPDATE users SET is_admin = 1, updated_at = CURRENT_TIMESTAMP WHERE google_subject IS NOT NULL AND email = 'you@sentry.io'"
```

Resetting `.wrangler/state` removes the promotion. Never run that command with `--remote`.
Never run that command with `--remote`.

## Authentication
### Troubleshooting

Google OAuth is the only browser authentication path in every environment, including local development. It uses the Authorization Code flow with PKCE, state, nonce, confidential server exchange, Google JWKS validation, exact verified `@sentry.io` enforcement, hashed opaque D1 sessions, and HttpOnly cookies.
- **Container does not start:** run `docker version` and `npm run video:processor:build`. Both client and server must be available.
- **Upload remains queued:** keep `npm run dev:video` running and inspect its Workflow step output. Local processing concurrency is intentionally one.
- **OAuth callback fails:** ensure `APP_ORIGIN`, the Google allowed origin, and `GOOGLE_REDIRECT_URI` all use `http://localhost:5173` exactly.
- **Stale local data:** stop the app and remove only `.wrangler/state`, then repeat the local migrations. This never touches remote resources.
- **Playback fails:** confirm the video is ready and signed-in playback returns `200` or `206`; unready, retired, and anonymous reads are intentionally rejected.

## Authentication

All core browser APIs except health require a D1-backed user. Authenticated mutations and logout require an exact same-origin `Origin` header. Logout revokes the current D1 session. Login rotates existing sessions. Google/client claims never grant admin access. Dormant Stream webhook and video-job endpoints use separate machine-auth boundaries if real Stream is approved later.
Google OAuth is the only browser authentication path. It uses Authorization Code with PKCE, state and nonce validation, Google JWKS verification, exact verified `@sentry.io` enforcement, hashed opaque D1 sessions, and HttpOnly cookies. D1 is the sole role authority. Authenticated mutations require the exact same-origin `Origin` header.

## Quality gates

```bash
npm run verify
npm audit --omit=dev --audit-level=high
```

This generates binding types, typechecks, checks formatting/lint, runs Worker/frontend/migration/player tests, builds, performs a credential-free deployment dry run, and runs an isolated seeded D1/R2 journey with fake-Stream contract coverage. Local tests do not prove real Google OAuth, deployed bindings, or imported data. Real Stream is outside that gate.
The gate generates binding types, typechecks, checks formatting/lint, runs the standard test suites, builds, and performs a credential-free production dry run. It does not deploy, provision, access remote resources, or prove real Google OAuth.
108 changes: 108 additions & 0 deletions migrations/0007_r2_video_lifecycle.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
PRAGMA foreign_keys = ON;

-- Expand only: keep the legacy project_videos and stream_events contract intact
-- until a separately approved contraction after the rollback window.
CREATE TABLE video_submissions (
id TEXT PRIMARY KEY NOT NULL,
project_id TEXT NOT NULL REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE,
original_name TEXT NOT NULL CHECK (length(trim(original_name)) BETWEEN 1 AND 255),
content_type TEXT,
size_bytes INTEGER CHECK (size_bytes IS NULL OR size_bytes BETWEEN 1 AND 5368709120),
original_r2_key TEXT UNIQUE,
processed_r2_key TEXT UNIQUE,
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'processing', 'ready', 'failed', 'retired')),
processing_attempt INTEGER NOT NULL DEFAULT 1 CHECK (processing_attempt >= 1),
duration_seconds REAL CHECK (duration_seconds IS NULL OR duration_seconds >= 0),
loudness_lufs REAL,
gain_db REAL CHECK (gain_db IS NULL OR gain_db BETWEEN -12 AND 12),
error_message TEXT,
retired_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK ((status = 'retired') = (retired_at IS NOT NULL)),
CHECK (status = 'retired' OR (original_r2_key IS NOT NULL AND size_bytes IS NOT NULL))
) STRICT;

CREATE UNIQUE INDEX video_submissions_active_project_idx
ON video_submissions(project_id) WHERE retired_at IS NULL;
CREATE INDEX video_submissions_status_idx
ON video_submissions(status, updated_at);

CREATE TABLE video_uploads (
id TEXT PRIMARY KEY NOT NULL,
video_id TEXT NOT NULL UNIQUE,
project_id TEXT NOT NULL REFERENCES projects(id) ON UPDATE CASCADE ON DELETE CASCADE,
creator_id TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE RESTRICT,
r2_upload_id TEXT,
original_r2_key TEXT NOT NULL UNIQUE,
original_name TEXT NOT NULL CHECK (length(trim(original_name)) BETWEEN 1 AND 255),
content_type TEXT,
expected_size_bytes INTEGER NOT NULL CHECK (expected_size_bytes BETWEEN 1 AND 5368709120),
part_size_bytes INTEGER NOT NULL CHECK (part_size_bytes >= 5242880),
status TEXT NOT NULL DEFAULT 'creating'
CHECK (status IN (
'creating', 'uploading', 'completing', 'expiring', 'completed', 'aborted', 'expired'
)),
expires_at TEXT NOT NULL,
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (r2_upload_id IS NOT NULL OR status IN ('creating', 'expiring', 'aborted', 'expired')),
CHECK ((status = 'completed') = (completed_at IS NOT NULL))
) STRICT;

CREATE UNIQUE INDEX video_uploads_active_project_idx
ON video_uploads(project_id)
WHERE status IN ('creating', 'uploading', 'completing', 'expiring');
CREATE INDEX video_uploads_expiry_idx
ON video_uploads(status, expires_at);

CREATE TRIGGER video_uploads_reject_active_submission
BEFORE INSERT ON video_uploads
WHEN NEW.status IN ('creating', 'uploading', 'completing', 'expiring')
AND EXISTS (
SELECT 1 FROM video_submissions
WHERE project_id = NEW.project_id AND retired_at IS NULL
)
BEGIN
SELECT RAISE(ABORT, 'active project video exists');
END;

CREATE TRIGGER video_submissions_reject_active_upload
BEFORE INSERT ON video_submissions
WHEN NEW.retired_at IS NULL
AND EXISTS (
SELECT 1 FROM video_uploads
WHERE project_id = NEW.project_id
AND status IN ('creating', 'uploading', 'completing', 'expiring')
)
BEGIN
SELECT RAISE(ABORT, 'active project upload exists');
END;

CREATE TABLE video_upload_parts (
upload_id TEXT NOT NULL REFERENCES video_uploads(id) ON UPDATE CASCADE ON DELETE CASCADE,
part_number INTEGER NOT NULL CHECK (part_number BETWEEN 1 AND 10000),
etag TEXT NOT NULL CHECK (length(etag) > 0),
size_bytes INTEGER NOT NULL CHECK (size_bytes > 0),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (upload_id, part_number)
) STRICT, WITHOUT ROWID;

CREATE TABLE video_processing_attempts (
video_id TEXT NOT NULL REFERENCES video_submissions(id) ON UPDATE CASCADE ON DELETE CASCADE,
attempt INTEGER NOT NULL CHECK (attempt >= 1),
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')),
output_r2_key TEXT,
error_message TEXT,
started_at TEXT,
finished_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (video_id, attempt)
) STRICT, WITHOUT ROWID;

CREATE INDEX video_processing_attempts_status_idx
ON video_processing_attempts(status, created_at);
Loading
Loading