This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.pgdata
|
||||
.env
|
||||
npm-debug.log
|
||||
@@ -0,0 +1,15 @@
|
||||
# Port the API listens on
|
||||
PORT=3001
|
||||
|
||||
# Postgres connection string. If omitted, the server falls back to an
|
||||
# embedded PGlite database (stored in ./.pgdata) — handy for local dev.
|
||||
# DATABASE_URL=postgres://skori:skori@postgres:5432/skori
|
||||
|
||||
# Secret used to sign JWT access tokens (CHANGE THIS in production)
|
||||
JWT_SECRET=dev-secret-change-me
|
||||
|
||||
# Comma-separated list of allowed CORS origins (only needed for cross-origin dev)
|
||||
# CORS_ORIGINS=http://localhost:5173
|
||||
|
||||
# Set to "production" to enable Secure cookies (requires HTTPS)
|
||||
NODE_ENV=development
|
||||
@@ -0,0 +1,25 @@
|
||||
# ÉTAPE 1 : Build du backend TypeScript
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ÉTAPE 2 : Image de production (deps de prod uniquement)
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
Generated
+2006
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "skori-server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"smoke": "tsx src/smoke-test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electric-sql/pglite": "^0.2.12",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"express": "^4.19.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pg": "^8.12.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/pg": "^8.11.6",
|
||||
"tsx": "^4.16.0",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { env } from "./config/env.js";
|
||||
import { errorHandler } from "./middleware/errorHandler.js";
|
||||
import { authRouter } from "./modules/auth/routes.js";
|
||||
import { usersRouter } from "./modules/users/routes.js";
|
||||
import { syncRouter } from "./modules/sync/routes.js";
|
||||
|
||||
export function createApp() {
|
||||
const app = express();
|
||||
|
||||
app.use(express.json({ limit: "10mb" }));
|
||||
app.use(cookieParser());
|
||||
|
||||
// Same-origin in production (served behind nginx). CORS only needed when the
|
||||
// frontend dev server runs on a different origin than the API.
|
||||
if (env.corsOrigins.length > 0) {
|
||||
app.use(cors({ origin: env.corsOrigins, credentials: true }));
|
||||
}
|
||||
|
||||
app.get("/api/v1/health", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
app.use("/api/v1/auth", authRouter);
|
||||
app.use("/api/v1", usersRouter);
|
||||
app.use("/api/v1/sync", syncRouter);
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export const env = {
|
||||
port: Number(process.env.PORT) || 3001,
|
||||
databaseUrl: process.env.DATABASE_URL || "",
|
||||
jwtSecret: process.env.JWT_SECRET || "dev-secret-change-me",
|
||||
isProduction: process.env.NODE_ENV === "production",
|
||||
corsOrigins: (process.env.CORS_ORIGINS || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
// Embedded PGlite data directory (used only when DATABASE_URL is empty)
|
||||
pgliteDir: process.env.PGLITE_DIR || "./.pgdata",
|
||||
};
|
||||
|
||||
// Token lifetimes
|
||||
export const ACCESS_TOKEN_TTL = "15m";
|
||||
export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
export const REFRESH_COOKIE_NAME = "skori_refresh";
|
||||
@@ -0,0 +1,41 @@
|
||||
import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
|
||||
import { drizzle as drizzlePglite } from "drizzle-orm/pglite";
|
||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import pg from "pg";
|
||||
import { PGlite } from "@electric-sql/pglite";
|
||||
import * as schema from "./schema.js";
|
||||
import { env } from "../config/env.js";
|
||||
import { ensureSchema } from "./init.js";
|
||||
|
||||
// Both drivers expose the same query API for our schema; we normalise the
|
||||
// exported type to the node-postgres flavour to keep call sites simple.
|
||||
export type AppDatabase = NodePgDatabase<typeof schema>;
|
||||
|
||||
let dbInstance: AppDatabase | null = null;
|
||||
|
||||
export async function initDb(): Promise<AppDatabase> {
|
||||
if (dbInstance) return dbInstance;
|
||||
|
||||
if (env.databaseUrl) {
|
||||
const pool = new pg.Pool({ connectionString: env.databaseUrl });
|
||||
dbInstance = drizzlePg(pool, { schema });
|
||||
console.log("[db] using PostgreSQL (node-postgres)");
|
||||
} else {
|
||||
const client = new PGlite(env.pgliteDir);
|
||||
await client.waitReady;
|
||||
dbInstance = drizzlePglite(client, {
|
||||
schema,
|
||||
}) as unknown as AppDatabase;
|
||||
console.log(`[db] using embedded PGlite (${env.pgliteDir})`);
|
||||
}
|
||||
|
||||
await ensureSchema(dbInstance);
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
export function getDb(): AppDatabase {
|
||||
if (!dbInstance) {
|
||||
throw new Error("Database not initialised — call initDb() first");
|
||||
}
|
||||
return dbInstance;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import type { AppDatabase } from "./client.js";
|
||||
|
||||
// Idempotent schema creation. Runs on every boot; safe to run repeatedly.
|
||||
// Kept as plain SQL so it works identically on node-postgres and PGlite,
|
||||
// without a separate migration toolchain for this single-instance app.
|
||||
const STATEMENTS = [
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id uuid PRIMARY KEY,
|
||||
email text NOT NULL UNIQUE,
|
||||
username text UNIQUE,
|
||||
password_hash text NOT NULL,
|
||||
display_name text NOT NULL,
|
||||
avatar_url text,
|
||||
created_at bigint NOT NULL,
|
||||
updated_at bigint NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash text NOT NULL,
|
||||
expires_at bigint NOT NULL,
|
||||
revoked_at bigint,
|
||||
created_at bigint NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS refresh_tokens_hash_idx ON refresh_tokens (token_hash)`,
|
||||
`CREATE TABLE IF NOT EXISTS locations (
|
||||
id uuid PRIMARY KEY,
|
||||
owner_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
address text,
|
||||
created_at bigint NOT NULL,
|
||||
updated_at bigint NOT NULL,
|
||||
deleted_at bigint
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS locations_owner_updated_idx ON locations (owner_id, updated_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS players (
|
||||
id uuid PRIMARY KEY,
|
||||
owner_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
avatar text,
|
||||
created_at bigint NOT NULL,
|
||||
updated_at bigint NOT NULL,
|
||||
deleted_at bigint
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS players_owner_updated_idx ON players (owner_id, updated_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS sessions (
|
||||
id uuid PRIMARY KEY,
|
||||
owner_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
game_id text NOT NULL,
|
||||
date_start bigint NOT NULL,
|
||||
date_end bigint,
|
||||
players jsonb NOT NULL,
|
||||
rounds jsonb NOT NULL,
|
||||
status text NOT NULL,
|
||||
options jsonb NOT NULL,
|
||||
winner_ids jsonb,
|
||||
location_id uuid,
|
||||
created_at bigint NOT NULL,
|
||||
updated_at bigint NOT NULL,
|
||||
deleted_at bigint
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS sessions_owner_updated_idx ON sessions (owner_id, updated_at)`,
|
||||
];
|
||||
|
||||
export async function ensureSchema(db: AppDatabase) {
|
||||
for (const statement of STATEMENTS) {
|
||||
await db.execute(sql.raw(statement));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
bigint,
|
||||
jsonb,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
// Epoch-millis timestamps (matches the client's number-based model exactly).
|
||||
const ms = (name: string) => bigint(name, { mode: "number" });
|
||||
|
||||
export const users = pgTable("users", {
|
||||
id: uuid("id").primaryKey(),
|
||||
email: text("email").notNull().unique(),
|
||||
username: text("username").unique(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
displayName: text("display_name").notNull(),
|
||||
avatarUrl: text("avatar_url"),
|
||||
createdAt: ms("created_at").notNull(),
|
||||
updatedAt: ms("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const refreshTokens = pgTable("refresh_tokens", {
|
||||
id: uuid("id").primaryKey(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
expiresAt: ms("expires_at").notNull(),
|
||||
revokedAt: ms("revoked_at"),
|
||||
createdAt: ms("created_at").notNull(),
|
||||
});
|
||||
|
||||
export const locations = pgTable(
|
||||
"locations",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
ownerId: uuid("owner_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
address: text("address"),
|
||||
createdAt: ms("created_at").notNull(),
|
||||
updatedAt: ms("updated_at").notNull(),
|
||||
deletedAt: ms("deleted_at"),
|
||||
},
|
||||
(t) => ({
|
||||
ownerUpdatedIdx: index("locations_owner_updated_idx").on(
|
||||
t.ownerId,
|
||||
t.updatedAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const players = pgTable(
|
||||
"players",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
ownerId: uuid("owner_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
avatar: text("avatar"),
|
||||
createdAt: ms("created_at").notNull(),
|
||||
updatedAt: ms("updated_at").notNull(),
|
||||
deletedAt: ms("deleted_at"),
|
||||
},
|
||||
(t) => ({
|
||||
ownerUpdatedIdx: index("players_owner_updated_idx").on(
|
||||
t.ownerId,
|
||||
t.updatedAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const sessions = pgTable(
|
||||
"sessions",
|
||||
{
|
||||
id: uuid("id").primaryKey(),
|
||||
ownerId: uuid("owner_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
gameId: text("game_id").notNull(),
|
||||
dateStart: ms("date_start").notNull(),
|
||||
dateEnd: ms("date_end"),
|
||||
players: jsonb("players").notNull(),
|
||||
rounds: jsonb("rounds").notNull(),
|
||||
status: text("status").notNull(),
|
||||
options: jsonb("options").notNull(),
|
||||
winnerIds: jsonb("winner_ids"),
|
||||
locationId: uuid("location_id"),
|
||||
createdAt: ms("created_at").notNull(),
|
||||
updatedAt: ms("updated_at").notNull(),
|
||||
deletedAt: ms("deleted_at"),
|
||||
},
|
||||
(t) => ({
|
||||
ownerUpdatedIdx: index("sessions_owner_updated_idx").on(
|
||||
t.ownerId,
|
||||
t.updatedAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createApp } from "./app.js";
|
||||
import { initDb } from "./db/client.js";
|
||||
import { env } from "./config/env.js";
|
||||
|
||||
async function main() {
|
||||
await initDb();
|
||||
const app = createApp();
|
||||
app.listen(env.port, () => {
|
||||
console.log(`[skori-server] listening on http://localhost:${env.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal startup error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { verifyAccessToken } from "../utils/jwt.js";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Express {
|
||||
interface Request {
|
||||
userId?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAuth(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
const header = req.headers.authorization;
|
||||
const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
|
||||
const userId = token ? verifyAccessToken(token) : null;
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({ error: "unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
req.userId = userId;
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function errorHandler(
|
||||
err: unknown,
|
||||
_req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction,
|
||||
): void {
|
||||
if (err instanceof ZodError) {
|
||||
res.status(400).json({ error: "validation_error", details: err.issues });
|
||||
return;
|
||||
}
|
||||
if (err instanceof HttpError) {
|
||||
res.status(err.status).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
console.error("[error]", err);
|
||||
res.status(500).json({ error: "internal_error" });
|
||||
}
|
||||
|
||||
// Wraps async route handlers so thrown errors reach the error handler.
|
||||
export function asyncHandler<
|
||||
T extends (req: Request, res: Response, next: NextFunction) => Promise<unknown>,
|
||||
>(fn: T) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
fn(req, res, next).catch(next);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { z } from "zod";
|
||||
import * as authService from "./service.js";
|
||||
import { HttpError } from "../../middleware/errorHandler.js";
|
||||
import {
|
||||
env,
|
||||
REFRESH_COOKIE_NAME,
|
||||
REFRESH_TOKEN_TTL_MS,
|
||||
} from "../../config/env.js";
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8).max(200),
|
||||
displayName: z.string().min(1).max(80),
|
||||
username: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(30)
|
||||
.regex(/^[a-zA-Z0-9_.-]+$/)
|
||||
.optional(),
|
||||
desiredId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
function setRefreshCookie(res: Response, token: string) {
|
||||
res.cookie(REFRESH_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: env.isProduction,
|
||||
sameSite: "strict",
|
||||
maxAge: REFRESH_TOKEN_TTL_MS,
|
||||
path: "/api/v1/auth",
|
||||
});
|
||||
}
|
||||
|
||||
function clearRefreshCookie(res: Response) {
|
||||
res.clearCookie(REFRESH_COOKIE_NAME, { path: "/api/v1/auth" });
|
||||
}
|
||||
|
||||
export async function register(req: Request, res: Response) {
|
||||
const input = registerSchema.parse(req.body);
|
||||
const result = await authService.register(input);
|
||||
setRefreshCookie(res, result.refreshToken);
|
||||
res.status(201).json({ user: result.user, accessToken: result.accessToken });
|
||||
}
|
||||
|
||||
export async function login(req: Request, res: Response) {
|
||||
const input = loginSchema.parse(req.body);
|
||||
const result = await authService.login(input);
|
||||
setRefreshCookie(res, result.refreshToken);
|
||||
res.json({ user: result.user, accessToken: result.accessToken });
|
||||
}
|
||||
|
||||
export async function refresh(req: Request, res: Response) {
|
||||
const token = req.cookies?.[REFRESH_COOKIE_NAME];
|
||||
if (!token) throw new HttpError(401, "missing_refresh_token");
|
||||
const result = await authService.refresh(token);
|
||||
setRefreshCookie(res, result.refreshToken);
|
||||
res.json({ user: result.user, accessToken: result.accessToken });
|
||||
}
|
||||
|
||||
export async function logout(req: Request, res: Response) {
|
||||
const token = req.cookies?.[REFRESH_COOKIE_NAME];
|
||||
if (token) await authService.logout(token);
|
||||
clearRefreshCookie(res);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Router } from "express";
|
||||
import { asyncHandler } from "../../middleware/errorHandler.js";
|
||||
import * as controller from "./controller.js";
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
authRouter.post("/register", asyncHandler(controller.register));
|
||||
authRouter.post("/login", asyncHandler(controller.login));
|
||||
authRouter.post("/refresh", asyncHandler(controller.refresh));
|
||||
authRouter.post("/logout", asyncHandler(controller.logout));
|
||||
@@ -0,0 +1,185 @@
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { getDb } from "../../db/client.js";
|
||||
import { users, refreshTokens } from "../../db/schema.js";
|
||||
import { hashPassword, verifyPassword } from "../../utils/password.js";
|
||||
import { signAccessToken } from "../../utils/jwt.js";
|
||||
import { generateRefreshToken, hashToken, newId } from "../../utils/tokens.js";
|
||||
import { REFRESH_TOKEN_TTL_MS } from "../../config/env.js";
|
||||
import { HttpError } from "../../middleware/errorHandler.js";
|
||||
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string | null;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
user: PublicUser;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
function toPublicUser(u: typeof users.$inferSelect): PublicUser {
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
username: u.username,
|
||||
displayName: u.displayName,
|
||||
avatarUrl: u.avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async function issueTokens(
|
||||
userId: string,
|
||||
): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
const db = getDb();
|
||||
const now = Date.now();
|
||||
const { token, hash } = generateRefreshToken();
|
||||
|
||||
await db.insert(refreshTokens).values({
|
||||
id: newId(),
|
||||
userId,
|
||||
tokenHash: hash,
|
||||
expiresAt: now + REFRESH_TOKEN_TTL_MS,
|
||||
revokedAt: null,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return { accessToken: signAccessToken(userId), refreshToken: token };
|
||||
}
|
||||
|
||||
export async function register(input: {
|
||||
email: string;
|
||||
password: string;
|
||||
displayName: string;
|
||||
username?: string;
|
||||
desiredId?: string;
|
||||
}): Promise<AuthResult> {
|
||||
const db = getDb();
|
||||
const email = input.email.toLowerCase().trim();
|
||||
|
||||
const existing = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
if (existing.length > 0) {
|
||||
throw new HttpError(409, "email_taken");
|
||||
}
|
||||
|
||||
if (input.username) {
|
||||
const takenUsername = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.username, input.username))
|
||||
.limit(1);
|
||||
if (takenUsername.length > 0) {
|
||||
throw new HttpError(409, "username_taken");
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse the client's local profile id as the account id (zero-mapping sync).
|
||||
let userId = input.desiredId || newId();
|
||||
if (input.desiredId) {
|
||||
const clash = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.id, input.desiredId))
|
||||
.limit(1);
|
||||
if (clash.length > 0) userId = newId();
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: userId,
|
||||
email,
|
||||
username: input.username || null,
|
||||
passwordHash,
|
||||
displayName: input.displayName.trim() || email,
|
||||
avatarUrl: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const tokens = await issueTokens(user.id);
|
||||
return { user: toPublicUser(user), ...tokens };
|
||||
}
|
||||
|
||||
export async function login(input: {
|
||||
email: string;
|
||||
password: string;
|
||||
}): Promise<AuthResult> {
|
||||
const db = getDb();
|
||||
const email = input.email.toLowerCase().trim();
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (!user || !(await verifyPassword(input.password, user.passwordHash))) {
|
||||
throw new HttpError(401, "invalid_credentials");
|
||||
}
|
||||
|
||||
const tokens = await issueTokens(user.id);
|
||||
return { user: toPublicUser(user), ...tokens };
|
||||
}
|
||||
|
||||
// Validates a refresh token and rotates it (single-use). Returns a fresh
|
||||
// access token, a new refresh token, and the owning user.
|
||||
export async function refresh(rawToken: string): Promise<AuthResult> {
|
||||
const db = getDb();
|
||||
const hash = hashToken(rawToken);
|
||||
const now = Date.now();
|
||||
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(refreshTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(refreshTokens.tokenHash, hash),
|
||||
isNull(refreshTokens.revokedAt),
|
||||
gt(refreshTokens.expiresAt, now),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
throw new HttpError(401, "invalid_refresh_token");
|
||||
}
|
||||
|
||||
// Rotate: revoke the used token before issuing a new one.
|
||||
await db
|
||||
.update(refreshTokens)
|
||||
.set({ revokedAt: now })
|
||||
.where(eq(refreshTokens.id, row.id));
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, row.userId))
|
||||
.limit(1);
|
||||
if (!user) {
|
||||
throw new HttpError(401, "invalid_refresh_token");
|
||||
}
|
||||
|
||||
const tokens = await issueTokens(user.id);
|
||||
return { user: toPublicUser(user), ...tokens };
|
||||
}
|
||||
|
||||
export async function logout(rawToken: string): Promise<void> {
|
||||
const db = getDb();
|
||||
const hash = hashToken(rawToken);
|
||||
await db
|
||||
.update(refreshTokens)
|
||||
.set({ revokedAt: Date.now() })
|
||||
.where(eq(refreshTokens.tokenHash, hash));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { z } from "zod";
|
||||
import * as syncService from "./service.js";
|
||||
|
||||
const pushSchema = z.object({
|
||||
locations: z.array(z.any()).optional(),
|
||||
players: z.array(z.any()).optional(),
|
||||
sessions: z.array(z.any()).optional(),
|
||||
});
|
||||
|
||||
export async function pull(req: Request, res: Response) {
|
||||
const since = Number(req.query.since) || 0;
|
||||
const result = await syncService.pull(req.userId!, since);
|
||||
res.json(result);
|
||||
}
|
||||
|
||||
export async function push(req: Request, res: Response) {
|
||||
const payload = pushSchema.parse(req.body);
|
||||
const result = await syncService.push(req.userId!, payload);
|
||||
res.json(result);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Router } from "express";
|
||||
import { requireAuth } from "../../middleware/auth.js";
|
||||
import { asyncHandler } from "../../middleware/errorHandler.js";
|
||||
import * as controller from "./controller.js";
|
||||
|
||||
export const syncRouter = Router();
|
||||
|
||||
syncRouter.get("/", requireAuth, asyncHandler(controller.pull));
|
||||
syncRouter.post("/", requireAuth, asyncHandler(controller.push));
|
||||
@@ -0,0 +1,143 @@
|
||||
import { and, eq, gt } from "drizzle-orm";
|
||||
import { getDb } from "../../db/client.js";
|
||||
import { locations, players, sessions } from "../../db/schema.js";
|
||||
|
||||
// ---- Pull: everything changed since a cursor (tombstones included) ----
|
||||
|
||||
export async function pull(ownerId: string, since: number) {
|
||||
const db = getDb();
|
||||
const serverTime = Date.now();
|
||||
|
||||
const [loc, pl, se] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(locations)
|
||||
.where(and(eq(locations.ownerId, ownerId), gt(locations.updatedAt, since))),
|
||||
db
|
||||
.select()
|
||||
.from(players)
|
||||
.where(and(eq(players.ownerId, ownerId), gt(players.updatedAt, since))),
|
||||
db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(and(eq(sessions.ownerId, ownerId), gt(sessions.updatedAt, since))),
|
||||
]);
|
||||
|
||||
return { serverTime, locations: loc, players: pl, sessions: se };
|
||||
}
|
||||
|
||||
// ---- Push: upsert client records; server clock is authoritative ----
|
||||
|
||||
export interface PushPayload {
|
||||
locations?: any[];
|
||||
players?: any[];
|
||||
sessions?: any[];
|
||||
}
|
||||
|
||||
interface Applied {
|
||||
id: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export async function push(ownerId: string, payload: PushPayload) {
|
||||
const db = getDb();
|
||||
const now = Date.now();
|
||||
const applied = {
|
||||
locations: [] as Applied[],
|
||||
players: [] as Applied[],
|
||||
sessions: [] as Applied[],
|
||||
};
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const r of payload.locations ?? []) {
|
||||
if (!r?.id) continue;
|
||||
await tx
|
||||
.insert(locations)
|
||||
.values({
|
||||
id: r.id,
|
||||
ownerId,
|
||||
name: r.name ?? "",
|
||||
address: r.address ?? null,
|
||||
createdAt: r.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
deletedAt: r.deletedAt ?? null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: locations.id,
|
||||
set: {
|
||||
name: r.name ?? "",
|
||||
address: r.address ?? null,
|
||||
updatedAt: now,
|
||||
deletedAt: r.deletedAt ?? null,
|
||||
},
|
||||
});
|
||||
applied.locations.push({ id: r.id, updatedAt: now });
|
||||
}
|
||||
|
||||
for (const r of payload.players ?? []) {
|
||||
if (!r?.id) continue;
|
||||
await tx
|
||||
.insert(players)
|
||||
.values({
|
||||
id: r.id,
|
||||
ownerId,
|
||||
name: r.name ?? "",
|
||||
avatar: r.avatar ?? null,
|
||||
createdAt: r.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
deletedAt: r.deletedAt ?? null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: players.id,
|
||||
set: {
|
||||
name: r.name ?? "",
|
||||
avatar: r.avatar ?? null,
|
||||
updatedAt: now,
|
||||
deletedAt: r.deletedAt ?? null,
|
||||
},
|
||||
});
|
||||
applied.players.push({ id: r.id, updatedAt: now });
|
||||
}
|
||||
|
||||
for (const r of payload.sessions ?? []) {
|
||||
if (!r?.id) continue;
|
||||
await tx
|
||||
.insert(sessions)
|
||||
.values({
|
||||
id: r.id,
|
||||
ownerId,
|
||||
gameId: r.gameId ?? "",
|
||||
dateStart: r.dateStart ?? now,
|
||||
dateEnd: r.dateEnd ?? null,
|
||||
players: r.players ?? [],
|
||||
rounds: r.rounds ?? [],
|
||||
status: r.status ?? "finished",
|
||||
options: r.options ?? {},
|
||||
winnerIds: r.winnerIds ?? null,
|
||||
locationId: r.locationId ?? null,
|
||||
createdAt: r.dateStart ?? now,
|
||||
updatedAt: now,
|
||||
deletedAt: r.deletedAt ?? null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: sessions.id,
|
||||
set: {
|
||||
gameId: r.gameId ?? "",
|
||||
dateStart: r.dateStart ?? now,
|
||||
dateEnd: r.dateEnd ?? null,
|
||||
players: r.players ?? [],
|
||||
rounds: r.rounds ?? [],
|
||||
status: r.status ?? "finished",
|
||||
options: r.options ?? {},
|
||||
winnerIds: r.winnerIds ?? null,
|
||||
locationId: r.locationId ?? null,
|
||||
updatedAt: now,
|
||||
deletedAt: r.deletedAt ?? null,
|
||||
},
|
||||
});
|
||||
applied.sessions.push({ id: r.id, updatedAt: now });
|
||||
}
|
||||
});
|
||||
|
||||
return { serverTime: now, applied };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getDb } from "../../db/client.js";
|
||||
import { users } from "../../db/schema.js";
|
||||
import { HttpError } from "../../middleware/errorHandler.js";
|
||||
|
||||
const patchSchema = z.object({
|
||||
displayName: z.string().min(1).max(80).optional(),
|
||||
avatarUrl: z.string().max(500_000).nullable().optional(),
|
||||
username: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(30)
|
||||
.regex(/^[a-zA-Z0-9_.-]+$/)
|
||||
.nullable()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
function publicUser(u: typeof users.$inferSelect) {
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
username: u.username,
|
||||
displayName: u.displayName,
|
||||
avatarUrl: u.avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMe(req: Request, res: Response) {
|
||||
const db = getDb();
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, req.userId!))
|
||||
.limit(1);
|
||||
if (!user) throw new HttpError(404, "user_not_found");
|
||||
res.json({ user: publicUser(user) });
|
||||
}
|
||||
|
||||
export async function patchMe(req: Request, res: Response) {
|
||||
const db = getDb();
|
||||
const input = patchSchema.parse(req.body);
|
||||
|
||||
if (input.username) {
|
||||
const [clash] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.username, input.username))
|
||||
.limit(1);
|
||||
if (clash && clash.id !== req.userId) {
|
||||
throw new HttpError(409, "username_taken");
|
||||
}
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.update(users)
|
||||
.set({ ...input, updatedAt: Date.now() })
|
||||
.where(eq(users.id, req.userId!))
|
||||
.returning();
|
||||
if (!user) throw new HttpError(404, "user_not_found");
|
||||
res.json({ user: publicUser(user) });
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Router } from "express";
|
||||
import { requireAuth } from "../../middleware/auth.js";
|
||||
import { asyncHandler } from "../../middleware/errorHandler.js";
|
||||
import * as controller from "./controller.js";
|
||||
|
||||
export const usersRouter = Router();
|
||||
|
||||
usersRouter.get("/me", requireAuth, asyncHandler(controller.getMe));
|
||||
usersRouter.patch("/me", requireAuth, asyncHandler(controller.patchMe));
|
||||
@@ -0,0 +1,174 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Server } from "node:http";
|
||||
import { createApp } from "./app.js";
|
||||
import { initDb } from "./db/client.js";
|
||||
|
||||
const BASE = "http://127.0.0.1:4599/api/v1";
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function check(label: string, cond: boolean) {
|
||||
if (cond) {
|
||||
passed++;
|
||||
console.log(` PASS ${label}`);
|
||||
} else {
|
||||
failed++;
|
||||
console.error(` FAIL ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function req(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
token?: string,
|
||||
cookie?: string,
|
||||
) {
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
if (token) headers.authorization = `Bearer ${token}`;
|
||||
if (cookie) headers.cookie = cookie;
|
||||
const res = await fetch(BASE + path, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const setCookie = res.headers.get("set-cookie") || undefined;
|
||||
let json: any = null;
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch {
|
||||
/* no body */
|
||||
}
|
||||
return { status: res.status, json, setCookie };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await initDb();
|
||||
const app = createApp();
|
||||
const server: Server = await new Promise((resolve) => {
|
||||
const s = app.listen(4599, () => resolve(s));
|
||||
});
|
||||
|
||||
try {
|
||||
// --- A. Register ---
|
||||
const email = `u${Date.now()}@test.dev`;
|
||||
const profileId = randomUUID();
|
||||
const reg = await req("POST", "/auth/register", {
|
||||
email,
|
||||
password: "supersecret",
|
||||
displayName: "Alice",
|
||||
desiredId: profileId,
|
||||
});
|
||||
check("register returns 201", reg.status === 201);
|
||||
check("register reuses profile id as account id", reg.json?.user?.id === profileId);
|
||||
check("register returns access token", typeof reg.json?.accessToken === "string");
|
||||
check("register sets refresh cookie", !!reg.setCookie);
|
||||
const tokenA = reg.json.accessToken;
|
||||
|
||||
// --- B. Push data from "device A" ---
|
||||
const locId = randomUUID();
|
||||
const playerId = randomUUID();
|
||||
const sessionId = randomUUID();
|
||||
const t0 = Date.now();
|
||||
const push1 = await req(
|
||||
"POST",
|
||||
"/sync",
|
||||
{
|
||||
locations: [
|
||||
{ id: locId, name: "Maison", createdAt: t0, updatedAt: t0 },
|
||||
],
|
||||
players: [
|
||||
{ id: playerId, name: "Bob", createdAt: t0, updatedAt: t0 },
|
||||
],
|
||||
sessions: [
|
||||
{
|
||||
id: sessionId,
|
||||
gameId: "skyjo",
|
||||
dateStart: t0,
|
||||
dateEnd: t0,
|
||||
players: [{ id: playerId, name: "Bob" }],
|
||||
rounds: [],
|
||||
status: "finished",
|
||||
options: {},
|
||||
winnerIds: [playerId],
|
||||
locationId: locId,
|
||||
updatedAt: t0,
|
||||
},
|
||||
],
|
||||
},
|
||||
tokenA,
|
||||
);
|
||||
check("push returns 200", push1.status === 200);
|
||||
check("push acks location", push1.json?.applied?.locations?.[0]?.id === locId);
|
||||
check("push ack has server updatedAt", typeof push1.json?.applied?.sessions?.[0]?.updatedAt === "number");
|
||||
|
||||
// --- C. Second device pulls everything from scratch ---
|
||||
const login = await req("POST", "/auth/login", {
|
||||
email,
|
||||
password: "supersecret",
|
||||
});
|
||||
check("login returns 200", login.status === 200);
|
||||
const tokenB = login.json.accessToken;
|
||||
|
||||
const pullB = await req("GET", "/sync?since=0", undefined, tokenB);
|
||||
check("pull returns 200", pullB.status === 200);
|
||||
check("device B pulls the location", pullB.json?.locations?.some((l: any) => l.id === locId));
|
||||
check("device B pulls the player", pullB.json?.players?.some((p: any) => p.id === playerId));
|
||||
check("device B pulls the session", pullB.json?.sessions?.some((s: any) => s.id === sessionId));
|
||||
check("pulled session keeps jsonb players", Array.isArray(pullB.json?.sessions?.[0]?.players));
|
||||
check("pull returns a server cursor", typeof pullB.json?.serverTime === "number");
|
||||
|
||||
// --- D. Conflict: both devices edit the same location, last push wins ---
|
||||
await req("POST", "/sync", {
|
||||
locations: [{ id: locId, name: "Maison A", updatedAt: Date.now() }],
|
||||
}, tokenA);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
await req("POST", "/sync", {
|
||||
locations: [{ id: locId, name: "Maison B", updatedAt: Date.now() }],
|
||||
}, tokenB);
|
||||
|
||||
const pullFinal = await req("GET", "/sync?since=0", undefined, tokenA);
|
||||
const finalLoc = pullFinal.json.locations.find((l: any) => l.id === locId);
|
||||
check("conflict resolves last-write-wins (Maison B)", finalLoc?.name === "Maison B");
|
||||
|
||||
// --- E. Tombstone: soft delete propagates ---
|
||||
await req("POST", "/sync", {
|
||||
players: [{ id: playerId, name: "Bob", deletedAt: Date.now(), updatedAt: Date.now() }],
|
||||
}, tokenA);
|
||||
const pullDel = await req("GET", "/sync?since=0", undefined, tokenB);
|
||||
const delPlayer = pullDel.json.players.find((p: any) => p.id === playerId);
|
||||
check("tombstone is returned on pull", !!delPlayer?.deletedAt);
|
||||
|
||||
// --- F. Incremental pull excludes old records ---
|
||||
const cursor = pullFinal.json.serverTime;
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const freshLoc = randomUUID();
|
||||
await req("POST", "/sync", {
|
||||
locations: [{ id: freshLoc, name: "Nouveau", updatedAt: Date.now() }],
|
||||
}, tokenA);
|
||||
const pullSince = await req("GET", `/sync?since=${cursor}`, undefined, tokenB);
|
||||
check("incremental pull includes new record", pullSince.json.locations.some((l: any) => l.id === freshLoc));
|
||||
|
||||
// --- G. Refresh token rotation ---
|
||||
const cookie = reg.setCookie!.split(";")[0];
|
||||
const refreshed = await req("POST", "/auth/refresh", undefined, undefined, cookie);
|
||||
check("refresh returns new access token", typeof refreshed.json?.accessToken === "string");
|
||||
// Old refresh token is now revoked (rotation)
|
||||
const reuse = await req("POST", "/auth/refresh", undefined, undefined, cookie);
|
||||
check("reusing rotated refresh token fails", reuse.status === 401);
|
||||
|
||||
// --- H. Auth required on sync ---
|
||||
const noAuth = await req("GET", "/sync?since=0");
|
||||
check("sync without token is 401", noAuth.status === 401);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Smoke test crashed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import { env, ACCESS_TOKEN_TTL } from "../config/env.js";
|
||||
|
||||
interface AccessPayload {
|
||||
sub: string; // user id
|
||||
}
|
||||
|
||||
export function signAccessToken(userId: string): string {
|
||||
return jwt.sign({ sub: userId } satisfies AccessPayload, env.jwtSecret, {
|
||||
expiresIn: ACCESS_TOKEN_TTL,
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyAccessToken(token: string): string | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, env.jwtSecret) as AccessPayload;
|
||||
return decoded.sub || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
// bcryptjs is pure-JS: no native build step, works out of the box in Alpine.
|
||||
const ROUNDS = 10;
|
||||
|
||||
export function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, ROUNDS);
|
||||
}
|
||||
|
||||
export function verifyPassword(
|
||||
password: string,
|
||||
hash: string,
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { randomBytes, createHash, randomUUID } from "node:crypto";
|
||||
|
||||
// Opaque refresh tokens: a random secret is stored only as a hash server-side.
|
||||
export function generateRefreshToken(): { token: string; hash: string } {
|
||||
const token = randomBytes(48).toString("base64url");
|
||||
return { token, hash: hashToken(token) };
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function newId(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user