From 60c8a5c99334b5b60c909716ddce055f088e7b33 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 25 Jun 2026 11:14:47 +0800 Subject: [PATCH] feat(users): guest role + reserved guest principal, excluded from count/list --- src/data/users.test.ts | 27 ++++++++++++++++++++++++++- src/data/users.ts | 12 +++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/data/users.test.ts b/src/data/users.test.ts index 66f7ff4..992622a 100644 --- a/src/data/users.test.ts +++ b/src/data/users.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { createDatabase, type BotDatabase } from "./database.js"; -import { createUserStore, UsernameTakenError, type UserStore } from "./users.js"; +import { createUserStore, UsernameTakenError, GUEST_USER_ID, GUEST_USERNAME, type UserStore } from "./users.js"; describe("UserStore", () => { let botDb: BotDatabase; @@ -184,3 +184,28 @@ describe("UserStore", () => { expect(users.countAdmins()).toBe(1); }); }); + +describe("guest row exclusion", () => { + let botDb: BotDatabase; + let users: UserStore; + + beforeEach(() => { + botDb = createDatabase(":memory:"); + users = createUserStore(botDb.db); + }); + + afterEach(() => { + botDb.close(); + }); + + it("countUsers and listUsers ignore the reserved guest row", async () => { + await users.createUser("alice", "password123", "member"); + // Insert the reserved guest row directly (mirrors the migration). + botDb.db.prepare( + "INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, 'guest')" + ).run(GUEST_USER_ID, GUEST_USERNAME, "!", Date.now(), Date.now()); + + expect(users.countUsers()).toBe(1); // alice only + expect(users.listUsers().some((u) => u.id === GUEST_USER_ID)).toBe(false); + }); +}); diff --git a/src/data/users.ts b/src/data/users.ts index 432887d..4a6d3b9 100644 --- a/src/data/users.ts +++ b/src/data/users.ts @@ -4,7 +4,13 @@ import bcrypt from "bcryptjs"; const BCRYPT_ROUNDS = 12; -export type UserRole = "admin" | "member"; +export type UserRole = "admin" | "member" | "guest"; + +/** Reserved synthetic principal for login-less guest sessions. The username is + * non-ASCII so it can never collide with an API-created account (which is + * validated against ^[A-Za-z0-9_\-.]{3,32}$). */ +export const GUEST_USER_ID = "__guest__"; +export const GUEST_USERNAME = "游客"; export interface UserRow { id: string; @@ -39,7 +45,7 @@ export class UsernameTakenError extends Error { } export function createUserStore(db: Database.Database): UserStore { - const countStmt = db.prepare("SELECT COUNT(*) AS n FROM users"); + const countStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role != 'guest'"); const countAdminsStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'"); const insertStmt = db.prepare( "INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, ?)" @@ -57,7 +63,7 @@ export function createUserStore(db: Database.Database): UserStore { "UPDATE users SET role = ?, updatedAt = ? WHERE id = ?" ); const listUsersStmt = db.prepare( - "SELECT id, username, createdAt, role FROM users ORDER BY createdAt ASC" + "SELECT id, username, createdAt, role FROM users WHERE role != 'guest' ORDER BY createdAt ASC" ); const deleteUserStmt = db.prepare("DELETE FROM users WHERE id = ?");