Compare commits

..

No commits in common. "72fc608d8a81b46bd7998cfc29d0c1db57c68341" and "9d6bbf81126867891d9efca6356f0c48155b9542" have entirely different histories.

39 changed files with 121 additions and 2667 deletions

View file

@ -25,13 +25,6 @@ MAM_API_URL=http://mam-api:3000
# Auth — default to ON in production. Setting to 'false' is a dev-only escape
# hatch that disables all auth checks and attaches a synthetic 'dev' user to
# every request. Never run with AUTH_ENABLED=false on a network you don't control.
#
# RBAC v2 note: with AUTH_ENABLED=true, per-project access is enforced. Service
# API tokens (capture sidecar, Premiere panel, integrations) must belong to a
# user with the access they need — an 'admin' user (full access), or a user with
# the right project grants. A non-admin service token with no grants will get
# 403 on asset registration (ingest) and streaming. In dev mode the synthetic
# user is admin, so this only matters once auth is on.
AUTH_ENABLED=true
# CORS allowlist — comma-separated origins that may carry credentials to the API.
@ -43,23 +36,3 @@ ALLOWED_ORIGINS=
# so secure-cookie + X-Forwarded-Proto behave correctly. ALSO required for accurate
# per-IP login rate-limiting (otherwise req.ip is always the nginx IP).
TRUST_PROXY=false
# Google OAuth (OIDC) sign-in — OPTIONAL. Leave the client id/secret blank to
# disable; the "Sign in with Google" button and the /auth/google routes only
# activate when all three of CLIENT_ID, CLIENT_SECRET, and REDIRECT_URL are set.
# Create an OAuth 2.0 Client (type: Web application) in Google Cloud Console and
# add OAUTH_REDIRECT_URL to its authorized redirect URIs.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Must exactly match a redirect URI on the OAuth client, e.g.
# https://dragonflight.live/api/v1/auth/google/callback
OAUTH_REDIRECT_URL=
# Restrict sign-in to one Google Workspace domain (recommended). First login from
# an allowed-domain account auto-provisions a NEW 'viewer' account (matched only
# by Google's stable subject id, never by email — so a Google login can never
# seize a pre-existing local account). An admin then grants project access.
# Leave blank to allow any verified Google account to self-provision (NOT advised).
GOOGLE_ALLOWED_DOMAIN=
# Note: if a Google-linked account also has TOTP enabled, sign-in still requires
# the authenticator code (Google is treated as the first factor). Accounts without
# TOTP complete sign-in in one Google step.

View file

@ -22,9 +22,7 @@
"bullmq": "^5.5.0",
"multer": "^1.4.5-lts.1",
"uuid": "^9.0.1",
"dotenv": "^16.4.5",
"qrcode": "^1.5.4",
"google-auth-library": "^9.14.0"
"dotenv": "^16.4.5"
},
"engines": {
"node": ">=22.0.0"

View file

@ -1,90 +0,0 @@
// Per-project authorization — the single source of truth for "can this user
// touch this project?". v1 auth answers "are you logged in?"; this answers
// "which projects, and at what level?".
//
// Model (locked with Zac):
// - role 'admin' → global bypass; every project at 'edit'.
// - role 'editor'/'viewer' → scoped to projects granted to them directly
// (project_access subject_type='user') or via a
// group they belong to (subject_type='group').
// - grant level 'view' → read-only; 'edit' → read-write.
//
// A user's effective level on a project is the MAX of every matching grant
// (direct + each group). 'edit' outranks 'view'.
//
// All functions take an optional `db` (defaults to the shared pool) so tests
// can inject an isolated test pool.
import defaultPool from '../db/pool.js';
const LEVEL_RANK = { view: 1, edit: 2 };
export function isAdmin(user) {
return user?.role === 'admin';
}
// Returns the higher of two levels (either may be null/undefined).
function maxLevel(a, b) {
const ra = LEVEL_RANK[a] || 0;
const rb = LEVEL_RANK[b] || 0;
if (ra === 0 && rb === 0) return null;
return ra >= rb ? a : b;
}
// Resolve every project the user can see, with their effective level.
// admin → { all: true, ids: null, levelByProject: null }
// else → { all: false, ids: Set<projectId>, levelByProject: Map<projectId, 'view'|'edit'> }
export async function accessibleProjectIds(user, db = defaultPool) {
if (isAdmin(user)) return { all: true, ids: null, levelByProject: null };
const levelByProject = new Map();
if (!user?.id) return { all: false, ids: new Set(), levelByProject };
const { rows } = await db.query(
`SELECT pa.project_id, pa.level
FROM project_access pa
WHERE (pa.subject_type = 'user' AND pa.subject_id = $1)
OR (pa.subject_type = 'group' AND pa.subject_id IN (
SELECT group_id FROM user_groups WHERE user_id = $1
))`,
[user.id]
);
for (const r of rows) {
levelByProject.set(r.project_id, maxLevel(levelByProject.get(r.project_id), r.level));
}
return { all: false, ids: new Set(levelByProject.keys()), levelByProject };
}
// Effective level on a single project: 'edit' | 'view' | null.
export async function projectLevel(user, projectId, db = defaultPool) {
if (isAdmin(user)) return 'edit';
if (!user?.id || !projectId) return null;
const { rows } = await db.query(
`SELECT pa.level
FROM project_access pa
WHERE pa.project_id = $1
AND ( (pa.subject_type = 'user' AND pa.subject_id = $2)
OR (pa.subject_type = 'group' AND pa.subject_id IN (
SELECT group_id FROM user_groups WHERE user_id = $2
)) )`,
[projectId, user.id]
);
let level = null;
for (const r of rows) level = maxLevel(level, r.level);
return level;
}
// Throw a 403-shaped error (caught by errorHandler) unless the user has at
// least `need` access on the project. `need` ∈ 'view' | 'edit'.
export async function assertProjectAccess(user, projectId, need = 'view', db = defaultPool) {
if (isAdmin(user)) return;
const have = await projectLevel(user, projectId, db);
if (!have || (LEVEL_RANK[have] || 0) < (LEVEL_RANK[need] || 0)) {
const err = new Error('forbidden');
err.status = 403;
throw err;
}
}

View file

@ -1,90 +0,0 @@
// Google OAuth (OIDC) sign-in helpers.
//
// Entirely config-gated: if GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET /
// OAUTH_REDIRECT_URL aren't set, isConfigured() is false and the routes 404, so
// a deployment without Google SSO behaves exactly as before. google-auth-library
// is imported lazily so the dependency is only required when the feature is on.
//
// Flow: /auth/google redirects to Google's consent screen with a signed `state`;
// /auth/google/callback exchanges the code, verifies the ID token, enforces the
// allowed Workspace domain, and auto-provisions a viewer account on first login.
const SCOPES = ['openid', 'email', 'profile'];
export function isConfigured() {
return !!(process.env.GOOGLE_CLIENT_ID
&& process.env.GOOGLE_CLIENT_SECRET
&& process.env.OAUTH_REDIRECT_URL);
}
export function allowedDomain() {
return (process.env.GOOGLE_ALLOWED_DOMAIN || '').trim().toLowerCase() || null;
}
// Lazily build an OAuth2 client (throws a clear error if the dep is missing).
async function makeClient() {
let OAuth2Client;
try {
({ OAuth2Client } = await import('google-auth-library'));
} catch {
const err = new Error('google-auth-library is not installed');
err.status = 500;
throw err;
}
return new OAuth2Client({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
redirectUri: process.env.OAUTH_REDIRECT_URL,
});
}
// URL of Google's consent screen. `state` is an opaque anti-CSRF token we also
// stash in the session and re-check on callback.
export async function buildAuthUrl(state) {
const client = await makeClient();
return client.generateAuthUrl({
access_type: 'online',
scope: SCOPES,
state,
prompt: 'select_account',
// If a Workspace domain is configured, hint Google to scope the picker to it.
...(allowedDomain() ? { hd: allowedDomain() } : {}),
});
}
// Exchange the authorization code and verify the returned ID token. Returns the
// verified { sub, email, name, hd } payload. Throws { status } on any failure.
export async function exchangeAndVerify(code) {
const client = await makeClient();
const { tokens } = await client.getToken(code);
if (!tokens.id_token) {
const err = new Error('no id_token from Google'); err.status = 401; throw err;
}
const ticket = await client.verifyIdToken({
idToken: tokens.id_token,
audience: process.env.GOOGLE_CLIENT_ID,
});
const p = ticket.getPayload();
if (!p || !p.sub) {
const err = new Error('invalid id_token'); err.status = 401; throw err;
}
// Require an explicitly verified email — a missing/undefined claim is NOT
// treated as verified, since the email drives account linking/provisioning.
if (!p.email || p.email_verified !== true) {
const err = new Error('email not verified'); err.status = 403; throw err;
}
const domain = allowedDomain();
if (domain) {
// ONLY trust Google's `hd` (hosted-domain) claim — it's present iff the
// account is a member of a Google Workspace domain that Google itself
// has verified. The email-suffix fallback we used to allow let any
// non-Workspace account with a spoof-friendly email through; if a
// GOOGLE_ALLOWED_DOMAIN is set, the operator means "only this Workspace,"
// and consumer accounts (no hd) must be rejected.
const hd = (p.hd || '').toLowerCase();
if (hd !== domain) {
const err = new Error('domain not allowed'); err.status = 403; throw err;
}
}
return { sub: p.sub, email: p.email, name: p.name || p.email, hd: p.hd || null };
}

View file

@ -1,58 +0,0 @@
// Short-lived MFA tickets bridging the two login steps.
//
// When a user with TOTP enabled passes password auth, we don't create a session
// yet — we hand back an opaque ticket. The second request (code or recovery
// code) redeems the ticket to finish login. Tickets are single-use and expire
// fast so a stolen ticket is near-useless.
//
// Tickets are bound to the issuing request's IP and User-Agent (hashed). A
// stolen ticket replayed from a different origin redeems to null. This is
// defense in depth against ticket exfiltration via a logged proxy, browser
// extension, or shoulder-surf; it does not stop an attacker who is on the same
// IP and UA.
//
// In-memory + single-instance, matching the existing login rate-limiter
// (auth/rate-limit.js). Documented limitation: in a multi-instance deployment
// the second step must hit the same node. Acceptable for Dragonflight's
// one-mam-api-per-node shape; revisit if that changes.
import { randomBytes, createHash } from 'node:crypto';
const TTL_MS = 5 * 60 * 1000; // 5 minutes to enter a code
const tickets = new Map(); // id -> { userId, ipHash, uaHash, expiresAt }
function sweep() {
const now = Date.now();
for (const [id, t] of tickets) if (t.expiresAt <= now) tickets.delete(id);
}
function hashBinding(value) {
return createHash('sha256').update(String(value || '')).digest('hex');
}
export function issueTicket(userId, { ip, userAgent } = {}) {
sweep();
const id = randomBytes(32).toString('hex');
tickets.set(id, {
userId,
ipHash: hashBinding(ip),
uaHash: hashBinding(userAgent),
expiresAt: Date.now() + TTL_MS,
});
return id;
}
// Redeem (and consume) a ticket. Returns the userId, or null if missing,
// expired, or the binding doesn't match the redeeming request.
export function redeemTicket(id, { ip, userAgent } = {}) {
if (!id) return null;
const t = tickets.get(id);
if (!t) return null;
tickets.delete(id); // single-use — burn even on binding mismatch so a
// wrong-binding probe can't be retried.
if (t.expiresAt <= Date.now()) return null;
// If a caller doesn't supply bindings (e.g. tests), accept — the issue side
// controls whether bindings get recorded.
if (ip !== undefined && t.ipHash !== hashBinding(ip)) return null;
if (userAgent !== undefined && t.uaHash !== hashBinding(userAgent)) return null;
return t.userId;
}

View file

@ -1,118 +0,0 @@
// TOTP (RFC 6238) implemented on node:crypto — no runtime dependency.
//
// Why hand-rolled: the algorithm is small and stable, and avoiding a dep keeps
// the auth core auditable. Verified against the RFC 6238 Appendix B test vectors
// in test/auth/totp.test.js.
//
// Defaults match every mainstream authenticator app (Google Authenticator,
// Authy, 1Password): SHA-1, 6 digits, 30-second step.
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
const DIGITS = 6;
const STEP_SECONDS = 30;
const RFC4648_B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
// ── base32 (RFC 4648, no padding) ──────────────────────────────────────────
export function base32Encode(buf) {
let bits = 0, value = 0, out = '';
for (const byte of buf) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
out += RFC4648_B32[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) out += RFC4648_B32[(value << (5 - bits)) & 31];
return out;
}
export function base32Decode(str) {
const clean = str.replace(/=+$/,'').toUpperCase().replace(/\s+/g, '');
let bits = 0, value = 0;
const out = [];
for (const ch of clean) {
const idx = RFC4648_B32.indexOf(ch);
if (idx === -1) continue; // skip stray chars
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
out.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return Buffer.from(out);
}
// Generate a new base32 secret (20 random bytes = 160 bits, the RFC-recommended
// SHA-1 key length).
export function generateSecret() {
return base32Encode(randomBytes(20));
}
// HOTP for a specific counter (RFC 4226).
function hotp(secretBuf, counter) {
const buf = Buffer.alloc(8);
// 64-bit big-endian counter.
buf.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
buf.writeUInt32BE(counter >>> 0, 4);
const hmac = createHmac('sha1', secretBuf).update(buf).digest();
const offset = hmac[hmac.length - 1] & 0x0f;
const code = ((hmac[offset] & 0x7f) << 24)
| ((hmac[offset + 1] & 0xff) << 16)
| ((hmac[offset + 2] & 0xff) << 8)
| (hmac[offset + 3] & 0xff);
return String(code % (10 ** DIGITS)).padStart(DIGITS, '0');
}
// The TOTP code for a given time (defaults to now).
export function generateToken(base32Secret, atMs = Date.now()) {
const counter = Math.floor(atMs / 1000 / STEP_SECONDS);
return hotp(base32Decode(base32Secret), counter);
}
// Verify a user-supplied code, allowing ±`window` steps of clock drift
// (default ±1 = 90s total tolerance). Constant-time compare per candidate.
//
// Returns the matched counter on success (so callers can persist it for
// replay protection — RFC 6238 §5.2), or null on failure. Boolean truthiness
// still works for the common case (`if (verifyToken(...))`).
export function verifyToken(base32Secret, token, atMs = Date.now(), window = 1) {
if (!base32Secret || !token) return null;
const cleaned = String(token).replace(/\s+/g, '');
if (!/^\d{6}$/.test(cleaned)) return null;
const secretBuf = base32Decode(base32Secret);
const counter = Math.floor(atMs / 1000 / STEP_SECONDS);
const want = Buffer.from(cleaned);
for (let w = -window; w <= window; w++) {
const candidate = Buffer.from(hotp(secretBuf, counter + w));
if (candidate.length === want.length && timingSafeEqual(candidate, want)) return counter + w;
}
return null;
}
// The otpauth:// URI an authenticator app scans. label/issuer show in the app.
export function otpauthURI(base32Secret, accountName, issuer = 'Dragonflight') {
const label = encodeURIComponent(`${issuer}:${accountName}`);
const params = new URLSearchParams({
secret: base32Secret,
issuer,
algorithm: 'SHA1',
digits: String(DIGITS),
period: String(STEP_SECONDS),
});
return `otpauth://totp/${label}?${params.toString()}`;
}
// Generate N human-friendly one-time recovery codes (raw form). Caller hashes
// them before storage and shows the raw set to the user exactly once.
export function generateRecoveryCodes(n = 10) {
const codes = [];
for (let i = 0; i < n; i++) {
// 10 hex chars in two dash-separated groups, e.g. "a1b2c-3d4e5".
const hex = randomBytes(5).toString('hex');
codes.push(hex.slice(0, 5) + '-' + hex.slice(5));
}
return codes;
}

View file

@ -1,30 +0,0 @@
-- Migration 026 — per-project access grants (RBAC v2).
--
-- v1 auth is flat: any logged-in user can do everything. This adds per-project
-- scoping. A grant targets either a user or a group (polymorphic subject) and
-- carries a level: 'view' (read-only) or 'edit' (read-write). Admins bypass all
-- of this in code (authz.js) and need no rows here.
--
-- subject_id is intentionally NOT a foreign key — it points at either users.id
-- or groups.id depending on subject_type. Rows are cleaned up when the project
-- is deleted (FK cascade). A deleted user/group leaves an orphan row that
-- resolves to nobody (harmless); a later sweep can prune them if desired.
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'access_level') THEN
CREATE TYPE access_level AS ENUM ('view', 'edit');
END IF;
END $$;
CREATE TABLE IF NOT EXISTS project_access (
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
subject_id UUID NOT NULL,
level access_level NOT NULL DEFAULT 'view',
granted_by UUID REFERENCES users ON DELETE SET NULL,
granted_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (project_id, subject_type, subject_id)
);
CREATE INDEX IF NOT EXISTS idx_project_access_subject
ON project_access (subject_type, subject_id);

View file

@ -1,20 +0,0 @@
-- Migration 027 — TOTP two-factor auth.
--
-- totp_secret holds the base32 shared secret once enrollment is confirmed
-- (NULL while disabled or mid-enrollment). totp_enabled flips true only after
-- the user verifies their first code, so a half-finished enrollment never locks
-- anyone out. Recovery codes are one-time bcrypt-hashed fallbacks; used_at marks
-- a code as spent.
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS user_recovery_codes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
code_hash TEXT NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_recovery_codes_user ON user_recovery_codes(user_id);

View file

@ -1,13 +0,0 @@
-- Migration 028 — Google OAuth (OIDC) sign-in.
--
-- google_sub is Google's stable subject identifier — the join key for a linked
-- or auto-provisioned account (unique, but NULL for password-only users).
-- email is captured for display + domain checks. password_hash becomes nullable
-- so an OAuth-only account can exist without a local password; such an account
-- simply can't use the password login path until an admin sets one.
ALTER TABLE users ADD COLUMN IF NOT EXISTS google_sub TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT;
ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_google_sub ON users(google_sub) WHERE google_sub IS NOT NULL;

View file

@ -1,9 +0,0 @@
-- Migration 030 — TOTP replay protection.
--
-- RFC 6238 §5.2 hardening: track the last counter value we accepted for each
-- user and reject codes at counters ≤ the last one. Without this, the same
-- 6-digit code can be submitted N times within its 30s step. Low impact in
-- practice (the code is only valid for ~90s with ±1 drift) but standard.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS totp_last_counter BIGINT NOT NULL DEFAULT 0;

View file

@ -8,7 +8,7 @@ import os from 'node:os';
import { exec } from 'node:child_process';
import pool from './db/pool.js';
import { errorHandler } from './middleware/errors.js';
import { requireAuth, requireUiHeader, requireAdmin } from './middleware/auth.js';
import { requireAuth, requireUiHeader } from './middleware/auth.js';
import { loadS3ConfigFromDb } from './s3/client.js';
import authRouter from './routes/auth.js';
@ -104,10 +104,7 @@ app.get('/health', (_req, res) => res.json({ status: 'ok' }));
// ── Auth gate ─────────────────────────────────────────────────────────────────
// req.path is relative to the /api/v1 mount, so /auth/login NOT /api/v1/auth/login.
const UNAUTH_PATHS = new Set([
'/auth/login', '/auth/login/totp', '/auth/setup', '/auth/setup-required',
'/auth/google', '/auth/google/callback', '/auth/google/enabled',
]);
const UNAUTH_PATHS = new Set(['/auth/login', '/auth/setup', '/auth/setup-required']);
// node-agent now authenticates /cluster/heartbeat with a bound api_token
// (migration 019 + bound_hostname on the token). requireAuth handles the
// bearer lookup and sets req.tokenBoundHostname; the heartbeat handler in
@ -120,10 +117,8 @@ app.use('/api/v1', (req, res, next) => {
// ── API Routes ────────────────────────────────────────────────────────────────
app.use('/api/v1/auth', authRouter);
// User and group administration is admin-only (RBAC v2). The auth gate above
// already established req.user; requireAdmin rejects non-admins with 403.
app.use('/api/v1/auth/users', requireAdmin, usersRouter);
app.use('/api/v1/users', requireAdmin, usersRouter); // alias for the SPA Users page
app.use('/api/v1/auth/users', usersRouter);
app.use('/api/v1/users', usersRouter); // alias for the existing SPA Users page that calls /api/v1/users; keeps the same auth gate
app.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
app.use('/api/v1/assets', assetsRouter);
app.use('/api/v1/projects', projectsRouter);
@ -134,7 +129,7 @@ app.use('/api/v1/upload', uploadRouter);
app.use('/api/v1/recorders', recordersRouter);
app.use('/api/v1/settings', settingsRouter);
app.use('/api/v1/ampp', amppRouter);
app.use('/api/v1/groups', requireAdmin, groupsRouter);
app.use('/api/v1/groups', groupsRouter);
app.use('/api/v1/sequences', sequencesRouter);
app.use('/api/v1/system', systemRouter);
app.use('/api/v1/cluster', clusterRouter);

View file

@ -4,9 +4,7 @@ import { parseBearer, hashToken } from '../auth/tokens.js';
// Stable UUID matching migration 023's seeded dev user.
/** UUID of the seeded dev-mode placeholder. NOT a sentinel for "any unauthenticated user". */
export const DEV_USER_ID = '00000000-0000-4000-8000-000000000000';
// role 'admin' so dev mode (AUTH_ENABLED=false) keeps full access through the
// RBAC v2 gates — matches migration 023's seeded dev row.
export const DEV_USER = { id: DEV_USER_ID, username: 'dev', display_name: 'Dev (AUTH_ENABLED=false)', role: 'admin' };
export const DEV_USER = { id: DEV_USER_ID, username: 'dev', display_name: 'Dev (AUTH_ENABLED=false)' };
const ABSOLUTE_MS = 8 * 3600 * 1000;
const IDLE_MS = 1 * 3600 * 1000;
@ -20,7 +18,7 @@ async function destroyAnd401(req, res) {
async function loadUser(id) {
const { rows } = await pool.query(
`SELECT id, username, display_name, role, totp_enabled FROM users WHERE id = $1`, [id]);
`SELECT id, username, display_name, role FROM users WHERE id = $1`, [id]);
return rows[0] || null;
}
@ -75,14 +73,6 @@ export async function requireAuth(req, res, next) {
return res.status(401).json({ error: 'unauthorized' });
}
// Gate a route to admins only. requireAuth must run first (it sets req.user).
// 401 when unauthenticated, 403 when authenticated but not an admin.
export function requireAdmin(req, res, next) {
if (!req.user) return res.status(401).json({ error: 'unauthorized' });
if (req.user.role !== 'admin') return res.status(403).json({ error: 'forbidden' });
return next();
}
// Belt-and-suspenders CSRF: SameSite=Lax already blocks most cross-site
// cookie sends, but a custom header that no <form> can produce hardens
// against the edge cases. Applied to mutating verbs only.

View file

@ -7,36 +7,9 @@ import pool from '../db/pool.js';
import { getSignedUrlForObject, deleteObject, s3Client, getS3Bucket } from '../s3/client.js';
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { validateUuid } from '../middleware/errors.js';
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
import { requireAdmin } from '../middleware/auth.js';
const router = express.Router();
// Every /:id asset route is scoped to the asset's project. The param handler
// validates the UUID, resolves the owning project_id, and asserts at least
// 'view' access (the baseline for touching an asset at all). Mutating routes
// additionally assert 'edit' via req.assetProjectId. A missing asset is a clean
// 404 here rather than leaking existence to users without access.
router.param('id', async (req, res, next) => {
validateUuid('id')(req, res, () => {});
if (res.headersSent) return;
try {
const { rows } = await pool.query('SELECT project_id FROM assets WHERE id = $1', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
req.assetProjectId = rows[0].project_id;
await assertProjectAccess(req.user, req.assetProjectId, 'view');
next();
} catch (err) { next(err); }
});
// Route-level guard for mutating /:id endpoints — escalates the param handler's
// 'view' baseline to 'edit'. Reuses req.assetProjectId (already resolved).
async function requireAssetEdit(req, res, next) {
try {
await assertProjectAccess(req.user, req.assetProjectId, 'edit');
next();
} catch (err) { next(err); }
}
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
// BullMQ queue connection (mirrors worker/src/index.js)
const parseRedisUrl = (url) => {
@ -93,15 +66,6 @@ router.get('/', async (req, res, next) => {
const params = [];
let paramCount = 1;
// Scope to projects the caller can access (admins are unfiltered). Without
// this, a granted user would see every asset across every project.
const access = await accessibleProjectIds(req.user);
if (!access.all) {
if (access.ids.size === 0) return res.json({ assets: [], total: 0 });
query += ` AND a.project_id = ANY($${paramCount++}::uuid[])`;
params.push([...access.ids]);
}
// Exclude archived unless explicitly requested — independent of status filter
if (include_archived !== 'true') {
query += ` AND a.status <> 'archived'`;
@ -168,9 +132,6 @@ router.post('/', async (req, res, next) => {
return res.status(400).json({ error: 'projectId and clipName are required' });
}
// Registering an asset writes into a project — require edit access there.
await assertProjectAccess(req.user, projectId, 'edit');
const durationNum = duration !== undefined && duration !== null ? Number(duration) : null;
if (durationNum !== null && !Number.isFinite(durationNum)) {
return res.status(400).json({ error: 'duration must be a finite number (seconds)' });
@ -259,8 +220,8 @@ router.post('/', async (req, res, next) => {
}
});
// POST /cleanup-live — cross-project maintenance, admin only.
router.post('/cleanup-live', requireAdmin, async (req, res, next) => {
// POST /cleanup-live
router.post('/cleanup-live', async (req, res, next) => {
try {
const maxAgeHours = Math.max(1, parseInt(req.query.max_age_hours || '4', 10));
const result = await pool.query(
@ -273,8 +234,8 @@ router.post('/cleanup-live', requireAdmin, async (req, res, next) => {
} catch (err) { next(err); }
});
// POST /cleanup-live-orphans — cross-project maintenance, admin only.
router.post('/cleanup-live-orphans', requireAdmin, async (_req, res, next) => {
// POST /cleanup-live-orphans
router.post('/cleanup-live-orphans', async (_req, res, next) => {
try {
const liveRoot = process.env.LIVE_DIR || '/live';
let entries;
@ -316,22 +277,10 @@ router.get('/:id', async (req, res, next) => {
});
// PATCH /:id
router.patch('/:id', requireAssetEdit, async (req, res, next) => {
router.patch('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const { display_name, tags, notes, bin_id } = req.body;
// bin_id must reference a bin in the asset's OWN project — otherwise an
// editor in project A could stuff their asset into project B's bin tree.
// Null/empty clears the bin, which is always allowed.
if (bin_id) {
const bin = await pool.query('SELECT project_id FROM bins WHERE id = $1', [bin_id]);
if (bin.rows.length === 0) return res.status(400).json({ error: 'bin_id not found' });
if (bin.rows[0].project_id !== req.assetProjectId) {
return res.status(400).json({ error: 'bin_id belongs to a different project' });
}
}
const updates = [], params = [];
let paramCount = 1;
if (display_name !== undefined) { updates.push(`display_name = $${paramCount++}`); params.push(display_name); }
@ -350,32 +299,13 @@ router.patch('/:id', requireAssetEdit, async (req, res, next) => {
});
// POST /:id/copy
router.post('/:id/copy', requireAssetEdit, async (req, res, next) => {
router.post('/:id/copy', async (req, res, next) => {
try {
const { id } = req.params;
const { binId, projectId } = req.body;
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
const src = r.rows[0];
// Destination project defaults to source's. If the caller overrides it,
// assert edit on the target — without this, an editor in project A could
// clone any asset they can see into project B with no grant on B.
const destProjectId = projectId || src.project_id;
if (projectId && projectId !== src.project_id) {
await assertProjectAccess(req.user, destProjectId, 'edit');
}
// Destination bin (if any) must belong to the destination project — same
// class of bug as the PATCH bin_id hole.
const destBinId = binId === undefined ? src.bin_id : (binId || null);
if (destBinId) {
const bin = await pool.query('SELECT project_id FROM bins WHERE id = $1', [destBinId]);
if (bin.rows.length === 0) return res.status(400).json({ error: 'binId not found' });
if (bin.rows[0].project_id !== destProjectId) {
return res.status(400).json({ error: 'binId belongs to a different project than the destination' });
}
}
const newId = uuidv4();
// Bug #60: null out proxy_s3_key and thumbnail_s3_key on the copy to avoid
// sharing S3 objects with the source. Set status to 'processing' so the copy
@ -390,8 +320,8 @@ router.post('/:id/copy', requireAssetEdit, async (req, res, next) => {
$1,$2,$3,$4,$5,$6,$7,$8,NULL,NULL,$9,$10,$11,$12,$13,$14,$15,$16,NOW(),NOW()
) RETURNING *`,
[
newId, destProjectId,
destBinId,
newId, projectId || src.project_id,
binId === undefined ? src.bin_id : (binId || null),
src.filename, src.display_name, 'processing', src.media_type,
src.original_s3_key,
src.codec, src.resolution, src.fps, src.duration_ms, src.start_tc,
@ -416,7 +346,7 @@ router.post('/:id/copy', requireAssetEdit, async (req, res, next) => {
});
// POST /:id/mark-empty
router.post('/:id/mark-empty', requireAssetEdit, async (req, res, next) => {
router.post('/:id/mark-empty', async (req, res, next) => {
try {
const { id } = req.params;
// Bug #66: first check the asset exists and what status it is in
@ -454,7 +384,7 @@ router.post('/:id/mark-empty', requireAssetEdit, async (req, res, next) => {
// the existing live row -> 409 -> asset stuck 'live', no jobs. Finalising by id
// flips it out of 'live', records duration + S3 keys, and kicks off the
// proxy -> thumbnail -> filmstrip job chain.
router.post('/:id/finalize', requireAssetEdit, async (req, res, next) => {
router.post('/:id/finalize', async (req, res, next) => {
try {
const { id } = req.params;
const { hiresKey, proxyKey, duration } = req.body;
@ -506,7 +436,7 @@ router.post('/:id/finalize', requireAssetEdit, async (req, res, next) => {
});
// POST /:id/generate-proxy
router.post('/:id/generate-proxy', requireAssetEdit, async (req, res, next) => {
router.post('/:id/generate-proxy', async (req, res, next) => {
try {
const { id } = req.params;
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
@ -522,8 +452,8 @@ router.post('/:id/generate-proxy', requireAssetEdit, async (req, res, next) => {
} catch (err) { next(err); }
});
// POST /backfill-proxies — cross-project maintenance, admin only.
router.post('/backfill-proxies', requireAdmin, async (_req, res, next) => {
// POST /backfill-proxies
router.post('/backfill-proxies', async (_req, res, next) => {
try {
const targets = await pool.query(
`SELECT id, original_s3_key FROM assets
@ -547,7 +477,7 @@ router.post('/backfill-proxies', requireAdmin, async (_req, res, next) => {
// POST /:id/reprocess?type=proxy|thumbnail|filmstrip
// Force-requeue a processing job regardless of current asset status.
router.post('/:id/reprocess', requireAssetEdit, async (req, res, next) => {
router.post('/:id/reprocess', async (req, res, next) => {
try {
const { id } = req.params;
const type = req.query.type || 'proxy';
@ -598,7 +528,7 @@ router.get('/:id/filmstrip', async (req, res, next) => {
});
// POST /:id/retry
router.post('/:id/retry', requireAssetEdit, async (req, res, next) => {
router.post('/:id/retry', async (req, res, next) => {
try {
const { id } = req.params;
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
@ -617,7 +547,7 @@ router.post('/:id/retry', requireAssetEdit, async (req, res, next) => {
});
// DELETE /:id
router.delete('/:id', requireAssetEdit, async (req, res, next) => {
router.delete('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const { hard } = req.query;
@ -978,15 +908,6 @@ router.post('/batch-trim', async (req, res, next) => {
return res.status(400).json({ error: 'Each clip must have assetId, filename, sourceInFrames, sourceOutFrames, timelineInFrames, timelineOutFrames, and a non-negative integer trackIndex' });
}
}
// Authorize every source asset's project (edit) before queuing any work.
const trimAssetIds = [...new Set(clips.map(c => c.assetId))];
const owning = await pool.query('SELECT id, project_id FROM assets WHERE id = ANY($1::uuid[])', [trimAssetIds]);
const projById = new Map(owning.rows.map(r => [r.id, r.project_id]));
for (const aid of trimAssetIds) {
const pid = projById.get(aid);
if (!pid) return res.status(404).json({ error: 'Asset not found: ' + aid });
await assertProjectAccess(req.user, pid, 'edit');
}
const jobId = uuidv4();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
await pool.query(

View file

@ -3,14 +3,6 @@ import pool from '../db/pool.js';
import { DEV_USER_ID, requireAuth } from '../middleware/auth.js';
import { hashPassword, comparePassword } from '../auth/passwords.js';
import { ipBackoff } from '../auth/rate-limit.js';
import {
generateSecret, verifyToken, otpauthURI, generateRecoveryCodes,
} from '../auth/totp.js';
import { issueTicket, redeemTicket } from '../auth/mfa-tickets.js';
import {
isConfigured as googleConfigured, buildAuthUrl, exchangeAndVerify,
} from '../auth/google-oauth.js';
import { randomBytes } from 'node:crypto';
const DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K';
@ -84,7 +76,7 @@ router.post('/login', async (req, res, next) => {
}
const { rows } = await pool.query(
`SELECT id, username, display_name, password_hash, totp_enabled FROM users WHERE username = $1 AND id <> $2`,
`SELECT id, username, display_name, password_hash FROM users WHERE username = $1 AND id <> $2`,
[username.trim(), DEV_USER_ID]
);
if (rows.length === 0) {
@ -101,123 +93,21 @@ router.post('/login', async (req, res, next) => {
return res.status(401).json({ error: 'invalid credentials' });
}
// Second factor: if TOTP is enabled, don't create a session yet. Hand back
// a short-lived ticket the client redeems via /login/totp with a code.
// Crucially: do NOT clear the per-IP failure counter here. If we did, each
// /login retry would reset the backoff and let an attacker brute the 6-digit
// TOTP space (10^6) with no per-attempt delay. The counter is cleared
// inside establishSession() once MFA has actually passed.
if (user.totp_enabled) {
return res.json({
mfa_required: true,
ticket: issueTicket(user.id, { ip, userAgent: req.get('user-agent') }),
});
}
req.session.user_id = user.id;
req.session.first_seen_at = Date.now();
req.session.last_seen_at = Date.now();
// The critical line — wait for the row to land in `sessions` before responding.
// Without this, the SPA's next request races the store write, hits 401, and
// the prior bounce-to-login logic produced an infinite loop.
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
await establishSession(req, user, ip);
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
} catch (err) { next(err); }
pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id])
.catch(err => console.error('[auth] last_login_at update failed:', err.message));
ipBackoff.recordSuccess(ip);
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } }); } catch (err) { next(err); }
});
// Write the session and wait for it to persist before responding. Extracted so
// both the password-only and the MFA-completion paths share one implementation.
// Clears the per-IP failure counter only here — after every required factor has
// actually been proven (password [+ TOTP if enabled, or OAuth + TOTP]).
async function establishSession(req, user, ip) {
req.session.user_id = user.id;
req.session.first_seen_at = Date.now();
req.session.last_seen_at = Date.now();
// The critical line — wait for the row to land in `sessions` before responding.
// Without this, the SPA's next request races the store write, hits 401, and
// the prior bounce-to-login logic produced an infinite loop.
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
if (ip) ipBackoff.recordSuccess(ip);
pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id])
.catch(err => console.error('[auth] last_login_at update failed:', err.message));
}
// POST /api/v1/auth/login/totp { ticket?, code } — second login step. `code` is
// either a 6-digit TOTP or a one-time recovery code. The ticket comes from the
// request body (password-login path) or req.session.mfa_ticket (Google path).
router.post('/login/totp', async (req, res, next) => {
try {
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
// Rate-limit the second factor with the same per-IP backoff as /login so
// the 6-digit code space can't be hammered.
const delay = ipBackoff.delayMs(ip);
if (delay > 0) await new Promise(r => setTimeout(r, delay));
const { ticket: bodyTicket, code } = req.body || {};
const ticket = bodyTicket || req.session?.mfa_ticket;
if (req.session?.mfa_ticket) delete req.session.mfa_ticket;
// Bound to the issuing request's IP + UA — replays from a different origin
// redeem to null. See mfa-tickets.js for the binding model.
const userId = redeemTicket(ticket, { ip, userAgent: req.get('user-agent') });
if (!userId) {
ipBackoff.recordFailure(ip);
return res.status(401).json({ error: 'invalid or expired ticket' });
}
if (!code) return res.status(400).json({ error: 'code required' });
const { rows } = await pool.query(
`SELECT id, username, display_name, totp_secret, totp_enabled, totp_last_counter
FROM users WHERE id = $1`, [userId]);
const user = rows[0];
if (!user || !user.totp_enabled || !user.totp_secret) {
return res.status(401).json({ error: 'invalid credentials' });
}
// verifyToken returns the matched counter on success. Reject codes at
// counters ≤ totp_last_counter to prevent replay within the same step.
// The CAS-style UPDATE makes this race-free under concurrent submissions.
const matchedCounter = verifyToken(user.totp_secret, code);
let ok = false;
if (matchedCounter !== null) {
const lastCounter = BigInt(user.totp_last_counter || 0);
if (BigInt(matchedCounter) > lastCounter) {
const upd = await pool.query(
`UPDATE users SET totp_last_counter = $1
WHERE id = $2 AND totp_last_counter < $1`,
[String(matchedCounter), user.id]
);
ok = upd.rowCount === 1;
}
// matchedCounter ≤ last → silent replay; falls through to recovery-code
// path which also fails → 401. Same UX as a wrong code, no info leak.
}
if (!ok) ok = await consumeRecoveryCode(user.id, code);
if (!ok) {
ipBackoff.recordFailure(ip);
// The ticket was single-use; the client must restart from /login.
return res.status(401).json({ error: 'invalid code' });
}
// recordSuccess is called by establishSession once the session lands —
// that's the first moment we know every required factor has passed.
await establishSession(req, user, ip);
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
} catch (err) { next(err); }
});
// Check a recovery code against the user's unused codes; mark it spent on match.
// The marking is atomic (UPDATE ... WHERE used_at IS NULL with a rowCount check)
// so two concurrent redemptions of the same code can't both succeed.
async function consumeRecoveryCode(userId, code) {
const cleaned = String(code).trim().toLowerCase();
if (!/^[0-9a-f]{5}-[0-9a-f]{5}$/.test(cleaned)) return false;
const { rows } = await pool.query(
`SELECT id, code_hash FROM user_recovery_codes WHERE user_id = $1 AND used_at IS NULL`, [userId]);
for (const row of rows) {
if (await comparePassword(cleaned, row.code_hash)) {
const upd = await pool.query(
`UPDATE user_recovery_codes SET used_at = NOW() WHERE id = $1 AND used_at IS NULL`, [row.id]);
// Lost the race if another request already consumed it.
return upd.rowCount === 1;
}
}
return false;
}
// POST /api/v1/auth/logout — destroys the session and clears the cookie.
router.post('/logout', (req, res) => {
if (!req.session) return res.status(204).end();
@ -235,7 +125,6 @@ router.get('/me', requireAuth, (req, res) => {
username: req.user.username,
display_name: req.user.display_name,
role: req.user.role,
totp_enabled: !!req.user.totp_enabled,
});
});
@ -260,202 +149,5 @@ router.post('/password', requireAuth, async (req, res, next) => {
} catch (err) { next(err); }
});
// ── TOTP enrollment (all require an active session) ─────────────────────────
// POST /api/v1/auth/totp/setup — begin enrollment. Generates a fresh secret,
// stores it (but leaves totp_enabled=false), and returns the otpauth URI + the
// base32 secret for manual entry. Enrollment isn't active until /enable
// confirms a code, so a started-but-abandoned setup never locks the user out.
router.post('/totp/setup', requireAuth, async (req, res, next) => {
try {
const { rows } = await pool.query(`SELECT totp_enabled FROM users WHERE id = $1`, [req.user.id]);
if (rows[0]?.totp_enabled) return res.status(409).json({ error: 'TOTP already enabled' });
const secret = generateSecret();
await pool.query(`UPDATE users SET totp_secret = $1 WHERE id = $2`, [secret, req.user.id]);
const uri = otpauthURI(secret, req.user.username || 'user');
// QR rendering is optional — the otpauth URI + manual secret are sufficient
// to enroll. Render a data-URL QR only if the optional `qrcode` dep is
// present, so a missing dependency degrades instead of 500-ing.
let qr = null;
try {
const QRCode = (await import('qrcode')).default;
qr = await QRCode.toDataURL(uri);
} catch { /* qrcode not installed — client falls back to manual entry */ }
res.json({ secret, otpauth_uri: uri, qr });
} catch (err) { next(err); }
});
// POST /api/v1/auth/totp/enable { code } — confirm enrollment with a code from
// the authenticator. On success, flips totp_enabled and returns one-time
// recovery codes (shown exactly once).
router.post('/totp/enable', requireAuth, async (req, res, next) => {
try {
const { code } = req.body || {};
if (!code) return badRequest(res, 'code required');
const { rows } = await pool.query(
`SELECT totp_secret, totp_enabled FROM users WHERE id = $1`, [req.user.id]);
const row = rows[0];
if (!row?.totp_secret) return badRequest(res, 'start setup first');
if (row.totp_enabled) return res.status(409).json({ error: 'TOTP already enabled' });
const enrollCounter = verifyToken(row.totp_secret, code);
if (enrollCounter === null) return badRequest(res, 'incorrect code');
const recovery = generateRecoveryCodes(10);
const hashes = await Promise.all(recovery.map(c => hashPassword(c)));
// Enable + seed totp_last_counter to the enrollment code's counter so the
// same code can't be reused on first login. Replace any stale recovery
// codes atomically.
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(
`UPDATE users SET totp_enabled = TRUE, totp_last_counter = $2 WHERE id = $1`,
[req.user.id, String(enrollCounter)]
);
await client.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.user.id]);
for (const h of hashes) {
await client.query(
`INSERT INTO user_recovery_codes (user_id, code_hash) VALUES ($1, $2)`, [req.user.id, h]);
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
throw e;
} finally { client.release(); }
res.json({ enabled: true, recovery_codes: recovery });
} catch (err) { next(err); }
});
// POST /api/v1/auth/totp/disable { password } — turn off 2FA. Requires the
// account password as a confirmation so a hijacked live session can't silently
// strip the second factor.
router.post('/totp/disable', requireAuth, async (req, res, next) => {
try {
const { password } = req.body || {};
if (!password) return badRequest(res, 'password required');
const { rows } = await pool.query(`SELECT password_hash FROM users WHERE id = $1`, [req.user.id]);
if (!rows.length) return res.status(401).json({ error: 'unauthorized' });
if (!(await comparePassword(password, rows[0].password_hash))) {
return badRequest(res, 'incorrect password');
}
await pool.query(
`UPDATE users SET totp_enabled = FALSE, totp_secret = NULL, totp_last_counter = 0 WHERE id = $1`,
[req.user.id]
);
await pool.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.user.id]);
res.status(204).end();
} catch (err) { next(err); }
});
// ── Google OAuth (OIDC) sign-in ─────────────────────────────────────────────
// All Google routes are config-gated: without GOOGLE_CLIENT_ID/SECRET and
// OAUTH_REDIRECT_URL they 404, so a deployment without SSO is unaffected.
// GET /api/v1/auth/google/enabled — cheap, no auth. Lets the login screen decide
// whether to render the "Sign in with Google" button.
router.get('/google/enabled', (_req, res) => {
res.json({ enabled: googleConfigured() });
});
// GET /api/v1/auth/google — kick off the OAuth dance. Stores an anti-CSRF state
// in the session and redirects to Google's consent screen.
router.get('/google', async (req, res, next) => {
try {
if (!googleConfigured()) return res.status(404).json({ error: 'google sign-in not configured' });
const state = randomBytes(16).toString('hex');
req.session.oauth_state = state;
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
res.redirect(await buildAuthUrl(state));
} catch (err) { next(err); }
});
// GET /api/v1/auth/google/callback — Google redirects back here with ?code&state.
// Verifies the ID token, enforces the allowed domain, auto-provisions a viewer
// on first login, establishes the session, then redirects to the SPA.
router.get('/google/callback', async (req, res, next) => {
try {
if (!googleConfigured()) return res.status(404).json({ error: 'google sign-in not configured' });
const { code, state } = req.query;
const expected = req.session.oauth_state;
delete req.session.oauth_state;
if (!code || !state || !expected || state !== expected) {
return res.status(400).json({ error: 'invalid oauth state' });
}
const profile = await exchangeAndVerify(code);
const user = await resolveGoogleUser(profile);
// If this account has TOTP enabled, Google is only the FIRST factor — route
// through the same second-factor step as password login. The ticket lives in
// the session (not the URL) and the SPA prompts for the code.
if (user.totp_enabled) {
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
req.session.mfa_ticket = issueTicket(user.id, {
ip,
userAgent: req.get('user-agent'),
});
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
return res.redirect('/?mfa=1');
}
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
await establishSession(req, user, ip);
// Redirect to the SPA root; AuthGate will re-check /auth/me and render the app.
res.redirect('/');
} catch (err) {
// Surface a friendly message on the login screen rather than a raw 500.
if (err.status === 403) return res.redirect('/?auth_error=domain');
if (err.status === 401) return res.redirect('/?auth_error=google');
next(err);
}
});
// Map a verified Google profile to a Dragonflight user row.
//
// Resolution order:
// 1. Existing link by google_sub → that user.
// 2. Otherwise auto-provision a fresh 'viewer'.
//
// We deliberately do NOT auto-link to an existing account by matching email:
// that would let anyone who controls a Google address with the same email sign
// in as a pre-existing local (possibly admin) account, bypassing its password
// and TOTP. Linking an existing account to Google is an explicit, authenticated
// action (a future "connect Google" under Settings), not something a login does.
async function resolveGoogleUser(profile) {
const found = await pool.query(
`SELECT id, username, display_name, totp_enabled FROM users WHERE google_sub = $1`, [profile.sub]);
if (found.rows.length) return found.rows[0];
const base = (profile.email.split('@')[0] || 'user').replace(/[^a-z0-9._-]/gi, '').toLowerCase() || 'user';
let username = base, n = 1;
while ((await pool.query(`SELECT 1 FROM users WHERE username = $1`, [username])).rows.length) {
username = base + (++n);
}
try {
const ins = await pool.query(
`INSERT INTO users (username, password_hash, display_name, role, email, google_sub)
VALUES ($1, NULL, $2, 'viewer', $3, $4)
RETURNING id, username, display_name, totp_enabled`,
[username, profile.name, profile.email, profile.sub]);
return ins.rows[0];
} catch (err) {
// Concurrent first-login race: the unique google_sub index rejected our
// INSERT because a sibling request just created the row. Re-resolve.
if (err.code === '23505') {
const retry = await pool.query(
`SELECT id, username, display_name, totp_enabled FROM users WHERE google_sub = $1`, [profile.sub]);
if (retry.rows.length) return retry.rows[0];
}
throw err;
}
}
export default router;
export { realUserCount, resolveGoogleUser, consumeRecoveryCode };
export { realUserCount };

View file

@ -1,60 +1,25 @@
import express from 'express';
import pool from '../db/pool.js';
import { validateUuid } from '../middleware/errors.js';
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
import { v4 as uuidv4 } from 'uuid';
const router = express.Router();
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
// Resolve the owning project for a /:id bin, assert 'view' baseline, stash the
// project_id for mutating routes to escalate to 'edit'.
router.param('id', async (req, res, next) => {
validateUuid('id')(req, res, () => {});
if (res.headersSent) return;
try {
const { rows } = await pool.query('SELECT project_id FROM bins WHERE id = $1', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Bin not found' });
req.binProjectId = rows[0].project_id;
await assertProjectAccess(req.user, req.binProjectId, 'view');
next();
} catch (err) { next(err); }
});
async function requireBinEdit(req, res, next) {
try {
await assertProjectAccess(req.user, req.binProjectId, 'edit');
next();
} catch (err) { next(err); }
}
// GET / - List bins. When project_id is supplied, scope to it (after an access
// check); otherwise return bins across every project the caller can access.
// GET / - List bins. Filter by project_id when supplied; otherwise return
// every bin across every project so the Library / asset-context-menu can
// present a global "move to bin" picker.
router.get('/', async (req, res, next) => {
try {
const { project_id } = req.query;
const params = [];
let where = '';
if (project_id) {
await assertProjectAccess(req.user, project_id, 'view');
const result = await pool.query(
`SELECT b.*, p.name AS project_name,
(SELECT COUNT(*)::int FROM assets a WHERE a.bin_id = b.id) AS asset_count
FROM bins b
LEFT JOIN projects p ON p.id = b.project_id
WHERE b.project_id = $1
ORDER BY b.created_at DESC`,
[project_id]
);
return res.json(result.rows);
where = 'WHERE b.project_id = $1';
params.push(project_id);
}
const access = await accessibleProjectIds(req.user);
let where = '';
const params = [];
if (!access.all) {
if (access.ids.size === 0) return res.json([]);
where = 'WHERE b.project_id = ANY($1::uuid[])';
params.push([...access.ids]);
}
const result = await pool.query(
`SELECT b.*, p.name AS project_name,
(SELECT COUNT(*)::int FROM assets a WHERE a.bin_id = b.id) AS asset_count
@ -64,13 +29,14 @@ router.get('/', async (req, res, next) => {
ORDER BY b.created_at DESC`,
params
);
res.json(result.rows);
} catch (err) {
next(err);
}
});
// POST / - Create bin (requires edit on the target project).
// POST / - Create bin
router.post('/', async (req, res, next) => {
try {
const { project_id, name, parent_id } = req.body;
@ -78,7 +44,6 @@ router.post('/', async (req, res, next) => {
if (!project_id || !name) {
return res.status(400).json({ error: 'project_id and name are required' });
}
await assertProjectAccess(req.user, project_id, 'edit');
const id = uuidv4();
@ -96,7 +61,7 @@ router.post('/', async (req, res, next) => {
});
// PATCH /:id - Update bin
router.patch('/:id', requireBinEdit, async (req, res, next) => {
router.patch('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const { name, parent_id } = req.body;
@ -142,7 +107,7 @@ router.patch('/:id', requireBinEdit, async (req, res, next) => {
});
// DELETE /:id - Delete bin
router.delete('/:id', requireBinEdit, async (req, res, next) => {
router.delete('/:id', async (req, res, next) => {
try {
const { id } = req.params;
@ -161,8 +126,8 @@ router.delete('/:id', requireBinEdit, async (req, res, next) => {
}
});
// POST /:id/assets - Add asset to bin (requires edit on the bin's project).
router.post('/:id/assets', requireBinEdit, async (req, res, next) => {
// POST /:id/assets - Add asset to bin
router.post('/:id/assets', async (req, res, next) => {
try {
const { id } = req.params;
const { asset_id } = req.body;
@ -171,13 +136,10 @@ router.post('/:id/assets', requireBinEdit, async (req, res, next) => {
return res.status(400).json({ error: 'asset_id is required' });
}
// Asset must live in the bin's own project. Without this, an editor in
// project A (where the bin lives) could pull an asset from project B (no
// grant) into A's bin tree, exposing it in A's views.
const a = await pool.query('SELECT project_id FROM assets WHERE id = $1', [asset_id]);
if (a.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
if (a.rows[0].project_id !== req.binProjectId) {
return res.status(400).json({ error: 'asset belongs to a different project than the bin' });
// Verify bin exists
const binCheck = await pool.query('SELECT id FROM bins WHERE id = $1', [id]);
if (binCheck.rows.length === 0) {
return res.status(404).json({ error: 'Bin not found' });
}
// Update asset's bin_id
@ -196,8 +158,8 @@ router.post('/:id/assets', requireBinEdit, async (req, res, next) => {
}
});
// DELETE /:id/assets/:assetId - Remove asset from bin (requires edit).
router.delete('/:id/assets/:assetId', requireBinEdit, async (req, res, next) => {
// DELETE /:id/assets/:assetId - Remove asset from bin
router.delete('/:id/assets/:assetId', async (req, res, next) => {
try {
const { id, assetId } = req.params;

View file

@ -1,7 +1,3 @@
// authz: intentionally any-logged-in (no per-project scoping). This is a thin
// proxy to shared capture hardware with no project_id of its own; the resulting
// asset is scoped when it's registered via the /assets route. Gated by the
// global requireAuth in index.js, like the rest of /api/v1.
import express from 'express';
const router = express.Router();

View file

@ -5,23 +5,9 @@
import express from 'express';
import pool from '../db/pool.js';
import { assertProjectAccess } from '../auth/authz.js';
const router = express.Router({ mergeParams: true });
// Scope every comment route to the parent asset's project: resolve project_id
// via the asset, then require 'view' to read and 'edit' to write. A non-UUID or
// unknown asset is a clean 404 before any access decision leaks its existence.
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
router.use(async (req, res, next) => {
try {
const { rows } = await pool.query('SELECT project_id FROM assets WHERE id = $1', [req.params.assetId]);
if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
await assertProjectAccess(req.user, rows[0].project_id, MUTATING.has(req.method) ? 'edit' : 'view');
next();
} catch (err) { next(err); }
});
function rowToJson(r) {
return {
id: r.id,
@ -63,9 +49,8 @@ router.post('/', async (req, res, next) => {
if (!body || !String(body).trim()) {
return res.status(400).json({ error: 'body is required' });
}
// Author is the authenticated user (requireAuth sets req.user for both
// session and bearer auth, and the dev user when AUTH_ENABLED=false).
const userId = req.user?.id || null;
// Best-effort author lookup — pull from the session if AUTH_ENABLED is on.
const userId = req.session?.userId || null;
const ins = await pool.query(
`INSERT INTO asset_comments (asset_id, user_id, body, frame_ms)

View file

@ -10,7 +10,6 @@ import express from 'express';
import { Queue } from 'bullmq';
import { v4 as uuidv4 } from 'uuid';
import pool from '../db/pool.js';
import { assertProjectAccess } from '../auth/authz.js';
const router = express.Router();
@ -61,8 +60,6 @@ router.post('/youtube', async (req, res, next) => {
if (projCheck.rows.length === 0) {
return res.status(404).json({ error: 'Project not found' });
}
// Importing writes an asset into the project — require edit access.
await assertProjectAccess(req.user, projectId, 'edit');
const assetId = uuidv4();

View file

@ -1,7 +1,6 @@
import express from 'express';
import pool from '../db/pool.js';
import { Queue } from 'bullmq';
import { assertProjectAccess } from '../auth/authz.js';
const router = express.Router();
// Note: jobs use BullMQ id format "<queueType>:<bullId>" (e.g. "conform:42"),
@ -325,10 +324,6 @@ router.post('/conform', async (req, res, next) => {
});
}
// Conform writes back into a project — require edit on that project. Without
// this, any logged-in user could enqueue conform jobs targeting any project.
await assertProjectAccess(req.user, project_id, 'edit');
const bullJob = await conformQueue.add('conform-task', {
edl,
projectId: project_id,

View file

@ -1,8 +1,6 @@
import express from 'express';
import pool from '../db/pool.js';
import { validateUuid } from '../middleware/errors.js';
import { requireAdmin } from '../middleware/auth.js';
import { accessibleProjectIds, assertProjectAccess } from '../auth/authz.js';
import { v4 as uuidv4 } from 'uuid';
const router = express.Router();
@ -18,29 +16,18 @@ const slugify = (str) => {
.replace(/-+/g, '-');
};
// GET / - List projects the caller can access (admins see all).
// GET / - List all projects
router.get('/', async (req, res, next) => {
try {
const access = await accessibleProjectIds(req.user);
if (access.all) {
const result = await pool.query('SELECT * FROM projects ORDER BY created_at DESC');
return res.json(result.rows);
}
if (access.ids.size === 0) return res.json([]);
const ids = [...access.ids];
const result = await pool.query(
`SELECT * FROM projects WHERE id = ANY($1::uuid[]) ORDER BY created_at DESC`,
[ids]
);
const result = await pool.query('SELECT * FROM projects ORDER BY created_at DESC');
res.json(result.rows);
} catch (err) {
next(err);
}
});
// POST / - Create project (admin only; new projects have no grants, so a
// scoped user could never reach one they just made).
router.post('/', requireAdmin, async (req, res, next) => {
// POST / - Create project
router.post('/', async (req, res, next) => {
try {
const { name, description } = req.body;
@ -64,11 +51,10 @@ router.post('/', requireAdmin, async (req, res, next) => {
}
});
// GET /:id - Single project with asset count (requires view access).
// GET /:id - Single project with asset count
router.get('/:id', async (req, res, next) => {
try {
const { id } = req.params;
await assertProjectAccess(req.user, id, 'view');
const result = await pool.query(
`SELECT p.*,
@ -90,11 +76,10 @@ router.get('/:id', async (req, res, next) => {
}
});
// PATCH /:id - Update project (requires edit access).
// PATCH /:id - Update project
router.patch('/:id', async (req, res, next) => {
try {
const { id } = req.params;
await assertProjectAccess(req.user, id, 'edit');
const { name, description } = req.body;
const updates = [];
@ -137,9 +122,8 @@ router.patch('/:id', async (req, res, next) => {
}
});
// DELETE /:id - Delete project and cascade (admin only — destructive, wipes
// every asset/bin/recorder under it).
router.delete('/:id', requireAdmin, async (req, res, next) => {
// DELETE /:id - Delete project and cascade
router.delete('/:id', async (req, res, next) => {
try {
const { id } = req.params;
@ -159,78 +143,4 @@ router.delete('/:id', requireAdmin, async (req, res, next) => {
}
});
// ── Per-project access grants (admin only) ──────────────────────────────────
// GET /:id/access — list grants with resolved user/group display names.
router.get('/:id/access', requireAdmin, async (req, res, next) => {
try {
const { rows } = await pool.query(
`SELECT pa.subject_type, pa.subject_id, pa.level, pa.granted_at,
CASE pa.subject_type
WHEN 'user' THEN u.display_name
WHEN 'group' THEN g.name
END AS subject_name,
CASE pa.subject_type
WHEN 'user' THEN u.username
ELSE NULL
END AS username
FROM project_access pa
LEFT JOIN users u ON pa.subject_type = 'user' AND u.id = pa.subject_id
LEFT JOIN groups g ON pa.subject_type = 'group' AND g.id = pa.subject_id
WHERE pa.project_id = $1
ORDER BY pa.subject_type, subject_name`,
[req.params.id]
);
res.json(rows);
} catch (err) { next(err); }
});
// POST /:id/access { subject_type, subject_id, level } — grant or update.
router.post('/:id/access', requireAdmin, async (req, res, next) => {
try {
const { subject_type, subject_id, level } = req.body || {};
if (!['user', 'group'].includes(subject_type)) {
return res.status(400).json({ error: "subject_type must be 'user' or 'group'" });
}
if (!subject_id) return res.status(400).json({ error: 'subject_id required' });
const lvl = level || 'view';
if (!['view', 'edit'].includes(lvl)) {
return res.status(400).json({ error: "level must be 'view' or 'edit'" });
}
// Validate the subject actually exists so we don't create dead grants.
const tbl = subject_type === 'user' ? 'users' : 'groups';
const exists = await pool.query(`SELECT 1 FROM ${tbl} WHERE id = $1`, [subject_id]);
if (exists.rows.length === 0) {
return res.status(404).json({ error: subject_type + ' not found' });
}
const { rows } = await pool.query(
`INSERT INTO project_access (project_id, subject_type, subject_id, level, granted_by)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (project_id, subject_type, subject_id)
DO UPDATE SET level = EXCLUDED.level, granted_by = EXCLUDED.granted_by, granted_at = NOW()
RETURNING project_id, subject_type, subject_id, level, granted_at`,
[req.params.id, subject_type, subject_id, lvl, req.user?.id || null]
);
res.status(201).json(rows[0]);
} catch (err) { next(err); }
});
// DELETE /:id/access/:subjectType/:subjectId — revoke a grant.
router.delete('/:id/access/:subjectType/:subjectId', requireAdmin, async (req, res, next) => {
try {
const { id, subjectType, subjectId } = req.params;
if (!['user', 'group'].includes(subjectType)) {
return res.status(400).json({ error: "subjectType must be 'user' or 'group'" });
}
const { rowCount } = await pool.query(
`DELETE FROM project_access
WHERE project_id = $1 AND subject_type = $2 AND subject_id = $3`,
[id, subjectType, subjectId]
);
if (rowCount === 0) return res.status(404).json({ error: 'grant not found' });
res.status(204).end();
} catch (err) { next(err); }
});
export default router;

View file

@ -6,34 +6,10 @@ import dgram from 'dgram';
import pool from '../db/pool.js';
import { getS3Bucket } from '../s3/client.js';
import { validateUuid } from '../middleware/errors.js';
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
import { v4 as uuidv4 } from 'uuid';
const router = express.Router();
// Every /:id recorder route is scoped to the recorder's project. The param
// handler validates the UUID, resolves the owning project_id, and asserts the
// 'view' baseline; mutating routes escalate to 'edit' via requireRecorderEdit.
// A recorder with a NULL project_id resolves to admin-only (assertProjectAccess
// throws 403 for non-admins on a null project).
router.param('id', async (req, res, next) => {
validateUuid('id')(req, res, () => {});
if (res.headersSent) return;
try {
const { rows } = await pool.query('SELECT project_id FROM recorders WHERE id = $1', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Recorder not found' });
req.recorderProjectId = rows[0].project_id;
await assertProjectAccess(req.user, req.recorderProjectId, 'view');
next();
} catch (err) { next(err); }
});
async function requireRecorderEdit(req, res, next) {
try {
await assertProjectAccess(req.user, req.recorderProjectId, 'edit');
next();
} catch (err) { next(err); }
}
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
// Base port for on-demand SDI sidecar containers on remote worker nodes.
// Device index 0 → 7438, index 1 → 7439, etc.
@ -173,17 +149,6 @@ function pickRecorderFields(body) {
// parallel with a per-call timeout from `dockerApi`.
router.get('/', async (req, res, next) => {
try {
// Scope to recorders in projects the caller can access (admins unfiltered).
// Recorders with a NULL project are admin-only and never appear for scoped
// users (accessibleProjectIds never yields a null id).
const access = await accessibleProjectIds(req.user);
let scopeClause = '';
const params = [];
if (!access.all) {
if (access.ids.size === 0) return res.json([]);
scopeClause = 'WHERE r.project_id = ANY($1::uuid[])';
params.push([...access.ids]);
}
const result = await pool.query(`
SELECT r.*, la.live_asset_id
FROM recorders r
@ -197,9 +162,8 @@ router.get('/', async (req, res, next) => {
ORDER BY a.created_at DESC
LIMIT 1
) la ON TRUE
${scopeClause}
ORDER BY r.created_at DESC
`, params);
`);
const rows = result.rows;
// Only inspect containers for recorders that actually claim to be recording.
@ -230,11 +194,6 @@ router.post('/', async (req, res, next) => {
.json({ error: 'Name and source_type are required' });
}
// Creating a recorder writes into a project — require edit there. A recorder
// with no project_id is admin-only (assertProjectAccess denies non-admins on
// a null project).
await assertProjectAccess(req.user, fields.project_id ?? null, 'edit');
// Defaults — written on insert so the DB row is always self-contained.
const defaults = {
source_config: {},
@ -297,7 +256,7 @@ router.get('/:id', async (req, res, next) => {
// PATCH /:id - Edit recorder settings
// Blocked while recorder is actively recording to prevent config drift.
router.patch('/:id', requireRecorderEdit, async (req, res, next) => {
router.patch('/:id', async (req, res, next) => {
try {
const { id } = req.params;
@ -336,7 +295,7 @@ router.patch('/:id', requireRecorderEdit, async (req, res, next) => {
});
// POST /:id/start - Start recording
router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
router.post('/:id/start', async (req, res, next) => {
try {
const { id } = req.params;
@ -386,14 +345,6 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
? req.body.projectId
: recorder.project_id;
// requireRecorderEdit only covered the recorder's own project. If this take
// is being routed into a DIFFERENT project, the caller must have edit there
// too — otherwise edit on recorder A's project would let them write live
// assets into any project B.
if (takeProjectId !== recorder.project_id) {
await assertProjectAccess(req.user, takeProjectId, 'edit');
}
// live-asset: create the asset row right now (status='live') so the
// library shows the recording while it is happening.
const assetIdLive = uuidv4();
@ -600,7 +551,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
});
// POST /:id/stop - Stop recording
router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
router.post('/:id/stop', async (req, res, next) => {
try {
const { id } = req.params;
@ -771,7 +722,7 @@ router.get('/:id/status', async (req, res, next) => {
});
// DELETE /:id - Delete recorder
router.delete('/:id', requireRecorderEdit, async (req, res, next) => {
router.delete('/:id', async (req, res, next) => {
try {
const { id } = req.params;

View file

@ -3,7 +3,6 @@ import express from 'express';
import pool from '../db/pool.js';
import { getSignedUrlForObject } from '../s3/client.js';
import { validateUuid } from '../middleware/errors.js';
import { assertProjectAccess } from '../auth/authz.js';
import { Queue } from 'bullmq';
const parseRedisUrl = (url) => {
@ -20,27 +19,7 @@ const conformQueue = new Queue('conform', {
});
const router = express.Router();
// Scope every /:id sequence route to its project: validate the UUID, resolve
// project_id, assert the 'view' baseline; mutators escalate via requireSequenceEdit.
router.param('id', async (req, res, next) => {
validateUuid('id')(req, res, () => {});
if (res.headersSent) return;
try {
const { rows } = await pool.query('SELECT project_id FROM sequences WHERE id = $1', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Sequence not found' });
req.sequenceProjectId = rows[0].project_id;
await assertProjectAccess(req.user, req.sequenceProjectId, 'view');
next();
} catch (err) { next(err); }
});
async function requireSequenceEdit(req, res, next) {
try {
await assertProjectAccess(req.user, req.sequenceProjectId, 'edit');
next();
} catch (err) { next(err); }
}
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
// ── Row mapper ────────────────────────────────────────────────────────────────
// node-postgres returns NUMERIC columns as strings. Coerce frame_rate to a
@ -145,7 +124,6 @@ router.get('/', async (req, res, next) => {
try {
const { project_id } = req.query;
if (!project_id) return res.status(400).json({ error: 'project_id is required' });
await assertProjectAccess(req.user, project_id, 'view');
const r = await pool.query(
`SELECT * FROM sequences WHERE project_id = $1 ORDER BY updated_at DESC`,
[project_id]
@ -165,7 +143,6 @@ router.post('/', async (req, res, next) => {
height = 1080,
} = req.body;
if (!project_id) return res.status(400).json({ error: 'project_id is required' });
await assertProjectAccess(req.user, project_id, 'edit');
const r = await pool.query(
`INSERT INTO sequences (project_id, name, frame_rate, width, height)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
@ -211,7 +188,7 @@ router.get('/:id', async (req, res, next) => {
});
// ── PUT /:id update sequence metadata ──────────────────────────────────────
router.put('/:id', requireSequenceEdit, async (req, res, next) => {
router.put('/:id', async (req, res, next) => {
try {
const { name, frame_rate, width, height } = req.body;
const updates = [];
@ -234,7 +211,7 @@ router.put('/:id', requireSequenceEdit, async (req, res, next) => {
});
// ── DELETE /:id ───────────────────────────────────────────────────────────────
router.delete('/:id', requireSequenceEdit, async (req, res, next) => {
router.delete('/:id', async (req, res, next) => {
try {
const r = await pool.query(`DELETE FROM sequences WHERE id = $1`, [req.params.id]);
if (!r.rowCount) return res.status(404).json({ error: 'Sequence not found' });
@ -243,41 +220,25 @@ router.delete('/:id', requireSequenceEdit, async (req, res, next) => {
});
// ── PUT /:id/clips full replace of clip array (single transaction) ──────────
router.put('/:id/clips', requireSequenceEdit, async (req, res, next) => {
router.put('/:id/clips', async (req, res, next) => {
// Verify sequence exists first (before acquiring transaction client)
const seqCheck = await pool.query(`SELECT id FROM sequences WHERE id = $1`, [req.params.id]);
if (!seqCheck.rows.length) return res.status(404).json({ error: 'Sequence not found' });
const clips = Array.isArray(req.body) ? req.body : [];
let client;
for (const c of clips) {
if (!c.asset_id) return res.status(400).json({ error: 'Each clip must have asset_id' });
if (!Number.isFinite(Number(c.timeline_in_frames)) || !Number.isFinite(Number(c.timeline_out_frames)) ||
!Number.isFinite(Number(c.source_in_frames)) || !Number.isFinite(Number(c.source_out_frames))) {
return res.status(400).json({ error: 'Clip frame fields must be finite numbers' });
}
if (!Number.isInteger(Number(c.track)) || Number(c.track) < 0) {
return res.status(400).json({ error: 'Clip track must be a non-negative integer' });
}
}
const client = await pool.connect();
try {
// Verify sequence exists first (before acquiring transaction client).
const seqCheck = await pool.query(`SELECT id FROM sequences WHERE id = $1`, [req.params.id]);
if (!seqCheck.rows.length) return res.status(404).json({ error: 'Sequence not found' });
for (const c of clips) {
if (!c.asset_id) return res.status(400).json({ error: 'Each clip must have asset_id' });
if (!Number.isFinite(Number(c.timeline_in_frames)) || !Number.isFinite(Number(c.timeline_out_frames)) ||
!Number.isFinite(Number(c.source_in_frames)) || !Number.isFinite(Number(c.source_out_frames))) {
return res.status(400).json({ error: 'Clip frame fields must be finite numbers' });
}
if (!Number.isInteger(Number(c.track)) || Number(c.track) < 0) {
return res.status(400).json({ error: 'Clip track must be a non-negative integer' });
}
}
// Every referenced asset must belong to THIS sequence's project. Without this,
// a user with edit on the sequence could splice in assets from a project they
// can't access — and GET /:id would then hand back those assets' names and
// signed proxy URLs (cross-project leak).
const assetIds = [...new Set(clips.map(c => c.asset_id))];
if (assetIds.length) {
const owning = await pool.query(
`SELECT id FROM assets WHERE id = ANY($1::uuid[]) AND project_id = $2`,
[assetIds, req.sequenceProjectId]
);
if (owning.rows.length !== assetIds.length) {
return res.status(400).json({ error: 'All clip assets must belong to the sequence\'s project' });
}
}
client = await pool.connect();
await client.query('BEGIN');
await client.query(
`DELETE FROM sequence_clips WHERE sequence_id = $1`,
@ -304,12 +265,10 @@ router.put('/:id/clips', requireSequenceEdit, async (req, res, next) => {
await client.query('COMMIT');
res.json({ ok: true, count: clips.length });
} catch (e) {
// client is only set once we've connected; a failure in the pre-transaction
// queries (existence/validation/ownership) has no transaction to roll back.
if (client) await client.query('ROLLBACK').catch(() => {});
await client.query('ROLLBACK');
next(e);
} finally {
if (client) client.release();
client.release();
}
});
@ -341,7 +300,7 @@ router.post('/:id/export/edl', async (req, res, next) => {
// ── POST /:id/conform conform sequence via FCP XML ─────────────────────────
// Accepts FCP XML content and encode settings from the Premiere plugin,
// queues a conform job in BullMQ, and returns the job ID for polling.
router.post('/:id/conform', requireSequenceEdit, async (req, res, next) => {
router.post('/:id/conform', async (req, res, next) => {
try {
const seqR = await pool.query(`SELECT * FROM sequences WHERE id = $1`, [req.params.id]);
if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' });

View file

@ -14,7 +14,6 @@ import {
AbortMultipartUploadCommand,
} from '@aws-sdk/client-s3';
import { getAmppConfig, ensureFolderPath } from '../ampp/client.js';
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
const router = express.Router();
@ -139,24 +138,16 @@ function mediaTypeFromMime(mime = '') {
return 'document';
}
// GET /api/v1/upload - List in-progress uploads (#68). Scoped to projects the
// caller can see — admins are unfiltered; a scoped viewer/editor only sees
// uploads for projects they have access to (no enumeration of other projects'
// in-flight filenames).
// GET /api/v1/upload - List in-progress uploads (#68)
router.get('/', async (req, res, next) => {
try {
const access = await accessibleProjectIds(req.user);
let query = `SELECT id, filename, display_name, project_id, bin_id, status, created_at, updated_at
FROM assets
WHERE status = 'ingesting'`;
const params = [];
if (!access.all) {
if (access.ids.size === 0) return res.json([]);
query += ` AND project_id = ANY($1::uuid[])`;
params.push([...access.ids]);
}
query += ` ORDER BY created_at DESC LIMIT 50`;
const result = await pool.query(query, params);
const result = await pool.query(
`SELECT id, filename, display_name, project_id, bin_id, status, created_at, updated_at
FROM assets
WHERE status = 'ingesting'
ORDER BY created_at DESC
LIMIT 50`
);
res.json(result.rows);
} catch (err) { next(err); }
});
@ -172,17 +163,6 @@ router.post('/init', async (req, res, next) => {
});
}
// Uploading creates an asset under a project — require edit on that project.
// Without this, any logged-in user could write into any project.
await assertProjectAccess(req.user, projectId, 'edit');
if (binId) {
const bin = await pool.query('SELECT project_id FROM bins WHERE id = $1', [binId]);
if (bin.rows.length === 0) return res.status(400).json({ error: 'binId not found' });
if (bin.rows[0].project_id !== projectId) {
return res.status(400).json({ error: 'binId belongs to a different project' });
}
}
const assetId = uuidv4();
const s3Key = `originals/${assetId}/${filename}`;
const tagsArray = tags ? (Array.isArray(tags) ? tags : [tags]) : [];
@ -346,20 +326,6 @@ router.post('/simple', upload.single('file'), async (req, res, next) => {
});
}
// Same authz gate as /init.
await assertProjectAccess(req.user, projectId, 'edit');
if (binId) {
const bin = await pool.query('SELECT project_id FROM bins WHERE id = $1', [binId]);
if (bin.rows.length === 0) {
unlinkPart(tmpPath);
return res.status(400).json({ error: 'binId not found' });
}
if (bin.rows[0].project_id !== projectId) {
unlinkPart(tmpPath);
return res.status(400).json({ error: 'binId belongs to a different project' });
}
}
const assetId = uuidv4();
const s3Key = `originals/${assetId}/${filename}`;
const mimeType = contentType || req.file.mimetype;

View file

@ -1,125 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
import {
isAdmin,
accessibleProjectIds,
projectLevel,
assertProjectAccess,
} from '../../src/auth/authz.js';
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
// ── isAdmin (pure, no DB) ───────────────────────────────────────────────────
test('isAdmin true only for role admin', () => {
assert.equal(isAdmin({ role: 'admin' }), true);
assert.equal(isAdmin({ role: 'editor' }), false);
assert.equal(isAdmin({ role: 'viewer' }), false);
assert.equal(isAdmin(null), false);
assert.equal(isAdmin(undefined), false);
});
// Seed helpers shared across the DB-backed cases.
async function seed(pool) {
const proj = async (name) =>
(await pool.query(
`INSERT INTO projects (name, s3_prefix) VALUES ($1, $1) RETURNING id`, [name]
)).rows[0].id;
const user = async (username, role) =>
(await pool.query(
`INSERT INTO users (username, password_hash, role) VALUES ($1, 'x', $2) RETURNING id`,
[username, role]
)).rows[0].id;
const group = async (name) =>
(await pool.query(`INSERT INTO groups (name) VALUES ($1) RETURNING id`, [name])).rows[0].id;
const grantUser = (pid, uid, level) =>
pool.query(
`INSERT INTO project_access (project_id, subject_type, subject_id, level)
VALUES ($1, 'user', $2, $3)`, [pid, uid, level]);
const grantGroup = (pid, gid, level) =>
pool.query(
`INSERT INTO project_access (project_id, subject_type, subject_id, level)
VALUES ($1, 'group', $2, $3)`, [pid, gid, level]);
const addToGroup = (uid, gid) =>
pool.query(`INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`, [uid, gid]);
return { proj, user, group, grantUser, grantGroup, addToGroup };
}
test('admin sees all projects, every project at edit', { skip: SKIP }, async () => {
const pool = await setupTestDb();
try {
const s = await seed(pool);
await s.proj('Alpha'); await s.proj('Beta');
const admin = { id: await s.user('adm', 'admin'), role: 'admin' };
const acc = await accessibleProjectIds(admin, pool);
assert.equal(acc.all, true);
assert.equal(await projectLevel(admin, '00000000-0000-4000-8000-000000000001', pool), 'edit');
await assertProjectAccess(admin, '00000000-0000-4000-8000-000000000001', 'edit', pool); // no throw
} finally { await pool.end(); }
});
test('direct user grant scopes access and respects level', { skip: SKIP }, async () => {
const pool = await setupTestDb();
try {
const s = await seed(pool);
const alpha = await s.proj('Alpha');
const beta = await s.proj('Beta');
const u = { id: await s.user('bob', 'editor'), role: 'editor' };
await s.grantUser(alpha, u.id, 'view');
const acc = await accessibleProjectIds(u, pool);
assert.equal(acc.all, false);
assert.deepEqual([...acc.ids], [alpha]);
assert.equal(await projectLevel(u, alpha, pool), 'view');
assert.equal(await projectLevel(u, beta, pool), null);
await assertProjectAccess(u, alpha, 'view', pool); // ok
await assert.rejects(() => assertProjectAccess(u, alpha, 'edit', pool), e => e.status === 403);
await assert.rejects(() => assertProjectAccess(u, beta, 'view', pool), e => e.status === 403);
} finally { await pool.end(); }
});
test('group grant flows through membership', { skip: SKIP }, async () => {
const pool = await setupTestDb();
try {
const s = await seed(pool);
const alpha = await s.proj('Alpha');
const u = { id: await s.user('carol', 'viewer'), role: 'viewer' };
const g = await s.group('broadcasters');
await s.addToGroup(u.id, g);
await s.grantGroup(alpha, g, 'edit');
assert.equal(await projectLevel(u, alpha, pool), 'edit');
const acc = await accessibleProjectIds(u, pool);
assert.deepEqual([...acc.ids], [alpha]);
await assertProjectAccess(u, alpha, 'edit', pool); // ok via group
} finally { await pool.end(); }
});
test('effective level is the max of direct + group grants', { skip: SKIP }, async () => {
const pool = await setupTestDb();
try {
const s = await seed(pool);
const alpha = await s.proj('Alpha');
const u = { id: await s.user('dan', 'editor'), role: 'editor' };
const g = await s.group('team');
await s.addToGroup(u.id, g);
await s.grantUser(alpha, u.id, 'view'); // direct: view
await s.grantGroup(alpha, g, 'edit'); // group: edit → wins
assert.equal(await projectLevel(u, alpha, pool), 'edit');
} finally { await pool.end(); }
});
test('user with no grants sees nothing', { skip: SKIP }, async () => {
const pool = await setupTestDb();
try {
const s = await seed(pool);
await s.proj('Alpha');
const u = { id: await s.user('eve', 'viewer'), role: 'viewer' };
const acc = await accessibleProjectIds(u, pool);
assert.equal(acc.ids.size, 0);
} finally { await pool.end(); }
});

View file

@ -1,40 +0,0 @@
// Unit tests for the config-gating + domain helpers in google-oauth.js. The
// token-exchange / ID-token-verify path requires Google's servers and is covered
// by manual verification (see .env.example); here we lock down the pure logic
// that decides whether the feature is on and which domain is allowed.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isConfigured, allowedDomain } from '../../src/auth/google-oauth.js';
function withEnv(vars, fn) {
const saved = {};
for (const k of Object.keys(vars)) { saved[k] = process.env[k];
if (vars[k] === undefined) delete process.env[k]; else process.env[k] = vars[k]; }
try { return fn(); }
finally {
for (const k of Object.keys(vars)) {
if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k];
}
}
}
test('isConfigured is false unless client id, secret, and redirect are all set', () => {
withEnv({ GOOGLE_CLIENT_ID: undefined, GOOGLE_CLIENT_SECRET: undefined, OAUTH_REDIRECT_URL: undefined }, () => {
assert.equal(isConfigured(), false);
});
withEnv({ GOOGLE_CLIENT_ID: 'x', GOOGLE_CLIENT_SECRET: undefined, OAUTH_REDIRECT_URL: undefined }, () => {
assert.equal(isConfigured(), false);
});
withEnv({ GOOGLE_CLIENT_ID: 'x', GOOGLE_CLIENT_SECRET: 'y', OAUTH_REDIRECT_URL: undefined }, () => {
assert.equal(isConfigured(), false);
});
withEnv({ GOOGLE_CLIENT_ID: 'x', GOOGLE_CLIENT_SECRET: 'y', OAUTH_REDIRECT_URL: 'https://h/cb' }, () => {
assert.equal(isConfigured(), true);
});
});
test('allowedDomain normalizes and defaults to null', () => {
withEnv({ GOOGLE_ALLOWED_DOMAIN: undefined }, () => assert.equal(allowedDomain(), null));
withEnv({ GOOGLE_ALLOWED_DOMAIN: '' }, () => assert.equal(allowedDomain(), null));
withEnv({ GOOGLE_ALLOWED_DOMAIN: ' WildDragon.NET ' }, () => assert.equal(allowedDomain(), 'wilddragon.net'));
});

View file

@ -1,49 +0,0 @@
// MFA ticket binding tests — the second login step's tickets are bound to the
// issuing request's IP + User-Agent (hashed) so a stolen ticket replayed from
// a different origin can't complete the second factor.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { issueTicket, redeemTicket } from '../../src/auth/mfa-tickets.js';
test('ticket round-trips when ip + userAgent match', () => {
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'curl/8' }), 'user-1');
});
test('ticket rejects redemption from a different IP', () => {
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
assert.equal(redeemTicket(id, { ip: '9.9.9.9', userAgent: 'curl/8' }), null);
});
test('ticket rejects redemption with a different User-Agent', () => {
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'Mozilla/5.0' }), null);
});
test('ticket is single-use even on binding mismatch', () => {
// A wrong-binding probe must still burn the ticket — otherwise an attacker
// could try multiple IPs/UAs against the same ticket.
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
assert.equal(redeemTicket(id, { ip: '9.9.9.9', userAgent: 'curl/8' }), null);
// Same ticket with correct bindings now also fails — it was consumed.
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'curl/8' }), null);
});
test('redeemTicket returns null for missing or unknown id', () => {
assert.equal(redeemTicket(null), null);
assert.equal(redeemTicket(undefined), null);
assert.equal(redeemTicket(''), null);
assert.equal(redeemTicket('not-a-real-id', { ip: 'x', userAgent: 'y' }), null);
});
test('ticket is single-use on success', () => {
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'curl/8' }), 'user-1');
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'curl/8' }), null);
});
test('issueTicket without bindings still works (back-compat / tests)', () => {
const id = issueTicket('user-1');
// No bindings on redeem either — both sides skip the check.
assert.equal(redeemTicket(id), 'user-1');
});

View file

@ -1,96 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
base32Encode, base32Decode, generateSecret, generateToken,
verifyToken, otpauthURI, generateRecoveryCodes,
} from '../../src/auth/totp.js';
// ── base32 round-trips ──────────────────────────────────────────────────────
test('base32 encode/decode round-trips arbitrary bytes', () => {
for (const s of ['', 'f', 'fo', 'foo', 'foob', 'fooba', 'foobar']) {
const buf = Buffer.from(s);
assert.deepEqual(base32Decode(base32Encode(buf)), buf);
}
});
// ── RFC 6238 Appendix B test vectors (SHA-1, 8 digits in the RFC; we use the
// low 6 here, so compare the last 6 digits of each published value). ──────────
// The RFC uses the ASCII secret "12345678901234567890". We base32-encode it and
// check the 6-digit code at each published timestamp.
test('matches RFC 6238 SHA-1 vectors (low 6 digits)', () => {
const secret = base32Encode(Buffer.from('12345678901234567890'));
// [unix seconds, full 8-digit code from the RFC] → expect last 6 digits.
const vectors = [
[59, '94287082'],
[1111111109, '07081804'],
[1111111111, '14050471'],
[1234567890, '89005924'],
[2000000000, '69279037'],
[20000000000, '65353130'],
];
for (const [secs, full8] of vectors) {
const got = generateToken(secret, secs * 1000);
assert.equal(got, full8.slice(-6), `t=${secs}`);
}
});
// ── verify with drift window ────────────────────────────────────────────────
// verifyToken returns the matched counter (truthy) or null (falsy).
test('verifyToken accepts the current code and ±1 step of drift', () => {
const secret = generateSecret();
const now = 1_700_000_000_000;
const code = generateToken(secret, now);
const baseCounter = Math.floor(now / 1000 / 30);
assert.equal(verifyToken(secret, code, now), baseCounter);
// 30s earlier / later still inside ±1 window — the *issued* code matches the
// baseCounter, but at now+30s we're in step baseCounter+1, so the issued
// code matches as drift = -1 step → returns baseCounter.
assert.equal(verifyToken(secret, code, now + 30_000), baseCounter);
assert.equal(verifyToken(secret, code, now - 30_000), baseCounter);
// 2 steps away → rejected.
assert.equal(verifyToken(secret, code, now + 90_000), null);
});
test('verifyToken rejects malformed / empty input without throwing', () => {
const secret = generateSecret();
assert.equal(verifyToken(secret, ''), null);
assert.equal(verifyToken(secret, 'abcdef'), null);
assert.equal(verifyToken(secret, '12345'), null); // too short
assert.equal(verifyToken(secret, '1234567'), null); // too long
assert.equal(verifyToken('', '123456'), null);
});
test('verifyToken tolerates spaces in the user-entered code', () => {
const secret = generateSecret();
const now = 1_700_000_000_000;
const code = generateToken(secret, now);
const expected = Math.floor(now / 1000 / 30);
assert.equal(verifyToken(secret, code.slice(0, 3) + ' ' + code.slice(3), now), expected);
});
test('verifyToken returns the matched counter (for replay protection)', () => {
const secret = generateSecret();
const now = 1_700_000_000_000;
const code = generateToken(secret, now);
const counter = verifyToken(secret, code, now);
assert.ok(typeof counter === 'number' && counter > 0);
assert.equal(counter, Math.floor(now / 1000 / 30));
});
// ── otpauth URI ─────────────────────────────────────────────────────────────
test('otpauthURI embeds secret, issuer, and account', () => {
const uri = otpauthURI('JBSWY3DPEHPK3PXP', 'alice', 'Dragonflight');
assert.match(uri, /^otpauth:\/\/totp\/Dragonflight%3Aalice\?/);
assert.match(uri, /secret=JBSWY3DPEHPK3PXP/);
assert.match(uri, /issuer=Dragonflight/);
assert.match(uri, /digits=6/);
assert.match(uri, /period=30/);
});
// ── recovery codes ──────────────────────────────────────────────────────────
test('generateRecoveryCodes returns N distinct formatted codes', () => {
const codes = generateRecoveryCodes(10);
assert.equal(codes.length, 10);
assert.equal(new Set(codes).size, 10);
for (const c of codes) assert.match(c, /^[0-9a-f]{5}-[0-9a-f]{5}$/);
});

View file

@ -1,79 +0,0 @@
// Regression test: GET /api/v1/assets must be scoped to the caller's accessible
// projects. A pre-fix bug returned every asset across every project to any
// authenticated user, defeating RBAC v2.
//
// Like project-access.test.js, the assets router uses the singleton pool, so we
// point DATABASE_URL at TEST_DATABASE_URL and seed through the same pool.
// Skips when TEST_DATABASE_URL is unset.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
async function appWithAssets(user) {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
process.env.AUTH_ENABLED = 'true';
// Importing the assets router constructs BullMQ queues; they connect lazily,
// and the list route only touches Postgres, so no Redis is needed here.
const { default: assetsRouter } = await import('../../src/routes/assets.js?v=' + Date.now());
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.user = user; next(); });
app.use('/api/v1/assets', assetsRouter);
return new Promise(r => {
const srv = app.listen(0, '127.0.0.1', () =>
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
});
}
async function seed(pool) {
const proj = async (n) => (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ($1,$1) RETURNING id`, [n])).rows[0].id;
const alpha = await proj('Alpha');
const beta = await proj('Beta');
const asset = (pid, name) => pool.query(
`INSERT INTO assets (project_id, filename, display_name, media_type, status)
VALUES ($1, $2, $2, 'video', 'ready')`, [pid, name]);
await asset(alpha, 'a1'); await asset(alpha, 'a2'); await asset(beta, 'b1');
const admin = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('adm','x','admin') RETURNING id`)).rows[0].id, role: 'admin' };
const scoped = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('vu','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
await pool.query(
`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'view')`,
[alpha, scoped.id]);
return { alpha, beta, admin, scoped };
}
test('GET /assets returns only assets in granted projects for scoped users', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { admin, scoped } = await seed(pool);
// Admin sees all three.
let a = await appWithAssets(admin);
let body = await (await fetch(a.baseUrl + '/api/v1/assets')).json();
assert.equal(body.assets.length, 3, 'admin should see every asset');
await a.close();
// Scoped viewer (granted only Alpha) sees the two Alpha assets, not Beta's.
let s = await appWithAssets(scoped);
body = await (await fetch(s.baseUrl + '/api/v1/assets')).json();
assert.equal(body.assets.length, 2, 'scoped user should see only granted-project assets');
assert.ok(body.assets.every(x => x.display_name !== 'b1'), 'must not leak ungranted-project asset');
await s.close();
} finally { await pool.end(); }
});
test('GET /assets returns nothing for a user with no grants', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
await seed(pool);
const nobody = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('no','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
const s = await appWithAssets(nobody);
const body = await (await fetch(s.baseUrl + '/api/v1/assets')).json();
assert.deepEqual(body, { assets: [], total: 0 });
await s.close();
} finally { await pool.end(); }
});

View file

@ -1,76 +0,0 @@
// RBAC coverage for asset comments: the guard resolves the project via the
// asset, requiring view to read and edit to write. Also verifies the author id
// is recorded from req.user (regression for the old req.session.userId bug).
// Skips without TEST_DATABASE_URL.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
async function appWithComments(user) {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
process.env.AUTH_ENABLED = 'true';
const { default: commentsRouter } = await import('../../src/routes/comments.js?v=' + Date.now());
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.user = user; next(); });
// Mirror index.js mount so :assetId flows through (mergeParams in the router).
app.use('/api/v1/assets/:assetId/comments', commentsRouter);
return new Promise(r => {
const srv = app.listen(0, '127.0.0.1', () =>
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
});
}
async function seed(pool) {
const proj = async (n) => (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ($1,$1) RETURNING id`, [n])).rows[0].id;
const alpha = await proj('Alpha');
const beta = await proj('Beta');
const asset = async (pid, name) => (await pool.query(
`INSERT INTO assets (project_id, filename, display_name, media_type, status) VALUES ($1,$2,$2,'video','ready') RETURNING id`, [pid, name])).rows[0].id;
const assetA = await asset(alpha, 'a1');
const assetB = await asset(beta, 'b1');
const viewer = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('vu','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
const editor = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('ed','x','editor') RETURNING id`)).rows[0].id, role: 'editor' };
await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'view')`, [alpha, viewer.id]);
await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'edit')`, [alpha, editor.id]);
return { assetA, assetB, viewer, editor };
}
test('comments require view to read and block ungranted assets', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { assetA, assetB, viewer } = await seed(pool);
const s = await appWithComments(viewer);
assert.equal((await fetch(s.baseUrl + '/api/v1/assets/' + assetA + '/comments')).status, 200);
assert.equal((await fetch(s.baseUrl + '/api/v1/assets/' + assetB + '/comments')).status, 403);
await s.close();
} finally { await pool.end(); }
});
test('posting a comment requires edit and records the author id from req.user', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { assetA, viewer, editor } = await seed(pool);
// viewer (view-only) cannot post.
let s = await appWithComments(viewer);
let r = await fetch(s.baseUrl + '/api/v1/assets/' + assetA + '/comments', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: 'hi' }) });
assert.equal(r.status, 403);
await s.close();
// editor can post, and the author id is the editor (not null).
let e = await appWithComments(editor);
r = await fetch(e.baseUrl + '/api/v1/assets/' + assetA + '/comments', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: 'looks good' }) });
assert.equal(r.status, 201);
const created = await r.json();
assert.equal(created.user_id, editor.id, 'comment author must be req.user.id');
await e.close();
} finally { await pool.end(); }
});

View file

@ -1,63 +0,0 @@
// Security regression test for resolveGoogleUser: a Google sign-in must NEVER
// adopt a pre-existing local account by matching email (that would be account
// takeover). It links only by google_sub, otherwise provisions a fresh viewer.
// Skips without TEST_DATABASE_URL.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
import { hashPassword } from '../../src/auth/passwords.js';
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
async function loadResolve() {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
return (await import('../../src/routes/auth.js?v=' + Date.now())).resolveGoogleUser;
}
test('a Google login with an email matching a local admin does NOT take over that account', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
// Pre-existing local admin with a password and the same email the attacker controls.
const adminId = (await pool.query(
`INSERT INTO users (username, password_hash, role, email)
VALUES ('boss', $1, 'admin', 'boss@wilddragon.net') RETURNING id`,
[await hashPassword('a-real-password-12')])).rows[0].id;
const resolveGoogleUser = await loadResolve();
const user = await resolveGoogleUser({ sub: 'google-attacker-sub', email: 'boss@wilddragon.net', name: 'Not The Boss' });
// Must be a brand-new account, NOT the admin.
assert.notEqual(user.id, adminId, 'Google login must not resolve to the existing admin');
const row = (await pool.query(`SELECT role, google_sub FROM users WHERE id = $1`, [user.id])).rows[0];
assert.equal(row.role, 'viewer', 'auto-provisioned account must be a viewer');
assert.equal(row.google_sub, 'google-attacker-sub');
// The admin row is untouched (no google_sub linked onto it).
const admin = (await pool.query(`SELECT google_sub FROM users WHERE id = $1`, [adminId])).rows[0];
assert.equal(admin.google_sub, null, 'the existing admin must not have been linked');
} finally { await pool.end(); }
});
test('a returning Google user resolves to the same account by google_sub', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const resolveGoogleUser = await loadResolve();
const first = await resolveGoogleUser({ sub: 'sub-123', email: 'alice@wilddragon.net', name: 'Alice' });
const second = await resolveGoogleUser({ sub: 'sub-123', email: 'alice@wilddragon.net', name: 'Alice' });
assert.equal(first.id, second.id, 'same google_sub must map to the same user');
const count = (await pool.query(`SELECT COUNT(*)::int AS n FROM users WHERE google_sub = 'sub-123'`)).rows[0].n;
assert.equal(count, 1, 'must not create a duplicate on second login');
} finally { await pool.end(); }
});
test('username collisions get a numeric suffix', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('sam', 'x', 'viewer')`);
const resolveGoogleUser = await loadResolve();
const u = await resolveGoogleUser({ sub: 'sub-sam', email: 'sam@wilddragon.net', name: 'Sam' });
assert.match(u.username, /^sam\d+$/, 'colliding username should get a numeric suffix');
} finally { await pool.end(); }
});

View file

@ -1,125 +0,0 @@
// Integration test for per-project RBAC: the grant-management API on the
// projects router + scoped enforcement on GET /projects and GET /projects/:id.
//
// NOTE: the routers use the singleton pool (src/db/pool.js), which reads
// DATABASE_URL. We point DATABASE_URL at the throwaway TEST_DATABASE_URL and
// seed through that same singleton pool so the router and the test share one
// database. Skips cleanly when TEST_DATABASE_URL is unset.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
// Build an app that injects a chosen user as req.user (simulating requireAuth),
// then mounts the real projects router with the same admin gate index.js uses.
async function appWithProjects(user) {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
process.env.AUTH_ENABLED = 'true';
const { default: projectsRouter } = await import('../../src/routes/projects.js?v=' + Date.now());
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.user = user; next(); });
app.use('/api/v1/projects', projectsRouter);
return new Promise(r => {
const srv = app.listen(0, '127.0.0.1', () =>
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
});
}
async function seedBaseline(pool) {
const alpha = (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ('Alpha','alpha') RETURNING id`)).rows[0].id;
const beta = (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ('Beta','beta') RETURNING id`)).rows[0].id;
const admin = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('adm','x','admin') RETURNING id`)).rows[0].id, role: 'admin' };
const scoped = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('vu','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
return { alpha, beta, admin, scoped };
}
test('admin grants a project, scoped user then sees only it; revoke removes access', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { alpha, beta, admin, scoped } = await seedBaseline(pool);
// Scoped viewer initially sees nothing.
let s = await appWithProjects(scoped);
let list = await (await fetch(s.baseUrl + '/api/v1/projects')).json();
assert.equal(list.length, 0, 'scoped user should see no projects before any grant');
// And cannot read Alpha directly.
let direct = await fetch(s.baseUrl + '/api/v1/projects/' + alpha);
assert.equal(direct.status, 403);
await s.close();
// Admin grants the scoped user 'view' on Alpha.
const a = await appWithProjects(admin);
const grant = await fetch(a.baseUrl + '/api/v1/projects/' + alpha + '/access', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subject_type: 'user', subject_id: scoped.id, level: 'view' }),
});
assert.equal(grant.status, 201);
const grantList = await (await fetch(a.baseUrl + '/api/v1/projects/' + alpha + '/access')).json();
assert.equal(grantList.length, 1);
assert.equal(grantList[0].subject_id, scoped.id);
await a.close();
// Scoped viewer now sees exactly Alpha (not Beta), can GET it, cannot PATCH (view-only).
s = await appWithProjects(scoped);
list = await (await fetch(s.baseUrl + '/api/v1/projects')).json();
assert.deepEqual(list.map(p => p.id), [alpha]);
assert.equal((await fetch(s.baseUrl + '/api/v1/projects/' + alpha)).status, 200);
assert.equal((await fetch(s.baseUrl + '/api/v1/projects/' + beta)).status, 403);
const patch = await fetch(s.baseUrl + '/api/v1/projects/' + alpha, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: 'hacked' }),
});
assert.equal(patch.status, 403, 'view-level grant must not allow edit');
await s.close();
// Admin revokes; scoped viewer goes back to seeing nothing.
const a2 = await appWithProjects(admin);
const del = await fetch(a2.baseUrl + '/api/v1/projects/' + alpha + '/access/user/' + scoped.id, { method: 'DELETE' });
assert.equal(del.status, 204);
await a2.close();
s = await appWithProjects(scoped);
list = await (await fetch(s.baseUrl + '/api/v1/projects')).json();
assert.equal(list.length, 0);
await s.close();
} finally { await pool.end(); }
});
test('non-admin cannot reach the grant-management API', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { alpha, scoped } = await seedBaseline(pool);
const s = await appWithProjects(scoped);
// requireAdmin sits on the access sub-routes; a viewer is 403.
const r = await fetch(s.baseUrl + '/api/v1/projects/' + alpha + '/access');
assert.equal(r.status, 403);
await s.close();
} finally { await pool.end(); }
});
test('edit-level grant allows PATCH', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { alpha, admin, scoped } = await seedBaseline(pool);
const a = await appWithProjects(admin);
await fetch(a.baseUrl + '/api/v1/projects/' + alpha + '/access', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subject_type: 'user', subject_id: scoped.id, level: 'edit' }),
});
await a.close();
const s = await appWithProjects(scoped);
const patch = await fetch(s.baseUrl + '/api/v1/projects/' + alpha, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: 'updated by editor' }),
});
assert.equal(patch.status, 200);
await s.close();
} finally { await pool.end(); }
});

View file

@ -1,107 +0,0 @@
// RBAC coverage for recorders: list is scoped to accessible projects, /:id
// asserts view, mutators assert edit, null-project recorders are admin-only.
// Same harness as assets-access.test.js — singleton pool on TEST_DATABASE_URL,
// req.user injected, router dynamic-imported. Skips without TEST_DATABASE_URL.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
async function appWithRecorders(user) {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
process.env.AUTH_ENABLED = 'true';
const { default: recordersRouter } = await import('../../src/routes/recorders.js?v=' + Date.now());
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.user = user; next(); });
app.use('/api/v1/recorders', recordersRouter);
return new Promise(r => {
const srv = app.listen(0, '127.0.0.1', () =>
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
});
}
async function seed(pool) {
const proj = async (n) => (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ($1,$1) RETURNING id`, [n])).rows[0].id;
const alpha = await proj('Alpha');
const beta = await proj('Beta');
const rec = async (pid, name) => (await pool.query(
`INSERT INTO recorders (name, source_type, project_id) VALUES ($1, 'srt', $2) RETURNING id`, [name, pid])).rows[0].id;
const recA = await rec(alpha, 'Cam A');
const recB = await rec(beta, 'Cam B');
const recNull = (await pool.query(
`INSERT INTO recorders (name, source_type, project_id) VALUES ('Unassigned','srt',NULL) RETURNING id`)).rows[0].id;
const admin = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('adm','x','admin') RETURNING id`)).rows[0].id, role: 'admin' };
const scoped = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('ed','x','editor') RETURNING id`)).rows[0].id, role: 'editor' };
await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'view')`, [alpha, scoped.id]);
return { alpha, beta, recA, recB, recNull, admin, scoped };
}
test('recorders list is scoped; admin sees all incl. null-project, scoped sees only granted', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { recA, admin, scoped } = await seed(pool);
let a = await appWithRecorders(admin);
let list = await (await fetch(a.baseUrl + '/api/v1/recorders')).json();
assert.equal(list.length, 3, 'admin sees all three recorders');
await a.close();
let s = await appWithRecorders(scoped);
list = await (await fetch(s.baseUrl + '/api/v1/recorders')).json();
assert.deepEqual(list.map(r => r.id), [recA], 'scoped editor sees only the granted-project recorder');
await s.close();
} finally { await pool.end(); }
});
test('recorder /:id enforces view; mutators enforce edit', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { recA, recB, recNull, scoped } = await seed(pool);
const s = await appWithRecorders(scoped);
// view-granted on Alpha: can GET recA, cannot GET recB (other project) or recNull (admin-only).
assert.equal((await fetch(s.baseUrl + '/api/v1/recorders/' + recA)).status, 200);
assert.equal((await fetch(s.baseUrl + '/api/v1/recorders/' + recB)).status, 403);
assert.equal((await fetch(s.baseUrl + '/api/v1/recorders/' + recNull)).status, 403);
// view-only grant cannot PATCH (edit) or start.
const patch = await fetch(s.baseUrl + '/api/v1/recorders/' + recA, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'x' }) });
assert.equal(patch.status, 403, 'view-level grant must not allow edit');
await s.close();
} finally { await pool.end(); }
});
test('creating a recorder requires edit; null-project create is admin-only', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { alpha, admin, scoped } = await seed(pool);
// scoped editor has only 'view' on Alpha → create denied.
let s = await appWithRecorders(scoped);
let r = await fetch(s.baseUrl + '/api/v1/recorders', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'New', source_type: 'srt', project_id: alpha }) });
assert.equal(r.status, 403);
// null project → admin-only, still denied for the editor.
r = await fetch(s.baseUrl + '/api/v1/recorders', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'New2', source_type: 'srt' }) });
assert.equal(r.status, 403);
await s.close();
// admin can create in any project and with no project.
let a = await appWithRecorders(admin);
r = await fetch(a.baseUrl + '/api/v1/recorders', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'AdminRec', source_type: 'srt' }) });
assert.equal(r.status, 201);
await a.close();
} finally { await pool.end(); }
});

View file

@ -1,103 +0,0 @@
// RBAC coverage for sequences: list/create assert on the query/body project,
// /:id asserts view, mutators assert edit. Skips without TEST_DATABASE_URL.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
async function appWithSequences(user) {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
process.env.AUTH_ENABLED = 'true';
const { default: sequencesRouter } = await import('../../src/routes/sequences.js?v=' + Date.now());
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.user = user; next(); });
app.use('/api/v1/sequences', sequencesRouter);
return new Promise(r => {
const srv = app.listen(0, '127.0.0.1', () =>
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
});
}
async function seed(pool) {
const proj = async (n) => (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ($1,$1) RETURNING id`, [n])).rows[0].id;
const alpha = await proj('Alpha');
const beta = await proj('Beta');
const seqB = (await pool.query(`INSERT INTO sequences (project_id, name) VALUES ($1,'B seq') RETURNING id`, [beta])).rows[0].id;
const viewer = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('vu','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
const editor = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('ed','x','editor') RETURNING id`)).rows[0].id, role: 'editor' };
await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'view')`, [alpha, viewer.id]);
await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'edit')`, [alpha, editor.id]);
return { alpha, beta, seqB, viewer, editor };
}
const asset = (pool, pid, name) => pool.query(
`INSERT INTO assets (project_id, filename, display_name, media_type, status) VALUES ($1,$2,$2,'video','ready') RETURNING id`,
[pid, name]).then(r => r.rows[0].id);
test('GET /sequences?project_id 403s on an ungranted project', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { alpha, beta, viewer } = await seed(pool);
const s = await appWithSequences(viewer);
assert.equal((await fetch(s.baseUrl + '/api/v1/sequences?project_id=' + alpha)).status, 200);
assert.equal((await fetch(s.baseUrl + '/api/v1/sequences?project_id=' + beta)).status, 403);
await s.close();
} finally { await pool.end(); }
});
test('POST /sequences requires edit; viewer denied, editor allowed; /:id view enforced', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { alpha, seqB, viewer, editor } = await seed(pool);
// viewer (view-only on Alpha) cannot create.
let s = await appWithSequences(viewer);
let r = await fetch(s.baseUrl + '/api/v1/sequences', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: alpha, name: 'X' }) });
assert.equal(r.status, 403);
// viewer cannot read a sequence in an ungranted project (Beta).
assert.equal((await fetch(s.baseUrl + '/api/v1/sequences/' + seqB)).status, 403);
await s.close();
// editor can create in Alpha and then PUT it.
let e = await appWithSequences(editor);
r = await fetch(e.baseUrl + '/api/v1/sequences', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: alpha, name: 'Mine' }) });
assert.equal(r.status, 201);
const seqId = (await r.json()).id;
const put = await fetch(e.baseUrl + '/api/v1/sequences/' + seqId, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Renamed' }) });
assert.equal(put.status, 200);
await e.close();
} finally { await pool.end(); }
});
test('PUT /:id/clips rejects assets from outside the sequence project (cross-project leak guard)', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
const { alpha, beta, editor } = await seed(pool);
const seqA = (await pool.query(`INSERT INTO sequences (project_id, name) VALUES ($1,'A seq') RETURNING id`, [alpha])).rows[0].id;
const assetA = await asset(pool, alpha, 'a-clip');
const assetB = await asset(pool, beta, 'b-clip'); // editor has NO access to project Beta
const e = await appWithSequences(editor);
const clip = (aid) => ({ asset_id: aid, track: 0, timeline_in_frames: 0, timeline_out_frames: 10, source_in_frames: 0, source_out_frames: 10 });
// Same-project asset is accepted.
let r = await fetch(e.baseUrl + '/api/v1/sequences/' + seqA + '/clips', {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([clip(assetA)]) });
assert.equal(r.status, 200);
// Splicing in a Beta asset must be rejected — it would leak B's media via GET /:id.
r = await fetch(e.baseUrl + '/api/v1/sequences/' + seqA + '/clips', {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([clip(assetA), clip(assetB)]) });
assert.equal(r.status, 400, 'foreign-project asset must be rejected');
await e.close();
} finally { await pool.end(); }
});

View file

@ -1,143 +0,0 @@
// Integration test for the TOTP two-step login + recovery codes.
//
// Mounts the real auth router with a session store on the throwaway test DB.
// Drives: enroll (setup → enable) → logout → password login returns mfa_required
// → complete with a generated code → and the recovery-code single-use path.
// Skips when TEST_DATABASE_URL is unset.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import session from 'express-session';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
import { hashPassword } from '../../src/auth/passwords.js';
import { generateToken } from '../../src/auth/totp.js';
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
async function appWithAuth(pool) {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
process.env.AUTH_ENABLED = 'true';
process.env.SESSION_SECRET = 'test';
const ConnectPg = (await import('connect-pg-simple')).default(session);
const { default: authRouter } = await import('../../src/routes/auth.js?v=' + Date.now());
const app = express();
app.use(express.json());
app.use(session({
store: new ConnectPg({ pool, tableName: 'sessions' }),
secret: 'test', name: 'dragonflight.sid',
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
rolling: false, resave: false, saveUninitialized: false,
}));
app.use('/api/v1/auth', authRouter);
return new Promise(r => {
const srv = app.listen(0, '127.0.0.1', () =>
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
});
}
const J = (cookie, body) => ({
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(cookie ? { cookie } : {}) },
body: JSON.stringify(body),
});
async function loginPassword(baseUrl, username, password) {
const r = await fetch(baseUrl + '/api/v1/auth/login', J(null, { username, password }));
const cookie = (r.headers.get('set-cookie') || '').split(';')[0];
return { r, body: await r.json().catch(() => ({})), cookie };
}
test('enable TOTP, then password login requires a second factor', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [await hashPassword('correct-horse-battery')]);
const { baseUrl, close } = await appWithAuth(pool);
// 1. Password login (no TOTP yet) → 200 with a session cookie.
const first = await loginPassword(baseUrl, 'alice', 'correct-horse-battery');
assert.equal(first.r.status, 200);
assert.ok(!first.body.mfa_required);
const cookie = first.cookie;
// 2. Enroll: setup returns a secret; enable confirms with a live code.
const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(cookie, {}))).json();
assert.match(setup.secret, /^[A-Z2-7]+$/);
const enableRes = await fetch(baseUrl + '/api/v1/auth/totp/enable', J(cookie, { code: generateToken(setup.secret) }));
assert.equal(enableRes.status, 200);
const enableBody = await enableRes.json();
assert.equal(enableBody.enabled, true);
assert.equal(enableBody.recovery_codes.length, 10);
// 3. Fresh password login now returns mfa_required + a ticket, NO session cookie.
const second = await loginPassword(baseUrl, 'alice', 'correct-horse-battery');
assert.equal(second.r.status, 200);
assert.equal(second.body.mfa_required, true);
assert.ok(second.body.ticket);
assert.ok(!second.cookie, 'no session cookie should be set before the second factor');
// 4. Wrong code → 401; the ticket is now spent.
const bad = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: second.body.ticket, code: '000000' }));
assert.equal(bad.status, 401);
// 5. New login + correct code → 200 with a session cookie.
const third = await loginPassword(baseUrl, 'alice', 'correct-horse-battery');
const ok = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: third.body.ticket, code: generateToken(setup.secret) }));
assert.equal(ok.status, 200);
assert.match(ok.headers.get('set-cookie') || '', /dragonflight\.sid=/);
await close();
} finally { await pool.end(); }
});
test('a recovery code logs in once and cannot be reused', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('bob', $1)`, [await hashPassword('correct-horse-battery')]);
const { baseUrl, close } = await appWithAuth(pool);
const first = await loginPassword(baseUrl, 'bob', 'correct-horse-battery');
const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(first.cookie, {}))).json();
const enableBody = await (await fetch(baseUrl + '/api/v1/auth/totp/enable', J(first.cookie, { code: generateToken(setup.secret) }))).json();
const recovery = enableBody.recovery_codes[0];
// Use a recovery code to complete a fresh login.
const login1 = await loginPassword(baseUrl, 'bob', 'correct-horse-battery');
const use1 = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: login1.body.ticket, code: recovery }));
assert.equal(use1.status, 200, 'recovery code should complete login once');
// The same recovery code must NOT work a second time.
const login2 = await loginPassword(baseUrl, 'bob', 'correct-horse-battery');
const use2 = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: login2.body.ticket, code: recovery }));
assert.equal(use2.status, 401, 'a spent recovery code must be rejected');
await close();
} finally { await pool.end(); }
});
test('disable TOTP returns login to single-factor', { skip: SKIP }, async () => {
const pool = await setupTestDb();
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
try {
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('carol', $1)`, [await hashPassword('correct-horse-battery')]);
const { baseUrl, close } = await appWithAuth(pool);
const first = await loginPassword(baseUrl, 'carol', 'correct-horse-battery');
const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(first.cookie, {}))).json();
await fetch(baseUrl + '/api/v1/auth/totp/enable', J(first.cookie, { code: generateToken(setup.secret) }));
// Disabling requires the password.
const wrongPw = await fetch(baseUrl + '/api/v1/auth/totp/disable', J(first.cookie, { password: 'nope' }));
assert.equal(wrongPw.status, 400);
const disabled = await fetch(baseUrl + '/api/v1/auth/totp/disable', J(first.cookie, { password: 'correct-horse-battery' }));
assert.equal(disabled.status, 204);
// Password login is single-factor again.
const relog = await loginPassword(baseUrl, 'carol', 'correct-horse-battery');
assert.equal(relog.r.status, 200);
assert.ok(!relog.body.mfa_required);
await close();
} finally { await pool.end(); }
});

View file

@ -97,18 +97,11 @@ function App() {
);
}
// Admin-only destinations. Non-admins who reach one (deep link, keyboard
// router, stale tab) get bounced home instead of a broken/forbidden page.
// The API enforces the same rules this is just UX.
const ADMIN_ROUTES = new Set(['users', 'containers', 'cluster', 'settings']);
const isAdmin = window.ZAMPP_DATA?.ME?.role === 'admin';
const effectiveRoute = (ADMIN_ROUTES.has(route) && !isAdmin) ? 'home' : route;
let content;
if (openAsset) {
content = <AssetDetail asset={openAsset} onClose={() => setOpenAsset(null)} />;
} else {
switch (effectiveRoute) {
switch (route) {
case 'home': content = <Home navigate={navigate} />; break;
case 'dashboard': content = <Dashboard navigate={navigate} />; break;
case 'library': content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} onClearProject={() => setOpenProject(null)} />; break;

View file

@ -258,26 +258,12 @@ function Users() {
{tab === 'groups' && <GroupsPanel groups={groups} users={users} onChange={refreshGroups} />}
{tab === 'policies' && (
<div className="panel" style={{ padding: '32px 24px', color: 'var(--text-2)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Icon name="lock" size={16} />
<div style={{ fontWeight: 600, fontSize: 14 }}>Access model</div>
</div>
<div style={{ fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.7, maxWidth: 640 }}>
<div style={{ marginBottom: 8 }}>
<strong style={{ color: 'var(--text-2)' }}>admin</strong> full access to every
project plus user, group, cluster, and system administration.
</div>
<div style={{ marginBottom: 8 }}>
<strong style={{ color: 'var(--text-2)' }}>editor / viewer</strong> see only the
projects they've been granted. A <em>view</em> grant is read-only; an
<em> edit</em> grant allows changes. Grants can target an individual user or a group.
</div>
<div>
Manage a project's grants from the <strong style={{ color: 'var(--text-2)' }}>Projects</strong> page
a project's <em>Manage access</em> menu. Group membership is managed on the
Groups tab above.
</div>
<div className="panel" style={{ padding: '48px 24px', textAlign: 'center', color: 'var(--text-3)' }}>
<Icon name="lock" size={24} />
<div style={{ marginTop: 10, fontWeight: 500, fontSize: 14, color: 'var(--text-2)' }}>Access policies</div>
<div style={{ fontSize: 12, marginTop: 4 }}>
Per-project and per-bin permissions are coming soon. For now, role-based access<br />
(admin / editor / viewer) is enforced API-wide.
</div>
</div>
)}
@ -1488,135 +1474,6 @@ function AccountSection() {
);
}
// Two-factor (TOTP) enrollment + management. Reflects window.ZAMPP_DATA.ME.totp_enabled.
function TotpSection() {
const me = window.ZAMPP_DATA?.ME || {};
const [enabled, setEnabled] = React.useState(!!me.totp_enabled);
const [phase, setPhase] = React.useState('idle'); // idle | enrolling | recovery
const [enroll, setEnroll] = React.useState(null); // { secret, otpauth_uri, qr }
const [code, setCode] = React.useState('');
const [recovery, setRecovery] = React.useState(null); // string[]
const [disablePw, setDisablePw] = React.useState('');
const [showDisable, setShowDisable] = React.useState(false);
const [msg, setMsg] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const api = (path, body) => fetch('/api/v1/auth' + path, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
body: JSON.stringify(body || {}),
});
const startSetup = async () => {
setMsg(null); setBusy(true);
try {
const r = await api('/totp/setup');
if (r.status === 200) { setEnroll(await r.json()); setPhase('enrolling'); }
else setMsg({ kind: 'err', text: (await r.json().catch(() => ({}))).error || 'Setup failed' });
} finally { setBusy(false); }
};
const confirmEnable = async () => {
setMsg(null); setBusy(true);
try {
const r = await api('/totp/enable', { code: code.trim() });
const body = await r.json().catch(() => ({}));
if (r.status === 200) {
setRecovery(body.recovery_codes || []); setPhase('recovery');
setEnabled(true); setCode('');
window.ZAMPP_DATA.ME = { ...(window.ZAMPP_DATA.ME || {}), totp_enabled: true };
} else setMsg({ kind: 'err', text: body.error || 'Could not enable' });
} finally { setBusy(false); }
};
const disable = async () => {
setMsg(null); setBusy(true);
try {
const r = await api('/totp/disable', { password: disablePw });
if (r.status === 204) {
setEnabled(false); setShowDisable(false); setDisablePw(''); setPhase('idle');
window.ZAMPP_DATA.ME = { ...(window.ZAMPP_DATA.ME || {}), totp_enabled: false };
setMsg({ kind: 'ok', text: 'Two-factor disabled' });
} else setMsg({ kind: 'err', text: (await r.json().catch(() => ({}))).error || 'Could not disable' });
} finally { setBusy(false); }
};
return (
<section className="panel" style={{ padding: 16, marginBottom: 16 }}>
<h3 style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 12 }}>Two-factor authentication</h3>
{/* Status line */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: phase === 'idle' ? 0 : 14 }}>
<span className={`badge ${enabled ? 'success' : 'neutral'}`}>{enabled ? 'Enabled' : 'Disabled'}</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{enabled ? 'An authenticator code is required at sign-in.' : 'Add a time-based code from an authenticator app.'}
</span>
<span style={{ flex: 1 }} />
{!enabled && phase === 'idle' && <button className="btn primary sm" disabled={busy} onClick={startSetup}>Set up</button>}
{enabled && phase !== 'recovery' && !showDisable && <button className="btn ghost sm danger" onClick={() => setShowDisable(true)}>Disable</button>}
</div>
{/* Enrolling: show QR / secret + code field */}
{phase === 'enrolling' && enroll && (
<div style={{ display: 'grid', gridTemplateColumns: '160px 1fr', gap: 16, alignItems: 'start' }}>
<div>
{enroll.qr
? <img src={enroll.qr} alt="TOTP QR code" style={{ width: 160, height: 160, borderRadius: 6, background: '#fff', padding: 6 }} />
: <div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>Scan the secret below in your app.</div>}
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>
Scan the QR with Google Authenticator, Authy, or 1Password or enter this secret manually:
</div>
<div className="mono" style={{ fontSize: 12, background: 'var(--bg-2)', padding: '6px 10px', borderRadius: 5, wordBreak: 'break-all', marginBottom: 12 }}>{enroll.secret}</div>
<div className="field">
<label className="field-label">Enter the 6-digit code to confirm</label>
<input className="field-input mono" value={code} autoFocus
onChange={e => setCode(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && code.trim()) confirmEnable(); }}
placeholder="123456" style={{ width: 140 }} />
</div>
<div style={{ marginTop: 10 }}>
<button className="btn primary sm" disabled={busy || !code.trim()} onClick={confirmEnable}>Enable</button>
<button className="btn ghost sm" style={{ marginLeft: 8 }} onClick={() => { setPhase('idle'); setEnroll(null); setCode(''); }}>Cancel</button>
</div>
</div>
</div>
)}
{/* Recovery codes — shown exactly once */}
{phase === 'recovery' && recovery && (
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>
Save these recovery codes somewhere safe. Each works once if you lose your authenticator. They won't be shown again.
</div>
<div className="mono" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, background: 'var(--bg-2)', padding: 12, borderRadius: 6, fontSize: 13, marginBottom: 10 }}>
{recovery.map(c => <div key={c}>{c}</div>)}
</div>
<button className="btn sm" onClick={() => navigator.clipboard && navigator.clipboard.writeText(recovery.join('\n'))}>Copy codes</button>
<button className="btn primary sm" style={{ marginLeft: 8 }} onClick={() => { setPhase('idle'); setRecovery(null); }}>Done</button>
</div>
)}
{/* Disable confirmation */}
{showDisable && (
<div style={{ marginTop: 12, display: 'grid', gridTemplateColumns: '160px 1fr auto auto', gap: 8, alignItems: 'end' }}>
<div className="field" style={{ marginBottom: 0, gridColumn: '1 / 3' }}>
<label className="field-label">Confirm your password to disable</label>
<input className="field-input" type="password" value={disablePw} autoComplete="current-password"
onChange={e => setDisablePw(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && disablePw) disable(); }} />
</div>
<button className="btn danger sm" disabled={busy || !disablePw} onClick={disable}>Disable 2FA</button>
<button className="btn ghost sm" onClick={() => { setShowDisable(false); setDisablePw(''); }}>Cancel</button>
</div>
)}
{msg && <div style={{ marginTop: 10, fontSize: 11.5, color: msg.kind === 'ok' ? 'var(--success)' : 'var(--danger)' }}>{msg.text}</div>}
</section>
);
}
function ApiTokensSection() {
const [tokens, setTokens] = React.useState([]);
const [name, setName] = React.useState('');
@ -1726,7 +1583,6 @@ function Settings() {
{section === 'account' && (
<>
<AccountSection />
<TotpSection />
<ApiTokensSection />
</>
)}

View file

@ -116,131 +116,27 @@
);
}
// Google sign-in availability + a friendly message for the callback's
// ?auth_error redirect (domain-not-allowed / generic google failure).
function useGoogleAndAuthError(setError) {
const [googleEnabled, setGoogleEnabled] = React.useState(false);
React.useEffect(() => {
fetch(API_BASE + '/auth/google/enabled', { credentials: 'include' })
.then(r => r.json()).then(d => setGoogleEnabled(!!d.enabled)).catch(() => {});
const params = new URLSearchParams(location.search);
const e = params.get('auth_error');
if (e === 'domain') setError('That Google account is not in an allowed domain.');
else if (e === 'google') setError('Google sign-in failed. Please try again.');
if (e) {
// Clean the query string so a reload doesn't re-show the error.
const url = location.pathname + location.hash;
history.replaceState(null, '', url);
}
}, [setError]);
return googleEnabled;
}
function GoogleButton() {
return (
<a href={API_BASE + '/auth/google'} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
width: '100%', boxSizing: 'border-box', textDecoration: 'none',
background: 'var(--bg-3)', color: 'var(--text-1)',
border: '1px solid var(--border)', borderRadius: 4,
padding: '9px', fontSize: 13, fontWeight: 600, marginTop: 10,
}}>
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 700 }}>G</span>
Sign in with Google
</a>
);
}
function Divider() {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '14px 0 4px' }}>
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
<span style={{ fontSize: 10, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>or</span>
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
</div>
);
}
function LoginScreen({ onDone }) {
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
const [error, setError] = React.useState('');
const [busy, setBusy] = React.useState(false);
// Second factor: when the server returns { mfa_required, ticket }, we switch
// to the code step instead of completing login. `ticket` may be a real value
// (password path) or the sentinel 'session' (Google path, where the ticket
// lives in the session cookie and is not exposed to JS).
const [ticket, setTicket] = React.useState(null);
const [code, setCode] = React.useState('');
const googleEnabled = useGoogleAndAuthError(setError);
// Google OAuth with TOTP redirects back to /?mfa=1; the ticket is in the
// session, so enter the code step without a body ticket.
React.useEffect(() => {
const params = new URLSearchParams(location.search);
if (params.get('mfa') === '1') {
setTicket('session');
history.replaceState(null, '', location.pathname + location.hash);
}
}, []);
const submit = async () => {
setError(''); setBusy(true);
try {
const r = await postJson('/auth/login', { username, password });
if (r.status === 200) {
const body = await r.json().catch(() => ({}));
if (body.mfa_required) { setTicket(body.ticket); setBusy(false); return; }
onDone(); return;
}
if (r.status === 200) { onDone(); return; }
const body = await r.json().catch(() => ({}));
setError(body.error || ('Login failed: ' + r.status));
} catch (e) { setError(e.message || 'Login failed'); }
finally { setBusy(false); }
};
const submitCode = async () => {
setError(''); setBusy(true);
try {
// For the Google path the ticket is the session sentinel send code only.
const payload = ticket === 'session' ? { code: code.trim() } : { ticket, code: code.trim() };
const r = await postJson('/auth/login/totp', payload);
if (r.status === 200) { onDone(); return; }
const body = await r.json().catch(() => ({}));
// An expired/used ticket means the user must start over.
if (r.status === 401 && /ticket/.test(body.error || '')) {
setTicket(null); setCode(''); setPassword('');
setError('Session timed out — please sign in again.');
} else {
setError(body.error || ('Verification failed: ' + r.status));
}
} catch (e) { setError(e.message || 'Verification failed'); }
finally { setBusy(false); }
};
if (ticket) {
return (
<Screen>
<div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 14 }}>
Two-factor authentication
</div>
<ErrorRow text={error} />
<Field label="Authenticator code" value={code} onChange={setCode} autoComplete="one-time-code" autoFocus />
<div style={{ fontSize: 10.5, color: 'var(--text-3)', marginBottom: 12 }}>
Enter the 6-digit code from your authenticator app, or a recovery code.
</div>
<Button type="submit" disabled={busy || !code.trim()} onClick={submitCode}>Verify</Button>
</Screen>
);
}
return (
<Screen>
<ErrorRow text={error} />
<Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus />
<Field label="Password" type="password" value={password} onChange={setPassword} autoComplete="current-password" />
<Button type="submit" disabled={busy || !username || !password} onClick={submit}>Sign in</Button>
{googleEnabled && <><Divider /><GoogleButton /></>}
</Screen>
);
}

