@stacksjs/defaults 0.74.60 → 0.74.62

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.
@@ -352,14 +352,34 @@ await authUser.authorize('edit-post', post) // throws if denied
352
352
  providers: { users: { driver: 'database', table: 'users' } },
353
353
  username: 'email', // AUTH_USERNAME_FIELD env
354
354
  password: 'password', // AUTH_PASSWORD_FIELD env
355
- tokenExpiry: 30, // days, AUTH_TOKEN_EXPIRY env
356
- tokenRotation: 7, // days, AUTH_TOKEN_ROTATION env
355
+ tokenExpiry: 60 * 60 * 1000, // milliseconds, 1 hour
356
+ refreshTokenExpiry: 30 * 24 * 60 * 60 * 1000, // milliseconds
357
+ browserSession: {
358
+ baselineLifetime: 7 * 24 * 60 * 60 * 1000, // absolute milliseconds
359
+ rememberedLifetime: 30 * 24 * 60 * 60 * 1000,
360
+ withRefreshToken: false, // fixed browser lifetime, no unused refresh token
361
+ logoutRedirect: '/login?logged_out=1', // local path for HTML logout only
362
+ },
363
+ tokenRotation: 24, // hours
357
364
  defaultAbilities: ['*'],
358
365
  defaultTokenName: 'auth-token',
359
366
  passwordReset: { expire: 60, throttle: 60 }
360
367
  }
361
368
  ```
362
369
 
370
+ `browserSession` applies to credentials issued by the default login,
371
+ registration, and completed two-factor actions. Dedicated personal access
372
+ token and OAuth issuance remain unchanged. The default login form sends
373
+ `remember`; registration uses the baseline tier unless a custom client sends
374
+ that field. A two-factor challenge preserves the choice without minting a
375
+ session until verification succeeds. Cookie Max-Age comes from the lifetime
376
+ returned by token issuance, so it cannot outlive the token. Cookie-authenticated
377
+ writes use the CSRF flow and same-origin credentials.
378
+
379
+ When migrating an app that copied framework auth actions, remove only the
380
+ equivalent login, registration, two-factor, logout, and cookie-helper overrides.
381
+ Retain application-specific onboarding and event hooks.
382
+
363
383
  ### config/hashing.ts
364
384
  ```typescript
365
385
  {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-dashboard
3
- description: Use when building or customizing the Stacks admin dashboard, including dashboard pages, model management views, analytics widgets, commerce dashboards, content management, settings panels, deployment monitoring, job/queue management, or the 400 built-in dashboard components. Covers the dashboard system at storage/framework/defaults/.
3
+ description: Use when building or customizing the Stacks admin dashboard, including dashboard pages, model management views, analytics widgets, commerce dashboards, content management, settings panels, deployment monitoring, job/queue management, or the 401 built-in dashboard components. Covers the dashboard system at storage/framework/defaults/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -8,7 +8,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
8
8
 
9
9
  # Stacks Dashboard
10
10
 
11
- The Stacks admin dashboard provides a full-featured admin panel with 100+ route views, 400 components, and a multi-section layout.
11
+ The Stacks admin dashboard provides a full-featured admin panel with 100+ route views, 401 components, and a multi-section layout.
12
12
 
13
13
  ## Key Paths
14
14
  - Dashboard components: `storage/framework/defaults/resources/components/Dashboard/`
@@ -1,5 +1,5 @@
1
1
  import { Action } from '@stacksjs/actions'
2
- import { Auth, authCookie, withMagicLink } from '@stacksjs/auth'
2
+ import { Auth, authCookieForBrowserSession, resolveBrowserSessionPolicy, withMagicLink } from '@stacksjs/auth'
3
3
  import { config } from '@stacksjs/config'
4
4
  import { response } from '@stacksjs/router'
5
5
  import { schema } from '@stacksjs/validation'
@@ -20,9 +20,13 @@ export default new Action({
20
20
  if (!config.auth.magicLink?.enabled)
21
21
  return response.notFound('Magic-link sign-in is not enabled')
22
22
 
23
+ const policy = resolveBrowserSessionPolicy(false)
23
24
  const consumed = await withMagicLink(String(request.get('token')), async grant => ({
24
25
  grant,
25
- result: await Auth.loginUsingId(grant.userId),
26
+ result: await Auth.loginUsingId(grant.userId, {
27
+ expiresInMinutes: policy.expiresInMinutes,
28
+ withRefreshToken: policy.withRefreshToken,
29
+ }),
26
30
  }))
27
31
  if (!consumed.ok) {
28
32
  const messages: Record<string, string> = {
@@ -51,6 +55,6 @@ export default new Action({
51
55
  email: result.user?.email,
52
56
  name: result.user?.name,
53
57
  },
54
- }, { headers: { 'Set-Cookie': authCookie(result.token) } })
58
+ }, { headers: { 'Set-Cookie': authCookieForBrowserSession(result.token, result.expiresIn) } })
55
59
  },
56
60
  })
