@stacksjs/defaults 0.72.98 → 0.72.99

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 (87) hide show
  1. package/ai/skills/stacks-auth/SKILL.md +35 -5
  2. package/ai/skills/stacks-config/SKILL.md +11 -1
  3. package/ai/skills/stacks-desktop/SKILL.md +46 -0
  4. package/ai/skills/stacks-env/SKILL.md +26 -0
  5. package/ai/skills/stacks-events/SKILL.md +76 -60
  6. package/ai/skills/stacks-jobs/SKILL.md +3 -0
  7. package/ai/skills/stacks-listeners/SKILL.md +39 -9
  8. package/ai/skills/stacks-middleware/SKILL.md +50 -12
  9. package/ai/skills/stacks-queue/SKILL.md +5 -0
  10. package/ai/skills/stacks-router/SKILL.md +17 -7
  11. package/ai/skills/stacks-scheduler/SKILL.md +1 -1
  12. package/app/Actions/Dashboard/Commerce/CommerceCustomersAction.ts +1 -1
  13. package/app/Actions/Dashboard/Commerce/CommerceDeliveryAction.ts +1 -1
  14. package/app/Actions/Dashboard/Commerce/CommerceOrdersAction.ts +1 -1
  15. package/app/Actions/Dashboard/Commerce/CommercePosAction.ts +1 -1
  16. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +1 -1
  17. package/app/Actions/Dashboard/Commerce/CommerceProductDetailAction.ts +1 -1
  18. package/app/Actions/Dashboard/Commerce/CommerceProductsAction.ts +1 -1
  19. package/app/Actions/Dashboard/Content/content-input.ts +2 -2
  20. package/app/Actions/Dashboard/Content/post-input.ts +4 -4
  21. package/app/Actions/Dashboard/Email/inbox-request.ts +1 -1
  22. package/app/Actions/Dashboard/Email/mail-preference.ts +1 -1
  23. package/app/Actions/Dashboard/Marketing/CampaignIndexAction.ts +1 -1
  24. package/app/Actions/Dashboard/Marketing/CampaignStoreAction.ts +1 -1
  25. package/app/Actions/Dashboard/Marketing/CampaignUpdateAction.ts +1 -1
  26. package/app/Actions/Dashboard/Teams/TeamInvitationDestroyAction.ts +2 -2
  27. package/app/Actions/Dashboard/Teams/TeamInvitationResendAction.ts +2 -2
  28. package/app/Actions/Dashboard/Teams/TeamInviteAction.ts +5 -5
  29. package/app/Actions/Dashboard/Teams/TeamMemberDestroyAction.ts +2 -2
  30. package/app/Actions/Dashboard/Teams/TeamMemberUpdateAction.ts +2 -2
  31. package/app/Actions/Dashboard/Teams/TeamPeopleIndexAction.ts +2 -2
  32. package/app/Actions/Dashboard/Teams/team-invitation-delivery.ts +2 -2
  33. package/app/Events.ts +9 -4
  34. package/app/Gates.ts +83 -85
  35. package/app/Listener.ts +34 -81
  36. package/app/Middleware.ts +22 -7
  37. package/ide/vscode/package.json +1 -1
  38. package/package.json +2 -2
  39. package/project/storage/framework/server/tsconfig.docker.json +1 -2
  40. package/resources/components/Buttons/Counter.stx +30 -21
  41. package/resources/components/Dashboard/Analytics/WebAnalyticsDashboard.stx +1 -3
  42. package/resources/components/Dashboard/Auth/ForgotPasswordDashboard.stx +1 -1
  43. package/resources/components/Dashboard/Auth/LoginDashboard.stx +2 -1
  44. package/resources/components/Dashboard/Auth/RegisterDashboard.stx +2 -1
  45. package/resources/components/Dashboard/Auth/ResetPasswordDashboard.stx +1 -1
  46. package/resources/components/Dashboard/Ci/CiDashboard.stx +9 -9
  47. package/resources/components/Dashboard/Ci/CiRunHistoryDrawer.stx +7 -7
  48. package/resources/components/Dashboard/Commerce/CommerceOverviewCharts.stx +2 -1
  49. package/resources/components/Dashboard/Commerce/CommercePaymentsDashboard.stx +1 -1
  50. package/resources/components/Dashboard/Content/BlogDashboard.stx +37 -15
  51. package/resources/components/Dashboard/Content/ContentDashboard.stx +3 -26
  52. package/resources/components/Dashboard/Email/EmailActivityDashboard.stx +2 -15
  53. package/resources/components/Dashboard/Environment/EnvironmentDashboard.stx +9 -1
  54. package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +15 -13
  55. package/resources/components/Dashboard/Kanban/KanbanBoardsDashboard.stx +10 -8
  56. package/resources/components/Dashboard/Kanban/KanbanCardDialog.stx +10 -10
  57. package/resources/components/Dashboard/Management/PermissionsDashboard.stx +3 -3
  58. package/resources/components/Dashboard/Modals/Popups/Alert.stx +4 -1
  59. package/resources/components/Dashboard/Navbar.stx +1 -1
  60. package/resources/components/Dashboard/Settings/AppearanceSettingsDashboard.stx +11 -6
  61. package/resources/components/Dashboard/Settings/SettingsDashboard.stx +2 -2
  62. package/resources/components/Dashboard/Transaction/index.stx +6 -6
  63. package/resources/components/Dashboard/UI/Avatar.stx +2 -2
  64. package/resources/components/Dashboard/UI/DropdownItem.stx +4 -4
  65. package/resources/components/Dashboard/UI/WindowControls.stx +17 -2
  66. package/resources/components/Docs/Demo/ComboboxDemo.stx +8 -3
  67. package/resources/components/Docs/Demo/DropdownDemo.stx +3 -3
  68. package/resources/components/Docs/Demo/StepperDemo.stx +1 -1
  69. package/resources/components/Forum/ForumLayout.stx +1 -1
  70. package/resources/components/Forum/ForumReplyForm.stx +1 -1
  71. package/resources/components/Marketing/ComingSoon.stx +5 -5
  72. package/resources/components/Storefront/CartDrawer.stx +44 -22
  73. package/resources/components/Storefront/ProductGallery.stx +3 -2
  74. package/resources/components/Storefront/QuantitySelector.stx +12 -7
  75. package/resources/functions/storefront/cart-cookie.ts +58 -0
  76. package/resources/layouts/storefront.stx +3 -2
  77. package/resources/views/cart.stx +19 -3
  78. package/resources/views/checkout/contact.stx +14 -3
  79. package/resources/views/checkout/payment.stx +14 -3
  80. package/resources/views/checkout/shipping.stx +14 -3
  81. package/resources/views/cms/blocks/form.stx +16 -11
  82. package/resources/views/cms/page.stx +28 -0
  83. package/resources/views/coming-soon.stx +0 -6
  84. package/resources/views/orders/[id].stx +19 -3
  85. package/views/dashboard/composables/index.ts +1 -1
  86. package/views/dashboard/composables/useChart.ts +26 -14
  87. package/views/dashboard/layouts/default.stx +42 -25