View file

@ -49,9 +49,6 @@ function Projects({ onOpenProject, navigate }) {
const [showNew, setShowNew] = React.useState(false);
const [menuFor, setMenuFor] = React.useState(null);
const [renamingProject, setRenamingProject] = React.useState(null);
const [accessProject, setAccessProject] = React.useState(null);
const isAdmin = window.ZAMPP_DATA?.ME?.role === 'admin';
const manageAccess = (p) => { setMenuFor(null); setAccessProject(p); };
const refresh = React.useCallback(() => {
window.ZAMPP_API.fetch('/projects')
@ -125,10 +122,8 @@ function Projects({ onOpenProject, navigate }) {
key={p.id}
project={p}
assets={ASSETS}
canManageAccess={isAdmin}
onOpen={() => onOpenProject(p)}
onRename={() => renameProject(p)}
onManageAccess={() => manageAccess(p)}
onDelete={() => deleteProject(p)}
/>
))}
@ -153,7 +148,6 @@ function Projects({ onOpenProject, navigate }) {
<div className="row-menu" onClick={e => e.stopPropagation()}>
<button onClick={() => { setMenuFor(null); onOpenProject(p); }}><Icon name="library" size={11} />Open</button>
<button onClick={() => renameProject(p)}><Icon name="edit" size={11} />Rename</button>
{isAdmin && <button onClick={() => manageAccess(p)}><Icon name="users" size={11} />Manage access</button>}
<button className="danger" onClick={() => deleteProject(p)}><Icon name="trash" size={11} />Delete</button>
</div>
)}
@ -171,140 +165,6 @@ function Projects({ onOpenProject, navigate }) {
onSaved={() => { setRenamingProject(null); refresh(); }}
/>
)}
{accessProject && (
<ProjectAccessModal
project={accessProject}
onClose={() => setAccessProject(null)}
/>
)}
</div>
);
}
// Admin-only: grant/revoke per-project access to users and groups.
// Backed by GET/POST/DELETE /api/v1/projects/:id/access.
function ProjectAccessModal({ project, onClose }) {
const [grants, setGrants] = React.useState([]);
const [users, setUsers] = React.useState([]);
const [groups, setGroups] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [err, setErr] = React.useState(null);
// Add-grant form state.
const [subjType, setSubjType] = React.useState('user');
const [subjId, setSubjId] = React.useState('');
const [level, setLevel] = React.useState('view');
const loadGrants = React.useCallback(() => {
return window.ZAMPP_API.fetch('/projects/' + project.id + '/access')
.then(list => setGrants(list || []))
.catch(e => setErr(e.message));
}, [project.id]);
React.useEffect(() => {
setLoading(true);
Promise.all([
loadGrants(),
window.ZAMPP_API.fetch('/users').then(setUsers).catch(() => setUsers([])),
window.ZAMPP_API.fetch('/groups').then(setGroups).catch(() => setGroups([])),
]).finally(() => setLoading(false));
}, [loadGrants]);
const addGrant = () => {
if (!subjId) return;
setErr(null);
window.ZAMPP_API.fetch('/projects/' + project.id + '/access', {
method: 'POST',
body: JSON.stringify({ subject_type: subjType, subject_id: subjId, level }),
})
.then(() => { setSubjId(''); return loadGrants(); })
.catch(e => setErr(e.message || 'Failed to add grant'));
};
const revoke = (g) => {
window.ZAMPP_API.fetch('/projects/' + project.id + '/access/' + g.subject_type + '/' + g.subject_id, { method: 'DELETE' })
.then(loadGrants)
.catch(e => setErr(e.message || 'Failed to revoke'));
};
// Candidates for the picker exclude subjects that already have a grant.
const grantedIds = new Set(grants.filter(g => g.subject_type === subjType).map(g => g.subject_id));
const candidates = (subjType === 'user' ? users : groups).filter(c => !grantedIds.has(c.id));
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 520 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Manage access · {project.name}</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 12 }}>
Admins always have full access. Grant specific users or groups view (read-only) or
edit (read-write) access to this project.
</div>
{/* Add-grant row */}
<div style={{ display: 'grid', gridTemplateColumns: '90px 1fr 90px auto', gap: 8, alignItems: 'end', marginBottom: 14 }}>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label">Type</label>
<select className="field-input" value={subjType} style={{ appearance: 'auto' }}
onChange={e => { setSubjType(e.target.value); setSubjId(''); }}>
<option value="user">User</option>
<option value="group">Group</option>
</select>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label">{subjType === 'user' ? 'User' : 'Group'}</label>
<select className="field-input" value={subjId} style={{ appearance: 'auto' }}
onChange={e => setSubjId(e.target.value)}>
<option value="">Pick a {subjType}</option>
{candidates.map(c => (
<option key={c.id} value={c.id}>
{subjType === 'user' ? ('@' + c.username + (c.display_name ? ' · ' + c.display_name : '')) : c.name}
</option>
))}
</select>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label">Level</label>
<select className="field-input" value={level} style={{ appearance: 'auto' }}
onChange={e => setLevel(e.target.value)}>
<option value="view">View</option>
<option value="edit">Edit</option>
</select>
</div>
<button className="btn primary sm" onClick={addGrant} disabled={!subjId}>Add</button>
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginBottom: 8 }}>{err}</div>}
{/* Existing grants */}
<div className="panel">
{loading && <div style={{ padding: 16, color: 'var(--text-3)', fontSize: 12.5 }}>Loading</div>}
{!loading && grants.length === 0 && (
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>
No grants yet only admins can see this project.
</div>
)}
{!loading && grants.map(g => (
<div key={g.subject_type + ':' + g.subject_id}
style={{ display: 'grid', gridTemplateColumns: '20px 1fr 70px 80px', gap: 10, alignItems: 'center', padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
<Icon name={g.subject_type === 'group' ? 'users' : 'user'} size={13} />
<div>
<div style={{ fontSize: 13, fontWeight: 500 }}>{g.subject_name || '(deleted)'}</div>
{g.username && <div className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>@{g.username}</div>}
</div>
<span className={`badge ${g.level === 'edit' ? 'accent' : 'neutral'}`}>{g.level}</span>
<button className="btn ghost sm danger" onClick={() => revoke(g)}>Revoke</button>
</div>
))}
</div>
</div>
<div className="modal-foot">
<button className="btn primary sm" onClick={onClose}>Done</button>
</div>
</div>
</div>
);
}
@ -346,7 +206,7 @@ function RenameProjectModal({ project, onClose, onSaved }) {
);
}
function ProjectCard({ project, assets, onOpen, onRename, onManageAccess, onDelete, canManageAccess }) {
function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
const ofProject = assets.filter(a => a.project_id === project.id);
const thumbAssets = ofProject.slice(0, 4);
@ -415,7 +275,6 @@ function ProjectCard({ project, assets, onOpen, onRename, onManageAccess, onDele
<div className="row-menu" style={{ position: 'fixed', top: ctx.y, left: ctx.x, zIndex: 9999 }} onClick={e => e.stopPropagation()}>
<button onClick={() => { setCtx(null); onOpen(); }}><Icon name="library" size={11} />Open</button>
<button onClick={() => { setCtx(null); onRename && onRename(); }}><Icon name="edit" size={11} />Rename</button>
{canManageAccess && <button onClick={() => { setCtx(null); onManageAccess && onManageAccess(); }}><Icon name="users" size={11} />Manage access</button>}
<button className="danger" onClick={() => { setCtx(null); onDelete && onDelete(); }}><Icon name="trash" size={11} />Delete</button>
</div>
)}
@ -425,4 +284,3 @@ function ProjectCard({ project, assets, onOpen, onRename, onManageAccess, onDele
window.Projects = Projects;
window.RenameProjectModal = RenameProjectModal;
window.ProjectAccessModal = ProjectAccessModal;

View file

@ -126,18 +126,13 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
// (Capture live-signal badge previously lived here; it now belongs in the
// topbar status pip alongside the cluster pip. See issue #149.)
// Apply the live Jobs badge to the Operations section, and gate the Admin
// section to admins only (RBAC v2). Non-admins never see Users/Cluster/etc.
// This is UX only the API enforces the same rules server-side.
const isAdmin = me?.role === 'admin';
// Apply the live Jobs badge to the Operations section.
const sections = React.useMemo(
() => NAV_SECTIONS
.filter(sec => sec.label !== 'Admin' || isAdmin)
.map(sec => ({
...sec,
items: sec.items.map(n => (n.id === 'jobs' && jobsBadge) ? { ...n, badge: jobsBadge } : n),
})),
[jobsBadge, isAdmin]
() => NAV_SECTIONS.map(sec => ({
...sec,
items: sec.items.map(n => (n.id === 'jobs' && jobsBadge) ? { ...n, badge: jobsBadge } : n),
})),
[jobsBadge]
);
const toggleGroup = (id) => {
setOpenGroups(prev => {