@@ -1,5 +1,5 @@
1
1
  import { Action } from '@stacksjs/actions'
2
- import { Auth, authCookie, resolveSocialSignIn, SocialSignInRefusedError } from '@stacksjs/auth'
2
+ import { Auth, authCookieForBrowserSession, resolveBrowserSessionPolicy, resolveSocialSignIn, SocialSignInRefusedError } from '@stacksjs/auth'
3
3
  import { log } from '@stacksjs/logging'
4
4
  import { response } from '@stacksjs/router'
5
5
  import { isSocialProviderConfigured, socialHandoffFailureRedirect, socialHandoffRedirect, socialProvider } from '@stacksjs/socials'
@@ -45,7 +45,11 @@ export default new Action({
45
45
 
46
46
  const { userId } = await resolveSocialSignIn(provider, identity)
47
47
 
48
- const session = await Auth.loginUsingId(userId)
48
+ const policy = resolveBrowserSessionPolicy(false)
49
+ const session = await Auth.loginUsingId(userId, {
50
+ expiresInMinutes: policy.expiresInMinutes,
51
+ withRefreshToken: policy.withRefreshToken,
52
+ })
49
53
  if (!session?.token)
50
54
  return socialHandoffFailureRedirect('Sign-in could not be completed. Please try again.')
51
55
 
@@ -61,7 +65,7 @@ export default new Action({
61
65
  })
62
66
 
63
67
  // The cookie signs server-rendered pages in; the fragment pack the SPA.
64
- redirect.headers.append('Set-Cookie', authCookie(session.token))
68
+ redirect.headers.append('Set-Cookie', authCookieForBrowserSession(session.token, session.expiresIn))
65
69
  return redirect
66
70
  }
67
71
  catch (error) {
@@ -36,8 +36,12 @@ export default defineModel({
36
36
  factory: () => 'default',
37
37
  },
38
38
 
39
+ // Both `text`: a payload is a serialized job envelope and an exception
40
+ // carries a stack trace, and neither fits the default varchar(255) that
41
+ // Postgres enforces.
39
42
  payload: {
40
43
  fillable: true,
44
+ type: 'text',
41
45
  validation: {
42
46
  rule: schema.string().required(),
43
47
  },
@@ -46,6 +50,7 @@ export default defineModel({
46
50
 
47
51
  exception: {
48
52
  fillable: true,
53
+ type: 'text',
49
54
  validation: {
50
55
  rule: schema.string().required(),
51
56
  },
package/app/Models/Job.ts CHANGED
@@ -28,6 +28,11 @@ export default defineModel({
28
28
 
29
29
  payload: {
30
30
  fillable: true,
31
+ // `text`, not the default varchar(255): a job's serialized envelope is
32
+ // far longer, and on Postgres every dispatch failed with "value too long
33
+ // for type character varying(255)". SQLite ignores the length, which is
34
+ // how it went unnoticed.
35
+ type: 'text',
31
36
  validation: {
32
37
  rule: schema.string().required(),
33
38
  },
package/functions/auth.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { resolveApiBaseUrl } from './api-url'
2
2
  import type { Ref } from '@stacksjs/stx'
3
- import type { AuthUser, LoginError, LoginResponse, MeResponse, RegisterCredentials, RegisterError, RegisterResponse, ResponseError, UserData } from '../types/dashboard'
3
+ import type { AuthUser, LoginError, LoginResponse, LoginResult, MeResponse, RegisterCredentials, RegisterError, RegisterResponse, ResponseError, TwoFactorLoginChallenge, UserData } from '../types/dashboard'
4
4
  import { withCsrfHeader } from '@stacksjs/browser/composables/csrf'
5
5
  import { useStorage } from '@stacksjs/browser/composables/useStorage'
6
6
  import { ref } from '@stacksjs/stx'
@@ -28,30 +28,72 @@ const baseUrl = resolveApiBaseUrl('')
28
28
  // Create singleton state
29
29
  const isAuthenticated = ref(false)
30
30
 
31
+ export function isTwoFactorChallenge(data: unknown): data is TwoFactorLoginChallenge {
32
+ if (!data || typeof data !== 'object')
33
+ return false
34
+ const candidate = data as Record<string, unknown>
35
+ return candidate.requires_two_factor === true && typeof candidate.challenge_token === 'string' && candidate.challenge_token.length > 0
36
+ }
37
+
38
+ export function isLoginResponse(data: unknown): data is LoginResponse {
39
+ if (!data || typeof data !== 'object')
40
+ return false
41
+ const candidate = data as Record<string, unknown>
42
+ return typeof candidate.token === 'string'
43
+ && candidate.token.length > 0
44
+ && typeof candidate.user === 'object'
45
+ && candidate.user !== null
46
+ && (candidate.refresh_token === undefined
47
+ || (typeof candidate.refresh_token === 'string' && candidate.refresh_token.length > 0))
48
+ }
49
+
50
+ /** Normalize a requested post-auth destination and reject cross-origin forms. */
51
+ export function safeAuthRedirect(value: unknown): string {
52
+ if (typeof value !== 'string')
53
+ return '/'
54
+ const candidate = value.trim()
55
+ if (!candidate.startsWith('/') || candidate.startsWith('//'))
56
+ return '/'
57
+
58
+ try {
59
+ const base = new URL('https://stacks.invalid')
60
+ const resolved = new URL(candidate, base)
61
+ if (resolved.origin !== base.origin)
62
+ return '/'
63
+ const target = `${resolved.pathname}${resolved.search}${resolved.hash}`
64
+ return target.startsWith('/') && !target.startsWith('//') ? target : '/'
65
+ }
66
+ catch {
67
+ return '/'
68
+ }
69
+ }
70
+
31
71
  export interface AuthComposable {
32
72
  isAuthenticated: Ref<boolean>
33
73
  user: { value: UserData | null }
34
- login: (user: AuthUser) => Promise<LoginResponse | LoginError>
74
+ login: (user: AuthUser) => Promise<LoginResult | LoginError>
75
+ verifyTwoFactorLogin: (challengeToken: string, code: string) => Promise<LoginResponse | LoginError>
35
76
  register: (user: RegisterCredentials) => Promise<RegisterResponse | RegisterError>
36
77
  fetchAuthUser: () => Promise<UserData | null>
37
78
  checkAuthentication: () => Promise<boolean>
38
- logout: () => void
79
+ logout: () => Promise<void>
39
80
  getToken: () => string | null
40
81
  token: { value: string | null }
41
82
  }
42
83
 
43
84
  export function useAuth(): AuthComposable {
85
+ function storeLogin(data: LoginResponse): void {
86
+ token.value = data.token
87
+ user.value = data.user
88
+ isAuthenticated.value = true
89
+ }
90
+
44
91
  async function fetchAuthUser(): Promise<UserData | null> {
45
92
  try {
46
- if (!token.value) {
47
- isAuthenticated.value = false
48
- user.value = null
49
- return null
50
- }
51
-
52
93
  const response = await fetch(`${baseUrl}/me`, {
94
+ credentials: 'same-origin',
53
95
  headers: {
54
- Authorization: `Bearer ${token.value}`,
96
+ ...(token.value ? { Authorization: `Bearer ${token.value}` } : {}),
55
97
  Accept: 'application/json',
56
98
  },
57
99
  })
@@ -122,7 +164,7 @@ export function useAuth(): AuthComposable {
122
164
  return 'token' in data && 'user' in data
123
165
  }
124
166
 
125
- async function login(credentials: AuthUser): Promise<LoginResponse | LoginError> {
167
+ async function login(credentials: AuthUser): Promise<LoginResult | LoginError> {
126
168
  const url = `${baseUrl}/login`
127
169
  const response = await fetch(url, {
128
170
  method: 'POST',
@@ -132,37 +174,49 @@ export function useAuth(): AuthComposable {
132
174
  }),
133
175
  body: JSON.stringify(credentials),
134
176
  })
135
- const data = await response.json() as LoginResponse | LoginError
177
+ const data = await response.json() as LoginResult | LoginError
136
178
 
137
- if (!response.ok || !('token' in data && 'user' in data))
179
+ if (!response.ok || isTwoFactorChallenge(data))
138
180
  return data
139
181
 
140
- token.value = data.token
141
- user.value = data.user
142
- isAuthenticated.value = true
182
+ if (!isLoginResponse(data))
183
+ return data
184
+
185
+ storeLogin(data)
186
+ return data
187
+ }
188
+
189
+ async function verifyTwoFactorLogin(challengeToken: string, code: string): Promise<LoginResponse | LoginError> {
190
+ const response = await fetch(`${baseUrl}/verify-two-factor-login`, {
191
+ method: 'POST',
192
+ credentials: 'same-origin',
193
+ headers: withCsrfHeader({ 'Content-Type': 'application/json' }),
194
+ body: JSON.stringify({ challenge_token: challengeToken, code }),
195
+ })
196
+ const data = await response.json() as LoginResponse | LoginError
197
+ if (!response.ok || !isLoginResponse(data))
198
+ return data
199
+
200
+ storeLogin(data)
143
201
  return data
144
202
  }
145
203
 
146
204
  async function logout() {
147
- try {
148
- if (token.value) {
149
- await fetch(`${baseUrl}/logout`, {
150
- method: 'POST',
151
- headers: {
152
- Authorization: `Bearer ${token.value}`,
153
- Accept: 'application/json',
154
- },
155
- })
156
- }
157
- }
158
- catch (error) {
159
- console.error('Error during logout:', error)
160
- }
161
- finally {
162
- token.value = ''
163
- user.value = null
164
- isAuthenticated.value = false
165
- }
205
+ const currentToken = token.value
206
+ const response = await fetch(`${baseUrl}/logout`, {
207
+ method: 'POST',
208
+ credentials: 'same-origin',
209
+ headers: withCsrfHeader({
210
+ ...(currentToken ? { Authorization: `Bearer ${currentToken}` } : {}),
211
+ Accept: 'application/json',
212
+ }),
213
+ })
214
+ if (!response.ok)
215
+ throw new Error(`Logout failed with status ${response.status}`)
216
+
217
+ token.value = ''
218
+ user.value = null
219
+ isAuthenticated.value = false
166
220
  }
167
221
 
168
222
  return {
@@ -172,6 +226,7 @@ export function useAuth(): AuthComposable {
172
226
  getToken: () => token.value,
173
227
  register,
174
228
  login,
229
+ verifyTwoFactorLogin,
175
230
  logout,
176
231
  fetchAuthUser,
177
232
  checkAuthentication,
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.74.60",
5
+ "version": "0.74.62",
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.74.60",
5
+ "version": "0.74.62",
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.60",
58
+ "@stacksjs/mobile": "^0.74.62",
59
59
  "@stacksjs/sanitizer": "^0.2.113",
60
60
  "ts-qr-codes": "^0.1.8"
61
61
  }
@@ -51,7 +51,7 @@ function handleSubmit(): void {
51
51
  emit('submit', {
52
52
  email: email(),
53
53
  password: password(),
54
- rememberMe: rememberMe(),
54
+ remember: rememberMe(),
55
55
  })
56
56
  }
57
57
  </script>
@@ -1,16 +1,16 @@
1
1
  <script client>
2
2
  import type { AuthUser } from '../../../../types/dashboard'
3
- import { describeAuthError, useAuth } from '../../../../functions/auth'
3
+ import { describeAuthError, isLoginResponse, isTwoFactorChallenge, safeAuthRedirect, useAuth } from '../../../../functions/auth'
4
4
 
5
- const { login } = useAuth()
5
+ const { login, verifyTwoFactorLogin } = useAuth()
6
6
  const route = useRoute()
7
7
 
8
8
  const isLoading = state(false)
9
9
  const error = state('')
10
+ const challengeToken = state('')
10
11
 
11
12
  function safeRedirect(): string {
12
- const redirect = String(route.query.redirect || '')
13
- return redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : '/'
13
+ return safeAuthRedirect(route.query.redirect)
14
14
  }
15
15
 
16
16
  async function handleLogin(credentials: AuthUser): Promise<void> {
@@ -20,7 +20,12 @@ async function handleLogin(credentials: AuthUser): Promise<void> {
20
20
  try {
21
21
  const result = await login(credentials)
22
22
 
23
- if (!result || !('token' in result)) {
23
+ if (isTwoFactorChallenge(result)) {
24
+ challengeToken.set(result.challenge_token)
25
+ return
26
+ }
27
+
28
+ if (!isLoginResponse(result)) {
24
29
  error.set(describeAuthError(result, 'Invalid credentials'))
25
30
  return
26
31
  }
@@ -34,10 +39,38 @@ async function handleLogin(credentials: AuthUser): Promise<void> {
34
39
  isLoading.set(false)
35
40
  }
36
41
  }
42
+
43
+ async function handleTwoFactor(code: string): Promise<void> {
44
+ isLoading.set(true)
45
+ error.set('')
46
+
47
+ try {
48
+ const result = await verifyTwoFactorLogin(challengeToken(), code)
49
+ if (!isLoginResponse(result)) {
50
+ challengeToken.set('')
51
+ error.set(describeAuthError(result, 'Invalid or expired login attempt. Please sign in again.'))
52
+ return
53
+ }
54
+
55
+ navigate(safeRedirect())
56
+ }
57
+ catch (err) {
58
+ error.set(err instanceof Error ? err.message : 'Unable to verify the authentication code')
59
+ }
60
+ finally {
61
+ isLoading.set(false)
62
+ }
63
+ }
64
+
65
+ function restartLogin(): void {
66
+ challengeToken.set('')
67
+ error.set('')
68
+ }
37
69
  </script>
38
70
 
39
71
  <template>
40
72
  <Login
73
+ :if="!challengeToken()"
41
74
  @submit="handleLogin"
42
75
  :isLoading="isLoading()"
43
76
  :error="error()"
@@ -47,4 +80,11 @@ async function handleLogin(credentials: AuthUser): Promise<void> {
47
80
  sign-in inside the card. -->
48
81
  <template #social><slot name="social" /></template>
49
82
  </Login>
83
+ <TwoFactorLogin
84
+ :if="challengeToken()"
85
+ @submit="handleTwoFactor"
86
+ @restart="restartLogin"
87
+ :isLoading="isLoading()"
88
+ :error="error()"
89
+ />
50
90
  </template>
@@ -0,0 +1,63 @@
1
+ @import('../UI/Input', '../UI/Button')
2
+
3
+ <script client>
4
+ const emit = defineEmits()
5
+ const isLoading = useReactiveProp<boolean>('isLoading', false)
6
+ const error = useReactiveProp<string>('error', '')
7
+ const code = state('')
8
+ const codeError = derived(() => code() && !/^\d{6}$/.test(code()) ? 'Enter the 6-digit code from your authenticator app' : '')
9
+
10
+ function submit(): void {
11
+ if (!/^\d{6}$/.test(code()))
12
+ return
13
+ emit('submit', code())
14
+ }
15
+ </script>
16
+
17
+ <div class="flex items-center justify-center px-4 py-12 min-h-screen">
18
+ <div class="max-w-sm w-full">
19
+ <div class="mb-8 text-center">
20
+ <div class="inline-flex items-center justify-center mb-4 h-12 w-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl shadow-blue-500/25 shadow-lg">
21
+ <span class="h-7 w-7 text-white i-hugeicons-shield-key"></span>
22
+ </div>
23
+ <h1 class="font-semibold text-2xl text-neutral-900 dark:text-white">Two-factor authentication</h1>
24
+ <p class="mt-2 text-neutral-500 text-sm dark:text-neutral-400">Enter the code from your authenticator app.</p>
25
+ </div>
26
+
27
+ <div class="p-6 bg-white dark:bg-neutral-900 border border-neutral-200/60 rounded-2xl dark:border-neutral-800 shadow-black/5 shadow-xl dark:shadow-black/20">
28
+ <div :if="error()" role="alert" class="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 rounded-lg dark:border-red-800">
29
+ <p class="text-red-600 text-sm dark:text-red-400">{{ error() }}</p>
30
+ </div>
31
+
32
+ <form @submit.prevent="submit">
33
+ <Input
34
+ type="text"
35
+ name="code"
36
+ label="Authentication code"
37
+ placeholder="123456"
38
+ autocomplete="one-time-code"
39
+ v-model:value="code"
40
+ :error="codeError()"
41
+ required
42
+ :disabled="isLoading()"
43
+ />
44
+
45
+ <div class="grid gap-3 mt-6">
46
+ <Button
47
+ type="submit"
48
+ variant="primary"
49
+ size="lg"
50
+ fullWidth
51
+ :loading="isLoading()"
52
+ :disabled="isLoading() || !/^\d{6}$/.test(code())"
53
+ >
54
+ Verify and sign in
55
+ </Button>
56
+ <Button type="button" variant="ghost" size="lg" fullWidth :disabled="isLoading()" @click="emit('restart')">
57
+ Back to sign in
58
+ </Button>
59
+ </div>
60
+ </form>
61
+ </div>
62
+ </div>
63
+ </div>
@@ -38,7 +38,7 @@ export interface RegisterResponse {
38
38
  // is kept alongside `access_token` for backward compatibility.
39
39
  export interface LoginResponse {
40
40
  access_token: string
41
- refresh_token: string
41
+ refresh_token?: string
42
42
  token_type: string
43
43
  expires_in: number
44
44
  token: string
@@ -49,6 +49,13 @@ export interface LoginResponse {
49
49
  }
50
50
  }
51
51
 
52
+ export interface TwoFactorLoginChallenge {
53
+ requires_two_factor: true
54
+ challenge_token: string
55
+ }
56
+
57
+ export type LoginResult = LoginResponse | TwoFactorLoginChallenge
58
+
52
59
  export interface Response<T> {
53
60
  errors: ResponseError
54
61
  data: T
@@ -57,6 +64,7 @@ export interface Response<T> {
57
64
  export interface AuthUser {
58
65
  email: string
59
66
  password: string
67
+ remember?: boolean
60
68
  }
61
69
 
62
70
  export interface RegisterCredentials extends AuthUser {