Files
website/scripts/deploy-preflight.ts

437 lines
18 KiB
TypeScript

import postgres from 'postgres';
const expectedOrigin = process.argv[2];
if (
!expectedOrigin ||
!['https://stanrunge.dev', 'https://staging.stanrunge.dev'].includes(expectedOrigin)
) {
throw new Error('Expected a known production or staging origin');
}
if (!process.env.DB_URL) throw new Error('DB_URL is missing');
if (!process.env.BETTER_AUTH_SECRET || process.env.BETTER_AUTH_SECRET.length < 32) {
throw new Error('BETTER_AUTH_SECRET must be at least 32 characters');
}
if (process.env.ORIGIN !== expectedOrigin || process.env.BETTER_AUTH_URL !== expectedOrigin) {
throw new Error('ORIGIN and BETTER_AUTH_URL must both match the public site origin');
}
const client = postgres(process.env.DB_URL, { max: 1, connect_timeout: 10 });
try {
await client`select 1`;
// Schema layout after the reorg (auth/money/house/ref/games/cs/animaldex/site/meta/lists).
const required = [
'auth.user',
'auth.session',
'auth.account',
'auth.verification',
'money.accounts',
'money.transactions',
'money.recurring',
'money.forecasts',
'money.utility_settings',
'money.utility_readings',
'money.wishlist',
'money.insurances',
'money.projection_settings',
'house.listings',
'house.price_history',
'house.alert_rules',
'house.worker_runs',
'ref.pokemon',
'ref.pokemon_games',
'ref.pokemon_game_entries',
'ref.pokemon_locations',
'ref.pokemon_encounters',
'ref.pokemon_evolutions',
'ref.pokemon_research',
'ref.pokemon_dex_numbers',
'ref.pokemon_moves',
'ref.pokemon_learnsets',
'ref.pokemon_game_research',
'ref.tracker_games',
'ref.tracker_items',
'ref.tracker_challenges',
'games.catch_em_all_entries',
'games.catch_em_all_games',
'games.catch_em_all_settings',
'games.pokemon_journeys',
'games.pokemon_catches',
'games.pokemon_milestones',
'games.pokemon_seen',
'games.tracker_progress',
'games.tracker_ranks',
'games.tracker_weapon_ranks',
'games.tracker_challenge_progress',
'games.osu_entries',
'cs.items',
'cs.price_observations',
'cs.item_dimensions',
'cs.price_streams',
'animaldex.animals',
'animaldex.sightings',
'animaldex.sighting_photos',
'site.blog_articles',
'site.tasks',
'site.topics',
'site.task_topics',
'meta.tables',
'meta.columns'
];
const missing = await client<{ name: string }[]>`
select name from unnest(${required}::text[]) as name where to_regclass(name) is null`;
if (missing.length)
throw new Error(`Schema reorg/migrations missing: ${missing.map((m) => m.name).join(', ')}`);
const [schema] = await client`
select
(select count(*) from information_schema.columns
where table_schema = 'auth' and table_name = 'user' and column_name = 'role') as user_role,
(select count(*) from information_schema.columns
where table_schema = 'money' and table_name = 'projection_settings'
and column_name = 'include_recurring' and data_type = 'boolean') as include_recurring_bool,
(select count(*) from information_schema.columns
where table_schema = 'games' and table_name = 'osu_entries'
and column_name in ('accuracy', 'pp', 'rate') and data_type = 'numeric') as osu_numeric,
(select count(*) from meta.tables where kind = 'list') as lists,
has_function_privilege('meta.create_list(text, text, text, boolean, jsonb, text, text, text, integer)', 'execute') as can_create_list,
has_schema_privilege('money', 'create') as app_has_ddl
`;
if (
!Number(schema.user_role) ||
!Number(schema.include_recurring_bool) ||
Number(schema.osu_numeric) !== 3 ||
!Number(schema.lists) ||
!schema.can_create_list
) {
throw new Error(
'The schema reorg has not been applied to this database (or grants are missing)'
);
}
if (schema.app_has_ddl)
throw new Error('DB_URL must use the app role (personal_app), not the schema owner');
// Private health schema: the app role reads and writes it, nobody else (not even personal_ro).
const [health] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
count(*) filter (
where exists (
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
where r.rolname = 'personal_ro'
)
) as ro_read,
count(*) as tables,
has_schema_privilege('public', 'health', 'usage') as public_usage
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'health' and c.relkind = 'r'
`;
if (
Number(health.tables) !== 7 ||
Number(health.app_rw) !== 7 ||
Number(health.ro_read) !== 0 ||
health.public_usage
)
throw new Error(`health schema grants are wrong: ${JSON.stringify(health)}`);
// Private task tracker: same rules as health (app role read/write, personal_ro none).
const [tasks] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
count(*) filter (
where exists (
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
where r.rolname = 'personal_ro'
)
) as ro_read,
count(*) as tables,
has_schema_privilege('public', 'tasks', 'usage') as public_usage
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'tasks' and c.relkind = 'r'
`;
if (
Number(tasks.tables) !== 5 ||
Number(tasks.app_rw) !== 5 ||
Number(tasks.ro_read) !== 0 ||
tasks.public_usage
)
throw new Error(`tasks schema grants are wrong: ${JSON.stringify(tasks)}`);
// Private grocery tracker: same rules (app role read/write, personal_ro none).
const [groceries] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
count(*) filter (
where exists (
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
where r.rolname = 'personal_ro'
)
) as ro_read,
count(*) as tables,
has_schema_privilege('public', 'groceries', 'usage') as public_usage
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'groceries' and c.relkind = 'r'
`;
if (
// 10 = stores, items, item_prices, groups, import_runs, categories, manual_prices, synonyms,
// stats_snapshots, item_sizes (more is fine as long as the app can read/write all of them).
Number(groceries.tables) < 10 ||
Number(groceries.app_rw) !== Number(groceries.tables) ||
Number(groceries.ro_read) !== 0 ||
groceries.public_usage
)
throw new Error(`groceries schema grants are wrong: ${JSON.stringify(groceries)}`);
// Public /stats section: open data, so the app role (and the host-side stats collector, which
// uses it) reads and writes, and personal_ro may read. Bump the count with every new stats table.
const STATS_TABLES = 55;
const [stats] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
count(*) as tables
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'stats' and c.relkind = 'r'
`;
if (Number(stats.tables) !== STATS_TABLES || Number(stats.app_rw) !== STATS_TABLES)
throw new Error(`stats schema tables/grants are wrong: ${JSON.stringify(stats)}`);
// Private-by-default AI tracker (news public in the app; bots and usage owner-only): app role
// read/write, personal_ro none. 5 = news_items, bot_status, usage_entries,
// provider_setup_providers, provider_setup_entries (setup tab, owner-only).
const AI_TABLES = 5;
const [ai] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
count(*) filter (
where exists (
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
where r.rolname = 'personal_ro'
)
) as ro_read,
count(*) as tables,
has_schema_privilege('public', 'ai', 'usage') as public_usage
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'ai' and c.relkind = 'r'
`;
if (
Number(ai.tables) !== AI_TABLES ||
Number(ai.app_rw) !== AI_TABLES ||
Number(ai.ro_read) !== 0 ||
ai.public_usage
)
throw new Error(`ai schema tables/grants are wrong: ${JSON.stringify(ai)}`);
// Movies & TV tracker (public pages, owner-only edits in the app): app role read/write,
// personal_ro none. 5 = titles, episodes, plays, recs, settings.
const MOVIES_TABLES = 5;
const [movies] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
count(*) filter (
where exists (
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
where r.rolname = 'personal_ro'
)
) as ro_read,
count(*) as tables,
has_schema_privilege('public', 'movies', 'usage') as public_usage
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'movies' and c.relkind = 'r'
`;
if (
Number(movies.tables) !== MOVIES_TABLES ||
Number(movies.app_rw) !== MOVIES_TABLES ||
Number(movies.ro_read) !== 0 ||
movies.public_usage
)
throw new Error(`movies schema tables/grants are wrong: ${JSON.stringify(movies)}`);
// Worldwide todo list (public pages, requests and owner approvals go through the app): app
// role read/write, personal_ro none. 3 = categories, tasks, change_requests.
const WORLDTODO_TABLES = 3;
const [worldtodo] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
count(*) filter (
where exists (
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
where r.rolname = 'personal_ro'
)
) as ro_read,
count(*) as tables,
has_schema_privilege('public', 'worldtodo', 'usage') as public_usage
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'worldtodo' and c.relkind = 'r'
`;
if (
Number(worldtodo.tables) !== WORLDTODO_TABLES ||
Number(worldtodo.app_rw) !== WORLDTODO_TABLES ||
Number(worldtodo.ro_read) !== 0 ||
worldtodo.public_usage
)
throw new Error(`worldtodo schema tables/grants are wrong: ${JSON.stringify(worldtodo)}`);
const [{ n: worldtodoTasks }] = await client`select count(*)::int as n from worldtodo.tasks`;
if (worldtodoTasks < 68)
throw new Error(`worldtodo.tasks has ${worldtodoTasks} rows, expected the 68 seed tasks`);
// Notes (always private; only a search/link index of the Obsidian vault): app role read/write,
// personal_ro none. 2 = files, links.
const NOTES_TABLES = 2;
const [notes] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
count(*) filter (
where exists (
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
where r.rolname = 'personal_ro'
)
) as ro_read,
count(*) as tables,
has_schema_privilege('public', 'notes', 'usage') as public_usage
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'notes' and c.relkind = 'r'
`;
if (
Number(notes.tables) !== NOTES_TABLES ||
Number(notes.app_rw) !== NOTES_TABLES ||
Number(notes.ro_read) !== 0 ||
notes.public_usage
)
throw new Error(`notes schema tables/grants are wrong: ${JSON.stringify(notes)}`);
// Math (the Napkin tracker, private by default): app role read/write, personal_ro none.
// 4 = parts, chapters, sections, activity; the migration seeds all 100 chapters.
const MATH_TABLES = 4;
const [math] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
count(*) filter (
where exists (
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
where r.rolname = 'personal_ro'
)
) as ro_read,
count(*) as tables,
has_schema_privilege('public', 'math', 'usage') as public_usage
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'math' and c.relkind = 'r'
`;
if (
Number(math.tables) !== MATH_TABLES ||
Number(math.app_rw) !== MATH_TABLES ||
Number(math.ro_read) !== 0 ||
math.public_usage
)
throw new Error(`math schema tables/grants are wrong: ${JSON.stringify(math)}`);
const [{ n: mathChapters }] = await client`select count(*)::int as n from math.chapters`;
if (mathChapters !== 100)
throw new Error(`math.chapters has ${mathChapters} rows, expected the 100 Napkin chapters`);
// Cargill plant tracker (owner-only): app role read/write, personal_ro none, and the app can run
// plant.apply_entries (the bulk import). 2 = entries, links; the migration seeds the factory guide.
const PLANT_TABLES = 3;
const [plant] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
count(*) filter (
where exists (
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
where r.rolname = 'personal_ro'
)
) as ro_read,
count(*) as tables,
has_schema_privilege('public', 'plant', 'usage') as public_usage,
has_function_privilege('plant.apply_entries(jsonb)', 'execute') as can_apply
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'plant' and c.relkind = 'r'
`;
if (
Number(plant.tables) !== PLANT_TABLES ||
Number(plant.app_rw) !== PLANT_TABLES ||
Number(plant.ro_read) !== 0 ||
plant.public_usage ||
!plant.can_apply
)
throw new Error(`plant schema tables/grants are wrong: ${JSON.stringify(plant)}`);
const [{ n: plantEntries }] = await client`select count(*)::int as n from plant.entries`;
if (!plantEntries) throw new Error('plant.entries is empty, expected the seeded factory guide');
// Radio tracker (owner-only): 7 = frequencies, channels, nets, iss_passes, iss_tle, log,
// reminders; the migration seeds Opa's frequencies.
const RADIO_TABLES = 7;
const [radio] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
count(*) filter (
where exists (
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
where r.rolname = 'personal_ro'
)
) as ro_read,
count(*) as tables,
has_schema_privilege('public', 'radio', 'usage') as public_usage
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'radio' and c.relkind = 'r'
`;
if (
Number(radio.tables) !== RADIO_TABLES ||
Number(radio.app_rw) !== RADIO_TABLES ||
Number(radio.ro_read) !== 0 ||
radio.public_usage
)
throw new Error(`radio schema tables/grants are wrong: ${JSON.stringify(radio)}`);
const [{ n: radioFreqs }] = await client`select count(*)::int as n from radio.frequencies`;
if (!radioFreqs) throw new Error('radio.frequencies is empty, expected the seeded frequencies');
// Energy tracker reads the ned-collector tables (SELECT only).
const [energy] = await client`
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
where granularity = 'Hour' and classification = 'Current'
and valid_from > now() - interval '7 days'
`;
if (!Number(energy.hours))
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
console.log(
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, movies: ${movies.tables} tables, worldtodo: ${worldtodo.tables} tables (${worldtodoTasks} tasks), notes: ${notes.tables} tables, math: ${math.tables} tables (${mathChapters} chapters), plant: ${plant.tables} tables (${plantEntries} entries), radio: ${radio.tables} tables (${radioFreqs} frequencies), app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
);
const [catalog] = await client`
select
(select count(*) from ref.pokemon) as pokemon,
(select count(*) from ref.pokemon_games where source_id is not null) as games,
(select count(*) from ref.pokemon_game_entries) as entries,
(select count(*) from ref.pokemon_encounters) as encounters,
(select count(*) from ref.tracker_games where franchise = 'call-of-duty') as cod_games,
(select count(*) from ref.pokemon_research) as pokemon_research,
(select count(*) from ref.pokemon_moves) as pokemon_moves,
(select count(*) from ref.pokemon_learnsets) as pokemon_learnsets,
(select count(*) from ref.pokemon_dex_numbers) as pokemon_dex_numbers,
(select count(*) from ref.tracker_challenges) as tracker_challenges
`;
if (
Number(catalog.pokemon) < 1025 ||
Number(catalog.games) < 53 ||
Number(catalog.entries) < 27267 ||
Number(catalog.encounters) < 60000
) {
throw new Error('Pokémon catalog has not been seeded in this database');
}
if (
Number(catalog.pokemon_research) < 1025 ||
Number(catalog.pokemon_moves) < 937 ||
Number(catalog.pokemon_learnsets) < 11000 ||
Number(catalog.pokemon_dex_numbers) < 16000 ||
Number(catalog.tracker_challenges) < 3600
)
throw new Error(
'Run db:seed:research and db:seed:challenges before deploying the tracker expansion'
);
if (Number(catalog.cod_games) < 7) {
throw new Error('Call of Duty catalog has not been seeded in this database');
}
const [bo2] = await client`select rules,
(select count(*)::int from ref.tracker_items i where i.game_id=g.id and i.kind='weapon') as weapons,
(select count(*)::int from ref.tracker_items i where i.game_id=g.id and i.kind='weapon' and i.icon is not null) as pictures
from ref.tracker_games g where slug='black-ops-2'`;
if (
!bo2 ||
bo2.rules.rankCaps?.weaponPrestige !== 2 ||
bo2.rules.rankCaps?.weaponLevelsByClass?.['Assault Rifles'] !== 17 ||
bo2.weapons < 40 ||
bo2.pictures < 39
) {
throw new Error('Seed the updated BO2 catalog and artwork before deploying');
}
} finally {
await client.end();
}
const google = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
const osu = !!(process.env.OSU_CLIENT_ID && process.env.OSU_CLIENT_SECRET);
console.log(
`Preflight passed for ${expectedOrigin}; Google ${google ? 'configured' : 'disabled'}, osu! ${osu ? 'configured' : 'disabled'}`
);