@stacksjs/defaults 0.75.41 → 0.75.43

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 (98) hide show
  1. package/ai/skills/stacks-actions/SKILL.md +1 -1
  2. package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
  3. package/ai/skills/stacks-chat/SKILL.md +51 -1
  4. package/ai/skills/stacks-cloud/SKILL.md +36 -0
  5. package/ai/skills/stacks-dashboard/SKILL.md +16 -2
  6. package/ai/skills/stacks-database/SKILL.md +24 -3
  7. package/ai/skills/stacks-deploy/SKILL.md +7 -1
  8. package/ai/skills/stacks-desktop/SKILL.md +10 -0
  9. package/ai/skills/stacks-development/SKILL.md +1 -1
  10. package/ai/skills/stacks-migrations/SKILL.md +2 -2
  11. package/ai/skills/stacks-models/SKILL.md +38 -2
  12. package/ai/skills/stacks-orm/SKILL.md +2 -2
  13. package/ai/skills/stacks-security/SKILL.md +28 -1
  14. package/app/Actions/Auth/DataExportAction.test.ts +84 -0
  15. package/app/Actions/Auth/DataExportAction.ts +40 -0
  16. package/app/Actions/Dashboard/Content/FileReprocessAction.ts +1 -1
  17. package/app/Actions/Dashboard/Content/file-manager.ts +120 -8
  18. package/app/Actions/Dashboard/Content/file-metadata.ts +108 -12
  19. package/app/Actions/Dashboard/Content/file-pipeline.test.ts +13 -9
  20. package/app/Actions/Dashboard/Content/file-preview.test.ts +487 -0
  21. package/app/Actions/Dashboard/Content/file-preview.ts +548 -0
  22. package/app/Jobs/GenerateStoragePreviewJob.ts +47 -0
  23. package/app/Jobs/OptimizeStorageImageJob.ts +7 -3
  24. package/app/Jobs/PruneRetainedDataJob.ts +46 -0
  25. package/app/Models/Activity.ts +2 -0
  26. package/app/Models/CampaignSend.ts +2 -0
  27. package/app/Models/Card.ts +1 -0
  28. package/app/Models/CardComment.ts +1 -0
  29. package/app/Models/Comment.ts +5 -0
  30. package/app/Models/CommunicationSuppression.ts +2 -1
  31. package/app/Models/ConsentEvent.ts +4 -3
  32. package/app/Models/Content/Author.ts +5 -0
  33. package/app/Models/EmailListSubscriber.ts +1 -0
  34. package/app/Models/EmailSuppression.ts +2 -0
  35. package/app/Models/Forms/FormSubmission.ts +5 -0
  36. package/app/Models/GdprRequest.ts +77 -0
  37. package/app/Models/MagicLinkToken.ts +2 -0
  38. package/app/Models/Notification.ts +2 -0
  39. package/app/Models/NotificationDelivery.ts +4 -0
  40. package/app/Models/PaymentMethod.ts +5 -0
  41. package/app/Models/PaymentTransaction.ts +1 -0
  42. package/app/Models/PersonalAccessToken.ts +3 -0
  43. package/app/Models/Referral.ts +4 -1
  44. package/app/Models/ReferralCode.ts +4 -1
  45. package/app/Models/Request.ts +3 -0
  46. package/app/Models/SmsOptOut.ts +2 -0
  47. package/app/Models/SocialAccount.ts +3 -0
  48. package/app/Models/SocialPost.ts +2 -0
  49. package/app/Models/StorageItemTask.ts +14 -8
  50. package/app/Models/Subscriber.ts +2 -0
  51. package/app/Models/SubscriberEmail.ts +2 -0
  52. package/app/Models/Subscription.ts +1 -0
  53. package/app/Models/TeamInvitation.ts +2 -0
  54. package/app/Models/TeamMember.ts +1 -0
  55. package/app/Models/User.ts +5 -0
  56. package/app/Models/commerce/Bid.ts +3 -0
  57. package/app/Models/commerce/Cart.ts +2 -0
  58. package/app/Models/commerce/Courier.ts +7 -0
  59. package/app/Models/commerce/CourierPing.ts +3 -0
  60. package/app/Models/commerce/Customer.ts +5 -0
  61. package/app/Models/commerce/DeliveryStop.ts +7 -0
  62. package/app/Models/commerce/GiftCard.ts +4 -0
  63. package/app/Models/commerce/Order.ts +5 -0
  64. package/app/Models/commerce/OrderItem.ts +2 -0
  65. package/app/Models/commerce/Payment.ts +5 -0
  66. package/app/Models/commerce/Pledge.ts +3 -0
  67. package/app/Models/commerce/Transaction.ts +2 -0
  68. package/app/Models/commerce/WaitlistProduct.ts +5 -0
  69. package/app/Models/commerce/WaitlistRestaurant.ts +4 -0
  70. package/ide/vscode/.vscode/extensions.json +1 -1
  71. package/ide/vscode/.vscode/settings.json +8 -149
  72. package/ide/vscode/README.md +48 -23
  73. package/ide/vscode/build.ts +43 -0
  74. package/ide/vscode/package.json +305 -56
  75. package/ide/vscode/scripts/stx-contributes.ts +66 -0
  76. package/ide/vscode/src/env-file.ts +103 -0
  77. package/ide/vscode/src/env.ts +241 -0
  78. package/ide/vscode/src/extension.ts +120 -26
  79. package/ide/vscode/src/pickier-protocol.ts +51 -0
  80. package/ide/vscode/src/pickier-worker.ts +92 -0
  81. package/ide/vscode/src/pickier.ts +433 -0
  82. package/ide/vscode/src/project.ts +14 -4
  83. package/ide/vscode/src/stacks.ts +18 -11
  84. package/ide/vscode/tests/build.test.ts +58 -0
  85. package/ide/vscode/tests/env.test.ts +166 -0
  86. package/ide/vscode/tests/extension.test.ts +8 -0
  87. package/ide/vscode/tests/fake-vscode.ts +228 -0
  88. package/ide/vscode/tests/manifest.test.ts +88 -0
  89. package/ide/vscode/tests/pickier.test.ts +202 -0
  90. package/ide/vscode/tsconfig.json +2 -2
  91. package/package.json +2 -2
  92. package/resources/components/Dashboard/Content/FileManagerDashboard.stx +2 -0
  93. package/resources/components/Dashboard/FileViewer/MediaCard.stx +14 -3
  94. package/routes/auth.ts +4 -0
  95. package/vcs/github/workflows/buddy-bot.yml +2 -2
  96. package/vcs/github/workflows/ci.yml +3 -3
  97. package/vcs/github/workflows/release.yml +1 -1
  98. package/ide/vscode/src/vscode.d.ts +0 -181
@@ -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 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/.
3
+ description: Use when working with Stacks server actions - creating actions in app/Actions/, auto-generated API actions from the useApi model trait, the 650 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
@@ -66,7 +66,7 @@ globalThis.toggleDark = toggleDark
66
66
  - **Custom Functions**: From `resources/functions/` (counter, dark mode, GPX, geo utilities)
67
67
 
68
68
  ### Server Auto-Imports (100+)
69
- - **All ORM Models**: User, Post, Author, Product, Order, Payment, Customer, etc. (103 models)
69
+ - **All ORM Models**: User, Post, Author, Product, Order, Payment, Customer, etc. (104 models)
70
70
  - **Request Models**: UserRequest, PostRequest, OrderRequest, etc.
71
71
  - **Actions**: Action types and helpers
72
72
  - **Schema**: validation schema builder
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-chat
3
- description: Use when implementing chat messaging in Stacks - sending messages to Slack (webhooks, bot tokens, block kit), Discord (webhooks, bot tokens, embeds), Microsoft Teams (adaptive cards, webhooks), the BaseChatDriver abstraction, retry logic, or multi-channel chat routing. Covers @stacksjs/chat.
3
+ description: Use when implementing chat messaging in Stacks - sending messages to Slack (webhooks, bot tokens, block kit), Discord (webhooks, bot tokens, embeds), Microsoft Teams (adaptive cards, webhooks), the BaseChatDriver abstraction, retry logic, multi-channel chat routing, or READING a person's conversations from iMessage, Slack and Discord and archiving them there (the inbox drivers). Covers @stacksjs/chat.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -342,6 +342,56 @@ async function sendCard(webhookUrl: string, card: TeamsAdaptiveCard, summary?: s
342
342
  - `driver` -- pre-instantiated singleton
343
343
  - `send()`, `sendWebhook()`, `sendCard()`, `configure()` functions
344
344
 
345
+ ## Inbox drivers (reading and archiving)
346
+
347
+ `send()` pushes messages out. The inbox drivers go the other way: they list a
348
+ person's conversations, read messages, notice new ones (a per-conversation
349
+ `cursor`), and archive a conversation *in the real app* as far as the provider
350
+ allows. Source: `chat/src/inbox/`. One interface, `InboxDriver`:
351
+
352
+ ```typescript
353
+ import { inbox } from '@stacksjs/chat'
354
+
355
+ const drivers = [
356
+ inbox.createInboxDriver('imessage', {}), // chat.db, needs Full Disk Access
357
+ inbox.createInboxDriver('slack', { token: env.SLACK_USER_TOKEN }), // xoxp user token
358
+ inbox.createInboxDriver('discord', { token: env.DISCORD_BOT_TOKEN, userId }), // bot token
359
+ ]
360
+ for (const driver of drivers) {
361
+ if (!(await driver.status()).connected) continue
362
+ for (const c of await driver.conversations()) {
363
+ const fresh = await driver.messages(c.id, { after: lastCursor[c.id] }) // oldest first
364
+ if (wantArchived) await driver.archive(c.id)
365
+ }
366
+ }
367
+ ```
368
+
369
+ What archiving does is part of every conversation (`conversation.archive`):
370
+
371
+ | Provider | `mode` | Effect in the real app |
372
+ |---|---|---|
373
+ | iMessage | `confirm` | Opens the conversation in Messages at Delete Conversation; the person confirms. Apple has no API, and editing chat.db is ignored or synced everywhere. |
374
+ | Slack DM / group DM | `native` | `conversations.close`; Slack reopens it when someone writes. `unarchive` reopens. |
375
+ | Slack public channel | `native` | `conversations.leave`; `unarchive` rejoins. |
376
+ | Slack private channel | `unsupported` | Untouched: leaving would need a re-invite. |
377
+ | Discord thread | `native` | Archived in Discord (reopens on a new post). |
378
+ | Discord channel | `unsupported` | Discord cannot hide a channel for one person. |
379
+
380
+ For iMessage, pass a `controller` (`{ remove(names, url, confirm), recover(names) }`)
381
+ whenever the app can drive Messages by accessibility: it receives every label
382
+ Messages might give the row (contact name, formatted number, handle) and should
383
+ act on the row found by name, never on the current selection - the driver's
384
+ fallback menu script does the latter. With a controller, `unarchive` recovers
385
+ from Recently Deleted, and `confirmDeletes: true` accepts Messages' alert too.
386
+ Attic (`~/Code/Apps/attic`, `app/Desktop/messages-control.swift`) is the reference.
387
+
388
+ Discord personal DMs are deliberately absent: only a user token reads them, and
389
+ automating a user account breaks Discord's terms. Reactions are separate
390
+ `kind: 'reaction'` messages with `targetId` and an emoji `reaction`; Slack and
391
+ Discord attachments carry a remote `url` that `driver.attachment()` fetches with
392
+ the right credentials. The HTTP drivers take an injectable `fetch`, which is how
393
+ `tests/inbox.test.ts` exercises them against API-shaped fixtures.
394
+
345
395
  ## Retry Logic
346
396
 
347
397
  All three drivers implement `sendWithRetry()`:
@@ -68,6 +68,40 @@ buddy deploy # deploy to cloud
68
68
  - Tags: Environment, Project, ManagedBy
69
69
  - OnFailure: ROLLBACK
70
70
 
71
+ ## CloudFront origin failover (AWS)
72
+
73
+ Off unless configured. Two shapes, both under `tsCloud.infrastructure` in `config/cloud.ts`, both
74
+ generated by ts-cloud into a CloudFront origin group (primary + secondary, failover status codes):
75
+
76
+ ```typescript
77
+ storage: {
78
+ public: {
79
+ website: true,
80
+ // Replica of this bucket in another region, replicated with S3 replication.
81
+ failover: { region: 'us-west-2', connectionAttempts: 1, connectionTimeout: 3 },
82
+ },
83
+ },
84
+ cdn: {
85
+ // A standalone distribution: any second origin, string or { domain, originPath, connection* }.
86
+ frontend: { origin: 'a.s3.us-east-1.amazonaws.com', failoverOrigin: 'b.s3.us-west-2.amazonaws.com' },
87
+ },
88
+ ```
89
+
90
+ - Bucket `failover` needs CloudFront in front of the bucket: a website bucket, `dns.domain`, and SSL
91
+ (`ssl.certificateArn`, or `ssl.enabled` + `dns.hostedZoneId`). Without them generation throws.
92
+ - Default codes: bucket failover `[403, 404, 500, 502, 503, 504]` (OAC answers a missing object
93
+ with 403); `cdn` failover `[500, 502, 503, 504]`. CloudFront accepts 400, 403, 404, 416, 429,
94
+ 500, 502, 503, 504 and nothing else.
95
+ - Only GET/HEAD/OPTIONS behaviors may target the group. Compute routes (`/api/*`, which accept
96
+ writes) stay on their own origin; a group behind a write-capable behavior throws at generation.
97
+ - `connectionAttempts` (1-3) / `connectionTimeout` (1-10 s) on the primary decide how fast an
98
+ unreachable primary fails over. CloudFront's default is 3 x 10 s.
99
+ - The replica is created by the deploy, not the stack (a stack only makes buckets in its own
100
+ region). Template metadata `TsCloud::StorageFailover` lists replicas; the deploy runs
101
+ `ensureFailoverReplicaBuckets` before the stack, `grantFailoverReplicaAccess` and
102
+ `seedFailoverReplicas` after (all from `@stacksjs/ts-cloud`).
103
+ - Docs: `docs/guide/cloud/origin-failover.md`.
104
+
71
105
  ## Hetzner and SSH targets
72
106
 
73
107
  Both deploy the same way once a host exists: build each site, package a release tarball, copy it
@@ -256,6 +290,8 @@ Stack naming: `{slugified-app-name}-cloud`
256
290
  - Default region is `us-east-1` (from AWS_DEFAULT_REGION env)
257
291
  - APP_KEY must be colon-separated format (validated during deployment)
258
292
  - Server mode uses EC2 + ALB; serverless uses Lambda + API Gateway + CloudFront
293
+ - Origin failover only retries GET, HEAD and OPTIONS on a cache miss; it does not protect writes,
294
+ and the failover replica outlives `buddy cloud:remove`
259
295
  - Jump boxes are optional, used for SSH access to private instances
260
296
  - `cloud:remove` with `--force` skips confirmation and is destructive
261
297
  - CDK toolkit stack is named `stacks-toolkit`
@@ -92,8 +92,8 @@ transcode is minutes, a vision call is a round trip to a third party. So an
92
92
  upload dispatches and the dashboard shows state.
93
93
 
94
94
  - `storage_item_tasks`, one row per `(disk, path, kind)`, kind being
