openvibers/openvibe.network Commits

Account: the 18+ confirmation for uncensored AI (#587)

OP OpenVibers committed 7 days ago · cdd1040451 · 11 files, +208 −3

* Account: the 18+ confirmation for uncensored AI

Add users.adult_confirmed_at, the person's GET/PUT/DELETE /api/v1/me/adult, a Content section on /my with the confirmation and the hard floor, and the service-only GET /internal/identity/adult that OpenVibe.AI reads under identity.subject.resolve. Anonymous accounts cannot confirm; withdrawal takes effect at once.

* Account: renumber the adult-confirmation migration to 0040

Origin/main already has 0028 (the Locker key directory is at 0039); the migration runner refuses two files with the same number.

* Merge remote-tracking branch 'origin/main' into feat/ai-adult-confirmation

* Merge remote-tracking branch 'origin/main' into feat/ai-adult-confirmation

* Account: the adult-confirmation migration is 0041, after main's 0040

main took 0040 for the AI offers (#581), so the 18+ confirmation moves to 0041.
The two tests that pin the migration list now read 0041 adult_confirmation.

Changes

ddiff --git a/migrations/0041_adult_confirmation.sql b/migrations/0041_adult_confirmation.sql
nnew file mode 100644
iindex 00000000..3a567ef5
---- /dev/null
++++ b/migrations/0041_adult_confirmation.sql
@@@ -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";
ddiff --git a/public/js/my/core.js b/public/js/my/core.js
iindex d6523fc2..39423105 100644
---- a/public/js/my/core.js
++++ b/public/js/my/core.js
@@@ -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)) {
ddiff --git a/public/js/my/tools.js b/public/js/my/tools.js
iindex 884edaae..df09a061 100644
---- a/public/js/my/tools.js
++++ b/public/js/my/tools.js
@@@ -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.'; }
++ });
++}
ddiff --git a/public/my.html b/public/my.html
iindex 00bd23d1..7ad0f3f6 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -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 &amp; 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">
ddiff --git a/server/identity/adult.js b/server/identity/adult.js
nnew file mode 100644
iindex 00000000..5aebf824
---- /dev/null
++++ b/server/identity/adult.js
@@@ -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 };
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 9859cca3..6f451ffc 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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']],
ddiff --git a/server/index.js b/server/index.js
iindex dd68678b..2a564d0a 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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.
{
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex 9d528bf4..bf5749ac 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -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) ───────────────────
ddiff --git a/test/adult-confirmation.test.js b/test/adult-confirmation.test.js
nnew file mode 100644
iindex 00000000..d703f7a0
---- /dev/null
++++ b/test/adult-confirmation.test.js
@@@ -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); });
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex f36f093c..56050f26 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -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.
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 66795788..e9fbd888 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -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 twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');