create-shibumi 0.2.5 → 0.2.8
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/package.json +3 -2
- package/scripts/shibumi.lock.json +2 -2
- package/scripts/ship.lock.json +2 -2
- package/src/templates/full-stack/agents.md +1 -1
- package/src/templates/full-stack/public/app.js +7 -2
- package/src/templates/full-stack/public/style.css +128 -38
- package/src/templates/full-stack/public/vendor/shibumi.css +422 -0
- package/src/templates/full-stack/src/app.ts +54 -9
- package/src/templates/full-stack/src/db/migrations/0002_counter.sql +6 -0
- package/src/templates/full-stack/src/db/schema.ts +8 -0
- package/src/templates/full-stack/test/app.test.ts +25 -2
- package/src/templates/shibumi.ts +2 -2
- package/src/templates/ship.ts +18 -15
- package/src/templates/web/public/style.css +128 -38
- package/src/templates/web/public/vendor/shibumi.css +422 -0
- package/src/templates/web/src/app.ts +17 -7
- package/src/templates/web/test/app.test.ts +1 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { HTTPException } from "hono/http-exception";
|
|
3
3
|
import { serveStatic } from "hono/bun";
|
|
4
|
-
import { desc } from "drizzle-orm";
|
|
4
|
+
import { desc, eq, sql } from "drizzle-orm";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { db } from "./db";
|
|
7
|
-
import { notes } from "./db/schema";
|
|
7
|
+
import { counters, notes } from "./db/schema";
|
|
8
8
|
|
|
9
9
|
// The exact response header set; test/app.test.ts asserts every entry on
|
|
10
10
|
// success, error, API, and static responses. Change both together.
|
|
@@ -67,12 +67,47 @@ export function createApp(): Hono {
|
|
|
67
67
|
return c.json({ notes: rows });
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
// The demo counter is the template's single unauthenticated mutation. It is
|
|
71
|
+
// safe by construction: one shared row, no request body read, value clamped
|
|
72
|
+
// by the schema CHECK, and an in-memory per-IP rate limit. Treat any other
|
|
73
|
+
// mutation route as needing authentication first (bun shi add auth).
|
|
74
|
+
const COUNTER_RATE = 30;
|
|
75
|
+
const COUNTER_WINDOW_MS = 60_000;
|
|
76
|
+
const counterHits = new Map<string, { count: number; resetAt: number }>();
|
|
77
|
+
function counterAllowed(key: string, now = Date.now()): boolean {
|
|
78
|
+
const entry = counterHits.get(key);
|
|
79
|
+
if (!entry || entry.resetAt <= now) {
|
|
80
|
+
if (counterHits.size > 10_000) counterHits.clear();
|
|
81
|
+
counterHits.set(key, { count: 1, resetAt: now + COUNTER_WINDOW_MS });
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
entry.count += 1;
|
|
85
|
+
return entry.count <= COUNTER_RATE;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
app.get("/api/counter", async (c) => {
|
|
89
|
+
const row = await db.select().from(counters).where(eq(counters.id, 1)).get();
|
|
90
|
+
return c.json({ count: row?.value ?? 0 });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
app.post("/api/counter", async (c) => {
|
|
94
|
+
const ip = (c.req.header("x-forwarded-for") ?? "local").split(",")[0]!.trim();
|
|
95
|
+
if (!counterAllowed(ip)) return c.json({ error: "Too many increments; try again in a minute." }, 429);
|
|
96
|
+
const [row] = await db
|
|
97
|
+
.update(counters)
|
|
98
|
+
.set({ value: sql`MIN(${counters.value} + 1, 1000000000)` })
|
|
99
|
+
.where(eq(counters.id, 1))
|
|
100
|
+
.returning();
|
|
101
|
+
return c.json({ count: row?.value ?? 0 });
|
|
102
|
+
});
|
|
103
|
+
|
|
70
104
|
const page = `<!doctype html>
|
|
71
105
|
<html lang="en">
|
|
72
106
|
<head>
|
|
73
107
|
<meta charset="utf-8" />
|
|
74
108
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
75
|
-
<title>
|
|
109
|
+
<title>Full Stack app · shibumi</title>
|
|
110
|
+
<link rel="stylesheet" href="/public/vendor/shibumi.css" />
|
|
76
111
|
<link rel="stylesheet" href="/public/style.css" />
|
|
77
112
|
<!-- app.js must load before Alpine so the alpine:init listener exists
|
|
78
113
|
when Alpine starts; both defer, so document order is execution order. -->
|
|
@@ -80,14 +115,24 @@ const page = `<!doctype html>
|
|
|
80
115
|
<script src="/public/vendor/alpine-csp-3.16.2.min.js" defer></script>
|
|
81
116
|
</head>
|
|
82
117
|
<body>
|
|
83
|
-
<main>
|
|
84
|
-
<
|
|
85
|
-
<
|
|
86
|
-
<
|
|
118
|
+
<main class="scaffold">
|
|
119
|
+
<p class="masthead"><span class="masthead-mark">渋み</span> shibumi full stack</p>
|
|
120
|
+
<h1>Full Stack app</h1>
|
|
121
|
+
<p class="lede">Hono serves the routes, Zod validates every input, Alpine runs the client behavior, and SQLite with Drizzle keeps your data on a volume that survives every deploy.</p>
|
|
122
|
+
<section class="demo" x-data="counter" aria-label="Alpine counter demo">
|
|
87
123
|
<button x-on:click="inc" type="button">Count</button>
|
|
88
|
-
<output x-text="count"
|
|
124
|
+
<output x-text="count">0</output>
|
|
125
|
+
<span class="demo-hint">stored in SQLite; reloads and deploys keep it</span>
|
|
89
126
|
</section>
|
|
90
|
-
<
|
|
127
|
+
<ul class="endpoints">
|
|
128
|
+
<li><a href="/api/hello?name=you"><span class="endpoint-path">GET /api/hello</span><span>query validated with Zod</span></a></li>
|
|
129
|
+
<li><a href="/api/notes"><span class="endpoint-path">GET /api/notes</span><span>Drizzle read from SQLite on the data volume</span></a></li>
|
|
130
|
+
<li><a href="/healthz"><span class="endpoint-path">GET /healthz</span><span>the check every deploy waits for</span></a></li>
|
|
131
|
+
</ul>
|
|
132
|
+
<footer class="colophon">
|
|
133
|
+
<p>This page lives in <code>src/app.ts</code>. House rules for coding agents are in <code>agents.md</code>. Add features with <code>bun shi add auth</code>.</p>
|
|
134
|
+
<p class="colophon-links"><a href="https://shibumistack.dev/docs" rel="noreferrer">shibumi docs</a> · <a href="https://server.shibumistack.dev/docs" rel="noreferrer">server docs</a> · <a href="https://shibumistack.dev/docs/cli/extensions" rel="noreferrer">extensions</a></p>
|
|
135
|
+
</footer>
|
|
91
136
|
</main>
|
|
92
137
|
</body>
|
|
93
138
|
</html>
|
|
@@ -11,3 +11,11 @@ export const notes = sqliteTable("notes", {
|
|
|
11
11
|
.notNull()
|
|
12
12
|
.default(sql`(datetime('now'))`),
|
|
13
13
|
});
|
|
14
|
+
|
|
15
|
+
// One demo counter row (id fixed to 1). The page increments it through
|
|
16
|
+
// POST /api/counter, the only unauthenticated mutation in the template:
|
|
17
|
+
// rate-limited, clamped by a CHECK constraint, and holding no user data.
|
|
18
|
+
export const counters = sqliteTable("counters", {
|
|
19
|
+
id: integer("id").primaryKey(),
|
|
20
|
+
value: integer("value").notNull().default(0),
|
|
21
|
+
});
|
|
@@ -39,7 +39,7 @@ describe("routes", () => {
|
|
|
39
39
|
const res = await req("/");
|
|
40
40
|
expect(res.status).toBe(200);
|
|
41
41
|
const html = await res.text();
|
|
42
|
-
expect(html).toContain("
|
|
42
|
+
expect(html).toContain("Full Stack app");
|
|
43
43
|
expect(html.indexOf("/public/app.js")).toBeLessThan(
|
|
44
44
|
html.indexOf("/public/vendor/alpine-csp-3.16.2.min.js")
|
|
45
45
|
);
|
|
@@ -108,18 +108,41 @@ describe("security", () => {
|
|
|
108
108
|
"GET /",
|
|
109
109
|
"GET /api/hello",
|
|
110
110
|
"GET /api/notes",
|
|
111
|
+
"GET /api/counter",
|
|
112
|
+
"POST /api/counter",
|
|
111
113
|
"GET /healthz",
|
|
112
114
|
].sort()
|
|
113
115
|
);
|
|
114
116
|
});
|
|
115
117
|
|
|
116
|
-
it("rejects every mutation verb on every route", async () => {
|
|
118
|
+
it("rejects every mutation verb on every route except the demo counter", async () => {
|
|
117
119
|
for (const path of ["/", "/api/hello", "/api/notes", "/healthz", "/public/style.css"]) {
|
|
118
120
|
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
|
119
121
|
const res = await req(path, { method });
|
|
120
122
|
expect([404, 405]).toContain(res.status);
|
|
121
123
|
}
|
|
122
124
|
}
|
|
125
|
+
for (const method of ["PUT", "PATCH", "DELETE"]) {
|
|
126
|
+
expect([404, 405]).toContain((await req("/api/counter", { method })).status);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("persists counter increments in the database", async () => {
|
|
131
|
+
const before = (await (await req("/api/counter")).json()).count;
|
|
132
|
+
const res = await req("/api/counter", { method: "POST", headers: { "x-forwarded-for": "10.9.0.1" } });
|
|
133
|
+
expect(res.status).toBe(200);
|
|
134
|
+
expect((await res.json()).count).toBe(before + 1);
|
|
135
|
+
// A fresh read (what a reload does) sees the stored value.
|
|
136
|
+
expect((await (await req("/api/counter")).json()).count).toBe(before + 1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("rate-limits counter increments per IP", async () => {
|
|
140
|
+
let limited = false;
|
|
141
|
+
for (let i = 0; i < 40; i++) {
|
|
142
|
+
const res = await req("/api/counter", { method: "POST", headers: { "x-forwarded-for": "10.9.0.2" } });
|
|
143
|
+
if (res.status === 429) { limited = true; break; }
|
|
144
|
+
}
|
|
145
|
+
expect(limited).toBe(true);
|
|
123
146
|
});
|
|
124
147
|
|
|
125
148
|
it("pins a CSP without unsafe-inline or unsafe-eval", () => {
|