@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,25 @@
|
|
|
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
|
+
// The compiled stylesheet. Cloudflare serves these; the Worker never sees them.
|
|
11
|
+
"assets": { "directory": "./public" },
|
|
12
|
+
|
|
13
|
+
"d1_databases": [
|
|
14
|
+
{
|
|
15
|
+
"binding": "DB",
|
|
16
|
+
"database_name": "__APP_NAME__",
|
|
17
|
+
// Create one: wrangler d1 create __APP_NAME__ — then paste the id here.
|
|
18
|
+
"database_id": ""
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
|
|
22
|
+
"vars": {
|
|
23
|
+
"DB_CONNECTION": "d1"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Ctx } from "@shaferllc/keel/core";
|
|
2
|
+
import { view } from "@shaferllc/keel/core";
|
|
3
|
+
|
|
4
|
+
import Welcome from "../../resources/views/welcome.js";
|
|
5
|
+
|
|
6
|
+
export class HomeController {
|
|
7
|
+
index(c: Ctx) {
|
|
8
|
+
return c.json({ message: "Keel is running." });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async welcome(c: Ctx) {
|
|
12
|
+
return c.html(await view(Welcome, { name: "world" }));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { HttpKernel, serveStatic } 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 (where
|
|
11
|
+
* Cloudflare serves the assets itself and this simply falls through).
|
|
12
|
+
*
|
|
13
|
+
* Files resolve as `root + path`, so /assets/app.css is ./public/assets/app.css.
|
|
14
|
+
*/
|
|
15
|
+
export class Kernel extends HttpKernel {
|
|
16
|
+
constructor(app: Application) {
|
|
17
|
+
super(app);
|
|
18
|
+
|
|
19
|
+
this.use(requestLogger);
|
|
20
|
+
this.use(serveStatic({ root: "./public" }));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -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
|
+
#!/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
|
+
* Both the server and the console enter through here.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Application, Router, HttpKernel } from "@shaferllc/keel/core";
|
|
7
|
+
|
|
8
|
+
import { providers } from "./providers.js";
|
|
9
|
+
import { Kernel } from "../app/Http/Kernel.js";
|
|
10
|
+
import registerWebRoutes from "../routes/web.js";
|
|
11
|
+
|
|
12
|
+
export async function createApplication(): 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 your 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,6 @@
|
|
|
1
|
+
import type { ProviderClass } from "@shaferllc/keel/core";
|
|
2
|
+
|
|
3
|
+
import { AppServiceProvider } from "../app/Providers/AppServiceProvider.js";
|
|
4
|
+
|
|
5
|
+
/** Service providers loaded on every request and command, in order. */
|
|
6
|
+
export const providers: ProviderClass[] = [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,29 @@
|
|
|
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
|
+
"css:build": "tailwindcss -i ./resources/css/app.css -o ./public/assets/app.css --minify",
|
|
10
|
+
"css:watch": "tailwindcss -i ./resources/css/app.css -o ./public/assets/app.css --watch",
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"dev:edge": "wrangler dev",
|
|
13
|
+
"deploy": "npm run css:build && wrangler deploy"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@hono/node-server": "^1.13.7",
|
|
17
|
+
"@shaferllc/keel": "__KEEL_VERSION__",
|
|
18
|
+
"hono": "^4.6.14"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@tailwindcss/cli": "^4.0.0",
|
|
22
|
+
"@types/node": "^22.10.0",
|
|
23
|
+
"concurrently": "^9.1.0",
|
|
24
|
+
"tailwindcss": "^4.0.0",
|
|
25
|
+
"tsx": "^4.19.2",
|
|
26
|
+
"typescript": "^5.7.2",
|
|
27
|
+
"wrangler": "^4.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
@@ -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-white text-slate-900 antialiased">{children}</body>
|
|
13
|
+
</html>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import Layout from "./layout.js";
|
|
2
|
+
|
|
3
|
+
export default function Welcome({ name }: { name: string }) {
|
|
4
|
+
return (
|
|
5
|
+
<Layout title="Keel">
|
|
6
|
+
<main class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6">
|
|
7
|
+
<h1 class="text-4xl font-semibold tracking-tight">Hello, {name}.</h1>
|
|
8
|
+
<p class="text-slate-600">
|
|
9
|
+
This is a JSX view, rendered on the server. Edit{" "}
|
|
10
|
+
<code class="rounded bg-slate-100 px-1.5 py-0.5 text-sm">resources/views/welcome.tsx</code>.
|
|
11
|
+
</p>
|
|
12
|
+
</main>
|
|
13
|
+
</Layout>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Router, Ctx } from "@shaferllc/keel/core";
|
|
2
|
+
|
|
3
|
+
import { HomeController } from "../app/Controllers/HomeController.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Handlers are a [Controller, method] tuple (resolved from the container) or an
|
|
7
|
+
* inline closure.
|
|
8
|
+
*/
|
|
9
|
+
export default function routes(router: Router): void {
|
|
10
|
+
router.get("/", [HomeController, "index"]);
|
|
11
|
+
router.get("/welcome", [HomeController, "welcome"]);
|
|
12
|
+
router.get("/hello/:name", (c: Ctx) => c.text(`Hello, ${c.req.param("name")}!`));
|
|
13
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
"jsx": "react-jsx",
|
|
12
|
+
"jsxImportSource": "hono/jsx",
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"noEmit": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["app", "bootstrap", "config", "routes", "resources", "bin"]
|
|
18
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Cloudflare Workers entry. `wrangler dev` / `wrangler deploy` use this;
|
|
3
|
+
* `keel serve` (Node) does not.
|
|
4
|
+
*
|
|
5
|
+
* The app is built once and reused across requests.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { HttpKernel } from "@shaferllc/keel/core";
|
|
9
|
+
|
|
10
|
+
import { createApplication } from "./bootstrap/app.js";
|
|
11
|
+
|
|
12
|
+
let handler: { fetch: (request: Request, env: unknown) => Response | Promise<Response> } | undefined;
|
|
13
|
+
|
|
14
|
+
export default {
|
|
15
|
+
async fetch(request: Request, env: unknown): Promise<Response> {
|
|
16
|
+
if (!handler) {
|
|
17
|
+
const app = await createApplication();
|
|
18
|
+
handler = app.make(HttpKernel).build();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return handler.fetch(request, env);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
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 context in it.
|
|
7
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
8
|
+
|
|
9
|
+
"assets": { "directory": "./public" }
|
|
10
|
+
}
|
|
@@ -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,125 @@
|
|
|
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 { createTeam, switchTeam } from "@shaferllc/keel/teams";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
import { User } from "../Models/User.js";
|
|
8
|
+
import Login from "../../resources/views/auth/login.js";
|
|
9
|
+
import Register from "../../resources/views/auth/register.js";
|
|
10
|
+
import TwoFactor from "../../resources/views/auth/two-factor.js";
|
|
11
|
+
import Forgot from "../../resources/views/auth/forgot.js";
|
|
12
|
+
|
|
13
|
+
const Credentials = z.object({
|
|
14
|
+
email: z.string().email(),
|
|
15
|
+
password: z.string().min(1),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const NewUser = z.object({
|
|
19
|
+
name: z.string().min(1),
|
|
20
|
+
email: z.string().email(),
|
|
21
|
+
password: z.string().min(8),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export class AuthController {
|
|
25
|
+
async showLogin(c: Ctx) {
|
|
26
|
+
return c.html(await view(Login, { error: null }));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async login(c: Ctx) {
|
|
30
|
+
const { email, password } = await validate(Credentials, await c.req.parseBody());
|
|
31
|
+
|
|
32
|
+
const result = await attempt(email, password);
|
|
33
|
+
|
|
34
|
+
if (result.status === "failed") {
|
|
35
|
+
// One message for a wrong email and a wrong password — anything more specific
|
|
36
|
+
// tells a stranger which addresses have accounts here.
|
|
37
|
+
return c.html(await view(Login, { error: "Those credentials don't match." }), 401);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (result.status === "two-factor") {
|
|
41
|
+
// NOT a session. Nothing is logged in until the code checks out.
|
|
42
|
+
session().put("2fa_challenge", result.challenge);
|
|
43
|
+
return c.redirect("/two-factor");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
auth().login(result.user.id);
|
|
47
|
+
return c.redirect("/dashboard");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async showTwoFactor(c: Ctx) {
|
|
51
|
+
return c.html(await view(TwoFactor, { error: null }));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async twoFactor(c: Ctx) {
|
|
55
|
+
const body = await c.req.parseBody();
|
|
56
|
+
const challenge = session().get("2fa_challenge") as string | undefined;
|
|
57
|
+
|
|
58
|
+
if (!challenge) return c.redirect("/login");
|
|
59
|
+
|
|
60
|
+
const user = await completeTwoFactor(challenge, String(body.code ?? ""));
|
|
61
|
+
|
|
62
|
+
if (!user) {
|
|
63
|
+
return c.html(await view(TwoFactor, { error: "That code isn't valid." }), 401);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
session().forget("2fa_challenge");
|
|
67
|
+
auth().login(user.id);
|
|
68
|
+
|
|
69
|
+
return c.redirect("/dashboard");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async showRegister(c: Ctx) {
|
|
73
|
+
return c.html(await view(Register, { error: null }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async register(c: Ctx) {
|
|
77
|
+
const data = await validate(NewUser, await c.req.parseBody());
|
|
78
|
+
|
|
79
|
+
if (await User.query().where("email", data.email.toLowerCase()).first()) {
|
|
80
|
+
return c.html(await view(Register, { error: "That email is already registered." }), 422);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const user = await User.create({
|
|
84
|
+
name: data.name,
|
|
85
|
+
email: data.email.toLowerCase(),
|
|
86
|
+
password: await hash.make(data.password),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// A personal team, immediately. Without one the user has no tenant, and every
|
|
90
|
+
// TenantModel query throws — which is exactly what it should do, so the fix is
|
|
91
|
+
// to give them a team rather than to soften the scope. A solo user is a team of
|
|
92
|
+
// one; adding tenancy later means backfilling every table.
|
|
93
|
+
const team = await createTeam(`${data.name}'s team`, user.id);
|
|
94
|
+
await switchTeam(user.id, team.id);
|
|
95
|
+
|
|
96
|
+
auth().login(user.id);
|
|
97
|
+
return c.redirect("/teams");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
logout(c: Ctx) {
|
|
101
|
+
auth().logout();
|
|
102
|
+
return c.redirect("/");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async showForgot(c: Ctx) {
|
|
106
|
+
return c.html(await view(Forgot, { sent: false }));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async forgot(c: Ctx) {
|
|
110
|
+
const body = await c.req.parseBody();
|
|
111
|
+
await requestPasswordReset(String(body.email ?? ""));
|
|
112
|
+
|
|
113
|
+
// The same answer whether or not that address has an account.
|
|
114
|
+
return c.html(await view(Forgot, { sent: true }));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async reset(c: Ctx) {
|
|
118
|
+
const body = await c.req.parseBody();
|
|
119
|
+
|
|
120
|
+
const ok = await resetPassword(String(body.token ?? ""), String(body.password ?? ""));
|
|
121
|
+
if (!ok) return c.text("That reset link is invalid or has expired.", 422);
|
|
122
|
+
|
|
123
|
+
return c.redirect("/login");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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,88 @@
|
|
|
1
|
+
import type { Ctx } from "@shaferllc/keel/core";
|
|
2
|
+
import { auth, view } from "@shaferllc/keel/core";
|
|
3
|
+
import {
|
|
4
|
+
acceptInvitation,
|
|
5
|
+
createTeam,
|
|
6
|
+
currentTeam,
|
|
7
|
+
invite,
|
|
8
|
+
pendingInvitations,
|
|
9
|
+
switchTeam,
|
|
10
|
+
teamsFor,
|
|
11
|
+
type Role,
|
|
12
|
+
} from "@shaferllc/keel/teams";
|
|
13
|
+
|
|
14
|
+
import type { User } from "../Models/User.js";
|
|
15
|
+
import { Project } from "../Models/Project.js";
|
|
16
|
+
import Teams from "../../resources/views/teams/index.js";
|
|
17
|
+
|
|
18
|
+
export class TeamController {
|
|
19
|
+
async index(c: Ctx) {
|
|
20
|
+
const user = await auth().user<User>();
|
|
21
|
+
if (!user) return c.redirect("/login");
|
|
22
|
+
|
|
23
|
+
const teamId = currentTeam();
|
|
24
|
+
|
|
25
|
+
return c.html(
|
|
26
|
+
await view(Teams, {
|
|
27
|
+
teams: (await teamsFor(user.id)).map((t) => ({ id: t.id, name: t.name })),
|
|
28
|
+
current: typeof teamId === "number" ? teamId : null,
|
|
29
|
+
// Scoped automatically — this is the current team's projects, and there is
|
|
30
|
+
// no `where` to forget.
|
|
31
|
+
projects: (await Project.all()).map((p) => ({ id: p.id, name: p.name })),
|
|
32
|
+
invitations: teamId ? await pendingInvitations(teamId) : [],
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async store(c: Ctx) {
|
|
38
|
+
const user = await auth().user<User>();
|
|
39
|
+
if (!user) return c.redirect("/login");
|
|
40
|
+
|
|
41
|
+
const body = await c.req.parseBody();
|
|
42
|
+
await createTeam(String(body.name ?? "New team"), user.id);
|
|
43
|
+
|
|
44
|
+
return c.redirect("/teams");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async switch(c: Ctx) {
|
|
48
|
+
const user = await auth().user<User>();
|
|
49
|
+
if (!user) return c.redirect("/login");
|
|
50
|
+
|
|
51
|
+
const body = await c.req.parseBody();
|
|
52
|
+
|
|
53
|
+
// Returns false for a team they aren't in — the check is the point.
|
|
54
|
+
await switchTeam(user.id, Number(body.team_id));
|
|
55
|
+
|
|
56
|
+
return c.redirect("/teams");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async invite(c: Ctx) {
|
|
60
|
+
const teamId = currentTeam();
|
|
61
|
+
if (typeof teamId !== "number") return c.redirect("/teams");
|
|
62
|
+
|
|
63
|
+
const body = await c.req.parseBody();
|
|
64
|
+
await invite(teamId, String(body.email ?? ""), (body.role as Role) ?? "member");
|
|
65
|
+
|
|
66
|
+
return c.redirect("/teams");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async accept(c: Ctx) {
|
|
70
|
+
const user = await auth().user<User>();
|
|
71
|
+
if (!user) return c.redirect("/login");
|
|
72
|
+
|
|
73
|
+
// The invited address is re-checked, so a forwarded link can't be redeemed by
|
|
74
|
+
// whoever happens to hold it.
|
|
75
|
+
const team = await acceptInvitation(c.req.param("token") ?? "", user.id, user.email);
|
|
76
|
+
|
|
77
|
+
return team ? c.redirect("/teams") : c.text("That invitation is invalid or has expired.", 422);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async createProject(c: Ctx) {
|
|
81
|
+
const body = await c.req.parseBody();
|
|
82
|
+
|
|
83
|
+
// Stamped with the current team by TenantModel — no team_id here on purpose.
|
|
84
|
+
await Project.create({ name: String(body.name ?? "Untitled") });
|
|
85
|
+
|
|
86
|
+
return c.redirect("/teams");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { HttpKernel, serveStatic, sessionMiddleware } from "@shaferllc/keel/core";
|
|
2
|
+
import type { Application } from "@shaferllc/keel/core";
|
|
3
|
+
import { teamContext } from "@shaferllc/keel/teams";
|
|
4
|
+
|
|
5
|
+
import { requestLogger } from "./Middleware/requestLogger.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Global middleware — runs on every request, in order.
|
|
9
|
+
*
|
|
10
|
+
* `teamContext()` puts the request inside the signed-in user's current team, which
|
|
11
|
+
* is what scopes every `TenantModel` without a single handler doing anything. It
|
|
12
|
+
* verifies membership rather than trusting `users.current_team_id` — that column is
|
|
13
|
+
* a number on a row the user can influence, so switching teams would otherwise be a
|
|
14
|
+
* matter of writing someone else's id into it.
|
|
15
|
+
*
|
|
16
|
+
* It must run after the session, or there's no user to resolve a team for.
|
|
17
|
+
*/
|
|
18
|
+
export class Kernel extends HttpKernel {
|
|
19
|
+
constructor(app: Application) {
|
|
20
|
+
super(app);
|
|
21
|
+
|
|
22
|
+
this.use(requestLogger);
|
|
23
|
+
this.use(serveStatic({ root: "./public" }));
|
|
24
|
+
this.use(sessionMiddleware());
|
|
25
|
+
this.use(teamContext());
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -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,20 @@
|
|
|
1
|
+
import { TenantModel } from "@shaferllc/keel/teams";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A project belongs to a team — and cannot be seen outside it.
|
|
5
|
+
*
|
|
6
|
+
* Extending `TenantModel` (not `Model`) is the entire difference. Reads are
|
|
7
|
+
* constrained by an inherited global scope, so even `Project.find(id)` returns null
|
|
8
|
+
* for another team's row; writes are stamped with the current team, so a project
|
|
9
|
+
* can't be born ownerless. You never write `.where("team_id", …)`, which means you
|
|
10
|
+
* can never forget it.
|
|
11
|
+
*/
|
|
12
|
+
export class Project extends TenantModel {
|
|
13
|
+
static override table = "projects";
|
|
14
|
+
static override fillable = ["name"];
|
|
15
|
+
static override timestamps = true;
|
|
16
|
+
|
|
17
|
+
declare id: number;
|
|
18
|
+
declare name: string;
|
|
19
|
+
declare team_id: number;
|
|
20
|
+
}
|
|
@@ -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
|
+
}
|