@stacksjs/defaults 0.74.27 → 0.74.29
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.
- package/ai/skills/stacks-auth/SKILL.md +1 -1
- package/ai/skills/stacks-buddy/SKILL.md +1 -1
- package/ai/skills/stacks-docs/SKILL.md +1 -1
- package/ai/skills/stacks-email/SKILL.md +1 -1
- package/ai/skills/stacks-middleware/SKILL.md +23 -0
- package/ai/skills/stacks-migrations/SKILL.md +1 -1
- package/ai/skills/stacks-models/SKILL.md +0 -1
- package/app/Actions/Dashboard/Marketing/campaign-records.test.ts +2 -2
- package/app/Gates.ts +2 -2
- package/app/Middleware/Csrf.ts +83 -37
- package/app/Middleware.ts +3 -2
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/Dashboard/Home/DashboardHome.stx +1 -1
- package/stx-components-plugin.ts +42 -1
|
@@ -378,7 +378,7 @@ import { defineGates } from '@stacksjs/auth'
|
|
|
378
378
|
|
|
379
379
|
export default defineGates({
|
|
380
380
|
gates: {
|
|
381
|
-
'access-admin': user => user?.email?.endsWith('@stacksjs.
|
|
381
|
+
'access-admin': user => user?.email?.endsWith('@stacksjs.com') ?? false,
|
|
382
382
|
'edit-settings': user => !!user,
|
|
383
383
|
'view-dashboard': user => !!user,
|
|
384
384
|
},
|
|
@@ -225,7 +225,7 @@ buddy migrate:fresh -s/--seed # seed after fresh migration
|
|
|
225
225
|
buddy migrate:fresh -a/--auth # include auth tables
|
|
226
226
|
buddy migrate:fresh -d/--diff # show SQL without running
|
|
227
227
|
|
|
228
|
-
buddy
|
|
228
|
+
buddy dns:sync # create the config/dns.ts records at the registrar
|
|
229
229
|
```
|
|
230
230
|
|
|
231
231
|
Both `migrate` and `migrate:fresh` validate that models exist in `app/Models` or `storage/framework/defaults/app/Models` before running.
|
|
@@ -54,7 +54,7 @@ export default {
|
|
|
54
54
|
// Navigation
|
|
55
55
|
nav: [
|
|
56
56
|
{ text: 'Changelog', link: 'https://github.com/stacksjs/stacks/blob/main/CHANGELOG.md' },
|
|
57
|
-
{ text: 'Blog', link: 'https://updates.stacksjs.
|
|
57
|
+
{ text: 'Blog', link: 'https://updates.stacksjs.com' },
|
|
58
58
|
],
|
|
59
59
|
|
|
60
60
|
// Markdown settings
|
|
@@ -290,7 +290,7 @@ are enforced rather than using their legacy warn-once compatibility path.
|
|
|
290
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
291
|
|
|
292
292
|
## CLI Commands
|
|
293
|
-
- `buddy email`
|
|
293
|
+
- `buddy email` - email management (there is no `buddy mail` alias: `mail:*` is the separate mail-server namespace)
|
|
294
294
|
- `buddy email:verify` - check domain verification
|
|
295
295
|
- `buddy email:test [recipient]` - send test email
|
|
296
296
|
- `buddy email:list` - list mailboxes
|
|
@@ -155,6 +155,29 @@ route.group({ prefix: '/api/v1', middleware: ['auth', 'throttle'] }, () => {
|
|
|
155
155
|
})
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
+
### STX Pages
|
|
159
|
+
|
|
160
|
+
The frontend and API share the same middleware definitions. Stacks loads the
|
|
161
|
+
aliases from `app/Middleware.ts` and the class-style handlers from
|
|
162
|
+
`app/Middleware/` into the normal stx dev and production page servers.
|
|
163
|
+
|
|
164
|
+
Declare an alias from that registry in a page's server metadata:
|
|
165
|
+
|
|
166
|
+
```stx
|
|
167
|
+
<script server>
|
|
168
|
+
definePageMeta({ middleware: ['auth', 'verified', 'role:admin'] })
|
|
169
|
+
</script>
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Do not create a second frontend-only middleware registry. A custom middleware
|
|
173
|
+
is defined once with `new Middleware({ name, priority, handle })`, added to
|
|
174
|
+
`app/Middleware.ts`, and may then guard an API route, an stx page, or both.
|
|
175
|
+
|
|
176
|
+
STX prepares the incoming request with the same EnhancedRequest helpers before
|
|
177
|
+
calling `handle`, sorts the combined page chain by priority, writes parameters
|
|
178
|
+
to `request._middlewareParams`, supports exact aliases containing colons and
|
|
179
|
+
`!alias` inversion, and fails closed when an alias is missing.
|
|
180
|
+
|
|
158
181
|
Group middleware is prepended to all routes inside the callback. Groups can be nested — middleware accumulates.
|
|
159
182
|
|
|
160
183
|
### Parameterized Middleware
|
|
@@ -23,7 +23,7 @@ buddy migrate --diff # show SQL without running
|
|
|
23
23
|
buddy migrate --auth # include auth tables
|
|
24
24
|
buddy migrate:fresh # drop ALL tables and re-migrate
|
|
25
25
|
buddy migrate:fresh --seed # drop, migrate, then seed
|
|
26
|
-
buddy
|
|
26
|
+
buddy dns:pull # live zone as a config/dns.ts block
|
|
27
27
|
buddy make:migration <name> # create new migration file
|
|
28
28
|
buddy seed # seed database
|
|
29
29
|
buddy generate:migrations # generate migrations from model diffs
|
|
@@ -102,7 +102,6 @@ generated model types stay precise.
|
|
|
102
102
|
| `useApi` | Generates REST actions and routes: `{ uri, routes, middleware? }` |
|
|
103
103
|
| `useSearch` (alias `searchable`) | Search-engine indexing: `{ displayable, searchable, sortable, filterable }` |
|
|
104
104
|
| `useSocials` | OAuth identities, e.g. `['github']` |
|
|
105
|
-
| `useActivityLog` | Writes an `Activity` row per change |
|
|
106
105
|
| `observe` | Emits `{model}:created` / `:updated` / `:deleted` events |
|
|
107
106
|
| `billable` | Stripe methods (`checkout()`, `activeSubscription()`, ...) |
|
|
108
107
|
| `taggable` / `categorizable` / `commentable` / `likeable` | Pivot tables and their relation methods |
|
|
@@ -54,7 +54,7 @@ describe('campaign records', () => {
|
|
|
54
54
|
emailListId: '4',
|
|
55
55
|
scheduledAt: '2030-01-01 09:00:00',
|
|
56
56
|
fromName: 'Product team',
|
|
57
|
-
fromAddress: 'product@stacksjs.
|
|
57
|
+
fromAddress: 'product@stacksjs.com',
|
|
58
58
|
currency: 'usd',
|
|
59
59
|
})
|
|
60
60
|
|
|
@@ -62,7 +62,7 @@ describe('campaign records', () => {
|
|
|
62
62
|
email_list_id: 4,
|
|
63
63
|
scheduled_at: '2030-01-01 09:00:00',
|
|
64
64
|
from_name: 'Product team',
|
|
65
|
-
from_address: 'product@stacksjs.
|
|
65
|
+
from_address: 'product@stacksjs.com',
|
|
66
66
|
currency: 'USD',
|
|
67
67
|
})
|
|
68
68
|
expect(validateCampaignWriteData(data, new Date('2029-01-01T00:00:00'))).toBe('')
|
package/app/Gates.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { defineGates } from '@stacksjs/auth'
|
|
|
11
11
|
* Registered at boot by `initializeAuthorization()`, which every entry point
|
|
12
12
|
* comes through - HTTP, `buddy seed`, a scheduled job, a console command.
|
|
13
13
|
*
|
|
14
|
-
* @see https://stacksjs.
|
|
14
|
+
* @see https://stacksjs.com/docs/security/authorization
|
|
15
15
|
*/
|
|
16
16
|
export default defineGates({
|
|
17
17
|
/**
|
|
@@ -30,7 +30,7 @@ export default defineGates({
|
|
|
30
30
|
gates: {
|
|
31
31
|
/** Check if user can access admin area */
|
|
32
32
|
'access-admin': (user: UserModel | null) => {
|
|
33
|
-
return user?.email?.endsWith('@stacksjs.
|
|
33
|
+
return user?.email?.endsWith('@stacksjs.com') ?? false
|
|
34
34
|
},
|
|
35
35
|
|
|
36
36
|
/** Check if user can edit application settings */
|
package/app/Middleware/Csrf.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
2
2
|
import { HttpError } from '@stacksjs/error-handling'
|
|
3
3
|
import type { EnhancedRequest } from '@stacksjs/bun-router'
|
|
4
4
|
import { Middleware } from '@stacksjs/router'
|
|
@@ -59,9 +59,18 @@ import { Middleware } from '@stacksjs/router'
|
|
|
59
59
|
*/
|
|
60
60
|
|
|
61
61
|
export const CSRF_COOKIE_NAME = 'X-CSRF-Token'
|
|
62
|
+
const CSRF_SECURE_TRANSPORT = Symbol.for('@stacksjs/router:csrf-secure-transport')
|
|
62
63
|
const CSRF_HEADER_NAME = 'x-csrf-token'
|
|
64
|
+
const CSRF_COOKIE_PREFIX = `${CSRF_COOKIE_NAME}=`
|
|
65
|
+
const LEGACY_CSRF_COOKIE_PREFIX = 'csrf-token='
|
|
66
|
+
const CSRF_COOKIE_SUFFIX = '; Path=/; SameSite=Lax; Max-Age=7200'
|
|
67
|
+
const CSRF_COOKIE_SECURE_SUFFIX = `${CSRF_COOKIE_SUFFIX}; Secure`
|
|
63
68
|
const TOKEN_BYTES = 32
|
|
69
|
+
const TOKEN_HEX_LENGTH = TOKEN_BYTES * 2
|
|
70
|
+
const TOKEN_RANDOM_BYTES = Buffer.allocUnsafe(TOKEN_BYTES)
|
|
64
71
|
const TOKEN_ENCODER = new TextEncoder()
|
|
72
|
+
const LEFT_TOKEN_BYTES = new Uint8Array(TOKEN_HEX_LENGTH)
|
|
73
|
+
const RIGHT_TOKEN_BYTES = new Uint8Array(TOKEN_HEX_LENGTH)
|
|
65
74
|
|
|
66
75
|
/**
|
|
67
76
|
* Generate a fresh CSRF token (hex-encoded, 32 random bytes → 64 chars).
|
|
@@ -73,7 +82,8 @@ const TOKEN_ENCODER = new TextEncoder()
|
|
|
73
82
|
* ```
|
|
74
83
|
*/
|
|
75
84
|
export function generateCsrfToken(): string {
|
|
76
|
-
|
|
85
|
+
crypto.getRandomValues(TOKEN_RANDOM_BYTES)
|
|
86
|
+
return TOKEN_RANDOM_BYTES.toHex()
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
/**
|
|
@@ -120,16 +130,25 @@ function responseAlreadySeeds(response: Response): boolean {
|
|
|
120
130
|
)
|
|
121
131
|
}
|
|
122
132
|
|
|
123
|
-
export function
|
|
124
|
-
const
|
|
133
|
+
export function createCsrfCookie(req: Request, minted?: string): string {
|
|
134
|
+
const token = minted || generateCsrfToken()
|
|
135
|
+
const knownSecureTransport = (req as unknown as Record<symbol, unknown>)[CSRF_SECURE_TRANSPORT]
|
|
136
|
+
const suffix = knownSecureTransport === true || (knownSecureTransport === undefined && req.url.startsWith('https://'))
|
|
137
|
+
? CSRF_COOKIE_SECURE_SUFFIX
|
|
138
|
+
: CSRF_COOKIE_SUFFIX
|
|
139
|
+
return `${CSRF_COOKIE_PREFIX}${token}${suffix}`
|
|
140
|
+
}
|
|
125
141
|
|
|
142
|
+
export function seedCsrfCookieIfMissing(req: Request, response: Response, minted?: string, responseHasNoCookies = false): Response {
|
|
126
143
|
// A token the router minted before rendering wins over "the header already
|
|
127
144
|
// has one", because it put that value in the header itself - and the page
|
|
128
145
|
// has already embedded it in every form it drew. Generating a second token
|
|
129
146
|
// here would store one string in the browser while the page carries another,
|
|
130
147
|
// which fails in a way indistinguishable from having no token at all.
|
|
131
|
-
if (!minted
|
|
132
|
-
|
|
148
|
+
if (!minted) {
|
|
149
|
+
const cookieHeader = req.headers.get('cookie') || ''
|
|
150
|
+
if (cookieHeader.includes(`${CSRF_COOKIE_NAME}=`) || cookieHeader.includes('csrf-token='))
|
|
151
|
+
return response
|
|
133
152
|
}
|
|
134
153
|
|
|
135
154
|
// A token already on its way to the browser counts as present, exactly like
|
|
@@ -138,18 +157,10 @@ export function seedCsrfCookieIfMissing(req: Request, response: Response, minted
|
|
|
138
157
|
// and appending a second here would leave the browser storing the last
|
|
139
158
|
// Set-Cookie while the page embedded the first. Two tokens fail the same way
|
|
140
159
|
// no token does, and are far harder to see.
|
|
141
|
-
if (responseAlreadySeeds(response))
|
|
160
|
+
if (!responseHasNoCookies && responseAlreadySeeds(response))
|
|
142
161
|
return response
|
|
143
162
|
|
|
144
|
-
const
|
|
145
|
-
const isSecure = req.url.startsWith('https://')
|
|
146
|
-
const cookie = [
|
|
147
|
-
`${CSRF_COOKIE_NAME}=${token}`,
|
|
148
|
-
'Path=/',
|
|
149
|
-
'SameSite=Lax',
|
|
150
|
-
'Max-Age=7200',
|
|
151
|
-
isSecure ? 'Secure' : null,
|
|
152
|
-
].filter(Boolean).join('; ')
|
|
163
|
+
const cookie = createCsrfCookie(req, minted)
|
|
153
164
|
|
|
154
165
|
// Append (not Set) so multiple Set-Cookie headers can coexist with any
|
|
155
166
|
// cookies the action handler set itself.
|
|
@@ -175,6 +186,17 @@ export function seedCsrfCookieIfMissing(req: Request, response: Response, minted
|
|
|
175
186
|
function csrfCookieToken(req: Request): string {
|
|
176
187
|
const header = req.headers.get('cookie')
|
|
177
188
|
if (!header) return ''
|
|
189
|
+
// The cookie this framework emits has one exact, whitespace-free shape.
|
|
190
|
+
// Recognize it before scanning for separators or trimming; browsers send
|
|
191
|
+
// this common single-cookie form on every authenticated unsafe request.
|
|
192
|
+
if (header.length === CSRF_COOKIE_PREFIX.length + TOKEN_HEX_LENGTH && header.startsWith(CSRF_COOKIE_PREFIX))
|
|
193
|
+
return header.slice(CSRF_COOKIE_PREFIX.length)
|
|
194
|
+
if (!header.includes(';')) {
|
|
195
|
+
if (header.startsWith(CSRF_COOKIE_PREFIX))
|
|
196
|
+
return header.slice(CSRF_COOKIE_PREFIX.length).trim()
|
|
197
|
+
if (header.startsWith(LEGACY_CSRF_COOKIE_PREFIX))
|
|
198
|
+
return header.slice(LEGACY_CSRF_COOKIE_PREFIX.length).trim()
|
|
199
|
+
}
|
|
178
200
|
let canonical = ''
|
|
179
201
|
let legacy = ''
|
|
180
202
|
for (const part of header.split(';')) {
|
|
@@ -197,6 +219,22 @@ function safeEqual(a: string, b: string): boolean {
|
|
|
197
219
|
if (typeof a !== 'string' || typeof b !== 'string') return false
|
|
198
220
|
if (a.length !== b.length) return false
|
|
199
221
|
try {
|
|
222
|
+
// Generated tokens are fixed-width ASCII hex. Reuse module-local scratch
|
|
223
|
+
// arrays for that dominant path: this function is synchronous, so another
|
|
224
|
+
// request cannot interleave between encoding and comparison. If either
|
|
225
|
+
// value is not 64 bytes of UTF-8, retain the allocation-based fallback.
|
|
226
|
+
if (a.length === TOKEN_HEX_LENGTH) {
|
|
227
|
+
const left = TOKEN_ENCODER.encodeInto(a, LEFT_TOKEN_BYTES)
|
|
228
|
+
const right = TOKEN_ENCODER.encodeInto(b, RIGHT_TOKEN_BYTES)
|
|
229
|
+
if (
|
|
230
|
+
left.read === TOKEN_HEX_LENGTH
|
|
231
|
+
&& left.written === TOKEN_HEX_LENGTH
|
|
232
|
+
&& right.read === TOKEN_HEX_LENGTH
|
|
233
|
+
&& right.written === TOKEN_HEX_LENGTH
|
|
234
|
+
) {
|
|
235
|
+
return timingSafeEqual(LEFT_TOKEN_BYTES, RIGHT_TOKEN_BYTES)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
200
238
|
return timingSafeEqual(TOKEN_ENCODER.encode(a), TOKEN_ENCODER.encode(b))
|
|
201
239
|
}
|
|
202
240
|
catch {
|
|
@@ -210,12 +248,10 @@ function safeEqual(a: string, b: string): boolean {
|
|
|
210
248
|
* an ambient cookie credential, so cross-site forgery doesn't apply).
|
|
211
249
|
*/
|
|
212
250
|
function hasBearerToken(req: Request): boolean {
|
|
213
|
-
const auth = req.headers.get('authorization')
|
|
214
|
-
return typeof auth === 'string' &&
|
|
251
|
+
const auth = req.headers.get('authorization')
|
|
252
|
+
return typeof auth === 'string' && /^bearer /i.test(auth)
|
|
215
253
|
}
|
|
216
254
|
|
|
217
|
-
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
218
|
-
|
|
219
255
|
/**
|
|
220
256
|
* Validate one request with the framework's native CSRF contract.
|
|
221
257
|
*
|
|
@@ -223,18 +259,16 @@ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
|
223
259
|
* the router, such as the local dashboard config editor, can enforce the same
|
|
224
260
|
* double-submit and bearer-token rules without duplicating security logic.
|
|
225
261
|
*/
|
|
226
|
-
|
|
227
|
-
|
|
262
|
+
function assertValidCsrfRequest(request: Request | EnhancedRequest): void {
|
|
263
|
+
// Fetch normalizes standard methods when constructing the Request.
|
|
264
|
+
const method = request.method
|
|
228
265
|
|
|
229
266
|
// Safe methods don't mutate state — no token check needed.
|
|
230
267
|
// Token *seeding* (set the cookie if it's missing) happens after
|
|
231
268
|
// the response is built; we don't have a post-response hook
|
|
232
269
|
// here, so action handlers / SPAs can call `generateCsrfToken()`
|
|
233
270
|
// themselves on the first GET they need it for.
|
|
234
|
-
if (
|
|
235
|
-
|
|
236
|
-
// API clients with a bearer token are exempt — see header docstring.
|
|
237
|
-
if (hasBearerToken(request)) return
|
|
271
|
+
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return
|
|
238
272
|
|
|
239
273
|
// Per-action opt-out: an action exporting `skipCsrf: true` (or
|
|
240
274
|
// `csrf: false`) has declared it can't participate in CSRF
|
|
@@ -247,8 +281,13 @@ export async function validateCsrfRequest(request: Request | EnhancedRequest): P
|
|
|
247
281
|
// Look up the submitted token. Header is the SPA path; body field
|
|
248
282
|
// is the traditional form-post path. We accept either.
|
|
249
283
|
const headerToken = request.headers.get(CSRF_HEADER_NAME)
|
|
250
|
-
|
|
251
|
-
|
|
284
|
+
|
|
285
|
+
// A browser or SPA that supplied an explicit CSRF header should take the
|
|
286
|
+
// matching-token path first. Bearer-only clients still exit immediately,
|
|
287
|
+
// while a malformed CSRF header on a bearer request retains the exemption
|
|
288
|
+
// after validation fails below.
|
|
289
|
+
if (!headerToken && hasBearerToken(request)) return
|
|
290
|
+
|
|
252
291
|
const body = enhanced.jsonBody || enhanced.formBody || {}
|
|
253
292
|
// A parsed body is `unknown`-valued: the token is validated as a string
|
|
254
293
|
// two lines down, so read it as one rather than asserting it is one.
|
|
@@ -262,13 +301,20 @@ export async function validateCsrfRequest(request: Request | EnhancedRequest): P
|
|
|
262
301
|
|
|
263
302
|
const cookieToken = csrfCookieToken(request)
|
|
264
303
|
|
|
265
|
-
if (
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
304
|
+
if (submitted && cookieToken && safeEqual(submitted, cookieToken)) return
|
|
305
|
+
|
|
306
|
+
// Preserve bearer precedence when a client happens to send both headers.
|
|
307
|
+
if (headerToken && hasBearerToken(request)) return
|
|
308
|
+
|
|
309
|
+
// 419 is the convention Laravel popularized for "CSRF token
|
|
310
|
+
// mismatch" — it's not in the IANA list but most SPAs already
|
|
311
|
+
// know how to refresh on 419. We use 403 for the strict-correct
|
|
312
|
+
// status code instead (419 is non-standard).
|
|
313
|
+
throw new HttpError(403, 'CSRF token mismatch')
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function validateCsrfRequest(request: Request | EnhancedRequest): Promise<void> {
|
|
317
|
+
assertValidCsrfRequest(request)
|
|
272
318
|
}
|
|
273
319
|
|
|
274
320
|
export default new Middleware({
|
|
@@ -277,7 +323,7 @@ export default new Middleware({
|
|
|
277
323
|
// we're going to reject anyway. After maintenance/throttle though.
|
|
278
324
|
priority: 2,
|
|
279
325
|
|
|
280
|
-
|
|
281
|
-
|
|
326
|
+
handle(request) {
|
|
327
|
+
assertValidCsrfRequest(request)
|
|
282
328
|
},
|
|
283
329
|
})
|
package/app/Middleware.ts
CHANGED
|
@@ -4,8 +4,9 @@ import { defineMiddleware } from '@stacksjs/router'
|
|
|
4
4
|
* The application's middleware aliases.
|
|
5
5
|
*
|
|
6
6
|
* Aliases may be used instead of class names to conveniently assign middleware
|
|
7
|
-
* to routes
|
|
8
|
-
*
|
|
7
|
+
* to API routes, route groups, and stx pages through `definePageMeta`. Each one
|
|
8
|
+
* names a class under `app/Middleware/`, or one of the framework defaults
|
|
9
|
+
* behind it, and the name is checked.
|
|
9
10
|
*
|
|
10
11
|
* This map is MERGED over the framework defaults rather than replacing them,
|
|
11
12
|
* so an alias the framework adds later is available here without an edit.
|
package/ide/vscode/package.json
CHANGED
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/defaults",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.74.
|
|
5
|
+
"version": "0.74.29",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/stacksjs/stacks.git",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@iconify-json/f7": "^1.2.2",
|
|
57
57
|
"@iconify-json/hugeicons": "^1.2.27",
|
|
58
|
-
"@stacksjs/mobile": "^0.74.
|
|
58
|
+
"@stacksjs/mobile": "^0.74.29",
|
|
59
59
|
"@stacksjs/sanitizer": "^0.2.113",
|
|
60
60
|
"ts-qr-codes": "^0.1.8"
|
|
61
61
|
}
|
package/stx-components-plugin.ts
CHANGED
|
@@ -19,7 +19,48 @@ const candidates = [
|
|
|
19
19
|
join(process.cwd(), 'node_modules/@stacksjs/components/src/ui'),
|
|
20
20
|
].filter((dir): dir is string => Boolean(dir))
|
|
21
21
|
|
|
22
|
+
const library = candidates.find(dir => existsSync(dir)) ?? candidates[candidates.length - 1]
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Component directories contributed by installed packages.
|
|
26
|
+
*
|
|
27
|
+
* Wrapped on its own rather than left to the plugin loader's catch. stx wraps
|
|
28
|
+
* plugin loading in `try { … } catch { console.warn(…) }`, so an exception
|
|
29
|
+
* raised here would drop the WHOLE plugin - and this plugin is what supplies
|
|
30
|
+
* the dashboard's sidebar. Degrading to "no package components" keeps the
|
|
31
|
+
* library and the framework defaults working.
|
|
32
|
+
*
|
|
33
|
+
* `@stacksjs/config` is imported for the resolution rather than re-implemented,
|
|
34
|
+
* because it carries the guard that refuses a path escaping the package.
|
|
35
|
+
*/
|
|
36
|
+
function packageDirs(): string[] {
|
|
37
|
+
try {
|
|
38
|
+
// eslint-disable-next-line ts/no-require-imports
|
|
39
|
+
const { packageComponentRoots } = require('@stacksjs/config')
|
|
40
|
+
return packageComponentRoots().map((root: { dir: string }) => root.dir)
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return []
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
22
47
|
export default {
|
|
23
48
|
name: '@stacksjs/components',
|
|
24
|
-
|
|
49
|
+
/*
|
|
50
|
+
* Order is the precedence, because stx searches this list and takes the
|
|
51
|
+
* first match.
|
|
52
|
+
*
|
|
53
|
+
* The framework's own components are listed explicitly, ahead of any
|
|
54
|
+
* package's. They already resolve today through stx's fallback directory, so
|
|
55
|
+
* naming them here changes nothing on its own - what it does is put a floor
|
|
56
|
+
* under the package roots, making an installed package purely ADDITIVE. A
|
|
57
|
+
* package shipping its own `Button.stx` gets it used where nothing else
|
|
58
|
+
* defines `<Button />`, and cannot quietly replace the framework's.
|
|
59
|
+
*/
|
|
60
|
+
components: [
|
|
61
|
+
library,
|
|
62
|
+
// Relative to this file, which is what stx resolves plugin entries against.
|
|
63
|
+
'resources/components',
|
|
64
|
+
...packageDirs(),
|
|
65
|
+
],
|
|
25
66
|
}
|