@shaferllc/keel 0.81.2 → 0.83.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 (121) hide show
  1. package/dist/core/database.d.ts +83 -0
  2. package/dist/core/database.js +248 -4
  3. package/dist/core/index.d.ts +1 -1
  4. package/dist/db/d1-http.d.ts +34 -0
  5. package/dist/db/d1-http.js +61 -0
  6. package/dist/db/libsql.d.ts +13 -3
  7. package/dist/teams/models.js +51 -5
  8. package/docs/ai-manifest.json +27 -1
  9. package/docs/database.md +5 -348
  10. package/docs/models.md +5 -2
  11. package/docs/orm.md +57 -0
  12. package/docs/query-builder.md +533 -0
  13. package/docs/starter-kits.md +88 -0
  14. package/llms-full.txt +4599 -4241
  15. package/llms.txt +3 -0
  16. package/package.json +7 -2
  17. package/templates/api/.env.example +12 -0
  18. package/templates/api/app/Controllers/PostController.ts +37 -0
  19. package/templates/api/app/Http/Kernel.ts +13 -0
  20. package/templates/api/app/Http/Middleware/requestLogger.ts +8 -0
  21. package/templates/api/app/Models/Post.ts +12 -0
  22. package/templates/api/app/Providers/AppServiceProvider.ts +8 -0
  23. package/templates/api/app/Providers/DatabaseServiceProvider.ts +52 -0
  24. package/templates/api/bin/keel.ts +15 -0
  25. package/templates/api/bootstrap/app.ts +24 -0
  26. package/templates/api/bootstrap/providers.edge.ts +14 -0
  27. package/templates/api/bootstrap/providers.ts +7 -0
  28. package/templates/api/config/app.ts +10 -0
  29. package/templates/api/config/database.ts +42 -0
  30. package/templates/api/database/migrations/0001_create_posts.ts +20 -0
  31. package/templates/api/package.json +30 -0
  32. package/templates/api/routes/web.ts +12 -0
  33. package/templates/api/tests/posts.test.ts +30 -0
  34. package/templates/api/tsconfig.json +16 -0
  35. package/templates/api/worker.ts +33 -0
  36. package/templates/api/wrangler.jsonc +22 -0
  37. package/templates/app/.env.example +12 -0
  38. package/templates/app/app/Controllers/AuthController.ts +117 -0
  39. package/templates/app/app/Controllers/DashboardController.ts +37 -0
  40. package/templates/app/app/Controllers/HomeController.ts +10 -0
  41. package/templates/app/app/Http/Kernel.ts +21 -0
  42. package/templates/app/app/Http/Middleware/requestLogger.ts +8 -0
  43. package/templates/app/app/Models/User.ts +15 -0
  44. package/templates/app/app/Providers/AppServiceProvider.ts +12 -0
  45. package/templates/app/app/Providers/DatabaseServiceProvider.ts +52 -0
  46. package/templates/app/bin/keel.ts +15 -0
  47. package/templates/app/bootstrap/app.ts +24 -0
  48. package/templates/app/bootstrap/providers.edge.ts +15 -0
  49. package/templates/app/bootstrap/providers.ts +18 -0
  50. package/templates/app/config/app.ts +10 -0
  51. package/templates/app/config/database.ts +42 -0
  52. package/templates/app/database/migrations/0001_create_users.ts +21 -0
  53. package/templates/app/package.json +35 -0
  54. package/templates/app/public/.gitkeep +2 -0
  55. package/templates/app/resources/css/app.css +1 -0
  56. package/templates/app/resources/views/auth/forgot.tsx +30 -0
  57. package/templates/app/resources/views/auth/login.tsx +24 -0
  58. package/templates/app/resources/views/auth/register.tsx +24 -0
  59. package/templates/app/resources/views/auth/two-factor.tsx +22 -0
  60. package/templates/app/resources/views/dashboard.tsx +20 -0
  61. package/templates/app/resources/views/layout.tsx +15 -0
  62. package/templates/app/resources/views/welcome.tsx +29 -0
  63. package/templates/app/routes/web.ts +35 -0
  64. package/templates/app/tests/auth.test.ts +47 -0
  65. package/templates/app/tsconfig.json +31 -0
  66. package/templates/app/worker.ts +33 -0
  67. package/templates/app/wrangler.jsonc +25 -0
  68. package/templates/minimal/.env.example +8 -0
  69. package/templates/minimal/app/Controllers/HomeController.ts +14 -0
  70. package/templates/minimal/app/Http/Kernel.ts +22 -0
  71. package/templates/minimal/app/Http/Middleware/requestLogger.ts +8 -0
  72. package/templates/minimal/app/Providers/AppServiceProvider.ts +8 -0
  73. package/templates/minimal/bin/keel.ts +15 -0
  74. package/templates/minimal/bootstrap/app.ts +24 -0
  75. package/templates/minimal/bootstrap/providers.ts +6 -0
  76. package/templates/minimal/config/app.ts +10 -0
  77. package/templates/minimal/package.json +29 -0
  78. package/templates/minimal/public/.gitkeep +2 -0
  79. package/templates/minimal/resources/css/app.css +1 -0
  80. package/templates/minimal/resources/views/layout.tsx +15 -0
  81. package/templates/minimal/resources/views/welcome.tsx +15 -0
  82. package/templates/minimal/routes/web.ts +13 -0
  83. package/templates/minimal/tsconfig.json +18 -0
  84. package/templates/minimal/worker.ts +23 -0
  85. package/templates/minimal/wrangler.jsonc +10 -0
  86. package/templates/saas/.env.example +12 -0
  87. package/templates/saas/app/Controllers/AuthController.ts +125 -0
  88. package/templates/saas/app/Controllers/DashboardController.ts +37 -0
  89. package/templates/saas/app/Controllers/HomeController.ts +10 -0
  90. package/templates/saas/app/Controllers/TeamController.ts +88 -0
  91. package/templates/saas/app/Http/Kernel.ts +27 -0
  92. package/templates/saas/app/Http/Middleware/requestLogger.ts +8 -0
  93. package/templates/saas/app/Models/Project.ts +20 -0
  94. package/templates/saas/app/Models/User.ts +15 -0
  95. package/templates/saas/app/Providers/AppServiceProvider.ts +12 -0
  96. package/templates/saas/app/Providers/DatabaseServiceProvider.ts +52 -0
  97. package/templates/saas/bin/keel.ts +15 -0
  98. package/templates/saas/bootstrap/app.ts +24 -0
  99. package/templates/saas/bootstrap/providers.edge.ts +18 -0
  100. package/templates/saas/bootstrap/providers.ts +22 -0
  101. package/templates/saas/config/app.ts +10 -0
  102. package/templates/saas/config/database.ts +42 -0
  103. package/templates/saas/database/migrations/0001_create_users.ts +21 -0
  104. package/templates/saas/database/migrations/0002_create_projects.ts +20 -0
  105. package/templates/saas/package.json +35 -0
  106. package/templates/saas/public/.gitkeep +2 -0
  107. package/templates/saas/resources/css/app.css +1 -0
  108. package/templates/saas/resources/views/auth/forgot.tsx +30 -0
  109. package/templates/saas/resources/views/auth/login.tsx +24 -0
  110. package/templates/saas/resources/views/auth/register.tsx +24 -0
  111. package/templates/saas/resources/views/auth/two-factor.tsx +22 -0
  112. package/templates/saas/resources/views/dashboard.tsx +20 -0
  113. package/templates/saas/resources/views/layout.tsx +15 -0
  114. package/templates/saas/resources/views/teams/index.tsx +85 -0
  115. package/templates/saas/resources/views/welcome.tsx +29 -0
  116. package/templates/saas/routes/web.ts +44 -0
  117. package/templates/saas/tests/auth.test.ts +47 -0
  118. package/templates/saas/tests/teams.test.ts +27 -0
  119. package/templates/saas/tsconfig.json +31 -0
  120. package/templates/saas/worker.ts +33 -0
  121. package/templates/saas/wrangler.jsonc +25 -0
package/llms.txt CHANGED
@@ -46,9 +46,11 @@ Userland imports everything from `@shaferllc/keel/core`.
46
46
  - [Models](https://github.com/shaferllc/keel/blob/main/docs/models.md): Model is a tiny active-record layer over the query builder.
47
47
  - [Notifications](https://github.com/shaferllc/keel/blob/main/docs/notifications.md): Send a message to a recipient over one or more channels — mail, database, or your own — inline or through the queue.
48
48
  - [OpenAPI](https://github.com/shaferllc/keel/blob/main/docs/openapi.md): Keel OpenAPI generates an OpenAPI 3 spec from your routes and serves Swagger UI to explore it.
49
+ - [ORM](https://github.com/shaferllc/keel/blob/main/docs/orm.md): Keel's ORM is a compact active record over the query builder: a model is a class pointed at a table, and its rows come back as typed objects with methods.
49
50
  - [Packages](https://github.com/shaferllc/keel/blob/main/docs/packages.md): A package is a redistributable slice of a Keel app — routes, a UI, config, migrations, console commands — that installs with a single app.register(...).
50
51
  - [Pages](https://github.com/shaferllc/keel/blob/main/docs/pages.md): Page-based routing — a file is a route.
51
52
  - [Service Providers](https://github.com/shaferllc/keel/blob/main/docs/providers.md): Service providers are the central place to configure your application.
53
+ - [Query Builder](https://github.com/shaferllc/keel/blob/main/docs/query-builder.md): Keel's driver-agnostic query builder — build and run SQL by chaining methods off db(table).
52
54
  - [Queues & Jobs](https://github.com/shaferllc/keel/blob/main/docs/queues.md): Move slow work — sending mail, calling an API, processing an upload — off the request path.
53
55
  - [Rate Limiting](https://github.com/shaferllc/keel/blob/main/docs/rate-limiting.md): rateLimiter() is a middleware that caps how many requests a client can make in a window.
54
56
  - [Redis](https://github.com/shaferllc/keel/blob/main/docs/redis.md): A Redis integration built on a small pluggable driver — like the database and mail layers, the core imports no client, so it runs on Node and on the edge.
@@ -58,6 +60,7 @@ Userland imports everything from `@shaferllc/keel/core`.
58
60
  - [Securing SSR apps](https://github.com/shaferllc/keel/blob/main/docs/security.md): Two middlewares harden server-rendered apps: securityHeaders() sets the defensive HTTP headers browsers act on, and csrf() blocks cross-site form submissions.
59
61
  - [Sessions](https://github.com/shaferllc/keel/blob/main/docs/sessions.md): Keel ships a cookie-backed session store. There's no external service to run, so it works the same on Node and on the edge. Session data lives in an HTTP-only cookie: the middleware reads it before your handler runs and writes it back afterward.
60
62
  - [Social authentication](https://github.com/shaferllc/keel/blob/main/docs/social-auth.md): "Sign in with GitHub / Google / Discord" — OAuth 2.0, without an SDK.
63
+ - [Starter kits](https://github.com/shaferllc/keel/blob/main/docs/starter-kits.md)
61
64
  - [Static Files](https://github.com/shaferllc/keel/blob/main/docs/static-files.md): serveStatic() serves files from a directory (default public/) before your routes run.
62
65
  - [Storage](https://github.com/shaferllc/keel/blob/main/docs/storage.md): File storage over a pluggable disk — like the database and mail layers, the core imports no filesystem or SDK, so it runs on Node and the edge.
63
66
  - [Teams](https://github.com/shaferllc/keel/blob/main/docs/teams.md): Multi-tenancy, membership, roles, and invitations — where a row belongs to a team, and one team can never see another's.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaferllc/keel",
3
- "version": "0.81.2",
3
+ "version": "0.83.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",
@@ -37,6 +37,10 @@
37
37
  "types": "./dist/db/d1.d.ts",
38
38
  "import": "./dist/db/d1.js"
39
39
  },
40
+ "./db/d1-http": {
41
+ "types": "./dist/db/d1-http.d.ts",
42
+ "import": "./dist/db/d1-http.js"
43
+ },
40
44
  "./db/pg": {
41
45
  "types": "./dist/db/pg.d.ts",
42
46
  "import": "./dist/db/pg.js"
@@ -88,7 +92,8 @@
88
92
  "docs",
89
93
  "AGENTS.md",
90
94
  "llms.txt",
91
- "llms-full.txt"
95
+ "llms-full.txt",
96
+ "templates"
92
97
  ],
93
98
  "publishConfig": {
94
99
  "access": "public"
@@ -0,0 +1,12 @@
1
+ APP_NAME="Keel API"
2
+ APP_ENV=local
3
+ APP_DEBUG=true
4
+ APP_URL=http://localhost:3000
5
+ APP_PORT=3000
6
+
7
+ # Signs tokens and encrypted payloads. Generate one: keel key:generate
8
+ APP_KEY=
9
+
10
+ # A file is enough to start; point this at Postgres when you outgrow it.
11
+ DB_CONNECTION=sqlite
12
+ DB_URL=file:./database.sqlite
@@ -0,0 +1,37 @@
1
+ import type { Ctx } from "@shaferllc/keel/core";
2
+ import { validate } from "@shaferllc/keel/core";
3
+ import { z } from "zod";
4
+
5
+ import { Post } from "../Models/Post.js";
6
+
7
+ /** Keel doesn't ship a validator — anything with `safeParse` works. */
8
+ const NewPost = z.object({
9
+ title: z.string().min(1),
10
+ body: z.string().default(""),
11
+ });
12
+
13
+ export class PostController {
14
+ async index(c: Ctx) {
15
+ return c.json(await Post.all());
16
+ }
17
+
18
+ async show(c: Ctx) {
19
+ // Throws a 404 on its own, so there's no null check to forget.
20
+ const post = await Post.findOrFail(Number(c.req.param("post")));
21
+ return c.json(post);
22
+ }
23
+
24
+ async store(c: Ctx) {
25
+ // A failed validation is a 422 with the field errors — you don't handle it here.
26
+ const data = await validate(NewPost, await c.req.json());
27
+
28
+ return c.json(await Post.create(data), 201);
29
+ }
30
+
31
+ async destroy(c: Ctx) {
32
+ const post = await Post.findOrFail(Number(c.req.param("post")));
33
+ await post.delete();
34
+
35
+ return c.body(null, 204);
36
+ }
37
+ }
@@ -0,0 +1,13 @@
1
+ import { HttpKernel } from "@shaferllc/keel/core";
2
+ import type { Application } from "@shaferllc/keel/core";
3
+
4
+ import { requestLogger } from "./Middleware/requestLogger.js";
5
+
6
+ /** Global middleware — runs on every request, in order. */
7
+ export class Kernel extends HttpKernel {
8
+ constructor(app: Application) {
9
+ super(app);
10
+
11
+ this.use(requestLogger);
12
+ }
13
+ }
@@ -0,0 +1,8 @@
1
+ import type { Ctx } from "@shaferllc/keel/core";
2
+
3
+ /** Log every request with its status and how long it took. */
4
+ export async function requestLogger(c: Ctx, next: () => Promise<void>): Promise<void> {
5
+ const started = Date.now();
6
+ await next();
7
+ console.log(`${c.req.method} ${c.req.path} ${c.res.status} ${Date.now() - started}ms`);
8
+ }
@@ -0,0 +1,12 @@
1
+ import { Model } from "@shaferllc/keel/core";
2
+
3
+ export class Post extends Model {
4
+ static override table = "posts";
5
+ // An allowlist, so a request body can't set columns you didn't intend.
6
+ static override fillable = ["title", "body"];
7
+ static override timestamps = true;
8
+
9
+ declare id: number;
10
+ declare title: string;
11
+ declare body: string;
12
+ }
@@ -0,0 +1,8 @@
1
+ import { ServiceProvider } from "@shaferllc/keel/core";
2
+
3
+ /** Bind your own services here. */
4
+ export class AppServiceProvider extends ServiceProvider {
5
+ register(): void {}
6
+
7
+ boot(): void {}
8
+ }
@@ -0,0 +1,52 @@
1
+ import { ServiceProvider, config, setConnection } from "@shaferllc/keel/core";
2
+
3
+ /**
4
+ * Opens the connection every model and query builder reads through — on **Node**.
5
+ * (In the Worker, worker.ts binds D1 directly and this provider isn't loaded.)
6
+ *
7
+ * Switching database is `DB_CONNECTION` and nothing else: no model or query changes,
8
+ * because they all talk to a `Connection` rather than to a driver.
9
+ *
10
+ * `d1` here means the HTTP API rather than the binding — which is what lets
11
+ * `keel migrate` reach your real D1 database from your laptop and from CI, where no
12
+ * binding exists.
13
+ */
14
+ export class DatabaseServiceProvider extends ServiceProvider {
15
+ async register(): Promise<void> {
16
+ const name = config<string>("database.default", "sqlite");
17
+
18
+ if (name === "d1") {
19
+ const { d1HttpConnection } = await import("@shaferllc/keel/db/d1-http");
20
+
21
+ setConnection(
22
+ d1HttpConnection({
23
+ accountId: config<string>("database.connections.d1.accountId", ""),
24
+ databaseId: config<string>("database.connections.d1.databaseId", ""),
25
+ apiToken: config<string>("database.connections.d1.apiToken", ""),
26
+ }),
27
+ "sqlite",
28
+ );
29
+ return;
30
+ }
31
+
32
+ if (name === "postgres") {
33
+ const { pgConnection } = await import("@shaferllc/keel/db/pg");
34
+ const { Pool } = await import("pg");
35
+
36
+ setConnection(
37
+ pgConnection(new Pool({ connectionString: config<string>("database.connections.postgres.url", "") })),
38
+ "postgres",
39
+ );
40
+ return;
41
+ }
42
+
43
+ // sqlite (a local file) or turso (libSQL over the network) — same driver.
44
+ const { libsqlConnection } = await import("@shaferllc/keel/db/libsql");
45
+ const { createClient } = await import("@libsql/client");
46
+
47
+ const url = config<string>(`database.connections.${name}.url`, "file:./database.sqlite");
48
+ const authToken = config<string>(`database.connections.${name}.authToken`, "");
49
+
50
+ setConnection(libsqlConnection(createClient(authToken ? { url, authToken } : { url })), "sqlite");
51
+ }
52
+ }
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * The application console. `npm run keel <command>`.
4
+ *
5
+ * The commands — serve, routes, repl, migrate:*, every make:* — come from the
6
+ * framework. This file only says how to build *your* application.
7
+ */
8
+ import { run } from "@shaferllc/keel/cli";
9
+
10
+ import { createApplication } from "../bootstrap/app.js";
11
+
12
+ run(process.argv, { createApplication }).catch((error) => {
13
+ console.error(error);
14
+ process.exit(1);
15
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Application bootstrap. Creates the container, boots providers, loads routes.
3
+ * The server, the console, and the Worker all enter through here.
4
+ */
5
+
6
+ import { Application, Router, HttpKernel, type ProviderClass } from "@shaferllc/keel/core";
7
+
8
+ import { providers as nodeProviders } from "./providers.js";
9
+ import { Kernel } from "../app/Http/Kernel.js";
10
+ import registerWebRoutes from "../routes/web.js";
11
+
12
+ export async function createApplication(providers: ProviderClass[] = nodeProviders): Promise<Application> {
13
+ const app = new Application(process.cwd());
14
+
15
+ // Binding our Kernel under HttpKernel is what makes `keel serve` use it. Without
16
+ // this the console falls back to a bare HttpKernel and the global middleware
17
+ // quietly vanishes.
18
+ app.singleton(HttpKernel, (container) => new Kernel(container as Application));
19
+
20
+ await app.boot(providers);
21
+ registerWebRoutes(app.make(Router));
22
+
23
+ return app;
24
+ }
@@ -0,0 +1,14 @@
1
+ import type { ProviderClass } from "@shaferllc/keel/core";
2
+
3
+ import { AppServiceProvider } from "../app/Providers/AppServiceProvider.js";
4
+
5
+ /**
6
+ * Providers for the Worker.
7
+ *
8
+ * `DatabaseServiceProvider` is deliberately absent, and that's not an optimization —
9
+ * it's what keeps the build working. It reaches for `pg` and `@libsql/client`, which
10
+ * need `net`/`tls`; if the Worker's import graph touched it, wrangler would try to
11
+ * bundle a TCP driver for the edge and fail. The D1 binding is wired in worker.ts
12
+ * before boot, so nothing here needs to open a connection.
13
+ */
14
+ export const edgeProviders: ProviderClass[] = [AppServiceProvider];
@@ -0,0 +1,7 @@
1
+ import type { ProviderClass } from "@shaferllc/keel/core";
2
+
3
+ import { AppServiceProvider } from "../app/Providers/AppServiceProvider.js";
4
+ import { DatabaseServiceProvider } from "../app/Providers/DatabaseServiceProvider.js";
5
+
6
+ /** Providers for the Node runtime (`keel serve`, the console, migrations). */
7
+ export const providers: ProviderClass[] = [DatabaseServiceProvider, AppServiceProvider];
@@ -0,0 +1,10 @@
1
+ import { env } from "@shaferllc/keel/core";
2
+
3
+ export default {
4
+ name: env("APP_NAME", "Keel App"),
5
+ env: env("APP_ENV", "local"),
6
+ debug: env("APP_DEBUG", true),
7
+ url: env("APP_URL", "http://localhost:3000"),
8
+ port: env("APP_PORT", 3000),
9
+ key: env("APP_KEY", ""),
10
+ };
@@ -0,0 +1,42 @@
1
+ import { env } from "@shaferllc/keel/core";
2
+
3
+ /**
4
+ * Every driver, out of the box. `DB_CONNECTION` picks one.
5
+ *
6
+ * The default is `d1` in production — Cloudflare is where this is meant to run —
7
+ * and `sqlite` locally, so `npm run dev` needs no wrangler and no account. Both are
8
+ * SQLite, so one schema and one set of migrations serve both.
9
+ */
10
+ export default {
11
+ default: env("DB_CONNECTION", "sqlite"),
12
+
13
+ connections: {
14
+ // Cloudflare D1. Inside the Worker the binding (env.DB) is used directly;
15
+ // migrations and scripts reach the same database over the HTTP API.
16
+ d1: {
17
+ driver: "d1",
18
+ binding: "DB",
19
+ accountId: env("CLOUDFLARE_ACCOUNT_ID", ""),
20
+ databaseId: env("D1_DATABASE_ID", ""),
21
+ apiToken: env("CLOUDFLARE_API_TOKEN", ""),
22
+ },
23
+
24
+ // A local file. Zero setup — this is what `npm run dev` uses.
25
+ sqlite: {
26
+ driver: "libsql",
27
+ url: env("DB_URL", "file:./database.sqlite"),
28
+ },
29
+
30
+ // libSQL over the network (Turso).
31
+ turso: {
32
+ driver: "libsql",
33
+ url: env("TURSO_URL", ""),
34
+ authToken: env("TURSO_AUTH_TOKEN", ""),
35
+ },
36
+
37
+ postgres: {
38
+ driver: "pg",
39
+ url: env("DATABASE_URL", ""),
40
+ },
41
+ },
42
+ };
@@ -0,0 +1,20 @@
1
+ import type { Migration } from "@shaferllc/keel/core";
2
+
3
+ const migration: Migration = {
4
+ name: "0001_create_posts",
5
+
6
+ async up(schema) {
7
+ await schema.createTable("posts", (t) => {
8
+ t.id();
9
+ t.string("title");
10
+ t.text("body");
11
+ t.timestamps();
12
+ });
13
+ },
14
+
15
+ async down(schema) {
16
+ await schema.dropTable("posts");
17
+ },
18
+ };
19
+
20
+ export default migration;
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "__APP_NAME__",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "keel": "tsx bin/keel.ts",
7
+ "dev": "tsx watch bin/keel.ts serve",
8
+ "serve": "tsx bin/keel.ts serve",
9
+ "migrate": "tsx bin/keel.ts migrate",
10
+ "test": "tsx --test --test-concurrency=1 tests/*.test.ts",
11
+ "typecheck": "tsc --noEmit",
12
+ "dev:edge": "wrangler dev",
13
+ "deploy": "wrangler deploy"
14
+ },
15
+ "dependencies": {
16
+ "@hono/node-server": "^1.13.7",
17
+ "@libsql/client": "^0.14.0",
18
+ "@shaferllc/keel": "__KEEL_VERSION__",
19
+ "hono": "^4.6.14",
20
+ "zod": "^3.24.1",
21
+ "pg": "^8.13.1"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^22.10.0",
25
+ "tsx": "^4.19.2",
26
+ "typescript": "^5.7.2",
27
+ "wrangler": "^4.0.0",
28
+ "@types/pg": "^8.11.10"
29
+ }
30
+ }
@@ -0,0 +1,12 @@
1
+ import type { Router, Ctx } from "@shaferllc/keel/core";
2
+
3
+ import { PostController } from "../app/Controllers/PostController.js";
4
+
5
+ export default function routes(router: Router): void {
6
+ router.get("/health", (c: Ctx) => c.json({ ok: true }));
7
+
8
+ router.get("/posts", [PostController, "index"]).name("posts.index");
9
+ router.post("/posts", [PostController, "store"]).name("posts.store");
10
+ router.get("/posts/:post", [PostController, "show"]).name("posts.show");
11
+ router.delete("/posts/:post", [PostController, "destroy"]).name("posts.destroy");
12
+ }
@@ -0,0 +1,30 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { testClient } from "@shaferllc/keel/core";
5
+ import { HttpKernel } from "@shaferllc/keel/core";
6
+
7
+ import { createApplication } from "../bootstrap/app.js";
8
+
9
+ /**
10
+ * A starter that ships no tests teaches that tests are optional. This one hits the
11
+ * real routes through the real kernel — no mocks, no server, no port.
12
+ */
13
+ test("the API lists, creates, and fetches posts", async () => {
14
+ const app = await createApplication();
15
+ const client = testClient(app.make(HttpKernel));
16
+
17
+ (await client.get("/health")).assertOk().assertJson({ ok: true });
18
+
19
+ const created = await client.post("/posts", { title: "Hello", body: "First." });
20
+ created.assertCreated();
21
+
22
+ (await client.get("/posts")).assertOk();
23
+ });
24
+
25
+ test("a post that doesn't exist is a 404", async () => {
26
+ const app = await createApplication();
27
+ const client = testClient(app.make(HttpKernel));
28
+
29
+ (await client.get("/posts/999999")).assertNotFound();
30
+ });
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022"],
7
+ "types": ["node"],
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "resolveJsonModule": true,
13
+ "noEmit": true
14
+ },
15
+ "include": ["app", "bootstrap", "config", "routes", "database", "bin", "tests"]
16
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The Cloudflare Workers entry. `wrangler dev` / `wrangler deploy` use this.
3
+ *
4
+ * D1's binding only exists inside a request, so the connection is wired here, before
5
+ * the app boots — and the app is built once, then reused.
6
+ *
7
+ * Note the provider list: `edgeProviders`, not the Node one. See providers.edge.ts.
8
+ */
9
+
10
+ import { setConnection, HttpKernel } from "@shaferllc/keel/core";
11
+ import { d1Connection, type D1Like } from "@shaferllc/keel/db/d1";
12
+
13
+ import { createApplication } from "./bootstrap/app.js";
14
+ import { edgeProviders } from "./bootstrap/providers.edge.js";
15
+
16
+ interface Env {
17
+ DB: D1Like;
18
+ }
19
+
20
+ let handler: { fetch: (request: Request, env: unknown) => Response | Promise<Response> } | undefined;
21
+
22
+ export default {
23
+ async fetch(request: Request, env: Env): Promise<Response> {
24
+ if (!handler) {
25
+ setConnection(d1Connection(env.DB), "sqlite");
26
+
27
+ const app = await createApplication(edgeProviders);
28
+ handler = app.make(HttpKernel).build();
29
+ }
30
+
31
+ return handler.fetch(request, env);
32
+ },
33
+ };
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "node_modules/wrangler/config-schema.json",
3
+ "name": "__APP_NAME__",
4
+ "main": "worker.ts",
5
+ "compatibility_date": "2025-01-01",
6
+ // AsyncLocalStorage — Keel carries the request, the open transaction, and the
7
+ // current team in it. Without this flag they have nowhere to live.
8
+ "compatibility_flags": ["nodejs_compat"],
9
+
10
+ "d1_databases": [
11
+ {
12
+ "binding": "DB",
13
+ "database_name": "__APP_NAME__",
14
+ // Create one: wrangler d1 create __APP_NAME__ — then paste the id here.
15
+ "database_id": ""
16
+ }
17
+ ],
18
+
19
+ "vars": {
20
+ "DB_CONNECTION": "d1"
21
+ }
22
+ }
@@ -0,0 +1,12 @@
1
+ APP_NAME="Keel API"
2
+ APP_ENV=local
3
+ APP_DEBUG=true
4
+ APP_URL=http://localhost:3000
5
+ APP_PORT=3000
6
+
7
+ # Signs tokens and encrypted payloads. Generate one: keel key:generate
8
+ APP_KEY=
9
+
10
+ # A file is enough to start; point this at Postgres when you outgrow it.
11
+ DB_CONNECTION=sqlite
12
+ DB_URL=file:./database.sqlite
@@ -0,0 +1,117 @@
1
+ import type { Ctx } from "@shaferllc/keel/core";
2
+ import { auth, hash, session, validate, view } from "@shaferllc/keel/core";
3
+ import { attempt, completeTwoFactor, requestPasswordReset, resetPassword } from "@shaferllc/keel/accounts";
4
+ import { z } from "zod";
5
+
6
+ import { User } from "../Models/User.js";
7
+ import Login from "../../resources/views/auth/login.js";
8
+ import Register from "../../resources/views/auth/register.js";
9
+ import TwoFactor from "../../resources/views/auth/two-factor.js";
10
+ import Forgot from "../../resources/views/auth/forgot.js";
11
+
12
+ const Credentials = z.object({
13
+ email: z.string().email(),
14
+ password: z.string().min(1),
15
+ });
16
+
17
+ const NewUser = z.object({
18
+ name: z.string().min(1),
19
+ email: z.string().email(),
20
+ password: z.string().min(8),
21
+ });
22
+
23
+ export class AuthController {
24
+ async showLogin(c: Ctx) {
25
+ return c.html(await view(Login, { error: null }));
26
+ }
27
+
28
+ async login(c: Ctx) {
29
+ const { email, password } = await validate(Credentials, await c.req.parseBody());
30
+
31
+ const result = await attempt(email, password);
32
+
33
+ if (result.status === "failed") {
34
+ // One message for a wrong email and a wrong password — anything more specific
35
+ // tells a stranger which addresses have accounts here.
36
+ return c.html(await view(Login, { error: "Those credentials don't match." }), 401);
37
+ }
38
+
39
+ if (result.status === "two-factor") {
40
+ // NOT a session. Nothing is logged in until the code checks out.
41
+ session().put("2fa_challenge", result.challenge);
42
+ return c.redirect("/two-factor");
43
+ }
44
+
45
+ auth().login(result.user.id);
46
+ return c.redirect("/dashboard");
47
+ }
48
+
49
+ async showTwoFactor(c: Ctx) {
50
+ return c.html(await view(TwoFactor, { error: null }));
51
+ }
52
+
53
+ async twoFactor(c: Ctx) {
54
+ const body = await c.req.parseBody();
55
+ const challenge = session().get("2fa_challenge") as string | undefined;
56
+
57
+ if (!challenge) return c.redirect("/login");
58
+
59
+ const user = await completeTwoFactor(challenge, String(body.code ?? ""));
60
+
61
+ if (!user) {
62
+ return c.html(await view(TwoFactor, { error: "That code isn't valid." }), 401);
63
+ }
64
+
65
+ session().forget("2fa_challenge");
66
+ auth().login(user.id);
67
+
68
+ return c.redirect("/dashboard");
69
+ }
70
+
71
+ async showRegister(c: Ctx) {
72
+ return c.html(await view(Register, { error: null }));
73
+ }
74
+
75
+ async register(c: Ctx) {
76
+ const data = await validate(NewUser, await c.req.parseBody());
77
+
78
+ if (await User.query().where("email", data.email.toLowerCase()).first()) {
79
+ return c.html(await view(Register, { error: "That email is already registered." }), 422);
80
+ }
81
+
82
+ const user = await User.create({
83
+ name: data.name,
84
+ email: data.email.toLowerCase(),
85
+ password: await hash.make(data.password),
86
+ });
87
+
88
+ auth().login(user.id);
89
+ return c.redirect("/dashboard");
90
+ }
91
+
92
+ logout(c: Ctx) {
93
+ auth().logout();
94
+ return c.redirect("/");
95
+ }
96
+
97
+ async showForgot(c: Ctx) {
98
+ return c.html(await view(Forgot, { sent: false }));
99
+ }
100
+
101
+ async forgot(c: Ctx) {
102
+ const body = await c.req.parseBody();
103
+ await requestPasswordReset(String(body.email ?? ""));
104
+
105
+ // The same answer whether or not that address has an account.
106
+ return c.html(await view(Forgot, { sent: true }));
107
+ }
108
+
109
+ async reset(c: Ctx) {
110
+ const body = await c.req.parseBody();
111
+
112
+ const ok = await resetPassword(String(body.token ?? ""), String(body.password ?? ""));
113
+ if (!ok) return c.text("That reset link is invalid or has expired.", 422);
114
+
115
+ return c.redirect("/login");
116
+ }
117
+ }
@@ -0,0 +1,37 @@
1
+ import type { Ctx } from "@shaferllc/keel/core";
2
+ import { auth, view } from "@shaferllc/keel/core";
3
+ import { enableTwoFactor, hasTwoFactor } from "@shaferllc/keel/accounts";
4
+
5
+ import type { User } from "../Models/User.js";
6
+ import Dashboard from "../../resources/views/dashboard.js";
7
+
8
+ export class DashboardController {
9
+ async index(c: Ctx) {
10
+ const user = await auth().user<User>();
11
+ if (!user) return c.redirect("/login");
12
+
13
+ return c.html(
14
+ await view(Dashboard, {
15
+ name: user.name,
16
+ twoFactor: hasTwoFactor(user as never),
17
+ }),
18
+ );
19
+ }
20
+
21
+ /** Step one of turning 2FA on: a secret and recovery codes. It is NOT on yet. */
22
+ async startTwoFactor(c: Ctx) {
23
+ const user = await auth().user<User>();
24
+ if (!user) return c.redirect("/login");
25
+
26
+ const setup = await enableTwoFactor(user as never, { issuer: "Keel App" });
27
+
28
+ // Render setup.uri to a QR code locally — it contains the shared secret, so it
29
+ // must never be sent to a third-party QR service.
30
+ return c.json({
31
+ uri: setup.uri,
32
+ secret: setup.secret,
33
+ recoveryCodes: setup.recoveryCodes,
34
+ next: "POST /two-factor/confirm with a code from your authenticator to turn it on.",
35
+ });
36
+ }
37
+ }