diff --git a/README.md b/README.md
index cb9e5ab..4d60ac7 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,18 @@
-# vSeeBox Stockroom
+

+
+# vBoxStock
A small, self-hosted inventory and sales tracker for people who buy, stock, and resell vSeeBox devices.
-Stockroom replaces spreadsheets and handwritten lists with one browser-based place to track each physical unit from receipt through sale. It records the identifiers that matter for electronics inventory—UID, serial number, and MAC address—along with condition, cost, customer, payment, shipping, and support history.
+vBoxStock replaces spreadsheets and handwritten lists with one browser-based place to track each physical unit from receipt through sale. It records the identifiers that matter for electronics inventory—UID, serial number, and MAC address—along with condition, cost, customer, payment, shipping, and support history.
-It was developed with Unraid in mind, but Unraid is not required. Stockroom is a standard OCI/Docker container and can run on a Docker-compatible Linux server, NAS, home lab, or cloud VM. The application is self-contained: the web server and SQLite database live inside one image, while durable data is stored in a mounted `/data` directory.
+It was developed with Unraid in mind, but Unraid is not required. vBoxStock is a standard OCI/Docker container and can run on a Docker-compatible Linux server, NAS, home lab, or cloud VM. The application is self-contained: the web server and SQLite database live inside one image, while durable data is stored in a mounted `/data` directory.
-
+
-## Why Stockroom exists
+## Why vBoxStock exists
-General inventory tools can be larger and more complicated than a small reseller needs. Stockroom focuses on a straightforward workflow:
+General inventory tools can be larger and more complicated than a small reseller needs. vBoxStock focuses on a straightforward workflow:
1. Receive an individually identifiable device into inventory.
2. Record its cost, model, condition, and notes.
@@ -38,11 +40,11 @@ General inventory tools can be larger and more complicated than a small reseller
### Secure sign-in
-
+
### User administration, backups, and audit history
-
+
## Accounts and security
@@ -51,7 +53,7 @@ On a new installation—or an upgraded installation with no configured accounts
- **Username:** `admin`
- **Password:** `admin`
-Stockroom immediately requires a new password and blocks access to application data until it is changed. Passwords must contain at least 8 characters and are stored as salted `scrypt` hashes, never as readable text.
+vBoxStock immediately requires a new password and blocks access to application data until it is changed. Passwords must contain at least 8 characters and are stored as salted `scrypt` hashes, never as readable text.
| Capability | Admin | Read-Only |
| --- | :---: | :---: |
@@ -64,7 +66,7 @@ Stockroom immediately requires a new password and blocks access to application d
Additional protections include HTTP-only SameSite session cookies, a 12-hour inactivity timeout, login throttling, required password confirmation before a restore, automatic session invalidation after a restore, and protection against disabling or deleting the final enabled administrator.
-For use outside a trusted private network, place Stockroom behind an HTTPS reverse proxy. The application does not provide TLS certificates directly.
+For use outside a trusted private network, place vBoxStock behind an HTTPS reverse proxy. The application does not provide TLS certificates directly.
## Quick start with Docker
@@ -113,7 +115,7 @@ Open the container's WebUI after installation. Updates can be applied with **For
## Data and backups
-Stockroom uses SQLite and does not require MySQL, PostgreSQL, Redis, or another service. Persistent content is stored under `/data`:
+vBoxStock uses SQLite and does not require MySQL, PostgreSQL, Redis, or another service. Persistent content is stored under `/data`:
- `/data/stockroom.db` — active application database
- `/data/backups/` — locally retained database snapshots
@@ -148,7 +150,7 @@ docker exec -e RESET_ADMIN_PASSWORD=NewPassword123 -it vseebox-stockroom node se
## Intended scope
-Stockroom is designed for a single reseller or small team operating one shared installation. It is not an accounting platform, payment processor, shipping-label service, or public storefront. Payment details are records of how a sale was accepted; Stockroom does not connect to Cash, Venmo, or PayPal or move money itself.
+vBoxStock is designed for a single reseller or small team operating one shared installation. It is not an accounting platform, payment processor, shipping-label service, or public storefront. Payment details are records of how a sale was accepted; vBoxStock does not connect to Cash, Venmo, or PayPal or move money itself.
## Updating and versioning
diff --git a/package.json b/package.json
index d6c0223..2297e72 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "vseebox-stockroom",
- "version": "2.1.2",
+ "version": "2.2.0",
"private": true,
"type": "module",
"engines": { "node": ">=22.13.0" },
diff --git a/server.mjs b/server.mjs
index f6db4be..2ccced2 100644
--- a/server.mjs
+++ b/server.mjs
@@ -56,7 +56,7 @@ function authorize(req,url){if(url.pathname==="/api/health"||url.pathname==="/ap
const backupName=(prefix="stockroom")=>`${prefix}-${new Date().toISOString().replace(/[:.]/g,"-")}.db`;
async function createBackup(prefix){const name=backupName(prefix),path=join(backupDir,name);await backup(db,path);return name}
function safeBackup(name){if(!/^[a-zA-Z0-9._-]+\.db$/.test(name))throw new Error("Invalid backup name");return join(backupDir,name)}
-function validateBackup(path){const candidate=new DatabaseSync(path,{readOnly:true});try{const tables=new Set(candidate.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(x=>x.name));if(!tables.has("products")||!tables.has("customers"))throw new Error("This is not a valid Stockroom database.");const integrity=candidate.prepare("PRAGMA integrity_check").get();if(Object.values(integrity)[0]!=="ok")throw new Error("The backup failed its integrity check.")}finally{candidate.close()}}
+function validateBackup(path){const candidate=new DatabaseSync(path,{readOnly:true});try{const tables=new Set(candidate.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(x=>x.name));if(!tables.has("products")||!tables.has("customers"))throw new Error("This is not a valid vBoxStock database.");const integrity=candidate.prepare("PRAGMA integrity_check").get();if(Object.values(integrity)[0]!=="ok")throw new Error("The backup failed its integrity check.")}finally{candidate.close()}}
async function restoreFrom(path,res,req,user){validateBackup(path);const target=path.split("/").pop();await createBackup("pre-restore");audit(req,user,"database_restore",target);db.exec("PRAGMA wal_checkpoint(TRUNCATE)");db.close();await copyFile(path,databasePath);await unlink(`${databasePath}-wal`).catch(()=>{});await unlink(`${databasePath}-shm`).catch(()=>{});await writeFile(restoreMarker,JSON.stringify({username:user.username,target,details:"Database restored; all sessions invalidated",ipAddress:clientIp(req)}));sessions.clear();json(res,200,{ok:true,restarting:true});setTimeout(()=>process.exit(0),250)}
function confirmPassword(user,password){const record=db.prepare("SELECT password_hash FROM users WHERE id=?").get(user.id);if(!record||!verifyPassword(String(password||""),record.password_hash))throw Object.assign(new Error("Current password is incorrect."),{status:403})}
function productInput(v){const model=String(v.model||""),condition=String(v.condition||"");if(!allowedModels.has(model)||!allowedConditions.has(condition)||!v.receivedAt)throw new Error("Model, condition, and received date are required.");return{uid:String(v.uid||"").trim(),sn:String(v.sn||"").trim(),mac:String(v.mac||"").trim(),model,condition,receivedAt:String(v.receivedAt),cost:Number(v.cost)||0,notes:String(v.notes||"").trim()}}
@@ -105,5 +105,5 @@ async function api(req,res,url){
if(req.method==="POST"&&action==="restock"){const v=await body(req);if(!allowedConditions.has(v.condition)||!v.receivedAt)throw new Error("Condition and return date are required.");db.prepare("UPDATE products SET status='available',condition=?,received_at=?,sold_at=NULL,customer_id=NULL,customer_name=NULL,phone=NULL,sale_price=NULL,ship_address1='',ship_address2='',ship_city='',ship_state='',ship_zip='',shipping_notes='',payment_method='',payment_reference='',sale_notes='' WHERE id=? AND status='sold'").run(v.condition,String(v.receivedAt),id);audit(req,user,"sale_voided_restocked",id);return json(res,200,getProduct().get(id))}
return json(res,405,{error:"Method not allowed"});
}
-const mime={".html":"text/html; charset=utf-8",".css":"text/css; charset=utf-8",".js":"text/javascript; charset=utf-8",".svg":"image/svg+xml"};
-createServer(async(req,res)=>{try{const url=new URL(req.url,`http://${req.headers.host||"localhost"}`);if(url.pathname.startsWith("/api/"))return await api(req,res,url);const requested=url.pathname==="/"?"index.html":url.pathname.slice(1),file=normalize(join(publicDir,requested));if(!file.startsWith(publicDir)||!(await stat(file)).isFile())throw new Error("NOT_FOUND");const content=await readFile(file);res.writeHead(200,{"content-type":mime[extname(file)]||"application/octet-stream","cache-control":"no-store"});res.end(content)}catch(error){if(error.message==="NOT_FOUND"||error.code==="ENOENT")return json(res,404,{error:"Not found"});const duplicate=String(error.message).includes("UNIQUE constraint failed");json(res,error.status||(duplicate?409:400),{error:duplicate?"That username, UID, SN, or MAC is already recorded.":error.message,code:error.code})}}).listen(port,"0.0.0.0",()=>console.log(`Stockroom listening on port ${port}`));
+const mime={".html":"text/html; charset=utf-8",".css":"text/css; charset=utf-8",".js":"text/javascript; charset=utf-8",".svg":"image/svg+xml",".png":"image/png",".webmanifest":"application/manifest+json; charset=utf-8"};
+createServer(async(req,res)=>{try{const url=new URL(req.url,`http://${req.headers.host||"localhost"}`);if(url.pathname.startsWith("/api/"))return await api(req,res,url);const requested=url.pathname==="/"?"index.html":url.pathname.slice(1),file=normalize(join(publicDir,requested));if(!file.startsWith(publicDir)||!(await stat(file)).isFile())throw new Error("NOT_FOUND");const content=await readFile(file);res.writeHead(200,{"content-type":mime[extname(file)]||"application/octet-stream","cache-control":"no-store"});res.end(content)}catch(error){if(error.message==="NOT_FOUND"||error.code==="ENOENT")return json(res,404,{error:"Not found"});const duplicate=String(error.message).includes("UNIQUE constraint failed");json(res,error.status||(duplicate?409:400),{error:duplicate?"That username, UID, SN, or MAC is already recorded.":error.message,code:error.code})}}).listen(port,"0.0.0.0",()=>console.log(`vBoxStock listening on port ${port}`));
diff --git a/stockroom-unraid.xml b/stockroom-unraid.xml
index bd412c3..3977034 100644
--- a/stockroom-unraid.xml
+++ b/stockroom-unraid.xml
@@ -1,10 +1,10 @@
- vSeeBox-Stockroomghcr.io/mfwadejr/vseebox-stockroom:latesthttps://github.com/mfwadejr/vseebox-stockroom/pkgs/container/vseebox-stockroom
+ vBoxStockghcr.io/mfwadejr/vseebox-stockroom:latesthttps://github.com/mfwadejr/vseebox-stockroom/pkgs/container/vseebox-stockroom
bridgeshfalse
Self-contained vSeeBox inventory and sales tracker with an embedded SQLite database.
Tools:http://[IP]:[PORT:3000]/
- 3000
+ 3000
/mnt/user/appdata/vseebox-stockroom
America/New_York