flora is a fast and secure runtime that lets you write discord bots for your servers, with a rich TypeScript SDK, without worrying about running infrastructure. [mirror]
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: deploymenat revisions

+2871 -312
+77
DEPLOYMENTS_REDESIGN_PLAN.txt
··· 1 + flora deployments redesign plan (guild-scoped) 2 + 3 + goals 4 + - redesign deployments page as guild-scoped revision history 5 + - capture metadata per deployment: id, actor, source, status, guild id, deployed_at, change summary 6 + - support rollback 7 + - show source-file diffs in frontend with @pierre/diffs (never bundle diff) 8 + 9 + decisions 10 + - no global cross-guild deployments listing 11 + - current deployment = latest successful revision for guild 12 + - failed revisions are visible in guild history but never current 13 + - diff base = previous successful revision in same guild 14 + - rollback creates a new revision cloned from a selected successful revision 15 + - files are required for deployments; bundle may exist for runtime execution 16 + 17 + phase 1: backend data + api 18 + 1) add migration for immutable deployment_revisions table 19 + - id uuid pk 20 + - guild_id text indexed 21 + - entry text not null 22 + - files jsonb 23 + - bundle text not null 24 + - source_map jsonb 25 + - status text (success|failed) not null 26 + - deployed_at timestamptz default now 27 + - deploy_source text (cli|webui|bootstrap|api|unknown) not null 28 + - actor_user_id text null 29 + - actor_username text null 30 + - actor_type text (session|token|system) not null 31 + - error_message text null 32 + - build_id text null 33 + - base_revision_id uuid null 34 + - change_summary jsonb null 35 + 2) backfill one success revision per existing deployments row 36 + 3) extend deployment service with: 37 + - create_revision(...) 38 + - list_guild_revisions(guild_id, limit, cursor) 39 + - get_revision(guild_id, revision_id) 40 + - get_current_successful(guild_id) 41 + - get_previous_successful_revision(guild_id) 42 + 4) update deployments handlers: 43 + - keep GET /deployments/{guild_id} => current successful deployment 44 + - POST /deployments/{guild_id} => create revision (success/failed), include source + actor + summary 45 + - add GET /deployments/{guild_id}/history 46 + - add GET /deployments/{guild_id}/revisions/{id} 47 + - add POST /deployments/{guild_id}/rollback/{id} 48 + - remove global GET /deployments/ 49 + 5) actor/source capture 50 + - parse x-flora-deploy-source header (validated enum) 51 + - session auth => actor id + username + actor_type=session 52 + - token auth => actor id + actor_type=token 53 + - bootstrap path => actor_type=system, source=bootstrap 54 + 6) runtime startup loads current successful deployments only 55 + 56 + phase 2: clients 57 + 7) cli deploy command sends x-flora-deploy-source: cli 58 + 8) web editor deploy flow sends x-flora-deploy-source: webui 59 + 60 + phase 3: frontend deployments page 61 + 9) replace card list with guild-scoped history table 62 + columns: id, actor, changes, guild id, deployed_at, source, status, entry/build id 63 + 10) row click opens details panel 64 + - metadata + errors + rollback action 65 + - diff section rendered with @pierre/diffs/react against base successful revision 66 + - only source files + package/lockfiles are diffed 67 + 11) remove reliance on legacy global deployments context for this page 68 + 69 + validation 70 + - runtime: cargo check -p flora 71 + - frontend: pnpm --filter frontend run typecheck 72 + - frontend lint: pnpm --filter frontend run lint 73 + - manual smoke: 74 + - deploy via webui, history row appears as webui + success 75 + - deploy via cli, history row appears as cli + success 76 + - force failed deploy, history row appears as failed with error 77 + - rollback creates new revision and updates current
+5 -18
apps/cli/src/commands/deployments.ts
··· 98 98 method: 'POST', 99 99 headers: { 100 100 ...headers, 101 - 'content-type': 'application/json' 101 + 'content-type': 'application/json', 102 + 'x-flora-deploy-source': 'cli' 102 103 }, 103 104 body: JSON.stringify({ 104 105 entry: build.entry, ··· 193 194 ) 194 195 } 195 196 196 - export async function list(config: CliConfig): Promise<void> { 197 - const client = createApiClient(config) 198 - const deployments = await expectOk( 199 - client.GET('/deployments/', { 200 - headers: authHeaders(config) 201 - }) 197 + export async function list(_config: CliConfig): Promise<void> { 198 + logger.warn( 199 + '`flora deployments list` was removed; use `flora deployments get --guild <guild_id>`' 202 200 ) 203 - 204 - if (deployments.length === 0) { 205 - logger.log('No deployments found') 206 - return 207 - } 208 - 209 - for (const deployment of deployments) { 210 - logger.log( 211 - `${deployment.guild_id} entry=${deployment.entry} created=${deployment.created_at} updated=${deployment.updated_at}` 212 - ) 213 - } 214 201 } 215 202 216 203 export async function health(config: CliConfig): Promise<void> {
+306 -27
apps/cli/src/generated/openapi-schema.ts
··· 87 87 patch?: never 88 88 trace?: never 89 89 } 90 - '/deployments/': { 90 + '/deployments/{guild_id}': { 91 + parameters: { 92 + query?: never 93 + header?: never 94 + path?: never 95 + cookie?: never 96 + } 97 + get: operations['get_deployment_handler'] 98 + put?: never 99 + post: operations['upsert_deployment_handler'] 100 + delete?: never 101 + options?: never 102 + head?: never 103 + patch?: never 104 + trace?: never 105 + } 106 + '/deployments/{guild_id}/history': { 107 + parameters: { 108 + query?: never 109 + header?: never 110 + path?: never 111 + cookie?: never 112 + } 113 + get: operations['list_deployment_history_handler'] 114 + put?: never 115 + post?: never 116 + delete?: never 117 + options?: never 118 + head?: never 119 + patch?: never 120 + trace?: never 121 + } 122 + '/deployments/{guild_id}/revisions/{revision_id}': { 91 123 parameters: { 92 124 query?: never 93 125 header?: never 94 126 path?: never 95 127 cookie?: never 96 128 } 97 - /** List every stored deployment. */ 98 - get: operations['list_deployments_handler'] 129 + get: operations['get_deployment_revision_handler'] 99 130 put?: never 100 131 post?: never 101 132 delete?: never ··· 104 135 patch?: never 105 136 trace?: never 106 137 } 107 - '/deployments/{guild_id}': { 138 + '/deployments/{guild_id}/rollback/{revision_id}': { 108 139 parameters: { 109 140 query?: never 110 141 header?: never 111 142 path?: never 112 143 cookie?: never 113 144 } 114 - /** Fetch a single deployment by guild id. */ 115 - get: operations['get_deployment_handler'] 145 + get?: never 116 146 put?: never 117 - /** Create or update a deployment for a guild. */ 118 - post: operations['upsert_deployment_handler'] 147 + post: operations['rollback_deployment_handler'] 119 148 delete?: never 120 149 options?: never 121 150 head?: never ··· 442 471 guild_id: string 443 472 store_name: string 444 473 } 474 + DeploymentActorResponse: { 475 + actor_type: components['schemas']['DeploymentActorType'] 476 + user_id?: string | null 477 + username?: string | null 478 + } 479 + /** @enum {string} */ 480 + DeploymentActorType: 'session' | 'token' | 'system' 481 + DeploymentChangeSummary: { 482 + added_files: number 483 + modified_files: number 484 + removed_files: number 485 + } 445 486 DeploymentFile: { 446 487 contents: string 447 488 path: string 448 489 } 449 - /** @description Body for creating or replacing a deployment. */ 450 490 DeploymentRequest: { 451 - /** @description Prebuilt JavaScript bundle source (legacy mode). */ 491 + build_id?: string | null 452 492 bundle?: string | null 453 - /** @description Entry point path for the bundle (e.g. src/main.ts). */ 454 493 entry: string 455 - /** @description Source files for the deployment. Preferred over raw bundle input. */ 456 494 files?: components['schemas']['DeploymentFile'][] | null 457 495 source_map?: null | components['schemas']['DeploymentSourceMapFile'] 458 496 } 459 - /** @description API representation of a deployment. */ 460 497 DeploymentResponse: { 461 498 bundle?: string | null 462 499 created_at: string ··· 466 503 source_map?: null | components['schemas']['DeploymentSourceMapFile'] 467 504 updated_at: string 468 505 } 506 + DeploymentRevisionResponse: { 507 + actor: components['schemas']['DeploymentActorResponse'] 508 + base_revision_id?: string | null 509 + build_id?: string | null 510 + bundle?: string | null 511 + change_summary?: null | components['schemas']['DeploymentChangeSummary'] 512 + deploy_source: components['schemas']['DeploymentSource'] 513 + deployed_at: string 514 + entry: string 515 + error_message?: string | null 516 + files?: components['schemas']['DeploymentFile'][] | null 517 + guild_id: string 518 + id: string 519 + source_map?: null | components['schemas']['DeploymentSourceMapFile'] 520 + status: components['schemas']['DeploymentRevisionStatus'] 521 + } 522 + /** @enum {string} */ 523 + DeploymentRevisionStatus: 'success' | 'failed' 524 + /** @enum {string} */ 525 + DeploymentSource: 'cli' | 'webui' | 'bootstrap' | 'api' | 'unknown' 469 526 DeploymentSourceMapFile: { 470 527 contents: string 471 528 path: string ··· 1045 1102 } 1046 1103 } 1047 1104 } 1048 - list_deployments_handler: { 1105 + get_deployment_handler: { 1106 + parameters: { 1107 + query?: { 1108 + /** @description Include bundled output in response */ 1109 + include_bundle?: boolean 1110 + } 1111 + header?: never 1112 + path: { 1113 + /** @description Discord guild id */ 1114 + guild_id: string 1115 + } 1116 + cookie?: never 1117 + } 1118 + requestBody?: never 1119 + responses: { 1120 + /** @description Successful response */ 1121 + 200: { 1122 + headers: { 1123 + [name: string]: unknown 1124 + } 1125 + content: { 1126 + 'application/json': { 1127 + bundle?: string | null 1128 + created_at: string 1129 + entry: string 1130 + files?: components['schemas']['DeploymentFile'][] | null 1131 + guild_id: string 1132 + source_map?: null | components['schemas']['DeploymentSourceMapFile'] 1133 + updated_at: string 1134 + } 1135 + } 1136 + } 1137 + /** @description Bad request */ 1138 + 400: { 1139 + headers: { 1140 + [name: string]: unknown 1141 + } 1142 + content: { 1143 + 'application/json': { 1144 + /** @description Human readable error message. */ 1145 + message: string 1146 + } 1147 + } 1148 + } 1149 + /** @description Authentication required */ 1150 + 401: { 1151 + headers: { 1152 + [name: string]: unknown 1153 + } 1154 + content: { 1155 + 'application/json': { 1156 + /** @description Human readable error message. */ 1157 + message: string 1158 + } 1159 + } 1160 + } 1161 + /** @description Forbidden */ 1162 + 403: { 1163 + headers: { 1164 + [name: string]: unknown 1165 + } 1166 + content: { 1167 + 'application/json': { 1168 + /** @description Human readable error message. */ 1169 + message: string 1170 + } 1171 + } 1172 + } 1173 + /** @description Resource not found */ 1174 + 404: { 1175 + headers: { 1176 + [name: string]: unknown 1177 + } 1178 + content: { 1179 + 'application/json': { 1180 + /** @description Human readable error message. */ 1181 + message: string 1182 + } 1183 + } 1184 + } 1185 + /** @description Internal server error */ 1186 + 500: { 1187 + headers: { 1188 + [name: string]: unknown 1189 + } 1190 + content: { 1191 + 'application/json': { 1192 + /** @description Human readable error message. */ 1193 + message: string 1194 + } 1195 + } 1196 + } 1197 + } 1198 + } 1199 + upsert_deployment_handler: { 1049 1200 parameters: { 1050 1201 query?: never 1051 1202 header?: never 1052 - path?: never 1203 + path: { 1204 + /** @description Discord guild id */ 1205 + guild_id: string 1206 + } 1053 1207 cookie?: never 1054 1208 } 1055 - requestBody?: never 1209 + requestBody: { 1210 + content: { 1211 + 'application/json': components['schemas']['DeploymentRequest'] 1212 + } 1213 + } 1056 1214 responses: { 1057 1215 /** @description Successful response */ 1058 1216 200: { ··· 1068 1226 guild_id: string 1069 1227 source_map?: null | components['schemas']['DeploymentSourceMapFile'] 1070 1228 updated_at: string 1229 + } 1230 + } 1231 + } 1232 + /** @description Bad request */ 1233 + 400: { 1234 + headers: { 1235 + [name: string]: unknown 1236 + } 1237 + content: { 1238 + 'application/json': { 1239 + /** @description Human readable error message. */ 1240 + message: string 1241 + } 1242 + } 1243 + } 1244 + /** @description Authentication required */ 1245 + 401: { 1246 + headers: { 1247 + [name: string]: unknown 1248 + } 1249 + content: { 1250 + 'application/json': { 1251 + /** @description Human readable error message. */ 1252 + message: string 1253 + } 1254 + } 1255 + } 1256 + /** @description Forbidden */ 1257 + 403: { 1258 + headers: { 1259 + [name: string]: unknown 1260 + } 1261 + content: { 1262 + 'application/json': { 1263 + /** @description Human readable error message. */ 1264 + message: string 1265 + } 1266 + } 1267 + } 1268 + /** @description Resource not found */ 1269 + 404: { 1270 + headers: { 1271 + [name: string]: unknown 1272 + } 1273 + content: { 1274 + 'application/json': { 1275 + /** @description Human readable error message. */ 1276 + message: string 1277 + } 1278 + } 1279 + } 1280 + /** @description Internal server error */ 1281 + 500: { 1282 + headers: { 1283 + [name: string]: unknown 1284 + } 1285 + content: { 1286 + 'application/json': { 1287 + /** @description Human readable error message. */ 1288 + message: string 1289 + } 1290 + } 1291 + } 1292 + } 1293 + } 1294 + list_deployment_history_handler: { 1295 + parameters: { 1296 + query?: { 1297 + /** @description Page size, max 100 */ 1298 + limit?: number 1299 + /** @description RFC3339 deployed_at cursor */ 1300 + cursor_deployed_at?: string 1301 + /** @description Revision id cursor */ 1302 + cursor_id?: string 1303 + /** @description Include bundled output in response */ 1304 + include_bundle?: boolean 1305 + } 1306 + header?: never 1307 + path: { 1308 + /** @description Discord guild id */ 1309 + guild_id: string 1310 + } 1311 + cookie?: never 1312 + } 1313 + requestBody?: never 1314 + responses: { 1315 + /** @description Successful response */ 1316 + 200: { 1317 + headers: { 1318 + [name: string]: unknown 1319 + } 1320 + content: { 1321 + 'application/json': { 1322 + actor: components['schemas']['DeploymentActorResponse'] 1323 + base_revision_id?: string | null 1324 + build_id?: string | null 1325 + bundle?: string | null 1326 + change_summary?: null | components['schemas']['DeploymentChangeSummary'] 1327 + deploy_source: components['schemas']['DeploymentSource'] 1328 + deployed_at: string 1329 + entry: string 1330 + error_message?: string | null 1331 + files?: components['schemas']['DeploymentFile'][] | null 1332 + guild_id: string 1333 + id: string 1334 + source_map?: null | components['schemas']['DeploymentSourceMapFile'] 1335 + status: components['schemas']['DeploymentRevisionStatus'] 1071 1336 }[] 1072 1337 } 1073 1338 } ··· 1133 1398 } 1134 1399 } 1135 1400 } 1136 - get_deployment_handler: { 1401 + get_deployment_revision_handler: { 1137 1402 parameters: { 1138 1403 query?: { 1139 1404 /** @description Include bundled output in response */ ··· 1143 1408 path: { 1144 1409 /** @description Discord guild id */ 1145 1410 guild_id: string 1411 + /** @description Revision id */ 1412 + revision_id: string 1146 1413 } 1147 1414 cookie?: never 1148 1415 } ··· 1155 1422 } 1156 1423 content: { 1157 1424 'application/json': { 1425 + actor: components['schemas']['DeploymentActorResponse'] 1426 + base_revision_id?: string | null 1427 + build_id?: string | null 1158 1428 bundle?: string | null 1159 - created_at: string 1429 + change_summary?: null | components['schemas']['DeploymentChangeSummary'] 1430 + deploy_source: components['schemas']['DeploymentSource'] 1431 + deployed_at: string 1160 1432 entry: string 1433 + error_message?: string | null 1161 1434 files?: components['schemas']['DeploymentFile'][] | null 1162 1435 guild_id: string 1436 + id: string 1163 1437 source_map?: null | components['schemas']['DeploymentSourceMapFile'] 1164 - updated_at: string 1438 + status: components['schemas']['DeploymentRevisionStatus'] 1165 1439 } 1166 1440 } 1167 1441 } ··· 1227 1501 } 1228 1502 } 1229 1503 } 1230 - upsert_deployment_handler: { 1504 + rollback_deployment_handler: { 1231 1505 parameters: { 1232 1506 query?: never 1233 1507 header?: never 1234 1508 path: { 1235 1509 /** @description Discord guild id */ 1236 1510 guild_id: string 1511 + /** @description Successful revision id to rollback to */ 1512 + revision_id: string 1237 1513 } 1238 1514 cookie?: never 1239 1515 } 1240 - requestBody: { 1241 - content: { 1242 - 'application/json': components['schemas']['DeploymentRequest'] 1243 - } 1244 - } 1516 + requestBody?: never 1245 1517 responses: { 1246 1518 /** @description Successful response */ 1247 1519 200: { ··· 1250 1522 } 1251 1523 content: { 1252 1524 'application/json': { 1525 + actor: components['schemas']['DeploymentActorResponse'] 1526 + base_revision_id?: string | null 1527 + build_id?: string | null 1253 1528 bundle?: string | null 1254 - created_at: string 1529 + change_summary?: null | components['schemas']['DeploymentChangeSummary'] 1530 + deploy_source: components['schemas']['DeploymentSource'] 1531 + deployed_at: string 1255 1532 entry: string 1533 + error_message?: string | null 1256 1534 files?: components['schemas']['DeploymentFile'][] | null 1257 1535 guild_id: string 1536 + id: string 1258 1537 source_map?: null | components['schemas']['DeploymentSourceMapFile'] 1259 - updated_at: string 1538 + status: components['schemas']['DeploymentRevisionStatus'] 1260 1539 } 1261 1540 } 1262 1541 }
+2
apps/cli/src/lib/types.ts
··· 8 8 root?: string 9 9 } 10 10 11 + export type DeploySourceMapMode = 'none' | 'inline' | 'external' 12 + 11 13 export const DEFAULT_API_URL = 'http://localhost:3000/api'
+1
apps/frontend/package.json
··· 18 18 "@fontsource-variable/figtree": "^5.2.10", 19 19 "@fontsource-variable/geist": "5.2.8", 20 20 "@monaco-editor/react": "4.7.0", 21 + "@pierre/diffs": "1.0.11", 21 22 "@radix-ui/react-scroll-area": "^1.2.10", 22 23 "@radix-ui/react-tabs": "^1.1.13", 23 24 "@tailwindcss/vite": "^4.1.18",
+3 -16
apps/frontend/src/components/deployments-page.tsx
··· 2 2 import { DashboardSidebar } from '@/components/sidebar/app-sidebar' 3 3 import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar' 4 4 import { useApp } from '@/contexts/AppContext' 5 - import { api } from '@/lib/openapi-client' 6 5 import { Seo } from '@/lib/seo' 7 - import { useQuery } from '@tanstack/react-query' 8 6 import { useEffect } from 'react' 9 7 import { useParams } from 'wouter' 10 8 ··· 17 15 setView('deployments') 18 16 }, [guildId, setSelectedGuild, setView]) 19 17 20 - const deploymentsQuery = useQuery({ 21 - queryKey: ['deployments', guildId], 22 - enabled: !!guildId, 23 - queryFn: () => 24 - api.GET('/deployments/{guild_id}', { params: { path: { guild_id: guildId! } } }).then((r) => 25 - r.data ? [r.data] : [] 26 - ) 27 - }) 28 - 29 18 return ( 30 19 <> 31 20 <Seo ··· 44 33 <div className='ml-auto' /> 45 34 </header> 46 35 <div className='flex-1 overflow-y-auto p-4 md:p-6 lg:p-8'> 47 - {deploymentsQuery.isLoading 48 - ? <div className='text-sm text-muted-foreground'>Loading deployments…</div> 49 - : deploymentsQuery.isError 50 - ? <div className='text-sm text-destructive'>Failed to load deployments</div> 51 - : <DeploymentHistory deploymentsOverride={deploymentsQuery.data} />} 36 + {!guildId 37 + ? <div className='text-sm text-destructive'>Missing guild id</div> 38 + : <DeploymentHistory guildId={guildId} />} 52 39 </div> 53 40 </SidebarInset> 54 41 </div>
+6 -13
apps/frontend/src/components/editor-page.tsx
··· 1 1 import { DashboardSidebar } from '@/components/sidebar/app-sidebar' 2 2 import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar' 3 3 import { useApp } from '@/contexts/AppContext' 4 - import { $api, api } from '@/lib/openapi-client' 4 + import { $api } from '@/lib/openapi-client' 5 5 import { Seo } from '@/lib/seo' 6 6 import { useTheme } from '@/lib/theme' 7 7 import { cn } from '@/lib/utils' ··· 80 80 const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null) 81 81 const [textActionModal, setTextActionModal] = useState<TextActionModalState | null>(null) 82 82 const [deleteTarget, setDeleteTarget] = useState<TreeSelection | null>(null) 83 - const [tailLogs, setTailLogs] = useState(true) 83 + const [tailLogs, setTailLogs] = useState(false) 84 84 const [workspaceReady, setWorkspaceReady] = useState(false) 85 85 const workspaceRef = useRef<Workspace | null>(null) 86 86 const selectedFileRef = useRef(selectedFile) ··· 122 122 refetchInterval: 3000 123 123 } 124 124 ) 125 + 126 + const deployMutation = $api.useMutation('post', '/deployments/{guild_id}') 125 127 126 128 const filesFromDeployment = useMemo( 127 129 () => extractFilesFromDeployment(deploymentQuery.data), ··· 695 697 .map(([path, contents]) => ({ path, contents })) 696 698 .sort((a, b) => a.path.localeCompare(b.path)) 697 699 698 - const deployResponse = await api.POST('/deployments/{guild_id}', { 700 + await deployMutation.mutateAsync({ 699 701 params: { path: { guild_id: buildResult.build.guild_id } }, 702 + headers: { 'x-flora-deploy-source': 'webui' }, 700 703 body: { 701 704 entry: buildResult.build.entry, 702 705 files: deployFiles, 703 706 bundle: buildResult.build.artifact.bundle 704 707 } 705 708 }) 706 - 707 - const deploymentError: unknown = deployResponse.error 708 - if (deploymentError) { 709 - const errorMessage = typeof deploymentError === 'string' 710 - ? deploymentError 711 - : JSON.stringify(deploymentError) 712 - setDeployError(errorMessage) 713 - setDeployState('error') 714 - return 715 - } 716 709 717 710 setDeployBuildLogs([]) 718 711 setDeployState('success')
+449 -65
apps/frontend/src/components/features/DeploymentHistory.tsx
··· 1 1 import { Badge } from '@/components/ui/badge' 2 - import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' 3 - import { useApp } from '@/contexts/AppContext' 4 - import type { components } from '@/lib/openapi-schema' 2 + import { Button } from '@/components/ui/button' 3 + import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' 4 + import { 5 + Table, 6 + TableBody, 7 + TableCell, 8 + TableHead, 9 + TableHeader, 10 + TableRow 11 + } from '@/components/ui/table' 12 + import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' 13 + import { cn } from '@/lib/utils' 14 + import { MultiFileDiff } from '@pierre/diffs/react' 15 + import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' 5 16 import { formatDistanceToNow } from 'date-fns' 6 - import { Code2, History } from 'lucide-react' 17 + import { ChevronDown, Loader2, RotateCcw } from 'lucide-react' 18 + import { useEffect, useMemo, useState } from 'react' 19 + 20 + type DeploymentChangeSummary = { 21 + added_files: number 22 + removed_files: number 23 + modified_files: number 24 + } 25 + 26 + type DeploymentRevision = { 27 + id: string 28 + guild_id: string 29 + entry: string 30 + status: string 31 + deployed_at: string 32 + deploy_source: string 33 + actor: { 34 + user_id?: string | null 35 + username?: string | null 36 + actor_type: string 37 + } 38 + error_message?: string | null 39 + build_id?: string | null 40 + base_revision_id?: string | null 41 + change_summary?: DeploymentChangeSummary | null 42 + files?: Array<{ path: string; contents: string }> | null 43 + } 44 + 45 + type ApiError = { 46 + message?: string 47 + } 48 + 49 + const API_BASE = import.meta.env.VITE_API_BASE ?? '/api' 50 + const DIFFABLE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cts']) 51 + const DIFFABLE_FILENAMES = new Set([ 52 + 'package.json', 53 + 'pnpm-lock.yaml', 54 + 'package-lock.json', 55 + 'yarn.lock', 56 + 'bun.lockb', 57 + 'tsconfig.json' 58 + ]) 7 59 8 60 function formatTimeAgo(value?: string | null) { 9 61 if (!value) return 'never' 10 62 return formatDistanceToNow(new Date(value), { addSuffix: true }) 11 63 } 12 64 13 - function EmptyState({ 14 - icon: Icon, 15 - title, 16 - description 17 - }: { 18 - icon: React.ComponentType<{ className?: string }> 19 - title: string 20 - description: string 21 - }) { 22 - return ( 23 - <div className='flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed py-8 text-center animate-in fade-in zoom-in-95'> 24 - <div className='rounded-full bg-muted p-3'> 25 - <Icon className='h-6 w-6 text-muted-foreground' /> 26 - </div> 27 - <div className='font-medium mt-2'>{title}</div> 28 - <div className='text-muted-foreground text-sm max-w-xs'>{description}</div> 29 - </div> 65 + function formatDateTime(value?: string | null) { 66 + if (!value) return '—' 67 + return new Date(value).toLocaleString() 68 + } 69 + 70 + function shortId(id: string) { 71 + return id.slice(0, 8) 72 + } 73 + 74 + function formatActor(actor: DeploymentRevision['actor']) { 75 + if (actor.username) return `@${actor.username}` 76 + return actor.user_id ?? actor.actor_type 77 + } 78 + 79 + function buildSummaryLabel(summary?: DeploymentChangeSummary | null) { 80 + if (!summary) return '—' 81 + return `+${summary.added_files} ~${summary.modified_files} -${summary.removed_files}` 82 + } 83 + 84 + function isDiffableFile(path: string) { 85 + const lowerPath = path.toLowerCase() 86 + const fileName = lowerPath.split('/').at(-1) ?? lowerPath 87 + if (DIFFABLE_FILENAMES.has(fileName)) return true 88 + return Array.from(DIFFABLE_EXTENSIONS).some((ext) => lowerPath.endsWith(ext)) 89 + } 90 + 91 + function buildFileMap(files?: Array<{ path: string; contents: string }> | null) { 92 + const map = new Map<string, string>() 93 + for (const file of files ?? []) { 94 + map.set(file.path, file.contents) 95 + } 96 + return map 97 + } 98 + 99 + function toErrorMessage(error: unknown) { 100 + if (!(error instanceof Error)) return 'Request failed' 101 + return error.message 102 + } 103 + 104 + function statusBadgeClass(status: string) { 105 + if (status === 'failed') { 106 + return 'bg-rose-500/15 text-rose-700 hover:bg-rose-500/25 dark:bg-rose-500/10 dark:text-rose-300 dark:hover:bg-rose-500/20 border-0' 107 + } 108 + 109 + return 'bg-green-500/15 text-green-700 hover:bg-green-500/25 dark:bg-green-500/10 dark:text-green-300 dark:hover:bg-green-500/20 border-0' 110 + } 111 + 112 + async function requestJson<T>(path: string, init?: RequestInit) { 113 + const response = await fetch(`${API_BASE}${path}`, { credentials: 'include', ...init }) 114 + if (!response.ok) { 115 + let message = `Request failed (${response.status})` 116 + try { 117 + const body = (await response.json()) as ApiError 118 + if (body.message) message = body.message 119 + } catch { 120 + // ignore JSON parse errors 121 + } 122 + throw new Error(message) 123 + } 124 + 125 + return (await response.json()) as T 126 + } 127 + 128 + export function DeploymentHistory({ guildId }: { guildId: string }) { 129 + const [selectedRevisionId, setSelectedRevisionId] = useState<string | null>(null) 130 + const [diffOpen, setDiffOpen] = useState(false) 131 + const queryClient = useQueryClient() 132 + 133 + const historyQuery = useQuery({ 134 + queryKey: ['deployment-history', guildId], 135 + queryFn: () => 136 + requestJson<DeploymentRevision[]>( 137 + `/deployments/${encodeURIComponent(guildId)}/history?limit=100` 138 + ) 139 + }) 140 + 141 + const selectedSummary = useMemo( 142 + () => historyQuery.data?.find((row) => row.id === selectedRevisionId), 143 + [historyQuery.data, selectedRevisionId] 30 144 ) 31 - } 32 145 33 - type Deployment = components['schemas']['DeploymentResponse'] 146 + useEffect(() => { 147 + if (!historyQuery.data?.length) { 148 + setSelectedRevisionId(null) 149 + return 150 + } 34 151 35 - export function DeploymentHistory({ deploymentsOverride }: { deploymentsOverride?: Deployment[] }) { 36 - const { deployments, selectedGuild, guilds } = useApp() 37 - const data = deploymentsOverride ?? deployments.data ?? [] 152 + if (!selectedRevisionId || !historyQuery.data.some((row) => row.id === selectedRevisionId)) { 153 + setSelectedRevisionId(historyQuery.data[0]?.id ?? null) 154 + } 155 + }, [historyQuery.data, selectedRevisionId]) 156 + 157 + const selectedRevisionQuery = useQuery({ 158 + queryKey: ['deployment-revision', guildId, selectedRevisionId], 159 + enabled: !!selectedRevisionId, 160 + queryFn: () => 161 + requestJson<DeploymentRevision>( 162 + `/deployments/${encodeURIComponent(guildId)}/revisions/${selectedRevisionId}` 163 + ) 164 + }) 165 + 166 + const baseRevisionId = selectedRevisionQuery.data?.base_revision_id ?? null 167 + const baseRevisionQuery = useQuery({ 168 + queryKey: ['deployment-revision', guildId, baseRevisionId], 169 + enabled: !!baseRevisionId, 170 + queryFn: () => 171 + requestJson<DeploymentRevision>( 172 + `/deployments/${encodeURIComponent(guildId)}/revisions/${baseRevisionId}` 173 + ) 174 + }) 175 + 176 + const rollbackMutation = useMutation({ 177 + mutationFn: async (revisionId: string) => 178 + requestJson<DeploymentRevision>( 179 + `/deployments/${encodeURIComponent(guildId)}/rollback/${revisionId}`, 180 + { 181 + method: 'POST' 182 + } 183 + ), 184 + onSuccess: async (revision) => { 185 + setSelectedRevisionId(revision.id) 186 + await queryClient.invalidateQueries({ queryKey: ['deployment-history', guildId] }) 187 + await queryClient.invalidateQueries({ queryKey: ['deployment-revision', guildId] }) 188 + } 189 + }) 190 + 191 + const diffFiles = useMemo(() => { 192 + const current = buildFileMap(selectedRevisionQuery.data?.files) 193 + const base = buildFileMap(baseRevisionQuery.data?.files) 194 + 195 + const allPaths = new Set<string>([...current.keys(), ...base.keys()]) 196 + 197 + return [...allPaths] 198 + .filter(isDiffableFile) 199 + .map((path) => ({ 200 + path, 201 + oldContents: base.get(path) ?? '', 202 + newContents: current.get(path) ?? '' 203 + })) 204 + .filter((file) => file.oldContents !== file.newContents) 205 + .sort((a, b) => a.path.localeCompare(b.path)) 206 + }, [baseRevisionQuery.data?.files, selectedRevisionQuery.data?.files]) 207 + 208 + const selectedRevision = selectedRevisionQuery.data ?? selectedSummary ?? null 38 209 39 210 return ( 40 - <Card> 41 - <CardHeader> 42 - <CardTitle>Deployment History</CardTitle> 43 - <CardDescription>Recent updates to your guild bots.</CardDescription> 44 - </CardHeader> 45 - <CardContent> 46 - {!data.length 211 + <div className='flex h-full min-h-0 flex-col gap-4'> 212 + <div className='rounded-lg border bg-card p-4'> 213 + {!selectedRevision 47 214 ? ( 48 - <EmptyState 49 - icon={History} 50 - title='No deployments' 51 - description="You haven't deployed any code yet." 52 - /> 215 + <div className='text-sm text-muted-foreground'> 216 + Select a revision to inspect metadata and source diffs. 217 + </div> 218 + ) 219 + : selectedRevisionQuery.isLoading 220 + ? ( 221 + <div className='flex items-center gap-2 text-sm text-muted-foreground'> 222 + <Loader2 className='size-4 animate-spin' /> 223 + Loading Revision Details… 224 + </div> 225 + ) 226 + : selectedRevisionQuery.isError 227 + ? ( 228 + <div className='text-sm text-destructive'> 229 + Failed to load revision: {toErrorMessage(selectedRevisionQuery.error)} 230 + </div> 53 231 ) 54 232 : ( 55 - <div className='space-y-4'> 56 - {data 57 - .filter((d) => d.guild_id === selectedGuild || !selectedGuild) 58 - .map((dep) => ( 59 - <div 60 - key={dep.guild_id} 61 - className='flex items-center justify-between rounded-lg border p-4 transition-colors hover:bg-muted/50' 233 + <div className='space-y-3'> 234 + <div className='flex items-center justify-between gap-2'> 235 + <div> 236 + <div className='text-sm font-medium'>Revision {selectedRevision.id}</div> 237 + <div className='text-xs text-muted-foreground'> 238 + {formatDateTime(selectedRevision.deployed_at)} 239 + </div> 240 + </div> 241 + <Button 242 + size='sm' 243 + variant='outline' 244 + disabled={rollbackMutation.isPending || selectedRevision.status !== 'success'} 245 + onClick={() => { 246 + if (selectedRevision.id) rollbackMutation.mutate(selectedRevision.id) 247 + }} 248 + > 249 + {rollbackMutation.isPending 250 + ? ( 251 + <> 252 + <Loader2 className='mr-1 size-4 animate-spin' /> 253 + Rolling back… 254 + </> 255 + ) 256 + : ( 257 + <> 258 + <RotateCcw className='mr-1 size-4' /> 259 + Rollback to this 260 + </> 261 + )} 262 + </Button> 263 + </div> 264 + 265 + {rollbackMutation.isError 266 + ? ( 267 + <div className='text-xs text-destructive'> 268 + Rollback failed: {toErrorMessage(rollbackMutation.error)} 269 + </div> 270 + ) 271 + : null} 272 + 273 + <div className='grid gap-3 md:grid-cols-4'> 274 + <div className='rounded-md border p-2'> 275 + <div className='text-[11px] text-muted-foreground'>Status</div> 276 + <Badge 277 + variant='outline' 278 + className={cn('mt-1 border-0', statusBadgeClass(selectedRevision.status))} 62 279 > 63 - <div className='flex items-center gap-4'> 64 - <div className='rounded-full bg-primary/10 p-2 text-primary'> 65 - <Code2 className='h-4 w-4' /> 66 - </div> 67 - <div> 68 - <p className='font-medium text-sm'> 69 - {guilds.data?.find((g) => g.id === dep.guild_id)?.name || dep.guild_id} 70 - </p> 71 - <p className='text-xs text-muted-foreground'> 72 - Deployed {formatTimeAgo(dep.updated_at)} 73 - </p> 74 - </div> 75 - </div> 76 - <div className='flex items-center gap-3'> 77 - <Badge variant='secondary' className='font-mono text-xs'> 78 - auto-detected 79 - </Badge> 80 - </div> 280 + {selectedRevision.status} 281 + </Badge> 282 + </div> 283 + <div className='rounded-md border p-2'> 284 + <div className='text-[11px] text-muted-foreground'>Source</div> 285 + <div className='mt-1 text-sm font-medium'>{selectedRevision.deploy_source}</div> 286 + </div> 287 + <div className='rounded-md border p-2'> 288 + <div className='text-[11px] text-muted-foreground'>Actor</div> 289 + <div className='mt-1 text-sm font-medium'> 290 + {formatActor(selectedRevision.actor)} 291 + </div> 292 + </div> 293 + <div className='rounded-md border p-2'> 294 + <div className='text-[11px] text-muted-foreground'>Changes</div> 295 + <div className='mt-1 font-mono text-sm'> 296 + {buildSummaryLabel(selectedRevision.change_summary)} 297 + </div> 298 + </div> 299 + </div> 300 + 301 + <div className='grid grid-cols-2 gap-3 text-xs md:grid-cols-4'> 302 + <div className='rounded-md border p-2'> 303 + <div className='text-muted-foreground'>Guild</div> 304 + <div className='font-mono'>{selectedRevision.guild_id}</div> 305 + </div> 306 + <div className='rounded-md border p-2'> 307 + <div className='text-muted-foreground'>Entry</div> 308 + <div className='font-mono'>{selectedRevision.entry}</div> 309 + </div> 310 + <div className='rounded-md border p-2'> 311 + <div className='text-muted-foreground'>Build</div> 312 + <div className='font-mono'>{selectedRevision.build_id ?? '—'}</div> 313 + </div> 314 + <div className='rounded-md border p-2'> 315 + <div className='text-muted-foreground'>Base Revision</div> 316 + <div className='font-mono'> 317 + {selectedRevision.base_revision_id 318 + ? shortId(selectedRevision.base_revision_id) 319 + : '—'} 320 + </div> 321 + </div> 322 + </div> 323 + 324 + {selectedRevision.error_message 325 + ? ( 326 + <pre className='overflow-x-auto rounded border bg-muted/30 p-2 text-xs text-destructive'> 327 + {selectedRevision.error_message} 328 + </pre> 329 + ) 330 + : null} 331 + </div> 332 + )} 333 + </div> 334 + 335 + <Collapsible open={diffOpen} onOpenChange={setDiffOpen} className='rounded-lg border bg-card'> 336 + <CollapsibleTrigger className='flex w-full items-center justify-between px-4 py-3 text-left'> 337 + <div> 338 + <div className='text-sm font-medium'>Source Diffs</div> 339 + <div className='text-xs text-muted-foreground'>{diffFiles.length} changed files</div> 340 + </div> 341 + <ChevronDown className={cn('size-4 transition-transform', diffOpen && 'rotate-180')} /> 342 + </CollapsibleTrigger> 343 + <CollapsibleContent className='border-t p-3'> 344 + {baseRevisionId && baseRevisionQuery.isLoading 345 + ? <div className='text-sm text-muted-foreground'>Loading base revision…</div> 346 + : baseRevisionQuery.isError 347 + ? ( 348 + <div className='text-sm text-destructive'> 349 + Failed to load base revision: {toErrorMessage(baseRevisionQuery.error)} 350 + </div> 351 + ) 352 + : !baseRevisionId 353 + ? <div className='text-sm text-muted-foreground'>No base revision for this entry.</div> 354 + : !diffFiles.length 355 + ? <div className='text-sm text-muted-foreground'>No diffable source-file changes.</div> 356 + : ( 357 + <div className='max-h-[42dvh] space-y-3 overflow-auto'> 358 + {diffFiles.map((file) => ( 359 + <div key={file.path} className='overflow-hidden rounded-lg border'> 360 + <MultiFileDiff 361 + oldFile={{ name: file.path, contents: file.oldContents }} 362 + newFile={{ name: file.path, contents: file.newContents }} 363 + options={{ 364 + diffStyle: 'split', 365 + overflow: 'wrap', 366 + lineDiffType: 'word' 367 + }} 368 + /> 81 369 </div> 82 370 ))} 371 + </div> 372 + )} 373 + </CollapsibleContent> 374 + </Collapsible> 375 + 376 + <div className='min-h-0 flex-1 overflow-hidden rounded-lg border bg-card'> 377 + {historyQuery.isLoading 378 + ? ( 379 + <div className='flex h-full items-center justify-center gap-2 text-sm text-muted-foreground'> 380 + <Loader2 className='size-4 animate-spin' /> 381 + Loading Deployment History… 382 + </div> 383 + ) 384 + : historyQuery.isError 385 + ? ( 386 + <div className='flex h-full items-center justify-center text-sm text-destructive'> 387 + Failed to load history: {toErrorMessage(historyQuery.error)} 388 + </div> 389 + ) 390 + : !historyQuery.data?.length 391 + ? ( 392 + <div className='flex h-full items-center justify-center text-sm text-muted-foreground'> 393 + No deployments yet for this guild. 394 + </div> 395 + ) 396 + : ( 397 + <div className='h-full overflow-auto'> 398 + <Table> 399 + <TableHeader className='sticky top-0 bg-background/95 backdrop-blur'> 400 + <TableRow className='hover:bg-transparent'> 401 + <TableHead>Id</TableHead> 402 + <TableHead>Actor</TableHead> 403 + <TableHead>Changes</TableHead> 404 + <TableHead>Deployed</TableHead> 405 + <TableHead>Source</TableHead> 406 + <TableHead>Status</TableHead> 407 + <TableHead>Entry</TableHead> 408 + <TableHead>Build</TableHead> 409 + <TableHead>Error</TableHead> 410 + </TableRow> 411 + </TableHeader> 412 + <TableBody> 413 + {historyQuery.data.map((row) => { 414 + const isSelected = row.id === selectedRevisionId 415 + 416 + return ( 417 + <TableRow 418 + key={row.id} 419 + data-state={isSelected ? 'selected' : undefined} 420 + className='cursor-pointer' 421 + onClick={() => { 422 + setSelectedRevisionId(row.id) 423 + }} 424 + > 425 + <TableCell className='font-mono text-xs'>{shortId(row.id)}</TableCell> 426 + <TableCell className='text-xs'>{formatActor(row.actor)}</TableCell> 427 + <TableCell className='font-mono text-xs'> 428 + {buildSummaryLabel(row.change_summary)} 429 + </TableCell> 430 + <TableCell className='text-xs whitespace-nowrap'> 431 + {formatTimeAgo(row.deployed_at)} 432 + </TableCell> 433 + <TableCell className='text-xs'>{row.deploy_source}</TableCell> 434 + <TableCell> 435 + <Badge 436 + variant='outline' 437 + className={cn('border-0', statusBadgeClass(row.status))} 438 + > 439 + {row.status} 440 + </Badge> 441 + </TableCell> 442 + <TableCell className='font-mono text-xs'>{row.entry}</TableCell> 443 + <TableCell className='font-mono text-xs'>{row.build_id ?? '—'}</TableCell> 444 + <TableCell className='max-w-60 text-xs'> 445 + {row.error_message 446 + ? ( 447 + <TooltipProvider> 448 + <Tooltip> 449 + <TooltipTrigger> 450 + <span className='block truncate text-destructive'> 451 + {row.error_message} 452 + </span> 453 + </TooltipTrigger> 454 + <TooltipContent className='max-w-lg'> 455 + {row.error_message} 456 + </TooltipContent> 457 + </Tooltip> 458 + </TooltipProvider> 459 + ) 460 + : '—'} 461 + </TableCell> 462 + </TableRow> 463 + ) 464 + })} 465 + </TableBody> 466 + </Table> 83 467 </div> 84 468 )} 85 - </CardContent> 86 - </Card> 469 + </div> 470 + </div> 87 471 ) 88 472 }
+25 -18
apps/frontend/src/components/features/TokenManager.tsx
··· 3 3 import { Input } from '@/components/ui/input' 4 4 import { Skeleton } from '@/components/ui/skeleton' 5 5 import { useApp } from '@/contexts/AppContext' 6 - import { api } from '@/lib/openapi-client' 6 + import { $api } from '@/lib/openapi-client' 7 7 import { formatDistanceToNow } from 'date-fns' 8 8 import { Copy, Plus, Shield, Trash2 } from 'lucide-react' 9 9 import { useState } from 'react' ··· 40 40 const [tokenLabel, setTokenLabel] = useState<string>('') 41 41 const [error, setError] = useState<string | null>(null) 42 42 43 + const createTokenMutation = $api.useMutation('post', '/tokens/', { 44 + onSuccess: (data) => { 45 + setNewToken(data?.token || null) 46 + setTokenLabel('') 47 + void refreshTokens() 48 + }, 49 + onError: (err: any) => { 50 + setNewToken(null) 51 + setError(err.message || 'Failed to create token') 52 + } 53 + }) 54 + 55 + const deleteTokenMutation = $api.useMutation('delete', '/tokens/{token_id}', { 56 + onSuccess: () => { 57 + void refreshTokens() 58 + }, 59 + onError: (err: any) => { 60 + setError(err.message || 'Failed to delete token') 61 + } 62 + }) 63 + 43 64 const handleCreateToken = () => { 44 65 setError(null) 45 66 const label = tokenLabel.trim() 46 67 47 - return api 48 - .POST('/tokens/', { body: label ? { label } : {} }) 49 - .then((res) => { 50 - setNewToken(res.data?.token || null) 51 - setTokenLabel('') 52 - return refreshTokens() 53 - }) 54 - .catch((err: any) => { 55 - setNewToken(null) 56 - setError(err.message || 'Failed to create token') 57 - }) 68 + return createTokenMutation.mutateAsync({ body: label ? { label } : {} }) 58 69 } 59 70 60 71 const handleDeleteToken = (tokenId: string) => { 61 - return api 62 - .DELETE('/tokens/{token_id}', { params: { path: { token_id: tokenId } } }) 63 - .then(() => refreshTokens()) 64 - .catch((err: any) => { 65 - setError(err.message || 'Failed to delete token') 66 - }) 72 + setError(null) 73 + return deleteTokenMutation.mutateAsync({ params: { path: { token_id: tokenId } } }) 67 74 } 68 75 69 76 return (
+62
apps/frontend/src/components/ui/table.tsx
··· 1 + import * as React from 'react' 2 + 3 + import { cn } from '@/lib/utils' 4 + 5 + function Table({ className, ...props }: React.ComponentProps<'table'>) { 6 + return ( 7 + <div className='relative w-full overflow-x-auto'> 8 + <table className={cn('w-full caption-bottom text-sm', className)} {...props} /> 9 + </div> 10 + ) 11 + } 12 + 13 + function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) { 14 + return <thead className={cn('[&_tr]:border-b', className)} {...props} /> 15 + } 16 + 17 + function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) { 18 + return <tbody className={cn('[&_tr:last-child]:border-0', className)} {...props} /> 19 + } 20 + 21 + function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) { 22 + return ( 23 + <tfoot 24 + className={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)} 25 + {...props} 26 + /> 27 + ) 28 + } 29 + 30 + function TableRow({ className, ...props }: React.ComponentProps<'tr'>) { 31 + return ( 32 + <tr 33 + className={cn( 34 + 'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', 35 + className 36 + )} 37 + {...props} 38 + /> 39 + ) 40 + } 41 + 42 + function TableHead({ className, ...props }: React.ComponentProps<'th'>) { 43 + return ( 44 + <th 45 + className={cn( 46 + 'text-muted-foreground h-10 px-3 text-left align-middle font-medium whitespace-nowrap', 47 + className 48 + )} 49 + {...props} 50 + /> 51 + ) 52 + } 53 + 54 + function TableCell({ className, ...props }: React.ComponentProps<'td'>) { 55 + return <td className={cn('p-3 align-middle', className)} {...props} /> 56 + } 57 + 58 + function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) { 59 + return <caption className={cn('text-muted-foreground mt-4 text-sm', className)} {...props} /> 60 + } 61 + 62 + export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow }
+19 -26
apps/frontend/src/contexts/AppContext.tsx
··· 1 - import { api } from '@/lib/openapi-client' 1 + import { $api, queryClient } from '@/lib/openapi-client' 2 2 import type { components } from '@/lib/openapi-schema' 3 3 import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from 'react' 4 4 ··· 58 58 const refreshSession = useCallback((): Promise<void> => { 59 59 setSessionLoading(true) 60 60 61 - return api 62 - .GET('/auth/me', {}) 63 - .then((res) => { 64 - const user = res.data ? res.data.user : null 61 + return queryClient 62 + .fetchQuery($api.queryOptions('get', '/auth/me', {})) 63 + .then((data) => { 64 + const user = data ? data.user : null 65 65 setSession(user) 66 66 setSessionError(null) 67 67 }) ··· 79 79 }, []) 80 80 81 81 const refreshGuilds = useCallback((): Promise<void> => { 82 - return api 83 - .GET('/guilds/', {}) 84 - .then((res) => { 85 - setGuilds({ data: res.data ?? null, loading: false, error: null }) 82 + return queryClient 83 + .fetchQuery($api.queryOptions('get', '/guilds/', {})) 84 + .then((data) => { 85 + setGuilds({ data: data ?? null, loading: false, error: null }) 86 86 }) 87 87 .catch((err: any) => { 88 88 setGuilds({ data: null, loading: false, error: err.message }) ··· 90 90 }, []) 91 91 92 92 const refreshDeployments = useCallback((): Promise<void> => { 93 - return api 94 - .GET('/deployments/', {}) 95 - .then((res) => { 96 - setDeployments({ data: res.data ?? null, loading: false, error: null }) 97 - 98 - if ((res.data?.length ?? 0) > 0) { 99 - const firstGuildId = res.data![0].guild_id 100 - setSelectedGuild((prev) => prev || firstGuildId) 101 - } 102 - }) 103 - .catch((err: any) => { 104 - setDeployments({ data: null, loading: false, error: err.message }) 105 - }) 93 + setDeployments((prev) => ({ 94 + data: prev.data, 95 + loading: false, 96 + error: null 97 + })) 98 + return Promise.resolve() 106 99 }, []) 107 100 108 101 const refreshTokens = useCallback((): Promise<void> => { 109 - return api 110 - .GET('/tokens/', {}) 111 - .then((res) => { 112 - setTokens({ data: res.data ?? null, loading: false, error: null }) 102 + return queryClient 103 + .fetchQuery($api.queryOptions('get', '/tokens/', {})) 104 + .then((data) => { 105 + setTokens({ data: data ?? null, loading: false, error: null }) 113 106 }) 114 107 .catch((err: any) => { 115 108 setTokens({ data: null, loading: false, error: err.message })
+5 -2
apps/frontend/src/lib/openapi-client.ts
··· 1 + import { QueryClient } from '@tanstack/react-query' 1 2 import createClient from 'openapi-fetch' 2 3 import createRQClient from 'openapi-react-query' 3 4 import type { $defs, paths, webhooks } from './openapi-schema' ··· 32 33 const _openApiTypeMarker: _OpenApiRootTypes | null = null 33 34 void _openApiTypeMarker 34 35 35 - export const api = createClient<paths>({ baseUrl, fetch: fetchWithCreds }) 36 + export const queryClient = new QueryClient() 36 37 37 - export const $api = createRQClient(api) 38 + const fetchClient = createClient<paths>({ baseUrl, fetch: fetchWithCreds }) 39 + 40 + export const $api = createRQClient(fetchClient)
+306 -27
apps/frontend/src/lib/openapi-schema.ts
··· 87 87 patch?: never 88 88 trace?: never 89 89 } 90 - '/deployments/': { 90 + '/deployments/{guild_id}': { 91 + parameters: { 92 + query?: never 93 + header?: never 94 + path?: never 95 + cookie?: never 96 + } 97 + get: operations['get_deployment_handler'] 98 + put?: never 99 + post: operations['upsert_deployment_handler'] 100 + delete?: never 101 + options?: never 102 + head?: never 103 + patch?: never 104 + trace?: never 105 + } 106 + '/deployments/{guild_id}/history': { 107 + parameters: { 108 + query?: never 109 + header?: never 110 + path?: never 111 + cookie?: never 112 + } 113 + get: operations['list_deployment_history_handler'] 114 + put?: never 115 + post?: never 116 + delete?: never 117 + options?: never 118 + head?: never 119 + patch?: never 120 + trace?: never 121 + } 122 + '/deployments/{guild_id}/revisions/{revision_id}': { 91 123 parameters: { 92 124 query?: never 93 125 header?: never 94 126 path?: never 95 127 cookie?: never 96 128 } 97 - /** List every stored deployment. */ 98 - get: operations['list_deployments_handler'] 129 + get: operations['get_deployment_revision_handler'] 99 130 put?: never 100 131 post?: never 101 132 delete?: never ··· 104 135 patch?: never 105 136 trace?: never 106 137 } 107 - '/deployments/{guild_id}': { 138 + '/deployments/{guild_id}/rollback/{revision_id}': { 108 139 parameters: { 109 140 query?: never 110 141 header?: never 111 142 path?: never 112 143 cookie?: never 113 144 } 114 - /** Fetch a single deployment by guild id. */ 115 - get: operations['get_deployment_handler'] 145 + get?: never 116 146 put?: never 117 - /** Create or update a deployment for a guild. */ 118 - post: operations['upsert_deployment_handler'] 147 + post: operations['rollback_deployment_handler'] 119 148 delete?: never 120 149 options?: never 121 150 head?: never ··· 442 471 guild_id: string 443 472 store_name: string 444 473 } 474 + DeploymentActorResponse: { 475 + actor_type: components['schemas']['DeploymentActorType'] 476 + user_id?: string | null 477 + username?: string | null 478 + } 479 + /** @enum {string} */ 480 + DeploymentActorType: 'session' | 'token' | 'system' 481 + DeploymentChangeSummary: { 482 + added_files: number 483 + modified_files: number 484 + removed_files: number 485 + } 445 486 DeploymentFile: { 446 487 contents: string 447 488 path: string 448 489 } 449 - /** @description Body for creating or replacing a deployment. */ 450 490 DeploymentRequest: { 451 - /** @description Prebuilt JavaScript bundle source (legacy mode). */ 491 + build_id?: string | null 452 492 bundle?: string | null 453 - /** @description Entry point path for the bundle (e.g. src/main.ts). */ 454 493 entry: string 455 - /** @description Source files for the deployment. Preferred over raw bundle input. */ 456 494 files?: components['schemas']['DeploymentFile'][] | null 457 495 source_map?: null | components['schemas']['DeploymentSourceMapFile'] 458 496 } 459 - /** @description API representation of a deployment. */ 460 497 DeploymentResponse: { 461 498 bundle?: string | null 462 499 created_at: string ··· 466 503 source_map?: null | components['schemas']['DeploymentSourceMapFile'] 467 504 updated_at: string 468 505 } 506 + DeploymentRevisionResponse: { 507 + actor: components['schemas']['DeploymentActorResponse'] 508 + base_revision_id?: string | null 509 + build_id?: string | null 510 + bundle?: string | null 511 + change_summary?: null | components['schemas']['DeploymentChangeSummary'] 512 + deploy_source: components['schemas']['DeploymentSource'] 513 + deployed_at: string 514 + entry: string 515 + error_message?: string | null 516 + files?: components['schemas']['DeploymentFile'][] | null 517 + guild_id: string 518 + id: string 519 + source_map?: null | components['schemas']['DeploymentSourceMapFile'] 520 + status: components['schemas']['DeploymentRevisionStatus'] 521 + } 522 + /** @enum {string} */ 523 + DeploymentRevisionStatus: 'success' | 'failed' 524 + /** @enum {string} */ 525 + DeploymentSource: 'cli' | 'webui' | 'bootstrap' | 'api' | 'unknown' 469 526 DeploymentSourceMapFile: { 470 527 contents: string 471 528 path: string ··· 1045 1102 } 1046 1103 } 1047 1104 } 1048 - list_deployments_handler: { 1105 + get_deployment_handler: { 1106 + parameters: { 1107 + query?: { 1108 + /** @description Include bundled output in response */ 1109 + include_bundle?: boolean 1110 + } 1111 + header?: never 1112 + path: { 1113 + /** @description Discord guild id */ 1114 + guild_id: string 1115 + } 1116 + cookie?: never 1117 + } 1118 + requestBody?: never 1119 + responses: { 1120 + /** @description Successful response */ 1121 + 200: { 1122 + headers: { 1123 + [name: string]: unknown 1124 + } 1125 + content: { 1126 + 'application/json': { 1127 + bundle?: string | null 1128 + created_at: string 1129 + entry: string 1130 + files?: components['schemas']['DeploymentFile'][] | null 1131 + guild_id: string 1132 + source_map?: null | components['schemas']['DeploymentSourceMapFile'] 1133 + updated_at: string 1134 + } 1135 + } 1136 + } 1137 + /** @description Bad request */ 1138 + 400: { 1139 + headers: { 1140 + [name: string]: unknown 1141 + } 1142 + content: { 1143 + 'application/json': { 1144 + /** @description Human readable error message. */ 1145 + message: string 1146 + } 1147 + } 1148 + } 1149 + /** @description Authentication required */ 1150 + 401: { 1151 + headers: { 1152 + [name: string]: unknown 1153 + } 1154 + content: { 1155 + 'application/json': { 1156 + /** @description Human readable error message. */ 1157 + message: string 1158 + } 1159 + } 1160 + } 1161 + /** @description Forbidden */ 1162 + 403: { 1163 + headers: { 1164 + [name: string]: unknown 1165 + } 1166 + content: { 1167 + 'application/json': { 1168 + /** @description Human readable error message. */ 1169 + message: string 1170 + } 1171 + } 1172 + } 1173 + /** @description Resource not found */ 1174 + 404: { 1175 + headers: { 1176 + [name: string]: unknown 1177 + } 1178 + content: { 1179 + 'application/json': { 1180 + /** @description Human readable error message. */ 1181 + message: string 1182 + } 1183 + } 1184 + } 1185 + /** @description Internal server error */ 1186 + 500: { 1187 + headers: { 1188 + [name: string]: unknown 1189 + } 1190 + content: { 1191 + 'application/json': { 1192 + /** @description Human readable error message. */ 1193 + message: string 1194 + } 1195 + } 1196 + } 1197 + } 1198 + } 1199 + upsert_deployment_handler: { 1049 1200 parameters: { 1050 1201 query?: never 1051 1202 header?: never 1052 - path?: never 1203 + path: { 1204 + /** @description Discord guild id */ 1205 + guild_id: string 1206 + } 1053 1207 cookie?: never 1054 1208 } 1055 - requestBody?: never 1209 + requestBody: { 1210 + content: { 1211 + 'application/json': components['schemas']['DeploymentRequest'] 1212 + } 1213 + } 1056 1214 responses: { 1057 1215 /** @description Successful response */ 1058 1216 200: { ··· 1068 1226 guild_id: string 1069 1227 source_map?: null | components['schemas']['DeploymentSourceMapFile'] 1070 1228 updated_at: string 1229 + } 1230 + } 1231 + } 1232 + /** @description Bad request */ 1233 + 400: { 1234 + headers: { 1235 + [name: string]: unknown 1236 + } 1237 + content: { 1238 + 'application/json': { 1239 + /** @description Human readable error message. */ 1240 + message: string 1241 + } 1242 + } 1243 + } 1244 + /** @description Authentication required */ 1245 + 401: { 1246 + headers: { 1247 + [name: string]: unknown 1248 + } 1249 + content: { 1250 + 'application/json': { 1251 + /** @description Human readable error message. */ 1252 + message: string 1253 + } 1254 + } 1255 + } 1256 + /** @description Forbidden */ 1257 + 403: { 1258 + headers: { 1259 + [name: string]: unknown 1260 + } 1261 + content: { 1262 + 'application/json': { 1263 + /** @description Human readable error message. */ 1264 + message: string 1265 + } 1266 + } 1267 + } 1268 + /** @description Resource not found */ 1269 + 404: { 1270 + headers: { 1271 + [name: string]: unknown 1272 + } 1273 + content: { 1274 + 'application/json': { 1275 + /** @description Human readable error message. */ 1276 + message: string 1277 + } 1278 + } 1279 + } 1280 + /** @description Internal server error */ 1281 + 500: { 1282 + headers: { 1283 + [name: string]: unknown 1284 + } 1285 + content: { 1286 + 'application/json': { 1287 + /** @description Human readable error message. */ 1288 + message: string 1289 + } 1290 + } 1291 + } 1292 + } 1293 + } 1294 + list_deployment_history_handler: { 1295 + parameters: { 1296 + query?: { 1297 + /** @description Page size, max 100 */ 1298 + limit?: number 1299 + /** @description RFC3339 deployed_at cursor */ 1300 + cursor_deployed_at?: string 1301 + /** @description Revision id cursor */ 1302 + cursor_id?: string 1303 + /** @description Include bundled output in response */ 1304 + include_bundle?: boolean 1305 + } 1306 + header?: never 1307 + path: { 1308 + /** @description Discord guild id */ 1309 + guild_id: string 1310 + } 1311 + cookie?: never 1312 + } 1313 + requestBody?: never 1314 + responses: { 1315 + /** @description Successful response */ 1316 + 200: { 1317 + headers: { 1318 + [name: string]: unknown 1319 + } 1320 + content: { 1321 + 'application/json': { 1322 + actor: components['schemas']['DeploymentActorResponse'] 1323 + base_revision_id?: string | null 1324 + build_id?: string | null 1325 + bundle?: string | null 1326 + change_summary?: null | components['schemas']['DeploymentChangeSummary'] 1327 + deploy_source: components['schemas']['DeploymentSource'] 1328 + deployed_at: string 1329 + entry: string 1330 + error_message?: string | null 1331 + files?: components['schemas']['DeploymentFile'][] | null 1332 + guild_id: string 1333 + id: string 1334 + source_map?: null | components['schemas']['DeploymentSourceMapFile'] 1335 + status: components['schemas']['DeploymentRevisionStatus'] 1071 1336 }[] 1072 1337 } 1073 1338 } ··· 1133 1398 } 1134 1399 } 1135 1400 } 1136 - get_deployment_handler: { 1401 + get_deployment_revision_handler: { 1137 1402 parameters: { 1138 1403 query?: { 1139 1404 /** @description Include bundled output in response */ ··· 1143 1408 path: { 1144 1409 /** @description Discord guild id */ 1145 1410 guild_id: string 1411 + /** @description Revision id */ 1412 + revision_id: string 1146 1413 } 1147 1414 cookie?: never 1148 1415 } ··· 1155 1422 } 1156 1423 content: { 1157 1424 'application/json': { 1425 + actor: components['schemas']['DeploymentActorResponse'] 1426 + base_revision_id?: string | null 1427 + build_id?: string | null 1158 1428 bundle?: string | null 1159 - created_at: string 1429 + change_summary?: null | components['schemas']['DeploymentChangeSummary'] 1430 + deploy_source: components['schemas']['DeploymentSource'] 1431 + deployed_at: string 1160 1432 entry: string 1433 + error_message?: string | null 1161 1434 files?: components['schemas']['DeploymentFile'][] | null 1162 1435 guild_id: string 1436 + id: string 1163 1437 source_map?: null | components['schemas']['DeploymentSourceMapFile'] 1164 - updated_at: string 1438 + status: components['schemas']['DeploymentRevisionStatus'] 1165 1439 } 1166 1440 } 1167 1441 } ··· 1227 1501 } 1228 1502 } 1229 1503 } 1230 - upsert_deployment_handler: { 1504 + rollback_deployment_handler: { 1231 1505 parameters: { 1232 1506 query?: never 1233 1507 header?: never 1234 1508 path: { 1235 1509 /** @description Discord guild id */ 1236 1510 guild_id: string 1511 + /** @description Successful revision id to rollback to */ 1512 + revision_id: string 1237 1513 } 1238 1514 cookie?: never 1239 1515 } 1240 - requestBody: { 1241 - content: { 1242 - 'application/json': components['schemas']['DeploymentRequest'] 1243 - } 1244 - } 1516 + requestBody?: never 1245 1517 responses: { 1246 1518 /** @description Successful response */ 1247 1519 200: { ··· 1250 1522 } 1251 1523 content: { 1252 1524 'application/json': { 1525 + actor: components['schemas']['DeploymentActorResponse'] 1526 + base_revision_id?: string | null 1527 + build_id?: string | null 1253 1528 bundle?: string | null 1254 - created_at: string 1529 + change_summary?: null | components['schemas']['DeploymentChangeSummary'] 1530 + deploy_source: components['schemas']['DeploymentSource'] 1531 + deployed_at: string 1255 1532 entry: string 1533 + error_message?: string | null 1256 1534 files?: components['schemas']['DeploymentFile'][] | null 1257 1535 guild_id: string 1536 + id: string 1258 1537 source_map?: null | components['schemas']['DeploymentSourceMapFile'] 1259 - updated_at: string 1538 + status: components['schemas']['DeploymentRevisionStatus'] 1260 1539 } 1261 1540 } 1262 1541 }
+2 -3
apps/frontend/src/main.tsx
··· 1 - import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 1 + import { QueryClientProvider } from '@tanstack/react-query' 2 2 import { ReactQueryDevtools } from '@tanstack/react-query-devtools' 3 3 import { StrictMode } from 'react' 4 4 import { createRoot } from 'react-dom/client' ··· 6 6 import { Router } from 'wouter' 7 7 import './index.css' 8 8 import App from './App.tsx' 9 - 10 - const queryClient = new QueryClient() 9 + import { queryClient } from './lib/openapi-client' 11 10 12 11 createRoot(document.getElementById('root')!).render( 13 12 <StrictMode>
+56
apps/runtime/migrations/0009_create_deployment_revisions.sql
··· 1 + CREATE TABLE IF NOT EXISTS deployment_revisions ( 2 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 3 + guild_id TEXT NOT NULL, 4 + entry TEXT NOT NULL, 5 + files JSONB, 6 + bundle TEXT NOT NULL, 7 + source_map JSONB, 8 + status TEXT NOT NULL CHECK (status IN ('success', 'failed')), 9 + deployed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 10 + deploy_source TEXT NOT NULL CHECK (deploy_source IN ('cli', 'webui', 'bootstrap', 'api', 'unknown')), 11 + actor_user_id TEXT, 12 + actor_username TEXT, 13 + actor_type TEXT NOT NULL CHECK (actor_type IN ('session', 'token', 'system')), 14 + error_message TEXT, 15 + build_id TEXT, 16 + base_revision_id UUID, 17 + change_summary JSONB, 18 + CONSTRAINT deployment_revisions_base_revision_fkey 19 + FOREIGN KEY (base_revision_id) 20 + REFERENCES deployment_revisions(id) 21 + ON DELETE SET NULL 22 + ); 23 + 24 + CREATE INDEX IF NOT EXISTS idx_deployment_revisions_guild_deployed_at 25 + ON deployment_revisions(guild_id, deployed_at DESC, id DESC); 26 + 27 + CREATE INDEX IF NOT EXISTS idx_deployment_revisions_guild_status_deployed_at 28 + ON deployment_revisions(guild_id, status, deployed_at DESC, id DESC); 29 + 30 + INSERT INTO deployment_revisions ( 31 + guild_id, 32 + entry, 33 + files, 34 + bundle, 35 + source_map, 36 + status, 37 + deployed_at, 38 + deploy_source, 39 + actor_type 40 + ) 41 + SELECT 42 + d.guild_id, 43 + d.entry, 44 + d.files, 45 + d.bundle, 46 + d.source_map, 47 + 'success', 48 + COALESCE(d.updated_at, d.created_at, NOW()), 49 + 'unknown', 50 + 'system' 51 + FROM deployments d 52 + WHERE NOT EXISTS ( 53 + SELECT 1 54 + FROM deployment_revisions r 55 + WHERE r.guild_id = d.guild_id 56 + );
+65 -17
apps/runtime/src/discord_handler.rs
··· 1 1 use crate::{ 2 2 runtime::BotRuntime, 3 - services::deployments::{DeploymentService, DeploymentSourceMapFile}, 3 + services::deployments::{ 4 + CreateDeploymentRevisionInput, Deployment, DeploymentActorType, DeploymentRevisionStatus, 5 + DeploymentService, DeploymentSource, DeploymentSourceMapFile, 6 + }, 4 7 }; 5 8 use color_eyre::{Report, eyre::eyre}; 6 9 use flora_macros::expose_payload; ··· 794 797 impl DiscordHandler { 795 798 async fn bootstrap_default_script(&self, guild_id: GuildId) -> Result<(), Report> { 796 799 let guild_str = guild_id.get().to_string(); 797 - if self.deployments.get_deployment(&guild_str).await?.is_some() { 800 + if self 801 + .deployments 802 + .get_current_successful(&guild_str) 803 + .await? 804 + .is_some() 805 + { 798 806 return Ok(()); 799 807 } 800 808 801 - let deployment = self 809 + let deployment = Deployment { 810 + guild_id: guild_str.clone(), 811 + entry: DEFAULT_GUILD_ENTRY.to_string(), 812 + files: None, 813 + source_map: Some(default_guild_source_map()), 814 + bundle: DEFAULT_GUILD_BUNDLE.to_string(), 815 + created_at: chrono::Utc::now(), 816 + updated_at: chrono::Utc::now(), 817 + }; 818 + 819 + let previous_success = self 802 820 .deployments 821 + .get_previous_successful_revision(&guild_str) 822 + .await?; 823 + let base_revision_id = previous_success.as_ref().map(|row| row.revision_id); 824 + let base_files = previous_success.as_ref().and_then(|row| row.files.as_ref()); 825 + let change_summary = 826 + DeploymentService::summarize_changes(deployment.files.as_ref(), base_files); 827 + 828 + let deploy_result = self.runtime.deploy_guild_script(deployment.clone()).await; 829 + let status = match &deploy_result { 830 + Ok(_) => DeploymentRevisionStatus::Success, 831 + Err(_) => DeploymentRevisionStatus::Failed, 832 + }; 833 + let error_message = deploy_result.as_ref().err().map(|err| err.to_string()); 834 + 835 + self.deployments 836 + .create_revision(CreateDeploymentRevisionInput { 837 + guild_id: guild_str.clone(), 838 + entry: deployment.entry.clone(), 839 + files: deployment.files.clone(), 840 + bundle: deployment.bundle.clone(), 841 + source_map: deployment.source_map.clone(), 842 + status, 843 + deploy_source: DeploymentSource::Bootstrap, 844 + actor_user_id: None, 845 + actor_username: Some("system".to_string()), 846 + actor_type: DeploymentActorType::System, 847 + error_message, 848 + build_id: None, 849 + base_revision_id, 850 + change_summary, 851 + }) 852 + .await?; 853 + 854 + deploy_result.map_err(|err| eyre!(err.to_string()))?; 855 + 856 + self.deployments 803 857 .upsert_deployment( 804 - guild_str.clone(), 805 - DEFAULT_GUILD_ENTRY.to_string(), 806 - None, 807 - DEFAULT_GUILD_BUNDLE.to_string(), 808 - Some(default_guild_source_map()), 858 + deployment.guild_id, 859 + deployment.entry, 860 + deployment.files, 861 + deployment.bundle, 862 + deployment.source_map, 809 863 ) 810 864 .await?; 811 - self.runtime 812 - .deploy_guild_script(deployment) 813 - .await 814 - .map_err(|err| eyre!(err.to_string()))?; 865 + 815 866 info!(target: "flora:deployments", guild_id = guild_str, "bootstrapped default script"); 816 867 Ok(()) 817 868 } ··· 826 877 discriminator: ready.user.discriminator.map(|d| d.get()), 827 878 bot: ready.user.bot(), 828 879 }, 829 - guild_ids: ready 830 - .guilds 831 - .iter() 832 - .map(|g| g.id.get().to_string()) 833 - .collect(), 880 + // Do not expose full bot guild membership to tenant scripts. 881 + guild_ids: Vec::new(), 834 882 } 835 883 } 836 884 }
+13 -7
apps/runtime/src/handlers/auth.rs
··· 212 212 pub session: Option<Session>, 213 213 } 214 214 215 - /// Resolve caller identity from either a bearer token or a session cookie. 215 + /// Resolve caller identity from session cookie first, then bearer token fallback. 216 + /// 217 + /// Session-first avoids accidental privilege confusion when a proxy injects 218 + /// Authorization headers for browser traffic. 216 219 pub async fn require_identity( 217 220 state: &AppState, 218 221 headers: &HeaderMap, 219 222 ) -> Result<IdentityContext, ApiError> { 223 + if let Ok(session) = require_session(&state.auth, headers).await { 224 + return Ok(IdentityContext { 225 + user_id: session.user.id.clone(), 226 + access_token: Some(session.access_token.clone()), 227 + session: Some(session), 228 + }); 229 + } 230 + 220 231 if let Some(bearer) = bearer_token(headers) 221 232 && let Some(token) = state 222 233 .tokens ··· 231 242 }); 232 243 } 233 244 234 - let session = require_session(&state.auth, headers).await?; 235 - Ok(IdentityContext { 236 - user_id: session.user.id.clone(), 237 - access_token: Some(session.access_token.clone()), 238 - session: Some(session), 239 - }) 245 + Err(ApiError::unauthorized("login required")) 240 246 } 241 247 242 248 /// Ensure the user is an admin or has manage-guild permissions in the target guild.
+103
apps/runtime/src/handlers/deployments/history.rs
··· 1 + use axum::{ 2 + Json, 3 + extract::{Path, Query, State}, 4 + http::HeaderMap, 5 + }; 6 + use chrono::{DateTime, Utc}; 7 + use serde::Deserialize; 8 + use tracing::error; 9 + use uuid::Uuid; 10 + 11 + use super::DeploymentRevisionResponse; 12 + use crate::{ 13 + handlers::{ 14 + auth::{ensure_guild_admin, require_identity}, 15 + error::ApiError, 16 + response::ApiJson, 17 + }, 18 + services::deployments::DeploymentRevisionCursor, 19 + state::AppState, 20 + }; 21 + 22 + #[derive(Debug, Deserialize)] 23 + pub struct DeploymentHistoryQuery { 24 + #[serde(default = "default_history_limit")] 25 + limit: i64, 26 + cursor_deployed_at: Option<String>, 27 + cursor_id: Option<String>, 28 + #[serde(default)] 29 + include_bundle: bool, 30 + } 31 + 32 + fn default_history_limit() -> i64 { 33 + 25 34 + } 35 + 36 + #[utoipa::path( 37 + get, 38 + path = "/{guild_id}/history", 39 + params( 40 + ("guild_id" = String, Path, description = "Discord guild id"), 41 + ("limit" = Option<i64>, Query, description = "Page size, max 100"), 42 + ("cursor_deployed_at" = Option<String>, Query, description = "RFC3339 deployed_at cursor"), 43 + ("cursor_id" = Option<String>, Query, description = "Revision id cursor"), 44 + ("include_bundle" = Option<bool>, Query, description = "Include bundled output in response") 45 + ), 46 + tag = "deployment", 47 + responses( 48 + (status = 200, description = "Revision history", body = [DeploymentRevisionResponse]), 49 + (status = 400, description = "Invalid cursor", body = crate::handlers::error::ErrorResponse), 50 + (status = 500, description = "Internal server error", body = crate::handlers::error::ErrorResponse) 51 + ) 52 + )] 53 + pub async fn list_deployment_history_handler( 54 + Path(guild_id): Path<String>, 55 + State(state): State<AppState>, 56 + headers: HeaderMap, 57 + Query(query): Query<DeploymentHistoryQuery>, 58 + ) -> Result<ApiJson<Vec<DeploymentRevisionResponse>>, ApiError> { 59 + let identity = require_identity(&state, &headers).await?; 60 + ensure_guild_admin(&state, &identity, &guild_id).await?; 61 + 62 + let limit = query.limit.clamp(1, 100); 63 + let cursor = parse_cursor(query.cursor_deployed_at, query.cursor_id)?; 64 + 65 + let revisions = state 66 + .deployments 67 + .list_guild_revisions(&guild_id, limit, cursor) 68 + .await 69 + .map_err(|err| { 70 + error!(target: "flora:api", guild_id, ?err, "failed to list deployment revisions"); 71 + ApiError::internal(err) 72 + })?; 73 + 74 + let mut response = Vec::with_capacity(revisions.len()); 75 + for revision in revisions { 76 + response.push(DeploymentRevisionResponse::from_revision( 77 + revision, 78 + query.include_bundle, 79 + )); 80 + } 81 + 82 + Ok(ApiJson(Json(response))) 83 + } 84 + 85 + fn parse_cursor( 86 + cursor_deployed_at: Option<String>, 87 + cursor_id: Option<String>, 88 + ) -> Result<Option<DeploymentRevisionCursor>, ApiError> { 89 + match (cursor_deployed_at, cursor_id) { 90 + (None, None) => Ok(None), 91 + (Some(_), None) | (None, Some(_)) => Err(ApiError::bad_request( 92 + "cursor_deployed_at and cursor_id must be provided together", 93 + )), 94 + (Some(cursor_deployed_at), Some(cursor_id)) => { 95 + let deployed_at = DateTime::parse_from_rfc3339(&cursor_deployed_at) 96 + .map_err(|_| ApiError::bad_request("cursor_deployed_at must be RFC3339"))? 97 + .with_timezone(&Utc); 98 + let id = Uuid::parse_str(&cursor_id) 99 + .map_err(|_| ApiError::bad_request("cursor_id must be a UUID"))?; 100 + Ok(Some(DeploymentRevisionCursor { deployed_at, id })) 101 + } 102 + } 103 + }
+36 -9
apps/runtime/src/handlers/deployments/mod.rs
··· 1 - use axum::{Router, routing::get}; 1 + use axum::{ 2 + Router, 3 + routing::{get, post}, 4 + }; 2 5 use utoipa::OpenApi; 3 6 4 7 use crate::state::AppState; 5 8 6 - pub mod list; 9 + pub mod history; 7 10 pub mod read; 11 + pub mod revision; 12 + pub mod rollback; 8 13 pub mod upsert; 9 14 10 - pub use list::list_deployments_handler; 15 + pub use history::list_deployment_history_handler; 11 16 pub use read::get_deployment_handler; 12 - pub use upsert::{DeploymentRequest, DeploymentResponse, upsert_deployment_handler}; 17 + pub use revision::get_deployment_revision_handler; 18 + pub use rollback::rollback_deployment_handler; 19 + pub use upsert::{ 20 + DeploymentActorResponse, DeploymentRequest, DeploymentResponse, DeploymentRevisionResponse, 21 + list_deploy_source_values, parse_deploy_source, upsert_deployment_handler, 22 + }; 13 23 14 - /// Deployment API surface. 15 24 #[derive(OpenApi)] 16 25 #[openapi( 17 26 paths( 18 - list::list_deployments_handler, 19 27 read::get_deployment_handler, 20 - upsert::upsert_deployment_handler 28 + upsert::upsert_deployment_handler, 29 + history::list_deployment_history_handler, 30 + revision::get_deployment_revision_handler, 31 + rollback::rollback_deployment_handler 21 32 ), 22 - components(schemas(DeploymentRequest, DeploymentResponse, super::error::ErrorResponse)), 33 + components( 34 + schemas( 35 + DeploymentRequest, 36 + DeploymentResponse, 37 + DeploymentRevisionResponse, 38 + DeploymentActorResponse, 39 + super::error::ErrorResponse 40 + ) 41 + ), 23 42 tags((name = "deployment", description = "Manage per-guild bot deployments")) 24 43 )] 25 44 pub struct DeploymentApi; 26 45 27 46 pub fn router() -> Router<AppState> { 28 47 Router::new() 29 - .route("/", get(list_deployments_handler)) 30 48 .route( 31 49 "/{guild_id}", 32 50 get(get_deployment_handler).post(upsert_deployment_handler), 51 + ) 52 + .route("/{guild_id}/history", get(list_deployment_history_handler)) 53 + .route( 54 + "/{guild_id}/revisions/{revision_id}", 55 + get(get_deployment_revision_handler), 56 + ) 57 + .route( 58 + "/{guild_id}/rollback/{revision_id}", 59 + post(rollback_deployment_handler), 33 60 ) 34 61 }
+3 -4
apps/runtime/src/handlers/deployments/read.rs
··· 22 22 include_bundle: bool, 23 23 } 24 24 25 - /// Fetch a single deployment by guild id. 26 25 #[utoipa::path( 27 26 get, 28 27 path = "/{guild_id}", ··· 44 43 Query(query): Query<DeploymentQuery>, 45 44 ) -> Result<ApiJson<DeploymentResponse>, ApiError> { 46 45 let identity = require_identity(&state, &headers).await?; 46 + ensure_guild_admin(&state, &identity, &guild_id).await?; 47 47 48 48 let deployment = state 49 49 .deployments 50 - .get_deployment(&guild_id) 50 + .get_current_successful(&guild_id) 51 51 .await 52 52 .map_err(|err| { 53 53 error!(target: "flora:api", guild_id, ?err, "failed to fetch deployment"); ··· 58 58 return Err(ApiError::not_found("deployment not found")); 59 59 }; 60 60 61 - ensure_guild_admin(&state, &identity, &guild_id).await?; 62 - 63 61 let files = deployment.files.clone(); 64 62 let source_map = deployment.source_map.clone(); 65 63 let bundle = deployment.bundle.clone(); ··· 69 67 if query.include_bundle { 70 68 response = response.with_bundle(bundle); 71 69 } 70 + 72 71 Ok(ApiJson(Json(response))) 73 72 }
+70
apps/runtime/src/handlers/deployments/revision.rs
··· 1 + use axum::{ 2 + Json, 3 + extract::{Path, Query, State}, 4 + http::HeaderMap, 5 + }; 6 + use serde::Deserialize; 7 + use tracing::error; 8 + use uuid::Uuid; 9 + 10 + use super::DeploymentRevisionResponse; 11 + use crate::{ 12 + handlers::{ 13 + auth::{ensure_guild_admin, require_identity}, 14 + error::ApiError, 15 + response::ApiJson, 16 + }, 17 + state::AppState, 18 + }; 19 + 20 + #[derive(Debug, Deserialize)] 21 + pub struct DeploymentRevisionQuery { 22 + #[serde(default)] 23 + include_bundle: bool, 24 + } 25 + 26 + #[utoipa::path( 27 + get, 28 + path = "/{guild_id}/revisions/{revision_id}", 29 + params( 30 + ("guild_id" = String, Path, description = "Discord guild id"), 31 + ("revision_id" = String, Path, description = "Revision id"), 32 + ("include_bundle" = Option<bool>, Query, description = "Include bundled output in response") 33 + ), 34 + tag = "deployment", 35 + responses( 36 + (status = 200, description = "Revision found", body = DeploymentRevisionResponse), 37 + (status = 404, description = "Revision not found", body = crate::handlers::error::ErrorResponse), 38 + (status = 500, description = "Internal server error", body = crate::handlers::error::ErrorResponse) 39 + ) 40 + )] 41 + pub async fn get_deployment_revision_handler( 42 + Path((guild_id, revision_id)): Path<(String, String)>, 43 + State(state): State<AppState>, 44 + headers: HeaderMap, 45 + Query(query): Query<DeploymentRevisionQuery>, 46 + ) -> Result<ApiJson<DeploymentRevisionResponse>, ApiError> { 47 + let identity = require_identity(&state, &headers).await?; 48 + ensure_guild_admin(&state, &identity, &guild_id).await?; 49 + 50 + let revision_id = 51 + Uuid::parse_str(&revision_id).map_err(|_| ApiError::bad_request("invalid revision id"))?; 52 + 53 + let revision = state 54 + .deployments 55 + .get_guild_revision(&guild_id, revision_id) 56 + .await 57 + .map_err(|err| { 58 + error!(target: "flora:api", guild_id, ?err, "failed to fetch deployment revision"); 59 + ApiError::internal(err) 60 + })?; 61 + 62 + let Some(revision) = revision else { 63 + return Err(ApiError::not_found("deployment revision not found")); 64 + }; 65 + 66 + Ok(ApiJson(Json(DeploymentRevisionResponse::from_revision( 67 + revision, 68 + query.include_bundle, 69 + )))) 70 + }
+155
apps/runtime/src/handlers/deployments/rollback.rs
··· 1 + use axum::{ 2 + Json, 3 + extract::{Path, State}, 4 + http::HeaderMap, 5 + }; 6 + use chrono::Utc; 7 + use tracing::error; 8 + use uuid::Uuid; 9 + 10 + use super::upsert::actor_from_identity; 11 + use super::{DeploymentRevisionResponse, parse_deploy_source}; 12 + use crate::{ 13 + handlers::{ 14 + auth::{ensure_guild_admin, require_identity}, 15 + error::ApiError, 16 + response::ApiJson, 17 + }, 18 + services::deployments::{ 19 + CreateDeploymentRevisionInput, Deployment, DeploymentRevisionStatus, DeploymentService, 20 + DeploymentSource, 21 + }, 22 + state::AppState, 23 + }; 24 + 25 + #[utoipa::path( 26 + post, 27 + path = "/{guild_id}/rollback/{revision_id}", 28 + params( 29 + ("guild_id" = String, Path, description = "Discord guild id"), 30 + ("revision_id" = String, Path, description = "Successful revision id to rollback to") 31 + ), 32 + tag = "deployment", 33 + responses( 34 + (status = 200, description = "Rollback created", body = DeploymentRevisionResponse), 35 + (status = 404, description = "Revision not found", body = crate::handlers::error::ErrorResponse), 36 + (status = 500, description = "Internal server error", body = crate::handlers::error::ErrorResponse) 37 + ) 38 + )] 39 + pub async fn rollback_deployment_handler( 40 + Path((guild_id, revision_id)): Path<(String, String)>, 41 + State(state): State<AppState>, 42 + headers: HeaderMap, 43 + ) -> Result<ApiJson<DeploymentRevisionResponse>, ApiError> { 44 + let identity = require_identity(&state, &headers).await?; 45 + ensure_guild_admin(&state, &identity, &guild_id).await?; 46 + 47 + let revision_id = 48 + Uuid::parse_str(&revision_id).map_err(|_| ApiError::bad_request("invalid revision id"))?; 49 + 50 + let revision = state 51 + .deployments 52 + .get_guild_revision(&guild_id, revision_id) 53 + .await 54 + .map_err(|err| { 55 + error!(target: "flora:api", guild_id, ?err, "failed to fetch rollback revision"); 56 + ApiError::internal(err) 57 + })?; 58 + 59 + let Some(revision) = revision else { 60 + return Err(ApiError::not_found("deployment revision not found")); 61 + }; 62 + 63 + if !matches!(revision.status, DeploymentRevisionStatus::Success) { 64 + return Err(ApiError::bad_request( 65 + "rollback target must be a successful revision", 66 + )); 67 + } 68 + 69 + let source = parse_deploy_source(&headers)?; 70 + let source = match source { 71 + DeploymentSource::Unknown => DeploymentSource::Api, 72 + _ => source, 73 + }; 74 + let actor = actor_from_identity(&identity); 75 + 76 + let previous_success = state 77 + .deployments 78 + .get_previous_successful_revision(&guild_id) 79 + .await 80 + .map_err(|err| { 81 + error!(target: "flora:api", guild_id, ?err, "failed to fetch previous successful revision"); 82 + ApiError::internal(err) 83 + })?; 84 + let base_revision_id = previous_success.as_ref().map(|row| row.revision_id); 85 + let base_files = previous_success.as_ref().and_then(|row| row.files.as_ref()); 86 + 87 + let change_summary = DeploymentService::summarize_changes(revision.files.as_ref(), base_files); 88 + 89 + let now = Utc::now(); 90 + let deployment = Deployment { 91 + guild_id: guild_id.clone(), 92 + entry: revision.entry.clone(), 93 + files: revision.files.clone(), 94 + source_map: revision.source_map.clone(), 95 + bundle: revision.bundle.clone(), 96 + created_at: now, 97 + updated_at: now, 98 + }; 99 + 100 + let deploy_result = state.runtime.deploy_guild_script(deployment.clone()).await; 101 + let status = match &deploy_result { 102 + Ok(_) => DeploymentRevisionStatus::Success, 103 + Err(_) => DeploymentRevisionStatus::Failed, 104 + }; 105 + let error_message = deploy_result.as_ref().err().map(|err| err.to_string()); 106 + 107 + let new_revision = state 108 + .deployments 109 + .create_revision(CreateDeploymentRevisionInput { 110 + guild_id: guild_id.clone(), 111 + entry: deployment.entry.clone(), 112 + files: deployment.files.clone(), 113 + bundle: deployment.bundle.clone(), 114 + source_map: deployment.source_map.clone(), 115 + status, 116 + deploy_source: source, 117 + actor_user_id: actor.user_id, 118 + actor_username: actor.username, 119 + actor_type: actor.actor_type, 120 + error_message, 121 + build_id: None, 122 + base_revision_id, 123 + change_summary, 124 + }) 125 + .await 126 + .map_err(|err| { 127 + error!(target: "flora:api", guild_id, ?err, "failed to create rollback revision"); 128 + ApiError::internal(err) 129 + })?; 130 + 131 + deploy_result.map_err(|err| { 132 + error!(target: "flora:api", guild_id, ?err, "failed to deploy rollback revision"); 133 + ApiError::internal(err) 134 + })?; 135 + 136 + state 137 + .deployments 138 + .upsert_deployment( 139 + deployment.guild_id, 140 + deployment.entry, 141 + deployment.files, 142 + deployment.bundle, 143 + deployment.source_map, 144 + ) 145 + .await 146 + .map_err(|err| { 147 + error!(target: "flora:api", guild_id, ?err, "failed to update deployment snapshot after rollback"); 148 + ApiError::internal(err) 149 + })?; 150 + 151 + Ok(ApiJson(Json(DeploymentRevisionResponse::from_revision( 152 + new_revision, 153 + false, 154 + )))) 155 + }
+208 -30
apps/runtime/src/handlers/deployments/upsert.rs
··· 1 1 use crate::{ 2 2 bundler::{BundleLimits, DeploymentFile, bundle_files}, 3 3 handlers::{ 4 - auth::{ensure_guild_admin, require_identity}, 4 + auth::{IdentityContext, ensure_guild_admin, require_identity}, 5 5 error::ApiError, 6 6 response::ApiJson, 7 7 }, 8 - services::deployments::{Deployment, DeploymentSourceMapFile}, 8 + services::deployments::{ 9 + CreateDeploymentRevisionInput, Deployment, DeploymentActorType, DeploymentChangeSummary, 10 + DeploymentRevision, DeploymentRevisionStatus, DeploymentService, DeploymentSource, 11 + DeploymentSourceMapFile, 12 + }, 9 13 state::AppState, 10 14 }; 11 15 use axum::{ 12 16 Json, 13 17 extract::{Path, State}, 14 - http::HeaderMap, 18 + http::{HeaderMap, header::HeaderName}, 15 19 }; 20 + use chrono::Utc; 16 21 use serde::{Deserialize, Serialize}; 22 + use std::str::FromStr; 17 23 use tracing::error; 18 24 use utoipa::ToSchema; 19 25 20 - /// Body for creating or replacing a deployment. 26 + pub const DEPLOY_SOURCE_HEADER: HeaderName = HeaderName::from_static("x-flora-deploy-source"); 27 + 21 28 #[derive(Debug, Deserialize, Serialize, ToSchema)] 22 29 pub struct DeploymentRequest { 23 - /// Entry point path for the bundle (e.g. src/main.ts). 24 30 pub entry: String, 25 - /// Source files for the deployment. Preferred over raw bundle input. 26 31 #[serde(default, skip_serializing_if = "Option::is_none")] 27 32 pub files: Option<Vec<DeploymentFile>>, 28 - /// Prebuilt JavaScript bundle source (legacy mode). 29 33 #[serde(default, skip_serializing_if = "Option::is_none")] 30 34 pub bundle: Option<String>, 31 - /// Optional source map file for the prebuilt bundle. 32 35 #[serde(default, skip_serializing_if = "Option::is_none")] 33 36 pub source_map: Option<DeploymentSourceMapFile>, 37 + #[serde(default, skip_serializing_if = "Option::is_none")] 38 + pub build_id: Option<String>, 34 39 } 35 40 36 - /// API representation of a deployment. 37 41 #[derive(Debug, Deserialize, Serialize, ToSchema)] 38 42 pub struct DeploymentResponse { 39 43 pub guild_id: String, ··· 48 52 pub bundle: Option<String>, 49 53 } 50 54 55 + #[derive(Debug, Deserialize, Serialize, ToSchema)] 56 + pub struct DeploymentActorResponse { 57 + #[serde(skip_serializing_if = "Option::is_none")] 58 + pub user_id: Option<String>, 59 + #[serde(skip_serializing_if = "Option::is_none")] 60 + pub username: Option<String>, 61 + pub actor_type: DeploymentActorType, 62 + } 63 + 64 + #[derive(Debug, Deserialize, Serialize, ToSchema)] 65 + pub struct DeploymentRevisionResponse { 66 + pub id: String, 67 + pub guild_id: String, 68 + pub entry: String, 69 + pub status: DeploymentRevisionStatus, 70 + pub deployed_at: String, 71 + pub deploy_source: DeploymentSource, 72 + pub actor: DeploymentActorResponse, 73 + #[serde(skip_serializing_if = "Option::is_none")] 74 + pub error_message: Option<String>, 75 + #[serde(skip_serializing_if = "Option::is_none")] 76 + pub build_id: Option<String>, 77 + #[serde(skip_serializing_if = "Option::is_none")] 78 + pub base_revision_id: Option<String>, 79 + #[serde(skip_serializing_if = "Option::is_none")] 80 + pub change_summary: Option<DeploymentChangeSummary>, 81 + #[serde(skip_serializing_if = "Option::is_none")] 82 + pub files: Option<Vec<DeploymentFile>>, 83 + #[serde(skip_serializing_if = "Option::is_none")] 84 + pub source_map: Option<DeploymentSourceMapFile>, 85 + #[serde(skip_serializing_if = "Option::is_none")] 86 + pub bundle: Option<String>, 87 + } 88 + 89 + impl DeploymentRevisionResponse { 90 + pub fn from_revision(revision: DeploymentRevision, include_bundle: bool) -> Self { 91 + let bundle = if include_bundle { 92 + Some(revision.bundle) 93 + } else { 94 + None 95 + }; 96 + 97 + Self { 98 + id: revision.id.to_string(), 99 + guild_id: revision.guild_id, 100 + entry: revision.entry, 101 + status: revision.status, 102 + deployed_at: revision.deployed_at.to_rfc3339(), 103 + deploy_source: revision.deploy_source, 104 + actor: DeploymentActorResponse { 105 + user_id: revision.actor_user_id, 106 + username: revision.actor_username, 107 + actor_type: revision.actor_type, 108 + }, 109 + error_message: revision.error_message, 110 + build_id: revision.build_id, 111 + base_revision_id: revision.base_revision_id.map(|value| value.to_string()), 112 + change_summary: revision.change_summary, 113 + files: revision.files, 114 + source_map: revision.source_map, 115 + bundle, 116 + } 117 + } 118 + } 119 + 51 120 impl From<Deployment> for DeploymentResponse { 52 121 fn from(value: Deployment) -> Self { 53 122 Self { ··· 79 148 } 80 149 } 81 150 151 + #[derive(Debug, Clone)] 152 + pub struct DeploymentActor { 153 + pub user_id: Option<String>, 154 + pub username: Option<String>, 155 + pub actor_type: DeploymentActorType, 156 + } 157 + 158 + pub fn list_deploy_source_values() -> &'static str { 159 + "cli|webui|bootstrap|api|unknown" 160 + } 161 + 162 + pub fn parse_deploy_source(headers: &HeaderMap) -> Result<DeploymentSource, ApiError> { 163 + let source = headers.get(&DEPLOY_SOURCE_HEADER); 164 + let Some(source) = source else { 165 + return Ok(DeploymentSource::Unknown); 166 + }; 167 + 168 + let source = source 169 + .to_str() 170 + .map_err(|_| ApiError::bad_request("x-flora-deploy-source must be ascii"))?; 171 + DeploymentSource::from_str(source).map_err(|_| { 172 + ApiError::bad_request(format!( 173 + "x-flora-deploy-source must be one of {}", 174 + list_deploy_source_values() 175 + )) 176 + }) 177 + } 178 + 179 + pub fn actor_from_identity(identity: &IdentityContext) -> DeploymentActor { 180 + let Some(session) = identity.session.as_ref() else { 181 + return DeploymentActor { 182 + user_id: Some(identity.user_id.clone()), 183 + username: None, 184 + actor_type: DeploymentActorType::Token, 185 + }; 186 + }; 187 + 188 + DeploymentActor { 189 + user_id: Some(identity.user_id.clone()), 190 + username: Some(session.user.username.clone()), 191 + actor_type: DeploymentActorType::Session, 192 + } 193 + } 194 + 82 195 fn validate_request(request: &DeploymentRequest) -> Result<(), ApiError> { 83 196 if request.entry.trim().is_empty() { 84 197 return Err(ApiError::bad_request("entry must not be empty")); ··· 126 239 Ok(()) 127 240 } 128 241 129 - /// Create or update a deployment for a guild. 130 242 #[utoipa::path( 131 243 post, 132 244 path = "/{guild_id}", ··· 151 263 152 264 validate_request(&request)?; 153 265 266 + let deploy_source = parse_deploy_source(&headers)?; 267 + let actor = actor_from_identity(&identity); 268 + 154 269 let files = request.files; 155 270 let (bundle, source_map) = if let Some(bundle) = request.bundle { 156 271 (bundle, request.source_map) ··· 173 288 (bundle, request.source_map) 174 289 }; 175 290 176 - let deployment = state 291 + let previous_success = state 177 292 .deployments 178 - .upsert_deployment(guild_id.clone(), request.entry, files, bundle, source_map) 293 + .get_previous_successful_revision(&guild_id) 179 294 .await 180 295 .map_err(|err| { 181 - error!(target: "flora:api", guild_id, ?err, "failed to upsert deployment"); 296 + error!(target: "flora:api", guild_id, ?err, "failed to fetch previous successful revision"); 182 297 ApiError::internal(err) 183 298 })?; 184 299 300 + let base_revision_id = previous_success.as_ref().map(|row| row.revision_id); 301 + let base_files = previous_success.as_ref().and_then(|row| row.files.as_ref()); 302 + let change_summary = DeploymentService::summarize_changes(files.as_ref(), base_files); 303 + 304 + let now = Utc::now(); 305 + let deployment = Deployment { 306 + guild_id: guild_id.clone(), 307 + entry: request.entry, 308 + files: files.clone(), 309 + source_map: source_map.clone(), 310 + bundle: bundle.clone(), 311 + created_at: now, 312 + updated_at: now, 313 + }; 314 + 315 + let deploy_result = state.runtime.deploy_guild_script(deployment.clone()).await; 316 + let status = match &deploy_result { 317 + Ok(_) => DeploymentRevisionStatus::Success, 318 + Err(_) => DeploymentRevisionStatus::Failed, 319 + }; 320 + let error_message = deploy_result.as_ref().err().map(|err| err.to_string()); 321 + 185 322 state 186 - .runtime 187 - .deploy_guild_script(deployment.clone()) 323 + .deployments 324 + .create_revision(CreateDeploymentRevisionInput { 325 + guild_id: guild_id.clone(), 326 + entry: deployment.entry.clone(), 327 + files: deployment.files.clone(), 328 + bundle: deployment.bundle.clone(), 329 + source_map: deployment.source_map.clone(), 330 + status, 331 + deploy_source, 332 + actor_user_id: actor.user_id, 333 + actor_username: actor.username, 334 + actor_type: actor.actor_type, 335 + error_message, 336 + build_id: request.build_id, 337 + base_revision_id, 338 + change_summary, 339 + }) 188 340 .await 189 341 .map_err(|err| { 190 - error!(target: "flora:api", guild_id, ?err, "failed to deploy guild script"); 342 + error!(target: "flora:api", guild_id, ?err, "failed to create deployment revision"); 343 + ApiError::internal(err) 344 + })?; 345 + 346 + deploy_result.map_err(|err| { 347 + error!(target: "flora:api", guild_id, ?err, "failed to deploy guild script"); 348 + ApiError::internal(err) 349 + })?; 350 + 351 + let deployment = state 352 + .deployments 353 + .upsert_deployment( 354 + deployment.guild_id, 355 + deployment.entry, 356 + deployment.files, 357 + deployment.bundle, 358 + deployment.source_map, 359 + ) 360 + .await 361 + .map_err(|err| { 362 + error!(target: "flora:api", guild_id, ?err, "failed to update deployment snapshot"); 191 363 ApiError::internal(err) 192 364 })?; 193 365 ··· 196 368 197 369 #[cfg(test)] 198 370 mod tests { 199 - use super::{DeploymentRequest, validate_request}; 371 + use super::{DeploymentRequest, parse_deploy_source, validate_request}; 200 372 use crate::{ 201 - bundler::DeploymentFile, handlers::error::ApiError, 202 - services::deployments::DeploymentSourceMapFile, 373 + bundler::DeploymentFile, 374 + handlers::error::ApiError, 375 + services::deployments::{DeploymentSource, DeploymentSourceMapFile}, 203 376 }; 377 + use axum::http::{HeaderMap, HeaderValue}; 204 378 205 379 #[test] 206 380 fn validate_request_rejects_empty_bundle() { ··· 209 383 files: None, 210 384 bundle: Some(" ".to_string()), 211 385 source_map: None, 386 + build_id: None, 212 387 }; 213 388 214 389 let result = validate_request(&request); ··· 225 400 path: "source-map.txt".to_string(), 226 401 contents: "{}".to_string(), 227 402 }), 403 + build_id: None, 228 404 }; 229 405 230 406 let result = validate_request(&request); ··· 241 417 path: "bundle.js.map".to_string(), 242 418 contents: "{}".to_string(), 243 419 }), 420 + build_id: None, 244 421 }; 245 422 246 423 validate_request(&request).expect("request should be valid"); ··· 256 433 }]), 257 434 bundle: None, 258 435 source_map: None, 436 + build_id: None, 259 437 }; 260 438 261 439 validate_request(&request).expect("request should be valid"); 262 440 } 263 441 264 442 #[test] 265 - fn validate_request_accepts_files_with_bundle() { 266 - let request = DeploymentRequest { 267 - entry: "src/main.ts".to_string(), 268 - files: Some(vec![DeploymentFile { 269 - path: "src/main.ts".to_string(), 270 - contents: "export default 1".to_string(), 271 - }]), 272 - bundle: Some("console.log('ok')".to_string()), 273 - source_map: None, 274 - }; 443 + fn parse_deploy_source_defaults_to_unknown() { 444 + let headers = HeaderMap::new(); 445 + let source = parse_deploy_source(&headers).expect("source"); 446 + assert!(matches!(source, DeploymentSource::Unknown)); 447 + } 275 448 276 - validate_request(&request).expect("request should be valid"); 449 + #[test] 450 + fn parse_deploy_source_reads_header() { 451 + let mut headers = HeaderMap::new(); 452 + headers.insert("x-flora-deploy-source", HeaderValue::from_static("cli")); 453 + let source = parse_deploy_source(&headers).expect("source"); 454 + assert!(matches!(source, DeploymentSource::Cli)); 277 455 } 278 456 }
+7 -11
apps/runtime/src/handlers/tokens.rs
··· 8 8 use utoipa::{OpenApi, ToSchema}; 9 9 10 10 use crate::{ 11 - handlers::{ 12 - auth::{IdentityContext, require_identity}, 13 - error::ApiError, 14 - response::ApiJson, 15 - }, 11 + handlers::{auth::require_session, error::ApiError, response::ApiJson}, 16 12 services::tokens::UserToken, 17 13 state::AppState, 18 14 }; ··· 81 77 headers: HeaderMap, 82 78 Json(body): Json<CreateTokenRequest>, 83 79 ) -> Result<ApiJson<CreateTokenResponse>, ApiError> { 84 - let IdentityContext { user_id, .. } = require_identity(&state, &headers).await?; 80 + let session = require_session(&state.auth, &headers).await?; 85 81 let token = state 86 82 .tokens 87 - .create_token(&user_id, body.label) 83 + .create_token(&session.user.id, body.label) 88 84 .await 89 85 .map_err(ApiError::internal)?; 90 86 ··· 105 101 State(state): State<AppState>, 106 102 headers: HeaderMap, 107 103 ) -> Result<ApiJson<Vec<TokenResponse>>, ApiError> { 108 - let IdentityContext { user_id, .. } = require_identity(&state, &headers).await?; 104 + let session = require_session(&state.auth, &headers).await?; 109 105 let tokens = state 110 106 .tokens 111 - .list_tokens(&user_id) 107 + .list_tokens(&session.user.id) 112 108 .await 113 109 .map_err(ApiError::internal)?; 114 110 Ok(ApiJson(Json( ··· 135 131 headers: HeaderMap, 136 132 Path(token_id): Path<String>, 137 133 ) -> Result<ApiJson<()>, ApiError> { 138 - let IdentityContext { user_id, .. } = require_identity(&state, &headers).await?; 134 + let session = require_session(&state.auth, &headers).await?; 139 135 let deleted = state 140 136 .tokens 141 - .delete_token(&user_id, &token_id) 137 + .delete_token(&session.user.id, &token_id) 142 138 .await 143 139 .map_err(ApiError::internal)?; 144 140 if deleted {
+96
apps/runtime/src/ops/authz.rs
··· 1 + use deno_core::OpState; 2 + use deno_error::JsErrorBox; 3 + use serenity::{ 4 + http::Http, 5 + model::{ 6 + channel::Channel, 7 + id::{ChannelId, GuildId, ThreadId, WebhookId}, 8 + }, 9 + }; 10 + 11 + pub fn ensure_guild_scope(state: &OpState, guild_id: GuildId) -> Result<(), JsErrorBox> { 12 + let runtime_guild_id = runtime_guild_id_from_state(state)?; 13 + if runtime_guild_id != guild_id { 14 + return Err(JsErrorBox::generic( 15 + "Forbidden: guild is outside runtime scope", 16 + )); 17 + } 18 + 19 + Ok(()) 20 + } 21 + 22 + pub fn runtime_guild_id_from_state(state: &OpState) -> Result<GuildId, JsErrorBox> { 23 + runtime_guild_id(state) 24 + } 25 + 26 + pub async fn ensure_channel_scope( 27 + runtime_guild_id: GuildId, 28 + http: &Http, 29 + channel_id: ChannelId, 30 + ) -> Result<(), JsErrorBox> { 31 + let channel = http 32 + .get_channel(channel_id.widen()) 33 + .await 34 + .map_err(|err| JsErrorBox::generic(err.to_string()))?; 35 + 36 + let Some(channel_guild_id) = channel_guild_id(channel) else { 37 + return Err(JsErrorBox::generic( 38 + "Forbidden: channel is not a guild channel", 39 + )); 40 + }; 41 + 42 + if channel_guild_id != runtime_guild_id { 43 + return Err(JsErrorBox::generic( 44 + "Forbidden: channel is outside runtime scope", 45 + )); 46 + } 47 + 48 + Ok(()) 49 + } 50 + 51 + pub async fn ensure_thread_scope( 52 + runtime_guild_id: GuildId, 53 + http: &Http, 54 + thread_id: ThreadId, 55 + ) -> Result<(), JsErrorBox> { 56 + ensure_channel_scope(runtime_guild_id, http, ChannelId::new(thread_id.get())).await 57 + } 58 + 59 + pub async fn ensure_webhook_scope( 60 + runtime_guild_id: GuildId, 61 + http: &Http, 62 + webhook_id: WebhookId, 63 + ) -> Result<(), JsErrorBox> { 64 + let webhook = http 65 + .get_webhook(webhook_id) 66 + .await 67 + .map_err(|err| JsErrorBox::generic(err.to_string()))?; 68 + 69 + let Some(webhook_guild_id) = webhook.guild_id else { 70 + return Err(JsErrorBox::generic( 71 + "Forbidden: webhook is not owned by a guild", 72 + )); 73 + }; 74 + 75 + if webhook_guild_id != runtime_guild_id { 76 + return Err(JsErrorBox::generic( 77 + "Forbidden: webhook is outside runtime scope", 78 + )); 79 + } 80 + 81 + Ok(()) 82 + } 83 + 84 + fn runtime_guild_id(state: &OpState) -> Result<GuildId, JsErrorBox> { 85 + let runtime_guild_id = state 86 + .try_borrow::<String>() 87 + .ok_or_else(|| JsErrorBox::generic("guild context not available"))?; 88 + runtime_guild_id 89 + .parse::<u64>() 90 + .map(GuildId::new) 91 + .map_err(|_| JsErrorBox::generic("invalid runtime guild id")) 92 + } 93 + 94 + fn channel_guild_id(channel: Channel) -> Option<GuildId> { 95 + channel.guild_id() 96 + }
+47
apps/runtime/src/ops/channels.rs
··· 1 + use super::authz::{ 2 + ensure_channel_scope, ensure_guild_scope, ensure_thread_scope, runtime_guild_id_from_state, 3 + }; 1 4 use deno_core::{OpState, op2}; 2 5 use deno_error::JsErrorBox; 3 6 use flora_macros::expose_input; ··· 30 33 state.borrow::<Arc<Http>>().clone() 31 34 }; 32 35 let guild_id = parse_guild_id(&args.guild_id)?; 36 + { 37 + let state = state.borrow(); 38 + ensure_guild_scope(&state, guild_id)?; 39 + } 33 40 let channel = http 34 41 .create_channel(guild_id, &args.payload, args.reason.as_deref()) 35 42 .await ··· 59 66 state.borrow::<Arc<Http>>().clone() 60 67 }; 61 68 let channel_id = parse_channel_id(&args.channel_id)?; 69 + let runtime_guild_id = { 70 + let state = state.borrow(); 71 + runtime_guild_id_from_state(&state)? 72 + }; 73 + ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 62 74 let channel = http 63 75 .edit_channel(channel_id.widen(), &args.payload, args.reason.as_deref()) 64 76 .await ··· 86 98 state.borrow::<Arc<Http>>().clone() 87 99 }; 88 100 let channel_id = parse_channel_id(&args.channel_id)?; 101 + let runtime_guild_id = { 102 + let state = state.borrow(); 103 + runtime_guild_id_from_state(&state)? 104 + }; 105 + ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 89 106 let channel = http 90 107 .delete_channel(channel_id.widen(), args.reason.as_deref()) 91 108 .await ··· 115 132 state.borrow::<Arc<Http>>().clone() 116 133 }; 117 134 let channel_id = parse_channel_id(&args.channel_id)?; 135 + let runtime_guild_id = { 136 + let state = state.borrow(); 137 + runtime_guild_id_from_state(&state)? 138 + }; 139 + ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 118 140 let thread = http 119 141 .create_thread(channel_id, &args.payload, args.reason.as_deref()) 120 142 .await ··· 146 168 state.borrow::<Arc<Http>>().clone() 147 169 }; 148 170 let channel_id = parse_channel_id(&args.channel_id)?; 171 + let runtime_guild_id = { 172 + let state = state.borrow(); 173 + runtime_guild_id_from_state(&state)? 174 + }; 175 + ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 149 176 let message_id = parse_message_id(&args.message_id)?; 150 177 let thread = http 151 178 .create_thread_from_message( ··· 176 203 state.borrow::<Arc<Http>>().clone() 177 204 }; 178 205 let thread_id = parse_thread_id(&args.thread_id)?; 206 + let runtime_guild_id = { 207 + let state = state.borrow(); 208 + runtime_guild_id_from_state(&state)? 209 + }; 210 + ensure_thread_scope(runtime_guild_id, &http, thread_id).await?; 179 211 http.join_thread_channel(thread_id) 180 212 .await 181 213 .map_err(|err| JsErrorBox::generic(err.to_string()))?; ··· 192 224 state.borrow::<Arc<Http>>().clone() 193 225 }; 194 226 let thread_id = parse_thread_id(&args.thread_id)?; 227 + let runtime_guild_id = { 228 + let state = state.borrow(); 229 + runtime_guild_id_from_state(&state)? 230 + }; 231 + ensure_thread_scope(runtime_guild_id, &http, thread_id).await?; 195 232 http.leave_thread_channel(thread_id) 196 233 .await 197 234 .map_err(|err| JsErrorBox::generic(err.to_string()))?; ··· 217 254 state.borrow::<Arc<Http>>().clone() 218 255 }; 219 256 let thread_id = parse_thread_id(&args.thread_id)?; 257 + let runtime_guild_id = { 258 + let state = state.borrow(); 259 + runtime_guild_id_from_state(&state)? 260 + }; 261 + ensure_thread_scope(runtime_guild_id, &http, thread_id).await?; 220 262 let user_id = parse_user_id(&args.user_id)?; 221 263 http.add_thread_channel_member(thread_id, user_id) 222 264 .await ··· 234 276 state.borrow::<Arc<Http>>().clone() 235 277 }; 236 278 let thread_id = parse_thread_id(&args.thread_id)?; 279 + let runtime_guild_id = { 280 + let state = state.borrow(); 281 + runtime_guild_id_from_state(&state)? 282 + }; 283 + ensure_thread_scope(runtime_guild_id, &http, thread_id).await?; 237 284 let user_id = parse_user_id(&args.user_id)?; 238 285 http.remove_thread_channel_member(thread_id, user_id) 239 286 .await
+36 -1
apps/runtime/src/ops/commands.rs
··· 1 - use super::interaction::{RawSlashCommand, RawSlashCommandOption}; 1 + use super::{ 2 + authz::ensure_guild_scope, 3 + interaction::{RawSlashCommand, RawSlashCommandOption}, 4 + }; 2 5 use deno_core::{OpState, op2}; 3 6 use deno_error::JsErrorBox; 4 7 use flora_macros::expose_input; ··· 30 33 state.borrow::<Arc<Http>>().clone() 31 34 }; 32 35 let guild_id = parse_guild_id(&args.guild_id)?; 36 + { 37 + let state = state.borrow(); 38 + ensure_guild_scope(&state, guild_id)?; 39 + } 33 40 let command = build_command(args.command)?; 34 41 let created = http 35 42 .create_guild_command(guild_id, &command) ··· 60 67 state.borrow::<Arc<Http>>().clone() 61 68 }; 62 69 let guild_id = parse_guild_id(&args.guild_id)?; 70 + { 71 + let state = state.borrow(); 72 + ensure_guild_scope(&state, guild_id)?; 73 + } 63 74 let command_id = parse_command_id(&args.command_id)?; 64 75 let command = build_command(args.command)?; 65 76 let updated = http ··· 88 99 state.borrow::<Arc<Http>>().clone() 89 100 }; 90 101 let guild_id = parse_guild_id(&args.guild_id)?; 102 + { 103 + let state = state.borrow(); 104 + ensure_guild_scope(&state, guild_id)?; 105 + } 91 106 let command_id = parse_command_id(&args.command_id)?; 92 107 http.delete_guild_command(guild_id, command_id) 93 108 .await ··· 115 130 state.borrow::<Arc<Http>>().clone() 116 131 }; 117 132 let guild_id = parse_guild_id(&args.guild_id)?; 133 + { 134 + let state = state.borrow(); 135 + ensure_guild_scope(&state, guild_id)?; 136 + } 118 137 let commands = http 119 138 .get_guild_commands(guild_id) 120 139 .await ··· 136 155 state.borrow::<Arc<Http>>().clone() 137 156 }; 138 157 let guild_id = parse_guild_id(&args.guild_id)?; 158 + { 159 + let state = state.borrow(); 160 + ensure_guild_scope(&state, guild_id)?; 161 + } 139 162 let command_id = parse_command_id(&args.command_id)?; 140 163 let command = http 141 164 .get_guild_command(guild_id, command_id) ··· 166 189 state.borrow::<Arc<Http>>().clone() 167 190 }; 168 191 let guild_id = parse_guild_id(&args.guild_id)?; 192 + { 193 + let state = state.borrow(); 194 + ensure_guild_scope(&state, guild_id)?; 195 + } 169 196 let command_id = parse_command_id(&args.command_id)?; 170 197 let permissions = http 171 198 .edit_guild_command_permissions(guild_id, command_id, &args.permissions) ··· 192 219 state.borrow::<Arc<Http>>().clone() 193 220 }; 194 221 let guild_id = parse_guild_id(&args.guild_id)?; 222 + { 223 + let state = state.borrow(); 224 + ensure_guild_scope(&state, guild_id)?; 225 + } 195 226 let command_id = parse_command_id(&args.command_id)?; 196 227 let permissions = http 197 228 .get_guild_command_permissions(guild_id, command_id) ··· 211 242 state.borrow::<Arc<Http>>().clone() 212 243 }; 213 244 let guild_id = parse_guild_id(&args.guild_id)?; 245 + { 246 + let state = state.borrow(); 247 + ensure_guild_scope(&state, guild_id)?; 248 + } 214 249 let permissions = http 215 250 .get_guild_commands_permissions(guild_id) 216 251 .await
+25
apps/runtime/src/ops/guilds.rs
··· 1 + use super::authz::ensure_guild_scope; 1 2 use deno_core::{OpState, op2}; 2 3 use deno_error::JsErrorBox; 3 4 use flora_macros::expose_input; ··· 29 30 state.borrow::<Arc<Http>>().clone() 30 31 }; 31 32 let guild_id = parse_guild_id(&args.guild_id)?; 33 + { 34 + let state = state.borrow(); 35 + ensure_guild_scope(&state, guild_id)?; 36 + } 32 37 let user_id = parse_user_id(&args.user_id)?; 33 38 http.kick_member(guild_id, user_id, args.reason.as_deref()) 34 39 .await ··· 59 64 state.borrow::<Arc<Http>>().clone() 60 65 }; 61 66 let guild_id = parse_guild_id(&args.guild_id)?; 67 + { 68 + let state = state.borrow(); 69 + ensure_guild_scope(&state, guild_id)?; 70 + } 62 71 let user_id = parse_user_id(&args.user_id)?; 63 72 let delete_seconds = args.delete_message_seconds.unwrap_or(0); 64 73 http.ban_user(guild_id, user_id, delete_seconds, args.reason.as_deref()) ··· 77 86 state.borrow::<Arc<Http>>().clone() 78 87 }; 79 88 let guild_id = parse_guild_id(&args.guild_id)?; 89 + { 90 + let state = state.borrow(); 91 + ensure_guild_scope(&state, guild_id)?; 92 + } 80 93 let user_id = parse_user_id(&args.user_id)?; 81 94 http.remove_ban(guild_id, user_id, args.reason.as_deref()) 82 95 .await ··· 107 120 state.borrow::<Arc<Http>>().clone() 108 121 }; 109 122 let guild_id = parse_guild_id(&args.guild_id)?; 123 + { 124 + let state = state.borrow(); 125 + ensure_guild_scope(&state, guild_id)?; 126 + } 110 127 let user_id = parse_user_id(&args.user_id)?; 111 128 let role_id = parse_role_id(&args.role_id)?; 112 129 http.add_member_role(guild_id, user_id, role_id, args.reason.as_deref()) ··· 125 142 state.borrow::<Arc<Http>>().clone() 126 143 }; 127 144 let guild_id = parse_guild_id(&args.guild_id)?; 145 + { 146 + let state = state.borrow(); 147 + ensure_guild_scope(&state, guild_id)?; 148 + } 128 149 let user_id = parse_user_id(&args.user_id)?; 129 150 let role_id = parse_role_id(&args.role_id)?; 130 151 http.remove_member_role(guild_id, user_id, role_id, args.reason.as_deref()) ··· 157 178 state.borrow::<Arc<Http>>().clone() 158 179 }; 159 180 let guild_id = parse_guild_id(&args.guild_id)?; 181 + { 182 + let state = state.borrow(); 183 + ensure_guild_scope(&state, guild_id)?; 184 + } 160 185 let user_id = parse_user_id(&args.user_id)?; 161 186 let member = http 162 187 .edit_member(guild_id, user_id, &args.payload, args.reason.as_deref())
+5 -1
apps/runtime/src/ops/interaction.rs
··· 21 21 use t0x::T0x; 22 22 use tracing::info; 23 23 24 - use super::components::parse_components; 25 24 use super::message::{ 26 25 RawAllowedMentions, RawAttachment, RawEmbed, build_allowed_mentions, build_attachment, 27 26 build_embed, 28 27 }; 28 + use super::{authz::ensure_guild_scope, components::parse_components}; 29 29 30 30 /// Arguments for sending an initial interaction response. 31 31 #[expose_input] ··· 417 417 .guild_id 418 418 .parse::<u64>() 419 419 .map_err(|_| JsErrorBox::generic("Invalid guild id"))?; 420 + { 421 + let state = state.borrow(); 422 + ensure_guild_scope(&state, serenity::model::id::GuildId::new(guild_id))?; 423 + } 420 424 421 425 let names: Vec<String> = command_defs.iter().map(|c| c.name.clone()).collect(); 422 426 let commands: Vec<CreateCommand<'static>> = command_defs
+64 -1
apps/runtime/src/ops/message.rs
··· 20 20 use tracing::info; 21 21 use url::Url; 22 22 23 - use super::components::parse_components; 23 + use super::{ 24 + authz::{ensure_channel_scope, runtime_guild_id_from_state}, 25 + components::parse_components, 26 + }; 24 27 25 28 /// Attachment to include in a message (either URL or base64-encoded data). 26 29 #[derive(Debug, Deserialize, T0x)] ··· 214 217 .parse::<u64>() 215 218 .map_err(|_| JsErrorBox::generic("Invalid channel id"))?; 216 219 let channel_id = ChannelId::new(channel_id_num); 220 + let runtime_guild_id = { 221 + let state = state.borrow(); 222 + runtime_guild_id_from_state(&state)? 223 + }; 224 + ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 217 225 let reply_to = args.reply_to.or(args.message_id); 218 226 tracing::info!( 219 227 target: "flora:ops", ··· 312 320 .parse::<u64>() 313 321 .map_err(|_| JsErrorBox::generic("Invalid message id"))?; 314 322 let channel_id = ChannelId::new(channel_id_num); 323 + let runtime_guild_id = { 324 + let state = state.borrow(); 325 + runtime_guild_id_from_state(&state)? 326 + }; 327 + ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 315 328 let message_id = MessageId::new(message_id_num); 316 329 317 330 let mut message = serenity::builder::EditMessage::new(); ··· 380 393 state.borrow::<Arc<Http>>().clone() 381 394 }; 382 395 let channel_id = parse_channel_id(&args.channel_id)?; 396 + let runtime_guild_id = { 397 + let state = state.borrow(); 398 + runtime_guild_id_from_state(&state)? 399 + }; 400 + ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 383 401 let message_id = parse_message_id(&args.message_id)?; 384 402 channel_id 385 403 .widen() ··· 408 426 state.borrow::<Arc<Http>>().clone() 409 427 }; 410 428 let channel_id = parse_channel_id(&args.channel_id)?; 429 + let runtime_guild_id = { 430 + let state = state.borrow(); 431 + runtime_guild_id_from_state(&state)? 432 + }; 433 + ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 411 434 let message_ids = args 412 435 .message_ids 413 436 .into_iter() ··· 440 463 state.borrow::<Arc<Http>>().clone() 441 464 }; 442 465 let channel_id = parse_channel_id(&args.channel_id)?; 466 + let runtime_guild_id = { 467 + let state = state.borrow(); 468 + runtime_guild_id_from_state(&state)? 469 + }; 470 + ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 443 471 let message_id = parse_message_id(&args.message_id)?; 444 472 channel_id 445 473 .widen() ··· 459 487 state.borrow::<Arc<Http>>().clone() 460 488 }; 461 489 let channel_id = parse_channel_id(&args.channel_id)?; 490 + let runtime_guild_id = { 491 + let state = state.borrow(); 492 + runtime_guild_id_from_state(&state)? 493 + }; 494 + ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 462 495 let message_id = parse_message_id(&args.message_id)?; 463 496 channel_id 464 497 .widen() ··· 488 521 state.borrow::<Arc<Http>>().clone() 489 522 }; 490 523 let channel_id = parse_channel_id(&args.channel_id)?; 524 + let runtime_guild_id = { 525 + let state = state.borrow(); 526 + runtime_guild_id_from_state(&state)? 527 + }; 528 + ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 491 529 let message_id = parse_message_id(&args.message_id)?; 492 530 let message = channel_id 493 531 .crosspost(&http, message_id) ··· 516 554 state.borrow::<Arc<Http>>().clone() 517 555 }; 518 556 let channel_id = parse_channel_id(&args.channel_id)?; 557 + let runtime_guild_id = { 558 + let state = state.borrow(); 559 + runtime_guild_id_from_state(&state)? 560 + }; 561 + ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 519 562 let message_id = parse_message_id(&args.message_id)?; 520 563 let message = http 521 564 .get_message(channel_id.widen(), message_id) ··· 550 593 state.borrow::<Arc<Http>>().clone() 551 594 }; 552 595 let channel_id = parse_channel_id(&args.channel_id)?; 596 + let runtime_guild_id = { 597 + let state = state.borrow(); 598 + runtime_guild_id_from_state(&state)? 599 + }; 600 + ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 553 601 let mut builder = GetMessages::new(); 554 602 if let Some(limit) = args.limit { 555 603 builder = builder.limit(limit); ··· 597 645 state.borrow::<Arc<Http>>().clone() 598 646 }; 599 647 let channel_id = parse_channel_id(&args.channel_id)?; 648 + let runtime_guild_id = { 649 + let state = state.borrow(); 650 + runtime_guild_id_from_state(&state)? 651 + }; 652 + ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 600 653 let message_id = parse_message_id(&args.message_id)?; 601 654 let reaction = parse_reaction(&args.emoji)?; 602 655 channel_id ··· 617 670 state.borrow::<Arc<Http>>().clone() 618 671 }; 619 672 let channel_id = parse_channel_id(&args.channel_id)?; 673 + let runtime_guild_id = { 674 + let state = state.borrow(); 675 + runtime_guild_id_from_state(&state)? 676 + }; 677 + ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 620 678 let message_id = parse_message_id(&args.message_id)?; 621 679 let reaction = parse_reaction(&args.emoji)?; 622 680 let user_id = if let Some(id) = args.user_id { ··· 656 714 state.borrow::<Arc<Http>>().clone() 657 715 }; 658 716 let channel_id = parse_channel_id(&args.channel_id)?; 717 + let runtime_guild_id = { 718 + let state = state.borrow(); 719 + runtime_guild_id_from_state(&state)? 720 + }; 721 + ensure_channel_scope(runtime_guild_id, &http, channel_id).await?; 659 722 let message_id = parse_message_id(&args.message_id)?; 660 723 if let Some(emoji) = args.emoji { 661 724 let reaction = parse_reaction(&emoji)?;
+1
apps/runtime/src/ops/mod.rs
··· 2 2 use serenity::http::Http; 3 3 use std::sync::Arc; 4 4 5 + mod authz; 5 6 pub mod channels; 6 7 pub mod commands; 7 8 pub mod components;
+22 -1
apps/runtime/src/ops/webhooks.rs
··· 1 - use super::components::parse_components; 2 1 use super::message::{ 3 2 RawAllowedMentions, RawAttachment, RawEmbed, build_allowed_mentions, build_attachment, 4 3 build_embed, 4 + }; 5 + use super::{ 6 + authz::{ensure_thread_scope, ensure_webhook_scope, runtime_guild_id_from_state}, 7 + components::parse_components, 5 8 }; 6 9 use deno_core::{OpState, op2}; 7 10 use deno_error::JsErrorBox; ··· 60 63 state.borrow::<Arc<Http>>().clone() 61 64 }; 62 65 let webhook_id = parse_webhook_id(&args.webhook_id)?; 66 + let runtime_guild_id = { 67 + let state = state.borrow(); 68 + runtime_guild_id_from_state(&state)? 69 + }; 70 + ensure_webhook_scope(runtime_guild_id, &http, webhook_id).await?; 63 71 let thread_id = match &args.thread_id { 64 72 Some(id) => Some(parse_thread_id(id)?), 65 73 None => None, 66 74 }; 75 + if let Some(thread_id) = thread_id { 76 + ensure_thread_scope(runtime_guild_id, &http, thread_id).await?; 77 + } 67 78 let wait = args.wait.unwrap_or(false); 68 79 let with_components = args.with_components.unwrap_or(false); 69 80 ··· 174 185 state.borrow::<Arc<Http>>().clone() 175 186 }; 176 187 let webhook_id = parse_webhook_id(&args.webhook_id)?; 188 + let runtime_guild_id = { 189 + let state = state.borrow(); 190 + runtime_guild_id_from_state(&state)? 191 + }; 192 + ensure_webhook_scope(runtime_guild_id, &http, webhook_id).await?; 177 193 let webhook = if let Some(token) = args.token { 178 194 http.edit_webhook_with_token(webhook_id, &token, &args.payload, args.reason.as_deref()) 179 195 .await ··· 206 222 state.borrow::<Arc<Http>>().clone() 207 223 }; 208 224 let webhook_id = parse_webhook_id(&args.webhook_id)?; 225 + let runtime_guild_id = { 226 + let state = state.borrow(); 227 + runtime_guild_id_from_state(&state)? 228 + }; 229 + ensure_webhook_scope(runtime_guild_id, &http, webhook_id).await?; 209 230 if let Some(token) = args.token { 210 231 http.delete_webhook_with_token(webhook_id, &token, args.reason.as_deref()) 211 232 .await
+106 -5
apps/runtime/src/runtime/secrets.rs
··· 1 1 use crate::services::secrets::SecretsRuntimeData; 2 2 use deno_error::JsErrorBox; 3 3 use std::{cell::RefCell, sync::Arc}; 4 + use url::{Host, Url}; 4 5 5 6 thread_local! { 6 7 static CURRENT_SECRETS: RefCell<Option<Arc<SecretsRuntimeData>>> = const { RefCell::new(None) }; ··· 92 93 if allowlist.is_empty() { 93 94 return true; 94 95 } 95 - let Some(host) = host else { 96 + 97 + let Some(host) = host.and_then(normalize_host) else { 96 98 return false; 97 99 }; 100 + 98 101 allowlist.iter().any(|pattern| { 99 - if let Some(suffix) = pattern.strip_prefix("*.") { 100 - host.ends_with(suffix) 101 - } else { 102 - host == pattern 102 + let Some(pattern) = parse_allowed_pattern(pattern) else { 103 + return false; 104 + }; 105 + 106 + match pattern { 107 + AllowedHostPattern::Exact(allowed) => host == allowed, 108 + AllowedHostPattern::WildcardSuffix(suffix) => { 109 + host == suffix || host.ends_with(&format!(".{suffix}")) 110 + } 103 111 } 104 112 }) 105 113 } 114 + 115 + #[derive(Debug, Clone, PartialEq, Eq)] 116 + enum AllowedHostPattern { 117 + Exact(String), 118 + WildcardSuffix(String), 119 + } 120 + 121 + fn parse_allowed_pattern(pattern: &str) -> Option<AllowedHostPattern> { 122 + let pattern = pattern.trim(); 123 + if pattern.is_empty() { 124 + return None; 125 + } 126 + 127 + if let Some(suffix) = pattern.strip_prefix("*.") { 128 + let suffix = parse_host_from_pattern(suffix)?; 129 + return Some(AllowedHostPattern::WildcardSuffix(suffix)); 130 + } 131 + 132 + let exact = parse_host_from_pattern(pattern)?; 133 + Some(AllowedHostPattern::Exact(exact)) 134 + } 135 + 136 + fn parse_host_from_pattern(pattern: &str) -> Option<String> { 137 + if let Some(host) = Url::parse(pattern) 138 + .ok() 139 + .and_then(|url| url.host_str().map(str::to_owned)) 140 + { 141 + return normalize_host(&host); 142 + } 143 + 144 + if let Some(host) = normalize_host(pattern) { 145 + return Some(host); 146 + } 147 + 148 + let candidate = format!("https://{pattern}"); 149 + Url::parse(&candidate) 150 + .ok() 151 + .and_then(|url| url.host_str().map(str::to_owned)) 152 + .and_then(|host| normalize_host(&host)) 153 + } 154 + 155 + fn normalize_host(host: &str) -> Option<String> { 156 + Host::parse(host.trim()).ok().map(|host| host.to_string()) 157 + } 158 + 159 + #[cfg(test)] 160 + mod tests { 161 + use super::host_allowed; 162 + 163 + #[test] 164 + fn allows_exact_hosts() { 165 + assert!(host_allowed( 166 + Some("api.example.com"), 167 + &["api.example.com".to_string()] 168 + )); 169 + assert!(!host_allowed( 170 + Some("api.example.com"), 171 + &["other.example.com".to_string()] 172 + )); 173 + } 174 + 175 + #[test] 176 + fn wildcard_matches_subdomain_or_root_only() { 177 + assert!(host_allowed( 178 + Some("api.example.com"), 179 + &["*.example.com".to_string()] 180 + )); 181 + assert!(host_allowed( 182 + Some("example.com"), 183 + &["*.example.com".to_string()] 184 + )); 185 + assert!(!host_allowed( 186 + Some("evil-example.com"), 187 + &["*.example.com".to_string()] 188 + )); 189 + assert!(!host_allowed( 190 + Some("notexample.com"), 191 + &["*.example.com".to_string()] 192 + )); 193 + } 194 + 195 + #[test] 196 + fn parses_urls_in_allowlist() { 197 + assert!(host_allowed( 198 + Some("api.example.com"), 199 + &["https://api.example.com/path".to_string()] 200 + )); 201 + assert!(!host_allowed( 202 + Some("other.com"), 203 + &["https://api.example.com/path".to_string()] 204 + )); 205 + } 206 + }
+459 -10
apps/runtime/src/services/deployments/mod.rs
··· 1 1 use chrono::{DateTime, Utc}; 2 - use color_eyre::eyre::Result; 2 + use color_eyre::eyre::{Result, eyre}; 3 3 use fred::{prelude::*, types::ConnectHandle}; 4 4 use serde::{Deserialize, Serialize}; 5 5 use sqlx::{FromRow, Pool, Postgres}; 6 - use std::sync::Arc; 6 + use std::{collections::HashMap, str::FromStr, sync::Arc}; 7 7 use tracing::{info, warn}; 8 8 use utoipa::ToSchema; 9 + use uuid::Uuid; 9 10 10 11 use crate::bundler::DeploymentFile; 11 12 ··· 15 16 pub contents: String, 16 17 } 17 18 19 + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] 20 + pub struct DeploymentChangeSummary { 21 + pub added_files: usize, 22 + pub removed_files: usize, 23 + pub modified_files: usize, 24 + } 25 + 26 + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] 27 + #[serde(rename_all = "snake_case")] 28 + pub enum DeploymentRevisionStatus { 29 + Success, 30 + Failed, 31 + } 32 + 33 + impl DeploymentRevisionStatus { 34 + pub fn as_str(&self) -> &'static str { 35 + match self { 36 + Self::Success => "success", 37 + Self::Failed => "failed", 38 + } 39 + } 40 + } 41 + 42 + impl FromStr for DeploymentRevisionStatus { 43 + type Err = color_eyre::eyre::Error; 44 + 45 + fn from_str(value: &str) -> Result<Self> { 46 + match value { 47 + "success" => Ok(Self::Success), 48 + "failed" => Ok(Self::Failed), 49 + _ => Err(eyre!("invalid deployment revision status: {value}")), 50 + } 51 + } 52 + } 53 + 54 + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] 55 + #[serde(rename_all = "snake_case")] 56 + pub enum DeploymentSource { 57 + Cli, 58 + Webui, 59 + Bootstrap, 60 + Api, 61 + Unknown, 62 + } 63 + 64 + impl DeploymentSource { 65 + pub fn as_str(&self) -> &'static str { 66 + match self { 67 + Self::Cli => "cli", 68 + Self::Webui => "webui", 69 + Self::Bootstrap => "bootstrap", 70 + Self::Api => "api", 71 + Self::Unknown => "unknown", 72 + } 73 + } 74 + } 75 + 76 + impl FromStr for DeploymentSource { 77 + type Err = color_eyre::eyre::Error; 78 + 79 + fn from_str(value: &str) -> Result<Self> { 80 + match value { 81 + "cli" => Ok(Self::Cli), 82 + "webui" => Ok(Self::Webui), 83 + "bootstrap" => Ok(Self::Bootstrap), 84 + "api" => Ok(Self::Api), 85 + "unknown" => Ok(Self::Unknown), 86 + _ => Err(eyre!("invalid deployment source: {value}")), 87 + } 88 + } 89 + } 90 + 91 + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] 92 + #[serde(rename_all = "snake_case")] 93 + pub enum DeploymentActorType { 94 + Session, 95 + Token, 96 + System, 97 + } 98 + 99 + impl DeploymentActorType { 100 + pub fn as_str(&self) -> &'static str { 101 + match self { 102 + Self::Session => "session", 103 + Self::Token => "token", 104 + Self::System => "system", 105 + } 106 + } 107 + } 108 + 109 + impl FromStr for DeploymentActorType { 110 + type Err = color_eyre::eyre::Error; 111 + 112 + fn from_str(value: &str) -> Result<Self> { 113 + match value { 114 + "session" => Ok(Self::Session), 115 + "token" => Ok(Self::Token), 116 + "system" => Ok(Self::System), 117 + _ => Err(eyre!("invalid deployment actor type: {value}")), 118 + } 119 + } 120 + } 121 + 122 + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] 123 + pub struct DeploymentRevision { 124 + pub id: Uuid, 125 + pub guild_id: String, 126 + pub entry: String, 127 + #[serde(default, skip_serializing_if = "Option::is_none")] 128 + pub files: Option<Vec<DeploymentFile>>, 129 + pub bundle: String, 130 + #[serde(default, skip_serializing_if = "Option::is_none")] 131 + pub source_map: Option<DeploymentSourceMapFile>, 132 + pub status: DeploymentRevisionStatus, 133 + pub deployed_at: DateTime<Utc>, 134 + pub deploy_source: DeploymentSource, 135 + #[serde(default, skip_serializing_if = "Option::is_none")] 136 + pub actor_user_id: Option<String>, 137 + #[serde(default, skip_serializing_if = "Option::is_none")] 138 + pub actor_username: Option<String>, 139 + pub actor_type: DeploymentActorType, 140 + #[serde(default, skip_serializing_if = "Option::is_none")] 141 + pub error_message: Option<String>, 142 + #[serde(default, skip_serializing_if = "Option::is_none")] 143 + pub build_id: Option<String>, 144 + #[serde(default, skip_serializing_if = "Option::is_none")] 145 + pub base_revision_id: Option<Uuid>, 146 + #[serde(default, skip_serializing_if = "Option::is_none")] 147 + pub change_summary: Option<DeploymentChangeSummary>, 148 + } 149 + 150 + #[derive(Debug, Clone)] 151 + pub struct CreateDeploymentRevisionInput { 152 + pub guild_id: String, 153 + pub entry: String, 154 + pub files: Option<Vec<DeploymentFile>>, 155 + pub bundle: String, 156 + pub source_map: Option<DeploymentSourceMapFile>, 157 + pub status: DeploymentRevisionStatus, 158 + pub deploy_source: DeploymentSource, 159 + pub actor_user_id: Option<String>, 160 + pub actor_username: Option<String>, 161 + pub actor_type: DeploymentActorType, 162 + pub error_message: Option<String>, 163 + pub build_id: Option<String>, 164 + pub base_revision_id: Option<Uuid>, 165 + pub change_summary: Option<DeploymentChangeSummary>, 166 + } 167 + 168 + #[derive(Debug, Clone)] 169 + pub struct DeploymentRevisionCursor { 170 + pub deployed_at: DateTime<Utc>, 171 + pub id: Uuid, 172 + } 173 + 174 + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] 175 + pub struct PreviousSuccessfulRevision { 176 + pub revision_id: Uuid, 177 + #[serde(default, skip_serializing_if = "Option::is_none")] 178 + pub files: Option<Vec<DeploymentFile>>, 179 + } 180 + 18 181 /// Stored representation of a guild deployment. 19 182 #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] 20 183 pub struct Deployment { ··· 46 209 updated_at: DateTime<Utc>, 47 210 } 48 211 212 + #[derive(FromRow)] 213 + struct DeploymentRevisionRow { 214 + id: Uuid, 215 + guild_id: String, 216 + entry: String, 217 + files: Option<sqlx::types::Json<Vec<DeploymentFile>>>, 218 + bundle: String, 219 + source_map: Option<sqlx::types::Json<DeploymentSourceMapFile>>, 220 + status: String, 221 + deployed_at: DateTime<Utc>, 222 + deploy_source: String, 223 + actor_user_id: Option<String>, 224 + actor_username: Option<String>, 225 + actor_type: String, 226 + error_message: Option<String>, 227 + build_id: Option<String>, 228 + base_revision_id: Option<Uuid>, 229 + change_summary: Option<sqlx::types::Json<DeploymentChangeSummary>>, 230 + } 231 + 49 232 impl DeploymentService { 50 233 pub fn new( 51 234 db_pool: Pool<Postgres>, ··· 67 250 bundle: String, 68 251 source_map: Option<DeploymentSourceMapFile>, 69 252 ) -> Result<Deployment> { 70 - let record = sqlx::query_as::<_, DeploymentRow>( 253 + let row = sqlx::query_as::<_, DeploymentRow>( 71 254 r#" 72 255 INSERT INTO deployments (guild_id, entry, files, bundle, source_map) 73 256 VALUES ($1, $2, $3, $4, $5) ··· 88 271 .fetch_one(&self.db_pool) 89 272 .await?; 90 273 91 - let deployment = to_deployment(record)?; 274 + let deployment = to_deployment(row)?; 92 275 self.cache_deployment(&deployment).await?; 93 276 info!(target: "flora:deployments", guild_id = deployment.guild_id, "deployment stored"); 94 277 Ok(deployment) 95 278 } 96 279 280 + pub async fn create_revision( 281 + &self, 282 + input: CreateDeploymentRevisionInput, 283 + ) -> Result<DeploymentRevision> { 284 + let row = sqlx::query_as::<_, DeploymentRevisionRow>( 285 + r#" 286 + INSERT INTO deployment_revisions ( 287 + guild_id, entry, files, bundle, source_map, status, deploy_source, 288 + actor_user_id, actor_username, actor_type, error_message, build_id, 289 + base_revision_id, change_summary 290 + ) 291 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) 292 + RETURNING id, guild_id, entry, files, bundle, source_map, status, deployed_at, 293 + deploy_source, actor_user_id, actor_username, actor_type, error_message, 294 + build_id, base_revision_id, change_summary 295 + "#, 296 + ) 297 + .bind(&input.guild_id) 298 + .bind(&input.entry) 299 + .bind(input.files.map(sqlx::types::Json)) 300 + .bind(&input.bundle) 301 + .bind(input.source_map.map(sqlx::types::Json)) 302 + .bind(input.status.as_str()) 303 + .bind(input.deploy_source.as_str()) 304 + .bind(input.actor_user_id) 305 + .bind(input.actor_username) 306 + .bind(input.actor_type.as_str()) 307 + .bind(input.error_message) 308 + .bind(input.build_id) 309 + .bind(input.base_revision_id) 310 + .bind(input.change_summary.map(sqlx::types::Json)) 311 + .fetch_one(&self.db_pool) 312 + .await?; 313 + 314 + to_revision(row) 315 + } 316 + 317 + pub async fn list_guild_revisions( 318 + &self, 319 + guild_id: &str, 320 + limit: i64, 321 + cursor: Option<DeploymentRevisionCursor>, 322 + ) -> Result<Vec<DeploymentRevision>> { 323 + let rows = if let Some(cursor) = cursor { 324 + sqlx::query_as::<_, DeploymentRevisionRow>( 325 + r#" 326 + SELECT id, guild_id, entry, files, bundle, source_map, status, deployed_at, 327 + deploy_source, actor_user_id, actor_username, actor_type, error_message, 328 + build_id, base_revision_id, change_summary 329 + FROM deployment_revisions 330 + WHERE guild_id = $1 331 + AND (deployed_at, id) < ($2, $3) 332 + ORDER BY deployed_at DESC, id DESC 333 + LIMIT $4 334 + "#, 335 + ) 336 + .bind(guild_id) 337 + .bind(cursor.deployed_at) 338 + .bind(cursor.id) 339 + .bind(limit) 340 + .fetch_all(&self.db_pool) 341 + .await? 342 + } else { 343 + sqlx::query_as::<_, DeploymentRevisionRow>( 344 + r#" 345 + SELECT id, guild_id, entry, files, bundle, source_map, status, deployed_at, 346 + deploy_source, actor_user_id, actor_username, actor_type, error_message, 347 + build_id, base_revision_id, change_summary 348 + FROM deployment_revisions 349 + WHERE guild_id = $1 350 + ORDER BY deployed_at DESC, id DESC 351 + LIMIT $2 352 + "#, 353 + ) 354 + .bind(guild_id) 355 + .bind(limit) 356 + .fetch_all(&self.db_pool) 357 + .await? 358 + }; 359 + 360 + let mut revisions = Vec::with_capacity(rows.len()); 361 + for row in rows { 362 + revisions.push(to_revision(row)?); 363 + } 364 + Ok(revisions) 365 + } 366 + 367 + pub async fn get_guild_revision( 368 + &self, 369 + guild_id: &str, 370 + revision_id: Uuid, 371 + ) -> Result<Option<DeploymentRevision>> { 372 + let row = sqlx::query_as::<_, DeploymentRevisionRow>( 373 + r#" 374 + SELECT id, guild_id, entry, files, bundle, source_map, status, deployed_at, 375 + deploy_source, actor_user_id, actor_username, actor_type, error_message, 376 + build_id, base_revision_id, change_summary 377 + FROM deployment_revisions 378 + WHERE guild_id = $1 AND id = $2 379 + "#, 380 + ) 381 + .bind(guild_id) 382 + .bind(revision_id) 383 + .fetch_optional(&self.db_pool) 384 + .await?; 385 + 386 + let Some(row) = row else { 387 + return Ok(None); 388 + }; 389 + 390 + Ok(Some(to_revision(row)?)) 391 + } 392 + 393 + pub async fn get_current_successful(&self, guild_id: &str) -> Result<Option<Deployment>> { 394 + let row = sqlx::query_as::<_, DeploymentRevisionRow>( 395 + r#" 396 + SELECT id, guild_id, entry, files, bundle, source_map, status, deployed_at, 397 + deploy_source, actor_user_id, actor_username, actor_type, error_message, 398 + build_id, base_revision_id, change_summary 399 + FROM deployment_revisions 400 + WHERE guild_id = $1 AND status = 'success' 401 + ORDER BY deployed_at DESC, id DESC 402 + LIMIT 1 403 + "#, 404 + ) 405 + .bind(guild_id) 406 + .fetch_optional(&self.db_pool) 407 + .await?; 408 + 409 + let Some(row) = row else { 410 + return Ok(None); 411 + }; 412 + 413 + let revision = to_revision(row)?; 414 + Ok(Some(Deployment { 415 + guild_id: revision.guild_id, 416 + entry: revision.entry, 417 + files: revision.files, 418 + source_map: revision.source_map, 419 + bundle: revision.bundle, 420 + created_at: revision.deployed_at, 421 + updated_at: revision.deployed_at, 422 + })) 423 + } 424 + 425 + pub async fn get_previous_successful_revision( 426 + &self, 427 + guild_id: &str, 428 + ) -> Result<Option<PreviousSuccessfulRevision>> { 429 + let row = sqlx::query_as::<_, DeploymentRevisionRow>( 430 + r#" 431 + SELECT id, guild_id, entry, files, bundle, source_map, status, deployed_at, 432 + deploy_source, actor_user_id, actor_username, actor_type, error_message, 433 + build_id, base_revision_id, change_summary 434 + FROM deployment_revisions 435 + WHERE guild_id = $1 AND status = 'success' 436 + ORDER BY deployed_at DESC, id DESC 437 + LIMIT 1 438 + "#, 439 + ) 440 + .bind(guild_id) 441 + .fetch_optional(&self.db_pool) 442 + .await?; 443 + 444 + let Some(row) = row else { 445 + return Ok(None); 446 + }; 447 + 448 + Ok(Some(PreviousSuccessfulRevision { 449 + revision_id: row.id, 450 + files: row.files.map(|files| files.0), 451 + })) 452 + } 453 + 97 454 pub async fn get_deployment(&self, guild_id: &str) -> Result<Option<Deployment>> { 98 455 if let Some(cached) = self.fetch_cached_deployment(guild_id).await? { 99 456 return Ok(Some(cached)); ··· 129 486 .fetch_all(&self.db_pool) 130 487 .await?; 131 488 132 - rows.into_iter() 133 - .map(to_deployment) 134 - .collect::<Result<Vec<_>>>() 489 + let mut deployments = Vec::with_capacity(rows.len()); 490 + for row in rows { 491 + deployments.push(to_deployment(row)?); 492 + } 493 + Ok(deployments) 494 + } 495 + 496 + pub fn summarize_changes( 497 + next_files: Option<&Vec<DeploymentFile>>, 498 + base_files: Option<&Vec<DeploymentFile>>, 499 + ) -> Option<DeploymentChangeSummary> { 500 + if next_files.is_none() && base_files.is_none() { 501 + return None; 502 + } 503 + 504 + let mut next_by_path = HashMap::new(); 505 + if let Some(files) = next_files { 506 + for file in files { 507 + next_by_path.insert(file.path.clone(), file.contents.clone()); 508 + } 509 + } 510 + 511 + let mut base_by_path = HashMap::new(); 512 + if let Some(files) = base_files { 513 + for file in files { 514 + base_by_path.insert(file.path.clone(), file.contents.clone()); 515 + } 516 + } 517 + 518 + let mut added_files = 0usize; 519 + let mut removed_files = 0usize; 520 + let mut modified_files = 0usize; 521 + 522 + for (path, next_contents) in &next_by_path { 523 + match base_by_path.get(path) { 524 + None => { 525 + added_files += 1; 526 + } 527 + Some(base_contents) => { 528 + if base_contents != next_contents { 529 + modified_files += 1; 530 + } 531 + } 532 + } 533 + } 534 + 535 + for path in base_by_path.keys() { 536 + if !next_by_path.contains_key(path) { 537 + removed_files += 1; 538 + } 539 + } 540 + 541 + Some(DeploymentChangeSummary { 542 + added_files, 543 + removed_files, 544 + modified_files, 545 + }) 135 546 } 136 547 137 548 async fn cache_deployment(&self, deployment: &Deployment) -> Result<()> { 138 549 let key = cache_key(&deployment.guild_id); 139 550 let value = serde_json::to_string(deployment)?; 140 - // cache for 10 minutes to reduce DB traffic. 141 551 self.cache_pool 142 552 .set::<(), _, _>(key, value, Some(Expiration::EX(600)), None, false) 143 553 .await?; ··· 177 587 }) 178 588 } 179 589 590 + fn to_revision(row: DeploymentRevisionRow) -> Result<DeploymentRevision> { 591 + Ok(DeploymentRevision { 592 + id: row.id, 593 + guild_id: row.guild_id, 594 + entry: row.entry, 595 + files: row.files.map(|files| files.0), 596 + bundle: row.bundle, 597 + source_map: row.source_map.map(|source_map| source_map.0), 598 + status: DeploymentRevisionStatus::from_str(&row.status)?, 599 + deployed_at: row.deployed_at, 600 + deploy_source: DeploymentSource::from_str(&row.deploy_source)?, 601 + actor_user_id: row.actor_user_id, 602 + actor_username: row.actor_username, 603 + actor_type: DeploymentActorType::from_str(&row.actor_type)?, 604 + error_message: row.error_message, 605 + build_id: row.build_id, 606 + base_revision_id: row.base_revision_id, 607 + change_summary: row.change_summary.map(|summary| summary.0), 608 + }) 609 + } 610 + 180 611 impl Deployment { 181 - /// Derive a synthetic module name for bundled modules. 182 612 pub fn module_name(&self) -> String { 183 613 format!("guild:{}.bundle.js", self.guild_id) 184 614 } ··· 189 619 use chrono::Utc; 190 620 use serde_json::json; 191 621 192 - use super::{Deployment, DeploymentFile, DeploymentSourceMapFile}; 622 + use super::{Deployment, DeploymentFile, DeploymentService, DeploymentSourceMapFile}; 193 623 194 624 #[test] 195 625 fn deployment_json_roundtrip_preserves_source_map() { ··· 233 663 234 664 assert!(deployment.source_map.is_none()); 235 665 assert!(deployment.files.is_none()); 666 + } 667 + 668 + #[test] 669 + fn summarize_changes_counts_modified_paths() { 670 + let current = vec![DeploymentFile { 671 + path: "src/main.ts".to_string(), 672 + contents: "console.log('next')".to_string(), 673 + }]; 674 + let base = vec![DeploymentFile { 675 + path: "src/main.ts".to_string(), 676 + contents: "console.log('base')".to_string(), 677 + }]; 678 + 679 + let summary = 680 + DeploymentService::summarize_changes(Some(&current), Some(&base)).expect("summary"); 681 + 682 + assert_eq!(summary.added_files, 0); 683 + assert_eq!(summary.removed_files, 0); 684 + assert_eq!(summary.modified_files, 1); 236 685 } 237 686 }
+26
pnpm-lock.yaml
··· 131 131 '@monaco-editor/react': 132 132 specifier: 4.7.0 133 133 version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 134 + '@pierre/diffs': 135 + specifier: 1.0.11 136 + version: 1.0.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 134 137 '@radix-ui/react-scroll-area': 135 138 specifier: ^1.2.10 136 139 version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ··· 1992 1995 resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} 1993 1996 engines: {node: '>= 10.0.0'} 1994 1997 1998 + '@pierre/diffs@1.0.11': 1999 + resolution: {integrity: sha512-j6zIEoyImQy1HfcJqbrDwP0O5I7V2VNXAaw53FqQ+SykRfaNwABeZHs9uibXO4supaXPmTx6LEH9Lffr03e1Tw==} 2000 + peerDependencies: 2001 + react: ^18.3.1 || ^19.0.0 2002 + react-dom: ^18.3.1 || ^19.0.0 2003 + 1995 2004 '@polka/url@1.0.0-next.29': 1996 2005 resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} 1997 2006 ··· 4358 4367 4359 4368 lru-cache@5.1.1: 4360 4369 resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} 4370 + 4371 + lru_map@0.4.1: 4372 + resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==} 4361 4373 4362 4374 lucide-react@0.562.0: 4363 4375 resolution: {integrity: sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==} ··· 6942 6954 '@parcel/watcher-win32-x64': 2.5.6 6943 6955 optional: true 6944 6956 6957 + '@pierre/diffs@1.0.11(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': 6958 + dependencies: 6959 + '@shikijs/core': 3.23.0 6960 + '@shikijs/engine-javascript': 3.23.0 6961 + '@shikijs/transformers': 3.23.0 6962 + diff: 8.0.3 6963 + hast-util-to-html: 9.0.5 6964 + lru_map: 0.4.1 6965 + react: 19.2.4 6966 + react-dom: 19.2.4(react@19.2.4) 6967 + shiki: 3.23.0 6968 + 6945 6969 '@polka/url@1.0.0-next.29': {} 6946 6970 6947 6971 '@poppinss/colors@4.1.6': ··· 9255 9279 lru-cache@5.1.1: 9256 9280 dependencies: 9257 9281 yallist: 3.1.1 9282 + 9283 + lru_map@0.4.1: {} 9258 9284 9259 9285 lucide-react@0.562.0(react@19.2.4): 9260 9286 dependencies: