@shaferllc/keel 0.79.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.
- package/README.md +3 -1
- package/dist/accounts/accounts.config.stub +50 -0
- package/dist/accounts/config.d.ts +46 -0
- package/dist/accounts/config.js +39 -0
- package/dist/accounts/flows.d.ts +50 -0
- package/dist/accounts/flows.js +133 -0
- package/dist/accounts/index.d.ts +28 -0
- package/dist/accounts/index.js +23 -0
- package/dist/accounts/migration.d.ts +14 -0
- package/dist/accounts/migration.js +39 -0
- package/dist/accounts/provider.d.ts +18 -0
- package/dist/accounts/provider.js +37 -0
- package/dist/accounts/routes.d.ts +15 -0
- package/dist/accounts/routes.js +116 -0
- package/dist/accounts/store.d.ts +33 -0
- package/dist/accounts/store.js +37 -0
- package/dist/accounts/tokens.d.ts +60 -0
- package/dist/accounts/tokens.js +116 -0
- package/dist/accounts/totp.d.ts +58 -0
- package/dist/accounts/totp.js +134 -0
- package/dist/accounts/two-factor.d.ts +56 -0
- package/dist/accounts/two-factor.js +146 -0
- package/dist/billing/billable.d.ts +83 -0
- package/dist/billing/billable.js +177 -0
- package/dist/billing/billing.config.stub +33 -0
- package/dist/billing/builder.d.ts +54 -0
- package/dist/billing/builder.js +104 -0
- package/dist/billing/config.d.ts +43 -0
- package/dist/billing/config.js +35 -0
- package/dist/billing/crypto.d.ts +11 -0
- package/dist/billing/crypto.js +27 -0
- package/dist/billing/drivers/fake.d.ts +58 -0
- package/dist/billing/drivers/fake.js +190 -0
- package/dist/billing/drivers/index.d.ts +11 -0
- package/dist/billing/drivers/index.js +22 -0
- package/dist/billing/drivers/paddle.d.ts +39 -0
- package/dist/billing/drivers/paddle.js +197 -0
- package/dist/billing/drivers/stripe.d.ts +33 -0
- package/dist/billing/drivers/stripe.js +278 -0
- package/dist/billing/events.d.ts +25 -0
- package/dist/billing/events.js +7 -0
- package/dist/billing/gateway.d.ts +170 -0
- package/dist/billing/gateway.js +24 -0
- package/dist/billing/index.d.ts +28 -0
- package/dist/billing/index.js +19 -0
- package/dist/billing/manager.d.ts +34 -0
- package/dist/billing/manager.js +61 -0
- package/dist/billing/migration.d.ts +13 -0
- package/dist/billing/migration.js +68 -0
- package/dist/billing/provider.d.ts +20 -0
- package/dist/billing/provider.js +42 -0
- package/dist/billing/routes.d.ts +11 -0
- package/dist/billing/routes.js +21 -0
- package/dist/billing/subscription-item.d.ts +18 -0
- package/dist/billing/subscription-item.js +11 -0
- package/dist/billing/subscription.d.ts +85 -0
- package/dist/billing/subscription.js +157 -0
- package/dist/billing/webhooks.d.ts +26 -0
- package/dist/billing/webhooks.js +75 -0
- package/dist/core/database.d.ts +36 -0
- package/dist/core/database.js +141 -4
- package/dist/core/index.d.ts +5 -2
- package/dist/core/index.js +3 -2
- package/dist/core/migrations.d.ts +52 -2
- package/dist/core/migrations.js +134 -3
- package/dist/core/model-events.d.ts +34 -0
- package/dist/core/model-events.js +71 -0
- package/dist/core/model-query.d.ts +68 -0
- package/dist/core/model-query.js +234 -0
- package/dist/core/model.d.ts +91 -4
- package/dist/core/model.js +217 -32
- package/dist/core/relations.d.ts +53 -0
- package/dist/core/relations.js +242 -0
- package/docs/accounts.md +214 -0
- package/docs/ai-manifest.json +70 -1
- package/docs/billing.md +242 -0
- package/docs/database.md +33 -0
- package/docs/examples/accounts.ts +150 -0
- package/docs/migrations.md +32 -3
- package/docs/models.md +133 -3
- package/docs/packages.md +3 -1
- package/llms-full.txt +671 -7
- package/llms.txt +3 -0
- 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 -->
|
|
@@ -5332,6 +5553,255 @@ all gates/policies/hooks (a test helper).
|
|
|
5332
5553
|
|
|
5333
5554
|
|
|
5334
5555
|
|
|
5556
|
+
---
|
|
5557
|
+
|
|
5558
|
+
<!-- source: docs/billing.md -->
|
|
5559
|
+
|
|
5560
|
+
# Billing
|
|
5561
|
+
|
|
5562
|
+
Keel Billing is a subscription-billing layer — a port of [Laravel
|
|
5563
|
+
Cashier](https://laravel.com/docs/13.x/billing) — for charging customers,
|
|
5564
|
+
managing subscriptions, and reconciling gateway state through webhooks. It ships
|
|
5565
|
+
as a Keel [package](./packages.md) and supports two gateways behind one API:
|
|
5566
|
+
**Stripe** and **Paddle**.
|
|
5567
|
+
|
|
5568
|
+
It attaches to a model with a mixin. Your `User` becomes billable, gains a
|
|
5569
|
+
gateway customer, and can create subscriptions and charges:
|
|
5570
|
+
|
|
5571
|
+
```ts
|
|
5572
|
+
import { Model } from "@shaferllc/keel/core";
|
|
5573
|
+
import { Billable } from "@shaferllc/keel/billing";
|
|
5574
|
+
|
|
5575
|
+
export class User extends Billable(Model) {
|
|
5576
|
+
static table = "users";
|
|
5577
|
+
declare email: string;
|
|
5578
|
+
}
|
|
5579
|
+
```
|
|
5580
|
+
|
|
5581
|
+
## Install
|
|
5582
|
+
|
|
5583
|
+
```ts
|
|
5584
|
+
// bootstrap/providers.ts
|
|
5585
|
+
import { BillingServiceProvider } from "@shaferllc/keel/billing";
|
|
5586
|
+
|
|
5587
|
+
export const providers = [AppServiceProvider, BillingServiceProvider];
|
|
5588
|
+
```
|
|
5589
|
+
|
|
5590
|
+
Publish the config and create the tables:
|
|
5591
|
+
|
|
5592
|
+
```bash
|
|
5593
|
+
keel vendor:publish --tag billing-config # writes config/billing.ts
|
|
5594
|
+
keel migrate # creates subscriptions + subscription_items,
|
|
5595
|
+
# and adds billing columns to users
|
|
5596
|
+
```
|
|
5597
|
+
|
|
5598
|
+
Set your keys in `.env`:
|
|
5599
|
+
|
|
5600
|
+
```ini
|
|
5601
|
+
BILLING_GATEWAY=stripe # or "paddle"
|
|
5602
|
+
STRIPE_SECRET_KEY=sk_...
|
|
5603
|
+
STRIPE_WEBHOOK_SECRET=whsec_...
|
|
5604
|
+
# Paddle:
|
|
5605
|
+
PADDLE_API_KEY=...
|
|
5606
|
+
PADDLE_WEBHOOK_SECRET=...
|
|
5607
|
+
PADDLE_CLIENT_TOKEN=...
|
|
5608
|
+
PADDLE_SANDBOX=true
|
|
5609
|
+
```
|
|
5610
|
+
|
|
5611
|
+
## One API, two gateways
|
|
5612
|
+
|
|
5613
|
+
Everything you call goes through a gateway-neutral interface, so switching from
|
|
5614
|
+
Stripe to Paddle is a config change. The active gateway comes from
|
|
5615
|
+
`config("billing.default")`; a billable can also carry its own in
|
|
5616
|
+
`billing_gateway`. Money is always an integer in the smallest currency unit
|
|
5617
|
+
(cents). See [Gateway differences](#gateway-differences) for where Paddle's
|
|
5618
|
+
merchant-of-record model diverges.
|
|
5619
|
+
|
|
5620
|
+
## Customers
|
|
5621
|
+
|
|
5622
|
+
A gateway customer is created lazily the first time you need one, but you can
|
|
5623
|
+
create it up front:
|
|
5624
|
+
|
|
5625
|
+
```ts
|
|
5626
|
+
await user.createAsCustomer(); // creates the customer, stores its id
|
|
5627
|
+
user.hasBillingId(); // true
|
|
5628
|
+
await user.getCustomerId(); // the id (creates if missing)
|
|
5629
|
+
```
|
|
5630
|
+
|
|
5631
|
+
Override what gets synced by defining `billingName()` / `billingEmail()` on your
|
|
5632
|
+
model. By default they read `name` / `email`.
|
|
5633
|
+
|
|
5634
|
+
## Subscriptions
|
|
5635
|
+
|
|
5636
|
+
Build a subscription with the fluent builder:
|
|
5637
|
+
|
|
5638
|
+
```ts
|
|
5639
|
+
await user
|
|
5640
|
+
.newSubscription("default", "price_pro")
|
|
5641
|
+
.trialDays(14)
|
|
5642
|
+
.quantity(3)
|
|
5643
|
+
.create(paymentMethodId); // paymentMethodId optional if a default is on file
|
|
5644
|
+
```
|
|
5645
|
+
|
|
5646
|
+
Multiple prices (add-ons) are an array; `withMetadata`, `trialUntil`, and
|
|
5647
|
+
`skipTrial` are also available. To send the customer to a hosted checkout
|
|
5648
|
+
instead of charging now, swap `.create()` for `.checkout()`:
|
|
5649
|
+
|
|
5650
|
+
```ts
|
|
5651
|
+
const session = await user
|
|
5652
|
+
.newSubscription("default", "price_pro")
|
|
5653
|
+
.checkout({ successUrl: "...", cancelUrl: "..." });
|
|
5654
|
+
// Stripe: redirect to session.url. Paddle: open the overlay with session.clientToken.
|
|
5655
|
+
```
|
|
5656
|
+
|
|
5657
|
+
### Status
|
|
5658
|
+
|
|
5659
|
+
Status questions are answered from local columns — no gateway round-trip:
|
|
5660
|
+
|
|
5661
|
+
```ts
|
|
5662
|
+
await user.subscribed(); // valid (active | trial | grace)
|
|
5663
|
+
await user.subscribedToPrice("price_pro");
|
|
5664
|
+
await user.onTrial();
|
|
5665
|
+
const sub = await user.subscription(); // the "default" subscription, or null
|
|
5666
|
+
|
|
5667
|
+
sub.active(); sub.onTrial(); sub.recurring();
|
|
5668
|
+
sub.canceled(); sub.onGracePeriod(); sub.ended();
|
|
5669
|
+
sub.paused(); sub.valid(); sub.hasIncompletePayment();
|
|
5670
|
+
```
|
|
5671
|
+
|
|
5672
|
+
### Changing a subscription
|
|
5673
|
+
|
|
5674
|
+
```ts
|
|
5675
|
+
await sub.swap("price_enterprise"); // change price(s)
|
|
5676
|
+
await sub.updateQuantity(10);
|
|
5677
|
+
await sub.incrementQuantity(2);
|
|
5678
|
+
await sub.decrementQuantity();
|
|
5679
|
+
```
|
|
5680
|
+
|
|
5681
|
+
Each of these calls the gateway and syncs the result back into the local row.
|
|
5682
|
+
|
|
5683
|
+
### Cancelling
|
|
5684
|
+
|
|
5685
|
+
```ts
|
|
5686
|
+
await sub.cancel(); // at period end — access continues through the grace period
|
|
5687
|
+
await sub.onGracePeriod();// true
|
|
5688
|
+
await sub.resume(); // revive a subscription still in its grace period
|
|
5689
|
+
await sub.cancelNow(); // immediately; sub.ended() becomes true
|
|
5690
|
+
```
|
|
5691
|
+
|
|
5692
|
+
### Trials
|
|
5693
|
+
|
|
5694
|
+
```ts
|
|
5695
|
+
await sub.endTrial();
|
|
5696
|
+
await sub.extendTrial(new Date("2026-01-01"));
|
|
5697
|
+
user.onGenericTrial(); // a trial_ends_at on the user, before any subscription
|
|
5698
|
+
```
|
|
5699
|
+
|
|
5700
|
+
## Single charges
|
|
5701
|
+
|
|
5702
|
+
```ts
|
|
5703
|
+
const charge = await user.charge(2000, { paymentMethod: "pm_1", description: "Credits" });
|
|
5704
|
+
await user.refund(charge.id); // full refund
|
|
5705
|
+
await user.refund(charge.id, 500); // partial
|
|
5706
|
+
|
|
5707
|
+
const session = await user.checkout("price_onetime", { successUrl, cancelUrl });
|
|
5708
|
+
```
|
|
5709
|
+
|
|
5710
|
+
## Payment methods (Stripe)
|
|
5711
|
+
|
|
5712
|
+
Collect a card with a SetupIntent, then create the subscription with the
|
|
5713
|
+
resulting payment method:
|
|
5714
|
+
|
|
5715
|
+
```ts
|
|
5716
|
+
const intent = await user.createSetupIntent(); // return intent.clientSecret to the front end
|
|
5717
|
+
const methods = await user.paymentMethods();
|
|
5718
|
+
```
|
|
5719
|
+
|
|
5720
|
+
These are Stripe-only capabilities; calling them on the Paddle gateway throws a
|
|
5721
|
+
`BillingError` (Paddle collects cards in its own hosted checkout).
|
|
5722
|
+
|
|
5723
|
+
## Invoices
|
|
5724
|
+
|
|
5725
|
+
```ts
|
|
5726
|
+
const invoices = await user.invoices(); // GatewayInvoice[] — total, currency, status, url
|
|
5727
|
+
```
|
|
5728
|
+
|
|
5729
|
+
## Webhooks
|
|
5730
|
+
|
|
5731
|
+
The package mounts one webhook endpoint per gateway at
|
|
5732
|
+
`config("billing.webhook.path")`:
|
|
5733
|
+
|
|
5734
|
+
```
|
|
5735
|
+
POST /billing/webhook/stripe
|
|
5736
|
+
POST /billing/webhook/paddle
|
|
5737
|
+
```
|
|
5738
|
+
|
|
5739
|
+
Point your gateway dashboard at the matching URL. Each request is verified
|
|
5740
|
+
against the gateway's signing secret (HMAC-SHA256 over the raw body), the local
|
|
5741
|
+
subscription is synced, and typed events fire:
|
|
5742
|
+
|
|
5743
|
+
```ts
|
|
5744
|
+
import { listen } from "@shaferllc/keel/core";
|
|
5745
|
+
|
|
5746
|
+
listen("billing.subscription.updated", (e) => {
|
|
5747
|
+
// e.gateway, e.subscriptionId, e.providerId, e.status
|
|
5748
|
+
});
|
|
5749
|
+
listen("billing.webhook.received", (e) => { /* e.gateway, e.type, e.id */ });
|
|
5750
|
+
```
|
|
5751
|
+
|
|
5752
|
+
Events: `billing.webhook.received`, `billing.subscription.created` / `.updated`
|
|
5753
|
+
/ `.deleted`.
|
|
5754
|
+
|
|
5755
|
+
An update to a subscription already in your database is always synced. A brand
|
|
5756
|
+
new subscription born from a Paddle checkout has no local row yet — register a
|
|
5757
|
+
resolver so the handler can create it:
|
|
5758
|
+
|
|
5759
|
+
```ts
|
|
5760
|
+
import { resolveBillableUsing } from "@shaferllc/keel/billing";
|
|
5761
|
+
|
|
5762
|
+
resolveBillableUsing(async (customerId) => {
|
|
5763
|
+
const user = (await User.query().where("billing_customer_id", customerId).first());
|
|
5764
|
+
return user ? { id: user.id, type: "User" } : null;
|
|
5765
|
+
});
|
|
5766
|
+
```
|
|
5767
|
+
|
|
5768
|
+
## Gateway differences
|
|
5769
|
+
|
|
5770
|
+
| Concern | Stripe | Paddle |
|
|
5771
|
+
|---------|--------|--------|
|
|
5772
|
+
| Create a subscription server-side | `create(pmId)` | Not supported — use `checkout()`; the webhook creates the local row |
|
|
5773
|
+
| One-off `charge()` | Confirms a PaymentIntent | Not supported — use `checkout({ mode })` / transactions |
|
|
5774
|
+
| SetupIntent / `paymentMethods()` | Supported | Throws `BillingError` (hosted checkout) |
|
|
5775
|
+
| Checkout handle | `session.url` (redirect) | `session.clientToken` (overlay/inline) |
|
|
5776
|
+
| Webhook signature | `Stripe-Signature: t=…,v1=…` | `Paddle-Signature: ts=…;h1=…` |
|
|
5777
|
+
|
|
5778
|
+
## Schema
|
|
5779
|
+
|
|
5780
|
+
The migration is gateway-neutral: `subscriptions` (with `gateway`,
|
|
5781
|
+
`provider_id`, `provider_status`, `provider_price`, trial/grace timestamps),
|
|
5782
|
+
`subscription_items`, and columns on `users` (`billing_gateway`,
|
|
5783
|
+
`billing_customer_id`, `pm_type`, `pm_last_four`, `trial_ends_at`). Cashier
|
|
5784
|
+
targets the standard `users` billable table.
|
|
5785
|
+
|
|
5786
|
+
## Testing
|
|
5787
|
+
|
|
5788
|
+
The package ships a `FakeGateway` — an in-memory gateway that records every call
|
|
5789
|
+
— so you can drive billing without touching a network:
|
|
5790
|
+
|
|
5791
|
+
```ts
|
|
5792
|
+
import { BillingManager, setBilling, FakeGateway } from "@shaferllc/keel/billing";
|
|
5793
|
+
|
|
5794
|
+
const fake = new FakeGateway();
|
|
5795
|
+
const manager = new BillingManager(config); // config.default = "fake"
|
|
5796
|
+
manager.register("fake", () => fake);
|
|
5797
|
+
setBilling(manager);
|
|
5798
|
+
|
|
5799
|
+
await user.newSubscription("default", "price_pro").create();
|
|
5800
|
+
fake.calls.filter((c) => c.method === "createSubscription"); // assert what was asked
|
|
5801
|
+
```
|
|
5802
|
+
|
|
5803
|
+
|
|
5804
|
+
|
|
5335
5805
|
---
|
|
5336
5806
|
|
|
5337
5807
|
<!-- source: docs/broadcasting.md -->
|
|
@@ -7324,6 +7794,23 @@ await db("posts").latest().get(); // ORDER BY created_at DESC
|
|
|
7324
7794
|
await db("posts").oldest("published_at").get();
|
|
7325
7795
|
```
|
|
7326
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
|
+
|
|
7327
7814
|
## Aggregates, single values, and pagination
|
|
7328
7815
|
|
|
7329
7816
|
```ts
|
|
@@ -7353,6 +7840,22 @@ Everything is parameterized — values become bindings, never string-interpolate
|
|
|
7353
7840
|
SQL — so it's injection-safe by construction. Writes return a `WriteResult`;
|
|
7354
7841
|
`insertGetId` unwraps it to just the new id.
|
|
7355
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
|
+
|
|
7356
7859
|
> **Guard your writes.** `update()` and `delete()` apply to every row that
|
|
7357
7860
|
> matches the current `where` clause — with no `where`, that's the whole table.
|
|
7358
7861
|
> Always scope a write with `where` unless you truly mean to touch every row.
|
|
@@ -12496,12 +12999,41 @@ t.boolean("active").default(true); // sqlite: DEFAULT 1, else DEFAULT true
|
|
|
12496
12999
|
t.integer("retries").default(0); // ... DEFAULT 0
|
|
12497
13000
|
```
|
|
12498
13001
|
|
|
12499
|
-
|
|
12500
|
-
|
|
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:
|
|
12501
13020
|
|
|
12502
13021
|
```ts
|
|
12503
13022
|
up: (schema) =>
|
|
12504
|
-
schema.
|
|
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)"),
|
|
12505
13037
|
```
|
|
12506
13038
|
|
|
12507
13039
|
> `raw()` writes through the connection **without** placeholder conversion, so
|
|
@@ -13079,6 +13611,85 @@ return json(user); // works directly — json() serializes it
|
|
|
13079
13611
|
user.fill({ name: "X" }); // merge mass-assignable attributes without saving
|
|
13080
13612
|
```
|
|
13081
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
|
+
|
|
13082
13693
|
## Relationships
|
|
13083
13694
|
|
|
13084
13695
|
Define a relationship as a method that returns one of `hasMany` / `hasOne` /
|
|
@@ -13128,6 +13739,29 @@ users[0].toJSON(); // includes `posts` and `roles`
|
|
|
13128
13739
|
Loaded relations are stored off the model, so they never leak into `save()`,
|
|
13129
13740
|
and `toJSON()` serializes them (nested models included).
|
|
13130
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
|
+
|
|
13131
13765
|
### Many-to-many
|
|
13132
13766
|
|
|
13133
13767
|
`belongsToMany` reads through a pivot table (default name: the two table names
|
|
@@ -13148,11 +13782,39 @@ this.belongsTo(User, "owner_id", "id");
|
|
|
13148
13782
|
this.belongsToMany(Role, "user_roles", "user_id", "role_id");
|
|
13149
13783
|
```
|
|
13150
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
|
+
|
|
13151
13812
|
## What this is (and isn't)
|
|
13152
13813
|
|
|
13153
|
-
This is a
|
|
13154
|
-
|
|
13155
|
-
(`
|
|
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
|
|
13156
13818
|
you can always drop to `db()` or your driver directly.
|
|
13157
13819
|
|
|
13158
13820
|
---
|
|
@@ -14329,7 +14991,9 @@ adds the conventions a *shippable* package needs so it can carry its own schema
|
|
|
14329
14991
|
and assets instead of asking the app to wire them by hand.
|
|
14330
14992
|
|
|
14331
14993
|
[Keel Watch](./watch.md) — the debug dashboard — is a first-party package and the
|
|
14332
|
-
reference implementation of everything below.
|
|
14994
|
+
reference implementation of everything below. [Billing](./billing.md) (a Cashier
|
|
14995
|
+
port for Stripe and Paddle) is another, and shows a package contributing models,
|
|
14996
|
+
a schema migration, gateway drivers, and verified webhook routes.
|
|
14333
14997
|
|
|
14334
14998
|
## The shape of a package
|
|
14335
14999
|
|