Skip to content

Latest commit

History

156 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

ebae

ebae - eBay Before Anyone Else

eBay, before anyone else.

Self-hosted eBay alerting. Polls your saved searches every 1-15 minutes via the official Browse API and pings Discord the moment a matching item lists - fast enough to catch Buy It Now drops before they're gone. One container, egress-only, nothing on your network exposed.

See DESIGN.md for architecture and roadmap.

Quick start (dev)

cp .env.example .env.local # set DATABASE_URL (Neon works great)
bun install
bun run dev # UI/API only; no polling or notification delivery

Open http://localhost:3000. Local development leaves the poller off so an accidental production DATABASE_URL cannot start timers or redeliver pending alerts. The scripts force NODE_ENV=development, even if the parent shell exported NODE_ENV=production. To try the complete pipeline against a scratch database, run bun run dev:poller instead. Without eBay credentials that explicit command runs in mock mode, generating fake listings for seeding, alerts and quota testing.

Neither command needs a new env var: one implicit user, no login, DATABASE_URL and the EBAY_*/DISCORD_WEBHOOK_URL vars are the whole config. ENABLE_DEV_POLLER is internal script plumbing: bun run dev sets it to 0; bun run dev:poller sets it to 1; do not set it in .env.local. A search created under normal bun run dev remains unseeded until you run the scratch database with dev:poller. Sharing the deployment with other people is opt-in - see Multi-user.

eBay credentials

  1. Create a free account at developer.ebay.com and create an app (production keyset).
  2. Put the App ID and Cert ID in .env.local as EBAY_CLIENT_ID / EBAY_CLIENT_SECRET.
  3. Restart. The Status & Settings page shows the token going live.

In a multi-user mode the env vars are ignored: each user enters their own App ID and Cert ID on the Status & Settings page instead, and the secret is encrypted before it is stored (see ENCRYPTION_KEY). Keys are checked against eBay on save, so a typo fails immediately. Without keys a user's searches idle - the UI says so.

Browse API default quota is 5,000 calls/day per eBay app, so EBAY_DAILY_QUOTA is a per-user ceiling. The UI projects your daily usage as you add searches and the poller enforces the budget.

The Status page shows what you've spent today against what an evenly-paced day would have spent by now, so 90% used reads differently at 09:00 than at 23:00. If the saved intervals still require more calls than the budget left today, a budget governor stretches them so polling lasts rather than running out at noon and leaving the rest of the day dark. It only ever slows polling down—never below the interval you set—is capped at 4x, and releases once the remaining workload has a 5% buffer. Changing an interval, pausing a search, or extending snooze can therefore restore the saved cadence before midnight. The Saved searches bar separates calls spent, work requested, and any overflow the governor must slow down. Nothing to configure.

Sold prices

An alert is only useful if you know what the thing is worth. By default ebae tracks what listings actually went for and prefers that: alerts then read "Sold ~$420 · ▼ 30% under", and the search's subtitle shows sold ~$420. Until it has enough sales, it compares against asking prices - a daily unfiltered sample of the same search, shown as "Market". Turn off Track sold prices & price drops on an individual search to save API calls.

eBay's sold-search APIs are enterprise-only, so ebae gets there by checking back on listings the search already found:

It also sends a price-drop alert whenever a followed fixed-price or Best Offer listing reaches a new lower price. Re-sightings use the ordinary poll result, and scheduled checks reuse their existing response, so this adds no eBay API calls.

  • Auctions are checked once, five minutes after they end. The end time arrives free with every poll, so nothing is spent guessing - and a snipe in the closing seconds is still captured.
  • Buy It Now listings are checked at 3, 7, 14 and 30 days, at most four times ever. Any poll that sees the listing again skips the next check for free: it's obviously still for sale.
  • Listings you excluded (exclude-terms, "for parts") are never followed - their prices would describe junk, not what you're hunting.
  • Best Offer listings are followed but kept out of the median: eBay keeps showing the asking price after a sale, so what we can read is a ceiling, not the realized price.
  • The deal context appears once three sales inside the last 30 days agree; below that it falls back to the market baseline.

Checks come out of the same EBAY_DAILY_QUOTA as polling and are counted in the projection on the Status page, so the budget governor accounts for them. They're the first thing dropped when the budget runs low, and at most three run per tick, so a backlog drains gradually instead of crowding out the polls that find deals. A check that fails outright (rate limit, an eBay outage) waits an hour rather than retrying immediately, and a listing is abandoned after six failed attempts.

Quota you don't use expires at midnight, so if your saved searches don't need the whole budget the poller spends the leftovers looking in on followed listings early - catching sales that would otherwise happen in a gap and be unreadable by the next scheduled check. It's paced across the day, never touches what your searches still need, and can't slow your polling down. Nothing to configure.

Turning the toggle off stops new checks immediately; listings already being followed age out with SEEN_RETENTION_DAYS. Editing what a search matches clears its sold history, the same way it clears the market baseline. Those sales describe the old criteria, and since the sold median outranks every other basis, keeping them would caption the new search's alerts with the old search's going rate.

Notifications

Alerts go to Discord webhooks, push notifications on your own devices, or both. Push stays enabled for every search. Discord defaults to all saved webhooks, or a search can route to one named webhook. An alert is retried after a restart only while no target has taken it.

Discord

Create a webhook in your Discord channel (channel settings → Integrations → Webhooks) and set DISCORD_WEBHOOK_URL. More targets can be added and named on the Status & Settings page, then selected while creating or editing a search. The environment webhook remains part of the all-webhook route and cannot be selected alone.

