{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hono-auth",
  "type": "registry:block",
  "title": "tneemo/hono-auth",
  "description": "Email and password accounts for Hono APIs: register, login, JWT bearer tokens, requireAuth middleware, scrypt password hashing.",
  "author": "tneemo",
  "dependencies": [
    "zod@>=3.23.0"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://pkg.tneemo.com/r/tneemo/sqlite-db.json"
  ],
  "files": [
    {
      "path": "db/migrations/20260101000000_auth_users.sql",
      "type": "registry:file",
      "target": "~/db/migrations/20260101000000_auth_users.sql",
      "content": "CREATE TABLE IF NOT EXISTS users (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  email TEXT NOT NULL UNIQUE COLLATE NOCASE,\n  password_hash TEXT NOT NULL,\n  created_at TEXT NOT NULL\n);\n"
    },
    {
      "path": "src/auth/index.ts",
      "type": "registry:file",
      "target": "~/src/auth/index.ts",
      "content": "export { requireAuth, type AuthEnv, type AuthUser } from \"./middleware\";\nexport { hashPassword, verifyPassword } from \"./password\";\nexport { authRoutes } from \"./routes\";\nexport { issueToken, verifyToken } from \"./token\";\nexport { findUserById, type User } from \"./users\";\n"
    },
    {
      "path": "src/auth/middleware.ts",
      "type": "registry:file",
      "target": "~/src/auth/middleware.ts",
      "content": "import { createMiddleware } from \"hono/factory\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { verifyToken, type TokenUser } from \"./token\";\n\nexport type AuthUser = TokenUser;\n\n/** Use as `new Hono<AuthEnv>()` so `c.get(\"user\")` is typed. */\nexport type AuthEnv = { Variables: { user: AuthUser } };\n\n/** Requires `Authorization: Bearer <token>`; sets c.get(\"user\"). */\nexport const requireAuth = createMiddleware<AuthEnv>(async (c, next) => {\n  const header = c.req.header(\"authorization\") ?? \"\";\n  const token = header.startsWith(\"Bearer \") ? header.slice(7).trim() : \"\";\n  if (!token) throw new HTTPException(401, { message: \"Missing bearer token\" });\n  let user: AuthUser;\n  try {\n    user = await verifyToken(token);\n  } catch {\n    throw new HTTPException(401, { message: \"Invalid or expired token\" });\n  }\n  c.set(\"user\", user);\n  await next();\n});\n"
    },
    {
      "path": "src/auth/password.ts",
      "type": "registry:file",
      "target": "~/src/auth/password.ts",
      "content": "import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from \"node:crypto\";\nimport { promisify } from \"node:util\";\n\nconst scrypt = promisify(scryptCallback) as (password: string, salt: Buffer, keylen: number) => Promise<Buffer>;\n\n/** Returns \"scrypt$<salt>$<hash>\" (base64). */\nexport async function hashPassword(password: string): Promise<string> {\n  const salt = randomBytes(16);\n  const hash = await scrypt(password, salt, 64);\n  return `scrypt$${salt.toString(\"base64\")}$${hash.toString(\"base64\")}`;\n}\n\nexport async function verifyPassword(password: string, stored: string): Promise<boolean> {\n  const [algorithm, salt, hash] = stored.split(\"$\");\n  if (algorithm !== \"scrypt\" || !salt || !hash) return false;\n  const expected = Buffer.from(hash, \"base64\");\n  const actual = await scrypt(password, Buffer.from(salt, \"base64\"), expected.length);\n  return timingSafeEqual(actual, expected);\n}\n"
    },
    {
      "path": "src/auth/routes.ts",
      "type": "registry:file",
      "target": "~/src/auth/routes.ts",
      "content": "import { Hono, type Context } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { z } from \"zod\";\nimport { requireAuth, type AuthEnv } from \"./middleware\";\nimport { hashPassword, verifyPassword } from \"./password\";\nimport { issueToken } from \"./token\";\nimport { findUserByEmail, findUserById, insertUser } from \"./users\";\n\nconst registerSchema = z.object({\n  email: z.string().trim().toLowerCase().email().max(254),\n  password: z.string().min(8, \"Password must be at least 8 characters\").max(200),\n});\nconst loginSchema = z.object({\n  email: z.string().trim().toLowerCase(),\n  password: z.string(),\n});\n\nasync function body<T>(c: Context, schema: z.ZodType<T>): Promise<T> {\n  const json = await c.req.json().catch(() => {\n    throw new HTTPException(400, { message: \"Invalid JSON body\" });\n  });\n  const parsed = schema.safeParse(json);\n  if (!parsed.success) {\n    const detail = parsed.error.issues.map((i) => `${i.path.join(\".\") || \"body\"}: ${i.message}`).join(\"; \");\n    throw new HTTPException(400, { message: `Invalid request: ${detail}` });\n  }\n  return parsed.data;\n}\n\n/** POST /register, POST /login, GET /me. Mount with app.route(\"/auth\", authRoutes). */\nexport const authRoutes = new Hono<AuthEnv>()\n  .post(\"/register\", async (c) => {\n    const { email, password } = await body(c, registerSchema);\n    if (findUserByEmail(email)) throw new HTTPException(409, { message: \"Email is already registered\" });\n    const user = insertUser(email, await hashPassword(password));\n    return c.json({ user, token: await issueToken(user) }, 201);\n  })\n  .post(\"/login\", async (c) => {\n    const { email, password } = await body(c, loginSchema);\n    const row = findUserByEmail(email);\n    if (!row || !(await verifyPassword(password, row.password_hash))) {\n      throw new HTTPException(401, { message: \"Invalid email or password\" });\n    }\n    const user = { id: row.id, email: row.email, created_at: row.created_at };\n    return c.json({ user, token: await issueToken(user) });\n  })\n  .get(\"/me\", requireAuth, (c) => {\n    const user = findUserById(c.get(\"user\").id);\n    if (!user) throw new HTTPException(401, { message: \"Account no longer exists\" });\n    return c.json({ user });\n  });\n"
    },
    {
      "path": "src/auth/token.ts",
      "type": "registry:file",
      "target": "~/src/auth/token.ts",
      "content": "import { sign, verify } from \"hono/jwt\";\n\nconst DEV_SECRET = \"insecure-development-secret\";\n\nfunction secret(): string {\n  const value = process.env.JWT_SECRET;\n  if (value) return value;\n  if (process.env.NODE_ENV === \"production\") throw new Error(\"JWT_SECRET must be set in production\");\n  return DEV_SECRET;\n}\n\nexport interface TokenUser {\n  id: number;\n  email: string;\n}\n\nexport async function issueToken(user: TokenUser): Promise<string> {\n  const now = Math.floor(Date.now() / 1000);\n  const ttl = Number(process.env.JWT_TTL_SECONDS ?? 60 * 60 * 24 * 7);\n  return sign({ sub: String(user.id), email: user.email, iat: now, exp: now + ttl }, secret(), \"HS256\");\n}\n\n/** Throws if the token is invalid or expired. */\nexport async function verifyToken(token: string): Promise<TokenUser> {\n  const payload = await verify(token, secret(), \"HS256\");\n  return { id: Number(payload.sub), email: String(payload.email) };\n}\n"
    },
    {
      "path": "src/auth/users.ts",
      "type": "registry:file",
      "target": "~/src/auth/users.ts",
      "content": "import { getDb } from \"../db\";\n\nexport interface User {\n  id: number;\n  email: string;\n  created_at: string;\n}\n\ninterface UserRow extends User {\n  password_hash: string;\n}\n\nexport function findUserByEmail(email: string): UserRow | undefined {\n  return getDb().prepare(\"SELECT id, email, password_hash, created_at FROM users WHERE email = ?\").get(email) as UserRow | undefined;\n}\n\nexport function findUserById(id: number): User | undefined {\n  return getDb().prepare(\"SELECT id, email, created_at FROM users WHERE id = ?\").get(id) as User | undefined;\n}\n\nexport function insertUser(email: string, passwordHash: string): User {\n  return getDb()\n    .prepare(\"INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?) RETURNING id, email, created_at\")\n    .get(email, passwordHash, new Date().toISOString()) as unknown as User;\n}\n"
    },
    {
      "path": "test/auth.test.ts",
      "type": "registry:file",
      "target": "~/test/auth.test.ts",
      "content": "import { Hono } from \"hono\";\nimport { describe, expect, it } from \"vitest\";\nimport { authRoutes, requireAuth, type AuthEnv } from \"../src/auth\";\n\nconst app = new Hono<AuthEnv>();\napp.route(\"/auth\", authRoutes);\napp.get(\"/private\", requireAuth, (c) => c.json({ email: c.get(\"user\").email }));\n\nconst post = (path: string, body: unknown) =>\n  app.request(path, { method: \"POST\", headers: { \"content-type\": \"application/json\" }, body: JSON.stringify(body) });\n\ndescribe(\"auth\", () => {\n  it(\"registers, logs in and authorizes requests\", async () => {\n    const reg = await post(\"/auth/register\", { email: \"Ada@Example.com\", password: \"correct horse\" });\n    expect(reg.status).toBe(201);\n    const { user } = (await reg.json()) as { user: { email: string } };\n    expect(user.email).toBe(\"ada@example.com\");\n\n    const login = await post(\"/auth/login\", { email: \"ada@example.com\", password: \"correct horse\" });\n    expect(login.status).toBe(200);\n    const { token } = (await login.json()) as { token: string };\n\n    const me = await app.request(\"/auth/me\", { headers: { authorization: `Bearer ${token}` } });\n    expect(me.status).toBe(200);\n    const priv = await app.request(\"/private\", { headers: { authorization: `Bearer ${token}` } });\n    expect(await priv.json()).toEqual({ email: \"ada@example.com\" });\n  });\n\n  it(\"rejects duplicates, bad passwords and missing tokens\", async () => {\n    await post(\"/auth/register\", { email: \"bob@example.com\", password: \"password123\" });\n    expect((await post(\"/auth/register\", { email: \"bob@example.com\", password: \"password123\" })).status).toBe(409);\n    expect((await post(\"/auth/login\", { email: \"bob@example.com\", password: \"wrong-password\" })).status).toBe(401);\n    expect((await post(\"/auth/register\", { email: \"not-an-email\", password: \"password123\" })).status).toBe(400);\n    expect((await app.request(\"/private\")).status).toBe(401);\n    expect((await app.request(\"/private\", { headers: { authorization: \"Bearer nope\" } })).status).toBe(401);\n  });\n});\n"
    }
  ],
  "envVars": {
    "JWT_SECRET": "replace-with-a-long-random-string",
    "JWT_TTL_SECONDS": "604800"
  },
  "docs": "1. Mount the routes where the Hono app is created (`src/app.ts` in `tneemo/hono-api`):\n   ```ts\n   import { authRoutes } from \"./auth\";\n   app.route(\"/auth\", authRoutes);\n   ```\n   This adds `POST /auth/register`, `POST /auth/login` and `GET /auth/me`.\n2. Protect your own routes and read the signed-in user:\n   ```ts\n   import { Hono } from \"hono\";\n   import { requireAuth, type AuthEnv } from \"../auth\";\n\n   export const todoRoutes = new Hono<AuthEnv>();\n   todoRoutes.use(requireAuth);\n   todoRoutes.get(\"/\", (c) => {\n     const { id } = c.get(\"user\"); // the signed-in user's id\n     // ...\n   });\n   ```\n3. Reference users from your own tables with `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE` in a new migration in `db/migrations/`.\n4. Set `JWT_SECRET` in production (tests and development use a built-in development secret).\n",
  "categories": [
    "auth",
    "authentication",
    "login",
    "signup",
    "register",
    "users",
    "accounts",
    "jwt",
    "password",
    "session"
  ],
  "meta": {
    "pkg": "https://pkg.tneemo.com/tneemo/hono-auth",
    "version": "1.0.0",
    "license": "MIT"
  }
}