@stacksjs/defaults 0.70.294 → 0.70.297
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-development/SKILL.md +3 -3
- package/ai/skills/stacks-stx/SKILL.md +3 -3
- package/ai/skills/stacks-ui/SKILL.md +3 -3
- package/app/Actions/Auth/LoginAction.ts +8 -2
- package/app/Actions/Auth/RegisterAction.ts +3 -2
- package/app/Actions/Auth/SocialCallbackAction.ts +75 -0
- package/app/Actions/Auth/SocialRedirectAction.ts +37 -0
- package/app/Actions/Dashboard/Analytics/request-analytics.ts +6 -1
- package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +7 -0
- package/app/Actions/Dashboard/Commerce/commerce-product-records.ts +3 -1
- package/app/Actions/Dashboard/Commerce/commerce-record.ts +13 -0
- package/app/Actions/Dashboard/Infrastructure/InsightsAction.ts +3 -3
- package/app/Actions/Dashboard/Jobs/job-records.ts +5 -1
- package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +9 -7
- package/app/Actions/Dashboard/Kanban/BoardStoreAction.ts +1 -1
- package/app/Actions/Dashboard/Kanban/BoardsIndexAction.ts +6 -4
- package/app/Actions/Dashboard/Kanban/CardShowAction.ts +4 -2
- package/app/Actions/Dashboard/Library/GetAverageReleaseTime.ts +1 -1
- package/app/Actions/Dashboard/Marketing/CampaignIndexAction.ts +2 -2
- package/app/Actions/Dashboard/Marketing/ListIndexAction.ts +1 -1
- package/app/Actions/Dashboard/Queries/query-dashboard.ts +5 -3
- package/app/Actions/Password/PasswordResetAction.ts +29 -7
- package/app/Middleware/Auth.ts +4 -2
- package/app/Middleware/Can.ts +58 -5
- package/app/Models/SocialAccount.ts +70 -0
- package/app/Models/User.ts +7 -4
- package/app/Models/commerce/DeliveryRoute.ts +34 -0
- package/app/Models/commerce/DeliveryStop.ts +166 -0
- package/app/Models/commerce/Driver.ts +72 -2
- package/app/Models/commerce/DriverPing.ts +103 -0
- package/app/Models/commerce/Order.ts +44 -3
- package/app/password-policy.ts +46 -0
- package/bootstrap.ts +67 -19
- package/ide/vscode/package.json +1 -1
- package/package.json +1 -1
- package/routes/auth.ts +83 -0
- package/routes/dashboard.ts +0 -61
- package/routes/socials.ts +32 -0
- package/vcs/github/CONTRIBUTING.md +1 -1
- package/vcs/github/workflows/README.md +8 -9
- package/vcs/github/workflows/release.yml +4 -181
- package/vcs/github/workflows/export-size.yml +0 -25
|
@@ -333,7 +333,7 @@ await authUser.authorize('edit-post', post) // throws if denied
|
|
|
333
333
|
tokenExpiry: 30, // days, AUTH_TOKEN_EXPIRY env
|
|
334
334
|
tokenRotation: 7, // days, AUTH_TOKEN_ROTATION env
|
|
335
335
|
defaultAbilities: ['*'],
|
|
336
|
-
defaultTokenName: '
|
|
336
|
+
defaultTokenName: 'auth-token',
|
|
337
337
|
passwordReset: { expire: 60, throttle: 60 }
|
|
338
338
|
}
|
|
339
339
|
```
|
|
@@ -279,9 +279,9 @@ Default configurations provided at `storage/framework/defaults/ide/`:
|
|
|
279
279
|
|
|
280
280
|
```typescript
|
|
281
281
|
{
|
|
282
|
-
componentsDir: '
|
|
283
|
-
layoutsDir: '
|
|
284
|
-
partialsDir: '
|
|
282
|
+
componentsDir: 'components',
|
|
283
|
+
layoutsDir: 'layouts',
|
|
284
|
+
partialsDir: 'partials',
|
|
285
285
|
}
|
|
286
286
|
```
|
|
287
287
|
|
|
@@ -127,9 +127,9 @@ dedicated signal for each binding. Arbitrary expressions such as
|
|
|
127
127
|
import type { StxOptions } from '@stacksjs/stx'
|
|
128
128
|
|
|
129
129
|
export default {
|
|
130
|
-
componentsDir: '
|
|
131
|
-
layoutsDir: '
|
|
132
|
-
partialsDir: '
|
|
130
|
+
componentsDir: 'components',
|
|
131
|
+
layoutsDir: 'layouts',
|
|
132
|
+
partialsDir: 'partials',
|
|
133
133
|
} satisfies StxOptions
|
|
134
134
|
```
|
|
135
135
|
|
|
@@ -179,9 +179,9 @@ export default {
|
|
|
179
179
|
|
|
180
180
|
```typescript
|
|
181
181
|
export default {
|
|
182
|
-
componentsDir: '
|
|
183
|
-
layoutsDir: '
|
|
184
|
-
partialsDir: '
|
|
182
|
+
componentsDir: 'components',
|
|
183
|
+
layoutsDir: 'layouts',
|
|
184
|
+
partialsDir: 'partials',
|
|
185
185
|
} satisfies StxOptions
|
|
186
186
|
```
|
|
187
187
|
|
|
@@ -3,6 +3,7 @@ import { Auth, createTwoFactorChallenge, getTwoFactorState } from '@stacksjs/aut
|
|
|
3
3
|
import { User } from '@stacksjs/orm'
|
|
4
4
|
import { response } from '@stacksjs/router'
|
|
5
5
|
import { schema } from '@stacksjs/validation'
|
|
6
|
+
import { PASSWORD_MAX_LENGTH, PASSWORD_PRESENCE_MESSAGE } from '../../password-policy'
|
|
6
7
|
|
|
7
8
|
export default new Action({
|
|
8
9
|
name: 'LoginAction',
|
|
@@ -14,9 +15,14 @@ export default new Action({
|
|
|
14
15
|
rule: schema.string().email(),
|
|
15
16
|
message: 'Email must be a valid email address.',
|
|
16
17
|
},
|
|
18
|
+
// Presence only, NOT the creation policy. Enforcing a minimum length on
|
|
19
|
+
// sign-in locks out every account created under a shorter one: the user is
|
|
20
|
+
// told their own password is too short, with a 422 raised before the
|
|
21
|
+
// credentials are ever checked. The policy belongs on the paths that SET a
|
|
22
|
+
// password (#2226).
|
|
17
23
|
password: {
|
|
18
|
-
rule: schema.string().min(
|
|
19
|
-
message:
|
|
24
|
+
rule: schema.string().min(1).max(PASSWORD_MAX_LENGTH),
|
|
25
|
+
message: PASSWORD_PRESENCE_MESSAGE,
|
|
20
26
|
},
|
|
21
27
|
},
|
|
22
28
|
|
|
@@ -3,6 +3,7 @@ import { Auth, register } from '@stacksjs/auth'
|
|
|
3
3
|
import { dispatch } from '@stacksjs/events'
|
|
4
4
|
import { response } from '@stacksjs/router'
|
|
5
5
|
import { schema } from '@stacksjs/validation'
|
|
6
|
+
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from '../../password-policy'
|
|
6
7
|
|
|
7
8
|
export default new Action({
|
|
8
9
|
name: 'RegisterAction',
|
|
@@ -15,8 +16,8 @@ export default new Action({
|
|
|
15
16
|
message: 'Email must be a valid email address.',
|
|
16
17
|
},
|
|
17
18
|
password: {
|
|
18
|
-
rule: schema.string().min(
|
|
19
|
-
message:
|
|
19
|
+
rule: schema.string().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH),
|
|
20
|
+
message: PASSWORD_POLICY_MESSAGE,
|
|
20
21
|
},
|
|
21
22
|
name: {
|
|
22
23
|
rule: schema.string().min(2).max(255),
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import { Auth, authCookie, resolveSocialSignIn, SocialSignInRefusedError } from '@stacksjs/auth'
|
|
3
|
+
import { log } from '@stacksjs/logging'
|
|
4
|
+
import { response } from '@stacksjs/router'
|
|
5
|
+
import { isSocialProviderConfigured, socialHandoffFailureRedirect, socialHandoffRedirect, socialProvider } from '@stacksjs/socials'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `GET|POST /auth/{provider}/callback` — complete a social sign-in
|
|
9
|
+
* (stacksjs/stacks#2276).
|
|
10
|
+
*
|
|
11
|
+
* The provider redirects back here with a `code`. The driver exchanges it for
|
|
12
|
+
* the provider's user, `resolveSocialSignIn` decides which local user that is
|
|
13
|
+
* (find-or-create, with the unverified-email takeover guard), and the session
|
|
14
|
+
* is handed to the browser twice over: the auth cookie for server-rendered
|
|
15
|
+
* pages, and the fragment handoff `useAuth().completeSocialLogin()` reads for
|
|
16
|
+
* the SPA side. POST as well as GET because Apple mandates
|
|
17
|
+
* `response_mode=form_post` whenever scopes are requested.
|
|
18
|
+
*
|
|
19
|
+
* A refused sign-in redirects to /login with a `social_error` the page can
|
|
20
|
+
* render; it is a policy decision, not an exception. Provider/exchange
|
|
21
|
+
* failures land there too, with the detail in the log rather than the URL.
|
|
22
|
+
*/
|
|
23
|
+
export default new Action({
|
|
24
|
+
name: 'SocialCallbackAction',
|
|
25
|
+
description: 'Completes a social provider OAuth flow and signs the browser in.',
|
|
26
|
+
method: 'GET',
|
|
27
|
+
|
|
28
|
+
async handle(request: RequestInstance) {
|
|
29
|
+
const provider = String(request.getParam('provider') ?? '').toLowerCase()
|
|
30
|
+
|
|
31
|
+
if (!isSocialProviderConfigured(provider))
|
|
32
|
+
return response.notFound(`Social provider "${provider}" is not configured.`)
|
|
33
|
+
|
|
34
|
+
const driver = socialProvider(provider)
|
|
35
|
+
if (!driver)
|
|
36
|
+
return response.notFound(`Social provider "${provider}" is not configured.`)
|
|
37
|
+
|
|
38
|
+
const code = String(request.get('code') ?? '')
|
|
39
|
+
if (!code)
|
|
40
|
+
return socialHandoffFailureRedirect(`${provider} did not return an authorization code.`)
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const accessToken = await driver.getAccessToken(code)
|
|
44
|
+
const identity = await driver.getUserByToken(accessToken)
|
|
45
|
+
|
|
46
|
+
const { userId } = await resolveSocialSignIn(provider, identity)
|
|
47
|
+
|
|
48
|
+
const session = await Auth.loginUsingId(userId)
|
|
49
|
+
if (!session?.token)
|
|
50
|
+
return socialHandoffFailureRedirect('Sign-in could not be completed. Please try again.')
|
|
51
|
+
|
|
52
|
+
const redirect = socialHandoffRedirect({
|
|
53
|
+
token: session.token,
|
|
54
|
+
...(session.refreshToken && { refreshToken: session.refreshToken }),
|
|
55
|
+
...(session.expiresIn !== undefined && { expiresIn: session.expiresIn }),
|
|
56
|
+
user: {
|
|
57
|
+
id: userId,
|
|
58
|
+
email: identity.email ?? null,
|
|
59
|
+
name: identity.name ?? identity.nickname ?? null,
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// The cookie signs server-rendered pages in; the fragment pack the SPA.
|
|
64
|
+
redirect.headers.append('Set-Cookie', authCookie(session.token))
|
|
65
|
+
return redirect
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error instanceof SocialSignInRefusedError)
|
|
69
|
+
return socialHandoffFailureRedirect(error.message)
|
|
70
|
+
|
|
71
|
+
log.error(`[socials] ${provider} callback failed: ${(error as Error).message}`)
|
|
72
|
+
return socialHandoffFailureRedirect('Sign-in could not be completed. Please try again.')
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import { response } from '@stacksjs/router'
|
|
3
|
+
import { isSocialProviderConfigured, socialProvider } from '@stacksjs/socials'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `GET /auth/{provider}` — send the browser to the provider's consent page
|
|
7
|
+
* (stacksjs/stacks#2276).
|
|
8
|
+
*
|
|
9
|
+
* A provider that is not fully configured 404s rather than redirecting into a
|
|
10
|
+
* half-built OAuth flow: the sign-in page renders its buttons from
|
|
11
|
+
* `configuredSocialProviders()`, so a visitor only reaches this with a name
|
|
12
|
+
* that works — anything else is URL guessing.
|
|
13
|
+
*/
|
|
14
|
+
export default new Action({
|
|
15
|
+
name: 'SocialRedirectAction',
|
|
16
|
+
description: 'Redirects the browser to a social provider\'s OAuth consent page.',
|
|
17
|
+
method: 'GET',
|
|
18
|
+
|
|
19
|
+
async handle(request: RequestInstance) {
|
|
20
|
+
const provider = String(request.getParam('provider') ?? '').toLowerCase()
|
|
21
|
+
|
|
22
|
+
if (!isSocialProviderConfigured(provider))
|
|
23
|
+
return response.notFound(`Social provider "${provider}" is not configured.`)
|
|
24
|
+
|
|
25
|
+
const driver = socialProvider(provider)
|
|
26
|
+
if (!driver)
|
|
27
|
+
return response.notFound(`Social provider "${provider}" is not configured.`)
|
|
28
|
+
|
|
29
|
+
return new Response(null, {
|
|
30
|
+
status: 302,
|
|
31
|
+
headers: {
|
|
32
|
+
'Location': await driver.getAuthUrl(),
|
|
33
|
+
'Cache-Control': 'no-store',
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
},
|
|
37
|
+
})
|
|
@@ -105,7 +105,12 @@ export function requestAnalyticsRow(record: AnalyticsModelRecord): RequestAnalyt
|
|
|
105
105
|
if (durationMs < 0)
|
|
106
106
|
throw new TypeError('Request.duration_ms cannot be negative.')
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
// Postgres and MySQL drivers return Date instances for timestamp columns;
|
|
109
|
+
// SQLite stores TEXT and returns strings.
|
|
110
|
+
const createdAtValue = record.get('created_at')
|
|
111
|
+
const createdAt = createdAtValue instanceof Date
|
|
112
|
+
? createdAtValue.toISOString()
|
|
113
|
+
: requiredRecordString(record, 'created_at')
|
|
109
114
|
if (!Number.isFinite(timestamp(createdAt)))
|
|
110
115
|
throw new TypeError('Request.created_at must be a valid timestamp.')
|
|
111
116
|
|
|
@@ -79,6 +79,13 @@ function receiptOptionalText(input: unknown, source: string, fieldName: string):
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
function receiptTimestamp(input: unknown, source: string, fieldName: string): string {
|
|
82
|
+
// Postgres and MySQL drivers return Date instances for timestamp columns;
|
|
83
|
+
// SQLite stores TEXT and returns strings. Accept both.
|
|
84
|
+
if (input instanceof Date) {
|
|
85
|
+
if (!Number.isFinite(input.getTime()))
|
|
86
|
+
throw receiptError(source, fieldName, 'a valid timestamp')
|
|
87
|
+
return input.toISOString()
|
|
88
|
+
}
|
|
82
89
|
const raw = receiptText(input, source, fieldName)
|
|
83
90
|
const date = new Date(/^\d{4}-\d{2}-\d{2} \d/.test(raw) ? `${raw.replace(' ', 'T')}Z` : raw)
|
|
84
91
|
if (!Number.isFinite(date.getTime()))
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { commerceTimestamp } from './commerce-record'
|
|
2
|
+
|
|
1
3
|
export interface CommerceProductRecord {
|
|
2
4
|
id: string
|
|
3
5
|
name: string
|
|
@@ -230,7 +232,7 @@ export function normalizeCommerceProductRecord(
|
|
|
230
232
|
variantCount: variantCounts.get(id) || 0,
|
|
231
233
|
unitCount: unitCounts.get(id) || 0,
|
|
232
234
|
reviewCount: reviewCounts.get(id) || 0,
|
|
233
|
-
createdAt:
|
|
235
|
+
createdAt: commerceTimestamp(value(product, 'created_at', 'createdAt'), source, 'created_at'),
|
|
234
236
|
}
|
|
235
237
|
}
|
|
236
238
|
|
|
@@ -174,6 +174,13 @@ export function commerceUrl(input: unknown, source: string, field: string): stri
|
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
export function commerceTimestamp(input: unknown, source: string, field = 'created_at'): string {
|
|
177
|
+
// Postgres and MySQL drivers return Date instances for timestamp columns;
|
|
178
|
+
// SQLite stores TEXT and returns strings. Accept both.
|
|
179
|
+
if (input instanceof Date) {
|
|
180
|
+
if (!Number.isFinite(input.getTime()))
|
|
181
|
+
throw commerceRecordError(source, field, 'a valid timestamp')
|
|
182
|
+
return input.toISOString()
|
|
183
|
+
}
|
|
177
184
|
const raw = typeof input === 'number'
|
|
178
185
|
? String(input)
|
|
179
186
|
: commerceRequiredString(input, source, field)
|
|
@@ -197,6 +204,12 @@ export function commerceOptionalTimestamp(input: unknown, source: string, field:
|
|
|
197
204
|
}
|
|
198
205
|
|
|
199
206
|
export function commerceDate(input: unknown, source: string, field: string): string {
|
|
207
|
+
// Postgres and MySQL drivers return Date instances for date columns.
|
|
208
|
+
if (input instanceof Date) {
|
|
209
|
+
if (!Number.isFinite(input.getTime()))
|
|
210
|
+
throw commerceRecordError(source, field, 'a valid YYYY-MM-DD date')
|
|
211
|
+
return input.toISOString().slice(0, 10)
|
|
212
|
+
}
|
|
200
213
|
const raw = commerceRequiredString(input, source, field)
|
|
201
214
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw))
|
|
202
215
|
throw commerceRecordError(source, field, 'a valid YYYY-MM-DD date')
|
|
@@ -56,20 +56,20 @@ export default new Action({
|
|
|
56
56
|
db.fn.max('duration_ms').as('maximum'),
|
|
57
57
|
db.fn.max('created_at').as('latest'),
|
|
58
58
|
])
|
|
59
|
-
.
|
|
59
|
+
.whereNull('deleted_at')
|
|
60
60
|
.executeTakeFirst() as Promise<NumericSummaryRow | undefined>
|
|
61
61
|
|
|
62
62
|
const requestSuccessQuery = db
|
|
63
63
|
.selectFrom('requests')
|
|
64
64
|
.select(db.fn.count('id').as('count'))
|
|
65
|
-
.
|
|
65
|
+
.whereNull('deleted_at')
|
|
66
66
|
.where('status_code', '<', 400)
|
|
67
67
|
.executeTakeFirst() as Promise<{ count: number | string } | undefined>
|
|
68
68
|
|
|
69
69
|
const slowRequestsQuery = db
|
|
70
70
|
.selectFrom('requests')
|
|
71
71
|
.select(['id', 'method', 'path', 'status_code', 'duration_ms', 'created_at'])
|
|
72
|
-
.
|
|
72
|
+
.whereNull('deleted_at')
|
|
73
73
|
.orderBy('duration_ms', 'desc')
|
|
74
74
|
.orderBy('id', 'desc')
|
|
75
75
|
.limit(5)
|
|
@@ -98,7 +98,11 @@ function timestampValue(
|
|
|
98
98
|
return undefined
|
|
99
99
|
|
|
100
100
|
let time = Number.NaN
|
|
101
|
-
|
|
101
|
+
// Postgres and MySQL drivers return Date instances for timestamp columns;
|
|
102
|
+
// SQLite stores TEXT and returns strings.
|
|
103
|
+
if (value instanceof Date)
|
|
104
|
+
time = value.getTime()
|
|
105
|
+
else if (typeof value === 'number' && Number.isInteger(value) && value >= 0)
|
|
102
106
|
time = value * 1000
|
|
103
107
|
else if (typeof value === 'string' && value.trim())
|
|
104
108
|
time = new Date(/^\d{4}-\d{2}-\d{2} \d/.test(value) ? `${value.replace(' ', 'T')}Z` : value).getTime()
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions'
|
|
2
2
|
import { db } from '@stacksjs/database'
|
|
3
|
+
import { modelBoolean } from './kanban-model'
|
|
3
4
|
import { kanbanError } from './kanban-response'
|
|
4
5
|
|
|
5
6
|
interface BoardRow {
|
|
@@ -10,7 +11,8 @@ interface BoardRow {
|
|
|
10
11
|
icon: string
|
|
11
12
|
color: string
|
|
12
13
|
position: number
|
|
13
|
-
|
|
14
|
+
// SQLite stores booleans as 0/1 INTEGER columns; Postgres returns real booleans.
|
|
15
|
+
archived: number | boolean
|
|
14
16
|
created_at: string | null
|
|
15
17
|
updated_at: string | null
|
|
16
18
|
}
|
|
@@ -37,7 +39,7 @@ interface CardRow {
|
|
|
37
39
|
position: number
|
|
38
40
|
created_by_user_id: number | null
|
|
39
41
|
due_date: string | null
|
|
40
|
-
archived: number
|
|
42
|
+
archived: number | boolean
|
|
41
43
|
created_at: string | null
|
|
42
44
|
updated_at: string | null
|
|
43
45
|
}
|
|
@@ -103,7 +105,7 @@ export default new Action({
|
|
|
103
105
|
[id],
|
|
104
106
|
).execute() as Promise<ColumnRow[]>,
|
|
105
107
|
db.unsafe(
|
|
106
|
-
'SELECT * FROM cards WHERE board_id = ? AND archived =
|
|
108
|
+
'SELECT * FROM cards WHERE board_id = ? AND archived = false ORDER BY column_id ASC, position ASC, id ASC',
|
|
107
109
|
[id],
|
|
108
110
|
).execute() as Promise<CardRow[]>,
|
|
109
111
|
db.unsafe(
|
|
@@ -121,7 +123,7 @@ export default new Action({
|
|
|
121
123
|
`SELECT cl.card_id, l.id, l.name, l.color
|
|
122
124
|
FROM card_labels cl
|
|
123
125
|
JOIN labels l ON l.id = cl.label_id
|
|
124
|
-
WHERE cl.card_id IN (SELECT id FROM cards WHERE board_id = ? AND archived =
|
|
126
|
+
WHERE cl.card_id IN (SELECT id FROM cards WHERE board_id = ? AND archived = false)
|
|
125
127
|
ORDER BY l.name ASC`,
|
|
126
128
|
[id],
|
|
127
129
|
).execute() as Promise<Array<{ card_id: number, id: number, name: string, color: string }>>,
|
|
@@ -129,7 +131,7 @@ export default new Action({
|
|
|
129
131
|
`SELECT ca.card_id, ca.user_id, u.name, u.email
|
|
130
132
|
FROM card_assignees ca
|
|
131
133
|
LEFT JOIN users u ON u.id = ca.user_id
|
|
132
|
-
WHERE ca.card_id IN (SELECT id FROM cards WHERE board_id = ? AND archived =
|
|
134
|
+
WHERE ca.card_id IN (SELECT id FROM cards WHERE board_id = ? AND archived = false)`,
|
|
133
135
|
[id],
|
|
134
136
|
).execute() as Promise<Array<{ card_id: number, user_id: number, name: string | null, email: string | null }>>,
|
|
135
137
|
])
|
|
@@ -175,7 +177,7 @@ export default new Action({
|
|
|
175
177
|
position: c.position,
|
|
176
178
|
createdByUserId: c.created_by_user_id,
|
|
177
179
|
dueDate: c.due_date,
|
|
178
|
-
archived: c
|
|
180
|
+
archived: modelBoolean(c, 'archived'),
|
|
179
181
|
createdAt: c.created_at,
|
|
180
182
|
updatedAt: c.updated_at,
|
|
181
183
|
labels: labelsByCard.get(c.id) ?? [],
|
|
@@ -192,7 +194,7 @@ export default new Action({
|
|
|
192
194
|
icon: board.icon,
|
|
193
195
|
color: board.color,
|
|
194
196
|
position: board.position,
|
|
195
|
-
archived: board
|
|
197
|
+
archived: modelBoolean(board, 'archived'),
|
|
196
198
|
createdAt: board.created_at,
|
|
197
199
|
updatedAt: board.updated_at,
|
|
198
200
|
},
|
|
@@ -46,7 +46,7 @@ export default new Action({
|
|
|
46
46
|
// is a sort hint, not a unique key. Reorder via /boards/reorder
|
|
47
47
|
// restores tight ordering when the user cares.
|
|
48
48
|
const maxRow = await db.unsafe(
|
|
49
|
-
'SELECT COALESCE(MAX(position), -1) AS m FROM boards WHERE archived =
|
|
49
|
+
'SELECT COALESCE(MAX(position), -1) AS m FROM boards WHERE archived = false',
|
|
50
50
|
).execute() as Array<{ m: number }>
|
|
51
51
|
const nextPosition = (Number(maxRow?.[0]?.m ?? -1) + 1) || 0
|
|
52
52
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions'
|
|
2
2
|
import { db } from '@stacksjs/database'
|
|
3
|
+
import { modelBoolean } from './kanban-model'
|
|
3
4
|
import { kanbanError } from './kanban-response'
|
|
4
5
|
|
|
5
6
|
interface BoardRow {
|
|
@@ -10,7 +11,8 @@ interface BoardRow {
|
|
|
10
11
|
icon: string
|
|
11
12
|
color: string
|
|
12
13
|
position: number
|
|
13
|
-
|
|
14
|
+
// SQLite stores booleans as 0/1 INTEGER columns; Postgres returns real booleans.
|
|
15
|
+
archived: number | boolean
|
|
14
16
|
created_at: string | null
|
|
15
17
|
updated_at: string | null
|
|
16
18
|
card_count?: number
|
|
@@ -46,9 +48,9 @@ export default new Action({
|
|
|
46
48
|
SELECT
|
|
47
49
|
b.id, b.uuid, b.name, b.description, b.icon, b.color,
|
|
48
50
|
b.position, b.archived, b.created_at, b.updated_at,
|
|
49
|
-
(SELECT COUNT(*) FROM cards c WHERE c.board_id = b.id AND c.archived =
|
|
51
|
+
(SELECT COUNT(*) FROM cards c WHERE c.board_id = b.id AND c.archived = false) AS card_count
|
|
50
52
|
FROM boards b
|
|
51
|
-
WHERE b.archived =
|
|
53
|
+
WHERE b.archived = false
|
|
52
54
|
ORDER BY b.position ASC, b.id ASC
|
|
53
55
|
`).execute() as BoardRow[]
|
|
54
56
|
|
|
@@ -60,7 +62,7 @@ export default new Action({
|
|
|
60
62
|
icon: r.icon,
|
|
61
63
|
color: r.color,
|
|
62
64
|
position: r.position,
|
|
63
|
-
archived: r
|
|
65
|
+
archived: modelBoolean(r, 'archived'),
|
|
64
66
|
cardCount: Number(r.card_count ?? 0),
|
|
65
67
|
createdAt: r.created_at,
|
|
66
68
|
updatedAt: r.updated_at,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions'
|
|
2
2
|
import { db } from '@stacksjs/database'
|
|
3
|
+
import { modelBoolean } from './kanban-model'
|
|
3
4
|
import { kanbanError } from './kanban-response'
|
|
4
5
|
|
|
5
6
|
interface CardRow {
|
|
@@ -12,7 +13,8 @@ interface CardRow {
|
|
|
12
13
|
position: number
|
|
13
14
|
created_by_user_id: number | null
|
|
14
15
|
due_date: string | null
|
|
15
|
-
|
|
16
|
+
// SQLite stores booleans as 0/1 INTEGER columns; Postgres returns real booleans.
|
|
17
|
+
archived: number | boolean
|
|
16
18
|
created_at: string | null
|
|
17
19
|
updated_at: string | null
|
|
18
20
|
}
|
|
@@ -91,7 +93,7 @@ export default new Action({
|
|
|
91
93
|
position: card.position,
|
|
92
94
|
createdByUserId: card.created_by_user_id,
|
|
93
95
|
dueDate: card.due_date,
|
|
94
|
-
archived: card
|
|
96
|
+
archived: modelBoolean(card, 'archived'),
|
|
95
97
|
createdAt: card.created_at,
|
|
96
98
|
updatedAt: card.updated_at,
|
|
97
99
|
},
|
|
@@ -10,7 +10,7 @@ export default new Action({
|
|
|
10
10
|
const rows = await db
|
|
11
11
|
.selectFrom('releases')
|
|
12
12
|
.select(['created_at'])
|
|
13
|
-
.
|
|
13
|
+
.whereNotNull('created_at')
|
|
14
14
|
.orderBy('created_at')
|
|
15
15
|
.execute() as unknown as Array<{ created_at: string }>
|
|
16
16
|
|
|
@@ -35,13 +35,13 @@ export default new Action({
|
|
|
35
35
|
db
|
|
36
36
|
.selectFrom('campaign_sends')
|
|
37
37
|
.select(['campaign_id', db.fn.count('id').as('count')])
|
|
38
|
-
.
|
|
38
|
+
.whereNotNull('opened_at')
|
|
39
39
|
.groupBy('campaign_id')
|
|
40
40
|
.execute(),
|
|
41
41
|
db
|
|
42
42
|
.selectFrom('campaign_sends')
|
|
43
43
|
.select(['campaign_id', db.fn.count('id').as('count')])
|
|
44
|
-
.
|
|
44
|
+
.whereNotNull('clicked_at')
|
|
45
45
|
.groupBy('campaign_id')
|
|
46
46
|
.execute(),
|
|
47
47
|
])
|
|
@@ -6,7 +6,8 @@ export interface QueryLogSourceRow {
|
|
|
6
6
|
connection?: string | null
|
|
7
7
|
status?: string | null
|
|
8
8
|
error?: string | null
|
|
9
|
-
|
|
9
|
+
// SQLite returns timestamp columns as TEXT; Postgres and MySQL drivers return Date instances.
|
|
10
|
+
executed_at: string | Date
|
|
10
11
|
model?: string | null
|
|
11
12
|
method?: string | null
|
|
12
13
|
rows_affected?: number | null
|
|
@@ -89,7 +90,8 @@ export function mapDashboardQueryLog(row: QueryLogSourceRow): DashboardQueryLog
|
|
|
89
90
|
const status = queryStatus(row.status, label)
|
|
90
91
|
if (typeof row.query !== 'string' || !row.query.trim())
|
|
91
92
|
throw new TypeError(`${label} query must be a non-empty string`)
|
|
92
|
-
|
|
93
|
+
const executedAt = row.executed_at instanceof Date ? row.executed_at.toISOString() : row.executed_at
|
|
94
|
+
if (typeof executedAt !== 'string' || !executedAt.trim())
|
|
93
95
|
throw new TypeError(`${label} executed_at must be a non-empty string`)
|
|
94
96
|
|
|
95
97
|
return {
|
|
@@ -101,7 +103,7 @@ export function mapDashboardQueryLog(row: QueryLogSourceRow): DashboardQueryLog
|
|
|
101
103
|
connection: row.connection || 'unknown',
|
|
102
104
|
status,
|
|
103
105
|
error: row.error || '',
|
|
104
|
-
executedAt
|
|
106
|
+
executedAt,
|
|
105
107
|
model: row.model || '',
|
|
106
108
|
method: row.method || '',
|
|
107
109
|
rowsAffected: nullableFiniteNumber(row.rows_affected, `${label} rows_affected`),
|
|
@@ -1,32 +1,54 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions'
|
|
2
2
|
import { passwordResets, RateLimiter } from '@stacksjs/auth'
|
|
3
3
|
import { response } from '@stacksjs/router'
|
|
4
|
+
import { schema } from '@stacksjs/validation'
|
|
5
|
+
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from '../../password-policy'
|
|
4
6
|
|
|
5
7
|
export default new Action({
|
|
6
8
|
name: 'PasswordResetAction',
|
|
7
9
|
description: 'Password Reset',
|
|
8
10
|
method: 'POST',
|
|
11
|
+
|
|
12
|
+
// Declared rather than hand-checked (#2226). `password.length < 8` inside
|
|
13
|
+
// handle() was invisible to everything that reads `validations:` — the
|
|
14
|
+
// client could not ask for it, and it drifted from the six every other
|
|
15
|
+
// password declaration used. Declaring it also means this endpoint answers a
|
|
16
|
+
// precognition probe, so a reset form can check the field on blur without
|
|
17
|
+
// spending a reset token to find out.
|
|
18
|
+
validations: {
|
|
19
|
+
email: {
|
|
20
|
+
rule: schema.string().required().email(),
|
|
21
|
+
message: 'Email must be a valid email address.',
|
|
22
|
+
},
|
|
23
|
+
token: {
|
|
24
|
+
rule: schema.string().required().min(1),
|
|
25
|
+
message: 'A reset token is required.',
|
|
26
|
+
},
|
|
27
|
+
password: {
|
|
28
|
+
rule: schema.string().required().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH),
|
|
29
|
+
message: PASSWORD_POLICY_MESSAGE,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
|
|
9
33
|
async handle(request) {
|
|
10
34
|
const token = request.get('token')
|
|
11
35
|
const password = request.get('password')
|
|
12
36
|
const passwordConfirmation = request.get('password_confirmation')
|
|
13
37
|
const email = request.get('email')
|
|
14
38
|
|
|
15
|
-
//
|
|
39
|
+
// Kept as a floor for direct invocation: `validations:` only runs when this
|
|
40
|
+
// action is reached through the router, and a test or a queued job calling
|
|
41
|
+
// handle() straight would otherwise pass undefined into resetPassword.
|
|
16
42
|
if (!token || !password || !email) {
|
|
17
43
|
return response.error('Missing required fields', 422)
|
|
18
44
|
}
|
|
19
45
|
|
|
20
|
-
//
|
|
46
|
+
// Cross-field, so it cannot live in `validations:` — those rules see one
|
|
47
|
+
// field at a time. Length and format are declared up there instead.
|
|
21
48
|
if (password !== passwordConfirmation) {
|
|
22
49
|
return response.error('Password confirmation does not match', 422)
|
|
23
50
|
}
|
|
24
51
|
|
|
25
|
-
// Validate password strength (minimum 8 characters)
|
|
26
|
-
if (password.length < 8) {
|
|
27
|
-
return response.error('Password must be at least 8 characters', 422)
|
|
28
|
-
}
|
|
29
|
-
|
|
30
52
|
// Rate limit password reset attempts by email
|
|
31
53
|
const rateLimitKey = `password_reset_attempt:${email.toLowerCase()}`
|
|
32
54
|
if (await RateLimiter.isRateLimited(rateLimitKey)) {
|
package/app/Middleware/Auth.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Auth, sessionUser } from '@stacksjs/auth'
|
|
1
|
+
import { Auth, authCookieName, sessionUser } from '@stacksjs/auth'
|
|
2
2
|
import { config } from '@stacksjs/config'
|
|
3
3
|
import { HttpError } from '@stacksjs/error-handling'
|
|
4
4
|
import { Middleware } from '@stacksjs/router'
|
|
@@ -40,7 +40,9 @@ export default new Middleware({
|
|
|
40
40
|
return
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
// One resolver, so the name a cookie is written under is the name it is
|
|
44
|
+
// read under (stacksjs/stacks#2236).
|
|
45
|
+
const tokenCookieName = authCookieName()
|
|
44
46
|
const cookieToken = request.cookie?.(tokenCookieName)
|
|
45
47
|
if (cookieToken) {
|
|
46
48
|
await stampTokenUser(request, cookieToken)
|