From 875afdeb207e2b83a8b99b56d200d0d6bc696b3b Mon Sep 17 00:00:00 2001 From: subtype Date: Tue, 14 Jul 2026 21:40:51 -0400 Subject: [PATCH] Add debugging to figure out dupe install_success endpoint call bug --- docker-compose.yml | 3 +- package.json | 3 +- scripts/trmnlQuery.ts | 246 ++++++++++++++++++ .../flights/flightInstallController.ts | 3 + .../flights/flightInstallSuccessController.ts | 5 +- 5 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 scripts/trmnlQuery.ts diff --git a/docker-compose.yml b/docker-compose.yml index c4e48ed..557ba80 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,4 +40,5 @@ networks: proxy: external: true api: - external: true \ No newline at end of file + external: true + \ No newline at end of file diff --git a/package.json b/package.json index d2faf91..be3dbb5 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "typecheck": "tsc -p tsconfig.test.json", "test": "vitest run", "test:watch": "vitest", - "preview": "npx tsx scripts/renderFlightPreview.ts" + "preview": "npx tsx scripts/renderFlightPreview.ts", + "trmnl:query": "npx tsx scripts/trmnlQuery.ts" }, "files": [ "build" diff --git a/scripts/trmnlQuery.ts b/scripts/trmnlQuery.ts new file mode 100644 index 0000000..cff3773 --- /dev/null +++ b/scripts/trmnlQuery.ts @@ -0,0 +1,246 @@ +// Ad hoc reporting/housekeeping CLI against the TRMNL sqlite DB. +// Opens its own connection directly (independent of dbConnector's server-lifecycle singleton) +// so it can run standalone against a dev or prod db file. Run via `npm run trmnl:query -- `. +import Database from 'better-sqlite3' +import { config } from '../src/config.js' + +const db = new Database(config.trmnl.dbPath, { fileMustExist: true }) + +type ConnectionRow = { + access_token_hash: string + user_uuid: string | null + created_at: number + last_seen_at: number | null + revoked_at: number | null +} + +function fmtDate(ts: number | null | undefined): string { + return ts ? new Date(ts).toISOString() : '--' +} + +function shortHash(hash: string): string { + return hash.slice(0, 12) + '…' +} + +function printRows(rows: Record[]): void { + if (rows.length === 0) { + console.log('(no rows)') + return + } + console.table(rows) +} + +function cmdSummary(): void { + const totals = db + .prepare( + ` + select + count(*) as total, + sum(case when user_uuid is null then 1 else 0 end) as unbound, + sum(case when revoked_at is not null then 1 else 0 end) as revoked, + sum(case when user_uuid is not null and revoked_at is null then 1 else 0 end) as active + from trmnl_connections + ` + ) + .get() as { total: number; unbound: number; revoked: number; active: number } + + const metro = db.prepare(`select count(*) as total from trmnl_settings`).get() as { total: number } + const flight = db.prepare(`select count(*) as total from trmnl_flight_settings`).get() as { total: number } + + console.log('Connections:', totals) + console.log('Metro plugin settings rows:', metro.total) + console.log('Flight plugin settings rows:', flight.total) +} + +function cmdUuid(uuid: string): void { + const connection = db.prepare(`select * from trmnl_connections where user_uuid = ?`).get(uuid) as + | ConnectionRow + | undefined + const metro = db.prepare(`select * from trmnl_settings where user_uuid = ?`).get(uuid) + const flight = db.prepare(`select * from trmnl_flight_settings where user_uuid = ?`).get(uuid) + + console.log('Connection:') + printRows( + connection + ? [ + { + access_token_hash: shortHash(connection.access_token_hash), + created_at: fmtDate(connection.created_at), + last_seen_at: fmtDate(connection.last_seen_at), + revoked_at: fmtDate(connection.revoked_at), + }, + ] + : [] + ) + + console.log('Metro settings:') + printRows(metro ? [metro as Record] : []) + + console.log('Flight settings:') + printRows(flight ? [flight as Record] : []) +} + +// Settings rows left behind after their connection was revoked/never bound - not cleaned up +// automatically since revoking only touches trmnl_connections. +function cmdOrphans(): void { + const metroOrphans = db + .prepare( + ` + select s.* from trmnl_settings s + left join trmnl_connections c on c.user_uuid = s.user_uuid and c.revoked_at is null + where c.user_uuid is null + ` + ) + .all() as Record[] + + const flightOrphans = db + .prepare( + ` + select s.* from trmnl_flight_settings s + left join trmnl_connections c on c.user_uuid = s.user_uuid and c.revoked_at is null + where c.user_uuid is null + ` + ) + .all() as Record[] + + console.log('Orphaned metro settings (no live connection):') + printRows(metroOrphans) + + console.log('Orphaned flight settings (no live connection):') + printRows(flightOrphans) +} + +// Bound connections where install_success ran but no plugin_setting_id ever landed on either +// plugin's settings table - these installs can't deep-link back to trmnl.com/plugin_settings. +function cmdStuck(): void { + const rows = db + .prepare( + ` + select c.user_uuid, c.created_at, c.last_seen_at + from trmnl_connections c + left join trmnl_settings ms on ms.user_uuid = c.user_uuid + left join trmnl_flight_settings fs on fs.user_uuid = c.user_uuid + where c.user_uuid is not null + and c.revoked_at is null + and coalesce(ms.plugin_setting_id, fs.plugin_setting_id) is null + ` + ) + .all() as { user_uuid: string; created_at: number; last_seen_at: number | null }[] + + printRows( + rows.map((r) => ({ + user_uuid: r.user_uuid, + created_at: fmtDate(r.created_at), + last_seen_at: fmtDate(r.last_seen_at), + })) + ) +} + +function cmdStale(days: number): void { + const cutoff = Date.now() - days * 24 * 60 * 60 * 1000 + const rows = db + .prepare( + ` + select access_token_hash, user_uuid, created_at, last_seen_at + from trmnl_connections + where revoked_at is null + and ( + (last_seen_at is not null and last_seen_at < ?) + or (last_seen_at is null and created_at < ?) + ) + order by coalesce(last_seen_at, created_at) asc + ` + ) + .all(cutoff, cutoff) as { access_token_hash: string; user_uuid: string | null; created_at: number; last_seen_at: number | null }[] + + printRows( + rows.map((r) => ({ + access_token_hash: shortHash(r.access_token_hash), + user_uuid: r.user_uuid ?? '(unbound)', + created_at: fmtDate(r.created_at), + last_seen_at: fmtDate(r.last_seen_at), + })) + ) +} + +function cmdRevoke(uuid: string, confirmed: boolean): void { + if (!confirmed) { + console.error('Refusing to revoke without --yes. Re-run as: revoke --yes') + process.exitCode = 1 + return + } + + const result = db + .prepare(`update trmnl_connections set revoked_at = ? where user_uuid = ? and revoked_at is null`) + .run(Date.now(), uuid) + + console.log(`Revoked ${result.changes} connection(s) for uuid ${uuid}`) +} + +function usage(): void { + console.log(` +Usage: npm run trmnl:query -- [args] + +Commands: + summary Counts of connections (active/revoked/unbound) and settings rows + uuid Everything known about one uuid across all three tables + orphans Settings rows with no matching live (non-revoked) connection + stuck Bound connections with no plugin_setting_id on either plugin's settings + stale [days=30] Non-revoked connections not seen in + revoke --yes Revoke all active connections bound to a uuid + +Reads TRMNL_DB_PATH from the environment (same as the server); defaults to ./trmnl.sqlite. +`) +} + +function main(): void { + const [, , cmd, ...rest] = process.argv + + switch (cmd) { + case 'summary': + cmdSummary() + break + case 'uuid': { + const uuid = rest[0] + if (!uuid) { + console.error('Missing ') + process.exitCode = 1 + break + } + cmdUuid(uuid) + break + } + case 'orphans': + cmdOrphans() + break + case 'stuck': + cmdStuck() + break + case 'stale': { + const days = rest[0] ? Number(rest[0]) : 30 + if (!Number.isFinite(days) || days <= 0) { + console.error('days must be a positive number') + process.exitCode = 1 + break + } + cmdStale(days) + break + } + case 'revoke': { + const uuid = rest[0] + if (!uuid) { + console.error('Missing ') + process.exitCode = 1 + break + } + cmdRevoke(uuid, rest.includes('--yes')) + break + } + default: + usage() + if (cmd) process.exitCode = 1 + } +} + +main() +db.close() diff --git a/src/v1/controllers/flights/flightInstallController.ts b/src/v1/controllers/flights/flightInstallController.ts index ab61be6..f3e8a3e 100644 --- a/src/v1/controllers/flights/flightInstallController.ts +++ b/src/v1/controllers/flights/flightInstallController.ts @@ -7,6 +7,8 @@ import { storeTrmnlToken } from '../../../utils/dbConnector.js' const sha256 = (v: string) => crypto.createHash('sha256').update(v).digest('hex') const flightInstallController: RequestHandler = async (req, res): Promise => { + logger.info('[TRMNL] Incoming flight install request!') + logger.debug('[TRMNL] Request debug:', { query: req.query, body: req.body }) const token = req.query.code as string | undefined const callback = req.query.installation_callback_url as string @@ -30,6 +32,7 @@ const flightInstallController: RequestHandler = async (req, res): Promise return } + logger.debug('[TRMNL] Exchanging token for access token...') const trmnlResp = await fetch('https://trmnl.com/oauth/token', { method: 'POST', headers: { diff --git a/src/v1/controllers/flights/flightInstallSuccessController.ts b/src/v1/controllers/flights/flightInstallSuccessController.ts index 78559b6..0a54277 100644 --- a/src/v1/controllers/flights/flightInstallSuccessController.ts +++ b/src/v1/controllers/flights/flightInstallSuccessController.ts @@ -3,6 +3,7 @@ import { logger } from '../../../utils/logger.js' import { bindUserUuidToToken, upsertFlightSettings } from '../../../utils/dbConnector.js' export const flightInstallSuccessController: RequestHandler = async (req, res) => { + logger.info('[AERO] Incoming install success request') const tokenHash = (req as any).trmnl?.tokenHash const userUuid = req.body?.user?.uuid @@ -16,6 +17,8 @@ export const flightInstallSuccessController: RequestHandler = async (req, res) = return } + logger.debug('[AERO] Binding user UUID to token hash', { tokenHash, userUuid }) + bindUserUuidToToken(tokenHash, userUuid) const pluginSettingId = req.body?.user?.plugin_setting_id @@ -27,6 +30,6 @@ export const flightInstallSuccessController: RequestHandler = async (req, res) = }) } - logger.info('[AERO] install success for uuid', { userUuid }) + logger.info('[AERO] install success', { tokenHash, userUuid }) res.status(200).json({ ok: true }) }