openvibers/openvibe.network Commits
Account: the 18+ confirmation for uncensored AI (#587)
Changes
nnew file mode 100644@@@ -0,0 +1,5 @@++-- phase: expand++-- The Exchange remake's uncensored tier: the account's own 18+ confirmation (server/identity/adult.js). It is a++-- timestamp, never a birth date; OpenVibe.AI reads it through /internal/identity/adult and never guesses an age.++-- Null means no confirmation (the default), and withdrawing the confirmation clears it.++ALTER TABLE users ADD COLUMN IF NOT EXISTS adult_confirmed_at text COLLATE "C";@@@ -158,6 +158,7 @@ function showSection(id, btn) {history.replaceState(null, '', id === 'profile' ? window.location.pathname : `${window.location.pathname}#${id}`);if (id === 'history' && typeof loadHistory === 'function') loadHistory();if (id === 'data') loadDataSection();++ if (id === 'content' && typeof loadAdultSection === 'function') loadAdultSection();if (id === 'approvals' && typeof loadApprovals === 'function') loadApprovals();if (id === 'machines' && typeof loadMachines === 'function') loadMachines();}@@@ -166,7 +167,7 @@ function openInitialSection() {const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/machines': 'machines', '/profile': 'profile', '/history': 'history' }[window.location.pathname];const routeSection = pathSection || window.location.hash.replace(/^#/, '') || 'profile';if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);-- if (isAnonSession() && ['notifications','linked','security','data','approvals','machines'].includes(routeSection)) {++ if (isAnonSession() && ['notifications','linked','security','data','content','approvals','machines'].includes(routeSection)) {return showSection('anon');}if (document.getElementById('sec-' + routeSection)) {@@@ -142,3 +142,45 @@ function exportModules() {document.body.appendChild(a); a.click(); a.remove();setTimeout(() => URL.revokeObjectURL(a.href), 1000);}++++// The Exchange remake's uncensored tier: the account's own 18+ confirmation (server/identity/adult.js). The flag is++// read from the account, never inferred; withdrawing it takes effect at once.++let adultWired = false, adultState = null;++function renderAdult(state) {++ adultState = state || { adult: false, confirmed_at: null };++ const status = document.getElementById('adult-status');++ const box = document.getElementById('adult-confirm-box');++ const yes = document.getElementById('adult-confirm');++ const no = document.getElementById('adult-withdraw');++ if (!status || !box || !yes || !no) return;++ status.textContent = adultState.adult++ ? 'Confirmed ' + (adultState.confirmed_at ? 'on ' + new Date(adultState.confirmed_at).toLocaleDateString() : '') + '. Uncensored models are available on Exchange.'++ : 'Not confirmed. Uncensored models are off for this account.';++ box.checked = Boolean(adultState.adult);++ yes.disabled = Boolean(adultState.adult);++ no.hidden = !adultState.adult;++}++async function loadAdultSection() {++ const box = document.getElementById('adult-confirm-box');++ if (!box) return;++ const msg = document.getElementById('adult-msg');++ try { renderAdult(await apiFetch('/api/v1/me/adult')); }++ catch { renderAdult({ adult: false, confirmed_at: null }); if (msg) msg.textContent = 'Could not read your account. Try again later.'; }++ if (adultWired) return;++ adultWired = true;++ document.getElementById('adult-confirm').addEventListener('click', async () => {++ if (!box.checked) { msg.textContent = 'Tick the box first.'; return; }++ if (!confirm('This records on your account that you are 18 or older. Uncensored AI is for adults only, and the hard floor still applies. Continue?')) return;++ try {++ renderAdult(await apiFetch('/api/v1/me/adult', { method: 'PUT', body: JSON.stringify({ confirm: true }) }));++ msg.textContent = 'Confirmed.';++ } catch (e) { msg.textContent = e.message || 'Could not confirm.'; }++ });++ document.getElementById('adult-withdraw').addEventListener('click', async () => {++ if (!confirm('Withdraw your 18+ confirmation? Uncensored models stop answering for this account at once.')) return;++ try {++ renderAdult(await apiFetch('/api/v1/me/adult', { method: 'DELETE' }));++ msg.textContent = 'Withdrawn.';++ } catch (e) { msg.textContent = e.message || 'Could not withdraw.'; }++ });++}@@@ -111,6 +111,7 @@<button onclick="showSection('notifications',this)"><i class="fa-solid fa-bell"></i> Notifications</button><button onclick="showSection('history',this)"><i class="fa-solid fa-clock-rotate-left"></i> History</button><button onclick="showSection('data',this)"><i class="fa-solid fa-database"></i> AI & Data</button>++ <button onclick="showSection('content',this)"><i class="fa-solid fa-shield-halved"></i> Content</button><button onclick="showSection('approvals',this)"><i class="fa-solid fa-user-check"></i> Approvals</button><button onclick="showSection('linked',this)"><i class="fa-solid fa-link"></i> Linked Services</button><button onclick="showSection('machines',this)"><i class="fa-solid fa-laptop"></i> Machines</button>@@@ -356,6 +357,22 @@</div></div>++ <!-- Content Section: the 18+ confirmation for uncensored AI -->++ <div class="section" id="sec-content">++ <div class="card">++ <h3><span class="icon"><i class="fa-solid fa-shield-halved"></i></span> Uncensored AI (18+)</h3>++ <p style="margin-bottom:12px">OpenVibe Exchange can run abliterated models that refuse less. They are for adults only. Your confirmation is kept on your account; OpenVibe AI asks for it on every uncensored request and never guesses your age.</p>++ <div id="adult-status" class="muted" role="status">Checking your account…</div>++ <label class="data-check" style="margin-top:12px"><input type="checkbox" id="adult-confirm-box"> <span><b>I am 18 or older.</b> I understand uncensored answers are unfiltered, that the hard floor below still applies, and that breaking it ends the account.</span></label>++ <div class="btn-row" style="margin-top:12px">++ <button type="button" class="btn btn-primary" id="adult-confirm">Confirm I am 18+</button>++ <button type="button" class="btn" id="adult-withdraw" hidden>Withdraw my confirmation</button>++ <span class="data-status" id="adult-msg" role="status"></span>++ </div>++ <p class="muted" style="margin-top:14px;font-size:13px">The hard floor on every model, uncensored or not: no sexual content involving minors; no non-consensual sexual content of real people; no help with weapons of mass harm or attacks on others.</p>++ </div>++ </div>++<!-- Linked Services Section --><div class="section" id="sec-approvals"><div class="card">nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++/**++ * The adult account flag (the Exchange remake, "Uncensored").++ *++ * OpenVibe.AI serves an uncensored model only to an account that confirmed, here, that it is 18 or older. The flag is++ * the account's own: `users.adult_confirmed_at` (migration 0041). Setting it is a deliberate act — the person ticks++ * the box on `/my` and confirms — and withdrawing it clears it at once. Network never infers an age.++ *++ * GET /api/v1/me/adult (session) → { adult, confirmed_at }++ * PUT /api/v1/me/adult (session) { confirm: true } → { adult: true, confirmed_at }++ * DELETE /api/v1/me/adult (session) → { adult: false }++ *++ * GET /internal/identity/adult?subject_id=usr_… (service token, identity.subject.resolve)++ * → { subject_id, adult, confirmed_at } (AI asks this; server/policy/adult-status.js)++ *++ * Anonymous accounts cannot confirm: the flag has to belong to an account that can be held to it.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const CONFIRMATION_VERSION = 1;++++async function readByUser(db, userId) {++ const row = await db.prepare('SELECT adult_confirmed_at FROM users WHERE id = ?').get(userId);++ return { adult: Boolean(row && row.adult_confirmed_at), confirmed_at: (row && row.adult_confirmed_at) || null };++}++++async function readBySubject(db, subjectId) {++ const row = await db.prepare('SELECT subject_id, adult_confirmed_at FROM users WHERE subject_id = ?').get(subjectId);++ if (!row) return null;++ return { subject_id: row.subject_id, adult: Boolean(row.adult_confirmed_at), confirmed_at: row.adult_confirmed_at || null };++}++++async function writeAudit(db, req, action, detail) {++ try { await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, action, JSON.stringify(detail)); }++ catch { /* the flag is the record; an audit failure must not undo a withdrawal */ }++}++++/** /api/v1/me/adult — the person's own confirmation. */++function userRouter({ requireAuth, now = () => new Date().toISOString() }) {++ const r = express.Router();++ const refuseAnon = (req, res, next) => (req.user && req.user.is_anon ? res.status(403).json({ error: 'Anonymous accounts cannot confirm an age; sign in to a full account first.' }) : next());++ r.get('/', requireAuth, async (req, res) => {++ res.set('Cache-Control', 'no-store').json(await readByUser(req.app.locals.db, req.user.id));++ });++ r.put('/', requireAuth, refuseAnon, async (req, res) => {++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ if (b.confirm !== true) return res.status(400).json({ error: 'Tick the box: this records that you are 18 or older.' });++ const at = now();++ await req.app.locals.db.prepare('UPDATE users SET adult_confirmed_at = ?, updated_at = ? WHERE id = ?').run(at, at, req.user.id);++ await writeAudit(req.app.locals.db, req, 'adult.confirmed', { confirmation_version: CONFIRMATION_VERSION, confirmed_at: at });++ res.set('Cache-Control', 'no-store').json({ adult: true, confirmed_at: at, confirmation_version: CONFIRMATION_VERSION });++ });++ r.delete('/', requireAuth, async (req, res) => {++ await req.app.locals.db.prepare('UPDATE users SET adult_confirmed_at = NULL, updated_at = ? WHERE id = ?').run(now(), req.user.id);++ await writeAudit(req.app.locals.db, req, 'adult.withdrawn', {});++ res.set('Cache-Control', 'no-store').json({ adult: false });++ });++ return r;++}++++/** /internal/identity/adult — what a first-party service with identity.subject.resolve may read. */++async function internalAdult(req, res) {++ const subjectId = String((req.query && req.query.subject_id) || '');++ if (!SUBJECT_RE.test(subjectId)) return http.sendProblem(res, 400, 'identity.bad_request', { detail: 'subject_id must be a usr_ subject', ctx: req.ov });++ const out = await readBySubject(req.app.locals.db, subjectId);++ if (!out) return http.sendProblem(res, 404, 'identity.subject_not_found', { detail: 'no subject for that id', ctx: req.ov });++ res.set('Cache-Control', 'no-store').json(out);++}++++module.exports = { userRouter, internalAdult, readByUser, readBySubject, SUBJECT_RE, CONFIRMATION_VERSION };@@@ -84,6 +84,9 @@ const DEFAULT_GRANTS = [['chat', 'network.modules.write', SELF_AUDIENCE, CHAT_NAMESPACES],['ai', 'network.modules.read', SELF_AUDIENCE, ['ai.preferences', 'ai.usage_summary']],['ai', 'network.modules.write', SELF_AUDIENCE, ['ai.usage_summary']],++ // The Exchange remake's uncensored tier: AI reads the account's 18+ confirmation before serving an uncensored++ // model (server/identity/adult.js, GET /internal/identity/adult).++ ['ai', 'identity.subject.resolve', SELF_AUDIENCE, []],['community', 'network.modules.read', SELF_AUDIENCE, ['community.profile']],['community', 'network.modules.write', SELF_AUDIENCE, ['community.profile']],['wiki', 'network.modules.read', SELF_AUDIENCE, ['wiki.projects']],@@@ -466,6 +466,9 @@ app.get('/internal/integrations/github-token', require('./identity/principals').// Platform blocks (WS-E task 5): a person's own list, and who blocked whom for Chat and Community// (network.blocks.read, service token only). Every change is network.block.changed (server/identity/blocks.js).app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('./identity/blocks').userRouter(requireAuth));++// The Exchange remake's uncensored tier: the account's own 18+ confirmation (server/identity/adult.js). AI reads it++// through /internal/identity/adult; this is the person's side, on the account hub (/my, Content).++app.use('/api/v1/me/adult', rateLimit({ windowMs: 60_000, max: 20 }), require('./identity/adult').userRouter({ requireAuth }));// Account merge (roadmap WS-B task 5, ADR-029): the person folds a second account into this one; staff// (staff.identity.merge) for account recovery, with a reason.{@@@ -53,6 +53,8 @@ router.post('/identity/resolve-batch', principals.guard('identity.subject.resolv// Writing the legacy map is part of identity resolution (ADR-001; identity.subject.resolve takes// identity.legacy-identity-map@1). TODO(contracts): a narrower identity.legacy_map.write capability.router.post('/identity/legacy-map', principals.guard('identity.subject.resolve'), ownSourceSystem);++// The Exchange remake's uncensored tier: AI asks whether an account confirmed it is 18+ (server/identity/adult.js).++router.get('/identity/adult', principals.guard('identity.subject.resolve'), require('../identity/adult').internalAdult);router.use('/identity', require('../identity/internal-routes'));// ── Subject assertions (ADR-003, the 2026-11-06 amendment; plan H1) ───────────────────nnew file mode 100644@@@ -0,0 +1,60 @@++'use strict';++// The Exchange remake's uncensored tier, Network side: the account's own 18+ confirmation++// (server/identity/adult.js). The flag is a timestamp, anonymous accounts cannot set it, withdrawing clears it, and++// a first-party service reads it by the person's usr_ subject. The schema comes from migrations/0028.++// node --import ./test/helpers/pg-preload.mjs test/adult-confirmation.test.js++const assert = require('assert');++const express = require('express');++const { getDb } = require('../server/db/database');++const adult = require('../server/identity/adult');++++(async () => {++ const db = getDb();++ const quiet = console.log;++ console.log = () => {};++ await db.prepare("INSERT INTO users (id, username, password_hash, email_verified, subject_id) VALUES (71, 'grown', 'x', 1, 'usr_01JADVNT000000000000000000'), (72, 'anon', 'x', 1, 'usr_01JAN0N0000000000000000000')").run();++ console.log = quiet;++++ let user = 71;++ const app = express();++ app.locals.db = db;++ app.use(express.json());++ app.use('/api/v1/me/adult', adult.userRouter({++ requireAuth: (req, _res, next) => { req.user = { id: user, is_anon: user === 72 ? 1 : 0 }; next(); },++ }));++ app.get('/internal/identity/adult', adult.internalAdult);++ const server = app.listen(0, '127.0.0.1');++ await new Promise((r) => server.once('listening', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const call = async (method, path, body) => {++ const res = await fetch(base + path, { method, headers: body !== undefined ? { 'content-type': 'application/json' } : {}, body: body !== undefined ? JSON.stringify(body) : undefined });++ return { status: res.status, body: await res.json() };++ };++ try {++ assert.deepStrictEqual((await call('GET', '/api/v1/me/adult')).body, { adult: false, confirmed_at: null });++ assert.strictEqual((await call('PUT', '/api/v1/me/adult', {})).status, 400);++ const confirmed = await call('PUT', '/api/v1/me/adult', { confirm: true });++ assert.strictEqual(confirmed.status, 200);++ assert.strictEqual(confirmed.body.adult, true);++ assert.ok(confirmed.body.confirmed_at);++ assert.strictEqual((await call('GET', '/api/v1/me/adult')).body.adult, true);++ const audit = await db.prepare("SELECT action FROM audit_log WHERE user_id = 71 AND action = 'adult.confirmed'").get();++ assert.ok(audit, 'the confirmation is audited');++++ // What OpenVibe.AI reads: by subject, and only for a usr_ subject.++ const internal = await call('GET', '/internal/identity/adult?subject_id=usr_01JADVNT000000000000000000');++ assert.strictEqual(internal.status, 200);++ assert.deepStrictEqual(internal.body, { subject_id: 'usr_01JADVNT000000000000000000', adult: true, confirmed_at: confirmed.body.confirmed_at });++ assert.strictEqual((await call('GET', '/internal/identity/adult?subject_id=usr_01JN0B0DY00000000000000000')).status, 404);++ assert.strictEqual((await call('GET', '/internal/identity/adult?subject_id=nope')).status, 400);++++ // Withdrawing takes effect at once, and an anonymous account cannot confirm.++ assert.strictEqual((await call('DELETE', '/api/v1/me/adult')).body.adult, false);++ assert.strictEqual((await call('GET', '/internal/identity/adult?subject_id=usr_01JADVNT000000000000000000')).body.adult, false);++ user = 72;++ assert.strictEqual((await call('PUT', '/api/v1/me/adult', { confirm: true })).status, 403);++ } finally {++ await new Promise((r) => server.close(r));++ }++ console.log('adult-confirmation: 1/1 passed');++})().catch((err) => { console.error(err); process.exit(1); });@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };// The real directory: 0016 applies on top of 0015, once.const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand']]);assert.ok(await hasAgents());assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };// ── 0020 applies on top of 0019, once, and rewrites nothing.const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand']]);assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+-- phase: expand
+-- The Exchange remake's uncensored tier: the account's own 18+ confirmation (server/identity/adult.js). It is a
+-- timestamp, never a birth date; OpenVibe.AI reads it through /internal/identity/adult and never guesses an age.
+-- Null means no confirmation (the default), and withdrawing the confirmation clears it.
+ALTER TABLE users ADD COLUMN IF NOT EXISTS adult_confirmed_at text COLLATE "C";
@@ -158,6 +158,7 @@ function showSection(id, btn) {
@@ -158,6 +158,7 @@ function showSection(id, btn) {
history.replaceState(null, '', id === 'profile' ? window.location.pathname : `${window.location.pathname}#${id}`);
history.replaceState(null, '', id === 'profile' ? window.location.pathname : `${window.location.pathname}#${id}`);
if (id === 'history' && typeof loadHistory === 'function') loadHistory();
if (id === 'history' && typeof loadHistory === 'function') loadHistory();
if (id === 'data') loadDataSection();
if (id === 'data') loadDataSection();
+ if (id === 'content' && typeof loadAdultSection === 'function') loadAdultSection();
if (id === 'approvals' && typeof loadApprovals === 'function') loadApprovals();
if (id === 'approvals' && typeof loadApprovals === 'function') loadApprovals();
if (id === 'machines' && typeof loadMachines === 'function') loadMachines();
if (id === 'machines' && typeof loadMachines === 'function') loadMachines();
}
}
@@ -166,7 +167,7 @@ function openInitialSection() {
@@ -166,7 +167,7 @@ function openInitialSection() {
const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/machines': 'machines', '/profile': 'profile', '/history': 'history' }[window.location.pathname];
const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/machines': 'machines', '/profile': 'profile', '/history': 'history' }[window.location.pathname];
const routeSection = pathSection || window.location.hash.replace(/^#/, '') || 'profile';
const routeSection = pathSection || window.location.hash.replace(/^#/, '') || 'profile';
if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);
if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);
- if (isAnonSession() && ['notifications','linked','security','data','approvals','machines'].includes(routeSection)) {
+ if (isAnonSession() && ['notifications','linked','security','data','content','approvals','machines'].includes(routeSection)) {
return showSection('anon');
return showSection('anon');
}
}
if (document.getElementById('sec-' + routeSection)) {
if (document.getElementById('sec-' + routeSection)) {
@@ -142,3 +142,45 @@ function exportModules() {
@@ -142,3 +142,45 @@ function exportModules() {
document.body.appendChild(a); a.click(); a.remove();
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
}
+
+// The Exchange remake's uncensored tier: the account's own 18+ confirmation (server/identity/adult.js). The flag is
+// read from the account, never inferred; withdrawing it takes effect at once.
+let adultWired = false, adultState = null;
+function renderAdult(state) {
+ adultState = state || { adult: false, confirmed_at: null };
+ const status = document.getElementById('adult-status');
+ const box = document.getElementById('adult-confirm-box');
+ const yes = document.getElementById('adult-confirm');
+ const no = document.getElementById('adult-withdraw');
+ if (!status || !box || !yes || !no) return;
+ status.textContent = adultState.adult
+ ? 'Confirmed ' + (adultState.confirmed_at ? 'on ' + new Date(adultState.confirmed_at).toLocaleDateString() : '') + '. Uncensored models are available on Exchange.'
+ : 'Not confirmed. Uncensored models are off for this account.';
+ box.checked = Boolean(adultState.adult);
+ yes.disabled = Boolean(adultState.adult);
+ no.hidden = !adultState.adult;
+}
+async function loadAdultSection() {
+ const box = document.getElementById('adult-confirm-box');
+ if (!box) return;
+ const msg = document.getElementById('adult-msg');
+ try { renderAdult(await apiFetch('/api/v1/me/adult')); }
+ catch { renderAdult({ adult: false, confirmed_at: null }); if (msg) msg.textContent = 'Could not read your account. Try again later.'; }
+ if (adultWired) return;
+ adultWired = true;
+ document.getElementById('adult-confirm').addEventListener('click', async () => {
+ if (!box.checked) { msg.textContent = 'Tick the box first.'; return; }
+ if (!confirm('This records on your account that you are 18 or older. Uncensored AI is for adults only, and the hard floor still applies. Continue?')) return;
+ try {
+ renderAdult(await apiFetch('/api/v1/me/adult', { method: 'PUT', body: JSON.stringify({ confirm: true }) }));
+ msg.textContent = 'Confirmed.';
+ } catch (e) { msg.textContent = e.message || 'Could not confirm.'; }
+ });
+ document.getElementById('adult-withdraw').addEventListener('click', async () => {
+ if (!confirm('Withdraw your 18+ confirmation? Uncensored models stop answering for this account at once.')) return;
+ try {
+ renderAdult(await apiFetch('/api/v1/me/adult', { method: 'DELETE' }));
+ msg.textContent = 'Withdrawn.';
+ } catch (e) { msg.textContent = e.message || 'Could not withdraw.'; }
+ });
+}
@@ -111,6 +111,7 @@
@@ -111,6 +111,7 @@
<button onclick="showSection('notifications',this)"><i class="fa-solid fa-bell"></i> Notifications</button>
<button onclick="showSection('notifications',this)"><i class="fa-solid fa-bell"></i> Notifications</button>
<button onclick="showSection('history',this)"><i class="fa-solid fa-clock-rotate-left"></i> History</button>
<button onclick="showSection('history',this)"><i class="fa-solid fa-clock-rotate-left"></i> History</button>
<button onclick="showSection('data',this)"><i class="fa-solid fa-database"></i> AI & Data</button>
<button onclick="showSection('data',this)"><i class="fa-solid fa-database"></i> AI & Data</button>
+ <button onclick="showSection('content',this)"><i class="fa-solid fa-shield-halved"></i> Content</button>
<button onclick="showSection('approvals',this)"><i class="fa-solid fa-user-check"></i> Approvals</button>
<button onclick="showSection('approvals',this)"><i class="fa-solid fa-user-check"></i> Approvals</button>
<button onclick="showSection('linked',this)"><i class="fa-solid fa-link"></i> Linked Services</button>
<button onclick="showSection('linked',this)"><i class="fa-solid fa-link"></i> Linked Services</button>
<button onclick="showSection('machines',this)"><i class="fa-solid fa-laptop"></i> Machines</button>
<button onclick="showSection('machines',this)"><i class="fa-solid fa-laptop"></i> Machines</button>
@@ -356,6 +357,22 @@
@@ -356,6 +357,22 @@
</div>
</div>
</div>
</div>
+ <!-- Content Section: the 18+ confirmation for uncensored AI -->
+ <div class="section" id="sec-content">
+ <div class="card">
+ <h3><span class="icon"><i class="fa-solid fa-shield-halved"></i></span> Uncensored AI (18+)</h3>
+ <p style="margin-bottom:12px">OpenVibe Exchange can run abliterated models that refuse less. They are for adults only. Your confirmation is kept on your account; OpenVibe AI asks for it on every uncensored request and never guesses your age.</p>
+ <div id="adult-status" class="muted" role="status">Checking your account…</div>
+ <label class="data-check" style="margin-top:12px"><input type="checkbox" id="adult-confirm-box"> <span><b>I am 18 or older.</b> I understand uncensored answers are unfiltered, that the hard floor below still applies, and that breaking it ends the account.</span></label>
+ <div class="btn-row" style="margin-top:12px">
+ <button type="button" class="btn btn-primary" id="adult-confirm">Confirm I am 18+</button>
+ <button type="button" class="btn" id="adult-withdraw" hidden>Withdraw my confirmation</button>
+ <span class="data-status" id="adult-msg" role="status"></span>
+ </div>
+ <p class="muted" style="margin-top:14px;font-size:13px">The hard floor on every model, uncensored or not: no sexual content involving minors; no non-consensual sexual content of real people; no help with weapons of mass harm or attacks on others.</p>
+ </div>
+ </div>
+
<!-- Linked Services Section -->
<!-- Linked Services Section -->
<div class="section" id="sec-approvals">
<div class="section" id="sec-approvals">
<div class="card">
<div class="card">
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+/**
+ * The adult account flag (the Exchange remake, "Uncensored").
+ *
+ * OpenVibe.AI serves an uncensored model only to an account that confirmed, here, that it is 18 or older. The flag is
+ * the account's own: `users.adult_confirmed_at` (migration 0041). Setting it is a deliberate act — the person ticks
+ * the box on `/my` and confirms — and withdrawing it clears it at once. Network never infers an age.
+ *
+ * GET /api/v1/me/adult (session) → { adult, confirmed_at }
+ * PUT /api/v1/me/adult (session) { confirm: true } → { adult: true, confirmed_at }
+ * DELETE /api/v1/me/adult (session) → { adult: false }
+ *
+ * GET /internal/identity/adult?subject_id=usr_… (service token, identity.subject.resolve)
+ * → { subject_id, adult, confirmed_at } (AI asks this; server/policy/adult-status.js)
+ *
+ * Anonymous accounts cannot confirm: the flag has to belong to an account that can be held to it.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const CONFIRMATION_VERSION = 1;
+
+async function readByUser(db, userId) {
+ const row = await db.prepare('SELECT adult_confirmed_at FROM users WHERE id = ?').get(userId);
+ return { adult: Boolean(row && row.adult_confirmed_at), confirmed_at: (row && row.adult_confirmed_at) || null };
+}
+
+async function readBySubject(db, subjectId) {
+ const row = await db.prepare('SELECT subject_id, adult_confirmed_at FROM users WHERE subject_id = ?').get(subjectId);
+ if (!row) return null;
+ return { subject_id: row.subject_id, adult: Boolean(row.adult_confirmed_at), confirmed_at: row.adult_confirmed_at || null };
+}
+
+async function writeAudit(db, req, action, detail) {
+ try { await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, action, JSON.stringify(detail)); }
+ catch { /* the flag is the record; an audit failure must not undo a withdrawal */ }
+}
+
+/** /api/v1/me/adult — the person's own confirmation. */
+function userRouter({ requireAuth, now = () => new Date().toISOString() }) {
+ const r = express.Router();
+ const refuseAnon = (req, res, next) => (req.user && req.user.is_anon ? res.status(403).json({ error: 'Anonymous accounts cannot confirm an age; sign in to a full account first.' }) : next());
+ r.get('/', requireAuth, async (req, res) => {
+ res.set('Cache-Control', 'no-store').json(await readByUser(req.app.locals.db, req.user.id));
+ });
+ r.put('/', requireAuth, refuseAnon, async (req, res) => {
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ if (b.confirm !== true) return res.status(400).json({ error: 'Tick the box: this records that you are 18 or older.' });
+ const at = now();
+ await req.app.locals.db.prepare('UPDATE users SET adult_confirmed_at = ?, updated_at = ? WHERE id = ?').run(at, at, req.user.id);
+ await writeAudit(req.app.locals.db, req, 'adult.confirmed', { confirmation_version: CONFIRMATION_VERSION, confirmed_at: at });
+ res.set('Cache-Control', 'no-store').json({ adult: true, confirmed_at: at, confirmation_version: CONFIRMATION_VERSION });
+ });
+ r.delete('/', requireAuth, async (req, res) => {
+ await req.app.locals.db.prepare('UPDATE users SET adult_confirmed_at = NULL, updated_at = ? WHERE id = ?').run(now(), req.user.id);
+ await writeAudit(req.app.locals.db, req, 'adult.withdrawn', {});
+ res.set('Cache-Control', 'no-store').json({ adult: false });
+ });
+ return r;
+}
+
+/** /internal/identity/adult — what a first-party service with identity.subject.resolve may read. */
+async function internalAdult(req, res) {
+ const subjectId = String((req.query && req.query.subject_id) || '');
+ if (!SUBJECT_RE.test(subjectId)) return http.sendProblem(res, 400, 'identity.bad_request', { detail: 'subject_id must be a usr_ subject', ctx: req.ov });
+ const out = await readBySubject(req.app.locals.db, subjectId);
+ if (!out) return http.sendProblem(res, 404, 'identity.subject_not_found', { detail: 'no subject for that id', ctx: req.ov });
+ res.set('Cache-Control', 'no-store').json(out);
+}
+
+module.exports = { userRouter, internalAdult, readByUser, readBySubject, SUBJECT_RE, CONFIRMATION_VERSION };
@@ -84,6 +84,9 @@ const DEFAULT_GRANTS = [
@@ -84,6 +84,9 @@ const DEFAULT_GRANTS = [
['chat', 'network.modules.write', SELF_AUDIENCE, CHAT_NAMESPACES],
['chat', 'network.modules.write', SELF_AUDIENCE, CHAT_NAMESPACES],
['ai', 'network.modules.read', SELF_AUDIENCE, ['ai.preferences', 'ai.usage_summary']],
['ai', 'network.modules.read', SELF_AUDIENCE, ['ai.preferences', 'ai.usage_summary']],
['ai', 'network.modules.write', SELF_AUDIENCE, ['ai.usage_summary']],
['ai', 'network.modules.write', SELF_AUDIENCE, ['ai.usage_summary']],
+ // The Exchange remake's uncensored tier: AI reads the account's 18+ confirmation before serving an uncensored
+ // model (server/identity/adult.js, GET /internal/identity/adult).
+ ['ai', 'identity.subject.resolve', SELF_AUDIENCE, []],
['community', 'network.modules.read', SELF_AUDIENCE, ['community.profile']],
['community', 'network.modules.read', SELF_AUDIENCE, ['community.profile']],
['community', 'network.modules.write', SELF_AUDIENCE, ['community.profile']],
['community', 'network.modules.write', SELF_AUDIENCE, ['community.profile']],
['wiki', 'network.modules.read', SELF_AUDIENCE, ['wiki.projects']],
['wiki', 'network.modules.read', SELF_AUDIENCE, ['wiki.projects']],
@@ -466,6 +466,9 @@ app.get('/internal/integrations/github-token', require('./identity/principals').
@@ -466,6 +466,9 @@ app.get('/internal/integrations/github-token', require('./identity/principals').
// Platform blocks (WS-E task 5): a person's own list, and who blocked whom for Chat and Community
// Platform blocks (WS-E task 5): a person's own list, and who blocked whom for Chat and Community
// (network.blocks.read, service token only). Every change is network.block.changed (server/identity/blocks.js).
// (network.blocks.read, service token only). Every change is network.block.changed (server/identity/blocks.js).
app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('./identity/blocks').userRouter(requireAuth));
app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('./identity/blocks').userRouter(requireAuth));
+// The Exchange remake's uncensored tier: the account's own 18+ confirmation (server/identity/adult.js). AI reads it
+// through /internal/identity/adult; this is the person's side, on the account hub (/my, Content).
+app.use('/api/v1/me/adult', rateLimit({ windowMs: 60_000, max: 20 }), require('./identity/adult').userRouter({ requireAuth }));
// Account merge (roadmap WS-B task 5, ADR-029): the person folds a second account into this one; staff
// Account merge (roadmap WS-B task 5, ADR-029): the person folds a second account into this one; staff
// (staff.identity.merge) for account recovery, with a reason.
// (staff.identity.merge) for account recovery, with a reason.
{
{
@@ -53,6 +53,8 @@ router.post('/identity/resolve-batch', principals.guard('identity.subject.resolv
@@ -53,6 +53,8 @@ router.post('/identity/resolve-batch', principals.guard('identity.subject.resolv
// Writing the legacy map is part of identity resolution (ADR-001; identity.subject.resolve takes
// Writing the legacy map is part of identity resolution (ADR-001; identity.subject.resolve takes
// identity.legacy-identity-map@1). TODO(contracts): a narrower identity.legacy_map.write capability.
// identity.legacy-identity-map@1). TODO(contracts): a narrower identity.legacy_map.write capability.
router.post('/identity/legacy-map', principals.guard('identity.subject.resolve'), ownSourceSystem);
router.post('/identity/legacy-map', principals.guard('identity.subject.resolve'), ownSourceSystem);
+// The Exchange remake's uncensored tier: AI asks whether an account confirmed it is 18+ (server/identity/adult.js).
+router.get('/identity/adult', principals.guard('identity.subject.resolve'), require('../identity/adult').internalAdult);
router.use('/identity', require('../identity/internal-routes'));
router.use('/identity', require('../identity/internal-routes'));
// ── Subject assertions (ADR-003, the 2026-11-06 amendment; plan H1) ───────────────────
// ── Subject assertions (ADR-003, the 2026-11-06 amendment; plan H1) ───────────────────
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+'use strict';
+// The Exchange remake's uncensored tier, Network side: the account's own 18+ confirmation
+// (server/identity/adult.js). The flag is a timestamp, anonymous accounts cannot set it, withdrawing clears it, and
+// a first-party service reads it by the person's usr_ subject. The schema comes from migrations/0028.
+// node --import ./test/helpers/pg-preload.mjs test/adult-confirmation.test.js
+const assert = require('assert');
+const express = require('express');
+const { getDb } = require('../server/db/database');
+const adult = require('../server/identity/adult');
+
+(async () => {
+ const db = getDb();
+ const quiet = console.log;
+ console.log = () => {};
+ await db.prepare("INSERT INTO users (id, username, password_hash, email_verified, subject_id) VALUES (71, 'grown', 'x', 1, 'usr_01JADVNT000000000000000000'), (72, 'anon', 'x', 1, 'usr_01JAN0N0000000000000000000')").run();
+ console.log = quiet;
+
+ let user = 71;
+ const app = express();
+ app.locals.db = db;
+ app.use(express.json());
+ app.use('/api/v1/me/adult', adult.userRouter({
+ requireAuth: (req, _res, next) => { req.user = { id: user, is_anon: user === 72 ? 1 : 0 }; next(); },
+ }));
+ app.get('/internal/identity/adult', adult.internalAdult);
+ const server = app.listen(0, '127.0.0.1');
+ await new Promise((r) => server.once('listening', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const call = async (method, path, body) => {
+ const res = await fetch(base + path, { method, headers: body !== undefined ? { 'content-type': 'application/json' } : {}, body: body !== undefined ? JSON.stringify(body) : undefined });
+ return { status: res.status, body: await res.json() };
+ };
+ try {
+ assert.deepStrictEqual((await call('GET', '/api/v1/me/adult')).body, { adult: false, confirmed_at: null });
+ assert.strictEqual((await call('PUT', '/api/v1/me/adult', {})).status, 400);
+ const confirmed = await call('PUT', '/api/v1/me/adult', { confirm: true });
+ assert.strictEqual(confirmed.status, 200);
+ assert.strictEqual(confirmed.body.adult, true);
+ assert.ok(confirmed.body.confirmed_at);
+ assert.strictEqual((await call('GET', '/api/v1/me/adult')).body.adult, true);
+ const audit = await db.prepare("SELECT action FROM audit_log WHERE user_id = 71 AND action = 'adult.confirmed'").get();
+ assert.ok(audit, 'the confirmation is audited');
+
+ // What OpenVibe.AI reads: by subject, and only for a usr_ subject.
+ const internal = await call('GET', '/internal/identity/adult?subject_id=usr_01JADVNT000000000000000000');
+ assert.strictEqual(internal.status, 200);
+ assert.deepStrictEqual(internal.body, { subject_id: 'usr_01JADVNT000000000000000000', adult: true, confirmed_at: confirmed.body.confirmed_at });
+ assert.strictEqual((await call('GET', '/internal/identity/adult?subject_id=usr_01JN0B0DY00000000000000000')).status, 404);
+ assert.strictEqual((await call('GET', '/internal/identity/adult?subject_id=nope')).status, 400);
+
+ // Withdrawing takes effect at once, and an anonymous account cannot confirm.
+ assert.strictEqual((await call('DELETE', '/api/v1/me/adult')).body.adult, false);
+ assert.strictEqual((await call('GET', '/internal/identity/adult?subject_id=usr_01JADVNT000000000000000000')).body.adult, false);
+ user = 72;
+ assert.strictEqual((await call('PUT', '/api/v1/me/adult', { confirm: true })).status, 403);
+ } finally {
+ await new Promise((r) => server.close(r));
+ }
+ console.log('adult-confirmation: 1/1 passed');
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand']]);
assert.ok(await hasAgents());
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');