Release branch for 4.0.0.
This commit is contained in:
jubnl
2026-08-27 13:22:39 +02:00
committed by GitHub
parent e60427f813
commit 77dddd5e8a
3355 changed files with 403744 additions and 67215 deletions
+17 -1
View File
@@ -49,6 +49,22 @@ const RETRIES = 3
// what restores the dropped territories (Greenland, Falklands, French Guiana, …).
const A3_TO_A2 = {"ABW":"AW", "AFG":"AF", "AGO":"AO", "AIA":"AI", "ALA":"AX", "ALB":"AL", "AND":"AD", "ARE":"AE", "ARG":"AR", "ARM":"AM", "ASM":"AS", "ATA":"AQ", "ATF":"TF", "ATG":"AG", "AUS":"AU", "AUT":"AT", "AZE":"AZ", "BDI":"BI", "BEL":"BE", "BEN":"BJ", "BES":"BQ", "BFA":"BF", "BGD":"BD", "BGR":"BG", "BHR":"BH", "BHS":"BS", "BIH":"BA", "BLM":"BL", "BLR":"BY", "BLZ":"BZ", "BMU":"BM", "BOL":"BO", "BRA":"BR", "BRB":"BB", "BRN":"BN", "BTN":"BT", "BVT":"BV", "BWA":"BW", "CAF":"CF", "CAN":"CA", "CCK":"CC", "CHE":"CH", "CHL":"CL", "CHN":"CN", "CIV":"CI", "CMR":"CM", "COD":"CD", "COG":"CG", "COK":"CK", "COL":"CO", "COM":"KM", "CPV":"CV", "CRI":"CR", "CUB":"CU", "CUW":"CW", "CXR":"CX", "CYM":"KY", "CYP":"CY", "CZE":"CZ", "DEU":"DE", "DJI":"DJ", "DMA":"DM", "DNK":"DK", "DOM":"DO", "DZA":"DZ", "ECU":"EC", "EGY":"EG", "ERI":"ER", "ESH":"EH", "ESP":"ES", "EST":"EE", "ETH":"ET", "FIN":"FI", "FJI":"FJ", "FLK":"FK", "FRA":"FR", "FRO":"FO", "FSM":"FM", "GAB":"GA", "GBR":"GB", "GEO":"GE", "GGY":"GG", "GHA":"GH", "GIB":"GI", "GIN":"GN", "GLP":"GP", "GMB":"GM", "GNB":"GW", "GNQ":"GQ", "GRC":"GR", "GRD":"GD", "GRL":"GL", "GTM":"GT", "GUF":"GF", "GUM":"GU", "GUY":"GY", "HKG":"HK", "HMD":"HM", "HND":"HN", "HRV":"HR", "HTI":"HT", "HUN":"HU", "IDN":"ID", "IMN":"IM", "IND":"IN", "IOT":"IO", "IRL":"IE", "IRN":"IR", "IRQ":"IQ", "ISL":"IS", "ISR":"IL", "ITA":"IT", "JAM":"JM", "JEY":"JE", "JOR":"JO", "JPN":"JP", "KAZ":"KZ", "KEN":"KE", "KGZ":"KG", "KHM":"KH", "KIR":"KI", "KNA":"KN", "KOR":"KR", "KWT":"KW", "LAO":"LA", "LBN":"LB", "LBR":"LR", "LBY":"LY", "LCA":"LC", "LIE":"LI", "LKA":"LK", "LSO":"LS", "LTU":"LT", "LUX":"LU", "LVA":"LV", "MAC":"MO", "MAF":"MF", "MAR":"MA", "MCO":"MC", "MDA":"MD", "MDG":"MG", "MDV":"MV", "MEX":"MX", "MHL":"MH", "MKD":"MK", "MLI":"ML", "MLT":"MT", "MMR":"MM", "MNE":"ME", "MNG":"MN", "MNP":"MP", "MOZ":"MZ", "MRT":"MR", "MSR":"MS", "MTQ":"MQ", "MUS":"MU", "MWI":"MW", "MYS":"MY", "MYT":"YT", "NAM":"NA", "NCL":"NC", "NER":"NE", "NFK":"NF", "NGA":"NG", "NIC":"NI", "NIU":"NU", "NLD":"NL", "NOR":"NO", "NPL":"NP", "NRU":"NR", "NZL":"NZ", "OMN":"OM", "PAK":"PK", "PAN":"PA", "PCN":"PN", "PER":"PE", "PHL":"PH", "PLW":"PW", "PNG":"PG", "POL":"PL", "PRI":"PR", "PRK":"KP", "PRT":"PT", "PRY":"PY", "PSE":"PS", "PYF":"PF", "QAT":"QA", "REU":"RE", "ROU":"RO", "RUS":"RU", "RWA":"RW", "SAU":"SA", "SDN":"SD", "SEN":"SN", "SGP":"SG", "SGS":"GS", "SHN":"SH", "SJM":"SJ", "SLB":"SB", "SLE":"SL", "SLV":"SV", "SMR":"SM", "SOM":"SO", "SPM":"PM", "SRB":"RS", "SSD":"SS", "STP":"ST", "SUR":"SR", "SVK":"SK", "SVN":"SI", "SWE":"SE", "SWZ":"SZ", "SXM":"SX", "SYC":"SC", "SYR":"SY", "TCA":"TC", "TCD":"TD", "TGO":"TG", "THA":"TH", "TJK":"TJ", "TKL":"TK", "TKM":"TM", "TLS":"TL", "TON":"TO", "TTO":"TT", "TUN":"TN", "TUR":"TR", "TUV":"TV", "TWN":"TW", "TZA":"TZ", "UGA":"UG", "UKR":"UA", "UMI":"UM", "URY":"UY", "USA":"US", "UZB":"UZ", "VAT":"VA", "VCT":"VC", "VEN":"VE", "VGB":"VG", "VIR":"VI", "VNM":"VN", "VUT":"VU", "WLF":"WF", "WSM":"WS", "XKX":"XK", "YEM":"YE", "ZAF":"ZA", "ZMB":"ZM", "ZWE":"ZW"}
/**
* Countries whose sub-national level is not the one everybody else's ADM1 is.
*
* geoBoundaries' GBR ADM1 is the four constituent countries — England, Scotland,
* Wales, Northern Ireland — so an atlas built from it has no polygon below
* "England", and a London borough marked as visited has nothing to light up
* (#1974). Its ADM2 is the 216 counties and unitary authorities, which is the
* granularity the rest of the world gets from ADM1 and the granularity the
* pre-3.1.0 Natural Earth layer had.
*
* Deliberately a short, hand-picked list. ADM2 everywhere would multiply the
* bundle several times over for no gain: for most countries ADM1 already is the
* level people think in.
*/
const ADM_LEVEL = { GBR: 'ADM2' }
const COUNTRIES = Object.keys(A3_TO_A2) // every ISO alpha-3 code (ADM1 fetch list)
// Cache raw downloads so re-runs (e.g. to tune simplification) don't re-fetch ~360 files.
@@ -216,7 +232,7 @@ async function main() {
// ADM1 (sub-national regions) — per-country gbOpen (carries ISO 3166-2 `shapeISO`).
console.log('[atlas-geo] downloading ADM1 (regions)…')
const adm1Raw = await pool(COUNTRIES, a3 => fetchGeo(MEDIA(a3, 'ADM1')))
const adm1Raw = await pool(COUNTRIES, a3 => fetchGeo(MEDIA(a3, ADM_LEVEL[a3] || 'ADM1')))
const adm1Features = []
let withCodes = 0
COUNTRIES.forEach((a3, idx) => {
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env node
/**
* Prints the per-domain `thresholds` block for vitest.config.ts from the last
* coverage run, at each domain's measured value minus one point of slack.
*
* npm run test:coverage # writes coverage/coverage-summary.json
* node scripts/coverage-thresholds.mjs
*
* The gate is a RATCHET: raise an entry when you improve a domain, never lower one
* to make a build pass. Use this when you have genuinely raised coverage and want the
* new floor written down, not to re-baseline a regression away.
*
* It reads coverage-summary.json rather than the text reporter on purpose. The text
* reporter prints one row per DIRECTORY, so a domain with subdirectories (plugins,
* memories, auth) reads several points higher there than it actually is, and a
* threshold copied off it would be wrong in the loose direction.
*/
import fs from 'node:fs';
import path from 'node:path';
const summaryPath = path.join(process.cwd(), 'coverage', 'coverage-summary.json');
if (!fs.existsSync(summaryPath)) {
console.error(`no ${summaryPath} — run "npm run test:coverage" first`);
process.exit(1);
}
const METRICS = [
['statements', 'statements'],
['branches', 'branches'],
['functions', 'functions'],
['lines', 'lines'],
];
const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
const domains = new Map();
for (const [file, entry] of Object.entries(summary)) {
if (file === 'total') continue;
const match = file.split('\\').join('/').match(/\/src\/nest\/([a-z0-9-]+)\//);
if (!match) continue;
const totals = domains.get(match[1]) ?? new Map(METRICS.map(([k]) => [k, { covered: 0, total: 0 }]));
for (const [key, metric] of METRICS) {
totals.get(key).covered += entry[metric].covered;
totals.get(key).total += entry[metric].total;
}
domains.set(match[1], totals);
}
// A domain with no branches at all reads as 100%, which is the right floor for it.
const floorOf = ({ covered, total }) => (total === 0 ? 100 : Math.max(0, Math.floor((100 * covered) / total) - 1));
for (const domain of [...domains.keys()].sort()) {
const totals = domains.get(domain);
const parts = METRICS.map(([key]) => `${key}: ${floorOf(totals.get(key))}`).join(', ');
console.log(` 'src/nest/${domain}/**/*.ts': { ${parts} },`);
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Turn hand-built spreads into auto-layout templates.
*
* Reads the book from a journey and writes the spreads out as data: every
* measurement divided by the page it was drawn on, so the same template lays
* out on an A4 landscape book as it does on the 210mm square it was designed
* for. Run from server/, output goes to the client.
*/
const Database = require('better-sqlite3')
const fs = require('fs')
const JOURNEY = Number(process.argv[2] || 7)
const OUT = process.argv[3] || '../client/src/components/Studio/bookTemplates.data.ts'
const db = new Database('data/travel.db', { readonly: true })
const row = db.prepare('SELECT document FROM journey_books WHERE journey_id = ?').get(JOURNEY)
db.close()
if (!row) throw new Error(`no book on journey ${JOURNEY}`)
/*
* Through the contract on the way in, so a template carries every field the
* document has — including any the schema gained since the spread was drawn.
* Without it a template written today is missing tomorrow's defaults and does
* not typecheck as a BookElement.
*/
const { normalizeBookDocument } = require('@trek/shared')
const doc = normalizeBookDocument(JSON.parse(row.document))
const PW = doc.page.pageWidth
const PH = doc.page.pageHeight
const r4 = n => Math.round(n * 10000) / 10000
/** A frame as fractions of one page's width and of the page height. */
function relFrame(f) {
return { x: r4(f.x / PW), y: r4(f.y / PH), w: r4(f.w / PW), h: r4(f.h / PH) }
}
/** Type size as a fraction of the page height, so it scales with the sheet. */
const relSize = n => r4(n / PH)
function element(e) {
// The id travels with the template and is replaced when it is applied —
// dropping it here would leave the element off the BookElement union.
const out = { ...e, frame: relFrame(e.frame) }
if (typeof out.size === 'number') out.size = relSize(out.size)
if (typeof out.radius === 'number') out.radius = r4(out.radius / PW)
if (typeof out.strokeWidth === 'number') out.strokeWidth = r4(out.strokeWidth / PW)
return out
}
const spreads = doc.spreads
.map((s, i) => ({ index: i, role: s.role, background: s.background, elements: s.elements.map(element) }))
.filter(s => s.role === 'inner')
const body = spreads.map((s, i) => ` {
id: 'ref-${i + 1}',
background: ${JSON.stringify(s.background)},
elements: ${JSON.stringify(s.elements, null, 6).replace(/\n/g, '\n ')},
},`).join('\n')
const file = `import type { BookElement } from '@trek/shared'
/**
* The spread templates, as data.
*
* ── Where these come from ────────────────────────────────────────────────
*
* Not from this file's author. They were built by hand in Studio and read back
* out of the document — which is the point: what a page should look like is a
* matter of taste, and taste is not something a layout function arrives at by
* reasoning. The editor is the design tool, and this is its output.
*
* To add one: build the spread in Studio, then run
*
* node scripts/extract-templates.cjs <journeyId>
*
* from server/ and commit what it writes here.
*
* ── Why the numbers are fractions ────────────────────────────────────────
*
* Every frame is divided by the page it was drawn on — x and w by one page's
* width, y and h by its height — and type sizes by the page height. A template
* built on a 210mm square therefore lays out on an A4 landscape book without
* being redrawn, which it would otherwise have to be for every trim size the
* picker offers.
*
* ── What gets filled in ──────────────────────────────────────────────────
*
* Elements carrying a \`binding\` take their text from the entry; empty photo
* frames take its photographs in order; the day, coordinate and country marks
* take what the entry knows about its stop. Everything else — the panels, the
* rules, the shapes that run off the page — is the design, and is kept as it
* was drawn.
*/
export interface SpreadTemplate {
id: string
background: string | null
/** Frames and sizes are fractions — see the note above. */
elements: BookElement[]
}
export const SPREAD_TEMPLATES: SpreadTemplate[] = [
${body}
]
`
fs.writeFileSync(OUT, file)
console.log(`wrote ${spreads.length} templates to ${OUT}`)
+159
View File
@@ -0,0 +1,159 @@
/**
* Regenerates every derived copy of the plugin permission facts from the single source,
* server/src/nest/plugins/protocol/envelope.ts.
*
* node --import tsx server/scripts/gen-plugin-facts.ts # write
* node --import tsx server/scripts/gen-plugin-facts.ts --check # exit 1 on drift
*
* It *imports* envelope.ts rather than scraping it. The guard it replaces
* (plugin-sdk/test/permissions-parity.test.ts) pulled the block out with a regex, which
* goes quietly null the moment someone reformats a closing brace — and that test only ran
* from prepublishOnly, so the failure would have surfaced as a broken npm release.
*
* The generated files are CHECKED IN: publish-plugin-sdk runs inside a standalone
* plugin-sdk/ checkout with no server present, so a build-time artefact would ship an SDK
* with an empty permission list.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
HOOK_PERMISSION, KNOWN_METHODS, KNOWN_PERMISSIONS, METHOD_PERMISSION,
EVENTS_PERMISSION, JOBS_PERMISSION, USER_DATA_PERMISSION, HTTP_OUTBOUND_PREFIX,
} from '../src/nest/plugins/protocol/envelope';
import { SNAPSHOT_GRANT, ENTITY_ID_KEYS } from '../src/plugin-event-sink';
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const HEADER = [
'// GENERATED — do not edit by hand.',
'// Source: server/src/nest/plugins/protocol/envelope.ts + server/src/plugin-event-sink.ts',
'// Regenerate: node --import tsx server/scripts/gen-plugin-facts.ts',
'',
].join('\n');
const list = (xs: readonly string[]) => xs.map((x) => ` '${x}',`).join('\n');
const pairs = (o: Readonly<Record<string, string>>) =>
Object.entries(o).map(([k, v]) => ` ${/^[A-Za-z_$][\w$]*$/.test(k) ? k : `'${k}'`}: '${v}',`).join('\n');
/**
* Types here are deliberately WIDE — Readonly<Record<string, string>> and string[] —
* matching exactly what plugin-sdk published before this file existed. An `as const`
* literal would be TS7053 under plugin-sdk/tsconfig.json's "strict": true, where
* grantGaps indexes HOOK_PERMISSION[key] with a plain string, and it would be a
* source-breaking type change for any plugin author doing a dynamic lookup. Strictness
* belongs on the host side, where the data source lives.
*/
const EVENT_FAMILIES = [...new Set([...Object.keys(ENTITY_ID_KEYS), ...Object.keys(SNAPSHOT_GRANT)])].sort();
const SDK_FACTS = `${HEADER}
export const HOOK_PERMISSION: Readonly<Record<string, string>> = {
${pairs(HOOK_PERMISSION)}
};
export const KNOWN_PERMISSIONS: string[] = [
${list(KNOWN_PERMISSIONS)}
];
export const METHOD_PERMISSION: Readonly<Record<string, string>> = {
${pairs(METHOD_PERMISSION)}
};
export const KNOWN_METHODS: string[] = [
${list(KNOWN_METHODS)}
];
export const USER_DATA_PERMISSION = '${USER_DATA_PERMISSION}';
export const EVENTS_PERMISSION = '${EVENTS_PERMISSION}';
export const JOBS_PERMISSION = '${JOBS_PERMISSION}';
export const HTTP_OUTBOUND_PREFIX = '${HTTP_OUTBOUND_PREFIX}';
/**
* Core-event catalog. Delivery names are the WebSocket broadcast names,
* \`<family>:<verb>\` (e.g. \`place:created\`). A subscribed plugin receives
* \`{event, tripId, entity?, entityId?, snapshot?}\`; \`snapshot\` is delivered only
* when the plugin also holds EVENT_SNAPSHOT_GRANT[family]. Delete/reorder/bulk
* events carry no snapshot.
*/
export const EVENT_FAMILIES: readonly string[] = [
${list(EVENT_FAMILIES)}
];
export const EVENT_SNAPSHOT_GRANT: Readonly<Record<string, string>> = {
${pairs(SNAPSHOT_GRANT)}
};
`;
const SHARED_FACTS = `${HEADER}
/** Widened on purpose so client callers can .includes() with a plain string. */
export const PLUGIN_PERMISSIONS: readonly string[] = [
${list(KNOWN_PERMISSIONS)}
];
export const PLUGIN_HOOK_PERMISSION: Readonly<Record<string, string>> = {
${pairs(HOOK_PERMISSION)}
};
`;
const OUTPUTS: Array<[string, string]> = [
['plugin-sdk/src/generated/host-facts.ts', SDK_FACTS],
['shared/src/plugin-permissions.ts', SHARED_FACTS],
];
/**
* Coverage assertions the type system cannot make, because the targets are not TypeScript
* unions. Both failure modes are silent today: a new permission reaches the consent screen
* as a raw code with no translation, and `trek-plugin permissions` prints it with no hint.
*/
function coverageProblems(): string[] {
const problems: string[] = [];
// Match the whole quoted key, not a prefix of it. `includes('plugins.perm.db:own')`
// also matches 'plugins.perm.db:ownX', and several permissions are prefixes of others
// ('db:read:files' vs 'db:read:files:content'), so a substring test passes on exactly
// the drift it is supposed to catch.
const enAdmin = fs.readFileSync(path.join(REPO, 'shared/src/i18n/en/admin.ts'), 'utf8');
const missingI18n = KNOWN_PERMISSIONS.filter((p) => !enAdmin.includes(`'admin.plugins.perm.${p}':`));
if (missingI18n.length) {
problems.push(
`shared/src/i18n/en/admin.ts is missing 'admin.plugins.perm.<permission>' for: ${missingI18n.join(', ')}`,
);
}
const ui = fs.readFileSync(path.join(REPO, 'plugin-sdk/src/cli/ui.ts'), 'utf8');
const missingHint = KNOWN_PERMISSIONS.filter((p) => !ui.includes(`'${p}'`));
if (missingHint.length) {
problems.push(
`plugin-sdk/src/cli/ui.ts PERMISSION_FAMILIES does not cover: ${missingHint.join(', ')}`,
);
}
return problems;
}
const check = process.argv.includes('--check');
let failed = false;
for (const [rel, content] of OUTPUTS) {
const abs = path.join(REPO, rel);
const current = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
if (current === content) continue;
if (check) {
console.error(`DRIFT: ${rel} does not match envelope.ts.`);
console.error(' Run: node --import tsx server/scripts/gen-plugin-facts.ts');
failed = true;
} else {
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
console.log(`wrote ${rel}`);
}
}
for (const problem of coverageProblems()) {
console.error(`COVERAGE: ${problem}`);
failed = true;
}
if (failed) process.exit(1);
if (check) console.log('plugin facts are in sync with envelope.ts');
+239 -7
View File
@@ -181,11 +181,47 @@ async function main() {
// Backup
const backupPath = `${dbPath}.backup-${Date.now()}`;
fs.copyFileSync(dbPath, backupPath);
console.log(`\nBackup created: ${backupPath}`);
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
// The rollback copy has to be a consistent snapshot, not a plain file copy:
// this script is documented as running against a live server, so in WAL mode
// the committed tail sits in the -wal sidecar a copy would leave behind.
// VACUUM INTO snapshots even while the server writes; the checkpoint plus
// copy is only the fallback for when it cannot (disk, lock).
try {
db.exec(`VACUUM INTO '${backupPath.replace(/'/g, "''")}'`);
} catch {
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
fs.copyFileSync(dbPath, backupPath);
}
console.log(`\nBackup created: ${backupPath}`);
// journal_mode lives in the file header, so hardcoding WAL here would quietly
// undo a deliberate DELETE/TRUNCATE setup (network storage) on the first key
// rotation. This script ships without src/ in the image and cannot import
// src/app-config, so it reads the same variables directly — keep the defaults
// in step with parsers.resolveDurability().
const journalModes = ['DELETE', 'TRUNCATE', 'PERSIST', 'MEMORY', 'WAL', 'OFF'];
const syncLevels = ['OFF', 'NORMAL', 'FULL', 'EXTRA'];
const wantedJournal = (process.env.TREK_DB_JOURNAL_MODE ?? '').trim().toUpperCase();
const journalMode = journalModes.includes(wantedJournal) ? wantedJournal : 'WAL';
if (wantedJournal && journalMode !== wantedJournal) {
console.warn(`TREK_DB_JOURNAL_MODE="${process.env.TREK_DB_JOURNAL_MODE}" is not a SQLite journal mode — using ${journalMode}.`);
}
const wantedSync = (process.env.TREK_DB_SYNCHRONOUS ?? '').trim().toUpperCase();
const defaultSync = journalMode === 'WAL' ? 'NORMAL' : 'FULL';
const synchronous = syncLevels.includes(wantedSync) ? wantedSync : defaultSync;
if (wantedSync && synchronous !== wantedSync) {
console.warn(`TREK_DB_SYNCHRONOUS="${process.env.TREK_DB_SYNCHRONOUS}" is not a SQLite synchronous level — using ${synchronous}.`);
}
db.pragma(`journal_mode = ${journalMode}`);
db.pragma(`synchronous = ${synchronous}`);
console.log(`Journal mode: ${db.pragma('journal_mode', { simple: true })}\n`);
const result: MigrationResult = { migrated: 0, alreadyMigrated: 0, skipped: 0, errors: [] };
@@ -237,8 +273,24 @@ async function main() {
}
db.transaction(() => {
// --- app_settings: oidc_client_secret, smtp_pass, admin_webhook_url, admin_ntfy_token ---
for (const key of ['oidc_client_secret', 'smtp_pass', 'admin_webhook_url', 'admin_ntfy_token']) {
// --- app_settings: oidc_client_secret, smtp_pass, admin_webhook_url, admin_ntfy_token,
// plus the instance-wide provider keys (#1939) ---
//
// The last two mirror INSTANCE_API_KEY_NAMES in
// src/nest/settings/instance-api-keys.ts. Spelled out rather than imported,
// because that module pulls in DatabaseService and this script deliberately
// stays independent of src/ (see the crypto note above). Miss one and a
// rotation leaves it encrypted under the old key, which reads back as "no
// key" and silently drops the install to OpenStreetMap, so the copy is
// pinned by tests/unit/db/migrate-encryption-parity.test.ts.
for (const key of [
'oidc_client_secret',
'smtp_pass',
'admin_webhook_url',
'admin_ntfy_token',
'maps_api_key',
'unsplash_api_key',
]) {
const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined;
if (!row?.value) continue;
const newVal = migrateApiKeyValue(row.value, `app_settings.${key}`);
@@ -248,7 +300,25 @@ async function main() {
}
// --- users: api key columns + synology credentials ---
const apiKeyColumns = ['maps_api_key', 'openweather_api_key', 'immich_api_key', 'synology_password', 'synology_sid', 'synology_did'];
// unsplash_api_key was missing here for as long as the column has existed. It
// matters now that the resolver reads it as the per-user fallback (#1939): left
// out of a rotation it stays encrypted under the old key and reads back as unset.
// airtrail_api_key arrived with a later migration, so an older database may
// not have the column at all — filter the list against the real table rather
// than letting one missing column throw the whole rotation.
const userColumns = new Set(
(db.prepare("SELECT name FROM pragma_table_info('users')").all() as { name: string }[]).map((r) => r.name),
);
const apiKeyColumns = [
'maps_api_key',
'unsplash_api_key',
'openweather_api_key',
'immich_api_key',
'synology_password',
'synology_sid',
'synology_did',
'airtrail_api_key',
].filter((c) => userColumns.has(c));
const users = db.prepare('SELECT id FROM users').all() as { id: number }[];
for (const user of users) {
@@ -271,8 +341,70 @@ async function main() {
}
}
}
// --- app_settings: storage.backends ---
// Storage backend secrets live encrypted INSIDE the storage.backends JSON
// array (BACKENDS_KEY in src/nest/storage/storage-registry.service.ts is
// its own app_settings row — 'storage.categories' is a sibling row with no
// secrets), on the fields the shared type registry marks `secret` (only
// options.secretAccessKey for s3 today; src/nest/storage/storage-secrets.ts
// encrypts it with the same enc:v1: scheme as the api-key columns above).
// This script can't import storageSecretFields from @trek/shared (kept
// independent of the app, see the header note), so the field list per
// backend type is pinned here. Miss one and that backend's secret stays
// encrypted under the old key after rotation, then fails to decrypt on the
// next read (StorageBackendError) — pinned by
// tests/unit/db/migrate-encryption-parity.test.ts.
const STORAGE_BACKEND_SECRET_FIELDS: Record<string, readonly string[]> = {
s3: ['secretAccessKey'],
};
{
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'storage.backends'").get() as
| { value: string }
| undefined;
if (row?.value) {
let backends: unknown;
try {
backends = JSON.parse(row.value);
} catch {
result.errors.push('app_settings.storage.backends: invalid JSON — skipping');
backends = undefined;
}
if (backends !== undefined) {
if (Array.isArray(backends)) {
let changed = false;
for (const backend of backends as Record<string, unknown>[]) {
if (!backend || typeof backend !== 'object') continue;
const type = typeof backend.type === 'string' ? backend.type : undefined;
const fields = type ? STORAGE_BACKEND_SECRET_FIELDS[type] : undefined;
if (!fields || fields.length === 0) continue;
const options = backend.options;
if (!options || typeof options !== 'object') continue;
const name = typeof backend.name === 'string' ? backend.name : '?';
for (const field of fields) {
const raw = (options as Record<string, unknown>)[field];
if (typeof raw !== 'string' || !raw) continue;
const newVal = migrateApiKeyValue(raw, `app_settings.storage.backends[${name}].${field}`);
if (newVal !== null) {
(options as Record<string, unknown>)[field] = newVal;
changed = true;
}
}
}
if (changed) {
db.prepare("UPDATE app_settings SET value = ? WHERE key = 'storage.backends'").run(
JSON.stringify(backends),
);
}
} else {
result.errors.push('app_settings.storage.backends: not an array — skipping');
}
}
}
}
// --- settings: per-user encrypted keys ---
const encryptedSettingKeys = ['webhook_url', 'ntfy_token', 'mapbox_access_token'];
const encryptedSettingKeys = ['webhook_url', 'ntfy_token', 'mapbox_access_token', 'carto_api_key', 'llm_api_key'];
const settingRows = db.prepare(
`SELECT user_id, key, value FROM settings WHERE key IN (${encryptedSettingKeys.map(() => '?').join(', ')})`
).all(...encryptedSettingKeys) as { user_id: number; key: string; value: string }[];
@@ -285,6 +417,106 @@ async function main() {
}
}
// --- plugins: OAuth tokens and secret settings fields ---
// The plugin host encrypts with the same enc:v1: scheme as everything above:
// plugin-oauth.service stores both tokens encrypted, and plugins.service
// encrypts every settings field the manifest marked secret (instance scope in
// plugins.config, user scope in plugin_user_config.config). Left out of a
// rotation, a connected plugin reads back as "not connected" and a secret
// setting as unset, with nothing in the log to say why. Every table here
// arrived with a later migration, so each block checks that it exists.
const tableExists = (name: string): boolean =>
!!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
if (tableExists('plugin_oauth_tokens')) {
const tokenRows = db
.prepare('SELECT plugin_id, user_id, access_token, refresh_token FROM plugin_oauth_tokens')
.all() as { plugin_id: string; user_id: number; access_token: string | null; refresh_token: string | null }[];
for (const row of tokenRows) {
for (const col of ['access_token', 'refresh_token'] as const) {
const raw = row[col];
if (!raw) continue;
const newVal = migrateApiKeyValue(raw, `plugin_oauth_tokens[${row.plugin_id},${row.user_id}].${col}`);
if (newVal !== null) {
db.prepare(`UPDATE plugin_oauth_tokens SET ${col} = ? WHERE plugin_id = ? AND user_id = ?`).run(
newVal,
row.plugin_id,
row.user_id,
);
}
}
}
}
if (tableExists('plugin_settings_fields')) {
// Which keys are secret is per plugin and per scope, and it lives in the DB
// rather than in this script, so nothing has to be pinned by hand here.
const secretFields = db
.prepare("SELECT plugin_id, field_key, scope FROM plugin_settings_fields WHERE secret = 1")
.all() as { plugin_id: string; field_key: string; scope: string | null }[];
const secretKeysFor = (pluginId: string, scope: string): string[] =>
secretFields
.filter((f) => f.plugin_id === pluginId && (f.scope ?? 'instance') === scope)
.map((f) => f.field_key);
const migrateConfigJson = (raw: string | null, keys: string[], label: string): string | null => {
if (!raw || keys.length === 0) return null;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
result.errors.push(`${label}: invalid JSON — skipping`);
return null;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
result.errors.push(`${label}: not an object — skipping`);
return null;
}
const config = parsed as Record<string, unknown>;
let changed = false;
for (const key of keys) {
const value = config[key];
if (typeof value !== 'string' || !value) continue;
const newVal = migrateApiKeyValue(value, `${label}.${key}`);
if (newVal !== null) {
config[key] = newVal;
changed = true;
}
}
return changed ? JSON.stringify(config) : null;
};
if (tableExists('plugins')) {
const pluginRows = db.prepare('SELECT id, config FROM plugins').all() as { id: string; config: string | null }[];
for (const row of pluginRows) {
const next = migrateConfigJson(row.config, secretKeysFor(row.id, 'instance'), `plugins[${row.id}].config`);
if (next !== null) {
db.prepare('UPDATE plugins SET config = ? WHERE id = ?').run(next, row.id);
}
}
}
if (tableExists('plugin_user_config')) {
const userConfigRows = db
.prepare('SELECT plugin_id, user_id, config FROM plugin_user_config')
.all() as { plugin_id: string; user_id: number; config: string | null }[];
for (const row of userConfigRows) {
const next = migrateConfigJson(
row.config,
secretKeysFor(row.plugin_id, 'user'),
`plugin_user_config[${row.plugin_id},${row.user_id}].config`,
);
if (next !== null) {
db.prepare('UPDATE plugin_user_config SET config = ? WHERE plugin_id = ? AND user_id = ?').run(
next,
row.plugin_id,
row.user_id,
);
}
}
}
}
// --- trip_album_links: passphrase ---
const albumLinks = db.prepare('SELECT id, passphrase FROM trip_album_links WHERE passphrase IS NOT NULL').all() as { id: number; passphrase: string }[];
for (const row of albumLinks) {