The Bali Bay
A small full-stack app for industrial parts sellers: upload one or more photos/notes about a part (drag & drop supported) → AI identifies it and drafts a title, description, pricing, condition, and item specifics → you review and edit everything in a dashboard → one explicit, confirmed click publishes it live to eBay.
Multi-user: each person creates their own account and connects their own eBay seller account (via eBay's OAuth consent flow) - nobody shares anyone else's eBay credentials, and everyone only ever sees and publishes their own drafts.
Public marketing/landing page at /, the actual dashboard (login, eBay connect, listings) lives
at /app.html. There's also a public "What's New" changelog, Privacy/Terms pages, and a standing
feedback box on every page.
Stack
- Backend: Node.js + Express
- Accounts: hand-rolled sessions (Node's built-in
crypto- scrypt password hashing, signed session cookies), no session/auth framework dependency - DB: SQLite (via Node's built-in
node:sqlite), file-based, no separate DB server needed - Frontend: plain HTML/CSS/JS (no build step) - a marketing landing page, an app dashboard, and a few static pages (What's New, Privacy, Terms, 404/500), sharing a light/dark ("Sunrise/Sunset") theme
- AI: pluggable via
AI_PROVIDER(services/aiService.js) - defaults to a free NVIDIA NIM vision model (Nemotron 3.5 Super VL, with an automatic fallback model), or Anthropic Claude as an alternative. Shared across all sellers (not per-user). - Marketplace: eBay Inventory API, authorized per-seller via 3-legged OAuth
- Photo hosting: Cloudflare R2 (eBay needs a public HTTPS URL to fetch each photo from)
- Feedback email: Resend (optional - feedback is always saved to the DB regardless of whether email delivery is configured)
- A single admin account (matched by email against
ADMIN_EMAIL, not a roles table) can post "What's New" entries and read submitted feedback
1. Install
Requires Node.js 22+ (uses the built-in node:sqlite module, which is experimental in Node 22
and stable from Node 24 — the npm start/npm run dev scripts already pass the
--experimental-sqlite flag for you).
npm install
2. Configure credentials
Copy the example env file and fill in your real values:
cp .env.example .env
You'll need:
- AI provider — set
AI_PROVIDERtonvidia(default) orclaude(kimiis still accepted as an alias fornvidia, from when this provider was Kimi K2.6):nvidiauses a vision model from NVIDIA's free NIM tier — sign up free at build.nvidia.com, then Account → API Keys, forNVIDIA_API_KEY. Free tier is rate-limited to roughly 40 requests/minute, and a draft can take 10-60+ seconds when the queue is busy. The primary model isNVIDIA_MODEL(defaultnvidia/nemotron-3.5-super-vl-120b-a12b); if it's retired, errors, or keeps returning unusable output,NVIDIA_FALLBACK_MODEL(defaultnvidia/nemotron-3-nano-omni-30b-a3b-reasoning) is tried automatically. NVIDIA retires catalog models often - if drafts start failing, list what your key can use (GET https://integrate.api.nvidia.com/v1/models) and updateNVIDIA_MODEL. Only the first 4 photos per draft are sent to the model.claudeuses Anthropic's API — getANTHROPIC_API_KEYfrom your Anthropic Console. Paid only, no free tier.
- Accounts —
SESSION_SECRETandTOKEN_ENCRYPTION_KEY, each a random 32-byte secret:
Run it twice for two different values. Keepnode -e "console.log(require('crypto').randomBytes(32).toString('hex'))"TOKEN_ENCRYPTION_KEYespecially safe and backed up - it's what decrypts every seller's stored eBay refresh token; losing it means everyone has to reconnect their eBay account. PUBLIC_BASE_URL— the public HTTPS origin this app is reachable at once deployed (see "Exposing this for other sellers" below). Used for theSecurecookie flag and the eBay OAuth callback URL. Leave unset for purelocalhostdevelopment.ADMIN_EMAIL— the account (matched by email) that can post "What's New" updates and read submitted feedback viaGET /api/feedback, and where feedback emails are sent. Must match the email of an account you've actually signed up with — there's no separate roles table.- Feedback email (optional) — if unset, feedback submitted through the site is still saved to
the DB, it just isn't emailed to you:
RESEND_API_KEY— a free API key from resend.com (no credit card required for the free tier).FEEDBACK_FROM_EMAIL— optional, defaults toonboarding@resend.dev. Without a verified sending domain on the Resend account, mail can only be delivered to the address that owns the Resend account — fine as long as that's the same asADMIN_EMAIL.
- eBay app credentials — from your eBay Developer account (developer.ebay.com):
EBAY_CLIENT_ID/EBAY_CLIENT_SECRET— your app's keys. These identify the app itself and are shared by every seller who connects - they are not a stand-in for any one seller's own selling authorization.EBAY_OAUTH_REDIRECT_URI— see "Exposing this for other sellers" below; each seller authorizes their own account via the in-app "Connect eBay Account" flow (Settings), there is no env var for an individual seller's token anymore.EBAY_CATEGORY_ID— fallback eBay category ID, only used for drafts where the per-listing category lookup found nothing (verify the exact ID for your product type at eBay's category lookup)
- Cloudflare R2 credentials — eBay's Inventory API fetches listing photos itself from a
public HTTPS URL; it can never reach
localhost, so each photo is uploaded to R2 right before publishing:- Create a bucket in the Cloudflare dashboard (dash.cloudflare.com) → R2, and enable public
access on it (either the free
r2.devsubdomain, or your own custom domain) - Create an R2 API token (R2 → Manage API Tokens) scoped to that bucket for
R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY R2_ACCOUNT_IDis shown on your Cloudflare dashboard's R2 overview pageR2_PUBLIC_BASE_URLis the public URL the bucket is served from, no trailing slash
- Create a bucket in the Cloudflare dashboard (dash.cloudflare.com) → R2, and enable public
access on it (either the free
- Database backups (optional, strongly recommended in production) — see "Backups" below.
Set
R2_BACKUP_BUCKET_NAMEto a separate, private R2 bucket. AI_DAILY_LIMIT(optional) — max AI drafts per seller per rolling 24 hours (default 50). Failed generations don't count. Sellers over the limit get a 429 with how long to wait.DATA_DIR/UPLOAD_DIR(optional) — where the SQLite DB and uploaded photos live. Default to./dataand./uploads, which is correct for local dev. Only override these when deploying somewhere with a persistent disk mounted outside this repo's own directory (see "Deploying to Render" below -render.yamlalready sets both for you there).
Start with EBAY_ENV=sandbox and eBay's sandbox credentials until you've tested the flow
end-to-end — this avoids any risk of pushing test data into a real store.
Each seller's own business policies (shipping, payment, returns, warehouse location) are
configured per-account in the dashboard's Settings modal after they connect their eBay account,
not in .env - paste them in from that seller's own eBay Seller Hub → Business Policies.
3. Run
npm start
Visit http://localhost:3000 for the landing page, or go straight to
http://localhost:3000/app.html to create the first account (it automatically inherits any parts
already sitting in the local dev database from before accounts existed), then connect an eBay
account from the gate screen or Settings. An onboarding checklist on the dashboard tracks the
remaining setup steps (connect eBay, configure business policies, create and publish a first
listing) until they're all done.
4. Tests
npm test
Runs Node's built-in test runner (node --test) over test/. Most of the suite exercises
services/* and middleware/auth.js against an isolated in-memory-style DB rather than the real
one — see the "pure query logic, kept separate from routes/ so it's testable" comment repeated
across services/*.js for why the routes stay thin and the logic lives in testable functions
underneath them.
Exposing this for other sellers
The eBay OAuth consent flow needs the app reachable at a stable public HTTPS URL - eBay
redirects the seller's browser back to a pre-registered callback URL after they approve access,
so a URL that changes on every restart (like a Cloudflare quick tunnel's random
trycloudflare.com hostname) will break reconnecting later.
For quick local testing, a Cloudflare named tunnel bound to a fixed hostname works (a quick tunnel is fine for a one-off manual test, but its random hostname breaks on every restart). For actually running this for real sellers, deploy it properly instead - see the next section.
Either way, one-time in your eBay Developer Account (Application Keys → "eBay Redirect URL"):
register (or reuse) a RuName whose Accept URL is <your public origin>/api/ebay/callback, and set
EBAY_OAUTH_REDIRECT_URI in .env to that RuName (it's a token eBay issues, not the literal URL
itself) and PUBLIC_BASE_URL to that public HTTPS origin.
Deploying to Render
This app is a single long-running Node process with a local SQLite DB (node:sqlite) and local
file uploads - it needs a host with a persistent disk and a process that stays running, not a
serverless/edge platform (Vercel's serverless functions have no persistent filesystem and won't
work here). Render and Fly.io both fit; these steps are for Render.
render.yaml in this repo is a Blueprint Render can build the service from directly. Steps:
- Push this repo to GitHub (Render deploys from a connected git repo).
- Persistent disk requires a paid Render plan - Free doesn't support disks at all. Check
Render's current pricing page for the cheapest plan that does;
render.yamldefaults tostarteras a starting point, adjust if needed. - In the Render dashboard: New → Blueprint, connect this repo. Render reads
render.yamland creates the web service + disk automatically, includingDATA_DIR/UPLOAD_DIRpointed at the mounted disk. - Fill in the environment variables Render prompts for during Blueprint creation (everything
marked
sync: falseinrender.yaml) - your AI provider key (NVIDIA_API_KEY), eBay Client ID/Secret,TOKEN_ENCRYPTION_KEY(generate it the same way as in local setup - see.env.example-render.yamldeliberately doesn't auto-generate this one, since it must be exact 32-byte hex and Render's auto-generated values aren't hex-formatted),ADMIN_EMAIL,RESEND_API_KEY(optional), and R2 credentials. LeaveEBAY_OAUTH_REDIRECT_URIandPUBLIC_BASE_URLblank for now - you don't know the assigned URL yet. - Deploy. Render assigns a stable
https://<something>.onrender.comURL (or attach your own custom domain). - Now go back and fill in the two you skipped: set
PUBLIC_BASE_URLto that URL, and in your eBay Developer Account register/update a RuName with Accept URL<that URL>/api/ebay/callback, then setEBAY_OAUTH_REDIRECT_URIto the resulting RuName. Redeploy (or just let Render's env var change trigger one). - Visit the deployed URL and sign up for real (at
/app.html) - this is a separate account from anything you created during local testing (different database entirely). Sign up with the same email asADMIN_EMAILif you want that account to be the admin.
Note: a service with an attached disk can't be horizontally scaled on Render - fine for this app's scale (also relevant: session state and the in-process rate limiters are both in-process/in-memory, so this app assumes a single instance regardless of host, and rate-limit counters reset on every restart/redeploy).
Backups
The whole app's state (accounts, sessions, eBay connections, drafts) is one SQLite file on one disk, so a disk failure or a bad deploy would otherwise lose every account. Once configured, the server uploads a snapshot of the database to R2 two minutes after startup and then once a day.
Setup:
- In Cloudflare R2, create a second bucket for backups and leave public access off. Do not reuse the photo bucket: it's publicly readable, and a backup contains every seller's email, password hash and encrypted eBay token. (The app refuses to back up into the photo bucket.)
- Make sure your R2 API token has read and write (including list and delete) access to that
bucket — widen the existing token, or make a new one and update the
R2_*keys. - Set
R2_BACKUP_BUCKET_NAMEto the bucket's name (in.env, or in Render's dashboard) and restart. The startup log saysDatabase backups enabled(or why they're disabled).
Each backup is a consistent snapshot taken while the app is running, integrity-checked before it's
uploaded, gzipped, and named by UTC date (backups/app-2026-10-27.db.gz) — a second run on the same
day replaces that day's file. Backups older than BACKUP_RETENTION_DAYS (default 14) are deleted;
the job only ever deletes files named like its own backups. A failed backup is logged
(Database backup FAILED: ...) but never crashes the server — worth an occasional look at the logs.
The R2 bucket is also protected by Cloudflare's at-rest encryption, but anyone with the bucket
credentials can read your users' emails and password hashes, so treat that API token like a secret.
Restoring:
node scripts/restore-backup.js # list available backups
node scripts/restore-backup.js latest # download + verify the newest one
node scripts/restore-backup.js backups/app-2026-10-27.db.gz /some/path/restored.db
This writes a verified copy (default restored-app.db beside the live DB) and never overwrites an
existing file. To actually switch to it: stop the app, move the current app.db aside, copy the
restored file to app.db, start the app. Try a restore once before you need one.
Photos are not part of these backups — they're stored in the upload directory and, for published listings, in the photo R2 bucket. Whether Render's own disk snapshots are enabled for your plan is worth checking in the Render dashboard as a second layer.
How the review → publish flow works
- Sign in and connect eBay — create an account (or log in) at
/app.html, then authorize your own eBay seller account via the "Connect eBay Account" gate. Every draft you create afterward belongs only to your account, and publishing always goes to your eBay store - never anyone else's. - Generate — uploading photo(s)/notes only calls the AI provider and saves a row in your local SQLite DB. Nothing touches eBay at this point.
- Review/edit — you can edit the title, description, price, condition, photos, and specifics directly in the dashboard before anything is sent anywhere. Add or remove photos at any point before publishing. Filter/search/sort your drafts by status, title, or price.
- Publish — one button, gated behind a confirmation dialog that says publishing makes the listing live and visible to real buyers immediately. Under the hood this creates/updates the eBay inventory item, creates/updates its offer, and publishes it, all in one request.
Earlier versions of this app had a separate "push to eBay as an unpublished draft" step before publishing. That was removed: eBay's own Seller Hub UI doesn't surface unpublished Inventory-API offers anywhere useful (they don't show up under Seller Hub → Listings → Drafts, which is a different, older mechanism), so the intermediate step added an eBay-side API call without a matching way to actually review it there. The real review step is this dashboard, before you click Publish.
Retries are safe. If a publish attempt fails after the item/offer were created (e.g. eBay rejects the listing for a fixable reason), the app remembers that item's SKU and offer ID and reuses them on your next attempt instead of creating a duplicate. Fix the issue and click Publish again.
Other pages and features
- Landing page (
/,public/index.html+landing.css/landing.js) — public marketing page for signed-out visitors; the "Bali Bay" logo (brand-nav.js, shared across every page) routes to the dashboard if you're already logged in, or back here if not. - What's New (
/updates.htmllist,/update.html?id=for a single entry) — a public changelog, readable without an account. The admin account posts/deletes entries viaPOST/DELETE /api/updates(gated byrequireAdmin); everyone canGETthem. - Feedback (
feedback-widget.js, mounted in the footer of every page) — a standing box that posts toPOST /api/feedback. Every submission is saved to thefeedbacktable regardless of email delivery; ifADMIN_EMAILandRESEND_API_KEYare set, the admin also gets emailed. Rate-limited per logged-in user. The admin reads submissions viaGET /api/feedback. - Onboarding checklist — shown on the dashboard until eBay is connected, business policies are
configured, and at least one listing has been created and published (
services/onboardingService.js). - Theme — light/dark, labeled "Sunrise"/"Sunset" in Settings, with a "system" option that
follows the OS preference. Preference is stored in
localStorage;theme.jsis isomorphic so the same file is bothrequire()-able from tests and loadable via a plain<script>tag. - Account settings — change password (invalidates every other session, keeps the current one
alive) and delete account (irreversible, requires re-entering your password; wipes sessions,
eBay connection, parts, and feedback for that user) —
POST /api/auth/change-passwordandPOST /api/auth/delete-account. - Error pages — a branded
404.html/500.htmlfor browser navigation; API routes (/api/*) get a JSON{ error }body instead, decided byservices/errorPageService.js.
Known things to double check before relying on this for real inventory
- Condition: the AI proposes a condition (New, New-Other, Used Excellent/Very Good/Good/ Acceptable, or For Parts/Not Working) based on the photo and notes, editable in the dashboard before pushing. Still worth double-checking — condition assessment from a photo alone has real limits, and it directly affects buyer expectations and return risk.
- Category: the AI proposes a product-type search phrase per item (e.g. "hydraulic spin-on
filter"), which the app looks up against eBay's real category tree via the Taxonomy API and
stores the matching leaf category — shown and searchable/overridable in the dashboard. The
automatic top match isn't always the most specific one (e.g. it may pick a generic "Filters"
category over "Hydraulic Filters"), so it's worth checking before publishing.
EBAY_CATEGORY_IDin.envis now only a fallback for drafts where the lookup didn't run or found nothing. - Access tokens: the eBay service fetches a fresh access token on every request for simplicity. This works fine at low volume; if you start batch-pushing many items at once, you may want to cache the token for its ~2 hour lifetime.
- Pricing: the AI's price suggestions are rough estimates based on general knowledge, not live comps. Worth spot-checking against actual sold listings before publishing, especially for less common parts.
- Photos require the R2 env vars to be filled in — each photo is uploaded to your R2 bucket right before eBay is called, so publishing will fail if those credentials are missin