@shaferllc/keel 0.80.0 → 0.81.0

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 (44) 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 +71 -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 +91 -4
  33. package/dist/core/model.js +217 -32
  34. package/dist/core/relations.d.ts +53 -0
  35. package/dist/core/relations.js +242 -0
  36. package/docs/accounts.md +214 -0
  37. package/docs/ai-manifest.json +63 -1
  38. package/docs/database.md +33 -0
  39. package/docs/examples/accounts.ts +150 -0
  40. package/docs/migrations.md +32 -3
  41. package/docs/models.md +133 -3
  42. package/llms-full.txt +419 -6
  43. package/llms.txt +2 -0
  44. package/package.json +6 -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.
@@ -12745,12 +12999,41 @@ t.boolean("active").default(true); // sqlite: DEFAULT 1, else DEFAULT true
12745
12999
  t.integer("retries").default(0); // ... DEFAULT 0
12746
13000
  ```
12747
13001
 
12748
- For anything the builder doesn't cover — indexes, foreign keys, an `ALTER
12749
- TABLE` — `schema.raw(sql, bindings?)` runs arbitrary SQL:
13002
+ ### Indexes and foreign keys
13003
+
13004
+ `createTable` builds indexes and foreign keys alongside the columns:
13005
+
13006
+ ```ts
13007
+ schema.createTable("members", (t) => {
13008
+ t.id();
13009
+ t.integer("team_id");
13010
+ t.string("email");
13011
+ t.uniqueIndex("email"); // or t.index(["a", "b"]) for composite
13012
+ t.foreign("team_id").references("id").on("teams").onDelete("cascade");
13013
+ });
13014
+ ```
13015
+
13016
+ ### Altering a table
13017
+
13018
+ `schema.alterTable(name, build)` adds, renames, and drops columns and indexes on
13019
+ an existing table (dialect-aware SQL). Drop an index before the column it covers:
12750
13020
 
12751
13021
  ```ts
12752
13022
  up: (schema) =>
12753
- schema.raw("CREATE INDEX idx_posts_user ON posts (user_id)"),
13023
+ schema.alterTable("users", (t) => {
13024
+ t.string("phone").nullable(); // ADD COLUMN
13025
+ t.renameColumn("name", "full_name");
13026
+ t.index("phone");
13027
+ t.dropIndex("users_legacy_index");
13028
+ t.dropColumn("legacy");
13029
+ }),
13030
+ ```
13031
+
13032
+ For anything the builder still doesn't cover, `schema.raw(sql, bindings?)` runs
13033
+ arbitrary SQL:
13034
+
13035
+ ```ts
13036
+ up: (schema) => schema.raw("CREATE INDEX idx_posts_user ON posts (user_id)"),
12754
13037
  ```
12755
13038
 
12756
13039
  > `raw()` writes through the connection **without** placeholder conversion, so
@@ -13328,6 +13611,85 @@ return json(user); // works directly — json() serializes it
13328
13611
  user.fill({ name: "X" }); // merge mass-assignable attributes without saving
13329
13612
  ```
13330
13613
 
13614
+ Control what `toJSON()` exposes with three statics. `hidden` strips columns;
13615
+ `visible` is an allowlist that wins over everything; `appends` adds computed
13616
+ attributes — a getter or a zero-arg method on the model:
13617
+
13618
+ ```ts
13619
+ class User extends Model {
13620
+ static table = "users";
13621
+ static hidden = ["password"]; // never serialized
13622
+ static appends = ["fullName"]; // added to the output
13623
+ get fullName() { return `${this.first} ${this.last}`; }
13624
+ }
13625
+ ```
13626
+
13627
+ ## Lifecycle events
13628
+
13629
+ A model fires events as it is retrieved, saved, and deleted. Hook onto them to
13630
+ slug a title, bust a cache, or cascade — without touching every call site. The
13631
+ `*ing` events are **cancelable**: a hook returning `false` aborts the write.
13632
+
13633
+ ```ts
13634
+ User.creating((user) => { user.uuid = crypto.randomUUID(); });
13635
+ User.saved((user) => cache().forget(`user:${user.id}`));
13636
+ User.deleting((user) => (user.isRoot ? false : undefined)); // veto
13637
+
13638
+ // Or group them in an observer:
13639
+ User.observe({
13640
+ creating: (u) => { u.uuid = crypto.randomUUID(); },
13641
+ deleted: (u) => audit(`deleted ${u.id}`),
13642
+ });
13643
+ ```
13644
+
13645
+ Events: `retrieved`, `creating`/`created`, `updating`/`updated`,
13646
+ `saving`/`saved`, `deleting`/`deleted`, `restoring`/`restored`. They're keyed by
13647
+ the exact class (subclasses don't inherit a parent's hooks).
13648
+
13649
+ ## Query scopes
13650
+
13651
+ A **global scope** constrains every query a model builds — the base for
13652
+ multi-tenancy, published-only reads, and soft deletes:
13653
+
13654
+ ```ts
13655
+ Post.addGlobalScope("published", (q) => q.where("published", true));
13656
+ await Post.all(); // only published
13657
+ await Post.query().where("author_id", 1).get(); // still only published
13658
+ ```
13659
+
13660
+ A **local scope** is just a static method returning a query — no framework
13661
+ feature needed:
13662
+
13663
+ ```ts
13664
+ class Post extends Model {
13665
+ static popular() { return this.query().where("views", ">", 1000); }
13666
+ }
13667
+ await Post.popular().orderBy("views", "desc").get();
13668
+ ```
13669
+
13670
+ ## Soft deletes
13671
+
13672
+ Opt in with `static softDeletes = true` and a `deleted_at` column. `delete()`
13673
+ then sets the timestamp instead of removing the row, and a global scope hides
13674
+ soft-deleted rows from every query.
13675
+
13676
+ ```ts
13677
+ class User extends Model {
13678
+ static table = "users";
13679
+ static softDeletes = true;
13680
+ static casts = { deleted_at: "date" };
13681
+ }
13682
+
13683
+ await user.delete(); // sets deleted_at; row stays in the table
13684
+ user.trashed(); // true
13685
+ await User.find(user.id); // null — hidden by the scope
13686
+
13687
+ await User.withTrashed().get(); // include soft-deleted
13688
+ await User.onlyTrashed().get(); // only soft-deleted
13689
+ await user.restore(); // clear deleted_at
13690
+ await user.forceDelete(); // remove the row for good
13691
+ ```
13692
+
13331
13693
  ## Relationships
13332
13694
 
13333
13695
  Define a relationship as a method that returns one of `hasMany` / `hasOne` /
@@ -13377,6 +13739,29 @@ users[0].toJSON(); // includes `posts` and `roles`
13377
13739
  Loaded relations are stored off the model, so they never leak into `save()`,
13378
13740
  and `toJSON()` serializes them (nested models included).
13379
13741
 
13742
+ ### Querying relationships (`with`, `withCount`, `whereHas`)
13743
+
13744
+ `Model.query()` returns a model-aware builder with the relationship operations a
13745
+ raw query can't express. `with()` eager-loads (dotted paths nest), `withCount()`
13746
+ adds a `<relation>_count`, and `has`/`whereHas`/`doesntHave` filter by whether a
13747
+ related row exists:
13748
+
13749
+ ```ts
13750
+ const users = await User.query()
13751
+ .where("active", true)
13752
+ .with("posts.comments") // nested eager load
13753
+ .withCount("posts") // users[i].posts_count
13754
+ .whereHas("posts", (q) => q.where("published", true))
13755
+ .get();
13756
+
13757
+ await User.has("posts").get(); // users with at least one post
13758
+ await User.doesntHave("posts").get(); // users with none
13759
+ ```
13760
+
13761
+ `with`/`withCount`/`whereHas`/`has`/`doesntHave` are also static shortcuts
13762
+ (`User.with(...)`, `User.whereHas(...)`). Existence filters use the same
13763
+ driver-agnostic two-query strategy as the relations themselves — no JOIN.
13764
+
13380
13765
  ### Many-to-many
13381
13766
 
13382
13767
  `belongsToMany` reads through a pivot table (default name: the two table names
@@ -13397,11 +13782,39 @@ this.belongsTo(User, "owner_id", "id");
13397
13782
  this.belongsToMany(Role, "user_roles", "user_id", "role_id");
13398
13783
  ```
13399
13784
 
13785
+ ### Polymorphic
13786
+
13787
+ A polymorphic relation lets one model belong to more than one type. The related
13788
+ rows carry `<name>_id` + `<name>_type`; register each owner type so `morphTo`
13789
+ can resolve it:
13790
+
13791
+ ```ts
13792
+ class Post extends Model {
13793
+ comments() { return this.morphMany(Comment, "commentable"); }
13794
+ }
13795
+ class Video extends Model {
13796
+ comments() { return this.morphMany(Comment, "commentable"); }
13797
+ }
13798
+ class Comment extends Model {
13799
+ commentable() { return this.morphTo("commentable"); } // resolves back to Post or Video
13800
+ }
13801
+
13802
+ registerMorphType("Post", Post);
13803
+ registerMorphType("Video", Video);
13804
+
13805
+ await post.comments().create({ body: "nice" }); // sets commentable_id/_type
13806
+ const owner = await comment.commentable(); // Post | Video | null
13807
+ ```
13808
+
13809
+ `morphOne` is the one-to-one variant. Eager loading (`Model.load` / `with`) works
13810
+ across mixed types.
13811
+
13400
13812
  ## What this is (and isn't)
13401
13813
 
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
13814
+ This is a compact active-record — CRUD, lifecycle events, scopes, soft deletes,
13815
+ serialization control, eager loading (including nested `with("posts.comments")`),
13816
+ relationship queries (`whereHas`/`withCount`), and polymorphic relations all on
13817
+ a driver-agnostic query builder, no ORM dependency. For complex one-off queries
13405
13818
  you can always drop to `db()` or your driver directly.
13406
13819
 
13407
13820
  ---
package/llms.txt CHANGED
@@ -9,6 +9,7 @@ Userland imports everything from `@shaferllc/keel/core`.
9
9
 
10
10
  ## Docs
11
11
 
12
+ - [Accounts](https://github.com/shaferllc/keel/blob/main/docs/accounts.md): Password reset, email verification, and two-factor authentication — the flows every app with a login needs, built on primitives already in core (hash, encryption, mail, rate-limit).
12
13
  - [Building Keel apps with AI](https://github.com/shaferllc/keel/blob/main/docs/ai.md): Keel is built to be written with an AI agent.
13
14
  - [API Resources](https://github.com/shaferllc/keel/blob/main/docs/api-resources.md): apiResource(router, Model, options) generates a full CRUD REST API from a Keel model — explicit, server-side, and composed from pieces you already have.
14
15
  - [Architecture](https://github.com/shaferllc/keel/blob/main/docs/architecture.md): Keel is small on purpose. This page maps the pieces and traces a request from socket to response. Nothing here is magic — every layer is a short, readable file in src/core/, and this guide is mostly a reading order for it.
@@ -73,6 +74,7 @@ Userland imports everything from `@shaferllc/keel/core`.
73
74
 
74
75
  Every topic has a runnable, type-checked example:
75
76
 
77
+ - [Accounts example](https://github.com/shaferllc/keel/blob/main/docs/examples/accounts.ts)
76
78
  - [API Resources example](https://github.com/shaferllc/keel/blob/main/docs/examples/api-resources.ts)
77
79
  - [Authentication example](https://github.com/shaferllc/keel/blob/main/docs/examples/authentication.ts)
78
80
  - [Authorization example](https://github.com/shaferllc/keel/blob/main/docs/examples/authorization.ts)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.80.0",
3
+ "version": "0.81.0",
4
4
  "type": "module",
5
5
  "description": "The house framework for Node.js — a service container, providers, routing, JSX views, and a code-generating console.",
6
6
  "license": "MIT",
@@ -61,6 +61,10 @@
61
61
  "types": "./dist/api/index.d.ts",
62
62
  "import": "./dist/api/index.js"
63
63
  },
64
+ "./accounts": {
65
+ "types": "./dist/accounts/index.d.ts",
66
+ "import": "./dist/accounts/index.js"
67
+ },
64
68
  "./billing": {
65
69
  "types": "./dist/billing/index.d.ts",
66
70
  "import": "./dist/billing/index.js"
@@ -93,7 +97,7 @@
93
97
  "test:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-exclude='tests/**' tests/*.test.ts",
94
98
  "build:ai": "tsx scripts/build-ai.ts",
95
99
  "build:watch-ui": "vite build --config src/watch/ui/vite.config.ts",
96
- "build:watch-copy": "mkdir -p dist/watch/ui && cp -R src/watch/ui/dist dist/watch/ui/dist && cp src/watch/watch.config.stub dist/watch/ && cp src/openapi/openapi.config.stub dist/openapi/ && cp src/api/api.config.stub dist/api/ && cp src/billing/billing.config.stub dist/billing/",
100
+ "build:watch-copy": "mkdir -p dist/watch/ui && cp -R src/watch/ui/dist dist/watch/ui/dist && cp src/watch/watch.config.stub dist/watch/ && cp src/openapi/openapi.config.stub dist/openapi/ && cp src/api/api.config.stub dist/api/ && cp src/billing/billing.config.stub dist/billing/ && cp src/accounts/accounts.config.stub dist/accounts/",
97
101
  "build": "npm run build:ai && npm run build:watch-ui && rm -rf dist && tsc -p tsconfig.build.json && npm run build:watch-copy",
98
102
  "prepare": "npm run build",
99
103
  "typecheck:tests": "tsc --noEmit -p tsconfig.tests.json",