Implement RFID tag dump PWA with REST API, auth, and CI (#1)

Add Next.js app with SQLite/Drizzle storage for sites and LF/HF tag
dumps, multi-user Auth.js (credentials + optional OIDC), personal API
tokens in Settings, versioned /api/v1 for UI and future CLI use,
MCT/Proxmark/JSON import-export, PWA offline shell, Vitest tests, and
GitHub Actions CI.

Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
Nick Trochalakis
2026-08-23 15:24:12 -07:00
committed by GitHub
co-authored by Cursor Agent
parent 5029945cc2
commit 8500d2f673
65 changed files with 13689 additions and 1 deletions
+62
View File
@@ -0,0 +1,62 @@
import type { NextRequest } from "next/server";
import { eq } from "drizzle-orm";
import { getDb } from "@/db/client";
import { apiTokens, users, type User } from "@/db/schema";
import { auth } from "@/lib/auth/auth";
import { hashToken, isApiTokenFormat } from "@/lib/auth/tokens";
import { jsonError, jsonOk } from "@/lib/api/errors";
export type AuthUser = Pick<User, "id" | "email" | "name">;
export { jsonError, jsonOk };
export async function requireApiUser(
request: NextRequest
): Promise<AuthUser | Response> {
const header = request.headers.get("authorization");
if (header?.toLowerCase().startsWith("bearer ")) {
const token = header.slice(7).trim();
if (!isApiTokenFormat(token)) {
return jsonError(401, "UNAUTHORIZED", "Invalid API token");
}
const db = getDb();
const tokenHash = hashToken(token);
const row = db
.select({
tokenId: apiTokens.id,
expiresAt: apiTokens.expiresAt,
userId: users.id,
email: users.email,
name: users.name,
})
.from(apiTokens)
.innerJoin(users, eq(apiTokens.userId, users.id))
.where(eq(apiTokens.tokenHash, tokenHash))
.get();
if (!row) {
return jsonError(401, "UNAUTHORIZED", "Invalid API token");
}
if (row.expiresAt && row.expiresAt.getTime() < Date.now()) {
return jsonError(401, "UNAUTHORIZED", "API token expired");
}
db.update(apiTokens)
.set({ lastUsedAt: new Date() })
.where(eq(apiTokens.id, row.tokenId))
.run();
return { id: row.userId, email: row.email, name: row.name };
}
const session = await auth();
if (!session?.user?.id) {
return jsonError(401, "UNAUTHORIZED", "Authentication required");
}
return {
id: session.user.id,
email: session.user.email ?? "",
name: session.user.name ?? "",
};
}
+11
View File
@@ -0,0 +1,11 @@
export function jsonError(
status: number,
code: string,
message: string
): Response {
return Response.json({ error: { code, message } }, { status });
}
export function jsonOk<T>(data: T, status = 200): Response {
return Response.json(data, { status });
}
+45
View File
@@ -0,0 +1,45 @@
import type { NextAuthConfig } from "next-auth";
/**
* Edge-safe auth config (no Node DB imports).
* Used by middleware; full providers live in auth.ts.
*/
export const authConfig = {
pages: {
signIn: "/login",
},
session: { strategy: "jwt" },
providers: [],
callbacks: {
async jwt({ token, user }) {
if (user?.id) {
token.sub = user.id;
}
return token;
},
async session({ session, token }) {
if (session.user && token.sub) {
session.user.id = token.sub;
}
return session;
},
authorized({ auth, request }) {
const { pathname } = request.nextUrl;
if (
pathname.startsWith("/login") ||
pathname.startsWith("/api/auth") ||
pathname === "/manifest.webmanifest" ||
pathname === "/sw.js" ||
pathname.startsWith("/icons")
) {
return true;
}
// API v1 uses its own Bearer/session check in handlers
if (pathname.startsWith("/api/v1")) {
return true;
}
return !!auth?.user;
},
},
trustHost: true,
} satisfies NextAuthConfig;
+92
View File
@@ -0,0 +1,92 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import type { Provider } from "next-auth/providers";
import { compare } from "bcryptjs";
import { eq } from "drizzle-orm";
import { getDb } from "@/db/client";
import { users } from "@/db/schema";
import { authConfig } from "@/lib/auth/auth.config";
function buildProviders(): Provider[] {
const providers: Provider[] = [
Credentials({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const email = credentials?.email?.toString().trim().toLowerCase();
const password = credentials?.password?.toString() ?? "";
if (!email || !password) return null;
const db = getDb();
const user = db.select().from(users).where(eq(users.email, email)).get();
if (!user?.passwordHash) return null;
const ok = await compare(password, user.passwordHash);
if (!ok) return null;
return { id: user.id, email: user.email, name: user.name };
},
}),
];
const issuer = process.env.AUTH_OIDC_ISSUER;
const clientId = process.env.AUTH_OIDC_CLIENT_ID;
const clientSecret = process.env.AUTH_OIDC_CLIENT_SECRET;
if (issuer && clientId && clientSecret) {
providers.push({
id: "oidc",
name: process.env.AUTH_OIDC_NAME || "SSO",
type: "oidc",
issuer,
clientId,
clientSecret,
allowDangerousEmailAccountLinking: true,
} as Provider);
}
return providers;
}
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
providers: buildProviders(),
callbacks: {
...authConfig.callbacks,
async signIn({ user, account }) {
if (account?.provider === "credentials") return true;
if (!user.email) return false;
const db = getDb();
const email = user.email.toLowerCase();
let existing = db.select().from(users).where(eq(users.email, email)).get();
if (!existing) {
const id = crypto.randomUUID();
db.insert(users)
.values({
id,
email,
name: user.name || email.split("@")[0],
passwordHash: null,
createdAt: new Date(),
})
.run();
existing = db.select().from(users).where(eq(users.id, id)).get();
}
if (existing) {
user.id = existing.id;
}
return true;
},
},
});
export function isOidcConfigured(): boolean {
return Boolean(
process.env.AUTH_OIDC_ISSUER &&
process.env.AUTH_OIDC_CLIENT_ID &&
process.env.AUTH_OIDC_CLIENT_SECRET
);
}
+25
View File
@@ -0,0 +1,25 @@
import { createHash, randomBytes } from "node:crypto";
const TOKEN_PREFIX = "rfid_";
export function generateApiToken(): {
token: string;
tokenHash: string;
prefix: string;
} {
const secret = randomBytes(32).toString("base64url");
const token = `${TOKEN_PREFIX}${secret}`;
return {
token,
tokenHash: hashToken(token),
prefix: token.slice(0, 12),
};
}
export function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function isApiTokenFormat(token: string): boolean {
return token.startsWith(TOKEN_PREFIX) && token.length > TOKEN_PREFIX.length + 16;
}
+28
View File
@@ -0,0 +1,28 @@
import type { MifareClassicDump, NtagDump, LfDump } from "@/lib/validation/rfid";
export function isMifareClassicDump(data: unknown): data is MifareClassicDump {
return (
typeof data === "object" &&
data !== null &&
"sectors" in data &&
Array.isArray((data as MifareClassicDump).sectors)
);
}
export function isNtagDump(data: unknown): data is NtagDump {
return (
typeof data === "object" &&
data !== null &&
"pages" in data &&
Array.isArray((data as NtagDump).pages)
);
}
export function isLfDump(data: unknown): data is LfDump {
return (
typeof data === "object" &&
data !== null &&
"uidBytes" in data &&
typeof (data as LfDump).uidBytes === "string"
);
}
+154
View File
@@ -0,0 +1,154 @@
import {
isLfDump,
isMifareClassicDump,
isNtagDump,
} from "@/lib/rfid/dump-types";
import type { ExportFormat } from "@/lib/rfid/types";
export type { CanonicalTag, ExportFormat } from "@/lib/rfid/types";
export function exportTag(
tag: {
id: string;
label: string;
frequency: string;
protocol: string;
uid: string;
dumpData: unknown;
keys: unknown;
notes: string | null;
},
format: ExportFormat
): { body: string; contentType: string; filename: string } {
switch (format) {
case "json":
return {
body: JSON.stringify(
{
id: tag.id,
label: tag.label,
frequency: tag.frequency,
protocol: tag.protocol,
uid: tag.uid,
dumpData: tag.dumpData,
keys: tag.keys,
notes: tag.notes,
},
null,
2
),
contentType: "application/json",
filename: `${safeName(tag.label)}.json`,
};
case "proxmark":
return {
body: exportProxmark(tag),
contentType: "application/json",
filename: `${safeName(tag.label)}.pm3.json`,
};
case "mct":
return {
body: exportMct(tag),
contentType: "text/plain",
filename: `${safeName(tag.label)}.mct`,
};
case "hex":
return {
body: exportHex(tag),
contentType: "text/plain",
filename: `${safeName(tag.label)}.hex`,
};
default:
throw new Error(`Unsupported format: ${format}`);
}
}
function safeName(label: string): string {
return label.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 64) || "tag";
}
function exportProxmark(tag: {
uid: string;
protocol: string;
dumpData: unknown;
keys: unknown;
}): string {
const blocks: string[] = [];
if (isMifareClassicDump(tag.dumpData)) {
for (const sector of [...tag.dumpData.sectors].sort(
(a, b) => a.index - b.index
)) {
for (const block of sector.blocks) {
blocks.push(block.toUpperCase());
}
}
}
return JSON.stringify(
{
Created: new Date().toISOString(),
FileType: "mfcard",
Card: {
UID: tag.uid.toUpperCase(),
ATQA: "",
SAK: "",
},
blocks,
SectorKeys: tag.keys ?? {},
},
null,
2
);
}
function exportMct(tag: {
uid: string;
dumpData: unknown;
}): string {
const lines: string[] = [`+UID: ${tag.uid.toUpperCase()}`];
if (isMifareClassicDump(tag.dumpData)) {
for (const sector of [...tag.dumpData.sectors].sort(
(a, b) => a.index - b.index
)) {
lines.push(`+Sector: ${sector.index}`);
for (const block of sector.blocks) {
lines.push(block.toUpperCase());
}
}
} else if (isNtagDump(tag.dumpData)) {
for (const page of [...tag.dumpData.pages].sort(
(a, b) => a.index - b.index
)) {
lines.push(`+Page: ${page.index}`);
lines.push(page.data.toUpperCase());
}
} else if (isLfDump(tag.dumpData)) {
lines.push(`+LF: ${tag.dumpData.format}`);
lines.push(tag.dumpData.uidBytes.toUpperCase());
}
return lines.join("\n") + "\n";
}
function exportHex(tag: { dumpData: unknown; uid: string }): string {
const lines: string[] = [`# UID ${tag.uid.toUpperCase()}`];
if (isMifareClassicDump(tag.dumpData)) {
for (const sector of [...tag.dumpData.sectors].sort(
(a, b) => a.index - b.index
)) {
sector.blocks.forEach((block, i) => {
lines.push(`S${sector.index}B${i} ${block.toUpperCase()}`);
});
}
} else if (isNtagDump(tag.dumpData)) {
for (const page of [...tag.dumpData.pages].sort(
(a, b) => a.index - b.index
)) {
lines.push(`P${page.index} ${page.data.toUpperCase()}`);
}
} else if (isLfDump(tag.dumpData)) {
lines.push(tag.dumpData.uidBytes.toUpperCase());
} else {
lines.push(JSON.stringify(tag.dumpData));
}
return lines.join("\n") + "\n";
}
+126
View File
@@ -0,0 +1,126 @@
import type { CanonicalTag } from "@/lib/rfid/types";
import { normalizeUid } from "@/lib/rfid/uid";
import type { Protocol } from "@/lib/rfid/protocols";
import type { MifareClassicDump } from "@/lib/validation/rfid";
export function parseImport(
content: string,
filenameHint?: string
): CanonicalTag {
const trimmed = content.trim();
const lower = (filenameHint ?? "").toLowerCase();
if (trimmed.startsWith("{") || lower.endsWith(".json")) {
return parseJsonDump(trimmed);
}
if (lower.endsWith(".mct") || trimmed.includes("+Sector:")) {
return parseMct(trimmed);
}
// Heuristic: MCT-like or hex lines
if (trimmed.includes("+Sector:") || trimmed.includes("+UID:")) {
return parseMct(trimmed);
}
throw new Error(
"Unrecognized dump format. Provide JSON, MCT (.mct), or Proxmark JSON."
);
}
function parseJsonDump(content: string): CanonicalTag {
const data = JSON.parse(content) as Record<string, unknown>;
// Proxmark-style
if (data.FileType === "mfcard" || Array.isArray(data.blocks)) {
return parseProxmarkJson(data);
}
// Canonical
if (data.dumpData && data.uid && data.protocol) {
return {
label: typeof data.label === "string" ? data.label : undefined,
frequency: (data.frequency as "LF" | "HF") ?? "HF",
protocol: data.protocol as Protocol,
uid: normalizeUid(String(data.uid)),
dumpData: data.dumpData,
keys: (data.keys as CanonicalTag["keys"]) ?? undefined,
notes: typeof data.notes === "string" ? data.notes : null,
};
}
throw new Error("JSON dump missing required fields (uid, protocol, dumpData)");
}
function parseProxmarkJson(data: Record<string, unknown>): CanonicalTag {
const card = (data.Card as { UID?: string } | undefined) ?? {};
const uid = normalizeUid(String(card.UID ?? data.UID ?? ""));
const blocks = (data.blocks as string[]) ?? [];
const dumpData = blocksToClassicDump(blocks);
return {
frequency: "HF",
protocol: dumpData.size === "4K" ? "MIFARE_CLASSIC_4K" : "MIFARE_CLASSIC_1K",
uid,
dumpData,
keys: (data.SectorKeys as CanonicalTag["keys"]) ?? undefined,
};
}
function blocksToClassicDump(blocks: string[]): MifareClassicDump {
const normalized = blocks.map((b) => b.replace(/\s/g, "").toUpperCase());
const size = normalized.length > 64 ? "4K" : "1K";
const sectors: MifareClassicDump["sectors"] = [];
let offset = 0;
let sectorIndex = 0;
while (offset < normalized.length) {
const blocksPerSector = sectorIndex < 32 ? 4 : 16;
const slice = normalized.slice(offset, offset + blocksPerSector);
if (slice.length === 0) break;
sectors.push({ index: sectorIndex, blocks: slice });
offset += blocksPerSector;
sectorIndex += 1;
}
return { size, sectors };
}
export function parseMct(content: string): CanonicalTag {
const lines = content.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
let uid = "";
const sectors: MifareClassicDump["sectors"] = [];
let current: { index: number; blocks: string[] } | null = null;
for (const line of lines) {
if (line.startsWith("+UID:")) {
uid = normalizeUid(line.slice(5).trim());
continue;
}
if (line.startsWith("+Sector:")) {
if (current) sectors.push(current);
current = {
index: Number.parseInt(line.slice(8).trim(), 10),
blocks: [],
};
continue;
}
if (/^[0-9A-Fa-f]+$/.test(line) && current) {
current.blocks.push(line.toUpperCase());
}
}
if (current) sectors.push(current);
if (!uid) {
// try first block of sector 0
const b0 = sectors.find((s) => s.index === 0)?.blocks[0];
if (b0 && b0.length >= 8) {
uid = b0.slice(0, 8);
}
}
if (!uid) throw new Error("MCT dump missing UID");
const totalBlocks = sectors.reduce((n, s) => n + s.blocks.length, 0);
const size = totalBlocks > 64 ? "4K" : "1K";
return {
frequency: "HF",
protocol: size === "4K" ? "MIFARE_CLASSIC_4K" : "MIFARE_CLASSIC_1K",
uid,
dumpData: { size, sectors },
};
}
+36
View File
@@ -0,0 +1,36 @@
export const FREQUENCIES = ["LF", "HF"] as const;
export type Frequency = (typeof FREQUENCIES)[number];
export const PROTOCOLS = [
"EM4100",
"HID_PROX",
"MIFARE_CLASSIC_1K",
"MIFARE_CLASSIC_4K",
"NTAG213",
"NTAG215",
"NTAG216",
"MIFARE_UL",
"ISO15693",
"UNKNOWN",
] as const;
export type Protocol = (typeof PROTOCOLS)[number];
export const LF_PROTOCOLS: Protocol[] = ["EM4100", "HID_PROX"];
export const HF_PROTOCOLS: Protocol[] = [
"MIFARE_CLASSIC_1K",
"MIFARE_CLASSIC_4K",
"NTAG213",
"NTAG215",
"NTAG216",
"MIFARE_UL",
"ISO15693",
"UNKNOWN",
];
export function defaultFrequencyForProtocol(protocol: Protocol): Frequency {
return LF_PROTOCOLS.includes(protocol) ? "LF" : "HF";
}
export function protocolLabel(protocol: Protocol): string {
return protocol.replaceAll("_", " ");
}
+14
View File
@@ -0,0 +1,14 @@
import type { TagKeys } from "@/lib/validation/rfid";
import type { Protocol } from "@/lib/rfid/protocols";
export type CanonicalTag = {
label?: string;
frequency: "LF" | "HF";
protocol: Protocol;
uid: string;
dumpData: unknown;
keys?: TagKeys;
notes?: string | null;
};
export type ExportFormat = "json" | "proxmark" | "mct" | "hex";
+27
View File
@@ -0,0 +1,27 @@
/**
* Normalize RFID UIDs to uppercase hex without separators.
* Accepts common forms: "04:A1:B2:C3", "04 A1 B2 C3", "04a1b2c3".
*/
export function normalizeUid(input: string): string {
const cleaned = input.replace(/[^0-9a-fA-F]/g, "").toUpperCase();
if (cleaned.length < 4 || cleaned.length % 2 !== 0) {
throw new Error(
`Invalid UID "${input}": expected even-length hex of at least 2 bytes`
);
}
return cleaned;
}
/** Display form with colon separators: 04:A1:B2:C3 */
export function formatUid(uid: string): string {
const normalized = normalizeUid(uid);
return normalized.match(/.{1,2}/g)?.join(":") ?? normalized;
}
export function tryNormalizeUid(input: string): string | null {
try {
return normalizeUid(input);
} catch {
return null;
}
}
+85
View File
@@ -0,0 +1,85 @@
import { z } from "zod";
import { FREQUENCIES, PROTOCOLS } from "@/lib/rfid/protocols";
export const mifareClassicDumpSchema = z.object({
size: z.enum(["1K", "4K"]),
sectors: z.array(
z.object({
index: z.number().int().min(0),
blocks: z.array(z.string().regex(/^[0-9A-Fa-f]+$/)).min(1),
})
),
});
export const ntagDumpSchema = z.object({
pages: z.array(
z.object({
index: z.number().int().min(0),
data: z.string().regex(/^[0-9A-Fa-f]+$/),
})
),
});
export const lfDumpSchema = z.object({
uidBytes: z.string().regex(/^[0-9A-Fa-f]+$/),
format: z.string().min(1),
});
export const dumpDataSchema = z.union([
mifareClassicDumpSchema,
ntagDumpSchema,
lfDumpSchema,
z.record(z.string(), z.unknown()),
]);
export const keysSchema = z
.object({
A: z.array(z.string()).optional(),
B: z.array(z.string()).optional(),
})
.passthrough()
.nullable()
.optional();
export const siteCreateSchema = z.object({
name: z.string().min(1).max(200),
code: z
.string()
.min(1)
.max(32)
.regex(/^[A-Za-z0-9_-]+$/, "Code must be alphanumeric"),
notes: z.string().max(5000).nullable().optional(),
});
export const siteUpdateSchema = siteCreateSchema.partial();
export const tagCreateSchema = z.object({
siteId: z.string().uuid(),
label: z.string().min(1).max(200),
frequency: z.enum(FREQUENCIES),
protocol: z.enum(PROTOCOLS),
uid: z.string().min(1),
dumpData: dumpDataSchema,
keys: keysSchema,
notes: z.string().max(5000).nullable().optional(),
lastWrittenAt: z.string().datetime().nullable().optional(),
});
export const tagUpdateSchema = tagCreateSchema.partial().omit({ siteId: true }).extend({
siteId: z.string().uuid().optional(),
});
export const tagUpsertSchema = tagCreateSchema;
export const tokenCreateSchema = z.object({
name: z.string().min(1).max(100),
expiresAt: z.string().datetime().nullable().optional(),
});
export const exportFormatSchema = z.enum(["json", "proxmark", "mct", "hex"]);
export type MifareClassicDump = z.infer<typeof mifareClassicDumpSchema>;
export type NtagDump = z.infer<typeof ntagDumpSchema>;
export type LfDump = z.infer<typeof lfDumpSchema>;
export type DumpData = z.infer<typeof dumpDataSchema>;
export type TagKeys = z.infer<typeof keysSchema>;