95
- `optimize` (images, via `ts-images`), `transcode` (video, via `ts-videos`) or
96
- `tag` (a vision model). They succeed and fail independently, which is why this
95
+ `optimize` (images, via `ts-images`), `transcode` (video, via `ts-videos`),
96
+ `tag` (a vision model) or `preview` (the file manager thumbnail, #308). They succeed and fail independently, which is why this
97
97
  is not a column on `storage_items` - a video whose transcode finished and
98
98
  whose tagging failed is a normal state.
99
99
  - `dispatchDashboardFileTasks` decides from the CONTENT TYPE what a file needs.
@@ -107,10 +107,24 @@ upload dispatches and the dashboard shows state.
107
107
  and the queue answer different questions - the queue decides whether to retry,
108
108
  the row is what somebody looking at the file sees.
109
109
  - `POST /files/reprocess` re-runs everything, or the kinds you name.
110
+ - `preview` is decided by `file-preview.ts`, from the type the file CLAIMS
111
+ (the disk's answer, or the extension when that is `octet-stream`), and the
112
+ job checks the bytes agree before drawing. Images, WAV waveforms and TrueType
113
+ specimens become `.variants/<path>/preview.png`; text becomes a
114
+ `preview.txt` snippet. SVG, video, compressed audio, PDF, CFF/WOFF fonts,
115
+ HEIC and TIFF are recorded `skipped` with the reason in `error` - no
116
+ homegrown renderer draws them, and SVG is never rasterized. Never add a
117
+ renderer that shells out: that is the rule the skips exist to keep.
118
+ - `skipped` is terminal: `runTask` records a thrown `StorageTaskSkipped`
119
+ without rethrowing, so the queue does not retry it, and `aggregateTaskState`
120
+ ignores it unless nothing else applied.
110
121
 
111
122
  Derivatives are written back to the same disk under `.variants/<path>/`. The
112
123
  leading dot keeps them out of the listing, which skips hidden components - a
113
124
  folder of thirty derivatives beside every photo makes the browser useless.
125
+ A rename or delete through the dashboard moves or removes that folder with the
126
+ file, after the storage operation succeeds and best effort, since the task rows
127
+ already followed and a missing derivative is regenerated by reprocessing.
114
128
 
115
129
  ### Remote commands (stacksjs/stacks#960)
116
130
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-database
3
- description: Use when working with databases in a Stacks application - configuring connections, running queries, migrations, seeding, SQL helpers, or using SQLite/MySQL/PostgreSQL/DynamoDB. Covers @stacksjs/database, bun-query-builder, config/database.ts, and the database/ migrations directory.
3
+ description: Use when working with databases in a Stacks application - configuring connections, running queries, migrations, seeding, SQL helpers, or using SQLite/Turso/MySQL/PostgreSQL/DynamoDB. Covers @stacksjs/database, bun-query-builder, config/database.ts, and the database/ migrations directory.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript, SQLite >= 3.47.2
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -12,7 +12,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
12
12
  - Database package: `storage/framework/core/database/src/`
13
13
  - Configuration: `config/database.ts`
14
14
  - QB config: `config/query-builder.ts`
15
- - Migrations: `database/migrations/` (231 migration files, `.sql` format)
15
+ - Migrations: `database/migrations/` (233 migration files, `.sql` format)
16
16
  - QB state: `.qb/`
17
17
  - ORM: `storage/framework/orm/`
18
18
 
@@ -60,6 +60,7 @@ Database.fromEnv() // From env vars (DB_CONNECTION, DB_DATABASE
60
60
  ## Factory Functions (database.ts)
61
61
  - `createDatabase(options: DatabaseOptions): Database`
62
62
  - `createSqliteDatabase(database: string, options?): Database`
63
+ - `createTursoDatabase(url: string, authToken?: string, options?): Database`
63
64
  - `createPostgresDatabase(connection: DatabaseConnectionConfig, options?): Database`
64
65
  - `createMysqlDatabase(connection: DatabaseConnectionConfig, options?): Database`
65
66
 
@@ -154,6 +155,7 @@ interface DatabaseOptions {
154
155
 
155
156
  ```typescript
156
157
  interface SqliteConfig { database: string, prefix?: string }
158
+ interface TursoConfig { url: string, authToken?: string, prefix?: string }
157
159
  interface MysqlConfig { name: string, host?: string, port?: number, username?: string, password?: string, prefix?: string, charset?: string, collation?: string }
158
160
  interface PostgresConfig { name: string, host?: string, port?: number, username?: string, password?: string, prefix?: string, schema?: string, sslMode?: 'disable' | 'require' | 'verify-ca' | 'verify-full' }
159
161
  interface DynamoDbConfig { key: string, secret: string, region?: string, prefix?: string, endpoint?: string, tableName?: string, singleTable?: { enabled?, pkAttribute?, skAttribute?, entityTypeAttribute?, keyDelimiter?, gsiCount? } }
@@ -256,11 +258,30 @@ Entity-centric API for single-table design:
256
258
  - `buddy seed` -- seed database
257
259
  - `buddy generate:migrations` -- generate migration diffs from models
258
260
 
261
+ ## Turso / libSQL (`DB_CONNECTION=turso`, alias `libsql`)
262
+
263
+ SQLite's SQL on a Turso or `sqld` server. Configure `TURSO_DATABASE_URL`
264
+ (`libsql://<db>-<org>.turso.io`, or `http://127.0.0.1:8080` for `turso dev`) and
265
+ `TURSO_AUTH_TOKEN` (a secret, never logged).
266
+
267
+ - Everything that renders SQL or DDL treats it as `sqlite`: `dialectCapabilities('turso').wire === 'sqlite'`,
268
+ `getDatabaseDialect()` returns `'sqlite'`, and it shares the SQLite migration corpus and
269
+ `model-snapshot.sqlite.json`. Switching between `sqlite` and `turso` needs no regeneration.
270
+ - The transport is bun-query-builder's: `setConfig({ dialect: 'sqlite', database: { url, authToken } })`
271
+ with a libSQL URL selects its dependency-free Hrana-over-HTTP connection (`createLibSQLSQL`).
272
+ - Code that opens the database FILE must ask `isLibsqlDriver(driver)` first - there is none. The SQLite
273
+ migration preprocessor reads an in-memory replica of the server's schema instead
274
+ (`loadLibsqlSchemaMirror` in migrations.ts), and the migration lock is a row in
275
+ `stacks_migration_lock` (`acquireLibsqlMigrationLock`), since migrating hosts share no filesystem.
276
+ - libSQL rejects SQLite's double-quoted-string fallback, so a statement naming a missing column fails
277
+ instead of storing the column name as text. `tests/corpus-rebuild-sources.test.ts` guards the corpus.
278
+ - Inside `transaction()`, write through the `tx` handle; model writes use their own connection.
279
+
259
280
  ## config/database.ts Shape
260
281
  ```typescript
261
282
  {
262
283
  default: env.DB_CONNECTION || 'mysql',
263
- connections: { sqlite, mysql, postgres, dynamodb },
284
+ connections: { sqlite, turso, mysql, singlestore, vitess, postgres, dynamodb },
264
285
  migrations: 'migrations',
265
286
  migrationLocks: 'migration_locks',
266
287
  queryLogging: {
@@ -75,10 +75,14 @@ a versioned `STACKS_DEPLOY_PREVIEW_JSON=` line for API and tool consumers.
75
75
  1. **Validation**: checks APP_KEY format, AWS region, app URL, team config
76
76
  2. **Build**: compiles application for production
77
77
  3. **Infrastructure**: generates CloudFormation template via ts-cloud
78
- 4. **Deploy**: creates or updates the CloudFormation stack
78
+ 4. **Failover replicas** (only when a bucket sets `failover`): creates each replica bucket in its
79
+ region before the stack, since the stack cannot and its replication rule needs it
80
+ 5. **Deploy**: creates or updates the CloudFormation stack
79
81
  - Capabilities: CAPABILITY_IAM, CAPABILITY_NAMED_IAM
80
82
  - OnFailure: ROLLBACK
81
83
  - Tags: Environment, Project, ManagedBy
84
+ 6. **Failover access**: lets the new distribution read each replica, then copies across objects
85
+ that predate replication
82
86
 
83
87
  **Hetzner and SSH**
84
88
 
@@ -190,4 +194,6 @@ buddy cloud:cleanup # clean retained resources
190
194
  fragment serves
191
195
  - `.env.<environment>` is decrypted at deploy time and shipped as every site's `.env`, so declare
192
196
  `tenants` in `config/cloud.ts` on a shared box to strip other tenants' keys
197
+ - A failover replica bucket (`infrastructure.storage.<name>.failover`) lives outside the stack, so
198
+ `buddy cloud:remove` leaves it, and its data, behind on purpose
193
199
  - For infrastructure details, see the `stacks-cloud` skill
@@ -240,6 +240,15 @@ uses App Store Connect API-key authentication for validation/upload.
240
240
  Start with `validate-only: true`. Upload only after the signed artifact passes
241
241
  local launch and UI QA.
242
242
 
243
+ To package on a developer machine without importing distribution keys into the
244
+ login keychain, generate keys and certificates with `buddy desktop:apple:csr`
245
+ and `buddy desktop:apple:provision --apply`, import the two `.p12` files into a
246
+ throwaway keychain file, and point `--keychain <path>` (or `APPLE_KEYCHAIN`) at
247
+ it. `codesign` and `productbuild` then look for identities in that file only,
248
+ so the search list never changes. The Mac App Distribution identity is named
249
+ `3rd Party Mac Developer Application: ...` and the installer one
250
+ `3rd Party Mac Developer Installer: ...`.
251
+
243
252
  Human-owned prerequisites that Buddy does not pretend to automate:
244
253
 
245
254
  - Apple Developer Program enrollment and agreement acceptance
@@ -257,6 +266,7 @@ Repository variables:
257
266
  - `DESKTOP_URL`
258
267
  - `APPLE_APP_SIGNING_IDENTITY`
259
268
  - `APPLE_INSTALLER_SIGNING_IDENTITY`
269
+ - `APPLE_KEYCHAIN` (local only, optional): sign from this keychain file instead of the search list
260
270
 
261
271
  Repository secrets:
262
272
 
@@ -252,7 +252,7 @@ Default configurations provided at `storage/framework/defaults/ide/`:
252
252
  - `vscode/.vscode/extensions.json` -- Recommended extensions
253
253
  - `vscode/.vscode/settings.json` -- Editor settings
254
254
  - `vscode/.vscode/stacks.code-snippets` -- Code snippets
255
- - `vscode/package.json` -- VS Code extension manifest
255
+ - `vscode/package.json` -- the Stacks extension (`Stacks.vscode-stacks`). Self-contained: it installs no extension pack and depends on no extension. It builds in `.stx` support from `@stacksjs/stx-vscode` (the stx extension's language support as a library; the standalone stx extension, `Stacks.vscode-stx`, stands down next to it), pickier lint/format/fix-all through the project's own pickier, and env file hover decryption and `buddy env:*` commands. Run `bun run sync:stx` there after updating `@stacksjs/stx-vscode`; never map `*.stx` to `html` in `files.associations`, which takes `.stx` files away from the stx language.
256
256
 
257
257
  ### JetBrains
258
258
  - `.idea/` config (ESLint, modules, VCS, web resources)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-migrations
3
- description: Use when working with database migrations in a Stacks application - creating migration files, running migrations, fresh migration (drop + recreate), seeding after migration, migration file naming conventions, or the 231 built-in migration files. For the database API itself (queries, connections, SQL helpers), see stacks-database.
3
+ description: Use when working with database migrations in a Stacks application - creating migration files, running migrations, fresh migration (drop + recreate), seeding after migration, migration file naming conventions, or the 233 built-in migration files. For the database API itself (queries, connections, SQL helpers), see stacks-database.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript, SQLite >= 3.47.2
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -119,6 +119,6 @@ The framework includes migrations for all built-in models:
119
119
  - Do not commit a second snapshot under `.qb/`; that indicates a missing `snapshotDir` configuration
120
120
  - If a generated SQLite migration rebuilds tables, test it against a copy of the current database and run `PRAGMA integrity_check` plus `PRAGMA foreign_key_check`
121
121
  - `--seed` flag after `migrate:fresh` seeds the database with factory data
122
- - 231 migration files exist by default for all framework models
122
+ - 233 migration files exist by default for all framework models
123
123
  - SQLite >= 3.47.2 is required (system requirement)
124
124
  - For the database API (queries, connections), see the `stacks-database` skill
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-models
3
- description: Use when working with data models in Stacks - the defineModel() API, model attributes with validation and factories, relationships (hasOne/hasMany/belongsTo/belongsToMany), traits (useAuth, useUuid, useTimestamps, useSearch, useApi, billable, taggable, categorizable, commentable, likeable, observe), computed properties (get/set), model generation, and the 103 built-in framework models. Covers model definitions and storage/framework/defaults/app/Models/.
3
+ description: Use when working with data models in Stacks - the defineModel() API, model attributes with validation and factories, relationships (hasOne/hasMany/belongsTo/belongsToMany), traits (useAuth, useUuid, useTimestamps, useSearch, useApi, billable, taggable, categorizable, commentable, likeable, observe), computed properties (get/set), model generation, and the 104 built-in framework models. Covers model definitions and storage/framework/defaults/app/Models/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript, SQLite >= 3.47.2
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -88,6 +88,7 @@ generated model types stay precise.
88
88
  | `guarded` | Block mass assignment |
89
89
  | `hidden` | Exclude from JSON serialization (passwords, tokens) |
90
90
  | `foreignKey` | Disable, infer, or configure the FK constraint |
91
+ | `personal` | Personal data: exported by `gdpr:export`, anonymized by erasure and retention. `true`, or `{ anonymize?, export? }`. See "Personal data (GDPR)" below |
91
92
  | `factory` | `(faker, attributes) => value`, used by seeders and tests. `attributes` holds what this record's earlier-declared factories produced, so one value can depend on another: `(faker, { discountType }) => ...` |
92
93
  | `validation` | `{ rule, message? }` - `rule` from `schema`, `message` keyed by rule name |
93
94
 
@@ -107,6 +108,7 @@ generated model types stay precise.
107
108
  | `observe` | Emits `{model}:created` / `:updated` / `:deleted` events |
108
109
  | `billable` | Stripe methods (`checkout()`, `activeSubscription()`, ...) |
109
110
  | `taggable` / `categorizable` / `commentable` / `likeable` | Pivot tables and their relation methods |
111
+ | `gdpr` | Whose data the rows are (`subject`), what erasure does (`erasure`), how long rows live (`retention`), and why (`basis`, `purpose`). See "Personal data (GDPR)" below |
110
112
 
111
113
  Also at the top level: `indexes: [{ name, columns, unique?, where? }]` for
112
114
  composite and partial-unique indexes, and `dashboard: { highlight: true }` to
@@ -212,6 +214,40 @@ scopes: {
212
214
  },
213
215
  ```
214
216
 
217
+ ## Personal data (GDPR)
218
+
219
+ Declared on the model, read by access exports, erasure, retention and the
220
+ processing register (stacksjs/stacks#365). Full guide: `docs/guide/gdpr.md`.
221
+
222
+ ```ts
223
+ belongsTo: ['Customer'],
224
+ traits: {
225
+ gdpr: {
226
+ subject: { via: 'Customer' }, // or 'user_id', ['a_id', 'b_id'], { column, where }, { email: 'col' }
227
+ erasure: 'anonymize', // default; or 'delete' | 'keep'
228
+ retention: { days: 3650, action: 'anonymize' }, // optional; column defaults to created_at
229
+ basis: 'legal_obligation',
230
+ purpose: 'Order fulfilment, kept for tax and accounting',
231
+ },
232
+ },
233
+ attributes: {
234
+ deliveryAddress: { personal: true, validation: { rule: schema.string() } },
235
+ },
236
+ ```
237
+
238
+ - `subject` is derived when left out: `id` on `User`, the `belongsTo: ['User']`
239
+ foreign key elsewhere. A `via` parent must itself declare a subject.
240
+ - Anonymization writes NULL to nullable columns and a typed placeholder to
241
+ `NOT NULL` ones (`erased-<id>` when unique). A `NOT NULL` enum or timestamp
242
+ needs `personal: { anonymize: <value> }`, or every request refuses to run.
243
+ - A model that `belongsTo: ['User']` and declares nothing is **unclassified**:
244
+ erasure does not reach it and `buddy gdpr:register` lists it. Classify it, even
245
+ as `erasure: 'keep'`. `core/orm/tests/gdpr.test.ts` fails if a built-in one is.
246
+ - Engine: `exportSubjectData`, `eraseSubject({ dryRun })`, `pruneRetainedData`,
247
+ `resolveGdprPlan`, `renderProcessingRegister`, all from `@stacksjs/orm`.
248
+ Commands: `buddy gdpr:export|erase|prune|register|register:check`. Every
249
+ request writes a `gdpr_requests` row (the `GdprRequest` model).
250
+
215
251
  ## Workflow
216
252
 
217
253
  ```sh
@@ -265,7 +301,7 @@ pass `--allow-protected` to override.
265
301
  ## Built-in models by category
266
302
 
267
303
  The ones below are worth knowing by name. They are a selection, not the set:
268
- `storage/framework/defaults/app/Models/` holds 103, and that directory is the
304
+ `storage/framework/defaults/app/Models/` holds 104, and that directory is the
269
305
  authority. This section said "All 62 built-in models by category" while
270
306
  listing fewer than that against 102 on disk, so an agent reading to the end
271
307
  had no way to tell it was short.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-orm
3
- description: Use when working with the Stacks ORM - defining models with defineModel(), model relationships (hasOne, hasMany, belongsTo, belongsToMany, morphOne, hasManyThrough), attributes, traits, factories, computed properties, query building, transactions, or the 103 built-in models. Covers @stacksjs/orm, storage/framework/orm/, and storage/framework/defaults/app/Models/.
3
+ description: Use when working with the Stacks ORM - defining models with defineModel(), model relationships (hasOne, hasMany, belongsTo, belongsToMany, morphOne, hasManyThrough), attributes, traits, factories, computed properties, query building, transactions, or the 104 built-in models. Covers @stacksjs/orm, storage/framework/orm/, and storage/framework/defaults/app/Models/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript, SQLite >= 3.47.2
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -11,7 +11,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
11
11
  ## Key Paths
12
12
  - Core ORM package: `storage/framework/core/orm/src/`
13
13
  - ORM implementation: `storage/framework/orm/`
14
- - Model definitions: `storage/framework/defaults/app/Models/` (103 models)
14
+ - Model definitions: `storage/framework/defaults/app/Models/` (104 models)
15
15
  - Application models: `app/Models/`
16
16
  - Default model templates: `storage/framework/defaults/app/Models/`
17
17
  - ORM type globals: `storage/framework/types/orm-globals.d.ts`
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-security
3
- description: Use when implementing security in Stacks - password hashing (bcrypt/argon2), app key generation, AES encryption/decryption, hash verification, rehashing detection, or security configuration (firewall, rate limiting, IP allowlists). Covers @stacksjs/security and config/security.ts.
3
+ description: Use when implementing security in Stacks - password hashing (bcrypt/argon2), app key generation, AES encryption/decryption, hash verification, rehashing detection, security configuration (firewall, rate limiting, IP allowlists), or GDPR data-subject requests (access export, erasure, retention, the processing register). Covers @stacksjs/security, config/security.ts and the GDPR layer in @stacksjs/orm.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -116,6 +116,33 @@ interface HashMakeOptions {
116
116
  }
117
117
  ```
118
118
 
119
+ ## Personal data and GDPR
120
+
121
+ Data-subject requests are driven by model declarations, not by code you write per
122
+ request: `personal: true` on an attribute and `traits.gdpr` on the model (see
123
+ `stacks-models`, "Personal data (GDPR)", and `docs/guide/gdpr.md`).
124
+
125
+ ```bash
126
+ buddy gdpr:export <id|email> --out file.json # access (Art. 15/20)
127
+ buddy gdpr:erase <id|email> --dry-run # then without --dry-run, or --yes
128
+ buddy gdpr:prune --dry-run # retention; PruneRetainedDataJob runs daily
129
+ buddy gdpr:register && buddy gdpr:register:check
130
+ ```
131
+
132
+ ```typescript
133
+ import { eraseSubject, exportSubjectData, pruneRetainedData } from '@stacksjs/orm'
134
+ ```
135
+
136
+ - `GET /me/data-export` (auth bundle, `auth` middleware, 3/hour) is the
137
+ self-service export; the subject is always the caller.
138
+ - Erasure runs in one transaction with its `gdpr_requests` audit row, writes only
139
+ rows it matched to the subject, then revokes tokens and destroys sessions.
140
+ - Erasure of the `User` row anonymizes rather than deletes: orders, payments and
141
+ memberships still point at it. Consent and suppression records are kept by
142
+ declaration, since honouring an opt-out needs the address.
143
+ - The audit ledger stores counts, never values. Do not log export payloads with
144
+ `log`, which writes to disk in production.
145
+
119
146
  ## Gotchas
120
147
  - Default hashing is bcrypt with 12 rounds — sufficient for most applications
121
148
  - `needsRehash()` compares current hash options against provided options — useful after config changes
@@ -0,0 +1,84 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
+ import { existsSync, unlinkSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import process from 'node:process'
6
+
7
+ /**
8
+ * GET /me/data-export, at the action seam, over the framework's own model
9
+ * declarations and a real SQLite database holding two users.
10
+ */
11
+
12
+ const DB_PATH = join(tmpdir(), `stacks-data-export-${process.pid}.sqlite`)
13
+ process.env.DB_CONNECTION = 'sqlite'
14
+ process.env.DB_DATABASE_PATH = DB_PATH
15
+ process.env.APP_ENV = 'test'
16
+
17
+ const { acquireDbConfigLock, db, ensureDatabaseConfigLoaded, initializeDbConfig } = await import('@stacksjs/database')
18
+ const { default: DataExportAction } = await import('./DataExportAction')
19
+
20
+ async function forceConfig(): Promise<void> {
21
+ const release = await acquireDbConfigLock()
22
+ try {
23
+ await ensureDatabaseConfigLoaded()
24
+ initializeDbConfig({
25
+ app: { env: 'test' },
26
+ database: { default: 'sqlite', connections: { sqlite: { database: DB_PATH, prefix: '' } } },
27
+ })
28
+ }
29
+ finally {
30
+ release()
31
+ }
32
+ }
33
+
34
+ function requestAs(user: { id: number } | undefined): RequestInstance {
35
+ return { user: async () => user } as unknown as RequestInstance
36
+ }
37
+
38
+ beforeAll(async () => {
39
+ for (const suffix of ['', '-wal', '-shm']) {
40
+ if (existsSync(`${DB_PATH}${suffix}`))
41
+ unlinkSync(`${DB_PATH}${suffix}`)
42
+ }
43
+ await forceConfig()
44
+ await db.unsafe(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT, name TEXT, email TEXT, password TEXT, avatar TEXT, created_at TEXT, updated_at TEXT)`).execute()
45
+ await db.unsafe(`CREATE TABLE gdpr_requests (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT, type TEXT NOT NULL, subject_id INTEGER, actor TEXT, status TEXT NOT NULL DEFAULT 'completed', summary TEXT, occurred_at TEXT NOT NULL, created_at TEXT, updated_at TEXT)`).execute()
46
+ await db.unsafe(`INSERT INTO users (name, email, password) VALUES ('Ada Lovelace', 'ada@example.com', 'hash-ada'), ('Bob Byron', 'bob@example.com', 'hash-bob')`).execute()
47
+ })
48
+
49
+ afterAll(() => {
50
+ for (const suffix of ['', '-wal', '-shm']) {
51
+ try {
52
+ if (existsSync(`${DB_PATH}${suffix}`))
53
+ unlinkSync(`${DB_PATH}${suffix}`)
54
+ }
55
+ catch {
56
+ // best effort
57
+ }
58
+ }
59
+ })
60
+
61
+ describe('DataExportAction', () => {
62
+ test('downloads the caller\'s own data, without the password hash, and records the request', async () => {
63
+ await forceConfig()
64
+ const res = await DataExportAction.handle(requestAs({ id: 2 })) as Response
65
+
66
+ expect(res.status).toBe(200)
67
+ expect(res.headers.get('Content-Disposition')).toMatch(/^attachment; filename="personal-data-\d{4}-\d{2}-\d{2}\.json"$/)
68
+ expect(res.headers.get('Cache-Control')).toBe('private, no-store')
69
+
70
+ const body = await res.json() as { subject: { id: number }, data: Record<string, Array<Record<string, unknown>>> }
71
+ expect(body.subject).toEqual({ id: 2 })
72
+ expect(body.data.User).toEqual([expect.objectContaining({ id: 2, name: 'Bob Byron', email: 'bob@example.com' })])
73
+ expect(JSON.stringify(body)).not.toContain('hash-bob')
74
+ expect(JSON.stringify(body)).not.toContain('ada@example.com')
75
+
76
+ const audit = await (db as any).selectFrom('gdpr_requests').selectAll().execute()
77
+ expect(audit).toEqual([expect.objectContaining({ type: 'access', subject_id: 2, actor: 'api:user:2' })])
78
+ })
79
+
80
+ test('refuses an unauthenticated caller', async () => {
81
+ const res = await DataExportAction.handle(requestAs(undefined)) as Response
82
+ expect(res.status).toBe(401)
83
+ })
84
+ })
@@ -0,0 +1,40 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { log } from '@stacksjs/logging'
3
+ import { exportSubjectData } from '@stacksjs/orm'
4
+ import { response } from '@stacksjs/router'
5
+
6
+ /**
7
+ * Self-service data export: the signed-in user downloads their own personal
8
+ * data (GDPR Articles 15 and 20, stacksjs/stacks#365).
9
+ *
10
+ * The subject is always the authenticated caller - there is no id parameter
11
+ * to tamper with. The export carries only what the models declare personal,
12
+ * plus ids, timestamps and the purpose/basis/retention each model states.
13
+ * Each one is recorded in `gdpr_requests` with the caller as actor, and the
14
+ * route is rate-limited because an export reads every table the user touches.
15
+ */
16
+ export default new Action({
17
+ name: 'DataExportAction',
18
+ description: 'Download the authenticated user\'s personal data',
19
+ method: 'GET',
20
+ async handle(request: RequestInstance) {
21
+ const user = await request.user()
22
+
23
+ if (!user?.id)
24
+ return response.unauthorized('Authentication required')
25
+
26
+ const result = await exportSubjectData(user.id, { actor: `api:user:${String(user.id)}` })
27
+ log.info(`[gdpr] access export served to user ${String(user.id)}`)
28
+
29
+ const day = result.generatedAt.slice(0, 10)
30
+ return new Response(`${JSON.stringify(result, null, 2)}\n`, {
31
+ status: 200,
32
+ headers: {
33
+ 'Cache-Control': 'private, no-store',
34
+ 'Content-Disposition': `attachment; filename="personal-data-${day}.json"`,
35
+ 'Content-Type': 'application/json; charset=utf-8',
36
+ 'X-Content-Type-Options': 'nosniff',
37
+ },
38
+ })
39
+ },
40
+ })
@@ -5,7 +5,7 @@ import { DashboardFileError, reprocessDashboardFile } from './file-manager'
5
5
 
6
6
  export default new Action({
7
7
  name: 'FileReprocessAction',
8
- description: 'Re-runs the background processing for a file: image variants, video renditions, AI tags.',
8
+ description: 'Re-runs the background processing for a file: image variants, video renditions, AI tags, previews.',
9
9
  method: 'POST',
10
10
  async handle(request: RequestInstance) {
11
11
  try {