openvibers/openvibe.media Commits
Developer-project tenants (Wave 20, ADR-014): app tokens get a Media tenant per project
Changes
@@@ -37,6 +37,13 @@ MEDIA_APPS_SEED=# Fallback short form (no webhooks/origins): "live:key1,games:key2"MEDIA_APP_KEYS=++# ── Developer-project tenants (ADR-014) ──────────────────────++# Created on first use by a developer app token (Network, audience openvibe.media) for its own++# project: /api/v1/prj_<ULID>/files, /api/v2/prj_<ULID>/objects. Default quotas in MB, applied++# when the tenant is created (change apps.quota_bytes afterwards to adjust one tenant).++MEDIA_APP_TENANT_QUOTA_MB=1024++MEDIA_APP_SANDBOX_QUOTA_MB=100++# ── Object storage tiering (optional; local-only when unset) ─# Backblaze B2 (canonical cold tier)MEDIA_B2_ENDPOINT=@@@ -65,9 +65,10 @@ Tenant = the `:app` path segment. Rows in the `apps` table define tenants:| `api_key_hash` | sha256 of the app API key (never stored in plaintext) || `webhook_url` / `webhook_secret` | outbound event delivery (HMAC) || `allowed_origins` | JSON array; guards browser (user-JWT) calls |--| `quota_bytes` | files-subsystem quota (0 = unlimited) |++| `quota_bytes` | storage quota: v1 files + native v2 objects (0 = unlimited) |++| `project_id` / `env` | set only on developer-project tenants (below) |--Two credential types on `/api/v1/:app/...`:++Credential types on `/api/v1/:app/...`:1. **App API key** — `Authorization: Bearer <app_api_key>`. Compared inconstant time against the app's stored hash. A key is only valid for its@@@ -79,6 +80,43 @@ Two credential types on `/api/v1/:app/...`:present. If the request carries an `Origin` header it must be in the app's`allowed_origins` (CORS is reflected for allow-listed origins only).++3. **Network principal token** (`sub svc:…`, `app:…`, `mod:…`; RS256, audience `openvibe.media`) —++ accepted only on routes that name a capability: files upload/delete and every objects v2 write++ (`media.object.upload`), files list/meta and objects v2 reads (`media.object.read`), and only for++ the `:app` namespaces in the token's `ns`.++++### Developer-project tenants (ADR-014)++++A developer app from OpenVibe.Network (token `sub app:app_<ULID>`, `project_id prj_<ULID>`,++`env sandbox|production`, `ns [project_id]`) holding `media.object.upload` / `media.object.read`++gets a tenant keyed by its project id, **created on first use**. The URL always names the project++(`/api/v1/prj_<ULID>/files`, `/api/v2/prj_<ULID>/objects`, so openvibe-sdk's++`createMediaClient({ app: projectId })` works); the token's `env` picks one of two separate tenants:++++| env | tenant (`apps.app_id`) | default quota |++|---|---|---|++| production | `prj_<ULID>` | `MEDIA_APP_TENANT_QUOTA_MB` (1024) |++| sandbox | `prj_<ULID>-sandbox` | `MEDIA_APP_SANDBOX_QUOTA_MB` (100) |++++- Both rows carry `project_id` and `env`, and have no API key (`api_key_hash` empty; the seeding++ env refuses `prj_…` ids). Only the project's own app tokens reach them: other projects' tokens,++ first-party service tokens, app keys and user JWTs get 403/404, and the `-sandbox` id cannot be++ named in a URL. Nothing is created unless the token's `project_id` is the path's project and the++ token holds the route's capability.++- App tokens reach only these tenants and only the capability routes (files, objects v2 without++ retention holds). VODs, clips, pastes, thumbnails, assets, stats and admin refuse them.++- **Sandbox tokens** (`env: sandbox`) are accepted on these routes only; everywhere else they get++ `401 token.sandbox_refused`.++- **Sandbox content is never public.** `/f/:key` and `/o/:id` answer 404 for it unless the URL++ carries a valid signature; the Files API returns a signed, short-lived `url` (+ `url_expires_at`,++ `sandbox: true`), objects have `public_url: null` and `/download` always signs, whatever the++ visibility. Sandbox tenants emit no platform events.++- The quota counts v1 files and native v2 objects together (files uploads now use the same count++ as objects for every tenant). v1 file keys in project tenants carry a tenant tag, so two tenants++ never collide on identical uploads. Project tenants' files are not listed in the public gallery.++- Not yet: reading quotas set in Network (`network.project.read`), and removing a project's++ tenants when the project is archived.++### App seedingOn every boot the service upserts apps from env (keys are hashed):@@@ -118,6 +118,7 @@ All routes live under `/api/v2/:app/objects`.- the app's API key;- a Network service token that grants `media.object.upload` (writes) or `media.object.read` (reads) for the namespace `:app`. `tenantAuth({ capability })` enforces this, as it does on v1.++- for `:app = prj_<ULID>` (a developer-project tenant, ADR-014): only that project's app tokens, production or sandbox. The token's `env` selects the tenant (`prj_<ULID>` or `prj_<ULID>-sandbox`), and upload URLs always name the project. Sandbox objects never get a `public_url`; `/download` signs them and `/o/:id` needs the signature, whatever their visibility. Retention holds are not available to app tokens. See the README's "Developer-project tenants".**Owner.**@@@ -16,7 +16,7 @@"dotenv": "^16.4.5","express": "^4.19.2","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0","sharp": "^0.33.4"@@@ -1919,9 +1919,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.8.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0",-- "integrity": "sha512-2Msdb+k+fJZf9kQTPS2jdY8+o9FhL9tBuu3FzArHs3JcBcc06ReHpOi2sgnSBgQcSvZSaSsWT2YVbmhjnEplqQ==",++ "version": "0.26.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0",++ "integrity": "sha512-yigHjNzLIyK578V0Weqv2zL6z8PO8ce3HRXPa/61n1XK5Y7pTi28qHMMiO6stboQjTziN1+8wMeByoCF1sGCnw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -11,7 +11,7 @@},"license": "MIT","dependencies": {-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2","@aws-sdk/client-s3": "^3.600.0","@aws-sdk/lib-storage": "^3.600.0",@@@ -96,10 +96,19 @@ function verifyUserJwt(token) {}}--/** A Network-issued service token for this service (sub svc:/app:/mod:), or null. */++/**++ * Verify a Network-issued principal token for this service (sub svc:/app:/mod:).++ * Returns { ok, claims } or { ok: false, code, reason }. `acceptSandbox` is true only on the++ * developer-project tenant routes; everywhere else an env=sandbox token is token.sandbox_refused.++ */++function verifyServiceTokenResult(token, { acceptSandbox = false } = {}) {++ if (!_networkPublicKeyPem) return { ok: false, code: 'token.unavailable', reason: 'Network signing key not loaded yet' };++ return serviceAuth.verifyServiceToken(token, { publicKey: _networkPublicKeyPem, issuer: config.network.url, audience: 'openvibe.media', acceptSandbox });++}++++/** A Network-issued service token for this service (sandbox tokens refused), or null. */function verifyServiceToken(token) {-- if (!_networkPublicKeyPem) return null;-- const r = serviceAuth.verifyServiceToken(token, { publicKey: _networkPublicKeyPem, issuer: config.network.url, audience: 'openvibe.media' });++ const r = verifyServiceTokenResult(token);return r.ok ? r.claims : null;}@@@ -107,6 +116,45 @@ function problem(res, status, code, detail) {return http.sendProblem(res, status, code, { detail });}++// ── Developer-project tenants (roadmap Wave 20, ADR-014) ─────++++const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++const isAppPrincipal = (claims) => !!claims && (claims.actor_type === 'app' || /^app:/.test(String(claims.sub || '')));++++/**++ * An app token on /api/v1/<project_id>/files or /api/v2/<project_id>/objects. The path names the++ * project; the token's env picks the tenant row (production prj_<ULID>, sandbox prj_<ULID>-sandbox),++ * which is created on first use with the default quota. Nothing is created unless the token's++ * project_id is the path's project and the token holds this route's capability for it.++ */++function projectTenant(req, res, next, projectId, claims, capability) {++ if (claims.project_id !== projectId) {++ return problem(res, 403, 'capability.namespace_denied', `this app token belongs to ${claims.project_id || 'no project'}, not ${projectId}`);++ }++ const env = claims.env === 'sandbox' ? 'sandbox' : 'production';++ const c = capabilities.check(claims, capability, { namespace: projectId });++ if (!c.allowed) return problem(res, 403, c.code, c.reason);++ let tenant;++ try {++ tenant = db.ensureProjectTenant(projectId, env, (env === 'sandbox' ? config.apps.sandboxQuotaMb : config.apps.projectQuotaMb) * 1024 * 1024);++ } catch (err) {++ if (err.code === 'media.tenant.conflict') return problem(res, 409, err.code, err.message);++ throw err;++ }++ req.appId = tenant.app_id; // the tenant (storage, quota, namespace of objects)++ req.appRow = tenant;++ req.appPath = projectId; // what the URL says; used to build URLs back to this tenant++ req.authType = 'app';++ req.tenantEnv = env;++ req.principal = { sub: claims.sub, cap: claims.cap, jti: claims.jti, project_id: projectId, env };++ return next();++}++++/** The path segment a tenant is addressed by: its project id for developer-project tenants. */++function tenantPath(app) {++ return app && app.project_id ? app.project_id : app && app.app_id;++}++/*** Tenants that exist only to be reached with service tokens (no API key is ever issued):* OpenVibe.Community stores screenshot bytes here under its own namespace.@@@ -173,13 +221,36 @@ function actingUserId(req) {function tenantAuth({ allowUser = false, capability = null } = {}) {return (req, res, next) => {const appId = String(req.params.app || '').trim();++ const token = bearerToken(req);++++ // 0) Principal tokens first, so a sandbox token is refused as such (never a 404 that hints++ // at which tenants exist). Only developer-project tenant routes (a route that names its++ // capability, under /<project_id>/) opt in to sandbox tokens.++ const appRoute = !!capability && PROJECT_ID_RE.test(appId);++ let svc = null;++ if (token && token.split('.').length === 3) {++ const r = verifyServiceTokenResult(token, { acceptSandbox: appRoute });++ if (r.ok) svc = r.claims;++ else if (r.code === 'token.sandbox_refused') {++ return problem(res, 401, 'token.sandbox_refused', 'sandbox tokens are accepted only on developer-project tenant routes (/api/v1/<project_id>/files, /api/v2/<project_id>/objects)');++ }++ }++ if (svc && isAppPrincipal(svc)) {++ // Developer apps reach only their own project's tenant, and only on routes that name a capability.++ if (!appRoute) {++ return problem(res, 403, capability ? 'capability.namespace_denied' : 'capability.denied',++ capability ? 'app tokens reach only /<project_id>/ tenants' : 'app tokens are not accepted on this route');++ }++ return projectTenant(req, res, next, appId, svc, capability);++ }++ if (svc && svc.env === 'sandbox') return problem(res, 401, 'token.sandbox_refused', 'only developer-app sandbox tokens are accepted, on their own project tenant');++const app = appId ? db.getApp(appId) : null;-- if (!app) return res.status(404).json({ error: 'Unknown app' });++ // Developer-project tenants are reachable only through their project's app tokens (above).++ if (!app || app.project_id) return res.status(404).json({ error: 'Unknown app' });req.appId = appId;req.appRow = app;-- const token = bearerToken(req);--// 1) App API key — only valid for its own :app segment. A valid key of a// DIFFERENT app must NOT fall through to anything else.if (token && checkAppKey(app, token)) {@@@ -201,16 +272,13 @@ function tenantAuth({ allowUser = false, capability = null } = {}) {// 1b) A service-principal token from OpenVibe.Network (roadmap Wave 1, ADR-003). Accepted only on// routes that name the capability they perform, and only for the :app namespaces the token was// granted. It carries the app's authority for that one action — no acting user.-- if (token && token.split('.').length === 3) {-- const svc = verifyServiceToken(token);-- if (svc) {-- if (!capability) return problem(res, 403, 'capability.denied', 'service tokens are not accepted on this route');-- const c = capabilities.check(svc, capability, { namespace: appId });-- if (!c.allowed) return problem(res, 403, c.code, c.reason);-- req.authType = 'app';-- req.principal = { sub: svc.sub, cap: svc.cap, jti: svc.jti };-- return next();-- }++ if (svc) {++ if (!capability) return problem(res, 403, 'capability.denied', 'service tokens are not accepted on this route');++ const c = capabilities.check(svc, capability, { namespace: appId });++ if (!c.allowed) return problem(res, 403, c.code, c.reason);++ req.authType = 'app';++ req.principal = { sub: svc.sub, cap: svc.cap, jti: svc.jti };++ return next();}// 2) A Network user JWT is a real credential, but it cannot say WHICH of an app's@@@ -290,6 +358,7 @@ function seedApps() {const list = JSON.parse(config.apps.seedJson);for (const entry of Array.isArray(list) ? list : []) {if (!entry || !entry.app_id || !entry.api_key) continue;++ if (/^prj_/.test(String(entry.app_id))) { console.warn(`[Auth] MEDIA_APPS_SEED: ${entry.app_id} skipped (developer-project tenants never get an API key)`); continue; }db.upsertApp(entry);seeded++;}@@@ -305,6 +374,7 @@ function seedApps() {const app_id = pair.slice(0, idx).trim();const api_key = pair.slice(idx + 1).trim();if (!app_id || !api_key) continue;++ if (/^prj_/.test(app_id)) { console.warn(`[Auth] MEDIA_APP_KEYS: ${app_id} skipped (developer-project tenants never get an API key)`); continue; }// Don't clobber a richer MEDIA_APPS_SEED entry for the same app.const existing = db.getApp(app_id);if (existing && config.apps.seedJson && config.apps.seedJson.includes(`"${app_id}"`)) continue;@@@ -327,8 +397,11 @@ function seedApps() {module.exports = {tenantAuth,++ tenantPath,ensureTokenOnlyApps,verifyServiceToken,++ verifyServiceTokenResult,++ PROJECT_ID_RE,_setNetworkPublicKeyForTests(pem) { _networkPublicKeyPem = pem; },tenantCors,optionalIdentity,@@@ -73,6 +73,10 @@ const config = {seedJson: process.env.MEDIA_APPS_SEED || '',// Fallback short form: "live:key1,games:key2"seedKeys: process.env.MEDIA_APP_KEYS || '',++ // Developer-project tenants (ADR-014), created on first use by an app token: default quotas.++ // Applied when the tenant is created; an operator can change apps.quota_bytes afterwards.++ projectQuotaMb: Math.max(1, intEnv('MEDIA_APP_TENANT_QUOTA_MB', 1024)),++ sandboxQuotaMb: Math.max(1, intEnv('MEDIA_APP_SANDBOX_QUOTA_MB', 100)),},};@@@ -76,6 +76,8 @@ function migrateColumns() {clips: [['channel_user_id', 'INTEGER'], ['cut_error', 'TEXT'], ['cut_attempts', 'INTEGER DEFAULT 0'], ['cut_next_at', 'DATETIME'], ['object_id', 'TEXT']],files: [['object_id', 'TEXT']],pastes: [['object_id', 'TEXT']],++ // Developer-project tenants (ADR-014): project_id prj_<ULID>, env sandbox|production. NULL on first-party tenants.++ apps: [['project_id', 'TEXT'], ['env', 'TEXT']],};for (const [table, cols] of Object.entries(wanted)) {const existing = database.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);@@@ -191,6 +193,10 @@ function listApps() {function upsertApp({ app_id, name, api_key, webhook_url, webhook_secret, allowed_origins, quota_bytes }) {if (!app_id || !api_key) throw new Error('app_id and api_key required');++ // Developer-project tenants never get an API key: they are reached only with their project's app tokens.++ if (/^prj_/.test(String(app_id))) throw new Error(`${app_id} is a developer-project tenant id; API keys are never issued for those`);++ const existing = getApp(app_id);++ if (existing && existing.project_id) throw new Error(`${app_id} is a developer-project tenant; API keys are never issued for those`);const origins = JSON.stringify(Array.isArray(allowed_origins) ? allowed_origins : []);run(`INSERT INTO apps (app_id, name, api_key_hash, webhook_url, webhook_secret, allowed_origins, quota_bytes)VALUES (?, ?, ?, ?, ?, ?, ?)@@@ -205,6 +211,37 @@ function upsertApp({ app_id, name, api_key, webhook_url, webhook_secret, allowedreturn getApp(app_id);}++/**++ * Developer-project tenant (ADR-014), created on first use. One project has up to two tenants:++ * production app_id = prj_<ULID>++ * sandbox app_id = prj_<ULID>-sandbox++ * Both are reached through the project id in the URL; the token's env picks the row. No API key.++ * Returns the row, or throws { code: 'media.tenant.conflict' } when the id is taken by something else.++ */++function projectTenantId(projectId, env) {++ return env === 'sandbox' ? `${projectId}-sandbox` : projectId;++}++++function ensureProjectTenant(projectId, env, quotaBytes) {++ const id = projectTenantId(projectId, env);++ run(`INSERT OR IGNORE INTO apps (app_id, name, api_key_hash, quota_bytes, project_id, env)++ VALUES (?, ?, '', ?, ?, ?)`, [id, `project ${projectId} (${env})`, quotaBytes, projectId, env]);++ const row = getApp(id);++ if (!row || row.project_id !== projectId || row.env !== env || row.api_key_hash) {++ const err = new Error(`tenant id ${id} is taken by a tenant that is not this project's ${env} tenant`);++ err.code = 'media.tenant.conflict';++ throw err;++ }++ return row;++}++++/** Is this tenant a developer project's sandbox? Its content is never served from public URLs. */++function isSandboxTenant(appId) {++ if (!appId) return false;++ const r = get('SELECT env FROM apps WHERE app_id = ?', [appId]);++ return !!(r && r.env === 'sandbox');++}++function appAllowedOrigins(app) {try { return JSON.parse(app.allowed_origins || '[]'); } catch { return []; }}@@@ -704,7 +741,7 @@ module.exports = {getDb, run, get, all, close, syncObject: _syncObject,getSetting, setSetting,// apps-- hashApiKey, getApp, listApps, upsertApp, appAllowedOrigins,++ hashApiKey, getApp, listApps, upsertApp, appAllowedOrigins, projectTenantId, ensureProjectTenant, isSandboxTenant,// vodscreateVod, getVodById, getVodByFileBasename, listVods, countVods, setVodVisibility, vodStatus,latestVodThumbsByManagedStreams, getAppStats, getAppStatSeries,@@@ -72,6 +72,9 @@ function slim(appId, data) {/** Queue the durable twin of a webhook event. Never throws; returns the envelope or null. */function emit(webhookEvent, appId, data) {if (!outbox) return null;++ // Developer-project sandbox tenants (ADR-014) produce no platform events: sandbox activity must++ // never reach production consumers.++ if (appId && db.isSandboxTenant(appId)) return null;const map = TYPES[webhookEvent];if (!map) return null;const [eventType, subjectType] = map;@@@ -10,8 +10,13 @@** Files live at FILES_PATH/<app>/<key> with key = <sha256-prefix>-<name>.* Public serving (Content-Type + Range) is at GET /f/:key.-- * Per-app quota: apps.quota_bytes (0 = unlimited) checked against the sum of-- * the app's stored file sizes.++ * Per-app quota: apps.quota_bytes (0 = unlimited) checked against the tenant's++ * stored bytes (v1 files + native v2 objects, objects/model.usedBytes).++ *++ * Network principal tokens: upload + delete need media.object.upload, list + meta++ * need media.object.read, for namespace = :app. Developer-project tenants++ * (:app = prj_<ULID>, app tokens only, ADR-014): sandbox files are never served++ * publicly — their `url` is a short-lived signed /f/:key URL.*/'use strict';@@@ -48,6 +53,8 @@ function filePathForKey(row) {}function filePublic(row) {++ const sandbox = db.isSandboxTenant(row.app_id);++ const signed = sandbox ? require('../objects/signing').signedFileUrl(row.key) : null;return {key: row.key,app_id: row.app_id,@@@ -56,7 +63,8 @@ function filePublic(row) {size: row.size,mime: row.mime,sha256: row.sha256,-- url: `/f/${row.key}`,++ url: signed ? signed.url : `/f/${row.key}`,++ ...(sandbox ? { sandbox: true, url_expires_at: signed.expires_at } : {}),created_at: row.created_at,};}@@@ -76,10 +84,10 @@ router.post('/', tenantAuth({ allowUser: true, capability: 'media.object.upload'try {if (!req.file) return res.status(400).json({ error: 'No file uploaded (multipart field: file)' });-- // Per-app quota check against quota_bytes (0 = unlimited).++ // Per-app quota check against quota_bytes (0 = unlimited): files + native objects.const quota = Number(req.appRow.quota_bytes) || 0;if (quota > 0) {-- const used = db.appFilesBytes(req.appId);++ const used = require('../objects/model').usedBytes(req.appId);if (used + req.file.size > quota) {try { fs.unlinkSync(req.file.path); } catch { /* */ }return res.status(413).json({@@@ -92,7 +100,10 @@ router.post('/', tenantAuth({ allowUser: true, capability: 'media.object.upload'const digest = await sha256File(req.file.path);const name = sanitizeName(req.file.originalname);-- const key = `${digest.slice(0, 12)}-${name}`;++ // Keys are global. A developer-project tenant's keys carry a tag of the tenant id, so two++ // tenants never collide on (and so never learn about) each other's identical uploads.++ const tag = req.appRow.project_id ? `${crypto.createHash('sha256').update(`tenant:${req.appId}`).digest('hex').slice(0, 8)}-` : '';++ const key = `${digest.slice(0, 12)}-${tag}${name}`;const existing = db.getFileByKey(key);if (existing) {@@@ -136,14 +147,14 @@ router.post('/', tenantAuth({ allowUser: true, capability: 'media.object.upload'});// ── List ─────────────────────────────────────────────────────--router.get('/', tenantAuth({ allowUser: true }), (req, res) => {++router.get('/', tenantAuth({ allowUser: true, capability: 'media.object.read' }), (req, res) => {try {const limit = Math.min(Math.max(parseInt(req.query.limit || '100', 10), 1), 500);const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);const files = db.listFiles(req.appId, { limit, offset });res.json({files: files.map(filePublic),-- used_bytes: db.appFilesBytes(req.appId),++ used_bytes: require('../objects/model').usedBytes(req.appId),quota_bytes: Number(req.appRow.quota_bytes) || 0,limit, offset,});@@@ -153,7 +164,7 @@ router.get('/', tenantAuth({ allowUser: true }), (req, res) => {});// ── Meta ─────────────────────────────────────────────────────--router.get('/:key', tenantAuth({ allowUser: true }), (req, res) => {++router.get('/:key', tenantAuth({ allowUser: true, capability: 'media.object.read' }), (req, res) => {try {const row = db.getFileByKey(String(req.params.key), req.appId);if (!row) return res.status(404).json({ error: 'File not found' });@@@ -164,7 +175,7 @@ router.get('/:key', tenantAuth({ allowUser: true }), (req, res) => {});// ── Delete ───────────────────────────────────────────────────--router.delete('/:key', tenantAuth({ allowUser: true }), (req, res) => {++router.delete('/:key', tenantAuth({ allowUser: true, capability: 'media.object.upload' }), (req, res) => {try {const row = db.getFileByKey(String(req.params.key), req.appId);if (!row) return res.status(404).json({ error: 'File not found' });@@@ -522,12 +522,14 @@ function objectPublic(obj, { locations = true } = {}) {content_hash: obj.content_hash || null,metadata: parseJson(obj.metadata, {}),held: isHeld(obj.id),-- public_url: obj.visibility !== 'private' && obj.lifecycle_status === 'ready'++ // Developer-project sandbox objects have no public URL: /download hands out signed ones.++ public_url: obj.visibility !== 'private' && obj.lifecycle_status === 'ready' && !db.isSandboxTenant(obj.app_id)? (legacyPublicUrl(obj) || `${config.publicUrl}/o/${obj.id}`) : null,created_at: obj.created_at,updated_at: obj.updated_at,deleted_at: obj.deleted_at || null,};++ if (db.isSandboxTenant(obj.app_id)) out.sandbox = true;// Where the bytes are, never the keys or paths themselves.if (locations) {out.locations = listLocations(obj.id).map(l => ({@@@ -14,7 +14,9 @@* DELETE /:id/holds/:holdId release it (app key only)** Auth: the app's API key, or a Network service token granting media.object.upload-- * (writes) / media.object.read (reads) for namespace = :app. X-OV-Subject names the++ * (writes) / media.object.read (reads) for namespace = :app. Developer-project tenants++ * (:app = prj_<ULID>) take their project's app tokens only (server/auth.js); their sandbox++ * objects are served only through signed URLs, whatever their visibility. X-OV-Subject names the* owner (usr_…); X-OV-User-Id (app key only) acts as one of the app's users. The* content PUT and complete also accept the upload token handed out at init, so a* browser can send the bytes directly.@@@ -34,7 +36,7 @@ const config = require('../config');const model = require('./model');const invariant = require('./invariant');const signing = require('./signing');--const { tenantAuth, tenantCors } = require('../auth');++const { tenantAuth, tenantCors, tenantPath } = require('../auth');const { sendWebhook } = require('../webhooks');const MB = 1024 * 1024;@@@ -75,10 +77,15 @@ function load(req, res) {function contentAuth(req, res, next) {const token = req.query.token || req.headers['x-upload-token'];if (!token) return upload(req, res, next);-- const app = db.getApp(String(req.params.app || ''));-- if (!app) return res.status(404).json({ error: 'Unknown app' });-- if (!signing.verifyUploadToken(String(req.params.id || ''), token)) return problem(res, 401, 'media.upload_token.invalid', 'Upload token is invalid or expired');++ const id = String(req.params.id || '');++ if (!signing.verifyUploadToken(id, token)) return problem(res, 401, 'media.upload_token.invalid', 'Upload token is invalid or expired');++ // The token names the object; its tenant must be the one the URL addresses (a developer-project++ // tenant is addressed by its project id, whichever of its production/sandbox rows holds the object).++ const obj = model.getObject(id);++ const app = obj ? db.getApp(obj.app_id) : null;++ if (!app || tenantPath(app) !== String(req.params.app || '')) return res.status(404).json({ error: 'Unknown app' });req.appId = app.app_id;++ req.appPath = tenantPath(app);req.appRow = app;req.authType = 'upload_token';next();@@@ -221,11 +228,11 @@ router.post('/', upload, (req, res) => {object: model.objectPublic(model.getObject(id)),upload: {method: 'PUT',-- url: `${config.publicUrl}/api/v2/${encodeURIComponent(req.appId)}/objects/${id}/content?token=${encodeURIComponent(tok.token)}`,++ url: `${config.publicUrl}/api/v2/${encodeURIComponent(req.appPath || req.appId)}/objects/${id}/content?token=${encodeURIComponent(tok.token)}`,token: tok.token,expires_at: tok.expires_at,max_bytes: size || max,-- complete_url: `${config.publicUrl}/api/v2/${encodeURIComponent(req.appId)}/objects/${id}/complete`,++ complete_url: `${config.publicUrl}/api/v2/${encodeURIComponent(req.appPath || req.appId)}/objects/${id}/complete`,},});} catch (err) {@@@ -327,7 +334,8 @@ router.get('/:id/download', read, (req, res) => {if (obj.lifecycle_status !== 'ready') return problem(res, 409, 'media.object.not_ready', `Object is ${obj.lifecycle_status}`);const json = req.query.format === 'json';res.set('Cache-Control', 'private, no-store');-- if (obj.visibility !== 'private') {++ // Developer-project sandbox objects are never public, whatever their visibility: always signed.++ if (obj.visibility !== 'private' && !db.isSandboxTenant(obj.app_id)) {const url = model.legacyPublicUrl(obj) || `${config.publicUrl}/o/${obj.id}`;return json ? res.json({ url, expires_at: null, public: true }) : res.redirect(302, url);}@@@ -374,8 +382,10 @@ publicRouter.get('/:id', async (req, res) => {if (obj.lifecycle_status === 'deleted') return res.status(410).json({ error: 'Gone' });if (obj.lifecycle_status !== 'ready') return res.status(404).json({ error: 'Not found' });const signed = !!req.query.sig && signing.verifyDownload(obj.id, req.query.exp, req.query.sig);-- // Private objects are indistinguishable from missing ones without a valid signature.-- if (obj.visibility === 'private' && !signed) return res.status(404).json({ error: 'Not found' });++ // Private objects (and every developer-project sandbox object) are indistinguishable from++ // missing ones without a valid signature.++ const sandbox = db.isSandboxTenant(obj.app_id);++ if ((obj.visibility === 'private' || sandbox) && !signed) return res.status(404).json({ error: 'Not found' });const mime = obj.mime_type || 'application/octet-stream';const md = model.parseJson(obj.metadata, {});@@@ -384,7 +394,7 @@ publicRouter.get('/:id', async (req, res) => {'Content-Type': mime,'X-Content-Type-Options': 'nosniff','X-Robots-Tag': 'noindex',-- 'Cache-Control': obj.visibility === 'private' ? 'private, no-store' : 'public, max-age=3600',++ 'Cache-Control': obj.visibility === 'private' || sandbox ? 'private, no-store' : 'public, max-age=3600','Content-Disposition': `${INLINE.test(mime) ? 'inline' : 'attachment'}; filename="${name}"`,};const locs = model.listLocations(obj.id);@@@ -48,6 +48,25 @@ function verifyDownload(objectId, exp, sig) {return safeEqual(sig, mac('get', objectId, e));}++/**++ * Short-lived GET URL for a v1 file (/f/:key). Its own purpose ('getf') and id space (file:<key>), so++ * a file signature is never valid for an object or the other way round. Used for developer-project++ * sandbox files, which are never served without one.++ */++function signedFileUrl(key, ttlS = config.objects.signedUrlTtlS) {++ const exp = nowS() + Math.min(3600, Math.max(30, Number(ttlS) || config.objects.signedUrlTtlS));++ return {++ url: `${config.publicUrl}/f/${encodeURIComponent(key)}?exp=${exp}&sig=${mac('getf', `file:${key}`, exp)}`,++ expires_at: new Date(exp * 1000).toISOString(),++ };++}++++function verifyFile(key, exp, sig) {++ const e = Number(exp);++ if (!Number.isInteger(e) || e < nowS()) return false;++ return safeEqual(sig, mac('getf', `file:${key}`, e));++}++/** Token for PUT /api/v2/:app/objects/:id/content: "<exp>.<mac>". */function uploadToken(objectId, ttlS = config.objects.uploadTokenTtlS) {const exp = nowS() + Math.max(60, Number(ttlS) || 3600);@@@ -62,4 +81,4 @@ function verifyUploadToken(objectId, token) {return safeEqual(m[2], mac('put', objectId, exp));}--module.exports = { signedDownloadUrl, verifyDownload, uploadToken, verifyUploadToken };++module.exports = { signedDownloadUrl, verifyDownload, signedFileUrl, verifyFile, uploadToken, verifyUploadToken };@@@ -97,6 +97,8 @@ function thumbCard(name, mtimeMs) {// ── Data per tab ─────────────────────────────────────────────const V_WHERE = "is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0";const C_WHERE = "COALESCE(is_public,1) = 1";++// Developer-project tenants' files (ADR-014) are theirs to share; the gallery lists first-party files only.++const F_WHERE = "app_id NOT IN (SELECT app_id FROM apps WHERE project_id IS NOT NULL)";const P_WHERE = "visibility = 'public'";let _thumbListCache = { at: 0, list: [] };@@@ -135,8 +137,8 @@ function fetchTab(tab, page) {cards: db.all(`SELECT * FROM pastes WHERE ${P_WHERE} AND COALESCE(type,'paste') <> 'screenshot' ${L}`).map(pasteCard),};case 'files': return {-- total: db.get('SELECT COUNT(*) c FROM files').c,-- cards: db.all(`SELECT * FROM files ${L}`).map(fileCard),++ total: db.get(`SELECT COUNT(*) c FROM files WHERE ${F_WHERE}`).c,++ cards: db.all(`SELECT * FROM files WHERE ${F_WHERE} ${L}`).map(fileCard),};case 'emotes': return {total: db.get("SELECT COUNT(*) c FROM assets WHERE kind = 'emote'").c,@@@ -183,7 +185,7 @@ function tabCounts() {clips: c(`SELECT COUNT(*) c FROM clips WHERE ${C_WHERE}`),images: c(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND type = 'screenshot'`),text: c(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND COALESCE(type,'paste') <> 'screenshot'`),-- files: c('SELECT COUNT(*) c FROM files'),++ files: c(`SELECT COUNT(*) c FROM files WHERE ${F_WHERE}`),emotes: c("SELECT COUNT(*) c FROM assets WHERE kind = 'emote'"),sounds: c("SELECT COUNT(*) c FROM assets WHERE kind = 'sound'"),thumbnails: thumbList().length,@@@ -389,6 +389,10 @@ router.get('/f/:key', (req, res) => {try {const row = db.getFileByKey(String(req.params.key));if (!row) return res.status(404).json({ error: 'Not found' });++ // Developer-project sandbox files are never public: only a valid signed URL (from the++ // Files API) serves them, and a missing signature looks exactly like a missing file.++ const sandbox = db.isSandboxTenant(row.app_id);++ if (sandbox && !require('../objects/signing').verifyFile(row.key, req.query.exp, req.query.sig)) return res.status(404).json({ error: 'Not found' });const filesRoutes = require('../files/routes');const filePath = filesRoutes.filePathForKey(row);if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Not found' });@@@ -398,7 +402,7 @@ router.get('/f/:key', (req, res) => {const contentType = row.mime || 'application/octet-stream';const headers = {'X-Robots-Tag': 'noindex',-- 'Cache-Control': 'public, max-age=86400',++ 'Cache-Control': sandbox ? 'private, no-store' : 'public, max-age=86400',};if (range) {const parts = range.replace(/bytes=/, '').split('-');nnew file mode 100644@@@ -0,0 +1,265 @@++'use strict';++// Developer-project tenants (roadmap Wave 20, ADR-014): app tokens (sub app:app_<ULID>, project_id,++// env) with media.object.upload|read and ns [project_id] get a tenant keyed by the project id, created++// on first use with a small default quota (sandbox smaller). Production and sandbox are separate++// tenants behind the same URL; no other credential reaches them, and they reach nothing else.++// Sandbox tokens are accepted on these routes only; sandbox content is never served publicly.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const crypto = require('crypto');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-apptenants-'));++const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };++process.env.DB_PATH = path.join(tmp, 'media.db');++process.env.VOD_PATH = dir('vods');++process.env.FILES_PATH = dir('files');++process.env.OBJECTS_PATH = dir('objects');++process.env.THUMBNAILS_PATH = dir('thumbnails');++process.env.OV_NETWORK_URL = 'https://openvibe.network';++process.env.MEDIA_PUBLIC_URL = 'https://media.test';++process.env.MEDIA_SIGNING_SECRET = 'test-signing-secret';++process.env.MEDIA_APP_TENANT_QUOTA_MB = '2';++process.env.MEDIA_APP_SANDBOX_QUOTA_MB = '1';++++const db = require('../server/db/database');++const auth = require('../server/auth');++const objectRoutes = require('../server/objects/routes');++const { serviceAuth, ids } = require('openvibe-contracts');++const express = require('express');++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++auth._setNetworkPublicKeyForTests(keys.publicKey);++db.upsertApp({ app_id: 'live', api_key: 'live-key' });++auth.ensureTokenOnlyApps();++++const PA = `prj_${ids.ulid()}`;++const PB = `prj_${ids.ulid()}`;++const PC = `prj_${ids.ulid()}`;++const APP_A = `app_${ids.ulid()}`;++const APP_B = `app_${ids.ulid()}`;++const now = Math.floor(Date.now() / 1000);++const BOTH = ['media.object.upload', 'media.object.read'];++const appTok = ({ project = PA, app = APP_A, env = 'production', cap = BOTH, ns, ...over } = {}) => serviceAuth.signServiceToken({++ iss: 'https://openvibe.network', sub: `app:${app}`, actor_type: 'app', aud: ['openvibe.media'], cap, ns: ns || [project],++ project_id: project, env, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}`, ...over,++}, keys.privateKey);++const svcTok = (over = {}) => serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:community', actor_type: 'service', aud: ['openvibe.media'], cap: BOTH, ns: ['community'], iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}`, ...over }, keys.privateKey);++++// Same mount order as server/index.js.++const app = express();++app.put('/api/v2/:app/objects/:id/content', ...objectRoutes.contentHandlers);++app.use(express.json());++app.use('/api/v1/:app/files', require('../server/files/routes'));++app.use('/api/v1/:app/vods', require('../server/vod/routes'));++app.use('/api/v2/:app/objects', objectRoutes);++app.use('/o', objectRoutes.publicRouter);++app.use('/', require('../server/public/routes'));++const server = http.createServer(app);++++(async () => {++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const local = (u) => base + String(u).replace('https://media.test', '');++ const req = async (method, p, { bearer, json, form, raw, headers = {} } = {}) => {++ const h = { ...headers };++ if (bearer) h.authorization = `Bearer ${bearer}`;++ let body;++ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }++ if (form) body = form;++ if (raw !== undefined) body = raw;++ const r = await fetch(p.startsWith('http') ? p : base + p, { method, headers: h, body, redirect: 'manual' });++ const text = await r.text();++ let parsed = null;++ try { parsed = JSON.parse(text); } catch { /* bytes */ }++ return { status: r.status, body: parsed, text, headers: r.headers };++ };++ const upload = (project, bearer, content = 'hello from a developer app', name = 'note.txt') => {++ const fd = new FormData();++ fd.append('file', new Blob([Buffer.from(content)], { type: 'text/plain' }), name);++ return req('POST', `/api/v1/${project}/files`, { bearer, form: fd });++ };++ const tenants = () => db.all("SELECT app_id, project_id, env, quota_bytes, api_key_hash FROM apps WHERE app_id LIKE 'prj_%' ORDER BY app_id");++++ // ── First use creates the tenant; nothing exists before ──++ assert.strictEqual(tenants().length, 0);++ let r = await req('GET', `/api/v1/${PA}/files`, { bearer: appTok({ cap: ['media.object.upload'] }) });++ assert.strictEqual(r.status, 403, 'list needs media.object.read');++ assert.strictEqual(r.body.code, 'capability.denied');++ assert.strictEqual(tenants().length, 0, 'a refused call creates no tenant');++ r = await req('GET', `/api/v1/${PB}/files`, { bearer: appTok() });++ assert.strictEqual(r.status, 403, "project A's token on project B's path");++ assert.strictEqual(r.body.code, 'capability.namespace_denied');++ r = await req('GET', `/api/v1/${PB}/files`, { bearer: appTok({ ns: [PA, PB] }) });++ assert.strictEqual(r.status, 403, 'the token project_id decides, not a widened ns');++ assert.strictEqual(tenants().length, 0, 'wrong project never creates a tenant');++++ r = await upload(PA, appTok());++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));++ const prodFile = r.body;++ assert.strictEqual(prodFile.url, `/f/${prodFile.key}`, 'production app files are public like any tenant');++ assert.ok(!prodFile.sandbox);++ r = await upload(PA, appTok({ env: 'sandbox' }), 'sandbox bytes');++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));++ const sbFile = r.body;++ assert.strictEqual(sbFile.sandbox, true);++ assert.ok(/^https:\/\/media\.test\/f\/.+\?exp=\d+&sig=/.test(sbFile.url), 'sandbox files get a signed URL');++ assert.ok(sbFile.url_expires_at);++++ const t = tenants();++ assert.deepStrictEqual(t.map(x => [x.app_id, x.project_id, x.env]), [[PA, PA, 'production'], [`${PA}-sandbox`, PA, 'sandbox']], 'separate production and sandbox tenants');++ assert.strictEqual(t[0].quota_bytes, 2 * 1024 * 1024);++ assert.strictEqual(t[1].quota_bytes, 1 * 1024 * 1024, 'sandbox quota is smaller');++ assert.ok(t.every(x => x.api_key_hash === ''), 'no API key for project tenants');++ assert.ok(fs.existsSync(path.join(process.env.FILES_PATH, `${PA}-sandbox`)), 'sandbox bytes live in their own tenant directory');++++ // ── Separation between environments and projects ──++ r = await req('GET', `/api/v1/${PA}/files`, { bearer: appTok() });++ assert.deepStrictEqual(r.body.files.map(f => f.key), [prodFile.key], 'production sees only production');++ r = await req('GET', `/api/v1/${PA}/files`, { bearer: appTok({ env: 'sandbox' }) });++ assert.deepStrictEqual(r.body.files.map(f => f.key), [sbFile.key], 'sandbox sees only sandbox');++ r = await req('GET', `/api/v1/${PA}/files/${encodeURIComponent(prodFile.key)}`, { bearer: appTok({ env: 'sandbox' }) });++ assert.strictEqual(r.status, 404, 'sandbox token cannot read a production file');++ r = await req('DELETE', `/api/v1/${PA}/files/${encodeURIComponent(sbFile.key)}`, { bearer: appTok() });++ assert.strictEqual(r.status, 404, 'production token cannot delete a sandbox file');++ r = await req('GET', `/api/v1/${PA}-sandbox/files`, { bearer: appTok({ env: 'sandbox', ns: [PA, `${PA}-sandbox`] }) });++ assert.strictEqual(r.status, 401, 'the sandbox tenant id is not addressable by path, even with a widened ns');++ assert.strictEqual(r.body.code, 'token.sandbox_refused');++ r = await req('GET', `/api/v1/${PA}-sandbox/files`, { bearer: appTok({ ns: [PA, `${PA}-sandbox`] }) });++ assert.strictEqual(r.status, 403, 'nor with a production token');++ r = await req('GET', `/api/v1/${PA}-sandbox/files`, { bearer: svcTok({ ns: ['*'] }) });++ assert.strictEqual(r.status, 404, 'nor with a first-party service token');++ r = await req('GET', `/api/v1/${PA}/files`, { bearer: svcTok({ ns: ['*'] }) });++ assert.strictEqual(r.status, 404, 'first-party service tokens never reach project tenants');++++ r = await upload(PB, appTok({ project: PB, app: APP_B }), 'project b');++ assert.strictEqual(r.status, 201);++ const bFile = r.body;++ r = await req('GET', `/api/v1/${PB}/files/${encodeURIComponent(prodFile.key)}`, { bearer: appTok({ project: PB, app: APP_B }) });++ assert.strictEqual(r.status, 404, "B cannot read A's file through its own path");++ for (const [m, p] of [['GET', `/api/v1/${PB}/files`], ['GET', `/api/v1/${PB}/files/${encodeURIComponent(bFile.key)}`], ['DELETE', `/api/v1/${PB}/files/${encodeURIComponent(bFile.key)}`]]) {++ r = await req(m, p, { bearer: appTok() });++ assert.strictEqual(r.status, 403, `A cannot ${m} ${p}`);++ }++ r = await upload(PB, appTok());++ assert.strictEqual(r.status, 403, 'A cannot upload into B');++ r = await upload(PA, appTok({ project: PB, app: APP_B }), 'same content as A', 'note.txt');++ assert.strictEqual(r.status, 403);++ // Identical bytes + name in two tenants never collide on the (global) key.++ r = await upload(PB, appTok({ project: PB, app: APP_B }));++ assert.strictEqual(r.status, 201);++ assert.notStrictEqual(r.body.key, prodFile.key, 'per-tenant key tag');++++ // ── App tokens reach nothing else; sandbox tokens are refused off the app routes ──++ r = await upload('community', appTok({ ns: ['community'] }));++ assert.strictEqual(r.status, 403, 'an app token never reaches a first-party tenant');++ r = await upload('community', appTok({ env: 'sandbox', ns: ['community'] }));++ assert.strictEqual(r.status, 401);++ assert.strictEqual(r.body.code, 'token.sandbox_refused', 'sandbox token on a first-party tenant');++ r = await req('GET', `/api/v1/${PA}/vods`, { bearer: appTok({ env: 'sandbox' }) });++ assert.strictEqual(r.status, 401);++ assert.strictEqual(r.body.code, 'token.sandbox_refused', 'sandbox token on a route that is not an app-tenant route');++ r = await req('GET', `/api/v1/${PA}/vods`, { bearer: appTok() });++ assert.strictEqual(r.status, 403, 'production app token on vods');++ r = await req('GET', '/api/v1/live/vods', { bearer: appTok({ ns: ['live'] }) });++ assert.strictEqual(r.status, 403);++ r = await req('GET', `/api/v1/${PA}/files`, { bearer: 'live-key' });++ assert.strictEqual(r.status, 404, "an app key never unlocks a project tenant");++ r = await req('GET', `/api/v1/${PA}/files`, { bearer: appTok({ aud: ['openvibe.events'] }) });++ assert.strictEqual(r.status, 404, 'a token for another audience is no credential here');++++ // svc: tokens keep their behaviour on first-party tenants.++ r = await upload('community', svcTok(), 'community bytes');++ assert.strictEqual(r.status, 201);++ const communityFile = r.body;++ r = await upload('live', svcTok());++ assert.strictEqual(r.body.code, 'capability.namespace_denied');++++ // ── Quotas ──++ r = await upload(PA, appTok({ env: 'sandbox' }), Buffer.alloc(1024 * 1024 + 10, 1).toString(), 'big.bin');++ assert.strictEqual(r.status, 413, 'sandbox quota (1 MB) enforced');++ r = await upload(PA, appTok(), Buffer.alloc(1024 * 1024 + 10, 1).toString(), 'big.bin');++ assert.strictEqual(r.status, 201, 'the production quota (2 MB) is larger');++ r = await upload(PA, appTok(), Buffer.alloc(1024 * 1024 + 10, 2).toString(), 'big2.bin');++ assert.strictEqual(r.status, 413, 'production quota enforced');++ r = await req('POST', `/api/v2/${PA}/objects`, { bearer: appTok({ env: 'sandbox' }), json: { size_bytes: 1024 * 1024 } });++ assert.strictEqual(r.status, 413, 'objects v2 counts files against the same quota');++++ // ── Public serving ──++ r = await req('GET', `/f/${encodeURIComponent(prodFile.key)}`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.text, 'hello from a developer app');++ r = await req('GET', `/f/${encodeURIComponent(communityFile.key)}`);++ assert.strictEqual(r.status, 200);++ r = await req('GET', `/f/${encodeURIComponent(sbFile.key)}`);++ assert.strictEqual(r.status, 404, 'sandbox file is not public');++ r = await req('GET', `/f/${encodeURIComponent(sbFile.key)}?exp=${now + 60}&sig=forged`);++ assert.strictEqual(r.status, 404, 'forged signature');++ r = await req('GET', local(sbFile.url));++ assert.strictEqual(r.status, 200, 'the signed URL serves it');++ assert.strictEqual(r.text, 'sandbox bytes');++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ // A download signature for an object id is not a file signature.++ const objSig = require('../server/objects/signing').signedDownloadUrl(sbFile.key);++ r = await req('GET', `/f/${encodeURIComponent(sbFile.key)}${new URL(objSig.url).search}`);++ assert.strictEqual(r.status, 404, 'purposes are separate');++ // The v2 projection of a sandbox file has no public URL either.++ const proj = db.get('SELECT object_id FROM files WHERE key = ?', [sbFile.key]);++ r = await req('GET', `/api/v2/${PA}/objects/${proj.object_id}`, { bearer: appTok({ env: 'sandbox' }) });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.public_url, null);++ assert.strictEqual(r.body.sandbox, true);++ r = await req('GET', `/o/${proj.object_id}`);++ assert.strictEqual(r.status, 404);++++ // ── Objects v2 in a sandbox tenant: public visibility still needs a signature ──++ const bytes = Buffer.from('sandbox object bytes');++ r = await req('POST', `/api/v2/${PA}/objects`, { bearer: appTok({ env: 'sandbox' }), json: { visibility: 'public', size_bytes: bytes.length, mime_type: 'text/plain' } });++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));++ const obj = r.body;++ assert.ok(obj.upload.url.includes(`/api/v2/${PA}/objects/`), 'upload URL addresses the project, never the internal sandbox tenant id');++ assert.ok(!obj.upload.url.includes('-sandbox'));++ r = await req('PUT', local(obj.upload.url.replace(`/api/v2/${PA}/`, `/api/v2/${PB}/`)), { raw: bytes, headers: { 'content-type': 'text/plain' } });++ assert.strictEqual(r.status, 404, 'an upload token only works under its own tenant path');++ r = await req('PUT', local(obj.upload.url), { raw: bytes, headers: { 'content-type': 'text/plain' } });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ r = await req('POST', `/api/v2/${PA}/objects/${obj.id}/complete`, { bearer: appTok({ env: 'sandbox' }), json: {} });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.strictEqual(r.body.public_url, null, 'no public URL for a sandbox object');++ r = await req('GET', `/o/${obj.id}`);++ assert.strictEqual(r.status, 404, 'public visibility does not make a sandbox object public');++ r = await req('GET', `/api/v2/${PA}/objects/${obj.id}/download?format=json`, { bearer: appTok({ env: 'sandbox' }) });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.public, false);++ r = await req('GET', local(r.body.url));++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.text, 'sandbox object bytes');++ r = await req('GET', `/api/v2/${PA}/objects/${obj.id}`, { bearer: appTok() });++ assert.strictEqual(r.status, 404, 'production cannot see the sandbox object');++ r = await req('GET', `/api/v2/${PB}/objects/${obj.id}`, { bearer: appTok({ project: PB, app: APP_B, env: 'sandbox' }) });++ assert.strictEqual(r.status, 404, 'nor can another project');++ r = await req('POST', `/api/v2/${PA}/objects/${obj.id}/holds`, { bearer: appTok({ env: 'sandbox' }), json: { kind: 'legal', reason: 'x' } });++ assert.strictEqual(r.status, 401, 'holds are not an app-tenant route (sandbox refused)');++++ // Production object, public visibility: served openly.++ r = await req('POST', `/api/v2/${PC}/objects`, { bearer: appTok({ project: PC }), json: { visibility: 'public', size_bytes: 3, mime_type: 'text/plain' } });++ assert.strictEqual(r.status, 201);++ await req('PUT', local(r.body.upload.url), { raw: Buffer.from('abc'), headers: { 'content-type': 'text/plain' } });++ r = await req('POST', `/api/v2/${PC}/objects/${r.body.id}/complete`, { bearer: appTok({ project: PC }), json: {} });++ assert.ok(r.body.public_url, 'production objects keep their public URL');++ r = await req('GET', local(r.body.public_url));++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.text, 'abc');++++ // A seeded API key can never be attached to a project tenant.++ assert.throws(() => db.upsertApp({ app_id: PA, api_key: 'x' }), /developer-project tenant/);++ // The Media gallery lists first-party files only.++ const browse = await req('GET', '/browse?tab=files');++ assert.ok(!browse.text.includes(sbFile.key) && !browse.text.includes(prodFile.key), 'project files are not in the gallery');++++ server.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ console.log('developer-project tenants: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });@@@ -61,8 +61,11 @@ const server = http.createServer(app);assert.strictEqual(r.status, 401);r = await upload('community', 'live-key');assert.strictEqual(r.status, 403, "live's key is not valid for the community tenant");-- const list = await fetch(`${base}/api/v1/community/files`, { headers: { authorization: `Bearer ${tok({ cap: ['media.object.upload', 'media.object.read'] })}` } });-- assert.strictEqual(list.status, 403, 'routes that name no capability refuse service tokens');++ let list = await fetch(`${base}/api/v1/community/files`, { headers: { authorization: `Bearer ${tok()}` } });++ assert.strictEqual(list.status, 403, 'listing needs media.object.read');++ list = await fetch(`${base}/api/v1/community/files`, { headers: { authorization: `Bearer ${tok({ cap: ['media.object.upload', 'media.object.read'] })}` } });++ assert.strictEqual(list.status, 200, 'listing with media.object.read');++ assert.strictEqual((await list.json()).files.length, 1);server.close();// ── Paste export bundle ──
@@ -37,6 +37,13 @@ MEDIA_APPS_SEED=
@@ -37,6 +37,13 @@ MEDIA_APPS_SEED=
# Fallback short form (no webhooks/origins): "live:key1,games:key2"
# Fallback short form (no webhooks/origins): "live:key1,games:key2"
MEDIA_APP_KEYS=
MEDIA_APP_KEYS=
+# ── Developer-project tenants (ADR-014) ──────────────────────
+# Created on first use by a developer app token (Network, audience openvibe.media) for its own
+# project: /api/v1/prj_<ULID>/files, /api/v2/prj_<ULID>/objects. Default quotas in MB, applied
+# when the tenant is created (change apps.quota_bytes afterwards to adjust one tenant).
+MEDIA_APP_TENANT_QUOTA_MB=1024
+MEDIA_APP_SANDBOX_QUOTA_MB=100
+
# ── Object storage tiering (optional; local-only when unset) ─
# ── Object storage tiering (optional; local-only when unset) ─
# Backblaze B2 (canonical cold tier)
# Backblaze B2 (canonical cold tier)
MEDIA_B2_ENDPOINT=
MEDIA_B2_ENDPOINT=
@@ -65,9 +65,10 @@ Tenant = the `:app` path segment. Rows in the `apps` table define tenants:
@@ -65,9 +65,10 @@ Tenant = the `:app` path segment. Rows in the `apps` table define tenants:
| `api_key_hash` | sha256 of the app API key (never stored in plaintext) |
| `api_key_hash` | sha256 of the app API key (never stored in plaintext) |
| `webhook_url` / `webhook_secret` | outbound event delivery (HMAC) |
| `webhook_url` / `webhook_secret` | outbound event delivery (HMAC) |
| `allowed_origins` | JSON array; guards browser (user-JWT) calls |
| `allowed_origins` | JSON array; guards browser (user-JWT) calls |
-| `quota_bytes` | files-subsystem quota (0 = unlimited) |
+| `quota_bytes` | storage quota: v1 files + native v2 objects (0 = unlimited) |
+| `project_id` / `env` | set only on developer-project tenants (below) |
-Two credential types on `/api/v1/:app/...`:
+Credential types on `/api/v1/:app/...`:
1. **App API key** — `Authorization: Bearer <app_api_key>`. Compared in
1. **App API key** — `Authorization: Bearer <app_api_key>`. Compared in
constant time against the app's stored hash. A key is only valid for its
constant time against the app's stored hash. A key is only valid for its
@@ -79,6 +80,43 @@ Two credential types on `/api/v1/:app/...`:
@@ -79,6 +80,43 @@ Two credential types on `/api/v1/:app/...`:
present. If the request carries an `Origin` header it must be in the app's
present. If the request carries an `Origin` header it must be in the app's
`allowed_origins` (CORS is reflected for allow-listed origins only).
`allowed_origins` (CORS is reflected for allow-listed origins only).
+3. **Network principal token** (`sub svc:…`, `app:…`, `mod:…`; RS256, audience `openvibe.media`) —
+ accepted only on routes that name a capability: files upload/delete and every objects v2 write
+ (`media.object.upload`), files list/meta and objects v2 reads (`media.object.read`), and only for
+ the `:app` namespaces in the token's `ns`.
+
+### Developer-project tenants (ADR-014)
+
+A developer app from OpenVibe.Network (token `sub app:app_<ULID>`, `project_id prj_<ULID>`,
+`env sandbox|production`, `ns [project_id]`) holding `media.object.upload` / `media.object.read`
+gets a tenant keyed by its project id, **created on first use**. The URL always names the project
+(`/api/v1/prj_<ULID>/files`, `/api/v2/prj_<ULID>/objects`, so openvibe-sdk's
+`createMediaClient({ app: projectId })` works); the token's `env` picks one of two separate tenants:
+
+| env | tenant (`apps.app_id`) | default quota |
+|---|---|---|
+| production | `prj_<ULID>` | `MEDIA_APP_TENANT_QUOTA_MB` (1024) |
+| sandbox | `prj_<ULID>-sandbox` | `MEDIA_APP_SANDBOX_QUOTA_MB` (100) |
+
+- Both rows carry `project_id` and `env`, and have no API key (`api_key_hash` empty; the seeding
+ env refuses `prj_…` ids). Only the project's own app tokens reach them: other projects' tokens,
+ first-party service tokens, app keys and user JWTs get 403/404, and the `-sandbox` id cannot be
+ named in a URL. Nothing is created unless the token's `project_id` is the path's project and the
+ token holds the route's capability.
+- App tokens reach only these tenants and only the capability routes (files, objects v2 without
+ retention holds). VODs, clips, pastes, thumbnails, assets, stats and admin refuse them.
+- **Sandbox tokens** (`env: sandbox`) are accepted on these routes only; everywhere else they get
+ `401 token.sandbox_refused`.
+- **Sandbox content is never public.** `/f/:key` and `/o/:id` answer 404 for it unless the URL
+ carries a valid signature; the Files API returns a signed, short-lived `url` (+ `url_expires_at`,
+ `sandbox: true`), objects have `public_url: null` and `/download` always signs, whatever the
+ visibility. Sandbox tenants emit no platform events.
+- The quota counts v1 files and native v2 objects together (files uploads now use the same count
+ as objects for every tenant). v1 file keys in project tenants carry a tenant tag, so two tenants
+ never collide on identical uploads. Project tenants' files are not listed in the public gallery.
+- Not yet: reading quotas set in Network (`network.project.read`), and removing a project's
+ tenants when the project is archived.
+
### App seeding
### App seeding
On every boot the service upserts apps from env (keys are hashed):
On every boot the service upserts apps from env (keys are hashed):
@@ -118,6 +118,7 @@ All routes live under `/api/v2/:app/objects`.
@@ -118,6 +118,7 @@ All routes live under `/api/v2/:app/objects`.
- the app's API key;
- the app's API key;
- a Network service token that grants `media.object.upload` (writes) or `media.object.read` (reads) for the namespace `:app`. `tenantAuth({ capability })` enforces this, as it does on v1.
- a Network service token that grants `media.object.upload` (writes) or `media.object.read` (reads) for the namespace `:app`. `tenantAuth({ capability })` enforces this, as it does on v1.
+- for `:app = prj_<ULID>` (a developer-project tenant, ADR-014): only that project's app tokens, production or sandbox. The token's `env` selects the tenant (`prj_<ULID>` or `prj_<ULID>-sandbox`), and upload URLs always name the project. Sandbox objects never get a `public_url`; `/download` signs them and `/o/:id` needs the signature, whatever their visibility. Retention holds are not available to app tokens. See the README's "Developer-project tenants".
**Owner.**
**Owner.**
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"dotenv": "^16.4.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express": "^4.19.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"sharp": "^0.33.4"
"sharp": "^0.33.4"
@@ -1919,9 +1919,9 @@
@@ -1919,9 +1919,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.8.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0",
- "integrity": "sha512-2Msdb+k+fJZf9kQTPS2jdY8+o9FhL9tBuu3FzArHs3JcBcc06ReHpOi2sgnSBgQcSvZSaSsWT2YVbmhjnEplqQ==",
+ "version": "0.26.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0",
+ "integrity": "sha512-yigHjNzLIyK578V0Weqv2zL6z8PO8ce3HRXPa/61n1XK5Y7pTi28qHMMiO6stboQjTziN1+8wMeByoCF1sGCnw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
},
},
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
"@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/lib-storage": "^3.600.0",
"@aws-sdk/lib-storage": "^3.600.0",
@@ -96,10 +96,19 @@ function verifyUserJwt(token) {
@@ -96,10 +96,19 @@ function verifyUserJwt(token) {
}
}
}
}
-/** A Network-issued service token for this service (sub svc:/app:/mod:), or null. */
+/**
+ * Verify a Network-issued principal token for this service (sub svc:/app:/mod:).
+ * Returns { ok, claims } or { ok: false, code, reason }. `acceptSandbox` is true only on the
+ * developer-project tenant routes; everywhere else an env=sandbox token is token.sandbox_refused.
+ */
+function verifyServiceTokenResult(token, { acceptSandbox = false } = {}) {
+ if (!_networkPublicKeyPem) return { ok: false, code: 'token.unavailable', reason: 'Network signing key not loaded yet' };
+ return serviceAuth.verifyServiceToken(token, { publicKey: _networkPublicKeyPem, issuer: config.network.url, audience: 'openvibe.media', acceptSandbox });
+}
+
+/** A Network-issued service token for this service (sandbox tokens refused), or null. */
function verifyServiceToken(token) {
function verifyServiceToken(token) {
- if (!_networkPublicKeyPem) return null;
- const r = serviceAuth.verifyServiceToken(token, { publicKey: _networkPublicKeyPem, issuer: config.network.url, audience: 'openvibe.media' });
+ const r = verifyServiceTokenResult(token);
return r.ok ? r.claims : null;
return r.ok ? r.claims : null;
}
}
@@ -107,6 +116,45 @@ function problem(res, status, code, detail) {
@@ -107,6 +116,45 @@ function problem(res, status, code, detail) {
return http.sendProblem(res, status, code, { detail });
return http.sendProblem(res, status, code, { detail });
}
}
+// ── Developer-project tenants (roadmap Wave 20, ADR-014) ─────
+
+const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+const isAppPrincipal = (claims) => !!claims && (claims.actor_type === 'app' || /^app:/.test(String(claims.sub || '')));
+
+/**
+ * An app token on /api/v1/<project_id>/files or /api/v2/<project_id>/objects. The path names the
+ * project; the token's env picks the tenant row (production prj_<ULID>, sandbox prj_<ULID>-sandbox),
+ * which is created on first use with the default quota. Nothing is created unless the token's
+ * project_id is the path's project and the token holds this route's capability for it.
+ */
+function projectTenant(req, res, next, projectId, claims, capability) {
+ if (claims.project_id !== projectId) {
+ return problem(res, 403, 'capability.namespace_denied', `this app token belongs to ${claims.project_id || 'no project'}, not ${projectId}`);
+ }
+ const env = claims.env === 'sandbox' ? 'sandbox' : 'production';
+ const c = capabilities.check(claims, capability, { namespace: projectId });
+ if (!c.allowed) return problem(res, 403, c.code, c.reason);
+ let tenant;
+ try {
+ tenant = db.ensureProjectTenant(projectId, env, (env === 'sandbox' ? config.apps.sandboxQuotaMb : config.apps.projectQuotaMb) * 1024 * 1024);
+ } catch (err) {
+ if (err.code === 'media.tenant.conflict') return problem(res, 409, err.code, err.message);
+ throw err;
+ }
+ req.appId = tenant.app_id; // the tenant (storage, quota, namespace of objects)
+ req.appRow = tenant;
+ req.appPath = projectId; // what the URL says; used to build URLs back to this tenant
+ req.authType = 'app';
+ req.tenantEnv = env;
+ req.principal = { sub: claims.sub, cap: claims.cap, jti: claims.jti, project_id: projectId, env };
+ return next();
+}
+
+/** The path segment a tenant is addressed by: its project id for developer-project tenants. */
+function tenantPath(app) {
+ return app && app.project_id ? app.project_id : app && app.app_id;
+}
+
/**
/**
* Tenants that exist only to be reached with service tokens (no API key is ever issued):
* Tenants that exist only to be reached with service tokens (no API key is ever issued):
* OpenVibe.Community stores screenshot bytes here under its own namespace.
* OpenVibe.Community stores screenshot bytes here under its own namespace.
@@ -173,13 +221,36 @@ function actingUserId(req) {
@@ -173,13 +221,36 @@ function actingUserId(req) {
function tenantAuth({ allowUser = false, capability = null } = {}) {
function tenantAuth({ allowUser = false, capability = null } = {}) {
return (req, res, next) => {
return (req, res, next) => {
const appId = String(req.params.app || '').trim();
const appId = String(req.params.app || '').trim();
+ const token = bearerToken(req);
+
+ // 0) Principal tokens first, so a sandbox token is refused as such (never a 404 that hints
+ // at which tenants exist). Only developer-project tenant routes (a route that names its
+ // capability, under /<project_id>/) opt in to sandbox tokens.
+ const appRoute = !!capability && PROJECT_ID_RE.test(appId);
+ let svc = null;
+ if (token && token.split('.').length === 3) {
+ const r = verifyServiceTokenResult(token, { acceptSandbox: appRoute });
+ if (r.ok) svc = r.claims;
+ else if (r.code === 'token.sandbox_refused') {
+ return problem(res, 401, 'token.sandbox_refused', 'sandbox tokens are accepted only on developer-project tenant routes (/api/v1/<project_id>/files, /api/v2/<project_id>/objects)');
+ }
+ }
+ if (svc && isAppPrincipal(svc)) {
+ // Developer apps reach only their own project's tenant, and only on routes that name a capability.
+ if (!appRoute) {
+ return problem(res, 403, capability ? 'capability.namespace_denied' : 'capability.denied',
+ capability ? 'app tokens reach only /<project_id>/ tenants' : 'app tokens are not accepted on this route');
+ }
+ return projectTenant(req, res, next, appId, svc, capability);
+ }
+ if (svc && svc.env === 'sandbox') return problem(res, 401, 'token.sandbox_refused', 'only developer-app sandbox tokens are accepted, on their own project tenant');
+
const app = appId ? db.getApp(appId) : null;
const app = appId ? db.getApp(appId) : null;
- if (!app) return res.status(404).json({ error: 'Unknown app' });
+ // Developer-project tenants are reachable only through their project's app tokens (above).
+ if (!app || app.project_id) return res.status(404).json({ error: 'Unknown app' });
req.appId = appId;
req.appId = appId;
req.appRow = app;
req.appRow = app;
- const token = bearerToken(req);
-
// 1) App API key — only valid for its own :app segment. A valid key of a
// 1) App API key — only valid for its own :app segment. A valid key of a
// DIFFERENT app must NOT fall through to anything else.
// DIFFERENT app must NOT fall through to anything else.
if (token && checkAppKey(app, token)) {
if (token && checkAppKey(app, token)) {
@@ -201,16 +272,13 @@ function tenantAuth({ allowUser = false, capability = null } = {}) {
@@ -201,16 +272,13 @@ function tenantAuth({ allowUser = false, capability = null } = {}) {
// 1b) A service-principal token from OpenVibe.Network (roadmap Wave 1, ADR-003). Accepted only on
// 1b) A service-principal token from OpenVibe.Network (roadmap Wave 1, ADR-003). Accepted only on
// routes that name the capability they perform, and only for the :app namespaces the token was
// routes that name the capability they perform, and only for the :app namespaces the token was
// granted. It carries the app's authority for that one action — no acting user.
// granted. It carries the app's authority for that one action — no acting user.
- if (token && token.split('.').length === 3) {
- const svc = verifyServiceToken(token);
- if (svc) {
- if (!capability) return problem(res, 403, 'capability.denied', 'service tokens are not accepted on this route');
- const c = capabilities.check(svc, capability, { namespace: appId });
- if (!c.allowed) return problem(res, 403, c.code, c.reason);
- req.authType = 'app';
- req.principal = { sub: svc.sub, cap: svc.cap, jti: svc.jti };
- return next();
- }
+ if (svc) {
+ if (!capability) return problem(res, 403, 'capability.denied', 'service tokens are not accepted on this route');
+ const c = capabilities.check(svc, capability, { namespace: appId });
+ if (!c.allowed) return problem(res, 403, c.code, c.reason);
+ req.authType = 'app';
+ req.principal = { sub: svc.sub, cap: svc.cap, jti: svc.jti };
+ return next();
}
}
// 2) A Network user JWT is a real credential, but it cannot say WHICH of an app's
// 2) A Network user JWT is a real credential, but it cannot say WHICH of an app's
@@ -290,6 +358,7 @@ function seedApps() {
@@ -290,6 +358,7 @@ function seedApps() {
const list = JSON.parse(config.apps.seedJson);
const list = JSON.parse(config.apps.seedJson);
for (const entry of Array.isArray(list) ? list : []) {
for (const entry of Array.isArray(list) ? list : []) {
if (!entry || !entry.app_id || !entry.api_key) continue;
if (!entry || !entry.app_id || !entry.api_key) continue;
+ if (/^prj_/.test(String(entry.app_id))) { console.warn(`[Auth] MEDIA_APPS_SEED: ${entry.app_id} skipped (developer-project tenants never get an API key)`); continue; }
db.upsertApp(entry);
db.upsertApp(entry);
seeded++;
seeded++;
}
}
@@ -305,6 +374,7 @@ function seedApps() {
@@ -305,6 +374,7 @@ function seedApps() {
const app_id = pair.slice(0, idx).trim();
const app_id = pair.slice(0, idx).trim();
const api_key = pair.slice(idx + 1).trim();
const api_key = pair.slice(idx + 1).trim();
if (!app_id || !api_key) continue;
if (!app_id || !api_key) continue;
+ if (/^prj_/.test(app_id)) { console.warn(`[Auth] MEDIA_APP_KEYS: ${app_id} skipped (developer-project tenants never get an API key)`); continue; }
// Don't clobber a richer MEDIA_APPS_SEED entry for the same app.
// Don't clobber a richer MEDIA_APPS_SEED entry for the same app.
const existing = db.getApp(app_id);
const existing = db.getApp(app_id);
if (existing && config.apps.seedJson && config.apps.seedJson.includes(`"${app_id}"`)) continue;
if (existing && config.apps.seedJson && config.apps.seedJson.includes(`"${app_id}"`)) continue;
@@ -327,8 +397,11 @@ function seedApps() {
@@ -327,8 +397,11 @@ function seedApps() {
module.exports = {
module.exports = {
tenantAuth,
tenantAuth,
+ tenantPath,
ensureTokenOnlyApps,
ensureTokenOnlyApps,
verifyServiceToken,
verifyServiceToken,
+ verifyServiceTokenResult,
+ PROJECT_ID_RE,
_setNetworkPublicKeyForTests(pem) { _networkPublicKeyPem = pem; },
_setNetworkPublicKeyForTests(pem) { _networkPublicKeyPem = pem; },
tenantCors,
tenantCors,
optionalIdentity,
optionalIdentity,
@@ -73,6 +73,10 @@ const config = {
@@ -73,6 +73,10 @@ const config = {
seedJson: process.env.MEDIA_APPS_SEED || '',
seedJson: process.env.MEDIA_APPS_SEED || '',
// Fallback short form: "live:key1,games:key2"
// Fallback short form: "live:key1,games:key2"
seedKeys: process.env.MEDIA_APP_KEYS || '',
seedKeys: process.env.MEDIA_APP_KEYS || '',
+ // Developer-project tenants (ADR-014), created on first use by an app token: default quotas.
+ // Applied when the tenant is created; an operator can change apps.quota_bytes afterwards.
+ projectQuotaMb: Math.max(1, intEnv('MEDIA_APP_TENANT_QUOTA_MB', 1024)),
+ sandboxQuotaMb: Math.max(1, intEnv('MEDIA_APP_SANDBOX_QUOTA_MB', 100)),
},
},
};
};
@@ -76,6 +76,8 @@ function migrateColumns() {
@@ -76,6 +76,8 @@ function migrateColumns() {
clips: [['channel_user_id', 'INTEGER'], ['cut_error', 'TEXT'], ['cut_attempts', 'INTEGER DEFAULT 0'], ['cut_next_at', 'DATETIME'], ['object_id', 'TEXT']],
clips: [['channel_user_id', 'INTEGER'], ['cut_error', 'TEXT'], ['cut_attempts', 'INTEGER DEFAULT 0'], ['cut_next_at', 'DATETIME'], ['object_id', 'TEXT']],
files: [['object_id', 'TEXT']],
files: [['object_id', 'TEXT']],
pastes: [['object_id', 'TEXT']],
pastes: [['object_id', 'TEXT']],
+ // Developer-project tenants (ADR-014): project_id prj_<ULID>, env sandbox|production. NULL on first-party tenants.
+ apps: [['project_id', 'TEXT'], ['env', 'TEXT']],
};
};
for (const [table, cols] of Object.entries(wanted)) {
for (const [table, cols] of Object.entries(wanted)) {
const existing = database.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
const existing = database.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
@@ -191,6 +193,10 @@ function listApps() {
@@ -191,6 +193,10 @@ function listApps() {
function upsertApp({ app_id, name, api_key, webhook_url, webhook_secret, allowed_origins, quota_bytes }) {
function upsertApp({ app_id, name, api_key, webhook_url, webhook_secret, allowed_origins, quota_bytes }) {
if (!app_id || !api_key) throw new Error('app_id and api_key required');
if (!app_id || !api_key) throw new Error('app_id and api_key required');
+ // Developer-project tenants never get an API key: they are reached only with their project's app tokens.
+ if (/^prj_/.test(String(app_id))) throw new Error(`${app_id} is a developer-project tenant id; API keys are never issued for those`);
+ const existing = getApp(app_id);
+ if (existing && existing.project_id) throw new Error(`${app_id} is a developer-project tenant; API keys are never issued for those`);
const origins = JSON.stringify(Array.isArray(allowed_origins) ? allowed_origins : []);
const origins = JSON.stringify(Array.isArray(allowed_origins) ? allowed_origins : []);
run(`INSERT INTO apps (app_id, name, api_key_hash, webhook_url, webhook_secret, allowed_origins, quota_bytes)
run(`INSERT INTO apps (app_id, name, api_key_hash, webhook_url, webhook_secret, allowed_origins, quota_bytes)
VALUES (?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?)
@@ -205,6 +211,37 @@ function upsertApp({ app_id, name, api_key, webhook_url, webhook_secret, allowed
@@ -205,6 +211,37 @@ function upsertApp({ app_id, name, api_key, webhook_url, webhook_secret, allowed
return getApp(app_id);
return getApp(app_id);
}
}
+/**
+ * Developer-project tenant (ADR-014), created on first use. One project has up to two tenants:
+ * production app_id = prj_<ULID>
+ * sandbox app_id = prj_<ULID>-sandbox
+ * Both are reached through the project id in the URL; the token's env picks the row. No API key.
+ * Returns the row, or throws { code: 'media.tenant.conflict' } when the id is taken by something else.
+ */
+function projectTenantId(projectId, env) {
+ return env === 'sandbox' ? `${projectId}-sandbox` : projectId;
+}
+
+function ensureProjectTenant(projectId, env, quotaBytes) {
+ const id = projectTenantId(projectId, env);
+ run(`INSERT OR IGNORE INTO apps (app_id, name, api_key_hash, quota_bytes, project_id, env)
+ VALUES (?, ?, '', ?, ?, ?)`, [id, `project ${projectId} (${env})`, quotaBytes, projectId, env]);
+ const row = getApp(id);
+ if (!row || row.project_id !== projectId || row.env !== env || row.api_key_hash) {
+ const err = new Error(`tenant id ${id} is taken by a tenant that is not this project's ${env} tenant`);
+ err.code = 'media.tenant.conflict';
+ throw err;
+ }
+ return row;
+}
+
+/** Is this tenant a developer project's sandbox? Its content is never served from public URLs. */
+function isSandboxTenant(appId) {
+ if (!appId) return false;
+ const r = get('SELECT env FROM apps WHERE app_id = ?', [appId]);
+ return !!(r && r.env === 'sandbox');
+}
+
function appAllowedOrigins(app) {
function appAllowedOrigins(app) {
try { return JSON.parse(app.allowed_origins || '[]'); } catch { return []; }
try { return JSON.parse(app.allowed_origins || '[]'); } catch { return []; }
}
}
@@ -704,7 +741,7 @@ module.exports = {
@@ -704,7 +741,7 @@ module.exports = {
getDb, run, get, all, close, syncObject: _syncObject,
getDb, run, get, all, close, syncObject: _syncObject,
getSetting, setSetting,
getSetting, setSetting,
// apps
// apps
- hashApiKey, getApp, listApps, upsertApp, appAllowedOrigins,
+ hashApiKey, getApp, listApps, upsertApp, appAllowedOrigins, projectTenantId, ensureProjectTenant, isSandboxTenant,
// vods
// vods
createVod, getVodById, getVodByFileBasename, listVods, countVods, setVodVisibility, vodStatus,
createVod, getVodById, getVodByFileBasename, listVods, countVods, setVodVisibility, vodStatus,
latestVodThumbsByManagedStreams, getAppStats, getAppStatSeries,
latestVodThumbsByManagedStreams, getAppStats, getAppStatSeries,
@@ -72,6 +72,9 @@ function slim(appId, data) {
@@ -72,6 +72,9 @@ function slim(appId, data) {
/** Queue the durable twin of a webhook event. Never throws; returns the envelope or null. */
/** Queue the durable twin of a webhook event. Never throws; returns the envelope or null. */
function emit(webhookEvent, appId, data) {
function emit(webhookEvent, appId, data) {
if (!outbox) return null;
if (!outbox) return null;
+ // Developer-project sandbox tenants (ADR-014) produce no platform events: sandbox activity must
+ // never reach production consumers.
+ if (appId && db.isSandboxTenant(appId)) return null;
const map = TYPES[webhookEvent];
const map = TYPES[webhookEvent];
if (!map) return null;
if (!map) return null;
const [eventType, subjectType] = map;
const [eventType, subjectType] = map;
@@ -10,8 +10,13 @@
@@ -10,8 +10,13 @@
*
*
* Files live at FILES_PATH/<app>/<key> with key = <sha256-prefix>-<name>.
* Files live at FILES_PATH/<app>/<key> with key = <sha256-prefix>-<name>.
* Public serving (Content-Type + Range) is at GET /f/:key.
* Public serving (Content-Type + Range) is at GET /f/:key.
- * Per-app quota: apps.quota_bytes (0 = unlimited) checked against the sum of
- * the app's stored file sizes.
+ * Per-app quota: apps.quota_bytes (0 = unlimited) checked against the tenant's
+ * stored bytes (v1 files + native v2 objects, objects/model.usedBytes).
+ *
+ * Network principal tokens: upload + delete need media.object.upload, list + meta
+ * need media.object.read, for namespace = :app. Developer-project tenants
+ * (:app = prj_<ULID>, app tokens only, ADR-014): sandbox files are never served
+ * publicly — their `url` is a short-lived signed /f/:key URL.
*/
*/
'use strict';
'use strict';
@@ -48,6 +53,8 @@ function filePathForKey(row) {
@@ -48,6 +53,8 @@ function filePathForKey(row) {
}
}
function filePublic(row) {
function filePublic(row) {
+ const sandbox = db.isSandboxTenant(row.app_id);
+ const signed = sandbox ? require('../objects/signing').signedFileUrl(row.key) : null;
return {
return {
key: row.key,
key: row.key,
app_id: row.app_id,
app_id: row.app_id,
@@ -56,7 +63,8 @@ function filePublic(row) {
@@ -56,7 +63,8 @@ function filePublic(row) {
size: row.size,
size: row.size,
mime: row.mime,
mime: row.mime,
sha256: row.sha256,
sha256: row.sha256,
- url: `/f/${row.key}`,
+ url: signed ? signed.url : `/f/${row.key}`,
+ ...(sandbox ? { sandbox: true, url_expires_at: signed.expires_at } : {}),
created_at: row.created_at,
created_at: row.created_at,
};
};
}
}
@@ -76,10 +84,10 @@ router.post('/', tenantAuth({ allowUser: true, capability: 'media.object.upload'
@@ -76,10 +84,10 @@ router.post('/', tenantAuth({ allowUser: true, capability: 'media.object.upload'
try {
try {
if (!req.file) return res.status(400).json({ error: 'No file uploaded (multipart field: file)' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded (multipart field: file)' });
- // Per-app quota check against quota_bytes (0 = unlimited).
+ // Per-app quota check against quota_bytes (0 = unlimited): files + native objects.
const quota = Number(req.appRow.quota_bytes) || 0;
const quota = Number(req.appRow.quota_bytes) || 0;
if (quota > 0) {
if (quota > 0) {
- const used = db.appFilesBytes(req.appId);
+ const used = require('../objects/model').usedBytes(req.appId);
if (used + req.file.size > quota) {
if (used + req.file.size > quota) {
try { fs.unlinkSync(req.file.path); } catch { /* */ }
try { fs.unlinkSync(req.file.path); } catch { /* */ }
return res.status(413).json({
return res.status(413).json({
@@ -92,7 +100,10 @@ router.post('/', tenantAuth({ allowUser: true, capability: 'media.object.upload'
@@ -92,7 +100,10 @@ router.post('/', tenantAuth({ allowUser: true, capability: 'media.object.upload'
const digest = await sha256File(req.file.path);
const digest = await sha256File(req.file.path);
const name = sanitizeName(req.file.originalname);
const name = sanitizeName(req.file.originalname);
- const key = `${digest.slice(0, 12)}-${name}`;
+ // Keys are global. A developer-project tenant's keys carry a tag of the tenant id, so two
+ // tenants never collide on (and so never learn about) each other's identical uploads.
+ const tag = req.appRow.project_id ? `${crypto.createHash('sha256').update(`tenant:${req.appId}`).digest('hex').slice(0, 8)}-` : '';
+ const key = `${digest.slice(0, 12)}-${tag}${name}`;
const existing = db.getFileByKey(key);
const existing = db.getFileByKey(key);
if (existing) {
if (existing) {
@@ -136,14 +147,14 @@ router.post('/', tenantAuth({ allowUser: true, capability: 'media.object.upload'
@@ -136,14 +147,14 @@ router.post('/', tenantAuth({ allowUser: true, capability: 'media.object.upload'
});
});
// ── List ─────────────────────────────────────────────────────
// ── List ─────────────────────────────────────────────────────
-router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
+router.get('/', tenantAuth({ allowUser: true, capability: 'media.object.read' }), (req, res) => {
try {
try {
const limit = Math.min(Math.max(parseInt(req.query.limit || '100', 10), 1), 500);
const limit = Math.min(Math.max(parseInt(req.query.limit || '100', 10), 1), 500);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
const files = db.listFiles(req.appId, { limit, offset });
const files = db.listFiles(req.appId, { limit, offset });
res.json({
res.json({
files: files.map(filePublic),
files: files.map(filePublic),
- used_bytes: db.appFilesBytes(req.appId),
+ used_bytes: require('../objects/model').usedBytes(req.appId),
quota_bytes: Number(req.appRow.quota_bytes) || 0,
quota_bytes: Number(req.appRow.quota_bytes) || 0,
limit, offset,
limit, offset,
});
});
@@ -153,7 +164,7 @@ router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
@@ -153,7 +164,7 @@ router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
});
});
// ── Meta ─────────────────────────────────────────────────────
// ── Meta ─────────────────────────────────────────────────────
-router.get('/:key', tenantAuth({ allowUser: true }), (req, res) => {
+router.get('/:key', tenantAuth({ allowUser: true, capability: 'media.object.read' }), (req, res) => {
try {
try {
const row = db.getFileByKey(String(req.params.key), req.appId);
const row = db.getFileByKey(String(req.params.key), req.appId);
if (!row) return res.status(404).json({ error: 'File not found' });
if (!row) return res.status(404).json({ error: 'File not found' });
@@ -164,7 +175,7 @@ router.get('/:key', tenantAuth({ allowUser: true }), (req, res) => {
@@ -164,7 +175,7 @@ router.get('/:key', tenantAuth({ allowUser: true }), (req, res) => {
});
});
// ── Delete ───────────────────────────────────────────────────
// ── Delete ───────────────────────────────────────────────────
-router.delete('/:key', tenantAuth({ allowUser: true }), (req, res) => {
+router.delete('/:key', tenantAuth({ allowUser: true, capability: 'media.object.upload' }), (req, res) => {
try {
try {
const row = db.getFileByKey(String(req.params.key), req.appId);
const row = db.getFileByKey(String(req.params.key), req.appId);
if (!row) return res.status(404).json({ error: 'File not found' });
if (!row) return res.status(404).json({ error: 'File not found' });
@@ -522,12 +522,14 @@ function objectPublic(obj, { locations = true } = {}) {
@@ -522,12 +522,14 @@ function objectPublic(obj, { locations = true } = {}) {
content_hash: obj.content_hash || null,
content_hash: obj.content_hash || null,
metadata: parseJson(obj.metadata, {}),
metadata: parseJson(obj.metadata, {}),
held: isHeld(obj.id),
held: isHeld(obj.id),
- public_url: obj.visibility !== 'private' && obj.lifecycle_status === 'ready'
+ // Developer-project sandbox objects have no public URL: /download hands out signed ones.
+ public_url: obj.visibility !== 'private' && obj.lifecycle_status === 'ready' && !db.isSandboxTenant(obj.app_id)
? (legacyPublicUrl(obj) || `${config.publicUrl}/o/${obj.id}`) : null,
? (legacyPublicUrl(obj) || `${config.publicUrl}/o/${obj.id}`) : null,
created_at: obj.created_at,
created_at: obj.created_at,
updated_at: obj.updated_at,
updated_at: obj.updated_at,
deleted_at: obj.deleted_at || null,
deleted_at: obj.deleted_at || null,
};
};
+ if (db.isSandboxTenant(obj.app_id)) out.sandbox = true;
// Where the bytes are, never the keys or paths themselves.
// Where the bytes are, never the keys or paths themselves.
if (locations) {
if (locations) {
out.locations = listLocations(obj.id).map(l => ({
out.locations = listLocations(obj.id).map(l => ({
@@ -14,7 +14,9 @@
@@ -14,7 +14,9 @@
* DELETE /:id/holds/:holdId release it (app key only)
* DELETE /:id/holds/:holdId release it (app key only)
*
*
* Auth: the app's API key, or a Network service token granting media.object.upload
* Auth: the app's API key, or a Network service token granting media.object.upload
- * (writes) / media.object.read (reads) for namespace = :app. X-OV-Subject names the
+ * (writes) / media.object.read (reads) for namespace = :app. Developer-project tenants
+ * (:app = prj_<ULID>) take their project's app tokens only (server/auth.js); their sandbox
+ * objects are served only through signed URLs, whatever their visibility. X-OV-Subject names the
* owner (usr_…); X-OV-User-Id (app key only) acts as one of the app's users. The
* owner (usr_…); X-OV-User-Id (app key only) acts as one of the app's users. The
* content PUT and complete also accept the upload token handed out at init, so a
* content PUT and complete also accept the upload token handed out at init, so a
* browser can send the bytes directly.
* browser can send the bytes directly.
@@ -34,7 +36,7 @@ const config = require('../config');
@@ -34,7 +36,7 @@ const config = require('../config');
const model = require('./model');
const model = require('./model');
const invariant = require('./invariant');
const invariant = require('./invariant');
const signing = require('./signing');
const signing = require('./signing');
-const { tenantAuth, tenantCors } = require('../auth');
+const { tenantAuth, tenantCors, tenantPath } = require('../auth');
const { sendWebhook } = require('../webhooks');
const { sendWebhook } = require('../webhooks');
const MB = 1024 * 1024;
const MB = 1024 * 1024;
@@ -75,10 +77,15 @@ function load(req, res) {
@@ -75,10 +77,15 @@ function load(req, res) {
function contentAuth(req, res, next) {
function contentAuth(req, res, next) {
const token = req.query.token || req.headers['x-upload-token'];
const token = req.query.token || req.headers['x-upload-token'];
if (!token) return upload(req, res, next);
if (!token) return upload(req, res, next);
- const app = db.getApp(String(req.params.app || ''));
- if (!app) return res.status(404).json({ error: 'Unknown app' });
- if (!signing.verifyUploadToken(String(req.params.id || ''), token)) return problem(res, 401, 'media.upload_token.invalid', 'Upload token is invalid or expired');
+ const id = String(req.params.id || '');
+ if (!signing.verifyUploadToken(id, token)) return problem(res, 401, 'media.upload_token.invalid', 'Upload token is invalid or expired');
+ // The token names the object; its tenant must be the one the URL addresses (a developer-project
+ // tenant is addressed by its project id, whichever of its production/sandbox rows holds the object).
+ const obj = model.getObject(id);
+ const app = obj ? db.getApp(obj.app_id) : null;
+ if (!app || tenantPath(app) !== String(req.params.app || '')) return res.status(404).json({ error: 'Unknown app' });
req.appId = app.app_id;
req.appId = app.app_id;
+ req.appPath = tenantPath(app);
req.appRow = app;
req.appRow = app;
req.authType = 'upload_token';
req.authType = 'upload_token';
next();
next();
@@ -221,11 +228,11 @@ router.post('/', upload, (req, res) => {
@@ -221,11 +228,11 @@ router.post('/', upload, (req, res) => {
object: model.objectPublic(model.getObject(id)),
object: model.objectPublic(model.getObject(id)),
upload: {
upload: {
method: 'PUT',
method: 'PUT',
- url: `${config.publicUrl}/api/v2/${encodeURIComponent(req.appId)}/objects/${id}/content?token=${encodeURIComponent(tok.token)}`,
+ url: `${config.publicUrl}/api/v2/${encodeURIComponent(req.appPath || req.appId)}/objects/${id}/content?token=${encodeURIComponent(tok.token)}`,
token: tok.token,
token: tok.token,
expires_at: tok.expires_at,
expires_at: tok.expires_at,
max_bytes: size || max,
max_bytes: size || max,
- complete_url: `${config.publicUrl}/api/v2/${encodeURIComponent(req.appId)}/objects/${id}/complete`,
+ complete_url: `${config.publicUrl}/api/v2/${encodeURIComponent(req.appPath || req.appId)}/objects/${id}/complete`,
},
},
});
});
} catch (err) {
} catch (err) {
@@ -327,7 +334,8 @@ router.get('/:id/download', read, (req, res) => {
@@ -327,7 +334,8 @@ router.get('/:id/download', read, (req, res) => {
if (obj.lifecycle_status !== 'ready') return problem(res, 409, 'media.object.not_ready', `Object is ${obj.lifecycle_status}`);
if (obj.lifecycle_status !== 'ready') return problem(res, 409, 'media.object.not_ready', `Object is ${obj.lifecycle_status}`);
const json = req.query.format === 'json';
const json = req.query.format === 'json';
res.set('Cache-Control', 'private, no-store');
res.set('Cache-Control', 'private, no-store');
- if (obj.visibility !== 'private') {
+ // Developer-project sandbox objects are never public, whatever their visibility: always signed.
+ if (obj.visibility !== 'private' && !db.isSandboxTenant(obj.app_id)) {
const url = model.legacyPublicUrl(obj) || `${config.publicUrl}/o/${obj.id}`;
const url = model.legacyPublicUrl(obj) || `${config.publicUrl}/o/${obj.id}`;
return json ? res.json({ url, expires_at: null, public: true }) : res.redirect(302, url);
return json ? res.json({ url, expires_at: null, public: true }) : res.redirect(302, url);
}
}
@@ -374,8 +382,10 @@ publicRouter.get('/:id', async (req, res) => {
@@ -374,8 +382,10 @@ publicRouter.get('/:id', async (req, res) => {
if (obj.lifecycle_status === 'deleted') return res.status(410).json({ error: 'Gone' });
if (obj.lifecycle_status === 'deleted') return res.status(410).json({ error: 'Gone' });
if (obj.lifecycle_status !== 'ready') return res.status(404).json({ error: 'Not found' });
if (obj.lifecycle_status !== 'ready') return res.status(404).json({ error: 'Not found' });
const signed = !!req.query.sig && signing.verifyDownload(obj.id, req.query.exp, req.query.sig);
const signed = !!req.query.sig && signing.verifyDownload(obj.id, req.query.exp, req.query.sig);
- // Private objects are indistinguishable from missing ones without a valid signature.
- if (obj.visibility === 'private' && !signed) return res.status(404).json({ error: 'Not found' });
+ // Private objects (and every developer-project sandbox object) are indistinguishable from
+ // missing ones without a valid signature.
+ const sandbox = db.isSandboxTenant(obj.app_id);
+ if ((obj.visibility === 'private' || sandbox) && !signed) return res.status(404).json({ error: 'Not found' });
const mime = obj.mime_type || 'application/octet-stream';
const mime = obj.mime_type || 'application/octet-stream';
const md = model.parseJson(obj.metadata, {});
const md = model.parseJson(obj.metadata, {});
@@ -384,7 +394,7 @@ publicRouter.get('/:id', async (req, res) => {
@@ -384,7 +394,7 @@ publicRouter.get('/:id', async (req, res) => {
'Content-Type': mime,
'Content-Type': mime,
'X-Content-Type-Options': 'nosniff',
'X-Content-Type-Options': 'nosniff',
'X-Robots-Tag': 'noindex',
'X-Robots-Tag': 'noindex',
- 'Cache-Control': obj.visibility === 'private' ? 'private, no-store' : 'public, max-age=3600',
+ 'Cache-Control': obj.visibility === 'private' || sandbox ? 'private, no-store' : 'public, max-age=3600',
'Content-Disposition': `${INLINE.test(mime) ? 'inline' : 'attachment'}; filename="${name}"`,
'Content-Disposition': `${INLINE.test(mime) ? 'inline' : 'attachment'}; filename="${name}"`,
};
};
const locs = model.listLocations(obj.id);
const locs = model.listLocations(obj.id);
@@ -48,6 +48,25 @@ function verifyDownload(objectId, exp, sig) {
@@ -48,6 +48,25 @@ function verifyDownload(objectId, exp, sig) {
return safeEqual(sig, mac('get', objectId, e));
return safeEqual(sig, mac('get', objectId, e));
}
}
+/**
+ * Short-lived GET URL for a v1 file (/f/:key). Its own purpose ('getf') and id space (file:<key>), so
+ * a file signature is never valid for an object or the other way round. Used for developer-project
+ * sandbox files, which are never served without one.
+ */
+function signedFileUrl(key, ttlS = config.objects.signedUrlTtlS) {
+ const exp = nowS() + Math.min(3600, Math.max(30, Number(ttlS) || config.objects.signedUrlTtlS));
+ return {
+ url: `${config.publicUrl}/f/${encodeURIComponent(key)}?exp=${exp}&sig=${mac('getf', `file:${key}`, exp)}`,
+ expires_at: new Date(exp * 1000).toISOString(),
+ };
+}
+
+function verifyFile(key, exp, sig) {
+ const e = Number(exp);
+ if (!Number.isInteger(e) || e < nowS()) return false;
+ return safeEqual(sig, mac('getf', `file:${key}`, e));
+}
+
/** Token for PUT /api/v2/:app/objects/:id/content: "<exp>.<mac>". */
/** Token for PUT /api/v2/:app/objects/:id/content: "<exp>.<mac>". */
function uploadToken(objectId, ttlS = config.objects.uploadTokenTtlS) {
function uploadToken(objectId, ttlS = config.objects.uploadTokenTtlS) {
const exp = nowS() + Math.max(60, Number(ttlS) || 3600);
const exp = nowS() + Math.max(60, Number(ttlS) || 3600);
@@ -62,4 +81,4 @@ function verifyUploadToken(objectId, token) {
@@ -62,4 +81,4 @@ function verifyUploadToken(objectId, token) {
return safeEqual(m[2], mac('put', objectId, exp));
return safeEqual(m[2], mac('put', objectId, exp));
}
}
-module.exports = { signedDownloadUrl, verifyDownload, uploadToken, verifyUploadToken };
+module.exports = { signedDownloadUrl, verifyDownload, signedFileUrl, verifyFile, uploadToken, verifyUploadToken };
@@ -97,6 +97,8 @@ function thumbCard(name, mtimeMs) {
@@ -97,6 +97,8 @@ function thumbCard(name, mtimeMs) {
// ── Data per tab ─────────────────────────────────────────────
// ── Data per tab ─────────────────────────────────────────────
const V_WHERE = "is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0";
const V_WHERE = "is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0";
const C_WHERE = "COALESCE(is_public,1) = 1";
const C_WHERE = "COALESCE(is_public,1) = 1";
+// Developer-project tenants' files (ADR-014) are theirs to share; the gallery lists first-party files only.
+const F_WHERE = "app_id NOT IN (SELECT app_id FROM apps WHERE project_id IS NOT NULL)";
const P_WHERE = "visibility = 'public'";
const P_WHERE = "visibility = 'public'";
let _thumbListCache = { at: 0, list: [] };
let _thumbListCache = { at: 0, list: [] };
@@ -135,8 +137,8 @@ function fetchTab(tab, page) {
@@ -135,8 +137,8 @@ function fetchTab(tab, page) {
cards: db.all(`SELECT * FROM pastes WHERE ${P_WHERE} AND COALESCE(type,'paste') <> 'screenshot' ${L}`).map(pasteCard),
cards: db.all(`SELECT * FROM pastes WHERE ${P_WHERE} AND COALESCE(type,'paste') <> 'screenshot' ${L}`).map(pasteCard),
};
};
case 'files': return {
case 'files': return {
- total: db.get('SELECT COUNT(*) c FROM files').c,
- cards: db.all(`SELECT * FROM files ${L}`).map(fileCard),
+ total: db.get(`SELECT COUNT(*) c FROM files WHERE ${F_WHERE}`).c,
+ cards: db.all(`SELECT * FROM files WHERE ${F_WHERE} ${L}`).map(fileCard),
};
};
case 'emotes': return {
case 'emotes': return {
total: db.get("SELECT COUNT(*) c FROM assets WHERE kind = 'emote'").c,
total: db.get("SELECT COUNT(*) c FROM assets WHERE kind = 'emote'").c,
@@ -183,7 +185,7 @@ function tabCounts() {
@@ -183,7 +185,7 @@ function tabCounts() {
clips: c(`SELECT COUNT(*) c FROM clips WHERE ${C_WHERE}`),
clips: c(`SELECT COUNT(*) c FROM clips WHERE ${C_WHERE}`),
images: c(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND type = 'screenshot'`),
images: c(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND type = 'screenshot'`),
text: c(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND COALESCE(type,'paste') <> 'screenshot'`),
text: c(`SELECT COUNT(*) c FROM pastes WHERE ${P_WHERE} AND COALESCE(type,'paste') <> 'screenshot'`),
- files: c('SELECT COUNT(*) c FROM files'),
+ files: c(`SELECT COUNT(*) c FROM files WHERE ${F_WHERE}`),
emotes: c("SELECT COUNT(*) c FROM assets WHERE kind = 'emote'"),
emotes: c("SELECT COUNT(*) c FROM assets WHERE kind = 'emote'"),
sounds: c("SELECT COUNT(*) c FROM assets WHERE kind = 'sound'"),
sounds: c("SELECT COUNT(*) c FROM assets WHERE kind = 'sound'"),
thumbnails: thumbList().length,
thumbnails: thumbList().length,
@@ -389,6 +389,10 @@ router.get('/f/:key', (req, res) => {
@@ -389,6 +389,10 @@ router.get('/f/:key', (req, res) => {
try {
try {
const row = db.getFileByKey(String(req.params.key));
const row = db.getFileByKey(String(req.params.key));
if (!row) return res.status(404).json({ error: 'Not found' });
if (!row) return res.status(404).json({ error: 'Not found' });
+ // Developer-project sandbox files are never public: only a valid signed URL (from the
+ // Files API) serves them, and a missing signature looks exactly like a missing file.
+ const sandbox = db.isSandboxTenant(row.app_id);
+ if (sandbox && !require('../objects/signing').verifyFile(row.key, req.query.exp, req.query.sig)) return res.status(404).json({ error: 'Not found' });
const filesRoutes = require('../files/routes');
const filesRoutes = require('../files/routes');
const filePath = filesRoutes.filePathForKey(row);
const filePath = filesRoutes.filePathForKey(row);
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Not found' });
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Not found' });
@@ -398,7 +402,7 @@ router.get('/f/:key', (req, res) => {
@@ -398,7 +402,7 @@ router.get('/f/:key', (req, res) => {
const contentType = row.mime || 'application/octet-stream';
const contentType = row.mime || 'application/octet-stream';
const headers = {
const headers = {
'X-Robots-Tag': 'noindex',
'X-Robots-Tag': 'noindex',
- 'Cache-Control': 'public, max-age=86400',
+ 'Cache-Control': sandbox ? 'private, no-store' : 'public, max-age=86400',
};
};
if (range) {
if (range) {
const parts = range.replace(/bytes=/, '').split('-');
const parts = range.replace(/bytes=/, '').split('-');
new file mode 100644
new file mode 100644
@@ -0,0 +1,265 @@
@@ -0,0 +1,265 @@
+'use strict';
+// Developer-project tenants (roadmap Wave 20, ADR-014): app tokens (sub app:app_<ULID>, project_id,
+// env) with media.object.upload|read and ns [project_id] get a tenant keyed by the project id, created
+// on first use with a small default quota (sandbox smaller). Production and sandbox are separate
+// tenants behind the same URL; no other credential reaches them, and they reach nothing else.
+// Sandbox tokens are accepted on these routes only; sandbox content is never served publicly.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const crypto = require('crypto');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-apptenants-'));
+const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
+process.env.DB_PATH = path.join(tmp, 'media.db');
+process.env.VOD_PATH = dir('vods');
+process.env.FILES_PATH = dir('files');
+process.env.OBJECTS_PATH = dir('objects');
+process.env.THUMBNAILS_PATH = dir('thumbnails');
+process.env.OV_NETWORK_URL = 'https://openvibe.network';
+process.env.MEDIA_PUBLIC_URL = 'https://media.test';
+process.env.MEDIA_SIGNING_SECRET = 'test-signing-secret';
+process.env.MEDIA_APP_TENANT_QUOTA_MB = '2';
+process.env.MEDIA_APP_SANDBOX_QUOTA_MB = '1';
+
+const db = require('../server/db/database');
+const auth = require('../server/auth');
+const objectRoutes = require('../server/objects/routes');
+const { serviceAuth, ids } = require('openvibe-contracts');
+const express = require('express');
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+auth._setNetworkPublicKeyForTests(keys.publicKey);
+db.upsertApp({ app_id: 'live', api_key: 'live-key' });
+auth.ensureTokenOnlyApps();
+
+const PA = `prj_${ids.ulid()}`;
+const PB = `prj_${ids.ulid()}`;
+const PC = `prj_${ids.ulid()}`;
+const APP_A = `app_${ids.ulid()}`;
+const APP_B = `app_${ids.ulid()}`;
+const now = Math.floor(Date.now() / 1000);
+const BOTH = ['media.object.upload', 'media.object.read'];
+const appTok = ({ project = PA, app = APP_A, env = 'production', cap = BOTH, ns, ...over } = {}) => serviceAuth.signServiceToken({
+ iss: 'https://openvibe.network', sub: `app:${app}`, actor_type: 'app', aud: ['openvibe.media'], cap, ns: ns || [project],
+ project_id: project, env, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}`, ...over,
+}, keys.privateKey);
+const svcTok = (over = {}) => serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:community', actor_type: 'service', aud: ['openvibe.media'], cap: BOTH, ns: ['community'], iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}`, ...over }, keys.privateKey);
+
+// Same mount order as server/index.js.
+const app = express();
+app.put('/api/v2/:app/objects/:id/content', ...objectRoutes.contentHandlers);
+app.use(express.json());
+app.use('/api/v1/:app/files', require('../server/files/routes'));
+app.use('/api/v1/:app/vods', require('../server/vod/routes'));
+app.use('/api/v2/:app/objects', objectRoutes);
+app.use('/o', objectRoutes.publicRouter);
+app.use('/', require('../server/public/routes'));
+const server = http.createServer(app);
+
+(async () => {
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const local = (u) => base + String(u).replace('https://media.test', '');
+ const req = async (method, p, { bearer, json, form, raw, headers = {} } = {}) => {
+ const h = { ...headers };
+ if (bearer) h.authorization = `Bearer ${bearer}`;
+ let body;
+ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
+ if (form) body = form;
+ if (raw !== undefined) body = raw;
+ const r = await fetch(p.startsWith('http') ? p : base + p, { method, headers: h, body, redirect: 'manual' });
+ const text = await r.text();
+ let parsed = null;
+ try { parsed = JSON.parse(text); } catch { /* bytes */ }
+ return { status: r.status, body: parsed, text, headers: r.headers };
+ };
+ const upload = (project, bearer, content = 'hello from a developer app', name = 'note.txt') => {
+ const fd = new FormData();
+ fd.append('file', new Blob([Buffer.from(content)], { type: 'text/plain' }), name);
+ return req('POST', `/api/v1/${project}/files`, { bearer, form: fd });
+ };
+ const tenants = () => db.all("SELECT app_id, project_id, env, quota_bytes, api_key_hash FROM apps WHERE app_id LIKE 'prj_%' ORDER BY app_id");
+
+ // ── First use creates the tenant; nothing exists before ──
+ assert.strictEqual(tenants().length, 0);
+ let r = await req('GET', `/api/v1/${PA}/files`, { bearer: appTok({ cap: ['media.object.upload'] }) });
+ assert.strictEqual(r.status, 403, 'list needs media.object.read');
+ assert.strictEqual(r.body.code, 'capability.denied');
+ assert.strictEqual(tenants().length, 0, 'a refused call creates no tenant');
+ r = await req('GET', `/api/v1/${PB}/files`, { bearer: appTok() });
+ assert.strictEqual(r.status, 403, "project A's token on project B's path");
+ assert.strictEqual(r.body.code, 'capability.namespace_denied');
+ r = await req('GET', `/api/v1/${PB}/files`, { bearer: appTok({ ns: [PA, PB] }) });
+ assert.strictEqual(r.status, 403, 'the token project_id decides, not a widened ns');
+ assert.strictEqual(tenants().length, 0, 'wrong project never creates a tenant');
+
+ r = await upload(PA, appTok());
+ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
+ const prodFile = r.body;
+ assert.strictEqual(prodFile.url, `/f/${prodFile.key}`, 'production app files are public like any tenant');
+ assert.ok(!prodFile.sandbox);
+ r = await upload(PA, appTok({ env: 'sandbox' }), 'sandbox bytes');
+ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
+ const sbFile = r.body;
+ assert.strictEqual(sbFile.sandbox, true);
+ assert.ok(/^https:\/\/media\.test\/f\/.+\?exp=\d+&sig=/.test(sbFile.url), 'sandbox files get a signed URL');
+ assert.ok(sbFile.url_expires_at);
+
+ const t = tenants();
+ assert.deepStrictEqual(t.map(x => [x.app_id, x.project_id, x.env]), [[PA, PA, 'production'], [`${PA}-sandbox`, PA, 'sandbox']], 'separate production and sandbox tenants');
+ assert.strictEqual(t[0].quota_bytes, 2 * 1024 * 1024);
+ assert.strictEqual(t[1].quota_bytes, 1 * 1024 * 1024, 'sandbox quota is smaller');
+ assert.ok(t.every(x => x.api_key_hash === ''), 'no API key for project tenants');
+ assert.ok(fs.existsSync(path.join(process.env.FILES_PATH, `${PA}-sandbox`)), 'sandbox bytes live in their own tenant directory');
+
+ // ── Separation between environments and projects ──
+ r = await req('GET', `/api/v1/${PA}/files`, { bearer: appTok() });
+ assert.deepStrictEqual(r.body.files.map(f => f.key), [prodFile.key], 'production sees only production');
+ r = await req('GET', `/api/v1/${PA}/files`, { bearer: appTok({ env: 'sandbox' }) });
+ assert.deepStrictEqual(r.body.files.map(f => f.key), [sbFile.key], 'sandbox sees only sandbox');
+ r = await req('GET', `/api/v1/${PA}/files/${encodeURIComponent(prodFile.key)}`, { bearer: appTok({ env: 'sandbox' }) });
+ assert.strictEqual(r.status, 404, 'sandbox token cannot read a production file');
+ r = await req('DELETE', `/api/v1/${PA}/files/${encodeURIComponent(sbFile.key)}`, { bearer: appTok() });
+ assert.strictEqual(r.status, 404, 'production token cannot delete a sandbox file');
+ r = await req('GET', `/api/v1/${PA}-sandbox/files`, { bearer: appTok({ env: 'sandbox', ns: [PA, `${PA}-sandbox`] }) });
+ assert.strictEqual(r.status, 401, 'the sandbox tenant id is not addressable by path, even with a widened ns');
+ assert.strictEqual(r.body.code, 'token.sandbox_refused');
+ r = await req('GET', `/api/v1/${PA}-sandbox/files`, { bearer: appTok({ ns: [PA, `${PA}-sandbox`] }) });
+ assert.strictEqual(r.status, 403, 'nor with a production token');
+ r = await req('GET', `/api/v1/${PA}-sandbox/files`, { bearer: svcTok({ ns: ['*'] }) });
+ assert.strictEqual(r.status, 404, 'nor with a first-party service token');
+ r = await req('GET', `/api/v1/${PA}/files`, { bearer: svcTok({ ns: ['*'] }) });
+ assert.strictEqual(r.status, 404, 'first-party service tokens never reach project tenants');
+
+ r = await upload(PB, appTok({ project: PB, app: APP_B }), 'project b');
+ assert.strictEqual(r.status, 201);
+ const bFile = r.body;
+ r = await req('GET', `/api/v1/${PB}/files/${encodeURIComponent(prodFile.key)}`, { bearer: appTok({ project: PB, app: APP_B }) });
+ assert.strictEqual(r.status, 404, "B cannot read A's file through its own path");
+ for (const [m, p] of [['GET', `/api/v1/${PB}/files`], ['GET', `/api/v1/${PB}/files/${encodeURIComponent(bFile.key)}`], ['DELETE', `/api/v1/${PB}/files/${encodeURIComponent(bFile.key)}`]]) {
+ r = await req(m, p, { bearer: appTok() });
+ assert.strictEqual(r.status, 403, `A cannot ${m} ${p}`);
+ }
+ r = await upload(PB, appTok());
+ assert.strictEqual(r.status, 403, 'A cannot upload into B');
+ r = await upload(PA, appTok({ project: PB, app: APP_B }), 'same content as A', 'note.txt');
+ assert.strictEqual(r.status, 403);
+ // Identical bytes + name in two tenants never collide on the (global) key.
+ r = await upload(PB, appTok({ project: PB, app: APP_B }));
+ assert.strictEqual(r.status, 201);
+ assert.notStrictEqual(r.body.key, prodFile.key, 'per-tenant key tag');
+
+ // ── App tokens reach nothing else; sandbox tokens are refused off the app routes ──
+ r = await upload('community', appTok({ ns: ['community'] }));
+ assert.strictEqual(r.status, 403, 'an app token never reaches a first-party tenant');
+ r = await upload('community', appTok({ env: 'sandbox', ns: ['community'] }));
+ assert.strictEqual(r.status, 401);
+ assert.strictEqual(r.body.code, 'token.sandbox_refused', 'sandbox token on a first-party tenant');
+ r = await req('GET', `/api/v1/${PA}/vods`, { bearer: appTok({ env: 'sandbox' }) });
+ assert.strictEqual(r.status, 401);
+ assert.strictEqual(r.body.code, 'token.sandbox_refused', 'sandbox token on a route that is not an app-tenant route');
+ r = await req('GET', `/api/v1/${PA}/vods`, { bearer: appTok() });
+ assert.strictEqual(r.status, 403, 'production app token on vods');
+ r = await req('GET', '/api/v1/live/vods', { bearer: appTok({ ns: ['live'] }) });
+ assert.strictEqual(r.status, 403);
+ r = await req('GET', `/api/v1/${PA}/files`, { bearer: 'live-key' });
+ assert.strictEqual(r.status, 404, "an app key never unlocks a project tenant");
+ r = await req('GET', `/api/v1/${PA}/files`, { bearer: appTok({ aud: ['openvibe.events'] }) });
+ assert.strictEqual(r.status, 404, 'a token for another audience is no credential here');
+
+ // svc: tokens keep their behaviour on first-party tenants.
+ r = await upload('community', svcTok(), 'community bytes');
+ assert.strictEqual(r.status, 201);
+ const communityFile = r.body;
+ r = await upload('live', svcTok());
+ assert.strictEqual(r.body.code, 'capability.namespace_denied');
+
+ // ── Quotas ──
+ r = await upload(PA, appTok({ env: 'sandbox' }), Buffer.alloc(1024 * 1024 + 10, 1).toString(), 'big.bin');
+ assert.strictEqual(r.status, 413, 'sandbox quota (1 MB) enforced');
+ r = await upload(PA, appTok(), Buffer.alloc(1024 * 1024 + 10, 1).toString(), 'big.bin');
+ assert.strictEqual(r.status, 201, 'the production quota (2 MB) is larger');
+ r = await upload(PA, appTok(), Buffer.alloc(1024 * 1024 + 10, 2).toString(), 'big2.bin');
+ assert.strictEqual(r.status, 413, 'production quota enforced');
+ r = await req('POST', `/api/v2/${PA}/objects`, { bearer: appTok({ env: 'sandbox' }), json: { size_bytes: 1024 * 1024 } });
+ assert.strictEqual(r.status, 413, 'objects v2 counts files against the same quota');
+
+ // ── Public serving ──
+ r = await req('GET', `/f/${encodeURIComponent(prodFile.key)}`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.text, 'hello from a developer app');
+ r = await req('GET', `/f/${encodeURIComponent(communityFile.key)}`);
+ assert.strictEqual(r.status, 200);
+ r = await req('GET', `/f/${encodeURIComponent(sbFile.key)}`);
+ assert.strictEqual(r.status, 404, 'sandbox file is not public');
+ r = await req('GET', `/f/${encodeURIComponent(sbFile.key)}?exp=${now + 60}&sig=forged`);
+ assert.strictEqual(r.status, 404, 'forged signature');
+ r = await req('GET', local(sbFile.url));
+ assert.strictEqual(r.status, 200, 'the signed URL serves it');
+ assert.strictEqual(r.text, 'sandbox bytes');
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ // A download signature for an object id is not a file signature.
+ const objSig = require('../server/objects/signing').signedDownloadUrl(sbFile.key);
+ r = await req('GET', `/f/${encodeURIComponent(sbFile.key)}${new URL(objSig.url).search}`);
+ assert.strictEqual(r.status, 404, 'purposes are separate');
+ // The v2 projection of a sandbox file has no public URL either.
+ const proj = db.get('SELECT object_id FROM files WHERE key = ?', [sbFile.key]);
+ r = await req('GET', `/api/v2/${PA}/objects/${proj.object_id}`, { bearer: appTok({ env: 'sandbox' }) });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.public_url, null);
+ assert.strictEqual(r.body.sandbox, true);
+ r = await req('GET', `/o/${proj.object_id}`);
+ assert.strictEqual(r.status, 404);
+
+ // ── Objects v2 in a sandbox tenant: public visibility still needs a signature ──
+ const bytes = Buffer.from('sandbox object bytes');
+ r = await req('POST', `/api/v2/${PA}/objects`, { bearer: appTok({ env: 'sandbox' }), json: { visibility: 'public', size_bytes: bytes.length, mime_type: 'text/plain' } });
+ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
+ const obj = r.body;
+ assert.ok(obj.upload.url.includes(`/api/v2/${PA}/objects/`), 'upload URL addresses the project, never the internal sandbox tenant id');
+ assert.ok(!obj.upload.url.includes('-sandbox'));
+ r = await req('PUT', local(obj.upload.url.replace(`/api/v2/${PA}/`, `/api/v2/${PB}/`)), { raw: bytes, headers: { 'content-type': 'text/plain' } });
+ assert.strictEqual(r.status, 404, 'an upload token only works under its own tenant path');
+ r = await req('PUT', local(obj.upload.url), { raw: bytes, headers: { 'content-type': 'text/plain' } });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ r = await req('POST', `/api/v2/${PA}/objects/${obj.id}/complete`, { bearer: appTok({ env: 'sandbox' }), json: {} });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.strictEqual(r.body.public_url, null, 'no public URL for a sandbox object');
+ r = await req('GET', `/o/${obj.id}`);
+ assert.strictEqual(r.status, 404, 'public visibility does not make a sandbox object public');
+ r = await req('GET', `/api/v2/${PA}/objects/${obj.id}/download?format=json`, { bearer: appTok({ env: 'sandbox' }) });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.public, false);
+ r = await req('GET', local(r.body.url));
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.text, 'sandbox object bytes');
+ r = await req('GET', `/api/v2/${PA}/objects/${obj.id}`, { bearer: appTok() });
+ assert.strictEqual(r.status, 404, 'production cannot see the sandbox object');
+ r = await req('GET', `/api/v2/${PB}/objects/${obj.id}`, { bearer: appTok({ project: PB, app: APP_B, env: 'sandbox' }) });
+ assert.strictEqual(r.status, 404, 'nor can another project');
+ r = await req('POST', `/api/v2/${PA}/objects/${obj.id}/holds`, { bearer: appTok({ env: 'sandbox' }), json: { kind: 'legal', reason: 'x' } });
+ assert.strictEqual(r.status, 401, 'holds are not an app-tenant route (sandbox refused)');
+
+ // Production object, public visibility: served openly.
+ r = await req('POST', `/api/v2/${PC}/objects`, { bearer: appTok({ project: PC }), json: { visibility: 'public', size_bytes: 3, mime_type: 'text/plain' } });
+ assert.strictEqual(r.status, 201);
+ await req('PUT', local(r.body.upload.url), { raw: Buffer.from('abc'), headers: { 'content-type': 'text/plain' } });
+ r = await req('POST', `/api/v2/${PC}/objects/${r.body.id}/complete`, { bearer: appTok({ project: PC }), json: {} });
+ assert.ok(r.body.public_url, 'production objects keep their public URL');
+ r = await req('GET', local(r.body.public_url));
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.text, 'abc');
+
+ // A seeded API key can never be attached to a project tenant.
+ assert.throws(() => db.upsertApp({ app_id: PA, api_key: 'x' }), /developer-project tenant/);
+ // The Media gallery lists first-party files only.
+ const browse = await req('GET', '/browse?tab=files');
+ assert.ok(!browse.text.includes(sbFile.key) && !browse.text.includes(prodFile.key), 'project files are not in the gallery');
+
+ server.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ console.log('developer-project tenants: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -61,8 +61,11 @@ const server = http.createServer(app);
@@ -61,8 +61,11 @@ const server = http.createServer(app);
assert.strictEqual(r.status, 401);
assert.strictEqual(r.status, 401);
r = await upload('community', 'live-key');
r = await upload('community', 'live-key');
assert.strictEqual(r.status, 403, "live's key is not valid for the community tenant");
assert.strictEqual(r.status, 403, "live's key is not valid for the community tenant");
- const list = await fetch(`${base}/api/v1/community/files`, { headers: { authorization: `Bearer ${tok({ cap: ['media.object.upload', 'media.object.read'] })}` } });
- assert.strictEqual(list.status, 403, 'routes that name no capability refuse service tokens');
+ let list = await fetch(`${base}/api/v1/community/files`, { headers: { authorization: `Bearer ${tok()}` } });
+ assert.strictEqual(list.status, 403, 'listing needs media.object.read');
+ list = await fetch(`${base}/api/v1/community/files`, { headers: { authorization: `Bearer ${tok({ cap: ['media.object.upload', 'media.object.read'] })}` } });
+ assert.strictEqual(list.status, 200, 'listing with media.object.read');
+ assert.strictEqual((await list.json()).files.length, 1);
server.close();
server.close();
// ── Paste export bundle ──
// ── Paste export bundle ──