@shaferllc/keel 0.83.1 → 0.83.3

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/docs/changelog.md CHANGED
@@ -4,6 +4,37 @@ All notable changes to Keel are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims to
5
5
  adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.83.3] — 2026-07-12
8
+
9
+ ### Added
10
+
11
+ - **`@shaferllc/keel/gates`** — signup gating for private alpha / waitlist:
12
+ email allowlist + invite codes (`canRegister` / `redeemInvite`), shipped as a
13
+ package with a migration. Used by Keel Cloud; not the same as team invitations
14
+ or authorization gates.
15
+ - **`@shaferllc/keel/hosting`** — Cloudflare client, hostname helpers, SQL dump,
16
+ and secrets encryption helpers for hosted Workers/D1 apps.
17
+
18
+ [0.83.3]: https://github.com/shaferllc/keel/releases/tag/v0.83.3
19
+
20
+ ## [0.83.2] — 2026-07-12
21
+
22
+ ### Added
23
+
24
+ - **More runnable doc examples.** Billing, ORM, query builder, CORS, security,
25
+ social auth, OpenAPI, Watch, packages, Hono, and starter kits now have
26
+ typechecked harnesses under `docs/examples/` — the same ones
27
+ `npm run typecheck:docs` compiles against the published surface.
28
+ - **Worked examples in the guides** — a complete billing subscribe flow (with
29
+ `FakeGateway`), an ORM CRUD+eager-load walkthrough, a production CORS recipe,
30
+ and a clearer starter-kit picker.
31
+
32
+ ### Changed
33
+
34
+ - Watch no longer describes itself by another product's name.
35
+
36
+ [0.83.2]: https://github.com/shaferllc/keel/releases/tag/v0.83.2
37
+
7
38
  ## [0.83.0] — 2026-07-12
8
39
 
9
40
  ### Added
package/docs/cors.md CHANGED
@@ -21,6 +21,34 @@ router.group(() => { /* … */ }).use(cors({ origin: ["https://app.example.com"]
21
21
  With no options, `cors()` reflects the caller's origin — convenient in
22
22
  development. **Lock it down in production** with an explicit allowlist.
23
23
 
24
+ ## A production API group
25
+
26
+ Typical setup for a JSON API served from `api.example.com` and called from a
27
+ SPA on `app.example.com`:
28
+
29
+ ```ts
30
+ // app/Http/Kernel.ts
31
+ import { cors } from "@shaferllc/keel/core";
32
+
33
+ this.use(
34
+ cors({
35
+ origin: ["https://app.example.com"],
36
+ credentials: true, // cookies / Authorization
37
+ exposeHeaders: ["X-Request-Id"],
38
+ }),
39
+ );
40
+ ```
41
+
42
+ During local development, allow any localhost port with a predicate:
43
+
44
+ ```ts
45
+ cors({
46
+ origin: (origin) =>
47
+ origin.startsWith("http://localhost:") || origin === "https://app.example.com",
48
+ credentials: true,
49
+ });
50
+ ```
51
+
24
52
  ## Options
25
53
 
26
54
  ```ts
@@ -0,0 +1,128 @@
1
+ // Type-check harness for docs/billing.md. Compile-only — never executed.
2
+ import { Model, listen } from "@shaferllc/keel/core";
3
+ import {
4
+ Billable,
5
+ BillingServiceProvider,
6
+ BillingManager,
7
+ FakeGateway,
8
+ setBilling,
9
+ resolveBillableUsing,
10
+ type CheckoutSession,
11
+ type GatewayCharge,
12
+ } from "@shaferllc/keel/billing";
13
+
14
+ export class User extends Billable(Model) {
15
+ static override table = "users";
16
+ declare id: number;
17
+ declare email: string;
18
+ declare name: string;
19
+ }
20
+
21
+ declare const user: User;
22
+ declare const paymentMethodId: string;
23
+ declare const successUrl: string;
24
+ declare const cancelUrl: string;
25
+
26
+ export const providers = [BillingServiceProvider];
27
+
28
+ /* -------------------------------- customers ------------------------------- */
29
+
30
+ export async function customers() {
31
+ await user.createAsCustomer();
32
+ user.hasBillingId();
33
+ await user.getCustomerId();
34
+ }
35
+
36
+ /* ------------------------------ subscriptions ----------------------------- */
37
+
38
+ export async function subscribe() {
39
+ await user
40
+ .newSubscription("default", "price_pro")
41
+ .trialDays(14)
42
+ .quantity(3)
43
+ .create(paymentMethodId);
44
+
45
+ const session: CheckoutSession = await user
46
+ .newSubscription("default", "price_pro")
47
+ .checkout({ successUrl, cancelUrl });
48
+ return session;
49
+ }
50
+
51
+ export async function status() {
52
+ await user.subscribed();
53
+ await user.subscribedToPrice("price_pro");
54
+ await user.onTrial();
55
+ const sub = await user.subscription();
56
+ if (!sub) return;
57
+
58
+ sub.active();
59
+ sub.onTrial();
60
+ sub.recurring();
61
+ sub.canceled();
62
+ sub.onGracePeriod();
63
+ sub.ended();
64
+ sub.paused();
65
+ sub.valid();
66
+ sub.hasIncompletePayment();
67
+
68
+ await sub.swap("price_enterprise");
69
+ await sub.updateQuantity(10);
70
+ await sub.incrementQuantity(2);
71
+ await sub.decrementQuantity();
72
+ await sub.cancel();
73
+ await sub.resume();
74
+ await sub.cancelNow();
75
+ await sub.endTrial();
76
+ await sub.extendTrial(new Date("2026-01-01"));
77
+ }
78
+
79
+ /* -------------------------------- charges --------------------------------- */
80
+
81
+ export async function charges() {
82
+ const charge: GatewayCharge = await user.charge(2000, {
83
+ paymentMethod: "pm_1",
84
+ description: "Credits",
85
+ });
86
+ await user.refund(charge.id);
87
+ await user.refund(charge.id, 500);
88
+ await user.checkout("price_onetime", { successUrl, cancelUrl });
89
+ await user.createSetupIntent();
90
+ await user.paymentMethods();
91
+ await user.invoices();
92
+ }
93
+
94
+ /* -------------------------------- webhooks -------------------------------- */
95
+
96
+ export function webhooks() {
97
+ listen("billing.subscription.updated", (e) => {
98
+ void e.gateway;
99
+ void e.subscriptionId;
100
+ });
101
+ listen("billing.webhook.received", (e) => {
102
+ void e.gateway;
103
+ void e.type;
104
+ });
105
+ resolveBillableUsing(async (customerId, _gateway) => {
106
+ return { id: user.id, type: "User" };
107
+ });
108
+ }
109
+
110
+ /* --------------------------------- testing -------------------------------- */
111
+
112
+ export function fakeGateway() {
113
+ const fake = new FakeGateway();
114
+ const manager = new BillingManager({
115
+ default: "fake",
116
+ currency: "usd",
117
+ billableModel: "User",
118
+ webhook: { path: "billing/webhook" },
119
+ gateways: {
120
+ stripe: { key: "", webhookSecret: "" },
121
+ paddle: { key: "", webhookSecret: "" },
122
+ fake: {},
123
+ },
124
+ });
125
+ manager.register("fake", () => fake);
126
+ setBilling(manager);
127
+ return fake;
128
+ }
@@ -0,0 +1,29 @@
1
+ // Type-check harness for docs/cors.md. Compile-only — never executed.
2
+ import type { MiddlewareHandler } from "hono";
3
+ import { cors, type CorsOptions } from "@shaferllc/keel/core";
4
+
5
+ export function kernel(): MiddlewareHandler {
6
+ return cors();
7
+ }
8
+
9
+ export function apiGroup(): MiddlewareHandler {
10
+ return cors({ origin: ["https://app.example.com"] });
11
+ }
12
+
13
+ export function production(): MiddlewareHandler {
14
+ const options: CorsOptions = {
15
+ origin: ["https://app.example.com"],
16
+ methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
17
+ headers: true,
18
+ exposeHeaders: ["X-Request-Id"],
19
+ credentials: true,
20
+ maxAge: 86400,
21
+ };
22
+ return cors(options);
23
+ }
24
+
25
+ export function dynamicOrigin(): MiddlewareHandler {
26
+ return cors({
27
+ origin: (origin) => origin.endsWith(".example.com") || origin.startsWith("http://localhost"),
28
+ });
29
+ }
@@ -0,0 +1,30 @@
1
+ // Type-check harness for docs/gates.md. Compile-only — never executed.
2
+ import { Application, json } from "@shaferllc/keel/core";
3
+ import {
4
+ GatesServiceProvider,
5
+ InviteCode,
6
+ EmailAllowlist,
7
+ canRegister,
8
+ redeemInvite,
9
+ } from "@shaferllc/keel/gates";
10
+
11
+ export function install() {
12
+ const app = new Application();
13
+ app.register(GatesServiceProvider);
14
+ return app;
15
+ }
16
+
17
+ export async function register(email: string, inviteCode?: string) {
18
+ const gate = await canRegister(email, inviteCode);
19
+ if (!gate.ok) return json({ error: gate.reason }, 403);
20
+
21
+ if (gate.via === "code" && gate.invite) {
22
+ await redeemInvite(gate.invite);
23
+ }
24
+ return json({ ok: true, via: gate.via });
25
+ }
26
+
27
+ export async function seed() {
28
+ await InviteCode.create({ code: "ALPHA-42", max_uses: 10, uses: 0, expires_at: null });
29
+ await EmailAllowlist.create({ email: "ada@example.com" });
30
+ }
@@ -0,0 +1,29 @@
1
+ // Type-check harness for docs/hono.md. Compile-only — never executed.
2
+ import type { Ctx } from "@shaferllc/keel/core";
3
+ import { Router, json } from "@shaferllc/keel/core";
4
+
5
+ export function withContext(router: Router) {
6
+ router.get("/users/:id", async (c: Ctx) => {
7
+ const id = c.req.param("id");
8
+ const q = c.req.query("q");
9
+ const auth = c.req.header("authorization");
10
+ void q;
11
+ void auth;
12
+ return c.json({ id });
13
+ });
14
+
15
+ router.get("/ping", (c) => c.text("pong"));
16
+ router.get("/go", (c) => c.redirect("/"));
17
+ }
18
+
19
+ export function ambientHelpers() {
20
+ return json({ ok: true });
21
+ }
22
+
23
+ export function keelContext(c: Ctx) {
24
+ return {
25
+ app: c.get("app"),
26
+ route: c.get("route"),
27
+ subdomains: c.get("subdomains"),
28
+ };
29
+ }
@@ -0,0 +1,35 @@
1
+ // Type-check harness for docs/openapi.md. Compile-only — never executed.
2
+ import { z } from "zod";
3
+ import { Application, Router, validateRequest, type Ctx } from "@shaferllc/keel/core";
4
+ import { OpenApiServiceProvider, apiDoc, OpenApi } from "@shaferllc/keel/openapi";
5
+
6
+ const NewUser = z.object({ email: z.string().email(), age: z.number().min(18) });
7
+ const UserShape = z.object({ id: z.number(), email: z.string().email() });
8
+
9
+ class Users {
10
+ store(_c: Ctx) {
11
+ return { id: 1, email: "a@b.com" };
12
+ }
13
+ }
14
+
15
+ export function install() {
16
+ const app = new Application();
17
+ app.register(OpenApiServiceProvider);
18
+ OpenApi.auth((c) => c.req.header("x-docs-key") === "secret");
19
+ return app;
20
+ }
21
+
22
+ export function document(router: Router) {
23
+ router
24
+ .post("/users", [Users, "store"])
25
+ .name("users.store")
26
+ .config(
27
+ apiDoc({
28
+ summary: "Create a user",
29
+ tags: ["users"],
30
+ request: { body: NewUser },
31
+ responses: { 201: { description: "The created user", schema: UserShape } },
32
+ }),
33
+ )
34
+ .middleware([validateRequest({ body: NewUser })]);
35
+ }
@@ -0,0 +1,30 @@
1
+ // Type-check harness for docs/orm.md. Compile-only — never executed.
2
+ import { Model } from "@shaferllc/keel/core";
3
+
4
+ class Post extends Model {
5
+ static override table = "posts";
6
+ declare id: number;
7
+ declare title: string;
8
+ declare user_id: number;
9
+ }
10
+
11
+ export class User extends Model {
12
+ static override table = "users";
13
+ declare id: number;
14
+ declare email: string;
15
+
16
+ posts() {
17
+ return this.hasMany(Post);
18
+ }
19
+ }
20
+
21
+ export async function map() {
22
+ const user = await User.find(1);
23
+ if (!user) return;
24
+ await user.posts();
25
+ await User.with("posts").where("email", user.email).first();
26
+ await User.create({ email: "ada@example.com" });
27
+ user.email = "grace@example.com";
28
+ await user.save();
29
+ await user.delete();
30
+ }
@@ -0,0 +1,26 @@
1
+ // Type-check harness for docs/packages.md. Compile-only — never executed.
2
+ import { PackageProvider, type Router, type Migration, type PackageCommand } from "@shaferllc/keel/core";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+
6
+ const here = dirname(fileURLToPath(import.meta.url));
7
+
8
+ declare const createInvoicesTable: Migration;
9
+ declare const syncInvoicesCommand: PackageCommand;
10
+ declare function registerBillingRoutes(r: Router): void;
11
+
12
+ export class BillingServiceProvider extends PackageProvider {
13
+ readonly name = "billing";
14
+
15
+ register(): void {
16
+ this.mergeConfig("billing", { enabled: true, path: "billing" });
17
+ this.migrations([createInvoicesTable]);
18
+ this.publishes({ [join(here, "config.stub")]: "config/billing.ts" }, "billing-config");
19
+ this.commands([syncInvoicesCommand]);
20
+ }
21
+
22
+ boot(): void {
23
+ this.assets("billing/assets", join(here, "ui/dist"), { maxAge: 3600 });
24
+ this.routes((r: Router) => registerBillingRoutes(r), { prefix: "billing", as: "billing" });
25
+ }
26
+ }
@@ -0,0 +1,118 @@
1
+ // Type-check harness for docs/query-builder.md. Compile-only — never executed.
2
+ import { db, type Row } from "@shaferllc/keel/core";
3
+
4
+ declare const email: string;
5
+ declare const name: string;
6
+ declare const id: number;
7
+ declare const now: number;
8
+ declare const search: string | undefined;
9
+ declare const includeArchived: boolean;
10
+ declare const key: string;
11
+ declare const value: string;
12
+
13
+ export async function retrieving() {
14
+ await db("users").get();
15
+ await db("users").where("id", 1).first();
16
+ await db("users").where("id", 1).firstOrFail();
17
+ await db("users").find(1);
18
+ await db("users").where("email", email).sole();
19
+ await db("users").where("id", 1).value("email");
20
+ await db("posts").pluck("title");
21
+ await db("tags").orderBy("name").implode("name", ", ");
22
+
23
+ await db("users")
24
+ .orderBy("id")
25
+ .chunk(500, async (rows) => {
26
+ for (const _row of rows) {
27
+ /* process */
28
+ }
29
+ });
30
+ }
31
+
32
+ export async function aggregates() {
33
+ await db("orders").count();
34
+ await db("orders").where("paid", true).sum("total");
35
+ await db("orders").avg("total");
36
+ await db("orders").min("total");
37
+ await db("orders").max("total");
38
+ await db("users").where("email", email).exists();
39
+ await db("users").where("banned", true).doesntExist();
40
+ }
41
+
42
+ export async function selectsAndWheres() {
43
+ db("users").select("id", "email");
44
+ db("users").select("id").addSelect("email");
45
+ db("orders").selectRaw("SUM(total) AS revenue");
46
+ db("users").distinct().select("country");
47
+
48
+ db("users").where("votes", 100);
49
+ db("users").where("votes", ">=", 100);
50
+ db("users").where("name", "like", "T%");
51
+ db("users").where("votes", 100).orWhere("name", "John");
52
+ db("users").whereNot("status", "cancelled");
53
+ db("users").whereIn("id", [1, 2, 3]).whereNotIn("id", [4]);
54
+ db("users").whereNull("deleted_at").whereNotNull("email_verified_at");
55
+ db("products").whereBetween("price", [10, 100]).whereNotBetween("stock", [0, 5]);
56
+ db("posts").whereLike("title", "%keel%");
57
+ db("events").whereColumn("updated_at", ">", "created_at");
58
+ db("users").whereRaw("score >= ? AND score <= ?", [10, 90]);
59
+
60
+ await db("users")
61
+ .where("active", true)
62
+ .where((q) => q.where("role", "admin").orWhere("role", "owner"))
63
+ .get();
64
+ }
65
+
66
+ export async function orderingJoinsWhen() {
67
+ db("users").orderBy("name").orderByDesc("created_at");
68
+ db("posts").latest();
69
+ db("posts").oldest();
70
+ db("posts").orderByRaw("LENGTH(title) DESC");
71
+ db("users").inRandomOrder();
72
+ db("users").reorder("name");
73
+ db("users").limit(10).offset(20);
74
+ db("users").take(10).skip(20);
75
+ db("users").forPage(3, 15);
76
+
77
+ await db("orders")
78
+ .select("user_id")
79
+ .selectRaw("SUM(total) AS spent")
80
+ .groupBy("user_id")
81
+ .having("spent", ">", 1000)
82
+ .get();
83
+
84
+ await db("posts")
85
+ .join("users", "posts.user_id", "users.id")
86
+ .leftJoin("images", "images.post_id", "posts.id")
87
+ .select("posts.title", "users.name")
88
+ .get();
89
+
90
+ await db("users")
91
+ .when(search, (q, term) => q.whereLike("name", `%${term}%`))
92
+ .unless(includeArchived, (q) => q.whereNull("archived_at"))
93
+ .get();
94
+ }
95
+
96
+ export async function writes() {
97
+ await db("users").insert({ email, name });
98
+ await db("users").insertGetId({ email, name });
99
+ await db("logs").insertOrIgnore({ key, value });
100
+ await db("users").upsert([{ id: 1, name: "Ada" }], ["id"], ["name"]);
101
+
102
+ await db("users").where("id", id).update({ name: "Grace" });
103
+ await db("users").updateOrInsert({ email }, { name });
104
+ await db("posts").where("id", id).increment("views");
105
+ await db("posts").where("id", id).decrement("stock", 3, { updated_at: now });
106
+ await db("counters").incrementEach({ hits: 1, misses: 2 });
107
+
108
+ await db("sessions").where("expires_at", "<", now).delete();
109
+ await db("cache").truncate();
110
+ }
111
+
112
+ export async function typed() {
113
+ type User = { id: number; email: string };
114
+ const row: User | null = await db<User>("users").where("id", 1).first();
115
+ const rows: User[] = await db<User>("users").get();
116
+ const raw: Row[] = await db("users").get();
117
+ return { row, rows, raw };
118
+ }
@@ -0,0 +1,31 @@
1
+ // Type-check harness for docs/security.md. Compile-only — never executed.
2
+ import type { MiddlewareHandler } from "hono";
3
+ import {
4
+ securityHeaders,
5
+ csrf,
6
+ csrfField,
7
+ csrfToken,
8
+ sessionMiddleware,
9
+ } from "@shaferllc/keel/core";
10
+
11
+ export function headers(): MiddlewareHandler[] {
12
+ return [
13
+ securityHeaders(),
14
+ securityHeaders({
15
+ csp: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "https://cdn.example.com"] },
16
+ hsts: { maxAge: 15552000, includeSubDomains: true },
17
+ frameGuard: "DENY",
18
+ }),
19
+ ];
20
+ }
21
+
22
+ export function csrfStack(): MiddlewareHandler[] {
23
+ return [sessionMiddleware(), csrf(), csrf({ except: ["/billing/webhook/*"] })];
24
+ }
25
+
26
+ export function forms() {
27
+ return {
28
+ field: csrfField(),
29
+ token: csrfToken(),
30
+ };
31
+ }
@@ -0,0 +1,76 @@
1
+ // Type-check harness for docs/social-auth.md. Compile-only — never executed.
2
+ import {
3
+ social,
4
+ session,
5
+ redirect,
6
+ request,
7
+ auth,
8
+ ForbiddenException,
9
+ Model,
10
+ config,
11
+ } from "@shaferllc/keel/core";
12
+
13
+ class User extends Model {
14
+ static override table = "users";
15
+ declare id: number;
16
+ declare email: string | null;
17
+ declare name: string | null;
18
+ declare github_id: string;
19
+ declare twitter_id: string;
20
+ }
21
+
22
+ const github = social.github({
23
+ clientId: config("services.github.id") as string,
24
+ clientSecret: config("services.github.secret") as string,
25
+ redirectUri: "https://app.example.com/auth/github/callback",
26
+ });
27
+
28
+ const twitter = social.twitter({
29
+ clientId: config("services.twitter.key") as string,
30
+ clientSecret: config("services.twitter.secret") as string,
31
+ redirectUri: "https://app.example.com/auth/twitter/callback",
32
+ });
33
+
34
+ export function redirectToGithub() {
35
+ const state = social.state();
36
+ session().put("oauth_state", state);
37
+ return redirect(github.redirect({ state }));
38
+ }
39
+
40
+ export async function githubCallback() {
41
+ const state = String(request.query("state") ?? "");
42
+ if (state !== session().pull("oauth_state")) {
43
+ throw new ForbiddenException("Invalid OAuth state");
44
+ }
45
+
46
+ const code = String(request.query("code") ?? "");
47
+ const gh = await github.user(code);
48
+ const existing = await User.query().where("github_id", gh.id).first();
49
+ const user =
50
+ existing ??
51
+ (await User.create({ github_id: gh.id, email: gh.email, name: gh.name }));
52
+
53
+ auth().login(user.id as number);
54
+ return redirect("/dashboard");
55
+ }
56
+
57
+ export async function twitterFlow() {
58
+ const requestToken = await twitter.requestToken();
59
+ session().put("twitter_secret", requestToken.tokenSecret);
60
+ const toProvider = redirect(twitter.redirect(requestToken));
61
+
62
+ const tw = await twitter.user(
63
+ String(request.query("oauth_token") ?? ""),
64
+ String(request.query("oauth_verifier") ?? ""),
65
+ String(session().pull("twitter_secret") ?? ""),
66
+ );
67
+ const user = await User.firstOrCreate({ twitter_id: tw.id }, { name: tw.name });
68
+ auth().login(user.id as number);
69
+ return { toProvider, user };
70
+ }
71
+
72
+ export async function tokenReuse() {
73
+ const code = String(request.query("code") ?? "");
74
+ const token = await github.exchangeCode(code);
75
+ return github.userFromToken(token);
76
+ }
@@ -0,0 +1,14 @@
1
+ // Type-check harness for docs/starter-kits.md. Compile-only — never executed.
2
+ import { TenantModel } from "@shaferllc/keel/teams";
3
+
4
+ class Project extends TenantModel {
5
+ static override table = "projects";
6
+ declare id: number;
7
+ declare name: string;
8
+ }
9
+
10
+ export async function tenantScoped() {
11
+ await Project.all();
12
+ await Project.create({ name: "Hi" });
13
+ await Project.find(1); // null if another team's
14
+ }
@@ -0,0 +1,10 @@
1
+ // Type-check harness for docs/watch.md. Compile-only — never executed.
2
+ import { Application } from "@shaferllc/keel/core";
3
+ import { WatchServiceProvider, Watch } from "@shaferllc/keel/watch";
4
+
5
+ export function install() {
6
+ const app = new Application();
7
+ app.register(WatchServiceProvider);
8
+ Watch.auth((c) => c.req.header("x-watch-key") === "secret");
9
+ return app;
10
+ }