In a multi-user mode DISCORD_WEBHOOK_URL is ignored (it would fan everyone's alerts into one channel) - each user adds their own webhooks in the UI.

Push

ebae is an installable PWA, so alerts can arrive as ordinary phone or desktop notifications with no Discord involved. Install it (Chrome: the address-bar install button; iPhone: Share → Add to Home Screen), open Status & Settings, and turn on Push to this device. It is per device - turn it on wherever you want alerts.

No configuration: the VAPID keypair is generated on first use and kept in the database. VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY override it if you would rather pin the keys, and VAPID_SUBJECT sets the contact URI the push services see (it defaults to this project's repo).

The toggle only appears where a browser can actually do push, which needs a secure context:

  • http://localhost counts as secure, so push works out of the box in dev.
  • http://<lan-ip>:3000 does not. Browsers refuse service workers there, so no push - put ebae behind TLS, or use Discord.
  • iPhone/iPad additionally require Add to Home Screen first. Safari tabs cannot receive push at all; the toggle appears only in the installed app.

Subscriptions expire on their own (iOS drops them after a week or two of inactivity, and Chrome revokes permission for sites you rarely open). ebae re-registers the current device every time you open it, and drops subscriptions the push service reports as gone, so this mostly takes care of itself - but if push goes quiet, opening the app is the fix.

Configuration

Config is env vars - see .env.example. Searches, webhooks and (in multi-user modes) each user's eBay keys live in Postgres and are managed in the UI.

varpurposedefault
DATABASE_URLPostgres connection stringrequired
EBAY_CLIENT_ID / EBAY_CLIENT_SECRETeBay app credentials (single mode only)unset = mock mode
EBAY_ENVproduction or sandbox (single mode only)production
EBAY_MARKETPLACEmarketplace id (single mode only)EBAY_US
DISCORD_WEBHOOK_URLnotification target (single mode only)unset
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEYpin the push keypair instead of generating oneunset = generated on first use
VAPID_SUBJECTmailto:/https: contact URI sent to the push servicesthis project's repo
AUTH_MODEsingle, cloudflare or proxysingle
CF_ACCESS_TEAM_DOMAIN<team>.cloudflareaccess.com (required in cloudflare)unset
CF_ACCESS_AUDAccess app Audience tag (required in cloudflare)unset
AUTH_TRUSTED_HEADERheader carrying the email (required in proxy)unset
ENCRYPTION_KEYbase64 32 bytes; encrypts eBay secrets saved in the UIunset
LEGACY_OWNER_EMAILone-time claim of pre-multi-user rowsunset
POLL_INTERVAL_DEFAULTfallback poll interval (min)5
CACHE_REFRESH_HOURSDB → cache refresh cadence12
SEEN_RETENTION_DAYSseen_items and tracked_items retention (days)90
MARKET_SAMPLE_HOURSmarket-baseline resample gap (band-limited searches)24
EBAY_DAILY_QUOTAenforced daily call budget, per user5000
LOG_LEVELerror/warn/info/debuginfo
LOG_FORMATjson or prettypretty on a TTY, else json

Everything above the log vars except DATABASE_URL is optional. The single-mode-only vars are ignored in cloudflare/proxy mode (the poller warns at boot if they are set).

Multi-user

By default (AUTH_MODE=single) there is no auth and one implicit user - the trust model is your LAN, your reverse proxy, your machine. Don't expose that to the internet. Set AUTH_MODE to share one deployment with other people: each gets their own searches, alerts, webhooks, snooze and eBay keys. Identity comes from an auth proxy in front of the app; ebae never handles passwords and has no signup, no roles - onboarding is done wherever your proxy keeps its allowlist.

The single-replica constraint doesn't change: poll timers and the seen-item cache are in process memory.

AUTH_MODE=cloudflare

Cloudflare Access in front of a Cloudflare Tunnel. The app validates the signed Cf-Access-Jwt-Assertion JWT against your team's JWKS, so a request that skips Access can't forge an identity.

  1. Zero Trust → Access → Applications → add a self-hosted application for ebae's hostname.
  2. Attach your Access policy (an email include list against your IdP - Google, GitHub, whatever).
  3. Copy the application's Application Audience (AUD) TagCF_ACCESS_AUD, and your team domain (<team>.cloudflareaccess.com) → CF_ACCESS_TEAM_DOMAIN. Boot fails if either is missing.

Onboarding a friend = adding their email to the policy. Nothing app-side; their user row is created on first login. The free Zero Trust tier covers up to 50 users. Service tokens are rejected: Access only mints an email claim for a real IdP login.

AUTH_MODE=proxy

Generic trusted-header SSO for Authelia, authentik, oauth2-proxy, Tailscale serve and friends. Set AUTH_TRUSTED_HEADER to the header your proxy puts the user's email in (e.g. Remote-Email); that value, lowercased, is the identity.

Only enable this when the app is reachable exclusively through that proxy. The header is plain text and is trusted as-is - anyone who can reach the app directly can set it to any address and become that user. Bind the app to localhost or a private network, and make sure the proxy strips the header from inbound requests.

Local development against a shared database

AUTH_MODE=single still runs against a database a multi-user mode created, but you will see an empty account: single mode signs you in as the implicit local@localhost user, while every existing row belongs to the SSO identity that created it. Nothing is lost and nothing is taken - the boot-time claim only adopts rows that have no owner - your data is simply someone else's.

bun run dev loads the cache for the UI but schedules no polls and performs no pending-alert redelivery. bun run dev:poller is the deliberate opt-in for a scratch database. Even then development polls only the implicit local@localhost user, never a real identity loaded from a shared database, and development never replays pending alerts. The UI labels a real identity as Guarded rather than asking you to re-enter credentials.

The safety boundary is background polling and delivery, not database access. Migrations, cache maintenance and any UI/API action still use the configured database. Use a scratch DATABASE_URL for normal work, or a read-only production role when inspection is unavoidable.

If you genuinely need your real rows locally, be yourself instead of being local@localhost:

AUTH_MODE=proxy
AUTH_TRUSTED_HEADER=X-Email
curl -H 'X-Email: you@example.com' localhost:3000/api/searches

For the browser you need something to inject that header on every request. Prefer a local reverse proxy over a browser extension. Development still refuses to poll that real identity; this setup is for inspecting or editing its rows through the UI/API.

ENCRYPTION_KEY

Needed only to save eBay credentials through the UI (encrypts the client secret at rest, AES-256-GCM). Single-mode deployments on EBAY_CLIENT_ID/EBAY_CLIENT_SECRET store nothing and don't need it.

openssl rand -base64 32

Keep it with your other secrets. There is no key history and no re-encryption tooling: lose it and each user re-enters their eBay keys in the UI. That is the whole recovery story, and it takes a minute.

LEGACY_OWNER_EMAIL

Only for switching an existing single-user database to a multi-user mode. Rows created before multi-user have no owner, and the poller skips them. Set this to your email for one boot; every existing search, channel and alert is assigned to that user, and the row is adopted (sub stamped) when you first log in. Remove the var afterwards - the claim is idempotent, so leaving it does nothing.

Two things do not survive that upgrade, both once-off:

  • Your snooze window resets to the defaults (off, 01:00-07:00). The old global settings row is dropped rather than copied, because the migration runs before your user row exists. Note your window down first and re-enter it in the UI.
  • DISCORD_WEBHOOK_URL stops being read, since one global webhook would fan every user's alerts into the same channel. The claim imports it once as your own channel so notifications keep working, and logs when it does; you can drop the var afterwards. Every other user adds their webhook in the UI.

Deploy

docker compose up -d # uses .env; bundled Postgres available in the compose file

Kubernetes: deploy/k8s.yaml (single replica - poll timers and the seen-item cache are in-process).

Recommended setup: run it on a home box and expose the UI through a Cloudflare Tunnel behind Cloudflare Access. All app traffic is outbound-only. Add AUTH_MODE=cloudflare and you can share it with other people - see Multi-user.

Releasing

Push an annotated semver tag. docker.yml builds and pushes the matching container image, while release.yml publishes GoReleaser's grouped changelog.

For an ordinary release:

git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin vX.Y.Z

For a release worth calling out, run git tag -a vX.Y.Z, then use this tag message:

vX.Y.Z
## Highlights
**Short title** — One friendly, user-facing sentence.

Add one line per highlight. GoReleaser prepends that block above the generated changelog; without the ## Highlights heading, it publishes the usual changelog and container-image footer only. The annotated tag is the canonical source: PR-template highlight candidates are never copied into a release automatically.

About

Ebay, before anyone else

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
GitHub - adawalli/ebae: Ebay, before anyone else · GitHub
Skip to content

Latest commit

History

156 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

ebae

ebae - eBay Before Anyone Else

eBay, before anyone else.

Self-hosted eBay alerting. Polls your saved searches every 1-15 minutes via the official Browse API and pings Discord the moment a matching item lists - fast enough to catch Buy It Now drops before they're gone. One container, egress-only, nothing on your network exposed.

See DESIGN.md for architecture and roadmap.

Quick start (dev)

cp .env.example .env.local # set DATABASE_URL (Neon works great)
bun install
bun run dev # UI/API only; no polling or notification delivery

Open http://localhost:3000. Local development leaves the poller off so an accidental production DATABASE_URL cannot start timers or redeliver pending alerts. The scripts force NODE_ENV=development, even if the parent shell exported NODE_ENV=production. To try the complete pipeline against a scratch database, run bun run dev:poller instead. Without eBay credentials that explicit command runs in mock mode, generating fake listings for seeding, alerts and quota testing.

Neither command needs a new env var: one implicit user, no login, DATABASE_URL and the EBAY_*/DISCORD_WEBHOOK_URL vars are the whole config. ENABLE_DEV_POLLER is internal script plumbing: bun run dev sets it to 0; bun run dev:poller sets it to 1; do not set it in .env.local. A search created under normal bun run dev remains unseeded until you run the scratch database with dev:poller. Sharing the deployment with other people is opt-in - see Multi-user.

eBay credentials

  1. Create a free account at developer.ebay.com and create an app (production keyset).
  2. Put the App ID and Cert ID in .env.local as EBAY_CLIENT_ID / EBAY_CLIENT_SECRET.
  3. Restart. The Status & Settings page shows the token going live.

In a multi-user mode the env vars are ignored: each user enters their own App ID and Cert ID on the Status & Settings page instead, and the secret is encrypted before it is stored (see ENCRYPTION_KEY). Keys are checked against eBay on save, so a typo fails immediately. Without keys a user's searches idle - the UI says so.

Browse API default quota is 5,000 calls/day per eBay app, so EBAY_DAILY_QUOTA is a per-user ceiling. The UI projects your daily usage as you add searches and the poller enforces the budget.

The Status page shows what you've spent today against what an evenly-paced day would have spent by now, so 90% used reads differently at 09:00 than at 23:00. If the saved intervals still require more calls than the budget left today, a budget governor stretches them so polling lasts rather than running out at noon and leaving the rest of the day dark. It only ever slows polling down—never below the interval you set—is capped at 4x, and releases once the remaining workload has a 5% buffer. Changing an interval, pausing a search, or extending snooze can therefore restore the saved cadence before midnight. The Saved searches bar separates calls spent, work requested, and any overflow the governor must slow down. Nothing to configure.

Sold prices

An alert is only useful if you know what the thing is worth. By default ebae tracks what listings actually went for and prefers that: alerts then read "Sold ~$420 · ▼ 30% under", and the search's subtitle shows sold ~$420. Until it has enough sales, it compares against asking prices - a daily unfiltered sample of the same search, shown as "Market". Turn off Track sold prices & price drops on an individual search to save API calls.

eBay's sold-search APIs are enterprise-only, so ebae gets there by checking back on listings the search already found:

It also sends a price-drop alert whenever a followed fixed-price or Best Offer listing reaches a new lower price. Re-sightings use the ordinary poll result, and scheduled checks reuse their existing response, so this adds no eBay API calls.

  • Auctions are checked once, five minutes after they end. The end time arrives free with every poll, so nothing is spent guessing - and a snipe in the closing seconds is still captured.
  • Buy It Now listings are checked at 3, 7, 14 and 30 days, at most four times ever. Any poll that sees the listing again skips the next check for free: it's obviously still for sale.
  • Listings you excluded (exclude-terms, "for parts") are never followed - their prices would describe junk, not what you're hunting.
  • Best Offer listings are followed but kept out of the median: eBay keeps showing the asking price after a sale, so what we can read is a ceiling, not the realized price.
  • The deal context appears once three sales inside the last 30 days agree; below that it falls back to the market baseline.

Checks come out of the same EBAY_DAILY_QUOTA as polling and are counted in the projection on the Status page, so the budget governor accounts for them. They're the first thing dropped when the budget runs low, and at most three run per tick, so a backlog drains gradually instead of crowding out the polls that find deals. A check that fails outright (rate limit, an eBay outage) waits an hour rather than retrying immediately, and a listing is abandoned after six failed attempts.

Quota you don't use expires at midnight, so if your saved searches don't need the whole budget the poller spends the leftovers looking in on followed listings early - catching sales that would otherwise happen in a gap and be unreadable by the next scheduled check. It's paced across the day, never touches what your searches still need, and can't slow your polling down. Nothing to configure.

Turning the toggle off stops new checks immediately; listings already being followed age out with SEEN_RETENTION_DAYS. Editing what a search matches clears its sold history, the same way it clears the market baseline. Those sales describe the old criteria, and since the sold median outranks every other basis, keeping them would caption the new search's alerts with the old search's going rate.

Notifications

Alerts go to Discord webhooks, push notifications on your own devices, or both. Push stays enabled for every search. Discord defaults to all saved webhooks, or a search can route to one named webhook. An alert is retried after a restart only while no target has taken it.

Discord

Create a webhook in your Discord channel (channel settings → Integrations → Webhooks) and set DISCORD_WEBHOOK_URL. More targets can be added and named on the Status & Settings page, then selected while creating or editing a search. The environment webhook remains part of the all-webhook route and cannot be selected alone.

In a multi-user mode DISCORD_WEBHOOK_URL is ignored (it would fan everyone's alerts into one channel) - each user adds their own webhooks in the UI.

Push

ebae is an installable PWA, so alerts can arrive as ordinary phone or desktop notifications with no Discord involved. Install it (Chrome: the address-bar install button; iPhone: Share → Add to Home Screen), open Status & Settings, and turn on Push to this device. It is per device - turn it on wherever you want alerts.

No configuration: the VAPID keypair is generated on first use and kept in the database. VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY override it if you would rather pin the keys, and VAPID_SUBJECT sets the contact URI the push services see (it defaults to this project's repo).

The toggle only appears where a browser can actually do push, which needs a secure context:

  • http://localhost counts as secure, so push works out of the box in dev.
  • http://<lan-ip>:3000 does not. Browsers refuse service workers there, so no push - put ebae behind TLS, or use Discord.
  • iPhone/iPad additionally require Add to Home Screen first. Safari tabs cannot receive push at all; the toggle appears only in the installed app.

Subscriptions expire on their own (iOS drops them after a week or two of inactivity, and Chrome revokes permission for sites you rarely open). ebae re-registers the current device every time you open it, and drops subscriptions the push service reports as gone, so this mostly takes care of itself - but if push goes quiet, opening the app is the fix.

Configuration

Config is env vars - see .env.example. Searches, webhooks and (in multi-user modes) each user's eBay keys live in Postgres and are managed in the UI.

varpurposedefault
DATABASE_URLPostgres connection stringrequired
EBAY_CLIENT_ID / EBAY_CLIENT_SECRETeBay app credentials (single mode only)unset = mock mode
EBAY_ENVproduction or sandbox (single mode only)production
EBAY_MARKETPLACEmarketplace id (single mode only)EBAY_US
DISCORD_WEBHOOK_URLnotification target (single mode only)unset
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEYpin the push keypair instead of generating oneunset = generated on first use
VAPID_SUBJECTmailto:/https: contact URI sent to the push servicesthis project's repo
AUTH_MODEsingle, cloudflare or proxysingle
CF_ACCESS_TEAM_DOMAIN<team>.cloudflareaccess.com (required in cloudflare)unset
CF_ACCESS_AUDAccess app Audience tag (required in cloudflare)unset
AUTH_TRUSTED_HEADERheader carrying the email (required in proxy)unset
ENCRYPTION_KEYbase64 32 bytes; encrypts eBay secrets saved in the UIunset
LEGACY_OWNER_EMAILone-time claim of pre-multi-user rowsunset
POLL_INTERVAL_DEFAULTfallback poll interval (min)5
CACHE_REFRESH_HOURSDB → cache refresh cadence12
SEEN_RETENTION_DAYSseen_items and tracked_items retention (days)90
MARKET_SAMPLE_HOURSmarket-baseline resample gap (band-limited searches)24
EBAY_DAILY_QUOTAenforced daily call budget, per user5000
LOG_LEVELerror/warn/info/debuginfo
LOG_FORMATjson or prettypretty on a TTY, else json

Everything above the log vars except DATABASE_URL is optional. The single-mode-only vars are ignored in cloudflare/proxy mode (the poller warns at boot if they are set).

Multi-user

By default (AUTH_MODE=single) there is no auth and one implicit user - the trust model is your LAN, your reverse proxy, your machine. Don't expose that to the internet. Set AUTH_MODE to share one deployment with other people: each gets their own searches, alerts, webhooks, snooze and eBay keys. Identity comes from an auth proxy in front of the app; ebae never handles passwords and has no signup, no roles - onboarding is done wherever your proxy keeps its allowlist.

The single-replica constraint doesn't change: poll timers and the seen-item cache are in process memory.

AUTH_MODE=cloudflare

Cloudflare Access in front of a Cloudflare Tunnel. The app validates the signed Cf-Access-Jwt-Assertion JWT against your team's JWKS, so a request that skips Access can't forge an identity.

  1. Zero Trust → Access → Applications → add a self-hosted application for ebae's hostname.
  2. Attach your Access policy (an email include list against your IdP - Google, GitHub, whatever).
  3. Copy the application's Application Audience (AUD) TagCF_ACCESS_AUD, and your team domain (<team>.cloudflareaccess.com) → CF_ACCESS_TEAM_DOMAIN. Boot fails if either is missing.

Onboarding a friend = adding their email to the policy. Nothing app-side; their user row is created on first login. The free Zero Trust tier covers up to 50 users. Service tokens are rejected: Access only mints an email claim for a real IdP login.

AUTH_MODE=proxy

Generic trusted-header SSO for Authelia, authentik, oauth2-proxy, Tailscale serve and friends. Set AUTH_TRUSTED_HEADER to the header your proxy puts the user's email in (e.g. Remote-Email); that value, lowercased, is the identity.

Only enable this when the app is reachable exclusively through that proxy. The header is plain text and is trusted as-is - anyone who can reach the app directly can set it to any address and become that user. Bind the app to localhost or a private network, and make sure the proxy strips the header from inbound requests.

Local development against a shared database

AUTH_MODE=single still runs against a database a multi-user mode created, but you will see an empty account: single mode signs you in as the implicit local@localhost user, while every existing row belongs to the SSO identity that created it. Nothing is lost and nothing is taken - the boot-time claim only adopts rows that have no owner - your data is simply someone else's.

bun run dev loads the cache for the UI but schedules no polls and performs no pending-alert redelivery. bun run dev:poller is the deliberate opt-in for a scratch database. Even then development polls only the implicit local@localhost user, never a real identity loaded from a shared database, and development never replays pending alerts. The UI labels a real identity as Guarded rather than asking you to re-enter credentials.

The safety boundary is background polling and delivery, not database access. Migrations, cache maintenance and any UI/API action still use the configured database. Use a scratch DATABASE_URL for normal work, or a read-only production role when inspection is unavoidable.

If you genuinely need your real rows locally, be yourself instead of being local@localhost:

AUTH_MODE=proxy
AUTH_TRUSTED_HEADER=X-Email
curl -H 'X-Email: you@example.com' localhost:3000/api/searches

For the browser you need something to inject that header on every request. Prefer a local reverse proxy over a browser extension. Development still refuses to poll that real identity; this setup is for inspecting or editing its rows through the UI/API.

ENCRYPTION_KEY

Needed only to save eBay credentials through the UI (encrypts the client secret at rest, AES-256-GCM). Single-mode deployments on EBAY_CLIENT_ID/EBAY_CLIENT_SECRET store nothing and don't need it.

openssl rand -base64 32

Keep it with your other secrets. There is no key history and no re-encryption tooling: lose it and each user re-enters their eBay keys in the UI. That is the whole recovery story, and it takes a minute.

LEGACY_OWNER_EMAIL

Only for switching an existing single-user database to a multi-user mode. Rows created before multi-user have no owner, and the poller skips them. Set this to your email for one boot; every existing search, channel and alert is assigned to that user, and the row is adopted (sub stamped) when you first log in. Remove the var afterwards - the claim is idempotent, so leaving it does nothing.

Two things do not survive that upgrade, both once-off:

  • Your snooze window resets to the defaults (off, 01:00-07:00). The old global settings row is dropped rather than copied, because the migration runs before your user row exists. Note your window down first and re-enter it in the UI.
  • DISCORD_WEBHOOK_URL stops being read, since one global webhook would fan every user's alerts into the same channel. The claim imports it once as your own channel so notifications keep working, and logs when it does; you can drop the var afterwards. Every other user adds their webhook in the UI.

Deploy

docker compose up -d # uses .env; bundled Postgres available in the compose file

Kubernetes: deploy/k8s.yaml (single replica - poll timers and the seen-item cache are in-process).

Recommended setup: run it on a home box and expose the UI through a Cloudflare Tunnel behind Cloudflare Access. All app traffic is outbound-only. Add AUTH_MODE=cloudflare and you can share it with other people - see Multi-user.

Releasing

Push an annotated semver tag. docker.yml builds and pushes the matching container image, while release.yml publishes GoReleaser's grouped changelog.

For an ordinary release:

git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin vX.Y.Z

For a release worth calling out, run git tag -a vX.Y.Z, then use this tag message:

vX.Y.Z
## Highlights
**Short title** — One friendly, user-facing sentence.

Add one line per highlight. GoReleaser prepends that block above the generated changelog; without the ## Highlights heading, it publishes the usual changelog and container-image footer only. The annotated tag is the canonical source: PR-template highlight candidates are never copied into a release automatically.

About

Ebay, before anyone else

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - adawalli/ebae: Ebay, before anyone else · GitHub
Skip to content

Latest commit

History

156 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

ebae

ebae - eBay Before Anyone Else

eBay, before anyone else.

Self-hosted eBay alerting. Polls your saved searches every 1-15 minutes via the official Browse API and pings Discord the moment a matching item lists - fast enough to catch Buy It Now drops before they're gone. One container, egress-only, nothing on your network exposed.

See DESIGN.md for architecture and roadmap.

Quick start (dev)

cp .env.example .env.local # set DATABASE_URL (Neon works great)
bun install
bun run dev # UI/API only; no polling or notification delivery

Open http://localhost:3000. Local development leaves the poller off so an accidental production DATABASE_URL cannot start timers or redeliver pending alerts. The scripts force NODE_ENV=development, even if the parent shell exported NODE_ENV=production. To try the complete pipeline against a scratch database, run bun run dev:poller instead. Without eBay credentials that explicit command runs in mock mode, generating fake listings for seeding, alerts and quota testing.

Neither command needs a new env var: one implicit user, no login, DATABASE_URL and the EBAY_*/DISCORD_WEBHOOK_URL vars are the whole config. ENABLE_DEV_POLLER is internal script plumbing: bun run dev sets it to 0; bun run dev:poller sets it to 1; do not set it in .env.local. A search created under normal bun run dev remains unseeded until you run the scratch database with dev:poller. Sharing the deployment with other people is opt-in - see Multi-user.

eBay credentials

  1. Create a free account at developer.ebay.com and create an app (production keyset).
  2. Put the App ID and Cert ID in .env.local as EBAY_CLIENT_ID / EBAY_CLIENT_SECRET.
  3. Restart. The Status & Settings page shows the token going live.

In a multi-user mode the env vars are ignored: each user enters their own App ID and Cert ID on the Status & Settings page instead, and the secret is encrypted before it is stored (see ENCRYPTION_KEY). Keys are checked against eBay on save, so a typo fails immediately. Without keys a user's searches idle - the UI says so.

Browse API default quota is 5,000 calls/day per eBay app, so EBAY_DAILY_QUOTA is a per-user ceiling. The UI projects your daily usage as you add searches and the poller enforces the budget.

The Status page shows what you've spent today against what an evenly-paced day would have spent by now, so 90% used reads differently at 09:00 than at 23:00. If the saved intervals still require more calls than the budget left today, a budget governor stretches them so polling lasts rather than running out at noon and leaving the rest of the day dark. It only ever slows polling down—never below the interval you set—is capped at 4x, and releases once the remaining workload has a 5% buffer. Changing an interval, pausing a search, or extending snooze can therefore restore the saved cadence before midnight. The Saved searches bar separates calls spent, work requested, and any overflow the governor must slow down. Nothing to configure.

Sold prices

An alert is only useful if you know what the thing is worth. By default ebae tracks what listings actually went for and prefers that: alerts then read "Sold ~$420 · ▼ 30% under", and the search's subtitle shows sold ~$420. Until it has enough sales, it compares against asking prices - a daily unfiltered sample of the same search, shown as "Market". Turn off Track sold prices & price drops on an individual search to save API calls.

eBay's sold-search APIs are enterprise-only, so ebae gets there by checking back on listings the search already found:

It also sends a price-drop alert whenever a followed fixed-price or Best Offer listing reaches a new lower price. Re-sightings use the ordinary poll result, and scheduled checks reuse their existing response, so this adds no eBay API calls.

  • Auctions are checked once, five minutes after they end. The end time arrives free with every poll, so nothing is spent guessing - and a snipe in the closing seconds is still captured.
  • Buy It Now listings are checked at 3, 7, 14 and 30 days, at most four times ever. Any poll that sees the listing again skips the next check for free: it's obviously still for sale.
  • Listings you excluded (exclude-terms, "for parts") are never followed - their prices would describe junk, not what you're hunting.
  • Best Offer listings are followed but kept out of the median: eBay keeps showing the asking price after a sale, so what we can read is a ceiling, not the realized price.
  • The deal context appears once three sales inside the last 30 days agree; below that it falls back to the market baseline.

Checks come out of the same EBAY_DAILY_QUOTA as polling and are counted in the projection on the Status page, so the budget governor accounts for them. They're the first thing dropped when the budget runs low, and at most three run per tick, so a backlog drains gradually instead of crowding out the polls that find deals. A check that fails outright (rate limit, an eBay outage) waits an hour rather than retrying immediately, and a listing is abandoned after six failed attempts.

Quota you don't use expires at midnight, so if your saved searches don't need the whole budget the poller spends the leftovers looking in on followed listings early - catching sales that would otherwise happen in a gap and be unreadable by the next scheduled check. It's paced across the day, never touches what your searches still need, and can't slow your polling down. Nothing to configure.

Turning the toggle off stops new checks immediately; listings already being followed age out with SEEN_RETENTION_DAYS. Editing what a search matches clears its sold history, the same way it clears the market baseline. Those sales describe the old criteria, and since the sold median outranks every other basis, keeping them would caption the new search's alerts with the old search's going rate.

Notifications

Alerts go to Discord webhooks, push notifications on your own devices, or both. Push stays enabled for every search. Discord defaults to all saved webhooks, or a search can route to one named webhook. An alert is retried after a restart only while no target has taken it.

Discord

Create a webhook in your Discord channel (channel settings → Integrations → Webhooks) and set DISCORD_WEBHOOK_URL. More targets can be added and named on the Status & Settings page, then selected while creating or editing a search. The environment webhook remains part of the all-webhook route and cannot be selected alone.

In a multi-user mode DISCORD_WEBHOOK_URL is ignored (it would fan everyone's alerts into one channel) - each user adds their own webhooks in the UI.

Push

ebae is an installable PWA, so alerts can arrive as ordinary phone or desktop notifications with no Discord involved. Install it (Chrome: the address-bar install button; iPhone: Share → Add to Home Screen), open Status & Settings, and turn on Push to this device. It is per device - turn it on wherever you want alerts.

No configuration: the VAPID keypair is generated on first use and kept in the database. VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY override it if you would rather pin the keys, and VAPID_SUBJECT sets the contact URI the push services see (it defaults to this project's repo).

The toggle only appears where a browser can actually do push, which needs a secure context:

  • http://localhost counts as secure, so push works out of the box in dev.
  • http://<lan-ip>:3000 does not. Browsers refuse service workers there, so no push - put ebae behind TLS, or use Discord.
  • iPhone/iPad additionally require Add to Home Screen first. Safari tabs cannot receive push at all; the toggle appears only in the installed app.

Subscriptions expire on their own (iOS drops them after a week or two of inactivity, and Chrome revokes permission for sites you rarely open). ebae re-registers the current device every time you open it, and drops subscriptions the push service reports as gone, so this mostly takes care of itself - but if push goes quiet, opening the app is the fix.

Configuration

Config is env vars - see .env.example. Searches, webhooks and (in multi-user modes) each user's eBay keys live in Postgres and are managed in the UI.

varpurposedefault
DATABASE_URLPostgres connection stringrequired
EBAY_CLIENT_ID / EBAY_CLIENT_SECRETeBay app credentials (single mode only)unset = mock mode
EBAY_ENVproduction or sandbox (single mode only)production
EBAY_MARKETPLACEmarketplace id (single mode only)EBAY_US
DISCORD_WEBHOOK_URLnotification target (single mode only)unset
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEYpin the push keypair instead of generating oneunset = generated on first use
VAPID_SUBJECTmailto:/https: contact URI sent to the push servicesthis project's repo
AUTH_MODEsingle, cloudflare or proxysingle
CF_ACCESS_TEAM_DOMAIN<team>.cloudflareaccess.com (required in cloudflare)unset
CF_ACCESS_AUDAccess app Audience tag (required in cloudflare)unset
AUTH_TRUSTED_HEADERheader carrying the email (required in proxy)unset
ENCRYPTION_KEYbase64 32 bytes; encrypts eBay secrets saved in the UIunset
LEGACY_OWNER_EMAILone-time claim of pre-multi-user rowsunset
POLL_INTERVAL_DEFAULTfallback poll interval (min)5
CACHE_REFRESH_HOURSDB → cache refresh cadence12
SEEN_RETENTION_DAYSseen_items and tracked_items retention (days)90
MARKET_SAMPLE_HOURSmarket-baseline resample gap (band-limited searches)24
EBAY_DAILY_QUOTAenforced daily call budget, per user5000
LOG_LEVELerror/warn/info/debuginfo
LOG_FORMATjson or prettypretty on a TTY, else json

Everything above the log vars except DATABASE_URL is optional. The single-mode-only vars are ignored in cloudflare/proxy mode (the poller warns at boot if they are set).

Multi-user

By default (AUTH_MODE=single) there is no auth and one implicit user - the trust model is your LAN, your reverse proxy, your machine. Don't expose that to the internet. Set AUTH_MODE to share one deployment with other people: each gets their own searches, alerts, webhooks, snooze and eBay keys. Identity comes from an auth proxy in front of the app; ebae never handles passwords and has no signup, no roles - onboarding is done wherever your proxy keeps its allowlist.

The single-replica constraint doesn't change: poll timers and the seen-item cache are in process memory.

AUTH_MODE=cloudflare

Cloudflare Access in front of a Cloudflare Tunnel. The app validates the signed Cf-Access-Jwt-Assertion JWT against your team's JWKS, so a request that skips Access can't forge an identity.

  1. Zero Trust → Access → Applications → add a self-hosted application for ebae's hostname.
  2. Attach your Access policy (an email include list against your IdP - Google, GitHub, whatever).
  3. Copy the application's Application Audience (AUD) TagCF_ACCESS_AUD, and your team domain (<team>.cloudflareaccess.com) → CF_ACCESS_TEAM_DOMAIN. Boot fails if either is missing.

Onboarding a friend = adding their email to the policy. Nothing app-side; their user row is created on first login. The free Zero Trust tier covers up to 50 users. Service tokens are rejected: Access only mints an email claim for a real IdP login.

AUTH_MODE=proxy

Generic trusted-header SSO for Authelia, authentik, oauth2-proxy, Tailscale serve and friends. Set AUTH_TRUSTED_HEADER to the header your proxy puts the user's email in (e.g. Remote-Email); that value, lowercased, is the identity.

Only enable this when the app is reachable exclusively through that proxy. The header is plain text and is trusted as-is - anyone who can reach the app directly can set it to any address and become that user. Bind the app to localhost or a private network, and make sure the proxy strips the header from inbound requests.

Local development against a shared database

AUTH_MODE=single still runs against a database a multi-user mode created, but you will see an empty account: single mode signs you in as the implicit local@localhost user, while every existing row belongs to the SSO identity that created it. Nothing is lost and nothing is taken - the boot-time claim only adopts rows that have no owner - your data is simply someone else's.

bun run dev loads the cache for the UI but schedules no polls and performs no pending-alert redelivery. bun run dev:poller is the deliberate opt-in for a scratch database. Even then development polls only the implicit local@localhost user, never a real identity loaded from a shared database, and development never replays pending alerts. The UI labels a real identity as Guarded rather than asking you to re-enter credentials.

The safety boundary is background polling and delivery, not database access. Migrations, cache maintenance and any UI/API action still use the configured database. Use a scratch DATABASE_URL for normal work, or a read-only production role when inspection is unavoidable.

If you genuinely need your real rows locally, be yourself instead of being local@localhost:

AUTH_MODE=proxy
AUTH_TRUSTED_HEADER=X-Email
curl -H 'X-Email: you@example.com' localhost:3000/api/searches

For the browser you need something to inject that header on every request. Prefer a local reverse proxy over a browser extension. Development still refuses to poll that real identity; this setup is for inspecting or editing its rows through the UI/API.

ENCRYPTION_KEY

Needed only to save eBay credentials through the UI (encrypts the client secret at rest, AES-256-GCM). Single-mode deployments on EBAY_CLIENT_ID/EBAY_CLIENT_SECRET store nothing and don't need it.

openssl rand -base64 32

Keep it with your other secrets. There is no key history and no re-encryption tooling: lose it and each user re-enters their eBay keys in the UI. That is the whole recovery story, and it takes a minute.

LEGACY_OWNER_EMAIL

Only for switching an existing single-user database to a multi-user mode. Rows created before multi-user have no owner, and the poller skips them. Set this to your email for one boot; every existing search, channel and alert is assigned to that user, and the row is adopted (sub stamped) when you first log in. Remove the var afterwards - the claim is idempotent, so leaving it does nothing.

Two things do not survive that upgrade, both once-off:

  • Your snooze window resets to the defaults (off, 01:00-07:00). The old global settings row is dropped rather than copied, because the migration runs before your user row exists. Note your window down first and re-enter it in the UI.
  • DISCORD_WEBHOOK_URL stops being read, since one global webhook would fan every user's alerts into the same channel. The claim imports it once as your own channel so notifications keep working, and logs when it does; you can drop the var afterwards. Every other user adds their webhook in the UI.

Deploy

docker compose up -d # uses .env; bundled Postgres available in the compose file

Kubernetes: deploy/k8s.yaml (single replica - poll timers and the seen-item cache are in-process).

Recommended setup: run it on a home box and expose the UI through a Cloudflare Tunnel behind Cloudflare Access. All app traffic is outbound-only. Add AUTH_MODE=cloudflare and you can share it with other people - see Multi-user.

Releasing

Push an annotated semver tag. docker.yml builds and pushes the matching container image, while release.yml publishes GoReleaser's grouped changelog.

For an ordinary release:

git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin vX.Y.Z

For a release worth calling out, run git tag -a vX.Y.Z, then use this tag message:

vX.Y.Z
## Highlights
**Short title** — One friendly, user-facing sentence.

Add one line per highlight. GoReleaser prepends that block above the generated changelog; without the ## Highlights heading, it publishes the usual changelog and container-image footer only. The annotated tag is the canonical source: PR-template highlight candidates are never copied into a release automatically.

About

Ebay, before anyone else

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - adawalli/ebae: Ebay, before anyone else · GitHub
Skip to content

Latest commit

History

156 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

ebae

ebae - eBay Before Anyone Else

eBay, before anyone else.

Self-hosted eBay alerting. Polls your saved searches every 1-15 minutes via the official Browse API and pings Discord the moment a matching item lists - fast enough to catch Buy It Now drops before they're gone. One container, egress-only, nothing on your network exposed.

See DESIGN.md for architecture and roadmap.

Quick start (dev)

cp .env.example .env.local # set DATABASE_URL (Neon works great)
bun install
bun run dev # UI/API only; no polling or notification delivery

Open http://localhost:3000. Local development leaves the poller off so an accidental production DATABASE_URL cannot start timers or redeliver pending alerts. The scripts force NODE_ENV=development, even if the parent shell exported NODE_ENV=production. To try the complete pipeline against a scratch database, run bun run dev:poller instead. Without eBay credentials that explicit command runs in mock mode, generating fake listings for seeding, alerts and quota testing.

Neither command needs a new env var: one implicit user, no login, DATABASE_URL and the EBAY_*/DISCORD_WEBHOOK_URL vars are the whole config. ENABLE_DEV_POLLER is internal script plumbing: bun run dev sets it to 0; bun run dev:poller sets it to 1; do not set it in .env.local. A search created under normal bun run dev remains unseeded until you run the scratch database with dev:poller. Sharing the deployment with other people is opt-in - see Multi-user.

eBay credentials

  1. Create a free account at developer.ebay.com and create an app (production keyset).
  2. Put the App ID and Cert ID in .env.local as EBAY_CLIENT_ID / EBAY_CLIENT_SECRET.
  3. Restart. The Status & Settings page shows the token going live.

In a multi-user mode the env vars are ignored: each user enters their own App ID and Cert ID on the Status & Settings page instead, and the secret is encrypted before it is stored (see ENCRYPTION_KEY). Keys are checked against eBay on save, so a typo fails immediately. Without keys a user's searches idle - the UI says so.

Browse API default quota is 5,000 calls/day per eBay app, so EBAY_DAILY_QUOTA is a per-user ceiling. The UI projects your daily usage as you add searches and the poller enforces the budget.

The Status page shows what you've spent today against what an evenly-paced day would have spent by now, so 90% used reads differently at 09:00 than at 23:00. If the saved intervals still require more calls than the budget left today, a budget governor stretches them so polling lasts rather than running out at noon and leaving the rest of the day dark. It only ever slows polling down—never below the interval you set—is capped at 4x, and releases once the remaining workload has a 5% buffer. Changing an interval, pausing a search, or extending snooze can therefore restore the saved cadence before midnight. The Saved searches bar separates calls spent, work requested, and any overflow the governor must slow down. Nothing to configure.

Sold prices

An alert is only useful if you know what the thing is worth. By default ebae tracks what listings actually went for and prefers that: alerts then read "Sold ~$420 · ▼ 30% under", and the search's subtitle shows sold ~$420. Until it has enough sales, it compares against asking prices - a daily unfiltered sample of the same search, shown as "Market". Turn off Track sold prices & price drops on an individual search to save API calls.

eBay's sold-search APIs are enterprise-only, so ebae gets there by checking back on listings the search already found:

It also sends a price-drop alert whenever a followed fixed-price or Best Offer listing reaches a new lower price. Re-sightings use the ordinary poll result, and scheduled checks reuse their existing response, so this adds no eBay API calls.

  • Auctions are checked once, five minutes after they end. The end time arrives free with every poll, so nothing is spent guessing - and a snipe in the closing seconds is still captured.
  • Buy It Now listings are checked at 3, 7, 14 and 30 days, at most four times ever. Any poll that sees the listing again skips the next check for free: it's obviously still for sale.
  • Listings you excluded (exclude-terms, "for parts") are never followed - their prices would describe junk, not what you're hunting.
  • Best Offer listings are followed but kept out of the median: eBay keeps showing the asking price after a sale, so what we can read is a ceiling, not the realized price.
  • The deal context appears once three sales inside the last 30 days agree; below that it falls back to the market baseline.

Checks come out of the same EBAY_DAILY_QUOTA as polling and are counted in the projection on the Status page, so the budget governor accounts for them. They're the first thing dropped when the budget runs low, and at most three run per tick, so a backlog drains gradually instead of crowding out the polls that find deals. A check that fails outright (rate limit, an eBay outage) waits an hour rather than retrying immediately, and a listing is abandoned after six failed attempts.

Quota you don't use expires at midnight, so if your saved searches don't need the whole budget the poller spends the leftovers looking in on followed listings early - catching sales that would otherwise happen in a gap and be unreadable by the next scheduled check. It's paced across the day, never touches what your searches still need, and can't slow your polling down. Nothing to configure.

Turning the toggle off stops new checks immediately; listings already being followed age out with SEEN_RETENTION_DAYS. Editing what a search matches clears its sold history, the same way it clears the market baseline. Those sales describe the old criteria, and since the sold median outranks every other basis, keeping them would caption the new search's alerts with the old search's going rate.

Notifications

Alerts go to Discord webhooks, push notifications on your own devices, or both. Push stays enabled for every search. Discord defaults to all saved webhooks, or a search can route to one named webhook. An alert is retried after a restart only while no target has taken it.

Discord

Create a webhook in your Discord channel (channel settings → Integrations → Webhooks) and set DISCORD_WEBHOOK_URL. More targets can be added and named on the Status & Settings page, then selected while creating or editing a search. The environment webhook remains part of the all-webhook route and cannot be selected alone.

In a multi-user mode DISCORD_WEBHOOK_URL is ignored (it would fan everyone's alerts into one channel) - each user adds their own webhooks in the UI.

Push

ebae is an installable PWA, so alerts can arrive as ordinary phone or desktop notifications with no Discord involved. Install it (Chrome: the address-bar install button; iPhone: Share → Add to Home Screen), open Status & Settings, and turn on Push to this device. It is per device - turn it on wherever you want alerts.

No configuration: the VAPID keypair is generated on first use and kept in the database. VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY override it if you would rather pin the keys, and VAPID_SUBJECT sets the contact URI the push services see (it defaults to this project's repo).

The toggle only appears where a browser can actually do push, which needs a secure context:

  • http://localhost counts as secure, so push works out of the box in dev.
  • http://<lan-ip>:3000 does not. Browsers refuse service workers there, so no push - put ebae behind TLS, or use Discord.
  • iPhone/iPad additionally require Add to Home Screen first. Safari tabs cannot receive push at all; the toggle appears only in the installed app.

Subscriptions expire on their own (iOS drops them after a week or two of inactivity, and Chrome revokes permission for sites you rarely open). ebae re-registers the current device every time you open it, and drops subscriptions the push service reports as gone, so this mostly takes care of itself - but if push goes quiet, opening the app is the fix.

Configuration

Config is env vars - see .env.example. Searches, webhooks and (in multi-user modes) each user's eBay keys live in Postgres and are managed in the UI.

varpurposedefault
DATABASE_URLPostgres connection stringrequired
EBAY_CLIENT_ID / EBAY_CLIENT_SECRETeBay app credentials (single mode only)unset = mock mode
EBAY_ENVproduction or sandbox (single mode only)production
EBAY_MARKETPLACEmarketplace id (single mode only)EBAY_US
DISCORD_WEBHOOK_URLnotification target (single mode only)unset
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEYpin the push keypair instead of generating oneunset = generated on first use
VAPID_SUBJECTmailto:/https: contact URI sent to the push servicesthis project's repo
AUTH_MODEsingle, cloudflare or proxysingle
CF_ACCESS_TEAM_DOMAIN<team>.cloudflareaccess.com (required in cloudflare)unset
CF_ACCESS_AUDAccess app Audience tag (required in cloudflare)unset
AUTH_TRUSTED_HEADERheader carrying the email (required in proxy)unset
ENCRYPTION_KEYbase64 32 bytes; encrypts eBay secrets saved in the UIunset
LEGACY_OWNER_EMAILone-time claim of pre-multi-user rowsunset
POLL_INTERVAL_DEFAULTfallback poll interval (min)5
CACHE_REFRESH_HOURSDB → cache refresh cadence12
SEEN_RETENTION_DAYSseen_items and tracked_items retention (days)90
MARKET_SAMPLE_HOURSmarket-baseline resample gap (band-limited searches)24
EBAY_DAILY_QUOTAenforced daily call budget, per user5000
LOG_LEVELerror/warn/info/debuginfo
LOG_FORMATjson or prettypretty on a TTY, else json

Everything above the log vars except DATABASE_URL is optional. The single-mode-only vars are ignored in cloudflare/proxy mode (the poller warns at boot if they are set).

Multi-user

By default (AUTH_MODE=single) there is no auth and one implicit user - the trust model is your LAN, your reverse proxy, your machine. Don't expose that to the internet. Set AUTH_MODE to share one deployment with other people: each gets their own searches, alerts, webhooks, snooze and eBay keys. Identity comes from an auth proxy in front of the app; ebae never handles passwords and has no signup, no roles - onboarding is done wherever your proxy keeps its allowlist.

The single-replica constraint doesn't change: poll timers and the seen-item cache are in process memory.

AUTH_MODE=cloudflare

Cloudflare Access in front of a Cloudflare Tunnel. The app validates the signed Cf-Access-Jwt-Assertion JWT against your team's JWKS, so a request that skips Access can't forge an identity.

  1. Zero Trust → Access → Applications → add a self-hosted application for ebae's hostname.
  2. Attach your Access policy (an email include list against your IdP - Google, GitHub, whatever).
  3. Copy the application's Application Audience (AUD) TagCF_ACCESS_AUD, and your team domain (<team>.cloudflareaccess.com) → CF_ACCESS_TEAM_DOMAIN. Boot fails if either is missing.

Onboarding a friend = adding their email to the policy. Nothing app-side; their user row is created on first login. The free Zero Trust tier covers up to 50 users. Service tokens are rejected: Access only mints an email claim for a real IdP login.

AUTH_MODE=proxy

Generic trusted-header SSO for Authelia, authentik, oauth2-proxy, Tailscale serve and friends. Set AUTH_TRUSTED_HEADER to the header your proxy puts the user's email in (e.g. Remote-Email); that value, lowercased, is the identity.

Only enable this when the app is reachable exclusively through that proxy. The header is plain text and is trusted as-is - anyone who can reach the app directly can set it to any address and become that user. Bind the app to localhost or a private network, and make sure the proxy strips the header from inbound requests.

Local development against a shared database

AUTH_MODE=single still runs against a database a multi-user mode created, but you will see an empty account: single mode signs you in as the implicit local@localhost user, while every existing row belongs to the SSO identity that created it. Nothing is lost and nothing is taken - the boot-time claim only adopts rows that have no owner - your data is simply someone else's.

bun run dev loads the cache for the UI but schedules no polls and performs no pending-alert redelivery. bun run dev:poller is the deliberate opt-in for a scratch database. Even then development polls only the implicit local@localhost user, never a real identity loaded from a shared database, and development never replays pending alerts. The UI labels a real identity as Guarded rather than asking you to re-enter credentials.

The safety boundary is background polling and delivery, not database access. Migrations, cache maintenance and any UI/API action still use the configured database. Use a scratch DATABASE_URL for normal work, or a read-only production role when inspection is unavoidable.

If you genuinely need your real rows locally, be yourself instead of being local@localhost:

AUTH_MODE=proxy
AUTH_TRUSTED_HEADER=X-Email
curl -H 'X-Email: you@example.com' localhost:3000/api/searches

For the browser you need something to inject that header on every request. Prefer a local reverse proxy over a browser extension. Development still refuses to poll that real identity; this setup is for inspecting or editing its rows through the UI/API.

ENCRYPTION_KEY

Needed only to save eBay credentials through the UI (encrypts the client secret at rest, AES-256-GCM). Single-mode deployments on EBAY_CLIENT_ID/EBAY_CLIENT_SECRET store nothing and don't need it.

openssl rand -base64 32

Keep it with your other secrets. There is no key history and no re-encryption tooling: lose it and each user re-enters their eBay keys in the UI. That is the whole recovery story, and it takes a minute.

LEGACY_OWNER_EMAIL

Only for switching an existing single-user database to a multi-user mode. Rows created before multi-user have no owner, and the poller skips them. Set this to your email for one boot; every existing search, channel and alert is assigned to that user, and the row is adopted (sub stamped) when you first log in. Remove the var afterwards - the claim is idempotent, so leaving it does nothing.

Two things do not survive that upgrade, both once-off:

  • Your snooze window resets to the defaults (off, 01:00-07:00). The old global settings row is dropped rather than copied, because the migration runs before your user row exists. Note your window down first and re-enter it in the UI.
  • DISCORD_WEBHOOK_URL stops being read, since one global webhook would fan every user's alerts into the same channel. The claim imports it once as your own channel so notifications keep working, and logs when it does; you can drop the var afterwards. Every other user adds their webhook in the UI.

Deploy

docker compose up -d # uses .env; bundled Postgres available in the compose file

Kubernetes: deploy/k8s.yaml (single replica - poll timers and the seen-item cache are in-process).

Recommended setup: run it on a home box and expose the UI through a Cloudflare Tunnel behind Cloudflare Access. All app traffic is outbound-only. Add AUTH_MODE=cloudflare and you can share it with other people - see Multi-user.

Releasing

Push an annotated semver tag. docker.yml builds and pushes the matching container image, while release.yml publishes GoReleaser's grouped changelog.

For an ordinary release:

git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin vX.Y.Z

For a release worth calling out, run git tag -a vX.Y.Z, then use this tag message:

vX.Y.Z
## Highlights
**Short title** — One friendly, user-facing sentence.

Add one line per highlight. GoReleaser prepends that block above the generated changelog; without the ## Highlights heading, it publishes the usual changelog and container-image footer only. The annotated tag is the canonical source: PR-template highlight candidates are never copied into a release automatically.

About

Ebay, before anyone else

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' GitHub - adawalli/ebae: Ebay, before anyone else · GitHub
Skip to content

Latest commit

History

156 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

ebae

ebae - eBay Before Anyone Else

eBay, before anyone else.

Self-hosted eBay alerting. Polls your saved searches every 1-15 minutes via the official Browse API and pings Discord the moment a matching item lists - fast enough to catch Buy It Now drops before they're gone. One container, egress-only, nothing on your network exposed.

See DESIGN.md for architecture and roadmap.

Quick start (dev)

cp .env.example .env.local # set DATABASE_URL (Neon works great)
bun install
bun run dev # UI/API only; no polling or notification delivery

Open http://localhost:3000. Local development leaves the poller off so an accidental production DATABASE_URL cannot start timers or redeliver pending alerts. The scripts force NODE_ENV=development, even if the parent shell exported NODE_ENV=production. To try the complete pipeline against a scratch database, run bun run dev:poller instead. Without eBay credentials that explicit command runs in mock mode, generating fake listings for seeding, alerts and quota testing.

Neither command needs a new env var: one implicit user, no login, DATABASE_URL and the EBAY_*/DISCORD_WEBHOOK_URL vars are the whole config. ENABLE_DEV_POLLER is internal script plumbing: bun run dev sets it to 0; bun run dev:poller sets it to 1; do not set it in .env.local. A search created under normal bun run dev remains unseeded until you run the scratch database with dev:poller. Sharing the deployment with other people is opt-in - see Multi-user.

eBay credentials

  1. Create a free account at developer.ebay.com and create an app (production keyset).
  2. Put the App ID and Cert ID in .env.local as EBAY_CLIENT_ID / EBAY_CLIENT_SECRET.
  3. Restart. The Status & Settings page shows the token going live.

In a multi-user mode the env vars are ignored: each user enters their own App ID and Cert ID on the Status & Settings page instead, and the secret is encrypted before it is stored (see ENCRYPTION_KEY). Keys are checked against eBay on save, so a typo fails immediately. Without keys a user's searches idle - the UI says so.

Browse API default quota is 5,000 calls/day per eBay app, so EBAY_DAILY_QUOTA is a per-user ceiling. The UI projects your daily usage as you add searches and the poller enforces the budget.

The Status page shows what you've spent today against what an evenly-paced day would have spent by now, so 90% used reads differently at 09:00 than at 23:00. If the saved intervals still require more calls than the budget left today, a budget governor stretches them so polling lasts rather than running out at noon and leaving the rest of the day dark. It only ever slows polling down—never below the interval you set—is capped at 4x, and releases once the remaining workload has a 5% buffer. Changing an interval, pausing a search, or extending snooze can therefore restore the saved cadence before midnight. The Saved searches bar separates calls spent, work requested, and any overflow the governor must slow down. Nothing to configure.

Sold prices

An alert is only useful if you know what the thing is worth. By default ebae tracks what listings actually went for and prefers that: alerts then read "Sold ~$420 · ▼ 30% under", and the search's subtitle shows sold ~$420. Until it has enough sales, it compares against asking prices - a daily unfiltered sample of the same search, shown as "Market". Turn off Track sold prices & price drops on an individual search to save API calls.

eBay's sold-search APIs are enterprise-only, so ebae gets there by checking back on listings the search already found:

It also sends a price-drop alert whenever a followed fixed-price or Best Offer listing reaches a new lower price. Re-sightings use the ordinary poll result, and scheduled checks reuse their existing response, so this adds no eBay API calls.

  • Auctions are checked once, five minutes after they end. The end time arrives free with every poll, so nothing is spent guessing - and a snipe in the closing seconds is still captured.
  • Buy It Now listings are checked at 3, 7, 14 and 30 days, at most four times ever. Any poll that sees the listing again skips the next check for free: it's obviously still for sale.
  • Listings you excluded (exclude-terms, "for parts") are never followed - their prices would describe junk, not what you're hunting.
  • Best Offer listings are followed but kept out of the median: eBay keeps showing the asking price after a sale, so what we can read is a ceiling, not the realized price.
  • The deal context appears once three sales inside the last 30 days agree; below that it falls back to the market baseline.

Checks come out of the same EBAY_DAILY_QUOTA as polling and are counted in the projection on the Status page, so the budget governor accounts for them. They're the first thing dropped when the budget runs low, and at most three run per tick, so a backlog drains gradually instead of crowding out the polls that find deals. A check that fails outright (rate limit, an eBay outage) waits an hour rather than retrying immediately, and a listing is abandoned after six failed attempts.

Quota you don't use expires at midnight, so if your saved searches don't need the whole budget the poller spends the leftovers looking in on followed listings early - catching sales that would otherwise happen in a gap and be unreadable by the next scheduled check. It's paced across the day, never touches what your searches still need, and can't slow your polling down. Nothing to configure.

Turning the toggle off stops new checks immediately; listings already being followed age out with SEEN_RETENTION_DAYS. Editing what a search matches clears its sold history, the same way it clears the market baseline. Those sales describe the old criteria, and since the sold median outranks every other basis, keeping them would caption the new search's alerts with the old search's going rate.

Notifications

Alerts go to Discord webhooks, push notifications on your own devices, or both. Push stays enabled for every search. Discord defaults to all saved webhooks, or a search can route to one named webhook. An alert is retried after a restart only while no target has taken it.

Discord

Create a webhook in your Discord channel (channel settings → Integrations → Webhooks) and set DISCORD_WEBHOOK_URL. More targets can be added and named on the Status & Settings page, then selected while creating or editing a search. The environment webhook remains part of the all-webhook route and cannot be selected alone.

In a multi-user mode DISCORD_WEBHOOK_URL is ignored (it would fan everyone's alerts into one channel) - each user adds their own webhooks in the UI.

Push

ebae is an installable PWA, so alerts can arrive as ordinary phone or desktop notifications with no Discord involved. Install it (Chrome: the address-bar install button; iPhone: Share → Add to Home Screen), open Status & Settings, and turn on Push to this device. It is per device - turn it on wherever you want alerts.

No configuration: the VAPID keypair is generated on first use and kept in the database. VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY override it if you would rather pin the keys, and VAPID_SUBJECT sets the contact URI the push services see (it defaults to this project's repo).

The toggle only appears where a browser can actually do push, which needs a secure context:

  • http://localhost counts as secure, so push works out of the box in dev.
  • http://<lan-ip>:3000 does not. Browsers refuse service workers there, so no push - put ebae behind TLS, or use Discord.
  • iPhone/iPad additionally require Add to Home Screen first. Safari tabs cannot receive push at all; the toggle appears only in the installed app.

Subscriptions expire on their own (iOS drops them after a week or two of inactivity, and Chrome revokes permission for sites you rarely open). ebae re-registers the current device every time you open it, and drops subscriptions the push service reports as gone, so this mostly takes care of itself - but if push goes quiet, opening the app is the fix.

Configuration

Config is env vars - see .env.example. Searches, webhooks and (in multi-user modes) each user's eBay keys live in Postgres and are managed in the UI.

varpurposedefault
DATABASE_URLPostgres connection stringrequired
EBAY_CLIENT_ID / EBAY_CLIENT_SECRETeBay app credentials (single mode only)unset = mock mode
EBAY_ENVproduction or sandbox (single mode only)production
EBAY_MARKETPLACEmarketplace id (single mode only)EBAY_US
DISCORD_WEBHOOK_URLnotification target (single mode only)unset
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEYpin the push keypair instead of generating oneunset = generated on first use
VAPID_SUBJECTmailto:/https: contact URI sent to the push servicesthis project's repo
AUTH_MODEsingle, cloudflare or proxysingle
CF_ACCESS_TEAM_DOMAIN<team>.cloudflareaccess.com (required in cloudflare)unset
CF_ACCESS_AUDAccess app Audience tag (required in cloudflare)unset
AUTH_TRUSTED_HEADERheader carrying the email (required in proxy)unset
ENCRYPTION_KEYbase64 32 bytes; encrypts eBay secrets saved in the UIunset
LEGACY_OWNER_EMAILone-time claim of pre-multi-user rowsunset
POLL_INTERVAL_DEFAULTfallback poll interval (min)5
CACHE_REFRESH_HOURSDB → cache refresh cadence12
SEEN_RETENTION_DAYSseen_items and tracked_items retention (days)90
MARKET_SAMPLE_HOURSmarket-baseline resample gap (band-limited searches)24
EBAY_DAILY_QUOTAenforced daily call budget, per user5000
LOG_LEVELerror/warn/info/debuginfo
LOG_FORMATjson or prettypretty on a TTY, else json

Everything above the log vars except DATABASE_URL is optional. The single-mode-only vars are ignored in cloudflare/proxy mode (the poller warns at boot if they are set).

Multi-user

By default (AUTH_MODE=single) there is no auth and one implicit user - the trust model is your LAN, your reverse proxy, your machine. Don't expose that to the internet. Set AUTH_MODE to share one deployment with other people: each gets their own searches, alerts, webhooks, snooze and eBay keys. Identity comes from an auth proxy in front of the app; ebae never handles passwords and has no signup, no roles - onboarding is done wherever your proxy keeps its allowlist.

The single-replica constraint doesn't change: poll timers and the seen-item cache are in process memory.

AUTH_MODE=cloudflare

Cloudflare Access in front of a Cloudflare Tunnel. The app validates the signed Cf-Access-Jwt-Assertion JWT against your team's JWKS, so a request that skips Access can't forge an identity.

  1. Zero Trust → Access → Applications → add a self-hosted application for ebae's hostname.
  2. Attach your Access policy (an email include list against your IdP - Google, GitHub, whatever).
  3. Copy the application's Application Audience (AUD) TagCF_ACCESS_AUD, and your team domain (<team>.cloudflareaccess.com) → CF_ACCESS_TEAM_DOMAIN. Boot fails if either is missing.

Onboarding a friend = adding their email to the policy. Nothing app-side; their user row is created on first login. The free Zero Trust tier covers up to 50 users. Service tokens are rejected: Access only mints an email claim for a real IdP login.

AUTH_MODE=proxy

Generic trusted-header SSO for Authelia, authentik, oauth2-proxy, Tailscale serve and friends. Set AUTH_TRUSTED_HEADER to the header your proxy puts the user's email in (e.g. Remote-Email); that value, lowercased, is the identity.

Only enable this when the app is reachable exclusively through that proxy. The header is plain text and is trusted as-is - anyone who can reach the app directly can set it to any address and become that user. Bind the app to localhost or a private network, and make sure the proxy strips the header from inbound requests.

Local development against a shared database

AUTH_MODE=single still runs against a database a multi-user mode created, but you will see an empty account: single mode signs you in as the implicit local@localhost user, while every existing row belongs to the SSO identity that created it. Nothing is lost and nothing is taken - the boot-time claim only adopts rows that have no owner - your data is simply someone else's.

bun run dev loads the cache for the UI but schedules no polls and performs no pending-alert redelivery. bun run dev:poller is the deliberate opt-in for a scratch database. Even then development polls only the implicit local@localhost user, never a real identity loaded from a shared database, and development never replays pending alerts. The UI labels a real identity as Guarded rather than asking you to re-enter credentials.

The safety boundary is background polling and delivery, not database access. Migrations, cache maintenance and any UI/API action still use the configured database. Use a scratch DATABASE_URL for normal work, or a read-only production role when inspection is unavoidable.

If you genuinely need your real rows locally, be yourself instead of being local@localhost:

AUTH_MODE=proxy
AUTH_TRUSTED_HEADER=X-Email
curl -H 'X-Email: you@example.com' localhost:3000/api/searches

For the browser you need something to inject that header on every request. Prefer a local reverse proxy over a browser extension. Development still refuses to poll that real identity; this setup is for inspecting or editing its rows through the UI/API.

ENCRYPTION_KEY

Needed only to save eBay credentials through the UI (encrypts the client secret at rest, AES-256-GCM). Single-mode deployments on EBAY_CLIENT_ID/EBAY_CLIENT_SECRET store nothing and don't need it.

openssl rand -base64 32

Keep it with your other secrets. There is no key history and no re-encryption tooling: lose it and each user re-enters their eBay keys in the UI. That is the whole recovery story, and it takes a minute.

LEGACY_OWNER_EMAIL

Only for switching an existing single-user database to a multi-user mode. Rows created before multi-user have no owner, and the poller skips them. Set this to your email for one boot; every existing search, channel and alert is assigned to that user, and the row is adopted (sub stamped) when you first log in. Remove the var afterwards - the claim is idempotent, so leaving it does nothing.

Two things do not survive that upgrade, both once-off:

  • Your snooze window resets to the defaults (off, 01:00-07:00). The old global settings row is dropped rather than copied, because the migration runs before your user row exists. Note your window down first and re-enter it in the UI.
  • DISCORD_WEBHOOK_URL stops being read, since one global webhook would fan every user's alerts into the same channel. The claim imports it once as your own channel so notifications keep working, and logs when it does; you can drop the var afterwards. Every other user adds their webhook in the UI.

Deploy

docker compose up -d # uses .env; bundled Postgres available in the compose file

Kubernetes: deploy/k8s.yaml (single replica - poll timers and the seen-item cache are in-process).

Recommended setup: run it on a home box and expose the UI through a Cloudflare Tunnel behind Cloudflare Access. All app traffic is outbound-only. Add AUTH_MODE=cloudflare and you can share it with other people - see Multi-user.

Releasing

Push an annotated semver tag. docker.yml builds and pushes the matching container image, while release.yml publishes GoReleaser's grouped changelog.

For an ordinary release:

git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin vX.Y.Z

For a release worth calling out, run git tag -a vX.Y.Z, then use this tag message:

vX.Y.Z
## Highlights
**Short title** — One friendly, user-facing sentence.

Add one line per highlight. GoReleaser prepends that block above the generated changelog; without the ## Highlights heading, it publishes the usual changelog and container-image footer only. The annotated tag is the canonical source: PR-template highlight candidates are never copied into a release automatically.

About

Ebay, before anyone else

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - adawalli/ebae: Ebay, before anyone else · GitHub
Skip to content

Latest commit

History

156 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

ebae

ebae - eBay Before Anyone Else

eBay, before anyone else.

Self-hosted eBay alerting. Polls your saved searches every 1-15 minutes via the official Browse API and pings Discord the moment a matching item lists - fast enough to catch Buy It Now drops before they're gone. One container, egress-only, nothing on your network exposed.

See DESIGN.md for architecture and roadmap.

Quick start (dev)

cp .env.example .env.local # set DATABASE_URL (Neon works great)
bun install
bun run dev # UI/API only; no polling or notification delivery

Open http://localhost:3000. Local development leaves the poller off so an accidental production DATABASE_URL cannot start timers or redeliver pending alerts. The scripts force NODE_ENV=development, even if the parent shell exported NODE_ENV=production. To try the complete pipeline against a scratch database, run bun run dev:poller instead. Without eBay credentials that explicit command runs in mock mode, generating fake listings for seeding, alerts and quota testing.

Neither command needs a new env var: one implicit user, no login, DATABASE_URL and the EBAY_*/DISCORD_WEBHOOK_URL vars are the whole config. ENABLE_DEV_POLLER is internal script plumbing: bun run dev sets it to 0; bun run dev:poller sets it to 1; do not set it in .env.local. A search created under normal bun run dev remains unseeded until you run the scratch database with dev:poller. Sharing the deployment with other people is opt-in - see Multi-user.

eBay credentials

  1. Create a free account at developer.ebay.com and create an app (production keyset).
  2. Put the App ID and Cert ID in .env.local as EBAY_CLIENT_ID / EBAY_CLIENT_SECRET.
  3. Restart. The Status & Settings page shows the token going live.

In a multi-user mode the env vars are ignored: each user enters their own App ID and Cert ID on the Status & Settings page instead, and the secret is encrypted before it is stored (see ENCRYPTION_KEY). Keys are checked against eBay on save, so a typo fails immediately. Without keys a user's searches idle - the UI says so.

Browse API default quota is 5,000 calls/day per eBay app, so EBAY_DAILY_QUOTA is a per-user ceiling. The UI projects your daily usage as you add searches and the poller enforces the budget.

The Status page shows what you've spent today against what an evenly-paced day would have spent by now, so 90% used reads differently at 09:00 than at 23:00. If the saved intervals still require more calls than the budget left today, a budget governor stretches them so polling lasts rather than running out at noon and leaving the rest of the day dark. It only ever slows polling down—never below the interval you set—is capped at 4x, and releases once the remaining workload has a 5% buffer. Changing an interval, pausing a search, or extending snooze can therefore restore the saved cadence before midnight. The Saved searches bar separates calls spent, work requested, and any overflow the governor must slow down. Nothing to configure.

Sold prices

An alert is only useful if you know what the thing is worth. By default ebae tracks what listings actually went for and prefers that: alerts then read "Sold ~$420 · ▼ 30% under", and the search's subtitle shows sold ~$420. Until it has enough sales, it compares against asking prices - a daily unfiltered sample of the same search, shown as "Market". Turn off Track sold prices & price drops on an individual search to save API calls.

eBay's sold-search APIs are enterprise-only, so ebae gets there by checking back on listings the search already found:

It also sends a price-drop alert whenever a followed fixed-price or Best Offer listing reaches a new lower price. Re-sightings use the ordinary poll result, and scheduled checks reuse their existing response, so this adds no eBay API calls.

  • Auctions are checked once, five minutes after they end. The end time arrives free with every poll, so nothing is spent guessing - and a snipe in the closing seconds is still captured.
  • Buy It Now listings are checked at 3, 7, 14 and 30 days, at most four times ever. Any poll that sees the listing again skips the next check for free: it's obviously still for sale.
  • Listings you excluded (exclude-terms, "for parts") are never followed - their prices would describe junk, not what you're hunting.
  • Best Offer listings are followed but kept out of the median: eBay keeps showing the asking price after a sale, so what we can read is a ceiling, not the realized price.
  • The deal context appears once three sales inside the last 30 days agree; below that it falls back to the market baseline.

Checks come out of the same EBAY_DAILY_QUOTA as polling and are counted in the projection on the Status page, so the budget governor accounts for them. They're the first thing dropped when the budget runs low, and at most three run per tick, so a backlog drains gradually instead of crowding out the polls that find deals. A check that fails outright (rate limit, an eBay outage) waits an hour rather than retrying immediately, and a listing is abandoned after six failed attempts.

Quota you don't use expires at midnight, so if your saved searches don't need the whole budget the poller spends the leftovers looking in on followed listings early - catching sales that would otherwise happen in a gap and be unreadable by the next scheduled check. It's paced across the day, never touches what your searches still need, and can't slow your polling down. Nothing to configure.

Turning the toggle off stops new checks immediately; listings already being followed age out with SEEN_RETENTION_DAYS. Editing what a search matches clears its sold history, the same way it clears the market baseline. Those sales describe the old criteria, and since the sold median outranks every other basis, keeping them would caption the new search's alerts with the old search's going rate.

Notifications

Alerts go to Discord webhooks, push notifications on your own devices, or both. Push stays enabled for every search. Discord defaults to all saved webhooks, or a search can route to one named webhook. An alert is retried after a restart only while no target has taken it.

Discord

Create a webhook in your Discord channel (channel settings → Integrations → Webhooks) and set DISCORD_WEBHOOK_URL. More targets can be added and named on the Status & Settings page, then selected while creating or editing a search. The environment webhook remains part of the all-webhook route and cannot be selected alone.

In a multi-user mode DISCORD_WEBHOOK_URL is ignored (it would fan everyone's alerts into one channel) - each user adds their own webhooks in the UI.

Push

ebae is an installable PWA, so alerts can arrive as ordinary phone or desktop notifications with no Discord involved. Install it (Chrome: the address-bar install button; iPhone: Share → Add to Home Screen), open Status & Settings, and turn on Push to this device. It is per device - turn it on wherever you want alerts.

No configuration: the VAPID keypair is generated on first use and kept in the database. VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY override it if you would rather pin the keys, and VAPID_SUBJECT sets the contact URI the push services see (it defaults to this project's repo).

The toggle only appears where a browser can actually do push, which needs a secure context:

  • http://localhost counts as secure, so push works out of the box in dev.
  • http://<lan-ip>:3000 does not. Browsers refuse service workers there, so no push - put ebae behind TLS, or use Discord.
  • iPhone/iPad additionally require Add to Home Screen first. Safari tabs cannot receive push at all; the toggle appears only in the installed app.

Subscriptions expire on their own (iOS drops them after a week or two of inactivity, and Chrome revokes permission for sites you rarely open). ebae re-registers the current device every time you open it, and drops subscriptions the push service reports as gone, so this mostly takes care of itself - but if push goes quiet, opening the app is the fix.

Configuration

Config is env vars - see .env.example. Searches, webhooks and (in multi-user modes) each user's eBay keys live in Postgres and are managed in the UI.

varpurposedefault
DATABASE_URLPostgres connection stringrequired
EBAY_CLIENT_ID / EBAY_CLIENT_SECRETeBay app credentials (single mode only)unset = mock mode
EBAY_ENVproduction or sandbox (single mode only)production
EBAY_MARKETPLACEmarketplace id (single mode only)EBAY_US
DISCORD_WEBHOOK_URLnotification target (single mode only)unset
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEYpin the push keypair instead of generating oneunset = generated on first use
VAPID_SUBJECTmailto:/https: contact URI sent to the push servicesthis project's repo
AUTH_MODEsingle, cloudflare or proxysingle
CF_ACCESS_TEAM_DOMAIN<team>.cloudflareaccess.com (required in cloudflare)unset
CF_ACCESS_AUDAccess app Audience tag (required in cloudflare)unset
AUTH_TRUSTED_HEADERheader carrying the email (required in proxy)unset
ENCRYPTION_KEYbase64 32 bytes; encrypts eBay secrets saved in the UIunset
LEGACY_OWNER_EMAILone-time claim of pre-multi-user rowsunset
POLL_INTERVAL_DEFAULTfallback poll interval (min)5
CACHE_REFRESH_HOURSDB → cache refresh cadence12
SEEN_RETENTION_DAYSseen_items and tracked_items retention (days)90
MARKET_SAMPLE_HOURSmarket-baseline resample gap (band-limited searches)24
EBAY_DAILY_QUOTAenforced daily call budget, per user5000
LOG_LEVELerror/warn/info/debuginfo
LOG_FORMATjson or prettypretty on a TTY, else json

Everything above the log vars except DATABASE_URL is optional. The single-mode-only vars are ignored in cloudflare/proxy mode (the poller warns at boot if they are set).

Multi-user

By default (AUTH_MODE=single) there is no auth and one implicit user - the trust model is your LAN, your reverse proxy, your machine. Don't expose that to the internet. Set AUTH_MODE to share one deployment with other people: each gets their own searches, alerts, webhooks, snooze and eBay keys. Identity comes from an auth proxy in front of the app; ebae never handles passwords and has no signup, no roles - onboarding is done wherever your proxy keeps its allowlist.

The single-replica constraint doesn't change: poll timers and the seen-item cache are in process memory.

AUTH_MODE=cloudflare

Cloudflare Access in front of a Cloudflare Tunnel. The app validates the signed Cf-Access-Jwt-Assertion JWT against your team's JWKS, so a request that skips Access can't forge an identity.

  1. Zero Trust → Access → Applications → add a self-hosted application for ebae's hostname.
  2. Attach your Access policy (an email include list against your IdP - Google, GitHub, whatever).
  3. Copy the application's Application Audience (AUD) TagCF_ACCESS_AUD, and your team domain (<team>.cloudflareaccess.com) → CF_ACCESS_TEAM_DOMAIN. Boot fails if either is missing.

Onboarding a friend = adding their email to the policy. Nothing app-side; their user row is created on first login. The free Zero Trust tier covers up to 50 users. Service tokens are rejected: Access only mints an email claim for a real IdP login.

AUTH_MODE=proxy

Generic trusted-header SSO for Authelia, authentik, oauth2-proxy, Tailscale serve and friends. Set AUTH_TRUSTED_HEADER to the header your proxy puts the user's email in (e.g. Remote-Email); that value, lowercased, is the identity.

Only enable this when the app is reachable exclusively through that proxy. The header is plain text and is trusted as-is - anyone who can reach the app directly can set it to any address and become that user. Bind the app to localhost or a private network, and make sure the proxy strips the header from inbound requests.

Local development against a shared database

AUTH_MODE=single still runs against a database a multi-user mode created, but you will see an empty account: single mode signs you in as the implicit local@localhost user, while every existing row belongs to the SSO identity that created it. Nothing is lost and nothing is taken - the boot-time claim only adopts rows that have no owner - your data is simply someone else's.

bun run dev loads the cache for the UI but schedules no polls and performs no pending-alert redelivery. bun run dev:poller is the deliberate opt-in for a scratch database. Even then development polls only the implicit local@localhost user, never a real identity loaded from a shared database, and development never replays pending alerts. The UI labels a real identity as Guarded rather than asking you to re-enter credentials.

The safety boundary is background polling and delivery, not database access. Migrations, cache maintenance and any UI/API action still use the configured database. Use a scratch DATABASE_URL for normal work, or a read-only production role when inspection is unavoidable.

If you genuinely need your real rows locally, be yourself instead of being local@localhost:

AUTH_MODE=proxy
AUTH_TRUSTED_HEADER=X-Email
curl -H 'X-Email: you@example.com' localhost:3000/api/searches

For the browser you need something to inject that header on every request. Prefer a local reverse proxy over a browser extension. Development still refuses to poll that real identity; this setup is for inspecting or editing its rows through the UI/API.

ENCRYPTION_KEY

Needed only to save eBay credentials through the UI (encrypts the client secret at rest, AES-256-GCM). Single-mode deployments on EBAY_CLIENT_ID/EBAY_CLIENT_SECRET store nothing and don't need it.

openssl rand -base64 32

Keep it with your other secrets. There is no key history and no re-encryption tooling: lose it and each user re-enters their eBay keys in the UI. That is the whole recovery story, and it takes a minute.

LEGACY_OWNER_EMAIL

Only for switching an existing single-user database to a multi-user mode. Rows created before multi-user have no owner, and the poller skips them. Set this to your email for one boot; every existing search, channel and alert is assigned to that user, and the row is adopted (sub stamped) when you first log in. Remove the var afterwards - the claim is idempotent, so leaving it does nothing.

Two things do not survive that upgrade, both once-off:

  • Your snooze window resets to the defaults (off, 01:00-07:00). The old global settings row is dropped rather than copied, because the migration runs before your user row exists. Note your window down first and re-enter it in the UI.
  • DISCORD_WEBHOOK_URL stops being read, since one global webhook would fan every user's alerts into the same channel. The claim imports it once as your own channel so notifications keep working, and logs when it does; you can drop the var afterwards. Every other user adds their webhook in the UI.

Deploy

docker compose up -d # uses .env; bundled Postgres available in the compose file

Kubernetes: deploy/k8s.yaml (single replica - poll timers and the seen-item cache are in-process).

Recommended setup: run it on a home box and expose the UI through a Cloudflare Tunnel behind Cloudflare Access. All app traffic is outbound-only. Add AUTH_MODE=cloudflare and you can share it with other people - see Multi-user.

Releasing

Push an annotated semver tag. docker.yml builds and pushes the matching container image, while release.yml publishes GoReleaser's grouped changelog.

For an ordinary release:

git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin vX.Y.Z

For a release worth calling out, run git tag -a vX.Y.Z, then use this tag message:

vX.Y.Z
## Highlights
**Short title** — One friendly, user-facing sentence.

Add one line per highlight. GoReleaser prepends that block above the generated changelog; without the ## Highlights heading, it publishes the usual changelog and container-image footer only. The annotated tag is the canonical source: PR-template highlight candidates are never copied into a release automatically.

About

Ebay, before anyone else

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - adawalli/ebae: Ebay, before anyone else · GitHub
Skip to content

Latest commit

History

156 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

ebae

ebae - eBay Before Anyone Else

eBay, before anyone else.

Self-hosted eBay alerting. Polls your saved searches every 1-15 minutes via the official Browse API and pings Discord the moment a matching item lists - fast enough to catch Buy It Now drops before they're gone. One container, egress-only, nothing on your network exposed.

See DESIGN.md for architecture and roadmap.

Quick start (dev)

cp .env.example .env.local # set DATABASE_URL (Neon works great)
bun install
bun run dev # UI/API only; no polling or notification delivery

Open http://localhost:3000. Local development leaves the poller off so an accidental production DATABASE_URL cannot start timers or redeliver pending alerts. The scripts force NODE_ENV=development, even if the parent shell exported NODE_ENV=production. To try the complete pipeline against a scratch database, run bun run dev:poller instead. Without eBay credentials that explicit command runs in mock mode, generating fake listings for seeding, alerts and quota testing.

Neither command needs a new env var: one implicit user, no login, DATABASE_URL and the EBAY_*/DISCORD_WEBHOOK_URL vars are the whole config. ENABLE_DEV_POLLER is internal script plumbing: bun run dev sets it to 0; bun run dev:poller sets it to 1; do not set it in .env.local. A search created under normal bun run dev remains unseeded until you run the scratch database with dev:poller. Sharing the deployment with other people is opt-in - see Multi-user.

eBay credentials

  1. Create a free account at developer.ebay.com and create an app (production keyset).
  2. Put the App ID and Cert ID in .env.local as EBAY_CLIENT_ID / EBAY_CLIENT_SECRET.
  3. Restart. The Status & Settings page shows the token going live.

In a multi-user mode the env vars are ignored: each user enters their own App ID and Cert ID on the Status & Settings page instead, and the secret is encrypted before it is stored (see ENCRYPTION_KEY). Keys are checked against eBay on save, so a typo fails immediately. Without keys a user's searches idle - the UI says so.

Browse API default quota is 5,000 calls/day per eBay app, so EBAY_DAILY_QUOTA is a per-user ceiling. The UI projects your daily usage as you add searches and the poller enforces the budget.

The Status page shows what you've spent today against what an evenly-paced day would have spent by now, so 90% used reads differently at 09:00 than at 23:00. If the saved intervals still require more calls than the budget left today, a budget governor stretches them so polling lasts rather than running out at noon and leaving the rest of the day dark. It only ever slows polling down—never below the interval you set—is capped at 4x, and releases once the remaining workload has a 5% buffer. Changing an interval, pausing a search, or extending snooze can therefore restore the saved cadence before midnight. The Saved searches bar separates calls spent, work requested, and any overflow the governor must slow down. Nothing to configure.

Sold prices

An alert is only useful if you know what the thing is worth. By default ebae tracks what listings actually went for and prefers that: alerts then read "Sold ~$420 · ▼ 30% under", and the search's subtitle shows sold ~$420. Until it has enough sales, it compares against asking prices - a daily unfiltered sample of the same search, shown as "Market". Turn off Track sold prices & price drops on an individual search to save API calls.

eBay's sold-search APIs are enterprise-only, so ebae gets there by checking back on listings the search already found:

It also sends a price-drop alert whenever a followed fixed-price or Best Offer listing reaches a new lower price. Re-sightings use the ordinary poll result, and scheduled checks reuse their existing response, so this adds no eBay API calls.

  • Auctions are checked once, five minutes after they end. The end time arrives free with every poll, so nothing is spent guessing - and a snipe in the closing seconds is still captured.
  • Buy It Now listings are checked at 3, 7, 14 and 30 days, at most four times ever. Any poll that sees the listing again skips the next check for free: it's obviously still for sale.
  • Listings you excluded (exclude-terms, "for parts") are never followed - their prices would describe junk, not what you're hunting.
  • Best Offer listings are followed but kept out of the median: eBay keeps showing the asking price after a sale, so what we can read is a ceiling, not the realized price.
  • The deal context appears once three sales inside the last 30 days agree; below that it falls back to the market baseline.

Checks come out of the same EBAY_DAILY_QUOTA as polling and are counted in the projection on the Status page, so the budget governor accounts for them. They're the first thing dropped when the budget runs low, and at most three run per tick, so a backlog drains gradually instead of crowding out the polls that find deals. A check that fails outright (rate limit, an eBay outage) waits an hour rather than retrying immediately, and a listing is abandoned after six failed attempts.

Quota you don't use expires at midnight, so if your saved searches don't need the whole budget the poller spends the leftovers looking in on followed listings early - catching sales that would otherwise happen in a gap and be unreadable by the next scheduled check. It's paced across the day, never touches what your searches still need, and can't slow your polling down. Nothing to configure.

Turning the toggle off stops new checks immediately; listings already being followed age out with SEEN_RETENTION_DAYS. Editing what a search matches clears its sold history, the same way it clears the market baseline. Those sales describe the old criteria, and since the sold median outranks every other basis, keeping them would caption the new search's alerts with the old search's going rate.

Notifications

Alerts go to Discord webhooks, push notifications on your own devices, or both. Push stays enabled for every search. Discord defaults to all saved webhooks, or a search can route to one named webhook. An alert is retried after a restart only while no target has taken it.

Discord

Create a webhook in your Discord channel (channel settings → Integrations → Webhooks) and set DISCORD_WEBHOOK_URL. More targets can be added and named on the Status & Settings page, then selected while creating or editing a search. The environment webhook remains part of the all-webhook route and cannot be selected alone.

In a multi-user mode DISCORD_WEBHOOK_URL is ignored (it would fan everyone's alerts into one channel) - each user adds their own webhooks in the UI.

Push

ebae is an installable PWA, so alerts can arrive as ordinary phone or desktop notifications with no Discord involved. Install it (Chrome: the address-bar install button; iPhone: Share → Add to Home Screen), open Status & Settings, and turn on Push to this device. It is per device - turn it on wherever you want alerts.

No configuration: the VAPID keypair is generated on first use and kept in the database. VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY override it if you would rather pin the keys, and VAPID_SUBJECT sets the contact URI the push services see (it defaults to this project's repo).

The toggle only appears where a browser can actually do push, which needs a secure context:

  • http://localhost counts as secure, so push works out of the box in dev.
  • http://<lan-ip>:3000 does not. Browsers refuse service workers there, so no push - put ebae behind TLS, or use Discord.
  • iPhone/iPad additionally require Add to Home Screen first. Safari tabs cannot receive push at all; the toggle appears only in the installed app.

Subscriptions expire on their own (iOS drops them after a week or two of inactivity, and Chrome revokes permission for sites you rarely open). ebae re-registers the current device every time you open it, and drops subscriptions the push service reports as gone, so this mostly takes care of itself - but if push goes quiet, opening the app is the fix.

Configuration

Config is env vars - see .env.example. Searches, webhooks and (in multi-user modes) each user's eBay keys live in Postgres and are managed in the UI.

varpurposedefault
DATABASE_URLPostgres connection stringrequired
EBAY_CLIENT_ID / EBAY_CLIENT_SECRETeBay app credentials (single mode only)unset = mock mode
EBAY_ENVproduction or sandbox (single mode only)production
EBAY_MARKETPLACEmarketplace id (single mode only)EBAY_US
DISCORD_WEBHOOK_URLnotification target (single mode only)unset
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEYpin the push keypair instead of generating oneunset = generated on first use
VAPID_SUBJECTmailto:/https: contact URI sent to the push servicesthis project's repo
AUTH_MODEsingle, cloudflare or proxysingle
CF_ACCESS_TEAM_DOMAIN<team>.cloudflareaccess.com (required in cloudflare)unset
CF_ACCESS_AUDAccess app Audience tag (required in cloudflare)unset
AUTH_TRUSTED_HEADERheader carrying the email (required in proxy)unset
ENCRYPTION_KEYbase64 32 bytes; encrypts eBay secrets saved in the UIunset
LEGACY_OWNER_EMAILone-time claim of pre-multi-user rowsunset
POLL_INTERVAL_DEFAULTfallback poll interval (min)5
CACHE_REFRESH_HOURSDB → cache refresh cadence12
SEEN_RETENTION_DAYSseen_items and tracked_items retention (days)90
MARKET_SAMPLE_HOURSmarket-baseline resample gap (band-limited searches)24
EBAY_DAILY_QUOTAenforced daily call budget, per user5000
LOG_LEVELerror/warn/info/debuginfo
LOG_FORMATjson or prettypretty on a TTY, else json

Everything above the log vars except DATABASE_URL is optional. The single-mode-only vars are ignored in cloudflare/proxy mode (the poller warns at boot if they are set).

Multi-user

By default (AUTH_MODE=single) there is no auth and one implicit user - the trust model is your LAN, your reverse proxy, your machine. Don't expose that to the internet. Set AUTH_MODE to share one deployment with other people: each gets their own searches, alerts, webhooks, snooze and eBay keys. Identity comes from an auth proxy in front of the app; ebae never handles passwords and has no signup, no roles - onboarding is done wherever your proxy keeps its allowlist.

The single-replica constraint doesn't change: poll timers and the seen-item cache are in process memory.

AUTH_MODE=cloudflare

Cloudflare Access in front of a Cloudflare Tunnel. The app validates the signed Cf-Access-Jwt-Assertion JWT against your team's JWKS, so a request that skips Access can't forge an identity.

  1. Zero Trust → Access → Applications → add a self-hosted application for ebae's hostname.
  2. Attach your Access policy (an email include list against your IdP - Google, GitHub, whatever).
  3. Copy the application's Application Audience (AUD) TagCF_ACCESS_AUD, and your team domain (<team>.cloudflareaccess.com) → CF_ACCESS_TEAM_DOMAIN. Boot fails if either is missing.

Onboarding a friend = adding their email to the policy. Nothing app-side; their user row is created on first login. The free Zero Trust tier covers up to 50 users. Service tokens are rejected: Access only mints an email claim for a real IdP login.

AUTH_MODE=proxy

Generic trusted-header SSO for Authelia, authentik, oauth2-proxy, Tailscale serve and friends. Set AUTH_TRUSTED_HEADER to the header your proxy puts the user's email in (e.g. Remote-Email); that value, lowercased, is the identity.

Only enable this when the app is reachable exclusively through that proxy. The header is plain text and is trusted as-is - anyone who can reach the app directly can set it to any address and become that user. Bind the app to localhost or a private network, and make sure the proxy strips the header from inbound requests.

Local development against a shared database

AUTH_MODE=single still runs against a database a multi-user mode created, but you will see an empty account: single mode signs you in as the implicit local@localhost user, while every existing row belongs to the SSO identity that created it. Nothing is lost and nothing is taken - the boot-time claim only adopts rows that have no owner - your data is simply someone else's.

bun run dev loads the cache for the UI but schedules no polls and performs no pending-alert redelivery. bun run dev:poller is the deliberate opt-in for a scratch database. Even then development polls only the implicit local@localhost user, never a real identity loaded from a shared database, and development never replays pending alerts. The UI labels a real identity as Guarded rather than asking you to re-enter credentials.

The safety boundary is background polling and delivery, not database access. Migrations, cache maintenance and any UI/API action still use the configured database. Use a scratch DATABASE_URL for normal work, or a read-only production role when inspection is unavoidable.

If you genuinely need your real rows locally, be yourself instead of being local@localhost:

AUTH_MODE=proxy
AUTH_TRUSTED_HEADER=X-Email
curl -H 'X-Email: you@example.com' localhost:3000/api/searches

For the browser you need something to inject that header on every request. Prefer a local reverse proxy over a browser extension. Development still refuses to poll that real identity; this setup is for inspecting or editing its rows through the UI/API.

ENCRYPTION_KEY

Needed only to save eBay credentials through the UI (encrypts the client secret at rest, AES-256-GCM). Single-mode deployments on EBAY_CLIENT_ID/EBAY_CLIENT_SECRET store nothing and don't need it.

openssl rand -base64 32

Keep it with your other secrets. There is no key history and no re-encryption tooling: lose it and each user re-enters their eBay keys in the UI. That is the whole recovery story, and it takes a minute.

LEGACY_OWNER_EMAIL

Only for switching an existing single-user database to a multi-user mode. Rows created before multi-user have no owner, and the poller skips them. Set this to your email for one boot; every existing search, channel and alert is assigned to that user, and the row is adopted (sub stamped) when you first log in. Remove the var afterwards - the claim is idempotent, so leaving it does nothing.

Two things do not survive that upgrade, both once-off:

  • Your snooze window resets to the defaults (off, 01:00-07:00). The old global settings row is dropped rather than copied, because the migration runs before your user row exists. Note your window down first and re-enter it in the UI.
  • DISCORD_WEBHOOK_URL stops being read, since one global webhook would fan every user's alerts into the same channel. The claim imports it once as your own channel so notifications keep working, and logs when it does; you can drop the var afterwards. Every other user adds their webhook in the UI.

Deploy

docker compose up -d # uses .env; bundled Postgres available in the compose file

Kubernetes: deploy/k8s.yaml (single replica - poll timers and the seen-item cache are in-process).

Recommended setup: run it on a home box and expose the UI through a Cloudflare Tunnel behind Cloudflare Access. All app traffic is outbound-only. Add AUTH_MODE=cloudflare and you can share it with other people - see Multi-user.

Releasing

Push an annotated semver tag. docker.yml builds and pushes the matching container image, while release.yml publishes GoReleaser's grouped changelog.

For an ordinary release:

git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin vX.Y.Z

For a release worth calling out, run git tag -a vX.Y.Z, then use this tag message:

vX.Y.Z
## Highlights
**Short title** — One friendly, user-facing sentence.

Add one line per highlight. GoReleaser prepends that block above the generated changelog; without the ## Highlights heading, it publishes the usual changelog and container-image footer only. The annotated tag is the canonical source: PR-template highlight candidates are never copied into a release automatically.

About

Ebay, before anyone else

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); GitHub - adawalli/ebae: Ebay, before anyone else · GitHub
Skip to content

Latest commit

History

156 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

ebae

ebae - eBay Before Anyone Else

eBay, before anyone else.

Self-hosted eBay alerting. Polls your saved searches every 1-15 minutes via the official Browse API and pings Discord the moment a matching item lists - fast enough to catch Buy It Now drops before they're gone. One container, egress-only, nothing on your network exposed.

See DESIGN.md for architecture and roadmap.

Quick start (dev)

cp .env.example .env.local # set DATABASE_URL (Neon works great)
bun install
bun run dev # UI/API only; no polling or notification delivery

Open http://localhost:3000. Local development leaves the poller off so an accidental production DATABASE_URL cannot start timers or redeliver pending alerts. The scripts force NODE_ENV=development, even if the parent shell exported NODE_ENV=production. To try the complete pipeline against a scratch database, run bun run dev:poller instead. Without eBay credentials that explicit command runs in mock mode, generating fake listings for seeding, alerts and quota testing.

Neither command needs a new env var: one implicit user, no login, DATABASE_URL and the EBAY_*/DISCORD_WEBHOOK_URL vars are the whole config. ENABLE_DEV_POLLER is internal script plumbing: bun run dev sets it to 0; bun run dev:poller sets it to 1; do not set it in .env.local. A search created under normal bun run dev remains unseeded until you run the scratch database with dev:poller. Sharing the deployment with other people is opt-in - see Multi-user.

eBay credentials

  1. Create a free account at developer.ebay.com and create an app (production keyset).
  2. Put the App ID and Cert ID in .env.local as EBAY_CLIENT_ID / EBAY_CLIENT_SECRET.
  3. Restart. The Status & Settings page shows the token going live.

In a multi-user mode the env vars are ignored: each user enters their own App ID and Cert ID on the Status & Settings page instead, and the secret is encrypted before it is stored (see ENCRYPTION_KEY). Keys are checked against eBay on save, so a typo fails immediately. Without keys a user's searches idle - the UI says so.

Browse API default quota is 5,000 calls/day per eBay app, so EBAY_DAILY_QUOTA is a per-user ceiling. The UI projects your daily usage as you add searches and the poller enforces the budget.

The Status page shows what you've spent today against what an evenly-paced day would have spent by now, so 90% used reads differently at 09:00 than at 23:00. If the saved intervals still require more calls than the budget left today, a budget governor stretches them so polling lasts rather than running out at noon and leaving the rest of the day dark. It only ever slows polling down—never below the interval you set—is capped at 4x, and releases once the remaining workload has a 5% buffer. Changing an interval, pausing a search, or extending snooze can therefore restore the saved cadence before midnight. The Saved searches bar separates calls spent, work requested, and any overflow the governor must slow down. Nothing to configure.

Sold prices

An alert is only useful if you know what the thing is worth. By default ebae tracks what listings actually went for and prefers that: alerts then read "Sold ~$420 · ▼ 30% under", and the search's subtitle shows sold ~$420. Until it has enough sales, it compares against asking prices - a daily unfiltered sample of the same search, shown as "Market". Turn off Track sold prices & price drops on an individual search to save API calls.

eBay's sold-search APIs are enterprise-only, so ebae gets there by checking back on listings the search already found:

It also sends a price-drop alert whenever a followed fixed-price or Best Offer listing reaches a new lower price. Re-sightings use the ordinary poll result, and scheduled checks reuse their existing response, so this adds no eBay API calls.

  • Auctions are checked once, five minutes after they end. The end time arrives free with every poll, so nothing is spent guessing - and a snipe in the closing seconds is still captured.
  • Buy It Now listings are checked at 3, 7, 14 and 30 days, at most four times ever. Any poll that sees the listing again skips the next check for free: it's obviously still for sale.
  • Listings you excluded (exclude-terms, "for parts") are never followed - their prices would describe junk, not what you're hunting.
  • Best Offer listings are followed but kept out of the median: eBay keeps showing the asking price after a sale, so what we can read is a ceiling, not the realized price.
  • The deal context appears once three sales inside the last 30 days agree; below that it falls back to the market baseline.

Checks come out of the same EBAY_DAILY_QUOTA as polling and are counted in the projection on the Status page, so the budget governor accounts for them. They're the first thing dropped when the budget runs low, and at most three run per tick, so a backlog drains gradually instead of crowding out the polls that find deals. A check that fails outright (rate limit, an eBay outage) waits an hour rather than retrying immediately, and a listing is abandoned after six failed attempts.

Quota you don't use expires at midnight, so if your saved searches don't need the whole budget the poller spends the leftovers looking in on followed listings early - catching sales that would otherwise happen in a gap and be unreadable by the next scheduled check. It's paced across the day, never touches what your searches still need, and can't slow your polling down. Nothing to configure.

Turning the toggle off stops new checks immediately; listings already being followed age out with SEEN_RETENTION_DAYS. Editing what a search matches clears its sold history, the same way it clears the market baseline. Those sales describe the old criteria, and since the sold median outranks every other basis, keeping them would caption the new search's alerts with the old search's going rate.

Notifications

Alerts go to Discord webhooks, push notifications on your own devices, or both. Push stays enabled for every search. Discord defaults to all saved webhooks, or a search can route to one named webhook. An alert is retried after a restart only while no target has taken it.

Discord

Create a webhook in your Discord channel (channel settings → Integrations → Webhooks) and set DISCORD_WEBHOOK_URL. More targets can be added and named on the Status & Settings page, then selected while creating or editing a search. The environment webhook remains part of the all-webhook route and cannot be selected alone.

In a multi-user mode DISCORD_WEBHOOK_URL is ignored (it would fan everyone's alerts into one channel) - each user adds their own webhooks in the UI.

Push

ebae is an installable PWA, so alerts can arrive as ordinary phone or desktop notifications with no Discord involved. Install it (Chrome: the address-bar install button; iPhone: Share → Add to Home Screen), open Status & Settings, and turn on Push to this device. It is per device - turn it on wherever you want alerts.

No configuration: the VAPID keypair is generated on first use and kept in the database. VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY override it if you would rather pin the keys, and VAPID_SUBJECT sets the contact URI the push services see (it defaults to this project's repo).

The toggle only appears where a browser can actually do push, which needs a secure context:

  • http://localhost counts as secure, so push works out of the box in dev.
  • http://<lan-ip>:3000 does not. Browsers refuse service workers there, so no push - put ebae behind TLS, or use Discord.
  • iPhone/iPad additionally require Add to Home Screen first. Safari tabs cannot receive push at all; the toggle appears only in the installed app.

Subscriptions expire on their own (iOS drops them after a week or two of inactivity, and Chrome revokes permission for sites you rarely open). ebae re-registers the current device every time you open it, and drops subscriptions the push service reports as gone, so this mostly takes care of itself - but if push goes quiet, opening the app is the fix.

Configuration

Config is env vars - see .env.example. Searches, webhooks and (in multi-user modes) each user's eBay keys live in Postgres and are managed in the UI.

varpurposedefault
DATABASE_URLPostgres connection stringrequired
EBAY_CLIENT_ID / EBAY_CLIENT_SECRETeBay app credentials (single mode only)unset = mock mode
EBAY_ENVproduction or sandbox (single mode only)production
EBAY_MARKETPLACEmarketplace id (single mode only)EBAY_US
DISCORD_WEBHOOK_URLnotification target (single mode only)unset
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEYpin the push keypair instead of generating oneunset = generated on first use
VAPID_SUBJECTmailto:/https: contact URI sent to the push servicesthis project's repo
AUTH_MODEsingle, cloudflare or proxysingle
CF_ACCESS_TEAM_DOMAIN<team>.cloudflareaccess.com (required in cloudflare)unset
CF_ACCESS_AUDAccess app Audience tag (required in cloudflare)unset
AUTH_TRUSTED_HEADERheader carrying the email (required in proxy)unset
ENCRYPTION_KEYbase64 32 bytes; encrypts eBay secrets saved in the UIunset
LEGACY_OWNER_EMAILone-time claim of pre-multi-user rowsunset
POLL_INTERVAL_DEFAULTfallback poll interval (min)5
CACHE_REFRESH_HOURSDB → cache refresh cadence12
SEEN_RETENTION_DAYSseen_items and tracked_items retention (days)90
MARKET_SAMPLE_HOURSmarket-baseline resample gap (band-limited searches)24
EBAY_DAILY_QUOTAenforced daily call budget, per user5000
LOG_LEVELerror/warn/info/debuginfo
LOG_FORMATjson or prettypretty on a TTY, else json

Everything above the log vars except DATABASE_URL is optional. The single-mode-only vars are ignored in cloudflare/proxy mode (the poller warns at boot if they are set).

Multi-user

By default (AUTH_MODE=single) there is no auth and one implicit user - the trust model is your LAN, your reverse proxy, your machine. Don't expose that to the internet. Set AUTH_MODE to share one deployment with other people: each gets their own searches, alerts, webhooks, snooze and eBay keys. Identity comes from an auth proxy in front of the app; ebae never handles passwords and has no signup, no roles - onboarding is done wherever your proxy keeps its allowlist.

The single-replica constraint doesn't change: poll timers and the seen-item cache are in process memory.

AUTH_MODE=cloudflare

Cloudflare Access in front of a Cloudflare Tunnel. The app validates the signed Cf-Access-Jwt-Assertion JWT against your team's JWKS, so a request that skips Access can't forge an identity.

  1. Zero Trust → Access → Applications → add a self-hosted application for ebae's hostname.
  2. Attach your Access policy (an email include list against your IdP - Google, GitHub, whatever).
  3. Copy the application's Application Audience (AUD) TagCF_ACCESS_AUD, and your team domain (<team>.cloudflareaccess.com) → CF_ACCESS_TEAM_DOMAIN. Boot fails if either is missing.

Onboarding a friend = adding their email to the policy. Nothing app-side; their user row is created on first login. The free Zero Trust tier covers up to 50 users. Service tokens are rejected: Access only mints an email claim for a real IdP login.

AUTH_MODE=proxy

Generic trusted-header SSO for Authelia, authentik, oauth2-proxy, Tailscale serve and friends. Set AUTH_TRUSTED_HEADER to the header your proxy puts the user's email in (e.g. Remote-Email); that value, lowercased, is the identity.

Only enable this when the app is reachable exclusively through that proxy. The header is plain text and is trusted as-is - anyone who can reach the app directly can set it to any address and become that user. Bind the app to localhost or a private network, and make sure the proxy strips the header from inbound requests.

Local development against a shared database

AUTH_MODE=single still runs against a database a multi-user mode created, but you will see an empty account: single mode signs you in as the implicit local@localhost user, while every existing row belongs to the SSO identity that created it. Nothing is lost and nothing is taken - the boot-time claim only adopts rows that have no owner - your data is simply someone else's.

bun run dev loads the cache for the UI but schedules no polls and performs no pending-alert redelivery. bun run dev:poller is the deliberate opt-in for a scratch database. Even then development polls only the implicit local@localhost user, never a real identity loaded from a shared database, and development never replays pending alerts. The UI labels a real identity as Guarded rather than asking you to re-enter credentials.

The safety boundary is background polling and delivery, not database access. Migrations, cache maintenance and any UI/API action still use the configured database. Use a scratch DATABASE_URL for normal work, or a read-only production role when inspection is unavoidable.

If you genuinely need your real rows locally, be yourself instead of being local@localhost:

AUTH_MODE=proxy
AUTH_TRUSTED_HEADER=X-Email
curl -H 'X-Email: you@example.com' localhost:3000/api/searches

For the browser you need something to inject that header on every request. Prefer a local reverse proxy over a browser extension. Development still refuses to poll that real identity; this setup is for inspecting or editing its rows through the UI/API.

ENCRYPTION_KEY

Needed only to save eBay credentials through the UI (encrypts the client secret at rest, AES-256-GCM). Single-mode deployments on EBAY_CLIENT_ID/EBAY_CLIENT_SECRET store nothing and don't need it.

openssl rand -base64 32

Keep it with your other secrets. There is no key history and no re-encryption tooling: lose it and each user re-enters their eBay keys in the UI. That is the whole recovery story, and it takes a minute.

LEGACY_OWNER_EMAIL

Only for switching an existing single-user database to a multi-user mode. Rows created before multi-user have no owner, and the poller skips them. Set this to your email for one boot; every existing search, channel and alert is assigned to that user, and the row is adopted (sub stamped) when you first log in. Remove the var afterwards - the claim is idempotent, so leaving it does nothing.

Two things do not survive that upgrade, both once-off:

  • Your snooze window resets to the defaults (off, 01:00-07:00). The old global settings row is dropped rather than copied, because the migration runs before your user row exists. Note your window down first and re-enter it in the UI.
  • DISCORD_WEBHOOK_URL stops being read, since one global webhook would fan every user's alerts into the same channel. The claim imports it once as your own channel so notifications keep working, and logs when it does; you can drop the var afterwards. Every other user adds their webhook in the UI.

Deploy

docker compose up -d # uses .env; bundled Postgres available in the compose file

Kubernetes: deploy/k8s.yaml (single replica - poll timers and the seen-item cache are in-process).

Recommended setup: run it on a home box and expose the UI through a Cloudflare Tunnel behind Cloudflare Access. All app traffic is outbound-only. Add AUTH_MODE=cloudflare and you can share it with other people - see Multi-user.

Releasing

Push an annotated semver tag. docker.yml builds and pushes the matching container image, while release.yml publishes GoReleaser's grouped changelog.

For an ordinary release:

git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin vX.Y.Z

For a release worth calling out, run git tag -a vX.Y.Z, then use this tag message:

vX.Y.Z
## Highlights
**Short title** — One friendly, user-facing sentence.

Add one line per highlight. GoReleaser prepends that block above the generated changelog; without the ## Highlights heading, it publishes the usual changelog and container-image footer only. The annotated tag is the canonical source: PR-template highlight candidates are never copied into a release automatically.

About

Ebay, before anyone else

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages