@stacksjs/defaults 0.74.29 → 0.74.31
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 +22 -0
- package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
- package/ai/skills/stacks-models/SKILL.md +3 -1
- package/ai/skills/stacks-orm/SKILL.md +1 -1
- package/app/Middleware/Api.ts +1 -1
- package/app/Middleware/Auth.ts +3 -1
- package/app/Middleware/Can.ts +2 -1
- package/app/Middleware/Compress.ts +66 -19
- package/app/Middleware/Cors.ts +1 -1
- package/app/Middleware/Csrf.ts +30 -13
- package/app/Middleware/EnsureEmailIsVerified.ts +1 -1
- package/app/Middleware/Maintenance.ts +3 -1
- package/app/Middleware/Permission.ts +2 -2
- package/app/Middleware/Role.ts +2 -2
- package/app/Middleware/Team.ts +1 -1
- package/app/Middleware/Throttle.ts +0 -1
- package/app/Models/Content/Author.ts +1 -0
- package/app/Models/Content/Post.ts +5 -3
- package/app/Models/PersonalAccessToken.ts +154 -0
- package/app/Models/User.ts +1 -1
- package/app/Models/commerce/CartItem.ts +3 -3
- package/app/Models/commerce/Payment.ts +2 -2
- package/app/Models/commerce/WaitlistProduct.ts +2 -2
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
|
@@ -52,6 +52,28 @@ auth/src/
|
|
|
52
52
|
- `Auth.login(credentials: AuthCredentials, options?: TokenCreateOptions): Promise<{ user, token } | null>` — login and create token
|
|
53
53
|
- `Auth.loginUsingId(userId: number, options?: TokenCreateOptions): Promise<{ user, token } | null>` — login by user ID
|
|
54
54
|
- `Auth.logout(): Promise<void>` — revoke current token
|
|
55
|
+
|
|
56
|
+
### Personal access tokens (Sanctum-shaped)
|
|
57
|
+
|
|
58
|
+
`oauth_access_tokens` is polymorphic: `tokenable_type` holds the owner's TABLE
|
|
59
|
+
name (`users`, `authors`) and `tokenable_id` its id there, so any model
|
|
60
|
+
declaring `useAuth` can hold tokens - not only `User`.
|
|
61
|
+
|
|
62
|
+
- `createToken(id, name, scopes, { tokenableType })` — mint one. Returns the
|
|
63
|
+
plaintext ONCE (`plainTextToken`); the table stores a hash and nothing can
|
|
64
|
+
recover it afterwards. `tokenableType` defaults to `users`.
|
|
65
|
+
- `tokens(id, tokenableType?)` — list an owner's live tokens.
|
|
66
|
+
- `tokenCan(scope)` / `tokenCanAll` / `tokenCanAny` / `tokenAbilities` — check
|
|
67
|
+
the current request's token.
|
|
68
|
+
- `revokeToken`, `revokeTokenById`, `revokeAllTokens(id, type?)`,
|
|
69
|
+
`revokeOtherTokens(id, type?)` — revocation also revokes the paired refresh
|
|
70
|
+
token, which a raw row delete does not.
|
|
71
|
+
- `setTrailActor(id)` — attribute writes in a queue job or CLI run that has no
|
|
72
|
+
request to read a user from.
|
|
73
|
+
|
|
74
|
+
The `PersonalAccessToken` model maps the same table, so `owner.with('tokenable')`
|
|
75
|
+
lists exactly what `createToken` minted. It deliberately generates no CRUD
|
|
76
|
+
routes: minting and revoking both carry semantics a generic route does not.
|
|
55
77
|
- `Auth.once(credentials: AuthCredentials): Promise<boolean>` — one-time auth without token
|
|
56
78
|
- `Auth.requestToken(credentials, clientId, clientSecret): Promise<{ token } | null>` — OAuth token request
|
|
57
79
|
|
|
@@ -66,7 +66,7 @@ globalThis.toggleDark = toggleDark
|
|
|
66
66
|
- **Custom Functions**: From `resources/functions/` (counter, dark mode, GPX, geo utilities)
|
|
67
67
|
|
|
68
68
|
### Server Auto-Imports (100+)
|
|
69
|
-
- **All ORM Models**: User, Post, Author, Product, Order, Payment, Customer, etc. (
|
|
69
|
+
- **All ORM Models**: User, Post, Author, Product, Order, Payment, Customer, etc. (98 models)
|
|
70
70
|
- **Request Models**: UserRequest, PostRequest, OrderRequest, etc.
|
|
71
71
|
- **Actions**: Action types and helpers
|
|
72
72
|
- **Schema**: validation schema builder
|
|
@@ -98,10 +98,12 @@ generated model types stay precise.
|
|
|
98
98
|
| `useUuid` | UUID column alongside the primary key |
|
|
99
99
|
| `useTimestamps` (alias `timestampable`) | `created_at` / `updated_at`. On by default |
|
|
100
100
|
| `useSoftDeletes` (alias `softDeletable`) | `deleted_at` plus soft-delete query scopes |
|
|
101
|
-
| `useAuth` (alias `authenticatable`) | Auth columns; `{ usePasskey: true }` adds passkeys |
|
|
101
|
+
| `useAuth` (alias `authenticatable`) | Auth columns; `{ usePasskey: true }` adds passkeys. Also confers `morphMany: { tokenable: 'PersonalAccessToken' }`, so any authenticatable model can hold API tokens |
|
|
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 `activities` feed row per change: `{ logOnly }` / `{ include }` / `{ exclude }` pick the attributes |
|
|
106
|
+
| `useAudit` | Writes a `model_audits` row per change with an old/new diff |
|
|
105
107
|
| `observe` | Emits `{model}:created` / `:updated` / `:deleted` events |
|
|
106
108
|
| `billable` | Stripe methods (`checkout()`, `activeSubscription()`, ...) |
|
|
107
109
|
| `taggable` / `categorizable` / `commentable` / `likeable` | Pivot tables and their relation methods |
|
|
@@ -11,7 +11,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
|
|
|
11
11
|
## Key Paths
|
|
12
12
|
- Core ORM package: `storage/framework/core/orm/src/`
|
|
13
13
|
- ORM implementation: `storage/framework/orm/`
|
|
14
|
-
- Model definitions: `storage/framework/defaults/app/Models/` (
|
|
14
|
+
- Model definitions: `storage/framework/defaults/app/Models/` (98 models)
|
|
15
15
|
- Application models: `app/Models/`
|
|
16
16
|
- Default model templates: `storage/framework/defaults/app/Models/`
|
|
17
17
|
- ORM type globals: `storage/framework/types/orm-globals.d.ts`
|
package/app/Middleware/Api.ts
CHANGED
|
@@ -16,7 +16,7 @@ export default new Middleware({
|
|
|
16
16
|
name: 'api',
|
|
17
17
|
priority: 1,
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
handle(request) {
|
|
20
20
|
// Ensure the request accepts JSON
|
|
21
21
|
const accept = request.headers.get('accept') || ''
|
|
22
22
|
if (!accept.includes('application/json') && !accept.includes('*/*')) {
|
package/app/Middleware/Auth.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { Auth
|
|
1
|
+
import { Auth } from '@stacksjs/auth/authentication'
|
|
2
|
+
import { authCookieName } from '@stacksjs/auth/cookie'
|
|
3
|
+
import { sessionUser } from '@stacksjs/auth/session-auth'
|
|
2
4
|
import { HttpError } from '@stacksjs/error-handling'
|
|
3
5
|
import { Middleware } from '@stacksjs/router'
|
|
4
6
|
|
package/app/Middleware/Can.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AuthorizationException, authorize } from '@stacksjs/auth/gate'
|
|
2
|
+
import { authenticatedUser } from '@stacksjs/auth/middleware'
|
|
2
3
|
import { HttpError } from '@stacksjs/error-handling'
|
|
3
4
|
import { Middleware, resolveRouteModel, setRouteModelFallback } from '@stacksjs/router'
|
|
4
5
|
|
|
@@ -37,7 +37,7 @@ import { Middleware } from '@stacksjs/router'
|
|
|
37
37
|
* - **Skip** for already-compressed content types (image/*, video/*,
|
|
38
38
|
* audio/*, application/zip, gzip, octet-stream).
|
|
39
39
|
* - **Skip** SSE / WebSocket upgrades (`text/event-stream`).
|
|
40
|
-
* - **Prefer
|
|
40
|
+
* - **Prefer the highest accepted weight**, using brotli on ties. Brotli typically
|
|
41
41
|
* achieves ~20% better ratios than gzip on HTML/JSON/CSS/JS at
|
|
42
42
|
* comparable CPU cost — well worth the swap when the client
|
|
43
43
|
* supports it.
|
|
@@ -75,24 +75,66 @@ const SKIP_CONTENT_TYPE_PREFIXES = [
|
|
|
75
75
|
'text/event-stream',
|
|
76
76
|
]
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
type Encoding = 'br' | 'gzip' | null
|
|
79
|
+
|
|
80
|
+
let lastAcceptEncoding: string | null | undefined
|
|
81
|
+
let lastEncoding: Encoding = null
|
|
82
|
+
|
|
83
|
+
/** Repeated offers reuse one pure result, without retaining a growing cache. */
|
|
79
84
|
function pickEncoding(acceptEncoding: string | null): 'br' | 'gzip' | null {
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
85
|
+
if (acceptEncoding === lastAcceptEncoding)
|
|
86
|
+
return lastEncoding
|
|
87
|
+
const encoding = parseEncoding(acceptEncoding)
|
|
88
|
+
lastAcceptEncoding = acceptEncoding
|
|
89
|
+
lastEncoding = encoding
|
|
90
|
+
return encoding
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Exact HTTP coding tokens and qualities, with Brotli first on equal weights. */
|
|
94
|
+
function parseEncoding(header: string | null): Encoding {
|
|
95
|
+
if (!header)
|
|
96
|
+
return null
|
|
97
|
+
|
|
98
|
+
let br: number | undefined
|
|
99
|
+
let gzip: number | undefined
|
|
100
|
+
let identity = 0
|
|
101
|
+
let wildcard = 0
|
|
102
|
+
for (const part of header.split(',')) {
|
|
103
|
+
const separator = part.indexOf(';')
|
|
104
|
+
const name = (separator < 0 ? part : part.slice(0, separator)).trim().toLowerCase()
|
|
105
|
+
if (name !== 'br' && name !== 'gzip' && name !== 'identity' && name !== '*')
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
let quality = 1
|
|
109
|
+
if (separator >= 0) {
|
|
110
|
+
const weight = /^;[\t ]*q=(0(?:\.\d{0,3})?|1(?:\.0{0,3})?)[\t ]*$/i.exec(part.slice(separator))
|
|
111
|
+
quality = weight ? Number(weight[1]) : 0
|
|
112
|
+
}
|
|
113
|
+
if (name === 'br')
|
|
114
|
+
br = quality
|
|
115
|
+
else if (name === 'gzip')
|
|
116
|
+
gzip = quality
|
|
117
|
+
else if (name === 'identity')
|
|
118
|
+
identity = quality
|
|
119
|
+
else
|
|
120
|
+
wildcard = quality
|
|
121
|
+
}
|
|
122
|
+
br ??= wildcard
|
|
123
|
+
gzip ??= wildcard
|
|
124
|
+
if (identity > Math.max(br, gzip))
|
|
125
|
+
return null
|
|
126
|
+
if (br > 0 && br >= gzip)
|
|
127
|
+
return 'br'
|
|
128
|
+
if (gzip > 0)
|
|
129
|
+
return 'gzip'
|
|
89
130
|
return null
|
|
90
131
|
}
|
|
91
132
|
|
|
92
133
|
/** Heuristic: is this content type worth compressing? */
|
|
93
134
|
function shouldCompressContentType(contentType: string | null): boolean {
|
|
94
135
|
if (!contentType) return true // unknown → assume text-like
|
|
95
|
-
const
|
|
136
|
+
const separator = contentType.indexOf(';')
|
|
137
|
+
const ct = (separator < 0 ? contentType : contentType.slice(0, separator)).trim().toLowerCase()
|
|
96
138
|
for (const prefix of SKIP_CONTENT_TYPE_PREFIXES) {
|
|
97
139
|
if (ct.startsWith(prefix)) return false
|
|
98
140
|
}
|
|
@@ -109,6 +151,9 @@ function shouldCompressContentType(contentType: string | null): boolean {
|
|
|
109
151
|
* JSON/HTML response sizes we're targeting (KB to single-digit MB).
|
|
110
152
|
* For multi-MB streamed responses, the middleware should be skipped
|
|
111
153
|
* (the SSE / event-stream check above handles the most common case).
|
|
154
|
+
* A caller that already knows a conservative UTF-8 byte upper bound may
|
|
155
|
+
* supply it to leave a proven sub-threshold body unconsumed. This must come
|
|
156
|
+
* from the body itself, not an unverified Content-Length header.
|
|
112
157
|
*
|
|
113
158
|
* @example
|
|
114
159
|
* ```ts
|
|
@@ -117,11 +162,14 @@ function shouldCompressContentType(contentType: string | null): boolean {
|
|
|
117
162
|
* // → response with `Content-Encoding: br` and a brotli-compressed body
|
|
118
163
|
* ```
|
|
119
164
|
*/
|
|
120
|
-
export async function applyCompression(request: Request, response: Response): Promise<Response> {
|
|
165
|
+
export async function applyCompression(request: Request, response: Response, bodySizeUpperBound?: number): Promise<Response> {
|
|
121
166
|
// Already encoded — leave it alone. Re-encoding would corrupt the
|
|
122
167
|
// body, and the client already knows what to expect.
|
|
123
168
|
if (response.headers.get('content-encoding')) return response
|
|
124
169
|
|
|
170
|
+
if (bodySizeUpperBound !== undefined && Number.isSafeInteger(bodySizeUpperBound) && bodySizeUpperBound >= 0 && bodySizeUpperBound < MIN_COMPRESS_BYTES)
|
|
171
|
+
return response
|
|
172
|
+
|
|
125
173
|
// No body or non-text content streams (SSE, etc.) — skip.
|
|
126
174
|
if (!response.body) return response
|
|
127
175
|
|
|
@@ -145,7 +193,7 @@ export async function applyCompression(request: Request, response: Response): Pr
|
|
|
145
193
|
})
|
|
146
194
|
}
|
|
147
195
|
|
|
148
|
-
let compressed:
|
|
196
|
+
let compressed: Uint8Array<ArrayBuffer>
|
|
149
197
|
if (encoding === 'br') {
|
|
150
198
|
// Brotli quality 5 is the sweet spot for dynamic responses:
|
|
151
199
|
// quality 11 (max) is ~10× slower for ~5% smaller output.
|
|
@@ -161,7 +209,7 @@ export async function applyCompression(request: Request, response: Response): Pr
|
|
|
161
209
|
// for typical response sizes — used here in preference to
|
|
162
210
|
// node:zlib for the gzip path. Brotli has no Bun-native API yet
|
|
163
211
|
// (as of writing) so we use node:zlib for that branch.
|
|
164
|
-
compressed =
|
|
212
|
+
compressed = Bun.gzipSync(bodyBuf)
|
|
165
213
|
}
|
|
166
214
|
|
|
167
215
|
const newHeaders = new Headers(response.headers)
|
|
@@ -180,9 +228,8 @@ export async function applyCompression(request: Request, response: Response): Pr
|
|
|
180
228
|
newHeaders.set('Vary', 'Accept-Encoding')
|
|
181
229
|
}
|
|
182
230
|
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
return new Response(new Uint8Array(compressed), {
|
|
231
|
+
// Both codecs return owned, non-shared bytes accepted directly by Response.
|
|
232
|
+
return new Response(compressed, {
|
|
186
233
|
status: response.status,
|
|
187
234
|
statusText: response.statusText,
|
|
188
235
|
headers: newHeaders,
|
|
@@ -207,7 +254,7 @@ export default new Middleware({
|
|
|
207
254
|
// to the action — the post-action wrapper picks it up by name.
|
|
208
255
|
priority: 100,
|
|
209
256
|
|
|
210
|
-
|
|
257
|
+
handle(request) {
|
|
211
258
|
// Stamp the request so the post-response wrapper knows to compress.
|
|
212
259
|
// This is read by the router's response-finalization step.
|
|
213
260
|
;request._compress = true
|
package/app/Middleware/Cors.ts
CHANGED
package/app/Middleware/Csrf.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { timingSafeEqual } from 'node:crypto'
|
|
2
2
|
import { HttpError } from '@stacksjs/error-handling'
|
|
3
|
-
import type { EnhancedRequest } from '@stacksjs/
|
|
3
|
+
import type { EnhancedRequest } from '@stacksjs/router'
|
|
4
4
|
import { Middleware } from '@stacksjs/router'
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -63,6 +63,8 @@ const CSRF_SECURE_TRANSPORT = Symbol.for('@stacksjs/router:csrf-secure-transport
|
|
|
63
63
|
const CSRF_HEADER_NAME = 'x-csrf-token'
|
|
64
64
|
const CSRF_COOKIE_PREFIX = `${CSRF_COOKIE_NAME}=`
|
|
65
65
|
const LEGACY_CSRF_COOKIE_PREFIX = 'csrf-token='
|
|
66
|
+
const JOINED_CSRF_COOKIE_PREFIX = `, ${CSRF_COOKIE_PREFIX}`
|
|
67
|
+
const JOINED_LEGACY_CSRF_COOKIE_PREFIX = `, ${LEGACY_CSRF_COOKIE_PREFIX}`
|
|
66
68
|
const CSRF_COOKIE_SUFFIX = '; Path=/; SameSite=Lax; Max-Age=7200'
|
|
67
69
|
const CSRF_COOKIE_SECURE_SUFFIX = `${CSRF_COOKIE_SUFFIX}; Secure`
|
|
68
70
|
const TOKEN_BYTES = 32
|
|
@@ -122,12 +124,15 @@ function responseAlreadySeeds(response: Response): boolean {
|
|
|
122
124
|
? headers.getSetCookie()
|
|
123
125
|
: [headers.get('set-cookie') || '']
|
|
124
126
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
127
|
+
for (const cookie of cookies) {
|
|
128
|
+
if (
|
|
129
|
+
cookie.startsWith(CSRF_COOKIE_PREFIX)
|
|
130
|
+
|| cookie.startsWith(LEGACY_CSRF_COOKIE_PREFIX)
|
|
131
|
+
|| cookie.includes(JOINED_CSRF_COOKIE_PREFIX)
|
|
132
|
+
|| cookie.includes(JOINED_LEGACY_CSRF_COOKIE_PREFIX)
|
|
133
|
+
) return true
|
|
134
|
+
}
|
|
135
|
+
return false
|
|
131
136
|
}
|
|
132
137
|
|
|
133
138
|
export function createCsrfCookie(req: Request, minted?: string): string {
|
|
@@ -199,14 +204,26 @@ function csrfCookieToken(req: Request): string {
|
|
|
199
204
|
}
|
|
200
205
|
let canonical = ''
|
|
201
206
|
let legacy = ''
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
207
|
+
let start = 0
|
|
208
|
+
while (start < header.length) {
|
|
209
|
+
// Scan each name only once, stopping at '=' or a malformed pair's ';'.
|
|
210
|
+
let equals = start
|
|
211
|
+
while (equals < header.length && header.charCodeAt(equals) !== 61 && header.charCodeAt(equals) !== 59)
|
|
212
|
+
equals++
|
|
213
|
+
if (equals === header.length)
|
|
214
|
+
break
|
|
215
|
+
if (header.charCodeAt(equals) === 59) {
|
|
216
|
+
start = equals + 1
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
const separator = header.indexOf(';', equals + 1)
|
|
220
|
+
const end = separator === -1 ? header.length : separator
|
|
221
|
+
const name = header.slice(start, equals).trim()
|
|
206
222
|
if (name === CSRF_COOKIE_NAME)
|
|
207
|
-
canonical =
|
|
223
|
+
canonical = header.slice(equals + 1, end).trim()
|
|
208
224
|
else if (name === 'csrf-token')
|
|
209
|
-
legacy =
|
|
225
|
+
legacy = header.slice(equals + 1, end).trim()
|
|
226
|
+
start = end + 1
|
|
210
227
|
}
|
|
211
228
|
return canonical || legacy
|
|
212
229
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Middleware } from '@stacksjs/router'
|
|
2
2
|
|
|
3
|
+
let serverModule: typeof import('@stacksjs/server') | undefined
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Maintenance / Coming-Soon Middleware
|
|
5
7
|
*
|
|
@@ -27,7 +29,7 @@ export default new Middleware({
|
|
|
27
29
|
priority: 0, // Run first, before all other middleware
|
|
28
30
|
|
|
29
31
|
async handle(request) {
|
|
30
|
-
const { maintenanceGate } = await import('@stacksjs/server')
|
|
32
|
+
const { maintenanceGate } = serverModule ??= await import('@stacksjs/server')
|
|
31
33
|
|
|
32
34
|
const gated = await maintenanceGate(request)
|
|
33
35
|
if (gated)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { authenticatedUser } from '@stacksjs/auth'
|
|
1
|
+
import { authenticatedUser } from '@stacksjs/auth/middleware'
|
|
2
2
|
import { HttpError } from '@stacksjs/error-handling'
|
|
3
3
|
import { Middleware } from '@stacksjs/router'
|
|
4
4
|
|
|
@@ -32,7 +32,7 @@ export default new Middleware({
|
|
|
32
32
|
throw new HttpError(401, 'Unauthenticated.')
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
const { hasAnyPermission } = await import('@stacksjs/auth')
|
|
35
|
+
const { hasAnyPermission } = await import('@stacksjs/auth/rbac')
|
|
36
36
|
|
|
37
37
|
const hasRequired = await hasAnyPermission(user, requiredPermissions)
|
|
38
38
|
|
package/app/Middleware/Role.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { authenticatedUser } from '@stacksjs/auth'
|
|
1
|
+
import { authenticatedUser } from '@stacksjs/auth/middleware'
|
|
2
2
|
import { HttpError } from '@stacksjs/error-handling'
|
|
3
3
|
import { Middleware } from '@stacksjs/router'
|
|
4
4
|
|
|
@@ -32,7 +32,7 @@ export default new Middleware({
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// Dynamically import to avoid circular dependency
|
|
35
|
-
const { hasAnyRole } = await import('@stacksjs/auth')
|
|
35
|
+
const { hasAnyRole } = await import('@stacksjs/auth/rbac')
|
|
36
36
|
|
|
37
37
|
const hasRequired = await hasAnyRole(user, requiredRoles)
|
|
38
38
|
|
package/app/Middleware/Team.ts
CHANGED
|
@@ -67,7 +67,6 @@ export default new Middleware({
|
|
|
67
67
|
// If result is a Response (429), throw it to short-circuit the request
|
|
68
68
|
if (result instanceof Response) {
|
|
69
69
|
// Transform the response to match stacks format
|
|
70
|
-
const _body = await result.clone().json().catch(() => ({}))
|
|
71
70
|
const retryAfter = result.headers.get('Retry-After') || '60'
|
|
72
71
|
|
|
73
72
|
throw new Response(JSON.stringify({
|
|
@@ -15,10 +15,12 @@ export default defineModel({
|
|
|
15
15
|
useUuid: true,
|
|
16
16
|
useTimestamps: true,
|
|
17
17
|
useSearch: {
|
|
18
|
-
displayable: ['id', 'title', 'slug', '
|
|
18
|
+
displayable: ['id', 'title', 'slug', 'authorId', 'views', 'status', 'poster', 'focusKeyword', 'metaDescription', 'canonicalUrl'],
|
|
19
19
|
// `content`, not `body` - the column is `content`, and the old spelling
|
|
20
|
-
// silently indexed nothing. `comments` likewise was never a column
|
|
21
|
-
|
|
20
|
+
// silently indexed nothing. `comments` likewise was never a column, and
|
|
21
|
+
// nor was `author`: the column is `author_id`, which is worth showing and
|
|
22
|
+
// filtering on but is not free text to search.
|
|
23
|
+
searchable: ['title', 'slug', 'content', 'excerpt', 'focusKeyword', 'metaDescription'],
|
|
22
24
|
sortable: ['published_at', 'views'],
|
|
23
25
|
filterable: ['status'],
|
|
24
26
|
},
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { defineModel, selfOwnership } from '@stacksjs/orm'
|
|
2
|
+
import { schema } from '@stacksjs/validation'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A named API token belonging to a user - the Sanctum shape.
|
|
6
|
+
*
|
|
7
|
+
* Maps the table the auth layer already owns rather than introducing a second
|
|
8
|
+
* one. `@stacksjs/auth` issues these through `createToken(userId, name, scopes)`
|
|
9
|
+
* and checks them with `tokenCan` / `tokenCanAll` / `tokenAbilities`; what was
|
|
10
|
+
* missing was the model, so `User.hasMany('PersonalAccessToken')` named
|
|
11
|
+
* something real and a token list could be queried, related and exposed like
|
|
12
|
+
* any other row.
|
|
13
|
+
*
|
|
14
|
+
* `oauth_access_tokens` is created by `ensureAuthTables()` rather than by a
|
|
15
|
+
* migration, the same as `notifications` - which is why nothing here declares
|
|
16
|
+
* columns the auth layer does not already define.
|
|
17
|
+
*
|
|
18
|
+
* The token column holds a HASH, never the plaintext: `createToken` returns the
|
|
19
|
+
* plaintext once, to the caller, and it is not recoverable afterwards. It is
|
|
20
|
+
* `hidden` so it cannot leave through a serialised response, and `guarded` so
|
|
21
|
+
* no mass assignment can set it.
|
|
22
|
+
*/
|
|
23
|
+
export default defineModel({
|
|
24
|
+
name: 'PersonalAccessToken',
|
|
25
|
+
table: 'oauth_access_tokens',
|
|
26
|
+
primaryKey: 'id',
|
|
27
|
+
autoIncrement: true,
|
|
28
|
+
|
|
29
|
+
traits: {
|
|
30
|
+
useTimestamps: true,
|
|
31
|
+
// Deliberately no `useApi`.
|
|
32
|
+
//
|
|
33
|
+
// Minting and revoking both have semantics a generic CRUD route does not
|
|
34
|
+
// carry. `createToken` returns the plaintext exactly once, so a generic
|
|
35
|
+
// `store` would write a row nobody holds the token for; and revocation has
|
|
36
|
+
// to take the paired refresh token with it (stacksjs/stacks#2306), which a
|
|
37
|
+
// row delete does not. `@stacksjs/auth` owns both operations.
|
|
38
|
+
//
|
|
39
|
+
// It also keeps the write surface honest: row scoping compares ONE field,
|
|
40
|
+
// and this table's owner is a `tokenable_type` / `tokenable_id` pair - so a
|
|
41
|
+
// generated `destroy` scoped to `tokenable_id` alone would let a user
|
|
42
|
+
// destroy an author's token whenever the two ids happened to match.
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Rows belong to whoever the pair names. Declared even with no generated
|
|
47
|
+
* routes, so adding `useApi` later cannot ship an unscoped write by omission
|
|
48
|
+
* (stacksjs/stacks#2375) - though see the note above about the one field this
|
|
49
|
+
* can express.
|
|
50
|
+
*/
|
|
51
|
+
ownership: selfOwnership('tokenable_id'),
|
|
52
|
+
|
|
53
|
+
// No `belongsTo`: the owner is polymorphic. `tokenable_type` names the
|
|
54
|
+
// owner's table, so a users-only foreign key would be a claim the column no
|
|
55
|
+
// longer makes - and the FK preflight reported exactly that once the legacy
|
|
56
|
+
// `user_id` stopped being the owner.
|
|
57
|
+
attributes: {
|
|
58
|
+
/**
|
|
59
|
+
* The owner: the table it lives in, and its id there.
|
|
60
|
+
*
|
|
61
|
+
* Declared so the generated row type carries them. They are written by
|
|
62
|
+
* `@stacksjs/auth` when a token is minted and never by a caller, so both
|
|
63
|
+
* are guarded - an owner a request could set is an owner a request could
|
|
64
|
+
* change.
|
|
65
|
+
*/
|
|
66
|
+
tokenableType: {
|
|
67
|
+
order: 1,
|
|
68
|
+
guarded: true,
|
|
69
|
+
validation: {
|
|
70
|
+
rule: schema.string().max(255),
|
|
71
|
+
},
|
|
72
|
+
factory: () => 'users',
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
tokenableId: {
|
|
76
|
+
order: 2,
|
|
77
|
+
guarded: true,
|
|
78
|
+
validation: {
|
|
79
|
+
rule: schema.number(),
|
|
80
|
+
},
|
|
81
|
+
factory: () => 1,
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
name: {
|
|
85
|
+
order: 3,
|
|
86
|
+
fillable: true,
|
|
87
|
+
validation: {
|
|
88
|
+
rule: schema.string().max(255),
|
|
89
|
+
},
|
|
90
|
+
factory: faker => `${faker.hacker.noun()}-token`,
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
token: {
|
|
94
|
+
order: 4,
|
|
95
|
+
// The stored hash. Never serialised, never mass-assignable.
|
|
96
|
+
hidden: true,
|
|
97
|
+
guarded: true,
|
|
98
|
+
validation: {
|
|
99
|
+
rule: schema.string(),
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
scopes: {
|
|
104
|
+
order: 5,
|
|
105
|
+
type: 'text',
|
|
106
|
+
fillable: true,
|
|
107
|
+
validation: {
|
|
108
|
+
rule: schema.string(),
|
|
109
|
+
},
|
|
110
|
+
factory: () => JSON.stringify(['*']),
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
revoked: {
|
|
114
|
+
order: 6,
|
|
115
|
+
fillable: true,
|
|
116
|
+
default: false,
|
|
117
|
+
validation: {
|
|
118
|
+
rule: schema.boolean(),
|
|
119
|
+
},
|
|
120
|
+
factory: () => false,
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
expiresAt: {
|
|
124
|
+
order: 7,
|
|
125
|
+
fillable: true,
|
|
126
|
+
validation: {
|
|
127
|
+
rule: schema.string(),
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* What the browser called itself, and where it came from.
|
|
133
|
+
*
|
|
134
|
+
* So a person can recognise their own sessions on a list well enough to
|
|
135
|
+
* revoke one. Untrusted - a client chooses the string - which is why
|
|
136
|
+
* nothing authorises on either.
|
|
137
|
+
*/
|
|
138
|
+
userAgent: {
|
|
139
|
+
order: 8,
|
|
140
|
+
fillable: true,
|
|
141
|
+
validation: {
|
|
142
|
+
rule: schema.string().max(255),
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
ipAddress: {
|
|
147
|
+
order: 9,
|
|
148
|
+
fillable: true,
|
|
149
|
+
validation: {
|
|
150
|
+
rule: schema.string().max(45),
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
} as const)
|
package/app/Models/User.ts
CHANGED
|
@@ -16,10 +16,10 @@ export default defineModel({
|
|
|
16
16
|
useUuid: true,
|
|
17
17
|
useTimestamps: true,
|
|
18
18
|
useSearch: {
|
|
19
|
-
displayable: ['id', 'cartId', '
|
|
20
|
-
searchable: ['id', 'cartId', '
|
|
19
|
+
displayable: ['id', 'cartId', 'productName', 'productSku', 'quantity', 'unitPrice', 'totalPrice'],
|
|
20
|
+
searchable: ['id', 'cartId', 'productName', 'productSku'],
|
|
21
21
|
sortable: ['createdAt', 'updatedAt', 'quantity', 'unitPrice'],
|
|
22
|
-
filterable: ['cartId', '
|
|
22
|
+
filterable: ['cartId', 'productSku'],
|
|
23
23
|
},
|
|
24
24
|
|
|
25
25
|
useSeeder: {
|
|
@@ -16,10 +16,10 @@ export default defineModel({
|
|
|
16
16
|
useUuid: true,
|
|
17
17
|
useTimestamps: true,
|
|
18
18
|
useSearch: {
|
|
19
|
-
displayable: ['id', 'orderId', 'customerId', 'amount', 'method', 'status', '
|
|
19
|
+
displayable: ['id', 'orderId', 'customerId', 'amount', 'method', 'status', 'createdAt'],
|
|
20
20
|
searchable: ['orderId', 'customerId', 'referenceNumber'],
|
|
21
21
|
sortable: ['amount', 'createdAt'],
|
|
22
|
-
filterable: ['method', 'status', '
|
|
22
|
+
filterable: ['method', 'status', 'createdAt'],
|
|
23
23
|
},
|
|
24
24
|
|
|
25
25
|
useSeeder: {
|
|
@@ -16,9 +16,9 @@ export default defineModel({
|
|
|
16
16
|
useUuid: true,
|
|
17
17
|
useTimestamps: true,
|
|
18
18
|
useSearch: {
|
|
19
|
-
displayable: ['id', 'name', 'email', 'phone', '
|
|
19
|
+
displayable: ['id', 'name', 'email', 'phone', 'quantity', 'notificationPreference', 'source', 'notes', 'status'],
|
|
20
20
|
searchable: ['name', 'email', 'phone', 'source'],
|
|
21
|
-
sortable: ['name', '
|
|
21
|
+
sortable: ['name', 'quantity', 'createdAt', 'updatedAt', 'status'],
|
|
22
22
|
filterable: ['notificationPreference', 'source', 'status'],
|
|
23
23
|
},
|
|
24
24
|
|
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.31",
|
|
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.31",
|
|
59
59
|
"@stacksjs/sanitizer": "^0.2.113",
|
|
60
60
|
"ts-qr-codes": "^0.1.8"
|
|
61
61
|
}
|