@shaferllc/keel 0.80.0 → 0.81.1

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 (65) hide show
  1. package/dist/accounts/accounts.config.stub +50 -0
  2. package/dist/accounts/config.d.ts +46 -0
  3. package/dist/accounts/config.js +39 -0
  4. package/dist/accounts/flows.d.ts +50 -0
  5. package/dist/accounts/flows.js +133 -0
  6. package/dist/accounts/index.d.ts +28 -0
  7. package/dist/accounts/index.js +23 -0
  8. package/dist/accounts/migration.d.ts +14 -0
  9. package/dist/accounts/migration.js +39 -0
  10. package/dist/accounts/provider.d.ts +18 -0
  11. package/dist/accounts/provider.js +37 -0
  12. package/dist/accounts/routes.d.ts +15 -0
  13. package/dist/accounts/routes.js +116 -0
  14. package/dist/accounts/store.d.ts +33 -0
  15. package/dist/accounts/store.js +37 -0
  16. package/dist/accounts/tokens.d.ts +60 -0
  17. package/dist/accounts/tokens.js +116 -0
  18. package/dist/accounts/totp.d.ts +58 -0
  19. package/dist/accounts/totp.js +134 -0
  20. package/dist/accounts/two-factor.d.ts +56 -0
  21. package/dist/accounts/two-factor.js +146 -0
  22. package/dist/core/database.d.ts +36 -0
  23. package/dist/core/database.js +141 -4
  24. package/dist/core/index.d.ts +5 -2
  25. package/dist/core/index.js +3 -2
  26. package/dist/core/migrations.d.ts +52 -2
  27. package/dist/core/migrations.js +134 -3
  28. package/dist/core/model-events.d.ts +34 -0
  29. package/dist/core/model-events.js +89 -0
  30. package/dist/core/model-query.d.ts +68 -0
  31. package/dist/core/model-query.js +234 -0
  32. package/dist/core/model.d.ts +109 -4
  33. package/dist/core/model.js +263 -32
  34. package/dist/core/relations.d.ts +53 -0
  35. package/dist/core/relations.js +242 -0
  36. package/dist/teams/config.d.ts +27 -0
  37. package/dist/teams/config.js +23 -0
  38. package/dist/teams/context.d.ts +54 -0
  39. package/dist/teams/context.js +73 -0
  40. package/dist/teams/index.d.ts +25 -0
  41. package/dist/teams/index.js +20 -0
  42. package/dist/teams/invitations.d.ts +38 -0
  43. package/dist/teams/invitations.js +123 -0
  44. package/dist/teams/middleware.d.ts +30 -0
  45. package/dist/teams/middleware.js +92 -0
  46. package/dist/teams/migration.d.ts +9 -0
  47. package/dist/teams/migration.js +52 -0
  48. package/dist/teams/models.d.ts +54 -0
  49. package/dist/teams/models.js +85 -0
  50. package/dist/teams/provider.d.ts +17 -0
  51. package/dist/teams/provider.js +27 -0
  52. package/dist/teams/teams.config.stub +24 -0
  53. package/dist/teams/tenant.d.ts +25 -0
  54. package/dist/teams/tenant.js +45 -0
  55. package/docs/accounts.md +214 -0
  56. package/docs/ai-manifest.json +70 -1
  57. package/docs/database.md +80 -0
  58. package/docs/examples/accounts.ts +150 -0
  59. package/docs/examples/teams.ts +101 -0
  60. package/docs/migrations.md +86 -6
  61. package/docs/models.md +279 -6
  62. package/docs/teams.md +176 -0
  63. package/llms-full.txt +849 -12
  64. package/llms.txt +4 -0
  65. package/package.json +10 -2
package/llms-full.txt CHANGED
@@ -4437,6 +4437,227 @@ router.get("/reports", handler).use(guards);
4437
4437
 
4438
4438
 
4439
4439
 
