@stacksjs/defaults 0.75.33 → 0.75.35

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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-actions
3
- description: Use when working with Stacks server actions - creating actions in app/Actions/, auto-generated API actions from the useApi model trait, the 647 default framework actions (auth, dashboard, commerce, content, deployment, jobs), action request/response handling, or action registration. Covers @stacksjs/actions and storage/framework/defaults/app/Actions/.
3
+ description: Use when working with Stacks server actions - creating actions in app/Actions/, auto-generated API actions from the useApi model trait, the 648 default framework actions (auth, dashboard, commerce, content, deployment, jobs), action request/response handling, or action registration. Covers @stacksjs/actions and storage/framework/defaults/app/Actions/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -161,8 +161,12 @@ 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, password, or client-credentials
165
- grants. It also does not implement token introspection or OpenID Connect.
164
+ refresh tokens. Confidential client credentials are opt-in through
165
+ `oauthProvider.clientCredentials` and mint access-only tokens without refresh
166
+ tokens. Protected resource-server introspection is separately opt-in through
167
+ `oauthProvider.introspection`; it authenticates a confidential client and
168
+ requires that client to share a configured resource audience with the token.
169
+ It does not support implicit or password grants or OpenID Connect.
166
170
 
167
171
  When enabled, the default auth route bundle registers:
168
172
 
@@ -178,6 +182,11 @@ Resource audiences are absolute URIs and redirect URIs use exact matching.
178
182
  The consent view is `auth/oauth/consent` by default and can be overridden with
179
183
  `oauthProvider.consent.view`. Use `oauthProvider.consent.resolveWorkspace` when
180
184
  the application must bind consent to current server-owned workspace authority.
185
+ Use `oauthProvider.subjectEligibility` when account status is represented by
186
+ application data that is not simply the presence of a user row. The callback
187
+ is rechecked before consent approval, delegated code exchange and refresh, and
188
+ by introspection; returning false revokes the affected grant and reports the
189
+ token inactive.
181
190
 
182
191
  Provider actions return 404 while `oauthProvider.enabled` is false. The token
183
192
  and revocation endpoints use protocol credentials and intentionally skip
@@ -611,7 +611,7 @@ buddy ai:context # compact deterministic project context for coding
611
611
  -o/--output [path] # write to a file instead of stdout
612
612
  --max-chars [characters] # prompt payload character budget (default: 4000)
613
613
  --model [model] # model family for heuristic token estimates
614
- buddy doctor # health checks (Bun version, Node, package.json, .env, APP_KEY)
614
+ buddy doctor # health checks (Bun version, Node, package.json, .env, APP_KEY, mail DNS)
615
615
  buddy tinker # interactive REPL with Stacks preloaded
616
616
  -e/--eval [expr] # evaluate expression and exit
617
617
  --print [expr] # evaluate, print, and exit
