Per-channel storefront geo-restriction for Vendure. Allow / block by country, by curated region preset (37 of them: EU, EEA, Schengen, GCC, ANZ, NATO, Commonwealth, …), by ISO-3166-2 subdivision (US states, CA provinces, AU states, DE Länder, IT regions, FR regions, ES autonomous communities, IN states, BR units, MX entities, UK constituent countries). Soft-block mode, IP allowlist, audit log, simulator and maintenance windows.
New in 0.7: SEO-safe bot allowlist so Googlebot / Bingbot / social
crawlers bypass by default · per-channel business-hours schedule
(overnight windows + DST handled) · one-line storefront drop-in JS
(hulo-geo.js) · branded block page (/geo-block/blocked).
Maintained by Wayne Garrison.
14-day free trial (card required, start it from the admin — nothing charged until day 15), then £4.95/month, or £99 one-off lifetime at elite.charity/licence/buy/vendure-plugin-geo-block. Start the trial (or buy) from the plugin's admin page — checkout opens in a new tab and the key installs itself.
Add GeoBlockPlugin.uiExtensions to your compileUiExtensions config.
GeoBlockNavModule, registered by uiExtensions), opening the page at
/admin/extensions/geo-block: channel picker, Rules, Block page, IP
allowlist, Simulate and Stats tabs, with a live plain-English readout of
what the current rules do.Visitor IP: the plugin uses Express's
req.ip(settrustProxyon the Vendure host). Only list headers your edge sets and strips from clients intrustedIpHeaders, e.g.['cf-connecting-ip']behind Cloudflare. Audit rows are pruned after 90 days / 500 000 rows by default (retention: falsekeeps everything).
Unlicensed / after the evaluation: saved rules are still enforced on
/geo-block/site-configand/geo-block/check, but on the free tier —modeis alwaysblock(no soft-block banner), subdivision rules are ignored, only the 5 free presets can be picked, and no audit log is written. The publicsite-configandcheckapply the same gate.
One-click bundles in five groups:
GET /geo-block/presets returns the live catalogue with country counts,
descriptions and each preset's countries (null for WORLDWIDE). The
admin page resolves the allow-list in the browser from that catalogue, so
the "Allow visitors from N countries" preview and the lockout warning
follow every edit before you save.
Each Vendure channel gets its own rules — perfect for multi-storefront installs (UK-only channel + EU channel + LATAM channel from one Vendure instance).
Per-channel mode field. block hides the storefront entirely;
soft renders it with a "we don't ship to your country" banner and
hides the checkout button. The verdict mode field is returned on
/geo-block/check so the storefront knows how to render.
JSON map { "US": ["CA","NY"], "DE": ["BY"] } enforced on visitors via
the channel custom field geoBlockAllowedSubdivisions. Catalogue at
GET /geo-block/subdivisions covers 11 countries (200+ subdivisions).
Legacy GB-only allowedGbRegions is preserved for back-compat.
Per-channel list of IPs / ranges that bypass every rule. Use for offices, oncall, payment-processor probes, monitoring.
Every block decision recorded in geo_block_event with country, region,
IP (hashed by default), UA, channel and reason. Admin Stats panel
shows top blocked countries, daily series and reason breakdown.
POST /geo-block/admin/simulate dry-runs a hypothetical visitor against
current rules without persisting anything — try any country / UK
region / IP and see exactly what would happen.
Per-channel blockMessage, blockRedirectUrl, blockLogoUrl. Falls
back to sensible defaults per block reason.
Reads cf-ipcountry / Akamai / Fastly region headers when present.
Saves a MaxMind lookup per request.
signingSecret HMAC-gates the ?country= override on /check so
storefront staff (or attackers) can't spoof a location at will.ipSalt; when it is unset a
random per-install salt is generated once and kept in hulo_licence_store
(key vendure-plugin-geo-block:ipsalt), shared by server and worker. Set
ipSalt to keep hashes comparable across reinstalls or DB restores.Weekly report. Every Monday at 08:00 (server-local time) the worker
process emails a short report: how many visits Geo Block hard-blocked and
soft-blocked last week and how many distinct addresses that covered, each
with the change against the week before, the next step from the set-up
checklist, and — on a trial or the free tier — what a licence switches on.
Every figure is a real count from geo_block_event; nothing is estimated.
supportEmail option, then the licence's customer email, then
the address entered in "Email me before it ends" on the trial banner. No
address → nothing is sent.SMTP_SERVER (or SMTP_HOST), SMTP_PORT, SMTP_USER,
SMTP_PASSWORD and SMTP_FROM environment variables through
nodemailer, which is an optional peer dependency — install it on
the host (yarn add nodemailer) to enable the report. Without SMTP the
report is simply off and the plugin logs one line saying so.GET/POST /geo-block/digest/settings ({enabled?, recipient?}),
POST /geo-block/digest/send-test ({to?}), GET /geo-block/digest/preview.hulo_plugin_prefs table (created on
boot); the weekly clock is multi-worker safe.Set-up checklist (GET /geo-block/setup) sits at the top of the admin
page until every item is done: rules set on a channel, block page
customised, your own IP allow-listed, a simulation run. "Hide" is remembered
in the browser; the card returns if a done item comes undone. The same
undone items are the "next steps" in the weekly report.
Dashboard widget. Dashboard → Add widget → Geo Block shows the last
7 days' blocked, soft-blocked and unique-IP counts (GET /geo-block/widget)
with a link to the page. Stats stay a premium feature: on the free tier the
widget and the Stats tab show an unlock prompt instead of figures.
Usage numbers. The same 30-day counts (requestsBlocked,
requestsSoftBlocked, uniqueIpsBlocked) are sent with the daily
evaluation ping and, after purchase, the licence heartbeat — counts only,
never addresses or URLs. HULO_HEARTBEAT_DISABLED=true stops the heartbeat.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /geo-block/site-config | public | resolved channel rules (cache client-side) |
GET | /geo-block/check | public | per-request decision + reason (logs to audit) |
GET | /geo-block/presets | public | 37-preset catalogue (with countries) |
GET | /geo-block/subdivisions | public | subdivision catalogue (11 countries) |
GET | /geo-block/admin/channels | admin | list channels + rules |
POST | /geo-block/admin/save | admin | save a channel's rules |
GET | /geo-block/admin/stats | admin | block totals + top countries + series |
POST | /geo-block/admin/simulate | admin | dry-run a hypothetical visitor |
POST | /geo-block/admin/gc | admin | prune old audit rows |
GET | /geo-block/status | admin | version + update status |
GET | /geo-block/licence/status | admin | licence, tier, lifecycle notice, premium features, recent stats |
POST | /geo-block/eval/remind-me | admin | "email me before the evaluation ends" opt-in |
GET | /geo-block/setup | admin | set-up checklist (items, complete) |
GET | /geo-block/widget | admin | dashboard widget feed (7-day figures) |
GET | /geo-block/digest/settings | admin | weekly report settings |
POST | /geo-block/digest/settings | admin | update {enabled?, recipient?} |
POST | /geo-block/digest/send-test | admin | send this week's report now {to?} |
GET | /geo-block/digest/preview | admin | this week's report as HTML |
GET | /geo-block/hulo-geo.js | public | storefront drop-in helper JS |
GET | /geo-block/blocked | public | branded block page (HTML or JSON) |
On DOMContentLoaded the helper calls /geo-block/check, and:
mode=block → redirects to /geo-block/blocked (or the channel's
blockRedirectUrl if set, or the tag's data-redirect)mode=soft → injects a sticky top banner with the operator's messageOptional data-* attributes: data-redirect="/somewhere-else",
data-timeout-ms="2500", data-preview (log verdict, no side effects).
User manual + screenshots: huloglobal.com/vendure-plugins/geo-block/docs/
Commercial. Buy at elite.charity/licence/buy/vendure-plugin-geo-block.
All notable changes to @huloglobal/vendure-plugin-geo-block are documented
here. The format follows Keep a Changelog
and this project adheres to semantic versioning.
Merchant engagement pass (licence-sdk 0.15.0).
SMTP_* environment
(nodemailer is an optional peer dependency); off when SMTP is unset.
Recipient: the configured supportEmail, else the licence customer, else
the trial-reminder address. Settings, "Send me a test" and "Preview" live
on the admin page (GET/POST /geo-block/digest/settings,
POST /geo-block/digest/send-test, GET /geo-block/digest/preview).
Preferences are stored in the shared hulo_plugin_prefs table.GET /geo-block/setup):
rules set on a channel, block page customised, own IP allow-listed,
simulation run. Real checks against the channel rows; "Hide" is
remembered in the browser until a done item comes undone.GET /geo-block/widget).GeoBlockNavModule, shared).requestsBlocked,
requestsSoftBlocked and uniqueIpsBlocked counts already sent with the
evaluation ping now also go with the daily heartbeat after purchase, so
renewal emails can say what the plugin did.POST /geo-block/eval/remind-me — "Email me before it ends" on the trial
banner, proxied to the licence server; the address is kept as the report
fallback recipient.GET /geo-block/licence/status gains notice (trial/key lifecycle
sentence), premiumFeatures and recentStats (top 30-day figures).licence-required bodies produce one consistent toast.@huloglobal/vendure-licence-sdk ^0.15.0.Follow-up to the 0.13.2 hardening pass.
ipSalt is unset, a random salt is
generated once at boot and stored in hulo_licence_store (key
vendure-plugin-geo-block:ipsalt, first writer wins so server and
worker agree) instead of hashing every install's audit IPs with the same
public constant. A boot warning says a generated salt is in use.GET /geo-block/presets now includes each
preset's countries; the admin page resolves the allow-list, the status
sentence and the lockout warning in the browser from the form state
(ui/resolve-allowed.ts, unit-tested against the server resolver)
instead of the list the server computed at load time.src/pg-corpus.test.ts, runs when HULO_PG_URL
is set): every raw SQL statement in src/ is translated by the dialect
adapter and executed against PostgreSQL 17 with TypeORM-quoted stand-ins
for channel and geo_block_event.presetCountries(key) is exported.site-config contract. The public site-config never
applied the free-tier gate that /check applied (mode=block, no
subdivisions), so a storefront caching site-config could render the
soft-block banner while /check blocked. The gate now lives in
buildConfig, so site-config, check and the simulator agree. The
README and e2e no longer claim the free tier returns enabled:false.Reliability and security pass — no new features.
/check and the rate limiter trusted
X-Forwarded-For, CF-Connecting-IP, True-Client-IP and X-Real-IP
from any client, so anyone could send an allow-listed office address
and walk through the block. The visitor address is now Express's
req.ip (honouring the host's trust proxy); vendor headers are only
read when listed in the new trustedIpHeaders option. IPv4-mapped IPv6
addresses (::ffff:…) now match IPv4 allowlist entries; matching is
case-insensitive.hulo-geo.js carried
Cross-Origin-Resource-Policy: same-origin, so a storefront on a
different origin than the API could not load it. It is now
cross-origin.createdAt/channelId are quoted in raw SQL; SUM(boolean) replaced
with SUM(CASE …); the enabled flag is bound as a boolean.customFields_huloGeoBlock…); geoBlockChannels, geoBlockSaveChannel
and geoBlockSimulate now use the real columns and the same JSON-array
storage as the REST routes.?country=/?region= overrides must be plain ISO codes and, when a
signingSecret is set, both must be signed (regionSig).maintenanceWindow.allowedIps is honoured; the simulator applies
subdivisions and reports schedule, bot and maintenance verdicts like
enforcement does.24:xx (fail-open).admin/stats authenticates before revealing the tier.insert after the response (no longer
awaited); url now records the referring page instead of the API URL.retention: false to
keep everything); a (channelId, createdAt) index is declared on
geo_block_event (run your migrations)./check
answers Cache-Control: no-store; /presets and /subdivisions are
cacheable for an hour.@, timers are cleared on destroy.hulo-global-logo.svg is now the HG wordmark on the HULO black rounded square (the same mark as the huloglobal.com header), with explicit -light and -dark variants alongside the auto-switching default..env edit, no restart. Renewed subscription keys are picked up automatically too. New admin endpoints licence/purchase-link and licence/claim-status.@huloglobal/vendure-licence-sdk ^0.12.0.dbConnectionOptions use.
Verified against PostgreSQL 17. MySQL/MariaDB installs are unaffected
(byte-identical passthrough).btn btn-secondary etc.) from the admin shell, but the
built admin CSS defines .btn only contextually and .btn-secondary
not at all — "+ Add" and friends rendered as bare native buttons.
Every control is now styled by the component itself (.gbtn system).--color-component-color-200/300, which the admin never defines, so
hardcoded slate fallbacks applied in both themes — unreadable on dark
surfaces. Replaced by per-theme ink tokens.#bfc3cc ≈ 1.7:1; dark border-200 is
darker than the surface, 1.01:1). Inputs/buttons/switch now use a
dedicated --gb-ui-border at ≥ 3:1 per theme.scripts/contrast-check.py
simulates the shipped color-mix() tokens against the real admin
theme values and checks every text/surface pair — 40/40 WCAG AA pass
(≥ 4.5:1 text, ≥ 3:1 UI boundaries) in light and dark.role="switch", keyboard + focus ring)
replaces the ON/OFF pill + button pair.role="tablist"/tab, aria-selected), labelled chip
remove buttons, aria-expanded on collapsibles, :focus-visible
rings on every control, prefers-reduced-motion support.color-mix()
instead of hardcoded light-mode pastels, which produced light-text-on-
cream unreadables in the dark theme (worst: selected preset cards).botAllowlist, defaulting
to 'strict' — matches every well-known SEO + social crawler
(Googlebot, Bingbot, DuckDuckBot, Baidu, Yandex, AppleBot, Slurp,
facebookexternalhit, Twitterbot, LinkedInBot, Slackbot, WhatsApp,
Discord, Telegram, Pinterest, Reddit + more). Ships crawler-safe
by default so restrictive geo rules don't silently drop search-engine
crawls and de-index the site. Also accepts 'permissive' (any UA
self-identifying as bot / crawler / spider), false (no allowlist —
use only when a WAF handles bots upstream), or a custom pattern
array of strings + regexes. New audit decision bot-allowlist.geoBlockSchedule — a JSON object with timezone, days, from,
to, and outsideAction (block / soft / allow). Recurring
weekly window per channel; outside the window the configured action
fires. Handles overnight windows and DST via Intl.DateTimeFormat.
New audit decision schedule. IP + bot allowlists still bypass so
ops and crawlers never see "closed for orders".GET /geo-block/hulo-geo.js.
One <script src> with data-channel-token="…" and the store gets
geo-blocking with zero custom code. Vanilla JS, no dependencies,
fails open on network error, ~2 KB minified. Optional attrs:
data-redirect, data-timeout-ms, data-preview. Cached
public, max-age=300, stale-while-revalidate=1200 (configurable via
new plugin option storefrontHelperMaxAgeSec).GET /geo-block/blocked?t=…&reason=….
Self-contained HTML — inline CSS, no deps, HULO amber-on-navy
identity. Renders channel blockLogoUrl, message, optional
redirect CTA, and support email (new plugin option supportEmail).
Returns JSON when Accept: application/json. Rate-limited.logo.svg in the package root
(globe + amber block-slash on the navy HULO frame)./geo-block/check now runs the bot allowlist and business-hours
schedule checks alongside the existing IP allowlist + maintenance
window checks. Precedence: IP allowlist → bot allowlist → schedule
→ maintenance → country / region rules.loadChannelRow now includes geoBlockSchedule and the previously-
missing geoBlockAllowedSubdivisions field, so subdivision rules
now round-trip correctly through the storefront /check path.geoBlockAllowedSubdivisions) was defined as a
channel custom field but never read into the runtime config on
/check — the storefront verdict ignored it. Now honoured
end-to-end (on licensed installs — unlicensed still forces the
map to empty per the 0.4 tier gates).warnIfIncompatibleVendure(). Logs a non-fatal warning when the runtime
@vendure/core version is outside the tested range. Silent when inside;
fail-open on unparseable versions.@vendure/core tightened to >=3.5.0 <4.0.0 — Vendure 3.5,
3.6 and 3.7 are all covered. Anything under 3.5 has never been tested;
anything from 4.0 upwards is deferred until the changelog is reviewed.@huloglobal/vendure-licence-sdk@^0.6.0.geoBlockPresets, geoBlockChannels, geoBlockStats
(paid), geoBlockSaveChannel, geoBlockSimulate (paid)./geo-block/check, /geo-block/site-config) stay
REST — they're anonymous, high-frequency, cacheable at the edge, and
GraphQL was never the right shape for them.Tier-gating on every premium feature via the SDK's isLicensed()
helper. Unlicensed installs get:
mode forced to block — no soft-block;Commenting out a boot check no longer unlocks anything — the gates are enforced at each call site.
Anti-tamper heartbeat via the SDK. Anonymous daily fingerprint of the embedded public key + verifier source. No personal data.
geoBlockAllowedSubdivisions storing a JSON map
{ "US": ["CA","NY"], "DE": ["BY"] }. Storefront enforcement checks
both the new map and the legacy GB-only field.GET /geo-block/subdivisions./site-config + /check.?country= override on /check (signingSecret).hashAuditIps, ipSalt).options.retention.@huloglobal/vendure-licence-sdk@^0.2.0.UpdateChecker integration — /geo-block/status endpoint returns
version + update info; admin banner appears on new releases.mode field (block or soft).
Soft mode renders the storefront with a "we don't ship here" banner
instead of hiding it.GeoBlockEvent entity records every block
decision (country, region, IP, UA, reason).GET /geo-block/admin/stats returns block totals,
top blocked countries, daily series and reason breakdown.POST /geo-block/admin/simulate dry-runs a
hypothetical visitor against current rules without persisting anything.blockMessage, blockRedirectUrl,
blockLogoUrl fields./geo-block/check endpoint — visitors can be checked
on the fly with logging to the audit table.GET /geo-block/presets lists every
preset with metadata (kind, description, country count)./geo-block/admin/* directly (no /ees/ prefix).isAllowed() and ipMatchesAny() exported for downstream use.GeoBlockPlugin registering five Channel customFields per channel
(enable toggle, region presets, allowed countries, blocked countries,
UK region sub-filter)./geo-block/site-config endpoint serving a flat resolved
allow-list per channel./geo-block/admin/channels and /geo-block/admin/save.resolveAllowedCountries exported as a pure helper.@huloglobal/vendure-licence-sdk with
revocation polling.