openvibers/openvibe.media Commits

Developer-project tenants (Wave 20, ADR-014): app tokens get a Media tenant per project

OP OpenVibers committed 23 Sept 2026 · bfc9e1bec8 · 17 files, +532 −53

- App tokens (sub app:app_<ULID>, project_id, env, ns [project_id]) holding media.object.upload/read
  get a tenant keyed by the project id, created on first use: production prj_<ULID>, sandbox
  prj_<ULID>-sandbox, both addressed as /api/v1/prj_<ULID>/files and /api/v2/prj_<ULID>/objects;
  the token's env picks the row. apps gains project_id and env; no API key is ever issued for them.
- Default quotas MEDIA_APP_TENANT_QUOTA_MB (1024) and MEDIA_APP_SANDBOX_QUOTA_MB (100); files v1
  uploads now count files + native objects like the objects API does.
- Only the project's own app tokens reach its tenants; app tokens reach nothing else. Sandbox tokens
  are accepted only on these routes (verifyServiceToken acceptSandbox), token.sandbox_refused elsewhere.
- Files list/meta accept media.object.read and delete accepts media.object.upload (principal tokens).
- Sandbox content is never public: /f/:key and /o/:id need a signature, the Files API returns a
  signed short-lived url, objects have no public_url and /download always signs; sandbox tenants
  emit no platform events. Project files are left out of the public gallery. v1 file keys in project
  tenants carry a tenant tag so tenants never collide.
- openvibe-contracts v0.26.0 (was v0.8.0).

Changes

ddiff --git a/.env.example b/.env.example
iindex 1a49df9..a0d7a41 100644
---- a/.env.example
++++ b/.env.example
@@@ -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=
ddiff --git a/README.md b/README.md
iindex 1e8ede8..f678c7e 100644
---- a/README.md
++++ b/README.md
@@@ -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 in
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/...`:
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 seeding
On every boot the service upserts apps from env (keys are hashed):
ddiff --git a/docs/object-model.md b/docs/object-model.md
iindex dc4d78e..256caf6 100644
---- a/docs/object-model.md
++++ b/docs/object-model.md
@@@ -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.**
ddiff --git a/package-lock.json b/package-lock.json
iindex ad232d3..8341ad4 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 9949800..030d273 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/auth.js b/server/auth.js
iindex 62c6fe1..b1205b7 100644
---- a/server/auth.js
++++ b/server/auth.js
@@@ -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,
ddiff --git a/server/config.js b/server/config.js
iindex e7dd093..4abdd01 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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)),
},
};
ddiff --git a/server/db/database.js b/server/db/database.js
iindex b4b97af..420a323 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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, allowed
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) {
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,
// vods
createVod, getVodById, getVodByFileBasename, listVods, countVods, setVodVisibility, vodStatus,
latestVodThumbsByManagedStreams, getAppStats, getAppStatSeries,
ddiff --git a/server/events.js b/server/events.js
iindex d5f1ff1..9094204 100644
---- a/server/events.js
++++ b/server/events.js
@@@ -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;
ddiff --git a/server/files/routes.js b/server/files/routes.js
iindex 32de5a2..00ab2e8 100644
---- a/server/files/routes.js
++++ b/server/files/routes.js
@@@ -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' });
ddiff --git a/server/objects/model.js b/server/objects/model.js
iindex 20af5b1..ccd1c93 100644
---- a/server/objects/model.js
++++ b/server/objects/model.js
@@@ -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 => ({
ddiff --git a/server/objects/routes.js b/server/objects/routes.js
iindex ea3444b..b63a893 100644
---- a/server/objects/routes.js
++++ b/server/objects/routes.js
@@@ -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);
ddiff --git a/server/objects/signing.js b/server/objects/signing.js
iindex 80916cb..4230a8a 100644
---- a/server/objects/signing.js
++++ b/server/objects/signing.js
@@@ -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 };
ddiff --git a/server/public/browse.js b/server/public/browse.js
iindex 98a9c4a..6c64f94 100644
---- a/server/public/browse.js
++++ b/server/public/browse.js
@@@ -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,
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex 89e0698..e975f2c 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -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('-');
ddiff --git a/test/app-tenants.test.js b/test/app-tenants.test.js
nnew file mode 100644
iindex 0000000..940c789
---- /dev/null
++++ b/test/app-tenants.test.js
@@@ -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); });
ddiff --git a/test/service-token.test.js b/test/service-token.test.js
iindex a1b682b..aba4ef6 100644
---- a/test/service-token.test.js
++++ b/test/service-token.test.js
@@@ -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 ──