iobroker.zeiterfassung 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -12
- package/build/lib/db/repositories/rfid.js +24 -0
- package/build/lib/db/repositories/rfid.js.map +2 -2
- package/build/lib/web/api.js +16 -0
- package/build/lib/web/api.js.map +2 -2
- package/io-package.json +14 -14
- package/package.json +1 -1
- package/src-pwa/src/i18n/de.json +5 -0
- package/src-pwa/src/i18n/en.json +5 -0
- package/src-pwa/src/i18n/es.json +6 -1
- package/src-pwa/src/i18n/fr.json +6 -1
- package/src-pwa/src/i18n/it.json +6 -1
- package/src-pwa/src/i18n/nl.json +5 -0
- package/src-pwa/src/i18n/pl.json +7 -2
- package/src-pwa/src/i18n/pt.json +6 -1
- package/src-pwa/src/i18n/ru.json +5 -0
- package/src-pwa/src/i18n/uk.json +5 -0
- package/src-pwa/src/i18n/zh-cn.json +6 -1
- package/www/assets/{index-B_4iEAhU.js → index-DEekjnsj.js} +2 -2
- package/www/assets/index-DEekjnsj.js.map +1 -0
- package/www/index.html +1 -1
- package/www/sw.js +1 -1
- package/www/sw.js.map +1 -1
- package/www/assets/index-B_4iEAhU.js.map +0 -1
package/README.md
CHANGED
|
@@ -305,6 +305,19 @@ local SQLite file, access is role-based, and every correction is written to an a
|
|
|
305
305
|
|
|
306
306
|
### **WORK IN PROGRESS**
|
|
307
307
|
|
|
308
|
+
### 0.1.3 (2026-09-18)
|
|
309
|
+
|
|
310
|
+
- (Alex) fix: the badge (RFID/NFC) tab builds the link from the address the administration is **currently** open
|
|
311
|
+
with — scheme, host and port come from the browser, so the link works on a plain HTTP instance and behind a reverse
|
|
312
|
+
proxy alike, and no server side guess can point at a scheme the instance does not serve
|
|
313
|
+
- (Alex) fix: the badge (RFID/NFC) tab shows the state of every badge — active, expired or revoked — and when it was
|
|
314
|
+
last used. A revoked badge now offers “Remove permanently” instead of failing with “not found”: the old entry
|
|
315
|
+
disappears from the list, while the audit trail keeps the trace of it
|
|
316
|
+
- (Alex) translations: a technical review pass over the 11 languages — the punch, break, absence, badge and overtime
|
|
317
|
+
text families were compared with each other. Five wrong `reports.overtime` labels were corrected (`pt`, `fr`, `it`,
|
|
318
|
+
`es`, `zh-cn` said “over time” instead of overtime), and the Polish punch labels now use the same root as the badge
|
|
319
|
+
texts. `docs/i18n.md` records the method and what a native speaker still has to settle
|
|
320
|
+
|
|
308
321
|
### 0.1.2 (2026-09-18)
|
|
309
322
|
|
|
310
323
|
- (Alex) fix: a badge (RFID/NFC) link points to the address the administration itself is reached with — the scheme
|
|
@@ -353,18 +366,6 @@ local SQLite file, access is role-based, and every correction is written to an a
|
|
|
353
366
|
per employee `monthWorkedMinutes`/`monthBalanceMinutes`/`yearBalanceMinutes` and `events.lastAt`/`lastType`/
|
|
354
367
|
`lastUser`/`lastDirection`/`lastSource`
|
|
355
368
|
|
|
356
|
-
### 0.0.18 (2026-09-18)
|
|
357
|
-
|
|
358
|
-
- (Alex) fix: an uploaded background picture can be taken away again — every picture field has a “Remove picture”
|
|
359
|
-
button (the logo as well), and “Default” resets the colour and the background picture in one click
|
|
360
|
-
- internal: the settings dialog shows the stored logo and background picture again (a read leaves the large pictures
|
|
361
|
-
out on purpose, so the preview and the remove button come from the branding route)
|
|
362
|
-
- (Alex) internal: the release flow asks the owner before the version commit and the tag — the question “may I push,
|
|
363
|
-
or do you have remarks?” comes first and only a “go” leads to commit, tag and push; `CONTRIBUTING.md` documents
|
|
364
|
-
the step and `npm run push:approve` reminds of it
|
|
365
|
-
- internal: the lint ignores the generated report of the browser tests (it carried a bundled viewer and made a local
|
|
366
|
-
`npm run lint` crash)
|
|
367
|
-
|
|
368
369
|
Older entries are kept in [`CHANGELOG_OLD.md`](CHANGELOG_OLD.md).
|
|
369
370
|
|
|
370
371
|
## Provenance
|
|
@@ -82,6 +82,7 @@ function createRfidRepository(db) {
|
|
|
82
82
|
);
|
|
83
83
|
const updateLastUsed = db.prepare("UPDATE rfid_tags SET last_used_at = ? WHERE id = ?");
|
|
84
84
|
const deactivate = db.prepare("UPDATE rfid_tags SET is_active = 0 WHERE id = ?");
|
|
85
|
+
const deleteTag = db.prepare("DELETE FROM rfid_tags WHERE id = ?");
|
|
85
86
|
const read = (id) => {
|
|
86
87
|
const row = selectById.get(id);
|
|
87
88
|
return row ? mapTagRow(row) : null;
|
|
@@ -174,6 +175,29 @@ function createRfidRepository(db) {
|
|
|
174
175
|
});
|
|
175
176
|
run();
|
|
176
177
|
return true;
|
|
178
|
+
},
|
|
179
|
+
remove(input) {
|
|
180
|
+
var _a;
|
|
181
|
+
const tag = read(input.id);
|
|
182
|
+
if (!tag) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
const now = (_a = input.now) != null ? _a : Math.floor(Date.now() / 1e3);
|
|
186
|
+
const run = db.transaction(() => {
|
|
187
|
+
var _a2;
|
|
188
|
+
deleteTag.run(input.id);
|
|
189
|
+
(0, import_audit.writeAuditLog)(db, {
|
|
190
|
+
atUtc: now,
|
|
191
|
+
actorId: input.actorId,
|
|
192
|
+
action: "rfid.delete",
|
|
193
|
+
entity: "rfid_tag",
|
|
194
|
+
entityId: input.id,
|
|
195
|
+
detail: { uid: tag.uid, userId: tag.userId, isActive: tag.isActive },
|
|
196
|
+
ip: (_a2 = input.actorIp) != null ? _a2 : null
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
run();
|
|
200
|
+
return true;
|
|
177
201
|
}
|
|
178
202
|
};
|
|
179
203
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/lib/db/repositories/rfid.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * RFID/NFC tags (`rfid_tags`).\n *\n * A tag is a **signed deep link**: `uid.userId.exp.signature`, where the signature is\n * `HMAC-SHA256(hmacSecret, \"uid.userId.exp\")`. That way a tag needs no login on the scanning phone, cannot be\n * guessed (a forged payload fails the signature) and can be revoked or given an expiry. The stored `token_hash`\n * is the signature itself; the extra `uid` row keeps the lookup cheap and lets a tag be revoked individually.\n */\n\nimport { createHmac, randomBytes, timingSafeEqual } from \"node:crypto\";\nimport type { Db } from \"../database\";\nimport { ValidationError } from \"../../errors\";\nimport { writeAuditLog } from \"./audit\";\n\n/** A tag as it is stored (without the signature). */\nexport interface RfidTagRecord {\n\t/** Primary key */\n\tid: number;\n\t/** Identifier of the tag (`uid` of the NFC tag or a generated value) */\n\tuid: string;\n\t/** Owner of the tag, `null` after the user was deleted */\n\tuserId: number | null;\n\t/** Free-form label */\n\tlabel: string | null;\n\t/** False for revoked tags */\n\tisActive: boolean;\n\t/** Instant the tag stops working, `null` = never */\n\texpiresAt: number | null;\n\t/** Instant of the last scan, `null` if never used */\n\tlastUsedAt: number | null;\n\t/** Instant of creation, UTC epoch seconds */\n\tcreatedAt: number;\n}\n\n/** Tag storage operations. */\nexport interface RfidRepository {\n\t/** Creates a tag and audits it (the signature is never written to the audit trail) */\n\tcreate(input: {\n\t\tuid: string;\n\t\tuserId: number;\n\t\tsignature: string;\n\t\tlabel?: string | null;\n\t\t/** Instant the tag stops working, `null` = never */\n\t\texpiresAt?: number | null;\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): RfidTagRecord;\n\t/** All tags, optionally including the revoked ones */\n\tlist(options?: { includeInactive?: boolean }): RfidTagRecord[];\n\t/** Reads a tag by id */\n\tfindById(id: number): RfidTagRecord | null;\n\t/** Checks a scanned token and returns the tag when signature, activity, owner and expiry match */\n\tverify(input: { uid: string; userId: number; signature: string; now?: number }): RfidTagRecord | null;\n\t/** Records a successful scan */\n\ttouch(input: { id: number; now?: number }): void;\n\t/** Revokes a tag */\n\trevoke(input: { id: number; actorId: number; actorIp?: string | null; now?: number }): boolean;\n}\n\n/**\n * Creates a random tag identifier.\n *\n * @returns base64url encoded identifier\n */\nexport function newTagUid(): string {\n\treturn randomBytes(16).toString(\"base64url\");\n}\n\n/**\n * Signs a tag payload.\n *\n * @param secret - instance secret (`hmacSecret`)\n * @param uid - tag identifier\n * @param userId - owner of the tag\n * @param expiresAt - expiry of the payload, UTC epoch seconds\n * @returns base64url encoded signature\n */\nexport function signTag(secret: string, uid: string, userId: number, expiresAt: number): string {\n\treturn createHmac(\"sha256\", secret).update(`${uid}.${userId}.${expiresAt}`).digest(\"base64url\");\n}\n\n/**\n * Builds the token a tag carries.\n *\n * @param uid - tag identifier\n * @param userId - owner of the tag\n * @param expiresAt - expiry of the payload\n * @param signature - signature of the payload\n * @returns the token\n */\nexport function buildTagToken(uid: string, userId: number, expiresAt: number, signature: string): string {\n\treturn `${uid}.${userId}.${expiresAt}.${signature}`;\n}\n\n/**\n * Splits a scanned token.\n *\n * @param token - token from the deep link or the QR code\n * @returns the parsed payload or `null` when the token is malformed\n */\nexport function parseTagToken(\n\ttoken: string,\n): { uid: string; userId: number; expiresAt: number; signature: string } | null {\n\tconst parts = token.trim().split(\".\");\n\tif (parts.length !== 4) {\n\t\treturn null;\n\t}\n\tconst [uid, rawUserId, rawExpires, signature] = parts;\n\tconst userId = Number(rawUserId);\n\tconst expiresAt = Number(rawExpires);\n\tif (!uid || !signature || !Number.isInteger(userId) || !Number.isInteger(expiresAt)) {\n\t\treturn null;\n\t}\n\treturn { uid, userId, expiresAt, signature };\n}\n\n/**\n * Compares two signatures without leaking the position of the first difference.\n *\n * @param expected - signature computed by the server\n * @param received - signature from the token\n * @returns true when both are identical\n */\nexport function sameSignature(expected: string, received: string): boolean {\n\tconst left = Buffer.from(expected);\n\tconst right = Buffer.from(received);\n\treturn left.length === right.length && timingSafeEqual(left, right);\n}\n\ninterface RfidRow {\n\tid: number;\n\tuid: string;\n\ttoken_hash: string;\n\tuser_id: number | null;\n\tlabel: string | null;\n\tis_active: number;\n\texpires_at: number | null;\n\tlast_used_at: number | null;\n\tcreated_at: number;\n}\n\nconst TAG_COLUMNS = `id, uid, token_hash, user_id, label, is_active, expires_at, last_used_at, created_at`;\n\n/**\n * Maps a database row to a tag record (the signature stays inside the repository).\n *\n * @param row - raw database row\n * @returns tag record\n */\nfunction mapTagRow(row: RfidRow): RfidTagRecord {\n\treturn {\n\t\tid: row.id,\n\t\tuid: row.uid,\n\t\tuserId: row.user_id,\n\t\tlabel: row.label,\n\t\tisActive: row.is_active !== 0,\n\t\texpiresAt: row.expires_at,\n\t\tlastUsedAt: row.last_used_at,\n\t\tcreatedAt: row.created_at,\n\t};\n}\n\n/**\n * Creates the tag repository.\n *\n * @param db - open database handle\n * @returns repository instance\n */\nexport function createRfidRepository(db: Db): RfidRepository {\n\tconst insertTag = db.prepare(\n\t\t`INSERT INTO rfid_tags (uid, token_hash, user_id, label, is_active, expires_at, created_at)\n\t\t VALUES (?, ?, ?, ?, 1, ?, ?)`,\n\t);\n\tconst selectById = db.prepare(`SELECT ${TAG_COLUMNS} FROM rfid_tags WHERE id = ?`);\n\tconst selectByUid = db.prepare(`SELECT ${TAG_COLUMNS} FROM rfid_tags WHERE uid = ?`);\n\tconst selectAll = db.prepare(`SELECT ${TAG_COLUMNS} FROM rfid_tags ORDER BY created_at DESC, id DESC`);\n\tconst selectActive = db.prepare(\n\t\t`SELECT ${TAG_COLUMNS} FROM rfid_tags WHERE is_active = 1 ORDER BY created_at DESC, id DESC`,\n\t);\n\tconst updateLastUsed = db.prepare(\"UPDATE rfid_tags SET last_used_at = ? WHERE id = ?\");\n\tconst deactivate = db.prepare(\"UPDATE rfid_tags SET is_active = 0 WHERE id = ?\");\n\n\t/**\n\t * Reads a tag.\n\t *\n\t * @param id - tag id\n\t * @returns tag or `null`\n\t */\n\tconst read = (id: number): RfidTagRecord | null => {\n\t\tconst row = selectById.get(id) as RfidRow | undefined;\n\t\treturn row ? mapTagRow(row) : null;\n\t};\n\n\treturn {\n\t\tcreate(input: {\n\t\t\tuid: string;\n\t\t\tuserId: number;\n\t\t\tsignature: string;\n\t\t\tlabel?: string | null;\n\t\t\texpiresAt?: number | null;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): RfidTagRecord {\n\t\t\tconst uid = input.uid.trim();\n\t\t\tif (!uid) {\n\t\t\t\tthrow new ValidationError(\"uid is required\");\n\t\t\t}\n\t\t\t// the caller passes the expiry it signed, so the stored value and the token cannot drift apart\n\t\t\tconst expiresAt = input.expiresAt ?? null;\n\t\t\tif (expiresAt !== null && (!Number.isInteger(expiresAt) || expiresAt <= 0)) {\n\t\t\t\tthrow new ValidationError(`expiresAt must be a positive instant (got ${input.expiresAt})`);\n\t\t\t}\n\t\t\tif (!input.signature) {\n\t\t\t\tthrow new ValidationError(\"signature is required\");\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\n\t\t\tlet tagId = 0;\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tconst result = insertTag.run(uid, input.signature, input.userId, input.label ?? null, expiresAt, now);\n\t\t\t\ttagId = Number(result.lastInsertRowid);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"rfid.create\",\n\t\t\t\t\tentity: \"rfid_tag\",\n\t\t\t\t\tentityId: tagId,\n\t\t\t\t\t// the signature is a credential and never part of the audit trail\n\t\t\t\t\tdetail: { uid, userId: input.userId, label: input.label ?? null, expiresAt },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst tag = read(tagId);\n\t\t\tif (!tag) {\n\t\t\t\tthrow new Error(`tag ${tagId} disappeared right after creation`);\n\t\t\t}\n\t\t\treturn tag;\n\t\t},\n\n\t\tlist(options?: { includeInactive?: boolean }): RfidTagRecord[] {\n\t\t\tconst rows = (options?.includeInactive === true ? selectAll : selectActive).all() as RfidRow[];\n\t\t\treturn rows.map(mapTagRow);\n\t\t},\n\n\t\tfindById: read,\n\n\t\tverify(input: { uid: string; userId: number; signature: string; now?: number }): RfidTagRecord | null {\n\t\t\tconst row = selectByUid.get(input.uid.trim()) as RfidRow | undefined;\n\t\t\tif (!row) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst tag = mapTagRow(row);\n\t\t\tif (!tag.isActive) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\t// an expired tag (or payload) is worthless even when the signature is correct\n\t\t\tif (tag.expiresAt !== null && tag.expiresAt <= now) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\tif (tag.userId !== input.userId) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\t// the stored signature has to match the signature of the scanned payload\n\t\t\treturn sameSignature(row.token_hash, input.signature) ? tag : null;\n\t\t},\n\n\t\ttouch(input: { id: number; now?: number }): void {\n\t\t\tupdateLastUsed.run(input.now ?? Math.floor(Date.now() / 1000), input.id);\n\t\t},\n\n\t\trevoke(input: { id: number; actorId: number; actorIp?: string | null; now?: number }): boolean {\n\t\t\tconst tag = read(input.id);\n\t\t\tif (!tag || !tag.isActive) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tdeactivate.run(input.id);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"rfid.revoke\",\n\t\t\t\t\tentity: \"rfid_tag\",\n\t\t\t\t\tentityId: input.id,\n\t\t\t\t\tdetail: { uid: tag.uid, userId: tag.userId },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\t\t\treturn true;\n\t\t},\n\t};\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AASA,yBAAyD;AAEzD,oBAAgC;AAChC,mBAA8B;
|
|
4
|
+
"sourcesContent": ["/**\n * RFID/NFC tags (`rfid_tags`).\n *\n * A tag is a **signed deep link**: `uid.userId.exp.signature`, where the signature is\n * `HMAC-SHA256(hmacSecret, \"uid.userId.exp\")`. That way a tag needs no login on the scanning phone, cannot be\n * guessed (a forged payload fails the signature) and can be revoked or given an expiry. The stored `token_hash`\n * is the signature itself; the extra `uid` row keeps the lookup cheap and lets a tag be revoked individually.\n */\n\nimport { createHmac, randomBytes, timingSafeEqual } from \"node:crypto\";\nimport type { Db } from \"../database\";\nimport { ValidationError } from \"../../errors\";\nimport { writeAuditLog } from \"./audit\";\n\n/** A tag as it is stored (without the signature). */\nexport interface RfidTagRecord {\n\t/** Primary key */\n\tid: number;\n\t/** Identifier of the tag (`uid` of the NFC tag or a generated value) */\n\tuid: string;\n\t/** Owner of the tag, `null` after the user was deleted */\n\tuserId: number | null;\n\t/** Free-form label */\n\tlabel: string | null;\n\t/** False for revoked tags */\n\tisActive: boolean;\n\t/** Instant the tag stops working, `null` = never */\n\texpiresAt: number | null;\n\t/** Instant of the last scan, `null` if never used */\n\tlastUsedAt: number | null;\n\t/** Instant of creation, UTC epoch seconds */\n\tcreatedAt: number;\n}\n\n/** Tag storage operations. */\nexport interface RfidRepository {\n\t/** Creates a tag and audits it (the signature is never written to the audit trail) */\n\tcreate(input: {\n\t\tuid: string;\n\t\tuserId: number;\n\t\tsignature: string;\n\t\tlabel?: string | null;\n\t\t/** Instant the tag stops working, `null` = never */\n\t\texpiresAt?: number | null;\n\t\tactorId: number;\n\t\tactorIp?: string | null;\n\t\tnow?: number;\n\t}): RfidTagRecord;\n\t/** All tags, optionally including the revoked ones */\n\tlist(options?: { includeInactive?: boolean }): RfidTagRecord[];\n\t/** Reads a tag by id */\n\tfindById(id: number): RfidTagRecord | null;\n\t/** Checks a scanned token and returns the tag when signature, activity, owner and expiry match */\n\tverify(input: { uid: string; userId: number; signature: string; now?: number }): RfidTagRecord | null;\n\t/** Records a successful scan */\n\ttouch(input: { id: number; now?: number }): void;\n\t/** Revokes a tag */\n\trevoke(input: { id: number; actorId: number; actorIp?: string | null; now?: number }): boolean;\n\t/** Deletes a tag for good \u2014 for a revoked badge that should disappear from the list */\n\tremove(input: { id: number; actorId: number; actorIp?: string | null; now?: number }): boolean;\n}\n\n/**\n * Creates a random tag identifier.\n *\n * @returns base64url encoded identifier\n */\nexport function newTagUid(): string {\n\treturn randomBytes(16).toString(\"base64url\");\n}\n\n/**\n * Signs a tag payload.\n *\n * @param secret - instance secret (`hmacSecret`)\n * @param uid - tag identifier\n * @param userId - owner of the tag\n * @param expiresAt - expiry of the payload, UTC epoch seconds\n * @returns base64url encoded signature\n */\nexport function signTag(secret: string, uid: string, userId: number, expiresAt: number): string {\n\treturn createHmac(\"sha256\", secret).update(`${uid}.${userId}.${expiresAt}`).digest(\"base64url\");\n}\n\n/**\n * Builds the token a tag carries.\n *\n * @param uid - tag identifier\n * @param userId - owner of the tag\n * @param expiresAt - expiry of the payload\n * @param signature - signature of the payload\n * @returns the token\n */\nexport function buildTagToken(uid: string, userId: number, expiresAt: number, signature: string): string {\n\treturn `${uid}.${userId}.${expiresAt}.${signature}`;\n}\n\n/**\n * Splits a scanned token.\n *\n * @param token - token from the deep link or the QR code\n * @returns the parsed payload or `null` when the token is malformed\n */\nexport function parseTagToken(\n\ttoken: string,\n): { uid: string; userId: number; expiresAt: number; signature: string } | null {\n\tconst parts = token.trim().split(\".\");\n\tif (parts.length !== 4) {\n\t\treturn null;\n\t}\n\tconst [uid, rawUserId, rawExpires, signature] = parts;\n\tconst userId = Number(rawUserId);\n\tconst expiresAt = Number(rawExpires);\n\tif (!uid || !signature || !Number.isInteger(userId) || !Number.isInteger(expiresAt)) {\n\t\treturn null;\n\t}\n\treturn { uid, userId, expiresAt, signature };\n}\n\n/**\n * Compares two signatures without leaking the position of the first difference.\n *\n * @param expected - signature computed by the server\n * @param received - signature from the token\n * @returns true when both are identical\n */\nexport function sameSignature(expected: string, received: string): boolean {\n\tconst left = Buffer.from(expected);\n\tconst right = Buffer.from(received);\n\treturn left.length === right.length && timingSafeEqual(left, right);\n}\n\ninterface RfidRow {\n\tid: number;\n\tuid: string;\n\ttoken_hash: string;\n\tuser_id: number | null;\n\tlabel: string | null;\n\tis_active: number;\n\texpires_at: number | null;\n\tlast_used_at: number | null;\n\tcreated_at: number;\n}\n\nconst TAG_COLUMNS = `id, uid, token_hash, user_id, label, is_active, expires_at, last_used_at, created_at`;\n\n/**\n * Maps a database row to a tag record (the signature stays inside the repository).\n *\n * @param row - raw database row\n * @returns tag record\n */\nfunction mapTagRow(row: RfidRow): RfidTagRecord {\n\treturn {\n\t\tid: row.id,\n\t\tuid: row.uid,\n\t\tuserId: row.user_id,\n\t\tlabel: row.label,\n\t\tisActive: row.is_active !== 0,\n\t\texpiresAt: row.expires_at,\n\t\tlastUsedAt: row.last_used_at,\n\t\tcreatedAt: row.created_at,\n\t};\n}\n\n/**\n * Creates the tag repository.\n *\n * @param db - open database handle\n * @returns repository instance\n */\nexport function createRfidRepository(db: Db): RfidRepository {\n\tconst insertTag = db.prepare(\n\t\t`INSERT INTO rfid_tags (uid, token_hash, user_id, label, is_active, expires_at, created_at)\n\t\t VALUES (?, ?, ?, ?, 1, ?, ?)`,\n\t);\n\tconst selectById = db.prepare(`SELECT ${TAG_COLUMNS} FROM rfid_tags WHERE id = ?`);\n\tconst selectByUid = db.prepare(`SELECT ${TAG_COLUMNS} FROM rfid_tags WHERE uid = ?`);\n\tconst selectAll = db.prepare(`SELECT ${TAG_COLUMNS} FROM rfid_tags ORDER BY created_at DESC, id DESC`);\n\tconst selectActive = db.prepare(\n\t\t`SELECT ${TAG_COLUMNS} FROM rfid_tags WHERE is_active = 1 ORDER BY created_at DESC, id DESC`,\n\t);\n\tconst updateLastUsed = db.prepare(\"UPDATE rfid_tags SET last_used_at = ? WHERE id = ?\");\n\tconst deactivate = db.prepare(\"UPDATE rfid_tags SET is_active = 0 WHERE id = ?\");\n\tconst deleteTag = db.prepare(\"DELETE FROM rfid_tags WHERE id = ?\");\n\n\t/**\n\t * Reads a tag.\n\t *\n\t * @param id - tag id\n\t * @returns tag or `null`\n\t */\n\tconst read = (id: number): RfidTagRecord | null => {\n\t\tconst row = selectById.get(id) as RfidRow | undefined;\n\t\treturn row ? mapTagRow(row) : null;\n\t};\n\n\treturn {\n\t\tcreate(input: {\n\t\t\tuid: string;\n\t\t\tuserId: number;\n\t\t\tsignature: string;\n\t\t\tlabel?: string | null;\n\t\t\texpiresAt?: number | null;\n\t\t\tactorId: number;\n\t\t\tactorIp?: string | null;\n\t\t\tnow?: number;\n\t\t}): RfidTagRecord {\n\t\t\tconst uid = input.uid.trim();\n\t\t\tif (!uid) {\n\t\t\t\tthrow new ValidationError(\"uid is required\");\n\t\t\t}\n\t\t\t// the caller passes the expiry it signed, so the stored value and the token cannot drift apart\n\t\t\tconst expiresAt = input.expiresAt ?? null;\n\t\t\tif (expiresAt !== null && (!Number.isInteger(expiresAt) || expiresAt <= 0)) {\n\t\t\t\tthrow new ValidationError(`expiresAt must be a positive instant (got ${input.expiresAt})`);\n\t\t\t}\n\t\t\tif (!input.signature) {\n\t\t\t\tthrow new ValidationError(\"signature is required\");\n\t\t\t}\n\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\n\t\t\tlet tagId = 0;\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tconst result = insertTag.run(uid, input.signature, input.userId, input.label ?? null, expiresAt, now);\n\t\t\t\ttagId = Number(result.lastInsertRowid);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"rfid.create\",\n\t\t\t\t\tentity: \"rfid_tag\",\n\t\t\t\t\tentityId: tagId,\n\t\t\t\t\t// the signature is a credential and never part of the audit trail\n\t\t\t\t\tdetail: { uid, userId: input.userId, label: input.label ?? null, expiresAt },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\n\t\t\tconst tag = read(tagId);\n\t\t\tif (!tag) {\n\t\t\t\tthrow new Error(`tag ${tagId} disappeared right after creation`);\n\t\t\t}\n\t\t\treturn tag;\n\t\t},\n\n\t\tlist(options?: { includeInactive?: boolean }): RfidTagRecord[] {\n\t\t\tconst rows = (options?.includeInactive === true ? selectAll : selectActive).all() as RfidRow[];\n\t\t\treturn rows.map(mapTagRow);\n\t\t},\n\n\t\tfindById: read,\n\n\t\tverify(input: { uid: string; userId: number; signature: string; now?: number }): RfidTagRecord | null {\n\t\t\tconst row = selectByUid.get(input.uid.trim()) as RfidRow | undefined;\n\t\t\tif (!row) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst tag = mapTagRow(row);\n\t\t\tif (!tag.isActive) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\t// an expired tag (or payload) is worthless even when the signature is correct\n\t\t\tif (tag.expiresAt !== null && tag.expiresAt <= now) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\tif (tag.userId !== input.userId) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\t// the stored signature has to match the signature of the scanned payload\n\t\t\treturn sameSignature(row.token_hash, input.signature) ? tag : null;\n\t\t},\n\n\t\ttouch(input: { id: number; now?: number }): void {\n\t\t\tupdateLastUsed.run(input.now ?? Math.floor(Date.now() / 1000), input.id);\n\t\t},\n\n\t\trevoke(input: { id: number; actorId: number; actorIp?: string | null; now?: number }): boolean {\n\t\t\tconst tag = read(input.id);\n\t\t\tif (!tag || !tag.isActive) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tdeactivate.run(input.id);\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"rfid.revoke\",\n\t\t\t\t\tentity: \"rfid_tag\",\n\t\t\t\t\tentityId: input.id,\n\t\t\t\t\tdetail: { uid: tag.uid, userId: tag.userId },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\t\t\treturn true;\n\t\t},\n\n\t\tremove(input: { id: number; actorId: number; actorIp?: string | null; now?: number }): boolean {\n\t\t\tconst tag = read(input.id);\n\t\t\tif (!tag) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst now = input.now ?? Math.floor(Date.now() / 1000);\n\t\t\tconst run = db.transaction((): void => {\n\t\t\t\tdeleteTag.run(input.id);\n\t\t\t\t// the row is gone, the audit trail keeps the trace of it\n\t\t\t\twriteAuditLog(db, {\n\t\t\t\t\tatUtc: now,\n\t\t\t\t\tactorId: input.actorId,\n\t\t\t\t\taction: \"rfid.delete\",\n\t\t\t\t\tentity: \"rfid_tag\",\n\t\t\t\t\tentityId: input.id,\n\t\t\t\t\tdetail: { uid: tag.uid, userId: tag.userId, isActive: tag.isActive },\n\t\t\t\t\tip: input.actorIp ?? null,\n\t\t\t\t});\n\t\t\t});\n\t\t\trun();\n\t\t\treturn true;\n\t\t},\n\t};\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["_a", "_b"]
|
|
7
7
|
}
|
package/build/lib/web/api.js
CHANGED
|
@@ -2187,6 +2187,22 @@ function createApi(deps) {
|
|
|
2187
2187
|
}
|
|
2188
2188
|
return (0, import_router.noContent)();
|
|
2189
2189
|
});
|
|
2190
|
+
route("DELETE", "/rfid/tags/:id/permanent", { permission: "rfid.manage", csrf: true }, (context) => {
|
|
2191
|
+
var _a2;
|
|
2192
|
+
if (!context.auth) {
|
|
2193
|
+
throw (0, import_problem.problem)(401, "no_session", "request rejected (no_session)");
|
|
2194
|
+
}
|
|
2195
|
+
const removed = rfid.remove({
|
|
2196
|
+
id: numberParam(context, "id"),
|
|
2197
|
+
actorId: context.auth.user.id,
|
|
2198
|
+
actorIp: (_a2 = context.request.remoteAddress) != null ? _a2 : null,
|
|
2199
|
+
now: now()
|
|
2200
|
+
});
|
|
2201
|
+
if (!removed) {
|
|
2202
|
+
throw new import_errors.NotFoundError(`tag ${context.params.id} not found`);
|
|
2203
|
+
}
|
|
2204
|
+
return (0, import_router.noContent)();
|
|
2205
|
+
});
|
|
2190
2206
|
route(
|
|
2191
2207
|
"POST",
|
|
2192
2208
|
"/rfid/scan",
|