4440
+ ---
4441
+
4442
+ <!-- source: docs/accounts.md -->
4443
+
4444
+ # Accounts
4445
+
4446
+ Password reset, email verification, and two-factor authentication — the flows every
4447
+ app with a login needs, built on primitives already in core (`hash`, `encryption`,
4448
+ `mail`, `rate-limit`).
4449
+
4450
+ They live in the framework, tested once, rather than being copy-pasted into each new
4451
+ app. A password-reset flow written five times is four copies that quietly rot.
4452
+
4453
+ ```bash
4454
+ npm install @shaferllc/keel
4455
+ ```
4456
+
4457
+ ```ts
4458
+ // bootstrap/providers.ts
4459
+ import { AccountsServiceProvider } from "@shaferllc/keel/accounts";
4460
+
4461
+ app.register(AccountsServiceProvider);
4462
+ ```
4463
+
4464
+ That merges config, adds four columns to your `users` table via a migration, and
4465
+ mounts the JSON endpoints. **Views stay yours** — these are functions and JSON
4466
+ endpoints; your controllers render the forms.
4467
+
4468
+ ## Login
4469
+
4470
+ `attempt()` checks a password. What comes back depends on whether the user has 2FA.
4471
+
4472
+ ```ts
4473
+ const result = await attempt(email, password);
4474
+
4475
+ if (result.status === "failed") {
4476
+ return { error: "Those credentials don't match." };
4477
+ }
4478
+
4479
+ if (result.status === "two-factor") {
4480
+ // Nothing is logged in yet. Hold the challenge, ask for a code.
4481
+ return { twoFactor: true, challenge: result.challenge };
4482
+ }
4483
+
4484
+ auth().login(result.user.id);
4485
+ ```
4486
+
4487
+ A wrong email and a wrong password give the same answer, and take the same time —
4488
+ `attempt()` hashes against `hash.dummy` when no user is found, because a fast "no
4489
+ such user" tells an attacker which addresses are registered.
4490
+
4491
+ ## Two-factor
4492
+
4493
+ ### The challenge is not a session
4494
+
4495
+ When 2FA is on, a correct password yields a **challenge**, not a login. Nothing is
4496
+ authenticated until the code verifies.
4497
+
4498
+ This matters more than it looks. The usual implementation logs the user in and sets a
4499
+ `needs_2fa` flag for middleware to check — which means they are holding a real
4500
+ authenticated session *before* the second factor. Every route that forgets the
4501
+ middleware, and every `auth()` call that only asks "is anyone logged in?", is then
4502
+ bypassable with just a password. The second factor becomes advisory.
4503
+
4504
+ Here there is no half-authenticated state to forget about, because there is no
4505
+ session. The challenge is a short-lived token bound to a single purpose, so it cannot
4506
+ be swapped for a session cookie or spent as a password-reset link.
4507
+
4508
+ ```ts
4509
+ const user = await completeTwoFactor(challenge, code);
4510
+ if (!user) return { error: "That code isn't valid." };
4511
+
4512
+ auth().login(user.id);
4513
+ ```
4514
+
4515
+ `completeTwoFactor()` accepts an authenticator code **or** a recovery code.
4516
+
4517
+ ### Turning it on takes two steps
4518
+
4519
+ ```ts
4520
+ // Step one: a secret and recovery codes. 2FA is NOT on yet.
4521
+ const setup = await enableTwoFactor(user, { issuer: "Acme" });
4522
+ ```
4523
+
4524
+ `setup.uri` is an `otpauth://` URI to render as a QR code. `setup.secret` is for
4525
+ manual entry. `setup.recoveryCodes` are shown **once**.
4526
+
4527
+ > Render the QR **locally**. The URI contains the shared secret, so posting it to a
4528
+ > QR-image service hands your users' second factor to a third party.
4529
+
4530
+ ```ts
4531
+ // Step two: a working code turns it on.
4532
+ const ok = await confirmTwoFactor(user, code);
4533
+ ```
4534
+
4535
+ The two-step dance is deliberate. A one-step "enable" locks out every user who scans
4536
+ the QR wrong or whose phone clock is off — and what's broken is the very thing they'd
4537
+ need to get back in. Until `confirmTwoFactor()` succeeds, `hasTwoFactor()` is false
4538
+ and login works as before.
4539
+
4540
+ ### Recovery codes
4541
+
4542
+ Eight by default, hashed at rest, single-use — redeeming one burns it, so a code read
4543
+ over your shoulder is one you have already spent.
4544
+
4545
+ ```ts
4546
+ await recoveryCodesRemaining(user); // worth surfacing when it gets low
4547
+ await regenerateRecoveryCodes(user); // invalidates the old set
4548
+ await disableTwoFactor(user); // destroys the secret and the codes
4549
+ ```
4550
+
4551
+ ### What's stored
4552
+
4553
+ The TOTP secret is **encrypted** at rest and the recovery codes are **hashed**, so a
4554
+ leaked database hands over neither the second factor nor the backdoor.
4555
+
4556
+ TOTP itself is RFC 6238, verified against the RFC's published test vectors, and built
4557
+ on WebCrypto with no dependencies — so it runs unchanged on the edge.
4558
+
4559
+ ## Password reset
4560
+
4561
+ ```ts
4562
+ await requestPasswordReset(email); // emails a link, or quietly does nothing
4563
+ ```
4564
+
4565
+ The answer is the same whether or not that address has an account. "No account with
4566
+ that address" is a free enumeration oracle on an unauthenticated endpoint that anyone
4567
+ can ask about anyone.
4568
+
4569
+ ```ts
4570
+ const ok = await resetPassword(token, password);
4571
+ ```
4572
+
4573
+ **A reset link works exactly once**, and there is no `password_resets` table. The
4574
+ token carries its own purpose and expiry inside the ciphertext, and it is bound to a
4575
+ fingerprint of the current password hash — so the moment the password changes, every
4576
+ token minted against the old one is dead. Nothing to store, nothing to clean up, and
4577
+ no window where a stale row is still redeemable because a cron job didn't run.
4578
+
4579
+ ## Email verification
4580
+
4581
+ ```ts
4582
+ await sendVerificationEmail(user);
4583
+ const user = await verifyEmail(token); // idempotent
4584
+ ```
4585
+
4586
+ The token is bound to the address it was sent to. A link mailed to an old address
4587
+ cannot verify a new one — otherwise changing your email to someone else's and clicking
4588
+ an older link would mark *their* address as proven.
4589
+
4590
+ ## The endpoints
4591
+
4592
+ Mounted at `auth` unless you turn them off (`routes.enabled: false`) and call the
4593
+ functions from your own controllers instead.
4594
+
4595
+ | Method | Path | Notes |
4596
+ | --- | --- | --- |
4597
+ | POST | `/auth/login` | `{ email, password }` → a user, or `{ twoFactor, challenge }` |
4598
+ | POST | `/auth/two-factor` | `{ challenge, code }` — code or recovery code |
4599
+ | POST | `/auth/password/forgot` | Always `202`. Never says who exists. |
4600
+ | POST | `/auth/password/reset` | `{ token, password }` |
4601
+ | POST | `/auth/email/verify` | `{ token }` |
4602
+ | POST | `/auth/email/resend` | Always `202` |
4603
+
4604
+ Every one is unauthenticated and touches credentials, so the group is rate-limited
4605
+ (5 per minute by default). Without a throttle, a six-digit code inside a 30-second
4606
+ window is guessable, and forgot-password is an email cannon pointed at whoever the
4607
+ caller names.
4608
+
4609
+ ## Configuration
4610
+
4611
+ ```bash
4612
+ keel vendor:publish --tag accounts-config
4613
+ ```
4614
+
4615
+ ```ts
4616
+ export default {
4617
+ userTable: "users",
4618
+ routes: { enabled: true, prefix: "auth" },
4619
+ passwordReset: { expiresIn: "60m", url: "/reset-password?token=:token" },
4620
+ verification: { expiresIn: "24h", url: "/verify-email?token=:token" },
4621
+ twoFactor: {
4622
+ issuer: env("APP_NAME", "Keel"),
4623
+ window: 1, // ±30s of clock drift
4624
+ challengeExpiresIn: "5m",
4625
+ recoveryCodes: 8,
4626
+ },
4627
+ rateLimit: { max: 5, window: 60 },
4628
+ };
4629
+ ```
4630
+
4631
+ `twoFactor.challengeExpiresIn` is the window in which a stolen password alone is
4632
+ enough. Keep it short.
4633
+
4634
+ ## A different users table
4635
+
4636
+ Accounts talks to a table through the query builder rather than assuming a `Model`.
4637
+ If your users live somewhere else — an auth service, a legacy schema — replace the
4638
+ store:
4639
+
4640
+ ```ts
4641
+ setAccountStore({
4642
+ async findById(id) { /* … */ },
4643
+ async findByEmail(email) { /* … */ },
4644
+ async update(id, values) { /* … */ },
4645
+ });
4646
+ ```
4647
+
4648
+ ## The schema
4649
+
4650
+ Four columns on your users table, and no tokens table:
4651
+
4652
+ | Column | |
4653
+ | --- | --- |
4654
+ | `email_verified_at` | null until proven |
4655
+ | `two_factor_secret` | encrypted at rest |
4656
+ | `two_factor_recovery_codes` | hashed, then encrypted |
4657
+ | `two_factor_confirmed_at` | null until a working code proves it |
4658
+
4659
+
4660
+
4440
4661
  ---
4441
4662
 
4442
4663
  <!-- source: docs/ai.md -->
@@ -7573,6 +7794,23 @@ await db("posts").latest().get(); // ORDER BY created_at DESC
7573
7794
  await db("posts").oldest("published_at").get();
7574
7795
  ```
7575
7796
 
7797
+ Joins, grouping, and conditional/raw clauses:
7798
+
7799
+ ```ts
7800
+ await db("posts")
7801
+ .join("users", "posts.user_id", "users.id") // also leftJoin(...)
7802
+ .select("posts.title", "users.name")
7803
+ .get();
7804
+
7805
+ await db("posts").select("user_id").groupBy("user_id").having("COUNT(*)", ">", 5).get();
7806
+ await db("users").distinct().select("country").pluck("country");
7807
+
7808
+ await db("events").whereColumn("updated_at", ">", "created_at").get();
7809
+ await db("users").whereRaw("score >= ?", [10]).orderByRaw("LENGTH(name) DESC").get();
7810
+
7811
+ await db("users").when(search, (q, term) => q.whereLike("name", `%${term}%`)).get();
7812
+ ```
7813
+
7576
7814
  ## Aggregates, single values, and pagination
7577
7815
 
7578
7816
  ```ts
@@ -7602,6 +7840,22 @@ Everything is parameterized — values become bindings, never string-interpolate
7602
7840
  SQL — so it's injection-safe by construction. Writes return a `WriteResult`;
7603
7841
  `insertGetId` unwraps it to just the new id.
7604
7842
 
7843
+ Counters, bulk upserts, and paged iteration:
7844
+
7845
+ ```ts
7846
+ await db("posts").where("id", id).increment("views"); // += 1
7847
+ await db("posts").where("id", id).decrement("stock", 3, { updated_at: now });
7848
+
7849
+ // Insert, updating the listed columns on a unique-key conflict (dialect-aware).
7850
+ await db("users").upsert([{ id: 1, name: "Ada" }], ["id"], ["name"]);
7851
+ await db("logs").insertOrIgnore({ key, value }); // skip duplicates
7852
+
7853
+ // Process a large table a page at a time (return false to stop early).
7854
+ await db("users").orderBy("id").chunk(500, async (rows) => {
7855
+ for (const row of rows) await process(row);
7856
+ });
7857
+ ```
7858
+
7605
7859
  > **Guard your writes.** `update()` and `delete()` apply to every row that
7606
7860
  > matches the current `where` clause — with no `where`, that's the whole table.
7607
7861
  > Always scope a write with `where` unless you truly mean to touch every row.
@@ -8004,6 +8258,53 @@ await db("sessions").where("expires_at", "<", now).delete();
8004
8258
  **Notes:** with no `where`, empties the table. There's no soft-delete here — pair
8005
8259
  with a `deleted_at` column and `whereNull` if you want one.
8006
8260
 
8261
+ #### `whereColumn(first, operator?, second)` · `whereRaw(sql, bindings?)`
8262
+
8263
+ Compare two columns (no binding) or add a raw WHERE fragment with its own
8264
+ bindings. `whereColumn("updated_at", ">", "created_at")`; `whereRaw("score >= ?", [10])`.
8265
+
8266
+ #### `join(table, first, operator?, second)` · `leftJoin(...)`
8267
+
8268
+ Add an `INNER JOIN` / `LEFT JOIN` on an equality (or the given operator).
8269
+ Included in `get`, `count`, and aggregates. Qualify ambiguous columns
8270
+ (`"posts.user_id"`).
8271
+
8272
+ #### `groupBy(...columns)` · `having(column, operator?, value)` · `distinct()`
8273
+
8274
+ `GROUP BY`, a bound `HAVING` predicate, and `SELECT DISTINCT`.
8275
+
8276
+ #### `orderByRaw(sql)` · `when(condition, then, otherwise?)`
8277
+
8278
+ A raw `ORDER BY` fragment; and conditional building — `then(query, value)` runs
8279
+ only when `condition` is truthy, else `otherwise`.
8280
+
8281
+ #### `increment(column, amount?, extra?)` · `decrement(column, amount?, extra?)`
8282
+
8283
+ `increment(column: string, amount = 1, extra: Row = {}): Promise<WriteResult>`
8284
+
8285
+ Atomically `column = column ± amount` on matching rows, optionally setting other
8286
+ columns in the same statement. Scope with `where`.
8287
+
8288
+ #### `upsert(rows, uniqueBy, update?)`
8289
+
8290
+ `upsert(rows: Row | Row[], uniqueBy: string[], update?: string[]): Promise<WriteResult>`
8291
+
8292
+ Insert rows, updating `update` columns (default: all non-unique) on a conflict
8293
+ against `uniqueBy`. Dialect-aware: `ON CONFLICT … DO UPDATE` (sqlite/postgres) or
8294
+ `ON DUPLICATE KEY UPDATE` (mysql).
8295
+
8296
+ #### `insertOrIgnore(rows)`
8297
+
8298
+ Insert one or more rows, skipping any that violate a unique constraint
8299
+ (`INSERT OR IGNORE` / `INSERT IGNORE` / `ON CONFLICT DO NOTHING`).
8300
+
8301
+ #### `chunk(size, callback)`
8302
+
8303
+ `chunk(size: number, callback: (rows: T[]) => void | boolean | Promise<void | boolean>): Promise<void>`
8304
+
8305
+ Process results a page at a time so a large table never loads at once. Return
8306
+ `false` from the callback to stop early. Pair with `orderBy` for a stable order.
8307
+
8007
8308
  ### Interfaces & types
8008
8309
 
8009
8310
  #### `Connection`
@@ -12745,12 +13046,41 @@ t.boolean("active").default(true); // sqlite: DEFAULT 1, else DEFAULT true
12745
13046
  t.integer("retries").default(0); // ... DEFAULT 0
12746
13047
  ```
12747
13048
 
12748
- For anything the builder doesn't cover — indexes, foreign keys, an `ALTER
12749
- TABLE` — `schema.raw(sql, bindings?)` runs arbitrary SQL:
13049
+ ### Indexes and foreign keys
13050
+
13051
+ `createTable` builds indexes and foreign keys alongside the columns:
13052
+
13053
+ ```ts
13054
+ schema.createTable("members", (t) => {
13055
+ t.id();
13056
+ t.integer("team_id");
13057
+ t.string("email");
13058
+ t.uniqueIndex("email"); // or t.index(["a", "b"]) for composite
13059
+ t.foreign("team_id").references("id").on("teams").onDelete("cascade");
13060
+ });
13061
+ ```
13062
+
13063
+ ### Altering a table
13064
+
13065
+ `schema.alterTable(name, build)` adds, renames, and drops columns and indexes on
13066
+ an existing table (dialect-aware SQL). Drop an index before the column it covers:
12750
13067
 
12751
13068
  ```ts
12752
13069
  up: (schema) =>
12753
- schema.raw("CREATE INDEX idx_posts_user ON posts (user_id)"),
13070
+ schema.alterTable("users", (t) => {
13071
+ t.string("phone").nullable(); // ADD COLUMN
13072
+ t.renameColumn("name", "full_name");
13073
+ t.index("phone");
13074
+ t.dropIndex("users_legacy_index");
13075
+ t.dropColumn("legacy");
13076
+ }),
13077
+ ```
13078
+
13079
+ For anything the builder still doesn't cover, `schema.raw(sql, bindings?)` runs
13080
+ arbitrary SQL:
13081
+
13082
+ ```ts
13083
+ up: (schema) => schema.raw("CREATE INDEX idx_posts_user ON posts (user_id)"),
12754
13084
  ```
12755
13085
 
12756
13086
  > `raw()` writes through the connection **without** placeholder conversion, so
@@ -12928,11 +13258,10 @@ already gone — the typical `down()` for a `createTable`.
12928
13258
 
12929
13259
  `raw(sql: string, bindings?: unknown[]): Promise<void>`
12930
13260
 
12931
- Runs arbitrary SQL through the connection — the escape hatch for indexes, foreign
12932
- keys, and `ALTER TABLE`.
13261
+ Runs arbitrary SQL through the connection — the escape hatch for anything the
13262
+ builders don't cover.
12933
13263
 
12934
13264
  ```ts
12935
- await schema.raw("CREATE INDEX idx_posts_user ON posts (user_id)");
12936
13265
  await schema.raw("UPDATE users SET active = ? WHERE active IS NULL", [true]);
12937
13266
  ```
12938
13267
 
@@ -12940,6 +13269,22 @@ await schema.raw("UPDATE users SET active = ? WHERE active IS NULL", [true]);
12940
13269
  `raw()` does **not** rewrite `?` to `$n`, so pass `$1, $2, …` yourself on the
12941
13270
  `postgres` dialect.
12942
13271
 
13272
+ #### `alterTable(name, build)`
13273
+
13274
+ `alterTable(name: string, build: (table: AlterTableBuilder) => void): Promise<void>`
13275
+
13276
+ Alter an existing table — the callback gets an [`AlterTableBuilder`](#altertablebuilder)
13277
+ for adding, renaming, and dropping columns and indexes. Emits one dialect-aware
13278
+ statement per operation, ordered so a dropped index precedes its column.
13279
+
13280
+ ```ts
13281
+ await schema.alterTable("users", (t) => {
13282
+ t.string("phone").nullable();
13283
+ t.renameColumn("name", "full_name");
13284
+ t.dropColumn("legacy");
13285
+ });
13286
+ ```
13287
+
12943
13288
  ### `TableBuilder`
12944
13289
 
12945
13290
  Describes a table's columns. **You get one from the `createTable` callback** — it
@@ -13056,6 +13401,27 @@ t.toCreateSql("users", "postgres");
13056
13401
  // CREATE TABLE users (id SERIAL PRIMARY KEY, email VARCHAR(255) NOT NULL)
13057
13402
  ```
13058
13403
 
13404
+ #### `index(columns, name?)` / `uniqueIndex(columns, name?)`
13405
+
13406
+ Add a (possibly composite) index or unique index; `columns` is a name or array.
13407
+ Emitted as `CREATE [UNIQUE] INDEX` after the table. Auto-named unless `name` is
13408
+ given.
13409
+
13410
+ ```ts
13411
+ t.index("email");
13412
+ t.uniqueIndex(["team_id", "slug"]);
13413
+ ```
13414
+
13415
+ #### `foreign(column)`
13416
+
13417
+ `foreign(column: string): ForeignKeyBuilder`
13418
+
13419
+ Add a foreign key, built fluently and emitted inline in the `CREATE TABLE`.
13420
+
13421
+ ```ts
13422
+ t.foreign("team_id").references("id").on("teams").onDelete("cascade");
13423
+ ```
13424
+
13059
13425
  #### `columns`
13060
13426
 
13061
13427
  `readonly columns: Column[]`
@@ -13063,6 +13429,21 @@ t.toCreateSql("users", "postgres");
13063
13429
  The `Column` instances added so far, in declaration order. Read-only inspection
13064
13430
  seam; you rarely touch it.
13065
13431
 
13432
+ ### `AlterTableBuilder`
13433
+
13434
+ From the `alterTable` callback. Column methods (`string`, `integer`, …) **add**
13435
+ columns; plus:
13436
+
13437
+ - `dropColumn(name)` — drop a column.
13438
+ - `renameColumn(from, to)` — rename a column.
13439
+ - `index(columns, name?)` / `uniqueIndex(columns, name?)` — add an index.
13440
+ - `dropIndex(name)` — drop an index (runs before column drops).
13441
+
13442
+ ### `ForeignKeyBuilder`
13443
+
13444
+ From `TableBuilder.foreign(column)`. Chainable: `references(column)`, `on(table)`,
13445
+ `onDelete(action)`, `onUpdate(action)`.
13446
+
13066
13447
  ### `Column`
13067
13448
 
13068
13449
  A single column definition. **You get one from a `TableBuilder` method** (`t.string(...)` etc.) — you don't construct it in migrations. Modifier methods
@@ -13328,6 +13709,110 @@ return json(user); // works directly — json() serializes it
13328
13709
  user.fill({ name: "X" }); // merge mass-assignable attributes without saving
13329
13710
  ```
13330
13711
 
13712
+ Control what `toJSON()` exposes with three statics. `hidden` strips columns;
13713
+ `visible` is an allowlist that wins over everything; `appends` adds computed
13714
+ attributes — a getter or a zero-arg method on the model:
13715
+
13716
+ ```ts
13717
+ class User extends Model {
13718
+ static table = "users";
13719
+ static hidden = ["password"]; // never serialized
13720
+ static appends = ["fullName"]; // added to the output
13721
+ get fullName() { return `${this.first} ${this.last}`; }
13722
+ }
13723
+ ```
13724
+
13725
+ ## Lifecycle events
13726
+
13727
+ A model fires events as it is retrieved, saved, and deleted. Hook onto them to
13728
+ slug a title, bust a cache, or cascade — without touching every call site. The
13729
+ `*ing` events are **cancelable**: a hook returning `false` aborts the write.
13730
+
13731
+ ```ts
13732
+ User.creating((user) => { user.uuid = crypto.randomUUID(); });
13733
+ User.saved((user) => cache().forget(`user:${user.id}`));
13734
+ User.deleting((user) => (user.isRoot ? false : undefined)); // veto
13735
+
13736
+ // Or group them in an observer:
13737
+ User.observe({
13738
+ creating: (u) => { u.uuid = crypto.randomUUID(); },
13739
+ deleted: (u) => audit(`deleted ${u.id}`),
13740
+ });
13741
+ ```
13742
+
13743
+ Events: `retrieved`, `creating`/`created`, `updating`/`updated`,
13744
+ `saving`/`saved`, `deleting`/`deleted`, `restoring`/`restored`. They're keyed by
13745
+ the exact class (subclasses don't inherit a parent's hooks).
13746
+
13747
+ ## Query scopes
13748
+
13749
+ A **global scope** constrains every query a model builds — the base for
13750
+ multi-tenancy, published-only reads, and soft deletes:
13751
+
13752
+ ```ts
13753
+ Post.addGlobalScope("published", (q) => q.where("published", true));
13754
+ await Post.all(); // only published
13755
+ await Post.query().where("author_id", 1).get(); // still only published
13756
+ ```
13757
+
13758
+ Scopes **inherit**. A scope declared on a base class constrains every model that
13759
+ extends it — which is what makes a single tenant-scoped base class possible:
13760
+
13761
+ ```ts
13762
+ class TenantModel extends Model {}
13763
+ TenantModel.addGlobalScope("tenant", (q) => q.where("teamId", currentTeamId()));
13764
+
13765
+ class Post extends TenantModel {} // scoped, without repeating yourself
13766
+ ```
13767
+
13768
+ Scopes from several levels all apply, and a subclass overrides an ancestor's scope
13769
+ by reusing its name — the nearest declaration wins.
13770
+
13771
+ ### Escaping a scope
13772
+
13773
+ ```ts
13774
+ await Post.withoutGlobalScope("tenant").get(); // one named scope
13775
+ await Post.withoutGlobalScopes().get(); // all of them
13776
+ ```
13777
+
13778
+ Escaping is deliberately explicit, and worth keeping that way. A query that steps
13779
+ outside a tenancy scope is exactly the thing you want to be able to *find* — so it
13780
+ should be typed out and greppable, never something you arrive at by forgetting a
13781
+ `where`.
13782
+
13783
+ A **local scope** is just a static method returning a query — no framework
13784
+ feature needed:
13785
+
13786
+ ```ts
13787
+ class Post extends Model {
13788
+ static popular() { return this.query().where("views", ">", 1000); }
13789
+ }
13790
+ await Post.popular().orderBy("views", "desc").get();
13791
+ ```
13792
+
13793
+ ## Soft deletes
13794
+
13795
+ Opt in with `static softDeletes = true` and a `deleted_at` column. `delete()`
13796
+ then sets the timestamp instead of removing the row, and a global scope hides
13797
+ soft-deleted rows from every query.
13798
+
13799
+ ```ts
13800
+ class User extends Model {
13801
+ static table = "users";
13802
+ static softDeletes = true;
13803
+ static casts = { deleted_at: "date" };
13804
+ }
13805
+
13806
+ await user.delete(); // sets deleted_at; row stays in the table
13807
+ user.trashed(); // true
13808
+ await User.find(user.id); // null — hidden by the scope
13809
+
13810
+ await User.withTrashed().get(); // include soft-deleted
13811
+ await User.onlyTrashed().get(); // only soft-deleted
13812
+ await user.restore(); // clear deleted_at
13813
+ await user.forceDelete(); // remove the row for good
13814
+ ```
13815
+
13331
13816
  ## Relationships
13332
13817
 
13333
13818
  Define a relationship as a method that returns one of `hasMany` / `hasOne` /
@@ -13377,6 +13862,29 @@ users[0].toJSON(); // includes `posts` and `roles`
13377
13862
  Loaded relations are stored off the model, so they never leak into `save()`,
13378
13863
  and `toJSON()` serializes them (nested models included).
13379
13864
 
13865
+ ### Querying relationships (`with`, `withCount`, `whereHas`)
13866
+
13867
+ `Model.query()` returns a model-aware builder with the relationship operations a
13868
+ raw query can't express. `with()` eager-loads (dotted paths nest), `withCount()`
13869
+ adds a `<relation>_count`, and `has`/`whereHas`/`doesntHave` filter by whether a
13870
+ related row exists:
13871
+
13872
+ ```ts
13873
+ const users = await User.query()
13874
+ .where("active", true)
13875
+ .with("posts.comments") // nested eager load
13876
+ .withCount("posts") // users[i].posts_count
13877
+ .whereHas("posts", (q) => q.where("published", true))
13878
+ .get();
13879
+
13880
+ await User.has("posts").get(); // users with at least one post
13881
+ await User.doesntHave("posts").get(); // users with none
13882
+ ```
13883
+
13884
+ `with`/`withCount`/`whereHas`/`has`/`doesntHave` are also static shortcuts
13885
+ (`User.with(...)`, `User.whereHas(...)`). Existence filters use the same
13886
+ driver-agnostic two-query strategy as the relations themselves — no JOIN.
13887
+
13380
13888
  ### Many-to-many
13381
13889
 
13382
13890
  `belongsToMany` reads through a pivot table (default name: the two table names
@@ -13397,11 +13905,39 @@ this.belongsTo(User, "owner_id", "id");
13397
13905
  this.belongsToMany(Role, "user_roles", "user_id", "role_id");
13398
13906
  ```
13399
13907
 
13908
+ ### Polymorphic
13909
+
13910
+ A polymorphic relation lets one model belong to more than one type. The related
13911
+ rows carry `<name>_id` + `<name>_type`; register each owner type so `morphTo`
13912
+ can resolve it:
13913
+
13914
+ ```ts
13915
+ class Post extends Model {
13916
+ comments() { return this.morphMany(Comment, "commentable"); }
13917
+ }
13918
+ class Video extends Model {
13919
+ comments() { return this.morphMany(Comment, "commentable"); }
13920
+ }
13921
+ class Comment extends Model {
13922
+ commentable() { return this.morphTo("commentable"); } // resolves back to Post or Video
13923
+ }
13924
+
13925
+ registerMorphType("Post", Post);
13926
+ registerMorphType("Video", Video);
13927
+
13928
+ await post.comments().create({ body: "nice" }); // sets commentable_id/_type
13929
+ const owner = await comment.commentable(); // Post | Video | null
13930
+ ```
13931
+
13932
+ `morphOne` is the one-to-one variant. Eager loading (`Model.load` / `with`) works
13933
+ across mixed types.
13934
+
13400
13935
  ## What this is (and isn't)
13401
13936
 
13402
- This is a deliberately small active-record — enough for CRUD, relationships,
13403
- casts, and simple queries without an ORM dependency. Nested eager loading
13404
- (`posts.comments`) and query-time `with()` aren't here yet. For complex schemas
13937
+ This is a compact active-record — CRUD, lifecycle events, scopes, soft deletes,
13938
+ serialization control, eager loading (including nested `with("posts.comments")`),
13939
+ relationship queries (`whereHas`/`withCount`), and polymorphic relations all on
13940
+ a driver-agnostic query builder, no ORM dependency. For complex one-off queries
13405
13941
  you can always drop to `db()` or your driver directly.
13406
13942
 
13407
13943
  ---
@@ -13557,6 +14093,55 @@ Rarely called directly — `create`/`save` use it internally.
13557
14093
  const storable = Post.toDatabase({ published: true }); // { published: 1 }
13558
14094
  ```
13559
14095
 
14096
+ #### `Model.with(...names)` · `Model.withCount(...names)`
14097
+
14098
+ Start a [`ModelQuery`](#modelquery) that eager-loads the named relations (dotted
14099
+ paths nest: `"posts.comments"`) or counts them into `<relation>_count`.
14100
+
14101
+ #### `Model.has(name)` · `Model.whereHas(name, constrain?)` · `Model.doesntHave(name, constrain?)`
14102
+
14103
+ Start a `ModelQuery` filtered by relationship existence — has at least one
14104
+ related row, has one matching `constrain(query)`, or has none. `constrain`
14105
+ receives the related-table query builder.
14106
+
14107
+ #### `Model.newQuery()`
14108
+
14109
+ `static newQuery(): ModelQuery<T>`
14110
+
14111
+ The model-aware query behind the sugar above — hydrates rows to models and adds
14112
+ `with`/`withCount`/`whereHas`.
14113
+
14114
+ #### `Model.addGlobalScope(name, scope)`
14115
+
14116
+ `static addGlobalScope(name: string, scope: (query: QueryBuilder) => void): void`
14117
+
14118
+ Register a constraint applied to every query the model builds. Inherited by
14119
+ subclasses; a subclass re-using a name overrides it.
14120
+
14121
+ #### `Model.withTrashed()` · `Model.onlyTrashed()` · `Model.withoutGlobalScope(...names)` · `Model.withoutGlobalScopes()`
14122
+
14123
+ Escape hatches returning a `QueryBuilder`: include (or only) soft-deleted rows,
14124
+ or drop named / all global scopes. Deliberately explicit so an unscoped query is
14125
+ greppable at audit time.
14126
+
14127
+ ### `Model` — lifecycle events
14128
+
14129
+ Register per-class hooks (keyed by the exact class). The `*ing` events are
14130
+ cancelable — a hook returning `false` aborts the operation.
14131
+
14132
+ #### `Model.creating` · `created` · `updating` · `updated` · `saving` · `saved` · `deleting` · `deleted` · `restoring` · `restored` · `retrieved`
14133
+
14134
+ `static <event>(hook: (model: T) => void | boolean | Promise<void | boolean>): void`
14135
+
14136
+ Add a hook for that lifecycle event. `create()` fires `saving`→`creating`→write→
14137
+ `created`→`saved`; a save that updates fires the `updating`/`updated` pair.
14138
+
14139
+ #### `Model.observe(observer)`
14140
+
14141
+ `static observe(observer: Partial<Record<ModelEvent, ModelHook<T>>>): void`
14142
+
14143
+ Attach an observer object — each method named after an event becomes a hook.
14144
+
13560
14145
  ### `Model` — configuration statics
13561
14146
 
13562
14147
  Set these on the subclass to configure it. All have defaults.
@@ -13617,6 +14202,21 @@ class Post extends Model {
13617
14202
  }
13618
14203
  ```
13619
14204
 
14205
+ #### `static hidden` / `static visible` / `static appends`
14206
+
14207
+ `static hidden: string[]` · `static visible: string[]` · `static appends: string[]`
14208
+
14209
+ Shape `toJSON()`: `hidden` strips columns, `visible` is an allowlist that wins,
14210
+ `appends` adds computed attributes (a getter or zero-arg method). All default `[]`.
14211
+
14212
+ #### `static softDeletes` / `static deletedAtColumn`
14213
+
14214
+ `static softDeletes: boolean` (default `false`) · `static deletedAtColumn: string`
14215
+ (default `"deleted_at"`)
14216
+
14217
+ Turn on soft deletes: `delete()` sets the timestamp and a global scope hides
14218
+ trashed rows.
14219
+
13620
14220
  ### `Model` — instance methods
13621
14221
 
13622
14222
  #### `new Model(attributes?)`
@@ -13655,14 +14255,22 @@ still issues the query.
13655
14255
 
13656
14256
  `delete(): Promise<void>`
13657
14257
 
13658
- Deletes the row matching this model's primary key.
14258
+ Deletes the row matching this model's primary key — or, with `static softDeletes`
14259
+ on, sets `deleted_at` instead. Fires `deleting`/`deleted`.
13659
14260
 
13660
14261
  ```ts
13661
14262
  await user.delete();
13662
14263
  ```
13663
14264
 
13664
- **Notes:** keys off the current `primaryKey` value; on a model without one, the
13665
- `where` binds `undefined`. Hard delete only — there's no soft-delete built in.
14265
+ **Notes:** keys off the current `primaryKey` value. See `forceDelete`/`restore`
14266
+ for the soft-delete variants.
14267
+
14268
+ #### `forceDelete()` · `restore()` · `trashed()`
14269
+
14270
+ `forceDelete(): Promise<void>` · `restore(): Promise<this>` · `trashed(): boolean`
14271
+
14272
+ For soft-deletable models: permanently remove the row, clear `deleted_at`
14273
+ (fires `restoring`/`restored`), or test whether it's currently trashed.
13666
14274
 
13667
14275
  #### `fill(attributes)`
13668
14276
 
@@ -13792,6 +14400,52 @@ joined with `_` (User + Role → `role_user`). The pivot keys default to
13792
14400
  `<model>_<primaryKey>`. Reads as two `whereIn` queries (no JOIN), so it stays
13793
14401
  edge-safe.
13794
14402
 
14403
+ #### `morphMany(related, name, localKey?)` · `morphOne(related, name, localKey?)`
14404
+
14405
+ `morphMany<T>(related: ModelClass<T>, name: string, localKey?: string): MorphMany<T>`
14406
+
14407
+ The parent side of a polymorphic relation. Related rows carry `<name>_id` +
14408
+ `<name>_type` (the type stored is this model's class name). `MorphMany` also has
14409
+ `.create(attributes)`, which fills the morph keys.
14410
+
14411
+ ```ts
14412
+ comments() { return this.morphMany(Comment, "commentable"); }
14413
+ ```
14414
+
14415
+ #### `morphTo(name, idColumn?, typeColumn?)`
14416
+
14417
+ `morphTo(name: string, idColumn?: string, typeColumn?: string): MorphTo`
14418
+
14419
+ The owning side — resolves the parent from the stored `<name>_type` (via
14420
+ [`registerMorphType`](#registermorphtypetype-model)) and `<name>_id`. Awaitable;
14421
+ returns the parent model or `null`.
14422
+
14423
+ ```ts
14424
+ commentable() { return this.morphTo("commentable"); }
14425
+ ```
14426
+
14427
+ #### `registerMorphType(type, model)`
14428
+
14429
+ `registerMorphType(type: string, related: ModelClass<Model>): void`
14430
+
14431
+ Register a model under a morph-type string (usually its class name) so `morphTo`
14432
+ can resolve it. Call once at boot for each owner type.
14433
+
14434
+ ### `ModelQuery`
14435
+
14436
+ The model-aware builder returned by `Model.query()`, `Model.newQuery()`, and the
14437
+ `with`/`whereHas`/`withCount` shortcuts. It proxies the query-builder constraint
14438
+ methods (`where`, `orderBy`, `limit`, …) and hydrates results to models, adding:
14439
+
14440
+ - `with(...names)` — eager-load relations; dotted paths nest (`"posts.comments"`).
14441
+ - `withCount(...names)` — add `<relation>_count` to each result.
14442
+ - `has(name)` / `whereHas(name, constrain?)` / `doesntHave(name, constrain?)` —
14443
+ filter by relationship existence.
14444
+ - Terminals `get()`, `first()`, `count()`, `exists()`, `paginate(page?, perPage?)`.
14445
+
14446
+ Existence filters and counts use the same driver-agnostic two-query strategy as
14447
+ the relations (no JOIN). `toBase()` returns the underlying `QueryBuilder`.
14448
+
13795
14449
  ### Relations
13796
14450
 
13797
14451
  You never `new` these — a relationship method (`user.posts()`) returns one. Each
@@ -17377,6 +18031,189 @@ Optional capabilities: `metadata` / `copy` / `move` / `signedUrl` /
17377
18031
 
17378
18032
 
17379
18033
 
18034
+ ---
18035
+
18036
+ <!-- source: docs/teams.md -->
18037
+
18038
+ # Teams
18039
+
18040
+ Multi-tenancy, membership, roles, and invitations — where a row belongs to a team,
18041
+ and one team can never see another's.
18042
+
18043
+ ```ts
18044
+ // bootstrap/providers.ts
18045
+ import { TeamsServiceProvider } from "@shaferllc/keel/teams";
18046
+
18047
+ app.register(TeamsServiceProvider);
18048
+ ```
18049
+
18050
+ ```ts
18051
+ // app/Http/Kernel.ts
18052
+ import { teamContext } from "@shaferllc/keel/teams";
18053
+
18054
+ protected middleware = [sessionMiddleware(), teamContext()];
18055
+ ```
18056
+
18057
+ Then a tenant-owned model is one word:
18058
+
18059
+ ```ts
18060
+ import { TenantModel } from "@shaferllc/keel/teams";
18061
+
18062
+ class Post extends TenantModel {
18063
+ static table = "posts";
18064
+ }
18065
+
18066
+ await Post.all(); // only the current team's posts
18067
+ await Post.create({ title: "Hi" }); // stamped with the current team
18068
+ ```
18069
+
18070
+ ## Isolation is the default, not a habit
18071
+
18072
+ Two halves, and both matter.
18073
+
18074
+ **Reads** are constrained by a global scope on `TenantModel`, so every query the
18075
+ model builds carries the team — including `find()`. Naming another team's row by its
18076
+ id returns `null`, not that row. This is the difference between tenancy and a list
18077
+ filter: a filter you forget on one endpoint is a leak; a scope you never write can't
18078
+ be forgotten.
18079
+
18080
+ **Writes** are stamped by a `creating` hook, so a row cannot be born ownerless and
18081
+ end up visible to everyone (or to no one).
18082
+
18083
+ ## No team means an error, not "everything"
18084
+
18085
+ A queued job, a console command, a webhook, a seeder — none of them run inside a
18086
+ request, so none of them have a team. **A tenant query there throws.**
18087
+
18088
+ ```ts
18089
+ await Post.all();
18090
+ // Error: No team in context, so a tenant-scoped query can't be built safely.
18091
+ // Inside a request, add teamContext() to your middleware.
18092
+ // In a job, command, or seeder, wrap the work: runForTeam(team, () => …).
18093
+ // If it genuinely spans every team, say so: withoutTenant(() => …).
18094
+ ```
18095
+
18096
+ This is the security model, and the alternatives are worse:
18097
+
18098
+ | If no team meant… | Then |
18099
+ | --- | --- |
18100
+ | *unscoped* | every background job sees every tenant's rows — this is how customer A's invoice reaches customer B |
18101
+ | `teamId = NULL` | jobs match nothing, "work" fine, and quietly do nothing for a month |
18102
+ | **an error** | a job that forgot **crashes in development** instead of leaking in production |
18103
+
18104
+ So a job says which team it's for:
18105
+
18106
+ ```ts
18107
+ await runForTeam(team, () => sendInvoices());
18108
+ ```
18109
+
18110
+ ...or says, out loud, that it isn't for one:
18111
+
18112
+ ```ts
18113
+ await withoutTenant(() => Post.withoutGlobalScope(TENANT_SCOPE).get());
18114
+ ```
18115
+
18116
+ Both are named calls you can **grep for at audit time**. That's the point: crossing a
18117
+ tenant boundary should be something you typed, never something you arrived at by
18118
+ forgetting a `where`.
18119
+
18120
+ > Your jobs will crash until each one is wrapped. That friction is the feature — it
18121
+ > is a loud failure in development in exchange for not having a silent one in
18122
+ > production.
18123
+
18124
+ The context lives in `AsyncLocalStorage`, not a module global, so two concurrent
18125
+ requests can't see each other's team.
18126
+
18127
+ ## Teams and membership
18128
+
18129
+ ```ts
18130
+ const team = await createTeam("Acme", user.id); // creator becomes the owner
18131
+
18132
+ await teamsFor(user.id); // the teams a user is in
18133
+ await roleOf(user.id, team.id); // "owner" | "admin" | "member" | null
18134
+ await memberOf(user.id, team.id, "admin");
18135
+ await switchTeam(user.id, team.id); // false if they aren't a member
18136
+ ```
18137
+
18138
+ A user is in a team **if and only if a membership row says so**. `teams.owner_id` is a
18139
+ convenience, not an authorization source.
18140
+
18141
+ `switchTeam()` verifies membership, and so does `teamContext()` on every request —
18142
+ `users.current_team_id` is just a number on a row the user can influence, so it is
18143
+ checked, never trusted. Without that, switching teams would be a matter of writing
18144
+ someone else's id onto your own row.
18145
+
18146
+ `Team` and `Membership` are deliberately **not** tenant-scoped: "which teams am I in?"
18147
+ is a question you have to answer *before* you know which team you're in.
18148
+
18149
+ ## Roles
18150
+
18151
+ `owner` > `admin` > `member`, ordered — an owner can do anything an admin can.
18152
+
18153
+ ```ts
18154
+ router.delete("/posts/:post", …).middleware(requireRole("admin"));
18155
+ ```
18156
+
18157
+ ```ts
18158
+ roleAtLeast("owner", "admin"); // true
18159
+ roleAtLeast("member", "admin"); // false
18160
+ ```
18161
+
18162
+ ## Invitations
18163
+
18164
+ ```ts
18165
+ const { token } = await invite(team.id, "grace@example.com", "admin");
18166
+ await acceptInvitation(token, user.id, user.email);
18167
+
18168
+ await pendingInvitations(team.id);
18169
+ await revokeInvitation(id);
18170
+ ```
18171
+
18172
+ Unlike a password-reset link, an invitation **is** a database row — it has to be
18173
+ listable ("3 pending") and revocable, and you can't revoke a stateless token. Only the
18174
+ token's **hash** is stored, so a database leak doesn't open every pending team.
18175
+
18176
+ The invited address is re-checked on accept, so a **forwarded link doesn't let someone
18177
+ else join** in the invitee's place — which is the interesting attack on an invitation
18178
+ system. Invitations are single-use, expire (72h by default), and re-inviting the same
18179
+ address replaces the outstanding invitation rather than stacking duplicates.
18180
+
18181
+ ## Personal teams
18182
+
18183
+ On by default: every new user gets a team of their own, and a solo user is simply a
18184
+ team of one.
18185
+
18186
+ Worth leaving on even for an app that feels single-user. **Tenancy is not a feature
18187
+ you can add later** — bolting a `team_id` onto a schema that already has customer data
18188
+ means a backfill, a migration on every table, and rewriting every query. Ignoring a
18189
+ team you have costs one unused row. Needing a team you don't have costs a weekend.
18190
+
18191
+ ## Configuration
18192
+
18193
+ ```bash
18194
+ keel vendor:publish --tag teams-config
18195
+ ```
18196
+
18197
+ ```ts
18198
+ export default {
18199
+ userTable: "users",
18200
+ personalTeams: true,
18201
+ invitations: { expiresInHours: 72, url: "/invitations/:token" },
18202
+ };
18203
+ ```
18204
+
18205
+ ## The schema
18206
+
18207
+ | Table | |
18208
+ | --- | --- |
18209
+ | `teams` | name, slug, owner_id |
18210
+ | `team_memberships` | team_id, user_id, role — **unique per (team, user)**, enforced by the database |
18211
+ | `team_invitations` | team_id, email, role, token **hash**, expires_at |
18212
+
18213
+ Plus `current_team_id` on your users table.
18214
+
18215
+
18216
+
17380
18217
  ---
17381
18218
 
17382
18219
  <!-- source: docs/telemetry.md -->