diff --git a/public/js/peers.js b/public/js/peers.js
index c3f40314..c62b1f41 100644
--- a/public/js/peers.js
+++ b/public/js/peers.js
@@ -385,6 +385,19 @@
});
}
+ // ─── Load owner selects ──────────────────────────────────
+ function populateOwnerSelects(ids) {
+ api.get('/api/v1/users').then(function (r) {
+ var users = (r.data || r.users || []);
+ (ids || ['add-peer-owner', 'edit-peer-owner']).forEach(function (id) {
+ var sel = document.getElementById(id); if (!sel) return;
+ sel.innerHTML = '';
+ sel.appendChild(new Option((window.GC && GC.t && GC.t['peers.owner.none']) || '(no owner)', ''));
+ users.forEach(function (u) { sel.appendChild(new Option(u.username, String(u.id))); });
+ });
+ }).catch(function () {});
+ }
+
// ─── Load peers ──────────────────────────────────────────
async function loadPeers() {
try {
@@ -799,6 +812,8 @@
document.getElementById('add-peer-desc').value = '';
document.getElementById('add-peer-tags').value = '';
document.getElementById('add-peer-group').value = '';
+ var addOwnerSel = document.getElementById('add-peer-owner');
+ if (addOwnerSel) addOwnerSel.value = '';
document.getElementById('add-peer-expires').value = '';
document.getElementById('add-peer-expires-date').value = '';
document.getElementById('add-peer-expires-date').style.display = 'none';
@@ -838,7 +853,9 @@
var isGateway = !!(isGatewayEl && isGatewayEl.checked);
var apiPort = apiPortEl ? parseInt(apiPortEl.value, 10) || 9876 : 9876;
var proxyPort = proxyPortEl ? parseInt(proxyPortEl.value, 10) || 8080 : 8080;
- var payload = { name: name, description: description, tags: tags, expires_at: expires_at, group_id: group_id, dns: dns || undefined };
+ var addOwnerEl = document.getElementById('add-peer-owner');
+ var payload = { name: name, description: description, tags: tags, expires_at: expires_at, group_id: group_id, dns: dns || undefined,
+ user_id: addOwnerEl ? (addOwnerEl.value === '' ? null : Number(addOwnerEl.value)) : null };
if (isGateway) {
payload.is_gateway = true;
payload.api_port = apiPort;
@@ -1059,6 +1076,8 @@
renderGroupDropdowns();
document.getElementById('edit-peer-group').value = peer.group_id ? String(peer.group_id) : '';
+ var editOwnerSel = document.getElementById('edit-peer-owner');
+ if (editOwnerSel) editOwnerSel.value = peer.user_id != null ? String(peer.user_id) : '';
var editExpiresSel = document.getElementById('edit-peer-expires');
var editExpiresDate = document.getElementById('edit-peer-expires-date');
@@ -1204,7 +1223,9 @@
try {
var dns = document.getElementById('edit-peer-dns') ? document.getElementById('edit-peer-dns').value.trim() : undefined;
var expires_at = computeExpiresAt('edit-peer-expires', 'edit-peer-expires-date');
- var data = await api.put('/api/peers/' + id, { name: name, description: description, tags: tags, expires_at: expires_at, group_id: group_id, dns: dns || undefined });
+ var editOwnerEl = document.getElementById('edit-peer-owner');
+ var data = await api.put('/api/peers/' + id, { name: name, description: description, tags: tags, expires_at: expires_at, group_id: group_id, dns: dns || undefined,
+ user_id: editOwnerEl ? (editOwnerEl.value === '' ? null : Number(editOwnerEl.value)) : null });
if (!data.ok) {
if (data.fields) {
showFieldErrors(data.fields, { name: 'edit-peer-name', description: 'edit-peer-desc' });
@@ -2435,6 +2456,17 @@
loadGroups();
loadPeers();
loadGateways();
+ populateOwnerSelects(['add-peer-owner', 'edit-peer-owner', 'peer-bulk-owner']);
+
+ var applyBtn = document.getElementById('peer-bulk-owner-apply');
+ if (applyBtn) applyBtn.addEventListener('click', function () {
+ var ids = Array.from(batchSelected).map(Number);
+ if (!ids.length) return;
+ var v = document.getElementById('peer-bulk-owner').value;
+ api.post('/api/v1/peers/batch-owner', { peer_ids: ids, user_id: v === '' ? null : Number(v) })
+ .then(function () { batchSelected.clear(); if (batchSelectAll) batchSelectAll.checked = false; updateBatchBar(); loadPeers(); showToast((GC.t && GC.t['peers.owner.bulk_done']) || 'Owners updated'); })
+ .catch(function (e) { showToast(e.message, 'error'); });
+ });
if (isAurora()) auroraInitStatusToggle();
setInterval(loadPeers, 15000);
setInterval(loadGroups, 30000);
diff --git a/src/db/migrationList.js b/src/db/migrationList.js
index fbb1e156..62e1184f 100644
--- a/src/db/migrationList.js
+++ b/src/db/migrationList.js
@@ -1046,6 +1046,18 @@ const migrations = [
);`,
detect: (db) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='domains'").get(),
},
+ {
+ version: 59,
+ name: 'peer_owner_user_id',
+ // Peer → owner (users.id). NO inline REFERENCES: ALTER TABLE ADD COLUMN
+ // silently ignores REFERENCES in some SQLite versions (see 'add_gateway_support').
+ // FK semantics live in the service layer (validation + null-on-user-delete).
+ sql: `
+ ALTER TABLE peers ADD COLUMN user_id INTEGER;
+ CREATE INDEX IF NOT EXISTS idx_peers_user_id ON peers(user_id);
+ `,
+ detect: (db) => hasColumn(db, 'peers', 'user_id'),
+ },
];
module.exports = { migrations };
diff --git a/src/i18n/de.json b/src/i18n/de.json
index 1f611fcc..d7139287 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -186,6 +186,10 @@
"error.tags.list": "Tags konnten nicht geladen werden",
"error.tags.create": "Tag konnte nicht angelegt werden",
"error.tags.delete": "Tag konnte nicht gelöscht werden",
+ "peers.owner.label": "Besitzer",
+ "peers.owner.none": "(kein Besitzer)",
+ "peers.owner.bulk_assign": "Besitzer zuweisen",
+ "peers.owner.bulk_done": "Besitzer aktualisiert",
"peers.expires": "Ablaufdatum",
"peers.expires_never": "Nie",
"peers.expires_1d": "1 Tag",
@@ -720,6 +724,8 @@
"error.peers.name_exists": "Ein Peer mit diesem Namen existiert bereits",
"error.peers.no_ips": "Keine verfügbaren IP-Adressen im Subnetz",
"error.peers.failover_home_blocked": "Dieses Gateway ist aktuell das Zuhause failover-umgezogener Routen. Hole sie zuerst zurück oder ziehe sie dauerhaft um.",
+ "error.peers.owner_invalid": "Ausgewählter Besitzer existiert nicht",
+ "error.peers.bulk_invalid_ids": "Ungültige oder zu viele Geräte-IDs",
"error.client.hostname_required": "Hostname ist erforderlich",
"error.client.invalid_hostname": "Ungültiger Hostname für Peer-Name",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index a20873f3..484bab77 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -186,6 +186,10 @@
"error.tags.list": "Failed to load tags",
"error.tags.create": "Failed to create tag",
"error.tags.delete": "Failed to delete tag",
+ "peers.owner.label": "Owner",
+ "peers.owner.none": "(no owner)",
+ "peers.owner.bulk_assign": "Assign owner",
+ "peers.owner.bulk_done": "Owners updated",
"peers.expires": "Expires",
"peers.expires_never": "Never",
"peers.expires_1d": "1 day",
@@ -720,6 +724,8 @@
"error.peers.name_exists": "A peer with this name already exists",
"error.peers.no_ips": "No available IP addresses in subnet",
"error.peers.failover_home_blocked": "This gateway is currently the home of failed-over routes. Restore them or permanently relocate them first.",
+ "error.peers.owner_invalid": "Selected owner does not exist",
+ "error.peers.bulk_invalid_ids": "Invalid or too many peer IDs",
"error.client.hostname_required": "Hostname is required",
"error.client.invalid_hostname": "Invalid hostname for peer name",
diff --git a/src/routes/api/peers.js b/src/routes/api/peers.js
index f11c28f8..744e7f79 100644
--- a/src/routes/api/peers.js
+++ b/src/routes/api/peers.js
@@ -14,6 +14,15 @@ const router = Router();
const peerCountFn = () => getDb().prepare('SELECT COUNT(*) as count FROM peers').get().count;
+function validateOwner(req, res, userId) {
+ if (userId == null) return true;
+ const uid = Number(userId);
+ if (!Number.isInteger(uid) || !require('../../services/users').getById(uid)) {
+ res.status(400).json({ ok: false, error: req.t('error.peers.owner_invalid') }); return false;
+ }
+ return true;
+}
+
const stripPeer = (p) => stripFields(p, ['private_key_encrypted', 'preshared_key_encrypted']);
/** Map service-layer error messages to i18n keys */
@@ -51,6 +60,29 @@ router.post('/batch', async (req, res) => {
}
});
+// Bulk owner assignment. Registered BEFORE '/:id' so the literal path isn't
+// captured as id="batch-owner". Inherits the same admin auth + CSRF (mount point).
+router.post('/batch-owner', (req, res) => {
+ try {
+ const { peer_ids, user_id } = req.body;
+ const MAX = 500;
+ if (!Array.isArray(peer_ids) || peer_ids.length === 0 || peer_ids.length > MAX
+ || !peer_ids.every(n => Number.isInteger(n))) {
+ return res.status(400).json({ ok: false, error: req.t('error.peers.bulk_invalid_ids') });
+ }
+ if (user_id != null && (!Number.isInteger(Number(user_id)) || !require('../../services/users').getById(Number(user_id)))) {
+ return res.status(400).json({ ok: false, error: req.t('error.peers.owner_invalid') });
+ }
+ const db = getDb();
+ const ph = peer_ids.map(() => '?').join(',');
+ const info = db.prepare(`UPDATE peers SET user_id = ?, updated_at = datetime('now') WHERE id IN (${ph})`)
+ .run(user_id == null ? null : Number(user_id), ...peer_ids);
+ res.json({ ok: true, affected: info.changes });
+ } catch (err) {
+ res.status(500).json({ ok: false, error: req.t('common.error') });
+ }
+});
+
/**
* GET /api/peers — List all peers with live status
*/
@@ -59,7 +91,9 @@ router.get('/', async (req, res) => {
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 250, 1), 250);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
const list = await peers.getAll({ limit, offset });
- res.json({ ok: true, peers: list.map(stripPeer), limit, offset });
+ const uMap = new Map(getDb().prepare('SELECT id, username FROM users').all().map(u => [u.id, u.username]));
+ const enriched = list.map(p => ({ ...p, owner_name: p.user_id != null ? (uMap.get(p.user_id) || null) : null }));
+ res.json({ ok: true, peers: enriched.map(stripPeer), limit, offset });
} catch (err) {
logger.error({ error: err.message }, 'Failed to list peers');
res.status(500).json({ ok: false, error: req.t('error.peers.list') });
@@ -85,7 +119,7 @@ router.get('/:id', (req, res) => {
*/
router.post('/', requireLimit('vpn_peers', peerCountFn), async (req, res) => {
try {
- const { name, description, tags, expires_at, group_id, dns, is_gateway, api_port, proxy_port } = req.body;
+ const { name, description, tags, expires_at, group_id, dns, is_gateway, api_port, proxy_port, user_id } = req.body;
// Field-level validation
const fields = {};
@@ -130,7 +164,8 @@ router.post('/', requireLimit('vpn_peers', peerCountFn), async (req, res) => {
});
}
- const peer = await peers.create({ name, description, tags, expiresAt: expires_at || null, groupId: group_id !== undefined ? group_id : null, dns });
+ if (!validateOwner(req, res, user_id)) return;
+ const peer = await peers.create({ name, description, tags, expiresAt: expires_at || null, groupId: group_id !== undefined ? group_id : null, dns, userId: user_id });
res.status(201).json({ ok: true, peer: stripPeer(peer) });
} catch (err) {
logger.error({ error: err.message }, 'Failed to create peer');
@@ -144,7 +179,7 @@ router.post('/', requireLimit('vpn_peers', peerCountFn), async (req, res) => {
*/
router.put('/:id', async (req, res) => {
try {
- const { name, description, dns, persistentKeepalive, enabled, tags, expires_at, group_id } = req.body;
+ const { name, description, dns, persistentKeepalive, enabled, tags, expires_at, group_id, user_id } = req.body;
// Field-level validation
const fields = {};
@@ -168,6 +203,10 @@ router.put('/:id', async (req, res) => {
if (group_id !== undefined) {
updateData.groupId = group_id || null;
}
+ if (user_id !== undefined && !validateOwner(req, res, user_id)) return;
+ if (user_id !== undefined) {
+ updateData.userId = user_id;
+ }
const peer = await peers.update(req.params.id, updateData);
res.json({ ok: true, peer: stripPeer(peer) });
diff --git a/src/services/peers.js b/src/services/peers.js
index 46dc6404..33fe41ab 100644
--- a/src/services/peers.js
+++ b/src/services/peers.js
@@ -79,8 +79,8 @@ async function create(data) {
const result = db.prepare(`
INSERT INTO peers (name, description, public_key, private_key_encrypted, preshared_key_encrypted,
- allowed_ips, dns, persistent_keepalive, enabled, tags, expires_at, group_id, peer_type)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
+ allowed_ips, dns, persistent_keepalive, enabled, tags, expires_at, group_id, peer_type, user_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)
`).run(
sanitize(data.name),
sanitize(data.description) || null,
@@ -93,7 +93,8 @@ async function create(data) {
sanitize(data.tags) || '',
data.expiresAt || null,
data.groupId || null,
- data.peerType || 'regular'
+ data.peerType || 'regular',
+ data.userId != null ? Number(data.userId) : null
);
return { peerId: result.lastInsertRowid, ip, allowedIps };
@@ -132,6 +133,7 @@ async function create(data) {
ip_address: ip,
peer_type: data.peerType || 'regular',
expires_at: data.expiresAt || null,
+ user_id: data.userId != null ? Number(data.userId) : null,
};
}
@@ -189,6 +191,9 @@ async function update(id, data) {
// Handle group_id: explicit null clears group, undefined means no change
const groupIdValue = data.groupId !== undefined ? (data.groupId || null) : undefined;
+ // Handle user_id: explicit null clears owner, undefined means no change
+ const userIdValue = data.userId !== undefined ? (data.userId == null ? null : Number(data.userId)) : undefined;
+
db.prepare(`
UPDATE peers SET
name = COALESCE(?, name),
@@ -199,6 +204,7 @@ async function update(id, data) {
tags = COALESCE(?, tags),
expires_at = CASE WHEN ? = 1 THEN ? ELSE expires_at END,
group_id = CASE WHEN ? = 1 THEN ? ELSE group_id END,
+ user_id = CASE WHEN ? = 1 THEN ? ELSE user_id END,
updated_at = datetime('now')
WHERE id = ?
`).run(
@@ -212,6 +218,8 @@ async function update(id, data) {
expiresAtValue !== undefined ? expiresAtValue : null,
groupIdValue !== undefined ? 1 : 0,
groupIdValue !== undefined ? groupIdValue : null,
+ userIdValue !== undefined ? 1 : 0,
+ userIdValue !== undefined ? userIdValue : null,
id
);
diff --git a/src/services/users.js b/src/services/users.js
index 4f3b5a6c..3e4e2555 100644
--- a/src/services/users.js
+++ b/src/services/users.js
@@ -286,7 +286,10 @@ function remove(id) {
}
}
- db.prepare('DELETE FROM users WHERE id = ?').run(id);
+ db.transaction(() => {
+ db.prepare('UPDATE peers SET user_id = NULL WHERE user_id = ?').run(id);
+ db.prepare('DELETE FROM users WHERE id = ?').run(id);
+ })();
activity.log('user_deleted', `User "${user.username}" deleted`, {
source: 'admin',
diff --git a/templates/aurora/pages/peers.njk b/templates/aurora/pages/peers.njk
index 584e672e..e2918207 100644
--- a/templates/aurora/pages/peers.njk
+++ b/templates/aurora/pages/peers.njk
@@ -93,10 +93,12 @@
-
+
+
+
diff --git a/templates/aurora/partials/modals/peer-add.njk b/templates/aurora/partials/modals/peer-add.njk
index 0ce01cba..c1229d2d 100644
--- a/templates/aurora/partials/modals/peer-add.njk
+++ b/templates/aurora/partials/modals/peer-add.njk
@@ -19,6 +19,10 @@
+
+
+
+
diff --git a/templates/aurora/partials/modals/peer-edit.njk b/templates/aurora/partials/modals/peer-edit.njk
index 75006092..49d2a8f6 100644
--- a/templates/aurora/partials/modals/peer-edit.njk
+++ b/templates/aurora/partials/modals/peer-edit.njk
@@ -20,6 +20,10 @@
+
+
+
+
diff --git a/templates/default/pages/peers.njk b/templates/default/pages/peers.njk
index 21889299..2b457ad2 100644
--- a/templates/default/pages/peers.njk
+++ b/templates/default/pages/peers.njk
@@ -108,10 +108,12 @@
-
+
+
+
diff --git a/templates/default/partials/modals/peer-add.njk b/templates/default/partials/modals/peer-add.njk
index 0ce01cba..c1229d2d 100644
--- a/templates/default/partials/modals/peer-add.njk
+++ b/templates/default/partials/modals/peer-add.njk
@@ -19,6 +19,10 @@
+
+
+
+
diff --git a/templates/default/partials/modals/peer-edit.njk b/templates/default/partials/modals/peer-edit.njk
index 622a2687..3d0eeb44 100644
--- a/templates/default/partials/modals/peer-edit.njk
+++ b/templates/default/partials/modals/peer-edit.njk
@@ -20,6 +20,10 @@
+
+
+
+
diff --git a/templates/pro/pages/peers.njk b/templates/pro/pages/peers.njk
index 781382d8..491bf0f6 100644
--- a/templates/pro/pages/peers.njk
+++ b/templates/pro/pages/peers.njk
@@ -108,10 +108,12 @@
-
+
+
+
diff --git a/templates/pro/partials/modals/peer-add.njk b/templates/pro/partials/modals/peer-add.njk
index 418a1e7b..d3710228 100644
--- a/templates/pro/partials/modals/peer-add.njk
+++ b/templates/pro/partials/modals/peer-add.njk
@@ -19,6 +19,10 @@
+
+
+
+
diff --git a/templates/pro/partials/modals/peer-edit.njk b/templates/pro/partials/modals/peer-edit.njk
index 16d3e85f..80935297 100644
--- a/templates/pro/partials/modals/peer-edit.njk
+++ b/templates/pro/partials/modals/peer-edit.njk
@@ -20,6 +20,10 @@
+
+
+
+
diff --git a/tests/peer_owner_api.test.js b/tests/peer_owner_api.test.js
new file mode 100644
index 00000000..28497115
--- /dev/null
+++ b/tests/peer_owner_api.test.js
@@ -0,0 +1,30 @@
+'use strict';
+const crypto = require('crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex');
+const { test, beforeEach, afterEach } = require('node:test');
+const assert = require('node:assert/strict');
+const { setup, teardown, getAgent, getCsrf } = require('./helpers/setup');
+let getDb;
+beforeEach(async () => { await setup(); getDb = require('../src/db/connection').getDb; });
+afterEach(teardown);
+function seedUser(n){ return getDb().prepare("INSERT INTO users (username,password_hash,role) VALUES (?,?,'admin')").run(n,'x').lastInsertRowid; }
+
+test('POST with user_id persists owner (201, body.peer.user_id)', async () => {
+ const uid = seedUser('alice'); const agent = getAgent(); const csrf = getCsrf();
+ const r = await agent.post('/api/v1/peers').set('X-CSRF-Token', csrf).send({ name: 'devD', user_id: uid }).expect(201);
+ assert.equal(r.body.peer.user_id, uid);
+});
+test('PUT sets user_id and GET reflects owner_name', async () => {
+ const uid = seedUser('bob'); const agent = getAgent(); const csrf = getCsrf();
+ const c = await agent.post('/api/v1/peers').set('X-CSRF-Token', csrf).send({ name: 'devE' }).expect(201);
+ const pid = c.body.peer.id;
+ await agent.put('/api/v1/peers/' + pid).set('X-CSRF-Token', csrf).send({ user_id: uid }).expect(200);
+ const list = await agent.get('/api/v1/peers').expect(200);
+ const row = list.body.peers.find(p => p.id === pid);
+ assert.equal(row.user_id, uid); assert.equal(row.owner_name, 'bob');
+});
+test('PUT rejects a non-existent user_id (400)', async () => {
+ const agent = getAgent(); const csrf = getCsrf();
+ const c = await agent.post('/api/v1/peers').set('X-CSRF-Token', csrf).send({ name: 'devF' }).expect(201);
+ await agent.put('/api/v1/peers/' + c.body.peer.id).set('X-CSRF-Token', csrf).send({ user_id: 999999 }).expect(400);
+});
diff --git a/tests/peer_owner_bulk.test.js b/tests/peer_owner_bulk.test.js
new file mode 100644
index 00000000..9a85e0a8
--- /dev/null
+++ b/tests/peer_owner_bulk.test.js
@@ -0,0 +1,36 @@
+'use strict';
+const crypto = require('crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex');
+const { test, beforeEach, afterEach } = require('node:test');
+const assert = require('node:assert/strict');
+const supertest = require('supertest');
+const { setup, teardown, getAgent, getCsrf } = require('./helpers/setup');
+let peers, getDb, app;
+beforeEach(async () => { await setup(); peers = require('../src/services/peers'); getDb = require('../src/db/connection').getDb; app = require('../src/app').createApp(); });
+afterEach(teardown);
+async function mk(n){ return (await peers.create({ name: n })).id; }
+function seedUser(n){ return getDb().prepare("INSERT INTO users (username,password_hash,role) VALUES (?,?,'admin')").run(n,'x').lastInsertRowid; }
+
+test('batch-owner sets owner for many (hits bulk handler: body.affected)', async () => {
+ const uid = seedUser('bob'); const a = await mk('p1'); const b = await mk('p2');
+ const agent = getAgent(); const csrf = getCsrf();
+ const r = await agent.post('/api/v1/peers/batch-owner').set('X-CSRF-Token', csrf).send({ peer_ids: [a, b], user_id: uid }).expect(200);
+ assert.equal(r.body.ok, true); assert.equal(r.body.affected, 2);
+ assert.equal(getDb().prepare('SELECT user_id FROM peers WHERE id=?').get(a).user_id, uid);
+});
+test('batch-owner user_id null clears ownership', async () => {
+ const uid = seedUser('carol'); const a = await mk('p3'); const agent = getAgent(); const csrf = getCsrf();
+ await agent.post('/api/v1/peers/batch-owner').set('X-CSRF-Token', csrf).send({ peer_ids: [a], user_id: uid }).expect(200);
+ await agent.post('/api/v1/peers/batch-owner').set('X-CSRF-Token', csrf).send({ peer_ids: [a], user_id: null }).expect(200);
+ assert.equal(getDb().prepare('SELECT user_id FROM peers WHERE id=?').get(a).user_id, null);
+});
+test('batch-owner rejects empty/too-large peer_ids and bad user_id (400)', async () => {
+ const agent = getAgent(); const csrf = getCsrf();
+ await agent.post('/api/v1/peers/batch-owner').set('X-CSRF-Token', csrf).send({ peer_ids: [], user_id: null }).expect(400);
+ await agent.post('/api/v1/peers/batch-owner').set('X-CSRF-Token', csrf).send({ peer_ids: Array.from({length:501},(_,i)=>i+1), user_id: null }).expect(400);
+ await agent.post('/api/v1/peers/batch-owner').set('X-CSRF-Token', csrf).send({ peer_ids: [1], user_id: 999999 }).expect(400);
+});
+test('batch-owner without auth/CSRF is rejected (401/403)', async () => {
+ const res = await supertest(app).post('/api/v1/peers/batch-owner').send({ peer_ids: [1], user_id: null });
+ assert.ok([401, 403].includes(res.status), `expected 401/403, got ${res.status}`);
+});
diff --git a/tests/peer_owner_bulk_ui.test.js b/tests/peer_owner_bulk_ui.test.js
new file mode 100644
index 00000000..d24d1d28
--- /dev/null
+++ b/tests/peer_owner_bulk_ui.test.js
@@ -0,0 +1,18 @@
+'use strict';
+const crypto = require('crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex');
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs'); const path = require('node:path');
+test('bulk owner control in batch bar (3 themes)', () => {
+ for (const theme of ['aurora', 'default', 'pro']) {
+ const html = fs.readFileSync(path.join(__dirname, '..', 'templates', theme, 'pages', 'peers.njk'), 'utf8');
+ ['peer-bulk-owner', 'peer-bulk-owner-apply'].forEach(id => assert.ok(html.includes(id), `${theme}: ${id}`));
+ }
+});
+test('peers.js wires bulk owner to batch-owner using batchSelected', () => {
+ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'peers.js'), 'utf8');
+ assert.ok(/peer-bulk-owner/.test(js));
+ assert.ok(/batch-owner/.test(js));
+ assert.ok(/batchSelected/.test(js));
+});
diff --git a/tests/peer_owner_migration.test.js b/tests/peer_owner_migration.test.js
new file mode 100644
index 00000000..2af99ccb
--- /dev/null
+++ b/tests/peer_owner_migration.test.js
@@ -0,0 +1,16 @@
+'use strict';
+const crypto = require('crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex');
+const { test, beforeEach, afterEach } = require('node:test');
+const assert = require('node:assert/strict');
+const { setup, teardown } = require('./helpers/setup');
+let getDb;
+beforeEach(async () => { await setup(); getDb = require('../src/db/connection').getDb; });
+afterEach(teardown);
+test('peers has a nullable user_id column', () => {
+ const col = getDb().prepare("PRAGMA table_info(peers)").all().find(c => c.name === 'user_id');
+ assert.ok(col, 'user_id column missing'); assert.equal(col.notnull, 0);
+});
+test('idx_peers_user_id exists', () => {
+ assert.ok(getDb().prepare("SELECT 1 FROM sqlite_master WHERE type='index' AND name='idx_peers_user_id'").get());
+});
diff --git a/tests/peer_owner_no_leak.test.js b/tests/peer_owner_no_leak.test.js
new file mode 100644
index 00000000..ab2061f4
--- /dev/null
+++ b/tests/peer_owner_no_leak.test.js
@@ -0,0 +1,80 @@
+'use strict';
+const crypto = require('crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex');
+
+const { test, beforeEach, afterEach } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const supertest = require('supertest');
+const { setup, teardown } = require('./helpers/setup');
+
+// Home-vhost Host header (matches default GC_DNS_DOMAIN = 'gc.internal').
+// portalIdentity only establishes identity when Host === effectivePortalHost().host.
+const HOME_HOST = 'home.gc.internal';
+
+let app, getDb;
+beforeEach(async () => {
+ await setup();
+ getDb = require('../src/db/connection').getDb;
+ app = require('../src/app').createApp();
+});
+afterEach(teardown);
+
+test('portal /device whitelists fields and never emits user_id/owner_name (behavioral)', async () => {
+ // Insert a peer so the handler returns data rather than the `unidentified` envelope.
+ // Identity is established via loopback (supertest behavior, like Caddy), the
+ // X-GC-Portal-Peer-IP header (reserved; read by portalIdentity to set req.portalPeerId),
+ // and the home Host vhost (matches effectivePortalHost().host for mgmt-vhost anti-forgery).
+ // This test FAILS if the peer is not identified (data would be null).
+ getDb().prepare(
+ `INSERT INTO peers (name, public_key, allowed_ips, enabled, peer_type)
+ VALUES ('devP','pk-no-leak-test','10.8.0.99/32',1,'regular')`
+ ).run();
+
+ const res = await supertest(app)
+ .get('/api/v1/portal/device')
+ .set('X-GC-Portal-Peer-IP', '10.8.0.99')
+ .set('Host', HOME_HOST)
+ .expect(200);
+
+ assert.equal(res.body.ok, true);
+ assert.ok(res.body.data !== null, 'peer should be identified and data must be returned (not unidentified)');
+ assert.ok(!('user_id' in res.body.data), 'portal /device must not expose user_id');
+ assert.ok(!('owner_name' in res.body.data), 'portal /device must not expose owner_name');
+});
+
+test('client-facing + portal route sources never reference user_id/owner_name', () => {
+ const clientDir = path.join(__dirname, '..', 'src/routes/api/client');
+ const clientFiles = fs.existsSync(clientDir)
+ ? fs.readdirSync(clientDir)
+ .filter(f => f.endsWith('.js'))
+ .map(f => path.join(clientDir, f))
+ : [];
+ const portalFile = path.join(__dirname, '..', 'src/routes/api/portal.js');
+ const candidates = [
+ ...clientFiles,
+ ...(fs.existsSync(portalFile) ? [portalFile] : []),
+ ];
+
+ // Guard: a scan matching zero files is silent-green and worthless.
+ // At minimum portal.js + at least one client/*.js file must be present.
+ assert.ok(
+ candidates.length >= 2,
+ `source scan matched only ${candidates.length} file(s) — expected at least 2 (portal.js + ≥1 client route)`
+ );
+
+ for (const f of candidates) {
+ const src = fs.readFileSync(f, 'utf8');
+ // Must not contain any reference to the owner-mapping columns.
+ assert.ok(
+ !/\buser_id\b|\bowner_name\b/.test(src),
+ `${path.basename(f)} must not reference owner mapping (user_id / owner_name)`
+ );
+ // Must not return a raw peer object: res.json(peer) or res.json({ ...peer ... }).
+ assert.ok(
+ !/res\.json\(\s*peer\s*\)/.test(src) && !/res\.json\(\s*\{[^}]*\.\.\.\s*\w*peer\w*/.test(src),
+ `${path.basename(f)} must not return a raw peer object`
+ );
+ }
+});
diff --git a/tests/peer_owner_service.test.js b/tests/peer_owner_service.test.js
new file mode 100644
index 00000000..f25d1f3d
--- /dev/null
+++ b/tests/peer_owner_service.test.js
@@ -0,0 +1,29 @@
+'use strict';
+const crypto = require('crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex');
+const { test, beforeEach, afterEach } = require('node:test');
+const assert = require('node:assert/strict');
+const { setup, teardown } = require('./helpers/setup');
+let peers, getDb;
+beforeEach(async () => { await setup(); peers = require('../src/services/peers'); getDb = require('../src/db/connection').getDb; });
+afterEach(teardown);
+function seedUser(n){ return getDb().prepare("INSERT INTO users (username,password_hash,role) VALUES (?,?,'admin')").run(n,'x').lastInsertRowid; }
+
+test('create persists userId, returns it, and getById reflects it', async () => {
+ const uid = seedUser('o1');
+ const p = await peers.create({ name: 'devA', userId: uid });
+ assert.equal(p.user_id, uid, 'create() return must include user_id');
+ assert.equal(getDb().prepare('SELECT user_id FROM peers WHERE id=?').get(p.id).user_id, uid);
+});
+test('create without userId stores null (backward compatible)', async () => {
+ const p = await peers.create({ name: 'legacy' });
+ assert.equal(getDb().prepare('SELECT user_id FROM peers WHERE id=?').get(p.id).user_id, null);
+});
+test('update sets/clears userId; undefined leaves unchanged', async () => {
+ const uid = seedUser('o2');
+ const p = await peers.create({ name: 'devB', userId: uid });
+ await peers.update(p.id, { name: 'devB' });
+ assert.equal(getDb().prepare('SELECT user_id FROM peers WHERE id=?').get(p.id).user_id, uid);
+ await peers.update(p.id, { userId: null });
+ assert.equal(getDb().prepare('SELECT user_id FROM peers WHERE id=?').get(p.id).user_id, null);
+});
diff --git a/tests/peer_owner_ui.test.js b/tests/peer_owner_ui.test.js
new file mode 100644
index 00000000..41d61f3d
--- /dev/null
+++ b/tests/peer_owner_ui.test.js
@@ -0,0 +1,18 @@
+'use strict';
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs'); const path = require('node:path');
+test('owner selects exist in add+edit peer partials (3 themes)', () => {
+ for (const theme of ['aurora', 'default', 'pro']) {
+ const add = fs.readFileSync(path.join(__dirname, '..', 'templates', theme, 'partials', 'modals', 'peer-add.njk'), 'utf8');
+ const edit = fs.readFileSync(path.join(__dirname, '..', 'templates', theme, 'partials', 'modals', 'peer-edit.njk'), 'utf8');
+ assert.ok(add.includes('add-peer-owner'), `${theme} add`);
+ assert.ok(edit.includes('edit-peer-owner'), `${theme} edit`);
+ }
+});
+test('peers.js loads users and sends user_id', () => {
+ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'peers.js'), 'utf8');
+ assert.ok(/add-peer-owner|edit-peer-owner/.test(js));
+ assert.ok(/\/api\/v1\/users/.test(js));
+ assert.ok(/user_id/.test(js));
+});
diff --git a/tests/peer_owner_user_delete.test.js b/tests/peer_owner_user_delete.test.js
new file mode 100644
index 00000000..5f719219
--- /dev/null
+++ b/tests/peer_owner_user_delete.test.js
@@ -0,0 +1,17 @@
+'use strict';
+const crypto = require('crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex');
+const { test, beforeEach, afterEach } = require('node:test');
+const assert = require('node:assert/strict');
+const { setup, teardown } = require('./helpers/setup');
+let peers, users, getDb;
+beforeEach(async () => { await setup(); peers = require('../src/services/peers'); users = require('../src/services/users'); getDb = require('../src/db/connection').getDb; });
+afterEach(teardown);
+// safe: setup() already seeds 1 admin, so removing this 2nd admin passes the last-admin guard.
+test('removing a user clears user_id on their peers; peer survives', async () => {
+ const uid = getDb().prepare("INSERT INTO users (username,password_hash,role) VALUES ('owner','x','admin')").run().lastInsertRowid;
+ const p = await peers.create({ name: 'devC', userId: uid });
+ users.remove(uid);
+ assert.equal(getDb().prepare('SELECT user_id FROM peers WHERE id=?').get(p.id).user_id, null);
+ assert.ok(getDb().prepare('SELECT 1 FROM peers WHERE id=?').get(p.id));
+});