openvibers/openvibe.network Commits
Add project placement: preferred regions and residency (#17)
Changes
nnew file mode 100644@@@ -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 '[%]');@@@ -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));@@@ -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.1class 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,nnew file mode 100644@@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -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 '[%]');
@@ -16,6 +16,7 @@ const cache = require('openvibe-shared/cache-policy');
@@ -16,6 +16,7 @@ const cache = require('openvibe-shared/cache-policy');
* POST /:project/archive owner or staff; revokes every app
* POST /:project/archive owner or staff; revokes every app
* PUT /:project/allowance { capabilities: [] } staff
* PUT /:project/allowance { capabilities: [] } staff
* PUT /:project/environment-policy { environment_policy } staff (sandbox | sandbox+production)
* 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+
* GET /:project/members viewer+
* POST /:project/members { username | subject_id, role } admin+ (owner for admins)
* POST /:project/members { username | subject_id, role } admin+ (owner for admins)
* PATCH /:project/members/:subject { role } admin+ (owner for admins)
* PATCH /:project/members/:subject { role } admin+ (owner for admins)
@@ -96,6 +97,7 @@ function router() {
@@ -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.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/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/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.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));
r.post('/:project/members', handle(async (db, a, req, o) => await store.addMember(db, a, req.params.project, req.body || {}, o), 201));
@@ -33,6 +33,7 @@ const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -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 CRED_ID_RE = /^crd_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SECRET_PREFIX = 'ovsec_';
const SECRET_PREFIX = 'ovsec_';
+const MAX_PREFERRED_REGIONS = 5; // docs/t2-cells-and-node-principal.md section 3.1
class DevError extends Error {
class DevError extends Error {
constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail; }
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') => {
@@ -125,10 +126,16 @@ const cleanName = (v, field = 'name') => {
};
};
async function projectView(db, p, role, settings) {
async function projectView(db, p, role, settings) {
+ const cell = await db.prepare('SELECT residency FROM platform_cells WHERE id = ?').get(p.home_cell);
return {
return {
id: p.id, name: p.name, owner: { type: 'user', id: p.owner_subject }, role: role || null,
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],
environment_policy: p.environment_policy, environments: policy.ENVIRONMENT_POLICIES[p.environment_policy],
allowance: JSON.parse(p.allowance || '[]'),
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.
// Held by sandbox apps without a staff decision (DEV_SANDBOX_ALLOWANCE); production apps use `allowance` only.
sandbox_allowance: (settings || policy.settings()).sandboxAllowance,
sandbox_allowance: (settings || policy.settings()).sandboxAllowance,
created_at: p.created_at, archived_at: p.archived_at || null,
created_at: p.created_at, archived_at: p.archived_at || null,
@@ -222,6 +229,35 @@ async function setEnvironmentPolicy(db, actor, projectId, { environment_policy:
@@ -222,6 +229,35 @@ async function setEnvironmentPolicy(db, actor, projectId, { environment_policy:
return { environment_policy: value, environments: policy.ENVIRONMENT_POLICIES[value] };
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 ────────────────────────────────────────────────────
// ── Members ────────────────────────────────────────────────────
async function memberView(db, r) {
async function memberView(db, r) {
@@ -615,7 +651,7 @@ async function deleteQuota(db, actor, projectId, capability, { ctx }) {
@@ -615,7 +651,7 @@ async function deleteQuota(db, actor, projectId, capability, { ctx }) {
module.exports = {
module.exports = {
ensureSchema, DevError, ROLES, APP_ID_RE, PROJECT_ID_RE, SECRET_PREFIX,
ensureSchema, DevError, ROLES, APP_ID_RE, PROJECT_ID_RE, SECRET_PREFIX,
audit, listAudit, actorOf, access, memberRole,
audit, listAudit, actorOf, access, memberRole,
- createProject, listProjects, renameProject, archiveProject, setAllowance, setEnvironmentPolicy, projectView,
+ createProject, listProjects, renameProject, archiveProject, setAllowance, setEnvironmentPolicy, setPlacement, projectView,
listMembers, addMember, updateMember, removeMember,
listMembers, addMember, updateMember, removeMember,
listApps, getApp, createApp, updateApp, revokeApp,
listApps, getApp, createApp, updateApp, revokeApp,
listCredentials, rotateCredential, revokeCredential, matchSecret, hashSecret,
listCredentials, rotateCredential, revokeCredential, matchSecret, hashSecret,
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -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); });