@@ -24,7 +24,7 @@ export default new Action({
24
24
  allRoutes,
25
25
  allZones,
26
26
  allDrivers,
27
- (config as any).commerce?.currency,
27
+ config.commerce?.currency,
28
28
  )
29
29
  }
30
30
  catch (error) {
@@ -50,7 +50,7 @@ export default new Action({
50
50
  .sort((left, right) => left.label.localeCompare(right.label)),
51
51
  statuses: [...new Set([...defaultStatuses, ...records.map(record => record.status)])],
52
52
  orderTypes: [...new Set([...defaultTypes, ...records.map(record => record.orderType)])],
53
- defaultCurrency: commerceCurrency((config as any).commerce?.currency, 'Commerce configuration'),
53
+ defaultCurrency: commerceCurrency(config.commerce?.currency, 'Commerce configuration'),
54
54
  }
55
55
  }
56
56
  catch (error) {
@@ -53,7 +53,7 @@ export default new Action({
53
53
  .filter(isCommercePosCustomerActive)
54
54
  .map(normalizeCommercePosCustomer),
55
55
  taxRate: selectCommercePosTaxRate(taxRates),
56
- defaultCurrency: normalizeCommerceCurrency((config as any).commerce?.currency),
56
+ defaultCurrency: normalizeCommerceCurrency(config.commerce?.currency),
57
57
  paymentMethods: [{ id: 'cash', label: 'Cash' }],
58
58
  summary: {
59
59
  products: records.length,
@@ -285,7 +285,7 @@ export default new Action({
285
285
  return dashboardOperationalError(error, 'Sale totals could not be calculated.', 'CommercePosCheckoutAction')
286
286
  }
287
287
 
288
- const currency = normalizeCommerceCurrency((config as any).commerce?.currency)
288
+ const currency = normalizeCommerceCurrency(config.commerce?.currency)
289
289
  const transactionId = randomUUIDv7()
290
290
  const referenceNumber = `POS-${transactionId.slice(0, 12).toUpperCase()}`
291
291
  let result
@@ -58,7 +58,7 @@ export default new Action({
58
58
  reviews: summarizeProductReviews(reviews),
59
59
  categories: categoryOptions,
60
60
  manufacturers: manufacturerOptions,
61
- defaultCurrency: normalizeCommerceCurrency((config as any).commerce?.currency),
61
+ defaultCurrency: normalizeCommerceCurrency(config.commerce?.currency),
62
62
  }
63
63
  }
64
64
  catch (error) {
@@ -52,7 +52,7 @@ export default new Action({
52
52
  summary: summarizeCommerceProducts(records),
53
53
  categories: categoryOptions,
54
54
  manufacturers: manufacturerOptions,
55
- defaultCurrency: normalizeCommerceCurrency((config as any).commerce?.currency),
55
+ defaultCurrency: normalizeCommerceCurrency(config.commerce?.currency),
56
56
  }
57
57
  }
58
58
  catch (error) {
@@ -54,12 +54,12 @@ export function slugify(value: string): string {
54
54
  * query builder's types want a table union rather than a `string`.
55
55
  */
56
56
  export async function findRow(table: string, id: number, database: typeof db = db): Promise<unknown> {
57
- return await (database as any).selectFrom(table).selectAll().where('id', '=', id).executeTakeFirst()
57
+ return await database.selectFrom(table).selectAll().where('id', '=', id).executeTakeFirst()
58
58
  }
59
59
 
60
60
  /** Whether a row with this id exists — the 404 check every write shares. */
61
61
  export async function rowExists(table: string, id: number, database: typeof db = db): Promise<boolean> {
62
- const row = await (database as any).selectFrom(table).select(['id']).where('id', '=', id).executeTakeFirst()
62
+ const row = await database.selectFrom(table).select(['id']).where('id', '=', id).executeTakeFirst()
63
63
 
64
64
  return Boolean(row)
65
65
  }
@@ -116,7 +116,7 @@ async function replacePostPivot(
116
116
  relatedIds: number[],
117
117
  now: string,
118
118
  ): Promise<void> {
119
- await (database as any)
119
+ await database
120
120
  .deleteFrom(table)
121
121
  .where(ownerKey, '=', postId)
122
122
  .where(typeKey, '=', 'posts')
@@ -125,7 +125,7 @@ async function replacePostPivot(
125
125
  if (relatedIds.length === 0)
126
126
  return
127
127
 
128
- await (database as any)
128
+ await database
129
129
  .insertInto(table)
130
130
  .values(relatedIds.map(relatedId => ({
131
131
  [foreignKey]: relatedId,
@@ -146,12 +146,12 @@ export async function syncPostRelations(database: typeof db, postId: number, pay
146
146
 
147
147
  /** Removes both post pivot sets on the caller's database or transaction. */
148
148
  export async function detachPostRelations(database: typeof db, postId: number): Promise<void> {
149
- await (database as any)
149
+ await database
150
150
  .deleteFrom('categorizable_models')
151
151
  .where('categorizable_id', '=', postId)
152
152
  .where('categorizable_type', '=', 'posts')
153
153
  .execute()
154
- await (database as any)
154
+ await database
155
155
  .deleteFrom('taggable_models')
156
156
  .where('taggable_id', '=', postId)
157
157
  .where('taggable_type', '=', 'posts')
@@ -5,7 +5,7 @@ import { response } from '@stacksjs/router'
5
5
  import { defaultMailbox } from './mail-preference'
6
6
 
7
7
  function configuredMailDomain(): string {
8
- const email = (config as any)?.email || {}
8
+ const email = config?.email || {}
9
9
  const fromAddress = String(email.from?.address || '').trim()
10
10
  const fromDomain = fromAddress.includes('@') ? fromAddress.split('@').at(-1) : ''
11
11
  return String(email.domain || fromDomain || 'stacksjs.com').trim().toLowerCase()
@@ -26,7 +26,7 @@ export interface MailPreferenceInput {
26
26
  }
27
27
 
28
28
  export function defaultMailbox(): string {
29
- const email = (config as any)?.email || {}
29
+ const email = config?.email || {}
30
30
  const domain = String(email.domain || 'stacksjs.com')
31
31
  const configured = Array.isArray(email.mailboxes) ? email.mailboxes[0] : undefined
32
32
  const mailbox = typeof configured === 'string' ? configured : configured?.email
@@ -55,7 +55,7 @@ export default new Action({
55
55
  sendRows,
56
56
  openedRows,
57
57
  clickedRows,
58
- String((config as any).commerce?.currency || 'USD').toUpperCase(),
58
+ String(config.commerce?.currency || 'USD').toUpperCase(),
59
59
  )
60
60
  }
61
61
  catch (error) {
@@ -15,7 +15,7 @@ export default new Action({
15
15
  async handle(request: RequestInstance) {
16
16
  const data = campaignWriteData(
17
17
  await request.all(),
18
- String((config as any).commerce?.currency || 'USD').toUpperCase(),
18
+ String(config.commerce?.currency || 'USD').toUpperCase(),
19
19
  )
20
20
  const validationError = validateCampaignWriteData(data)
21
21
  if (validationError)
@@ -18,7 +18,7 @@ export default new Action({
18
18
  return response.json({ message: 'A valid campaign id is required.' }, 400)
19
19
  const data = campaignWriteData(
20
20
  await request.all(),
21
- String((config as any).commerce?.currency || 'USD').toUpperCase(),
21
+ String(config.commerce?.currency || 'USD').toUpperCase(),
22
22
  )
23
23
  const validationError = validateCampaignWriteData(data)
24
24
  if (validationError)
@@ -25,7 +25,7 @@ export default new Action({
25
25
 
26
26
  let invitation
27
27
  try {
28
- invitation = await (db as any)
28
+ invitation = await db
29
29
  .selectFrom('team_invitations')
30
30
  .where('id', '=', invitationId)
31
31
  .where('team_id', '=', teamId)
@@ -41,7 +41,7 @@ export default new Action({
41
41
  return response.json({ message: 'Only pending invitations can be revoked.' }, 409)
42
42
 
43
43
  try {
44
- const result = await (db as any)
44
+ const result = await db
45
45
  .updateTable('team_invitations')
46
46
  .set({
47
47
  status: 'revoked',
@@ -28,7 +28,7 @@ export default new Action({
28
28
 
29
29
  let invitation
30
30
  try {
31
- invitation = await (db as any)
31
+ invitation = await db
32
32
  .selectFrom('team_invitations')
33
33
  .innerJoin('teams', 'teams.id', '=', 'team_invitations.team_id')
34
34
  .where('team_invitations.id', '=', invitationId)
@@ -55,7 +55,7 @@ export default new Action({
55
55
  const tokenHash = hashInvitationToken(token)
56
56
  const expiresAt = invitationExpiresAt()
57
57
  try {
58
- const result = await (db as any)
58
+ const result = await db
59
59
  .updateTable('team_invitations')
60
60
  .set({
61
61
  token_hash: tokenHash,
@@ -69,7 +69,7 @@ export default new Action({
69
69
  .where('email', '=', email)
70
70
  .select(['id'])
71
71
  .executeTakeFirst(),
72
- (trx as any)
72
+ trx
73
73
  .selectFrom('team_invitations')
74
74
  .where('team_id', '=', teamId)
75
75
  .where('email', '=', email)
@@ -78,7 +78,7 @@ export default new Action({
78
78
  .executeTakeFirst(),
79
79
  ])
80
80
  if (existingUser) {
81
- const membership = await (trx as any)
81
+ const membership = await trx
82
82
  .selectFrom('team_members')
83
83
  .where('team_id', '=', teamId)
84
84
  .where('user_id', '=', Number(existingUser.id))
@@ -92,7 +92,7 @@ export default new Action({
92
92
  return response.json({ message: 'A pending invitation already exists. Resend or revoke it from the invitation list.' }, 409)
93
93
  }
94
94
  if (pending) {
95
- const expired = await (trx as any)
95
+ const expired = await trx
96
96
  .updateTable('team_invitations')
97
97
  .set({ status: 'expired', pending_key: null, updated_at: sqlTimestamp() })
98
98
  .where('id', '=', Number(pending.id))
@@ -103,7 +103,7 @@ export default new Action({
103
103
  throw new TeamStateConflictError('The pending invitation changed before it could be replaced.')
104
104
  }
105
105
 
106
- await (trx as any)
106
+ await trx
107
107
  .insertInto('team_invitations')
108
108
  .values({
109
109
  team_id: teamId,
@@ -119,7 +119,7 @@ export default new Action({
119
119
  })
120
120
  .execute()
121
121
 
122
- const invitation = await (trx as any)
122
+ const invitation = await trx
123
123
  .selectFrom('team_invitations')
124
124
  .where('token_hash', '=', tokenHash)
125
125
  .select(['id'])
@@ -22,7 +22,7 @@ export default new Action({
22
22
  try {
23
23
  result = await db.transaction(async (rawTrx) => {
24
24
  const trx = rawTrx as unknown as typeof db
25
- const member = await (trx as any)
25
+ const member = await trx
26
26
  .selectFrom('team_members')
27
27
  .where('id', '=', memberId)
28
28
  .where('team_id', '=', teamId)
@@ -33,7 +33,7 @@ export default new Action({
33
33
  if (member.role === 'owner')
34
34
  return response.json({ message: 'Transfer team ownership before removing the owner.' }, 409)
35
35
 
36
- const deleted = await (trx as any)
36
+ const deleted = await trx
37
37
  .deleteFrom('team_members')
38
38
  .where('id', '=', memberId)
39
39
  .where('team_id', '=', teamId)
@@ -27,7 +27,7 @@ export default new Action({
27
27
  try {
28
28
  return await db.transaction(async (rawTrx) => {
29
29
  const trx = rawTrx as unknown as typeof db
30
- const member = await (trx as any)
30
+ const member = await trx
31
31
  .selectFrom('team_members')
32
32
  .where('id', '=', memberId)
33
33
  .where('team_id', '=', teamId)
@@ -45,7 +45,7 @@ export default new Action({
45
45
  if (!['active', 'suspended'].includes(status))
46
46
  return response.json({ message: 'Choose a valid member status.' }, 422)
47
47
 
48
- const updated = await (trx as any)
48
+ const updated = await trx
49
49
  .updateTable('team_members')
50
50
  .set({ role, status, updated_at: sqlTimestamp() })
51
51
  .where('id', '=', memberId)
@@ -32,7 +32,7 @@ export default new Action({
32
32
 
33
33
  try {
34
34
  const [members, invitations] = await Promise.all([
35
- (db as any)
35
+ db
36
36
  .selectFrom('team_members')
37
37
  .innerJoin('users', 'users.id', '=', 'team_members.user_id')
38
38
  .where('team_members.team_id', '=', teamId)
@@ -47,7 +47,7 @@ export default new Action({
47
47
  ])
48
48
  .orderBy('users.name', 'asc')
49
49
  .execute(),
50
- (db as any)
50
+ db
51
51
  .selectFrom('team_invitations')
52
52
  .where('team_id', '=', teamId)
53
53
  .where('status', '=', 'pending')
@@ -45,7 +45,7 @@ export async function deliverTeamInvitation(input: DeliveryInput): Promise<void>
45
45
  }
46
46
 
47
47
  try {
48
- const result = await (db as any)
48
+ const result = await db
49
49
  .updateTable('team_invitations')
50
50
  .set({
51
51
  delivery_status: 'sent',
@@ -66,7 +66,7 @@ export async function deliverTeamInvitation(input: DeliveryInput): Promise<void>
66
66
  }
67
67
 
68
68
  export async function markTeamInvitationDeliveryFailed(id: number, tokenHash: string, error: string): Promise<void> {
69
- await (db as any)
69
+ await db
70
70
  .updateTable('team_invitations')
71
71
  .set({
72
72
  delivery_status: 'failed',
package/app/Events.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Events } from '@stacksjs/types'
1
+ import { defineEvents } from '@stacksjs/events'
2
2
 
3
3
  /**
4
4
  * **Events Configuration**
@@ -6,9 +6,14 @@ import type { Events } from '@stacksjs/types'
6
6
  * This configuration defines all of your events. Because Stacks is fully-typed, you may
7
7
  * hover any of the options below and the definitions will be provided. In case you
8
8
  * have any questions, feel free to reach out via Discord or GitHub Discussions.
9
+ *
10
+ * Both halves of every entry are checked. The key must be an event that exists -
11
+ * declare your own on `AppEvents` in `@stacksjs/events` - and each listener must
12
+ * name a file under `app/Listeners/` or `app/Actions/`, or one of the framework
13
+ * defaults behind them.
9
14
  */
10
- export default {
11
- // eventName: ['Listener1', 'Listener2'] -> listeners default to ./app/actions/*
15
+ export default defineEvents({
16
+ // eventName: ['Listener1', 'Listener2'] -> listeners resolve from ./app/Listeners/*, then ./app/Actions/*
12
17
  'user:registered': ['SendWelcomeEmail'],
13
18
  'user:created': ['NotifyUser'],
14
- } satisfies Events
19
+ })
package/app/Gates.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { UserModel } from '@stacksjs/orm'
2
+ import { defineGates } from '@stacksjs/auth'
2
3
 
3
4
  /**
4
5
  * Authorization Gates Configuration
@@ -7,101 +8,98 @@ import type { UserModel } from '@stacksjs/orm'
7
8
  * Gates provide a simple way to authorize actions, while policies
8
9
  * organize authorization logic around particular models.
9
10
  *
10
- * @see https://stacksjs.org/docs/security/authorization
11
- */
12
-
13
- /**
14
- * Gate definitions
11
+ * Registered at boot by `initializeAuthorization()`, which every entry point
12
+ * comes through - HTTP, `buddy seed`, a scheduled job, a console command.
15
13
  *
16
- * Simple ability checks that don't require a model.
17
- *
18
- * @example
19
- * // In your code:
20
- * import { Gate } from '@stacksjs/auth'
21
- *
22
- * if (await Gate.allows('edit-settings', user)) {
23
- * // User can edit settings
24
- * }
14
+ * @see https://stacksjs.org/docs/security/authorization
25
15
  */
26
- export const gates = {
16
+ export default defineGates({
27
17
  /**
28
- * Check if user can access admin area
18
+ * Gate definitions
19
+ *
20
+ * Simple ability checks that don't require a model. The names below are what
21
+ * `Gate.allows('...')` offers as completions.
22
+ *
23
+ * @example
24
+ * import { Gate } from '@stacksjs/auth'
25
+ *
26
+ * if (await Gate.allows('edit-settings', user)) {
27
+ * // User can edit settings
28
+ * }
29
29
  */
30
- 'access-admin': (user: UserModel | null) => {
31
- return user?.email?.endsWith('@stacksjs.org') ?? false
30
+ gates: {
31
+ /** Check if user can access admin area */
32
+ 'access-admin': (user: UserModel | null) => {
33
+ return user?.email?.endsWith('@stacksjs.org') ?? false
34
+ },
35
+
36
+ /** Check if user can edit application settings */
37
+ 'edit-settings': (user: UserModel | null) => {
38
+ // Add your logic here
39
+ return user !== null
40
+ },
41
+
42
+ /** Check if user can view dashboard */
43
+ 'view-dashboard': (user: UserModel | null) => {
44
+ return user !== null
45
+ },
46
+
47
+ // Add more gates here...
48
+ // 'ability-name': (user, ...args) => boolean,
32
49
  },
33
50
 
34
51
  /**
35
- * Check if user can edit application settings
52
+ * Policy mappings
53
+ *
54
+ * Map model names to their policy classes. Policy files live in
55
+ * `app/Policies/`, or in the framework defaults behind it. Both halves are
56
+ * checked: the key names a model that exists, the value a policy that does.
57
+ *
58
+ * A policy whose file is named `<Model>Policy.ts` is picked up by convention
59
+ * and does not need an entry here. An entry WINS over the convention, which
60
+ * is the point of writing one.
61
+ *
62
+ * @example
63
+ * // Simple mapping (uses PostPolicy for the Post model)
64
+ * Post: 'PostPolicy',
65
+ *
66
+ * // Or with config:
67
+ * Post: {
68
+ * policy: 'PostPolicy',
69
+ * model: 'Post',
70
+ * },
36
71
  */
37
- 'edit-settings': (user: UserModel | null) => {
38
- // Add your logic here
39
- return user !== null
72
+ policies: {
73
+ // Post: 'PostPolicy',
74
+ // User: 'UserPolicy',
75
+ // Comment: 'CommentPolicy',
40
76
  },
41
77
 
42
78
  /**
43
- * Check if user can view dashboard
79
+ * Before callbacks
80
+ *
81
+ * Run before any gate/policy check. Return true to allow,
82
+ * false to deny, or null to continue to the actual check.
44
83
  */
45
- 'view-dashboard': (user: UserModel | null) => {
46
- return user !== null
47
- },
48
-
49
- // Add more gates here...
50
- // 'ability-name': (user, ...args) => boolean,
51
- }
84
+ before: [
85
+ // Example: Super admin bypass
86
+ // (user) => {
87
+ // if (user?.role === 'super-admin')
88
+ // return true // Allow everything for super admins
89
+ // return null // Continue to normal checks
90
+ // },
91
+ ],
52
92
 
53
- /**
54
- * Policy mappings
55
- *
56
- * Map model names to their policy classes.
57
- * Policy files should be in app/Policies/ directory.
58
- *
59
- * @example
60
- * // Simple mapping (uses PostPolicy for Post model)
61
- * 'Post': 'PostPolicy',
62
- *
63
- * // Or with config:
64
- * 'Post': {
65
- * policy: 'PostPolicy',
66
- * model: Post,
67
- * },
68
- */
69
- export const policies: Record<string, string | { policy: string, model?: string }> = {
70
- // 'Post': 'PostPolicy',
71
- // 'User': 'UserPolicy',
72
- // 'Comment': 'CommentPolicy',
73
- }
74
-
75
- /**
76
- * Before callbacks
77
- *
78
- * Run before any gate/policy check. Return true to allow,
79
- * false to deny, or null to continue to the actual check.
80
- *
81
- * @example
82
- * // Super admins bypass all checks
83
- * (user) => user?.role === 'super-admin' ? true : null
84
- */
85
- export const before: Array<(user: UserModel | null, ability: string, args: any[]) => boolean | null | Promise<boolean | null>> = [
86
- // Example: Super admin bypass
87
- // (user, ability) => {
88
- // if (user?.role === 'super-admin') {
89
- // return true // Allow everything for super admins
90
- // }
91
- // return null // Continue to normal checks
92
- // },
93
- ]
94
-
95
- /**
96
- * After callbacks
97
- *
98
- * Run after gate/policy checks. Can override the result.
99
- */
100
- export const after: Array<(user: UserModel | null, ability: string, result: boolean, args: any[]) => boolean | void | Promise<boolean | void>> = [
101
- // Example: Log all authorization checks
102
- // (user, ability, result) => {
103
- // console.log(`User ${user?.id} ${result ? 'allowed' : 'denied'} for ${ability}`)
104
- // },
105
- ]
106
-
107
- export default { gates, policies, before, after }
93
+ /**
94
+ * After callbacks
95
+ *
96
+ * Run after gate/policy checks. Returning a boolean overrides the result;
97
+ * returning nothing keeps it.
98
+ */
99
+ after: [
100
+ // Example: Log all authorization checks
101
+ // (user, ability, result) => {
102
+ // console.log(`User ${user?.id} ${result ? 'allowed' : 'denied'} for ${ability}`)
103
+ // },
104
+ ],
105
+ })
package/app/Listener.ts CHANGED
@@ -1,84 +1,37 @@
1
- import type { StacksEvents, WildcardHandler } from '@stacksjs/events'
2
- import { handleError } from '@stacksjs/error-handling'
3
- import { emitter } from '@stacksjs/events'
1
+ import { registerAppListeners } from '@stacksjs/events'
4
2
  import { path as p } from '@stacksjs/path'
5
- import events from './Events'
6
3
 
7
- // Cache resolved action modules to avoid repeated dynamic imports
8
- const actionCache = new Map<string, { handle: (event: any) => Promise<any> | any }>()
9
-
10
- // Deduplicate in-flight imports to prevent concurrent import races
11
- const pendingImports = new Map<string, Promise<{ handle: (event: any) => Promise<any> | any } | null>>()
12
-
13
- // Pre-compute which event types have listeners for O(1) lookup
14
- const eventTypes = new Set(Object.keys(events))
15
-
16
- export async function handleEvents() {
17
- emitter.on('*', listenEvents as WildcardHandler<StacksEvents>)
18
- }
19
-
20
- function listenEvents(type: keyof typeof events, event: any) {
21
- // Fast path: skip events with no registered listeners
22
- if (!eventTypes.has(type as string))
23
- return
24
-
25
- const eventListeners = events[type]
26
- if (!eventListeners || eventListeners.length === 0)
27
- return
28
-
29
- // Fire-and-forget with error handling — mitt doesn't await wildcard handlers
30
- processListeners(type as string, eventListeners, event)
31
- }
32
-
33
- async function resolveAction(listener: string): Promise<{ handle: (event: any) => Promise<any> | any } | null> {
34
- // Check cache first
35
- const cached = actionCache.get(listener)
36
- if (cached) return cached
37
-
38
- // Check if an import is already in-flight
39
- let pending = pendingImports.get(listener)
40
- if (pending) return pending
41
-
42
- // Start the import and cache the promise to deduplicate concurrent calls
43
- pending = (async () => {
44
- try {
45
- const imported = await import(p.appPath(`Actions/${listener}.ts`))
46
- const actionModule = imported.default
47
- if (actionModule && typeof actionModule.handle === 'function') {
48
- actionCache.set(listener, actionModule)
49
- return actionModule
50
- }
51
- handleError(`Action "${listener}" does not export a handle method`)
52
- return null
53
- }
54
- catch (error) {
55
- handleError(`Failed to import action "${listener}"`, error)
56
- return null
57
- }
58
- finally {
59
- pendingImports.delete(listener)
60
- }
61
- })()
62
-
63
- pendingImports.set(listener, pending)
64
- return pending
65
- }
66
-
67
- async function processListeners(type: string, listeners: string[], event: any) {
68
- for (const listener of listeners) {
69
- try {
70
- if (typeof listener === 'function') {
71
- await (listener as any)(event)
72
- continue
73
- }
74
-
75
- const action = await resolveAction(listener)
76
- if (action) {
77
- await action.handle(event)
78
- }
79
- }
80
- catch (error) {
81
- handleError(`Failed to execute listener "${listener}" for event "${type}"`, error)
82
- }
83
- }
4
+ /**
5
+ * **Event Listener Bootstrap**
6
+ *
7
+ * Registers every listener the application declares: the `app/Events.ts` map of
8
+ * event name to listener names, and any module under `app/Listeners/` that
9
+ * declares its own `listensTo`.
10
+ *
11
+ * This file used to carry its own implementation of the first half - a wildcard
12
+ * handler that read the map on every dispatch and imported the named action out
13
+ * of `app/Actions/`. It was a second implementation of what
14
+ * `registerAppListeners` already does, and having two was worse than having the
15
+ * wrong one:
16
+ *
17
+ * - In dev they both ran. `injectGlobalAutoImports()` calls
18
+ * `registerAppListeners()` and the dev API entrypoint then called
19
+ * `handleEvents()`, so every listener named in the map fired TWICE per
20
+ * event - two welcome emails, two rows, and no error anywhere to say so.
21
+ * - The copy resolved names against `app/Actions/` alone, so a listener under
22
+ * `app/Listeners/` and any of the framework's default actions were
23
+ * unreachable from the map.
24
+ * - It matched events by exact name only, dropping the glob subscriptions
25
+ * (`user:*`) the emitter supports.
26
+ *
27
+ * Delegating keeps this file as the app's hook into boot - override it to
28
+ * register listeners of your own - with one implementation behind it, whose
29
+ * registry also makes the call idempotent across a dev-server reload.
30
+ */
31
+ export async function handleEvents(): Promise<number> {
32
+ // Anchored to the project root rather than left to `process.cwd()`. A buddy
33
+ // command, a test runner or a worker started from a subdirectory would
34
+ // otherwise look for `app/Events.ts` beside wherever it happened to be
35
+ // launched, find nothing, and register no listeners without saying so.
36
+ return registerAppListeners({ base: p.projectPath() })
84
37
  }