From 0b54eba71e99998306797eaff2f1b5bf4c7267ae Mon Sep 17 00:00:00 2001
From: Salmatcre8 <118213044+Salmatcre8@users.noreply.github.com>
Date: Mon, 10 Aug 2026 04:38:53 +0100
Subject: [PATCH 1/4] fix(pulse-webhooks)!: close the SSRF bypass in the
delivery guard
`WebhookDelivery` kept its own `net.BlockList` of private ranges, separate
from the list in `UrlValidator`. The two drifted, and the copy guarding
outbound deliveries was the weaker one: it omitted `0.0.0.0/8`, the IPv6
unspecified address, CGNAT `100.64.0.0/10` and `192.0.0.0/24`.
Because each of those is an IP literal, `validateResolvedHostname` returned
early and never re-checked them. So `http://0.0.0.0:8080/` passed validation
and was fetched - and on Linux `0.0.0.0` routes to loopback, meaning a
customer-registered webhook URL could reach services bound to 127.0.0.1 on
the delivery worker. Verified end to end: `validateUrl` returned null for
`0.0.0.0` while correctly blocking `127.0.0.1`, and a real fetch reached a
loopback-bound server.
Extract the range checks into `private-ip.ts` and have both callers use it,
so there is nothing left to drift. `validateUrl` also picks up the
`.localhost` suffix check it was missing - RFC 6761 reserves the whole
suffix, not just the bare label.
`UrlValidator` is now exported. Its own docs claimed consumers wire it in
front of their own fetch, but it was never re-exported from index.ts, so
nobody could - the claim was false and the class was unreachable.
Adds 8 regression cases against the delivery path, not just the validator.
---
packages/pulse-webhooks/src/index.ts | 65 ++++++------
packages/pulse-webhooks/src/private-ip.ts | 99 +++++++++++++++++++
packages/pulse-webhooks/src/url-validator.ts | 88 +++--------------
.../test/pulse-webhooks.test.ts | 44 +++++++++
4 files changed, 184 insertions(+), 112 deletions(-)
create mode 100644 packages/pulse-webhooks/src/private-ip.ts
diff --git a/packages/pulse-webhooks/src/index.ts b/packages/pulse-webhooks/src/index.ts
index c9c8daa3..eb7b9916 100644
--- a/packages/pulse-webhooks/src/index.ts
+++ b/packages/pulse-webhooks/src/index.ts
@@ -9,7 +9,6 @@ import type {
import { timingSafeEqual, randomUUID } from "crypto";
import { lookup } from "dns/promises";
-import { BlockList, isIP } from "net";
import type { DeadLetterStore as DeadLetterStoreInterface } from "./DeadLetterStore.js";
import { MemoryDeadLetterStore } from "./MemoryDeadLetterStore.js";
@@ -17,27 +16,27 @@ import { exponentialJittered } from "./backoff.js";
import type { BackoffStrategy } from "./backoff.js";
import type { RetryQueue, RetryRecord } from "./RetryQueue.js";
import { signWebhookPayload } from "./signing.js";
+import {
+ isIpLiteral,
+ isLoopbackHostname,
+ isPrivateIpLiteral,
+ normalizeHostname,
+} from "./private-ip.js";
import type { Tracer, UrlEntry, VerifyWebhookOptions, WebhookConfig } from "./types.js";
import { DEFAULT_MAX_AGE_MS, DEFAULT_CLOCK_SKEW_MS } from "./types.js";
import { NOOP_WEBHOOK_METRICS } from "./metrics.js";
-const BLOCKED_WEBHOOK_ADDRESSES = new BlockList();
-BLOCKED_WEBHOOK_ADDRESSES.addSubnet("10.0.0.0", 8, "ipv4");
-BLOCKED_WEBHOOK_ADDRESSES.addSubnet("127.0.0.0", 8, "ipv4");
-BLOCKED_WEBHOOK_ADDRESSES.addSubnet("172.16.0.0", 12, "ipv4");
-BLOCKED_WEBHOOK_ADDRESSES.addSubnet("192.168.0.0", 16, "ipv4");
-BLOCKED_WEBHOOK_ADDRESSES.addSubnet("169.254.0.0", 16, "ipv4");
-BLOCKED_WEBHOOK_ADDRESSES.addAddress("::1", "ipv6");
-BLOCKED_WEBHOOK_ADDRESSES.addSubnet("fc00::", 7, "ipv6");
-BLOCKED_WEBHOOK_ADDRESSES.addSubnet("fe80::", 10, "ipv6");
-BLOCKED_WEBHOOK_ADDRESSES.addSubnet("::ffff:a00:0", 104, "ipv6");
-BLOCKED_WEBHOOK_ADDRESSES.addSubnet("::ffff:7f00:0", 104, "ipv6");
-BLOCKED_WEBHOOK_ADDRESSES.addSubnet("::ffff:ac10:0", 108, "ipv6");
-BLOCKED_WEBHOOK_ADDRESSES.addSubnet("::ffff:c0a8:0", 112, "ipv6");
-BLOCKED_WEBHOOK_ADDRESSES.addSubnet("::ffff:a9fe:0", 112, "ipv6");
-
const BLOCKED_ADDRESS_ERROR = "Webhook URL points to a blocked private address";
export { signWebhookPayload } from "./signing.js";
+// Previously unreachable: `UrlValidator`'s own docs said consumers wire it in
+// front of their own fetch, but it was never re-exported here, so nobody could.
+export { UrlValidator } from "./url-validator.js";
+export {
+ isIpLiteral,
+ isLoopbackHostname,
+ isPrivateIpLiteral,
+ normalizeHostname,
+} from "./private-ip.js";
export { configureDeadLetterStore } from "./DeadLetterStore.js";
export type {
DeadLetterEntry,
@@ -523,8 +522,10 @@ export class WebhookDelivery {
return "Invalid webhook URL";
}
- const hostname = this.normalizeHostname(parsedUrl.hostname);
- if (hostname === "localhost") {
+ const hostname = normalizeHostname(parsedUrl.hostname);
+ // `.localhost` too, not just the bare label: RFC 6761 reserves the suffix
+ // and resolvers answer it with a loopback address.
+ if (isLoopbackHostname(hostname)) {
return BLOCKED_ADDRESS_ERROR;
}
@@ -535,27 +536,21 @@ export class WebhookDelivery {
return null;
}
- private normalizeHostname(hostname: string): string {
- return hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
- }
-
+ /**
+ * Shared with `UrlValidator` - see `./private-ip.js` for why these are no
+ * longer two separate lists.
+ *
+ * URL normalisation converts mapped dotted forms such as `::ffff:10.0.0.1`
+ * to their canonical hexadecimal form `::ffff:a00:1`, which
+ * `isPrivateIpLiteral` decodes back before checking.
+ */
private isBlockedIp(address: string): boolean {
- const ipVersion = isIP(address);
- if (ipVersion === 4) {
- return BLOCKED_WEBHOOK_ADDRESSES.check(address, "ipv4");
- }
- if (ipVersion === 6) {
- // URL normalisation converts mapped dotted forms such as
- // ::ffff:10.0.0.1 to their canonical hexadecimal form ::ffff:a00:1.
- return BLOCKED_WEBHOOK_ADDRESSES.check(address, "ipv6");
- }
-
- return false;
+ return isPrivateIpLiteral(address);
}
private async validateResolvedHostname(url: string): Promise {
- const hostname = this.normalizeHostname(new URL(url).hostname);
- if (isIP(hostname) !== 0) return null;
+ const hostname = normalizeHostname(new URL(url).hostname);
+ if (isIpLiteral(hostname)) return null;
try {
// Check every A and AAAA answer before each attempt. This prevents a
diff --git a/packages/pulse-webhooks/src/private-ip.ts b/packages/pulse-webhooks/src/private-ip.ts
new file mode 100644
index 00000000..1beb6ad5
--- /dev/null
+++ b/packages/pulse-webhooks/src/private-ip.ts
@@ -0,0 +1,99 @@
+import { isIP } from "node:net";
+
+/**
+ * The single definition of "an address a server-side fetch must never reach".
+ *
+ * This used to exist twice: as a `net.BlockList` inside `WebhookDelivery` and
+ * as `UrlValidator.isPrivateIp`. The two drifted, and the copy that actually
+ * guards outbound deliveries was the weaker one - it omitted `0.0.0.0/8`, the
+ * IPv6 unspecified address, CGNAT and `192.0.0.0/24`. Since those are IP
+ * literals, the post-DNS re-check skipped them too, so `http://0.0.0.0:8080/`
+ * passed validation and reached a service bound to loopback on Linux.
+ *
+ * One exported function, used by both, so there is nothing left to drift.
+ */
+
+/**
+ * Whether `hostname` is an IP literal in a range that must not be reachable.
+ *
+ * Returns `false` for anything that is not an IP literal - a DNS name has to be
+ * resolved first and each answer checked, which is the caller's job
+ * (`WebhookDelivery.validateResolvedHostname` does this before every attempt).
+ *
+ * The ranges are deliberately explicit rather than clever: `169.254.0.0/16` is
+ * the cloud metadata range and the single most valuable SSRF target, and
+ * `127.0.0.0/8` is far wider than the `127.0.0.1` people remember.
+ */
+export function isPrivateIpLiteral(hostname: string): boolean {
+ const version = isIP(hostname);
+
+ if (version === 4) {
+ const [a, b] = hostname.split(".").map(Number) as [number, number, number, number];
+
+ if (a === 0) return true; // 0.0.0.0/8 - "this network"; 0.0.0.0 routes to loopback on Linux
+ if (a === 10) return true; // 10.0.0.0/8
+ if (a === 127) return true; // 127.0.0.0/8 - loopback
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 - link-local / cloud metadata
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
+ if (a === 192 && b === 0) return true; // 192.0.0.0/24 - protocol assignments
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 - CGNAT
+ if (a >= 224) return true; // multicast and reserved
+ return false;
+ }
+
+ if (version === 6) {
+ if (hostname === "::" || hostname === "::1") return true;
+ if (/^f[cd]/.test(hostname)) return true; // fc00::/7 unique-local
+ if (/^fe[89ab]/.test(hostname)) return true; // fe80::/10 link-local
+ // IPv4-mapped (::ffff:a00:1) and IPv4-compatible forms: re-check the
+ // embedded address rather than trusting the textual shape.
+ const mapped = /^::(?:ffff:)?([0-9a-f.:]+)$/.exec(hostname);
+ const embedded = mapped?.[1] ? embeddedIpv4(mapped[1]) : null;
+ if (embedded && isPrivateIpLiteral(embedded)) return true;
+ return false;
+ }
+
+ return false;
+}
+
+/**
+ * Whether `hostname` names the local machine by name rather than by address.
+ *
+ * `.localhost` is reserved by RFC 6761 and resolvers are expected to answer it
+ * with a loopback address, so the suffix has to be covered and not just the
+ * bare label.
+ */
+export function isLoopbackHostname(hostname: string): boolean {
+ return hostname === "localhost" || hostname.endsWith(".localhost");
+}
+
+/** Strips IPv6 brackets and lowercases, so the checks above see a bare address. */
+export function normalizeHostname(hostname: string): string {
+ return hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
+}
+
+/**
+ * Whether `hostname` is already an IP address rather than a name needing DNS.
+ *
+ * Callers use this to decide whether a post-resolution re-check is required at
+ * all: a literal was checked directly by `isPrivateIpLiteral`, so there is
+ * nothing left to resolve.
+ */
+export function isIpLiteral(hostname: string): boolean {
+ return isIP(hostname) !== 0;
+}
+
+/** Converts the tail of a mapped IPv6 address to dotted-quad, when it is one. */
+function embeddedIpv4(tail: string): string | null {
+ if (isIP(tail) === 4) return tail;
+
+ const hexGroups = tail.split(":").filter((group) => group !== "");
+ if (hexGroups.length !== 2) return null;
+
+ const high = Number.parseInt(hexGroups[0]!, 16);
+ const low = Number.parseInt(hexGroups[1]!, 16);
+ if (!Number.isFinite(high) || !Number.isFinite(low)) return null;
+
+ return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
+}
diff --git a/packages/pulse-webhooks/src/url-validator.ts b/packages/pulse-webhooks/src/url-validator.ts
index f106dc7c..0cf1ecbd 100644
--- a/packages/pulse-webhooks/src/url-validator.ts
+++ b/packages/pulse-webhooks/src/url-validator.ts
@@ -1,14 +1,15 @@
-import { isIP } from "node:net";
+import { isLoopbackHostname, isPrivateIpLiteral, normalizeHostname } from "./private-ip.js";
/**
* A pluggable URL validator for custom block-lists.
*
- * `WebhookDelivery` has its own SSRF guard (`BLOCKED_WEBHOOK_ADDRESSES` plus a
- * post-DNS re-check) and does not use this class - this is the extension point
- * for consumers who need to add their own rules, and the reference for what
- * "blocked" means. It is exported, so its private-range checks have to be
- * correct rather than illustrative: a consumer who wires this in front of their
- * own fetch is relying on it.
+ * This is the extension point for consumers who need to add their own rules on
+ * top of the built-in SSRF guard - ASN block-lists, allow-lists, and so on.
+ *
+ * It shares its address checks with `WebhookDelivery` via `./private-ip.js`.
+ * They used to be separate implementations, which is how the delivery path
+ * ended up the weaker of the two; keep new rules in the shared module unless
+ * they are genuinely specific to one caller.
*
* Reviewed for #926.
*/
@@ -45,13 +46,13 @@ export class UrlValidator {
return "URL must not contain credentials";
}
- const hostname = this.normalizeHostname(parsedUrl.hostname);
+ const hostname = normalizeHostname(parsedUrl.hostname);
- if (this.isLoopbackHostname(hostname)) {
+ if (isLoopbackHostname(hostname)) {
return "URL points to a loopback address";
}
- if (this.isPrivateIp(hostname)) {
+ if (isPrivateIpLiteral(hostname)) {
return "URL points to a private IP address";
}
@@ -63,73 +64,6 @@ export class UrlValidator {
return null;
}
- /** Strips IPv6 brackets and lowercases, so checks see a bare address. */
- private normalizeHostname(hostname: string): string {
- return hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
- }
-
- private isLoopbackHostname(hostname: string): boolean {
- return hostname === "localhost" || hostname.endsWith(".localhost");
- }
-
- /**
- * Blocks the address ranges that must never be reachable from a server-side
- * fetch.
- *
- * The list is deliberately explicit: `169.254.0.0/16` is the cloud metadata
- * range and the single most valuable SSRF target, and `127.0.0.0/8` is far
- * wider than the `127.0.0.1` people remember.
- */
- private isPrivateIp(hostname: string): boolean {
- const version = isIP(hostname);
-
- if (version === 4) {
- const [a, b] = hostname.split(".").map(Number) as [number, number, number, number];
-
- if (a === 0) return true; // 0.0.0.0/8 - "this network"
- if (a === 10) return true; // 10.0.0.0/8
- if (a === 127) return true; // 127.0.0.0/8 - loopback
- if (a === 169 && b === 254) return true; // 169.254.0.0/16 - link-local / metadata
- if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
- if (a === 192 && b === 168) return true; // 192.168.0.0/16
- if (a === 192 && b === 0) return true; // 192.0.0.0/24 - protocol assignments
- if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 - CGNAT
- if (a >= 224) return true; // multicast and reserved
- return false;
- }
-
- if (version === 6) {
- if (hostname === "::" || hostname === "::1") return true;
- if (/^f[cd]/.test(hostname)) return true; // fc00::/7 unique-local
- if (/^fe[89ab]/.test(hostname)) return true; // fe80::/10 link-local
- // IPv4-mapped (::ffff:a00:1) and IPv4-compatible forms: re-check the
- // embedded address rather than trusting the textual shape.
- const mapped = /^::(?:ffff:)?([0-9a-f.:]+)$/.exec(hostname);
- const embedded = mapped?.[1] ? this.embeddedIpv4(mapped[1]) : null;
- if (embedded && this.isPrivateIp(embedded)) return true;
- return false;
- }
-
- // Not an IP literal - a hostname. DNS resolution is the caller's
- // responsibility; `WebhookDelivery` re-checks after resolving, and any
- // consumer using this class directly must do the same.
- return false;
- }
-
- /** Converts the tail of a mapped IPv6 address to dotted-quad, when it is one. */
- private embeddedIpv4(tail: string): string | null {
- if (isIP(tail) === 4) return tail;
-
- const hexGroups = tail.split(":").filter((group) => group !== "");
- if (hexGroups.length !== 2) return null;
-
- const high = Number.parseInt(hexGroups[0]!, 16);
- const low = Number.parseInt(hexGroups[1]!, 16);
- if (!Number.isFinite(high) || !Number.isFinite(low)) return null;
-
- return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
- }
-
private async lookupAsn(hostname: string): Promise {
try {
// Example using a public API for ASN lookup. In production, use a cached
diff --git a/packages/pulse-webhooks/test/pulse-webhooks.test.ts b/packages/pulse-webhooks/test/pulse-webhooks.test.ts
index a60be131..ee71babd 100644
--- a/packages/pulse-webhooks/test/pulse-webhooks.test.ts
+++ b/packages/pulse-webhooks/test/pulse-webhooks.test.ts
@@ -340,6 +340,50 @@ describe("pulse-webhooks WebhookDelivery", () => {
expect(failedHandler).toHaveBeenCalledTimes(1);
});
+ /**
+ * Regression: these all passed `validateUrl` and were fetched.
+ *
+ * The delivery guard was a `net.BlockList` that had drifted from
+ * `UrlValidator`'s range list, omitting `0.0.0.0/8`, the IPv6 unspecified
+ * address, CGNAT and `192.0.0.0/24`. Because each of these is an IP literal,
+ * `validateResolvedHostname` returned early and never re-checked them, so
+ * `http://0.0.0.0:8080/` reached a service bound to loopback on Linux.
+ *
+ * `.localhost` is here for the same reason: only the bare `localhost` label
+ * was matched, though RFC 6761 reserves the whole suffix for loopback.
+ */
+ it.each([
+ "http://0.0.0.0:8080/hook", // routes to loopback on Linux
+ "http://0.0.0.1/hook", // rest of 0.0.0.0/8
+ "https://[::]/hook", // IPv6 unspecified
+ "https://[::ffff:0.0.0.0]/hook", // mapped form of the same
+ "http://100.64.0.1/hook", // CGNAT
+ "http://192.0.0.1/hook", // IETF protocol assignments
+ "http://224.0.0.1/hook", // multicast
+ "http://sub.localhost/hook", // RFC 6761 reserved suffix
+ ])("blocks previously-bypassable destination %s", async (url) => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ vi.stubGlobal("fetch", fetchMock);
+
+ const watcher = new Watcher("GABC");
+ const failedHandler = vi.fn();
+ watcher.on("webhook.failed", failedHandler);
+
+ new WebhookDelivery(watcher, { url, secret: "top-secret" });
+ watcher.emit("*", deliveryEvent);
+ await flushAsyncWork();
+
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(failedHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ raw: expect.objectContaining({
+ url,
+ error: "Webhook URL points to a blocked private address",
+ }),
+ }),
+ );
+ });
+
it.each([
"https://[fc00::1]/hook",
"https://[fdff:ffff::1]/hook",
From d77bbcbddce3679bed1ab8c2481a7a4faa294fb4 Mon Sep 17 00:00:00 2001
From: Salmatcre8 <118213044+Salmatcre8@users.noreply.github.com>
Date: Mon, 10 Aug 2026 04:39:14 +0100
Subject: [PATCH 2/4] fix(web): make the marketing surface reachable and stop
overstating it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A first-time visitor landing on `/` could not get anywhere. Every nav link,
both hero buttons, the CTA, and all nine footer links were `href="#"` -
sixteen dead links on pages whose destinations (`/docs`, `/reference`,
`/demo/contracts`, `/starters`, `/changelog`) all already existed and worked.
Centralise every off-page destination in `lib/links.ts` so a renamed route
breaks in one file instead of rotting silently in four.
Also fixed, all in the same category of claiming more than is true:
- `/starters` linked three repos that do not exist - `orbital-next-starter`,
`orbital-express-starter`, `orbital-anchor-starter` all 404, so every
source link and all six deploy buttons pointed at nothing. Repointed at
the in-repo `examples/`, with the repo path now an actual link. Vercel's
clone flow takes `root-directory`, so the Next.js starter keeps a working
one-click; Railway's `templateUrl` has no subdirectory equivalent, so
those two link to their README rather than ship a button that deploys the
repo root by mistake.
- The anchor starter was described as having "a live React dashboard". It is
a CLI that writes an append-only JSON Lines audit log. Description now
matches the code.
- The footer hardcoded "● All systems operational" next to a Status link
that went nowhere. There is no uptime monitor behind it, so it read green
while the demo endpoint was returning 503. Removed rather than faked.
- The footer listed 2 of the published packages as unlinked plain text. Now
lists the 4 that are actually on npm, linked. `anchor-sdk` and
`orbital-indexer` are deliberately absent - they 404 on the registry, and
linking them would recreate the exact problem this commit fixes.
- The product is called Orbital, not "Orbit Stellar" - including in the
``, so the browser tab was wrong too.
- Twitter is dropped: no account exists, and an invented handle is worse
than an absent row.
`examples/next-starter` was untracked and unfinished - no page, no layout,
no README, and `lib/engine.ts` imported `./config.js`, which does not
resolve under `moduleResolution: "bundler"`, so it could never have built.
Completed and verified with a real `next build`.
---
apps/web/app/layout.tsx | 2 +-
apps/web/components/Footer.tsx | 95 ++++++++++---------
apps/web/components/Hero.tsx | 8 +-
apps/web/components/HowItWorks.tsx | 4 +-
apps/web/components/Nav.tsx | 11 ++-
apps/web/components/StartersPage.tsx | 94 +++++++++++-------
apps/web/components/docs/DocNavbar.tsx | 3 +-
apps/web/lib/links.ts | 79 +++++++++++++++
examples/anchor-starter/README.md | 4 +-
examples/next-starter/.env.example | 20 ++++
examples/next-starter/README.md | 51 ++++++++++
examples/next-starter/app/EventFeed.tsx | 95 +++++++++++++++++++
.../app/api/events/[address]/route.ts | 80 ++++++++++++++++
examples/next-starter/app/layout.tsx | 26 +++++
examples/next-starter/app/page.tsx | 39 ++++++++
examples/next-starter/lib/config.ts | 83 ++++++++++++++++
examples/next-starter/lib/engine.ts | 30 ++++++
examples/next-starter/next-env.d.ts | 6 ++
examples/next-starter/next.config.js | 12 +++
examples/next-starter/package.json | 35 +++++++
examples/next-starter/tsconfig.json | 19 ++++
pnpm-lock.yaml | 45 ++++++++-
22 files changed, 750 insertions(+), 91 deletions(-)
create mode 100644 apps/web/lib/links.ts
create mode 100644 examples/next-starter/.env.example
create mode 100644 examples/next-starter/README.md
create mode 100644 examples/next-starter/app/EventFeed.tsx
create mode 100644 examples/next-starter/app/api/events/[address]/route.ts
create mode 100644 examples/next-starter/app/layout.tsx
create mode 100644 examples/next-starter/app/page.tsx
create mode 100644 examples/next-starter/lib/config.ts
create mode 100644 examples/next-starter/lib/engine.ts
create mode 100644 examples/next-starter/next-env.d.ts
create mode 100644 examples/next-starter/next.config.js
create mode 100644 examples/next-starter/package.json
create mode 100644 examples/next-starter/tsconfig.json
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 4ea47859..22916c6d 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -13,7 +13,7 @@ const instrumentSerif = Instrument_Serif({
});
export const metadata: Metadata = {
- title: "Orbit Stellar - Real-time event infrastructure for Stellar developers",
+ title: "Orbital - Real-time event infrastructure for Stellar developers",
description:
"Watch any Stellar address. Register webhooks. React hooks for on-chain events. The missing event layer for Stellar developers.",
};
diff --git a/apps/web/components/Footer.tsx b/apps/web/components/Footer.tsx
index bc81852a..c2a5344c 100644
--- a/apps/web/components/Footer.tsx
+++ b/apps/web/components/Footer.tsx
@@ -2,6 +2,14 @@
import Link from 'next/link'
+import {
+ FOOTER_COMMUNITY_LINKS,
+ FOOTER_PRODUCT_LINKS,
+ NPM_PACKAGES,
+ npmUrl,
+ type NavLink,
+} from '@/lib/links'
+
const labelStyle: React.CSSProperties = {
fontFamily: 'var(--font-sans)',
fontSize: '11px',
@@ -22,6 +30,20 @@ const linkStyle: React.CSSProperties = {
transition: 'color 0.15s',
}
+function FooterLink({ label, href, external }: NavLink) {
+ return (
+ (e.currentTarget.style.color = '#fff')}
+ onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--muted2)')}
+ >
+ {label}
+
+ )
+}
+
export default function Footer() {
return (
MIT License
-
- ●
- All systems operational
-
+ {/*
+ A hardcoded "● All systems operational" badge used to sit here,
+ next to a "Status" link that went nowhere. There is no uptime
+ monitor behind it, so it was green even while the demo endpoint
+ was returning 503. Removed rather than faked - put it back only
+ alongside a real status source.
+ */}
{/* Product */}