diff --git a/CHANGELOG.md b/CHANGELOG.md index dd93540..819acce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,45 @@ Both packages are released together and share a version. +## 0.1.8 + +### Added + +- **Hosted collectors have names.** `analytics.collector` takes + `"snow-analytics"` or `"snowseo"` and fills in the ingest URL from the new + exported `COLLECTORS` map. The endpoint default only ever named SnowSEO, so + every other collector had to be spelled out as a full URL — and getting that + URL subtly wrong (a bare origin, or the wrong mount path) fails as an opaque + `401` rather than a 404. `endpoint` still wins, for self-hosted collectors. + +- **Reporting-only beacons.** `createBeacon({ analytics })` with no `dir` and + no `resolve` is now a valid, analytics-only install instead of a thrown + error. It used to need a `resolve: () => null` whose only purpose was to + satisfy that check — boilerplate that read like it did something. + +### Changed + +- **`siteUrl` is optional.** It is required only by the methods that mint + absolute URLs (`markdownUrlFor`, `sitemap`, `llmsTxt`, `robotsDirective`, and + twin responses), which now throw an error naming it. A reporting-only beacon + mints no URLs, so it does not need one. + +- **`strictNegotiation` defaults to `false` for reporting-only beacons** and + stays `true` when the beacon serves twins. A beacon that will never answer a + request has no business turning the site's own JSON endpoints into `406`s. + Passing the option explicitly still overrides both defaults. + +- **`host` is optional on the wire.** A key belongs to one site, so a collector + can resolve it; `@snowseo/beacon` omits `host` only when the caller gives + neither `siteUrl` nor `analytics.host`, and `@snowseo/beacon-server` falls + back to the key's host when the key names exactly one (and answers `400` when + it names several, where guessing would file hits under the wrong site). A + present-but-empty `host` is still rejected — that is a client that failed to + compute a value, not one deliberately leaving it out. + + Existing installs are unaffected: anything passing `siteUrl` sends `host` + exactly as before. + ## 0.1.3 ### Fixed diff --git a/README.md b/README.md index 19c69ef..53f3c97 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,28 @@ protocol: one `auto_prepend_file` line, no application code touched. It lives with the [WordPress plugin](https://github.com/Snow-SEO/snowseo-wordpress-plugin), which is what it is built into. +## Reporting only + +Measuring AI crawlers and serving them Markdown are separate jobs. If you only +want the measurement, that is the whole config: + +```ts +import { createBeacon } from "@snowseo/beacon"; + +export const beacon = createBeacon({ + analytics: { key: process.env.SNOW_API_KEY, collector: "snow-analytics" }, +}); +``` + +No `siteUrl` — a key belongs to one site, so the collector resolves it. No +`resolve`, because there are no twins to serve. And `strictNegotiation` +defaults to `false` here, so a beacon that answers nothing never turns your own +JSON endpoints into `406`s. + +`collector` takes a name from `COLLECTORS`: `"snow-analytics"` or `"snowseo"`. +Naming it beats spelling out a URL, which is easy to get subtly wrong — a bare +origin, or the wrong mount path, fails as an opaque `401` rather than a 404. + ## Running your own collector The analytics half is not tied to any hosted service. Point it wherever you diff --git a/packages/beacon-server/package.json b/packages/beacon-server/package.json index 658a486..6ce5a38 100644 --- a/packages/beacon-server/package.json +++ b/packages/beacon-server/package.json @@ -1,6 +1,6 @@ { "name": "@snowseo/beacon-server", - "version": "0.1.7", + "version": "0.1.8", "description": "Server side of the beacon protocol: AI crawler verification (provider IP ranges, datacenter detection, reverse DNS, Web Bot Auth) plus a reference ingest server.", "keywords": [ "ai", diff --git a/packages/beacon-server/src/protocol.ts b/packages/beacon-server/src/protocol.ts index a8d5030..25f0c10 100644 --- a/packages/beacon-server/src/protocol.ts +++ b/packages/beacon-server/src/protocol.ts @@ -29,7 +29,12 @@ export interface IncomingHit { } export interface IngestRequestBody { - host: string; + /** + * The site the hits belong to. Optional on the wire: a key identifies one + * site, so a server that knows its keys can resolve the host itself. Servers + * that cannot must reject a batch without one. + */ + host?: string; hits: IncomingHit[]; } @@ -70,11 +75,16 @@ export function validateIngestBody(value: unknown): string | null { return "body must be a JSON object"; } const body = value as Partial; - if (typeof body.host !== "string" || body.host.length === 0) { - return "`host` must be a non-empty string"; - } - if (body.host.length > MAX_HOST_LENGTH) { - return `\`host\` exceeds ${MAX_HOST_LENGTH} characters`; + // Optional: a key belongs to a known set of hosts, so a server can resolve + // the site itself. Present-but-empty is still an error — that is a client + // sending a value it failed to compute, not one deliberately omitting it. + if (body.host !== undefined) { + if (typeof body.host !== "string" || body.host.length === 0) { + return "`host` must be a non-empty string when present"; + } + if (body.host.length > MAX_HOST_LENGTH) { + return `\`host\` exceeds ${MAX_HOST_LENGTH} characters`; + } } if (!Array.isArray(body.hits) || body.hits.length === 0) { return "`hits` must be a non-empty array"; diff --git a/packages/beacon-server/src/server/app.ts b/packages/beacon-server/src/server/app.ts index b4bcca6..f2ea64a 100644 --- a/packages/beacon-server/src/server/app.ts +++ b/packages/beacon-server/src/server/app.ts @@ -23,6 +23,16 @@ import { type BeaconKey, KeyRegistry } from "./auth.js"; const DEFAULT_MAX_BODY_BYTES = 4 * 1024 * 1024; +/** + * The one host a key is scoped to, or undefined when it is scoped to several + * (or to none, meaning "any host" — which is equally unresolvable). + */ +function soleAllowedHost( + allowedHosts: readonly string[] | undefined, +): string | undefined { + return allowedHosts?.length === 1 ? allowedHosts[0] : undefined; +} + export interface BeaconServerOptions { store: HitStore; keys: readonly BeaconKey[]; @@ -147,7 +157,22 @@ export function createBeaconServer(options: BeaconServerOptions): Server { sendError(res, 400, "BAD_REQUEST", invalid); return; } - const { host, hits } = parsed as IngestRequestBody; + const body = parsed as IngestRequestBody; + // A batch may omit `host` and let the key say which site it is for, but + // only when the key names exactly one — with several allowed hosts there + // is nothing to fall back to, and guessing would file hits under the wrong + // site. + const host = body.host ?? soleAllowedHost(key.allowedHosts); + if (!host) { + sendError( + res, + 400, + "BAD_REQUEST", + "`host` is required: this key is valid for more than one host, so the site cannot be inferred", + ); + return; + } + const { hits } = body; if ( key.allowedHosts && !hostMatchesAllowList(normalizeHost(host), key.allowedHosts) diff --git a/packages/beacon-server/test/protocol.test.ts b/packages/beacon-server/test/protocol.test.ts index af62610..75595ae 100644 --- a/packages/beacon-server/test/protocol.test.ts +++ b/packages/beacon-server/test/protocol.test.ts @@ -25,8 +25,17 @@ describe("validateIngestBody", () => { null, ); }); - it("rejects a missing host", () => { - assert.match(validateIngestBody({ hits: [{}] }) ?? "", HOST_REQUIRED_RE); + // A key identifies one site, so an omitted host is a valid batch: the server + // resolves it. Present-but-empty stays an error — that is a client that + // failed to compute a value, not one deliberately leaving it out. + it("accepts a batch that omits host", () => { + assert.equal(validateIngestBody({ hits: [{}] }), null); + }); + it("rejects an empty host", () => { + assert.match( + validateIngestBody({ host: "", hits: [{}] }) ?? "", + HOST_REQUIRED_RE, + ); }); it("rejects an empty hits array", () => { assert.match( diff --git a/packages/beacon/package.json b/packages/beacon/package.json index b51e402..3ac0a85 100644 --- a/packages/beacon/package.json +++ b/packages/beacon/package.json @@ -1,6 +1,6 @@ { "name": "@snowseo/beacon", - "version": "0.1.7", + "version": "0.1.8", "description": "Serve Markdown twins to AI crawlers, make them discoverable, and measure which crawlers actually arrive.", "keywords": [ "ai", diff --git a/packages/beacon/src/analytics.ts b/packages/beacon/src/analytics.ts index f35ae61..df22357 100644 --- a/packages/beacon/src/analytics.ts +++ b/packages/beacon/src/analytics.ts @@ -6,7 +6,23 @@ import { export const INGEST_PATH = "/beacon/hits"; -export const DEFAULT_INGEST_ENDPOINT = "https://api.snowseo.com/v3/beacon/hits"; +/** + * Hosted collectors that speak this protocol, by name. + * + * Beacon is a protocol client, not one product's SDK, but the endpoint default + * only ever named SnowSEO — so pointing it anywhere else meant spelling out a + * full URL, and getting that URL subtly wrong (a bare origin, or the wrong + * mount path) fails as an opaque `401` rather than a 404. Naming the collectors + * removes the URL from the install entirely. + */ +export const COLLECTORS = { + "snow-analytics": "https://api.snowanalytics.app/api/beacon/hits", + snowseo: "https://api.snowseo.com/v3/beacon/hits", +} as const; + +export type CollectorName = keyof typeof COLLECTORS; + +export const DEFAULT_INGEST_ENDPOINT = COLLECTORS.snowseo; const DEFAULT_BATCH_SIZE = 25; @@ -29,6 +45,30 @@ export function normalizeIngestEndpoint(endpoint: string): string { return url.toString(); } +/** + * `endpoint` (self-hosted) beats `collector` (hosted by name) beats the + * historical default, which stays SnowSEO so existing installs are unaffected. + */ +function resolveEndpoint( + config: Pick, +): string { + if (config.endpoint) { + return normalizeIngestEndpoint(config.endpoint); + } + if (config.collector) { + const endpoint = COLLECTORS[config.collector]; + if (!endpoint) { + throw new Error( + `[beacon] unknown collector "${config.collector}". Known collectors: ${Object.keys( + COLLECTORS, + ).join(", ")}. Pass \`endpoint\` for a self-hosted one.`, + ); + } + return endpoint; + } + return DEFAULT_INGEST_ENDPOINT; +} + export interface BeaconHit { path: string; userAgent: string; @@ -48,7 +88,20 @@ export interface BeaconHit { export interface AnalyticsConfig { key: string; - host: string; + /** + * Which site the hits belong to. + * + * Optional: a key belongs to exactly one site, so a collector can resolve it + * server-side. Send it when one key covers several hosts, or when the + * collector requires it — an omitted host is simply left out of the body. + */ + host?: string; + /** + * A hosted collector by name — the endpoint is filled in from + * {@link COLLECTORS}. Ignored when `endpoint` is given. + */ + collector?: CollectorName; + /** A full ingest URL, for self-hosted collectors. Wins over `collector`. */ endpoint?: string; batchSize?: number; flushIntervalMs?: number; @@ -109,8 +162,12 @@ function looksAutomated(userAgent: string | undefined): boolean { export class BeaconAnalytics { private readonly config: Required< - Omit + Omit< + AnalyticsConfig, + "onError" | "fetch" | "onHit" | "disableCategories" | "host" | "collector" + > > & { + host?: string; onError?: (error: unknown) => void; onHit?: (hit: BeaconHit, match: AICrawlerMatch) => void; fetch: typeof fetch; @@ -122,9 +179,7 @@ export class BeaconAnalytics { this.config = { key: config.key, host: config.host, - endpoint: config.endpoint - ? normalizeIngestEndpoint(config.endpoint) - : DEFAULT_INGEST_ENDPOINT, + endpoint: resolveEndpoint(config), batchSize: config.batchSize ?? DEFAULT_BATCH_SIZE, flushIntervalMs: config.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS, timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS, @@ -201,7 +256,9 @@ export class BeaconAnalytics { "Content-Type": "application/json", "X-Beacon-Key": this.config.key, }, - body: JSON.stringify({ host: this.config.host, hits }), + body: JSON.stringify( + this.config.host ? { host: this.config.host, hits } : { hits }, + ), signal: controller.signal, keepalive: true, }); diff --git a/packages/beacon/src/beacon.ts b/packages/beacon/src/beacon.ts index 2ece506..5abeee9 100644 --- a/packages/beacon/src/beacon.ts +++ b/packages/beacon/src/beacon.ts @@ -38,12 +38,24 @@ export type MarkdownResolver = ( | null; export interface BeaconConfig { - siteUrl: string; + /** + * Your public origin, e.g. `https://example.com`. + * + * Required for anything that mints a URL — Markdown twins, `llmsTxt`, + * `sitemap`, `markdownUrlFor`. A **reporting-only** beacon (see + * {@link createBeacon}) mints no URLs, so it may be omitted there; the + * collector resolves the site from the API key instead. + */ + siteUrl?: string; dir?: string; resolve?: MarkdownResolver; - analytics?: Omit & { - host?: string; - }; + analytics?: AnalyticsConfig; + /** + * Answer `406` when a request accepts neither HTML nor Markdown. Defaults to + * `true` when this beacon serves twins and `false` when it is + * reporting-only, where refusing a request it will never answer would only + * break the site's own JSON endpoints. + */ strictNegotiation?: boolean; getIp?: (request: Request) => string | null | undefined; exists?: (path: string, request: Request) => Promise | boolean; @@ -54,30 +66,54 @@ export interface HandleContext extends WaitUntilContext { } export class Beacon { + /** Empty on a reporting-only beacon; use {@link requireSiteUrl} internally. */ readonly siteUrl: string; + /** True when this beacon only reports crawler hits and serves no twins. */ + readonly reportingOnly: boolean; private readonly config: BeaconConfig; private readonly resolve: MarkdownResolver; private readonly analytics: BeaconAnalytics | null; constructor(config: BeaconConfig) { this.config = config; - this.siteUrl = config.siteUrl.replace(TRAILING_SLASHES, ""); - warnOnLoopbackSiteUrl(this.siteUrl); + this.siteUrl = (config.siteUrl ?? "").replace(TRAILING_SLASHES, ""); + if (this.siteUrl) { + warnOnLoopbackSiteUrl(this.siteUrl); + } + this.reportingOnly = !(config.resolve || config.dir); if (config.resolve) { this.resolve = config.resolve; } else if (config.dir) { this.resolve = createDirResolver(config.dir); + } else if (config.analytics) { + // Reporting-only. Measuring crawlers and serving them Markdown are + // separate jobs, and the config for "just measure" used to be a + // `resolve: () => null` whose only purpose was to satisfy this check — + // boilerplate that read like it did something. + this.resolve = () => null; } else { throw new Error( - "[beacon] createBeacon needs either `dir` (a directory of twins from `beacon build`) or `resolve`.", + "[beacon] createBeacon needs `dir` (a directory of twins from `beacon build`), `resolve`, or `analytics` for a reporting-only install.", ); } this.analytics = config.analytics ? new BeaconAnalytics({ ...config.analytics, - host: config.analytics.host ?? new URL(this.siteUrl).hostname, + host: config.analytics.host ?? hostOf(this.siteUrl), }) : null; } + /** + * The origin, or a message naming the option that is missing. Only the + * URL-minting methods need it, so a reporting-only beacon never reaches this. + */ + private requireSiteUrl(feature: string): string { + if (!this.siteUrl) { + throw new Error( + `[beacon] ${feature} needs \`siteUrl\` — it mints absolute URLs. Pass siteUrl to createBeacon.`, + ); + } + return this.siteUrl; + } async handle( request: Request, ctx?: HandleContext, @@ -87,7 +123,8 @@ export class Beacon { const wantsMarkdownUrl = isMarkdownPath(url.pathname); const format = negotiateFormat(accept); if (!wantsMarkdownUrl && format !== "markdown") { - if (format === null && this.config.strictNegotiation !== false) { + const strict = this.config.strictNegotiation ?? !this.reportingOnly; + if (format === null && strict) { return notAcceptableResponse(); } return null; @@ -108,7 +145,8 @@ export class Beacon { }); return markdownResponse(resolved.markdown, { htmlUrl: wantsMarkdownUrl - ? (resolved.htmlUrl ?? `${this.siteUrl}${htmlPath}`) + ? (resolved.htmlUrl ?? + `${this.requireSiteUrl("Markdown twins")}${htmlPath}`) : undefined, originalTokens: resolved.originalTokens, cacheControl: resolved.cacheControl, @@ -193,7 +231,7 @@ export class Beacon { this.analytics?.flush(ctx); } markdownUrlFor(path: string): string { - return `${this.siteUrl}${toMarkdownPath(path)}`; + return `${this.requireSiteUrl("markdownUrlFor")}${toMarkdownPath(path)}`; } llmsTxt(options: LlmsTxtOptions): Response { return new Response(renderLlmsTxt(options), { @@ -212,7 +250,7 @@ export class Beacon { const value = typeof entry === "string" ? { url: entry } : { ...entry }; value.url = value.url.startsWith("http") ? value.url - : `${this.siteUrl}${toMarkdownPath(value.url)}`; + : `${this.requireSiteUrl("sitemap")}${toMarkdownPath(value.url)}`; return value; }); return new Response(buildMdSitemap(absolute), { @@ -223,10 +261,12 @@ export class Beacon { }); } robotsDirective(sitemapPath = "/sitemap-md.xml"): string { - return robotsSitemapDirective(`${this.siteUrl}${sitemapPath}`); + return robotsSitemapDirective( + `${this.requireSiteUrl("robotsDirective")}${sitemapPath}`, + ); } robotsLlmsDirective(llmsPath = "/llms.txt"): string { - return `# Markdown index: ${this.siteUrl}${llmsPath}`; + return `# Markdown index: ${this.requireSiteUrl("robotsLlmsDirective")}${llmsPath}`; } } @@ -243,6 +283,18 @@ function isFromBrowser(request: Request): boolean { const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]); +/** Hostname of an origin, or undefined when there is no origin to read. */ +function hostOf(siteUrl: string): string | undefined { + if (!siteUrl) { + return undefined; + } + try { + return new URL(siteUrl).hostname; + } catch { + return undefined; + } +} + function warnOnLoopbackSiteUrl(siteUrl: string): void { if ( typeof process === "undefined" || diff --git a/packages/beacon/src/index.ts b/packages/beacon/src/index.ts index 1bb0061..647dc05 100644 --- a/packages/beacon/src/index.ts +++ b/packages/beacon/src/index.ts @@ -7,6 +7,8 @@ export { type AnalyticsConfig, BeaconAnalytics, type BeaconHit, + COLLECTORS, + type CollectorName, DEFAULT_INGEST_ENDPOINT, INGEST_PATH, normalizeIngestEndpoint, diff --git a/packages/beacon/test/analytics.test.ts b/packages/beacon/test/analytics.test.ts index 5781cfb..7261eeb 100644 --- a/packages/beacon/test/analytics.test.ts +++ b/packages/beacon/test/analytics.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { BeaconAnalytics, + COLLECTORS, DEFAULT_INGEST_ENDPOINT, INGEST_PATH, normalizeIngestEndpoint, @@ -202,4 +203,77 @@ describe("BeaconAnalytics", () => { }; assert.equal(body.hits[0]?.signatureAgent, '"https://agent.bot.goog"'); }); + + describe("collectors", () => { + it("resolves a named collector to its full ingest URL", async () => { + const { fetchImpl, calls } = stubFetch(); + const analytics = new BeaconAnalytics({ + collector: "snow-analytics", + fetch: fetchImpl, + host: "example.com", + key: KEY, + }); + analytics.record({ path: "/", userAgent: GPTBOT }); + const pending: Promise[] = []; + analytics.flush({ waitUntil: (p) => pending.push(p) }); + await Promise.all(pending); + assert.equal(calls[0]?.url, COLLECTORS["snow-analytics"]); + }); + + // The mount path is the whole reason `collector` exists: Snow Analytics + // serves the ingest under /api, so the bare-origin default (/beacon/hits) + // resolves to a route that answers an opaque 401 rather than a 404. + it("keeps Snow Analytics' /api mount path", () => { + assert.ok( + COLLECTORS["snow-analytics"].endsWith("/api/beacon/hits"), + "snow-analytics must carry its full mount path", + ); + }); + + it("lets an explicit endpoint win over a collector", async () => { + const { fetchImpl, calls } = stubFetch(); + const analytics = new BeaconAnalytics({ + collector: "snow-analytics", + endpoint: "https://self.hosted.test/beacon/hits", + fetch: fetchImpl, + host: "example.com", + key: KEY, + }); + analytics.record({ path: "/", userAgent: GPTBOT }); + const pending: Promise[] = []; + analytics.flush({ waitUntil: (p) => pending.push(p) }); + await Promise.all(pending); + assert.equal(calls[0]?.url, "https://self.hosted.test/beacon/hits"); + }); + + it("still defaults to SnowSEO when neither is given", () => { + assert.equal(DEFAULT_INGEST_ENDPOINT, COLLECTORS.snowseo); + }); + + it("names the known collectors when one is misspelled", () => { + assert.throws( + () => + new BeaconAnalytics({ + // biome-ignore lint/suspicious/noExplicitAny: deliberately invalid + collector: "snowanalytics" as any, + key: KEY, + }), + /unknown collector "snowanalytics".*snow-analytics/s, + ); + }); + }); + + // A key belongs to one site, so the collector can resolve it. Sending an + // empty `host` instead of omitting it would fail the protocol validator. + it("omits host from the body when none is configured", async () => { + const { fetchImpl, calls } = stubFetch(); + const analytics = new BeaconAnalytics({ fetch: fetchImpl, key: KEY }); + analytics.record({ path: "/", userAgent: GPTBOT }); + const pending: Promise[] = []; + analytics.flush({ waitUntil: (p) => pending.push(p) }); + await Promise.all(pending); + const body = calls[0]?.body as Record; + assert.ok(!("host" in body), "host must be absent, not empty"); + assert.ok(Array.isArray(body.hits)); + }); }); diff --git a/packages/beacon/test/core.test.ts b/packages/beacon/test/core.test.ts index e396bf8..febd321 100644 --- a/packages/beacon/test/core.test.ts +++ b/packages/beacon/test/core.test.ts @@ -1012,3 +1012,75 @@ describe("fetch resolver frontmatter", () => { ); }); }); + +/** + * The install a pure analytics user writes. Before this, "just measure + * crawlers" still required `siteUrl`, a `resolve: () => null` that existed only + * to satisfy a constructor check, and `strictNegotiation: false` to stop the + * beacon answering 406 for the site's own JSON endpoints — three options to + * switch off features the reader never asked for. + */ +describe("reporting-only beacons", () => { + it("needs nothing but a key", () => { + const beacon = createBeacon({ + analytics: { collector: "snow-analytics", key: "snw_live_abc" }, + }); + assert.equal(beacon.reportingOnly, true); + }); + + it("never answers 406, because it serves no twins", async () => { + const beacon = createBeacon({ + analytics: { collector: "snow-analytics", key: "snw_live_abc" }, + }); + const response = await beacon.handle( + new Request("https://example.com/api/data", { + headers: { accept: "application/json" }, + }), + ); + assert.equal(response, null, "a JSON request must pass straight through"); + }); + + it("still answers 406 when it does serve twins", async () => { + const beacon = createBeacon({ + resolve: () => "# hi", + siteUrl: "https://example.com", + }); + const response = await beacon.handle( + new Request("https://example.com/api/data", { + headers: { accept: "application/json" }, + }), + ); + assert.equal(response?.status, 406); + }); + + it("lets strictNegotiation be forced back on", async () => { + const beacon = createBeacon({ + analytics: { collector: "snow-analytics", key: "snw_live_abc" }, + strictNegotiation: true, + }); + const response = await beacon.handle( + new Request("https://example.com/api/data", { + headers: { accept: "application/json" }, + }), + ); + assert.equal(response?.status, 406); + }); + + // Failing here is far better than minting "undefined/page.md" into a + // sitemap, which is what an optional siteUrl invites. + it("names siteUrl when a URL-minting method needs it", () => { + const beacon = createBeacon({ + analytics: { collector: "snow-analytics", key: "snw_live_abc" }, + }); + assert.throws(() => beacon.markdownUrlFor("/pricing"), /siteUrl/); + }); + + it("still derives the analytics host from siteUrl when given", () => { + const beacon = createBeacon({ + analytics: { collector: "snow-analytics", key: "snw_live_abc" }, + siteUrl: "https://example.com/", + }); + assert.equal(beacon.siteUrl, "https://example.com"); + assert.equal(beacon.reportingOnly, true); + }); +}); diff --git a/packages/beacon/test/generate.test.ts b/packages/beacon/test/generate.test.ts index 276cbcc..3ced7cd 100644 --- a/packages/beacon/test/generate.test.ts +++ b/packages/beacon/test/generate.test.ts @@ -19,7 +19,7 @@ const LLMS_PRIVACY_LINK = /https:\/\/example\.com\/privacy\.md/; const SITEMAP_INDEX_LOC = /https:\/\/example\.com\/index\.md<\/loc>/; -const NEEDS_DIR = /needs either `dir`/; +const NEEDS_DIR = /needs `dir`/; const PRIVACY_TWIN = /We keep very little/; @@ -178,7 +178,7 @@ describe("createDirResolver", () => { assert.equal(await resolve("/../../etc/passwd", request), null); assert.equal(await resolve("/..%2f..%2fetc/passwd", request), null); }); - it("requires either dir or resolve", () => { + it("requires dir, resolve, or analytics", () => { assert.throws(() => createBeacon({ siteUrl: SITE }), NEEDS_DIR); }); });