From b819182829f2417b7030c2df4882d430b2879adf Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:02:54 +0200 Subject: [PATCH 01/42] refactor: introduce generic tag and event schema --- src/db/schema.ts | 88 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 75 insertions(+), 13 deletions(-) diff --git a/src/db/schema.ts b/src/db/schema.ts index dac6050..75cc621 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -15,14 +15,44 @@ CREATE TABLE IF NOT EXISTS items ( updated_at TEXT DEFAULT CURRENT_TIMESTAMP ); -CREATE TABLE IF NOT EXISTS rfid_tags ( +CREATE TABLE IF NOT EXISTS tag_security_profiles ( id INTEGER PRIMARY KEY AUTOINCREMENT, - epc TEXT NOT NULL UNIQUE, + profile_key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + technology TEXT NOT NULL, + verifier TEXT NOT NULL, + key_ref TEXT, + config_json TEXT, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + technology TEXT NOT NULL, + identifier TEXT NOT NULL, + epc TEXT, tid TEXT, - item_id INTEGER NOT NULL, - status TEXT DEFAULT 'active', + uid TEXT, + manufacturer TEXT, + chip_family TEXT, + chip_model TEXT, + product_family TEXT, + part_number TEXT, + capabilities_json TEXT, + metadata_json TEXT, + security_profile_id INTEGER, + item_id INTEGER, + status TEXT NOT NULL DEFAULT 'active', + last_auth_status TEXT NOT NULL DEFAULT 'not-requested', + last_tamper_status TEXT NOT NULL DEFAULT 'unknown', + last_seen_at TEXT, registered_at TEXT DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (item_id) REFERENCES items(id) + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (security_profile_id) REFERENCES tag_security_profiles(id), + FOREIGN KEY (item_id) REFERENCES items(id), + UNIQUE(technology, identifier) ); CREATE TABLE IF NOT EXISTS stations ( @@ -49,24 +79,56 @@ CREATE TABLE IF NOT EXISTS inventory_sessions ( FOREIGN KEY (station_id) REFERENCES stations(id) ); -CREATE TABLE IF NOT EXISTS inventory_reads ( +CREATE TABLE IF NOT EXISTS inventory_observations ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL, - epc TEXT NOT NULL, - tid TEXT, + tag_id INTEGER, + technology TEXT NOT NULL, + identifier TEXT NOT NULL, source TEXT NOT NULL, rssi REAL, antenna INTEGER, - read_count INTEGER DEFAULT 1, - first_seen_at TEXT, - last_seen_at TEXT, + read_count INTEGER NOT NULL DEFAULT 1, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, known_item_id INTEGER, + auth_status TEXT NOT NULL DEFAULT 'not-requested', + tamper_status TEXT NOT NULL DEFAULT 'unknown', FOREIGN KEY (session_id) REFERENCES inventory_sessions(id), + FOREIGN KEY (tag_id) REFERENCES tags(id), FOREIGN KEY (known_item_id) REFERENCES items(id), - UNIQUE(session_id, epc) + UNIQUE(session_id, technology, identifier) +); + +CREATE TABLE IF NOT EXISTS tag_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_kind TEXT NOT NULL, + session_id INTEGER, + station_id INTEGER, + tag_id INTEGER, + technology TEXT NOT NULL, + identifier TEXT NOT NULL, + source TEXT NOT NULL, + auth_status TEXT NOT NULL DEFAULT 'not-requested', + tamper_status TEXT NOT NULL DEFAULT 'unknown', + sensor_value REAL, + sensor_unit TEXT, + payload_json TEXT, + raw TEXT, + occurred_at TEXT NOT NULL, + FOREIGN KEY (session_id) REFERENCES inventory_sessions(id), + FOREIGN KEY (station_id) REFERENCES stations(id), + FOREIGN KEY (tag_id) REFERENCES tags(id) ); CREATE INDEX IF NOT EXISTS idx_items_name ON items(name); CREATE INDEX IF NOT EXISTS idx_items_sku ON items(sku); -CREATE INDEX IF NOT EXISTS idx_inventory_reads_session ON inventory_reads(session_id); +CREATE INDEX IF NOT EXISTS idx_tags_item ON tags(item_id); +CREATE INDEX IF NOT EXISTS idx_tags_identifier ON tags(technology, identifier); +CREATE INDEX IF NOT EXISTS idx_tags_auth ON tags(last_auth_status); +CREATE INDEX IF NOT EXISTS idx_tags_tamper ON tags(last_tamper_status); +CREATE INDEX IF NOT EXISTS idx_inventory_observations_session ON inventory_observations(session_id); +CREATE INDEX IF NOT EXISTS idx_tag_events_tag ON tag_events(tag_id, occurred_at DESC); +CREATE INDEX IF NOT EXISTS idx_tag_events_session ON tag_events(session_id, occurred_at DESC); +CREATE INDEX IF NOT EXISTS idx_tag_events_kind ON tag_events(event_kind, occurred_at DESC); `; From 6139dbc698f55fc75a099792acb6b1b2af6f67b1 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:03:10 +0200 Subject: [PATCH 02/42] refactor: migrate legacy RFID data into generic tables --- src/db/connection.ts | 95 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 87 insertions(+), 8 deletions(-) diff --git a/src/db/connection.ts b/src/db/connection.ts index 3485ceb..2d3d99c 100644 --- a/src/db/connection.ts +++ b/src/db/connection.ts @@ -4,21 +4,100 @@ import { DatabaseSync } from "node:sqlite"; import { config } from "../config.js"; import { schemaSql } from "./schema.js"; -export function createDb() { - fs.mkdirSync(path.dirname(config.databaseUrl), { recursive: true }); - const db = new DatabaseSync(config.databaseUrl); +export function createDb(databaseUrl = config.databaseUrl) { + if (databaseUrl !== ":memory:") fs.mkdirSync(path.dirname(databaseUrl), { recursive: true }); + const db = new DatabaseSync(databaseUrl); db.exec("PRAGMA journal_mode = WAL"); db.exec("PRAGMA foreign_keys = ON"); db.exec(schemaSql); - ensureColumn(db, "items", "status", "TEXT DEFAULT 'active'"); + migrateLegacyRfidData(db); return db; } export type AppDb = ReturnType; -function ensureColumn(db: DatabaseSync, table: string, column: string, definition: string) { - const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; - if (!columns.some((entry) => entry.name === column)) { - db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); +function tableExists(db: DatabaseSync, table: string) { + const row = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) as { name: string } | undefined; + return Boolean(row); +} + +function migrateLegacyRfidData(db: DatabaseSync) { + if (!tableExists(db, "rfid_tags")) return; + + db.exec("BEGIN IMMEDIATE"); + try { + db.exec(` + INSERT INTO tags ( + technology, + identifier, + epc, + tid, + chip_family, + capabilities_json, + item_id, + status, + registered_at, + updated_at + ) + SELECT + 'uhf-rain', + UPPER(REPLACE(REPLACE(TRIM(epc), ':', ''), '-', '')), + UPPER(REPLACE(REPLACE(TRIM(epc), ':', ''), '-', '')), + tid, + 'legacy-uhf', + '{"bulkInventory":true}', + item_id, + status, + registered_at, + registered_at + FROM rfid_tags + WHERE TRIM(epc) != '' + ON CONFLICT(technology, identifier) DO NOTHING; + `); + + if (tableExists(db, "inventory_reads")) { + db.exec(` + INSERT INTO inventory_observations ( + session_id, + tag_id, + technology, + identifier, + source, + rssi, + antenna, + read_count, + first_seen_at, + last_seen_at, + known_item_id, + auth_status, + tamper_status + ) + SELECT + legacy.session_id, + tags.id, + 'uhf-rain', + UPPER(REPLACE(REPLACE(TRIM(legacy.epc), ':', ''), '-', '')), + legacy.source, + legacy.rssi, + legacy.antenna, + legacy.read_count, + COALESCE(legacy.first_seen_at, legacy.last_seen_at, CURRENT_TIMESTAMP), + COALESCE(legacy.last_seen_at, legacy.first_seen_at, CURRENT_TIMESTAMP), + legacy.known_item_id, + 'not-requested', + 'unknown' + FROM inventory_reads legacy + LEFT JOIN tags + ON tags.technology = 'uhf-rain' + AND tags.identifier = UPPER(REPLACE(REPLACE(TRIM(legacy.epc), ':', ''), '-', '')) + WHERE TRIM(legacy.epc) != '' + ON CONFLICT(session_id, technology, identifier) DO NOTHING; + `); + } + + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; } } From a5f917e0e3e23fd2ba754428a0aed2aade143b87 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:03:23 +0200 Subject: [PATCH 03/42] feat: add generic tag types --- src/modules/tags/tag.types.ts | 63 +++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/modules/tags/tag.types.ts diff --git a/src/modules/tags/tag.types.ts b/src/modules/tags/tag.types.ts new file mode 100644 index 0000000..8887c59 --- /dev/null +++ b/src/modules/tags/tag.types.ts @@ -0,0 +1,63 @@ +export type TagTechnology = "uhf-rain" | "nfc"; +export type TagStatus = "active" | "inactive" | "ignored" | "external"; +export type AuthStatus = "not-requested" | "pending" | "verified" | "failed" | "unsupported" | "error"; +export type TamperStatus = "unknown" | "sealed" | "open" | "closed" | "opened-once"; +export type TagReadSource = "browser-hid" | "browser-webnfc" | "reader-bridge" | "mock"; + +export type TagCapabilities = { + ndef?: boolean; + sun?: boolean; + sdm?: boolean; + mutualAuth?: boolean; + passwordProtectedMemory?: boolean; + aesProtectedMemory?: boolean; + secureMessaging?: boolean; + tamper?: boolean; + currentTamperStatus?: boolean; + permanentTamperStatus?: boolean; + statusDetect?: boolean; + sensor?: boolean; + bulkInventory?: boolean; + uhfAesAuth?: boolean; + readerChallengeResponse?: boolean; +}; + +export type TagObservationInput = { + technology: TagTechnology; + identifier: string; + epc?: string; + tid?: string; + uid?: string; + source: TagReadSource; + stationKey?: string; + sessionKey?: string; + rssi?: number; + antenna?: number; + raw?: string; + ndefUrl?: string; + payload?: Record; + authStatus?: AuthStatus; + tamperStatus?: TamperStatus; + sensorValue?: number; + sensorUnit?: string; + seenAt?: string; +}; + +export type RegisterTagInput = { + technology: TagTechnology; + identifier: string; + itemId?: number; + epc?: string; + tid?: string; + uid?: string; + catalogKey?: string; + manufacturer?: string; + chipFamily?: string; + chipModel?: string; + productFamily?: string; + partNumber?: string; + securityProfileKey?: string; + capabilities?: TagCapabilities; + metadata?: Record; + status?: TagStatus; +}; From 1fe5f770ece1cbcaf6d2940022b019d91f1c0d82 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:03:36 +0200 Subject: [PATCH 04/42] feat: add NFC and RAIN security tag catalog --- src/modules/tags/tag.catalog.ts | 149 ++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/modules/tags/tag.catalog.ts diff --git a/src/modules/tags/tag.catalog.ts b/src/modules/tags/tag.catalog.ts new file mode 100644 index 0000000..c8178a4 --- /dev/null +++ b/src/modules/tags/tag.catalog.ts @@ -0,0 +1,149 @@ +import type { TagCapabilities, TagTechnology } from "./tag.types.js"; + +export type TagCatalogEntry = { + key: string; + label: string; + technology: TagTechnology; + manufacturer?: string; + chipFamily?: string; + chipModel?: string; + productFamily?: string; + partNumber?: string; + capabilities: TagCapabilities; + notes?: string; +}; + +export const tagCatalog: TagCatalogEntry[] = [ + { key: "generic-nfc-ndef", label: "Generic NFC / NDEF", technology: "nfc", capabilities: { ndef: true } }, + { key: "generic-rain", label: "Generic RAIN / UHF", technology: "uhf-rain", capabilities: { bulkInventory: true } }, + { + key: "nxp-ntag223-dna", + label: "NXP NTAG 223 DNA", + technology: "nfc", + manufacturer: "NXP", + chipFamily: "NTAG 22x DNA", + chipModel: "NTAG 223 DNA", + capabilities: { ndef: true, sun: true, passwordProtectedMemory: true } + }, + { + key: "nxp-ntag224-dna", + label: "NXP NTAG 224 DNA", + technology: "nfc", + manufacturer: "NXP", + chipFamily: "NTAG 22x DNA", + chipModel: "NTAG 224 DNA", + capabilities: { ndef: true, sun: true, mutualAuth: true, aesProtectedMemory: true } + }, + { + key: "nxp-ntag223-dna-statusdetect", + label: "NXP NTAG 223 DNA StatusDetect", + technology: "nfc", + manufacturer: "NXP", + chipFamily: "NTAG 22x DNA StatusDetect", + chipModel: "NTAG 223 DNA StatusDetect", + capabilities: { ndef: true, sun: true, passwordProtectedMemory: true, tamper: true, currentTamperStatus: true, statusDetect: true, sensor: true } + }, + { + key: "nxp-ntag224-dna-statusdetect", + label: "NXP NTAG 224 DNA StatusDetect", + technology: "nfc", + manufacturer: "NXP", + chipFamily: "NTAG 22x DNA StatusDetect", + chipModel: "NTAG 224 DNA StatusDetect", + capabilities: { ndef: true, sun: true, mutualAuth: true, aesProtectedMemory: true, tamper: true, currentTamperStatus: true, statusDetect: true, sensor: true } + }, + { + key: "nxp-ntag424-dna", + label: "NXP NTAG 424 DNA", + technology: "nfc", + manufacturer: "NXP", + chipFamily: "NTAG 424 DNA", + chipModel: "NTAG 424 DNA", + capabilities: { ndef: true, sun: true, sdm: true, mutualAuth: true, aesProtectedMemory: true, secureMessaging: true } + }, + { + key: "nxp-ntag424-dna-tt", + label: "NXP NTAG 424 DNA TagTamper", + technology: "nfc", + manufacturer: "NXP", + chipFamily: "NTAG 424 DNA", + chipModel: "NTAG 424 DNA TagTamper", + capabilities: { ndef: true, sun: true, sdm: true, mutualAuth: true, aesProtectedMemory: true, secureMessaging: true, tamper: true, currentTamperStatus: true, permanentTamperStatus: true } + }, + { + key: "identiv-ntag223-dna-statusdetect-conductive", + label: "Identiv NTAG 223 DNA StatusDetect — conductive", + technology: "nfc", + manufacturer: "Identiv / NXP", + chipFamily: "NTAG 22x DNA StatusDetect", + chipModel: "NTAG 223 DNA StatusDetect", + productFamily: "Identiv NTAG 22x DNA Portfolio", + partNumber: "LA1XADNW9025", + capabilities: { ndef: true, sun: true, passwordProtectedMemory: true, tamper: true, currentTamperStatus: true, statusDetect: true } + }, + { + key: "identiv-ntag223-dna-statusdetect-capacitive", + label: "Identiv NTAG 223 DNA StatusDetect — capacitive", + technology: "nfc", + manufacturer: "Identiv / NXP", + chipFamily: "NTAG 22x DNA StatusDetect", + chipModel: "NTAG 223 DNA StatusDetect", + productFamily: "Identiv NTAG 22x DNA Portfolio", + partNumber: "LA1XADNW9B25", + capabilities: { ndef: true, sun: true, passwordProtectedMemory: true, statusDetect: true, sensor: true } + }, + { + key: "identiv-ntag224-dna", + label: "Identiv NTAG 224 DNA", + technology: "nfc", + manufacturer: "Identiv / NXP", + chipFamily: "NTAG 22x DNA", + chipModel: "NTAG 224 DNA", + productFamily: "Identiv NTAG 22x DNA Portfolio", + partNumber: "LA1PADNX9025", + capabilities: { ndef: true, sun: true, mutualAuth: true, aesProtectedMemory: true } + }, + { + key: "identiv-id-nxp-ntag424dna-tt", + label: "Identiv ID-NXP NTAG 424 DNA TT", + technology: "nfc", + manufacturer: "Identiv / NXP", + chipFamily: "NTAG 424 DNA", + chipModel: "NTAG 424 DNA TagTamper", + productFamily: "ID-NXP NTAG 424 DNA TT", + capabilities: { ndef: true, sun: true, sdm: true, mutualAuth: true, aesProtectedMemory: true, secureMessaging: true, tamper: true, currentTamperStatus: true, permanentTamperStatus: true }, + notes: "Exact inlay/label part number is selected after sample inspection." + }, + { + key: "nxp-ucode-dna", + label: "NXP UCODE DNA", + technology: "uhf-rain", + manufacturer: "NXP", + chipFamily: "UCODE DNA", + chipModel: "UCODE DNA", + capabilities: { bulkInventory: true, uhfAesAuth: true, readerChallengeResponse: true } + }, + { + key: "nxp-ucode-dna-track", + label: "NXP UCODE DNA Track", + technology: "uhf-rain", + manufacturer: "NXP", + chipFamily: "UCODE DNA", + chipModel: "UCODE DNA Track", + capabilities: { bulkInventory: true, uhfAesAuth: true, readerChallengeResponse: true } + }, + { + key: "nxp-ucode-guard", + label: "NXP UCODE Guard", + technology: "uhf-rain", + manufacturer: "NXP", + chipFamily: "UCODE Guard", + chipModel: "UCODE Guard", + capabilities: { bulkInventory: true, uhfAesAuth: true, readerChallengeResponse: true }, + notes: "Tracked alongside UCODE DNA as a secure RAIN authentication family." + } +]; + +export function getCatalogEntry(key?: string) { + return key ? tagCatalog.find((entry) => entry.key === key) : undefined; +} From 9c8d0753ae8637bd1757556ba1682749e94be40f Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:03:45 +0200 Subject: [PATCH 05/42] feat: normalize generic tag identifiers --- src/utils/normalize-tag.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/utils/normalize-tag.ts diff --git a/src/utils/normalize-tag.ts b/src/utils/normalize-tag.ts new file mode 100644 index 0000000..83e13f4 --- /dev/null +++ b/src/utils/normalize-tag.ts @@ -0,0 +1,16 @@ +import type { TagTechnology } from "../modules/tags/tag.types.js"; + +export function normalizeTagIdentifier(technology: TagTechnology, raw: string) { + const value = raw.trim(); + if (!value) return ""; + + if (technology === "uhf-rain") { + const normalized = value.replace(/^0x/i, "").replace(/[\s:_-]/g, "").toUpperCase(); + return /^[0-9A-F]+$/.test(normalized) ? normalized : ""; + } + + const compactHex = value.replace(/^0x/i, "").replace(/[\s:_-]/g, "").toUpperCase(); + if (/^[0-9A-F]+$/.test(compactHex)) return compactHex; + + return value; +} From b48d96e02b780bf1c0df657605315b13b7863f62 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:04:00 +0200 Subject: [PATCH 06/42] refactor: generalize tag service across NFC and RAIN --- src/modules/tags/tags.service.ts | 202 +++++++++++++++++++------------ 1 file changed, 123 insertions(+), 79 deletions(-) diff --git a/src/modules/tags/tags.service.ts b/src/modules/tags/tags.service.ts index f143170..6d44bf1 100644 --- a/src/modules/tags/tags.service.ts +++ b/src/modules/tags/tags.service.ts @@ -1,101 +1,145 @@ import type { AppDb } from "../../db/connection.js"; -import { normalizeEpc } from "../../utils/normalize-epc.js"; +import { normalizeTagIdentifier } from "../../utils/normalize-tag.js"; +import { nowIso } from "../../utils/time.js"; +import { getCatalogEntry, tagCatalog } from "./tag.catalog.js"; +import type { RegisterTagInput, TagStatus, TagTechnology } from "./tag.types.js"; + +function selectTagSql() { + return ` + SELECT + tags.*, + items.name AS item_name, + items.sku AS item_sku, + items.category AS item_category, + tag_security_profiles.profile_key AS security_profile_key, + tag_security_profiles.name AS security_profile_name, + tag_security_profiles.verifier AS security_verifier + FROM tags + LEFT JOIN items ON items.id = tags.item_id + LEFT JOIN tag_security_profiles ON tag_security_profiles.id = tags.security_profile_id + `; +} export function createTagsService(db: AppDb) { + function resolve(technology: TagTechnology, rawIdentifier: string) { + const identifier = normalizeTagIdentifier(technology, rawIdentifier); + if (!identifier) return null; + + return db.prepare(`${selectTagSql()} WHERE tags.technology = ? AND tags.identifier = ?`).get(technology, identifier); + } + return { - list(status?: string) { - if (status) { - return db.prepare(` - SELECT - rfid_tags.*, - items.name AS item_name, - items.sku AS item_sku, - items.category AS item_category - FROM rfid_tags - LEFT JOIN items ON items.id = rfid_tags.item_id - WHERE rfid_tags.status = ? - ORDER BY rfid_tags.registered_at DESC - `).all(status); + catalog() { + return tagCatalog; + }, + + list(filters: { status?: string; technology?: TagTechnology; search?: string } = {}) { + const clauses: string[] = []; + const values: unknown[] = []; + + if (filters.status) { + clauses.push("tags.status = ?"); + values.push(filters.status); + } + if (filters.technology) { + clauses.push("tags.technology = ?"); + values.push(filters.technology); + } + if (filters.search) { + const term = `%${filters.search}%`; + clauses.push("(tags.identifier LIKE ? OR tags.epc LIKE ? OR tags.uid LIKE ? OR tags.chip_model LIKE ? OR tags.product_family LIKE ? OR items.name LIKE ? OR items.sku LIKE ?)"); + values.push(term, term, term, term, term, term, term); } - return db.prepare(` - SELECT - rfid_tags.*, - items.name AS item_name, - items.sku AS item_sku, - items.category AS item_category - FROM rfid_tags - LEFT JOIN items ON items.id = rfid_tags.item_id - ORDER BY rfid_tags.registered_at DESC - `).all(); + const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : ""; + return db.prepare(`${selectTagSql()}${where} ORDER BY COALESCE(tags.last_seen_at, tags.registered_at) DESC, tags.id DESC`).all(...values); }, - resolve(rawEpc: string) { - const epc = normalizeEpc(rawEpc); - if (!epc) return null; - - return db.prepare(` - SELECT - rfid_tags.*, - items.name AS item_name, - items.sku AS item_sku, - items.category AS item_category - FROM rfid_tags - LEFT JOIN items ON items.id = rfid_tags.item_id - WHERE rfid_tags.epc = ? - `).get(epc); + getById(id: number) { + return db.prepare(`${selectTagSql()} WHERE tags.id = ?`).get(id); }, - register(rawEpc: string, itemId: number, tid?: string) { - const epc = normalizeEpc(rawEpc); - if (!epc) return { ok: false, error: "INVALID_EPC" }; + resolve, - const item = db.prepare("SELECT id FROM items WHERE id = ?").get(itemId); - if (!item) return { ok: false, error: "ITEM_NOT_FOUND" }; + register(input: RegisterTagInput) { + const catalog = getCatalogEntry(input.catalogKey); + const technology = catalog?.technology ?? input.technology; + const identifier = normalizeTagIdentifier(technology, input.identifier); + if (!identifier) return { ok: false, error: "INVALID_IDENTIFIER" as const }; - db.prepare(` - INSERT INTO rfid_tags (epc, tid, item_id) - VALUES (?, ?, ?) - ON CONFLICT(epc) DO UPDATE SET - tid = COALESCE(excluded.tid, rfid_tags.tid), - item_id = excluded.item_id, - status = 'active' - `).run(epc, tid ?? null, itemId); - - return { ok: true, tag: this.resolve(epc) }; - }, + if (input.itemId !== undefined) { + const item = db.prepare("SELECT id FROM items WHERE id = ? AND status != 'archived'").get(input.itemId); + if (!item) return { ok: false, error: "ITEM_NOT_FOUND" as const }; + } - setStatus(rawEpc: string, status: "active" | "inactive" | "ignored" | "external") { - const epc = normalizeEpc(rawEpc); - if (!epc) return { ok: false, error: "INVALID_EPC" }; + let securityProfileId: number | null = null; + if (input.securityProfileKey) { + const profile = db.prepare("SELECT id FROM tag_security_profiles WHERE profile_key = ? AND status = 'active'").get(input.securityProfileKey) as { id: number } | undefined; + if (!profile) return { ok: false, error: "SECURITY_PROFILE_NOT_FOUND" as const }; + securityProfileId = profile.id; + } - const result = db.prepare("UPDATE rfid_tags SET status = ? WHERE epc = ?").run(status, epc); - if (result.changes === 0) return { ok: false, error: "TAG_NOT_FOUND" }; + const capabilities = { ...(catalog?.capabilities ?? {}), ...(input.capabilities ?? {}) }; + const timestamp = nowIso(); + const epc = technology === "uhf-rain" ? normalizeTagIdentifier("uhf-rain", input.epc ?? identifier) : input.epc ?? null; + const uid = technology === "nfc" ? normalizeTagIdentifier("nfc", input.uid ?? identifier) : input.uid ?? null; - return { ok: true, tag: this.resolve(epc) }; - }, + db.prepare(` + INSERT INTO tags ( + technology, identifier, epc, tid, uid, + manufacturer, chip_family, chip_model, product_family, part_number, + capabilities_json, metadata_json, security_profile_id, item_id, + status, registered_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(technology, identifier) DO UPDATE SET + epc = COALESCE(excluded.epc, tags.epc), + tid = COALESCE(excluded.tid, tags.tid), + uid = COALESCE(excluded.uid, tags.uid), + manufacturer = COALESCE(excluded.manufacturer, tags.manufacturer), + chip_family = COALESCE(excluded.chip_family, tags.chip_family), + chip_model = COALESCE(excluded.chip_model, tags.chip_model), + product_family = COALESCE(excluded.product_family, tags.product_family), + part_number = COALESCE(excluded.part_number, tags.part_number), + capabilities_json = COALESCE(excluded.capabilities_json, tags.capabilities_json), + metadata_json = COALESCE(excluded.metadata_json, tags.metadata_json), + security_profile_id = COALESCE(excluded.security_profile_id, tags.security_profile_id), + item_id = COALESCE(excluded.item_id, tags.item_id), + status = excluded.status, + updated_at = excluded.updated_at + `).run( + technology, + identifier, + epc, + input.tid ?? null, + uid, + input.manufacturer ?? catalog?.manufacturer ?? null, + input.chipFamily ?? catalog?.chipFamily ?? null, + input.chipModel ?? catalog?.chipModel ?? null, + input.productFamily ?? catalog?.productFamily ?? null, + input.partNumber ?? catalog?.partNumber ?? null, + Object.keys(capabilities).length ? JSON.stringify(capabilities) : null, + input.metadata ? JSON.stringify(input.metadata) : null, + securityProfileId, + input.itemId ?? null, + input.status ?? "active", + timestamp, + timestamp + ); - markUnknown(rawEpc: string, status: "ignored" | "external") { - const epc = normalizeEpc(rawEpc); - if (!epc) return { ok: false, error: "INVALID_EPC" }; + return { ok: true, tag: resolve(technology, identifier) }; + }, - const placeholder = db.prepare("SELECT id FROM items WHERE sku = ?").get(`SYSTEM-${status.toUpperCase()}`) as { id: number } | undefined; - const itemId = placeholder?.id ?? Number(db.prepare(` - INSERT INTO items (sku, name, category, notes) - VALUES (?, ?, 'System', ?) - `).run( - `SYSTEM-${status.toUpperCase()}`, - status === "ignored" ? "Ignored external tag" : "External tag", - "System placeholder for non-inventory RFID reads" - ).lastInsertRowid); + setStatus(technology: TagTechnology, rawIdentifier: string, status: TagStatus) { + const identifier = normalizeTagIdentifier(technology, rawIdentifier); + if (!identifier) return { ok: false, error: "INVALID_IDENTIFIER" as const }; - db.prepare(` - INSERT INTO rfid_tags (epc, item_id, status) - VALUES (?, ?, ?) - ON CONFLICT(epc) DO UPDATE SET status = excluded.status - `).run(epc, itemId, status); + const result = db.prepare("UPDATE tags SET status = ?, updated_at = ? WHERE technology = ? AND identifier = ?").run(status, nowIso(), technology, identifier); + if (result.changes === 0) return { ok: false, error: "TAG_NOT_FOUND" as const }; + return { ok: true, tag: resolve(technology, identifier) }; + }, - return { ok: true, tag: this.resolve(epc) }; + markUnknown(technology: TagTechnology, rawIdentifier: string, status: "ignored" | "external") { + return this.register({ technology, identifier: rawIdentifier, status }); } }; } From 6f38327dcd1cdf18786f0b95d163391237e6c1d4 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:04:41 +0200 Subject: [PATCH 07/42] feat: add unified NFC and RAIN observation pipeline --- .../observations/observations.service.ts | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 src/modules/observations/observations.service.ts diff --git a/src/modules/observations/observations.service.ts b/src/modules/observations/observations.service.ts new file mode 100644 index 0000000..58bcbe1 --- /dev/null +++ b/src/modules/observations/observations.service.ts @@ -0,0 +1,174 @@ +import type { AppDb } from "../../db/connection.js"; +import { normalizeTagIdentifier } from "../../utils/normalize-tag.js"; +import { nowIso } from "../../utils/time.js"; +import type { AuthStatus, TagObservationInput, TamperStatus } from "../tags/tag.types.js"; + +export function createObservationsService(db: AppDb) { + function findTag(technology: TagObservationInput["technology"], identifier: string) { + return db.prepare(` + SELECT + tags.*, + items.name AS item_name, + items.sku AS item_sku, + items.category AS item_category + FROM tags + LEFT JOIN items ON items.id = tags.item_id + WHERE tags.technology = ? AND tags.identifier = ? AND tags.status = 'active' + `).get(technology, identifier) as any; + } + + function findSession(sessionKey?: string) { + if (!sessionKey) return undefined; + return db.prepare("SELECT id, session_key, status FROM inventory_sessions WHERE session_key = ?").get(sessionKey) as { id: number; session_key: string; status: string } | undefined; + } + + function findStation(stationKey?: string) { + if (!stationKey) return undefined; + return db.prepare("SELECT id, station_key FROM stations WHERE station_key = ?").get(stationKey) as { id: number; station_key: string } | undefined; + } + + return { + process(input: TagObservationInput) { + const identifier = normalizeTagIdentifier(input.technology, input.identifier); + if (!identifier) return { ok: false as const, error: "INVALID_IDENTIFIER" as const }; + + const seenAt = input.seenAt ?? nowIso(); + const session = findSession(input.sessionKey); + if (input.sessionKey && !session) return { ok: false as const, error: "SESSION_NOT_FOUND" as const }; + if (session && session.status !== "open") return { ok: false as const, error: "SESSION_CLOSED" as const }; + + const station = findStation(input.stationKey); + if (input.stationKey && !station) return { ok: false as const, error: "STATION_NOT_FOUND" as const }; + + const tag = findTag(input.technology, identifier); + const authStatus: AuthStatus = input.authStatus ?? "not-requested"; + const tamperStatus: TamperStatus = input.tamperStatus ?? "unknown"; + + if (tag) { + db.prepare(` + UPDATE tags + SET + epc = COALESCE(?, epc), + tid = COALESCE(?, tid), + uid = COALESCE(?, uid), + last_seen_at = ?, + last_auth_status = CASE WHEN ? = 'not-requested' THEN last_auth_status ELSE ? END, + last_tamper_status = CASE WHEN ? = 'unknown' THEN last_tamper_status ELSE ? END, + updated_at = ? + WHERE id = ? + `).run( + input.epc ?? null, + input.tid ?? null, + input.uid ?? null, + seenAt, + authStatus, + authStatus, + tamperStatus, + tamperStatus, + seenAt, + tag.id + ); + } + + if (session) { + db.prepare(` + INSERT INTO inventory_observations ( + session_id, tag_id, technology, identifier, source, rssi, antenna, + read_count, first_seen_at, last_seen_at, known_item_id, auth_status, tamper_status + ) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?) + ON CONFLICT(session_id, technology, identifier) DO UPDATE SET + tag_id = COALESCE(excluded.tag_id, inventory_observations.tag_id), + read_count = inventory_observations.read_count + 1, + last_seen_at = excluded.last_seen_at, + known_item_id = COALESCE(excluded.known_item_id, inventory_observations.known_item_id), + auth_status = CASE WHEN excluded.auth_status = 'not-requested' THEN inventory_observations.auth_status ELSE excluded.auth_status END, + tamper_status = CASE WHEN excluded.tamper_status = 'unknown' THEN inventory_observations.tamper_status ELSE excluded.tamper_status END, + rssi = COALESCE(excluded.rssi, inventory_observations.rssi), + antenna = COALESCE(excluded.antenna, inventory_observations.antenna) + `).run( + session.id, + tag?.id ?? null, + input.technology, + identifier, + input.source, + input.rssi ?? null, + input.antenna ?? null, + seenAt, + seenAt, + tag?.item_id ?? null, + authStatus, + tamperStatus + ); + } + + const shouldRecordEvent = + input.technology === "nfc" || + authStatus !== "not-requested" || + tamperStatus !== "unknown" || + input.sensorValue !== undefined; + + if (shouldRecordEvent) { + const eventKind = authStatus !== "not-requested" + ? "verification" + : tamperStatus !== "unknown" || input.sensorValue !== undefined + ? "status" + : "scan"; + + db.prepare(` + INSERT INTO tag_events ( + event_kind, session_id, station_id, tag_id, technology, identifier, + source, auth_status, tamper_status, sensor_value, sensor_unit, + payload_json, raw, occurred_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + eventKind, + session?.id ?? null, + station?.id ?? null, + tag?.id ?? null, + input.technology, + identifier, + input.source, + authStatus, + tamperStatus, + input.sensorValue ?? null, + input.sensorUnit ?? null, + input.payload || input.ndefUrl ? JSON.stringify({ ...(input.payload ?? {}), ...(input.ndefUrl ? { ndefUrl: input.ndefUrl } : {}) }) : null, + input.raw ?? null, + seenAt + ); + } + + return { + ok: true as const, + technology: input.technology, + identifier, + known: Boolean(tag), + authStatus, + tamperStatus, + item: tag ? { + id: tag.item_id, + name: tag.item_name, + sku: tag.item_sku, + category: tag.item_category + } : null + }; + }, + + recentEvents(limit = 100) { + const bounded = Math.max(1, Math.min(limit, 500)); + return db.prepare(` + SELECT + tag_events.*, + items.name AS item_name, + items.sku AS item_sku, + tags.chip_model, + tags.product_family + FROM tag_events + LEFT JOIN tags ON tags.id = tag_events.tag_id + LEFT JOIN items ON items.id = tags.item_id + ORDER BY tag_events.occurred_at DESC, tag_events.id DESC + LIMIT ? + `).all(bounded); + } + }; +} From 4a5692a2004473efe40ae388feb11600d09ac699 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:05:18 +0200 Subject: [PATCH 08/42] feat: add AES-CMAC primitive for self-hosted verification --- src/security/aes-cmac.ts | 73 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/security/aes-cmac.ts diff --git a/src/security/aes-cmac.ts b/src/security/aes-cmac.ts new file mode 100644 index 0000000..685e4bc --- /dev/null +++ b/src/security/aes-cmac.ts @@ -0,0 +1,73 @@ +import { createCipheriv } from "node:crypto"; + +const BLOCK_SIZE = 16; +const RB = 0x87; + +function encryptBlock(key: Buffer, block: Buffer) { + const cipher = createCipheriv("aes-128-ecb", key, null); + cipher.setAutoPadding(false); + return Buffer.concat([cipher.update(block), cipher.final()]); +} + +function xor(left: Buffer, right: Buffer) { + const out = Buffer.alloc(left.length); + for (let i = 0; i < left.length; i += 1) out[i] = left[i] ^ right[i]; + return out; +} + +function shiftLeft(block: Buffer) { + const out = Buffer.alloc(block.length); + let carry = 0; + for (let i = block.length - 1; i >= 0; i -= 1) { + const value = block[i]; + out[i] = ((value << 1) & 0xff) | carry; + carry = (value & 0x80) !== 0 ? 1 : 0; + } + return { block: out, carry }; +} + +function generateSubkeys(key: Buffer) { + const l = encryptBlock(key, Buffer.alloc(BLOCK_SIZE)); + const k1Shift = shiftLeft(l); + const k1 = k1Shift.block; + if (k1Shift.carry) k1[BLOCK_SIZE - 1] ^= RB; + + const k2Shift = shiftLeft(k1); + const k2 = k2Shift.block; + if (k2Shift.carry) k2[BLOCK_SIZE - 1] ^= RB; + + return { k1, k2 }; +} + +export function aesCmac(key: Buffer, message: Buffer) { + if (key.length !== 16) throw new Error("AES_CMAC_KEY_MUST_BE_16_BYTES"); + + const { k1, k2 } = generateSubkeys(key); + const complete = message.length > 0 && message.length % BLOCK_SIZE === 0; + const blockCount = Math.max(1, Math.ceil(message.length / BLOCK_SIZE)); + const lastStart = (blockCount - 1) * BLOCK_SIZE; + const lastRaw = message.subarray(lastStart); + + let last: Buffer; + if (complete) { + last = xor(lastRaw, k1); + } else { + const padded = Buffer.alloc(BLOCK_SIZE); + lastRaw.copy(padded); + padded[lastRaw.length] = 0x80; + last = xor(padded, k2); + } + + let state = Buffer.alloc(BLOCK_SIZE); + for (let index = 0; index < blockCount - 1; index += 1) { + const block = message.subarray(index * BLOCK_SIZE, (index + 1) * BLOCK_SIZE); + state = encryptBlock(key, xor(state, block)); + } + + return encryptBlock(key, xor(state, last)); +} + +export function nxpTruncatedMac(fullMac: Buffer) { + if (fullMac.length !== 16) throw new Error("NXP_MAC_MUST_BE_16_BYTES"); + return Buffer.from(fullMac.filter((_, index) => index % 2 === 1)); +} From 4c4bb8f91089f9af82033e6e7a6ba941f7247068 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:05:29 +0200 Subject: [PATCH 09/42] feat: verify NTAG 424 DNA SDM MACs self-hosted --- src/security/ntag424-sdm.ts | 100 ++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 src/security/ntag424-sdm.ts diff --git a/src/security/ntag424-sdm.ts b/src/security/ntag424-sdm.ts new file mode 100644 index 0000000..ab577ff --- /dev/null +++ b/src/security/ntag424-sdm.ts @@ -0,0 +1,100 @@ +import { createDecipheriv, timingSafeEqual } from "node:crypto"; +import { aesCmac, nxpTruncatedMac } from "./aes-cmac.js"; + +export type Ntag424SdmConfig = { + sdmFileReadKey: Buffer; + sdmMetaReadKey?: Buffer; + macInput?: Buffer; +}; + +export type Ntag424SdmInput = { + uid?: string; + readCounter?: string; + encryptedPiccData?: string; + mac: string; +}; + +export type Ntag424SdmResult = { + valid: boolean; + uid: string; + readCounter: string; + expectedMac: string; + receivedMac: string; +}; + +function parseHex(value: string, expectedBytes?: number) { + const compact = value.trim().replace(/^0x/i, "").replace(/[^0-9a-f]/gi, ""); + if (!compact || compact.length % 2 !== 0) throw new Error("INVALID_HEX"); + const buffer = Buffer.from(compact, "hex"); + if (expectedBytes !== undefined && buffer.length !== expectedBytes) throw new Error("INVALID_HEX_LENGTH"); + return buffer; +} + +function reverseBytes(value: Buffer) { + return Buffer.from(value).reverse(); +} + +function decryptPiccData(key: Buffer, encrypted: Buffer) { + if (key.length !== 16) throw new Error("SDM_META_READ_KEY_MUST_BE_16_BYTES"); + if (encrypted.length % 16 !== 0) throw new Error("INVALID_ENCRYPTED_PICC_LENGTH"); + + const decipher = createDecipheriv("aes-128-cbc", key, Buffer.alloc(16)); + decipher.setAutoPadding(false); + return Buffer.concat([decipher.update(encrypted), decipher.final()]); +} + +function extractPiccData(data: Buffer) { + if (data.length < 2) throw new Error("INVALID_PICC_DATA"); + const tag = data[0]; + const uidMirrored = (tag & 0x80) !== 0; + const counterMirrored = (tag & 0x40) !== 0; + const uidLength = tag & 0x0f; + let offset = 1; + + if (!uidMirrored || uidLength === 0 || data.length < offset + uidLength) throw new Error("PICC_UID_NOT_AVAILABLE"); + const uid = data.subarray(offset, offset + uidLength); + offset += uidLength; + + if (!counterMirrored || data.length < offset + 3) throw new Error("PICC_COUNTER_NOT_AVAILABLE"); + const counterLe = data.subarray(offset, offset + 3); + + return { uid, counterLe }; +} + +function sessionVectorMac(uid: Buffer, counterLe: Buffer) { + const prefix = Buffer.from("3CC300010080", "hex"); + const raw = Buffer.concat([prefix, uid, counterLe]); + const paddingLength = (16 - (raw.length % 16)) % 16; + return Buffer.concat([raw, Buffer.alloc(paddingLength)]); +} + +export function verifyNtag424Sdm(config: Ntag424SdmConfig, input: Ntag424SdmInput): Ntag424SdmResult { + if (config.sdmFileReadKey.length !== 16) throw new Error("SDM_FILE_READ_KEY_MUST_BE_16_BYTES"); + + let uid: Buffer; + let counterLe: Buffer; + + if (input.encryptedPiccData) { + if (!config.sdmMetaReadKey) throw new Error("SDM_META_READ_KEY_REQUIRED"); + const plain = decryptPiccData(config.sdmMetaReadKey, parseHex(input.encryptedPiccData)); + ({ uid, counterLe } = extractPiccData(plain)); + } else { + if (!input.uid || !input.readCounter) throw new Error("UID_AND_COUNTER_REQUIRED"); + uid = parseHex(input.uid); + const counterMirrored = parseHex(input.readCounter, 3); + counterLe = reverseBytes(counterMirrored); + } + + const sessionMacKey = aesCmac(config.sdmFileReadKey, sessionVectorMac(uid, counterLe)); + const expected = nxpTruncatedMac(aesCmac(sessionMacKey, config.macInput ?? Buffer.alloc(0))); + const received = parseHex(input.mac, 8); + const valid = timingSafeEqual(expected, received); + + return { + valid, + uid: uid.toString("hex").toUpperCase(), + readCounter: reverseBytes(counterLe).toString("hex").toUpperCase(), + expectedMac: expected.toString("hex").toUpperCase(), + receivedMac: received.toString("hex").toUpperCase() + }; +} From 8b66399af463749f028965452d99c094bc8675a1 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:06:00 +0200 Subject: [PATCH 10/42] feat: verify NTAG 22x DNA SUN messages --- src/security/ntag22x-sun.ts | 83 +++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/security/ntag22x-sun.ts diff --git a/src/security/ntag22x-sun.ts b/src/security/ntag22x-sun.ts new file mode 100644 index 0000000..8ae8390 --- /dev/null +++ b/src/security/ntag22x-sun.ts @@ -0,0 +1,83 @@ +import { timingSafeEqual } from "node:crypto"; +import { aesCmac, nxpTruncatedMac } from "./aes-cmac.js"; + +export type Ntag22xSunInput = { + uid: string; + counter: string; + mac: string; + tamperMirror?: string; +}; + +export type Ntag22xSunResult = { + valid: boolean; + uid: string; + counter: string; + expectedMac: string; + receivedMac: string; + storedTamperStatus?: "closed" | "open" | "invalid" | "hidden"; + currentTamperStatus?: "closed" | "open" | "invalid" | "hidden"; + tamperData?: string; +}; + +function parseHex(value: string, expectedBytes?: number) { + const compact = value.trim().replace(/^0x/i, "").replace(/[^0-9a-f]/gi, "").toUpperCase(); + if (!compact || compact.length % 2 !== 0) throw new Error("INVALID_HEX"); + const data = Buffer.from(compact, "hex"); + if (expectedBytes !== undefined && data.length !== expectedBytes) throw new Error("INVALID_HEX_LENGTH"); + return data; +} + +function statusNibble(char: string) { + switch (char.toUpperCase()) { + case "C": return 0x3; + case "O": return 0xf; + case "I": return 0x9; + case "0": return 0x0; + default: throw new Error("INVALID_TAMPER_STATUS"); + } +} + +function tamperStatus(nibble: number): "closed" | "open" | "invalid" | "hidden" { + if (nibble === 0x3) return "closed"; + if (nibble === 0xf) return "open"; + if (nibble === 0x9) return "invalid"; + return "hidden"; +} + +export function decodeNtag22xTamperMirror(value: string) { + const compact = value.trim().toUpperCase(); + if (compact.length !== 10) throw new Error("TAMPER_MIRROR_MUST_BE_10_CHARACTERS"); + if (!/^[COI0][COI0][0-9A-F]{8}$/.test(compact)) throw new Error("INVALID_TAMPER_MIRROR"); + + const firstByte = (statusNibble(compact[0]) << 4) | statusNibble(compact[1]); + const tail = Buffer.from(compact.slice(2), "hex"); + const data = Buffer.concat([Buffer.from([firstByte]), tail]); + + return { + data, + storedTamperStatus: tamperStatus(firstByte >> 4), + currentTamperStatus: tamperStatus(firstByte & 0x0f) + }; +} + +export function verifyNtag22xSun(key: Buffer, input: Ntag22xSunInput): Ntag22xSunResult { + if (key.length !== 16) throw new Error("SUN_KEY_MUST_BE_16_BYTES"); + + const uid = parseHex(input.uid, 7); + const counter = parseHex(input.counter, 3); + const tamper = input.tamperMirror ? decodeNtag22xTamperMirror(input.tamperMirror) : undefined; + const dynamicSunData = Buffer.concat([uid, counter, tamper?.data ?? Buffer.alloc(0)]); + const expected = nxpTruncatedMac(aesCmac(key, dynamicSunData)); + const received = parseHex(input.mac, 8); + + return { + valid: timingSafeEqual(expected, received), + uid: uid.toString("hex").toUpperCase(), + counter: counter.toString("hex").toUpperCase(), + expectedMac: expected.toString("hex").toUpperCase(), + receivedMac: received.toString("hex").toUpperCase(), + storedTamperStatus: tamper?.storedTamperStatus, + currentTamperStatus: tamper?.currentTamperStatus, + tamperData: tamper?.data.toString("hex").toUpperCase() + }; +} From a5058885373852113c476cbbc4172aadc0f1fd58 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:06:20 +0200 Subject: [PATCH 11/42] feat: model replay authentication state --- src/modules/tags/tag.types.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/modules/tags/tag.types.ts b/src/modules/tags/tag.types.ts index 8887c59..8648d58 100644 --- a/src/modules/tags/tag.types.ts +++ b/src/modules/tags/tag.types.ts @@ -1,7 +1,7 @@ export type TagTechnology = "uhf-rain" | "nfc"; export type TagStatus = "active" | "inactive" | "ignored" | "external"; -export type AuthStatus = "not-requested" | "pending" | "verified" | "failed" | "unsupported" | "error"; -export type TamperStatus = "unknown" | "sealed" | "open" | "closed" | "opened-once"; +export type AuthStatus = "not-requested" | "pending" | "verified" | "failed" | "replay" | "unsupported" | "error"; +export type TamperStatus = "unknown" | "sealed" | "open" | "closed" | "opened-once" | "invalid"; export type TagReadSource = "browser-hid" | "browser-webnfc" | "reader-bridge" | "mock"; export type TagCapabilities = { @@ -37,6 +37,7 @@ export type TagObservationInput = { ndefUrl?: string; payload?: Record; authStatus?: AuthStatus; + authCounter?: number; tamperStatus?: TamperStatus; sensorValue?: number; sensorUnit?: string; From a85b8d306c49d6724ab6dd5f6faaa07c0b93ee88 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:06:30 +0200 Subject: [PATCH 12/42] feat: persist authentication counters for replay detection --- src/db/schema.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/db/schema.ts b/src/db/schema.ts index 75cc621..6f70e17 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -46,6 +46,7 @@ CREATE TABLE IF NOT EXISTS tags ( item_id INTEGER, status TEXT NOT NULL DEFAULT 'active', last_auth_status TEXT NOT NULL DEFAULT 'not-requested', + last_auth_counter INTEGER, last_tamper_status TEXT NOT NULL DEFAULT 'unknown', last_seen_at TEXT, registered_at TEXT DEFAULT CURRENT_TIMESTAMP, @@ -110,6 +111,7 @@ CREATE TABLE IF NOT EXISTS tag_events ( identifier TEXT NOT NULL, source TEXT NOT NULL, auth_status TEXT NOT NULL DEFAULT 'not-requested', + auth_counter INTEGER, tamper_status TEXT NOT NULL DEFAULT 'unknown', sensor_value REAL, sensor_unit TEXT, From 11dfb2aecd8960a2bc581e3fc42c7cde1bda00d3 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:06:47 +0200 Subject: [PATCH 13/42] feat: persist verification counters in observations --- src/modules/observations/observations.service.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/modules/observations/observations.service.ts b/src/modules/observations/observations.service.ts index 58bcbe1..a29ac3b 100644 --- a/src/modules/observations/observations.service.ts +++ b/src/modules/observations/observations.service.ts @@ -53,6 +53,7 @@ export function createObservationsService(db: AppDb) { uid = COALESCE(?, uid), last_seen_at = ?, last_auth_status = CASE WHEN ? = 'not-requested' THEN last_auth_status ELSE ? END, + last_auth_counter = CASE WHEN ? IS NULL THEN last_auth_counter ELSE ? END, last_tamper_status = CASE WHEN ? = 'unknown' THEN last_tamper_status ELSE ? END, updated_at = ? WHERE id = ? @@ -63,6 +64,8 @@ export function createObservationsService(db: AppDb) { seenAt, authStatus, authStatus, + input.authCounter ?? null, + input.authCounter ?? null, tamperStatus, tamperStatus, seenAt, @@ -117,9 +120,9 @@ export function createObservationsService(db: AppDb) { db.prepare(` INSERT INTO tag_events ( event_kind, session_id, station_id, tag_id, technology, identifier, - source, auth_status, tamper_status, sensor_value, sensor_unit, + source, auth_status, auth_counter, tamper_status, sensor_value, sensor_unit, payload_json, raw, occurred_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( eventKind, session?.id ?? null, @@ -129,6 +132,7 @@ export function createObservationsService(db: AppDb) { identifier, input.source, authStatus, + input.authCounter ?? null, tamperStatus, input.sensorValue ?? null, input.sensorUnit ?? null, @@ -144,6 +148,7 @@ export function createObservationsService(db: AppDb) { identifier, known: Boolean(tag), authStatus, + authCounter: input.authCounter ?? null, tamperStatus, item: tag ? { id: tag.item_id, From 7d36d32e253467582ebf1e6bdf8ae5aa1f9cfbcc Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:06:53 +0200 Subject: [PATCH 14/42] feat: load verification keys from self-hosted secret storage --- src/security/key-store.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/security/key-store.ts diff --git a/src/security/key-store.ts b/src/security/key-store.ts new file mode 100644 index 0000000..c7b517c --- /dev/null +++ b/src/security/key-store.ts @@ -0,0 +1,22 @@ +import fs from "node:fs"; +import path from "node:path"; + +export function resolveKeysDir() { + return path.resolve(process.env.STORAGE_TAGS_KEYS_DIR ?? "/run/secrets/storage-tags"); +} + +export function loadAes128Key(keyRef: string) { + if (!keyRef.trim()) throw new Error("KEY_REF_REQUIRED"); + + const base = resolveKeysDir(); + const filePath = path.resolve(base, keyRef); + const relative = path.relative(base, filePath); + if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("INVALID_KEY_REF"); + + const data = fs.readFileSync(filePath); + if (data.length === 16) return data; + + const text = data.toString("utf-8").trim().replace(/^0x/i, "").replace(/\s+/g, ""); + if (!/^[0-9a-fA-F]{32}$/.test(text)) throw new Error("AES_KEY_FILE_MUST_CONTAIN_16_RAW_BYTES_OR_32_HEX_CHARS"); + return Buffer.from(text, "hex"); +} From f894290f98d43de237d54cf112ccf27e19cc6ef2 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:07:12 +0200 Subject: [PATCH 15/42] feat: track current and permanent tamper state --- src/modules/tags/tag.types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/tags/tag.types.ts b/src/modules/tags/tag.types.ts index 8648d58..aca4948 100644 --- a/src/modules/tags/tag.types.ts +++ b/src/modules/tags/tag.types.ts @@ -39,6 +39,7 @@ export type TagObservationInput = { authStatus?: AuthStatus; authCounter?: number; tamperStatus?: TamperStatus; + permanentTamperStatus?: TamperStatus; sensorValue?: number; sensorUnit?: string; seenAt?: string; From 71cd46a77e73f9b950c5b1e8fd2320b57d68f1df Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:07:24 +0200 Subject: [PATCH 16/42] feat: persist permanent tamper state --- src/db/schema.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/db/schema.ts b/src/db/schema.ts index 6f70e17..751dfe0 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS tags ( last_auth_status TEXT NOT NULL DEFAULT 'not-requested', last_auth_counter INTEGER, last_tamper_status TEXT NOT NULL DEFAULT 'unknown', + permanent_tamper_status TEXT NOT NULL DEFAULT 'unknown', last_seen_at TEXT, registered_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP, @@ -95,6 +96,7 @@ CREATE TABLE IF NOT EXISTS inventory_observations ( known_item_id INTEGER, auth_status TEXT NOT NULL DEFAULT 'not-requested', tamper_status TEXT NOT NULL DEFAULT 'unknown', + permanent_tamper_status TEXT NOT NULL DEFAULT 'unknown', FOREIGN KEY (session_id) REFERENCES inventory_sessions(id), FOREIGN KEY (tag_id) REFERENCES tags(id), FOREIGN KEY (known_item_id) REFERENCES items(id), @@ -113,6 +115,7 @@ CREATE TABLE IF NOT EXISTS tag_events ( auth_status TEXT NOT NULL DEFAULT 'not-requested', auth_counter INTEGER, tamper_status TEXT NOT NULL DEFAULT 'unknown', + permanent_tamper_status TEXT NOT NULL DEFAULT 'unknown', sensor_value REAL, sensor_unit TEXT, payload_json TEXT, @@ -128,7 +131,7 @@ CREATE INDEX IF NOT EXISTS idx_items_sku ON items(sku); CREATE INDEX IF NOT EXISTS idx_tags_item ON tags(item_id); CREATE INDEX IF NOT EXISTS idx_tags_identifier ON tags(technology, identifier); CREATE INDEX IF NOT EXISTS idx_tags_auth ON tags(last_auth_status); -CREATE INDEX IF NOT EXISTS idx_tags_tamper ON tags(last_tamper_status); +CREATE INDEX IF NOT EXISTS idx_tags_tamper ON tags(last_tamper_status, permanent_tamper_status); CREATE INDEX IF NOT EXISTS idx_inventory_observations_session ON inventory_observations(session_id); CREATE INDEX IF NOT EXISTS idx_tag_events_tag ON tag_events(tag_id, occurred_at DESC); CREATE INDEX IF NOT EXISTS idx_tag_events_session ON tag_events(session_id, occurred_at DESC); From e47f6aecbb6fb09291b125bd57436c88a3936470 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:07:41 +0200 Subject: [PATCH 17/42] feat: capture current and permanent tamper evidence --- .../observations/observations.service.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/modules/observations/observations.service.ts b/src/modules/observations/observations.service.ts index a29ac3b..d3ce780 100644 --- a/src/modules/observations/observations.service.ts +++ b/src/modules/observations/observations.service.ts @@ -43,6 +43,7 @@ export function createObservationsService(db: AppDb) { const tag = findTag(input.technology, identifier); const authStatus: AuthStatus = input.authStatus ?? "not-requested"; const tamperStatus: TamperStatus = input.tamperStatus ?? "unknown"; + const permanentTamperStatus: TamperStatus = input.permanentTamperStatus ?? "unknown"; if (tag) { db.prepare(` @@ -55,6 +56,7 @@ export function createObservationsService(db: AppDb) { last_auth_status = CASE WHEN ? = 'not-requested' THEN last_auth_status ELSE ? END, last_auth_counter = CASE WHEN ? IS NULL THEN last_auth_counter ELSE ? END, last_tamper_status = CASE WHEN ? = 'unknown' THEN last_tamper_status ELSE ? END, + permanent_tamper_status = CASE WHEN ? = 'unknown' THEN permanent_tamper_status ELSE ? END, updated_at = ? WHERE id = ? `).run( @@ -68,6 +70,8 @@ export function createObservationsService(db: AppDb) { input.authCounter ?? null, tamperStatus, tamperStatus, + permanentTamperStatus, + permanentTamperStatus, seenAt, tag.id ); @@ -77,8 +81,9 @@ export function createObservationsService(db: AppDb) { db.prepare(` INSERT INTO inventory_observations ( session_id, tag_id, technology, identifier, source, rssi, antenna, - read_count, first_seen_at, last_seen_at, known_item_id, auth_status, tamper_status - ) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?) + read_count, first_seen_at, last_seen_at, known_item_id, auth_status, + tamper_status, permanent_tamper_status + ) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?) ON CONFLICT(session_id, technology, identifier) DO UPDATE SET tag_id = COALESCE(excluded.tag_id, inventory_observations.tag_id), read_count = inventory_observations.read_count + 1, @@ -86,6 +91,7 @@ export function createObservationsService(db: AppDb) { known_item_id = COALESCE(excluded.known_item_id, inventory_observations.known_item_id), auth_status = CASE WHEN excluded.auth_status = 'not-requested' THEN inventory_observations.auth_status ELSE excluded.auth_status END, tamper_status = CASE WHEN excluded.tamper_status = 'unknown' THEN inventory_observations.tamper_status ELSE excluded.tamper_status END, + permanent_tamper_status = CASE WHEN excluded.permanent_tamper_status = 'unknown' THEN inventory_observations.permanent_tamper_status ELSE excluded.permanent_tamper_status END, rssi = COALESCE(excluded.rssi, inventory_observations.rssi), antenna = COALESCE(excluded.antenna, inventory_observations.antenna) `).run( @@ -100,7 +106,8 @@ export function createObservationsService(db: AppDb) { seenAt, tag?.item_id ?? null, authStatus, - tamperStatus + tamperStatus, + permanentTamperStatus ); } @@ -108,21 +115,22 @@ export function createObservationsService(db: AppDb) { input.technology === "nfc" || authStatus !== "not-requested" || tamperStatus !== "unknown" || + permanentTamperStatus !== "unknown" || input.sensorValue !== undefined; if (shouldRecordEvent) { const eventKind = authStatus !== "not-requested" ? "verification" - : tamperStatus !== "unknown" || input.sensorValue !== undefined + : tamperStatus !== "unknown" || permanentTamperStatus !== "unknown" || input.sensorValue !== undefined ? "status" : "scan"; db.prepare(` INSERT INTO tag_events ( event_kind, session_id, station_id, tag_id, technology, identifier, - source, auth_status, auth_counter, tamper_status, sensor_value, sensor_unit, - payload_json, raw, occurred_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + source, auth_status, auth_counter, tamper_status, permanent_tamper_status, + sensor_value, sensor_unit, payload_json, raw, occurred_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( eventKind, session?.id ?? null, @@ -134,6 +142,7 @@ export function createObservationsService(db: AppDb) { authStatus, input.authCounter ?? null, tamperStatus, + permanentTamperStatus, input.sensorValue ?? null, input.sensorUnit ?? null, input.payload || input.ndefUrl ? JSON.stringify({ ...(input.payload ?? {}), ...(input.ndefUrl ? { ndefUrl: input.ndefUrl } : {}) }) : null, @@ -150,6 +159,7 @@ export function createObservationsService(db: AppDb) { authStatus, authCounter: input.authCounter ?? null, tamperStatus, + permanentTamperStatus, item: tag ? { id: tag.item_id, name: tag.item_name, From 4399c46a5566c6093c76fc321f328da69b7a73e4 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:08:11 +0200 Subject: [PATCH 18/42] feat: add self-hosted tag verification profiles --- src/modules/security/security.service.ts | 255 +++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 src/modules/security/security.service.ts diff --git a/src/modules/security/security.service.ts b/src/modules/security/security.service.ts new file mode 100644 index 0000000..b978a6e --- /dev/null +++ b/src/modules/security/security.service.ts @@ -0,0 +1,255 @@ +import type { AppDb } from "../../db/connection.js"; +import { loadAes128Key } from "../../security/key-store.js"; +import { verifyNtag22xSun } from "../../security/ntag22x-sun.js"; +import { verifyNtag424Sdm } from "../../security/ntag424-sdm.js"; +import { normalizeTagIdentifier } from "../../utils/normalize-tag.js"; +import { nowIso } from "../../utils/time.js"; +import { createObservationsService } from "../observations/observations.service.js"; +import type { AuthStatus, TagReadSource, TagTechnology, TamperStatus } from "../tags/tag.types.js"; + +export type SecurityVerifier = "ntag22x-sun" | "ntag424-sdm" | "reader-bridge"; + +export type SecurityProfileInput = { + profileKey: string; + name: string; + technology: TagTechnology; + verifier: SecurityVerifier; + keyRef?: string; + config?: Record; + status?: "active" | "inactive"; +}; + +export type VerifyInput = { + technology: TagTechnology; + identifier: string; + profileKey?: string; + url?: string; + source?: TagReadSource; + stationKey?: string; + sessionKey?: string; + evidence?: Record; +}; + +type StoredProfile = { + id: number; + profile_key: string; + name: string; + technology: TagTechnology; + verifier: SecurityVerifier; + key_ref: string | null; + config_json: string | null; + status: string; +}; + +function configOf(profile: StoredProfile) { + if (!profile.config_json) return {} as Record; + try { + return JSON.parse(profile.config_json) as Record; + } catch { + throw new Error("INVALID_SECURITY_PROFILE_CONFIG"); + } +} + +function queryValue(url: URL, config: Record, configKey: string, fallback: string) { + const name = typeof config[configKey] === "string" ? config[configKey] : fallback; + return url.searchParams.get(name) ?? undefined; +} + +function tamperFromValue(value: string | undefined, map: Record | undefined): TamperStatus { + if (!value || !map) return "unknown"; + const mapped = map[value]; + if (["unknown", "sealed", "open", "closed", "opened-once", "invalid"].includes(String(mapped))) return mapped as TamperStatus; + return "unknown"; +} + +function parseCounter(counterHex: string) { + const value = Number.parseInt(counterHex, 16); + if (!Number.isSafeInteger(value) || value < 0) throw new Error("INVALID_AUTH_COUNTER"); + return value; +} + +export function createSecurityService(db: AppDb) { + const observations = createObservationsService(db); + + function getProfile(profileKey: string) { + return db.prepare(` + SELECT id, profile_key, name, technology, verifier, key_ref, config_json, status + FROM tag_security_profiles + WHERE profile_key = ? + `).get(profileKey) as StoredProfile | undefined; + } + + return { + listProfiles() { + return db.prepare(` + SELECT profile_key, name, technology, verifier, key_ref, config_json, status, created_at, updated_at + FROM tag_security_profiles + ORDER BY name, profile_key + `).all(); + }, + + upsertProfile(input: SecurityProfileInput) { + const timestamp = nowIso(); + db.prepare(` + INSERT INTO tag_security_profiles ( + profile_key, name, technology, verifier, key_ref, config_json, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(profile_key) DO UPDATE SET + name = excluded.name, + technology = excluded.technology, + verifier = excluded.verifier, + key_ref = excluded.key_ref, + config_json = excluded.config_json, + status = excluded.status, + updated_at = excluded.updated_at + `).run( + input.profileKey, + input.name, + input.technology, + input.verifier, + input.keyRef ?? null, + input.config ? JSON.stringify(input.config) : null, + input.status ?? "active", + timestamp, + timestamp + ); + return getProfile(input.profileKey); + }, + + verify(input: VerifyInput) { + const identifier = normalizeTagIdentifier(input.technology, input.identifier); + if (!identifier) return { ok: false as const, error: "INVALID_IDENTIFIER" as const }; + + const tag = db.prepare(` + SELECT tags.*, tag_security_profiles.profile_key + FROM tags + LEFT JOIN tag_security_profiles ON tag_security_profiles.id = tags.security_profile_id + WHERE tags.technology = ? AND tags.identifier = ? AND tags.status = 'active' + `).get(input.technology, identifier) as any; + if (!tag) return { ok: false as const, error: "TAG_NOT_FOUND" as const }; + + const profileKey = input.profileKey ?? tag.profile_key; + if (!profileKey) { + const observation = observations.process({ + technology: input.technology, + identifier, + source: input.source ?? "browser-webnfc", + stationKey: input.stationKey, + sessionKey: input.sessionKey, + authStatus: "unsupported", + ndefUrl: input.url, + payload: { reason: "SECURITY_PROFILE_REQUIRED" } + }); + return { ok: true as const, verified: false, authStatus: "unsupported" as AuthStatus, observation }; + } + + const profile = getProfile(profileKey); + if (!profile || profile.status !== "active") return { ok: false as const, error: "SECURITY_PROFILE_NOT_FOUND" as const }; + if (profile.technology !== input.technology) return { ok: false as const, error: "SECURITY_PROFILE_TECHNOLOGY_MISMATCH" as const }; + + if (profile.verifier === "reader-bridge") { + const observation = observations.process({ + technology: input.technology, + identifier, + source: input.source ?? "reader-bridge", + stationKey: input.stationKey, + sessionKey: input.sessionKey, + authStatus: "unsupported", + payload: { reason: "AUTHENTICATED_READER_BRIDGE_REQUIRED", profileKey } + }); + return { ok: true as const, verified: false, authStatus: "unsupported" as AuthStatus, observation }; + } + + if (!profile.key_ref) return { ok: false as const, error: "SECURITY_KEY_REF_REQUIRED" as const }; + if (!input.url) return { ok: false as const, error: "NDEF_URL_REQUIRED" as const }; + + const url = new URL(input.url); + const config = configOf(profile); + const key = loadAes128Key(profile.key_ref); + let cryptoValid = false; + let counterHex = ""; + let uid = ""; + let currentTamper: TamperStatus = "unknown"; + let permanentTamper: TamperStatus = "unknown"; + let details: Record = {}; + + if (profile.verifier === "ntag22x-sun") { + const uidValue = queryValue(url, config, "uidParam", "uid"); + const counterValue = queryValue(url, config, "counterParam", "ctr"); + const macValue = queryValue(url, config, "macParam", "cmac") ?? queryValue(url, config, "macParam", "c"); + const tamperValue = queryValue(url, config, "tamperParam", "tt"); + if (!uidValue || !counterValue || !macValue) return { ok: false as const, error: "SUN_PARAMETERS_REQUIRED" as const }; + + const result = verifyNtag22xSun(key, { uid: uidValue, counter: counterValue, mac: macValue, tamperMirror: tamperValue }); + cryptoValid = result.valid; + counterHex = result.counter; + uid = result.uid; + currentTamper = result.currentTamperStatus === "open" ? "open" : result.currentTamperStatus === "closed" ? "closed" : result.currentTamperStatus === "invalid" ? "invalid" : "unknown"; + permanentTamper = result.storedTamperStatus === "open" ? "opened-once" : result.storedTamperStatus === "closed" ? "closed" : result.storedTamperStatus === "invalid" ? "invalid" : "unknown"; + details = { expectedMac: result.expectedMac, receivedMac: result.receivedMac, tamperData: result.tamperData }; + } else { + const uidValue = queryValue(url, config, "uidParam", "uid"); + const counterValue = queryValue(url, config, "counterParam", "ctr"); + const encryptedPiccData = queryValue(url, config, "encryptedPiccParam", "e"); + const macValue = queryValue(url, config, "macParam", "c"); + if (!macValue || (!encryptedPiccData && (!uidValue || !counterValue))) return { ok: false as const, error: "SDM_PARAMETERS_REQUIRED" as const }; + + const metaKeyRef = typeof config.metaKeyRef === "string" ? config.metaKeyRef : undefined; + const result = verifyNtag424Sdm({ + sdmFileReadKey: key, + sdmMetaReadKey: encryptedPiccData && metaKeyRef ? loadAes128Key(metaKeyRef) : undefined, + macInput: typeof config.macInputHex === "string" ? Buffer.from(config.macInputHex, "hex") : undefined + }, { + uid: uidValue, + readCounter: counterValue, + encryptedPiccData, + mac: macValue + }); + cryptoValid = result.valid; + counterHex = result.readCounter; + uid = result.uid; + currentTamper = tamperFromValue(queryValue(url, config, "currentTamperParam", "tt"), config.tamperValueMap); + permanentTamper = tamperFromValue(queryValue(url, config, "permanentTamperParam", "ttp"), config.permanentTamperValueMap ?? config.tamperValueMap); + details = { expectedMac: result.expectedMac, receivedMac: result.receivedMac }; + } + + const authCounter = parseCounter(counterHex); + const replay = cryptoValid && tag.last_auth_counter !== null && tag.last_auth_counter !== undefined && authCounter <= Number(tag.last_auth_counter); + const authStatus: AuthStatus = !cryptoValid ? "failed" : replay ? "replay" : "verified"; + + const observation = observations.process({ + technology: input.technology, + identifier, + uid, + source: input.source ?? "browser-webnfc", + stationKey: input.stationKey, + sessionKey: input.sessionKey, + authStatus, + authCounter: cryptoValid && !replay ? authCounter : undefined, + tamperStatus: currentTamper, + permanentTamperStatus: permanentTamper, + ndefUrl: input.url, + payload: { + profileKey, + verifier: profile.verifier, + counter: counterHex, + replay, + ...details + } + }); + + return { + ok: true as const, + verified: authStatus === "verified", + authStatus, + replay, + counter: counterHex, + uid, + tamperStatus: currentTamper, + permanentTamperStatus: permanentTamper, + profileKey, + observation + }; + } + }; +} From fb72083a56ae3d3ecbcafce7539501c6b6d48bba Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:08:28 +0200 Subject: [PATCH 19/42] refactor: harden additive schema upgrades --- src/db/connection.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/db/connection.ts b/src/db/connection.ts index 2d3d99c..c1d5785 100644 --- a/src/db/connection.ts +++ b/src/db/connection.ts @@ -10,6 +10,12 @@ export function createDb(databaseUrl = config.databaseUrl) { db.exec("PRAGMA journal_mode = WAL"); db.exec("PRAGMA foreign_keys = ON"); db.exec(schemaSql); + ensureColumn(db, "items", "status", "TEXT DEFAULT 'active'"); + ensureColumn(db, "tags", "last_auth_counter", "INTEGER"); + ensureColumn(db, "tags", "permanent_tamper_status", "TEXT NOT NULL DEFAULT 'unknown'"); + ensureColumn(db, "inventory_observations", "permanent_tamper_status", "TEXT NOT NULL DEFAULT 'unknown'"); + ensureColumn(db, "tag_events", "auth_counter", "INTEGER"); + ensureColumn(db, "tag_events", "permanent_tamper_status", "TEXT NOT NULL DEFAULT 'unknown'"); migrateLegacyRfidData(db); return db; } @@ -21,6 +27,12 @@ function tableExists(db: DatabaseSync, table: string) { return Boolean(row); } +function ensureColumn(db: DatabaseSync, table: string, column: string, definition: string) { + if (!tableExists(db, table)) return; + const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; + if (!columns.some((entry) => entry.name === column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); +} + function migrateLegacyRfidData(db: DatabaseSync) { if (!tableExists(db, "rfid_tags")) return; @@ -70,7 +82,8 @@ function migrateLegacyRfidData(db: DatabaseSync) { last_seen_at, known_item_id, auth_status, - tamper_status + tamper_status, + permanent_tamper_status ) SELECT legacy.session_id, @@ -85,6 +98,7 @@ function migrateLegacyRfidData(db: DatabaseSync) { COALESCE(legacy.last_seen_at, legacy.first_seen_at, CURRENT_TIMESTAMP), legacy.known_item_id, 'not-requested', + 'unknown', 'unknown' FROM inventory_reads legacy LEFT JOIN tags From 75f56d0ba717eb0165a7ca47a8442ad98009dfea Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:08:50 +0200 Subject: [PATCH 20/42] refactor: manage generic tags from items service --- src/modules/items/items.service.ts | 52 ++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/src/modules/items/items.service.ts b/src/modules/items/items.service.ts index 0a74404..91530b4 100644 --- a/src/modules/items/items.service.ts +++ b/src/modules/items/items.service.ts @@ -18,18 +18,43 @@ export function createItemsService(db: AppDb) { if (search) { const term = `%${search}%`; return db.prepare(` - SELECT * FROM items - WHERE (name LIKE ? OR sku LIKE ? OR category LIKE ?) - AND status != 'archived' - ORDER BY updated_at DESC, id DESC + SELECT + items.*, + COUNT(tags.id) AS tag_count, + SUM(CASE WHEN tags.technology = 'nfc' AND tags.status = 'active' THEN 1 ELSE 0 END) AS nfc_tag_count, + SUM(CASE WHEN tags.technology = 'uhf-rain' AND tags.status = 'active' THEN 1 ELSE 0 END) AS uhf_tag_count + FROM items + LEFT JOIN tags ON tags.item_id = items.id + WHERE (items.name LIKE ? OR items.sku LIKE ? OR items.category LIKE ?) + AND items.status != 'archived' + GROUP BY items.id + ORDER BY items.updated_at DESC, items.id DESC `).all(term, term, term); } - return db.prepare("SELECT * FROM items WHERE status != 'archived' ORDER BY updated_at DESC, id DESC").all(); + return db.prepare(` + SELECT + items.*, + COUNT(tags.id) AS tag_count, + SUM(CASE WHEN tags.technology = 'nfc' AND tags.status = 'active' THEN 1 ELSE 0 END) AS nfc_tag_count, + SUM(CASE WHEN tags.technology = 'uhf-rain' AND tags.status = 'active' THEN 1 ELSE 0 END) AS uhf_tag_count + FROM items + LEFT JOIN tags ON tags.item_id = items.id + WHERE items.status != 'archived' + GROUP BY items.id + ORDER BY items.updated_at DESC, items.id DESC + `).all(); }, get(id: number) { - return db.prepare("SELECT * FROM items WHERE id = ?").get(id); + const item = db.prepare("SELECT * FROM items WHERE id = ?").get(id) as any; + if (!item) return null; + const tags = db.prepare(` + SELECT * FROM tags + WHERE item_id = ? + ORDER BY status = 'active' DESC, technology, registered_at DESC + `).all(id); + return { ...item, tags }; }, create(input: CreateItemInput) { @@ -52,7 +77,7 @@ export function createItemsService(db: AppDb) { }, update(id: number, input: UpdateItemInput) { - const existing = this.get(id) as { + const existing = db.prepare("SELECT * FROM items WHERE id = ?").get(id) as { sku: string | null; name: string; description: string | null; @@ -64,14 +89,7 @@ export function createItemsService(db: AppDb) { db.prepare(` UPDATE items - SET - sku = ?, - name = ?, - description = ?, - category = ?, - photo_url = ?, - notes = ?, - updated_at = ? + SET sku = ?, name = ?, description = ?, category = ?, photo_url = ?, notes = ?, updated_at = ? WHERE id = ? `).run( input.sku ?? existing.sku ?? null, @@ -88,10 +106,10 @@ export function createItemsService(db: AppDb) { }, remove(id: number) { - const existing = this.get(id); + const existing = db.prepare("SELECT id FROM items WHERE id = ?").get(id); if (!existing) return false; - db.prepare("UPDATE rfid_tags SET status = 'inactive' WHERE item_id = ?").run(id); + db.prepare("UPDATE tags SET status = 'inactive', updated_at = ? WHERE item_id = ?").run(nowIso(), id); db.prepare("UPDATE items SET status = 'archived', updated_at = ? WHERE id = ?").run(nowIso(), id); return true; } From 0bc4adc725f114c3c2a9687fe0f8e4580a38c2e3 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:09:01 +0200 Subject: [PATCH 21/42] refactor: make inventory sessions tag-technology aware --- src/modules/sessions/sessions.service.ts | 52 +++++++++++++++++++----- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/src/modules/sessions/sessions.service.ts b/src/modules/sessions/sessions.service.ts index 6b30704..bc422e6 100644 --- a/src/modules/sessions/sessions.service.ts +++ b/src/modules/sessions/sessions.service.ts @@ -41,32 +41,62 @@ export function createSessionsService(db: AppDb) { return db.prepare(` SELECT inventory_sessions.*, - COUNT(inventory_reads.id) AS unique_reads, - COALESCE(SUM(inventory_reads.read_count), 0) AS raw_reads + stations.station_key, + stations.name AS station_name, + COUNT(inventory_observations.id) AS unique_reads, + COALESCE(SUM(inventory_observations.read_count), 0) AS raw_reads, + SUM(CASE WHEN inventory_observations.technology = 'nfc' THEN 1 ELSE 0 END) AS nfc_reads, + SUM(CASE WHEN inventory_observations.technology = 'uhf-rain' THEN 1 ELSE 0 END) AS uhf_reads, + SUM(CASE WHEN inventory_observations.auth_status IN ('failed', 'replay', 'error') THEN 1 ELSE 0 END) AS auth_alerts, + SUM(CASE WHEN inventory_observations.tamper_status IN ('open', 'invalid') OR inventory_observations.permanent_tamper_status = 'opened-once' THEN 1 ELSE 0 END) AS tamper_alerts FROM inventory_sessions - LEFT JOIN inventory_reads ON inventory_reads.session_id = inventory_sessions.id + LEFT JOIN stations ON stations.id = inventory_sessions.station_id + LEFT JOIN inventory_observations ON inventory_observations.session_id = inventory_sessions.id GROUP BY inventory_sessions.id ORDER BY inventory_sessions.started_at DESC `).all(); }, get(sessionKey: string) { - const session = db.prepare("SELECT * FROM inventory_sessions WHERE session_key = ?").get(sessionKey) as { id: number } | undefined; + const session = db.prepare(` + SELECT inventory_sessions.*, stations.station_key, stations.name AS station_name + FROM inventory_sessions + LEFT JOIN stations ON stations.id = inventory_sessions.station_id + WHERE inventory_sessions.session_key = ? + `).get(sessionKey) as { id: number } | undefined; if (!session) return null; const reads = db.prepare(` SELECT - inventory_reads.*, + inventory_observations.*, + items.name AS item_name, + items.sku AS item_sku, + items.category AS item_category, + tags.chip_model, + tags.product_family, + tags.part_number + FROM inventory_observations + LEFT JOIN items ON items.id = inventory_observations.known_item_id + LEFT JOIN tags ON tags.id = inventory_observations.tag_id + WHERE inventory_observations.session_id = ? + ORDER BY inventory_observations.last_seen_at DESC + `).all(session.id); + + const events = db.prepare(` + SELECT + tag_events.*, items.name AS item_name, items.sku AS item_sku, - items.category AS item_category - FROM inventory_reads - LEFT JOIN items ON items.id = inventory_reads.known_item_id - WHERE inventory_reads.session_id = ? - ORDER BY inventory_reads.last_seen_at DESC + tags.chip_model, + tags.product_family + FROM tag_events + LEFT JOIN tags ON tags.id = tag_events.tag_id + LEFT JOIN items ON items.id = tags.item_id + WHERE tag_events.session_id = ? + ORDER BY tag_events.occurred_at DESC, tag_events.id DESC `).all(session.id); - return { ...session, reads }; + return { ...session, reads, events }; }, close(sessionKey: string) { From 6477a8a2312461f3bdeb8a979d6609e1053fcac6 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:09:14 +0200 Subject: [PATCH 22/42] refactor: add unified inventory and security reports --- src/modules/reports/reports.service.ts | 97 ++++++++++++++++++++++---- 1 file changed, 82 insertions(+), 15 deletions(-) diff --git a/src/modules/reports/reports.service.ts b/src/modules/reports/reports.service.ts index 5f9b5da..d5a4fe0 100644 --- a/src/modules/reports/reports.service.ts +++ b/src/modules/reports/reports.service.ts @@ -2,17 +2,51 @@ import type { AppDb } from "../../db/connection.js"; export function createReportsService(db: AppDb) { return { + dashboardSummary() { + const counts = db.prepare(` + SELECT + (SELECT COUNT(*) FROM items WHERE status != 'archived') AS active_items, + (SELECT COUNT(*) FROM tags WHERE status = 'active') AS active_tags, + (SELECT COUNT(*) FROM tags WHERE status = 'active' AND technology = 'nfc') AS nfc_tags, + (SELECT COUNT(*) FROM tags WHERE status = 'active' AND technology = 'uhf-rain') AS uhf_tags, + (SELECT COUNT(*) FROM inventory_sessions WHERE status = 'open') AS open_sessions, + (SELECT COUNT(*) FROM tags WHERE last_auth_status IN ('failed', 'replay', 'error')) AS auth_alerts, + (SELECT COUNT(*) FROM tags WHERE last_tamper_status IN ('open', 'invalid') OR permanent_tamper_status = 'opened-once') AS tamper_alerts, + (SELECT COUNT(*) FROM tags WHERE security_profile_id IS NOT NULL) AS secured_tags + `).get(); + + const recentEvents = db.prepare(` + SELECT + tag_events.*, + items.name AS item_name, + items.sku AS item_sku, + tags.chip_model, + tags.product_family + FROM tag_events + LEFT JOIN tags ON tags.id = tag_events.tag_id + LEFT JOIN items ON items.id = tags.item_id + ORDER BY tag_events.occurred_at DESC, tag_events.id DESC + LIMIT 12 + `).all(); + + return { counts, recentEvents }; + }, + unknownTags() { return db.prepare(` SELECT - inventory_reads.epc, - COUNT(DISTINCT inventory_reads.session_id) AS session_count, - SUM(inventory_reads.read_count) AS total_reads, - MAX(inventory_reads.last_seen_at) AS last_seen_at - FROM inventory_reads - LEFT JOIN rfid_tags ON rfid_tags.epc = inventory_reads.epc AND rfid_tags.status = 'active' - WHERE rfid_tags.id IS NULL - GROUP BY inventory_reads.epc + inventory_observations.technology, + inventory_observations.identifier, + COUNT(DISTINCT inventory_observations.session_id) AS session_count, + SUM(inventory_observations.read_count) AS total_reads, + MAX(inventory_observations.last_seen_at) AS last_seen_at + FROM inventory_observations + LEFT JOIN tags + ON tags.technology = inventory_observations.technology + AND tags.identifier = inventory_observations.identifier + AND tags.status = 'active' + WHERE tags.id IS NULL + GROUP BY inventory_observations.technology, inventory_observations.identifier ORDER BY last_seen_at DESC `).all(); }, @@ -24,15 +58,48 @@ export function createReportsService(db: AppDb) { items.name, items.sku, items.category, - rfid_tags.epc, - MAX(inventory_reads.last_seen_at) AS last_seen_at, - SUM(inventory_reads.read_count) AS total_reads + tags.technology, + tags.identifier, + tags.chip_model, + tags.product_family, + MAX(inventory_observations.last_seen_at) AS last_seen_at, + COALESCE(SUM(inventory_observations.read_count), 0) AS total_reads FROM items - LEFT JOIN rfid_tags ON rfid_tags.item_id = items.id AND rfid_tags.status = 'active' - LEFT JOIN inventory_reads ON inventory_reads.epc = rfid_tags.epc - GROUP BY items.id, rfid_tags.epc - ORDER BY last_seen_at DESC + LEFT JOIN tags ON tags.item_id = items.id AND tags.status = 'active' + LEFT JOIN inventory_observations ON inventory_observations.tag_id = tags.id + WHERE items.status != 'archived' + GROUP BY items.id, tags.id + ORDER BY last_seen_at DESC, items.name `).all(); + }, + + securityAlerts(limit = 100) { + const bounded = Math.max(1, Math.min(limit, 500)); + return db.prepare(` + SELECT + tags.id, + tags.technology, + tags.identifier, + tags.chip_model, + tags.product_family, + tags.last_auth_status, + tags.last_auth_counter, + tags.last_tamper_status, + tags.permanent_tamper_status, + tags.last_seen_at, + items.name AS item_name, + items.sku AS item_sku + FROM tags + LEFT JOIN items ON items.id = tags.item_id + WHERE tags.status = 'active' + AND ( + tags.last_auth_status IN ('failed', 'replay', 'error') + OR tags.last_tamper_status IN ('open', 'invalid') + OR tags.permanent_tamper_status = 'opened-once' + ) + ORDER BY COALESCE(tags.last_seen_at, tags.updated_at) DESC + LIMIT ? + `).all(bounded); } }; } From 8bb22ac1d65ae130cecc16dc50d71e7913b8f448 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:09:52 +0200 Subject: [PATCH 23/42] refactor: expose unified tag observation and security APIs --- src/server.ts | 116 +++++++++++++++++++++++++++++++------------------- 1 file changed, 72 insertions(+), 44 deletions(-) diff --git a/src/server.ts b/src/server.ts index 4d13a1b..6d21046 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,12 +8,13 @@ import { config } from "./config.js"; import { createDb } from "./db/connection.js"; import { createAdminService } from "./modules/admin/admin.service.js"; import { createItemsService } from "./modules/items/items.service.js"; +import { createObservationsService } from "./modules/observations/observations.service.js"; import { createReportsService } from "./modules/reports/reports.service.js"; -import { createRfidService } from "./modules/rfid/rfid.service.js"; -import type { RfidReadEvent, RfidSource } from "./modules/rfid/rfid.types.js"; +import { createSecurityService, type SecurityProfileInput, type VerifyInput } from "./modules/security/security.service.js"; import { createSessionsService } from "./modules/sessions/sessions.service.js"; import { createStationsService } from "./modules/stations/stations.service.js"; import { createTagsService } from "./modules/tags/tags.service.js"; +import type { RegisterTagInput, TagObservationInput, TagStatus, TagTechnology } from "./modules/tags/tag.types.js"; import { nowIso } from "./utils/time.js"; const app = Fastify({ logger: true }); @@ -21,14 +22,21 @@ const db = createDb(); const items = createItemsService(db); const tags = createTagsService(db); const sessions = createSessionsService(db); -const rfid = createRfidService(db); +const observations = createObservationsService(db); +const security = createSecurityService(db); const stations = createStationsService(db); const reports = createReportsService(db); const admin = createAdminService(db); await app.register(cors, { origin: true }); -app.get("/api/health", async () => ({ ok: true, service: "storage-tags", time: nowIso() })); +app.get("/api/health", async () => ({ + ok: true, + service: "storage-tags", + version: "0.0.1", + time: nowIso(), + capabilities: ["uhf-rain", "nfc", "sun", "sdm", "tamper", "security-profiles", "reader-bridge"] +})); app.get<{ Querystring: { search?: string } }>("/api/items", async (request) => items.list(request.query.search)); app.get<{ Params: { id: string } }>("/api/items/:id", async (request, reply) => { @@ -48,58 +56,72 @@ app.delete<{ Params: { id: string } }>("/api/items/:id", async (request, reply) return items.remove(Number(request.params.id)) ? { ok: true } : reply.code(404).send({ ok: false, error: "ITEM_NOT_FOUND" }); }); -app.get<{ Querystring: { status?: string } }>("/api/tags", async (request) => tags.list(request.query.status)); -app.get<{ Params: { epc: string } }>("/api/tags/:epc", async (request) => { - const tag = tags.resolve(request.params.epc); +app.get("/api/tag-catalog", async () => tags.catalog()); +app.get<{ Querystring: { status?: string; technology?: TagTechnology; search?: string } }>("/api/tags", async (request) => tags.list(request.query)); +app.get<{ Params: { technology: TagTechnology; identifier: string } }>("/api/tags/:technology/:identifier", async (request) => { + const tag = tags.resolve(request.params.technology, request.params.identifier); return { ok: true, known: Boolean(tag), tag }; }); - -app.post<{ Body: { epc?: string; itemId?: number; tid?: string } }>("/api/tags/register", async (request, reply) => { - if (!request.body.epc || !request.body.itemId) return reply.code(400).send({ ok: false, error: "EPC_AND_ITEM_REQUIRED" }); - const result = tags.register(request.body.epc, request.body.itemId, request.body.tid); +app.post<{ Body: RegisterTagInput }>("/api/tags", async (request, reply) => { + if (!request.body.technology || !request.body.identifier) return reply.code(400).send({ ok: false, error: "TECHNOLOGY_AND_IDENTIFIER_REQUIRED" }); + const result = tags.register(request.body); return result.ok ? result : reply.code(400).send(result); }); -app.put<{ Params: { epc: string }; Body: { status?: "active" | "inactive" | "ignored" | "external" } }>("/api/tags/:epc/status", async (request, reply) => { +app.put<{ Params: { technology: TagTechnology; identifier: string }; Body: { status?: TagStatus } }>("/api/tags/:technology/:identifier/status", async (request, reply) => { if (!request.body.status) return reply.code(400).send({ ok: false, error: "STATUS_REQUIRED" }); - const result = tags.setStatus(request.params.epc, request.body.status); + const result = tags.setStatus(request.params.technology, request.params.identifier, request.body.status); + return result.ok ? result : reply.code(400).send(result); +}); +app.post<{ Body: { technology?: TagTechnology; identifier?: string; status?: "ignored" | "external" } }>("/api/tags/mark-unknown", async (request, reply) => { + if (!request.body.technology || !request.body.identifier || !request.body.status) return reply.code(400).send({ ok: false, error: "TECHNOLOGY_IDENTIFIER_AND_STATUS_REQUIRED" }); + const result = tags.markUnknown(request.body.technology, request.body.identifier, request.body.status); return result.ok ? result : reply.code(400).send(result); }); -app.post<{ Body: { epc?: string; status?: "ignored" | "external" } }>("/api/tags/mark-unknown", async (request, reply) => { - if (!request.body.epc || !request.body.status) return reply.code(400).send({ ok: false, error: "EPC_AND_STATUS_REQUIRED" }); - const result = tags.markUnknown(request.body.epc, request.body.status); + +app.post<{ Body: TagObservationInput }>("/api/observations", async (request, reply) => { + if (!request.body.technology || !request.body.identifier || !request.body.source) return reply.code(400).send({ ok: false, error: "TECHNOLOGY_IDENTIFIER_AND_SOURCE_REQUIRED" }); + const result = observations.process(request.body); return result.ok ? result : reply.code(400).send(result); }); +app.post<{ Body: { observations?: TagObservationInput[] } }>("/api/observations/batch", async (request, reply) => { + if (!Array.isArray(request.body.observations)) return reply.code(400).send({ ok: false, error: "OBSERVATIONS_REQUIRED" }); + return { ok: true, results: request.body.observations.map((entry) => observations.process(entry)) }; +}); +app.get<{ Querystring: { limit?: string } }>("/api/events", async (request) => observations.recentEvents(Number(request.query.limit ?? 100))); + +app.get("/api/security/profiles", async () => security.listProfiles()); +app.put<{ Params: { profileKey: string }; Body: Omit }>("/api/security/profiles/:profileKey", async (request, reply) => { + if (!request.body.name || !request.body.technology || !request.body.verifier) return reply.code(400).send({ ok: false, error: "PROFILE_FIELDS_REQUIRED" }); + return { ok: true, profile: security.upsertProfile({ ...request.body, profileKey: request.params.profileKey }) }; +}); +app.post<{ Body: VerifyInput }>("/api/security/verify", async (request, reply) => { + if (!request.body.technology || !request.body.identifier) return reply.code(400).send({ ok: false, error: "TECHNOLOGY_AND_IDENTIFIER_REQUIRED" }); + try { + const result = security.verify(request.body); + return result.ok ? result : reply.code(400).send(result); + } catch (error) { + request.log.error(error); + const message = error instanceof Error ? error.message : "VERIFICATION_ERROR"; + return reply.code(400).send({ ok: false, error: message }); + } +}); -app.post<{ Body: Partial }>("/api/rfid/browser-read", async (request, reply) => { +app.post<{ Body: { epc?: string; tid?: string; source?: string; stationId?: string; sessionId?: string; rssi?: number; antenna?: number; raw?: string; seenAt?: string } }>("/api/rfid/browser-read", async (request, reply) => { if (!request.body.epc) return reply.code(400).send({ ok: false, error: "EPC_REQUIRED" }); - const event: RfidReadEvent = { + const result = observations.process({ + technology: "uhf-rain", + identifier: request.body.epc, epc: request.body.epc, tid: request.body.tid, - source: (request.body.source ?? "browser-hid") as RfidSource, - stationId: request.body.stationId, - sessionId: request.body.sessionId, - deviceLabel: request.body.deviceLabel, + source: "browser-hid", + stationKey: request.body.stationId, + sessionKey: request.body.sessionId, + rssi: request.body.rssi, + antenna: request.body.antenna, raw: request.body.raw, seenAt: request.body.seenAt ?? nowIso() - }; - - const result = rfid.processRead(event); - return result.ok ? result : reply.code(400).send(result); -}); - -app.post<{ Body: { epcs?: string[]; source?: RfidSource; stationId?: string; sessionId?: string; deviceLabel?: string } }>("/api/rfid/batch-browser-read", async (request, reply) => { - if (!Array.isArray(request.body.epcs)) return reply.code(400).send({ ok: false, error: "EPCS_REQUIRED" }); - - const results = request.body.epcs.map((epc) => rfid.processRead({ - epc, - source: request.body.source ?? "browser-hid", - stationId: request.body.stationId, - sessionId: request.body.sessionId, - deviceLabel: request.body.deviceLabel, - seenAt: nowIso() - })); - - return { ok: true, results }; + }); + return result.ok ? { ...result, deprecatedRoute: true } : reply.code(400).send(result); }); app.get("/api/inventory-sessions", async () => sessions.list()); @@ -118,7 +140,7 @@ app.get<{ Params: { stationKey: string } }>("/api/stations/:stationKey", async ( const station = stations.get(request.params.stationKey); return station ? station : reply.code(404).send({ ok: false, error: "STATION_NOT_FOUND" }); }); -app.put<{ Params: { stationKey: string }; Body: { name?: string; type?: string; inputMode?: string; deviceLabel?: string; config?: Record } }>("/api/stations/:stationKey", async (request, reply) => { +app.put<{ Params: { stationKey: string }; Body: { name?: string; type?: string; inputMode?: string; deviceLabel?: string; config?: Record } }>("/api/stations/:stationKey", async (request) => { const stationKey = request.params.stationKey.trim(); const name = request.body.name?.trim() || stationKey; return { ok: true, station: stations.upsert({ ...request.body, stationKey, name }) }; @@ -128,8 +150,10 @@ app.post<{ Body: { stationKey?: string; name?: string; type?: string; inputMode? return { ok: true, station: stations.upsert({ ...request.body, stationKey: request.body.stationKey.trim(), name: request.body.name?.trim() || request.body.stationKey.trim() }) }; }); +app.get("/api/reports/dashboard", async () => reports.dashboardSummary()); app.get("/api/reports/unknown-tags", async () => reports.unknownTags()); app.get("/api/reports/items-last-seen", async () => reports.itemsLastSeen()); +app.get<{ Querystring: { limit?: string } }>("/api/reports/security-alerts", async (request) => reports.securityAlerts(Number(request.query.limit ?? 100))); app.post("/api/admin/backup", async () => ({ ok: true, backup: admin.backup() })); app.post("/api/admin/seed-demo", async () => ({ ok: true, ...admin.seedDemoData() })); @@ -138,13 +162,17 @@ app.get<{ Params: { sessionKey: string } }>("/api/reports/session/:sessionKey/cs if (!session) return reply.code(404).send({ ok: false, error: "SESSION_NOT_FOUND" }); const rows = [ - ["EPC", "Item Name", "SKU", "Category", "Read Count", "First Seen", "Last Seen"], + ["Technology", "Identifier", "Item Name", "SKU", "Category", "Read Count", "Auth Status", "Current Tamper", "Permanent Tamper", "First Seen", "Last Seen"], ...session.reads.map((read: any) => [ - read.epc, + read.technology, + read.identifier, read.item_name ?? "Unknown tag", read.item_sku ?? "", read.item_category ?? "", read.read_count, + read.auth_status, + read.tamper_status, + read.permanent_tamper_status, read.first_seen_at ?? "", read.last_seen_at ?? "" ]) From 45cdcaa077d16e81f35574032dfa732923a35368 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:10:11 +0200 Subject: [PATCH 24/42] refactor: seed demo data for mixed NFC and RAIN tags --- src/modules/admin/admin.service.ts | 66 +++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/src/modules/admin/admin.service.ts b/src/modules/admin/admin.service.ts index e1d9694..38c4b8b 100644 --- a/src/modules/admin/admin.service.ts +++ b/src/modules/admin/admin.service.ts @@ -19,27 +19,73 @@ export function createAdminService(db: AppDb) { const count = db.prepare("SELECT COUNT(*) AS count FROM items").get() as { count: number }; if (count.count > 0) return { inserted: false }; - const items = [ - ["OIL-EVO-500", "Extra Virgin Olive Oil 500 ml", "Food / Oil", "Demo item"], + const demoItems = [ + ["OIL-EVO-500", "Extra Virgin Olive Oil 500 ml", "Food / Oil", "Demo item with UHF and NFC identity"], ["PASTA-001", "Pasta 1 kg", "Food / Dry Goods", "Demo item"], ["DOC-BOX-A", "Archive Box A", "Documents", "Demo item"] ]; - for (const item of items) { + for (const item of demoItems) { db.prepare("INSERT INTO items (sku, name, category, notes) VALUES (?, ?, ?, ?)").run(...item); } const rows = db.prepare("SELECT id, sku FROM items").all() as { id: number; sku: string }[]; const bySku = new Map(rows.map((row) => [row.sku, row.id])); const tags = [ - ["3034257BF7194E4000001A85", bySku.get("OIL-EVO-500")], - ["3034257BF7194E4000001A86", bySku.get("PASTA-001")], - ["E2806894000040178F2A91B5", bySku.get("DOC-BOX-A")] - ] as [string, number | undefined][]; + { + technology: "uhf-rain", + identifier: "3034257BF7194E4000001A85", + epc: "3034257BF7194E4000001A85", + itemId: bySku.get("OIL-EVO-500"), + chipFamily: "generic-uhf", + capabilities: { bulkInventory: true } + }, + { + technology: "nfc", + identifier: "04DE5F1EACC040", + uid: "04DE5F1EACC040", + itemId: bySku.get("OIL-EVO-500"), + manufacturer: "NXP", + chipFamily: "NTAG 424 DNA", + chipModel: "NTAG 424 DNA TagTamper", + capabilities: { ndef: true, sun: true, sdm: true, tamper: true, currentTamperStatus: true, permanentTamperStatus: true } + }, + { + technology: "uhf-rain", + identifier: "3034257BF7194E4000001A86", + epc: "3034257BF7194E4000001A86", + itemId: bySku.get("PASTA-001"), + chipFamily: "generic-uhf", + capabilities: { bulkInventory: true } + }, + { + technology: "uhf-rain", + identifier: "E2806894000040178F2A91B5", + epc: "E2806894000040178F2A91B5", + itemId: bySku.get("DOC-BOX-A"), + chipFamily: "generic-uhf", + capabilities: { bulkInventory: true } + } + ]; - for (const [epc, itemId] of tags) { - if (!itemId) continue; - db.prepare("INSERT INTO rfid_tags (epc, item_id) VALUES (?, ?)").run(epc, itemId); + for (const tag of tags) { + if (!tag.itemId) continue; + db.prepare(` + INSERT INTO tags ( + technology, identifier, epc, uid, manufacturer, chip_family, chip_model, + capabilities_json, item_id, status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active') + `).run( + tag.technology, + tag.identifier, + tag.epc ?? null, + tag.uid ?? null, + tag.manufacturer ?? null, + tag.chipFamily, + tag.chipModel ?? null, + JSON.stringify(tag.capabilities), + tag.itemId + ); } return { inserted: true }; From f25d0c7b3ec087bb3cf52af9d80c516f846d540a Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:10:22 +0200 Subject: [PATCH 25/42] test: cover AES-CMAC and NXP truncation --- src/security/aes-cmac.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/security/aes-cmac.test.ts diff --git a/src/security/aes-cmac.test.ts b/src/security/aes-cmac.test.ts new file mode 100644 index 0000000..de7f9ce --- /dev/null +++ b/src/security/aes-cmac.test.ts @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { aesCmac, nxpTruncatedMac } from "./aes-cmac.js"; + +test("AES-CMAC matches RFC 4493 vector", () => { + const key = Buffer.from("2B7E151628AED2A6ABF7158809CF4F3C", "hex"); + const mac = aesCmac(key, Buffer.alloc(0)); + assert.equal(mac.toString("hex").toUpperCase(), "BB1D6929E95937287FA37D129B756746"); +}); + +test("NXP truncation keeps alternating CMAC bytes", () => { + const full = Buffer.from("E194C7EE12D9F7EE8A65C8331B704386", "hex"); + assert.equal(nxpTruncatedMac(full).toString("hex").toUpperCase(), "94EED9EE65337086"); +}); From 99aaa9c7d989d03142de787886950d0b0a5d2460 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:10:30 +0200 Subject: [PATCH 26/42] test: verify NTAG 424 DNA official SDM vector --- src/security/ntag424-sdm.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/security/ntag424-sdm.test.ts diff --git a/src/security/ntag424-sdm.test.ts b/src/security/ntag424-sdm.test.ts new file mode 100644 index 0000000..fde6a52 --- /dev/null +++ b/src/security/ntag424-sdm.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { verifyNtag424Sdm } from "./ntag424-sdm.js"; + +test("NTAG 424 DNA verifies the NXP encrypted PICC SDM example", () => { + const zeroKey = Buffer.alloc(16); + const result = verifyNtag424Sdm({ + sdmFileReadKey: zeroKey, + sdmMetaReadKey: zeroKey + }, { + encryptedPiccData: "EF963FF7828658A599F3041510671E88", + mac: "94EED9EE65337086" + }); + + assert.equal(result.valid, true); + assert.equal(result.uid, "04DE5F1EACC040"); + assert.equal(result.readCounter, "00003D"); + assert.equal(result.expectedMac, "94EED9EE65337086"); +}); From a05f9183c71cf4ea6c590c21f77c607ac7375e9c Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:10:40 +0200 Subject: [PATCH 27/42] test: cover NTAG 22x DNA SUN and tamper decoding --- src/security/ntag22x-sun.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/security/ntag22x-sun.test.ts diff --git a/src/security/ntag22x-sun.test.ts b/src/security/ntag22x-sun.test.ts new file mode 100644 index 0000000..9c20b27 --- /dev/null +++ b/src/security/ntag22x-sun.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { decodeNtag22xTamperMirror, verifyNtag22xSun } from "./ntag22x-sun.js"; + +test("NTAG 22x StatusDetect decodes stored and current tamper state", () => { + const result = decodeNtag22xTamperMirror("CO00000000"); + assert.equal(result.data.toString("hex").toUpperCase(), "3F00000000"); + assert.equal(result.storedTamperStatus, "closed"); + assert.equal(result.currentTamperStatus, "open"); +}); + +test("NTAG 22x SUN verifies UID, counter, tamper data and truncated CMAC", () => { + const key = Buffer.from("000102030405060708090A0B0C0D0E0F", "hex"); + const result = verifyNtag22xSun(key, { + uid: "04E141124C2880", + counter: "0004AF", + tamperMirror: "CO00000000", + mac: "D0FDE26641C764D4" + }); + + assert.equal(result.valid, true); + assert.equal(result.tamperData, "3F00000000"); +}); From 7bc378447c6cfd9d2b1dcb41c2bde980d3dfa538 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:10:53 +0200 Subject: [PATCH 28/42] chore: reset version to 0.0.1 and add test script --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index af5ef47..52d4209 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "storage-tags", - "version": "0.1.0", + "version": "0.0.1", "private": true, "license": "SEE LICENSE IN LICENSE", "type": "module", @@ -8,6 +8,7 @@ "dev": "concurrently \"npm:dev:api\" \"npm:dev:web\"", "dev:api": "tsx watch src/server.ts", "dev:web": "vite --host 0.0.0.0", + "test": "tsx --test src/**/*.test.ts", "build": "tsc && vite build", "start": "node dist/server.js" }, From dd856f5db76f6c6ceb915759fd7b04131f663944 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:12:29 +0200 Subject: [PATCH 29/42] ci: require tests and build before merge --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d613940 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - "feat/**" + +permissions: + contents: read + +jobs: + test-build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Test + run: npm test + + - name: Build + run: npm run build From 7ee65246bce1991a56a339f68cceb20ac0a0b2b3 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:12:39 +0200 Subject: [PATCH 30/42] docs: start changelog at 0.0.1 --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1cdf99b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +All notable changes to Storage Tags will be documented in this file. + +The project follows Semantic Versioning. Release tags are created only after the corresponding pull request has passed tests, been reviewed, and merged. + +## [0.0.1] - Unreleased + +### Added + +- Unified tag domain for NFC and RAIN/UHF identities. +- Product/capability catalog for generic NFC and RAIN tags, NXP NTAG 223/224 DNA, StatusDetect, NTAG 424 DNA/TagTamper, Identiv DNA product families, NXP UCODE DNA/Track, and UCODE Guard. +- Mixed-tag inventory sessions so an item can carry both UHF and NFC identities. +- Immutable tag security/status events in addition to aggregated inventory observations. +- Self-hosted NTAG 22x DNA SUN verification with StatusDetect decoding. +- Self-hosted NTAG 424 DNA SDM verification with replay-counter tracking. +- Current and permanent/once-opened tamper state tracking. +- Security profiles that reference mounted secret keys without storing key material in application data. +- Reader-bridge abstraction for authenticated USB NFC and RAIN readers. +- Automated cryptographic tests and GitHub Actions test/build gate. + +### Changed + +- Replaced the EPC-first application model with technology-aware tag identifiers. +- Reset the development version baseline to `0.0.1`. +- Kept `/api/rfid/browser-read` only as a compatibility adapter over the generic observation pipeline. + +### Security + +- AES keys are resolved server-side from configured secret storage and are not exposed by REST/UI/MCP surfaces. +- SUN/SDM counters are tracked to distinguish a valid fresh message from a replayed valid message. From 41284b050ece433d3cf24b59f0f35d351306f609 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:13:24 +0200 Subject: [PATCH 31/42] feat: expose application domain through MCP stdio --- src/mcp-server.ts | 320 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 src/mcp-server.ts diff --git a/src/mcp-server.ts b/src/mcp-server.ts new file mode 100644 index 0000000..88ccd7c --- /dev/null +++ b/src/mcp-server.ts @@ -0,0 +1,320 @@ +import readline from "node:readline"; +import { createDb } from "./db/connection.js"; +import { createAdminService } from "./modules/admin/admin.service.js"; +import { createItemsService } from "./modules/items/items.service.js"; +import { createObservationsService } from "./modules/observations/observations.service.js"; +import { createReportsService } from "./modules/reports/reports.service.js"; +import { createSecurityService } from "./modules/security/security.service.js"; +import { createSessionsService } from "./modules/sessions/sessions.service.js"; +import { createStationsService } from "./modules/stations/stations.service.js"; +import { createTagsService } from "./modules/tags/tags.service.js"; + +const db = createDb(); +const admin = createAdminService(db); +const items = createItemsService(db); +const observations = createObservationsService(db); +const reports = createReportsService(db); +const security = createSecurityService(db); +const sessions = createSessionsService(db); +const stations = createStationsService(db); +const tags = createTagsService(db); + +const protocolVersion = "2025-06-18"; + +type JsonRpcRequest = { + jsonrpc: "2.0"; + id?: string | number | null; + method: string; + params?: any; +}; + +type McpTool = { + name: string; + description: string; + inputSchema: Record; +}; + +const objectSchema = (properties: Record = {}, required: string[] = []) => ({ + type: "object", + properties, + ...(required.length ? { required } : {}), + additionalProperties: false +}); + +const tools: McpTool[] = [ + { + name: "dashboard_summary", + description: "Get operational totals, authentication/tamper alerts, and recent security events.", + inputSchema: objectSchema() + }, + { + name: "list_items", + description: "List inventory items and their NFC/RAIN tag counts.", + inputSchema: objectSchema({ search: { type: "string" } }) + }, + { + name: "get_item", + description: "Get one inventory item including all linked NFC and RAIN tags.", + inputSchema: objectSchema({ id: { type: "integer" } }, ["id"]) + }, + { + name: "create_item", + description: "Create an inventory item.", + inputSchema: objectSchema({ + sku: { type: "string" }, name: { type: "string" }, description: { type: "string" }, + category: { type: "string" }, photoUrl: { type: "string" }, notes: { type: "string" } + }, ["name"]) + }, + { + name: "update_item", + description: "Update an inventory item.", + inputSchema: objectSchema({ + id: { type: "integer" }, sku: { type: "string" }, name: { type: "string" }, description: { type: "string" }, + category: { type: "string" }, photoUrl: { type: "string" }, notes: { type: "string" } + }, ["id"]) + }, + { + name: "archive_item", + description: "Archive an item and deactivate its tags.", + inputSchema: objectSchema({ id: { type: "integer" } }, ["id"]) + }, + { + name: "tag_catalog", + description: "List supported generic, NXP, and Identiv tag families with security capabilities.", + inputSchema: objectSchema() + }, + { + name: "list_tags", + description: "List or filter NFC and RAIN tags.", + inputSchema: objectSchema({ + status: { type: "string" }, + technology: { type: "string", enum: ["nfc", "uhf-rain"] }, + search: { type: "string" } + }) + }, + { + name: "get_tag", + description: "Resolve a tag by technology and identifier.", + inputSchema: objectSchema({ + technology: { type: "string", enum: ["nfc", "uhf-rain"] }, identifier: { type: "string" } + }, ["technology", "identifier"]) + }, + { + name: "register_tag", + description: "Register or update a generic NFC/RAIN tag and optionally link it to an item and security profile.", + inputSchema: objectSchema({ + technology: { type: "string", enum: ["nfc", "uhf-rain"] }, identifier: { type: "string" }, itemId: { type: "integer" }, + epc: { type: "string" }, tid: { type: "string" }, uid: { type: "string" }, catalogKey: { type: "string" }, + manufacturer: { type: "string" }, chipFamily: { type: "string" }, chipModel: { type: "string" }, + productFamily: { type: "string" }, partNumber: { type: "string" }, securityProfileKey: { type: "string" }, + metadata: { type: "object" }, status: { type: "string", enum: ["active", "inactive", "ignored", "external"] } + }, ["technology", "identifier"]) + }, + { + name: "set_tag_status", + description: "Set a tag lifecycle status.", + inputSchema: objectSchema({ + technology: { type: "string", enum: ["nfc", "uhf-rain"] }, identifier: { type: "string" }, + status: { type: "string", enum: ["active", "inactive", "ignored", "external"] } + }, ["technology", "identifier", "status"]) + }, + { + name: "ingest_observation", + description: "Record an NFC or RAIN observation, optionally linked to an open inventory session.", + inputSchema: objectSchema({ + technology: { type: "string", enum: ["nfc", "uhf-rain"] }, identifier: { type: "string" }, + epc: { type: "string" }, tid: { type: "string" }, uid: { type: "string" }, + source: { type: "string", enum: ["browser-hid", "browser-webnfc", "reader-bridge", "mock"] }, + stationKey: { type: "string" }, sessionKey: { type: "string" }, rssi: { type: "number" }, antenna: { type: "integer" }, + raw: { type: "string" }, ndefUrl: { type: "string" }, payload: { type: "object" }, sensorValue: { type: "number" }, sensorUnit: { type: "string" } + }, ["technology", "identifier", "source"]) + }, + { + name: "recent_events", + description: "Read immutable scan, verification, tamper, and sensor events.", + inputSchema: objectSchema({ limit: { type: "integer", minimum: 1, maximum: 500 } }) + }, + { + name: "list_security_profiles", + description: "List self-hosted verification profiles. Returns key references only, never key material.", + inputSchema: objectSchema() + }, + { + name: "upsert_security_profile", + description: "Create or update a verification profile using a server-side key reference.", + inputSchema: objectSchema({ + profileKey: { type: "string" }, name: { type: "string" }, technology: { type: "string", enum: ["nfc", "uhf-rain"] }, + verifier: { type: "string", enum: ["ntag22x-sun", "ntag424-sdm", "reader-bridge"] }, keyRef: { type: "string" }, + config: { type: "object" }, status: { type: "string", enum: ["active", "inactive"] } + }, ["profileKey", "name", "technology", "verifier"]) + }, + { + name: "verify_tag", + description: "Perform self-hosted SUN/SDM verification, replay detection, and tamper/status capture.", + inputSchema: objectSchema({ + technology: { type: "string", enum: ["nfc", "uhf-rain"] }, identifier: { type: "string" }, profileKey: { type: "string" }, + url: { type: "string" }, source: { type: "string", enum: ["browser-hid", "browser-webnfc", "reader-bridge", "mock"] }, + stationKey: { type: "string" }, sessionKey: { type: "string" }, evidence: { type: "object" } + }, ["technology", "identifier"]) + }, + { + name: "list_sessions", + description: "List inventory sessions with NFC/RAIN/security metrics.", + inputSchema: objectSchema() + }, + { + name: "get_session", + description: "Get a session including aggregated observations and immutable events.", + inputSchema: objectSchema({ sessionKey: { type: "string" } }, ["sessionKey"]) + }, + { + name: "create_session", + description: "Create an inventory session.", + inputSchema: objectSchema({ stationKey: { type: "string" }, containerCode: { type: "string" }, locationName: { type: "string" }, notes: { type: "string" } }) + }, + { + name: "close_session", + description: "Close an inventory session.", + inputSchema: objectSchema({ sessionKey: { type: "string" } }, ["sessionKey"]) + }, + { + name: "list_stations", + description: "List browser and reader-bridge stations.", + inputSchema: objectSchema() + }, + { + name: "save_station", + description: "Create or update a station/reader endpoint.", + inputSchema: objectSchema({ + stationKey: { type: "string" }, name: { type: "string" }, type: { type: "string" }, inputMode: { type: "string" }, + deviceLabel: { type: "string" }, config: { type: "object" } + }, ["stationKey", "name"]) + }, + { + name: "unknown_tags_report", + description: "List observed identifiers that do not have an active registered tag.", + inputSchema: objectSchema() + }, + { + name: "items_last_seen_report", + description: "Report last-seen data per item/tag identity.", + inputSchema: objectSchema() + }, + { + name: "security_alerts_report", + description: "List authentication failures/replays and tamper alerts.", + inputSchema: objectSchema({ limit: { type: "integer", minimum: 1, maximum: 500 } }) + }, + { + name: "create_backup", + description: "Create a local SQLite backup on the Storage Tags host.", + inputSchema: objectSchema() + }, + { + name: "seed_demo", + description: "Load non-secret demo inventory and tag records when the database is empty.", + inputSchema: objectSchema() + } +]; + +function toolResult(value: unknown, isError = false) { + return { + content: [{ type: "text", text: JSON.stringify(value, null, 2) }], + ...(isError ? { isError: true } : {}) + }; +} + +function callTool(name: string, args: any) { + switch (name) { + case "dashboard_summary": return reports.dashboardSummary(); + case "list_items": return items.list(args.search); + case "get_item": return items.get(Number(args.id)); + case "create_item": return items.create(args); + case "update_item": { + const { id, ...input } = args; + return items.update(Number(id), input); + } + case "archive_item": return { archived: items.remove(Number(args.id)) }; + case "tag_catalog": return tags.catalog(); + case "list_tags": return tags.list(args); + case "get_tag": return tags.resolve(args.technology, args.identifier); + case "register_tag": return tags.register(args); + case "set_tag_status": return tags.setStatus(args.technology, args.identifier, args.status); + case "ingest_observation": return observations.process(args); + case "recent_events": return observations.recentEvents(Number(args.limit ?? 100)); + case "list_security_profiles": return security.listProfiles(); + case "upsert_security_profile": return security.upsertProfile(args); + case "verify_tag": return security.verify(args); + case "list_sessions": return sessions.list(); + case "get_session": return sessions.get(args.sessionKey); + case "create_session": return sessions.create(args); + case "close_session": return sessions.close(args.sessionKey); + case "list_stations": return stations.list(); + case "save_station": return stations.upsert(args); + case "unknown_tags_report": return reports.unknownTags(); + case "items_last_seen_report": return reports.itemsLastSeen(); + case "security_alerts_report": return reports.securityAlerts(Number(args.limit ?? 100)); + case "create_backup": return admin.backup(); + case "seed_demo": return admin.seedDemoData(); + default: throw new Error("UNKNOWN_TOOL"); + } +} + +function send(payload: unknown) { + process.stdout.write(`${JSON.stringify(payload)}\n`); +} + +function success(id: JsonRpcRequest["id"], result: unknown) { + if (id === undefined) return; + send({ jsonrpc: "2.0", id, result }); +} + +function failure(id: JsonRpcRequest["id"], code: number, message: string) { + if (id === undefined) return; + send({ jsonrpc: "2.0", id, error: { code, message } }); +} + +async function handle(request: JsonRpcRequest) { + try { + if (request.method === "initialize") { + return success(request.id, { + protocolVersion, + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "storage-tags", version: "0.0.1" } + }); + } + + if (request.method === "notifications/initialized" || request.method === "notifications/cancelled") return; + if (request.method === "ping") return success(request.id, {}); + if (request.method === "tools/list") return success(request.id, { tools }); + + if (request.method === "tools/call") { + const name = request.params?.name; + const args = request.params?.arguments ?? {}; + if (typeof name !== "string") return failure(request.id, -32602, "Tool name is required"); + + try { + return success(request.id, toolResult(callTool(name, args))); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return success(request.id, toolResult({ ok: false, error: message }, true)); + } + } + + return failure(request.id, -32601, `Method not found: ${request.method}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return failure(request.id, -32603, message); + } +} + +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false }); +rl.on("line", (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + try { + void handle(JSON.parse(trimmed) as JsonRpcRequest); + } catch { + send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }); + } +}); From a4e832cefe5092b6130c181fbdaa71d58a9ae68a Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:13:33 +0200 Subject: [PATCH 32/42] feat: add MCP server command --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 52d4209..14a9c6a 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "dev:web": "vite --host 0.0.0.0", "test": "tsx --test src/**/*.test.ts", "build": "tsc && vite build", - "start": "node dist/server.js" + "start": "node dist/server.js", + "mcp": "node dist/mcp-server.js" }, "dependencies": { "@fastify/cors": "^11.0.1", From cdf813b2d6b711418a9057b0923f546be8b3acb6 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:14:00 +0200 Subject: [PATCH 33/42] refactor: align web client with unified tag APIs --- web/lib/api.ts | 156 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 146 insertions(+), 10 deletions(-) diff --git a/web/lib/api.ts b/web/lib/api.ts index 63801a3..2071cf4 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -1,3 +1,7 @@ +export type TagTechnology = "nfc" | "uhf-rain"; +export type AuthStatus = "not-requested" | "pending" | "verified" | "failed" | "replay" | "unsupported" | "error"; +export type TamperStatus = "unknown" | "sealed" | "open" | "closed" | "opened-once" | "invalid"; + export type Item = { id: number; sku: string | null; @@ -7,6 +11,50 @@ export type Item = { photo_url: string | null; notes: string | null; status?: string; + tag_count?: number; + nfc_tag_count?: number; + uhf_tag_count?: number; + tags?: Tag[]; +}; + +export type Tag = { + id: number; + technology: TagTechnology; + identifier: string; + epc: string | null; + tid: string | null; + uid: string | null; + manufacturer: string | null; + chip_family: string | null; + chip_model: string | null; + product_family: string | null; + part_number: string | null; + capabilities_json: string | null; + metadata_json: string | null; + item_id: number | null; + item_name?: string | null; + item_sku?: string | null; + item_category?: string | null; + security_profile_key?: string | null; + status: string; + last_auth_status: AuthStatus; + last_auth_counter: number | null; + last_tamper_status: TamperStatus; + permanent_tamper_status: TamperStatus; + last_seen_at: string | null; +}; + +export type TagCatalogEntry = { + key: string; + label: string; + technology: TagTechnology; + manufacturer?: string; + chipFamily?: string; + chipModel?: string; + productFamily?: string; + partNumber?: string; + capabilities: Record; + notes?: string; }; export type Station = { @@ -20,24 +68,74 @@ export type Station = { }; export type SessionRead = { - epc: string; + technology: TagTechnology; + identifier: string; read_count: number; first_seen_at: string | null; last_seen_at: string | null; item_name: string | null; item_sku: string | null; item_category: string | null; + chip_model: string | null; + product_family: string | null; + auth_status: AuthStatus; + tamper_status: TamperStatus; + permanent_tamper_status: TamperStatus; +}; + +export type TagEvent = { + id: number; + event_kind: string; + technology: TagTechnology; + identifier: string; + source: string; + auth_status: AuthStatus; + auth_counter: number | null; + tamper_status: TamperStatus; + permanent_tamper_status: TamperStatus; + occurred_at: string; + item_name?: string | null; + item_sku?: string | null; + chip_model?: string | null; + product_family?: string | null; }; export type InventorySession = { id: number; session_key: string; + station_key?: string | null; + station_name?: string | null; container_code: string | null; location_name: string | null; started_at: string; ended_at: string | null; status: string; reads: SessionRead[]; + events: TagEvent[]; +}; + +export type SecurityProfile = { + profile_key: string; + name: string; + technology: TagTechnology; + verifier: "ntag22x-sun" | "ntag424-sdm" | "reader-bridge"; + key_ref: string | null; + config_json: string | null; + status: string; +}; + +export type DashboardSummary = { + counts: { + active_items: number; + active_tags: number; + nfc_tags: number; + uhf_tags: number; + open_sessions: number; + auth_alerts: number; + tamper_alerts: number; + secured_tags: number; + }; + recentEvents: TagEvent[]; }; async function request(path: string, init?: RequestInit): Promise { @@ -57,27 +155,65 @@ async function request(path: string, init?: RequestInit): Promise { return response.json() as Promise; } +function query(params: Record) { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) if (value) search.set(key, value); + const text = search.toString(); + return text ? `?${text}` : ""; +} + export const api = { - health: () => request<{ ok: boolean; time: string }>("/api/health"), - listItems: (search = "") => request(`/api/items${search ? `?search=${encodeURIComponent(search)}` : ""}`), - createItem: (body: Partial & { photoUrl?: string }) => request<{ ok: boolean; item: Item }>("/api/items", { method: "POST", body: JSON.stringify(body) }), + health: () => request<{ ok: boolean; version: string; time: string; capabilities: string[] }>("/api/health"), + dashboard: () => request("/api/reports/dashboard"), + + listItems: (search = "") => request(`/api/items${query({ search })}`), + getItem: (id: number) => request(`/api/items/${id}`), + createItem: (body: Partial & { name: string; photoUrl?: string }) => request<{ ok: boolean; item: Item }>("/api/items", { method: "POST", body: JSON.stringify(body) }), updateItem: (id: number, body: Partial & { photoUrl?: string }) => request<{ ok: boolean; item: Item }>(`/api/items/${id}`, { method: "PUT", body: JSON.stringify(body) }), deleteItem: (id: number) => request<{ ok: boolean }>(`/api/items/${id}`, { method: "DELETE" }), - resolveTag: (epc: string) => request<{ ok: boolean; known: boolean; tag: any }>(`/api/tags/${encodeURIComponent(epc)}`), - registerTag: (epc: string, itemId: number) => request<{ ok: boolean; tag: any }>("/api/tags/register", { method: "POST", body: JSON.stringify({ epc, itemId }) }), - markUnknownTag: (epc: string, status: "ignored" | "external") => request<{ ok: boolean; tag: any }>("/api/tags/mark-unknown", { method: "POST", body: JSON.stringify({ epc, status }) }), + + tagCatalog: () => request("/api/tag-catalog"), + listTags: (filters: { status?: string; technology?: TagTechnology; search?: string } = {}) => request(`/api/tags${query(filters)}`), + resolveTag: (technology: TagTechnology, identifier: string) => request<{ ok: boolean; known: boolean; tag: Tag | null }>(`/api/tags/${technology}/${encodeURIComponent(identifier)}`), + registerTag: (body: { + technology: TagTechnology; identifier: string; itemId?: number; epc?: string; tid?: string; uid?: string; + catalogKey?: string; securityProfileKey?: string; status?: string; metadata?: Record; + }) => request<{ ok: boolean; tag: Tag }>("/api/tags", { method: "POST", body: JSON.stringify(body) }), + setTagStatus: (technology: TagTechnology, identifier: string, status: "active" | "inactive" | "ignored" | "external") => + request<{ ok: boolean; tag: Tag }>(`/api/tags/${technology}/${encodeURIComponent(identifier)}/status`, { method: "PUT", body: JSON.stringify({ status }) }), + markUnknownTag: (technology: TagTechnology, identifier: string, status: "ignored" | "external") => + request<{ ok: boolean; tag: Tag }>("/api/tags/mark-unknown", { method: "POST", body: JSON.stringify({ technology, identifier, status }) }), + + postObservation: (body: { + technology: TagTechnology; identifier: string; epc?: string; tid?: string; uid?: string; + source: "browser-hid" | "browser-webnfc" | "reader-bridge" | "mock"; stationKey?: string; sessionKey?: string; + rssi?: number; antenna?: number; raw?: string; ndefUrl?: string; payload?: Record; + }) => request("/api/observations", { method: "POST", body: JSON.stringify({ seenAt: new Date().toISOString(), ...body }) }), + recentEvents: (limit = 100) => request(`/api/events?limit=${limit}`), + + listSecurityProfiles: () => request("/api/security/profiles"), + saveSecurityProfile: (profileKey: string, body: { + name: string; technology: TagTechnology; verifier: SecurityProfile["verifier"]; keyRef?: string; + config?: Record; status?: "active" | "inactive"; + }) => request<{ ok: boolean; profile: SecurityProfile }>(`/api/security/profiles/${encodeURIComponent(profileKey)}`, { method: "PUT", body: JSON.stringify(body) }), + verifyTag: (body: { + technology: TagTechnology; identifier: string; profileKey?: string; url?: string; + source?: "browser-webnfc" | "reader-bridge" | "mock"; stationKey?: string; sessionKey?: string; + }) => request("/api/security/verify", { method: "POST", body: JSON.stringify(body) }), + createSession: (body: { stationKey?: string; containerCode?: string; locationName?: string; notes?: string }) => request<{ ok: boolean; session: InventorySession }>("/api/inventory-sessions", { method: "POST", body: JSON.stringify(body) }), getSession: (sessionKey: string) => request(`/api/inventory-sessions/${encodeURIComponent(sessionKey)}`), listSessions: () => request("/api/inventory-sessions"), closeSession: (sessionKey: string) => request<{ ok: boolean; session: InventorySession }>(`/api/inventory-sessions/${encodeURIComponent(sessionKey)}/close`, { method: "POST" }), + listStations: () => request("/api/stations"), saveStation: (stationKey: string, body: { name?: string; type?: string; inputMode?: string; deviceLabel?: string; config?: Record }) => request<{ ok: boolean; station: Station }>(`/api/stations/${encodeURIComponent(stationKey)}`, { method: "PUT", body: JSON.stringify(body) }), + unknownTags: () => request("/api/reports/unknown-tags"), itemsLastSeen: () => request("/api/reports/items-last-seen"), + securityAlerts: (limit = 100) => request(`/api/reports/security-alerts?limit=${limit}`), backup: () => request<{ ok: boolean; backup: { path: string } }>("/api/admin/backup", { method: "POST" }), - seedDemo: () => request<{ ok: boolean; inserted: boolean }>("/api/admin/seed-demo", { method: "POST" }), - postRead: (body: { epc: string; sessionId?: string; stationId?: string; source?: string }) => - request<{ ok: boolean; epc: string; known: boolean; item: Item | null }>("/api/rfid/browser-read", { method: "POST", body: JSON.stringify({ source: "browser-hid", seenAt: new Date().toISOString(), ...body }) }) + seedDemo: () => request<{ ok: boolean; inserted: boolean }>("/api/admin/seed-demo", { method: "POST" }) }; From 418a16da82ac304da9defbca1f43bf4afabc7565 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:14:13 +0200 Subject: [PATCH 34/42] feat: add Web NFC reader adapter --- web/lib/nfc.ts | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 web/lib/nfc.ts diff --git a/web/lib/nfc.ts b/web/lib/nfc.ts new file mode 100644 index 0000000..8ace826 --- /dev/null +++ b/web/lib/nfc.ts @@ -0,0 +1,67 @@ +export type WebNfcRead = { + serialNumber: string; + url?: string; + records: { recordType: string; mediaType?: string; text?: string }[]; +}; + +type NdefRecordLike = { + recordType: string; + mediaType?: string; + data?: DataView; +}; + +type NdefReadingEventLike = Event & { + serialNumber?: string; + message: { records: NdefRecordLike[] }; +}; + +type NdefReaderLike = EventTarget & { + scan(options?: { signal?: AbortSignal }): Promise; +}; + +type NdefReaderConstructor = new () => NdefReaderLike; + +function decodeRecord(record: NdefRecordLike) { + if (!record.data) return undefined; + const bytes = new Uint8Array(record.data.buffer, record.data.byteOffset, record.data.byteLength); + return new TextDecoder().decode(bytes); +} + +export function webNfcSupport() { + const available = typeof window !== "undefined" && "NDEFReader" in window; + const secure = typeof window !== "undefined" && window.isSecureContext; + return { + available, + secure, + ready: available && secure, + reason: !secure ? "Web NFC requires a secure HTTPS context." : !available ? "Web NFC is not available in this browser. Use Android Chrome or a reader bridge." : "Ready" + }; +} + +export async function startWebNfcScan(onRead: (read: WebNfcRead) => void, onError: (error: Error) => void) { + const support = webNfcSupport(); + if (!support.ready) throw new Error(support.reason); + + const Reader = (window as unknown as { NDEFReader: NdefReaderConstructor }).NDEFReader; + const reader = new Reader(); + const controller = new AbortController(); + + reader.addEventListener("readingerror", () => onError(new Error("NFC tag detected but NDEF data could not be read."))); + reader.addEventListener("reading", ((event: NdefReadingEventLike) => { + try { + const records = event.message.records.map((record) => ({ + recordType: record.recordType, + mediaType: record.mediaType, + text: decodeRecord(record) + })); + const urlRecord = records.find((record) => record.recordType === "url" || record.recordType === "absolute-url"); + const serialNumber = (event.serialNumber ?? "").replace(/[:\s-]/g, "").toUpperCase(); + onRead({ serialNumber, url: urlRecord?.text, records }); + } catch (error) { + onError(error instanceof Error ? error : new Error(String(error))); + } + }) as EventListener); + + await reader.scan({ signal: controller.signal }); + return () => controller.abort(); +} From 75dc566d4c33693e30b3f2383498f8897594cefd Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:17:29 +0200 Subject: [PATCH 35/42] refactor: rebuild operational UI for NFC RAIN and security --- web/App.svelte | 679 +++++++++++++++++-------------------------------- 1 file changed, 238 insertions(+), 441 deletions(-) diff --git a/web/App.svelte b/web/App.svelte index 64465d8..c39b056 100644 --- a/web/App.svelte +++ b/web/App.svelte @@ -1,541 +1,338 @@
- - - {#if message} -
{message}
- {/if} - - {#if page === "dashboard"} - - -
-
{items.length}Items
-
{sessions.length}Sessions
-
{openSessions}Open sessions
-
{unknownTags.length}Unknown tags
-
- -
-
-

Recent sessions

- - - - {#each sessions.slice(0, 6) as session} - openSession(session.session_key)}> - - - - - - {/each} - -
SessionContainerStatusReads
{session.session_key}{session.container_code ?? ""}{session.status}{session.unique_reads}
-
-
-

Unknown tags

- - - - {#each unknownTags.slice(0, 6) as tag} - - - - - - {/each} - -
EPCReadsAction
{tag.epc}{tag.total_reads}
+ +

{page}

{navItems.find((item) => item.page === page)?.label}

{#if activeSession}{activeSession.session_key} · {activeSession.status}{/if}
+ {#if message}
{message}
{/if} + {#if errorMessage}{/if} + + {#if loading} +
Loading operational data…Refreshing tags, sessions, security state and reader configuration.
+ {:else if page === "dashboard"} +
+
Active items{dashboard?.counts.active_items ?? 0}{dashboard?.counts.active_tags ?? 0} linked tags
+
RAIN / UHF{dashboard?.counts.uhf_tags ?? 0}bulk inventory identities
+
NFC{dashboard?.counts.nfc_tags ?? 0}{dashboard?.counts.secured_tags ?? 0} security profiles
+
0}>Auth alerts{dashboard?.counts.auth_alerts ?? 0}failed, replay or error
+
0}>Tamper alerts{dashboard?.counts.tamper_alerts ?? 0}current or permanent
+
+
+

Needs attention

Authentication and tamper exceptions requiring an operator decision.

+ {#if securityAlerts.length === 0}
No security exceptions. Fresh verified events will appear in the event stream.
{:else}
{#each securityAlerts.slice(0, 8) as tag} editTag(tag)}>{/each}
Item / tagTechnologyAuthenticationCurrent tamperPermanentLast seen
{tag.item_name ?? "Unassigned tag"}{tag.identifier}{tag.technology}{tag.chip_model ?? tag.product_family ?? "generic"}{tag.last_auth_status}{tag.last_tamper_status}{tag.permanent_tamper_status}{formatDate(tag.last_seen_at)}
{/if}
-
- {:else if page === "inventory"} - - -
- - - - +

Reader readiness

Inputs available to this browser.

UHF keyboard/HID
ready
Web NFC
{nfcState.ready ? "ready" : "unavailable"}
Authenticated reader bridge
not configured
Open inventory sessions
{dashboard?.counts.open_sessions ?? 0}
+

Recent tag events

Immutable point scans, verification outcomes and status transitions.

{#if (dashboard?.recentEvents.length ?? 0) === 0}
No NFC/security events recorded yet.
{:else}
{#each dashboard?.recentEvents ?? [] as event}{/each}
TimeEventItem / identifierTechnologyAuthTamperSource
{formatDate(event.occurred_at)}{event.event_kind}{event.item_name ?? "Unassigned"}{event.identifier}{event.technology}{event.auth_status}{event.tamper_status}{event.source}
{/if}
-
-
{activeSession?.status ?? "No session"}Status
-
{activeSession?.reads?.length ?? 0}Unique tags
-
{totalRawReads}Raw reads
-
{knownReads} / {unknownReads}Known / Unknown
+ {:else if page === "inventory"} +
{#if activeSession}Export CSV{/if}
+
Session{activeSession?.status ?? "none"}{activeSession?.container_code ?? "Start or open a session"}
Unique identities{activeSession?.reads.length ?? 0}NFC + RAIN
Raw reads{totalRawReads}aggregated per identity
0}>Exceptions{inventoryAlerts}auth or tamper
+

Capture

UHF keyboard/HID for bulk reads; Web NFC for point verification.

{lastScannedIdentifier || "waiting"}
UHF / RAIN

Focus remains on the hidden scanner input. Paste an EPC here for manual testing.

NFC / Web NFC

{nfcState.reason}

Last result

{lastScanStatus}

{#if lastNdefUrl}{lastNdefUrl}{/if}
+

Session observations

Operational counts with latest verification and tamper state.

{#if !activeSession}
No session selectedStart a new session or open one from Sessions.
{:else if activeSession.reads.length === 0}
Session is open. Scan UHF tags or tap NFC tags to begin.
{:else}
{#each activeSession.reads as read}{/each}
ItemIdentifierTechnologyReadsAuthCurrent tamperPermanentLast seen
{read.item_name ?? "Unknown tag"}{read.item_sku ?? "Registration needed"}{read.identifier}{read.chip_model ?? read.product_family ?? ""}{read.technology}{read.read_count}{read.auth_status}{read.tamper_status}{read.permanent_tamper_status}{formatDate(read.last_seen_at)}
{/if}
+ + {:else if page === "tags"} +

Register / assign tag

One item can own multiple NFC and RAIN identities.

{#if selectedTag}
Selected{selectedTag.identifier}{selectedTag.product_family ?? selectedTag.chip_model ?? "Generic tag"}
{/if}
+

Tag registry

Technology, ownership, product identity and last security state.

{filteredTags.length} results
{#if filteredTags.length === 0}
No tags match the active filters.
{:else}
{#each filteredTags as tag}{/each}
Item / identifierTechnologyChip / productAuthTamperStatusActions
editTag(tag)}>{tag.item_name ?? "Unassigned"}{tag.identifier}{tag.technology}{tag.chip_model ?? "Generic"}{tag.product_family ?? tag.part_number ?? ""}{tag.last_auth_status}{tag.last_tamper_status}perm: {tag.permanent_tamper_status}{tag.status}{#if tag.status === "active"}{:else}{/if}
{/if}
-
- - - + {:else if page === "items"} +

{editingItemId ? "Edit item" : "Create item"}

Inventory metadata independent from tag technology.

{#if editingItemId}{/if}
+

Inventory items

Assets with linked NFC and RAIN identities.

{items.length} active
{#if items.length === 0}
No items yetCreate an item or load demo data from Reports.
{:else}
{#each items as item}{/each}
ItemCategoryTagsNFCRAINActions
{item.name}{item.sku ?? "No SKU"}{item.category ?? "—"}{item.tag_count ?? 0}{item.nfc_tag_count ?? 0}{item.uhf_tag_count ?? 0}
{/if}
- - - - {#each activeSession?.reads ?? [] as read} - - - - - - - - - {/each} - -
ItemEPCReadsConfidenceLast seenActions
{read.item_name ?? "Unknown tag"}{read.item_sku ?? "Registration needed"}{read.epc}{read.read_count}{confidenceLabel(read.read_count)}{read.last_seen_at ?? ""} - {#if !read.item_name} - - - - {/if} -
- {:else if page === "register"} - - -
-
Scanned EPC{scannedEpc || "Waiting for scan"}
-
Tag status{tagStatus}
-
Reader modeHID Keyboard
+ {:else if page === "security"} +
Secured tags{dashboard?.counts.secured_tags ?? 0}assigned profiles
0}>Auth alerts{dashboard?.counts.auth_alerts ?? 0}failed / replay / error
0}>Tamper alerts{dashboard?.counts.tamper_alerts ?? 0}current + permanent
Profiles{profiles.length}SUN, SDM, reader bridge
+

Verification profile

References a server-mounted key. Never enter AES key material here.

+

Profiles and alerts

Self-hosted verification configuration plus current exceptions.

Profiles

{#if profiles.length === 0}
No verification profiles configured. Sample tags must be inspected before provisioning assumptions are made.
{:else}
{#each profiles as profile} editProfile(profile)}>{/each}
ProfileTechnologyVerifierKey referenceStatus
{profile.name}{profile.profile_key}{profile.technology}{profile.verifier}{profile.key_ref ?? "—"}{profile.status}
{/if}

Current security alerts

{#if securityAlerts.length === 0}
No active security alerts.
{:else}
{#each securityAlerts as tag} editTag(tag)}>{/each}
TagItemAuthCounterTamperPermanent
{tag.identifier}{tag.chip_model ?? ""}{tag.item_name ?? "Unassigned"}{tag.last_auth_status}{tag.last_auth_counter ?? "—"}{tag.last_tamper_status}{tag.permanent_tamper_status}
{/if}
- - - {#if !selectedItemId} -
- - - - - - -
- {/if} - {:else if page === "items"} - - -
- - - - - - -
+ {:else if page === "readers"} +

Web NFC

Point verification on Android Chrome, baseline Pixel 7 Pro or newer.

{nfcState.ready ? "ready" : "unavailable"}

{nfcState.reason}

UHF keyboard / HID

Bulk EPC capture from readers that emulate a keyboard.

ready

100 ms scan grouping. Works without a hardware-specific driver.

Authenticated reader bridge

Required for low-level NFC mutual auth/protected memory and UCODE DNA challenge-response.

hardware pending

No USB NFC or authenticated RAIN reader model has been selected. The backend contract is ready; vendor binding waits for hardware selection.

Stations

Persistent logical endpoints used by sessions and event attribution.

{#each stations as station} stationForm = { stationKey: station.station_key, name: station.name, inputMode: station.input_mode, deviceLabel: station.device_label ?? "" }}>{/each}
StationTypeInputDevice
{station.name}{station.station_key}{station.type}{station.input_mode}{station.device_label ?? "—"}
- - - - {#each items as item} - - - - - - - - {/each} - -
NameSKUCategoryNotesActions
{item.name}{item.sku ?? ""}{item.category ?? ""}{item.notes ?? ""} - - -
{:else if page === "sessions"} - - - - - - {#each sessions as session} - openSession(session.session_key)}> - - - - - - - - {/each} - -
SessionContainerLocationStatusUniqueRaw
{session.session_key}{session.container_code ?? ""}{session.location_name ?? ""}{session.status}{session.unique_reads}{session.raw_reads}
- {:else if page === "reader"} - - -
- - - - -
+

Inventory sessions

Mixed RAIN bulk observations and NFC point verification in one audit context.

{#if sessions.length === 0}
No inventory sessions yet.
{:else}
{#each sessions as session} openSession(session.session_key)}>{/each}
SessionLocation / containerStatusUniqueRAINNFCAuth alertsTamperStarted
{session.session_key}{session.location_name ?? "—"}{session.container_code ?? ""}{session.status}{session.unique_reads}{session.uhf_reads ?? 0}{session.nfc_reads ?? 0}{session.auth_alerts ?? 0}{session.tamper_alerts ?? 0}{formatDate(session.started_at)}
{/if}
-
-
Last EPC{scannedEpc || "No scan yet"}
-
Status{tagStatus}
-
Saved stations{stations.length}
-
- - - {:else} - - -
-
-

Unknown tags

- - - - {#each unknownTags as tag} - - - - - - - - {/each} - -
EPCSessionsTotal readsLast seenAction
{tag.epc}{tag.session_count}{tag.total_reads}{tag.last_seen_at}
-
-
-

Items last seen

- - - - {#each lastSeen as row} - - - - - - - {/each} - -
ItemEPCLast seenReads
{row.name}{row.sku ?? ""}{row.epc ?? "No tag"}{row.last_seen_at ?? "Never"}{row.total_reads ?? 0}
-
-
+ {:else if page === "reports"} +

Unknown observed tags

Identifiers seen in sessions without an active registry record.

{#if unknownTags.length === 0}
No unknown observed identifiers.
{:else}
{#each unknownTags as row}{/each}
TechnologyIdentifierSessionsReadsLast seen
{row.technology}{row.identifier}{row.session_count}{row.total_reads}{formatDate(row.last_seen_at)}
{/if}

Admin actions

Local maintenance only.

Demo data contains no keys or security secrets.

Items last seen

Latest observation by item and tag identity.

{#if lastSeen.length === 0}
No observations available.
{:else}
{#each lastSeen as row}{/each}
ItemTechnologyIdentifierChipTotal readsLast seen
{row.name}{row.sku ?? ""}{row.technology ?? "—"}{row.identifier ?? "—"}{row.chip_model ?? row.product_family ?? "—"}{row.total_reads ?? 0}{formatDate(row.last_seen_at)}
{/if}

Immutable security event stream

Verification, replay, current/permanent tamper and NFC point scans.

{#if events.length === 0}
No security/status events yet.
{:else}
{#each events as event}{/each}
TimeKindTagItemAuthCounterCurrentPermanentSource
{formatDate(event.occurred_at)}{event.event_kind}{event.technology}{event.identifier}{event.item_name ?? "Unassigned"}{event.auth_status}{event.auth_counter ?? "—"}{event.tamper_status}{event.permanent_tamper_status}{event.source}
{/if}
{/if}
From b21b184a8a2ca9d149864a996685de5dea8a20e0 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:18:08 +0200 Subject: [PATCH 36/42] refactor: apply dense enterprise operational design system --- web/styles.css | 529 +++++++++++++++++++------------------------------ 1 file changed, 202 insertions(+), 327 deletions(-) diff --git a/web/styles.css b/web/styles.css index aae7f91..c1556d3 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1,339 +1,214 @@ :root { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - color: #182026; - background: #f5f2ec; + color: #17212b; + background: #f3f5f6; font-synthesis: none; text-rendering: optimizeLegibility; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; -} - -button, -input, -select, -textarea { - font: inherit; -} - -button { - border: 0; + --bg: #f3f5f6; + --surface: #ffffff; + --surface-subtle: #f8f9fa; + --border: #d8dee3; + --border-strong: #c3ccd3; + --text: #17212b; + --muted: #63717d; + --sidebar: #17232b; + --sidebar-hover: #223540; + --accent: #19686d; + --accent-strong: #12575b; + --good-bg: #e7f5ef; + --good-text: #1e654e; + --warn-bg: #fff4d5; + --warn-text: #7c5a07; + --danger-bg: #fbe9e7; + --danger-text: #a23830; +} + +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); } +button, input, select, textarea { font: inherit; } +button, .button-link { + border: 1px solid transparent; border-radius: 6px; - background: #1d5c63; - color: white; + background: var(--accent); + color: #fff; cursor: pointer; - min-height: 40px; - padding: 0 14px; -} - -button.secondary { - background: #46515c; -} - -button.danger { - background: #a33c35; -} - -button:disabled { - cursor: not-allowed; - opacity: 0.45; -} - -.button-link { - align-items: center; - background: #46515c; - border-radius: 6px; - color: white; - display: inline-flex; - min-height: 40px; - padding: 0 14px; - text-decoration: none; -} - -.button-link.disabled { - opacity: 0.45; - pointer-events: none; -} - -button.compact { - min-height: 32px; - padding: 0 10px; -} - -.app-shell { - display: grid; - grid-template-columns: 248px 1fr; - min-height: 100vh; -} - + min-height: 36px; + padding: 0 12px; + font-weight: 650; + transition: background 120ms ease, border-color 120ms ease, opacity 120ms ease; +} +button:hover, .button-link:hover { background: var(--accent-strong); } +button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { + outline: 3px solid rgba(25, 104, 109, 0.22); + outline-offset: 2px; +} +button:disabled { cursor: not-allowed; opacity: .45; } +button.secondary, .button-link.secondary { background: #44545f; } +button.secondary:hover, .button-link.secondary:hover { background: #35444d; } +button.danger { background: #a5423a; } +button.danger:hover { background: #87352f; } +button.quiet { background: transparent; color: #3a4a55; border-color: var(--border); } +button.quiet:hover { background: #edf1f3; } +button.compact { min-height: 30px; padding: 0 9px; font-size: 12px; } +button.full, .button-link.full { width: 100%; } +.button-link { display: inline-flex; align-items: center; text-decoration: none; } + +.app-shell { min-height: 100vh; display: grid; grid-template-columns: 216px minmax(0, 1fr); } .sidebar { - background: #162126; - color: white; + position: sticky; + top: 0; + height: 100vh; + background: var(--sidebar); + color: #eef4f6; + padding: 18px 14px; display: flex; flex-direction: column; - justify-content: space-between; - padding: 24px; -} - -.sidebar h1 { - font-size: 24px; - margin: 0 0 8px; -} - -.status { - color: #c9d3d7; - margin: 0; -} - -nav { - display: grid; - gap: 8px; - margin-top: 32px; -} - -nav button { - background: transparent; - color: #edf3f5; - text-align: left; -} - -nav button.active, -nav button:hover { - background: #284149; -} - -.sidebar-actions { - display: grid; - gap: 8px; - margin-top: 24px; -} - -.workspace { - padding: 28px; - overflow-x: auto; -} - -.scan-capture { - height: 1px; - opacity: 0.02; - position: absolute; - width: 1px; -} - -.notice { - background: #fff8d8; - border: 1px solid #e3cb66; - border-radius: 6px; - margin-bottom: 16px; - padding: 12px 14px; -} - -.page-header { - align-items: center; - display: flex; - gap: 20px; - justify-content: space-between; - margin-bottom: 20px; -} - -.eyebrow { - color: #7b4b2a; - font-size: 13px; - font-weight: 700; - letter-spacing: 0; - margin: 0 0 4px; - text-transform: uppercase; -} - -h2 { - font-size: 30px; - line-height: 1.15; - margin: 0; -} - -h3 { - font-size: 18px; - margin: 0 0 12px; -} - -.actions { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.form-grid { - display: grid; - gap: 14px; - grid-template-columns: repeat(4, minmax(160px, 1fr)); - margin-bottom: 20px; -} - -.form-grid.two { - grid-template-columns: repeat(2, minmax(220px, 1fr)); -} - -label { - color: #3f4b53; - display: grid; - font-size: 13px; - font-weight: 700; - gap: 6px; -} - -.wide { - grid-column: 1 / -1; -} - -input, -select, -textarea { - background: white; - border: 1px solid #cfd6d8; - border-radius: 6px; - color: #182026; - min-height: 42px; - padding: 10px 12px; -} - -textarea { - min-height: 88px; - resize: vertical; -} - -.metrics, -.reader-strip { - display: grid; - gap: 12px; - grid-template-columns: repeat(4, minmax(140px, 1fr)); - margin: 18px 0; -} - -.metrics div, -.reader-strip div { - background: white; - border: 1px solid #d9dcda; - border-radius: 8px; - padding: 14px; -} - -.metrics span, -.reader-strip span { - color: #66737b; - display: block; - font-size: 13px; - margin-bottom: 8px; -} - -.metrics strong, -.reader-strip strong { - display: block; - overflow-wrap: anywhere; -} - -.reader-strip { - grid-template-columns: repeat(3, minmax(140px, 1fr)); -} - -.quick-scans { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin: 0 0 14px; -} - -.split { - display: grid; gap: 18px; - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.split section { - min-width: 0; -} - -table { - background: white; - border-collapse: collapse; - border-radius: 8px; - overflow: hidden; - width: 100%; -} - -th, -td { - border-bottom: 1px solid #e2e5e3; - padding: 12px 14px; - text-align: left; - vertical-align: top; -} - -th { - background: #e9ece7; - color: #344149; - font-size: 13px; - text-transform: uppercase; -} - -td small { - color: #69767d; - display: block; - margin-top: 3px; -} - -tbody tr:hover { - background: #f7faf8; } - -.row-actions { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.low { - color: #a33c35; - font-weight: 700; -} - -.mono { - font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace; - font-size: 13px; - overflow-wrap: anywhere; -} - -@media (max-width: 900px) { - .app-shell { - grid-template-columns: 1fr; - } - - .sidebar { - display: block; - } - - nav { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .page-header { - align-items: stretch; - flex-direction: column; - } - - .form-grid, - .form-grid.two, - .metrics, - .reader-strip, - .split { - grid-template-columns: 1fr; - } - - .workspace { - padding: 18px; - } +.brand-block { display: flex; align-items: center; gap: 10px; padding: 4px 6px 8px; } +.brand-mark { width: 36px; height: 36px; border-radius: 7px; background: #d8efec; color: #174e52; display: grid; place-items: center; font-size: 13px; font-weight: 900; } +.brand-block h1 { margin: 0; font-size: 16px; line-height: 1.2; } +.brand-block p { margin: 3px 0 0; color: #aab8bf; font-size: 11px; } +nav { display: grid; gap: 3px; } +nav button { min-height: 44px; padding: 7px 10px; background: transparent; border-color: transparent; text-align: left; display: grid; gap: 1px; } +nav button span { font-size: 13px; } +nav button small { color: #91a2ab; font-size: 10px; font-weight: 500; } +nav button:hover, nav button.active { background: var(--sidebar-hover); } +nav button.active { border-color: #35505e; } +nav button.active small { color: #c4d2d8; } +.sidebar-footer { margin-top: auto; border-top: 1px solid #30434d; padding-top: 14px; display: grid; gap: 10px; } +.sidebar-footer .quiet { color: #d9e3e7; border-color: #40545f; } +.sidebar-footer .quiet:hover { background: #223540; } +.server-line { display: grid; grid-template-columns: 8px 1fr auto; align-items: center; gap: 7px; font-size: 11px; color: #aebbc2; } +.server-line strong { color: #eef4f6; font-size: 12px; } +.dot { width: 8px; height: 8px; border-radius: 999px; background: #9c5852; } +.dot.online { background: #54b68f; } + +.workspace { min-width: 0; padding: 18px 22px 34px; overflow-x: hidden; } +.scan-capture { position: fixed; width: 1px; height: 1px; opacity: .001; pointer-events: none; } +.context-bar { min-height: 58px; display: flex; justify-content: space-between; align-items: center; gap: 18px; border-bottom: 1px solid var(--border); margin-bottom: 16px; padding: 0 0 14px; } +.context-bar h2 { margin: 0; font-size: 24px; line-height: 1.1; } +.eyebrow { margin: 0 0 3px; text-transform: uppercase; font-size: 10px; letter-spacing: .08em; color: var(--muted); font-weight: 800; } +.context-actions, .actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.context-chip { display: inline-flex; align-items: center; min-height: 32px; padding: 0 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: #43515b; font-size: 11px; } + +.notice { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 14px; padding: 9px 11px; border: 1px solid; border-radius: 6px; font-size: 13px; } +.notice.success { background: var(--good-bg); color: var(--good-text); border-color: #b8decf; } +.notice.error { background: var(--danger-bg); color: var(--danger-text); border-color: #efc1bd; } +.notice button { min-height: 24px; padding: 0 7px; background: transparent; color: inherit; border-color: currentColor; } + +.metric-strip { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 7px; overflow: hidden; margin: 0 0 14px; } +.metric-strip.four { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.metric-strip article { min-width: 0; background: var(--surface); padding: 12px 14px; } +.metric-strip article.metric-alert { background: #fff9f8; } +.metric-strip article > span { display: block; color: var(--muted); font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: .05em; } +.metric-strip article > strong { display: block; font-size: 25px; line-height: 1; margin: 6px 0; font-variant-numeric: tabular-nums; } +.metric-strip article > strong.metric-text { font-size: 18px; text-transform: capitalize; } +.metric-strip article > small { display: block; color: var(--muted); font-size: 10px; } +.metric-strip article.metric-alert > strong { color: var(--danger-text); } + +.dashboard-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; align-items: start; } +.span-2 { grid-column: span 2; } +.span-3 { grid-column: span 3; } +.panel { min-width: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 7px; } +.panel-head { min-height: 56px; display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 12px 14px; border-bottom: 1px solid var(--border); } +.panel-head h3 { margin: 0; font-size: 14px; } +.panel-head p { margin: 3px 0 0; color: var(--muted); font-size: 11px; line-height: 1.35; } +.panel-head > span { color: var(--muted); font-size: 11px; white-space: nowrap; } +h4 { margin: 14px; font-size: 12px; text-transform: uppercase; color: #4e5d67; letter-spacing: .04em; } + +.table-wrap { width: 100%; overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: 12px; } +th, td { padding: 9px 11px; border-bottom: 1px solid #e5e9ec; text-align: left; vertical-align: top; } +th { background: var(--surface-subtle); color: #52616b; font-size: 10px; letter-spacing: .03em; text-transform: uppercase; white-space: nowrap; position: sticky; top: 0; z-index: 1; } +tbody tr { transition: background 100ms ease; } +tbody tr:hover { background: #f6f9fa; } +tbody tr.selected { background: #edf7f6; } +td strong { display: block; font-size: 12px; } +td small { display: block; margin-top: 3px; color: var(--muted); font-size: 10px; } +.mono { font-family: "SFMono-Regular", "Cascadia Code", Consolas, monospace; letter-spacing: -.02em; } +.break { overflow-wrap: anywhere; } +.row-actions { display: flex; flex-wrap: wrap; gap: 5px; white-space: nowrap; } +.badge { display: inline-flex; align-items: center; min-height: 21px; border-radius: 4px; background: #edf1f3; border: 1px solid #dce3e7; padding: 0 6px; font-size: 10px; font-weight: 800; text-transform: uppercase; white-space: nowrap; } +.status-pill { display: inline-flex; align-items: center; min-height: 21px; border-radius: 999px; padding: 0 7px; font-size: 10px; font-weight: 800; white-space: nowrap; background: #eef1f3; color: #52616b; } +.status-pill.good { background: var(--good-bg); color: var(--good-text); } +.status-pill.warn { background: var(--warn-bg); color: var(--warn-text); } +.status-pill.danger { background: var(--danger-bg); color: var(--danger-text); } +.status-pill.neutral { background: #eef1f3; color: #52616b; } + +.status-list { margin: 0; padding: 4px 14px 10px; } +.status-list > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid #edf0f2; padding: 10px 0; } +.status-list > div:last-child { border-bottom: 0; } +.status-list dt { font-size: 11px; color: #47555f; } +.status-list dd { margin: 0; } + +.state-panel { min-height: 120px; display: grid; place-content: center; justify-items: center; text-align: center; gap: 5px; padding: 28px; color: #3d4b55; } +.state-panel span { color: var(--muted); font-size: 11px; } +.empty-row { padding: 22px 14px; color: var(--muted); font-size: 11px; text-align: center; } + +.action-band { background: var(--surface); border: 1px solid var(--border); border-radius: 7px; padding: 10px; margin-bottom: 14px; display: flex; justify-content: space-between; align-items: end; gap: 12px; } +.form-row { display: flex; flex-wrap: wrap; align-items: end; gap: 9px; padding: 12px 14px; } +.action-band .form-row { padding: 0; flex: 1; } +.compact-fields label { min-width: 145px; flex: 1; } +.form-stack { display: grid; gap: 11px; padding: 14px; } +label { display: grid; gap: 5px; color: #45535d; font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: .03em; } +input, select, textarea { width: 100%; min-height: 36px; border: 1px solid var(--border-strong); border-radius: 5px; background: #fff; color: var(--text); padding: 7px 9px; font-size: 12px; } +textarea { min-height: 76px; resize: vertical; } +.inline-input { display: flex; gap: 7px; } +.inline-input input { min-width: 0; } +.filter-bar { display: grid; grid-template-columns: minmax(200px, 1fr) 150px 130px; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--surface-subtle); } +.two-column { display: grid; grid-template-columns: minmax(260px, 330px) minmax(0, 1fr); gap: 14px; align-items: start; } +.inspector { position: sticky; top: 14px; } +.inspector-note { margin: 0 14px 14px; padding: 10px; background: var(--surface-subtle); border: 1px solid var(--border); border-radius: 5px; display: grid; gap: 4px; font-size: 11px; } +.main-list { min-width: 0; } + +.scan-console { margin-bottom: 14px; } +.capture-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); } +.capture-grid > div { min-width: 0; padding: 14px; border-right: 1px solid var(--border); } +.capture-grid > div:last-child { border-right: 0; } +.capture-grid strong { font-size: 12px; } +.capture-grid p, .reader-state p { color: var(--muted); font-size: 11px; line-height: 1.45; } +button.nfc-live { background: #9a4c24; } +.reader-state { padding: 14px; } +.admin-actions { display: grid; gap: 8px; padding: 14px; } +.muted { color: var(--muted); font-size: 10px; line-height: 1.4; } + +@media (max-width: 1180px) { + .metric-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .dashboard-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .span-3 { grid-column: span 2; } + .capture-grid { grid-template-columns: 1fr; } + .capture-grid > div { border-right: 0; border-bottom: 1px solid var(--border); } + .capture-grid > div:last-child { border-bottom: 0; } +} + +@media (max-width: 860px) { + .app-shell { grid-template-columns: 1fr; } + .sidebar { position: static; width: 100%; height: auto; padding: 10px; gap: 10px; } + .brand-block { padding: 2px; } + nav { grid-template-columns: repeat(4, minmax(0, 1fr)); } + nav button { min-height: 42px; } + .sidebar-footer { display: none; } + .workspace { padding: 14px; } + .context-bar { align-items: flex-start; } + .context-actions { justify-content: flex-end; } + .metric-strip, .metric-strip.four { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dashboard-grid, .two-column { grid-template-columns: 1fr; } + .span-2, .span-3 { grid-column: span 1; } + .inspector { position: static; } + .action-band { align-items: stretch; flex-direction: column; } + .filter-bar { grid-template-columns: 1fr; } +} + +@media (max-width: 560px) { + nav { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .context-bar { flex-direction: column; } + .context-actions { width: 100%; justify-content: flex-start; } + .context-chip { max-width: 100%; overflow: hidden; text-overflow: ellipsis; } + .metric-strip, .metric-strip.four { grid-template-columns: 1fr; } + .form-row, .compact-fields { display: grid; grid-template-columns: 1fr; width: 100%; } + .compact-fields label { min-width: 0; } + .actions { width: 100%; } + .actions button, .actions .button-link { flex: 1; justify-content: center; } + th, td { padding: 8px 9px; } } From a7f81a103fff07e69e3676bac9510640571d9e9b Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:18:45 +0200 Subject: [PATCH 37/42] docs: document unified NFC RAIN security architecture --- README.md | 210 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 116 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 89edb3c..c23442b 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,69 @@ # Storage Tags -Storage Tags is a local browser-based UHF RFID inventory system. It is designed for the Scenario B architecture: the RFID reader is connected to the operator device, the browser captures EPC values, and the local backend stores normalized reads in SQLite. - -## What It Does - -- Registers RFID EPC tags and associates them with inventory items. -- Runs inventory sessions for boxes, shelves, or containers. -- Accepts browser HID keyboard RFID input as the first MVP reader mode. -- Deduplicates repeated reads per inventory session. -- Shows known items, unknown tags, read counts, and confidence labels. -- Lets operators register unknown tags, mark them as external, or ignore them. -- Saves browser station settings for RFID input devices. -- Creates local SQLite backups. -- Loads demo data for testing without a physical RFID reader. -- Exports saved session results as CSV. +Storage Tags is a self-hosted inventory and tag-authentication platform for mixed **RAIN/UHF RFID** and **NFC** workflows. It supports bulk UHF inventory, point NFC verification, tag security/tamper state, local SQLite storage, a browser UI, REST APIs, and an MCP stdio server. + +Current development version: **0.0.1**. + +## Core capabilities + +- Register multiple tag identities against one inventory item, including both UHF EPCs and NFC UIDs. +- Run inventory sessions that aggregate RAIN/UHF reads and NFC point checks in the same audit context. +- Capture UHF readers that behave as keyboard/HID devices. +- Capture NDEF-compatible NFC tags through Web NFC where the browser supports it. +- Store immutable scan, verification, tamper, and sensor events alongside aggregated session observations. +- Perform self-hosted NTAG DNA SUN/SDM verification without a managed verification service. +- Detect replayed SUN/SDM messages by tracking verified counters. +- Track current and permanent/once-opened tamper state where the tag family exposes it. +- Reference AES keys through server-side secret files rather than storing key material in application data. +- Expose the application domain to MCP clients through a stdio MCP server. +- Keep a reader-bridge boundary for low-level NFC operations and authenticated RAIN readers. + +## Tag families represented in the catalog + +The capability catalog currently includes: + +- generic NFC / NDEF; +- generic RAIN / UHF; +- NXP NTAG 223 DNA; +- NXP NTAG 224 DNA; +- NXP NTAG 223 / 224 DNA StatusDetect; +- NXP NTAG 424 DNA and NTAG 424 DNA TagTamper; +- Identiv NTAG 22x DNA portfolio entries; +- Identiv ID-NXP NTAG 424 DNA TT family; +- NXP UCODE DNA; +- NXP UCODE DNA Track; +- NXP UCODE Guard. + +Catalog presence describes capabilities and product identity. Full cryptographic operation still depends on the actual tag configuration and, for low-level operations, a compatible reader. ## Architecture -The data path is: - ```text -RFID Reader -> Browser -> Fastify API -> SQLite +RAIN HID reader ───────┐ + ├─> Browser UI ──> Fastify REST API ──> Domain services ──> SQLite +Android Web NFC ───────┘ │ + ├─> self-hosted SUN / SDM verification +Authenticated reader bridge ────────────────────┘ + +MCP client ──stdio──> Storage Tags MCP server ──> same domain services ──> SQLite ``` -The backend does not need direct USB access to the reader. A low-cost USB-C, USB-A, OTG, or Bluetooth HID RFID reader can type EPC codes into the browser, and the frontend forwards those codes to the API. +The data model is technology-aware rather than EPC-only. A tag is keyed by `(technology, identifier)` and can carry transport-specific fields such as EPC, TID, or UID. + +## Web NFC and reader bridge + +Web NFC is used only for browser-accessible NDEF flows. It is suitable for reading dynamic SUN/SDM URLs on supported Android browsers, but it is not treated as a replacement for low-level NFC commands, protected-memory access, or mutual authentication. + +Those operations, plus UCODE DNA authenticated challenge-response, belong behind the `reader-bridge` adapter. No USB NFC or authenticated RAIN reader model is hard-coded yet. ## Requirements - Node.js 24 or newer - npm -- Docker and Docker Compose, optional for containerized usage +- Linux is the primary server target +- Docker / Docker Compose optional -This project currently uses Node's built-in SQLite API, so no native npm SQLite package or Visual Studio C++ build tools are required for local development. +The intended deployment targets include conventional Linux hosts, Proxmox VMs/LXCs, and Docker. ## Install @@ -39,146 +71,136 @@ This project currently uses Node's built-in SQLite API, so no native npm SQLite npm install ``` -## Run In Development +## Development ```bash npm run dev ``` -Development starts two services: +Development starts: - API: `http://localhost:3000` -- Frontend: `http://localhost:5173` +- frontend: `http://localhost:5173` The Vite frontend proxies `/api` requests to the Fastify backend. -## Build +## Test and build ```bash +npm test npm run build ``` -The frontend is built into `public/`, and the TypeScript backend is built into `dist/`. +Pull requests are expected to pass both commands in GitHub Actions before merge. -## Run The Production Build +## Production ```bash +npm run build npm start ``` -Then open: +Then open `http://localhost:3000`. -```text -http://localhost:3000 -``` - -## Run With Docker +With Docker: ```bash docker compose up --build ``` -The app is exposed at: - -```text -http://localhost:3000 -``` - -SQLite data is stored in the local `data/` directory through the Docker volume mapping. - -## Environment Variables +## Environment variables | Variable | Default | Purpose | | --- | --- | --- | | `HOST` | `0.0.0.0` | Fastify bind host | | `PORT` | `3000` | Fastify port | | `DATABASE_URL` | `data/inventory.sqlite` | SQLite database file | -| `NODE_ENV` | `development` | Enables static frontend serving when set to `production` | -| `RFID_INPUT_MODE` | `browser` in Docker Compose | Documents the intended reader mode | -| `APP_BASE_URL` | `http://localhost:3000` in Docker Compose | Public base URL hint | - -## Main Pages +| `NODE_ENV` | `development` | Runtime mode | +| `STORAGE_TAGS_KEYS_DIR` | `/run/secrets/storage-tags` | Base directory for server-side tag verification keys | -- `Dashboard`: review inventory activity, open sessions, and unknown tags. -- `Inventory`: start and stop container inventory sessions. -- `Register Tag`: scan an EPC and associate it with a new item. -- `Items`: create, edit, and archive inventory items. -- `Sessions`: reopen saved inventory sessions. -- `Reader`: test HID keyboard input, inspect raw input, and save station settings. -- `Reports`: review unknown tags and item last-seen data. +Verification profiles store a **key reference** such as `production/oil-sdm-file-read.key`. They do not store the AES key itself. A key file may contain either 16 raw bytes or 32 hexadecimal characters. -## RFID Reader Usage +## Main UI surfaces -1. Connect the RFID reader to the operator device. -2. Open the app in a browser. -3. Go to `Reader`, `Register Tag`, or `Inventory`. -4. Make sure the browser page is active. -5. Scan a tag. -6. The reader should type the EPC and usually submit it with Enter or Tab. +- **Dashboard** — operational metrics, security exceptions, reader readiness, recent immutable events. +- **Inventory** — mixed RAIN/NFC session capture and CSV export. +- **Tags** — generic tag registry, product family, item assignment, security-profile assignment. +- **Items** — inventory item lifecycle and linked-tag counts. +- **Security** — SUN/SDM/reader-bridge profiles, replay/tamper alerts, point verification. +- **Readers** — HID, Web NFC and reader-bridge station configuration. +- **Sessions** — session history with NFC/UHF/security metrics. +- **Reports** — unknown identifiers, last-seen data and immutable security event history. -For Android devices, the reader must be supported as USB OTG, Bluetooth HID, or another keyboard-like input device. +## REST API overview -## API Overview +### Items -- `GET /api/health` - `GET /api/items` -- `POST /api/items` - `GET /api/items/:id` +- `POST /api/items` - `PUT /api/items/:id` - `DELETE /api/items/:id` + +### Tags and observations + +- `GET /api/tag-catalog` - `GET /api/tags` -- `GET /api/tags/:epc` -- `POST /api/tags/register` -- `PUT /api/tags/:epc/status` +- `GET /api/tags/:technology/:identifier` +- `POST /api/tags` +- `PUT /api/tags/:technology/:identifier/status` - `POST /api/tags/mark-unknown` -- `POST /api/rfid/browser-read` -- `POST /api/rfid/batch-browser-read` +- `POST /api/observations` +- `POST /api/observations/batch` +- `GET /api/events` + +`POST /api/rfid/browser-read` remains as a compatibility adapter for browser-HID UHF reads. + +### Security + +- `GET /api/security/profiles` +- `PUT /api/security/profiles/:profileKey` +- `POST /api/security/verify` + +### Inventory and stations + - `GET /api/inventory-sessions` - `POST /api/inventory-sessions` - `GET /api/inventory-sessions/:sessionKey` - `POST /api/inventory-sessions/:sessionKey/close` - `GET /api/stations` -- `POST /api/stations` - `GET /api/stations/:stationKey` +- `POST /api/stations` - `PUT /api/stations/:stationKey` + +### Reports and administration + +- `GET /api/reports/dashboard` - `GET /api/reports/unknown-tags` - `GET /api/reports/items-last-seen` +- `GET /api/reports/security-alerts` - `GET /api/reports/session/:sessionKey/csv` - `POST /api/admin/backup` - `POST /api/admin/seed-demo` -## Testing Without A Reader - -1. Start the app. -2. Click `Load Demo Data`. -3. Open `Inventory`. -4. Start a session. -5. Use the demo scan buttons to simulate known and unknown RFID reads. -6. Open `Reports` to review unknown tags and last-seen item data. +## MCP server -The demo EPC values are: +After building the project: -```text -3034257BF7194E4000001A85 -3034257BF7194E4000001A86 -E2806894000040178F2A91B5 +```bash +npm run mcp ``` -## Backups +The MCP server communicates over stdio and exposes the same domain operations used by the REST/UI layers: dashboard summary, items, tags, observations, immutable events, security profiles and verification, sessions, stations, reports, backup, and demo seeding. -Click `Create Backup` in the sidebar or call: +Key material is never returned as an MCP result. Security tools expose only profile metadata and key references. -```bash -curl -X POST http://localhost:3000/api/admin/backup \ - -H "Content-Type: application/json" \ - -d "{}" -``` +## Demo data -Backups are written under: +The demo seed contains non-secret inventory records with mixed UHF/NFC identities. It intentionally does not configure verification keys. -```text -data/backups/ -``` +## Development workflow + +Feature work is developed on branches and submitted through pull requests. `CHANGELOG.md` is maintained from version `0.0.1`; release tags are created only after the corresponding pull request passes tests, is reviewed, and is merged. ## License From 414bc5f892a31ecd094c9638d777d07cb1b4cd2a Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:19:24 +0200 Subject: [PATCH 38/42] fix: type dynamic tag query values for node sqlite --- src/modules/tags/tags.service.ts | 164 ++++++++++++++----------------- 1 file changed, 75 insertions(+), 89 deletions(-) diff --git a/src/modules/tags/tags.service.ts b/src/modules/tags/tags.service.ts index 6d44bf1..c8b16bb 100644 --- a/src/modules/tags/tags.service.ts +++ b/src/modules/tags/tags.service.ts @@ -24,122 +24,108 @@ export function createTagsService(db: AppDb) { function resolve(technology: TagTechnology, rawIdentifier: string) { const identifier = normalizeTagIdentifier(technology, rawIdentifier); if (!identifier) return null; - return db.prepare(`${selectTagSql()} WHERE tags.technology = ? AND tags.identifier = ?`).get(technology, identifier); } + function register(input: RegisterTagInput) { + const catalog = getCatalogEntry(input.catalogKey); + const technology = catalog?.technology ?? input.technology; + const identifier = normalizeTagIdentifier(technology, input.identifier); + if (!identifier) return { ok: false, error: "INVALID_IDENTIFIER" as const }; + + if (input.itemId !== undefined) { + const item = db.prepare("SELECT id FROM items WHERE id = ? AND status != 'archived'").get(input.itemId); + if (!item) return { ok: false, error: "ITEM_NOT_FOUND" as const }; + } + + let securityProfileId: number | null = null; + if (input.securityProfileKey) { + const profile = db.prepare("SELECT id FROM tag_security_profiles WHERE profile_key = ? AND status = 'active'").get(input.securityProfileKey) as { id: number } | undefined; + if (!profile) return { ok: false, error: "SECURITY_PROFILE_NOT_FOUND" as const }; + securityProfileId = profile.id; + } + + const capabilities = { ...(catalog?.capabilities ?? {}), ...(input.capabilities ?? {}) }; + const timestamp = nowIso(); + const epc = technology === "uhf-rain" ? normalizeTagIdentifier("uhf-rain", input.epc ?? identifier) : input.epc ?? null; + const uid = technology === "nfc" ? normalizeTagIdentifier("nfc", input.uid ?? identifier) : input.uid ?? null; + + db.prepare(` + INSERT INTO tags ( + technology, identifier, epc, tid, uid, + manufacturer, chip_family, chip_model, product_family, part_number, + capabilities_json, metadata_json, security_profile_id, item_id, + status, registered_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(technology, identifier) DO UPDATE SET + epc = COALESCE(excluded.epc, tags.epc), + tid = COALESCE(excluded.tid, tags.tid), + uid = COALESCE(excluded.uid, tags.uid), + manufacturer = COALESCE(excluded.manufacturer, tags.manufacturer), + chip_family = COALESCE(excluded.chip_family, tags.chip_family), + chip_model = COALESCE(excluded.chip_model, tags.chip_model), + product_family = COALESCE(excluded.product_family, tags.product_family), + part_number = COALESCE(excluded.part_number, tags.part_number), + capabilities_json = COALESCE(excluded.capabilities_json, tags.capabilities_json), + metadata_json = COALESCE(excluded.metadata_json, tags.metadata_json), + security_profile_id = COALESCE(excluded.security_profile_id, tags.security_profile_id), + item_id = COALESCE(excluded.item_id, tags.item_id), + status = excluded.status, + updated_at = excluded.updated_at + `).run( + technology, + identifier, + epc, + input.tid ?? null, + uid, + input.manufacturer ?? catalog?.manufacturer ?? null, + input.chipFamily ?? catalog?.chipFamily ?? null, + input.chipModel ?? catalog?.chipModel ?? null, + input.productFamily ?? catalog?.productFamily ?? null, + input.partNumber ?? catalog?.partNumber ?? null, + Object.keys(capabilities).length ? JSON.stringify(capabilities) : null, + input.metadata ? JSON.stringify(input.metadata) : null, + securityProfileId, + input.itemId ?? null, + input.status ?? "active", + timestamp, + timestamp + ); + + return { ok: true, tag: resolve(technology, identifier) }; + } + return { - catalog() { - return tagCatalog; - }, + catalog() { return tagCatalog; }, list(filters: { status?: string; technology?: TagTechnology; search?: string } = {}) { const clauses: string[] = []; - const values: unknown[] = []; - - if (filters.status) { - clauses.push("tags.status = ?"); - values.push(filters.status); - } - if (filters.technology) { - clauses.push("tags.technology = ?"); - values.push(filters.technology); - } + const values: string[] = []; + if (filters.status) { clauses.push("tags.status = ?"); values.push(filters.status); } + if (filters.technology) { clauses.push("tags.technology = ?"); values.push(filters.technology); } if (filters.search) { const term = `%${filters.search}%`; clauses.push("(tags.identifier LIKE ? OR tags.epc LIKE ? OR tags.uid LIKE ? OR tags.chip_model LIKE ? OR tags.product_family LIKE ? OR items.name LIKE ? OR items.sku LIKE ?)"); values.push(term, term, term, term, term, term, term); } - const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : ""; return db.prepare(`${selectTagSql()}${where} ORDER BY COALESCE(tags.last_seen_at, tags.registered_at) DESC, tags.id DESC`).all(...values); }, - getById(id: number) { - return db.prepare(`${selectTagSql()} WHERE tags.id = ?`).get(id); - }, - + getById(id: number) { return db.prepare(`${selectTagSql()} WHERE tags.id = ?`).get(id); }, resolve, - - register(input: RegisterTagInput) { - const catalog = getCatalogEntry(input.catalogKey); - const technology = catalog?.technology ?? input.technology; - const identifier = normalizeTagIdentifier(technology, input.identifier); - if (!identifier) return { ok: false, error: "INVALID_IDENTIFIER" as const }; - - if (input.itemId !== undefined) { - const item = db.prepare("SELECT id FROM items WHERE id = ? AND status != 'archived'").get(input.itemId); - if (!item) return { ok: false, error: "ITEM_NOT_FOUND" as const }; - } - - let securityProfileId: number | null = null; - if (input.securityProfileKey) { - const profile = db.prepare("SELECT id FROM tag_security_profiles WHERE profile_key = ? AND status = 'active'").get(input.securityProfileKey) as { id: number } | undefined; - if (!profile) return { ok: false, error: "SECURITY_PROFILE_NOT_FOUND" as const }; - securityProfileId = profile.id; - } - - const capabilities = { ...(catalog?.capabilities ?? {}), ...(input.capabilities ?? {}) }; - const timestamp = nowIso(); - const epc = technology === "uhf-rain" ? normalizeTagIdentifier("uhf-rain", input.epc ?? identifier) : input.epc ?? null; - const uid = technology === "nfc" ? normalizeTagIdentifier("nfc", input.uid ?? identifier) : input.uid ?? null; - - db.prepare(` - INSERT INTO tags ( - technology, identifier, epc, tid, uid, - manufacturer, chip_family, chip_model, product_family, part_number, - capabilities_json, metadata_json, security_profile_id, item_id, - status, registered_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(technology, identifier) DO UPDATE SET - epc = COALESCE(excluded.epc, tags.epc), - tid = COALESCE(excluded.tid, tags.tid), - uid = COALESCE(excluded.uid, tags.uid), - manufacturer = COALESCE(excluded.manufacturer, tags.manufacturer), - chip_family = COALESCE(excluded.chip_family, tags.chip_family), - chip_model = COALESCE(excluded.chip_model, tags.chip_model), - product_family = COALESCE(excluded.product_family, tags.product_family), - part_number = COALESCE(excluded.part_number, tags.part_number), - capabilities_json = COALESCE(excluded.capabilities_json, tags.capabilities_json), - metadata_json = COALESCE(excluded.metadata_json, tags.metadata_json), - security_profile_id = COALESCE(excluded.security_profile_id, tags.security_profile_id), - item_id = COALESCE(excluded.item_id, tags.item_id), - status = excluded.status, - updated_at = excluded.updated_at - `).run( - technology, - identifier, - epc, - input.tid ?? null, - uid, - input.manufacturer ?? catalog?.manufacturer ?? null, - input.chipFamily ?? catalog?.chipFamily ?? null, - input.chipModel ?? catalog?.chipModel ?? null, - input.productFamily ?? catalog?.productFamily ?? null, - input.partNumber ?? catalog?.partNumber ?? null, - Object.keys(capabilities).length ? JSON.stringify(capabilities) : null, - input.metadata ? JSON.stringify(input.metadata) : null, - securityProfileId, - input.itemId ?? null, - input.status ?? "active", - timestamp, - timestamp - ); - - return { ok: true, tag: resolve(technology, identifier) }; - }, + register, setStatus(technology: TagTechnology, rawIdentifier: string, status: TagStatus) { const identifier = normalizeTagIdentifier(technology, rawIdentifier); if (!identifier) return { ok: false, error: "INVALID_IDENTIFIER" as const }; - const result = db.prepare("UPDATE tags SET status = ?, updated_at = ? WHERE technology = ? AND identifier = ?").run(status, nowIso(), technology, identifier); if (result.changes === 0) return { ok: false, error: "TAG_NOT_FOUND" as const }; return { ok: true, tag: resolve(technology, identifier) }; }, markUnknown(technology: TagTechnology, rawIdentifier: string, status: "ignored" | "external") { - return this.register({ technology, identifier: rawIdentifier, status }); + return register({ technology, identifier: rawIdentifier, status }); } }; } From 6520e8394bb78339bfe1999e20e6be33519cec0e Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:20:29 +0200 Subject: [PATCH 39/42] test: cover mixed NFC and RAIN inventory session --- .../observations/observations.service.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/modules/observations/observations.service.test.ts diff --git a/src/modules/observations/observations.service.test.ts b/src/modules/observations/observations.service.test.ts new file mode 100644 index 0000000..d77edff --- /dev/null +++ b/src/modules/observations/observations.service.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createDb } from "../../db/connection.js"; +import { createItemsService } from "../items/items.service.js"; +import { createSessionsService } from "../sessions/sessions.service.js"; +import { createTagsService } from "../tags/tags.service.js"; +import { createObservationsService } from "./observations.service.js"; + +test("one item can aggregate RAIN and NFC observations in the same session", () => { + const db = createDb(":memory:"); + const items = createItemsService(db); + const tags = createTagsService(db); + const sessions = createSessionsService(db); + const observations = createObservationsService(db); + + const item = items.create({ name: "Mixed tag item", sku: "MIX-001" }) as { id: number }; + + const uhf = tags.register({ + technology: "uhf-rain", + identifier: "3034257BF7194E4000001A85", + itemId: item.id, + catalogKey: "generic-rain" + }); + const nfc = tags.register({ + technology: "nfc", + identifier: "04DE5F1EACC040", + itemId: item.id, + catalogKey: "nxp-ntag424-dna-tt" + }); + assert.equal(uhf.ok, true); + assert.equal(nfc.ok, true); + + const session = sessions.create({ stationKey: "test-station", containerCode: "BOX-1" }) as { session_key: string }; + + assert.equal(observations.process({ + technology: "uhf-rain", + identifier: "3034257BF7194E4000001A85", + source: "browser-hid", + sessionKey: session.session_key, + stationKey: "test-station" + }).ok, true); + assert.equal(observations.process({ + technology: "uhf-rain", + identifier: "3034257BF7194E4000001A85", + source: "browser-hid", + sessionKey: session.session_key, + stationKey: "test-station" + }).ok, true); + assert.equal(observations.process({ + technology: "nfc", + identifier: "04DE5F1EACC040", + uid: "04DE5F1EACC040", + source: "browser-webnfc", + sessionKey: session.session_key, + stationKey: "test-station", + ndefUrl: "https://example.invalid/tag" + }).ok, true); + + const detail = sessions.get(session.session_key) as any; + assert.equal(detail.reads.length, 2); + + const rainRead = detail.reads.find((read: any) => read.technology === "uhf-rain"); + const nfcRead = detail.reads.find((read: any) => read.technology === "nfc"); + assert.equal(rainRead.read_count, 2); + assert.equal(nfcRead.read_count, 1); + assert.equal(rainRead.known_item_id, item.id); + assert.equal(nfcRead.known_item_id, item.id); + + assert.equal(detail.events.length, 1); + assert.equal(detail.events[0].technology, "nfc"); + assert.equal(detail.events[0].event_kind, "scan"); + + db.close(); +}); From f2d38402bb7b4b7a829b10bf03a214740477b958 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:21:26 +0200 Subject: [PATCH 40/42] fix: type inventory session domain records --- src/modules/sessions/sessions.service.ts | 102 +++++++++++++---------- 1 file changed, 59 insertions(+), 43 deletions(-) diff --git a/src/modules/sessions/sessions.service.ts b/src/modules/sessions/sessions.service.ts index bc422e6..8c4f830 100644 --- a/src/modules/sessions/sessions.service.ts +++ b/src/modules/sessions/sessions.service.ts @@ -9,6 +9,20 @@ export type CreateSessionInput = { notes?: string; }; +type SessionRow = { + id: number; + session_key: string; + station_id: number | null; + station_key: string | null; + station_name: string | null; + container_code: string | null; + location_name: string | null; + started_at: string; + ended_at: string | null; + status: string; + notes: string | null; +}; + export function createSessionsService(db: AppDb) { function ensureStation(stationKey?: string): number | null { if (!stationKey) return null; @@ -24,6 +38,48 @@ export function createSessionsService(db: AppDb) { return Number(result.lastInsertRowid); } + function get(sessionKey: string) { + const session = db.prepare(` + SELECT inventory_sessions.*, stations.station_key, stations.name AS station_name + FROM inventory_sessions + LEFT JOIN stations ON stations.id = inventory_sessions.station_id + WHERE inventory_sessions.session_key = ? + `).get(sessionKey) as SessionRow | undefined; + if (!session) return null; + + const reads = db.prepare(` + SELECT + inventory_observations.*, + items.name AS item_name, + items.sku AS item_sku, + items.category AS item_category, + tags.chip_model, + tags.product_family, + tags.part_number + FROM inventory_observations + LEFT JOIN items ON items.id = inventory_observations.known_item_id + LEFT JOIN tags ON tags.id = inventory_observations.tag_id + WHERE inventory_observations.session_id = ? + ORDER BY inventory_observations.last_seen_at DESC + `).all(session.id); + + const events = db.prepare(` + SELECT + tag_events.*, + items.name AS item_name, + items.sku AS item_sku, + tags.chip_model, + tags.product_family + FROM tag_events + LEFT JOIN tags ON tags.id = tag_events.tag_id + LEFT JOIN items ON items.id = tags.item_id + WHERE tag_events.session_id = ? + ORDER BY tag_events.occurred_at DESC, tag_events.id DESC + `).all(session.id); + + return { ...session, reads, events }; + } + return { create(input: CreateSessionInput) { const stationId = ensureStation(input.stationKey); @@ -34,7 +90,7 @@ export function createSessionsService(db: AppDb) { VALUES (?, ?, ?, ?, ?, ?, 'open') `).run(sessionKey, stationId, input.containerCode ?? null, input.locationName ?? null, input.notes ?? null, nowIso()); - return this.get(sessionKey); + return get(sessionKey); }, list() { @@ -57,47 +113,7 @@ export function createSessionsService(db: AppDb) { `).all(); }, - get(sessionKey: string) { - const session = db.prepare(` - SELECT inventory_sessions.*, stations.station_key, stations.name AS station_name - FROM inventory_sessions - LEFT JOIN stations ON stations.id = inventory_sessions.station_id - WHERE inventory_sessions.session_key = ? - `).get(sessionKey) as { id: number } | undefined; - if (!session) return null; - - const reads = db.prepare(` - SELECT - inventory_observations.*, - items.name AS item_name, - items.sku AS item_sku, - items.category AS item_category, - tags.chip_model, - tags.product_family, - tags.part_number - FROM inventory_observations - LEFT JOIN items ON items.id = inventory_observations.known_item_id - LEFT JOIN tags ON tags.id = inventory_observations.tag_id - WHERE inventory_observations.session_id = ? - ORDER BY inventory_observations.last_seen_at DESC - `).all(session.id); - - const events = db.prepare(` - SELECT - tag_events.*, - items.name AS item_name, - items.sku AS item_sku, - tags.chip_model, - tags.product_family - FROM tag_events - LEFT JOIN tags ON tags.id = tag_events.tag_id - LEFT JOIN items ON items.id = tags.item_id - WHERE tag_events.session_id = ? - ORDER BY tag_events.occurred_at DESC, tag_events.id DESC - `).all(session.id); - - return { ...session, reads, events }; - }, + get, close(sessionKey: string) { db.prepare(` @@ -106,7 +122,7 @@ export function createSessionsService(db: AppDb) { WHERE session_key = ? AND status = 'open' `).run(nowIso(), sessionKey); - return this.get(sessionKey); + return get(sessionKey); } }; } From 390d3608a250c959a11ff9a66866d50e4557a552 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:21:45 +0200 Subject: [PATCH 41/42] test: discover nested TypeScript test files portably --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 14a9c6a..88d7b62 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "dev": "concurrently \"npm:dev:api\" \"npm:dev:web\"", "dev:api": "tsx watch src/server.ts", "dev:web": "vite --host 0.0.0.0", - "test": "tsx --test src/**/*.test.ts", + "test": "tsx --test \"src/**/*.test.ts\"", "build": "tsc && vite build", "start": "node dist/server.js", "mcp": "node dist/mcp-server.js" From ee211d8c875ff96247ddd1364c6779ba4a649db0 Mon Sep 17 00:00:00 2001 From: The Code Learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:23:25 +0200 Subject: [PATCH 42/42] ci: gate high severity production dependency vulnerabilities --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d613940..376f416 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Audit production dependencies + run: npm audit --omit=dev --audit-level=high + - name: Test run: npm test