@stacksjs/defaults 0.75.72 → 0.75.74

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 (32) hide show
  1. package/ai/skills/stacks-ai/SKILL.md +56 -10
  2. package/ai/skills/stacks-buddy/SKILL.md +4 -4
  3. package/ai/skills/stacks-chat/SKILL.md +11 -5
  4. package/ai/skills/stacks-cloud/SKILL.md +10 -7
  5. package/ai/skills/stacks-email/SKILL.md +5 -3
  6. package/ai/skills/stacks-models/SKILL.md +4 -1
  7. package/ai/skills/stacks-push/SKILL.md +30 -5
  8. package/ai/skills/stacks-queue/SKILL.md +1 -1
  9. package/ai/skills/stacks-realtime/SKILL.md +61 -15
  10. package/ai/skills/stacks-scheduler/SKILL.md +6 -6
  11. package/app/Actions/Auth/RefreshTokenAction.ts +3 -4
  12. package/app/Actions/Dashboard/Buddy/BuddyChatAction.ts +4 -7
  13. package/app/Actions/Dashboard/Buddy/BuddyChatStateAction.ts +2 -4
  14. package/app/Actions/Dashboard/Buddy/buddy-chat.test.ts +23 -8
  15. package/app/Actions/Dashboard/Buddy/buddy-chat.ts +45 -19
  16. package/app/Actions/Dashboard/Settings/mail-settings.ts +19 -10
  17. package/app/Middleware/Auth.ts +9 -11
  18. package/app/Middleware/Cors.ts +50 -14
  19. package/app/Middleware/Csrf.ts +9 -8
  20. package/functions/buddy-chat.ts +7 -2
  21. package/ide/vscode/package.json +1 -1
  22. package/package.json +3 -3
  23. package/resources/components/Dashboard/Buddy/BuddyAssistant.stx +5 -3
  24. package/resources/components/Dashboard/Settings/MailSettingsDashboard.stx +8 -6
  25. package/resources/components/NativeAppShell.stx +20 -0
  26. package/resources/components/NativeComponents.test.ts +21 -0
  27. package/resources/components/NativeNavBar.stx +40 -6
  28. package/resources/components/NativeTabBar.stx +4 -1
  29. package/resources/components/NativeTabItem.stx +4 -3
  30. package/vcs/github/workflows/buddy-bot.yml +2 -2
  31. package/vcs/github/workflows/ci.yml +3 -3
  32. package/vcs/github/workflows/release.yml +1 -1
@@ -21,7 +21,7 @@ ai/src/
21
21
  │ ├── anthropic.ts # Claude driver
22
22
  │ ├── openai.ts # GPT + DALL-E + Whisper + TTS
23
23
  │ ├── ollama.ts # Local LLM driver
24
- │ └── bedrock.ts # AWS Bedrock utilities
24
+ │ └── bedrock/ # Amazon Bedrock, through the Converse API
25
25
  ├── image.ts # Image generation & vision
26
26
  ├── speech.ts # Provider-neutral text-to-speech & speech-to-text
27
27
  ├── search.ts # RAG, embeddings, vector index
@@ -30,7 +30,7 @@ ai/src/
30
30
  ├── buddy.ts # AI coding assistant
31
31
  ├── claude-agent.ts # Claude CLI agent (local & EC2)
32
32
  ├── claude-agent-sdk.ts # Claude Agent SDK driver
33
- └── text.ts # Bedrock text summarization
33
+ └── text.ts # ask() / summarize(), on the Bedrock driver
34
34
  ```
35
35
 
36
36
  ## Anthropic Driver
@@ -38,7 +38,7 @@ ai/src/
38
38
  ```typescript
39
39
  import { anthropic } from '@stacksjs/ai'
40
40
 
41
- anthropic.configure({ apiKey: '...', model: 'claude-sonnet-4-20250514', maxTokens: 4096 })
41
+ anthropic.configure({ apiKey: '...', model: 'claude-sonnet-5-5', maxTokens: 4096 })
42
42
  const result = await anthropic.chat([{ role: 'user', content: 'Hello' }])
43
43
  const stream = await anthropic.streamChat(messages, options)
44
44
  const response = await anthropic.prompt('Summarize this text...')
@@ -59,10 +59,52 @@ const transcription = await openai.transcribe(audioFile)
59
59
  const speech = await openai.textToSpeech('Hello world')
60
60
  ```
61
61
 
62
+ ## Bedrock Driver
63
+
64
+ ```typescript
65
+ import { bedrock } from '@stacksjs/ai'
66
+
67
+ bedrock.configure({ model: 'global.anthropic.claude-sonnet-5-5', region: 'us-east-1' })
68
+ const result = await bedrock.chat([{ role: 'user', content: 'Hello' }], { system: 'Be brief.' })
69
+ ```
70
+
71
+ - Every request goes through Bedrock's Converse API, so one shape serves Nova,
72
+ Claude and Llama; the model is configuration. No streaming yet.
73
+ - Credentials come from the AWS credential chain, never config. Region:
74
+ `drivers.bedrock.region`, `AWS_REGION`, `AWS_DEFAULT_REGION`, us-east-1.
75
+ - Default model `amazon.nova-lite-v1:0`: on demand, no profile, no agreement.
76
+ - Claude and the newer Nova/Llama models are invoked by **inference profile**
77
+ id (`global.anthropic.claude-sonnet-5-5`, `us.meta.llama4-...`). The bare
78
+ `anthropic.claude-...` id is refused by Bedrock. `bedrockModels` in
79
+ `@stacksjs/types` lists the ids Bedrock serves, each in invocable form.
80
+ - `buddy ai:access` (or `requestModelAccess()`) makes the models in
81
+ `config/ai.ts` invocable and reports each one.
82
+
83
+ ## Tool Calls
84
+
85
+ ```typescript
86
+ import { assistantTurn, toolResultsTurn } from '@stacksjs/ai'
87
+
88
+ const result = await client.generate(messages, { tools })
89
+ // result.toolCalls: [{ id, name, arguments }] - same shape from every driver
90
+ messages.push(assistantTurn(result), toolResultsTurn(
91
+ result.toolCalls!.map(call => ({ toolCallId: call.id, name: call.name, content: run(call) })),
92
+ ))
93
+ ```
94
+
95
+ - `content` is the model's text, `''` when it only called tools.
96
+ - The `responseFormat` structured-output tool is internal: its JSON is
97
+ `content`, never a tool call.
98
+ - Ollama refuses `toolChoice: 'required'` / `{ name }`; it cannot force a call.
99
+ - `streamChat()` streams text only.
100
+
62
101
  ## Provider-Neutral Client
63
102
 
64
103
  Use the config-driven client for application features that can run against
65
- Anthropic, OpenAI, or Ollama. Configuration inspection is safe to return from a
104
+ Anthropic, OpenAI, Ollama, or Bedrock. `default` is a provider name or a model
105
+ id that implies one: `claude-...` Anthropic, `gpt-...` OpenAI, any Bedrock
106
+ model or profile id (`amazon.nova-lite-v1:0`, `global.anthropic.claude-...`)
107
+ Bedrock. Configuration inspection is safe to return from a
66
108
  status endpoint because it never includes credentials.
67
109
 
68
110
  ```typescript
@@ -216,7 +258,7 @@ const sentiment = await analyzeSentiment('I love this product!')
216
258
  const classification = await classifyText('Fix the login bug', ['bug', 'feature', 'question'])
217
259
  // { label: 'bug', confidence: 0.92, allLabels: [...] }
218
260
 
219
- const summary = await summarize(longText, { maxLength: 100, style: 'bullet' })
261
+ const summary = await summarize(longText, { maxTokenCount: 100 })
220
262
 
221
263
  const profile = createProfile('user-123', ['tech', 'gaming'])
222
264
  recordInteraction(profile, { type: 'view', itemId: 'article-1', weight: 1.0 })
@@ -229,7 +271,7 @@ const interests = await extractUserInterests(profile, items)
229
271
  ```typescript
230
272
  import { processCommand, buddyProcessStreaming, buddyStreamSimple, getAvailableDrivers } from '@stacksjs/ai'
231
273
 
232
- const drivers = getAvailableDrivers() // ['anthropic', 'openai', 'ollama']
274
+ const drivers = getAvailableDrivers() // ['claude-cli-local', 'claude-cli-ec2', 'claude', 'claude-sdk', 'openai', 'ollama', 'mock']
233
275
  const context = await getRepoContext('/path/to/repo')
234
276
  await processCommand('Add error handling to auth.ts', 'anthropic')
235
277
  // Streaming
@@ -298,9 +340,13 @@ await driver.resumeSession(sessionId, 'Add validation')
298
340
 
299
341
  ```typescript
300
342
  {
301
- default: 'meta.llama2-70b-chat-v1',
302
- models: ['meta.llama2-70b-chat-v1', ...], // AWS Bedrock models
303
- deploy: true
343
+ default: 'anthropic', // or 'openai', 'ollama', 'bedrock', a model id
344
+ models: ['amazon.nova-lite-v1:0'], // Bedrock models `buddy ai:access` enables
345
+ deploy: false,
346
+ drivers: {
347
+ anthropic: { model: 'claude-sonnet-5-5' },
348
+ bedrock: { model: 'global.anthropic.claude-sonnet-5-5' },
349
+ },
304
350
  }
