Skip to content

Commit a14ee2b

Browse files
committed
chore(web/scripts): refresh-bot-scopes — bring office bot PATs to current catalog
When src/lib/api/scopes.ts grows new entries, existing api_tokens rows keep whatever scopes the catalog held at mint time (each token's scopes is just a text[] snapshot). seed-office-bots.ts mints with [...SCOPES] but only on first creation; the idempotent skip-if-exists branch never refreshes scopes. This script grants every office bot's PAT the full current catalog without re-minting (so .env.office PATs keep working). It only touches rows where users.is_agent = true and api_tokens.name matches the seed script's mint name, leaving user-minted PATs alone. Idempotent — re-running on already-current rows is a no-op. pnpm exec tsx --env-file=.env.local scripts/refresh-bot-scopes.ts [--apply] Note: api_token_events.event is a closed enum {mint, revoke}, so the audit row is skipped here. Adding a "scope_change" variant + audit row is a follow-up if scope mutations land in user-facing PAT flows.
1 parent 8173cf9 commit a14ee2b

1 file changed

Lines changed: 111 additions & 0 deletions

File tree

web/scripts/refresh-bot-scopes.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* Grant each office bot's PAT the full current scope catalog.
3+
*
4+
* pnpm exec tsx --env-file=.env.local scripts/refresh-bot-scopes.ts [--apply]
5+
*
6+
* Default is dry-run; --apply commits the UPDATEs.
7+
*
8+
* Why: bot tokens are minted by seed-office-bots.ts with `scopes: [...SCOPES]`
9+
* — i.e. whatever the catalog held at mint time. When the catalog grows
10+
* (e.g. submission:delete + comment:delete added 2026-05-06), pre-existing
11+
* bot rows do NOT get the new scopes automatically; the rate-limit/scope
12+
* column on each api_tokens row is just a text[] snapshot. This script
13+
* brings each bot's row back in sync without re-minting (so .env.office
14+
* PATs keep working).
15+
*
16+
* Audit trail: api_token_events.event is a closed enum {mint, revoke}.
17+
* Until/unless a `scope_change` variant lands, this script logs to
18+
* stdout instead of inserting an audit row. The git history of this
19+
* script + lib/api/scopes.ts is the recoverable trail.
20+
*
21+
* Idempotent — re-running on already-current rows is a no-op.
22+
*
23+
* Required env: DATABASE_URL (or NEON_DATABASE_URL).
24+
*/
25+
26+
import { and, eq, isNull } from "drizzle-orm";
27+
28+
import { db } from "@/db/client";
29+
import { apiTokens, users } from "@/db/schema";
30+
import { SCOPES, type Scope } from "@/lib/api/scopes";
31+
32+
// Mint name used by scripts/seed-office-bots.ts. Filtering on it scopes
33+
// this script tightly to the office-bot fleet — user-minted PATs (which
34+
// carry user-chosen scopes) are deliberately untouched.
35+
const TARGET_TOKEN_NAME = "office (full access, no-expiry)";
36+
37+
function diffScopes(current: readonly string[], desired: readonly Scope[]) {
38+
const currentSet: Set<string> = new Set(current);
39+
const desiredSet: Set<string> = new Set(desired);
40+
const toAdd = desired.filter((s) => !currentSet.has(s));
41+
const stale = current.filter((s) => !desiredSet.has(s));
42+
return { toAdd, stale };
43+
}
44+
45+
async function main() {
46+
const apply = process.argv.includes("--apply");
47+
console.log(`> refresh-bot-scopes — ${apply ? "APPLY" : "dry-run"}`);
48+
console.log(`> target catalog: [${[...SCOPES].join(", ")}]`);
49+
50+
const rows = await db
51+
.select({
52+
id: apiTokens.id,
53+
displayPrefix: apiTokens.displayPrefix,
54+
scopes: apiTokens.scopes,
55+
username: users.username,
56+
})
57+
.from(apiTokens)
58+
.innerJoin(users, eq(users.id, apiTokens.userId))
59+
.where(
60+
and(
61+
eq(apiTokens.name, TARGET_TOKEN_NAME),
62+
eq(users.isAgent, true),
63+
isNull(apiTokens.revokedAt),
64+
),
65+
);
66+
67+
if (rows.length === 0) {
68+
console.log("> no matching bot tokens — nothing to do");
69+
return;
70+
}
71+
console.log(`> ${rows.length} bot token(s) under management`);
72+
73+
const desired = [...SCOPES];
74+
const stalebots: typeof rows = [];
75+
for (const row of rows) {
76+
const { toAdd, stale } = diffScopes(row.scopes as string[], desired);
77+
if (toAdd.length === 0 && stale.length === 0) {
78+
console.log(` · @${row.username} (${row.displayPrefix}…): up-to-date`);
79+
continue;
80+
}
81+
stalebots.push(row);
82+
const adds = toAdd.length > 0 ? `+[${toAdd.join(", ")}]` : "";
83+
const removes = stale.length > 0 ? ` -[${stale.join(", ")}]` : "";
84+
console.log(` · @${row.username} (${row.displayPrefix}…): ${adds}${removes}`);
85+
}
86+
87+
if (stalebots.length === 0) {
88+
console.log("> all bots already on the latest scope catalog");
89+
return;
90+
}
91+
if (!apply) {
92+
console.log(
93+
`> dry-run: ${stalebots.length} token(s) would update — re-run with --apply`,
94+
);
95+
return;
96+
}
97+
98+
for (const row of stalebots) {
99+
await db
100+
.update(apiTokens)
101+
.set({ scopes: desired })
102+
.where(eq(apiTokens.id, row.id));
103+
console.log(` ✓ refreshed @${row.username}`);
104+
}
105+
console.log(`> applied — ${stalebots.length} token(s) refreshed`);
106+
}
107+
108+
main().catch((err) => {
109+
console.error("✗ refresh-bot-scopes failed:", err);
110+
process.exit(1);
111+
});

0 commit comments

Comments
 (0)