@stacksjs/defaults 0.75.27 → 0.75.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.
@@ -161,7 +161,28 @@ routes: minting and revoking both carry semantics a generic route does not.
161
161
  The authorization server is opt-in through `config/auth.ts` under
162
162
  `oauthProvider`. It is separate from social sign-in, where Stacks is the OAuth
163
163
  client. The provider profile is Authorization Code with S256 PKCE and rotating
164
- refresh tokens. It does not support implicit or password grants.
164
+ refresh tokens. It does not support implicit, password, or client-credentials
165
+ grants. It also does not implement token introspection or OpenID Connect.
166
+
167
+ When enabled, the default auth route bundle registers:
168
+
169
+ - `GET` and `POST /oauth/authorize`
170
+ - `POST /oauth/token`
171
+ - `POST /oauth/revoke`
172
+ - `GET /.well-known/oauth-authorization-server`
173
+ - owner-managed clients under `/auth/oauth/clients`
174
+ - user-managed connected applications under `/auth/oauth/connections`
175
+
176
+ Set a canonical `issuer`, then register every scope and resource explicitly.
177
+ Resource audiences are absolute URIs and redirect URIs use exact matching.
178
+ The consent view is `auth/oauth/consent` by default and can be overridden with
179
+ `oauthProvider.consent.view`. Use `oauthProvider.consent.resolveWorkspace` when
180
+ the application must bind consent to current server-owned workspace authority.
181
+
182
+ Provider actions return 404 while `oauthProvider.enabled` is false. The token
183
+ and revocation endpoints use protocol credentials and intentionally skip
184
+ browser CSRF. Authorization approval, client management, and disconnect remain
185
+ authenticated and CSRF protected.
165
186
 
166
187
  - `resolveOAuthProviderConfig(options)` returns `null` unless explicitly enabled
167
188
  - `generatePkceVerifier()` creates a 256-bit RFC 7636 verifier
@@ -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 406 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 407 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, 406 components, and a multi-section layout.
11
+ The Stacks admin dashboard provides a full-featured admin panel with 100+ route views, 407 components, and a multi-section layout.
12
12
 
13
13
  ## Key Paths
14
14
  - Dashboard components: `storage/framework/defaults/resources/components/Dashboard/`
@@ -106,6 +106,42 @@ export default {
106
106
  }