305
351
  ```
306
352
 
@@ -314,5 +360,5 @@ await driver.resumeSession(sessionId, 'Add validation')
314
360
  - MCP supports stdio (subprocess), SSE, and HTTP transports
315
361
  - The buddy assistant has git integration (commit, push, apply changes)
316
362
  - Claude Agent SDK wraps the Claude Code CLI for agentic workflows
317
- - Bedrock utilities are for AWS-hosted model invocation
363
+ - A Bedrock id as `default` selects the Bedrock driver; it never reaches api.anthropic.com
318
364
  - Sentiment/classification use AI models — they're not rule-based
@@ -426,14 +426,14 @@ buddy cloud --diff # show infrastructure changes (local vs deployed
426
426
  buddy cloud --invalidate-cache # invalidate CloudFront CDN cache
427
427
  --paths [paths] # specific paths to invalidate (default: /*)
428
428
 
429
- buddy cloud:add --jump-box # add jump box EC2 instance
429
+ buddy cloud:add --jump-box # check for the jump box infrastructure.jumpBox declares (buddy deploy creates it)
430
430
  buddy cloud:remove # remove cloud infrastructure (aliases: cloud:destroy, cloud:rm, undeploy)
431
- --jump-box # remove just the jump-box
431
+ --jump-box # check for the jump box, say how to drop it from the stack
432
432
  --force # force deletion
433
433
  --yes # skip confirmation
434
- buddy cloud:optimize-cost # remove optional resources (jump-box)
434
+ buddy cloud:optimize-cost # check optional resources (jump-box) that can be dropped
435
435
  buddy cloud:cleanup # clean up retained resources after stack deletion
436
- # Cleans: jump-boxes, S3 buckets, Lambda functions, CloudWatch logs,
436
+ # Cleans: S3 buckets, Lambda functions, CloudWatch logs,
437
437
  # Parameter Store, VPCs, Subnets, CDK remnants, IAM users
438
438
  buddy cloud:invalidate-cache # invalidate CloudFront cache
439
439
  --paths [paths] # paths to invalidate
@@ -43,7 +43,13 @@ await sendToDiscord(webhookUrl, content, options?)
43
43
  await sendToTeams(webhookUrl, text)
44
44
  ```
45
45
 
46
- Configure functions:
46
+ Settings come from `config/services.ts` by default, read on each send after the
47
+ app's config has loaded: `SLACK_WEBHOOK_URL` / `SLACK_BOT_TOKEN`,
48
+ `DISCORD_WEBHOOK_URL` / `DISCORD_BOT_TOKEN`, `TEAMS_WEBHOOK_URL`, and each
49
+ driver's `*_MAX_RETRIES` / `*_RETRY_TIMEOUT`. A webhook URL from config passes
50
+ the same HTTPS and host checks as one given to `configure*()`.
51
+
52
+ The configure functions override config, for an app that keeps these elsewhere:
47
53
  ```typescript
48
54
  import { configureSlack, configureDiscord, configureTeams } from '@stacksjs/chat'
49
55
  configureSlack({ webhookUrl: '...', botToken: '...' })
@@ -324,9 +330,9 @@ interface TeamsMessage {
324
330
  ### Teams Sending
325
331
 
326
332
  - Uses `config.webhookUrl` or falls back to `message.to` as the webhook URL
327
- - Validates URL contains `webhook.office.com`
328
- - Simple messages (no subject/template): sends `{ type: 'message', text: content }`
329
- - Rich messages (with subject or template): builds an Adaptive Card v1.4 with:
333
+ - Validates the URL as a Teams Workflows (Power Automate) webhook on https: `*.environment.api.powerplatform.com`, `*.api.powerautomate.com`, `flow.microsoft.com` or `*.logic.azure.com` (`.us` / `.cn` too). An Office 365 connector URL (`*.webhook.office.com`) is refused with a migration message - Microsoft retired those connectors in May 2026
334
+ - Every message is an Adaptive Card in `attachments`, which the Workflows webhook template requires; a plain message is a card holding its text
335
+ - Rich messages (with subject or template): the Adaptive Card v1.4 has:
330
336
  - Title as `TextBlock` (size: Large, weight: Bolder)
331
337
  - Content as `TextBlock` (wrap: true)
332
338
  - Timestamp as `TextBlock` (size: Small, color: Dark)
@@ -436,7 +442,7 @@ All three drivers implement `sendWithRetry()`:
436
442
  - Slack bot token mode uses `https://slack.com/api/chat.postMessage`, not a webhook
437
443
  - Discord webhook returns `204` on success with no body -- this is handled as success
438
444
  - Discord bot token mode uses `message.to` as the channel ID
439
- - Teams validates that the webhook URL contains `webhook.office.com`
445
+ - Teams accepts only Workflows webhook URLs; the retired `webhook.office.com` connectors are refused
440
446
  - Teams uses `message.to` as fallback webhook URL if `config.webhookUrl` is not set
441
447
  - All drivers set `mrkdwn: true` or equivalent by default
442
448
  - Block Kit blocks are only built when `message.template` is set (Slack) or when `message.subject`/`message.template` is set (Discord/Teams)