@@ -190,6 +190,8 @@ email. Moderate without a dashboard with `buddy comments:list [--status] [--type
190
190
  | GET | `/blog/feed.xml` | RSS 2.0 feed (20 recent) |
191
191
  | GET | `/blog/sitemap.xml` | XML sitemap for SEO |
192
192
 
193
+ The site-wide `/sitemap.xml` merges the published posts in, so crawlers find them from the root sitemap too (see `stacks-server`, "Sitemap and robots.txt").
194
+
193
195
  ## Post Bodies: Images, Grids, Zoom and Embeds
194
196
 
195
197
  Blog post HTML goes through `renderPostHtml` from `@stacksjs/cms/article`, built on ts-medium-editor's article renderer. The BunPress blog (`core/actions/src/blog.ts`) does this already; an stx-native blog calls it from its post view:
@@ -65,7 +65,7 @@ they name: `defineEvents` and `defineListener` from `@stacksjs/events`,
65
65
  ### Core App
66
66
  | File | Type | Key Settings |
67
67
  |------|------|-------------|
68
- | `app.ts` | AppConfig | name, env, url, debug, key, timezone, locale |
68
+ | `app.ts` | AppConfig | name, env, url, debug, key, timezone, locale, seo (sitemap.xml / robots.txt) |
69
69
  | `auth.ts` | AuthConfig | guards, providers, token and browser-session lifetimes, passwordReset |
70
70
  | `database.ts` | DatabaseConfig | default driver, connections (sqlite/mysql/postgres/dynamodb), queryLogging |
71
71
  | `cache.ts` | CacheConfig | driver('memory'), ttl(3600), maxKeys(-1), redis config |
@@ -244,6 +244,8 @@ With `server.enabled`, `buddy deploy` reconciles the mail domain's DNS through w
244
244
 
245
245
  Postmaster Tools is the only view of why Gmail files mail as spam when SPF, DKIM and DMARC all pass. Its value is issued per Google account and cannot be generated: add the domain at https://postmaster.google.com, copy the TXT value, set `server.postmaster.google`, deploy, then press Verify in Postmaster Tools. Until it is set the deploy prints where to get it; `postmaster: false` silences that.
246
246
 
247
+ When no configured provider can write the zone (no credentials for the provider its nameservers name, e.g. a Cloudflare zone with no `CLOUDFLARE_API_TOKEN`), nothing is published, on this deploy or any later one. The deploy warns with the exact fix (`buddy env:set CLOUDFLARE_API_TOKEN <value> -f .env.production`) and the records to add by hand. `buddy doctor` reports the same without deploying: "Mail DNS provider" (which provider the nameservers name, and whether its keys are in `.env.production`, by name only), "Mail DNS records" (MX, SPF, DKIM at `mail._domainkey`, DMARC in public DNS, each exactly once, the DMARC policy against config, SPF against the MX host's address) and "Postmaster Tools". Code: `core/buddy/src/mail-dns-health.ts`, `core/buddy/src/dns-credentials.ts`.
248
+
247
249
  ## Application Mail Example
248
250
 
249
251
  ```typescript
@@ -106,6 +106,70 @@ provider // 'github-actions' | 'gitlab-ci' | 'circleci' | 'travis' | 'jen
106
106
  providerInfo // { name: string, ... }
107
107
  ```
108
108
 
109
+ ## Remote integration environments
110
+
111
+ Gate anything that transmits off the box - error reporting, analytics, log
112
+ shipping - through `@stacksjs/env`, rather than inventing a policy per adapter.
113
+ Without a shared gate, every developer machine and CI run reports into the same
114
+ project as production, and a local startup failure reads as a live incident
115
+ (stacksjs/stacks#2792).
116
+
117
+ ```typescript
118
+ import { initializeIntegration } from '@stacksjs/env'
119
+
120
+ // The callback does not run when the gate is closed, so constructing a client,
121
+ // registering hooks or starting a flush timer inside it is safe.
122
+ const result = initializeIntegration(config.bughq, ({ environment }) =>
123
+ installBugHq({ key: config.bughq.key, environment }))
124
+
125
+ if (!result.initialized)
126
+ log.debug(`BugHQ not installed: ${result.gate.reason}`)
127
+ ```
128
+
129
+ An application declares **only** `environments`. Do not add a sibling
130
+ `environment` option and do not ask the app to repeat `env.APP_ENV`: the gate
131
+ resolves the effective label once and hands it to the adapter for event
132
+ metadata.
133
+
134
+ Extend `EnvironmentGatedIntegration` from the adapter's own options type so
135
+ the application gets `enabled` and `environments` and nothing else. Analytics
136
+ is the adapter shipping this today:
137
+
138
+ ```typescript
139
+ // config/analytics.ts
140
+ export default {
141
+ driver: 'analyticshq',
142
+ environments: ['production', 'staging'], // omit to get exactly this
143
+ drivers: { analyticshq: { siteId: '...' } },
144
+ } satisfies AnalyticsConfig
145
+ ```
146
+
147
+ | Configuration | Result |
148
+ |---|---|
149
+ | `environments` omitted | `REMOTE_TELEMETRY_ENVIRONMENTS`, which is `production` and `staging` |
150
+ | `environments: []` | off everywhere, the explicit way to disable without removing credentials |
151
+ | `enabled: false` | off, always; checked before the allowlist so it is the last word |
152
+ | `enabled: true` | does **not** bypass the allowlist |
153
+ | `APP_ENV` unset or malformed | off, with `reason: 'environment-unknown'` |
154
+
155
+ The gate reads `APP_ENV` only, and reads it at call time. It does not fall back
156
+ to `NODE_ENV` the way `appEnv()` does, because an absent `APP_ENV` must not be
157
+ guessed into a label an allowlist might admit. Call time rather than import time
158
+ matters because a test harness or a CLI resolving `--env` routinely sets it
159
+ after the module graph has loaded (stacksjs/stacks#2581).
160
+
161
+ `prod`, `stage` and `dev` fold onto `production`, `staging` and `development`.
162
+ `local`, `development` and `test` are deliberately **not** folded together:
163
+ allowing your own machine must not also allow CI.
164
+
165
+ The answer is the same in a web or API server, the dashboard, a worker and a
166
+ Buddy command, because all of them read the same variable. Browser integrations
167
+ receive only the resolved public label, never server credentials.
168
+
169
+ `APP_ENV` is a configuration label, not proof of deployment identity: a local
170
+ command launched with `APP_ENV=production` qualifies. It prevents accidents; it
171
+ is not a security boundary.
172
+
109
173
  ## .env File Loading
110
174
 
111
175
  ```typescript
@@ -349,6 +349,51 @@ if (await isDownForMaintenance()) {
349
349
  await up()
350
350
  ```
351
351
 
352
+ ## Sitemap and robots.txt (core/actions/src/seo.ts)
353
+
354
+ The views server answers `/sitemap.xml` and `/robots.txt` for every app, generated from
355
+ `resources/views` at request time. `buddy dev` (`core/actions/src/dev/views.ts`) and `buddy serve`
356
+ (`core/buddy/src/production-server.ts`) call the same `createSiteSeoHandler()` at the same point in
357
+ `onRequest` (after redirects and rewrites), so dev and production answer identically. No route, no
358
+ build step, no app code. Do not add `route.get('/sitemap.xml')` to `routes/`: the views server does
359
+ not forward root GETs to the API.
360
+
361
+ - **Listed**: every static route under `resources/views`, with `lastmod` from the file, plus the
362
+ blog's published posts (`content/blog`, or a CMS-built `dist/blog/sitemap.xml`) merged into the
363
+ same file. `/blog/sitemap.xml` is still served too.
364
+ - **Not listed**: dynamic routes (`[slug].stx`), error and holding pages (`404`, `errors/*`,
365
+ `coming-soon`), `components/`, `layouts/`, `partials/`, `emails/`, `_`-prefixed files, pages behind
366
+ `definePageMeta({ middleware: ['auth'] })`, paths in `seo.sitemap.exclude`, paths robots.txt
367
+ disallows, and any page that says **noindex**. The framework's default views (`/login`, ...) are
368
+ never listed.
369
+ - **noindex**, the one signal, read from the page source: `<meta name="robots" content="noindex">`,
370
+ `robots: 'noindex'` in a head object, or `const noindex = true` in the server script (the flag a
371
+ shared head partial turns into the tag - the scan cannot follow an `@include`, so spell it that
372
+ way). `definePageMeta({ sitemap: false })` also keeps a page out.
373
+ - **robots.txt**: `User-agent: *`, `Allow: /`, `Disallow: /api/` plus `seo.robots.disallow`, extra
374
+ groups from `seo.robots.rules`, and `Sitemap: <origin>/sitemap.xml`. Never disallow a page just to
375
+ hide it: a crawler that cannot fetch it never sees its noindex.
376
+ - **Hand-written files win**: `public/sitemap.xml` / `public/robots.txt` are served instead, and the
377
+ server warns at boot when the hand-written sitemap misses a page it would list. Delete the file to
378
+ switch, or set `seo.handwritten: 'replace'`.
379
+ - **Origin**: `seo.origin`, else `app.url` (APP_URL), else the request origin (local dev only).
380
+ - **Config** (`config/app.ts`, type `SeoOptions` in `core/types/src/seo.ts`):
381
+
382
+ ```ts
383
+ seo: {
384
+ sitemap: {
385
+ include: ['/products/blue-mug'], // instances of dynamic routes
386
+ entries: async () => (await Product.all()).map(p => `/products/${p.slug}`),
387
+ exclude: ['/styleguide', '/internal/'], // trailing / or * = subtree
388
+ },
389
+ robots: { disallow: ['/checkout/', '/thanks'], rules: [{ userAgent: 'GPTBot', disallow: ['/'] }] },
390
+ // sitemap: false / robots: false turn either off
391
+ }
392
+ ```
393
+
394
+ Production caches each file for ten minutes (for `entries()`); development regenerates per request.
395
+ Static frontend builds (`buddy build views`) use stx's own SSG sitemap instead.
396
+
352
397
  ## Docker Build Pipeline (server/build.ts)
353
398
 
354
399
  The build process (`bun build.ts` from `storage/framework/server/`):
@@ -240,6 +240,30 @@ function showSearch(): void {
240
240
  - **Markdown** — Markdown rendering with syntax highlighting
241
241
  - **A11y** — Accessibility checking and auto-fixing
242
242
 
243
+ ### SEO directives
244
+
245
+ Every argument is an expression evaluated against the template context (`<script server>` variables, the render context, and, inside an `@include` partial, the partial's own `<script server>`). A quoted string is literal text.
246
+
247
+ ```html
248
+ <script server>
249
+ const seo = { title: post.title, description: post.excerpt, canonical: url, openGraph: { image: post.cover } }
250
+ </script>
251
+
252
+ @seo(seo) <!-- or @seo({ ...seo, title }), @seo(buildSeo(post)), @seo({ title: 'Literal' }) -->
253
+ @seo(page.seo ?? {}) <!-- a variable only some pages define -->
254
+ @meta('author', post.author) <!-- unquoted value: an expression -->
255
+ @meta('description', 'Plain text') <!-- quoted value: literal, never looked up -->
256
+ @meta('keywords', tags) <!-- an array joins with ", " -->
257
+ @meta('og:title') <!-- one argument: reads `title`, then `openGraph.title` -->
258
+ @metaTag({ httpEquiv: 'refresh', content: '30' })
259
+ @structuredData(product) <!-- object or array of objects; @context defaults to schema.org -->
260
+ ```
261
+
262
+ - `@seo` reads only `title`, `description`, `keywords`, `robots`, `canonical`, `openGraph`, `twitter`, `structuredData`. `image`, `url` and `type` are not top-level keys (use `openGraph.image`, `canonical`, `openGraph.type`); stray keys are ignored with a warning.
263
+ - A directive that cannot produce its tag (a variable that is not defined, a value of the wrong type, a syntax error, an unclosed call) leaves `<!-- [SEO Error ...] -->` in its place and logs `[stx] @seo(seo) in <file>: ...`. A defined variable holding `null` renders nothing, quietly. Read those warnings; they are the only sign a page shipped without its preview card.
264
+ - `@meta` in a page is staged into `<head>`. `@seo`, `@metaTag` and `@structuredData` write where they sit, so put them inside `<head>` (a layout or head partial). In a page body use `useSeoMeta({ title, description, ogImage })` in `<script server>`, which always reaches `<head>`.
265
+ - Requires `@stacksjs/stx` >= 0.2.343. Older versions only matched `@seo({ ... })` literally and sent unquoted `@meta` values such as `post.author` as text, without a warning.
266
+
243
267
  ### Dev Tools
244
268
  - **Dev server** with HMR (Hot Module Replacement)
245
269
  - **Image optimization**
@@ -291,4 +315,5 @@ await addLayout('admin', { nav: true, footer: true })
291
315
  - **Script block restrictions** — only stx-compatible code (signals, composables, directives), no vanilla DOM APIs
292
316
  - **Pre-paint state** - use `@appearanceBootstrap`, never a raw browser script in the template
293
317
  - **Components go in `resources/`** — not in `app/` or `storage/`
318
+ - **SEO directives take expressions** - `@seo(seo)`, `@meta('author', post.author)` and `@structuredData(product)` are evaluated; a failure shows up as an inline `<!-- [... Error] -->` comment plus a `[stx]` console warning naming the file, never as silent emptiness. Keep `@seo` inside `<head>`
294
319
  - **118+ modules** — STX is a comprehensive framework covering rendering, routing, forms, i18n, SEO, PWA, and more
@@ -26,10 +26,13 @@ export default new Action({
26
26
  const body = await request.all() as Record<string, unknown>
27
27
  const name = typeof body.name === 'string' ? body.name : null
28
28
  const type = body.type === 'public' || body.type === 'confidential' ? body.type : null
29
- const redirectUris = oauthStringList(body.redirect_uris)
29
+ const redirectUris = body.redirect_uris === undefined ? [] : oauthStringList(body.redirect_uris)
30
+ const grantTypes = body.grant_types === undefined
31
+ ? ['authorization_code', 'refresh_token']
32
+ : oauthStringList(body.grant_types)
30
33
  const scopes = oauthStringList(body.scopes)
31
34
  const resources = oauthStringList(body.resources)
32
- if (!name || !type || !redirectUris || !scopes || !resources)
35
+ if (!name || !type || !redirectUris || !grantTypes || !scopes || !resources)
33
36
  return response.badRequest('OAuth client registration metadata is invalid')
34
37
 
35
38
  try {
@@ -38,7 +41,7 @@ export default new Action({
38
41
  type,
39
42
  tokenEndpointAuthMethod: type === 'public' ? 'none' : 'client_secret_basic',
40
43
  redirectUris,
41
- grantTypes: ['authorization_code', 'refresh_token'],
44
+ grantTypes,
42
45
  scopes,
43
46
  resources,
44
47
  })
@@ -30,17 +30,20 @@ export default new Action({
30
30
 
31
31
  const body = await request.all() as Record<string, unknown>
32
32
  const name = typeof body.name === 'string' ? body.name : null
33
- const redirectUris = oauthStringList(body.redirect_uris)
33
+ const redirectUris = body.redirect_uris === undefined ? [] : oauthStringList(body.redirect_uris)
34
+ const grantTypes = body.grant_types === undefined
35
+ ? ['authorization_code', 'refresh_token']
36
+ : oauthStringList(body.grant_types)
34
37
  const scopes = oauthStringList(body.scopes)
35
38
  const resources = oauthStringList(body.resources)
36
- if (!name || !redirectUris || !scopes || !resources)
39
+ if (!name || !redirectUris || !grantTypes || !scopes || !resources)
37
40
  return response.badRequest('OAuth client update metadata is invalid')
38
41
 
39
42
  try {
40
43
  const client = await updateOAuthClient(provider, ownerId, clientId, {
41
44
  name,
42
45
  redirectUris,
43
- grantTypes: ['authorization_code', 'refresh_token'],
46
+ grantTypes,
44
47
  scopes,
45
48
  resources,
46
49
  })
@@ -0,0 +1,25 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { handleOAuthIntrospectionRequest, resolveOAuthProviderConfig } from '@stacksjs/auth'
3
+ import { config } from '@stacksjs/config'
4
+ import { response } from '@stacksjs/router'
5
+
6
+ export default new Action({
7
+ name: 'OAuthIntrospectionAction',
8
+ description: 'Introspect a delegated OAuth token',
9
+ method: 'POST',
10
+ // Resource servers authenticate with their registered OAuth client instead
11
+ // of a browser session, so this protocol endpoint does not use CSRF.
12
+ skipCsrf: true,
13
+
14
+ async handle(request: RequestInstance) {
15
+ const provider = resolveOAuthProviderConfig(config.auth.oauthProvider)
16
+ if (!provider || !provider.introspection)
17
+ return response.notFound('OAuth introspection is not enabled')
18
+
19
+ return handleOAuthIntrospectionRequest(provider, {
20
+ body: (await request.rawBody?.()) ?? '',
21
+ contentType: request.headers.get('content-type'),
22
+ authorization: request.headers.get('authorization'),
23
+ })
24
+ },
25
+ })
@@ -15,7 +15,7 @@ const isSubjectEligible: OAuthSubjectEligibility = async (subject) => {
15
15
 
16
16
  export default new Action({
17
17
  name: 'OAuthTokenAction',
18
- description: 'Exchange OAuth authorization codes and refresh tokens',
18
+ description: 'Exchange OAuth authorization codes, refresh tokens, and machine credentials',
19
19
  method: 'POST',
20
20
  // OAuth clients authenticate with PKCE or HTTP Basic and cannot obtain the
21
21
  // browser CSRF cookie. The protocol boundary validates its own credentials.
@@ -26,10 +26,12 @@ export default new Action({
26
26
  if (!provider)
27
27
  return response.notFound('OAuth provider is not enabled')
28
28
 
29
+ const eligible = provider.subjectEligibility ?? isSubjectEligible
30
+
29
31
  return handleOAuthTokenRequest(provider, {
30
32
  body: (await request.rawBody?.()) ?? '',
31
33
  contentType: request.headers.get('content-type'),
32
34
  authorization: request.headers.get('authorization'),
33
- }, { isSubjectEligible })
35
+ }, { isSubjectEligible: eligible })
34
36
  },
35
37
  })
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.33",
5
+ "version": "0.75.35",
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.33",
5
+ "version": "0.75.35",
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.33",
58
+ "@stacksjs/mobile": "^0.75.35",
59
59
  "@stacksjs/sanitizer": "^0.2.113",
60
60
  "ts-qr-codes": "^0.1.8"
61
61
  }
@@ -79,18 +79,30 @@ function tapBack(event: MouseEvent): void {
79
79
  align-items: center;
80
80
  min-height: 2.75rem;
81
81
  padding: 0 0.75rem;
82
- border-bottom: 1px solid transparent;
83
- transition: background-color 180ms ease, border-color 180ms ease;
84
82
  }
85
83
 
86
- .native-nav-bar.is-scrolled {
87
- border-color: var(--native-bar-border, rgb(148 163 184 / 0.25));
84
+ /* The frosted material is always there and only fades. Adding or removing
85
+ backdrop-filter rebuilds the page's layers, which took 50-140ms of the
86
+ main thread at the moment the page crossed the threshold, mid-scroll. An
87
+ opacity change is the compositor's alone, as long as it never reaches 0:
88
+ WebKit drops a transparent layer and builds it again on the way back. */
89
+ .native-nav-bar::before {
90
+ content: '';
91
+ position: absolute;
92
+ inset: 0;
93
+ z-index: -1;
94
+ border-bottom: 1px solid var(--native-bar-border, rgb(148 163 184 / 0.25));
88
95
  background: var(--native-bar-background, rgb(248 250 252 / 0.86));
89
96
  backdrop-filter: blur(20px) saturate(180%);
90
97
  -webkit-backdrop-filter: blur(20px) saturate(180%);
98
+ opacity: 0.001;
99
+ pointer-events: none;
100
+ transition: opacity 180ms ease;
91
101
  }
92
102
 
93
- .dark .native-nav-bar.is-scrolled {
103
+ .native-nav-bar.is-scrolled::before { opacity: 1; }
104
+
105
+ .dark .native-nav-bar::before {
94
106
  border-color: var(--native-bar-border-dark, rgb(51 65 85 / 0.6));
95
107
  background: var(--native-bar-background-dark, rgb(2 6 23 / 0.86));
96
108
  }
@@ -141,11 +153,11 @@ function tapBack(event: MouseEvent): void {
141
153
  }
142
154
 
143
155
  @media (prefers-reduced-motion: reduce) {
144
- .native-nav-bar, .native-nav-title { transition: none; }
156
+ .native-nav-bar::before, .native-nav-title { transition: none; }
145
157
  }
146
158
 
147
159
  @media (prefers-reduced-transparency: reduce) {
148
- .native-nav-bar.is-scrolled { backdrop-filter: none; -webkit-backdrop-filter: none; background: var(--native-bar-solid, rgb(248 250 252)); }
149
- .dark .native-nav-bar.is-scrolled { background: var(--native-bar-solid-dark, rgb(2 6 23)); }
160
+ .native-nav-bar::before { backdrop-filter: none; -webkit-backdrop-filter: none; background: var(--native-bar-solid, rgb(248 250 252)); }
161
+ .dark .native-nav-bar::before { background: var(--native-bar-solid-dark, rgb(2 6 23)); }
150
162
  }
151
163
  </style>
@@ -77,6 +77,8 @@ const indicatorStyle = derived(() => {
77
77
  opacity: 0;
78
78
  pointer-events: none;
79
79
  transform: translateX(-50%) scale(0.6);
80
+ /* Restyled every frame of a pull: its own layer keeps that off the page's paint. */
81
+ will-change: transform, opacity;
80
82
  }
81
83
 
82
84
  .dark .native-ptr-indicator { color: var(--native-accent-dark, rgb(96 165 250)); background: var(--native-ptr-background-dark, rgb(30 41 59)); }
@@ -19,7 +19,9 @@ const hasBadge = badge !== '' && badge !== 0 && badge !== '0'
19
19
 
20
20
  <!-- `draggable="false"`: a slow tap on a busy page reaches the WebView as a
21
21
  long press, and a draggable link starts a native drag instead of
22
- navigating. -->
22
+ navigating. `data-stx-prefetch="eager"`: every tab's page is fetched once
23
+ the current one is idle, so switching tabs is served from the router's
24
+ cache, as instant as a native tab bar. -->
23
25
  <StxLink
24
26
  to="{{ href }}"
25
27
  aria-label="{{ label }}"
@@ -27,6 +29,7 @@ const hasBadge = badge !== '' && badge !== 0 && badge !== '0'
27
29
  activeMatch="{{ match }}"
28
30
  class="native-tab-item"
29
31
  draggable="false"
32
+ data-stx-prefetch="eager"
30
33
  >
31
34
  <span class="native-tab-glyph">
32
35
  <i aria-hidden="true" class="{{ icon }} native-tab-icon"></i>
package/routes/auth.ts CHANGED
@@ -31,6 +31,7 @@ const oauthProvider = resolveOAuthProviderConfig(config.auth.oauthProvider)
31
31
  const oauthAuthorizationPath = oauthProvider ? new URL(oauthProvider.endpoints.authorization).pathname : '/oauth/authorize'
32
32
  const oauthTokenPath = oauthProvider ? new URL(oauthProvider.endpoints.token).pathname : '/oauth/token'
33
33
  const oauthRevocationPath = oauthProvider ? new URL(oauthProvider.endpoints.revocation).pathname : '/oauth/revoke'
34
+ const oauthIntrospectionPath = oauthProvider ? new URL(oauthProvider.endpoints.introspection).pathname : '/oauth/introspect'
34
35
  const oauthMetadataPath = oauthProvider
35
36
  ? oauthAuthorizationServerMetadataPath(oauthProvider.issuer)
36
37
  : '/.well-known/oauth-authorization-server'
@@ -49,6 +50,7 @@ route.post('/register', 'Actions/Auth/RegisterAction').rateLimit(3, 'minute')
49
50
  // the endpoint's credential boundary, so the action opts out of browser CSRF.
50
51
  route.post(oauthTokenPath, 'Actions/Auth/OAuthTokenAction').rateLimit(30, 'minute')
51
52
  route.post(oauthRevocationPath, 'Actions/Auth/OAuthRevocationAction').rateLimit(30, 'minute')
53
+ route.post(oauthIntrospectionPath, 'Actions/Auth/OAuthIntrospectionAction').rateLimit(30, 'minute')
52
54
  route.get(oauthAuthorizationPath, 'Actions/Auth/OAuthAuthorizationAction').rateLimit(60, 'minute')
53
55
  route.post(oauthAuthorizationPath, 'Actions/Auth/OAuthConsentAction').middleware('auth').rateLimit(30, 'minute')
54
56
  route.get(oauthMetadataPath, 'Actions/Auth/OAuthMetadataAction')