openvibers/openvibe.network Commits

Add project placement: preferred regions and residency (#17)

OP OpenVibers committed 2 Oct 2026 · 835b681c04 · 4 files, +160 −1

local checks ✓ 3/3 · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.Network/pull/17

Changes

ddiff --git a/migrations/0015_project_regions.sql b/migrations/0015_project_regions.sql
nnew file mode 100644
iindex 00000000..6ad810e3
---- /dev/null
++++ b/migrations/0015_project_regions.sql
@@@ -0,0 +1,9 @@
++-- phase: expand
++-- plan T2. Project placement (docs/t2-cells-and-node-principal.md section 3.1, slice N3): a project's preferred
++-- regions, in preference order, as a JSON array of platform_regions.id (at most 5, unique). An array cannot carry a
++-- foreign key, so server/developer/store.js validates every id against platform_regions in the same transaction.
++-- dev_projects already has home_cell (0008); a project's residency is its home cell's and gets no column (decision A:
++-- one source of truth), it is derived on read. text, not jsonb, as every other JSON column here.
++
++ALTER TABLE dev_projects ADD COLUMN IF NOT EXISTS preferred_regions text COLLATE "C" NOT NULL DEFAULT '[]'
++ CHECK (preferred_regions LIKE '[%]');
ddiff --git a/server/developer/routes.js b/server/developer/routes.js
iindex 58d9ec1d..cbc77dbd 100644
---- a/server/developer/routes.js
++++ b/server/developer/routes.js
@@@ -16,6 +16,7 @@ const cache = require('openvibe-shared/cache-policy');
* POST /:project/archive owner or staff; revokes every app
* PUT /:project/allowance { capabilities: [] } staff
* PUT /:project/environment-policy { environment_policy } staff (sandbox | sandbox+production)
++ * PUT /:project/placement { preferred_regions, residency? } admin+ (home_cell/residency are derived)
* GET /:project/members viewer+
* POST /:project/members { username | subject_id, role } admin+ (owner for admins)
* PATCH /:project/members/:subject { role } admin+ (owner for admins)
@@@ -96,6 +97,7 @@ function router() {
r.post('/:project/archive', handle(async (db, a, req, o) => await store.archiveProject(db, a, req.params.project, o)));
r.put('/:project/allowance', handle(async (db, a, req, o) => await store.setAllowance(db, a, req.params.project, req.body || {}, o)));
r.put('/:project/environment-policy', handle(async (db, a, req, o) => await store.setEnvironmentPolicy(db, a, req.params.project, req.body || {}, o)));
++ r.put('/:project/placement', handle(async (db, a, req, o) => await store.setPlacement(db, a, req.params.project, req.body || {}, o)));
r.get('/:project/members', handle(async (db, a, req) => ({ members: await store.listMembers(db, a, req.params.project) })));
r.post('/:project/members', handle(async (db, a, req, o) => await store.addMember(db, a, req.params.project, req.body || {}, o), 201));
ddiff --git a/server/developer/store.js b/server/developer/store.js
iindex 9fbc6dd4..a655d1d3 100644
---- a/server/developer/store.js
++++ b/server/developer/store.js
@@@ -33,6 +33,7 @@ const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const CRED_ID_RE = /^crd_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SECRET_PREFIX = 'ovsec_';
++const MAX_PREFERRED_REGIONS = 5; // docs/t2-cells-and-node-principal.md section 3.1
class DevError extends Error {
constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail; }
@@@ -125,10 +126,16 @@ const cleanName = (v, field = 'name') => {
};
async function projectView(db, p, role, settings) {
++ const cell = await db.prepare('SELECT residency FROM platform_cells WHERE id = ?').get(p.home_cell);
return {
id: p.id, name: p.name, owner: { type: 'user', id: p.owner_subject }, role: role || null,
environment_policy: p.environment_policy, environments: policy.ENVIRONMENT_POLICIES[p.environment_policy],
allowance: JSON.parse(p.allowance || '[]'),
++ // Placement (N3): home_cell is where the project's machines and resources live (0008); a project's
++ // residency is its home cell's, derived here and never stored (decision A). preferred_regions is stored.
++ home_cell: p.home_cell || null,
++ residency: cell ? cell.residency : null,
++ preferred_regions: JSON.parse(p.preferred_regions || '[]'),
// Held by sandbox apps without a staff decision (DEV_SANDBOX_ALLOWANCE); production apps use `allowance` only.
sandbox_allowance: (settings || policy.settings()).sandboxAllowance,
created_at: p.created_at, archived_at: p.archived_at || null,
@@@ -222,6 +229,35 @@ async function setEnvironmentPolicy(db, actor, projectId, { environment_policy:
return { environment_policy: value, environments: policy.ENVIRONMENT_POLICIES[value] };
}
++/**
++ * Placement (N3, docs/t2-cells-and-node-principal.md section 3.1): set a project's preferred regions, in
++ * preference order. An array cannot carry a foreign key, so every id is checked against platform_regions in the
++ * same transaction as the write. `residency` is the home cell's and is never stored: a caller may state it, and it
++ * is refused unless it agrees with the home cell (a project cannot move itself; changing home_cell is not designed).
++ * Admin+ membership (not staff as such), like renameProject.
++ */
++async function setPlacement(db, actor, projectId, { preferred_regions: regions, residency } = {}, { ctx, settings }) {
++ const { project, role } = await access(db, actor, projectId, { need: 'admin', staffOk: false });
++ if (!Array.isArray(regions)) fail(400, 'project.invalid', 'preferred_regions must be an array of region ids');
++ if (regions.length > MAX_PREFERRED_REGIONS) fail(400, 'registry.too_many_regions', `at most ${MAX_PREFERRED_REGIONS} preferred regions`);
++ const wanted = regions.map(String);
++ if (new Set(wanted).size !== wanted.length) fail(400, 'registry.duplicate_region', 'preferred regions must be unique');
++ await db.tx(async () => {
++ for (const id of wanted) {
++ if (!(await db.prepare('SELECT id FROM platform_regions WHERE id = ?').get(id))) fail(400, 'registry.unknown_region', `no such region: ${id}`);
++ }
++ if (residency !== undefined) {
++ const cell = await db.prepare('SELECT residency FROM platform_cells WHERE id = ?').get(project.home_cell);
++ const home = cell && cell.residency;
++ if (String(residency) !== home) fail(400, 'registry.unknown_residency', `residency must be the home cell's (${home})`);
++ }
++ await db.prepare('UPDATE dev_projects SET preferred_regions = ? WHERE id = ?').run(JSON.stringify(wanted), project.id);
++ await audit(db, { projectId: project.id, actor: actor.label, action: 'project.placement_changed', target: project.id,
++ detail: { from: JSON.parse(project.preferred_regions || '[]'), to: wanted }, ctx });
++ });
++ return await projectView(db, await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id), role, settings);
++}
++
// ── Members ────────────────────────────────────────────────────
async function memberView(db, r) {
@@@ -615,7 +651,7 @@ async function deleteQuota(db, actor, projectId, capability, { ctx }) {
module.exports = {
ensureSchema, DevError, ROLES, APP_ID_RE, PROJECT_ID_RE, SECRET_PREFIX,
audit, listAudit, actorOf, access, memberRole,
-- createProject, listProjects, renameProject, archiveProject, setAllowance, setEnvironmentPolicy, projectView,
++ createProject, listProjects, renameProject, archiveProject, setAllowance, setEnvironmentPolicy, setPlacement, projectView,
listMembers, addMember, updateMember, removeMember,
listApps, getApp, createApp, updateApp, revokeApp,
listCredentials, rotateCredential, revokeCredential, matchSecret, hashSecret,
ddiff --git a/test/project-placement.test.js b/test/project-placement.test.js
nnew file mode 100644
iindex 00000000..a4556d9e
---- /dev/null
++++ b/test/project-placement.test.js
@@@ -0,0 +1,112 @@
++'use strict';
++// Project placement (slice N3, docs/t2-cells-and-node-principal.md section 3.1 and decision A):
++// migration 0015 adds dev_projects.preferred_regions; the project view returns home_cell, residency (derived from
++// the home cell, never stored) and preferred_regions; PUT /:project/placement sets them. Admin+ membership only.
++// node test/project-placement.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const express = require('express');
++const jwt = require('jsonwebtoken');
++const { getDb } = require('../server/db/database');
++
++(async () => {
++const out = (...a) => console.log(...a);
++
++const db = getDb();
++await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
++ (20, 'placer-owner', 'x', 'user'), (21, 'placer-dev', 'x', 'user'), (22, 'placer-stranger', 'x', 'user')`).run();
++
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++const ISSUER = 'https://openvibe.network';
++const app = express();
++app.use(express.json());
++app.locals.db = db;
++app.locals.config = {
++ baseUrl: ISSUER, loginUrl: ISSUER, internalKey: 'legacy-key',
++ jwt: { issuer: ISSUER, accessTokenExpiry: '1h' },
++ developer: { sandboxAudiences: 'openvibe.media', sandboxAllowance: '', credentialOverlapS: 60 },
++};
++app.locals.privateKey = keys.privateKey;
++app.locals.publicKey = keys.publicKey;
++app.use('/api/v1/projects', require('../server/developer/routes').router());
++const server = http.createServer(app);
++
++const userToken = (id) => jwt.sign({ sub: id, id }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
++const T = { owner: userToken(20), dev: userToken(21), stranger: userToken(22) };
++
++(async () => {
++ await new Promise(r => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const api = async (who, method, p, body) => {
++ const h = { ...(who ? { authorization: `Bearer ${T[who]}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}) };
++ const r = await fetch(`${base}/api/v1/projects${p}`, { method, headers: h, body: body ? JSON.stringify(body) : undefined });
++ const text = await r.text();
++ return { status: r.status, body: text ? JSON.parse(text) : null };
++ };
++
++ // ── Defaults: an existing and a new project both sit in wnam-1 (residency US) with no preferred regions ──
++ let r = await api('owner', 'POST', '', { name: 'Existing' });
++ assert.strictEqual(r.status, 201);
++ const P = r.body.id;
++ r = await api('owner', 'GET', `/${P}`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.body.home_cell, 'wnam-1');
++ assert.strictEqual(r.body.residency, 'US');
++ assert.deepStrictEqual(r.body.preferred_regions, []);
++ r = await api('owner', 'POST', '', { name: 'New' });
++ assert.strictEqual(r.status, 201);
++ assert.strictEqual(r.body.home_cell, 'wnam-1');
++ assert.strictEqual(r.body.residency, 'US');
++ assert.deepStrictEqual(r.body.preferred_regions, []);
++ // The column default is what made those empty arrays (0015), not the insert.
++ const n = (await db.prepare("SELECT COUNT(*) AS n FROM dev_projects WHERE preferred_regions = '[]'").get()).n;
++ assert.ok(n >= 2, 'every project defaults to an empty array');
++
++ // A developer member may read the project but not place it.
++ r = await api('owner', 'POST', `/${P}/members`, { username: 'placer-dev', role: 'developer' });
++ assert.strictEqual(r.status, 201);
++
++ // ── A valid update, and nothing else in the project read changes ──
++ const before = await api('owner', 'GET', `/${P}`);
++ r = await api('owner', 'PUT', `/${P}/placement`, { preferred_regions: ['us-west'] });
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(r.body.preferred_regions, ['us-west']);
++ assert.strictEqual(r.body.home_cell, 'wnam-1');
++ assert.strictEqual(r.body.residency, 'US');
++ r = await api('owner', 'GET', `/${P}`);
++ assert.deepStrictEqual(r.body.preferred_regions, ['us-west']);
++ const strip = (v) => { const { home_cell, residency, preferred_regions, ...rest } = v; return rest; };
++ assert.deepStrictEqual(strip(r.body), strip(before.body), 'placement changes nothing else in the project read');
++ const audited = await db.prepare("SELECT detail FROM dev_audit WHERE project_id = ? AND action = 'project.placement_changed'").all(P);
++ assert.strictEqual(audited.length, 1);
++ assert.deepStrictEqual(JSON.parse(audited[0].detail), { from: [], to: ['us-west'] });
++
++ // ── Refusals: unknown region, too many, duplicates, a bad residency, a non-admin caller ──
++ const refused = async (who, body, status, code, label) => {
++ const x = await api(who, 'PUT', `/${P}/placement`, body);
++ assert.strictEqual(x.status, status, `${label}: status`);
++ assert.strictEqual(x.body.code, code, `${label}: code`);
++ };
++ await refused('owner', { preferred_regions: ['zz-mars'] }, 400, 'registry.unknown_region', 'unknown region');
++ await refused('owner', { preferred_regions: ['us-west', 'us-east', 'eu-west', 'eu-central', 'ap-south', 'sa-east'] }, 400, 'registry.too_many_regions', 'six regions');
++ await refused('owner', { preferred_regions: ['us-west', 'us-west'] }, 400, 'registry.duplicate_region', 'duplicates');
++ await refused('owner', { preferred_regions: 'us-west' }, 400, 'project.invalid', 'not an array');
++ await refused('owner', { preferred_regions: [], residency: 'ZZ' }, 400, 'registry.unknown_residency', 'bad residency');
++ await refused('dev', { preferred_regions: ['us-west'] }, 403, 'project.forbidden', 'a member cannot place');
++ r = await api('stranger', 'PUT', `/${P}/placement`, { preferred_regions: ['us-west'] });
++ assert.strictEqual(r.status, 404, 'a non-member is not told the project exists');
++ // None of those wrote.
++ r = await api('owner', 'GET', `/${P}`);
++ assert.deepStrictEqual(r.body.preferred_regions, ['us-west']);
++ assert.strictEqual((await db.prepare("SELECT COUNT(*) AS n FROM dev_audit WHERE project_id = ? AND action = 'project.placement_changed'").get(P)).n, 1);
++
++ // Residency may be stated when it agrees with the home cell (it is derived, never stored).
++ r = await api('owner', 'PUT', `/${P}/placement`, { preferred_regions: ['us-west'], residency: 'US' });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.body.residency, 'US');
++
++ out('project placement: all checks passed');
++ server.close();
++})().catch(err => { console.error(err); process.exit(1); });
++})().catch(err => { console.error(err); process.exit(1); });