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 { describe, expect, it } from "vitest";
import { parseMct, parseImport } from "@/lib/rfid/parsers";
import { exportTag } from "@/lib/rfid/exporters";
import { mifareClassicDumpSchema } from "@/lib/validation/rfid";
const sampleMct = `+UID: 04A1B2C3
+Sector: 0
04A1B2C304A1B2C304A1B2C304A1B2C3
00000000000000000000000000000000
00000000000000000000000000000000
FFFFFFFFFFFFFF078069FFFFFFFFFFFF
+Sector: 1
00000000000000000000000000000000
00000000000000000000000000000000
00000000000000000000000000000000
FFFFFFFFFFFFFF078069FFFFFFFFFFFF
`;
describe("MCT parser/exporter", () => {
it("parses MCT dumps", () => {
const tag = parseMct(sampleMct);
expect(tag.uid).toBe("04A1B2C3");
expect(tag.protocol).toBe("MIFARE_CLASSIC_1K");
expect(mifareClassicDumpSchema.safeParse(tag.dumpData).success).toBe(true);
});
it("round-trips via export mct", () => {
const tag = parseMct(sampleMct);
const exported = exportTag(
{
id: "00000000-0000-4000-8000-000000000001",
label: "test",
frequency: tag.frequency,
protocol: tag.protocol,
uid: tag.uid,
dumpData: tag.dumpData,
keys: null,
notes: null,
},
"mct"
);
const again = parseImport(exported.body, "test.mct");
expect(again.uid).toBe(tag.uid);
expect(again.protocol).toBe(tag.protocol);
});
it("parses canonical JSON", () => {
const json = JSON.stringify({
label: "Dock",
frequency: "HF",
protocol: "MIFARE_CLASSIC_1K",
uid: "04:A1:B2:C3",
dumpData: {
size: "1K",
sectors: [{ index: 0, blocks: ["AABBCCDDEEFF00112233445566778899"] }],
},
});
const tag = parseImport(json, "x.json");
expect(tag.uid).toBe("04A1B2C3");
expect(tag.label).toBe("Dock");
});
});
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { generateApiToken, hashToken, isApiTokenFormat } from "@/lib/auth/tokens";
describe("api tokens", () => {
it("generates rfid_ prefixed tokens", () => {
const { token, tokenHash, prefix } = generateApiToken();
expect(isApiTokenFormat(token)).toBe(true);
expect(tokenHash).toBe(hashToken(token));
expect(prefix).toBe(token.slice(0, 12));
expect(hashToken(token)).not.toBe(token);
});
it("rejects non-tokens", () => {
expect(isApiTokenFormat("Bearer abc")).toBe(false);
expect(isApiTokenFormat("rfid_short")).toBe(false);
});
});
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { formatUid, normalizeUid, tryNormalizeUid } from "@/lib/rfid/uid";
describe("normalizeUid", () => {
it("strips separators and uppercases", () => {
expect(normalizeUid("04:a1:b2:c3")).toBe("04A1B2C3");
expect(normalizeUid("04 a1 b2 c3")).toBe("04A1B2C3");
expect(normalizeUid("04a1b2c3")).toBe("04A1B2C3");
});
it("rejects odd length", () => {
expect(() => normalizeUid("04A")).toThrow();
});
it("formats with colons", () => {
expect(formatUid("04a1b2c3")).toBe("04:A1:B2:C3");
});
it("tryNormalizeUid returns null on bad input", () => {
expect(tryNormalizeUid("xyz")).toBeNull();
});
});