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.
@@ -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>Shibumi web app</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
- <h1>It persists.</h1>
85
- <p>Hono routes, Zod validation, Alpine behavior, and SQLite with Drizzle under a persistent volume. To get started, open <code>src/app.ts</code>; this page lives there.</p>
86
- <section x-data="counter">
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"></output>
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
- <p><a href="/api/hello?name=you">/api/hello</a> · <a href="/api/notes">/api/notes</a> · <a href="/healthz">/healthz</a></p>
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>
@@ -0,0 +1,6 @@
1
+ CREATE TABLE counters (
2
+ id INTEGER PRIMARY KEY CHECK (id = 1),
3
+ value INTEGER NOT NULL DEFAULT 0 CHECK (value BETWEEN 0 AND 1000000000)
4
+ );
5
+
6
+ INSERT INTO counters (id, value) VALUES (1, 0);
@@ -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("It persists.");
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", () => {