New
Apps 818Makers 817This week 818
Promote your app−30%
0views in
the last 30 days

The Bali Bay

AI drafted eBay listings for industrial parts sellers, with human review before publish....

Built with Claude/Claude Code

by notbali · from USUnited States · added Oct 29, 2026
no ratings0 comments

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_PROVIDER to nvidia (default) or claude (kimi is still accepted as an alias for nvidia, from when this provider was Kimi K2.6):
    • nvidia uses a vision model from NVIDIA's free NIM tier — sign up free at build.nvidia.com, then Account → API Keys, for NVIDIA_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 is NVIDIA_MODEL (default nvidia/nemotron-3.5-super-vl-120b-a12b); if it's retired, errors, or keeps returning unusable output, NVIDIA_FALLBACK_MODEL (default nvidia/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 update NVIDIA_MODEL. Only the first 4 photos per draft are sent to the model.
    • claude uses Anthropic's API — get ANTHROPIC_API_KEY from your Anthropic Console. Paid only, no free tier.
  • Accounts — SESSION_SECRET and TOKEN_ENCRYPTION_KEY, each a random 32-byte secret:
    node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
    
    Run it twice for two different values. Keep TOKEN_ENCRYPTION_KEY especially 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 the Secure cookie flag and the eBay OAuth callback URL. Leave unset for pure localhost development.
  • ADMIN_EMAIL — the account (matched by email) that can post "What's New" updates and read submitted feedback via GET /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 to onboarding@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 as ADMIN_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.dev subdomain, 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_ID is shown on your Cloudflare dashboard's R2 overview page
    • R2_PUBLIC_BASE_URL is the public URL the bucket is served from, no trailing slash
  • Database backups (optional, strongly recommended in production) — see "Backups" below. Set R2_BACKUP_BUCKET_NAME to 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 ./data and ./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.yaml already 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:

  1. Push this repo to GitHub (Render deploys from a connected git repo).
  2. 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.yaml defaults to starter as a starting point, adjust if needed.
  3. In the Render dashboard: New → Blueprint, connect this repo. Render reads render.yaml and creates the web service + disk automatically, including DATA_DIR/UPLOAD_DIR pointed at the mounted disk.
  4. Fill in the environment variables Render prompts for during Blueprint creation (everything marked sync: false in render.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.yaml deliberately 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. Leave EBAY_OAUTH_REDIRECT_URI and PUBLIC_BASE_URL blank for now - you don't know the assigned URL yet.
  5. Deploy. Render assigns a stable https://<something>.onrender.com URL (or attach your own custom domain).
  6. Now go back and fill in the two you skipped: set PUBLIC_BASE_URL to that URL, and in your eBay Developer Account register/update a RuName with Accept URL <that URL>/api/ebay/callback, then set EBAY_OAUTH_REDIRECT_URI to the resulting RuName. Redeploy (or just let Render's env var change trigger one).
  7. 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 as ADMIN_EMAIL if 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:

  1. 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.)
  2. 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.
  3. Set R2_BACKUP_BUCKET_NAME to the bucket's name (in .env, or in Render's dashboard) and restart. The startup log says Database 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.html list, /update.html?id= for a single entry) — a public changelog, readable without an account. The admin account posts/deletes entries via POST/DELETE /api/updates (gated by requireAdmin); everyone can GET them.
  • Feedback (feedback-widget.js, mounted in the footer of every page) — a standing box that posts to POST /api/feedback. Every submission is saved to the feedback table regardless of email delivery; if ADMIN_EMAIL and RESEND_API_KEY are set, the admin also gets emailed. Rate-limited per logged-in user. The admin reads submissions via GET /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.js is isomorphic so the same file is both require()-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-password and POST /api/auth/delete-account.
  • Error pages — a branded 404.html/500.html for browser navigation; API routes (/api/*) get a JSON { error } body instead, decided by services/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_ID in .env is 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

Description taken from the repository README.

Ratings

–
0 ratings
5 0
4 0
3 0
2 0
1 0

Rate this app

Sign in to rate

Comments 0

Sign in to comment

No comments yet.