@stacksjs/defaults 0.70.354 → 0.70.356

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/ai/skills/stacks-browse/SKILL.md +17 -2
  2. package/ai/skills/stacks-browse/scripts/browse.ts +207 -6
  3. package/ai/skills/stacks-email/SKILL.md +73 -16
  4. package/app/Actions/ContactAction.ts +2 -2
  5. package/app/Actions/Dashboard/Analytics/EventAnalyticsAction.ts +2 -3
  6. package/app/Actions/Dashboard/Analytics/MarketingAnalyticsAction.ts +2 -3
  7. package/app/Actions/Dashboard/Analytics/SalesAnalyticsAction.ts +2 -3
  8. package/app/Actions/Dashboard/Analytics/WebAnalyticsAction.ts +9 -3
  9. package/app/Actions/Dashboard/Auth/MeAction.ts +12 -14
  10. package/app/Actions/Dashboard/Buddy/BuddyChatAction.ts +2 -3
  11. package/app/Actions/Dashboard/Ci/RepoRunJobsAction.ts +10 -15
  12. package/app/Actions/Dashboard/Ci/RepoRunsAction.ts +12 -14
  13. package/app/Actions/Dashboard/Ci/RunnerHistoryAction.ts +11 -10
  14. package/app/Actions/Dashboard/Ci/StatusAction.ts +2 -11
  15. package/app/Actions/Dashboard/Cloud/CloudIndexAction.ts +8 -1
  16. package/app/Actions/Dashboard/Cloud/ServerlessIndexAction.ts +15 -8
  17. package/app/Actions/Dashboard/Commerce/CommerceCategoriesAction.ts +2 -4
  18. package/app/Actions/Dashboard/Commerce/CommerceCouponsAction.ts +2 -4
  19. package/app/Actions/Dashboard/Commerce/CommerceCustomersAction.ts +2 -4
  20. package/app/Actions/Dashboard/Commerce/CommerceDashboardAction.ts +2 -4
  21. package/app/Actions/Dashboard/Commerce/CommerceDeliveryAction.ts +2 -4
  22. package/app/Actions/Dashboard/Commerce/CommerceGiftCardsAction.ts +2 -4
  23. package/app/Actions/Dashboard/Commerce/CommerceOrdersAction.ts +2 -4
  24. package/app/Actions/Dashboard/Commerce/CommercePaymentsAction.ts +2 -4
  25. package/app/Actions/Dashboard/Commerce/CommercePosAction.ts +2 -4
  26. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +76 -61
  27. package/app/Actions/Dashboard/Commerce/CommercePrintDevicesAction.ts +2 -4
  28. package/app/Actions/Dashboard/Commerce/CommercePrintLogsAction.ts +4 -7
  29. package/app/Actions/Dashboard/Commerce/CommerceProductDetailAction.ts +4 -4
  30. package/app/Actions/Dashboard/Commerce/CommerceProductsAction.ts +2 -4
  31. package/app/Actions/Dashboard/Commerce/CommerceTaxesAction.ts +2 -4
  32. package/app/Actions/Dashboard/Commerce/DeliveryRouteIndexAction.ts +2 -4
  33. package/app/Actions/Dashboard/Commerce/DigitalDeliveryIndexAction.ts +2 -4
  34. package/app/Actions/Dashboard/Commerce/DriverIndexAction.ts +2 -4
  35. package/app/Actions/Dashboard/Commerce/LicenseKeyIndexAction.ts +2 -4
  36. package/app/Actions/Dashboard/Commerce/LicenseKeyOptionsAction.ts +2 -4
  37. package/app/Actions/Dashboard/Commerce/ManufacturerIndexAction.ts +2 -4
  38. package/app/Actions/Dashboard/Commerce/PaymentRefundAction.ts +1 -0
  39. package/app/Actions/Dashboard/Commerce/ProductUnitDefaultAction.ts +1 -0
  40. package/app/Actions/Dashboard/Commerce/ProductUnitIndexAction.ts +2 -4
  41. package/app/Actions/Dashboard/Commerce/ProductVariantIndexAction.ts +2 -4
  42. package/app/Actions/Dashboard/Commerce/ProductWaitlistIndexAction.ts +2 -4
  43. package/app/Actions/Dashboard/Commerce/RestaurantWaitlistIndexAction.ts +2 -4
  44. package/app/Actions/Dashboard/Commerce/ReviewIndexAction.ts +2 -4
  45. package/app/Actions/Dashboard/Commerce/ShippingMethodIndexAction.ts +2 -4
  46. package/app/Actions/Dashboard/Commerce/ShippingRateIndexAction.ts +2 -4
  47. package/app/Actions/Dashboard/Commerce/ShippingZoneIndexAction.ts +2 -4
  48. package/app/Actions/Dashboard/Commerce/TaxRateDefaultAction.ts +1 -0
  49. package/app/Actions/Dashboard/DashboardActivityAction.ts +2 -5
  50. package/app/Actions/Dashboard/Data/ActivityIndexAction.ts +2 -5
  51. package/app/Actions/Dashboard/Data/SubscriberIndexAction.ts +2 -5
  52. package/app/Actions/Dashboard/Data/TeamIndexAction.ts +2 -5
  53. package/app/Actions/Dashboard/Data/UserIndexAction.ts +2 -5
  54. package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +26 -21
  55. package/app/Actions/Dashboard/Deployments/GetAverageDeploymentTime.ts +8 -2
  56. package/app/Actions/Dashboard/Deployments/GetDeployScript.ts +3 -6
  57. package/app/Actions/Dashboard/Deployments/GetDeployment.ts +5 -5
  58. package/app/Actions/Dashboard/Deployments/GetDeploymentCount.ts +7 -1
  59. package/app/Actions/Dashboard/Deployments/GetDeploymentLiveTerminalOutput.ts +10 -9
  60. package/app/Actions/Dashboard/Deployments/GetDeployments.ts +2 -4
  61. package/app/Actions/Dashboard/Deployments/GetRecentDeployments.ts +8 -2
  62. package/app/Actions/Dashboard/Deployments/UpdateDeployScript.ts +21 -7
  63. package/app/Actions/Dashboard/Email/CapturedMailIndexAction.ts +2 -4
  64. package/app/Actions/Dashboard/Email/CapturedMailShowAction.ts +3 -4
  65. package/app/Actions/Dashboard/Email/InboxActivityAction.ts +28 -16
  66. package/app/Actions/Dashboard/Email/InboxAttachmentDownloadAction.ts +45 -0
  67. package/app/Actions/Dashboard/Email/InboxDeleteAction.ts +11 -6
  68. package/app/Actions/Dashboard/Email/InboxIndexAction.ts +5 -7
  69. package/app/Actions/Dashboard/Email/InboxMarkReadAction.ts +11 -6
  70. package/app/Actions/Dashboard/Email/InboxMarkUnreadAction.ts +11 -6
  71. package/app/Actions/Dashboard/Email/InboxPreferenceShowAction.ts +10 -4
  72. package/app/Actions/Dashboard/Email/InboxPreferenceUpdateAction.ts +8 -9
  73. package/app/Actions/Dashboard/Email/InboxShowAction.ts +7 -7
  74. package/app/Actions/Dashboard/Email/InboxStatsAction.ts +5 -7
  75. package/app/Actions/Dashboard/Email/inbox-request.ts +33 -0
  76. package/app/Actions/Dashboard/Infrastructure/InsightsAction.ts +15 -14
  77. package/app/Actions/Dashboard/Infrastructure/LogIndexAction.ts +4 -11
  78. package/app/Actions/Dashboard/Infrastructure/ServerShowAction.ts +3 -6
  79. package/app/Actions/Dashboard/Jobs/JobIndexAction.ts +9 -18
  80. package/app/Actions/Dashboard/Jobs/JobRetryAction.ts +4 -3
  81. package/app/Actions/Dashboard/Jobs/JobShowAction.ts +16 -11
  82. package/app/Actions/Dashboard/Jobs/JobStatsAction.ts +2 -4
  83. package/app/Actions/Dashboard/Kanban/BoardDestroyAction.ts +7 -8
  84. package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +11 -19
  85. package/app/Actions/Dashboard/Kanban/BoardStoreAction.ts +6 -6
  86. package/app/Actions/Dashboard/Kanban/BoardUpdateAction.ts +7 -8
  87. package/app/Actions/Dashboard/Kanban/BoardsIndexAction.ts +3 -5
  88. package/app/Actions/Dashboard/Kanban/BoardsReorderAction.ts +6 -6
  89. package/app/Actions/Dashboard/Kanban/CardAssigneesSyncAction.ts +13 -12
  90. package/app/Actions/Dashboard/Kanban/CardCommentDestroyAction.ts +12 -15
  91. package/app/Actions/Dashboard/Kanban/CardCommentStoreAction.ts +21 -46
  92. package/app/Actions/Dashboard/Kanban/CardCommentUpdateAction.ts +43 -0
  93. package/app/Actions/Dashboard/Kanban/CardDestroyAction.ts +7 -8
  94. package/app/Actions/Dashboard/Kanban/CardLabelsSyncAction.ts +8 -9
  95. package/app/Actions/Dashboard/Kanban/CardShowAction.ts +6 -7
  96. package/app/Actions/Dashboard/Kanban/CardStoreAction.ts +9 -9
  97. package/app/Actions/Dashboard/Kanban/CardUpdateAction.ts +7 -8
  98. package/app/Actions/Dashboard/Kanban/CardsReorderAction.ts +6 -6
  99. package/app/Actions/Dashboard/Kanban/ColumnDestroyAction.ts +7 -8
  100. package/app/Actions/Dashboard/Kanban/ColumnStoreAction.ts +6 -6
  101. package/app/Actions/Dashboard/Kanban/ColumnUpdateAction.ts +7 -8
  102. package/app/Actions/Dashboard/Kanban/ColumnsReorderAction.ts +6 -6
  103. package/app/Actions/Dashboard/Kanban/LabelDestroyAction.ts +6 -7
  104. package/app/Actions/Dashboard/Kanban/LabelStoreAction.ts +6 -6
  105. package/app/Actions/Dashboard/Kanban/LabelUpdateAction.ts +7 -8
  106. package/app/Actions/Dashboard/Kanban/UsersListAction.ts +3 -4
  107. package/app/Actions/Dashboard/Kanban/kanban-comment.ts +14 -0
  108. package/app/Actions/Dashboard/Kanban/kanban-model.test.ts +36 -0
  109. package/app/Actions/Dashboard/Kanban/kanban-model.ts +14 -7
  110. package/app/Actions/Dashboard/Kanban/kanban-response.ts +5 -0
  111. package/app/Actions/Dashboard/Library/CreateComponent.ts +15 -6
  112. package/app/Actions/Dashboard/Library/CreateFunction.ts +15 -6
  113. package/app/Actions/Dashboard/Library/DependencyIndexAction.ts +88 -77
  114. package/app/Actions/Dashboard/Library/GetAverageReleaseTime.ts +21 -15
  115. package/app/Actions/Dashboard/Library/GetComponents.ts +14 -7
  116. package/app/Actions/Dashboard/Library/GetDownloadCount.ts +14 -5
  117. package/app/Actions/Dashboard/Library/GetFunctions.ts +12 -5
  118. package/app/Actions/Dashboard/Library/GetReleaseCount.ts +14 -5
  119. package/app/Actions/Dashboard/Library/GetReleases.ts +17 -10
  120. package/app/Actions/Dashboard/Library/PackageIndexAction.ts +14 -7
  121. package/app/Actions/Dashboard/Models/ModelDestroyAction.ts +11 -8
  122. package/app/Actions/Dashboard/Models/ModelShowAction.ts +97 -192
  123. package/app/Actions/Dashboard/Models/ModelStoreAction.ts +14 -12
  124. package/app/Actions/Dashboard/Models/ModelUpdateAction.ts +18 -16
  125. package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +50 -19
  126. package/app/Actions/Dashboard/Models/model-api.ts +27 -0
  127. package/app/Actions/Dashboard/Models/model-write.ts +22 -15
  128. package/app/Actions/Dashboard/Notifications/NotificationDeliveryHistoryAction.ts +49 -40
  129. package/app/Actions/Dashboard/Notifications/NotificationDeliveryIndexAction.ts +20 -10
  130. package/app/Actions/Dashboard/Notifications/NotificationDeliveryOverviewAction.ts +96 -84
  131. package/app/Actions/Dashboard/Notifications/NotificationDeliveryRetryAction.ts +34 -21
  132. package/app/Actions/Dashboard/Queries/QueryDashboardAction.ts +2 -4
  133. package/app/Actions/Dashboard/Queries/QueryShowAction.ts +3 -3
  134. package/app/Actions/Dashboard/Queue/QueueStatsAction.ts +2 -4
  135. package/app/Actions/Dashboard/Queue/QueueWorkersAction.ts +2 -4
  136. package/app/Actions/Dashboard/Rbac/PermissionDestroyAction.ts +6 -6
  137. package/app/Actions/Dashboard/Rbac/PermissionStoreAction.ts +5 -4
  138. package/app/Actions/Dashboard/Rbac/PermissionsIndexAction.ts +2 -3
  139. package/app/Actions/Dashboard/Rbac/RoleDestroyAction.ts +6 -6
  140. package/app/Actions/Dashboard/Rbac/RolePermissionsShowAction.ts +6 -6
  141. package/app/Actions/Dashboard/Rbac/RolePermissionsSyncAction.ts +6 -9
  142. package/app/Actions/Dashboard/Rbac/RoleStoreAction.ts +5 -4
  143. package/app/Actions/Dashboard/Rbac/RolesIndexAction.ts +4 -6
  144. package/app/Actions/Dashboard/Rbac/UserRolesShowAction.ts +6 -7
  145. package/app/Actions/Dashboard/Rbac/UserRolesSyncAction.ts +6 -13
  146. package/app/Actions/Dashboard/Rbac/UsersListAction.ts +2 -3
  147. package/app/Actions/Dashboard/Rbac/rbac-response.ts +10 -0
  148. package/app/Actions/Dashboard/Realtime/RealtimeStatsAction.ts +2 -4
  149. package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +69 -55
  150. package/app/Actions/Dashboard/Teams/TeamInvitationDestroyAction.ts +34 -16
  151. package/app/Actions/Dashboard/Teams/TeamInvitationResendAction.ts +52 -29
  152. package/app/Actions/Dashboard/Teams/TeamInviteAction.ts +104 -73
  153. package/app/Actions/Dashboard/Teams/TeamMemberDestroyAction.ts +36 -17
  154. package/app/Actions/Dashboard/Teams/TeamMemberUpdateAction.ts +41 -28
  155. package/app/Actions/Dashboard/Teams/TeamPeopleIndexAction.ts +71 -58
  156. package/app/Actions/Dashboard/Teams/team-invitation-delivery.ts +29 -7
  157. package/app/Actions/Dashboard/Teams/team-member-count.ts +4 -2
  158. package/app/Actions/Dashboard/Teams/team-records.test.ts +2 -0
  159. package/app/Actions/Dashboard/Teams/team-records.ts +2 -0
  160. package/app/Actions/Dashboard/dashboard-request.ts +13 -0
  161. package/app/Actions/Dashboard/dashboard-response.ts +16 -0
  162. package/app/Actions/SendWelcomeEmail.ts +1 -1
  163. package/app/Actions/Teams/AcceptInvitationAction.ts +74 -51
  164. package/app/Actions/Teams/ShowInvitationAction.ts +21 -14
  165. package/app/Actions/Teams/team-response.ts +23 -0
  166. package/app/Mail/OrderConfirmation.ts +1 -1
  167. package/app/Mail/SubscriptionConfirmation.ts +1 -1
  168. package/app/Mail/TeamInvitation.ts +1 -1
  169. package/app/Models/CardComment.ts +3 -4
  170. package/app/Models/EmailIdempotency.ts +64 -0
  171. package/app/Models/EmailSuppression.ts +52 -0
  172. package/app/Models/EmailWebhookEvent.ts +51 -0
  173. package/app/Models/TeamInvitation.ts +13 -0
  174. package/functions/dashboard-api.ts +51 -10
  175. package/functions/data.ts +0 -1
  176. package/functions/inbox.test.ts +2 -0
  177. package/functions/inbox.ts +27 -2
  178. package/functions/jobs.ts +0 -1
  179. package/functions/public-application-url.test.ts +13 -0
  180. package/functions/public-application-url.ts +27 -0
  181. package/ide/vscode/package.json +1 -1
  182. package/package.json +1 -1
  183. package/resources/components/Dashboard/Auth/AccessTokens.stx +15 -0
  184. package/resources/components/Dashboard/Ci/CiDashboard.stx +24 -6
  185. package/resources/components/Dashboard/Commerce/CommerceTaxDialog.stx +1 -1
  186. package/resources/components/Dashboard/Commerce/ProductWaitlistDashboard.stx +8 -7
  187. package/resources/components/Dashboard/Commerce/RestaurantWaitlistDashboard.stx +8 -7
  188. package/resources/components/Dashboard/Content/BlogDashboard.stx +14 -2
  189. package/resources/components/Dashboard/Content/PostsDashboard.stx +7 -1
  190. package/resources/components/Dashboard/Data/DataRecords.stx +0 -4
  191. package/resources/components/Dashboard/Deployments/DeploymentDetail.stx +16 -2
  192. package/resources/components/Dashboard/Email/EmailNavigation.stx +5 -5
  193. package/resources/components/Dashboard/Email/EmailSidebar.stx +1 -1
  194. package/resources/components/Dashboard/Email/InboxAttachmentList.stx +53 -0
  195. package/resources/components/Dashboard/Email/InboxDashboard.stx +5 -4
  196. package/resources/components/Dashboard/Email/InboxMessageDetail.stx +6 -4
  197. package/resources/components/Dashboard/Jobs/JobDetail.stx +3 -2
  198. package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +63 -6
  199. package/resources/components/Dashboard/Kanban/KanbanBoardsDashboard.stx +3 -6
  200. package/resources/components/Dashboard/Management/PermissionsDashboard.stx +4 -4
  201. package/resources/components/Dashboard/Models/ModelRecordsDashboard.stx +7 -13
  202. package/resources/components/Dashboard/Models/ModelsOverview.stx +1 -1
  203. package/resources/components/Dashboard/NotFoundDashboard.stx +2 -5
  204. package/resources/components/Dashboard/Queries/QueryDashboard.stx +6 -2
  205. package/resources/components/Dashboard/Settings/AppearanceSettingsDashboard.stx +4 -4
  206. package/resources/components/Dashboard/UI/EmptyState.stx +2 -2
  207. package/resources/components/Dashboard/UI/PageLayout.stx +3 -3
  208. package/resources/functions/dashboard/sidebar.ts +4 -4
  209. package/routes/dashboard-api.ts +8 -20
  210. package/views/dashboard/.discovered-models.json +54 -0
  211. package/views/dashboard/layouts/default.stx +19 -16
  212. package/views/dashboard/stores/auth.ts +15 -14
  213. package/views/dashboard/stores/ci.ts +13 -7
  214. package/views/dashboard/stores/kanban.ts +48 -11
@@ -96,12 +96,27 @@ bun storage/framework/defaults/ai/skills/stacks-browse/scripts/browse.ts snapsho
96
96
  ```
97
97
  Extracts headings, links (`text -> href`), buttons, forms (action + field count), and ARIA landmarks — useful for auditing structure and catching broken links.
98
98
 
99
+ ### Scenario (stateful SPA interactions)
100
+ ```bash
101
+ bun storage/framework/defaults/ai/skills/stacks-browse/scripts/browse.ts scenario <url> \
102
+ --step '{"action":"click","selector":"button[data-open]"}' \
103
+ --step '{"action":"fill","selector":"input[name=name]","value":"Example"}' \
104
+ --step '{"action":"click","selector":"button[type=submit]"}' \
105
+ --step '{"action":"assert","selector":"main","text":"Saved"}' \
106
+ --out storage/framework/runtime/shots/scenario.png
107
+ ```
108
+
109
+ Steps run in order within one isolated browser page, preserving reactive STX state and SPA navigation. Supported actions are `click`, `fill`, `press`, `wait`, and `assert`. Each step is a JSON object passed through a repeatable `--step` flag. A click step may include `text` to select the matching control from its CSS selector. The command reports every completed step, the final URL and page text, console exceptions, failed requests, and an optional screenshot. It exits nonzero when the scenario assertion, browser console, or network fails.
110
+
99
111
  ### Crawl (whole-site browser audit)
100
112
  ```bash
101
- bun storage/framework/defaults/ai/skills/stacks-browse/scripts/browse.ts crawl <url> [--max 500] [--path /extra-route] [--settle 350] [--progress]
113
+ bun storage/framework/defaults/ai/skills/stacks-browse/scripts/browse.ts crawl <url> [--viewport 1280x900] [--max 500] [--path /extra-route] [--settle 350] [--progress]
102
114
  ```
103
- Reuses one Chromium process with a fresh isolated page target per route while
115
+ Uses a fresh isolated page target per route, relaunching Chromium if its DevTools
116
+ session exits during a long audit, while
104
117
  following every same-origin link it discovers.
118
+ The viewport is deterministic and defaults to `1280x900`; pass `--viewport 768x1024`
119
+ to repeat the same route audit at a tablet breakpoint.
105
120
  Each page is checked for a non-200 document, console exceptions, failed
106
121
  requests, and horizontal overflow. Repeat `--path` to seed routes that are not
107
122
  linked from the starting page. The command prints every crawled path and exits
@@ -12,7 +12,8 @@
12
12
  * bun browse.ts responsive <url> [--out-dir DIR]
13
13
  * bun browse.ts monitor <url> [--ms 5000]
14
14
  * bun browse.ts snapshot <url>
15
- * bun browse.ts crawl <url> [--max 500] [--path /extra] [--progress]
15
+ * bun browse.ts scenario <url> --step '{"action":"click","selector":"button"}'
16
+ * bun browse.ts crawl <url> [--viewport 1280x900] [--max 500] [--path /extra] [--progress]
16
17
  *
17
18
  * Browser discovery order: $BROWSE_BROWSER → PATH (chromium, google-chrome, …)
18
19
  * → common macOS app bundles → a Playwright-cached chromium as last resort.
@@ -405,6 +406,14 @@ interface CrawlPage {
405
406
  consoleErrors: string[]
406
407
  failedRequests: Array<{ status: number, url: string }>
407
408
  horizontalOverflowPx: number
409
+ layout: {
410
+ bodyScrollWidth: number
411
+ documentScrollWidth: number
412
+ mainClientWidth: number | null
413
+ mainOverflowX: string | null
414
+ mainScrollWidth: number | null
415
+ viewportWidth: number
416
+ }
408
417
  overflowingElements: Array<{
409
418
  element: string
410
419
  left: number
@@ -488,6 +497,118 @@ function parseViewport(value: string | undefined): { w: number, h: number } {
488
497
  return { w, h }
489
498
  }
490
499
 
500
+ type ScenarioAction = 'assert' | 'click' | 'fill' | 'press' | 'wait'
501
+
502
+ interface ScenarioStep {
503
+ action: ScenarioAction
504
+ selector?: string
505
+ text?: string
506
+ value?: string
507
+ key?: string
508
+ ms?: number
509
+ settle?: number
510
+ }
511
+
512
+ function parseScenarioStep(value: string): ScenarioStep {
513
+ let parsed: unknown
514
+ try {
515
+ parsed = JSON.parse(value)
516
+ }
517
+ catch (error) {
518
+ throw new TypeError(`Invalid scenario step JSON: ${value}`, { cause: error })
519
+ }
520
+
521
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
522
+ throw new TypeError('Each scenario step must be a JSON object.')
523
+
524
+ const step = parsed as Record<string, unknown>
525
+ if (!['assert', 'click', 'fill', 'press', 'wait'].includes(String(step.action)))
526
+ throw new TypeError(`Unsupported scenario action: ${String(step.action)}`)
527
+
528
+ const action = step.action as ScenarioAction
529
+ if (['assert', 'click', 'fill'].includes(action) && typeof step.selector !== 'string')
530
+ throw new TypeError(`Scenario action "${action}" requires a selector.`)
531
+ if (action === 'fill' && typeof step.value !== 'string')
532
+ throw new TypeError('Scenario action "fill" requires a string value.')
533
+ if (action === 'press' && typeof step.key !== 'string')
534
+ throw new TypeError('Scenario action "press" requires a key.')
535
+ if (action === 'wait' && (!Number.isFinite(step.ms) || Number(step.ms) < 0 || Number(step.ms) > 30_000))
536
+ throw new TypeError('Scenario action "wait" requires ms between 0 and 30000.')
537
+
538
+ return step as unknown as ScenarioStep
539
+ }
540
+
541
+ async function runScenarioStep(cdp: Cdp, step: ScenarioStep): Promise<Record<string, unknown>> {
542
+ if (step.action === 'wait') {
543
+ await Bun.sleep(Number(step.ms))
544
+ return { action: step.action, ms: Number(step.ms), ok: true }
545
+ }
546
+
547
+ if (step.action === 'press') {
548
+ const key = step.key || ''
549
+ const keyCode = key.length === 1 ? key.charCodeAt(0) : ({ Enter: 13, Escape: 27, Tab: 9 }[key] || 0)
550
+ await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key, code: key, windowsVirtualKeyCode: keyCode })
551
+ await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code: key, windowsVirtualKeyCode: keyCode })
552
+ return { action: step.action, key, ok: true }
553
+ }
554
+
555
+ const result = await cdp.send('Runtime.evaluate', {
556
+ expression: `(() => {
557
+ const step = ${JSON.stringify(step)}
558
+ const candidates = Array.from(document.querySelectorAll(step.selector))
559
+ const normalizedText = (element) => (element.innerText || element.textContent || '').replace(/\\s+/g, ' ').trim()
560
+ const element = step.action === 'click' && step.text !== undefined
561
+ ? candidates.find(candidate => normalizedText(candidate) === step.text)
562
+ || candidates.find(candidate => normalizedText(candidate).includes(step.text))
563
+ : candidates[0]
564
+ if (!element)
565
+ return { ok: false, error: 'Element not found', selector: step.selector }
566
+
567
+ const rect = element.getBoundingClientRect()
568
+ const style = getComputedStyle(element)
569
+ const visible = rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'
570
+ if (!visible)
571
+ return { ok: false, error: 'Element is not visible', selector: step.selector }
572
+
573
+ if (step.action === 'click') {
574
+ if (element.disabled || element.getAttribute('aria-disabled') === 'true')
575
+ return { ok: false, error: 'Element is disabled', selector: step.selector }
576
+ element.click()
577
+ }
578
+ else if (step.action === 'fill') {
579
+ if (!('value' in element))
580
+ return { ok: false, error: 'Element has no value', selector: step.selector }
581
+ const prototype = Object.getPrototypeOf(element)
582
+ const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
583
+ if (setter)
584
+ setter.call(element, step.value)
585
+ else
586
+ element.value = step.value
587
+ element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: step.value }))
588
+ element.dispatchEvent(new Event('change', { bubbles: true }))
589
+ }
590
+ else if (step.action === 'assert' && step.text !== undefined) {
591
+ const content = (element.innerText || element.textContent || '').replace(/\\s+/g, ' ').trim()
592
+ if (!content.includes(step.text))
593
+ return { ok: false, error: 'Expected text was not found', selector: step.selector, expected: step.text, actual: content.slice(0, 240) }
594
+ }
595
+
596
+ return {
597
+ ok: true,
598
+ action: step.action,
599
+ selector: step.selector,
600
+ tag: element.tagName.toLowerCase(),
601
+ text: normalizedText(element).slice(0, 160),
602
+ }
603
+ })()`,
604
+ returnByValue: true,
605
+ })
606
+ const value = result.result?.value as Record<string, unknown> | undefined
607
+ if (!value?.ok)
608
+ throw new Error(`Scenario ${step.action} failed: ${String(value?.error || 'unknown error')} (${step.selector || step.key || ''})`)
609
+ return value
610
+ }
611
+
491
612
  const BREAKPOINTS = [
492
613
  { device: 'Mobile S', w: 320, h: 568 },
493
614
  { device: 'Mobile L', w: 428, h: 926 },
@@ -502,14 +623,14 @@ async function main() {
502
623
  const url = positional[0]
503
624
 
504
625
  if (!command || command === 'help' || !url) {
505
- console.log('Usage: bun browse.ts <navigate|screenshot|responsive|monitor|snapshot|crawl> <url> [flags]')
626
+ console.log('Usage: bun browse.ts <navigate|screenshot|responsive|monitor|snapshot|scenario|crawl> <url> [flags]')
506
627
  console.log(' --cookie "name=value" repeatable; pre-seeds cookies (e.g. coming-soon bypass)')
507
628
  console.log(' --settle 1500 ms to wait after load before acting (default 700; stretch for entrance animations)')
508
629
  console.log(' --scheme dark emulate prefers-color-scheme (light|dark) for QA of theme-aware pages')
509
630
  process.exit(url ? 0 : 1)
510
631
  }
511
632
 
512
- const session = await launch()
633
+ let session = await launch()
513
634
  const cookies = flagList(flags.cookie)
514
635
  const settleMs = flags.settle ? Number(flags.settle) : undefined
515
636
  const scheme = typeof flags.scheme === 'string' ? flags.scheme : undefined
@@ -554,7 +675,7 @@ async function main() {
554
675
  const cdp = await openPage(session.port)
555
676
  const state = await gotoAndInstrument(cdp, url, { viewport: { w: bp.w, h: bp.h }, cookies, settleMs, scheme })
556
677
  const overflow = await cdp.send('Runtime.evaluate', {
557
- expression: 'document.documentElement.scrollWidth > window.innerWidth ? document.documentElement.scrollWidth - window.innerWidth : 0',
678
+ expression: 'document.body.scrollWidth > window.innerWidth ? document.body.scrollWidth - window.innerWidth : 0',
558
679
  returnByValue: true,
559
680
  })
560
681
  const out = join(outDir, `${bp.device.toLowerCase().replace(/\s+/g, '-')}.png`)
@@ -611,9 +732,57 @@ async function main() {
611
732
  cdp.close()
612
733
  }
613
734
 
735
+ else if (command === 'scenario') {
736
+ const steps = flagList(flags.step).map(parseScenarioStep)
737
+ if (!steps.length)
738
+ throw new TypeError('Scenario requires at least one --step JSON object.')
739
+
740
+ const cdp = await openPage(session.port)
741
+ const viewport = parseViewport(typeof flags.viewport === 'string' ? flags.viewport : undefined)
742
+ const state = await gotoAndInstrument(cdp, url, { viewport, cookies, settleMs, scheme })
743
+ const results: Record<string, unknown>[] = []
744
+
745
+ for (const step of steps) {
746
+ results.push(await runScenarioStep(cdp, step))
747
+ if (step.action !== 'wait')
748
+ await Bun.sleep(step.settle ?? 250)
749
+ }
750
+
751
+ const finalState = await cdp.send('Runtime.evaluate', {
752
+ expression: `({
753
+ url: location.href,
754
+ title: document.title,
755
+ activeElement: document.activeElement?.tagName?.toLowerCase() || null,
756
+ bodyText: (document.body.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 500),
757
+ })`,
758
+ returnByValue: true,
759
+ })
760
+ let screenshot: string | null = null
761
+ if (typeof flags.out === 'string') {
762
+ screenshot = flags.out
763
+ mkdirSync(screenshot.split('/').slice(0, -1).join('/') || '.', { recursive: true })
764
+ await Bun.write(screenshot, await captureScreenshot(cdp, { full: !!flags.full }))
765
+ }
766
+ const failedRequests = state.responses.filter(response => response.status >= 400)
767
+ console.log(JSON.stringify({
768
+ url,
769
+ viewport: `${viewport.w}x${viewport.h}`,
770
+ steps: results,
771
+ final: finalState.result?.value,
772
+ screenshot,
773
+ consoleErrors: state.consoleErrors,
774
+ failedRequests,
775
+ }, null, 2))
776
+ if (state.consoleErrors.length || failedRequests.length)
777
+ process.exitCode = 1
778
+ state.dispose()
779
+ cdp.close()
780
+ }
781
+
614
782
  else if (command === 'crawl') {
615
783
  const start = new URL(url)
616
784
  const max = flags.max ? Number(flags.max) : 500
785
+ const crawlViewport = parseViewport(typeof flags.viewport === 'string' ? flags.viewport : undefined)
617
786
  if (!Number.isInteger(max) || max < 1)
618
787
  throw new TypeError(`Invalid crawl max "${String(flags.max)}". Expected a positive integer.`)
619
788
 
@@ -629,9 +798,19 @@ async function main() {
629
798
  const crawlSettleMs = settleMs ?? 350
630
799
  let browserPage = await createPage(session.port)
631
800
  let cdp = browserPage.cdp
801
+ const createReplacementPage = async (): Promise<BrowserPage> => {
802
+ try {
803
+ return await createPage(session.port)
804
+ }
805
+ catch {
806
+ kill(session)
807
+ session = await launch()
808
+ return createPage(session.port)
809
+ }
810
+ }
632
811
  const replacePage = async (): Promise<void> => {
633
812
  await closePage(session.port, browserPage)
634
- browserPage = await createPage(session.port)
813
+ browserPage = await createReplacementPage()
635
814
  cdp = browserPage.cdp
636
815
  }
637
816
  const inspectPage = async (current: string): Promise<{
@@ -640,6 +819,7 @@ async function main() {
640
819
  links?: string[]
641
820
  overflow?: number
642
821
  overflowing?: CrawlPage['overflowingElements']
822
+ layout?: CrawlPage['layout']
643
823
  title?: string
644
824
  }
645
825
  }> => {
@@ -651,12 +831,24 @@ async function main() {
651
831
  cookies,
652
832
  settleMs: crawlSettleMs,
653
833
  scheme,
834
+ viewport: crawlViewport,
654
835
  })
655
836
  const inspection = await cdp.send('Runtime.evaluate', {
656
837
  expression: `(() => ({
657
838
  title: document.title,
658
839
  links: Array.from(document.querySelectorAll('a[href]')).map(link => link.href),
659
- overflow: Math.max(0, document.documentElement.scrollWidth - window.innerWidth),
840
+ overflow: Math.max(0, document.body.scrollWidth - window.innerWidth),
841
+ layout: (() => {
842
+ const main = document.querySelector('[data-stx-content]')
843
+ return {
844
+ bodyScrollWidth: document.body.scrollWidth,
845
+ documentScrollWidth: document.documentElement.scrollWidth,
846
+ mainClientWidth: main?.clientWidth ?? null,
847
+ mainOverflowX: main ? getComputedStyle(main).overflowX : null,
848
+ mainScrollWidth: main?.scrollWidth ?? null,
849
+ viewportWidth: window.innerWidth,
850
+ }
851
+ })(),
660
852
  overflowing: Array.from(document.querySelectorAll('body *'))
661
853
  .map((element) => {
662
854
  const rect = element.getBoundingClientRect()
@@ -736,6 +928,14 @@ async function main() {
736
928
  consoleErrors: [...new Set(state.consoleErrors)],
737
929
  failedRequests,
738
930
  horizontalOverflowPx: Number(value.overflow) || 0,
931
+ layout: value.layout || {
932
+ bodyScrollWidth: 0,
933
+ documentScrollWidth: 0,
934
+ mainClientWidth: null,
935
+ mainOverflowX: null,
936
+ mainScrollWidth: null,
937
+ viewportWidth: 0,
938
+ },
739
939
  overflowingElements: value.overflowing || [],
740
940
  })
741
941
  if (flags.progress) {
@@ -767,6 +967,7 @@ async function main() {
767
967
  console.log(JSON.stringify({
768
968
  start: start.href,
769
969
  browser: session.browser,
970
+ viewport: `${crawlViewport.w}x${crawlViewport.h}`,
770
971
  crawled: pages.length,
771
972
  remaining: queue.length,
772
973
  max,
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-email
3
- description: Use when working with email in a Stacks application sending emails via SES/SendGrid/Mailgun/Mailtrap/SMTP, email templates with STX, email drivers, the Mail singleton, the EmailSDK for inbox management, or email configuration. Covers @stacksjs/email, config/email.ts, and app/Mail/.
3
+ description: Use when working with email in a Stacks application - sending emails via SES/SendGrid/Mailgun/Mailtrap/SMTP, email templates with STX, email drivers, the Mail singleton, the EmailSDK for inbox management, inbound MIME parsing, or email configuration. Covers @stacksjs/email, config/email.ts, and app/Mail/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -14,6 +14,7 @@ Multi-driver email system with template rendering, S3-based inbox management, an
14
14
  - Core package: `storage/framework/core/email/src/`
15
15
  - Configuration: `config/email.ts`
16
16
  - Application mail: `app/Mail/`
17
+ - Persistence models: `storage/framework/defaults/app/Models/EmailSuppression.ts`, `EmailIdempotency.ts`, `EmailWebhookEvent.ts`
17
18
  - Email layouts: `storage/framework/defaults/resources/emails/layouts/`
18
19
  - Email resources: `storage/framework/defaults/resources/emails/`
19
20
 
@@ -23,8 +24,9 @@ email/src/
23
24
  ├── index.ts # All exports
24
25
  ├── email.ts # Email class + Mail singleton
25
26
  ├── template.ts # Template rendering engine
27
+ ├── inbound-parser.ts # Bounded RFC MIME parsing for inbound mail
26
28
  ├── types.ts # Types and interfaces
27
- ├── sdk/index.ts # EmailSDK (send + inbox management)
29
+ ├── sdk/index.ts # EmailSDK (send + inbox and attachment management)
28
30
  └── drivers/
29
31
  ├── base.ts # BaseEmailDriver abstract class
30
32
  ├── ses.ts # AWS SES driver
@@ -53,11 +55,30 @@ await mail.send({
53
55
  text: 'Hello!'
54
56
  })
55
57
 
58
+ // Use this when later state depends on successful provider delivery.
59
+ await mail.sendOrFail({
60
+ to: 'user@example.com',
61
+ subject: 'Your invitation',
62
+ text: 'Open the invitation link.'
63
+ })
64
+
56
65
  // Switch driver
57
66
  const sendgridMail = mail.use('sendgrid')
58
67
  await sendgridMail.send(message)
59
68
  ```
60
69
 
70
+ `mail.send()` always returns an `EmailResult`. Provider rejection is represented
71
+ as `{ success: false, message, provider }`, which is useful for campaign jobs
72
+ that aggregate individual outcomes. It may still throw for invalid framework
73
+ configuration. `mail.sendOrFail()` returns the same successful result and throws
74
+ `EmailDeliveryError` for a structured provider failure. Use `sendOrFail()` when
75
+ an action reports that mail was sent, when a caller relies on `.catch()`, or when
76
+ delivery status is persisted after the call.
77
+
78
+ Keep template rendering fallback separate from provider delivery. Resolve HTML
79
+ and text first, fall back to plain text only when rendering fails, then call
80
+ `sendOrFail()` once outside the template `try` block.
81
+
61
82
  ## Email Class
62
83
 
63
84
  ```typescript
@@ -106,14 +127,16 @@ import { emailSDK, sendEmail, getInbox, searchEmails, deleteEmail } from '@stack
106
127
  await sendEmail({ from: { address: 'a@b.com' }, to: 'c@d.com', subject: 'Hi', html: '<p>Hello</p>' })
107
128
 
108
129
  // Send with template
109
- await emailSDK.sendTemplate({ to: 'user@example.com', templateName: 'welcome', data: { name: 'John' } })
130
+ await emailSDK.sendTemplate({ to: 'user@example.com', template: 'welcome', data: { name: 'John' } })
110
131
 
111
132
  // Read inbox (from S3)
112
133
  const emails = await getInbox('chris', { limit: 20 })
113
134
  const email = await emailSDK.getEmail('chris', messageId)
135
+ const attachments = await emailSDK.getAttachments('chris', messageId)
136
+ const download = await emailSDK.getAttachment('chris', messageId, attachments?.[0]?.id || '')
114
137
 
115
138
  // Search
116
- const results = await searchEmails('chris', { from: 'boss', after: '2024-01-01', hasAttachments: true })
139
+ const results = await searchEmails('chris', { from: 'boss', after: new Date('2024-01-01'), hasAttachments: true })
117
140
 
118
141
  // Manage
119
142
  await emailSDK.markAsRead('chris', messageId)
@@ -227,7 +250,7 @@ export async function sendSubscriptionConfirmation({ to, subscriberUuid }: Optio
227
250
  }
228
251
  })
229
252
 
230
- await mail.send({
253
+ await mail.sendOrFail({
231
254
  from: { name: config.app.name, address: config.email.from.address },
232
255
  to,
233
256
  subject: 'Confirm your subscription',
@@ -237,26 +260,60 @@ export async function sendSubscriptionConfirmation({ to, subscriberUuid }: Optio
237
260
  }
238
261
  ```
239
262
 
263
+ ## Delivery persistence models
264
+
265
+ Stacks ships three internal models and their generated migrations:
266
+
267
+ - `EmailSuppression` uses `email_suppressions` and uniquely keys `email + type`.
268
+ - `EmailIdempotency` uses `email_idempotency` and uniquely keys `idempotency_key`.
269
+ - `EmailWebhookEvent` uses `email_webhook_events` and uniquely keys `provider + event_id`.
270
+
271
+ Each model declares authenticated `useApi` index, show, and destroy routes, but
272
+ sets `dashboard.enabled` to false so operational records do not clutter the
273
+ generic model catalog. Sensitive idempotency keys, recipients, subjects, and
274
+ provider event IDs are hidden from generated responses. Run `buddy migrate`
275
+ after upgrading so suppression, send deduplication, and webhook deduplication
276
+ are enforced rather than using their legacy warn-once compatibility path.
277
+
278
+ ## Inbound MIME and attachment storage
279
+
280
+ `parseInboundEmail()` parses RFC messages with bounded header, nesting, total-size, and attachment-count limits. It returns normalized sender and recipient data, text and HTML bodies, and binary-safe attachments. Attachment filenames are sanitized before they become S3 keys.
281
+
282
+ `buddy email:reprocess` reads raw messages with `getObjectBytes()`, parses them through this shared helper, and writes:
283
+
284
+ - `raw.eml`
285
+ - `metadata.json`
286
+ - `body.txt` and `body.html` when present
287
+ - binary objects under `attachments/`
288
+ - the per-mailbox `inbox.json` index
289
+
290
+ Reprocessing refreshes existing messages instead of skipping them, preserves their read state, and repairs body and attachment metadata written by older versions. The dashboard receives opaque attachment IDs, resolves them against the stored message before download, and never accepts arbitrary S3 keys from a client.
291
+
240
292
  ## CLI Commands
241
- - `buddy email` / `buddy mail` email management
242
- - `buddy email:verify` check domain verification
243
- - `buddy email:test [recipient]` send test email
244
- - `buddy email:list` list mailboxes
245
- - `buddy email:logs -n 50` view logs
246
- - `buddy email:status` server status
247
- - `buddy email:inbox [mailbox]` view inbox from S3
248
- - `buddy mail:user:add <email>` add mail user
249
- - `buddy mail:user:list` list mail users
250
- - `buddy mail:user:delete <email>` delete mail user
293
+ - `buddy email` / `buddy mail` - email management
294
+ - `buddy email:verify` - check domain verification
295
+ - `buddy email:test [recipient]` - send test email
296
+ - `buddy email:list` - list mailboxes
297
+ - `buddy email:logs -n 50` - view logs
298
+ - `buddy email:status` - server status
299
+ - `buddy email:inbox [mailbox]` - view inbox from S3
300
+ - `buddy email:reprocess` - parse raw S3 mail into mailbox bodies and attachments
301
+ - `buddy mail:user:add <email>` - add mail user
302
+ - `buddy mail:user:list` - list mail users
303
+ - `buddy mail:user:delete <email>` - delete mail user
251
304
 
252
305
  ## Gotchas
253
- - Default driver is `ses` requires AWS credentials
306
+ - Default driver is `ses` - requires AWS credentials
254
307
  - Template rendering supports both `.stx` and `.html` files
255
308
  - Variable interpolation uses `{{ }}` double-brace syntax
256
309
  - The `mail` singleton auto-registers all 5 drivers on initialization
257
310
  - SMTP driver handles TLS handshake manually (not via node:tls)
258
311
  - SendGrid/Mailgun retry with exponential backoff on failure
312
+ - `mail.send()` returns structured failures; use `mail.sendOrFail()` when success is required
313
+ - Suppression, send idempotency, and webhook dedup are backed by built-in `useApi` models
259
314
  - Mailtrap requires `inboxId` for sandbox mode
260
315
  - EmailSDK reads inbox from S3 (bucket configured via env)
316
+ - EmailSDK attachment downloads use binary-safe S3 reads and opaque IDs
317
+ - `buddy email:reprocess` preserves existing read state and exits nonzero on failure
261
318
  - Email categorization auto-sorts incoming mail by domain/substring patterns
262
319
  - The `text` fallback is auto-generated from HTML via `htmlToText()`
@@ -53,7 +53,7 @@ export default new Action({
53
53
  ].join('\n')
54
54
 
55
55
  try {
56
- await mail.send({
56
+ await mail.sendOrFail({
57
57
  to: [toAddress],
58
58
  from: { name: appName, address: fromAddress },
59
59
  replyTo: email,
@@ -63,7 +63,7 @@ export default new Action({
63
63
  }
64
64
  catch (err) {
65
65
  const errMessage = err instanceof Error ? err.message : String(err)
66
- console.error('[ContactAction] mail.send failed:', errMessage)
66
+ console.error('[ContactAction] mail.sendOrFail failed:', errMessage)
67
67
  return { success: false, message: 'We could not send your message. Try again in a moment.' }
68
68
  }
69
69
 
@@ -2,6 +2,7 @@ import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
3
  import { AnalyticsEvent } from '@stacksjs/orm'
4
4
  import { response } from '@stacksjs/router'
5
+ import { dashboardOperationalError } from '../dashboard-response'
5
6
  import {
6
7
  analyticsCurrency,
7
8
  analyticsIdentifier,
@@ -50,9 +51,7 @@ export default new Action({
50
51
  )
51
52
  }
52
53
  catch (error) {
53
- return response.json({
54
- message: error instanceof Error ? error.message : 'Event analytics records could not be read.',
55
- }, 503)
54
+ return dashboardOperationalError(error, 'Event analytics records could not be read.', 'EventAnalyticsAction')
56
55
  }
57
56
  },
58
57
  })
@@ -2,6 +2,7 @@ import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
3
  import { Campaign } from '@stacksjs/orm'
4
4
  import { response } from '@stacksjs/router'
5
+ import { dashboardOperationalError } from '../dashboard-response'
5
6
  import {
6
7
  analyticsCurrency,
7
8
  analyticsIdentifier,
@@ -55,9 +56,7 @@ export default new Action({
55
56
  )
56
57
  }
57
58
  catch (error) {
58
- return response.json({
59
- message: error instanceof Error ? error.message : 'Campaign analytics records could not be read.',
60
- }, 503)
59
+ return dashboardOperationalError(error, 'Campaign analytics records could not be read.', 'MarketingAnalyticsAction')
61
60
  }
62
61
  },
63
62
  })
@@ -2,6 +2,7 @@ import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
3
  import { Category, Order, OrderItem, Payment, Product } from '@stacksjs/orm'
4
4
  import { response } from '@stacksjs/router'
5
+ import { dashboardOperationalError } from '../dashboard-response'
5
6
  import {
6
7
  analyticsCurrency,
7
8
  analyticsIdentifier,
@@ -93,9 +94,7 @@ export default new Action({
93
94
  )
94
95
  }
95
96
  catch (error) {
96
- return response.json({
97
- message: error instanceof Error ? error.message : 'Sales analytics records could not be read.',
98
- }, 503)
97
+ return dashboardOperationalError(error, 'Sales analytics records could not be read.', 'SalesAnalyticsAction')
99
98
  }
100
99
  },
101
100
  })
@@ -2,6 +2,7 @@ import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
3
  import { Request } from '@stacksjs/orm'
4
4
  import { response } from '@stacksjs/router'
5
+ import { dashboardOperationalError } from '../dashboard-response'
5
6
  import {
6
7
  buildWebAnalytics,
7
8
  normalizeAnalyticsRange,
@@ -27,9 +28,14 @@ export default new Action({
27
28
  message: error instanceof Error ? error.message : 'The analytics query is invalid.',
28
29
  }, 422)
29
30
  }
30
- const records = await Request.orderByDesc('id').limit(10_000).get()
31
- const rows = records.map(requestAnalyticsRow)
31
+ try {
32
+ const records = await Request.orderByDesc('id').limit(10_000).get()
33
+ const rows = records.map(requestAnalyticsRow)
32
34
 
33
- return buildWebAnalytics(rows, range, new Date(), scope)
35
+ return buildWebAnalytics(rows, range, new Date(), scope)
36
+ }
37
+ catch (error) {
38
+ return dashboardOperationalError(error, 'Web analytics records could not be read.', 'WebAnalyticsAction')
39
+ }
34
40
  },
35
41
  })
@@ -1,5 +1,7 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { getUserRoles } from '@stacksjs/auth'
4
+ import { dashboardOperationalError } from '../dashboard-response'
3
5
 
4
6
  interface MeResponse {
5
7
  user: {
@@ -32,7 +34,7 @@ interface MeResponse {
32
34
  * a 401 in the network tab. Instead:
33
35
  *
34
36
  * - If the request carries a valid bearer token, the framework's auth
35
- * middleware already populated `request.user` we return that user
37
+ * middleware already populated `request.user()` - we return that user
36
38
  * plus their roles.
37
39
  * - If not, we return `{ user: null, roles: [], unauthenticated: true }`.
38
40
  * The composable's "no auth → assume dev" default kicks in client-side,
@@ -48,34 +50,30 @@ export default new Action({
48
50
  description: 'Returns the authenticated user + their RBAC roles for dashboard role gating.',
49
51
  method: 'GET',
50
52
  apiResponse: true,
51
- async handle(request) {
52
- const user = (request as any)?.user ?? (request as any)?._authenticatedUser ?? null
53
+ async handle(request: RequestInstance) {
54
+ const user = await request.user()
53
55
 
54
- if (!user || typeof user !== 'object' || typeof (user as { id?: unknown }).id !== 'number') {
56
+ if (!user || !Number.isInteger(Number(user.id))) {
55
57
  const res: MeResponse = { user: null, roles: [], unauthenticated: true }
56
58
  return res
57
59
  }
58
60
 
59
- const userId = (user as { id: number }).id
61
+ const userId = Number(user.id)
60
62
 
61
- let roles: string[] = []
63
+ let roles: string[]
62
64
  try {
63
65
  const records = await getUserRoles(userId)
64
66
  roles = records.map(r => r.name)
65
67
  }
66
- catch (err) {
67
- // RBAC store not configured, migrations not run, or DB unreachable.
68
- // Empty roles → useRole will fall back to its dev default. We don't
69
- // want to 500 the dashboard just because the role tables haven't
70
- // been migrated yet on a fresh project.
71
- console.warn('[dashboard/auth/me] getUserRoles failed:', err instanceof Error ? err.message : err)
68
+ catch (error) {
69
+ return dashboardOperationalError(error, 'Dashboard identity could not be loaded.', 'MeAction')
72
70
  }
73
71
 
74
72
  const res: MeResponse = {
75
73
  user: {
76
74
  id: userId,
77
- name: (user as { name?: string | null }).name ?? null,
78
- email: (user as { email?: string | null }).email ?? null,
75
+ name: user.name ?? null,
76
+ email: user.email ?? null,
79
77
  },
80
78
  roles,
81
79
  }