@shaferllc/keel 0.82.0 → 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.
- package/dist/db/d1-http.d.ts +34 -0
- package/dist/db/d1-http.js +61 -0
- package/dist/db/libsql.d.ts +13 -3
- package/dist/teams/models.js +51 -5
- package/docs/ai-manifest.json +22 -1
- package/docs/database.md +5 -522
- package/docs/models.md +5 -2
- package/docs/orm.md +57 -0
- package/docs/query-builder.md +533 -0
- package/docs/starter-kits.md +88 -0
- package/llms-full.txt +5299 -5115
- package/llms.txt +3 -0
- package/package.json +7 -2
- package/templates/api/.env.example +12 -0
- package/templates/api/app/Controllers/PostController.ts +37 -0
- package/templates/api/app/Http/Kernel.ts +13 -0
- package/templates/api/app/Http/Middleware/requestLogger.ts +8 -0
- package/templates/api/app/Models/Post.ts +12 -0
- package/templates/api/app/Providers/AppServiceProvider.ts +8 -0
- package/templates/api/app/Providers/DatabaseServiceProvider.ts +52 -0
- package/templates/api/bin/keel.ts +15 -0
- package/templates/api/bootstrap/app.ts +24 -0
- package/templates/api/bootstrap/providers.edge.ts +14 -0
- package/templates/api/bootstrap/providers.ts +7 -0
- package/templates/api/config/app.ts +10 -0
- package/templates/api/config/database.ts +42 -0
- package/templates/api/database/migrations/0001_create_posts.ts +20 -0
- package/templates/api/package.json +30 -0
- package/templates/api/routes/web.ts +12 -0
- package/templates/api/tests/posts.test.ts +30 -0
- package/templates/api/tsconfig.json +16 -0
- package/templates/api/worker.ts +33 -0
- package/templates/api/wrangler.jsonc +22 -0
- package/templates/app/.env.example +12 -0
- package/templates/app/app/Controllers/AuthController.ts +117 -0
- package/templates/app/app/Controllers/DashboardController.ts +37 -0
- package/templates/app/app/Controllers/HomeController.ts +10 -0
- package/templates/app/app/Http/Kernel.ts +21 -0
- package/templates/app/app/Http/Middleware/requestLogger.ts +8 -0
- package/templates/app/app/Models/User.ts +15 -0
- package/templates/app/app/Providers/AppServiceProvider.ts +12 -0
- package/templates/app/app/Providers/DatabaseServiceProvider.ts +52 -0
- package/templates/app/bin/keel.ts +15 -0
- package/templates/app/bootstrap/app.ts +24 -0
- package/templates/app/bootstrap/providers.edge.ts +15 -0
- package/templates/app/bootstrap/providers.ts +18 -0
- package/templates/app/config/app.ts +10 -0
- package/templates/app/config/database.ts +42 -0
- package/templates/app/database/migrations/0001_create_users.ts +21 -0
- package/templates/app/package.json +35 -0
- package/templates/app/public/.gitkeep +2 -0
- package/templates/app/resources/css/app.css +1 -0
- package/templates/app/resources/views/auth/forgot.tsx +30 -0
- package/templates/app/resources/views/auth/login.tsx +24 -0
- package/templates/app/resources/views/auth/register.tsx +24 -0
- package/templates/app/resources/views/auth/two-factor.tsx +22 -0
- package/templates/app/resources/views/dashboard.tsx +20 -0
- package/templates/app/resources/views/layout.tsx +15 -0
- package/templates/app/resources/views/welcome.tsx +29 -0
- package/templates/app/routes/web.ts +35 -0
- package/templates/app/tests/auth.test.ts +47 -0
- package/templates/app/tsconfig.json +31 -0
- package/templates/app/worker.ts +33 -0
- package/templates/app/wrangler.jsonc +25 -0
- package/templates/minimal/.env.example +8 -0
- package/templates/minimal/app/Controllers/HomeController.ts +14 -0
- package/templates/minimal/app/Http/Kernel.ts +22 -0
- package/templates/minimal/app/Http/Middleware/requestLogger.ts +8 -0
- package/templates/minimal/app/Providers/AppServiceProvider.ts +8 -0
- package/templates/minimal/bin/keel.ts +15 -0
- package/templates/minimal/bootstrap/app.ts +24 -0
- package/templates/minimal/bootstrap/providers.ts +6 -0
- package/templates/minimal/config/app.ts +10 -0
- package/templates/minimal/package.json +29 -0
- package/templates/minimal/public/.gitkeep +2 -0
- package/templates/minimal/resources/css/app.css +1 -0
- package/templates/minimal/resources/views/layout.tsx +15 -0
- package/templates/minimal/resources/views/welcome.tsx +15 -0
- package/templates/minimal/routes/web.ts +13 -0
- package/templates/minimal/tsconfig.json +18 -0
- package/templates/minimal/worker.ts +23 -0
- package/templates/minimal/wrangler.jsonc +10 -0
- package/templates/saas/.env.example +12 -0
- package/templates/saas/app/Controllers/AuthController.ts +125 -0
- package/templates/saas/app/Controllers/DashboardController.ts +37 -0
- package/templates/saas/app/Controllers/HomeController.ts +10 -0
- package/templates/saas/app/Controllers/TeamController.ts +88 -0
- package/templates/saas/app/Http/Kernel.ts +27 -0
- package/templates/saas/app/Http/Middleware/requestLogger.ts +8 -0
- package/templates/saas/app/Models/Project.ts +20 -0
- package/templates/saas/app/Models/User.ts +15 -0
- package/templates/saas/app/Providers/AppServiceProvider.ts +12 -0
- package/templates/saas/app/Providers/DatabaseServiceProvider.ts +52 -0
- package/templates/saas/bin/keel.ts +15 -0
- package/templates/saas/bootstrap/app.ts +24 -0
- package/templates/saas/bootstrap/providers.edge.ts +18 -0
- package/templates/saas/bootstrap/providers.ts +22 -0
- package/templates/saas/config/app.ts +10 -0
- package/templates/saas/config/database.ts +42 -0
- package/templates/saas/database/migrations/0001_create_users.ts +21 -0
- package/templates/saas/database/migrations/0002_create_projects.ts +20 -0
- package/templates/saas/package.json +35 -0
- package/templates/saas/public/.gitkeep +2 -0
- package/templates/saas/resources/css/app.css +1 -0
- package/templates/saas/resources/views/auth/forgot.tsx +30 -0
- package/templates/saas/resources/views/auth/login.tsx +24 -0
- package/templates/saas/resources/views/auth/register.tsx +24 -0
- package/templates/saas/resources/views/auth/two-factor.tsx +22 -0
- package/templates/saas/resources/views/dashboard.tsx +20 -0
- package/templates/saas/resources/views/layout.tsx +15 -0
- package/templates/saas/resources/views/teams/index.tsx +85 -0
- package/templates/saas/resources/views/welcome.tsx +29 -0
- package/templates/saas/routes/web.ts +44 -0
- package/templates/saas/tests/auth.test.ts +47 -0
- package/templates/saas/tests/teams.test.ts +27 -0
- package/templates/saas/tsconfig.json +31 -0
- package/templates/saas/worker.ts +33 -0
- package/templates/saas/wrangler.jsonc +25 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Ctx } from "@shaferllc/keel/core";
|
|
2
|
+
import { auth, view } from "@shaferllc/keel/core";
|
|
3
|
+
|
|
4
|
+
import Welcome from "../../resources/views/welcome.js";
|
|
5
|
+
|
|
6
|
+
export class HomeController {
|
|
7
|
+
async index(c: Ctx) {
|
|
8
|
+
return c.html(await view(Welcome, { signedIn: auth().check() }));
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { HttpKernel, serveStatic, sessionMiddleware } from "@shaferllc/keel/core";
|
|
2
|
+
import type { Application } from "@shaferllc/keel/core";
|
|
3
|
+
|
|
4
|
+
import { requestLogger } from "./Middleware/requestLogger.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Global middleware — runs on every request, in order.
|
|
8
|
+
*
|
|
9
|
+
* Keel's own `serveStatic` (not `@hono/node-server`'s) because this kernel also runs
|
|
10
|
+
* inside the Worker: it imports `node:fs` dynamically, so it loads on the edge.
|
|
11
|
+
* Files resolve as `root + path` — /assets/app.css is ./public/assets/app.css.
|
|
12
|
+
*/
|
|
13
|
+
export class Kernel extends HttpKernel {
|
|
14
|
+
constructor(app: Application) {
|
|
15
|
+
super(app);
|
|
16
|
+
|
|
17
|
+
this.use(requestLogger);
|
|
18
|
+
this.use(serveStatic({ root: "./public" }));
|
|
19
|
+
this.use(sessionMiddleware());
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -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,15 @@
|
|
|
1
|
+
import { Model } from "@shaferllc/keel/core";
|
|
2
|
+
|
|
3
|
+
export class User extends Model {
|
|
4
|
+
static override table = "users";
|
|
5
|
+
static override fillable = ["name", "email", "password"];
|
|
6
|
+
// Never serialize these — `hidden` is a denylist on toJSON().
|
|
7
|
+
static override hidden = ["password", "two_factor_secret", "two_factor_recovery_codes"];
|
|
8
|
+
static override timestamps = true;
|
|
9
|
+
|
|
10
|
+
declare id: number;
|
|
11
|
+
declare name: string;
|
|
12
|
+
declare email: string;
|
|
13
|
+
declare password: string;
|
|
14
|
+
declare email_verified_at: string | null;
|
|
15
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ServiceProvider, setUserProvider } from "@shaferllc/keel/core";
|
|
2
|
+
|
|
3
|
+
import { User } from "../Models/User.js";
|
|
4
|
+
|
|
5
|
+
export class AppServiceProvider extends ServiceProvider {
|
|
6
|
+
register(): void {}
|
|
7
|
+
|
|
8
|
+
boot(): void {
|
|
9
|
+
// How `auth().user()` loads the signed-in user from the id in the session.
|
|
10
|
+
setUserProvider((id) => User.find(Number(id)));
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -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,15 @@
|
|
|
1
|
+
import type { ProviderClass } from "@shaferllc/keel/core";
|
|
2
|
+
import { AccountsServiceProvider } from "@shaferllc/keel/accounts";
|
|
3
|
+
|
|
4
|
+
import { AppServiceProvider } from "../app/Providers/AppServiceProvider.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Providers for the Worker.
|
|
8
|
+
*
|
|
9
|
+
* `DatabaseServiceProvider` is deliberately absent, and that's not an optimization —
|
|
10
|
+
* it's what keeps the build working. It reaches for `pg` and `@libsql/client`, which
|
|
11
|
+
* need `net`/`tls`; if the Worker's import graph touched it, wrangler would try to
|
|
12
|
+
* bundle a TCP driver for the edge and the deploy would fail. worker.ts binds D1
|
|
13
|
+
* before boot, so nothing here needs to open a connection.
|
|
14
|
+
*/
|
|
15
|
+
export const edgeProviders: ProviderClass[] = [AccountsServiceProvider, AppServiceProvider];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ProviderClass } from "@shaferllc/keel/core";
|
|
2
|
+
import { AccountsServiceProvider } from "@shaferllc/keel/accounts";
|
|
3
|
+
|
|
4
|
+
import { AppServiceProvider } from "../app/Providers/AppServiceProvider.js";
|
|
5
|
+
import { DatabaseServiceProvider } from "../app/Providers/DatabaseServiceProvider.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Providers for the Node runtime (`keel serve`, the console, migrations).
|
|
9
|
+
*
|
|
10
|
+
* AccountsServiceProvider brings password reset, email verification, and two-factor —
|
|
11
|
+
* routes, migration and all. The flows live in the framework, tested once, rather
|
|
12
|
+
* than being copy-pasted into every app that has a login.
|
|
13
|
+
*/
|
|
14
|
+
export const providers: ProviderClass[] = [
|
|
15
|
+
DatabaseServiceProvider,
|
|
16
|
+
AccountsServiceProvider,
|
|
17
|
+
AppServiceProvider,
|
|
18
|
+
];
|
|
@@ -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,21 @@
|
|
|
1
|
+
import type { Migration } from "@shaferllc/keel/core";
|
|
2
|
+
|
|
3
|
+
const migration: Migration = {
|
|
4
|
+
name: "0001_create_users",
|
|
5
|
+
|
|
6
|
+
async up(schema) {
|
|
7
|
+
await schema.createTable("users", (t) => {
|
|
8
|
+
t.id();
|
|
9
|
+
t.string("name");
|
|
10
|
+
t.string("email").unique();
|
|
11
|
+
t.string("password");
|
|
12
|
+
t.timestamps();
|
|
13
|
+
});
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
async down(schema) {
|
|
17
|
+
await schema.dropTable("users");
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default migration;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__APP_NAME__",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"keel": "tsx bin/keel.ts",
|
|
7
|
+
"dev": "npm run css:build && concurrently -k -n css,web \"npm run css:watch\" \"tsx watch bin/keel.ts serve\"",
|
|
8
|
+
"serve": "tsx bin/keel.ts serve",
|
|
9
|
+
"dev:edge": "wrangler dev",
|
|
10
|
+
"deploy": "npm run css:build && wrangler deploy",
|
|
11
|
+
"migrate": "tsx bin/keel.ts migrate",
|
|
12
|
+
"css:build": "tailwindcss -i ./resources/css/app.css -o ./public/assets/app.css --minify",
|
|
13
|
+
"css:watch": "tailwindcss -i ./resources/css/app.css -o ./public/assets/app.css --watch",
|
|
14
|
+
"test": "tsx --test --test-concurrency=1 tests/*.test.ts",
|
|
15
|
+
"typecheck": "tsc --noEmit"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@hono/node-server": "^1.13.7",
|
|
19
|
+
"@libsql/client": "^0.14.0",
|
|
20
|
+
"@shaferllc/keel": "__KEEL_VERSION__",
|
|
21
|
+
"hono": "^4.6.14",
|
|
22
|
+
"zod": "^3.24.1",
|
|
23
|
+
"pg": "^8.13.1"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^22.10.0",
|
|
27
|
+
"tsx": "^4.19.2",
|
|
28
|
+
"typescript": "^5.7.2",
|
|
29
|
+
"wrangler": "^4.0.0",
|
|
30
|
+
"@types/pg": "^8.11.10",
|
|
31
|
+
"@tailwindcss/cli": "^4.0.0",
|
|
32
|
+
"tailwindcss": "^4.0.0",
|
|
33
|
+
"concurrently": "^9.1.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import Layout from "../layout.js";
|
|
2
|
+
|
|
3
|
+
export default function Forgot({ sent }: { sent: boolean }) {
|
|
4
|
+
return (
|
|
5
|
+
<Layout title="Reset your password">
|
|
6
|
+
<main class="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-6 px-6">
|
|
7
|
+
<h1 class="text-2xl font-semibold tracking-tight">Reset your password</h1>
|
|
8
|
+
|
|
9
|
+
{sent ? (
|
|
10
|
+
// The same answer whether or not that address has an account — otherwise
|
|
11
|
+
// this page tells a stranger which emails are registered.
|
|
12
|
+
<p class="rounded-lg bg-slate-100 px-3 py-2 text-sm">
|
|
13
|
+
If that address has an account, a link is on its way.
|
|
14
|
+
</p>
|
|
15
|
+
) : (
|
|
16
|
+
<form method="post" action="/forgot-password" class="flex flex-col gap-3">
|
|
17
|
+
<input
|
|
18
|
+
class="rounded-lg border border-slate-300 px-3 py-2"
|
|
19
|
+
type="email"
|
|
20
|
+
name="email"
|
|
21
|
+
placeholder="Email"
|
|
22
|
+
required
|
|
23
|
+
/>
|
|
24
|
+
<button class="rounded-lg bg-slate-900 px-4 py-2 text-white">Send the link</button>
|
|
25
|
+
</form>
|
|
26
|
+
)}
|
|
27
|
+
</main>
|
|
28
|
+
</Layout>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import Layout from "../layout.js";
|
|
2
|
+
|
|
3
|
+
export default function Login({ error }: { error: string | null }) {
|
|
4
|
+
return (
|
|
5
|
+
<Layout title="Log in">
|
|
6
|
+
<main class="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-6 px-6">
|
|
7
|
+
<h1 class="text-2xl font-semibold tracking-tight">Log in</h1>
|
|
8
|
+
|
|
9
|
+
{error && <p class="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>}
|
|
10
|
+
|
|
11
|
+
<form method="post" action="/login" class="flex flex-col gap-3">
|
|
12
|
+
<input class="rounded-lg border border-slate-300 px-3 py-2" type="email" name="email" placeholder="Email" required />
|
|
13
|
+
<input class="rounded-lg border border-slate-300 px-3 py-2" type="password" name="password" placeholder="Password" required />
|
|
14
|
+
<button class="rounded-lg bg-slate-900 px-4 py-2 text-white">Log in</button>
|
|
15
|
+
</form>
|
|
16
|
+
|
|
17
|
+
<p class="text-sm text-slate-500">
|
|
18
|
+
<a class="underline" href="/register">Register</a> ·{" "}
|
|
19
|
+
<a class="underline" href="/forgot-password">Forgot password?</a>
|
|
20
|
+
</p>
|
|
21
|
+
</main>
|
|
22
|
+
</Layout>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import Layout from "../layout.js";
|
|
2
|
+
|
|
3
|
+
export default function Register({ error }: { error: string | null }) {
|
|
4
|
+
return (
|
|
5
|
+
<Layout title="Register">
|
|
6
|
+
<main class="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-6 px-6">
|
|
7
|
+
<h1 class="text-2xl font-semibold tracking-tight">Register</h1>
|
|
8
|
+
|
|
9
|
+
{error && <p class="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>}
|
|
10
|
+
|
|
11
|
+
<form method="post" action="/register" class="flex flex-col gap-3">
|
|
12
|
+
<input class="rounded-lg border border-slate-300 px-3 py-2" name="name" placeholder="Name" required />
|
|
13
|
+
<input class="rounded-lg border border-slate-300 px-3 py-2" type="email" name="email" placeholder="Email" required />
|
|
14
|
+
<input class="rounded-lg border border-slate-300 px-3 py-2" type="password" name="password" placeholder="Password" required />
|
|
15
|
+
<button class="rounded-lg bg-slate-900 px-4 py-2 text-white">Register</button>
|
|
16
|
+
</form>
|
|
17
|
+
|
|
18
|
+
<p class="text-sm text-slate-500">
|
|
19
|
+
Already have an account? <a class="underline" href="/login">Log in</a>
|
|
20
|
+
</p>
|
|
21
|
+
</main>
|
|
22
|
+
</Layout>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import Layout from "../layout.js";
|
|
2
|
+
|
|
3
|
+
export default function TwoFactor({ error }: { error: string | null }) {
|
|
4
|
+
return (
|
|
5
|
+
<Layout title="Two-factor">
|
|
6
|
+
<main class="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-6 px-6">
|
|
7
|
+
<h1 class="text-2xl font-semibold tracking-tight">Two-factor</h1>
|
|
8
|
+
|
|
9
|
+
{error && <p class="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>}
|
|
10
|
+
|
|
11
|
+
<form method="post" action="/two-factor" class="flex flex-col gap-3">
|
|
12
|
+
<input class="rounded-lg border border-slate-300 px-3 py-2" name="code" placeholder="6-digit code, or a recovery code" required autofocus />
|
|
13
|
+
<button class="rounded-lg bg-slate-900 px-4 py-2 text-white">Two-factor</button>
|
|
14
|
+
</form>
|
|
15
|
+
|
|
16
|
+
<p class="text-sm text-slate-500">
|
|
17
|
+
Your password was accepted. You are not signed in until this code checks out.
|
|
18
|
+
</p>
|
|
19
|
+
</main>
|
|
20
|
+
</Layout>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import Layout from "./layout.js";
|
|
2
|
+
|
|
3
|
+
export default function Dashboard({ name, twoFactor }: { name: string; twoFactor: boolean }) {
|
|
4
|
+
return (
|
|
5
|
+
<Layout title="Dashboard">
|
|
6
|
+
<main class="mx-auto max-w-2xl px-6 py-16">
|
|
7
|
+
<h1 class="text-3xl font-semibold tracking-tight">Hello, {name}.</h1>
|
|
8
|
+
|
|
9
|
+
<p class="mt-4 text-slate-600">
|
|
10
|
+
Two-factor is {twoFactor ? "on" : "off"}.{" "}
|
|
11
|
+
{!twoFactor && "POST /two-factor/enable to start setting it up."}
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<form method="post" action="/logout" class="mt-8">
|
|
15
|
+
<button class="rounded-lg border border-slate-300 px-4 py-2">Log out</button>
|
|
16
|
+
</form>
|
|
17
|
+
</main>
|
|
18
|
+
</Layout>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PropsWithChildren } from "hono/jsx";
|
|
2
|
+
|
|
3
|
+
export default function Layout({ title, children }: PropsWithChildren<{ title: string }>) {
|
|
4
|
+
return (
|
|
5
|
+
<html lang="en">
|
|
6
|
+
<head>
|
|
7
|
+
<meta charset="utf-8" />
|
|
8
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
9
|
+
<title>{title}</title>
|
|
10
|
+
<link rel="stylesheet" href="/assets/app.css" />
|
|
11
|
+
</head>
|
|
12
|
+
<body class="min-h-screen bg-slate-50 text-slate-900 antialiased">{children}</body>
|
|
13
|
+
</html>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import Layout from "./layout.js";
|
|
2
|
+
|
|
3
|
+
export default function Welcome({ signedIn }: { signedIn: boolean }) {
|
|
4
|
+
return (
|
|
5
|
+
<Layout title="Keel">
|
|
6
|
+
<main class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-6 px-6">
|
|
7
|
+
<h1 class="text-4xl font-semibold tracking-tight">Keel</h1>
|
|
8
|
+
<p class="text-slate-600">Sessions, password reset, and two-factor are already wired.</p>
|
|
9
|
+
|
|
10
|
+
<div class="flex gap-3">
|
|
11
|
+
{signedIn ? (
|
|
12
|
+
<a class="rounded-lg bg-slate-900 px-4 py-2 text-white" href="/dashboard">
|
|
13
|
+
Dashboard
|
|
14
|
+
</a>
|
|
15
|
+
) : (
|
|
16
|
+
<>
|
|
17
|
+
<a class="rounded-lg bg-slate-900 px-4 py-2 text-white" href="/login">
|
|
18
|
+
Log in
|
|
19
|
+
</a>
|
|
20
|
+
<a class="rounded-lg border border-slate-300 px-4 py-2" href="/register">
|
|
21
|
+
Register
|
|
22
|
+
</a>
|
|
23
|
+
</>
|
|
24
|
+
)}
|
|
25
|
+
</div>
|
|
26
|
+
</main>
|
|
27
|
+
</Layout>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Router, Ctx } from "@shaferllc/keel/core";
|
|
2
|
+
import { auth } from "@shaferllc/keel/core";
|
|
3
|
+
|
|
4
|
+
import { AuthController } from "../app/Controllers/AuthController.js";
|
|
5
|
+
import { DashboardController } from "../app/Controllers/DashboardController.js";
|
|
6
|
+
import { HomeController } from "../app/Controllers/HomeController.js";
|
|
7
|
+
|
|
8
|
+
/** Send guests to the login page. */
|
|
9
|
+
const authenticated = async (c: Ctx, next: () => Promise<void>) => {
|
|
10
|
+
if (auth().guest()) return c.redirect("/login");
|
|
11
|
+
await next();
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default function routes(router: Router): void {
|
|
15
|
+
router.get("/", [HomeController, "index"]);
|
|
16
|
+
router.get("/health", (c: Ctx) => c.json({ ok: true }));
|
|
17
|
+
|
|
18
|
+
router.get("/login", [AuthController, "showLogin"]).name("login");
|
|
19
|
+
router.post("/login", [AuthController, "login"]);
|
|
20
|
+
|
|
21
|
+
router.get("/two-factor", [AuthController, "showTwoFactor"]);
|
|
22
|
+
router.post("/two-factor", [AuthController, "twoFactor"]);
|
|
23
|
+
|
|
24
|
+
router.get("/register", [AuthController, "showRegister"]).name("register");
|
|
25
|
+
router.post("/register", [AuthController, "register"]);
|
|
26
|
+
|
|
27
|
+
router.post("/logout", [AuthController, "logout"]).name("logout");
|
|
28
|
+
|
|
29
|
+
router.get("/forgot-password", [AuthController, "showForgot"]);
|
|
30
|
+
router.post("/forgot-password", [AuthController, "forgot"]);
|
|
31
|
+
router.post("/reset-password", [AuthController, "reset"]);
|
|
32
|
+
|
|
33
|
+
router.get("/dashboard", [DashboardController, "index"]).middleware(authenticated).name("dashboard");
|
|
34
|
+
router.post("/two-factor/enable", [DashboardController, "startTwoFactor"]).middleware(authenticated);
|
|
35
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { HttpKernel, testClient } from "@shaferllc/keel/core";
|
|
5
|
+
|
|
6
|
+
import { createApplication } from "../bootstrap/app.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* These hit the real routes through the real kernel. A starter that ships no tests
|
|
10
|
+
* teaches that tests are optional — and auth is the last place you want that.
|
|
11
|
+
*/
|
|
12
|
+
test("a visitor can register and reach the dashboard", async () => {
|
|
13
|
+
const app = await createApplication();
|
|
14
|
+
const client = testClient(app.make(HttpKernel));
|
|
15
|
+
|
|
16
|
+
(await client.get("/login")).assertOk();
|
|
17
|
+
|
|
18
|
+
const registered = await client.form("/register", {
|
|
19
|
+
name: "Ada",
|
|
20
|
+
email: `ada+${crypto.randomUUID()}@example.com`,
|
|
21
|
+
password: "correct horse battery",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// A redirect to the dashboard means the session was set.
|
|
25
|
+
assert.equal(registered.status, 302);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("the dashboard turns guests away", async () => {
|
|
29
|
+
const app = await createApplication();
|
|
30
|
+
const client = testClient(app.make(HttpKernel));
|
|
31
|
+
|
|
32
|
+
const response = await client.get("/dashboard");
|
|
33
|
+
|
|
34
|
+
assert.equal(response.status, 302, "a guest is redirected, not shown the page");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("a wrong password says nothing about whether the account exists", async () => {
|
|
38
|
+
const app = await createApplication();
|
|
39
|
+
const client = testClient(app.make(HttpKernel));
|
|
40
|
+
|
|
41
|
+
const response = await client.form("/login", {
|
|
42
|
+
email: "nobody@example.com",
|
|
43
|
+
password: "wrong",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
assert.equal(response.status, 401);
|
|
47
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": [
|
|
7
|
+
"ES2022"
|
|
8
|
+
],
|
|
9
|
+
"types": [
|
|
10
|
+
"node"
|
|
11
|
+
],
|
|
12
|
+
"strict": true,
|
|
13
|
+
"noUncheckedIndexedAccess": true,
|
|
14
|
+
"esModuleInterop": true,
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
"resolveJsonModule": true,
|
|
17
|
+
"noEmit": true,
|
|
18
|
+
"jsx": "react-jsx",
|
|
19
|
+
"jsxImportSource": "hono/jsx"
|
|
20
|
+
},
|
|
21
|
+
"include": [
|
|
22
|
+
"app",
|
|
23
|
+
"bootstrap",
|
|
24
|
+
"config",
|
|
25
|
+
"routes",
|
|
26
|
+
"database",
|
|
27
|
+
"resources",
|
|
28
|
+
"bin",
|
|
29
|
+
"tests"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -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
|
+
};
|