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 ( + <Link + href={href} + {...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})} + style={linkStyle} + onMouseEnter={(e) => (e.currentTarget.style.color = '#fff')} + onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--muted2)')} + > + {label} + </Link> + ) +} + export default function Footer() { return ( <footer style={{ borderTop: '1px solid var(--border)', padding: '80px 52px 0' }}> @@ -46,7 +68,7 @@ export default function Footer() { marginBottom: '12px', }} > - Orbit Stellar + Orbital </p> <p style={{ @@ -69,72 +91,59 @@ export default function Footer() { > MIT License </p> - <p - style={{ - fontFamily: 'var(--font-sans)', - fontSize: '12px', - color: 'var(--muted2)', - display: 'flex', - alignItems: 'center', - gap: '6px', - }} - > - <span style={{ color: 'var(--accent)' }}>●</span> - All systems operational - </p> + {/* + 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. + */} </div> {/* Product */} <div> <span style={labelStyle}>Product</span> - {['Docs', 'SDKs', 'How it works', 'Changelog', 'Status'].map((item) => ( - <Link - key={item} - href="#" - style={linkStyle} - onMouseEnter={(e) => (e.currentTarget.style.color = '#fff')} - onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--muted2)')} - > - {item} - </Link> + {FOOTER_PRODUCT_LINKS.map((link) => ( + <FooterLink key={link.label} {...link} /> ))} </div> {/* Packages */} <div> <span style={labelStyle}>Packages</span> - {[ - 'npm i @orbital-stellar/pulse-webhooks', - 'npm i @orbital-stellar/pulse-notify', - ].map((cmd) => ( - <p - key={cmd} + {/* + All six published packages, each linked to its npm page. This + listed only two of them as unlinked plain text, which understated + what is actually shipped and gave the reader nowhere to go. + */} + {NPM_PACKAGES.map((pkg) => ( + <Link + key={pkg} + href={npmUrl(pkg)} + target="_blank" + rel="noopener noreferrer" style={{ fontFamily: 'var(--font-mono)', fontSize: '13px', color: 'var(--muted2)', + textDecoration: 'none', display: 'block', marginTop: '12px', + transition: 'color 0.15s', }} + onMouseEnter={(e) => (e.currentTarget.style.color = '#fff')} + onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--muted2)')} > - {cmd} - </p> + @orbital-stellar/{pkg} + </Link> ))} </div> {/* Community */} <div> <span style={labelStyle}>Community</span> - {['GitHub', 'Twitter', 'SCF Grant', 'Open an issue'].map((item) => ( - <Link - key={item} - href="#" - style={linkStyle} - onMouseEnter={(e) => (e.currentTarget.style.color = '#fff')} - onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--muted2)')} - > - {item} - </Link> + {FOOTER_COMMUNITY_LINKS.map((link) => ( + <FooterLink key={link.label} {...link} /> ))} </div> </div> @@ -157,7 +166,7 @@ export default function Footer() { color: 'var(--muted)', }} > - © 2026 Orbit Stellar + © 2026 Orbital </span> <span style={{ diff --git a/apps/web/components/Hero.tsx b/apps/web/components/Hero.tsx index d429bc73..15e3a34b 100644 --- a/apps/web/components/Hero.tsx +++ b/apps/web/components/Hero.tsx @@ -3,6 +3,8 @@ import Link from "next/link"; import { motion } from "framer-motion"; +import { GITHUB_REPO } from "@/lib/links"; + const ease = [0.22, 1, 0.36, 1] as const; const fadeUp = (delay: number) => ({ @@ -85,7 +87,7 @@ export default function Hero() { style={{ display: "flex", gap: "12px", flexWrap: "wrap", justifyContent: "center" }} > <Link - href="#" + href="/docs" style={{ background: "var(--accent)", color: "#000", @@ -100,7 +102,9 @@ export default function Hero() { Read the docs </Link> <Link - href="#" + href={GITHUB_REPO} + target="_blank" + rel="noopener noreferrer" style={{ background: "transparent", color: "#fff", diff --git a/apps/web/components/HowItWorks.tsx b/apps/web/components/HowItWorks.tsx index 33bf8843..40f94136 100644 --- a/apps/web/components/HowItWorks.tsx +++ b/apps/web/components/HowItWorks.tsx @@ -11,7 +11,7 @@ const STEPS = [ }, { num: "03", - title: "Orbit Stellar", + title: "Orbital", description: "Filters by address, normalizes the payload, and routes to subscribers.", }, { @@ -23,7 +23,7 @@ const STEPS = [ export default function HowItWorks() { return ( - <section style={{ padding: "120px 32px" }}> + <section id="how-it-works" style={{ padding: "120px 32px" }}> <div style={{ maxWidth: "var(--max-width)", margin: "0 auto" }}> <h2 style={{ diff --git a/apps/web/components/Nav.tsx b/apps/web/components/Nav.tsx index a3e8dc76..70353b25 100644 --- a/apps/web/components/Nav.tsx +++ b/apps/web/components/Nav.tsx @@ -2,6 +2,8 @@ import Link from "next/link"; +import { GET_STARTED_HREF, NAV_LINKS } from "@/lib/links"; + export default function Nav() { return ( <nav @@ -37,15 +39,16 @@ export default function Nav() { letterSpacing: "-0.01em", }} > - Orbit Stellar + Orbital </span> {/* Center links - hidden on mobile */} <div className="hidden md:flex" style={{ gap: "32px", alignItems: "center" }}> - {["Docs", "SDKs", "Changelog", "GitHub"].map((label) => ( + {NAV_LINKS.map(({ label, href, external }) => ( <Link key={label} - href="#" + href={href} + {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})} style={{ fontSize: "14px", color: "var(--muted2)", @@ -63,7 +66,7 @@ export default function Nav() { {/* CTA */} <Link - href="#" + href={GET_STARTED_HREF} style={{ background: "var(--accent)", color: "#000", diff --git a/apps/web/components/StartersPage.tsx b/apps/web/components/StartersPage.tsx index eae292b2..7f96db62 100644 --- a/apps/web/components/StartersPage.tsx +++ b/apps/web/components/StartersPage.tsx @@ -3,60 +3,79 @@ import { useState } from "react"; import Nav from "@/components/Nav"; import Footer from "@/components/Footer"; +import { GITHUB_REPO, exampleTreeUrl } from "@/lib/links"; +/** + * The starters live in this monorepo under `examples/`, not in repos of their + * own. They used to be listed as `determined-001/orbital-<name>` - three repos + * that do not exist, so every source link 404'd and every deploy button cloned + * nothing. Pointing at `examples/` keeps one repo to maintain and makes the + * links true. + */ interface Starter { - name: string; - repo: string; + /** Directory under `examples/`. Also the display name. */ + dir: string; description: string; packages: string[]; - target: "Vercel" | "Railway"; what: string; + /** + * Vercel's clone flow can target a subdirectory of a monorepo via + * `root-directory`, so the Next.js starter keeps a working one-click. + * Railway's `templateUrl` deploy link has no equivalent - it clones the repo + * root - so the two Node services link to their README instead of shipping a + * button that silently deploys the wrong thing. + */ + deploy: { kind: "vercel" } | { kind: "readme" }; } const STARTERS: Starter[] = [ { - name: "orbital-next-starter", - repo: "determined-001/orbital-next-starter", + dir: "next-starter", description: - "A production-ready Next.js app with real-time Stellar event subscriptions via React hooks. Subscribe to any address and render live payment/operation updates.", + "A Next.js app with real-time Stellar event subscriptions. Subscribe to any address over SSE and render live payment and operation updates.", packages: ["@orbital-stellar/pulse-core", "@orbital-stellar/pulse-notify"], - target: "Vercel", what: "Real-time Stellar event UI with React hooks", + deploy: { kind: "vercel" }, }, { - name: "orbital-express-starter", - repo: "determined-001/orbital-express-starter", + dir: "express-starter", description: - "An Express.js server that consumes Stellar events and delivers HMAC-signed webhooks. Includes retry logic, SSRF hardening, and edge-runtime verification.", + "An Express server that ingests Stellar events, persists a cursor, and delivers HMAC-signed webhooks, with a receiver that verifies them. The composition from docs/COOKBOOK.md, in code that compiles.", packages: ["@orbital-stellar/pulse-core", "@orbital-stellar/pulse-webhooks"], - target: "Railway", what: "Webhook delivery server with signed payloads", + deploy: { kind: "readme" }, }, { - name: "orbital-anchor-starter", - repo: "determined-001/orbital-anchor-starter", + dir: "anchor-starter", description: - "A full anchor service scaffold with event monitoring, signed webhook delivery, and a live React dashboard - everything a Stellar anchor needs out of the box.", - packages: [ - "@orbital-stellar/pulse-core", - "@orbital-stellar/pulse-webhooks", - "@orbital-stellar/pulse-notify", - ], - target: "Railway", - what: "Full anchor service with dashboard", + "Audit-grade, replay-safe event capture for Stellar anchors. Captures payment and trustline events for a set of distribution accounts into an append-only JSON Lines audit log that an auditor can replay byte-identically.", + packages: ["@orbital-stellar/pulse-core", "@orbital-stellar/pulse-webhooks"], + what: "Replayable append-only anchor audit log", + deploy: { kind: "readme" }, }, ]; -function DeployButton({ target, repo }: { target: "Vercel" | "Railway"; repo: string }) { - const encodedRepo = encodeURIComponent(`https://github.com/${repo}`); - const href = - target === "Vercel" - ? `https://vercel.com/new/clone?repository-url=${encodedRepo}&project-name=${repo.split("/")[1]}&repository-name=${repo.split("/")[1]}` - : `https://railway.app/new/template?templateUrl=${encodedRepo}`; +function deployHref(starter: Starter): string { + if (starter.deploy.kind === "vercel") { + const params = new URLSearchParams({ + "repository-url": GITHUB_REPO, + "root-directory": `examples/${starter.dir}`, + "project-name": `orbital-${starter.dir}`, + "repository-name": `orbital-${starter.dir}`, + }); + return `https://vercel.com/new/clone?${params.toString()}`; + } + return `${exampleTreeUrl(starter.dir)}#readme`; +} + +function DeployButton({ starter }: { starter: Starter }) { + const target = starter.deploy.kind === "vercel" ? "Vercel" : "Railway"; + const label = + starter.deploy.kind === "vercel" ? "Deploy to Vercel" : "Read the deploy guide"; return ( <a - href={href} + href={deployHref(starter)} target="_blank" rel="noopener noreferrer" style={{ @@ -103,7 +122,7 @@ function DeployButton({ target, repo }: { target: "Vercel" | "Railway"; repo: st /> </svg> )} - Deploy to {target} + {label} </a> ); } @@ -124,17 +143,22 @@ function StarterCard({ starter }: { starter: Starter }) { transition: "border-color 0.15s", }} > - {/* Repo name */} - <p + {/* Source path - linked, so the card is a way into the code and not just a label */} + <a + href={exampleTreeUrl(starter.dir)} + target="_blank" + rel="noopener noreferrer" style={{ fontFamily: "var(--font-mono)", fontSize: "13px", color: "var(--accent)", + textDecoration: "none", marginBottom: "16px", + display: "block", }} > - {starter.repo} - </p> + examples/{starter.dir} → + </a> {/* Description */} <p @@ -217,7 +241,7 @@ function StarterCard({ starter }: { starter: Starter }) { </div> {/* Deploy button */} - <DeployButton target={starter.target} repo={starter.repo} /> + <DeployButton starter={starter} /> </div> ); } @@ -292,7 +316,7 @@ export default function StartersPage() { }} > {STARTERS.map((starter) => ( - <StarterCard key={starter.name} starter={starter} /> + <StarterCard key={starter.dir} starter={starter} /> ))} </div> diff --git a/apps/web/components/docs/DocNavbar.tsx b/apps/web/components/docs/DocNavbar.tsx index 845155d0..3161e012 100644 --- a/apps/web/components/docs/DocNavbar.tsx +++ b/apps/web/components/docs/DocNavbar.tsx @@ -6,6 +6,7 @@ import { usePathname } from "next/navigation"; import SearchDialog from "./SearchDialog"; import AIPanel from "./AIPanel"; import { docSections, type DocSection } from "@/lib/docroutes"; +import { GITHUB_REPO } from "@/lib/links"; type Props = { sections?: DocSection[]; @@ -87,7 +88,7 @@ export default function DocNavbar({ sections = docSections }: Props) { </button> <a - href="https://github.com" + href={GITHUB_REPO} target="_blank" rel="noopener noreferrer" title="GitHub" diff --git a/apps/web/lib/links.ts b/apps/web/lib/links.ts new file mode 100644 index 00000000..7ea05c9c --- /dev/null +++ b/apps/web/lib/links.ts @@ -0,0 +1,79 @@ +/** + * Every off-page destination the marketing surface links to, in one place. + * + * These used to be inline `href="#"` placeholders scattered across `Nav`, + * `Hero`, `Footer` and `DocNavbar` - sixteen of them, none of which went + * anywhere, on pages whose destinations all already existed. Centralising them + * means a renamed route breaks in one file instead of silently rotting in four, + * and `npm`/GitHub URLs stay consistent with the published package names. + */ + +/** Canonical repository. Matches the badges and clone URLs in the root README. */ +export const GITHUB_REPO = "https://github.com/determined-001/orbital_stellar"; + +export const GITHUB_ISSUES = `${GITHUB_REPO}/issues/new/choose`; + +/** The SCF grant proposal, rendered by GitHub - it is not part of the site's docs content. */ +export const SCF_PROPOSAL = `${GITHUB_REPO}/blob/main/docs/proposal.md`; + +/** Source tree for a starter under `examples/`. */ +export function exampleTreeUrl(name: string): string { + return `${GITHUB_REPO}/tree/main/examples/${name}`; +} + +/** + * Packages that are actually published, in dependency order - verified with + * `npm view @orbital-stellar/<name> version`. + * + * `anchor-sdk` and `orbital-indexer` exist in `packages/` but are NOT on npm + * yet (both 404 on the registry), so they are deliberately absent: linking them + * would recreate the broken-link problem this list exists to fix. Add them here + * when they ship. + */ +export const NPM_PACKAGES = [ + "pulse-core", + "pulse-webhooks", + "pulse-notify", + "abi-registry", +] as const; + +export function npmUrl(pkg: string): string { + return `https://www.npmjs.com/package/@orbital-stellar/${pkg}`; +} + +export type NavLink = { + label: string; + href: string; + /** External links get `target="_blank"` and a noopener rel. */ + external?: boolean; +}; + +/** Primary navigation, shared by the landing page and `/starters`. */ +export const NAV_LINKS: NavLink[] = [ + { label: "Docs", href: "/docs" }, + { label: "SDKs", href: "/reference" }, + { label: "Demo", href: "/demo/contracts" }, + { label: "Changelog", href: "/changelog" }, + { label: "GitHub", href: GITHUB_REPO, external: true }, +]; + +export const GET_STARTED_HREF = "/docs/getting-started/quick-start"; + +export const FOOTER_PRODUCT_LINKS: NavLink[] = [ + { label: "Docs", href: "/docs" }, + { label: "SDKs", href: "/reference" }, + { label: "How it works", href: "/#how-it-works" }, + { label: "Live demo", href: "/demo/contracts" }, + { label: "Starters", href: "/starters" }, + { label: "Changelog", href: "/changelog" }, +]; + +/** + * No Twitter/X account exists for the project, so there is no entry for one. + * An unlinked or invented handle is worse than an absent row. + */ +export const FOOTER_COMMUNITY_LINKS: NavLink[] = [ + { label: "GitHub", href: GITHUB_REPO, external: true }, + { label: "SCF Grant proposal", href: SCF_PROPOSAL, external: true }, + { label: "Open an issue", href: GITHUB_ISSUES, external: true }, +]; diff --git a/examples/anchor-starter/README.md b/examples/anchor-starter/README.md index ebaa1765..e61ccab0 100644 --- a/examples/anchor-starter/README.md +++ b/examples/anchor-starter/README.md @@ -12,8 +12,8 @@ verify. ```bash # Clone and install -git clone https://github.com/determined-001/orbital-anchor-starter.git -cd orbital-anchor-starter +git clone https://github.com/determined-001/orbital_stellar.git +cd orbital_stellar/examples/anchor-starter pnpm install pnpm build diff --git a/examples/next-starter/.env.example b/examples/next-starter/.env.example new file mode 100644 index 00000000..7004ed15 --- /dev/null +++ b/examples/next-starter/.env.example @@ -0,0 +1,20 @@ +# Copy to .env.local and fill in. `lib/config.ts` validates these at startup and +# refuses to boot on a bad value rather than silently watching nothing. + +# Required. Comma-separated Stellar account IDs to offer on the home page. +STELLAR_ADDRESSES=GCIX4IU5CLPZ5FFIZQ2NP54WUTXHIBLN6URD2LCRI4G5MB2EBKNV2BKZ + +# testnet (default) or mainnet. +NEXT_PUBLIC_STELLAR_NETWORK=testnet + +# Optional. Soroban RPC endpoint; defaults per network. +# SOROBAN_RPC_URL=https://soroban-testnet.stellar.org + +# Optional. Where the file-backed cursor is written, so a restart resumes +# instead of replaying. Defaults to .orbital +# CURSOR_DIR=.orbital + +# Optional. A deployed Soroban contract to watch. Placeholder values written by +# contracts/deploy/deploy_testnet.sh are rejected, so an undeployed contract +# reads as "not configured" rather than as a page that never emits. +# DEMO_CONTRACT_ID=C... diff --git a/examples/next-starter/README.md b/examples/next-starter/README.md new file mode 100644 index 00000000..025531fa --- /dev/null +++ b/examples/next-starter/README.md @@ -0,0 +1,51 @@ +# orbital-next-starter + +Live, typed Stellar events in the browser via React hooks, with the event engine +and cursor on the server. + +The engine keeps one long-lived Horizon connection per server process and a +file-backed cursor; the browser gets a plain SSE stream. That split is the point +— your Stellar credentials and your cursor never leave the server, and the +client is just `useStellarEvent`. + +## Quickstart + +```bash +cp .env.example .env.local # set STELLAR_ADDRESSES +pnpm install +pnpm dev +``` + +Open http://localhost:3000, pick an address, and events appear as the account +transacts. On testnet you can trigger one with Friendbot or any wallet payment. + +## How it fits together + +| Piece | File | What it does | +|---|---|---| +| Config | `lib/config.ts` | Validates env at startup; refuses to boot on a bad address | +| Engine | `lib/engine.ts` | One `EventEngine` per process, cached on `globalThis`, `FileCursorStore` for resume | +| SSE route | `app/api/events/[address]/route.ts` | Bridges engine → browser; heartbeats every 10s | +| UI | `app/EventFeed.tsx` | `useStellarEvent({ serverUrl: "/api", address })` | + +`useStellarEvent` builds its URL as `${serverUrl}/events/${address}`, which is +why `serverUrl` is `"/api"` and the route lives at `app/api/events/[address]`. + +## Deploy + +One-click to Vercel from the [starters page](https://orbital-stellar.vercel.app/starters). +Because this lives inside the Orbital monorepo, the deploy link sets +**Root Directory** to `examples/next-starter` — if you deploy manually, set that +yourself or the build will run against the repo root. + +Set `STELLAR_ADDRESSES` in the project's environment variables. Note that +`CURSOR_DIR` writes to the local filesystem, which is ephemeral on serverless — +for production, swap `FileCursorStore` in `lib/engine.ts` for a durable +`CursorStore` (Postgres and Redis implementations ship in `@orbital-stellar/pulse-core`). + +## Extending it + +`lib/config.ts` already reads an optional `DEMO_CONTRACT_ID` and rejects the +placeholder values that `contracts/deploy/deploy_testnet.sh` writes before a real +deployment. Wire it to `engine.subscribeContract()` and `useContractEvent` from +`@orbital-stellar/pulse-notify` to add a typed contract-event page. diff --git a/examples/next-starter/app/EventFeed.tsx b/examples/next-starter/app/EventFeed.tsx new file mode 100644 index 00000000..583dea29 --- /dev/null +++ b/examples/next-starter/app/EventFeed.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useState } from "react"; +import { useStellarEvent } from "@orbital-stellar/pulse-notify"; + +/** + * The whole point of the starter, in one component: pick an address, and + * `useStellarEvent` keeps a live SSE connection to `/api/events/<address>` + * open and hands back the latest normalized event. + * + * `serverUrl` is `/api` because the hook builds `${serverUrl}/events/${address}` + * (see `connectionPool.ts`), which is exactly the route in `app/api/events`. + */ +export default function EventFeed({ addresses }: { addresses: string[] }) { + const [address, setAddress] = useState(addresses[0]!); + const [seen, setSeen] = useState<Array<{ key: string; line: string }>>([]); + + const { connected, error, lastEventAt } = useStellarEvent({ + serverUrl: "/api", + address, + onEvent: (event) => { + setSeen((previous) => + [ + { + key: `${event.type}-${event.timestamp}-${previous.length}`, + line: `${event.type} · ${event.timestamp}`, + }, + ...previous, + ].slice(0, 25), + ); + }, + }); + + return ( + <section> + <label style={{ display: "block", marginBottom: 8, fontSize: 14, opacity: 0.7 }}> + Watching + </label> + <select + value={address} + onChange={(e) => { + setAddress(e.target.value); + setSeen([]); + }} + style={{ + width: "100%", + padding: "10px 12px", + background: "#151517", + color: "inherit", + border: "1px solid #2a2a2e", + fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", + fontSize: 13, + }} + > + {addresses.map((candidate) => ( + <option key={candidate} value={candidate}> + {candidate} + </option> + ))} + </select> + + <p style={{ fontSize: 14, marginTop: 16 }}> + <span style={{ color: connected ? "#4ade80" : "#facc15" }}>●</span>{" "} + {connected ? "connected" : "connecting…"} + {lastEventAt ? ` · last event ${lastEventAt}` : ""} + </p> + + {error ? <p style={{ color: "#f87171", fontSize: 14 }}>{error}</p> : null} + + <h2 style={{ fontSize: 15, marginTop: 32, marginBottom: 8 }}>Events</h2> + {seen.length === 0 ? ( + <p style={{ fontSize: 14, opacity: 0.6 }}> + Nothing yet. Events appear here the moment this account transacts — send it a + payment from another wallet, or use Friendbot on testnet, to see one arrive. + </p> + ) : ( + <ul + style={{ + listStyle: "none", + padding: 0, + margin: 0, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", + fontSize: 13, + }} + > + {seen.map(({ key, line }) => ( + <li key={key} style={{ padding: "8px 0", borderBottom: "1px solid #1e1e21" }}> + {line} + </li> + ))} + </ul> + )} + </section> + ); +} diff --git a/examples/next-starter/app/api/events/[address]/route.ts b/examples/next-starter/app/api/events/[address]/route.ts new file mode 100644 index 00000000..f6576f4e --- /dev/null +++ b/examples/next-starter/app/api/events/[address]/route.ts @@ -0,0 +1,80 @@ +import { StrKey } from "@orbital-stellar/pulse-core"; +import { getEngine } from "@/lib/engine"; + +/** + * Server-Sent Events bridge: the engine runs on the server, the browser gets a + * stream. `useStellarEvent` on the client connects straight to this route. + * + * Node runtime, not edge - the engine keeps a long-lived Horizon connection + * and a file-backed cursor. + */ +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ address: string }> }, +): Promise<Response> { + const { address } = await params; + + if (!StrKey.isValidEd25519PublicKey(address)) { + return Response.json( + { error: "invalid_address", message: "Not a valid Stellar public key" }, + { status: 400 }, + ); + } + + const engine = getEngine(); + const watcher = engine.subscribe(address); + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + start(controller) { + let closed = false; + + const close = () => { + if (closed) return; + closed = true; + clearInterval(heartbeat); + watcher.removeListener("*", onEvent); + engine.unsubscribe(address); + try { + controller.close(); + } catch { + /* already closed */ + } + }; + + const onEvent = (event: unknown) => { + if (closed) return; + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + } catch { + close(); + } + }; + + // Proxies and load balancers drop idle connections; a comment line every + // ten seconds keeps the stream alive without emitting a fake event. + const heartbeat = setInterval(() => { + if (closed) return; + try { + controller.enqueue(encoder.encode(": heartbeat\n\n")); + } catch { + close(); + } + }, 10_000); + + watcher.on("*", onEvent); + request.signal.addEventListener("abort", close); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} diff --git a/examples/next-starter/app/layout.tsx b/examples/next-starter/app/layout.tsx new file mode 100644 index 00000000..f0c17875 --- /dev/null +++ b/examples/next-starter/app/layout.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Orbital Next.js starter", + description: "Live, typed Stellar events in the browser via React hooks.", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + <html lang="en"> + <body + style={{ + margin: 0, + padding: "48px 24px", + background: "#0b0b0c", + color: "#e8e8ea", + fontFamily: + "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", + lineHeight: 1.6, + }} + > + <main style={{ maxWidth: 720, margin: "0 auto" }}>{children}</main> + </body> + </html> + ); +} diff --git a/examples/next-starter/app/page.tsx b/examples/next-starter/app/page.tsx new file mode 100644 index 00000000..435f5950 --- /dev/null +++ b/examples/next-starter/app/page.tsx @@ -0,0 +1,39 @@ +import EventFeed from "./EventFeed"; +import { loadConfig, StarterConfigError } from "@/lib/config"; + +/** + * Server component. Config is validated here rather than in the browser, so a + * missing `STELLAR_ADDRESSES` surfaces as a readable page instead of an empty + * stream that never explains itself. + */ +export default function Home() { + let config; + try { + config = loadConfig(); + } catch (err) { + if (err instanceof StarterConfigError) { + return ( + <> + <h1 style={{ fontSize: 22 }}>Orbital Next.js starter</h1> + <p style={{ color: "#f87171" }}>{err.message}</p> + <p style={{ fontSize: 14, opacity: 0.7 }}> + Copy <code>.env.example</code> to <code>.env.local</code> and set the values it + lists, then restart the dev server. + </p> + </> + ); + } + throw err; + } + + return ( + <> + <h1 style={{ fontSize: 22, marginBottom: 4 }}>Orbital Next.js starter</h1> + <p style={{ fontSize: 14, opacity: 0.7, marginTop: 0 }}> + Live Stellar events on <strong>{config.network}</strong>, streamed from the server + engine over SSE. + </p> + <EventFeed addresses={config.addresses} /> + </> + ); +} diff --git a/examples/next-starter/lib/config.ts b/examples/next-starter/lib/config.ts new file mode 100644 index 00000000..3280985a --- /dev/null +++ b/examples/next-starter/lib/config.ts @@ -0,0 +1,83 @@ +import { StrKey } from "@orbital-stellar/pulse-core"; +import type { Network } from "@orbital-stellar/pulse-core"; + +/** + * Environment validation, run at module load on the server. + * + * A missing or malformed variable fails startup with a message naming the + * variable and what it expected. An app that starts and then silently watches + * nothing is worse than one that refuses to boot. + */ + +export class StarterConfigError extends Error { + constructor(variable: string, problem: string) { + super(`[next-starter] ${variable}: ${problem}`); + this.name = "StarterConfigError"; + } +} + +export type StarterConfig = { + network: Network; + /** Accounts offered on the home page. */ + addresses: string[]; + /** + * Optional Soroban contract to watch, or null when none is configured. + * Unused by the pages that ship here - see "Extending it" in the README for + * wiring it to `useContractEvent`. + */ + contractId: string | null; + /** Where the file-backed cursor lives. */ + cursorDir: string; + /** Soroban RPC endpoint for contract event subscription. */ + sorobanRpcUrl: string; +}; + +const DEFAULT_RPC: Record<Network, string> = { + testnet: "https://soroban-testnet.stellar.org", + mainnet: "https://mainnet.sorobanrpc.com", +}; + +/** + * Placeholders written by `contracts/deploy/deploy_testnet.sh` before a real + * deployment. Treating one as configured would produce a page that looks live + * and never emits. + */ +export function isPlaceholderContractId(id: string): boolean { + return id.startsWith("<") || id.includes("POPULATED BY") || id.length < 8; +} + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): StarterConfig { + const network: Network = env.NEXT_PUBLIC_STELLAR_NETWORK === "mainnet" ? "mainnet" : "testnet"; + + const raw = env.STELLAR_ADDRESSES ?? ""; + const addresses = raw + .split(",") + .map((value) => value.trim()) + .filter((value) => value !== ""); + + if (addresses.length === 0) { + throw new StarterConfigError( + "STELLAR_ADDRESSES", + "required - set it to a comma-separated list of Stellar account IDs (see .env.example)", + ); + } + + const invalid = addresses.filter((address) => !StrKey.isValidEd25519PublicKey(address)); + if (invalid.length > 0) { + throw new StarterConfigError( + "STELLAR_ADDRESSES", + `not a valid Stellar public key: ${invalid.join(", ")}`, + ); + } + + const contractEnv = env.DEMO_CONTRACT_ID?.trim(); + const contractId = contractEnv && !isPlaceholderContractId(contractEnv) ? contractEnv : null; + + return { + network, + addresses, + contractId, + cursorDir: env.CURSOR_DIR ?? ".orbital", + sorobanRpcUrl: env.SOROBAN_RPC_URL ?? DEFAULT_RPC[network], + }; +} diff --git a/examples/next-starter/lib/engine.ts b/examples/next-starter/lib/engine.ts new file mode 100644 index 00000000..deefa500 --- /dev/null +++ b/examples/next-starter/lib/engine.ts @@ -0,0 +1,30 @@ +import { EventEngine, FileCursorStore } from "@orbital-stellar/pulse-core"; +// No `.js` extension: this app resolves with `moduleResolution: "bundler"` +// (see tsconfig.json), unlike the packages, which are NodeNext and need one. +import { loadConfig } from "./config"; + +/** + * One engine per server process, cached on `globalThis` so Next's dev-mode + * module reloading does not open a second Horizon stream on every edit. + * + * The cursor is file-backed: restart the dev server and it resumes where it + * left off instead of replaying or skipping. + */ +const globalRef = globalThis as unknown as { __orbitalStarterEngine?: EventEngine }; + +export function getEngine(): EventEngine { + if (!globalRef.__orbitalStarterEngine) { + const config = loadConfig(); + + const engine = new EventEngine({ + network: config.network, + cursorStore: new FileCursorStore(config.cursorDir), + soroban: { rpcUrl: config.sorobanRpcUrl }, + }); + + engine.start(); + globalRef.__orbitalStarterEngine = engine; + } + + return globalRef.__orbitalStarterEngine; +} diff --git a/examples/next-starter/next-env.d.ts b/examples/next-starter/next-env.d.ts new file mode 100644 index 00000000..9edff1c7 --- /dev/null +++ b/examples/next-starter/next-env.d.ts @@ -0,0 +1,6 @@ +/// <reference types="next" /> +/// <reference types="next/image-types/global" /> +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/examples/next-starter/next.config.js b/examples/next-starter/next.config.js new file mode 100644 index 00000000..6acf0648 --- /dev/null +++ b/examples/next-starter/next.config.js @@ -0,0 +1,12 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + // The workspace packages ship ESM built from TypeScript; transpiling them + // here keeps `next dev` working against source in the monorepo. + transpilePackages: [ + "@orbital-stellar/pulse-core", + "@orbital-stellar/pulse-notify", + "@orbital-stellar/abi-registry", + ], +}; + +module.exports = nextConfig; diff --git a/examples/next-starter/package.json b/examples/next-starter/package.json new file mode 100644 index 00000000..dd7de9dd --- /dev/null +++ b/examples/next-starter/package.json @@ -0,0 +1,35 @@ +{ + "name": "orbital-next-starter", + "version": "0.1.0", + "private": true, + "description": "Next.js App Router starter: live, typed Stellar events in the browser via React hooks, with the engine and cursor on the server.", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "vitest run", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "@orbital-stellar/abi-registry": "workspace:*", + "@orbital-stellar/pulse-core": "workspace:*", + "@orbital-stellar/pulse-notify": "workspace:*", + "next": "^16.2.10", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/node": "^26.1.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitest/coverage-v8": "^4.1.10", + "typescript": "^6.0.3", + "vite": "^8.1.5", + "vitest": "^4.1.10" + } +} diff --git a/examples/next-starter/tsconfig.json b/examples/next-starter/tsconfig.json new file mode 100644 index 00000000..82531183 --- /dev/null +++ b/examples/next-starter/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["dom", "dom.iterable", "es2022"], + "jsx": "preserve", + "noEmit": true, + "allowJs": true, + "incremental": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "types": ["node"], + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 422b59b7..68460e5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,6 +192,49 @@ importers: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@7.3.6(@types/node@26.1.2)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.1)(yaml@2.9.0)) + examples/next-starter: + dependencies: + '@orbital-stellar/abi-registry': + specifier: workspace:* + version: link:../../packages/abi-registry + '@orbital-stellar/pulse-core': + specifier: workspace:* + version: link:../../packages/pulse-core + '@orbital-stellar/pulse-notify': + specifier: workspace:* + version: link:../../packages/pulse-notify + next: + specifier: ^16.2.10 + version: 16.2.12(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + devDependencies: + '@types/node': + specifier: ^26.1.2 + version: 26.1.2 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitest/coverage-v8': + specifier: ^4.1.10 + version: 4.1.10(vitest@4.1.10) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: 7.3.6 + version: 7.3.6(@types/node@26.1.2)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.1)(yaml@2.9.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@7.3.6(@types/node@26.1.2)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.1)(yaml@2.9.0)) + packages/abi-registry: dependencies: '@stellar/js-xdr': @@ -6332,7 +6375,7 @@ snapshots: esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 - postcss: 8.5.25 + postcss: 8.5.26 rollup: 4.62.4 tinyglobby: 0.2.17 optionalDependencies: From b01e3569dda5598598278f6c6eba05ffdbbec296 Mon Sep 17 00:00:00 2001 From: Salmatcre8 <118213044+Salmatcre8@users.noreply.github.com> Date: Mon, 10 Aug 2026 04:54:15 +0100 Subject: [PATCH 3/4] fix(web): replace the fabricated AI assistant and cap two open endpoints The docs sidebar shipped an "AI Assistant" with no model behind it. It greeted every visitor with "Ask me anything about the SDK, webhooks, or real-time events", waited a hardcoded 1200ms to look like it was thinking, then returned a canned template string with the question interpolated into it. No "coming soon" label, mounted unconditionally, so it was live on every /docs page - a developer evaluating the SDK asked a real question and got a confident non-answer. Rewritten as what it can honestly be: a docs search panel over the existing /api/docs/search endpoint, using the same debounced fetch as SearchDialog. Every line it shows is now a real section with a real link, and when nothing matches it says so instead of inventing prose. Button relabelled AI -> Search. Two unmetered amplifiers on the same surface: - /api/docs/search re-read, re-parsed and re-stripped every markdown file in the corpus on every request, uncached, with no rate limit, on a route the UI calls on a 200ms debounce. The content is build-time static, so it is now parsed once per process. Query length is capped at 128 chars. - /api/webhook-sample read an unbounded JSON body and HMAC-ed over an unbounded caller-supplied secret. Body now capped at 4KB (413 past that), secret at 256 chars, address at 56. Also: clientIp() silently collapses every caller into a single "unknown" bucket when neither x-vercel-forwarded-for nor TRUSTED_PROXY_HOPS is available. Failing closed is right, but it turns perIpStreams: 1 into a global limit - one SSE slot for the whole internet on a non-Vercel deploy. Now warns once per process naming the fix. --- apps/web/app/api/docs/search/route.ts | 82 +++++-- apps/web/app/api/webhook-sample/route.ts | 31 ++- apps/web/components/docs/AIPanel.tsx | 266 +++++++++++------------ apps/web/components/docs/DocNavbar.tsx | 4 +- apps/web/lib/demo-limits.ts | 28 +++ 5 files changed, 243 insertions(+), 168 deletions(-) diff --git a/apps/web/app/api/docs/search/route.ts b/apps/web/app/api/docs/search/route.ts index 9259cc6f..886d8d88 100644 --- a/apps/web/app/api/docs/search/route.ts +++ b/apps/web/app/api/docs/search/route.ts @@ -44,16 +44,29 @@ function getSnippet(content: string, query: string, length = 160): string { return snippet } -export async function GET(request: NextRequest) { - const query = request.nextUrl.searchParams.get('q')?.trim() ?? '' +type IndexedDoc = { + title: string + href: string + section: string + plainContent: string + lowerTitle: string + lowerContent: string +} - if (query.length < 2) { - return NextResponse.json([] as SearchResult[]) - } +/** + * The docs corpus, parsed once per process. + * + * Every request used to `existsSync` + `readFileSync` + `gray-matter` + regex + * strip every doc file, on a route with no rate limit that the search UI calls + * on a 200ms debounce. The content is build-time static - it cannot change + * while the server is running - so there is no reason to redo any of it. + */ +let corpus: IndexedDoc[] | null = null - const lowerQuery = query.toLowerCase() - const results: (SearchResult & { score: number })[] = [] +function getCorpus(): IndexedDoc[] { + if (corpus) return corpus + const docs: IndexedDoc[] = [] for (const section of docSections) { for (const item of section.items) { const slug = item.href.replace('/docs/', '').split('/') @@ -63,26 +76,59 @@ export async function GET(request: NextRequest) { const raw = fs.readFileSync(filePath, 'utf-8') const { data: fm, content } = matter(raw) const title = (fm.title as string) || item.title - - const titleMatch = title.toLowerCase().includes(lowerQuery) const plainContent = stripMarkdown(content) - const contentMatch = plainContent.toLowerCase().includes(lowerQuery) - - if (!titleMatch && !contentMatch) continue - const snippet = contentMatch ? getSnippet(plainContent, query) : plainContent.slice(0, 140).trim() + '…' - - results.push({ + docs.push({ title, href: item.href, section: section.title, - snippet, - matchInTitle: titleMatch, - score: titleMatch ? 10 : 1, + plainContent, + lowerTitle: title.toLowerCase(), + lowerContent: plainContent.toLowerCase(), }) } } + corpus = docs + return corpus +} + +/** + * Longest query we will scan the corpus for. Past this a query cannot match + * anything meaningful, and the length is attacker-controlled. + */ +const MAX_QUERY_LENGTH = 128 + +export async function GET(request: NextRequest) { + const query = request.nextUrl.searchParams.get('q')?.trim().slice(0, MAX_QUERY_LENGTH) ?? '' + + if (query.length < 2) { + return NextResponse.json([] as SearchResult[]) + } + + const lowerQuery = query.toLowerCase() + const results: (SearchResult & { score: number })[] = [] + + for (const doc of getCorpus()) { + const titleMatch = doc.lowerTitle.includes(lowerQuery) + const contentMatch = doc.lowerContent.includes(lowerQuery) + + if (!titleMatch && !contentMatch) continue + + const snippet = contentMatch + ? getSnippet(doc.plainContent, query) + : doc.plainContent.slice(0, 140).trim() + '…' + + results.push({ + title: doc.title, + href: doc.href, + section: doc.section, + snippet, + matchInTitle: titleMatch, + score: titleMatch ? 10 : 1, + }) + } + results.sort((a, b) => b.score - a.score) return NextResponse.json( diff --git a/apps/web/app/api/webhook-sample/route.ts b/apps/web/app/api/webhook-sample/route.ts index 94d4554d..17e006dc 100644 --- a/apps/web/app/api/webhook-sample/route.ts +++ b/apps/web/app/api/webhook-sample/route.ts @@ -37,18 +37,39 @@ export async function POST(req: Request) { }); } + // Nothing this endpoint accepts is large. Reading an unbounded body - and + // then HMAC-ing over an unbounded caller-supplied secret - is free CPU for + // anyone who asks, on a route that exists only to show what a payload looks + // like. Both are capped well above any legitimate input. + const MAX_BODY_BYTES = 4_096; + const MAX_SECRET_LENGTH = 256; + const MAX_ADDRESS_LENGTH = 56; + let body: Body = {}; try { if (req.headers.get("content-type")?.includes("application/json")) { - body = (await req.json()) as Body; + const raw = await req.text(); + if (Buffer.byteLength(raw, "utf8") > MAX_BODY_BYTES) { + return Response.json( + { + error: "payload_too_large", + message: `Request body is capped at ${MAX_BODY_BYTES} bytes.`, + }, + { status: 413 }, + ); + } + body = raw ? (JSON.parse(raw) as Body) : {}; } } catch { - /* allow empty body */ + /* allow empty or malformed body */ } - const secret = body.secret?.trim() || `whsec_demo_${randomBytes(16).toString("hex")}`; - const generatedSecret = !body.secret?.trim(); - const address = body.address?.trim() || "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUV"; + const callerSecret = body.secret?.trim().slice(0, MAX_SECRET_LENGTH); + const secret = callerSecret || `whsec_demo_${randomBytes(16).toString("hex")}`; + const generatedSecret = !callerSecret; + const address = + body.address?.trim().slice(0, MAX_ADDRESS_LENGTH) || + "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUV"; const event = generateSamplePayment(address); const payload = JSON.stringify(event); diff --git a/apps/web/components/docs/AIPanel.tsx b/apps/web/components/docs/AIPanel.tsx index 942665b2..ebd8d058 100644 --- a/apps/web/components/docs/AIPanel.tsx +++ b/apps/web/components/docs/AIPanel.tsx @@ -1,18 +1,28 @@ 'use client' -import { useState, useRef, useEffect } from 'react' - -type Message = { - id: string - role: 'user' | 'assistant' - content: string -} +import { useState, useRef, useEffect, useCallback } from 'react' +import Link from 'next/link' +import type { SearchResult } from '@/app/api/docs/search/route' + +/** + * Docs search sidebar. + * + * This was an "AI Assistant" that greeted visitors with "Ask me anything about + * the SDK, webhooks, or real-time events", waited a hardcoded 1200ms to + * simulate thinking, and returned a canned template string. There was no model + * behind it and no "coming soon" label, so a developer evaluating the SDK asked + * a real question and got a confident non-answer. + * + * It now answers from the docs corpus over `GET /api/docs/search`, the same + * endpoint `SearchDialog` uses. Every line it shows is a real section with a + * real link. When nothing matches it says so rather than inventing prose. + */ const SUGGESTED = [ - 'How do I register a webhook?', - 'What events does pulse-core emit?', - 'How to verify webhook signatures?', - 'Getting started with pulse-notify', + 'register a webhook', + 'verify webhook signatures', + 'real-time events', + 'cursor persistence', ] type Props = { @@ -21,11 +31,12 @@ type Props = { } export default function AIPanel({ open, onClose }: Props) { - const [messages, setMessages] = useState<Message[]>([]) - const [input, setInput] = useState('') - const [thinking, setThinking] = useState(false) - const bottomRef = useRef<HTMLDivElement>(null) + const [query, setQuery] = useState('') + const [results, setResults] = useState<SearchResult[]>([]) + const [loading, setLoading] = useState(false) + const [searched, setSearched] = useState(false) const inputRef = useRef<HTMLInputElement>(null) + const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) useEffect(() => { if (open) { @@ -33,27 +44,34 @@ export default function AIPanel({ open, onClose }: Props) { } }, [open]) + const runSearch = useCallback(async (text: string) => { + const trimmed = text.trim() + if (trimmed.length < 2) { + setResults([]) + setSearched(false) + return + } + + setLoading(true) + try { + const res = await fetch(`/api/docs/search?q=${encodeURIComponent(trimmed)}`) + setResults(res.ok ? ((await res.json()) as SearchResult[]) : []) + } catch { + setResults([]) + } finally { + setLoading(false) + setSearched(true) + } + }, []) + + // Debounced, matching SearchDialog - one request per pause, not per keystroke. useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages, thinking]) - - const sendMessage = async (text: string) => { - if (!text.trim() || thinking) return - const userMsg: Message = { id: Date.now().toString(), role: 'user', content: text } - setMessages((m) => [...m, userMsg]) - setInput('') - setThinking(true) - - // Placeholder: replace with real AI API call - await new Promise((r) => setTimeout(r, 1200)) - const reply: Message = { - id: (Date.now() + 1).toString(), - role: 'assistant', - content: `I can help with that! Check out the relevant documentation section for more details. If you have a specific question about **${text.toLowerCase()}**, feel free to ask and I'll point you to the right place.`, + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => void runSearch(query), 200) + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current) } - setMessages((m) => [...m, reply]) - setThinking(false) - } + }, [query, runSearch]) return ( <> @@ -75,120 +93,90 @@ export default function AIPanel({ open, onClose }: Props) { <div className="flex items-center justify-between px-4 py-3.5 border-b border-white/[0.08] flex-shrink-0"> <div className="flex items-center gap-2"> <div className="w-5 h-5 rounded bg-accent/20 flex items-center justify-center"> - <SparklesIcon /> + <SearchIcon /> </div> - <span className="text-sm font-semibold text-white">AI Assistant</span> - </div> - <div className="flex items-center gap-2"> - {messages.length > 0 && ( - <button - onClick={() => setMessages([])} - className="text-xs text-white/30 hover:text-white/60 transition-colors" - > - Clear - </button> - )} - <button - onClick={onClose} - className="p-1.5 rounded-md text-white/40 hover:text-white/70 hover:bg-white/[0.06] transition-colors" - > - <XIcon /> - </button> + <span className="text-sm font-semibold text-white">Search the docs</span> </div> - </div> - - {/* Messages */} - <div className="flex-1 overflow-y-auto px-4 py-4 space-y-4"> - {messages.length === 0 ? ( - <div className="space-y-4"> - <div className="flex items-start gap-2.5"> - <div className="w-6 h-6 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0 mt-0.5"> - <SparklesIcon /> - </div> - <div className="bg-white/[0.05] rounded-xl rounded-tl-sm px-3 py-2.5 text-sm text-white/80 leading-relaxed"> - Hi! I'm your Orbital Stellar docs assistant. Ask me anything about the SDK, webhooks, or real-time events. - </div> - </div> - - {/* Suggested questions */} - <div className="space-y-1.5 pt-1"> - <p className="text-xs text-white/25 mb-2">Suggested questions</p> - {SUGGESTED.map((q) => ( - <button - key={q} - onClick={() => sendMessage(q)} - className="w-full text-left text-xs px-3 py-2 rounded-lg border border-white/[0.08] text-white/50 hover:text-white/80 hover:border-white/20 hover:bg-white/[0.04] transition-all duration-100" - > - {q} - </button> - ))} - </div> - </div> - ) : ( - messages.map((msg) => ( - <div key={msg.id} className={`flex items-start gap-2.5 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}> - <div className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 text-xs font-semibold ${ - msg.role === 'user' - ? 'bg-accent/20 text-accent' - : 'bg-white/10 text-white/50' - }`}> - {msg.role === 'user' ? 'U' : <SparklesIcon />} - </div> - <div className={`max-w-[220px] rounded-xl px-3 py-2.5 text-sm leading-relaxed ${ - msg.role === 'user' - ? 'bg-accent/10 text-accent rounded-tr-sm' - : 'bg-white/[0.05] text-white/80 rounded-tl-sm' - }`}> - {msg.content} - </div> - </div> - )) - )} - - {/* Thinking indicator */} - {thinking && ( - <div className="flex items-start gap-2.5"> - <div className="w-6 h-6 rounded-full bg-white/10 flex items-center justify-center flex-shrink-0 mt-0.5"> - <SparklesIcon /> - </div> - <div className="bg-white/[0.05] rounded-xl rounded-tl-sm px-4 py-3 flex items-center gap-1.5"> - <span className="w-1.5 h-1.5 rounded-full bg-white/40 animate-bounce [animation-delay:0ms]" /> - <span className="w-1.5 h-1.5 rounded-full bg-white/40 animate-bounce [animation-delay:150ms]" /> - <span className="w-1.5 h-1.5 rounded-full bg-white/40 animate-bounce [animation-delay:300ms]" /> - </div> - </div> - )} - <div ref={bottomRef} /> + <button + onClick={onClose} + className="p-1.5 rounded-md text-white/40 hover:text-white/70 hover:bg-white/[0.06] transition-colors" + > + <XIcon /> + </button> </div> {/* Input */} - <div className="flex-shrink-0 px-3 py-3 border-t border-white/[0.08]"> + <div className="flex-shrink-0 px-3 py-3 border-b border-white/[0.08]"> <form onSubmit={(e) => { e.preventDefault() - sendMessage(input) + void runSearch(query) }} className="flex items-center gap-2 bg-white/[0.04] border border-white/[0.08] rounded-xl px-3 py-2.5 focus-within:border-white/20 transition-colors" > <input ref={inputRef} type="text" - value={input} - onChange={(e) => setInput(e.target.value)} - placeholder="Ask a question..." - disabled={thinking} - className="flex-1 bg-transparent text-sm text-white placeholder-white/25 outline-none disabled:opacity-50" + value={query} + onChange={(e) => setQuery(e.target.value)} + placeholder="Search guides and API reference..." + className="flex-1 bg-transparent text-sm text-white placeholder-white/25 outline-none" /> - <button - type="submit" - disabled={!input.trim() || thinking} - className="flex-shrink-0 w-7 h-7 rounded-lg bg-accent flex items-center justify-center text-bg transition-opacity disabled:opacity-30" - > - <SendIcon /> - </button> </form> - <p className="text-[10px] text-white/15 text-center mt-2"> - AI can make mistakes. Verify important info. + </div> + + {/* Results */} + <div className="flex-1 overflow-y-auto px-3 py-3 space-y-1.5"> + {query.trim().length < 2 ? ( + <> + <p className="text-xs text-white/25 px-1 mb-2">Try searching for</p> + {SUGGESTED.map((q) => ( + <button + key={q} + onClick={() => setQuery(q)} + className="w-full text-left text-xs px-3 py-2 rounded-lg border border-white/[0.08] text-white/50 hover:text-white/80 hover:border-white/20 hover:bg-white/[0.04] transition-all duration-100" + > + {q} + </button> + ))} + </> + ) : loading ? ( + <p className="text-xs text-white/30 px-1">Searching…</p> + ) : results.length === 0 ? ( + searched && ( + <p className="text-xs text-white/40 px-1 leading-relaxed"> + No docs match “{query.trim()}”. Try a different term, or{' '} + <Link href="/reference" className="text-accent hover:underline"> + browse the API reference + </Link> + . + </p> + ) + ) : ( + results.map((result) => ( + <Link + key={result.href} + href={result.href} + onClick={onClose} + className="block px-3 py-2.5 rounded-lg border border-white/[0.06] hover:border-white/20 hover:bg-white/[0.04] transition-all duration-100" + > + <p className="text-[10px] uppercase tracking-wider text-white/25 mb-0.5"> + {result.section} + </p> + <p className="text-sm text-white/85 font-medium">{result.title}</p> + {result.snippet && ( + <p className="text-xs text-white/40 mt-1 leading-relaxed line-clamp-3"> + {result.snippet} + </p> + )} + </Link> + )) + )} + </div> + + <div className="flex-shrink-0 px-3 py-2 border-t border-white/[0.08]"> + <p className="text-[10px] text-white/15 text-center"> + Results come from this site's documentation. </p> </div> </aside> @@ -196,10 +184,10 @@ export default function AIPanel({ open, onClose }: Props) { ) } -function SparklesIcon() { +function SearchIcon() { return ( - <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent"> - <path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" /> + <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="text-accent"> + <circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /> </svg> ) } @@ -211,11 +199,3 @@ function XIcon() { </svg> ) } - -function SendIcon() { - return ( - <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> - <path d="M22 2L11 13" /><path d="M22 2L15 22l-4-9-9-4 20-7z" /> - </svg> - ) -} diff --git a/apps/web/components/docs/DocNavbar.tsx b/apps/web/components/docs/DocNavbar.tsx index 3161e012..fab04e5e 100644 --- a/apps/web/components/docs/DocNavbar.tsx +++ b/apps/web/components/docs/DocNavbar.tsx @@ -68,7 +68,7 @@ export default function DocNavbar({ sections = docSections }: Props) { <div className="flex items-center gap-2 flex-shrink-0"> <button onClick={() => setAiOpen(!aiOpen)} - title="AI Assistant" + title="Search the docs" className={`hidden sm:flex items-center gap-1.5 h-8 px-3 rounded-lg text-xs font-semibold transition-all duration-150 ${ aiOpen ? "bg-accent text-bg shadow-[0_0_16px_rgba(232,255,71,0.25)]" @@ -76,7 +76,7 @@ export default function DocNavbar({ sections = docSections }: Props) { }`} > <SparklesIcon active={aiOpen} /> - AI + Search </button> <button diff --git a/apps/web/lib/demo-limits.ts b/apps/web/lib/demo-limits.ts index 20144949..4dc6684d 100644 --- a/apps/web/lib/demo-limits.ts +++ b/apps/web/lib/demo-limits.ts @@ -123,6 +123,33 @@ function trustedProxyHops(): number { * append semantics, so a directly-reachable deployment cannot tell an * nginx-set header from a client-set one. */ +/** + * Warn once per process when we cannot identify callers at all. + * + * Collapsing everyone into one bucket is the right direction for abuse, but it + * also means `perIpStreams: 1` becomes a global limit: on a non-Vercel deploy + * without `TRUSTED_PROXY_HOPS`, the entire internet shares one SSE slot and one + * webhook-sample call per 20s, and the demo looks broken to everybody. That is + * a deployment mistake worth surfacing rather than absorbing silently. + */ +let warnedAboutUnknownIp = false; + +function warnUnidentifiedOnce(): void { + if (warnedAboutUnknownIp) return; + warnedAboutUnknownIp = true; + console.warn( + "[demo-limits] No x-vercel-forwarded-for and TRUSTED_PROXY_HOPS is unset, so " + + "every caller shares one rate-limit bucket. On Vercel this should never happen. " + + "Anywhere else, set TRUSTED_PROXY_HOPS to the number of proxies in front of this " + + "deployment, or the per-IP demo limits act as global limits.", + ); +} + +/** Test helper - clears the once-per-process warning latch between cases. */ +export function __resetUnidentifiedWarningForTests(): void { + warnedAboutUnknownIp = false; +} + export function clientIp(req: Request): string { const vercel = req.headers.get("x-vercel-forwarded-for")?.trim(); if (vercel) return vercel; @@ -145,5 +172,6 @@ export function clientIp(req: Request): string { // anonymous traffic is the safe failure direction - handing each unidentified // request its own key would silently disable every limit that uses this, which // is precisely the bug this function used to have. + warnUnidentifiedOnce(); return "unknown"; } From ac2fb1682b21816b1f311203a571922217247e62 Mon Sep 17 00:00:00 2001 From: Salmatcre8 <118213044+Salmatcre8@users.noreply.github.com> Date: Mon, 10 Aug 2026 05:38:20 +0100 Subject: [PATCH 4/4] fix(pulse-core): stop dropping unified cursor writes, and de-flake two suites `onCursor` fired `void this.persistUnifiedCursor(...)`, discarding the promise. Nothing tracked it, so `stop()` could not wait for it and two writes were never ordered against each other. Two consequences: a shutdown could lose the newest cursor, and on a store whose writes complete out of order (Postgres, Redis, the filesystem under load) the OLDER cursor could be the one that survived. Either way the next start replays events that were already delivered - which matters for a pipeline the anchor starter sells as "audit-grade, replay-safe". Writes are now chained, and `stop()` awaits the tail. The chain is null until the first write is queued, deliberately: making `stop()` await unconditionally deferred the teardown after it by a microtask and broke three callers that read engine state immediately after stopping. That also removes the race behind EventEngine.unifiedCursorResume.test.ts, which slept 20ms after `stop()` and hoped. It failed 2 of 3 local runs; now 3 of 3. The two DeadLetterStore flakes had a different cause: unlike its sibling pulse-webhooks.test.ts, that file never mocked `dns/promises`, so every delivery attempt did a real DNS lookup of example.com - on real time, inside `vi.useFakeTimers()`, where `vi.waitFor` could not wait for it. Mocked, as the sibling already does. examples/next-starter declared a `test` script with no tests, failing `pnpm -r test`. Covered the config validation instead, which is the part worth testing. Widened `loadConfig` to take the env shape it actually reads. abi-registry's CLI-only config loader is annotated so bundlers stop trying to trace a runtime path. This does NOT clear the "whole project was traced" warning in apps/web - that needs the module moved behind its own subpath export and dropped from index.ts, which is a public-API change, so the comment says so rather than implying it is fixed. --- examples/next-starter/lib/config.ts | 9 ++- examples/next-starter/test/config.test.ts | 62 +++++++++++++++++++ packages/abi-registry/src/configLoader.ts | 16 ++++- packages/pulse-core/src/EventEngine.ts | 52 +++++++++++++++- .../EventEngine.unifiedCursorResume.test.ts | 18 +++--- .../test/DeadLetterStore.test.ts | 20 +++++- 6 files changed, 165 insertions(+), 12 deletions(-) create mode 100644 examples/next-starter/test/config.test.ts diff --git a/examples/next-starter/lib/config.ts b/examples/next-starter/lib/config.ts index 3280985a..5d64be2d 100644 --- a/examples/next-starter/lib/config.ts +++ b/examples/next-starter/lib/config.ts @@ -46,7 +46,14 @@ export function isPlaceholderContractId(id: string): boolean { return id.startsWith("<") || id.includes("POPULATED BY") || id.length < 8; } -export function loadConfig(env: NodeJS.ProcessEnv = process.env): StarterConfig { +/** + * `Record<string, string | undefined>` rather than `NodeJS.ProcessEnv`: that is + * all this function reads, and the narrower type forces every caller - tests + * included - to supply unrelated required members like `NODE_ENV`. + */ +export function loadConfig( + env: Record<string, string | undefined> = process.env, +): StarterConfig { const network: Network = env.NEXT_PUBLIC_STELLAR_NETWORK === "mainnet" ? "mainnet" : "testnet"; const raw = env.STELLAR_ADDRESSES ?? ""; diff --git a/examples/next-starter/test/config.test.ts b/examples/next-starter/test/config.test.ts new file mode 100644 index 00000000..bc414af3 --- /dev/null +++ b/examples/next-starter/test/config.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { loadConfig, StarterConfigError, isPlaceholderContractId } from "../lib/config"; + +/** + * `loadConfig` is the reason this starter refuses to boot on bad input instead + * of silently watching nothing, so the failure paths are the part worth + * testing. Everything here is pure - no network, no filesystem. + */ + +const VALID = "GCIX4IU5CLPZ5FFIZQ2NP54WUTXHIBLN6URD2LCRI4G5MB2EBKNV2BKZ"; +const VALID_2 = "GAAGDYLQAJDE4PNSC72C43CKC74ARAWI5N4OJDNEXSP25UKLS37K6FCW"; + +describe("loadConfig", () => { + it("parses a comma-separated address list and defaults to testnet", () => { + const config = loadConfig({ STELLAR_ADDRESSES: `${VALID}, ${VALID_2}` }); + + expect(config.addresses).toEqual([VALID, VALID_2]); + expect(config.network).toBe("testnet"); + expect(config.sorobanRpcUrl).toBe("https://soroban-testnet.stellar.org"); + }); + + it("selects mainnet and its RPC endpoint", () => { + const config = loadConfig({ + STELLAR_ADDRESSES: VALID, + NEXT_PUBLIC_STELLAR_NETWORK: "mainnet", + }); + + expect(config.network).toBe("mainnet"); + expect(config.sorobanRpcUrl).toBe("https://mainnet.sorobanrpc.com"); + }); + + it("refuses to start with no addresses", () => { + expect(() => loadConfig({})).toThrow(StarterConfigError); + expect(() => loadConfig({ STELLAR_ADDRESSES: " , " })).toThrow(/required/); + }); + + it("names the invalid key rather than failing later at subscribe time", () => { + expect(() => loadConfig({ STELLAR_ADDRESSES: `${VALID},NOTAKEY` })).toThrow( + /not a valid Stellar public key: NOTAKEY/, + ); + }); + + it("treats deploy-script placeholders as no contract at all", () => { + // The manifest ships with these until deploy_testnet.sh has actually run; + // accepting one would produce a page that looks live and never emits. + for (const placeholder of ["<POPULATED BY deploy_testnet.sh>", "<unset>", "C"]) { + expect(isPlaceholderContractId(placeholder)).toBe(true); + expect( + loadConfig({ STELLAR_ADDRESSES: VALID, DEMO_CONTRACT_ID: placeholder }).contractId, + ).toBeNull(); + } + }); + + it("accepts a real contract id", () => { + const contractId = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; + expect(isPlaceholderContractId(contractId)).toBe(false); + expect(loadConfig({ STELLAR_ADDRESSES: VALID, DEMO_CONTRACT_ID: contractId }).contractId).toBe( + contractId, + ); + }); +}); diff --git a/packages/abi-registry/src/configLoader.ts b/packages/abi-registry/src/configLoader.ts index 7b6da73c..141e80e9 100644 --- a/packages/abi-registry/src/configLoader.ts +++ b/packages/abi-registry/src/configLoader.ts @@ -40,7 +40,17 @@ export async function loadConfig(configPath?: string): Promise<{ // For TypeScript configs, we need to use dynamic import // Convert to file URL for proper module loading const fileUrl = pathToFileURL(resolvedPath).href; - const module = await import(fileUrl); + // The specifier is a user's absolute path, known only at runtime, so a + // bundler must not try to follow it. + // + // NOTE: this alone does not silence the "whole project was traced" + // warning in apps/web. The remaining cause is structural - this CLI-only + // module is reachable from the package's main entry, so anything that + // imports abi-registry (apps/web, via pulse-core) drags its filesystem + // operations into the serverless bundle. The real fix is to move config + // loading behind its own subpath export and drop it from index.ts, which + // is a public-API change and deliberately not made here. + const module = await import(/* turbopackIgnore: true */ /* webpackIgnore: true */ fileUrl); config = module.default || module; } else { // For JSON configs @@ -97,7 +107,9 @@ function resolveConfigPath(configPath?: string): string { ]; for (const path of possiblePaths) { - const fullPath = resolve(path); + // Resolved against the CLI's working directory, which a bundler cannot + // know - see the note on the dynamic import above. + const fullPath = resolve(/* turbopackIgnore: true */ path); if (existsSync(fullPath)) { return fullPath; } diff --git a/packages/pulse-core/src/EventEngine.ts b/packages/pulse-core/src/EventEngine.ts index 2a67cc39..27981767 100644 --- a/packages/pulse-core/src/EventEngine.ts +++ b/packages/pulse-core/src/EventEngine.ts @@ -238,6 +238,21 @@ export class EventEngine { private unifiedController?: AbortController; /** Resolves once the unified poller's loop has fully exited after `stop()` aborts it. */ private unifiedPollPromise?: Promise<{ cursor: string | undefined }>; + /** + * Tail of the chain of in-flight unified-cursor writes. + * + * `onCursor` fires from inside the poll loop and cannot await, so writes are + * chained onto this promise instead of being dropped. That buys two things + * the previous `void this.persistUnifiedCursor(...)` did not: `stop()` can + * wait for the last write to land, and two writes can never race such that + * an older cursor is the one that survives. + * + * Null until the first write is queued. That matters: `stop()` must not + * introduce an `await` on engines that never ran a unified poller, because + * the teardown after it would then be deferred by a microtask and callers + * that read engine state straight after `stop()` would see it un-torn-down. + */ + private unifiedCursorWrites: Promise<void> | null = null; private unifiedRunning = false; private unifiedLastEventAt: string | null = null; private unifiedCursorKey = ""; @@ -1131,6 +1146,20 @@ export class EventEngine { } this.unifiedPollPromise = undefined; } + // The poller has stopped, but its last cursor write may still be in + // flight. Shutting down without it means the next start replays events + // that were already delivered. Only awaited when a write was actually + // queued, so engines with no unified poller keep stopping synchronously. + const pendingCursorWrites = this.flushUnifiedCursorWrites(); + if (pendingCursorWrites) { + try { + await pendingCursorWrites; + } catch (err) { + this.log.warn("[pulse-core] a unified cursor write did not settle before stop.", { + error: err instanceof Error ? err.message : String(err), + }); + } + } this.unifiedRunning = false; } @@ -1181,7 +1210,7 @@ export class EventEngine { { signal: controller.signal, cursor, - onCursor: (nextCursor) => void this.persistUnifiedCursor(nextCursor), + onCursor: (nextCursor) => this.enqueueUnifiedCursorWrite(nextCursor), onRetry: ({ attempt, delayMs, rateLimited }) => { const type = rateLimited ? "engine.rate_limited" : "engine.reconnecting"; this.log.warn(`[pulse-core] unified transport ${type.split(".")[1]}.`, { @@ -1226,6 +1255,27 @@ export class EventEngine { } } + /** + * Queues a unified-cursor write behind any already in flight. + * + * Serialising them matters for stores whose writes can complete out of order + * (Postgres, Redis, and the filesystem under load): two concurrent `set()` + * calls could otherwise leave the OLDER cursor persisted, which on the next + * restart replays events that were already delivered. + */ + private enqueueUnifiedCursorWrite(cursor: string): void { + const previous = this.unifiedCursorWrites ?? Promise.resolve(); + this.unifiedCursorWrites = previous.then(() => this.persistUnifiedCursor(cursor)); + } + + /** + * Resolves once every queued cursor write has settled, or immediately (and + * synchronously, without yielding) when none were ever queued. + */ + private flushUnifiedCursorWrites(): Promise<void> | null { + return this.unifiedCursorWrites; + } + private async persistUnifiedCursor(cursor: string): Promise<void> { if (!this.cursorStore || this.isCursorStoreUnhealthy) return; try { diff --git a/packages/pulse-core/test/EventEngine.unifiedCursorResume.test.ts b/packages/pulse-core/test/EventEngine.unifiedCursorResume.test.ts index e1b55055..1d7e4e03 100644 --- a/packages/pulse-core/test/EventEngine.unifiedCursorResume.test.ts +++ b/packages/pulse-core/test/EventEngine.unifiedCursorResume.test.ts @@ -22,12 +22,15 @@ import type { CursorStoreLike } from "../src/CursorStore.js"; const TESTNET_PASSPHRASE = "Test SDF Network ; September 2015"; /** - * Real timers throughout this file, deliberately - not fake ones. Both the - * cursor read on startup (`resolveUnifiedCursor`) and the cursor write after - * each page (`persistUnifiedCursor`, fire-and-forget - `EventEngine` doesn't - * await it) go through the real filesystem for `FileCursorStore`, which fake - * timers don't advance. A short real delay is enough since each session here - * only needs to observe a single `getEvents` call. + * Real timers throughout this file, deliberately - not fake ones. The cursor + * read on startup (`resolveUnifiedCursor`) goes through the real filesystem for + * `FileCursorStore`, which fake timers don't advance, and a short real delay is + * enough since each session here only needs to observe a single `getEvents` + * call. + * + * The cursor WRITE is no longer a race: `EventEngine.stop()` flushes queued + * cursor writes before resolving. This file used to sleep 20ms after `stop()` + * and hope, which failed on slower machines and under parallel test load. */ function delay(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -78,8 +81,9 @@ async function runOnePollCycle( }); engine.start(); await delay(50); + // `stop()` awaits the poller and then flushes pending cursor writes, so the + // store is guaranteed settled once this resolves - no trailing sleep. await engine.stop(); - await delay(20); } describe("unified-stream cursor persistence", () => { diff --git a/packages/pulse-webhooks/test/DeadLetterStore.test.ts b/packages/pulse-webhooks/test/DeadLetterStore.test.ts index 773f10cd..c6478a5e 100644 --- a/packages/pulse-webhooks/test/DeadLetterStore.test.ts +++ b/packages/pulse-webhooks/test/DeadLetterStore.test.ts @@ -1,8 +1,21 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Watcher } from "@orbital-stellar/pulse-core"; import { MemoryDeadLetterStore, WebhookDelivery, configureDeadLetterStore } from "../src/index.js"; +/** + * `WebhookDelivery` resolves the target hostname before every attempt as part + * of its SSRF guard. Without this mock these tests performed a real DNS lookup + * of `example.com` on each try, inside `vi.useFakeTimers()` - so the lookup ran + * on real time that fake timers could not advance, and `vi.waitFor` gave up + * before the dead-letter write landed. That is why two of them failed under + * parallel test load and passed when run alone. + * + * `pulse-webhooks.test.ts` already mocks this; the two files simply diverged. + */ +const dnsLookupMock = vi.hoisted(() => vi.fn()); +vi.mock("dns/promises", () => ({ lookup: dnsLookupMock })); + const deliveryEvent = { type: "payment.received", to: "GDEST", @@ -16,6 +29,11 @@ const deliveryEvent = { const hookUrl = "https://example.com/webhooks"; describe("DeadLetterStore", () => { + beforeEach(() => { + dnsLookupMock.mockReset(); + dnsLookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); + }); + afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals();