@stacksjs/defaults 0.70.354 → 0.70.355

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 (117) 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 +35 -15
  4. package/app/Actions/Dashboard/Auth/MeAction.ts +8 -7
  5. package/app/Actions/Dashboard/Ci/RepoRunJobsAction.ts +10 -15
  6. package/app/Actions/Dashboard/Ci/RepoRunsAction.ts +12 -14
  7. package/app/Actions/Dashboard/Ci/RunnerHistoryAction.ts +11 -10
  8. package/app/Actions/Dashboard/Ci/StatusAction.ts +2 -11
  9. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +1 -0
  10. package/app/Actions/Dashboard/Commerce/CommercePrintLogsAction.ts +3 -4
  11. package/app/Actions/Dashboard/Commerce/CommerceProductDetailAction.ts +1 -0
  12. package/app/Actions/Dashboard/Commerce/PaymentRefundAction.ts +1 -0
  13. package/app/Actions/Dashboard/Commerce/ProductUnitDefaultAction.ts +1 -0
  14. package/app/Actions/Dashboard/Commerce/TaxRateDefaultAction.ts +1 -0
  15. package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +3 -2
  16. package/app/Actions/Dashboard/Deployments/GetDeployment.ts +1 -0
  17. package/app/Actions/Dashboard/Deployments/UpdateDeployScript.ts +3 -2
  18. package/app/Actions/Dashboard/Email/InboxActivityAction.ts +28 -16
  19. package/app/Actions/Dashboard/Email/InboxAttachmentDownloadAction.ts +45 -0
  20. package/app/Actions/Dashboard/Email/InboxDeleteAction.ts +11 -6
  21. package/app/Actions/Dashboard/Email/InboxIndexAction.ts +5 -7
  22. package/app/Actions/Dashboard/Email/InboxMarkReadAction.ts +11 -6
  23. package/app/Actions/Dashboard/Email/InboxMarkUnreadAction.ts +11 -6
  24. package/app/Actions/Dashboard/Email/InboxPreferenceShowAction.ts +10 -4
  25. package/app/Actions/Dashboard/Email/InboxPreferenceUpdateAction.ts +8 -9
  26. package/app/Actions/Dashboard/Email/InboxShowAction.ts +7 -7
  27. package/app/Actions/Dashboard/Email/InboxStatsAction.ts +5 -7
  28. package/app/Actions/Dashboard/Email/inbox-request.ts +33 -0
  29. package/app/Actions/Dashboard/Infrastructure/LogIndexAction.ts +3 -4
  30. package/app/Actions/Dashboard/Infrastructure/ServerShowAction.ts +3 -6
  31. package/app/Actions/Dashboard/Jobs/JobIndexAction.ts +8 -15
  32. package/app/Actions/Dashboard/Jobs/JobRetryAction.ts +4 -3
  33. package/app/Actions/Dashboard/Jobs/JobShowAction.ts +3 -3
  34. package/app/Actions/Dashboard/Kanban/BoardDestroyAction.ts +5 -5
  35. package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +9 -16
  36. package/app/Actions/Dashboard/Kanban/BoardStoreAction.ts +4 -3
  37. package/app/Actions/Dashboard/Kanban/BoardUpdateAction.ts +5 -5
  38. package/app/Actions/Dashboard/Kanban/BoardsIndexAction.ts +1 -2
  39. package/app/Actions/Dashboard/Kanban/BoardsReorderAction.ts +4 -3
  40. package/app/Actions/Dashboard/Kanban/CardAssigneesSyncAction.ts +11 -9
  41. package/app/Actions/Dashboard/Kanban/CardCommentDestroyAction.ts +10 -12
  42. package/app/Actions/Dashboard/Kanban/CardCommentStoreAction.ts +19 -43
  43. package/app/Actions/Dashboard/Kanban/CardCommentUpdateAction.ts +44 -0
  44. package/app/Actions/Dashboard/Kanban/CardDestroyAction.ts +5 -5
  45. package/app/Actions/Dashboard/Kanban/CardLabelsSyncAction.ts +6 -6
  46. package/app/Actions/Dashboard/Kanban/CardShowAction.ts +4 -4
  47. package/app/Actions/Dashboard/Kanban/CardStoreAction.ts +7 -6
  48. package/app/Actions/Dashboard/Kanban/CardUpdateAction.ts +5 -5
  49. package/app/Actions/Dashboard/Kanban/CardsReorderAction.ts +4 -3
  50. package/app/Actions/Dashboard/Kanban/ColumnDestroyAction.ts +5 -5
  51. package/app/Actions/Dashboard/Kanban/ColumnStoreAction.ts +4 -3
  52. package/app/Actions/Dashboard/Kanban/ColumnUpdateAction.ts +5 -5
  53. package/app/Actions/Dashboard/Kanban/ColumnsReorderAction.ts +4 -3
  54. package/app/Actions/Dashboard/Kanban/LabelDestroyAction.ts +4 -4
  55. package/app/Actions/Dashboard/Kanban/LabelStoreAction.ts +4 -3
  56. package/app/Actions/Dashboard/Kanban/LabelUpdateAction.ts +5 -5
  57. package/app/Actions/Dashboard/Kanban/UsersListAction.ts +1 -1
  58. package/app/Actions/Dashboard/Kanban/kanban-comment.ts +14 -0
  59. package/app/Actions/Dashboard/Kanban/kanban-model.test.ts +36 -0
  60. package/app/Actions/Dashboard/Kanban/kanban-model.ts +14 -7
  61. package/app/Actions/Dashboard/Models/ModelDestroyAction.ts +4 -3
  62. package/app/Actions/Dashboard/Models/ModelShowAction.ts +15 -18
  63. package/app/Actions/Dashboard/Models/ModelStoreAction.ts +8 -8
  64. package/app/Actions/Dashboard/Models/ModelUpdateAction.ts +9 -9
  65. package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +4 -3
  66. package/app/Actions/Dashboard/Models/model-api.ts +27 -0
  67. package/app/Actions/Dashboard/Models/model-write.ts +2 -10
  68. package/app/Actions/Dashboard/Notifications/NotificationDeliveryHistoryAction.ts +3 -4
  69. package/app/Actions/Dashboard/Notifications/NotificationDeliveryIndexAction.ts +6 -4
  70. package/app/Actions/Dashboard/Queries/QueryShowAction.ts +1 -0
  71. package/app/Actions/Dashboard/Rbac/PermissionDestroyAction.ts +4 -4
  72. package/app/Actions/Dashboard/Rbac/PermissionStoreAction.ts +3 -2
  73. package/app/Actions/Dashboard/Rbac/RoleDestroyAction.ts +4 -4
  74. package/app/Actions/Dashboard/Rbac/RolePermissionsShowAction.ts +4 -4
  75. package/app/Actions/Dashboard/Rbac/RolePermissionsSyncAction.ts +4 -3
  76. package/app/Actions/Dashboard/Rbac/RoleStoreAction.ts +3 -2
  77. package/app/Actions/Dashboard/Rbac/UserRolesShowAction.ts +4 -5
  78. package/app/Actions/Dashboard/Rbac/UserRolesSyncAction.ts +4 -4
  79. package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +1 -0
  80. package/app/Actions/Dashboard/Teams/TeamInviteAction.ts +3 -3
  81. package/app/Actions/Dashboard/Teams/TeamMemberUpdateAction.ts +2 -2
  82. package/app/Actions/Dashboard/dashboard-request.ts +13 -0
  83. package/app/Models/CardComment.ts +3 -4
  84. package/functions/dashboard-api.ts +51 -10
  85. package/functions/inbox.test.ts +2 -0
  86. package/functions/inbox.ts +27 -2
  87. package/functions/public-application-url.test.ts +13 -0
  88. package/functions/public-application-url.ts +27 -0
  89. package/ide/vscode/package.json +1 -1
  90. package/package.json +1 -1
  91. package/resources/components/Dashboard/Auth/AccessTokens.stx +15 -0
  92. package/resources/components/Dashboard/Ci/CiDashboard.stx +24 -6
  93. package/resources/components/Dashboard/Commerce/CommerceTaxDialog.stx +1 -1
  94. package/resources/components/Dashboard/Commerce/ProductWaitlistDashboard.stx +8 -7
  95. package/resources/components/Dashboard/Commerce/RestaurantWaitlistDashboard.stx +8 -7
  96. package/resources/components/Dashboard/Content/BlogDashboard.stx +14 -2
  97. package/resources/components/Dashboard/Content/PostsDashboard.stx +7 -1
  98. package/resources/components/Dashboard/Deployments/DeploymentDetail.stx +16 -2
  99. package/resources/components/Dashboard/Email/EmailNavigation.stx +5 -5
  100. package/resources/components/Dashboard/Email/EmailSidebar.stx +1 -1
  101. package/resources/components/Dashboard/Email/InboxAttachmentList.stx +53 -0
  102. package/resources/components/Dashboard/Email/InboxDashboard.stx +5 -4
  103. package/resources/components/Dashboard/Email/InboxMessageDetail.stx +6 -4
  104. package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +63 -6
  105. package/resources/components/Dashboard/Kanban/KanbanBoardsDashboard.stx +3 -6
  106. package/resources/components/Dashboard/Management/PermissionsDashboard.stx +4 -4
  107. package/resources/components/Dashboard/NotFoundDashboard.stx +2 -5
  108. package/resources/components/Dashboard/Queries/QueryDashboard.stx +6 -2
  109. package/resources/components/Dashboard/Settings/AppearanceSettingsDashboard.stx +4 -4
  110. package/resources/components/Dashboard/UI/EmptyState.stx +2 -2
  111. package/resources/components/Dashboard/UI/PageLayout.stx +3 -3
  112. package/resources/functions/dashboard/sidebar.ts +4 -4
  113. package/routes/dashboard-api.ts +8 -20
  114. package/views/dashboard/.discovered-models.json +24 -0
  115. package/views/dashboard/layouts/default.stx +19 -16
  116. package/views/dashboard/stores/ci.ts +13 -7
  117. package/views/dashboard/stores/kanban.ts +48 -11
@@ -1,3 +1,4 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { deletePermission } from '@stacksjs/auth'
3
4
  import { response } from '@stacksjs/router'
@@ -14,13 +15,12 @@ export default new Action({
14
15
  description: 'Hard-delete a permission + its pivot rows.',
15
16
  method: 'DELETE',
16
17
  apiResponse: true,
17
- async handle(request) {
18
- const name = String((request as any)?.params?.name ?? '').trim()
18
+ async handle(request: RequestInstance) {
19
+ const name = request.getParam('name').trim()
19
20
  if (!name) {
20
21
  return response.json({ error: '`name` route param is required.' }, 400)
21
22
  }
22
- const url = new URL(request.url ?? 'http://localhost/')
23
- const guardName = (url.searchParams.get('guard') || 'web').trim()
23
+ const guardName = String(request.get('guard', 'web')).trim()
24
24
  if (!guardName || guardName.length > 60) {
25
25
  return response.json({ error: 'Invalid guard name.' }, 400)
26
26
  }
@@ -1,3 +1,4 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { createPermission, findPermission } from '@stacksjs/auth'
3
4
  import { response } from '@stacksjs/router'
@@ -20,8 +21,8 @@ export default new Action({
20
21
  description: 'Create a new permission.',
21
22
  method: 'POST',
22
23
  apiResponse: true,
23
- async handle(request) {
24
- const body = (request as any).jsonBody as PermissionInput | undefined ?? {}
24
+ async handle(request: RequestInstance<PermissionInput>) {
25
+ const body = request.all()
25
26
 
26
27
  const name = typeof body.name === 'string' ? body.name.trim() : ''
27
28
  if (!name || name.length > 100) {
@@ -1,3 +1,4 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { deleteRole } from '@stacksjs/auth'
3
4
  import { response } from '@stacksjs/router'
@@ -16,13 +17,12 @@ export default new Action({
16
17
  description: 'Hard-delete a role + its pivot rows.',
17
18
  method: 'DELETE',
18
19
  apiResponse: true,
19
- async handle(request) {
20
- const name = String((request as any)?.params?.name ?? '').trim()
20
+ async handle(request: RequestInstance) {
21
+ const name = request.getParam('name').trim()
21
22
  if (!name) {
22
23
  return response.json({ error: '`name` route param is required.' }, 400)
23
24
  }
24
- const url = new URL(request.url ?? 'http://localhost/')
25
- const guardName = (url.searchParams.get('guard') || 'web').trim()
25
+ const guardName = String(request.get('guard', 'web')).trim()
26
26
  if (!guardName || guardName.length > 60) {
27
27
  return response.json({ error: 'Invalid guard name.' }, 400)
28
28
  }
@@ -1,3 +1,4 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { findRole, getRolePermissions } from '@stacksjs/auth'
3
4
  import { response } from '@stacksjs/router'
@@ -15,13 +16,12 @@ export default new Action({
15
16
  description: 'List permissions attached to one role.',
16
17
  method: 'GET',
17
18
  apiResponse: true,
18
- async handle(request) {
19
- const name = String((request as any)?.params?.name ?? '').trim()
19
+ async handle(request: RequestInstance) {
20
+ const name = request.getParam('name').trim()
20
21
  if (!name) {
21
22
  return response.json({ error: '`name` route param is required.' }, 400)
22
23
  }
23
- const url = new URL(request.url ?? 'http://localhost/')
24
- const guardName = (url.searchParams.get('guard') || 'web').trim()
24
+ const guardName = String(request.get('guard', 'web')).trim()
25
25
  if (!guardName || guardName.length > 60) {
26
26
  return response.json({ error: 'Invalid guard name.' }, 400)
27
27
  }
@@ -1,3 +1,4 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { getRolePermissions, findRole, syncRolePermissions } from '@stacksjs/auth'
3
4
  import { response } from '@stacksjs/router'
@@ -19,13 +20,13 @@ export default new Action({
19
20
  description: 'Replace the permission set attached to one role.',
20
21
  method: 'POST',
21
22
  apiResponse: true,
22
- async handle(request) {
23
- const roleName = String((request as any)?.params?.name ?? '').trim()
23
+ async handle(request: RequestInstance<SyncInput>) {
24
+ const roleName = request.getParam('name').trim()
24
25
  if (!roleName) {
25
26
  return response.json({ error: '`name` route param is required.' }, 400)
26
27
  }
27
28
 
28
- const body = (request as any).jsonBody as SyncInput | undefined ?? {}
29
+ const body = request.all()
29
30
  if (!Array.isArray(body.permissions)) {
30
31
  return response.json({ error: '`permissions` must be an array of permission names (possibly empty).' }, 400)
31
32
  }
@@ -1,3 +1,4 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { createRole, findRole } from '@stacksjs/auth'
3
4
  import { response } from '@stacksjs/router'
@@ -25,8 +26,8 @@ export default new Action({
25
26
  description: 'Create a new role.',
26
27
  method: 'POST',
27
28
  apiResponse: true,
28
- async handle(request) {
29
- const body = (request as any).jsonBody as RoleInput | undefined ?? {}
29
+ async handle(request: RequestInstance<RoleInput>) {
30
+ const body = request.all()
30
31
 
31
32
  const name = typeof body.name === 'string' ? body.name.trim() : ''
32
33
  if (!name || name.length > 60) {
@@ -1,3 +1,4 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { getUserRoles } from '@stacksjs/auth'
3
4
  import { User } from '@stacksjs/orm'
@@ -16,14 +17,12 @@ export default new Action({
16
17
  description: 'List roles attached to one user.',
17
18
  method: 'GET',
18
19
  apiResponse: true,
19
- async handle(request) {
20
- const rawId = (request as any)?.params?.id
21
- const userId = Number(rawId)
20
+ async handle(request: RequestInstance) {
21
+ const userId = Number(request.getParam('id'))
22
22
  if (!Number.isFinite(userId) || userId <= 0) {
23
23
  return response.json({ error: 'Invalid user id.' }, 400)
24
24
  }
25
- const url = new URL(request.url ?? 'http://localhost/')
26
- const guardName = (url.searchParams.get('guard') || 'web').trim()
25
+ const guardName = String(request.get('guard', 'web')).trim()
27
26
  if (!guardName || guardName.length > 60) {
28
27
  return response.json({ error: 'Invalid guard name.' }, 400)
29
28
  }
@@ -1,3 +1,4 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { getUserRoles, syncRoles } from '@stacksjs/auth'
3
4
  import { User } from '@stacksjs/orm'
@@ -24,14 +25,13 @@ export default new Action({
24
25
  description: 'Replace the role set attached to one user.',
25
26
  method: 'POST',
26
27
  apiResponse: true,
27
- async handle(request) {
28
- const rawId = (request as any)?.params?.id
29
- const userId = Number(rawId)
28
+ async handle(request: RequestInstance<SyncInput>) {
29
+ const userId = Number(request.getParam('id'))
30
30
  if (!Number.isFinite(userId) || userId <= 0) {
31
31
  return response.json({ error: 'Invalid user id.' }, 400)
32
32
  }
33
33
 
34
- const body = (request as any).jsonBody as SyncInput | undefined ?? {}
34
+ const body = request.all()
35
35
  if (!Array.isArray(body.roles)) {
36
36
  return response.json({ error: '`roles` must be an array of role names (possibly empty).' }, 400)
37
37
  }
@@ -1,3 +1,4 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
1
2
  import { Action } from '@stacksjs/actions'
2
3
  import { env } from '@stacksjs/env'
3
4
  import { readdirSync } from 'node:fs'
@@ -25,12 +25,12 @@ export default new Action({
25
25
  method: 'POST',
26
26
  apiResponse: true,
27
27
 
28
- async handle(request: RequestInstance) {
28
+ async handle(request: RequestInstance<InviteInput>) {
29
29
  const teamId = parsePositiveId(request.getParam('id'))
30
30
  if (!teamId)
31
31
  return response.json({ message: 'Invalid team id.' }, 400)
32
32
 
33
- const input = (request as any).jsonBody as InviteInput | undefined ?? {}
33
+ const input = request.all()
34
34
  const email = normalizeInvitationEmail(input.email)
35
35
  const role = normalizeInvitationRole(input.role ?? 'member')
36
36
  if (!email)
@@ -83,7 +83,7 @@ export default new Action({
83
83
  const token = generateInvitationToken()
84
84
  const tokenHash = hashInvitationToken(token)
85
85
  const expiresAt = invitationExpiresAt()
86
- const requester = (request as any).user ?? (request as any)._authenticatedUser ?? null
86
+ const requester = await request.user()
87
87
  const invitedByUserId = requester && Number.isInteger(Number(requester.id))
88
88
  ? Number(requester.id)
89
89
  : null
@@ -16,7 +16,7 @@ export default new Action({
16
16
  method: 'PATCH',
17
17
  apiResponse: true,
18
18
 
19
- async handle(request: RequestInstance) {
19
+ async handle(request: RequestInstance<UpdateInput>) {
20
20
  const teamId = parsePositiveId(request.getParam('id'))
21
21
  const memberId = parsePositiveId(request.getParam('memberId'))
22
22
  if (!teamId || !memberId)
@@ -33,7 +33,7 @@ export default new Action({
33
33
  if (member.role === 'owner')
34
34
  return response.json({ message: 'Transfer team ownership before changing the owner.' }, 409)
35
35
 
36
- const input = (request as any).jsonBody as UpdateInput | undefined ?? {}
36
+ const input = request.all()
37
37
  const role = input.role === undefined ? String(member.role) : normalizeInvitationRole(input.role)
38
38
  const status = input.status === undefined ? String(member.status) : String(input.status).trim().toLowerCase()
39
39
  if (!role)
@@ -0,0 +1,13 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+
3
+ /** Reads one scalar dashboard input value through the native request bag. */
4
+ export function dashboardRequestValue(
5
+ request: RequestInstance,
6
+ key: string,
7
+ fallback = '',
8
+ ): string {
9
+ const value = request.get<unknown>(key, fallback)
10
+ if (Array.isArray(value))
11
+ return value.length > 0 ? String(value[0]).trim() : fallback
12
+ return value == null ? fallback : String(value).trim()
13
+ }
@@ -2,13 +2,12 @@ import { defineModel } from '@stacksjs/orm'
2
2
  import { schema } from '@stacksjs/validation'
3
3
 
4
4
  /**
5
- * Card comment (stacksjs/stacks#1846 Phase 3).
5
+ * Card comment for the dashboard Kanban surface.
6
6
  *
7
7
  * Each comment belongs to a card and (optionally) to the user who
8
8
  * wrote it. Anonymous comments are allowed via nullable `user_id` —
9
9
  * makes the dev dashboard usable on localhost without an auth round-
10
- * trip, and supports system-generated activity entries
11
- * (eventually — Phase 4 could write status-change comments here).
10
+ * trip, and supports system-generated activity entries.
12
11
  *
13
12
  * Distinct from `Comment` (which is post-specific) so the schemas
14
13
  * don't fight: post comments carry `author_name`/`author_email` for
@@ -35,7 +34,7 @@ export default defineModel({
35
34
 
36
35
  useApi: {
37
36
  uri: 'card-comments',
38
- routes: ['index', 'store', 'show', 'destroy'],
37
+ routes: ['index', 'store', 'show', 'update', 'destroy'],
39
38
  middleware: ['auth'],
40
39
  },
41
40
  },
@@ -37,16 +37,9 @@ export class DashboardApiError extends Error {
37
37
  }
38
38
  }
39
39
 
40
- /**
41
- * Call a dashboard API endpoint and return its parsed JSON.
42
- *
43
- * Throws on a non-2xx response, using the server's `message` / `error`
44
- * field when there is one so callers can surface something better than a
45
- * bare status code.
46
- */
47
- export async function dashboardApi<T = unknown>(path: string, options: ApiRequestOptions = {}): Promise<T> {
40
+ async function dashboardResponse(path: string, options: ApiRequestOptions, accept: string): Promise<Response> {
48
41
  const method = options.method ?? 'GET'
49
- const headers: Record<string, string> = { accept: 'application/json' }
42
+ const headers: Record<string, string> = { accept }
50
43
  const authToken = useAuth().getToken()
51
44
 
52
45
  if (options.body !== undefined && options.formData)
@@ -62,7 +55,7 @@ export async function dashboardApi<T = unknown>(path: string, options: ApiReques
62
55
  ? headers
63
56
  : withCsrfHeader(headers)
64
57
 
65
- const res = await fetch(path, {
58
+ return fetch(path, {
66
59
  method,
67
60
  headers: requestHeaders,
68
61
  credentials: 'same-origin',
@@ -70,6 +63,33 @@ export async function dashboardApi<T = unknown>(path: string, options: ApiReques
70
63
  ...(options.body !== undefined && { body: JSON.stringify(options.body) }),
71
64
  ...(options.formData && { body: options.formData }),
72
65
  })
66
+ }
67
+
68
+ async function dashboardResponseError(res: Response): Promise<DashboardApiError> {
69
+ const text = await res.text()
70
+ let payload: ApiErrorBody | null = null
71
+ try {
72
+ payload = text ? JSON.parse(text) as ApiErrorBody : null
73
+ }
74
+ catch {
75
+ payload = null
76
+ }
77
+
78
+ const failure = describeResponseError(res.status, payload)
79
+ if (failure.unexpected)
80
+ console.error('[dashboard-api] Unexpected request failure:', failure.cause)
81
+ return new DashboardApiError(failure.message, res.status, failure.fields)
82
+ }
83
+
84
+ /**
85
+ * Call a dashboard API endpoint and return its parsed JSON.
86
+ *
87
+ * Throws on a non-2xx response, using the server's `message` / `error`
88
+ * field when there is one so callers can surface something better than a
89
+ * bare status code.
90
+ */
91
+ export async function dashboardApi<T = unknown>(path: string, options: ApiRequestOptions = {}): Promise<T> {
92
+ const res = await dashboardResponse(path, options, 'application/json')
73
93
 
74
94
  const text = await res.text()
75
95
  let payload: any = null
@@ -89,3 +109,24 @@ export async function dashboardApi<T = unknown>(path: string, options: ApiReques
89
109
 
90
110
  return payload as T
91
111
  }
112
+
113
+ /**
114
+ * Download a guarded dashboard resource while preserving bearer and session
115
+ * authentication. The object URL only exists for the duration of the browser
116
+ * download and is then released.
117
+ */
118
+ export async function dashboardDownload(path: string, filename: string): Promise<void> {
119
+ const res = await dashboardResponse(path, {}, 'application/octet-stream')
120
+ if (!res.ok)
121
+ throw await dashboardResponseError(res)
122
+
123
+ const objectUrl = URL.createObjectURL(await res.blob())
124
+ const anchor = document.createElement('a')
125
+ anchor.href = objectUrl
126
+ anchor.download = filename
127
+ anchor.hidden = true
128
+ document.body.append(anchor)
129
+ anchor.click()
130
+ anchor.remove()
131
+ setTimeout(() => URL.revokeObjectURL(objectUrl), 0)
132
+ }
@@ -35,6 +35,8 @@ describe('dashboard inbox mapping', () => {
35
35
  date: '2026-07-29T12:00:00.000Z',
36
36
  read: false,
37
37
  hasAttachments: true,
38
+ attachments: [],
39
+ detailsLoaded: false,
38
40
  })
39
41
  })
40
42
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { ref } from '@stacksjs/stx'
8
8
  import { get } from './api'
9
- import { dashboardApi } from './dashboard-api'
9
+ import { dashboardApi, dashboardDownload } from './dashboard-api'
10
10
  import { pushToast } from './toasts'
11
11
 
12
12
  export type InboxActivityRange = 'day' | 'week' | 'month' | 'year'
@@ -71,6 +71,15 @@ export interface DashboardInboxEmail {
71
71
  date: string
72
72
  read: boolean
73
73
  hasAttachments: boolean
74
+ attachments: DashboardInboxAttachment[]
75
+ detailsLoaded: boolean
76
+ }
77
+
78
+ export interface DashboardInboxAttachment {
79
+ id: string
80
+ name: string
81
+ size: number
82
+ lastModified?: string
74
83
  }
75
84
 
76
85
  interface DashboardInboxEntry {
@@ -94,6 +103,7 @@ interface DashboardInboxResponse {
94
103
  interface DashboardInboxBodyResponse {
95
104
  html?: string
96
105
  text?: string
106
+ attachments?: DashboardInboxAttachment[]
97
107
  error?: string
98
108
  }
99
109
 
@@ -166,6 +176,8 @@ export function mapDashboardInboxEntry(item: DashboardInboxEntry): DashboardInbo
166
176
  date: item.date,
167
177
  read: item.read === true,
168
178
  hasAttachments: item.hasAttachments === true,
179
+ attachments: [],
180
+ detailsLoaded: false,
169
181
  }
170
182
  }
171
183
 
@@ -179,7 +191,7 @@ export async function fetchDashboardInbox(mailbox?: string): Promise<LoadedDashb
179
191
  }
180
192
  }
181
193
 
182
- export async function fetchDashboardInboxBody(messageId: string, mailbox?: string): Promise<Pick<DashboardInboxEmail, 'bodyHtml' | 'bodyText'>> {
194
+ export async function fetchDashboardInboxBody(messageId: string, mailbox?: string): Promise<Pick<DashboardInboxEmail, 'attachments' | 'bodyHtml' | 'bodyText' | 'detailsLoaded' | 'hasAttachments'>> {
183
195
  const query = mailbox ? `?mailbox=${encodeURIComponent(mailbox)}` : ''
184
196
  const data = await dashboardApi<DashboardInboxBodyResponse>(`/api/dashboard/email/inbox/${encodeURIComponent(messageId)}${query}`)
185
197
  if (data.error)
@@ -187,9 +199,22 @@ export async function fetchDashboardInboxBody(messageId: string, mailbox?: strin
187
199
  return {
188
200
  bodyHtml: data.html || '',
189
201
  bodyText: data.text || '',
202
+ attachments: data.attachments || [],
203
+ detailsLoaded: true,
204
+ hasAttachments: (data.attachments || []).length > 0,
190
205
  }
191
206
  }
192
207
 
208
+ export async function downloadDashboardInboxAttachment(
209
+ messageId: string,
210
+ attachment: DashboardInboxAttachment,
211
+ mailbox?: string,
212
+ ): Promise<void> {
213
+ const query = mailbox ? `?mailbox=${encodeURIComponent(mailbox)}` : ''
214
+ const path = `/api/dashboard/email/inbox/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(attachment.id)}${query}`
215
+ await dashboardDownload(path, attachment.name)
216
+ }
217
+
193
218
  export async function fetchInboxActivity(range: InboxActivityRange): Promise<InboxActivity | null> {
194
219
  try {
195
220
  return await dashboardApi<InboxActivity>(`/api/dashboard/email/activity?range=${range}`)
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { publicApplicationUrl } from './public-application-url'
3
+
4
+ describe('publicApplicationUrl', () => {
5
+ it('uses HTTPS for application domains and removes duplicate separators', () => {
6
+ expect(publicApplicationUrl('/blog', 'stacks.localhost/')).toBe('https://stacks.localhost/blog')
7
+ expect(publicApplicationUrl('/blog/post', 'https://stacksjs.com/')).toBe('https://stacksjs.com/blog/post')
8
+ })
9
+
10
+ it('keeps explicit local development protocols', () => {
11
+ expect(publicApplicationUrl('/blog', 'http://localhost:3000')).toBe('http://localhost:3000/blog')
12
+ })
13
+ })
@@ -0,0 +1,27 @@
1
+ import process from 'node:process'
2
+
3
+ function configuredApplicationUrl(): string {
4
+ const frontendUrl = process.env.FRONTEND_APP_URL?.trim()
5
+ if (frontendUrl && !frontendUrl.includes('${'))
6
+ return frontendUrl
7
+
8
+ return process.env.APP_URL?.trim() || 'http://localhost:3000'
9
+ }
10
+
11
+ function withProtocol(baseUrl: string): string {
12
+ if (/^https?:\/\//i.test(baseUrl))
13
+ return baseUrl
14
+
15
+ const host = baseUrl.split('/')[0].split(':')[0].toLowerCase()
16
+ const protocol = ['localhost', '127.0.0.1', '0.0.0.0', '[::1]'].includes(host)
17
+ ? 'http'
18
+ : 'https'
19
+ return `${protocol}://${baseUrl}`
20
+ }
21
+
22
+ /** Build an absolute URL for a page served by the public Stacks application. */
23
+ export function publicApplicationUrl(pathname = '/', baseUrl = configuredApplicationUrl()): string {
24
+ const normalizedBase = withProtocol(baseUrl.trim()).replace(/\/+$/, '')
25
+ const normalizedPath = pathname.replace(/^\/+/, '')
26
+ return new URL(normalizedPath, `${normalizedBase}/`).toString()
27
+ }
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.70.354",
5
+ "version": "0.70.355",
6
6
  "description": "A modern Stacks development environment.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.70.354",
5
+ "version": "0.70.355",
6
6
  "description": "The complete managed Stacks application scaffold, including runtime defaults, AI guidance, editor metadata, and npm-backed project support files.",
7
7
  "author": "Chris Breuer",
8
8
  "license": "MIT",
@@ -1,5 +1,6 @@
1
1
  <script client>
2
2
  import { DashboardApiError, dashboardApi } from '../../../../functions/dashboard-api'
3
+ import { authStore } from '../../../../views/dashboard/stores/auth'
3
4
 
4
5
  interface AccessTokenRow {
5
6
  id: number
@@ -106,6 +107,20 @@ async function loadTokens(): Promise<void> {
106
107
  loading.set(true)
107
108
  loadError.set('')
108
109
  try {
110
+ await authStore.load()
111
+
112
+ if (authStore.error()) {
113
+ tokens.set([])
114
+ loadError.set(authStore.error() || 'The signed-in account could not be verified.')
115
+ return
116
+ }
117
+
118
+ if (authStore.unauthenticated()) {
119
+ tokens.set([])
120
+ authenticationRequired.set(true)
121
+ return
122
+ }
123
+
109
124
  const response = await dashboardApi<TokenListResponse>('/auth/tokens')
110
125
  tokens.set(response.tokens || [])
111
126
  authenticationRequired.set(false)
@@ -5,7 +5,7 @@
5
5
  // (Action: Dashboard/Ci/StatusAction → @stacksjs/github). Active tab +
6
6
  // status filter live in the `ciStore` so they survive SPA navigation.
7
7
  //
8
- // No <script server>, no window./document. reads per #1838 the page
8
+ // No <script server>, no window./document. reads - per #1838 the page
9
9
  // is pure presentation against the store.
10
10
  import { useRole } from '~/storage/framework/defaults/views/dashboard/composables/useRole'
11
11
  import { ciStore } from '~/storage/framework/defaults/views/dashboard/stores/ci'
@@ -17,7 +17,7 @@
17
17
  // Role check is now backed by the real RBAC store + `/api/dashboard/auth/me`
18
18
  // (stacksjs/stacks#1843). `isDev()` returns true if the authenticated user
19
19
  // holds the `admin` or `dev` role, OR if the request is unauthenticated
20
- // (i.e. local dev dashboard with no bearer token) see `useRole.ts` for
20
+ // (i.e. local dev dashboard with no bearer token) - see `useRole.ts` for
21
21
  // the full resolution chain.
22
22
  const { isDev } = useRole()
23
23
  const canView = derived(() => isDev())
@@ -129,7 +129,7 @@
129
129
 
130
130
  // ─── Runner pressure sparkline (stacksjs/stacks#1850) ────────────
131
131
  //
132
- // Inline SVG sparkline no chart lib. The path is built off the
132
+ // Inline SVG sparkline - no chart lib. The path is built off the
133
133
  // active tab's sample history with a simple linear scale: x maps
134
134
  // to sample index (oldest left, newest right), y maps to queue
135
135
  // depth normalised against the larger of the org's cap or the
@@ -139,6 +139,8 @@
139
139
  const tab = activeTab()
140
140
  return (ci.runnerHistoryByOrg()[tab] || [])
141
141
  })
142
+ const runnerHistoryError = derived(() => ci.runnerHistoryErrors()[activeTab()] || '')
143
+ const loadingHistory = derived(() => ci.loadingRunnerHistory()[activeTab()] === true)
142
144
 
143
145
  effect(() => {
144
146
  const tab = activeTab()
@@ -173,7 +175,12 @@
173
175
  return samples && samples.length >= 2
174
176
  }
175
177
 
176
- // Top-level fetch — runs on both hard reload (SSR hydration) and SPA
178
+ function retryRunnerHistory() {
179
+ const tab = activeTab()
180
+ if (tab) ci.loadRunnerHistory(tab, true)
181
+ }
182
+
183
+ // Top-level fetch - runs on both hard reload (SSR hydration) and SPA
177
184
  // mount, since `<script client>` re-executes on every entry. The store
178
185
  // tracks its own loading flag so back-to-back calls don't double-fire.
179
186
  ci.load()
@@ -195,7 +202,7 @@
195
202
  <p class="mt-1 text-sm text-zinc-500">GitHub Actions health across the configured orgs. Configure in <code class="text-xs">config/dashboard.ts</code>.</p>
196
203
  </header>
197
204
 
198
- <!-- Disabled state the surface is off in config/dashboard.ts -->
205
+ <!-- Disabled state - the surface is off in config/dashboard.ts -->
199
206
  <div :if="disabled" class="px-5 py-8 text-center bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 rounded-lg dark:border-zinc-800">
200
207
  <p class="text-sm text-zinc-600 dark:text-zinc-400">CI tracking is disabled.</p>
201
208
  <p class="mt-1 text-xs text-zinc-500">Enable it in <code>config/dashboard.ts</code> by setting <code>ci.enabled = true</code> and listing one or more orgs.</p>
@@ -247,7 +254,7 @@
247
254
  <span class="text-zinc-300 dark:text-zinc-700">·</span>
248
255
  <span class="text-zinc-500 dark:text-zinc-400" :text="runnersText"></span>
249
256
  </div>
250
- <!-- Sparkline showing recent queue depth for the active org
257
+ <!-- Sparkline showing recent queue depth for the active org -
251
258
  appears only when alerts are on (history collection is gated
252
259
  on `ci.alerts.enabled`). Drawn inline as a single SVG path
253
260
  so we don't pull a chart library for 20 datapoints. -->
@@ -257,6 +264,17 @@
257
264
  <path :d="sparklinePath()" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" />
258
265
  </svg>
259
266
  </div>
267
+ <div :if="loadingHistory() && !hasHistory()" role="status" class="flex gap-2 items-center mb-5 text-zinc-500 text-xs dark:text-zinc-400">
268
+ <span aria-hidden="true" class="h-3.5 w-3.5 animate-spin i-hugeicons-loading-03"></span>
269
+ <span>Loading runner history</span>
270
+ </div>
271
+ <div :if="runnerHistoryError()" role="alert" class="flex flex-wrap gap-3 items-center justify-between mb-5 px-3 py-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 rounded-lg dark:border-amber-900/50">
272
+ <div>
273
+ <p class="font-medium text-amber-800 text-xs dark:text-amber-200">Runner history unavailable</p>
274
+ <p class="mt-0.5 text-amber-700 text-xs dark:text-amber-300" :text="runnerHistoryError"></p>
275
+ </div>
276
+ <Button variant="secondary" size="xs" :loading="loadingHistory" @click="retryRunnerHistory()">Retry</Button>
277
+ </div>
260
278
 
261
279
  <!-- Repo cards -->
262
280
  <div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(340px, 1fr))">
@@ -132,7 +132,7 @@ function errorMessages(): string[] {
132
132
  <input x-model="exemptible" type="checkbox" class="mt-0.5 h-4 w-4 text-blue-600 border-gray-300 rounded" />
133
133
  <span>
134
134
  <span class="block font-medium text-gray-700 text-sm dark:text-neutral-200">Can be exempted</span>
135
- <span class="block mt-0.5 text-gray-500 text-xs dark:text-neutral-400">Customers who qualify for an exemption are not charged this component. Leave off for anything owed regardless an excise or a city levy is usually still due.</span>
135
+ <span class="block mt-0.5 text-gray-500 text-xs dark:text-neutral-400">Customers who qualify for an exemption are not charged this component. Leave off for anything owed regardless - an excise or a city levy is usually still due.</span>
136
136
  </span>
137
137
  </label>
138
138
  </div>
@@ -23,6 +23,13 @@ const deleteRecord = state<ProductWaitlistRecord | null>(null)
23
23
  const isSaving = state(false)
24
24
  const isDeleting = state(false)
25
25
  const validationErrors = state<Record<string, string>>({})
26
+ const metrics = derived(() => [
27
+ { label: 'Recorded', value: summary().total },
28
+ { label: 'Waiting', value: summary().waiting },
29
+ { label: 'Notified', value: summary().notified },
30
+ { label: 'Purchased', value: summary().purchased },
31
+ { label: 'Conversion', value: `${summary().conversionRate}%` },
32
+ ])
26
33
 
27
34
  function rows(): ProductWaitlistRecord[] {
28
35
  const query = search().trim().toLowerCase()
@@ -166,13 +173,7 @@ onMount(() => {
166
173
  </div>
167
174
 
168
175
  <section class="grid gap-3 grid-cols-2 lg:grid-cols-5">
169
- <template :for="item in [
170
- { label: 'Recorded', value: summary().total },
171
- { label: 'Waiting', value: summary().waiting },
172
- { label: 'Notified', value: summary().notified },
173
- { label: 'Purchased', value: summary().purchased },
174
- { label: 'Conversion', value: summary().conversionRate + '%' }
175
- ]">
176
+ <template :for="item in metrics()">
176
177
  <div class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
177
178
  <p class="text-gray-500 text-xs uppercase dark:text-neutral-400">{{ item.label }}</p>
178
179
  <p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p>