@stacksjs/defaults 0.72.56 → 0.72.60

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.
Files changed (30) hide show
  1. package/ai/skills/stacks-api/SKILL.md +88 -1
  2. package/ai/skills/stacks-browse/scripts/browse.ts +44 -29
  3. package/ai/skills/stacks-events/SKILL.md +53 -7
  4. package/ai/skills/stacks-new-feature/SKILL.md +27 -0
  5. package/ai/skills/stacks-router/SKILL.md +95 -2
  6. package/app/Actions/AI/AskAction.ts +1 -1
  7. package/app/Actions/AI/SummaryAction.ts +1 -1
  8. package/app/Actions/Auth/DisableTwoFactorAction.ts +1 -1
  9. package/app/Actions/Auth/EnableTwoFactorAction.ts +1 -1
  10. package/app/Actions/Auth/LoginAction.ts +2 -2
  11. package/app/Actions/Auth/MagicLinkConsumeAction.ts +1 -1
  12. package/app/Actions/Auth/MagicLinkSendAction.ts +1 -1
  13. package/app/Actions/Auth/RefreshTokenAction.ts +1 -1
  14. package/app/Actions/Auth/RegisterAction.ts +3 -3
  15. package/app/Actions/Auth/VerifyTwoFactorLoginAction.ts +2 -2
  16. package/app/Actions/Cms/CategorizableStoreAction.ts +3 -3
  17. package/app/Actions/Cms/CategorizableUpdateAction.ts +8 -2
  18. package/app/Actions/Cms/CommentStoreAction.ts +2 -2
  19. package/app/Actions/Cms/CommentUpdateAction.ts +8 -2
  20. package/app/Actions/Cms/PostViewsUpdateAction.ts +7 -1
  21. package/app/Actions/Cms/TaggableStoreAction.ts +2 -2
  22. package/app/Actions/Cms/TaggableUpdateAction.ts +8 -2
  23. package/app/Actions/Dashboard/Kanban/BoardsIndexAction.ts +1 -1
  24. package/app/Actions/Dashboard/Kanban/UsersListAction.ts +1 -1
  25. package/app/Actions/LogAction.ts +2 -2
  26. package/app/Actions/UploadTestAction.ts +0 -1
  27. package/app/Middleware/Auth.ts +0 -1
  28. package/ide/vscode/package.json +1 -1
  29. package/package.json +2 -2
  30. package/routes/dashboard.ts +1 -1
@@ -340,9 +340,96 @@ route.get('/admin', handler).middleware('abilities:admin,write')
340
340
  // Params accessible in middleware via: (request as any)._middlewareParams.abilities === 'admin,write'
341
341
  ```
342
342
 
343
+ ## Which client to reach for
344
+
345
+ Stacks has three, and they answer different questions. Pick by who is calling.
346
+
347
+ | Caller | Client | Why |
348
+ |---|---|---|
349
+ | TypeScript in this repo or a workspace package | **Typed client** (below) | Full input/output inference with **no generation step**. Change a route, the call site stops compiling. |
350
+ | Anything that is not TypeScript here - native iOS/Android via the Craft bridge, third-party integrators, Swagger UI | **Generated REST client** (`buddy generate:openapi`) | Needs a real spec. That pipeline is unchanged and permanent. |
351
+ | One-off calls to any URL | **`Fetcher`** (below) | No route awareness, and none intended. |
352
+
353
+ The first two are both permanent. This is not a migration off REST.
354
+
355
+ ## Typed client (zero generation)
356
+
357
+ Register routes with `createTypedRouter()` and the compiler can see both ends —
358
+ no `buddy generate:openapi`, no committed file to go stale.
359
+
360
+ ```typescript
361
+ // routes/api.ts
362
+ import IndexAction from '../app/Actions/Project/IndexAction'
363
+ import StoreAction from '../app/Actions/Project/StoreAction'
364
+ import { createTypedRouter } from '@stacksjs/router'
365
+
366
+ export const api = createTypedRouter()
367
+ .get('/v1/projects', IndexAction)
368
+ .get('/v1/projects/{id}', ShowAction)
369
+ .post('/v1/projects', StoreAction, { middleware: 'auth' })
370
+
371
+ export type AppRoutes = typeof api
372
+ ```
373
+
374
+ ```typescript
375
+ // any TypeScript consumer
376
+ import type { AppRoutes } from '../routes/api'
377
+ import { createTypedClient } from '@stacksjs/router'
378
+
379
+ const client = createTypedClient<AppRoutes>({ baseUrl: 'https://api.example.com' })
380
+
381
+ const projects = await client.get('/v1/projects')
382
+ const one = await client.get('/v1/projects/{id}', { params: { id: '42' } })
383
+ const created = await client.post('/v1/projects', { name: 'apollo', budget: 1200 })
384
+ ```
385
+
386
+ An unknown path is a compile error. A body that does not match the action's
387
+ `validations` is a compile error. The result is the action's own return type.
388
+
389
+ **How it infers.** Input comes from the action's `validations` — the same object
390
+ the validator runs, so the two cannot drift. Output comes from `handle`'s return
391
+ type. An action returning a `Response` or a stream is typed `unknown`, honestly:
392
+ it took over the wire format.
393
+
394
+ **Per-route settings are an argument**, not a chained call — chaining
395
+ `.middleware()` would return the route and lose the accumulated type:
396
+
397
+ ```typescript
398
+ .post('/v1/projects', StoreAction, {
399
+ middleware: ['auth', 'can:create,project'],
400
+ name: 'projects.store',
401
+ skipCsrf: true,
402
+ rateLimit: { max: 10, window: 'minute' },
403
+ })
404
+ ```
405
+
406
+ **No `.group()`.** A prefix applied only at runtime makes every accumulated path
407
+ type wrong; one applied only in the type is a second place for the URL to live.
408
+ Write the full path.
409
+
410
+ **The string form is untouched.** `route.get('/x', 'Actions/Foo')` stays exactly
411
+ as it is — lazy import, hot-reload friendly — and simply produces no inferred
412
+ types. A route wanting inference opts in by using the builder instead. Routes
413
+ registered either way still land in the generated OpenAPI document.
414
+
415
+ **Generated CRUD.** A `useApi` model's endpoints are describable too, without
416
+ touching them:
417
+
418
+ ```typescript
419
+ import type { ApiRoutesFor } from '@stacksjs/orm'
420
+ import type Product from '../app/Models/Product'
421
+
422
+ type AppRoutes = RoutesOf<typeof api> & ApiRoutesFor<typeof Product>
423
+ await client.get('/api/products') // typed listing envelope
424
+ ```
425
+
426
+ The builder, the client and the route-map contract all live in
427
+ `@stacksjs/bun-router` and are re-exported by `@stacksjs/router`. Import
428
+ `createTypedClient` from `@stacksjs/bun-router` directly in a browser bundle.
429
+
343
430
  ## Fetcher (HTTP Client)
344
431
 
345
- A fluent HTTP client for making API requests:
432
+ A fluent HTTP client for ad hoc requests, with no route awareness:
346
433
 
347
434
  ```typescript
348
435
  import { fetcher } from '@stacksjs/api'
@@ -313,7 +313,7 @@ interface PageState {
313
313
  mainStatus: number | null
314
314
  }
315
315
 
316
- async function gotoAndInstrument(cdp: Cdp, url: string, opts: { viewport?: { w: number, h: number }, scale?: number, timeoutMs?: number, cookies?: string[], settleMs?: number, scheme?: string } = {}): Promise<PageState> {
316
+ async function gotoAndInstrument(cdp: Cdp, url: string, opts: { viewport?: { w: number, h: number }, scale?: number, timeoutMs?: number, cookies?: string[], settleMs?: number, scheme?: string, reducedMotion?: boolean } = {}): Promise<PageState> {
317
317
  let unsubscribe = () => {}
318
318
  const state: PageState = {
319
319
  consoleErrors: [],
@@ -356,12 +356,29 @@ async function gotoAndInstrument(cdp: Cdp, url: string, opts: { viewport?: { w:
356
356
  }
357
357
 
358
358
  // Emulate light/dark so prefers-color-scheme pages can be QA'd in both
359
- // schemes without flipping the host OS setting.
360
- if (opts.scheme === 'light' || opts.scheme === 'dark') {
361
- await cdp.send('Emulation.setEmulatedMedia', {
362
- features: [{ name: 'prefers-color-scheme', value: opts.scheme }],
363
- })
364
- }
359
+ // schemes without flipping the host OS setting. `reducedMotion` rides the
360
+ // same call (a second `setEmulatedMedia` would replace this one's features
361
+ // rather than add to them) - it's set for `--full`/`--element` captures,
362
+ // which paint beyond the current viewport via `captureBeyondViewport`.
363
+ // That API paints pixels beyond the viewport WITHOUT moving `scrollY`, so
364
+ // a scroll-driven CSS reveal (`animation-timeline: view()`) never
365
+ // resolves past its initial (typically invisible) keyframe for anything
366
+ // that was never really scrolled past - a `--full` capture would silently
367
+ // screenshot every such section as blank. `prefers-reduced-motion: reduce`
368
+ // sidesteps that instead of fighting it with a scroll trick that would
369
+ // itself misplace `position: sticky`/`fixed` elements: this project's own
370
+ // marketing CSS already turns every scroll/entrance animation off and
371
+ // leaves elements at their plain (opaque) resting state under reduced
372
+ // motion (`@media (prefers-reduced-motion: reduce)`), which is exactly
373
+ // the already-shipped, already-tested fallback a real accessibility user
374
+ // gets - reusing it here means nothing new to prove correct.
375
+ const features: { name: string, value: string }[] = []
376
+ if (opts.scheme === 'light' || opts.scheme === 'dark')
377
+ features.push({ name: 'prefers-color-scheme', value: opts.scheme })
378
+ if (opts.reducedMotion)
379
+ features.push({ name: 'prefers-reduced-motion', value: 'reduce' })
380
+ if (features.length)
381
+ await cdp.send('Emulation.setEmulatedMedia', { features })
365
382
 
366
383
  unsubscribe = cdp.on((e) => {
367
384
  if (e.method === 'Runtime.consoleAPICalled') {
@@ -404,23 +421,11 @@ async function title(cdp: Cdp): Promise<string> {
404
421
  }
405
422
 
406
423
  /**
407
- * Scroll the real page to `y` and let one frame settle before the caller
408
- * screenshots it.
409
- *
410
- * `--full` captures the whole document through `captureBeyondViewport`,
411
- * which paints content at its full-page layout position without ever
412
- * moving `scrollY` - correct for a top-to-bottom review, but useless for
413
- * checking anything that depends on an actual mid-scroll viewport: a
414
- * `position: sticky` nav overlapping content beneath it, a CSS
415
- * `animation-timeline: view()` reveal mid-transition, or simply "what does
416
- * the fold look like 800px down". `window.scrollTo(..., { behavior:
417
- * 'instant' })` forces the jump synchronously even when the page sets
418
- * `scroll-behavior: smooth`, which would otherwise animate the scroll and
419
- * leave a screenshot taken immediately after mid-flight. The two rAF
420
- * round-trips that follow give the compositor one full frame to repaint
421
- * sticky/fixed layers and any scroll-driven animation at the new offset
422
- * before `Page.captureScreenshot` reads back pixels, which
423
- * `Runtime.evaluate` returning does not by itself guarantee.
424
+ * Scroll the real page to `y` and let two animation-frame round-trips settle
425
+ * before the caller reads pixels back - `behavior: 'instant'` forces the
426
+ * jump synchronously even when the page sets `scroll-behavior: smooth`,
427
+ * which would otherwise animate the scroll and leave a screenshot taken
428
+ * immediately after mid-flight.
424
429
  */
425
430
  async function scrollTo(cdp: Cdp, y: number): Promise<void> {
426
431
  const r = await cdp.send('Runtime.evaluate', {
@@ -753,7 +758,7 @@ async function main() {
753
758
  console.log('Usage: bun browse.ts <navigate|screenshot|responsive|monitor|snapshot|scenario|crawl> <url> [flags]')
754
759
  console.log(' --cookie "name=value" repeatable; pre-seeds cookies (e.g. coming-soon bypass)')
755
760
  console.log(' --settle 1500 ms to wait after load before acting (default 700; stretch for entrance animations)')
756
- console.log(' --scheme dark emulate prefers-color-scheme (light|dark) for QA of theme-aware pages')
761
+ console.log(' --scheme dark emulate prefers-color-scheme (light|dark); defaults to light when omitted')
757
762
  console.log(' --scroll-y 800 screenshot: jump to this document Y first, then capture just the viewport')
758
763
  console.log(' (--full renders beyond-viewport without ever scrolling, so it cannot show a')
759
764
  console.log(' position:sticky/fixed element overlapping content, or a scroll-linked')
@@ -764,7 +769,17 @@ async function main() {
764
769
  let session = await launch()
765
770
  const cookies = flagList(flags.cookie)
766
771
  const settleMs = flags.settle ? Number(flags.settle) : undefined
767
- const scheme = typeof flags.scheme === 'string' ? flags.scheme : undefined
772
+ // Defaults to 'light' rather than leaving prefers-color-scheme unset:
773
+ // headless Chromium's own default for that media feature isn't
774
+ // documented or guaranteed, and was observed reporting 'dark' matches
775
+ // with nothing here asking for it - every screenshot taken without
776
+ // --scheme would silently show this site's dark theme instead of its
777
+ // actual 'colored' (light) default. Forcing 'light' makes an unflagged
778
+ // capture deterministic and representative of what a visitor with no
779
+ // saved choice and no OS dark-mode preference actually sees.
780
+ if (flags.scheme !== undefined && flags.scheme !== 'light' && flags.scheme !== 'dark')
781
+ throw new TypeError(`Invalid --scheme "${String(flags.scheme)}". Expected "light" or "dark".`)
782
+ const scheme = typeof flags.scheme === 'string' ? flags.scheme : 'light'
768
783
  try {
769
784
  if (command === 'navigate' || command === 'go') {
770
785
  const cdp = await openPage(session.port)
@@ -794,7 +809,7 @@ async function main() {
794
809
  throw new TypeError(`--full, --element, and --scroll-y are mutually exclusive; got ${modes.join(', ')}.`)
795
810
  const out = (flags.out as string) || `storage/framework/runtime/shots/${new URL(url).pathname.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '') || 'home'}.png`
796
811
  mkdirSync(out.split('/').slice(0, -1).join('/') || '.', { recursive: true })
797
- const state = await gotoAndInstrument(cdp, url, { viewport, scale, cookies, settleMs, scheme })
812
+ const state = await gotoAndInstrument(cdp, url, { viewport, scale, cookies, settleMs, scheme, reducedMotion: !!flags.full || !!flags.element })
798
813
  const png = await captureScreenshot(cdp, { full: !!flags.full, element: flags.element as string | undefined, scrollY: scrollY ?? undefined })
799
814
  await Bun.write(out, png)
800
815
  console.log(JSON.stringify({ url, out, viewport: `${viewport.w}x${viewport.h}`, scale, full: !!flags.full, element: flags.element ?? null, scrollY: scrollY ?? null, bytes: png.length }, null, 2))
@@ -808,7 +823,7 @@ async function main() {
808
823
  const results: any[] = []
809
824
  for (const bp of BREAKPOINTS) {
810
825
  const cdp = await openPage(session.port)
811
- const state = await gotoAndInstrument(cdp, url, { viewport: { w: bp.w, h: bp.h }, cookies, settleMs, scheme })
826
+ const state = await gotoAndInstrument(cdp, url, { viewport: { w: bp.w, h: bp.h }, cookies, settleMs, scheme, reducedMotion: true })
812
827
  const overflow = await cdp.send('Runtime.evaluate', {
813
828
  expression: 'document.body.scrollWidth > window.innerWidth ? document.body.scrollWidth - window.innerWidth : 0',
814
829
  returnByValue: true,
@@ -874,7 +889,7 @@ async function main() {
874
889
 
875
890
  const cdp = await openPage(session.port)
876
891
  const viewport = parseViewport(typeof flags.viewport === 'string' ? flags.viewport : undefined)
877
- const state = await gotoAndInstrument(cdp, url, { viewport, cookies, settleMs, scheme })
892
+ const state = await gotoAndInstrument(cdp, url, { viewport, cookies, settleMs, scheme, reducedMotion: !!flags.full })
878
893
  const results: Record<string, unknown>[] = []
879
894
  await cdp.send('Page.bringToFront')
880
895
  await cdp.send('Runtime.evaluate', {
@@ -122,12 +122,57 @@ The `Record<EventType, unknown>` intersection allows arbitrary event names beyon
122
122
 
123
123
  ## Model Events
124
124
 
125
- Every model with `observe: true` trait (in defineModel) emits via `afterCreate`/`afterUpdate`/`afterDelete` hooks:
126
- - `'{model}:created'` -- after insert
127
- - `'{model}:updated'` -- after update
128
- - `'{model}:deleted'` -- after delete
125
+ Every model with the `observe: true` trait emits **eight** events:
129
126
 
130
- Model name is lowercased: `'user:created'`, `'post:updated'`, `'order:deleted'`
127
+ | Event | When | Payload |
128
+ |---|---|---|
129
+ | `{model}:saving` | before any write | the model object |
130
+ | `{model}:creating` / `:updating` / `:deleting` | before that write | the model object |
131
+ | `{model}:created` / `:updated` / `:deleted` | after that write | the row |
132
+ | `{model}:saved` | after insert OR update | the row |
133
+
134
+ Model name is lowercased: `'user:created'`, `'post:updated'`, `'teammember:saved'`.
135
+
136
+ A **before** listener can cancel the write by returning `false`:
137
+
138
+ ```ts
139
+ listen('user:deleting', (model) => {
140
+ if (model.attributes.email.endsWith('@example.com'))
141
+ return false // the delete does not happen
142
+ })
143
+ ```
144
+
145
+ Before-events carry the model object (`.attributes` holds the row); after-events
146
+ carry the row itself.
147
+
148
+ ### The payloads are typed, and nothing generates them
149
+
150
+ `listen('user:created', user => user.emial)` is a compile error - the payload is
151
+ the User row, with the columns your model declares.
152
+
153
+ `storage/framework/types/model-events.d.ts` derives the whole map from the models
154
+ barrel with a mapped type:
155
+
156
+ ```ts
157
+ type ModelAfterEvents = {
158
+ [K in keyof Models & string as `${Lowercase<K>}:${AfterEvent}`]: ModelRow<Models[K]>
159
+ }
160
+ ```
161
+
162
+ So a model existing IS its events existing - there is no generated list to keep in
163
+ agreement, and nothing to re-run after adding a model. (It replaced an 817-line
164
+ generated file, and before that a hand-maintained one that listed three events per
165
+ model and typed every payload `Record<string, any>`.)
166
+
167
+ Declare your own events by augmenting `AppEvents`:
168
+
169
+ ```ts
170
+ declare module '@stacksjs/events' {
171
+ interface AppEvents {
172
+ 'invoice:overdue': { id: number, daysLate: number }
173
+ }
174
+ }
175
+ ```
131
176
 
132
177
  Events are dispatched via lazy `import('@stacksjs/events').then(({ dispatch }) => dispatch(...))` to avoid circular dependencies. If the import fails (e.g., browser context), errors are silently caught.
133
178
 
@@ -136,8 +181,9 @@ The `observe` trait can be:
136
181
  - `['create', 'update']` -- emits only specified events
137
182
  - `false` / undefined -- no events
138
183
 
139
- Full model list (45+ with events defined in `storage/framework/types/events.ts`):
140
- Author, Page, Post, User, Activity, Campaign, Cart, CartItem, Category, Comment, Coupon, Customer, DeliveryRoute, DigitalDelivery, Driver, EmailList, GiftCard, LicenseKey, LoyaltyPoint, LoyaltyReward, Manufacturer, Notification, Order, OrderItem, Payment, PrintDevice, Product, ProductUnit, ProductVariant, Receipt, Review, ShippingMethod, ShippingRate, ShippingZone, SocialPost, Subscription, Tag, TaxRate, Transaction, WaitlistProduct, WaitlistRestaurant, Websocket
184
+ There is no model list to keep here. Every model in `storage/framework/auto-imports/models.ts`
185
+ has its eight events, and that barrel is generated from disk for the runtime, so the
186
+ answer to "which models emit events" is "the ones that exist".
141
187
 
142
188
  ## Event-to-Listener Mapping (app/Events.ts)
143
189
 
@@ -121,6 +121,33 @@ route.group({ prefix: '/articles', middleware: ['auth'] }, () => {
121
121
 
122
122
  Or rely on auto-generated routes from `useApi` trait — they're created automatically.
123
123
 
124
+ ### When a TypeScript client will call these
125
+
126
+ Register through `createTypedRouter()` instead, and the client gets full
127
+ input/output inference with no `buddy generate:openapi` step in between:
128
+
129
+ ```typescript
130
+ // routes/api.ts
131
+ import CreateArticle from '../app/Actions/CreateArticle'
132
+ import ListArticles from '../app/Actions/ListArticles'
133
+ import { createTypedRouter } from '@stacksjs/router'
134
+
135
+ export const api = createTypedRouter()
136
+ .get('/articles', ListArticles, { middleware: 'auth' })
137
+ .post('/articles', CreateArticle, { middleware: 'auth' })
138
+
139
+ export type AppRoutes = typeof api
140
+ ```
141
+
142
+ ```typescript
143
+ const client = createTypedClient<AppRoutes>({ baseUrl })
144
+ const created = await client.post('/articles', { title: 'x', content: 'y' })
145
+ ```
146
+
147
+ Same runtime path, same middleware, same OpenAPI document — the difference is
148
+ entirely at compile time. Keep the string form for routes no TypeScript consumer
149
+ calls; it stays lazily imported. See the `stacks-api` and `stacks-router` skills.
150
+
124
151
  ## Step 5: Add Event Listeners (Optional)
125
152
 
126
153
  ```typescript
@@ -47,10 +47,103 @@ route.group({ prefix: '/api/v1', middleware: ['auth', 'throttle'] }, () => {
47
47
  ```
48
48
 
49
49
  ### Handler Types
50
- - Function: `(req: EnhancedRequest) => Response | Promise<Response>`
51
- - Action string: `'Actions/CreateUser'` — auto-loads action
50
+ - Function: `(req) => …` — return a `Response`, or any value `formatResult`
51
+ handles: an object/array becomes JSON, a string becomes text, `null` becomes
52
+ 204, a `ReadableStream` streams. `req.params` is narrowed to the path's own
53
+ placeholders, so `req.params.slugTypo` is a compile error rather than
54
+ `undefined` at runtime.
55
+ - Action string: `'Actions/CreateUser'` — auto-loads action, lazily
56
+ - Action object: an imported action, passed directly — see typed routes below
52
57
  - Controller: `'Controllers/UserController@index'` — calls controller method
53
58
 
59
+ ## The strings are typed (run `buddy generate:types`)
60
+
61
+ Action paths, middleware aliases and route names are all checked at compile
62
+ time against what this application actually has. `buddy generate:types`
63
+ discovers them and writes them into the router's type registry
64
+ (`storage/framework/types/actions.d.ts`); nothing is maintained by hand.
65
+
66
+ ```typescript
67
+ route.get('/login', 'Actions/Auth/LogniAction') // ✗ no such action
68
+ route.get('/admin', handler).middleware('atuh') // ✗ no such middleware alias
69
+ url('email.unsubscrbe', { token }) // ✗ no such route name
70
+ url('user.post', { id: 42 }) // params come from the path
71
+ ```
72
+
73
+ The middleware one is the one that matters most: a typo'd alias used to serve
74
+ the route **without** the protection, silently.
75
+
76
+ Notes:
77
+ - Controllers stay a pattern (`'Controllers/X@method'`) — the method half is a
78
+ member name, not a filename.
79
+ - Negated (`'!auth'`) and parameterised (`'throttle:60,1'`) middleware forms are
80
+ both accepted.
81
+ - Regenerate after adding an action, a middleware alias, or a `.name()`. A stale
82
+ file rejects code that is correct.
83
+ - `resource()` takes a BASE, and composes `Actions/<Base><Kind>Action` from it.
84
+ `route.resource('posts', 'Post')` → `Actions/PostIndexAction`, matching where
85
+ `buddy make:crud` writes. The base is checked against the actions that exist;
86
+ which of the five siblings you need depends on `only`/`except`, so that part
87
+ is settled when the route is hit.
88
+
89
+ ### Path params arrive decoded
90
+
91
+ `/users/{name}` given `/users/caf%C3%A9` hands the handler `café`, and `%2F`
92
+ becomes a real `/`. Decoded exactly once, in bun-router — do NOT decode again in
93
+ an action or middleware: two passes turn `%2520` into a space, which is how a
94
+ filter that rejects `../` gets walked past. A malformed escape (`%ZZ`) passes
95
+ through raw rather than failing the request.
96
+
97
+ A decoded param can contain `/`, so anything joining one into a filesystem path
98
+ still has to sanitise. Decoding makes the value correct, not safe.
99
+
100
+ ## Typed Routes (zero generation)
101
+
102
+ `route.get('/x', 'Actions/Foo')` resolves its action by a dynamic `import()` of a
103
+ string. Good for the runtime — lazy, hot-reload friendly — and completely opaque
104
+ to the compiler, so no client can be typed from it without a generation step.
105
+
106
+ `createTypedRouter()` registers through the same router while accumulating a
107
+ route map into its own type:
108
+
109
+ ```typescript
110
+ import IndexAction from '../app/Actions/Project/IndexAction'
111
+ import StoreAction from '../app/Actions/Project/StoreAction'
112
+ import { createTypedRouter } from '@stacksjs/router'
113
+
114
+ export const api = createTypedRouter()
115
+ .get('/v1/projects', IndexAction)
116
+ .post('/v1/projects', StoreAction, { middleware: 'auth', rateLimit: { max: 10 } })
117
+
118
+ export type AppRoutes = typeof api
119
+ ```
120
+
121
+ Any TypeScript consumer then gets full inference with **no CLI step**:
122
+
123
+ ```typescript
124
+ import { createTypedClient } from '@stacksjs/router'
125
+
126
+ const client = createTypedClient<AppRoutes>({ baseUrl })
127
+ const projects = await client.get('/v1/projects') // typed from the action
128
+ ```
129
+
130
+ Facts worth knowing before using it:
131
+
132
+ - **One runtime path.** A directly-registered action goes through the same
133
+ `wrapAction` as a string-registered one — validation, `authorize`, `before`,
134
+ `formatResult`, error reporting. Only the compile-time story differs.
135
+ - **Input from `validations`, output from `handle`'s return type.** An action
136
+ returning a `Response` is typed `unknown`; it took over the wire format.
137
+ - **Options are an argument**, not chained — chaining would return the route and
138
+ lose the accumulated type.
139
+ - **No `.group()`.** A runtime-only prefix makes every path type wrong; a
140
+ type-only prefix is a second place for the URL to live.
141
+ - **Both forms feed OpenAPI.** Directly-registered actions are reported by
142
+ `listRegisteredRoutes()`, so the generator reads their schema with no file
143
+ path to import.
144
+ - The builder, the client and the contract live in `@stacksjs/bun-router` and
145
+ are re-exported here. See the `stacks-api` skill for the full client story.
146
+
54
147
  ## Route Registry (app/Routes.ts)
55
148
 
56
149
  ```typescript
@@ -13,7 +13,7 @@ export default new Action({
13
13
 
14
14
  validations: {
15
15
  question: {
16
- rule: schema.string().min(3).max(255),
16
+ rule: schema.string().min(3).max(255).required(),
17
17
  message: 'The question must be between 3 and 255 characters long.',
18
18
  },
19
19
  },
@@ -13,7 +13,7 @@ export default new Action({
13
13
 
14
14
  validations: {
15
15
  text: {
16
- rule: schema.string().min(3),
16
+ rule: schema.string().min(3).required(),
17
17
  message: 'The text must be at least 3 characters long.',
18
18
  },
19
19
  },
@@ -10,7 +10,7 @@ export default new Action({
10
10
 
11
11
  validations: {
12
12
  password: {
13
- rule: schema.string().min(1),
13
+ rule: schema.string().min(1).required(),
14
14
  message: 'Password is required to disable two-factor authentication.',
15
15
  },
16
16
  },
@@ -10,7 +10,7 @@ export default new Action({
10
10
 
11
11
  validations: {
12
12
  code: {
13
- rule: schema.string().min(6).max(6),
13
+ rule: schema.string().min(6).max(6).required(),
14
14
  message: 'Code must be a 6-digit TOTP code.',
15
15
  },
16
16
  },
@@ -12,7 +12,7 @@ export default new Action({
12
12
 
13
13
  validations: {
14
14
  email: {
15
- rule: schema.string().email(),
15
+ rule: schema.string().email().required(),
16
16
  message: 'Email must be a valid email address.',
17
17
  },
18
18
  // Presence only, NOT the creation policy. Enforcing a minimum length on
@@ -21,7 +21,7 @@ export default new Action({
21
21
  // credentials are ever checked. The policy belongs on the paths that SET a
22
22
  // password (#2226).
23
23
  password: {
24
- rule: schema.string().min(1).max(PASSWORD_MAX_LENGTH),
24
+ rule: schema.string().min(1).max(PASSWORD_MAX_LENGTH).required(),
25
25
  message: PASSWORD_PRESENCE_MESSAGE,
26
26
  },
27
27
  },
@@ -11,7 +11,7 @@ export default new Action({
11
11
 
12
12
  validations: {
13
13
  token: {
14
- rule: schema.string().min(16).max(255),
14
+ rule: schema.string().min(16).max(255).required(),
15
15
  message: 'Token is required.',
16
16
  },
17
17
  },
@@ -11,7 +11,7 @@ export default new Action({
11
11
 
12
12
  validations: {
13
13
  email: {
14
- rule: schema.string().email(),
14
+ rule: schema.string().email().required(),
15
15
  message: 'Email must be a valid email address.',
16
16
  },
17
17
  },
@@ -12,7 +12,7 @@ export default new Action({
12
12
 
13
13
  await request.validate({
14
14
  refresh_token: {
15
- rule: schema.string().min(1),
15
+ rule: schema.string().min(1).required(),
16
16
  message: {
17
17
  min: 'Refresh token is required',
18
18
  },
@@ -12,15 +12,15 @@ export default new Action({
12
12
 
13
13
  validations: {
14
14
  email: {
15
- rule: schema.string().email(),
15
+ rule: schema.string().email().required(),
16
16
  message: 'Email must be a valid email address.',
17
17
  },
18
18
  password: {
19
- rule: schema.string().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH),
19
+ rule: schema.string().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH).required(),
20
20
  message: PASSWORD_POLICY_MESSAGE,
21
21
  },
22
22
  name: {
23
- rule: schema.string().min(2).max(255),
23
+ rule: schema.string().min(2).max(255).required(),
24
24
  message: 'Name must be between 2 and 255 characters.',
25
25
  },
26
26
  },
@@ -10,11 +10,11 @@ export default new Action({
10
10
 
11
11
  validations: {
12
12
  challenge_token: {
13
- rule: schema.string().min(1),
13
+ rule: schema.string().min(1).required(),
14
14
  message: 'A challenge token is required.',
15
15
  },
16
16
  code: {
17
- rule: schema.string().min(6).max(6),
17
+ rule: schema.string().min(6).max(6).required(),
18
18
  message: 'Code must be a 6-digit TOTP code.',
19
19
  },
20
20
  },
@@ -10,20 +10,20 @@ export default new Action({
10
10
  async handle(request: RequestInstance) {
11
11
  await request.validate({
12
12
  name: {
13
- rule: schema.string(),
13
+ rule: schema.string().required(),
14
14
  message: {
15
15
  name: 'Name is required',
16
16
  },
17
17
  },
18
18
  description: {
19
- rule: schema.string(),
19
+ rule: schema.string().required(),
20
20
  message: {
21
21
  description: 'Description is required',
22
22
  },
23
23
  },
24
24
 
25
25
  categorizable_type: {
26
- rule: schema.string(),
26
+ rule: schema.string().required(),
27
27
  message: {
28
28
  categorizable_type: 'Categorizable type is required',
29
29
  },
@@ -8,17 +8,23 @@ export default new Action({
8
8
  description: 'Category Update ORM Action',
9
9
  method: 'PATCH',
10
10
  async handle(request: RequestInstance) {
11
+ /*
12
+ * PATCH, so these are NOT required: a partial update sends the fields it
13
+ * means to change. The messages used to say "X is required", which fires on
14
+ * a type failure and never on absence - a message describing a rule the
15
+ * block does not have.
16
+ */
11
17
  await request.validate({
12
18
  name: {
13
19
  rule: schema.string(),
14
20
  message: {
15
- name: 'Name is required',
21
+ name: 'Name must be a string.',
16
22
  },
17
23
  },
18
24
  description: {
19
25
  rule: schema.string(),
20
26
  message: {
21
- description: 'Description is required',
27
+ description: 'Description must be a string.',
22
28
  },
23
29
  },
24
30
  })
@@ -10,13 +10,13 @@ export default new Action({
10
10
  async handle(request: RequestInstance) {
11
11
  await request.validate({
12
12
  title: {
13
- rule: schema.string(),
13
+ rule: schema.string().required(),
14
14
  message: {
15
15
  title: 'Title is required',
16
16
  },
17
17
  },
18
18
  body: {
19
- rule: schema.string(),
19
+ rule: schema.string().required(),
20
20
  message: {
21
21
  body: 'Body is required',
22
22
  },
@@ -8,17 +8,23 @@ export default new Action({
8
8
  description: 'Comment Update ORM Action',
9
9
  method: 'PATCH',
10
10
  async handle(request: RequestInstance) {
11
+ /*
12
+ * PATCH, so these are NOT required: a partial update sends the fields it
13
+ * means to change. The messages used to say "X is required", which fires on
14
+ * a type failure and never on absence - a message describing a rule the
15
+ * block does not have.
16
+ */
11
17
  await request.validate({
12
18
  title: {
13
19
  rule: schema.string(),
14
20
  message: {
15
- title: 'Title is required',
21
+ title: 'Title must be a string.',
16
22
  },
17
23
  },
18
24
  body: {
19
25
  rule: schema.string(),
20
26
  message: {
21
- body: 'Body is required',
27
+ body: 'Body must be a string.',
22
28
  },
23
29
  },
24
30
  })
@@ -8,11 +8,17 @@ export default new Action({
8
8
  description: 'Updates the view count for a post',
9
9
  method: 'PATCH',
10
10
  async handle(request: RequestInstance) {
11
+ /*
12
+ * PATCH, so these are NOT required: a partial update sends the fields it
13
+ * means to change. The messages used to say "X is required", which fires on
14
+ * a type failure and never on absence - a message describing a rule the
15
+ * block does not have.
16
+ */
11
17
  await request.validate({
12
18
  views: {
13
19
  rule: schema.number(),
14
20
  message: {
15
- views: 'Views is required',
21
+ views: 'Views must be a number.',
16
22
  },
17
23
  },
18
24
  })
@@ -10,13 +10,13 @@ export default new Action({
10
10
  async handle(request: RequestInstance) {
11
11
  await request.validate({
12
12
  name: {
13
- rule: schema.string(),
13
+ rule: schema.string().required(),
14
14
  message: {
15
15
  name: 'Name is required',
16
16
  },
17
17
  },
18
18
  description: {
19
- rule: schema.string(),
19
+ rule: schema.string().required(),
20
20
  message: {
21
21
  description: 'Description is required',
22
22
  },
@@ -8,17 +8,23 @@ export default new Action({
8
8
  description: 'Tag Update ORM Action',
9
9
  method: 'PATCH',
10
10
  async handle(request: RequestInstance) {
11
+ /*
12
+ * PATCH, so these are NOT required: a partial update sends the fields it
13
+ * means to change. The messages used to say "X is required", which fires on
14
+ * a type failure and never on absence - a message describing a rule the
15
+ * block does not have.
16
+ */
11
17
  await request.validate({
12
18
  name: {
13
19
  rule: schema.string(),
14
20
  message: {
15
- name: 'Name is required',
21
+ name: 'Name must be a string.',
16
22
  },
17
23
  },
18
24
  description: {
19
25
  rule: schema.string(),
20
26
  message: {
21
- description: 'Description is required',
27
+ description: 'Description must be a string.',
22
28
  },
23
29
  },
24
30
  })
@@ -1,7 +1,7 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { db } from '@stacksjs/database'
3
3
  import { modelBoolean } from './kanban-model'
4
- import { kanbanActionError, kanbanError } from './kanban-response'
4
+ import { kanbanActionError } from './kanban-response'
5
5
 
6
6
  interface BoardRow {
7
7
  id: number
@@ -1,6 +1,6 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { db } from '@stacksjs/database'
3
- import { kanbanActionError, kanbanError } from './kanban-response'
3
+ import { kanbanActionError } from './kanban-response'
4
4
 
5
5
  /**
6
6
  * `GET /api/dashboard/kanban/users`.
@@ -15,7 +15,7 @@ export default new Action({
15
15
  // the request object is optional, but if it is provided, it will be used for validation
16
16
  validations: {
17
17
  message: {
18
- rule: schema.string().min(3).max(255),
18
+ rule: schema.string().min(3).max(255).required(),
19
19
  message: 'The message must be between 3 and 255 characters long.',
20
20
  },
21
21
 
@@ -25,7 +25,7 @@ export default new Action({
25
25
  // is not a function" at module evaluation. Use `schema.enum([...])`
26
26
  // — that's the working enum primitive used throughout the framework
27
27
  // defaults and in the typical project's models.
28
- rule: schema.enum(['info', 'warn', 'error']),
28
+ rule: schema.enum(['info', 'warn', 'error']).required(),
29
29
  message: 'The log level must be one of "info", "warn", or "error".',
30
30
  },
31
31
  },
@@ -1,5 +1,4 @@
1
1
  import { Action } from '@stacksjs/actions'
2
- import { Storage } from '@stacksjs/storage'
3
2
 
4
3
  interface Request {
5
4
  file: (key: string) => any
@@ -1,5 +1,4 @@
1
1
  import { Auth, authCookieName, sessionUser } from '@stacksjs/auth'
2
- import { config } from '@stacksjs/config'
3
2
  import { HttpError } from '@stacksjs/error-handling'
4
3
  import { Middleware } from '@stacksjs/router'
5
4
 
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.72.56",
5
+ "version": "0.72.60",
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.72.56",
5
+ "version": "0.72.60",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -51,7 +51,7 @@
51
51
  "dependencies": {
52
52
  "@iconify-json/f7": "^1.2.2",
53
53
  "@iconify-json/hugeicons": "^1.2.27",
54
- "@stacksjs/mobile": "^0.72.56",
54
+ "@stacksjs/mobile": "^0.72.60",
55
55
  "@stacksjs/sanitizer": "^0.2.113"
56
56
  },
57
57
  "scripts": {
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import process from 'node:process'
19
- import { response, route } from '@stacksjs/router'
19
+ import { route } from '@stacksjs/router'
20
20
 
21
21
  // ============================================================================
22
22
  // Email