107
107
  ```
108
108
 
109
+ ## The base design: email components
110
+
111
+ STX templates build on a bundled component library (`defaults/resources/components/Email`),
112
+ which every bundled template uses and which gives an app a finished look with a few props:
113
+
114
+ ```html
115
+ <EmailLayout
116
+ title="Your license key"
117
+ preheader="Shown after the subject in the inbox"
118
+ brand="Uplink" brandUrl="https://uplink.example" accent="#f59e0b"
119
+ footer="You are getting this because you bought Uplink."
120
+ >
121
+ <EmailText size="heading">You have Uplink Monthly.</EmailText>
122
+ <EmailText>Here is your license key.</EmailText>
123
+ <EmailCode>UPLK-7RKR-Z2BJ-V6QV-4PUH</EmailCode>
124
+ <EmailButton href="https://uplink.example/activate" bg="#f59e0b" color="#18181b">Activate</EmailButton>
125
+ <EmailDivider />
126
+ <EmailText size="sm">Small print.</EmailText>
127
+ </EmailLayout>
128
+ ```
129
+
130
+ - `<EmailLayout>`: a muted page with one bordered card (a border, not a shadow: Gmail strips
131
+ shadows), an optional brand header and footer, a hidden `preheader`, and `@media` rules for
132
+ dark mode and phones on the class hooks the other components carry. Clients that ignore
133
+ `@media` keep the inline light design.
134
+ - `<EmailText size="heading|lg|md|sm">`, `<EmailButton>`, `<EmailDivider>`, `<EmailSection>`,
135
+ `<EmailImage>`, `<EmailLink>`.
136
+ - `<EmailCode>`: a key or one-time code, large and monospace, selectable in one tap, wrapping at
137
+ hyphens on a phone.
138
+
139
+ A colour passed as a prop is the template's own choice: that element gets no dark-mode hook.
140
+
141
+ Two stx traps when writing a component or template: never put the literal text of a style
142
+ tag inside a `<script server>` comment (the parser takes it for one), and write head CSS as a
143
+ raw string (`{!! css !!}`) rather than a style element, which stx lifts out as component CSS.
144
+
109
145
  ## Template Locations
110
146
  - STX templates: `.stx` files processed by STX engine
111
147
  - HTML templates: `.html` files with `{{ }}` variable interpolation
@@ -0,0 +1,81 @@
1
+ import type { OAuthAuthorizationConsentPageContext } from '@stacksjs/auth'
2
+ import { existsSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { Action } from '@stacksjs/actions'
5
+ import { resolveDefaultsResources } from '@stacksjs/actions/dev/defaults-resources'
6
+ import {
7
+ authenticatedUser,
8
+ authUser,
9
+ handleOAuthAuthorizationPageRequest,
10
+ resolveOAuthConsentWorkspace,
11
+ resolveOAuthProviderConfig,
12
+ } from '@stacksjs/auth'
13
+ import { config } from '@stacksjs/config'
14
+ import { path } from '@stacksjs/path'
15
+ import { getCurrentRequest, response } from '@stacksjs/router'
16
+ import { oauthPositiveId } from './oauth-request'
17
+
18
+ function identityLabel(user: { email: string, [key: string]: unknown }): string {
19
+ const name = typeof user.name === 'string' ? user.name.trim() : ''
20
+ return name || user.email.trim()
21
+ }
22
+
23
+ async function renderConsentView(
24
+ view: string,
25
+ context: OAuthAuthorizationConsentPageContext,
26
+ ): Promise<string> {
27
+ const relative = view.endsWith('.stx') ? view : `${view}.stx`
28
+ const applicationView = path.userViewsPath(relative)
29
+ const frameworkView = join(resolveDefaultsResources(), 'views', relative)
30
+ const template = existsSync(applicationView) ? applicationView : frameworkView
31
+ const { renderTemplate } = await import('@stacksjs/stx')
32
+
33
+ return String(await renderTemplate(template, {
34
+ context,
35
+ injectCSS: true,
36
+ templateOnly: true,
37
+ processClientScripts: false,
38
+ }))
39
+ }
40
+
41
+ export default new Action({
42
+ name: 'OAuthAuthorizationAction',
43
+ description: 'Start or resume an OAuth authorization request',
44
+ method: 'GET',
45
+
46
+ async handle(request: RequestInstance) {
47
+ const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
48
+ if (!provider)
49
+ return response.notFound('OAuth provider is not enabled')
50
+
51
+ // This GET route must stay public so an anonymous browser can persist the
52
+ // request before login. `request.user()` only exposes identity populated
53
+ // by auth middleware, which this route deliberately cannot use, so fall
54
+ // through to the lazy cookie/bearer resolver after honoring a directly
55
+ // injected action-test or upstream middleware identity.
56
+ const user = await authenticatedUser(request)
57
+ ?? (getCurrentRequest() ? await authUser() : undefined)
58
+ const subjectId = user ? oauthPositiveId(user.id) : null
59
+ const workspace = user && subjectId
60
+ ? await resolveOAuthConsentWorkspace(provider, { request, user })
61
+ : null
62
+ const resolveWorkspaceId = user
63
+ ? async () => (await resolveOAuthConsentWorkspace(provider, { request, user }))?.id ?? null
64
+ : undefined
65
+ return handleOAuthAuthorizationPageRequest({
66
+ provider,
67
+ request: request as unknown as Request,
68
+ identity: user ? { label: identityLabel(user), workspaceLabel: workspace?.label } : null,
69
+ subject: subjectId
70
+ ? {
71
+ type: 'users',
72
+ id: subjectId,
73
+ workspaceId: workspace?.id,
74
+ resolveWorkspaceId,
75
+ }
76
+ : null,
77
+ csrfToken: (request as unknown as { _csrfToken?: string })._csrfToken,
78
+ dependencies: { render: renderConsentView },
79
+ })
80
+ },
81
+ })
@@ -0,0 +1,38 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { disableOAuthClient, resolveOAuthProviderConfig } from '@stacksjs/auth'
3
+ import { config } from '@stacksjs/config'
4
+ import { response } from '@stacksjs/router'
5
+ import { oauthPositiveId } from './oauth-request'
6
+
7
+ export default new Action({
8
+ name: 'OAuthClientDisableAction',
9
+ description: 'Disable an OAuth client owned by the authenticated user',
10
+ method: 'POST',
11
+
12
+ async handle(request: RequestInstance) {
13
+ const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
14
+ if (!provider)
15
+ return response.notFound('OAuth provider is not enabled')
16
+
17
+ const user = await request.user()
18
+ const ownerId = user ? oauthPositiveId(user.id) : null
19
+ if (!ownerId)
20
+ return response.unauthorized('Authentication required')
21
+
22
+ const rawClientId = request.getParam('id')
23
+ const clientId = /^\d+$/.test(rawClientId) ? oauthPositiveId(rawClientId) : null
24
+ if (!clientId)
25
+ return response.badRequest('Invalid OAuth client ID')
26
+
27
+ const disabled = await disableOAuthClient(ownerId, clientId)
28
+ if (!disabled)
29
+ return response.notFound('OAuth client not found')
30
+
31
+ return response.json({
32
+ message: 'OAuth client disabled successfully',
33
+ client_id: clientId,
34
+ }, {
35
+ headers: { 'Cache-Control': 'no-store' },
36
+ })
37
+ },
38
+ })
@@ -0,0 +1,41 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { resolveOAuthProviderConfig, rotateOAuthClientSecret } from '@stacksjs/auth'
3
+ import { config } from '@stacksjs/config'
4
+ import { response } from '@stacksjs/router'
5
+ import { oauthPositiveId } from './oauth-request'
6
+
7
+ export default new Action({
8
+ name: 'OAuthClientSecretRotateAction',
9
+ description: 'Rotate an OAuth client secret owned by the authenticated user',
10
+ method: 'POST',
11
+
12
+ async handle(request: RequestInstance) {
13
+ const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
14
+ if (!provider)
15
+ return response.notFound('OAuth provider is not enabled')
16
+
17
+ const user = await request.user()
18
+ const ownerId = user ? oauthPositiveId(user.id) : null
19
+ if (!ownerId)
20
+ return response.unauthorized('Authentication required')
21
+
22
+ const rawClientId = request.getParam('id')
23
+ const clientId = /^\d+$/.test(rawClientId) ? oauthPositiveId(rawClientId) : null
24
+ if (!clientId)
25
+ return response.badRequest('Invalid OAuth client ID')
26
+
27
+ const clientSecret = await rotateOAuthClientSecret(ownerId, clientId)
28
+ if (!clientSecret)
29
+ return response.notFound('OAuth client not found')
30
+
31
+ return response.json({
32
+ client_id: clientId,
33
+ client_secret: clientSecret,
34
+ }, {
35
+ headers: {
36
+ 'Cache-Control': 'no-store',
37
+ 'Pragma': 'no-cache',
38
+ },
39
+ })
40
+ },
41
+ })
@@ -0,0 +1,73 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import {
3
+ OAuthClientRegistrationError,
4
+ registerOAuthClient,
5
+ resolveOAuthProviderConfig,
6
+ } from '@stacksjs/auth'
7
+ import { config } from '@stacksjs/config'
8
+ import { response } from '@stacksjs/router'
9
+ import { oauthPositiveId, oauthStringList } from './oauth-request'
10
+
11
+ export default new Action({
12
+ name: 'OAuthClientStoreAction',
13
+ description: 'Register an OAuth client for the authenticated user',
14
+ method: 'POST',
15
+
16
+ async handle(request: RequestInstance) {
17
+ const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
18
+ if (!provider)
19
+ return response.notFound('OAuth provider is not enabled')
20
+
21
+ const user = await request.user()
22
+ const ownerId = user ? oauthPositiveId(user.id) : null
23
+ if (!ownerId)
24
+ return response.unauthorized('Authentication required')
25
+
26
+ const body = await request.all() as Record<string, unknown>
27
+ const name = typeof body.name === 'string' ? body.name : null
28
+ const type = body.type === 'public' || body.type === 'confidential' ? body.type : null
29
+ const redirectUris = oauthStringList(body.redirect_uris)
30
+ const scopes = oauthStringList(body.scopes)
31
+ const resources = oauthStringList(body.resources)
32
+ if (!name || !type || !redirectUris || !scopes || !resources)
33
+ return response.badRequest('OAuth client registration metadata is invalid')
34
+
35
+ try {
36
+ const result = await registerOAuthClient(provider, ownerId, {
37
+ name,
38
+ type,
39
+ tokenEndpointAuthMethod: type === 'public' ? 'none' : 'client_secret_basic',
40
+ redirectUris,
41
+ grantTypes: ['authorization_code', 'refresh_token'],
42
+ scopes,
43
+ resources,
44
+ })
45
+
46
+ return response.json({
47
+ client: {
48
+ id: result.client.id,
49
+ name: result.client.name,
50
+ type: result.client.type,
51
+ token_endpoint_auth_method: result.client.tokenEndpointAuthMethod,
52
+ redirect_uris: result.client.redirectUris,
53
+ grant_types: result.client.grantTypes,
54
+ scopes: result.client.scopes,
55
+ resources: result.client.resources,
56
+ created_at: result.client.createdAt.toISOString(),
57
+ },
58
+ ...(result.plainTextSecret ? { client_secret: result.plainTextSecret } : {}),
59
+ }, {
60
+ status: 201,
61
+ headers: {
62
+ 'Cache-Control': 'no-store',
63
+ 'Pragma': 'no-cache',
64
+ },
65
+ })
66
+ }
67
+ catch (error) {
68
+ if (error instanceof OAuthClientRegistrationError)
69
+ return response.json({ message: error.message }, { status: 422 })
70
+ throw error
71
+ }
72
+ },
73
+ })
@@ -0,0 +1,73 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import {
3
+ OAuthClientRegistrationError,
4
+ resolveOAuthProviderConfig,
5
+ updateOAuthClient,
6
+ } from '@stacksjs/auth'
7
+ import { config } from '@stacksjs/config'
8
+ import { response } from '@stacksjs/router'
9
+ import { oauthPositiveId, oauthStringList } from './oauth-request'
10
+
11
+ export default new Action({
12
+ name: 'OAuthClientUpdateAction',
13
+ description: 'Update an OAuth client owned by the authenticated user',
14
+ method: 'PATCH',
15
+
16
+ async handle(request: RequestInstance) {
17
+ const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
18
+ if (!provider)
19
+ return response.notFound('OAuth provider is not enabled')
20
+
21
+ const user = await request.user()
22
+ const ownerId = user ? oauthPositiveId(user.id) : null
23
+ if (!ownerId)
24
+ return response.unauthorized('Authentication required')
25
+
26
+ const rawClientId = request.getParam('id')
27
+ const clientId = /^\d+$/.test(rawClientId) ? oauthPositiveId(rawClientId) : null
28
+ if (!clientId)
29
+ return response.badRequest('Invalid OAuth client ID')
30
+
31
+ const body = await request.all() as Record<string, unknown>
32
+ const name = typeof body.name === 'string' ? body.name : null
33
+ const redirectUris = oauthStringList(body.redirect_uris)
34
+ const scopes = oauthStringList(body.scopes)
35
+ const resources = oauthStringList(body.resources)
36
+ if (!name || !redirectUris || !scopes || !resources)
37
+ return response.badRequest('OAuth client update metadata is invalid')
38
+
39
+ try {
40
+ const client = await updateOAuthClient(provider, ownerId, clientId, {
41
+ name,
42
+ redirectUris,
43
+ grantTypes: ['authorization_code', 'refresh_token'],
44
+ scopes,
45
+ resources,
46
+ })
47
+ if (!client)
48
+ return response.notFound('OAuth client not found')
49
+
50
+ return response.json({
51
+ client: {
52
+ id: client.id,
53
+ name: client.name,
54
+ type: client.type,
55
+ token_endpoint_auth_method: client.tokenEndpointAuthMethod,
56
+ redirect_uris: client.redirectUris,
57
+ grant_types: client.grantTypes,
58
+ scopes: client.scopes,
59
+ resources: client.resources,
60
+ revoked: client.revoked,
61
+ created_at: client.createdAt.toISOString(),
62
+ },
63
+ }, {
64
+ headers: { 'Cache-Control': 'no-store' },
65
+ })
66
+ }
67
+ catch (error) {
68
+ if (error instanceof OAuthClientRegistrationError)
69
+ return response.json({ message: error.message }, { status: 422 })
70
+ throw error
71
+ }
72
+ },
73
+ })
@@ -0,0 +1,42 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { listOAuthClients, resolveOAuthProviderConfig } from '@stacksjs/auth'
3
+ import { config } from '@stacksjs/config'
4
+ import { response } from '@stacksjs/router'
5
+ import { oauthPositiveId } from './oauth-request'
6
+
7
+ export default new Action({
8
+ name: 'OAuthClientsAction',
9
+ description: 'List OAuth clients owned by the authenticated user',
10
+ method: 'GET',
11
+
12
+ async handle(request: RequestInstance) {
13
+ const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
14
+ if (!provider)
15
+ return response.notFound('OAuth provider is not enabled')
16
+
17
+ const user = await request.user()
18
+ const id = user ? oauthPositiveId(user.id) : null
19
+ if (!id)
20
+ return response.unauthorized('Authentication required')
21
+
22
+ const clients = await listOAuthClients(id)
23
+
24
+ return response.json({
25
+ clients: clients.map(client => ({
26
+ id: client.id,
27
+ name: client.name,
28
+ type: client.type,
29
+ token_endpoint_auth_method: client.tokenEndpointAuthMethod,
30
+ redirect_uris: client.redirectUris,
31
+ grant_types: client.grantTypes,
32
+ scopes: client.scopes,
33
+ resources: client.resources,
34
+ revoked: client.revoked,
35
+ created_at: client.createdAt.toISOString(),
36
+ })),
37
+ count: clients.length,
38
+ }, {
39
+ headers: { 'Cache-Control': 'no-store' },
40
+ })
41
+ },
42
+ })
@@ -0,0 +1,40 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { listOAuthConnections, resolveOAuthProviderConfig } from '@stacksjs/auth'
3
+ import { config } from '@stacksjs/config'
4
+ import { response } from '@stacksjs/router'
5
+ import { oauthPositiveId } from './oauth-request'
6
+
7
+ export default new Action({
8
+ name: 'OAuthConnectionsAction',
9
+ description: 'List OAuth applications connected to the authenticated user',
10
+ method: 'GET',
11
+
12
+ async handle(request: RequestInstance) {
13
+ const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
14
+ if (!provider)
15
+ return response.notFound('OAuth provider is not enabled')
16
+
17
+ const user = await request.user()
18
+ const id = user ? oauthPositiveId(user.id) : null
19
+ if (!id)
20
+ return response.unauthorized('Authentication required')
21
+
22
+ const connections = await listOAuthConnections('users', id)
23
+
24
+ return response.json({
25
+ connections: connections.map(connection => ({
26
+ grant_id: connection.grantId,
27
+ client_id: connection.clientId,
28
+ client_name: connection.clientName,
29
+ scopes: connection.scopes,
30
+ resources: connection.resources,
31
+ audiences: connection.audiences,
32
+ workspace_id: connection.workspaceId,
33
+ created_at: connection.createdAt.toISOString(),
34
+ })),
35
+ count: connections.length,
36
+ }, {
37
+ headers: { 'Cache-Control': 'no-store' },
38
+ })
39
+ },
40
+ })
@@ -0,0 +1,34 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import {
3
+ handleOAuthAuthorizationConsentRequest,
4
+ resolveOAuthConsentWorkspace,
5
+ resolveOAuthProviderConfig,
6
+ } from '@stacksjs/auth'
7
+ import { config } from '@stacksjs/config'
8
+ import { response } from '@stacksjs/router'
9
+ import { oauthPositiveId } from './oauth-request'
10
+
11
+ export default new Action({
12
+ name: 'OAuthConsentAction',
13
+ description: 'Approve or deny an OAuth authorization request',
14
+ method: 'POST',
15
+
16
+ async handle(request: RequestInstance) {
17
+ const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
18
+ if (!provider)
19
+ return response.notFound('OAuth provider is not enabled')
20
+
21
+ const user = await request.user()
22
+ const id = user ? oauthPositiveId(user.id) : null
23
+ if (!id)
24
+ return response.unauthorized('Authentication required')
25
+
26
+ return handleOAuthAuthorizationConsentRequest({
27
+ provider,
28
+ request: request as unknown as Request,
29
+ subjectType: 'users',
30
+ subjectId: id,
31
+ resolveWorkspaceId: async () => (await resolveOAuthConsentWorkspace(provider, { request, user }))?.id ?? null,
32
+ })
33
+ },
34
+ })
@@ -0,0 +1,37 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { disconnectOAuthGrant, resolveOAuthProviderConfig } from '@stacksjs/auth'
3
+ import { config } from '@stacksjs/config'
4
+ import { response } from '@stacksjs/router'
5
+ import { oauthPositiveId } from './oauth-request'
6
+
7
+ export default new Action({
8
+ name: 'OAuthDisconnectAction',
9
+ description: 'Disconnect an OAuth application from the authenticated user',
10
+ method: 'POST',
11
+
12
+ async handle(request: RequestInstance) {
13
+ const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
14
+ if (!provider)
15
+ return response.notFound('OAuth provider is not enabled')
16
+
17
+ const user = await request.user()
18
+ const id = user ? oauthPositiveId(user.id) : null
19
+ if (!id)
20
+ return response.unauthorized('Authentication required')
21
+
22
+ const grantId = request.getParam('id')
23
+ if (!/^[a-f0-9]{32}$/.test(grantId))
24
+ return response.badRequest('Invalid OAuth grant ID')
25
+
26
+ const disconnected = await disconnectOAuthGrant('users', id, grantId)
27
+ if (!disconnected)
28
+ return response.notFound('OAuth connection not found')
29
+
30
+ return response.json({
31
+ message: 'OAuth application disconnected successfully',
32
+ grant_id: grantId,
33
+ }, {
34
+ headers: { 'Cache-Control': 'no-store' },
35
+ })
36
+ },
37
+ })
@@ -0,0 +1,20 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { oauthAuthorizationServerMetadata, resolveOAuthProviderConfig } from '@stacksjs/auth'
3
+ import { config } from '@stacksjs/config'
4
+ import { response } from '@stacksjs/router'
5
+
6
+ export default new Action({
7
+ name: 'OAuthMetadataAction',
8
+ description: 'Publish OAuth authorization-server metadata',
9
+ method: 'GET',
10
+
11
+ async handle() {
12
+ const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
13
+ if (!provider)
14
+ return response.notFound('OAuth provider is not enabled')
15
+
16
+ return response.json(oauthAuthorizationServerMetadata(provider), {
17
+ headers: { 'Cache-Control': 'public, max-age=300' },
18
+ })
19
+ },
20
+ })
@@ -0,0 +1,24 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { handleOAuthRevocationRequest, resolveOAuthProviderConfig } from '@stacksjs/auth'
3
+ import { config } from '@stacksjs/config'
4
+ import { response } from '@stacksjs/router'
5
+
6
+ export default new Action({
7
+ name: 'OAuthRevocationAction',
8
+ description: 'Revoke a delegated OAuth token',
9
+ method: 'POST',
10
+ // OAuth clients authenticate with their registered protocol method instead
11
+ // of a browser session, so they cannot and must not need a CSRF cookie.
12
+ skipCsrf: true,
13
+
14
+ async handle(request: RequestInstance) {
15
+ if (!resolveOAuthProviderConfig(config.auth.oauthProvider))
16
+ return response.notFound('OAuth provider is not enabled')
17
+
18
+ return handleOAuthRevocationRequest({
19
+ body: (await request.rawBody?.()) ?? '',
20
+ contentType: request.headers.get('content-type'),
21
+ authorization: request.headers.get('authorization'),
22
+ })
23
+ },
24
+ })
@@ -0,0 +1,35 @@
1
+ import type { OAuthSubjectEligibility } from '@stacksjs/auth'
2
+ import { Action } from '@stacksjs/actions'
3
+ import { handleOAuthTokenRequest, resolveOAuthProviderConfig } from '@stacksjs/auth'
4
+ import { config } from '@stacksjs/config'
5
+ import { db } from '@stacksjs/database/runtime'
6
+ import { response } from '@stacksjs/router'
7
+
8
+ const isSubjectEligible: OAuthSubjectEligibility = async (subject) => {
9
+ if (subject.type !== 'users') return false
10
+ return Boolean(await db.primary.selectFrom('users')
11
+ .where('id', '=', subject.id)
12
+ .select('id')
13
+ .executeTakeFirst())
14
+ }
15
+
16
+ export default new Action({
17
+ name: 'OAuthTokenAction',
18
+ description: 'Exchange OAuth authorization codes and refresh tokens',
19
+ method: 'POST',
20
+ // OAuth clients authenticate with PKCE or HTTP Basic and cannot obtain the
21
+ // browser CSRF cookie. The protocol boundary validates its own credentials.
22
+ skipCsrf: true,
23
+
24
+ async handle(request: RequestInstance) {
25
+ const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
26
+ if (!provider)
27
+ return response.notFound('OAuth provider is not enabled')
28
+
29
+ return handleOAuthTokenRequest(provider, {
30
+ body: (await request.rawBody?.()) ?? '',
31
+ contentType: request.headers.get('content-type'),
32
+ authorization: request.headers.get('authorization'),
33
+ }, { isSubjectEligible })
34
+ },
35
+ })
@@ -0,0 +1,10 @@
1
+ export function oauthPositiveId(value: number | string): number | null {
2
+ const id = Number(value)
3
+ return Number.isSafeInteger(id) && id > 0 ? id : null
4
+ }
5
+
6
+ export function oauthStringList(value: unknown): string[] | null {
7
+ return Array.isArray(value) && value.every(item => typeof item === 'string')
8
+ ? value
9
+ : null
10
+ }
@@ -215,7 +215,7 @@ export function seedCsrfCookieIfMissing(req: Request, response: Response, minted
215
215
  * Last duplicate wins; the canonical name takes precedence over the legacy
216
216
  * name unless its final value is empty. Malformed pairs are skipped.
217
217
  */
218
- function csrfCookieToken(req: Request): string {
218
+ export function csrfCookieToken(req: Request): string {
219
219
  const header = req.headers.get('cookie')
220
220
  if (!header) return ''
221
221
  // The cookie this framework emits has one exact, whitespace-free shape.
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.27",
5
+ "version": "0.75.29",
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.75.27",
5
+ "version": "0.75.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.75.27",
58
+ "@stacksjs/mobile": "^0.75.29",
59
59
  "@stacksjs/sanitizer": "^0.2.113",
60
60
  "ts-qr-codes": "^0.1.8"
61
61
  }
@@ -23,8 +23,8 @@ const padY = props.padY || '14px'
23
23
  </script>
24
24
  <table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:24px 0;border-collapse:separate;">
25
25
  <tr>
26
- <td align="center" style="border-radius:6px;background-color:{{ bg }};mso-padding-alt:0;">
27
- <a href="{{ href }}" style="display:inline-block;padding:{{ padY }} {{ padX }};color:{{ color }};text-decoration:none;border-radius:6px;font-weight:600;font-size:16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;text-align:center;mso-padding-alt:{{ padY }} {{ padX }};">
26
+ <td align="center" style="border-radius:8px;background-color:{{ bg }};mso-padding-alt:0;">
27
+ <a href="{{ href }}" style="display:inline-block;padding:{{ padY }} {{ padX }};color:{{ color }};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;text-align:center;mso-padding-alt:{{ padY }} {{ padX }};">
28
28
  <slot />
29
29
  </a>
30
30
  </td>
@@ -0,0 +1,28 @@
1
+ <script server>
2
+ // A code the reader copies: a license key, a one-time passcode, a recovery
3
+ // code. Large monospace on a tinted block, spaced so O and 0, l and 1 are
4
+ // told apart, and selectable as one unit where the client allows it
5
+ // (`user-select: all`), so a tap or double-click takes the whole code. On a
6
+ // narrow screen it wraps at a hyphen rather than mid-group, and only breaks
7
+ // inside a group when a code has no hyphens to wrap at.
8
+ //
9
+ // <EmailCode>UPLK-7RKR-Z2BJ-V6QV-4PUH</EmailCode>
10
+ // <EmailCode size="32px" align="center">482 913</EmailCode>
11
+ interface EmailCodeProps {
12
+ size?: string
13
+ align?: 'left' | 'center' | 'right'
14
+ spacing?: string
15
+ }
16
+
17
+ const props = defineProps<EmailCodeProps>()
18
+ const size = props.size || '22px'
19
+ const align = props.align || 'center'
20
+ const spacing = props.spacing || '24px'
21
+ </script>
22
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0" style="width:100%;border-collapse:separate;margin:0 0 {{ spacing }} 0;">
23
+ <tr>
24
+ <td class="email-code" align="{{ align }}" style="padding:18px 20px;background-color:#f4f4f5;border:1px solid #e4e4e7;border-radius:10px;color:#18181b;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,'Liberation Mono',monospace;font-size:{{ size }};font-weight:600;letter-spacing:0.08em;line-height:1.4;text-align:{{ align }};-webkit-user-select:all;user-select:all;overflow-wrap:anywhere;">
25
+ <slot />
26
+ </td>
27
+ </tr>
28
+ </table>
@@ -10,11 +10,11 @@ interface EmailDividerProps {
10
10
  }
11
11
 
12
12
  const props = defineProps<EmailDividerProps>()
13
- const color = props.color || '#e5e7eb'
13
+ const color = props.color || '#e4e4e7'
14
14
  const spacing = props.spacing || '24px'
15
15
  </script>
16
16
  <table role="presentation" cellpadding="0" cellspacing="0" border="0" style="width:100%;border-collapse:collapse;margin:{{ spacing }} 0;">
17
17
  <tr>
18
- <td style="height:1px;font-size:1px;line-height:1px;background-color:{{ color }};">&nbsp;</td>
18
+ <td class="email-divider" style="height:1px;font-size:1px;line-height:1px;background-color:{{ color }};">&nbsp;</td>
19
19
  </tr>
20
20
  </table>
@@ -3,10 +3,25 @@
3
3
  //
4
4
  // Wraps content in the table-based body that Outlook + Gmail (Android)
5
5
  // require, plus the standard meta block (charset / viewport /
6
- // X-UA-Compatible / colour-scheme). Two-tone bodyBg / contentBg
7
- // mirrors the bundled templates: muted page colour outside, white
8
- // card inside. Named slots: head / header / footer. See README for
9
- // usage examples.
6
+ // X-UA-Compatible / colour-scheme). Named slots: head / header / footer.
7
+ //
8
+ // The base design, which every bundled template inherits:
9
+ // - a muted page with one white card, a hairline border and a 12px radius.
10
+ // A border rather than a shadow: Gmail strips box-shadow, so a shadow-only
11
+ // card lost its edge in the most-used client.
12
+ // - `preheader`: the line an inbox shows after the subject. Without it the
13
+ // inbox shows whatever text comes first - usually the brand name again.
14
+ // - `brand` / `accent` / `brandUrl`: a small brand header (an accent dot and
15
+ // the name) when the template does not fill the `header` slot itself.
16
+ // - `footer`: a muted, centred line under the card when the template does
17
+ // not fill the `footer` slot.
18
+ // - dark mode and a tighter phone layout, as `@media` rules on the class
19
+ // hooks below. The CSS inliner leaves `@media` rules in the head
20
+ // (stacksjs/stacks#1902), so clients that honour them (Apple Mail, iOS,
21
+ // Outlook for Mac) get them and the rest keep the inline light design.
22
+ // - those rules are written out raw from here, not as a style element in
23
+ // the template: stx treats a component's own style element as component
24
+ // CSS and lifts it out of the head, which lost them.
10
25
  //
11
26
  // Notes on `defineProps()`: stx fills missing props with an empty
12
27
  // string (not `undefined`), so destructure defaults silently no-op
@@ -17,6 +32,11 @@ interface EmailLayoutProps {
17
32
  width?: number | string
18
33
  bodyBg?: string
19
34
  contentBg?: string
35
+ preheader?: string
36
+ brand?: string
37
+ brandUrl?: string
38
+ accent?: string
39
+ footer?: string
20
40
  }
21
41
 
22
42
  const props = defineProps<EmailLayoutProps>()
@@ -24,6 +44,32 @@ const title = props.title || ''
24
44
  const width = props.width || 600
25
45
  const bodyBg = props.bodyBg || '#f4f4f5'
26
46
  const contentBg = props.contentBg || '#ffffff'
47
+ const preheader = props.preheader || ''
48
+ const brand = props.brand || ''
49
+ const brandUrl = props.brandUrl || ''
50
+ const accent = props.accent || '#3451b2'
51
+ const footer = props.footer || ''
52
+ const fontStack = '-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,\'Helvetica Neue\',Arial,sans-serif'
53
+
54
+ const responsiveCss = [
55
+ '<style>',
56
+ '@media only screen and (max-width: 620px) {',
57
+ ' .email-outer { padding: 16px 8px !important; }',
58
+ ' .email-pad { padding: 28px 24px !important; }',
59
+ ' .email-brand { padding: 24px 24px 0 !important; }',
60
+ ' .email-code { font-size: 18px !important; letter-spacing: 0.04em !important; }',
61
+ '}',
62
+ '@media (prefers-color-scheme: dark) {',
63
+ ' .email-body, .email-outer { background-color: #0c0c0e !important; }',
64
+ ' .email-card { background-color: #141417 !important; border-color: #27272a !important; }',
65
+ ' .email-heading, .email-brand-name { color: #f4f4f5 !important; }',
66
+ ' .email-text { color: #d4d4d8 !important; }',
67
+ ' .email-muted { color: #a1a1aa !important; }',
68
+ ' .email-code { background-color: #1c1c20 !important; border-color: #27272a !important; color: #f4f4f5 !important; }',
69
+ ' .email-divider { background-color: #27272a !important; }',
70
+ '}',
71
+ '</style>',
72
+ ].join('\n')
27
73
  </script>
28
74
 
29
75
  <!DOCTYPE html>
@@ -35,19 +81,42 @@ const contentBg = props.contentBg || '#ffffff'
35
81
  <meta name="color-scheme" content="light dark">
36
82
  <meta name="supported-color-schemes" content="light dark">
37
83
  <title>{{ title }}</title>
84
+ {!! responsiveCss !!}
38
85
  <slot name="head" />
39
86
  </head>
40
- <body style="margin:0;padding:0;background-color:{{ bodyBg }};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;line-height:1.6;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
87
+ <body class="email-body" style="margin:0;padding:0;background-color:{{ bodyBg }};font-family:{{ fontStack }};line-height:1.6;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
88
+ @if(preheader)
89
+ <div style="display:none;max-height:0;max-width:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:{{ bodyBg }};">{{ preheader }}&#8199;&#847;&#8199;&#847;&#8199;&#847;&#8199;&#847;&#8199;&#847;&#8199;&#847;&#8199;&#847;&#8199;&#847;</div>
90
+ @endif
41
91
  <table role="presentation" cellpadding="0" cellspacing="0" border="0" style="width:100%;border-collapse:collapse;background-color:{{ bodyBg }};">
42
92
  <tr>
43
- <td align="center" style="padding:40px 20px;">
44
- <table role="presentation" cellpadding="0" cellspacing="0" border="0" style="max-width:{{ width }}px;width:100%;background-color:{{ contentBg }};border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1);">
93
+ <td class="email-outer" align="center" style="padding:40px 20px;background-color:{{ bodyBg }};">
94
+ <table class="email-card" role="presentation" cellpadding="0" cellspacing="0" border="0" style="max-width:{{ width }}px;width:100%;background-color:{{ contentBg }};border:1px solid #e4e4e7;border-radius:12px;overflow:hidden;border-collapse:separate;">
95
+ @if(brand)
96
+ <tr>
97
+ <td class="email-brand" style="padding:32px 40px 0;">
98
+ <span style="display:inline-block;width:10px;height:10px;border-radius:5px;background-color:{{ accent }};vertical-align:middle;margin-right:10px;"></span>
99
+ @if(brandUrl)
100
+ <a class="email-brand-name" href="{{ brandUrl }}" style="color:#18181b;text-decoration:none;font-size:15px;font-weight:600;letter-spacing:-0.01em;vertical-align:middle;font-family:{{ fontStack }};">{{ brand }}</a>
101
+ @else
102
+ <span class="email-brand-name" style="color:#18181b;font-size:15px;font-weight:600;letter-spacing:-0.01em;vertical-align:middle;font-family:{{ fontStack }};">{{ brand }}</span>
103
+ @endif
104
+ </td>
105
+ </tr>
106
+ @endif
45
107
  <slot name="header" />
46
- <tr><td style="padding:40px;">
108
+ <tr><td class="email-pad" style="padding:32px 40px 40px;">
47
109
  <slot />
48
110
  </td></tr>
49
111
  <slot name="footer" />
50
112
  </table>
113
+ @if(footer)
114
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0" style="max-width:{{ width }}px;width:100%;border-collapse:collapse;">
115
+ <tr>
116
+ <td class="email-muted" style="padding:20px 24px 0;text-align:center;color:#71717a;font-size:12px;line-height:1.6;font-family:{{ fontStack }};">{{ footer }}</td>
117
+ </tr>
118
+ </table>
119
+ @endif
51
120
  </td>
52
121
  </tr>
53
122
  </table>
@@ -14,17 +14,20 @@ interface EmailTextProps {
14
14
  const props = defineProps<EmailTextProps>()
15
15
  const align = props.align || 'left'
16
16
  const size = props.size || 'md'
17
- const color = props.color || (size === 'heading' ? '#111827' : '#4b5563')
17
+ const color = props.color || (size === 'heading' ? '#18181b' : size === 'sm' ? '#71717a' : '#3f3f46')
18
+ // Hooks for the dark-mode rules in <EmailLayout>. A colour passed as a prop is
19
+ // the template's own choice, so it gets no hook and dark mode leaves it alone.
20
+ const role = props.color ? '' : size === 'heading' ? 'email-heading' : size === 'sm' ? 'email-muted' : 'email-text'
18
21
  const spacing = props.spacing || '16px'
19
22
 
20
23
  const sizeStyles: Record<string, string> = {
21
24
  sm: 'font-size:14px;line-height:1.5;',
22
25
  md: 'font-size:16px;line-height:1.6;',
23
26
  lg: 'font-size:18px;line-height:1.6;',
24
- heading: 'font-size:20px;line-height:1.4;font-weight:600;',
27
+ heading: 'font-size:22px;line-height:1.3;font-weight:600;letter-spacing:-0.01em;',
25
28
  }
26
29
  const sizeStyle = sizeStyles[size] || sizeStyles.md
27
30
  </script>
28
- <p style="margin:0 0 {{ spacing }} 0;text-align:{{ align }};color:{{ color }};{{ sizeStyle }}">
31
+ <p class="{{ role }}" style="margin:0 0 {{ spacing }} 0;text-align:{{ align }};color:{{ color }};{{ sizeStyle }}">
29
32
  <slot />
30
33
  </p>
@@ -67,10 +67,12 @@ effect(() => {
67
67
  })
68
68
  </script>
69
69
 
70
+ {{-- No src attribute until there is a video: an empty one resolves to the
71
+ page's own URL, which the browser then downloads as the video. --}}
70
72
  <figure class="m-0 {{ className }}">
71
73
  <video-player
72
74
  ref="player"
73
- src="{{ src }}"
75
+ @if(src) src="{{ src }}" @endif
74
76
  :src="liveSrc() || null"
75
77
  :poster="livePoster() || null"
76
78
  data-media-config="{{ playerConfig }}"
@@ -93,7 +95,7 @@ effect(() => {
93
95
  @if(isEmbed)
94
96
  <a data-native-fallback href="{{ src }}" target="_blank" rel="noopener" class="flex items-center justify-center h-full w-full text-sm text-white underline">Watch {{ title }}</a>
95
97
  @else
96
- <video data-native-fallback src="{{ src }}" @if(poster) poster="{{ poster }}" @endif @if(width) width="{{ width }}" @endif @if(height) height="{{ height }}" @endif @if(controls) controls @endif @if(props.autoplay) autoplay @endif @if(props.loop) loop @endif @if(props.muted) muted @endif @if(inline) playsinline @endif preload="{{ preload }}" @if(crossorigin) crossorigin="{{ crossorigin }}" @endif controlslist="{{ controlslist }}" @if(props.disablePictureInPicture) disablepictureinpicture @endif aria-label="{{ title }}" class="object-contain h-full w-full">
98
+ <video data-native-fallback @if(src) src="{{ src }}" @endif @if(poster) poster="{{ poster }}" @endif @if(width) width="{{ width }}" @endif @if(height) height="{{ height }}" @endif @if(controls) controls @endif @if(props.autoplay) autoplay @endif @if(props.loop) loop @endif @if(props.muted) muted @endif @if(inline) playsinline @endif preload="{{ preload }}" @if(crossorigin) crossorigin="{{ crossorigin }}" @endif controlslist="{{ controlslist }}" @if(props.disablePictureInPicture) disablepictureinpicture @endif aria-label="{{ title }}" class="object-contain h-full w-full">
97
99
  @foreach (sources as source)
98
100
  <source src="{{ source.src }}" @if(source.type) type="{{ source.type }}" @endif>
99
101
  @endforeach
@@ -11,6 +11,14 @@ describe('Video', () => {
11
11
  expect(video).toContain(`:poster="livePoster() || null"`)
12
12
  })
13
13
 
14
+ it('renders no src attribute without a video, which would fetch the page itself', () => {
15
+ // An empty src resolves to the document's URL: the browser downloads the
16
+ // whole page as the video and fails, every time a player with nothing
17
+ // chosen yet renders.
18
+ expect(video).not.toMatch(/(?<!@if\(src\) )src="\{\{ src \}\}"/)
19
+ expect(video.match(/@if\(src\) src="\{\{ src \}\}" @endif/g)?.length).toBe(2)
20
+ })
21
+
14
22
  it('pauses when its src is cleared, as closing a sheet does', () => {
15
23
  expect(video).toContain(`useRef('player')`)
16
24
  expect(video).toContain('ref="player"')
package/routes/auth.ts CHANGED
@@ -23,8 +23,18 @@
23
23
  * and user routes load first, so your handler always takes priority.
24
24
  */
25
25
 
26
+ import { oauthAuthorizationServerMetadataPath, resolveOAuthProviderConfig } from '@stacksjs/auth'
27
+ import { config } from '@stacksjs/config'
26
28
  import { route } from '@stacksjs/router'
27
29
 
30
+ const oauthProvider = resolveOAuthProviderConfig(config.auth.oauthProvider)
31
+ const oauthAuthorizationPath = oauthProvider ? new URL(oauthProvider.endpoints.authorization).pathname : '/oauth/authorize'
32
+ const oauthTokenPath = oauthProvider ? new URL(oauthProvider.endpoints.token).pathname : '/oauth/token'
33
+ const oauthRevocationPath = oauthProvider ? new URL(oauthProvider.endpoints.revocation).pathname : '/oauth/revoke'
34
+ const oauthMetadataPath = oauthProvider
35
+ ? oauthAuthorizationServerMetadataPath(oauthProvider.issuer)
36
+ : '/.well-known/oauth-authorization-server'
37
+
28
38
  // Rate limits on token-issuance + password-reset endpoints
29
39
  // (stacksjs/stacks#1921). `Auth.attempt()` already has a per-email
30
40
  // lockout but it doesn't stop credential-stuffing across many emails
@@ -34,6 +44,14 @@ import { route } from '@stacksjs/router'
34
44
  // `routes/api.ts` (user routes win) gets to pick its own limits.
35
45
  route.post('/login', 'Actions/Auth/LoginAction').rateLimit(5, 'minute')
36
46
  route.post('/register', 'Actions/Auth/RegisterAction').rateLimit(3, 'minute')
47
+ // OAuth authorization-server token exchange. The action remains a 404 unless
48
+ // config.auth.oauthProvider.enabled is explicitly true. PKCE or HTTP Basic is
49
+ // the endpoint's credential boundary, so the action opts out of browser CSRF.
50
+ route.post(oauthTokenPath, 'Actions/Auth/OAuthTokenAction').rateLimit(30, 'minute')
51
+ route.post(oauthRevocationPath, 'Actions/Auth/OAuthRevocationAction').rateLimit(30, 'minute')
52
+ route.get(oauthAuthorizationPath, 'Actions/Auth/OAuthAuthorizationAction').rateLimit(60, 'minute')
53
+ route.post(oauthAuthorizationPath, 'Actions/Auth/OAuthConsentAction').middleware('auth').rateLimit(30, 'minute')
54
+ route.get(oauthMetadataPath, 'Actions/Auth/OAuthMetadataAction')
37
55
  // Magic links (config.auth.magicLink.enabled gates both, 404 when off).
38
56
  // The send endpoint answers a uniform 202 either way (anti-enumeration
39
57
  // lives in sendMagicLink); the consume endpoint is a POST because email
@@ -67,6 +85,13 @@ route.post('/verify-two-factor-login', 'Actions/Auth/VerifyTwoFactorLoginAction'
67
85
 
68
86
  route.group({ prefix: '/auth' }, () => {
69
87
  route.post('/refresh', 'Actions/Auth/RefreshTokenAction').rateLimit(10, 'minute')
88
+ route.get('/oauth/clients', 'Actions/Auth/OAuthClientsAction').middleware('auth')
89
+ route.post('/oauth/clients', 'Actions/Auth/OAuthClientStoreAction').middleware('auth').rateLimit(10, 'minute')
90
+ route.patch('/oauth/clients/{id}', 'Actions/Auth/OAuthClientUpdateAction').middleware('auth').rateLimit(20, 'minute')
91
+ route.post('/oauth/clients/{id}/disable', 'Actions/Auth/OAuthClientDisableAction').middleware('auth').rateLimit(10, 'minute')
92
+ route.post('/oauth/clients/{id}/rotate-secret', 'Actions/Auth/OAuthClientSecretRotateAction').middleware('auth').rateLimit(5, 'minute')
93
+ route.get('/oauth/connections', 'Actions/Auth/OAuthConnectionsAction').middleware('auth')
94
+ route.post('/oauth/connections/{id}/disconnect', 'Actions/Auth/OAuthDisconnectAction').middleware('auth').rateLimit(10, 'minute')
70
95
  route.get('/tokens', 'Actions/Auth/ListTokensAction').middleware('auth')
71
96
  route.post('/token', 'Actions/Auth/CreateTokenAction').middleware('auth').rateLimit(10, 'minute')
72
97
  route.delete('/tokens/{id}', 'Actions/Auth/RevokeTokenAction').middleware('auth')