@@ -157,15 +157,15 @@ await purchaseDomain('example.com', { years: 1, privacy: true, autoRenew: true }
157
157
 
158
158
  // Infrastructure queries
159
159
  const sgId = await getSecurityGroupId('my-sg')
160
- const jumpBoxId = await getJumpBoxInstanceId('stack-name')
160
+ const jumpBoxId = await getJumpBoxInstanceId() // the deployed stack's JumpBoxInstanceId output
161
161
  const deployed = await hasBeenDeployed()
162
162
  const firstDeploy = await isFirstDeployment()
163
163
  const failed = await isFailedState()
164
164
 
165
- // Resource management
166
- await addJumpBox('stack-name')
167
- await deleteJumpBox('stack-name')
168
- await deleteEc2Instance(instanceId, 'stack-name')
165
+ // The jump box is declared by infrastructure.jumpBox and created by `buddy deploy`;
166
+ // these report the stack's state and what to change, never create or terminate.
167
+ await addJumpBox()
168
+ await deleteJumpBox()
169
169
 
170
170
  // Cleanup
171
171
  await deleteStacksBuckets()
@@ -236,7 +236,7 @@ buddy server:setup # adopt and bootstrap an ssh host
236
236
  buddy cloud --diff # show infrastructure changes (aws)
237
237
  buddy cloud --ssh # SSH into cloud
238
238
  buddy cloud --invalidate-cache # invalidate CDN
239
- buddy cloud:add --jump-box # add jump box instance
239
+ buddy cloud:add --jump-box # check for the jump box (declare infrastructure.jumpBox + buddy deploy)
240
240
  buddy cloud:remove --force # destroy cloud resources
241
241
  buddy cloud:cleanup # clean retained resources
242
242
  buddy cloud:optimize-cost # remove optional resources
@@ -292,7 +292,10 @@ Stack naming: `{slugified-app-name}-cloud`
292
292
  - Server mode uses EC2 + ALB; serverless uses Lambda + API Gateway + CloudFront
293
293
  - Origin failover only retries GET, HEAD and OPTIONS on a cache miss; it does not protect writes,
294
294
  and the failover replica outlives `buddy cloud:remove`
295
- - Jump boxes are optional, used for SSH access to private instances
295
+ - Jump boxes are optional, used for SSH access to private instances. They are stack resources:
296
+ `infrastructure.jumpBox` in config/cloud.ts plus `buddy deploy`, found by the stack's
297
+ `JumpBoxInstanceId` output. The stack is `stacksCloudName()` (`<project.name>-cloud`) in
298
+ `stacksCloudRegion()` (AWS_REGION, else the configured region)
296
299
  - `cloud:remove` with `--force` skips confirmation and is destructive
297
300
  - CDK toolkit stack is named `stacks-toolkit`
298
301
  - Environment mapping: `local` → `development`, others preserved
@@ -164,9 +164,11 @@ await deleteEmail('chris', messageId)
164
164
  - Configurable domain and endpoint
165
165
 
166
166
  ### Mailtrap Driver
167
- - Inbox-aware sending
168
- - Sandbox and production modes
169
- - Host default: `sandbox.api.mailtrap.io`
167
+ - `MAILTRAP_INBOX_ID` picks the API: set, mail is captured in that sandbox
168
+ inbox (`sandbox.api.mailtrap.io/api/send/<inbox>`); unset, it is delivered
169
+ through the Sending API (`send.api.mailtrap.io/api/send`)
170
+ - `MAILTRAP_HOST` overrides the host for either; blank means Mailtrap's own
171
+ - A 4xx other than 429 is not retried
170
172
 
171
173
  ### SMTP Driver
172
174
  - Raw TCP/TLS socket connection
@@ -296,7 +296,10 @@ buddy seed --include-defaults # framework built-ins too
296
296
 
297
297
  A model with no `useSeeder` trait is never seeded. Auth and OAuth models are
298
298
  skipped on a non-fresh database so re-seeding cannot invalidate live sessions -
299
- pass `--allow-protected` to override.
299
+ pass `--allow-protected` to override. A `belongsTo` to `User`, `Team` or `Customer` is
300
+ filled only from accounts the same run seeded (never a pre-existing, possibly real one);
301
+ `--attach-accounts` lifts that on a scratch database. Composite `unique: true` indexes are
302
+ honoured: fewer rows are seeded rather than a duplicate combination.
300
303
 
301
304
  ## Built-in models by category
302
305
 
@@ -21,7 +21,7 @@ no longer a way to configure this driver and there is no fallback to it.
21
21
  ## Source Files
22
22
  ```
23
23
  push/src/
24
- ├── index.ts # send(), configureFCM(), PushNotification type
24
+ ├── index.ts # send(), configureFCM(), configureExpo(), PushNotification type
25
25
  └── drivers/
26
26
  ├── index.ts # re-exports expo and fcm
27
27
  ├── expo.ts # Expo Push Service driver
@@ -204,6 +204,8 @@ interface FCMMessage {
204
204
  function configure(options: FCMConfig): void
205
205
  ```
206
206
  Sets module-level config. Called by `configureFCM()` from the main index.
207
+ Optional: anything it does not set is read from `config/services.ts` on each
208
+ send (see Configuration), and anything it does set wins.
207
209
 
208
210
  #### send()
209
211
  ```typescript
@@ -273,10 +275,30 @@ unsubscribing; that asymmetry is the API's.
273
275
 
274
276
  ## Configuration
275
277
 
278
+ Set the env vars `config/services.ts` reads and both drivers pick them up on the
279
+ next send. No code is needed:
280
+
281
+ ```bash
282
+ FCM_PROJECT_ID=my-firebase-project
283
+ FCM_CLIENT_EMAIL=firebase@project.iam.gserviceaccount.com
284
+ # Paste the key from the service account JSON as-is: its literal \n escapes
285
+ # are turned back into newlines.
286
+ FCM_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
287
+
288
+ # Only needed once "enhanced push security" is on for the Expo project:
289
+ # Expo then rejects every send and receipt request without it.
290
+ EXPO_ACCESS_TOKEN=...
291
+ ```
292
+
293
+ The values are read lazily, after the app's config has loaded, never at
294
+ import - a value captured at import would be the framework default.
295
+
296
+ `configureFCM()` / `configureExpo()` override config, for an app that keeps
297
+ credentials somewhere else:
298
+
276
299
  ```typescript
277
- import { configureFCM } from '@stacksjs/push'
300
+ import { configureExpo, configureFCM } from '@stacksjs/push'
278
301
 
279
- // FCM v1 API: the only FCM configuration there is
280
302
  configureFCM({
281
303
  projectId: 'my-firebase-project',
282
304
  serviceAccount: {
@@ -285,10 +307,13 @@ configureFCM({
285
307
  }
286
308
  })
287
309
 
288
-
289
- // Expo requires no configuration
310
+ configureExpo({ accessToken: '...' })
290
311
  ```
291
312
 
313
+ FCM access tokens are cached per service account until a minute before they
314
+ expire, so a multicast exchanges one token, not one per device.
315
+ `fcm.resetConfiguration()` drops both the configured credentials and the cache.
316
+
292
317
  ## Usage Examples
293
318
 
294
319
  ```typescript
@@ -101,7 +101,7 @@ The `job()` helper loads job modules from `app/Jobs/{name}.ts` via `runJob()`.
101
101
 
102
102
  ### runJob(name, options)
103
103
  Dynamically imports `app/Jobs/{name}.ts` and executes:
104
- 1. `jobConfig.handle(payload)` if handle is a function
104
+ 1. `jobConfig.handle(payload, context)` if handle is a function (`context` is what `.withContext()` attached, on every driver)
105
105
  2. `runAction(jobConfig.action)` if action is a string
106
106
  3. `jobConfig.action()` if action is a function
107
107
  4. `jobConfig(payload, context)` if default export is a function
@@ -22,7 +22,8 @@ realtime/src/
22
22
  emit.ts # emit(), emitToUser(), emitToUsers()
23
23
  channel.ts # Channel class + channel() factory
24
24
  broadcast.ts # Broadcast class + runBroadcast() + broadcast()
25
- ws.ts # WebSocket request handler (legacy, kept for backward compat)
25
+ ws.ts # setWsAuthenticator() - gates upgrades on the broadcast server
26
+ replay-buffer.ts # setReplayBuffer() / replaySince() - per-channel replay with seq numbers
26
27
  server-instance.ts # server lifecycle (createServer/getServer/setServer/stopServer)
27
28
  ```
28
29
 
@@ -43,7 +44,9 @@ export type { EmitOptions } from './emit'
43
44
  export { channel as createChannel, Channel as StacksChannel } from './channel'
44
45
  export { broadcast as dispatchBroadcast, runBroadcast, Broadcast as LegacyBroadcast } from './broadcast'
45
46
  export type { BroadcastInstance } from './broadcast'
46
- export { setBunSocket, handleWebSocketRequest, storeWebSocketEvent } from './ws'
47
+ export { debugSnapshot, getReplayBuffer, pruneExpired, recordBroadcast, replaySince, setReplayBuffer } from './replay-buffer'
48
+ export { storeWebSocketEvent, setWsAuthenticator, getWsAuthenticator } from './ws'
49
+ export type { WsAuthenticator, WsAuthResult } from './ws'
47
50
  ```
48
51
 
49
52
  Note the renamed exports:
@@ -72,9 +75,9 @@ emit('orders', 'updated', { status: 'shipped' }, { private: true })
72
75
  // Broadcast to a presence channel (auto-prefixes 'presence-')
73
76
  emit('room-1', 'user-joined', { userId: 42 }, { presence: true })
74
77
 
75
- // Exclude specific socket(s) from receiving
76
- // NOTE: BroadcastServer only supports single socket exclusion;
77
- // if array is passed, only the first element is used
78
+ // Exclude specific socket(s) from receiving - socket IDs (the `socket_id`
79
+ // a client gets in `connection_established`), not user IDs. Every ID in an
80
+ // array is excluded.
78
81
  emit('chat', 'typing', data, { exclude: 'socket-id-1' })
79
82
  emit('chat', 'typing', data, { exclude: ['socket-id-1', 'socket-id-2'] })
80
83
  ```
@@ -85,7 +88,7 @@ emit('chat', 'typing', data, { exclude: ['socket-id-1', 'socket-id-2'] })
85
88
  interface EmitOptions {
86
89
  private?: boolean // prefix channel with 'private-'
87
90
  presence?: boolean // prefix channel with 'presence-'
88
- exclude?: string | string[] // socket IDs to exclude (only first used)
91
+ exclude?: string | string[] // socket IDs to exclude (all of them)
89
92
  driver?: string // broadcast driver override
90
93
  }
91
94
  ```
@@ -185,7 +188,53 @@ setServer(server)
185
188
  await stopServer()
186
189
  ```
187
190
 
188
- `createServer()` dynamically imports `ts-broadcasting`, instantiates `BroadcastServer`, calls `start()`, stores the instance via `setServer()`, and returns it.
191
+ `createServer()` dynamically imports `ts-broadcasting`, instantiates `BroadcastServer`, calls `start()`, stores the instance via `setServer()`, and returns it. That server is the WebSocket endpoint (`/app` and `/ws` on its own host/port); there is no upgrade handler for the app's HTTP server.
192
+
193
+ ### Connection auth
194
+
195
+ ```typescript
196
+ import { setWsAuthenticator } from '@stacksjs/realtime'
197
+
198
+ // Read on every upgrade, so it applies whether installed before or after createServer().
199
+ setWsAuthenticator(async (req) => {
200
+ const token = new URL(req.url).searchParams.get('token')
201
+ const user = token ? await verify(token) : null
202
+ if (!user) return { ok: false, status: 401, message: 'Unauthorized' }
203
+ // user -> socket.data.user, data -> socket.data.data (what channel authorizers read)
204
+ return { ok: true, user: { id: user.id }, data: { team: user.team } }
205
+ })
206
+ ```
207
+
208
+ A refusal answers the upgrade with its status; a throw refuses with a 500 and is logged, not sent. An `authorizeConnection` in the `createServer()` config still runs after it. Per-channel access is still `Broadcast.channel('private-orders.{id}', (socket, params) => ...)`.
209
+
210
+ `/stats` and `/metrics` are off unless `createServer({ endpoints: { stats: true, metrics: true, token } })`.
211
+
212
+ ### Dead sockets and slow consumers
213
+
214
+ Bun's own WebSocket options, passed through `createServer()`:
215
+
216
+ ```typescript
217
+ await createServer({
218
+ host: '0.0.0.0',
219
+ port: 6001,
220
+ websocket: {
221
+ idleTimeout: 60, // Bun pings idle sockets; no pong within this -> closed (rounded up to 4s steps)
222
+ sendPings: true,
223
+ backpressureLimit: 1024 * 1024,
224
+ closeOnBackpressureLimit: true, // drop a consumer that falls 1 MB behind
225
+ },
226
+ })
227
+ ```
228
+
229
+ ### Replay buffer
230
+
231
+ ```typescript
232
+ import { replaySince, setReplayBuffer } from '@stacksjs/realtime'
233
+
234
+ setReplayBuffer({ channels: ['orders.*'], maxPerChannel: 100, ttlMs: 5 * 60_000 })
235
+ ```
236
+
237
+ Every broadcast on a matching channel, from any API, is recorded and reaches clients as `{ event, channel, data, seq }`. Patterns match with or without the `private-`/`presence-` prefix (`orders.*` covers `private-orders.1`); `replaySince()` takes the full name. The client keeps the last `seq` per channel and sends it back after reconnecting (through a route of the app's), which answers with `replaySince('private-orders.1', seq)`. The buffer and its numbering are per process.
189
238
 
190
239
  ---
191
240
 
@@ -270,13 +319,10 @@ class Broadcast {
270
319
  }
271
320
  ```
272
321
 
273
- ### WebSocket handler (ws.ts)
322
+ ### ws.ts
274
323
 
275
324
  ```typescript
276
- // Deprecated - handled by ts-broadcasting internally
277
- setBunSocket(server: BroadcastServer | null): void // calls setServer()
278
- storeWebSocketEvent(type, socket, details): Promise<void> // no-op
279
- handleWebSocketRequest(req, server): Promise<Response | undefined> // delegates to server.upgrade()
325
+ storeWebSocketEvent(type, socket, details): Promise<void> // no-op, kept for compatibility
280
326
  ```
281
327
 
282
328
  ---
@@ -430,11 +476,11 @@ export default {
430
476
  - Private channels auto-prefix `private-` -- don't add it yourself (the code checks `channel.startsWith('private-')`)
431
477
  - Presence channels auto-prefix `presence-` -- same logic applies
432
478
  - `emitToUser()` sends to `private-user.{userId}` -- users must subscribe to this channel pattern
433
- - When using `emit()` with `exclude`, only the first socket ID in an array is used (BroadcastServer limitation)
479
+ - `emit()`'s `exclude` takes socket IDs, not user IDs; every ID in an array is excluded
434
480
  - Broadcast files are dynamically imported from `app/Broadcasts/` using `bun.globSync`
435
481
  - The `Broadcast` class is legacy -- new code should use `emit()` and `channel()` directly
436
- - `handleWebSocketRequest()` and `storeWebSocketEvent()` are deprecated -- ws events are tracked internally by ts-broadcasting
437
- - `setBunSocket()` is deprecated -- use `setServer()` instead
482
+ - There is no `handleWebSocketRequest()`: sockets upgraded on the app's own server were never part of the broadcast server, so they could not join channels. Clients connect to the broadcast server's `/ws`
483
+ - There is no Stacks heartbeat or backpressure guard; use `createServer({ websocket: { idleTimeout, sendPings, backpressureLimit, closeOnBackpressureLimit } })`
438
484
  - Serverless mode uses API Gateway WebSocket for AWS Lambda with DynamoDB for connection management
439
485
  - Rate limiting defaults: 100 connections per IP, 50 messages/second, 64KB max payload, 300s ban duration
440
486
  - Redis adapter enables horizontal scaling across multiple server instances
@@ -89,11 +89,11 @@ Each sets an internal cron pattern. Once a timing method is called, the schedule
89
89
  setTimeZone(timezone: Timezone): this
90
90
  withErrorHandler(handler: CatchCallbackFn): this
91
91
  withMaxRuns(runs: number): this
92
- withProtection(callback?: (job: ScheduledJob) => void): this
92
+ withProtection(callback?: (job: ScheduledJob) => void): this // skip a tick while the last run is still going (this process); callback hears each skip
93
93
  withName(name: string): this
94
- withContext(context: any): this
95
- withInterval(seconds: number): this
96
- between(startAt: string | Date, stopAt: string | Date): this
94
+ withContext(context: any): this // passed to the task on each run: the callback's argument, or the job's `context`
95
+ withInterval(seconds: number): this // at least this many seconds between runs; sooner ticks are skipped
96
+ between(startAt: string | Date, stopAt: string | Date): this // only run inside the window; stops for good once it closes
97
97
  withoutOverlapping(expiresAfterMinutes?: number): this
98
98
  onOneServer(): this
99
99
  runInBackground(): this
@@ -132,7 +132,7 @@ When `withoutOverlapping()` or `onOneServer()` is called:
132
132
 
133
133
  ### Background Execution
134
134
 
135
- When `runInBackground()` is called, the task is spawned as a detached child process via `node:child_process.spawn`. The child is `unref()`'d so it does not keep the parent alive.
135
+ When `runInBackground()` is called, each run starts `buddy schedule:run-one <name> --in-process` as a detached child in the project directory. That process registers the app's schedule the way `schedule:run` does and runs the one task there, taking any `withoutOverlapping()` / `onOneServer()` lock itself. The child is `unref()`'d so it does not keep the parent alive, and a non-zero exit is logged under the task's name. The task needs a name (`schedule.job/action/command` have one; a callback needs `.withName()`), or registration fails.
136
136
 
137
137
  ## Helper Functions
138
138
 
@@ -351,7 +351,7 @@ export default {
351
351
  - The `Queue` class in `schedule.ts` is just an empty subclass of `Schedule` (`export class Queue extends Schedule {}`) -- it adds no functionality
352
352
  - `withoutOverlapping()` uses file-based locks in `storage/framework/locks/` -- this only prevents overlap within a single machine, not across a cluster
353
353
  - `onOneServer()` also uses file-based locks (same as `withoutOverlapping`), so it does not actually coordinate across multiple servers
354
- - `runInBackground()` spawns a detached child process with `spawn(process.execPath, ['-e', ...])` -- the task function is `.toString()`'d and eval'd, so closures over external variables will not work
354
+ - `runInBackground()` runs the task by name in a `buddy schedule:run-one <name> --in-process` child, so the task must be registered under that name by the app's scheduler file, and an unnamed callback is refused
355
355
  - There are TWO scheduler systems: `@stacksjs/scheduler` (fluent API in `schedule.ts`) and the queue-level scheduler in `@stacksjs/queue` (`queue/src/scheduler.ts`). The former runs tasks in-process; the latter dispatches to the queue
356
356
  - `sendAt()` throws on invalid cron expressions (it delegates to `parse()` which throws)
357
357
  - `timeout()` returns `-1` (not `0` or `Infinity`) when there is no upcoming run
@@ -20,10 +20,9 @@ export default new Action({
20
20
  })
21
21
 
22
22
  try {
23
- const result = await refreshToken(refreshTokenValue, {
24
- expiresInMinutes: 60, // 1 hour access token
25
- refreshExpiresInDays: 30, // 30 day refresh token
26
- })
23
+ // The lifetimes config/auth.ts sets (tokenExpiry, refreshTokenExpiry).
24
+ // Hard-coding an hour and 30 days here overrode them on every refresh.
25
+ const result = await refreshToken(refreshTokenValue)
27
26
 
28
27
  // Rotation invalidates the token the cookie was carrying, so a cookie
29
28
  // left untouched here would go stale at the exact moment the session was
@@ -5,7 +5,7 @@ import { buddyState, createAIClient } from '@stacksjs/ai'
5
5
  import { config } from '@stacksjs/config'
6
6
  import { response } from '@stacksjs/router'
7
7
  import { dashboardOperationalError } from '../dashboard-response'
8
- import { buddySystemPrompt, isBuddyProviderConfigured, publicBuddyHistory, resolveBuddyProvider } from './buddy-chat'
8
+ import { buddyProviderStatus, buddySystemPrompt, publicBuddyHistory } from './buddy-chat'
9
9
 
10
10
  export default new Action({
11
11
  name: 'BuddyChatAction',
@@ -21,12 +21,9 @@ export default new Action({
21
21
  return response.json({ message: 'Question must be 4,000 characters or fewer.' }, 422)
22
22
 
23
23
  const aiConfig = (config.ai || {}) as ConfiguredAIOptions
24
- const provider = resolveBuddyProvider(aiConfig)
25
- if (!isBuddyProviderConfigured(provider, aiConfig, process.env)) {
26
- return response.json({
27
- message: `${provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY'} is not configured.`,
28
- }, 503)
29
- }
24
+ const { provider, configured, problem } = buddyProviderStatus(aiConfig)
25
+ if (!provider || !configured)
26
+ return response.json({ message: problem }, 503)
30
27
 
31
28
  try {
32
29
  const history = publicBuddyHistory(buddyState.getState().conversationHistory, 20)
@@ -2,7 +2,7 @@ import type { ConfiguredAIOptions } from '@stacksjs/ai'
2
2
  import { Action } from '@stacksjs/actions/runtime'
3
3
  import { buddyState } from '@stacksjs/ai'
4
4
  import { config } from '@stacksjs/config'
5
- import { publicBuddyHistory, isBuddyProviderConfigured, resolveBuddyProvider } from './buddy-chat'
5
+ import { buddyProviderStatus, publicBuddyHistory } from './buddy-chat'
6
6
 
7
7
  export default new Action({
8
8
  name: 'BuddyChatStateAction',
@@ -12,12 +12,10 @@ export default new Action({
12
12
 
13
13
  async handle() {
14
14
  const aiConfig = (config.ai || {}) as ConfiguredAIOptions
15
- const provider = resolveBuddyProvider(aiConfig)
16
15
  const state = buddyState.getState()
17
16
 
18
17
  return {
19
- provider,
20
- configured: isBuddyProviderConfigured(provider, aiConfig, process.env),
18
+ ...buddyProviderStatus(aiConfig),
21
19
  history: publicBuddyHistory(state.conversationHistory),
22
20
  repository: state.repo
23
21
  ? {
@@ -1,17 +1,32 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { buddySystemPrompt, isBuddyProviderConfigured, publicBuddyHistory, resolveBuddyProvider } from './buddy-chat'
2
+ import { buddyProviderStatus, buddySystemPrompt, publicBuddyHistory } from './buddy-chat'
3
3
 
4
4
  describe('dashboard Buddy chat', () => {
5
- test('normalizes the configured provider', () => {
6
- expect(resolveBuddyProvider({ default: 'anthropic.claude-sonnet' })).toBe('anthropic')
7
- expect(resolveBuddyProvider({ default: 'ollama' })).toBe('ollama')
8
- expect(resolveBuddyProvider({ default: 'gpt-5' })).toBe('openai')
5
+ test('resolves the provider the way createAIClient() does', () => {
6
+ expect(buddyProviderStatus({ default: 'claude-sonnet-5-5' }, { ANTHROPIC_API_KEY: 'k' }).provider).toBe('anthropic')
7
+ expect(buddyProviderStatus({ default: 'ollama' }, {}).provider).toBe('ollama')
8
+ expect(buddyProviderStatus({ default: 'gpt-5' }, { OPENAI_API_KEY: 'k' }).provider).toBe('openai')
9
+ expect(buddyProviderStatus({}, { OPENAI_API_KEY: 'k' }).provider).toBe('openai')
10
+ // A Bedrock id is Bedrock, not the first-party Anthropic API, and not
11
+ // OpenAI, which is where Buddy's own copy of this sent it.
12
+ expect(buddyProviderStatus({ default: 'global.anthropic.claude-sonnet-5-5' }, {}).provider).toBe('bedrock')
13
+ expect(buddyProviderStatus({ default: 'bedrock' }, {})).toEqual({ provider: 'bedrock', configured: true, problem: null, missingKey: null })
9
14
  })
10
15
 
11
16
  test('reports provider readiness without exposing keys', () => {
12
- expect(isBuddyProviderConfigured('openai', {}, { OPENAI_API_KEY: 'configured' })).toBe(true)
13
- expect(isBuddyProviderConfigured('anthropic', {}, {})).toBe(false)
14
- expect(isBuddyProviderConfigured('ollama', {}, {})).toBe(true)
17
+ expect(buddyProviderStatus({ default: 'openai' }, { OPENAI_API_KEY: 'configured' }).configured).toBe(true)
18
+ expect(buddyProviderStatus({ default: 'anthropic' }, {}))
19
+ .toEqual({ provider: 'anthropic', configured: false, problem: 'ANTHROPIC_API_KEY is not configured.', missingKey: 'ANTHROPIC_API_KEY' })
20
+ expect(buddyProviderStatus({ default: 'ollama' }, {}).configured).toBe(true)
21
+ // A provider reached through a base URL needs no key.
22
+ expect(buddyProviderStatus({ default: 'openai', drivers: { openai: { baseUrl: 'http://localhost:1234/v1' } } }, {}).configured).toBe(true)
23
+ })
24
+
25
+ test('names an unsupported default instead of answering with OpenAI', () => {
26
+ const status = buddyProviderStatus({ default: 'mistral-large' }, { OPENAI_API_KEY: 'k' })
27
+ expect(status.provider).toBeNull()
28
+ expect(status.configured).toBe(false)
29
+ expect(status.problem).toContain('Unsupported configured AI driver: mistral-large')
15
30
  })
16
31
 
17
32
  test('only returns public text history', () => {