rework pwa cc
Build and Publish Docker Image / build-and-push-image (push) Successful in 1m48s

This commit is contained in:
Zed
2026-07-11 02:01:08 +02:00
parent 9ae37f98ed
commit ce1db937f2
52 changed files with 5210 additions and 48 deletions
+31
View File
@@ -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;
}
+17
View File
@@ -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";
+41
View File
@@ -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;
}
+70
View File
@@ -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));
}
}
+103
View File
@@ -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,
),
}),
);
+16
View File
@@ -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);
});
+29
View File
@@ -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();
}
+39
View File
@@ -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);
};
}
+70
View File
@@ -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 });
}
+10
View File
@@ -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));
+185
View File
@@ -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));
}
+21
View File
@@ -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);
}
+9
View File
@@ -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));
+143
View File
@@ -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 };
}
+63
View File
@@ -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) });
}
+9
View File
@@ -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));
+174
View File
@@ -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);
});
+21
View File
@@ -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;
}
}
+15
View File
@@ -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);
}
+15
View File
@@ -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();
}