summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--Deploy.md16
-rw-r--r--app/components/EventCard.tsx1
-rw-r--r--app/components/EventListRow.tsx1
-rw-r--r--app/components/FilterBar.tsx19
-rw-r--r--app/lib/auth.server.ts304
-rw-r--r--app/lib/db.server.ts64
-rw-r--r--app/routes.ts3
-rw-r--r--app/routes/api.auth.$step.ts63
-rw-r--r--app/routes/events.$id.tsx1
-rw-r--r--app/routes/events._index.tsx15
-rw-r--r--app/routes/events.by-date.tsx8
-rw-r--r--app/routes/favorites.tsx161
-rw-r--r--app/routes/login.tsx117
-rw-r--r--app/routes/venues.tsx1
-rw-r--r--package-lock.json334
-rw-r--r--package.json2
17 files changed, 1098 insertions, 13 deletions
diff --git a/.gitignore b/.gitignore
index 7c4fad0..0b33ad8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,4 @@
# Generated Markdown event files (dev reference, auto-generated)
/events/
+registration-token
diff --git a/Deploy.md b/Deploy.md
index 11921f3..91ae34e 100644
--- a/Deploy.md
+++ b/Deploy.md
@@ -66,6 +66,22 @@ git remote add hetzner golive:/var/git/tokyo-livehouse-events.git
git push hetzner master
```
+## パスキー(ログイン)
+
+利用者は1人。どのパスキーでログインしても同じオーナー扱い。ログインするとお気に入りワード(`/favorites`)が使える。
+
+```bash
+# 初回登録: パスキー未登録なら /login へのアクセス時にトークンが自動生成される
+curl -s https://golive.yyamashita.com/login > /dev/null
+ssh root@localhost 'cat /app/tokyo-livehouse-events/data/registration-token'
+# → https://golive.yyamashita.com/login?token=<トークン> を開いて登録(トークンは1回で消える)
+
+# 全デバイスを失った場合: トークンを手で作って再登録
+ssh root@localhost 'openssl rand -base64 24 | tr "+/" "-_" > /app/tokyo-livehouse-events/data/registration-token'
+```
+
+2台目以降は、ログイン済みの状態で `/favorites` →「パスキーを追加」。
+
## ローカル開発
```bash
diff --git a/app/components/EventCard.tsx b/app/components/EventCard.tsx
index b23742d..ad55a57 100644
--- a/app/components/EventCard.tsx
+++ b/app/components/EventCard.tsx
@@ -30,6 +30,7 @@ export default function EventCard({ event }: Props) {
<div className="flex-1 p-4 flex flex-col gap-2">
{/* Title */}
<h2 className="font-semibold text-sm leading-snug group-hover:text-indigo-300 transition-colors line-clamp-2">
+ {event.is_favorite ? <span className="text-amber-400" title="お気に入り">★ </span> : null}
{event.title}
</h2>
diff --git a/app/components/EventListRow.tsx b/app/components/EventListRow.tsx
index 30800fb..42f3e8b 100644
--- a/app/components/EventListRow.tsx
+++ b/app/components/EventListRow.tsx
@@ -31,6 +31,7 @@ export default function EventListRow({ event }: Props) {
{/* Title + artist */}
<span className="flex-1 min-w-0">
<span className="block truncate font-medium text-gray-100 group-hover:text-indigo-300 transition-colors">
+ {event.is_favorite ? <span className="text-amber-400" title="お気に入り">★ </span> : null}
{event.title}
</span>
{event.artist && (
diff --git a/app/components/FilterBar.tsx b/app/components/FilterBar.tsx
index 7a95d2c..f897e67 100644
--- a/app/components/FilterBar.tsx
+++ b/app/components/FilterBar.tsx
@@ -6,9 +6,11 @@ interface Props {
areas?: string[];
defaultDateFrom?: string;
defaultDateTo?: string;
+ /** Show the "favorites only" toggle (logged in only) */
+ showFavorites?: boolean;
}
-export default function FilterBar({ venues, areas, defaultDateFrom, defaultDateTo }: Props) {
+export default function FilterBar({ venues, areas, defaultDateFrom, defaultDateTo, showFavorites }: Props) {
const [searchParams] = useSearchParams();
return (
@@ -98,6 +100,19 @@ export default function FilterBar({ venues, areas, defaultDateFrom, defaultDateT
</select>
</div>
+ {showFavorites && (
+ <label className="flex items-center gap-1.5 py-1.5 text-sm text-gray-300 cursor-pointer">
+ <input
+ name="fav"
+ type="checkbox"
+ value="1"
+ defaultChecked={searchParams.get("fav") === "1"}
+ className="accent-amber-400"
+ />
+ <span className="text-amber-400">★</span> お気に入りのみ
+ </label>
+ )}
+
<button
type="submit"
className="rounded-md bg-gray-700 px-4 py-1.5 text-sm font-medium hover:bg-gray-600 transition-colors"
@@ -118,5 +133,5 @@ export default function FilterBar({ venues, areas, defaultDateFrom, defaultDateT
}
function hasFilters(params: URLSearchParams): boolean {
- return ["keyword", "venue_id", "date_from", "date_to", "capacity_range", "area"].some((k) => params.get(k));
+ return ["keyword", "venue_id", "date_from", "date_to", "capacity_range", "area", "fav"].some((k) => params.get(k));
}
diff --git a/app/lib/auth.server.ts b/app/lib/auth.server.ts
new file mode 100644
index 0000000..e0e93b8
--- /dev/null
+++ b/app/lib/auth.server.ts
@@ -0,0 +1,304 @@
+/**
+ * Single-user passkey auth + favorites.
+ *
+ * - どのパスキーでログインしても同じ「オーナー」としてログインする(ユーザーは1人)
+ * - 未ログイン時のパスキー登録は、DATA_DIR/registration-token に書かれたトークンが必要
+ * - パスキーが1つも無ければ起動後の初回アクセス時に自動生成される
+ * - 全デバイスを失った場合は、このファイルを手で作れば再登録できる
+ * - ログイン済みなら追加のパスキー(別デバイス)をトークン無しで登録できる
+ */
+import crypto from "crypto";
+import fs from "fs";
+import path from "path";
+import {
+ generateAuthenticationOptions,
+ generateRegistrationOptions,
+ verifyAuthenticationResponse,
+ verifyRegistrationResponse,
+ type AuthenticationResponseJSON,
+ type RegistrationResponseJSON,
+} from "@simplewebauthn/server";
+import { DATA_DIR, getDb, normalizeTerm } from "./db.server";
+
+const SESSION_COOKIE = "sid";
+const CHALLENGE_COOKIE = "wa_chal";
+const SESSION_DAYS = 90;
+const CHALLENGE_MINUTES = 5;
+const RP_NAME = "ライブに行くしかない";
+const TOKEN_PATH = path.join(DATA_DIR, "registration-token");
+
+// ---------- Request helpers ----------
+
+/** Origin/RP ID as seen by the browser. Behind Caddy, the app itself receives plain http. */
+function relyingParty(request: Request): { origin: string; rpID: string } {
+ const url = new URL(request.url);
+ const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? url.host;
+ const proto = request.headers.get("x-forwarded-proto") ?? url.protocol.replace(/:$/, "");
+ return { origin: `${proto}://${host}`, rpID: host.replace(/:\d+$/, "") };
+}
+
+function readCookie(request: Request, name: string): string | null {
+ const header = request.headers.get("cookie");
+ if (!header) return null;
+ for (const part of header.split(";")) {
+ const [k, ...v] = part.trim().split("=");
+ if (k === name) return decodeURIComponent(v.join("="));
+ }
+ return null;
+}
+
+function cookie(request: Request, name: string, value: string, maxAgeSec: number): string {
+ const secure = relyingParty(request).origin.startsWith("https:") ? "; Secure" : "";
+ return `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSec}${secure}`;
+}
+
+function sqlTimeIn(seconds: number): string {
+ return new Date(Date.now() + seconds * 1000).toISOString().replace("T", " ").slice(0, 19);
+}
+
+// ---------- Sessions ----------
+
+export function isLoggedIn(request: Request): boolean {
+ const sid = readCookie(request, SESSION_COOKIE);
+ if (!sid) return false;
+ const row = getDb()
+ .prepare("SELECT 1 FROM sessions WHERE id = ? AND expires_at > datetime('now')")
+ .get(sid);
+ return !!row;
+}
+
+export function requireLogin(request: Request): void {
+ if (!isLoggedIn(request)) {
+ const url = new URL(request.url);
+ throw new Response(null, {
+ status: 302,
+ headers: { Location: `/login?next=${encodeURIComponent(url.pathname + url.search)}` },
+ });
+ }
+}
+
+function createSession(request: Request): string {
+ const db = getDb();
+ db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run();
+ const sid = crypto.randomBytes(32).toString("base64url");
+ const maxAge = SESSION_DAYS * 86400;
+ db.prepare("INSERT INTO sessions (id, expires_at) VALUES (?, ?)").run(sid, sqlTimeIn(maxAge));
+ return cookie(request, SESSION_COOKIE, sid, maxAge);
+}
+
+export function logout(request: Request): string {
+ const sid = readCookie(request, SESSION_COOKIE);
+ if (sid) getDb().prepare("DELETE FROM sessions WHERE id = ?").run(sid);
+ return cookie(request, SESSION_COOKIE, "", 0);
+}
+
+// ---------- Challenges (one-time, short-lived) ----------
+
+function storeChallenge(request: Request, challenge: string): string {
+ const db = getDb();
+ db.prepare("DELETE FROM auth_challenges WHERE expires_at <= datetime('now')").run();
+ const id = crypto.randomBytes(16).toString("base64url");
+ const maxAge = CHALLENGE_MINUTES * 60;
+ db.prepare("INSERT INTO auth_challenges (id, challenge, expires_at) VALUES (?, ?, ?)").run(
+ id, challenge, sqlTimeIn(maxAge)
+ );
+ return cookie(request, CHALLENGE_COOKIE, id, maxAge);
+}
+
+function consumeChallenge(request: Request): string | null {
+ const id = readCookie(request, CHALLENGE_COOKIE);
+ if (!id) return null;
+ const db = getDb();
+ const row = db
+ .prepare("SELECT challenge FROM auth_challenges WHERE id = ? AND expires_at > datetime('now')")
+ .get(id) as { challenge: string } | undefined;
+ db.prepare("DELETE FROM auth_challenges WHERE id = ?").run(id);
+ return row?.challenge ?? null;
+}
+
+// ---------- Registration token ----------
+
+function passkeyCount(): number {
+ return (getDb().prepare("SELECT COUNT(*) AS n FROM passkeys").get() as { n: number }).n;
+}
+
+/** Ensure a registration token exists while no passkey is registered (first-time setup). */
+export function ensureSetupToken(): void {
+ if (passkeyCount() > 0 || fs.existsSync(TOKEN_PATH)) return;
+ fs.writeFileSync(TOKEN_PATH, crypto.randomBytes(24).toString("base64url") + "\n", { mode: 0o600 });
+ console.log(`[auth] No passkey registered. Registration token written to ${TOKEN_PATH}`);
+}
+
+function checkToken(token: string | null): boolean {
+ if (!token || !fs.existsSync(TOKEN_PATH)) return false;
+ const expected = Buffer.from(fs.readFileSync(TOKEN_PATH, "utf8").trim());
+ const given = Buffer.from(token.trim());
+ return expected.length === given.length && crypto.timingSafeEqual(expected, given);
+}
+
+export function canRegister(request: Request, token: string | null): boolean {
+ return isLoggedIn(request) || checkToken(token);
+}
+
+// ---------- WebAuthn ceremonies ----------
+
+interface PasskeyRow {
+ id: string;
+ public_key: Buffer;
+ counter: number;
+ transports: string | null;
+}
+
+const OWNER_ID = new TextEncoder().encode("owner");
+
+export async function registrationOptions(request: Request) {
+ const { rpID } = relyingParty(request);
+ const existing = getDb().prepare("SELECT id, transports FROM passkeys").all() as PasskeyRow[];
+ const options = await generateRegistrationOptions({
+ rpName: RP_NAME,
+ rpID,
+ userID: OWNER_ID,
+ userName: "owner",
+ userDisplayName: "オーナー",
+ attestationType: "none",
+ excludeCredentials: existing.map((p) => ({
+ id: p.id,
+ transports: p.transports ? JSON.parse(p.transports) : undefined,
+ })),
+ authenticatorSelection: { residentKey: "required", userVerification: "preferred" },
+ });
+ return { options, setCookie: storeChallenge(request, options.challenge) };
+}
+
+export async function verifyRegistration(
+ request: Request,
+ response: RegistrationResponseJSON,
+ label: string | null,
+ usedToken: boolean
+): Promise<string> {
+ const expectedChallenge = consumeChallenge(request);
+ if (!expectedChallenge) throw new Error("challenge expired");
+ const { origin, rpID } = relyingParty(request);
+ const result = await verifyRegistrationResponse({
+ response,
+ expectedChallenge,
+ expectedOrigin: origin,
+ expectedRPID: rpID,
+ requireUserVerification: false,
+ });
+ if (!result.verified) throw new Error("registration not verified");
+
+ const { credential } = result.registrationInfo;
+ getDb()
+ .prepare("INSERT INTO passkeys (id, public_key, counter, transports, label) VALUES (?, ?, ?, ?, ?)")
+ .run(
+ credential.id,
+ Buffer.from(credential.publicKey),
+ credential.counter,
+ credential.transports ? JSON.stringify(credential.transports) : null,
+ label?.slice(0, 60) || null
+ );
+
+ // Token is single-use
+ if (usedToken) fs.rmSync(TOKEN_PATH, { force: true });
+
+ return createSession(request);
+}
+
+export async function authenticationOptions(request: Request) {
+ const { rpID } = relyingParty(request);
+ const options = await generateAuthenticationOptions({ rpID, userVerification: "preferred" });
+ return { options, setCookie: storeChallenge(request, options.challenge) };
+}
+
+export async function verifyAuthentication(
+ request: Request,
+ response: AuthenticationResponseJSON
+): Promise<string> {
+ const expectedChallenge = consumeChallenge(request);
+ if (!expectedChallenge) throw new Error("challenge expired");
+ const db = getDb();
+ const passkey = db.prepare("SELECT * FROM passkeys WHERE id = ?").get(response.id) as
+ | PasskeyRow
+ | undefined;
+ if (!passkey) throw new Error("unknown passkey");
+
+ const { origin, rpID } = relyingParty(request);
+ const result = await verifyAuthenticationResponse({
+ response,
+ expectedChallenge,
+ expectedOrigin: origin,
+ expectedRPID: rpID,
+ credential: {
+ id: passkey.id,
+ publicKey: new Uint8Array(passkey.public_key),
+ counter: passkey.counter,
+ transports: passkey.transports ? JSON.parse(passkey.transports) : undefined,
+ },
+ requireUserVerification: false,
+ });
+ if (!result.verified) throw new Error("authentication not verified");
+
+ db.prepare("UPDATE passkeys SET counter = ?, last_used_at = datetime('now') WHERE id = ?").run(
+ result.authenticationInfo.newCounter,
+ passkey.id
+ );
+ return createSession(request);
+}
+
+// ---------- Passkey management ----------
+
+export interface PasskeyInfo {
+ id: string;
+ label: string | null;
+ created_at: string;
+ last_used_at: string | null;
+}
+
+export function listPasskeys(): PasskeyInfo[] {
+ return getDb()
+ .prepare("SELECT id, label, created_at, last_used_at FROM passkeys ORDER BY created_at")
+ .all() as PasskeyInfo[];
+}
+
+/** Refuses to delete the last passkey so the owner can't lock themselves out. */
+export function deletePasskey(id: string): boolean {
+ if (passkeyCount() <= 1) return false;
+ return getDb().prepare("DELETE FROM passkeys WHERE id = ?").run(id).changes > 0;
+}
+
+// ---------- Favorites ----------
+
+export interface Favorite {
+ id: number;
+ term: string;
+ upcoming_count: number;
+}
+
+export function listFavorites(): Favorite[] {
+ return getDb()
+ .prepare(
+ `SELECT f.id, f.term,
+ (SELECT COUNT(*) FROM events e
+ WHERE e.date >= date('now', 'localtime')
+ AND instr(nfkc_lower(e.title || ' ' || COALESCE(e.artist, '')), f.term_norm) > 0
+ ) AS upcoming_count
+ FROM favorites f ORDER BY f.term COLLATE NOCASE`
+ )
+ .all() as Favorite[];
+}
+
+export function addFavorite(term: string): boolean {
+ const trimmed = term.trim().slice(0, 100);
+ const norm = normalizeTerm(trimmed);
+ if (!norm) return false;
+ return (
+ getDb()
+ .prepare("INSERT OR IGNORE INTO favorites (term, term_norm) VALUES (?, ?)")
+ .run(trimmed, norm).changes > 0
+ );
+}
+
+export function deleteFavorite(id: number): void {
+ getDb().prepare("DELETE FROM favorites WHERE id = ?").run(id);
+}
diff --git a/app/lib/db.server.ts b/app/lib/db.server.ts
index 82a0025..9d6597d 100644
--- a/app/lib/db.server.ts
+++ b/app/lib/db.server.ts
@@ -9,11 +9,22 @@ const DB_PATH = process.env.DB_PATH
let _db: Database.Database | null = null;
-function getDb(): Database.Database {
+/** Directory holding the DB file — also used for other runtime state files. */
+export const DATA_DIR = path.dirname(DB_PATH);
+
+/** Normalization used for favorite matching: full/half-width and case insensitive. */
+export function normalizeTerm(s: string): string {
+ return s.normalize("NFKC").toLowerCase().trim();
+}
+
+export function getDb(): Database.Database {
if (!_db) {
_db = new Database(DB_PATH);
_db.pragma("journal_mode = WAL");
_db.pragma("foreign_keys = ON");
+ _db.function("nfkc_lower", { deterministic: true }, (s: unknown) =>
+ typeof s === "string" ? normalizeTerm(s) : null
+ );
initSchema(_db);
}
return _db;
@@ -63,6 +74,36 @@ function initSchema(db: Database.Database) {
CREATE INDEX IF NOT EXISTS idx_scrape_logs_run_id ON scrape_logs(run_id);
CREATE INDEX IF NOT EXISTS idx_scrape_logs_venue_id ON scrape_logs(venue_id);
+
+ -- Single-user auth: any registered passkey logs in as "the" owner
+ CREATE TABLE IF NOT EXISTS passkeys (
+ id TEXT PRIMARY KEY, -- credential ID (base64url)
+ public_key BLOB NOT NULL,
+ counter INTEGER NOT NULL DEFAULT 0,
+ transports TEXT, -- JSON array
+ label TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ last_used_at TEXT
+ );
+
+ CREATE TABLE IF NOT EXISTS sessions (
+ id TEXT PRIMARY KEY,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ expires_at TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS auth_challenges (
+ id TEXT PRIMARY KEY,
+ challenge TEXT NOT NULL,
+ expires_at TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS favorites (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ term TEXT NOT NULL,
+ term_norm TEXT NOT NULL UNIQUE,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
`);
// Migration: add capacity column to existing venues tables
@@ -99,6 +140,8 @@ export interface Event {
description: string | null;
source_url: string | null;
fetched_at: string;
+ /** 1 if title/artist matches a favorite term. Only computed when with_favorites is set. */
+ is_favorite?: number;
}
export interface EventInput {
@@ -173,13 +216,24 @@ export interface QueryEventsParams {
keyword?: string;
capacity_range?: CapacityRange;
area?: string;
+ /** Compute is_favorite for each row */
+ with_favorites?: boolean;
+ /** Only events matching a favorite term */
+ favorites_only?: boolean;
limit?: number;
offset?: number;
}
+const FAVORITE_MATCH_SQL = `EXISTS (
+ SELECT 1 FROM favorites f
+ WHERE instr(nfkc_lower(e.title || ' ' || COALESCE(e.artist, '')), f.term_norm) > 0
+)`;
+
export function queryEvents(params: QueryEventsParams = {}): Event[] {
- const { date_from, date_to, venue_id, keyword, capacity_range, area, limit = 60, offset = 0 } =
- params;
+ const {
+ date_from, date_to, venue_id, keyword, capacity_range, area,
+ with_favorites, favorites_only, limit = 60, offset = 0,
+ } = params;
const clauses: string[] = [];
const args: unknown[] = [];
@@ -211,12 +265,16 @@ export function queryEvents(params: QueryEventsParams = {}): Event[] {
clauses.push("v.area = ?");
args.push(area);
}
+ if (favorites_only) {
+ clauses.push(FAVORITE_MATCH_SQL);
+ }
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
return getDb()
.prepare(
`SELECT e.*, v.name AS venue_name, v.area AS venue_area
+ ${with_favorites ? `, ${FAVORITE_MATCH_SQL} AS is_favorite` : ""}
FROM events e JOIN venues v ON e.venue_id = v.id
${where}
ORDER BY e.date ASC, e.start_time ASC
diff --git a/app/routes.ts b/app/routes.ts
index 21a950d..d45106c 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -8,6 +8,8 @@ export default [
route(":id", "routes/events.$id.tsx"),
]),
route("venues", "routes/venues.tsx"),
+ route("favorites", "routes/favorites.tsx"),
+ route("login", "routes/login.tsx"),
...prefix("api", [
route("scrape", "routes/api.scrape.ts"),
route("scrape-status", "routes/api.scrape-status.ts"),
@@ -17,5 +19,6 @@ export default [
route("venues", "routes/api.venues.ts"),
route("openapi.json", "routes/api.openapi.ts"),
route("image", "routes/api.image.ts"),
+ route("auth/:step", "routes/api.auth.$step.ts"),
]),
] satisfies RouteConfig;
diff --git a/app/routes/api.auth.$step.ts b/app/routes/api.auth.$step.ts
new file mode 100644
index 0000000..64940f2
--- /dev/null
+++ b/app/routes/api.auth.$step.ts
@@ -0,0 +1,63 @@
+/**
+ * Resource route: POST /api/auth/:step
+ *
+ * register-options / register-verify — パスキー登録(ログイン済み or 登録トークン必須)
+ * login-options / login-verify — パスキーでログイン
+ * logout — セッション破棄して /events へ
+ */
+import { redirect } from "react-router";
+import type { Route } from "./+types/api.auth.$step";
+import {
+ authenticationOptions,
+ canRegister,
+ isLoggedIn,
+ logout,
+ registrationOptions,
+ verifyAuthentication,
+ verifyRegistration,
+} from "~/lib/auth.server";
+
+export async function action({ request, params }: Route.ActionArgs) {
+ switch (params.step) {
+ case "register-options": {
+ const { token } = await request.json();
+ if (!canRegister(request, token ?? null)) return forbidden();
+ const { options, setCookie } = await registrationOptions(request);
+ return Response.json(options, { headers: { "Set-Cookie": setCookie } });
+ }
+ case "register-verify": {
+ const { token, response, label } = await request.json();
+ const loggedIn = isLoggedIn(request);
+ if (!canRegister(request, token ?? null)) return forbidden();
+ return withSession(() =>
+ verifyRegistration(request, response, label ?? null, !loggedIn)
+ );
+ }
+ case "login-options": {
+ const { options, setCookie } = await authenticationOptions(request);
+ return Response.json(options, { headers: { "Set-Cookie": setCookie } });
+ }
+ case "login-verify": {
+ const { response } = await request.json();
+ return withSession(() => verifyAuthentication(request, response));
+ }
+ case "logout":
+ return redirect("/events", { headers: { "Set-Cookie": logout(request) } });
+ default:
+ throw new Response("Not Found", { status: 404 });
+ }
+}
+
+async function withSession(verify: () => Promise<string>): Promise<Response> {
+ try {
+ const setCookie = await verify();
+ return Response.json({ ok: true }, { headers: { "Set-Cookie": setCookie } });
+ } catch (e) {
+ console.warn("[auth]", e);
+ return Response.json({ ok: false, error: (e as Error).message }, { status: 400 });
+ }
+}
+
+function forbidden(): Response {
+ return Response.json({ ok: false, error: "登録トークンが無効です" }, { status: 403 });
+}
diff --git a/app/routes/events.$id.tsx b/app/routes/events.$id.tsx
index 87836ed..6837430 100644
--- a/app/routes/events.$id.tsx
+++ b/app/routes/events.$id.tsx
@@ -73,6 +73,7 @@ export default function EventDetail() {
<Link to="/events" className="hover:text-white transition-colors">イベント</Link>
<Link to="/events/by-date" className="hover:text-white transition-colors">日付別</Link>
<Link to="/venues" className="hover:text-white transition-colors">会場一覧</Link>
+ <Link to="/favorites" className="hover:text-white transition-colors">★ お気に入り</Link>
</nav>
</header>
diff --git a/app/routes/events._index.tsx b/app/routes/events._index.tsx
index 33568df..563b0c0 100644
--- a/app/routes/events._index.tsx
+++ b/app/routes/events._index.tsx
@@ -6,6 +6,7 @@ import EventListRow from "~/components/EventListRow";
import FilterBar from "~/components/FilterBar";
import ScrapeButton from "~/components/ScrapeButton";
import { useVenueFilter } from "~/lib/venue-filter";
+import { isLoggedIn } from "~/lib/auth.server";
function defaultWindow() {
const today = new Date();
@@ -27,19 +28,24 @@ export async function loader({ request }: Route.LoaderArgs) {
const keyword = url.searchParams.get("keyword") ?? undefined;
const capacity_range = (url.searchParams.get("capacity_range") ?? undefined) as CapacityRange | undefined;
const area = url.searchParams.get("area") ?? undefined;
+ const loggedIn = isLoggedIn(request);
+ const favorites_only = loggedIn && url.searchParams.get("fav") === "1";
const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1", 10));
const limit = 30;
const offset = (page - 1) * limit;
- const events = queryEvents({ date_from, date_to, venue_id, keyword, capacity_range, area, limit, offset });
+ const events = queryEvents({
+ date_from, date_to, venue_id, keyword, capacity_range, area,
+ with_favorites: loggedIn, favorites_only, limit, offset,
+ });
const venues = getVenues();
const areas = [...new Set(venues.filter((v) => v.area).map((v) => v.area as string))].sort();
- return { events, venues, areas, page, hasMore: events.length === limit, date_from, date_to };
+ return { events, venues, areas, page, hasMore: events.length === limit, date_from, date_to, loggedIn };
}
export default function EventsIndex() {
- const { events, venues, areas, page, hasMore, date_from, date_to } = useLoaderData<typeof loader>();
+ const { events, venues, areas, page, hasMore, date_from, date_to, loggedIn } = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const view = (searchParams.get("view") ?? "card") as "card" | "list";
const { hiddenIds } = useVenueFilter();
@@ -67,6 +73,7 @@ export default function EventsIndex() {
<Link to="/events" className="hover:text-white transition-colors">イベント</Link>
<Link to="/events/by-date" className="hover:text-white transition-colors">日付別</Link>
<Link to="/venues" className="hover:text-white transition-colors">会場一覧</Link>
+ <Link to="/favorites" className="hover:text-white transition-colors">★ お気に入り</Link>
</nav>
</header>
@@ -94,7 +101,7 @@ export default function EventsIndex() {
</div>
</div>
- <FilterBar venues={venues} areas={areas} defaultDateFrom={date_from} defaultDateTo={date_to} />
+ <FilterBar venues={venues} areas={areas} defaultDateFrom={date_from} defaultDateTo={date_to} showFavorites={loggedIn} />
{hiddenEventCount > 0 && (
<p className="mt-3 text-xs text-gray-500">
diff --git a/app/routes/events.by-date.tsx b/app/routes/events.by-date.tsx
index ee4a873..5c7876f 100644
--- a/app/routes/events.by-date.tsx
+++ b/app/routes/events.by-date.tsx
@@ -2,6 +2,7 @@ import { useLoaderData, useSearchParams, Link, Form } from "react-router";
import { queryEvents, getVenues, type CapacityRange } from "~/lib/db.server";
import type { Event } from "~/lib/db.server";
import { useVenueFilter } from "~/lib/venue-filter";
+import { isLoggedIn } from "~/lib/auth.server";
function todayJst(): string {
// JST = UTC+9
@@ -38,7 +39,10 @@ export async function loader({ request }: { request: Request }) {
const capacity_range = (url.searchParams.get("capacity_range") ?? undefined) as CapacityRange | undefined;
const area = url.searchParams.get("area") ?? undefined;
- const events = queryEvents({ date_from: date, date_to: date, keyword, venue_id, capacity_range, area, limit: 500 });
+ const events = queryEvents({
+ date_from: date, date_to: date, keyword, venue_id, capacity_range, area,
+ with_favorites: isLoggedIn(request), limit: 500,
+ });
const venues = getVenues();
const areas = [...new Set(venues.filter((v) => v.area).map((v) => v.area as string))].sort();
@@ -89,6 +93,7 @@ export default function EventsByDate() {
<Link to="/events" className="hover:text-white transition-colors">イベント</Link>
<Link to="/events/by-date" className="text-white">日付別</Link>
<Link to="/venues" className="hover:text-white transition-colors">会場一覧</Link>
+ <Link to="/favorites" className="hover:text-white transition-colors">★ お気に入り</Link>
</nav>
</header>
@@ -265,6 +270,7 @@ function VenueEventRow({ event }: { event: Event }) {
{/* Title + artist */}
<span className="flex-1 min-w-0">
<span className="block truncate text-sm font-medium text-gray-100 group-hover:text-indigo-300 transition-colors">
+ {event.is_favorite ? <span className="text-amber-400" title="お気に入り">★ </span> : null}
{event.title}
</span>
{event.artist && (
diff --git a/app/routes/favorites.tsx b/app/routes/favorites.tsx
new file mode 100644
index 0000000..e2f620d
--- /dev/null
+++ b/app/routes/favorites.tsx
@@ -0,0 +1,161 @@
+import { Form, Link, useLoaderData, useNavigation } from "react-router";
+import type { Route } from "./+types/favorites";
+import {
+ addFavorite,
+ deleteFavorite,
+ deletePasskey,
+ listFavorites,
+ listPasskeys,
+ requireLogin,
+} from "~/lib/auth.server";
+
+export async function loader({ request }: Route.LoaderArgs) {
+ requireLogin(request);
+ return { favorites: listFavorites(), passkeys: listPasskeys() };
+}
+
+export async function action({ request }: Route.ActionArgs) {
+ requireLogin(request);
+ const form = await request.formData();
+ switch (form.get("intent")) {
+ case "add": {
+ const added = addFavorite(String(form.get("term") ?? ""));
+ return { error: added ? null : "空欄か、登録済みのワードです" };
+ }
+ case "delete":
+ deleteFavorite(Number(form.get("id")));
+ return { error: null };
+ case "delete-passkey":
+ return {
+ error: deletePasskey(String(form.get("id"))) ? null : "最後のパスキーは削除できません",
+ };
+ }
+ return { error: null };
+}
+
+export default function Favorites({ actionData }: Route.ComponentProps) {
+ const { favorites, passkeys } = useLoaderData<typeof loader>();
+ const navigation = useNavigation();
+ const adding = navigation.formData?.get("intent") === "add";
+
+ return (
+ <div className="min-h-screen bg-gray-950 text-gray-100">
+ <header className="border-b border-gray-800 px-4 sm:px-6 py-3 sm:py-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-0">
+ <Link to="/" className="text-xl font-bold tracking-tight text-white">
+ 🎸 ライブに行くしかない
+ </Link>
+ <nav className="flex gap-4 sm:gap-6 text-sm text-gray-400">
+ <Link to="/events" className="hover:text-white transition-colors">イベント</Link>
+ <Link to="/events/by-date" className="hover:text-white transition-colors">日付別</Link>
+ <Link to="/venues" className="hover:text-white transition-colors">会場一覧</Link>
+ <Link to="/favorites" className="text-white font-medium">★ お気に入り</Link>
+ </nav>
+ </header>
+
+ <main className="max-w-2xl mx-auto px-4 py-8 flex flex-col gap-10">
+ <section>
+ <div className="mb-4 flex items-center justify-between">
+ <h1 className="text-2xl font-bold">お気に入り</h1>
+ <Link
+ to="/events?fav=1"
+ className="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium hover:bg-indigo-500 transition-colors"
+ >
+ 該当イベントを見る →
+ </Link>
+ </div>
+ <p className="mb-4 text-xs text-gray-400">
+ バンド名などを登録すると、タイトルか出演者に含まれるイベントに ★ が付きます(全角/半角・大文字/小文字は区別しません)。
+ </p>
+
+ <Form method="post" className="flex gap-2" key={favorites.length}>
+ <input type="hidden" name="intent" value="add" />
+ <input
+ name="term"
+ required
+ maxLength={100}
+ placeholder="バンド名、イベント名..."
+ className="flex-1 rounded-md bg-gray-800 border border-gray-700 px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500"
+ />
+ <button
+ type="submit"
+ disabled={adding}
+ className="rounded-md bg-gray-700 px-4 py-1.5 text-sm font-medium hover:bg-gray-600 disabled:opacity-50 transition-colors"
+ >
+ 追加
+ </button>
+ </Form>
+ {actionData?.error && <p className="mt-2 text-sm text-red-400">{actionData.error}</p>}
+
+ {favorites.length === 0 ? (
+ <p className="mt-6 text-sm text-gray-500">まだ登録されていません</p>
+ ) : (
+ <ul className="mt-4 flex flex-col gap-1.5">
+ {favorites.map((f) => (
+ <li
+ key={f.id}
+ className="flex items-center gap-3 rounded-lg px-4 py-2.5 bg-gray-800/40 border border-gray-700/30 text-sm"
+ >
+ <Link
+ to={`/events?keyword=${encodeURIComponent(f.term)}`}
+ className="flex-1 min-w-0 truncate hover:text-indigo-300 transition-colors"
+ >
+ {f.term}
+ </Link>
+ <span className={`text-xs tabular-nums ${f.upcoming_count > 0 ? "text-indigo-300" : "text-gray-600"}`}>
+ {f.upcoming_count} 件
+ </span>
+ <Form method="post">
+ <input type="hidden" name="intent" value="delete" />
+ <input type="hidden" name="id" value={f.id} />
+ <button type="submit" className="text-gray-500 hover:text-red-400 transition-colors" title="削除">
+ ✕
+ </button>
+ </Form>
+ </li>
+ ))}
+ </ul>
+ )}
+ </section>
+
+ <section className="border-t border-gray-800 pt-8">
+ <h2 className="mb-3 text-lg font-semibold">パスキー</h2>
+ <ul className="flex flex-col gap-1.5">
+ {passkeys.map((p) => (
+ <li key={p.id} className="flex items-center gap-3 rounded-lg px-4 py-2.5 bg-gray-800/40 border border-gray-700/30 text-sm">
+ <span className="flex-1 min-w-0 truncate">{p.label ?? "(名前なし)"}</span>
+ <span className="text-xs text-gray-500">
+ {p.last_used_at ? `最終使用 ${p.last_used_at.slice(0, 10)}` : `登録 ${p.created_at.slice(0, 10)}`}
+ </span>
+ {passkeys.length > 1 && (
+ <Form method="post">
+ <input type="hidden" name="intent" value="delete-passkey" />
+ <input type="hidden" name="id" value={p.id} />
+ <button type="submit" className="text-gray-500 hover:text-red-400 transition-colors" title="削除">
+ ✕
+ </button>
+ </Form>
+ )}
+ </li>
+ ))}
+ </ul>
+ <div className="mt-4 flex gap-3">
+ <Link
+ to="/login"
+ className="rounded-md border border-gray-700 px-4 py-1.5 text-sm text-gray-300 hover:text-white transition-colors"
+ >
+ パスキーを追加
+ </Link>
+ <Form method="post" action="/api/auth/logout">
+ <button
+ type="submit"
+ className="rounded-md border border-gray-700 px-4 py-1.5 text-sm text-gray-400 hover:text-white transition-colors"
+ >
+ ログアウト
+ </button>
+ </Form>
+ </div>
+ </section>
+ </main>
+ </div>
+ );
+}
diff --git a/app/routes/login.tsx b/app/routes/login.tsx
new file mode 100644
index 0000000..69353ea
--- /dev/null
+++ b/app/routes/login.tsx
@@ -0,0 +1,117 @@
+import { useState } from "react";
+import { Link, useLoaderData, useNavigate } from "react-router";
+import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
+import type { Route } from "./+types/login";
+import { ensureSetupToken, isLoggedIn } from "~/lib/auth.server";
+
+export async function loader({ request }: Route.LoaderArgs) {
+ ensureSetupToken();
+ const url = new URL(request.url);
+ const next = url.searchParams.get("next");
+ return {
+ loggedIn: isLoggedIn(request),
+ token: url.searchParams.get("token"),
+ // Only allow same-site relative redirects
+ next: next && /^\/(?![/\\])/.test(next) ? next : "/favorites",
+ };
+}
+
+async function postJson(path: string, body: unknown) {
+ const res = await fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const data = await res.json();
+ if (!res.ok || data.ok === false) throw new Error(data.error ?? `HTTP ${res.status}`);
+ return data;
+}
+
+export default function Login() {
+ const { loggedIn, token, next } = useLoaderData<typeof loader>();
+ const navigate = useNavigate();
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [label, setLabel] = useState("");
+ const canRegister = loggedIn || !!token;
+
+ async function run(fn: () => Promise<void>) {
+ setBusy(true);
+ setError(null);
+ try {
+ await fn();
+ navigate(next);
+ } catch (e) {
+ const err = e as Error;
+ setError(err.name === "NotAllowedError" ? "キャンセルされました" : err.message);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ const login = () =>
+ run(async () => {
+ const optionsJSON = await postJson("/api/auth/login-options", {});
+ const response = await startAuthentication({ optionsJSON });
+ await postJson("/api/auth/login-verify", { response });
+ });
+
+ const register = () =>
+ run(async () => {
+ const optionsJSON = await postJson("/api/auth/register-options", { token });
+ const response = await startRegistration({ optionsJSON });
+ await postJson("/api/auth/register-verify", { token, response, label: label || null });
+ });
+
+ return (
+ <div className="min-h-screen bg-gray-950 text-gray-100 flex items-center justify-center px-4">
+ <div className="w-full max-w-sm rounded-xl border border-gray-800 bg-gray-900 p-6 flex flex-col gap-5">
+ <Link to="/events" className="text-lg font-bold tracking-tight text-white">
+ 🎸 ライブに行くしかない
+ </Link>
+
+ {!loggedIn && (
+ <button
+ onClick={login}
+ disabled={busy}
+ className="rounded-md bg-indigo-600 px-4 py-2.5 text-sm font-medium hover:bg-indigo-500 disabled:opacity-50 transition-colors"
+ >
+ 🔑 パスキーでログイン
+ </button>
+ )}
+
+ {loggedIn && !token && (
+ <p className="text-sm text-gray-300">
+ ログイン済みです。{" "}
+ <Link to="/favorites" className="text-indigo-400 hover:text-indigo-300 underline">
+ お気に入りへ
+ </Link>
+ </p>
+ )}
+
+ {canRegister && (
+ <div className="flex flex-col gap-2 border-t border-gray-800 pt-5">
+ <p className="text-xs text-gray-400">
+ {loggedIn ? "このデバイスのパスキーを追加登録" : "パスキーを新規登録"}
+ </p>
+ <input
+ value={label}
+ onChange={(e) => setLabel(e.target.value)}
+ placeholder="デバイス名(例: iPhone)"
+ className="rounded-md bg-gray-800 border border-gray-700 px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500"
+ />
+ <button
+ onClick={register}
+ disabled={busy}
+ className="rounded-md bg-gray-700 px-4 py-2 text-sm font-medium hover:bg-gray-600 disabled:opacity-50 transition-colors"
+ >
+ パスキーを登録
+ </button>
+ </div>
+ )}
+
+ {error && <p className="text-sm text-red-400">{error}</p>}
+ </div>
+ </div>
+ );
+}
diff --git a/app/routes/venues.tsx b/app/routes/venues.tsx
index 9b8093a..690b1c2 100644
--- a/app/routes/venues.tsx
+++ b/app/routes/venues.tsx
@@ -26,6 +26,7 @@ export default function Venues() {
<Link to="/events" className="hover:text-white transition-colors">イベント</Link>
<Link to="/events/by-date" className="hover:text-white transition-colors">日付別</Link>
<Link to="/venues" className="text-white font-medium">会場一覧</Link>
+ <Link to="/favorites" className="hover:text-white transition-colors">★ お気に入り</Link>
</nav>
</header>
diff --git a/package-lock.json b/package-lock.json
index 2ae510b..4ddb459 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,8 @@
"dependencies": {
"@react-router/node": "^7.3.0",
"@react-router/serve": "^7.3.0",
+ "@simplewebauthn/browser": "^14.0.0",
+ "@simplewebauthn/server": "^14.0.2",
"better-sqlite3": "^12.9.0",
"cheerio": "^1.2.0",
"isbot": "^5.1.36",
@@ -924,6 +926,12 @@
"node": ">=18"
}
},
+ "node_modules/@hexagon/base64": {
+ "version": "1.1.28",
+ "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
+ "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
+ "license": "MIT"
+ },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -986,6 +994,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@levischuck/tiny-cbor": {
+ "version": "0.2.11",
+ "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
+ "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
+ "license": "MIT"
+ },
"node_modules/@mjackson/node-fetch-server": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz",
@@ -1067,6 +1081,239 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
+ "node_modules/@peculiar/asn1-android": {
+ "version": "2.9.5",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.9.5.tgz",
+ "integrity": "sha512-KnNE4l033gbJ3ZzfYk7fJpSZwhd5xT+dPAnrB8bcwqylHiLN2zjZ0OyFao0LDIOI/ZdXRabV0n3Ww/KvpgZq6w==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.9.5",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-asym-key": {
+ "version": "2.9.5",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-asym-key/-/asn1-asym-key-2.9.5.tgz",
+ "integrity": "sha512-U3Q2bUWYNVpfk+oOhPl/+OSHxXaBTQBwMf93jJ7oPi/sbmcY5CnARdjGMwLoXT/ggWSryo5t7A7o3/Gfz0U2WA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-pkcs8": "^2.9.5",
+ "@peculiar/asn1-schema": "^2.9.5",
+ "@peculiar/asn1-x509": "^2.9.5",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-cms": {
+ "version": "2.9.5",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.5.tgz",
+ "integrity": "sha512-4Ivz0fsyFofe0bslNsYcGN9+iETdHX4v+BFwIPr+K/BAt61sHXECP63ff9FntU9zcfVQELJFdUG9so44OjUlBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.9.5",
+ "@peculiar/asn1-x509": "^2.9.5",
+ "@peculiar/asn1-x509-attr": "^2.9.5",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-csr": {
+ "version": "2.9.5",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.5.tgz",
+ "integrity": "sha512-9dO6PcoJeI+1xJ4rG7eINMa6f9Ix/QdRs5UGZ0D7v/R3TzmsbVr5cqAgfYw4xgS9U54ouHYqEz0bIZBbM/UY1g==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.9.5",
+ "@peculiar/asn1-x509": "^2.9.5",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-ecc": {
+ "version": "2.9.5",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.5.tgz",
+ "integrity": "sha512-OD+HLlcV7wCL64w0rheX/C0J31DbJce0nf8ulv3UNftQ617JEZE3sQ4cMN6PotXWlJ1M5GQx90G3bywtdKOstw==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.9.5",
+ "@peculiar/asn1-x509": "^2.9.5",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-pfx": {
+ "version": "2.9.5",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.5.tgz",
+ "integrity": "sha512-btcH7lbcD4410qnjHvv7F0kEuMNKbX8CFAZSs9CDPWHKzT1byLPHYeR40AnUw9z4CzsijMY0daBFYUvVx75sdA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-cms": "^2.9.5",
+ "@peculiar/asn1-pkcs8": "^2.9.5",
+ "@peculiar/asn1-rsa": "^2.9.5",
+ "@peculiar/asn1-schema": "^2.9.5",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-pkcs8": {
+ "version": "2.9.5",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.5.tgz",
+ "integrity": "sha512-c7UghAGQdBfAHL9JdwcJkAc/lDJKNXfCdYXPXlf8BZdD/fVJjJnXKHxX4gyLhkFTvnGhME7caieSgc2ROtqYTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.9.5",
+ "@peculiar/asn1-x509": "^2.9.5",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-pkcs9": {
+ "version": "2.9.5",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.5.tgz",
+ "integrity": "sha512-4R/zMgLSHbQIZRBwP1KzX6iuNHmW3xFWwu9TS9A00N60fmQqp9DkkqnQFyd0mAYMQTrQefuzQ/yP4U8sOLGUgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-cms": "^2.9.5",
+ "@peculiar/asn1-pfx": "^2.9.5",
+ "@peculiar/asn1-pkcs8": "^2.9.5",
+ "@peculiar/asn1-schema": "^2.9.5",
+ "@peculiar/asn1-x509": "^2.9.5",
+ "@peculiar/asn1-x509-attr": "^2.9.5",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-rsa": {
+ "version": "2.9.5",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.5.tgz",
+ "integrity": "sha512-kHwBLVMkm9ljlM521ovS7y4z4mr8xuHTdNyyoPlDvckBwFKSgs9HqKBryLBeqHqL2YpIgl+Vspl7kzNGG2phzA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.9.5",
+ "@peculiar/asn1-x509": "^2.9.5",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-schema": {
+ "version": "2.9.5",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.5.tgz",
+ "integrity": "sha512-Ez3wLKVjaxdsLcgeWN4OE31QkM7oBOgKuuBJxldRYAkfYw2C+8zJPcSd/SThnbhszsEOlAmoaS5kIIkN29fGKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/utils": "^2.0.2",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-x509": {
+ "version": "2.9.5",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.5.tgz",
+ "integrity": "sha512-NP3ecvN6zTDlwCvogIWL7MJN9AZNuskkIbnlhrlkB97Mn2EqirBUQ8GKzsOmdAL3pRvjGu1Uk/a4DNfiZrWxhw==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.9.5",
+ "@peculiar/utils": "^2.0.2",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-x509-attr": {
+ "version": "2.9.5",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.5.tgz",
+ "integrity": "sha512-PlbL1a74RZiry7puPCZ5hdk/puvivxYJPbjadr2i7DBjHCJFEKCfTRPKL3DILeZFtzJjn95J5EBtSc4/TB9/6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.9.5",
+ "@peculiar/asn1-x509": "^2.9.5",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/asn1-x509-post-quantum": {
+ "version": "2.9.5",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-post-quantum/-/asn1-x509-post-quantum-2.9.5.tgz",
+ "integrity": "sha512-CTznDkboSgwRpXSFhta+Pe5cQ12JtojdMrmOZTWYkBSvbW/1R5pflPIHkpSlaILIfGcAaxIVpXq880xCfJYzow==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-asym-key": "^2.9.5",
+ "@peculiar/asn1-schema": "^2.9.5",
+ "@peculiar/asn1-x509": "^2.9.5",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@peculiar/utils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
+ "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/x509": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.1.0.tgz",
+ "integrity": "sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-cms": "^2.9.4",
+ "@peculiar/asn1-csr": "^2.9.4",
+ "@peculiar/asn1-ecc": "^2.9.4",
+ "@peculiar/asn1-pkcs9": "^2.9.4",
+ "@peculiar/asn1-rsa": "^2.9.4",
+ "@peculiar/asn1-schema": "^2.9.4",
+ "@peculiar/asn1-x509": "^2.9.4",
+ "@peculiar/asn1-x509-post-quantum": "^2.9.4",
+ "pvtsutils": "^1.3.6",
+ "tslib": "^2.8.1",
+ "tsyringe": "^4.10.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -1536,6 +1783,33 @@
"win32"
]
},
+ "node_modules/@simplewebauthn/browser": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-14.0.0.tgz",
+ "integrity": "sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow==",
+ "license": "MIT"
+ },
+ "node_modules/@simplewebauthn/server": {
+ "version": "14.0.2",
+ "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.2.tgz",
+ "integrity": "sha512-g+m/xv9/8FRWsDY22CwsTYRzt3WwS9fJrR3P7eY3Ti01FXH5FQ36Xn55v6D+5tb6VajlqOj4hywP/Vxsb6a3Sg==",
+ "license": "MIT",
+ "dependencies": {
+ "@hexagon/base64": "^1.1.27",
+ "@levischuck/tiny-cbor": "^0.2.2",
+ "@peculiar/asn1-android": "^2.6.0",
+ "@peculiar/asn1-ecc": "^2.6.1",
+ "@peculiar/asn1-rsa": "^2.6.1",
+ "@peculiar/asn1-schema": "^2.6.0",
+ "@peculiar/asn1-x509": "^2.6.1",
+ "@peculiar/asn1-x509-post-quantum": "^2.9.4",
+ "@peculiar/x509": "^2.1.0",
+ "reflect-metadata": "^0.2.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@tailwindcss/node": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
@@ -1905,6 +2179,20 @@
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
+ "node_modules/asn1js": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
+ "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "pvtsutils": "^1.3.6",
+ "pvutils": "^1.1.5",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/babel-dead-code-elimination": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz",
@@ -4190,6 +4478,24 @@
"once": "^1.3.1"
}
},
+ "node_modules/pvtsutils": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
+ "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/pvutils": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz",
+ "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
@@ -4329,6 +4635,12 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/reflect-metadata": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
+ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
+ "license": "Apache-2.0"
+ },
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -4912,9 +5224,7 @@
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
- "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "dev": true,
- "optional": true
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/tsx": {
"version": "4.21.0",
@@ -5420,6 +5730,24 @@
"@esbuild/win32-x64": "0.27.7"
}
},
+ "node_modules/tsyringe": {
+ "version": "4.10.0",
+ "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
+ "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^1.9.3"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/tsyringe/node_modules/tslib": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+ "license": "0BSD"
+ },
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
diff --git a/package.json b/package.json
index 5232259..ea13320 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,8 @@
"dependencies": {
"@react-router/node": "^7.3.0",
"@react-router/serve": "^7.3.0",
+ "@simplewebauthn/browser": "^14.0.0",
+ "@simplewebauthn/server": "^14.0.2",
"better-sqlite3": "^12.9.0",
"cheerio": "^1.2.0",
"isbot": "^5.1.36",