diff --git a/README.md b/README.md index 7250e17..ab69074 100644 --- a/README.md +++ b/README.md @@ -81,10 +81,11 @@ Environment: `OTEL_EXPORTER_OTLP_ENDPOINT` (on/off), `OTEL_SDK_DISABLED=true` (k | Import | What | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `@codelittinc/otel-node` | `startTelemetry`, `shutdownTelemetry`, `isTelemetryStarted`, `setHttpRoute`, `getMeter`, `log`, `configureLogger`, `flushLog`, `captureConsole`, `installFatalHandlers`, `boundedLabel`, `currentTraceparent`, `withLinkedSpan`, `withRootSpan`, and the `TelemetryOptions`, `TelemetryConfig`, `TelemetryEnv`, `MetricViewOptions`, `ErrorHookContext`, `Meter` and `BatchObservableResult` types | +| `@codelittinc/otel-node` | `startTelemetry`, `shutdownTelemetry`, `isTelemetryStarted`, `setHttpRoute`, `getMeter`, `log`, `configureLogger`, `flushLog`, `captureConsole`, `installFatalHandlers`, `boundedLabel`, `currentTraceparent`, `withLinkedSpan`, `withRootSpan`, `serializeError` (the scrubbed error shape `log` exports under `err`), `traceLogFields` (the active span's `trace_id` and `span_id`, or `{}`), and the `TelemetryOptions`, `TelemetryConfig`, `TelemetryEnv`, `MetricViewOptions`, `ErrorHookContext`, `Meter` and `BatchObservableResult` types | | `@codelittinc/otel-node/bullmq` | `bullmqTelemetry()`: the adapter for a BullMQ Queue or Worker `telemetry` option | | `@codelittinc/otel-node/register` | `node --import` entry, no options | | `@codelittinc/otel-node/testing` | `createTestLogger()`: the real logger, capturing parsed records in memory | +| `@codelittinc/otel-node/scrub` | `scrubMessage`, `scrubErrorText`, `stripQuery`, `templateUrlPath` and the read-only `TOKEN_PATH_TEMPLATES`: the server's own scrub functions, browser-safe, for client-side redaction. Imports nothing from Node or OpenTelemetry | | `@codelittinc/otel-node/url-credentials` | URL userinfo and query scrub. Imports nothing from Node or OpenTelemetry, so a browser bundle can use it | `bullmq` (`^6.3.8`) is an optional peer dependency, used only by the `./bullmq` entry, so an app diff --git a/dist/index.d.ts b/dist/index.d.ts index 94db880..160acf9 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -8,3 +8,5 @@ export { captureConsole } from "./console-capture.js"; export { installFatalHandlers } from "./fatal.js"; export { boundedLabel } from "./bounded.js"; export { currentTraceparent, withLinkedSpan, withRootSpan } from "./trace-context.js"; +export { serializeError } from "./log-serializer.js"; +export { traceLogFields } from "./log-fields.js"; diff --git a/dist/index.js b/dist/index.js index 9e051de..eb14c8e 100644 --- a/dist/index.js +++ b/dist/index.js @@ -6,3 +6,5 @@ export { captureConsole } from "./console-capture.js"; export { installFatalHandlers } from "./fatal.js"; export { boundedLabel } from "./bounded.js"; export { currentTraceparent, withLinkedSpan, withRootSpan } from "./trace-context.js"; +export { serializeError } from "./log-serializer.js"; +export { traceLogFields } from "./log-fields.js"; diff --git a/dist/log-fields.d.ts b/dist/log-fields.d.ts index ecbe90f..fefb5ca 100644 --- a/dist/log-fields.d.ts +++ b/dist/log-fields.d.ts @@ -1,4 +1,4 @@ -/** Trace correlation fields for the active span; empty outside a valid span. */ +/** Returns the active span's `trace_id` and `span_id`, or `{}` when there is no active span. */ export declare function traceLogFields(): { trace_id?: string; span_id?: string; diff --git a/dist/log-fields.js b/dist/log-fields.js index de421d2..49e12bf 100644 --- a/dist/log-fields.js +++ b/dist/log-fields.js @@ -1,5 +1,5 @@ import { isSpanContextValid, trace } from "@opentelemetry/api"; -/** Trace correlation fields for the active span; empty outside a valid span. */ +/** Returns the active span's `trace_id` and `span_id`, or `{}` when there is no active span. */ export function traceLogFields() { const spanContext = trace.getActiveSpan()?.spanContext(); if (!spanContext || !isSpanContextValid(spanContext)) diff --git a/dist/log-serializer.d.ts b/dist/log-serializer.d.ts index 15a1183..a423f67 100644 --- a/dist/log-serializer.d.ts +++ b/dist/log-serializer.d.ts @@ -1,2 +1,2 @@ -/** The `err` serializer: an allowlisted, scrubbed copy of the error chain. Never throws. */ +/** Serializes one error value to the scrubbed shape `log` exports under `err`: message and stack scrubbed, only allowlisted fields kept, never throws. */ export declare function serializeError(value: unknown): unknown; diff --git a/dist/log-serializer.js b/dist/log-serializer.js index 24b0c73..02e4c3e 100644 --- a/dist/log-serializer.js +++ b/dist/log-serializer.js @@ -1,6 +1,6 @@ import { serializeErrorBase } from "./log-errors.js"; import { scrubErrorText } from "./log-scrub.js"; -/** The `err` serializer: an allowlisted, scrubbed copy of the error chain. Never throws. */ +/** Serializes one error value to the scrubbed shape `log` exports under `err`: message and stack scrubbed, only allowlisted fields kept, never throws. */ export function serializeError(value) { try { return serializeErrorBase(value); diff --git a/dist/redact.d.ts b/dist/redact.d.ts index 9df37af..33ec690 100644 --- a/dist/redact.d.ts +++ b/dist/redact.d.ts @@ -1,6 +1,6 @@ import type { Attributes } from "@opentelemetry/api"; -/** `schemeless` reads a value with no scheme and no leading `/` as starting at its authority. */ -export declare function stripQuery(url: string, schemeless?: boolean): string; +import { stripQuery } from "./strip-query.js"; +export { stripQuery }; /** Pinned by scrub-contract.test.ts: the agent's layer-2 span scrub repeats these. */ export declare const REDACT_PATTERNS: Readonly>; export declare const REDACT_KEY_SETS: Readonly>; diff --git a/dist/redact.js b/dist/redact.js index e22efe5..1ab9c3c 100644 --- a/dist/redact.js +++ b/dist/redact.js @@ -1,8 +1,10 @@ import { isAppAttributeKey } from "./app-keys.js"; import { scrubMessage } from "./log-scrub.js"; import { describeKeySets, describePatterns } from "./scrub-descriptor.js"; -import { stripUserinfo, URL_AUTHORITY } from "./url-credentials.js"; +import { stripQuery, URL_QUERY_START } from "./strip-query.js"; +import { URL_AUTHORITY } from "./url-credentials.js"; import { routeTemplateOf, templateUrlPath, TOKEN_PATH_TEMPLATES } from "./url-path.js"; +export { stripQuery }; const URL_KEYS = new Set(["url.full", "url.original", "http.url", "http.target", "url.path"]); const DROPPED_KEYS = new Set([ "url.query", @@ -28,15 +30,7 @@ const STATEMENT_KEYS = ["db.statement", "db.query.text"]; // A span with either key set to this is a Redis call, whose statement quotes the values it sent. const REDIS_SYSTEM_KEYS = ["db.system.name", "db.system"]; const REDIS_SYSTEM = "redis"; -const URL_QUERY_START = /[?#;]/; const FRAME_LINE = /^\s+at /; -/** `schemeless` reads a value with no scheme and no leading `/` as starting at its authority. */ -export function stripQuery(url, schemeless = false) { - // Userinfo first: a password can hold a `?` or `#`. - const withoutUserinfo = stripUserinfo(url, schemeless); - const cut = withoutUserinfo.search(URL_QUERY_START); - return cut === -1 ? withoutUserinfo : withoutUserinfo.slice(0, cut); -} // `METHOD /path`, `METHOD scheme://host/path` or `METHOD user@host/path`. const NAMED_REQUEST = /^([A-Z]+) ((?:\/|[a-z][a-z0-9+.-]*:\/\/|[^\s/?#@]*@)\S*)$/i; const NAME_KEYS = new Set(["next.span_name"]); diff --git a/dist/scrub-entry.d.ts b/dist/scrub-entry.d.ts new file mode 100644 index 0000000..a07e145 --- /dev/null +++ b/dist/scrub-entry.d.ts @@ -0,0 +1,3 @@ +export { scrubMessage, scrubErrorText } from "./log-scrub.js"; +export { stripQuery } from "./strip-query.js"; +export { TOKEN_PATH_TEMPLATES, templateUrlPath } from "./url-path.js"; diff --git a/dist/scrub-entry.js b/dist/scrub-entry.js new file mode 100644 index 0000000..f0b874d --- /dev/null +++ b/dist/scrub-entry.js @@ -0,0 +1,5 @@ +// Browser-safe scrub functions: the same objects the server scrub uses, so a client can never +// drift from it. Imports nothing from Node or OpenTelemetry. +export { scrubMessage, scrubErrorText } from "./log-scrub.js"; +export { stripQuery } from "./strip-query.js"; +export { TOKEN_PATH_TEMPLATES, templateUrlPath } from "./url-path.js"; diff --git a/dist/strip-query.d.ts b/dist/strip-query.d.ts new file mode 100644 index 0000000..050dbff --- /dev/null +++ b/dist/strip-query.d.ts @@ -0,0 +1,3 @@ +export declare const URL_QUERY_START: RegExp; +/** `schemeless` reads a value with no scheme and no leading `/` as starting at its authority. */ +export declare function stripQuery(url: string, schemeless?: boolean): string; diff --git a/dist/strip-query.js b/dist/strip-query.js new file mode 100644 index 0000000..5a0479f --- /dev/null +++ b/dist/strip-query.js @@ -0,0 +1,9 @@ +import { stripUserinfo } from "./url-credentials.js"; +export const URL_QUERY_START = /[?#;]/; +/** `schemeless` reads a value with no scheme and no leading `/` as starting at its authority. */ +export function stripQuery(url, schemeless = false) { + // Userinfo first: a password can hold a `?` or `#`. + const withoutUserinfo = stripUserinfo(url, schemeless); + const cut = withoutUserinfo.search(URL_QUERY_START); + return cut === -1 ? withoutUserinfo : withoutUserinfo.slice(0, cut); +} diff --git a/dist/url-path.js b/dist/url-path.js index c1630ce..305f3d0 100644 --- a/dist/url-path.js +++ b/dist/url-path.js @@ -5,10 +5,10 @@ import { describePatterns } from "./scrub-descriptor.js"; * the web page (`/apply/[token]`) and the api routes it calls. Any letter case and any run of `/` * match, as a server routes them alike. */ -export const TOKEN_PATH_TEMPLATES = [ - [/^\/+v1\/+applications\/+by-token\/+[^/?#]+/i, "/v1/applications/by-token/:token"], - [/^\/+apply\/+[^/?#]+/i, "/apply/[token]"], -]; +export const TOKEN_PATH_TEMPLATES = Object.freeze([ + Object.freeze([/^\/+v1\/+applications\/+by-token\/+[^/?#]+/i, "/v1/applications/by-token/:token"]), + Object.freeze([/^\/+apply\/+[^/?#]+/i, "/apply/[token]"]), +]); // A `scheme:` chain and `//`, or a bare `//`, then the host. const ORIGIN = /^((?:[a-z][a-z0-9+.-]*:)*\/\/[^/?#]*)(.*)$/is; // An `http.route` is a template only when it is a path. diff --git a/package.json b/package.json index ff9a427..fd23b02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codelittinc/otel-node", - "version": "0.1.0", + "version": "0.1.1", "description": "In-process OpenTelemetry for Node services: SDK boot, layer-1 PII scrub, OTLP logs.", "license": "UNLICENSED", "repository": "github:codelittinc/otel-node", @@ -29,6 +29,10 @@ "types": "./dist/register.d.ts", "default": "./dist/register.js" }, + "./scrub": { + "types": "./dist/scrub-entry.d.ts", + "default": "./dist/scrub-entry.js" + }, "./url-credentials": { "types": "./dist/url-credentials.d.ts", "default": "./dist/url-credentials.js" diff --git a/src/__tests__/error-helpers-entry.test.ts b/src/__tests__/error-helpers-entry.test.ts new file mode 100644 index 0000000..90beb39 --- /dev/null +++ b/src/__tests__/error-helpers-entry.test.ts @@ -0,0 +1,58 @@ +import { afterAll, describe, expect, it } from "vitest"; +import { context, trace } from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; +import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base"; +import { serializeError, traceLogFields } from "../index.js"; +import { createTestLogger } from "../testing.js"; + +const provider = new BasicTracerProvider(); +context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); +trace.setGlobalTracerProvider(provider); +const tracer = provider.getTracer("error-helpers-test"); + +afterAll(async () => { + await provider.shutdown(); + trace.disable(); + context.disable(); +}); + +describe("serializeError from the main entry", () => { + it("matches the err shape log exports, scrubbed and redacted", () => { + const error = Object.assign(new Error("bad ssn 123-45-6789"), { + password: "hunter2", + }); + const { logger, records } = createTestLogger(); + logger.error({ err: error }, "failed"); + + const shaped = serializeError(error); + expect(shaped).toEqual(records[0]!.err); + const text = JSON.stringify(shaped); + expect(text).not.toContain("123-45-6789"); + expect(text).not.toContain("hunter2"); + }); +}); + +describe("traceLogFields from the main entry", () => { + it("returns the active span ids", () => { + tracer.startActiveSpan("work", (span) => { + const { traceId, spanId } = span.spanContext(); + expect(traceLogFields()).toEqual({ trace_id: traceId, span_id: spanId }); + span.end(); + }); + }); + + it("returns {} when the active span has an invalid context", () => { + const span = trace.wrapSpanContext({ + traceId: "0".repeat(32), + spanId: "0".repeat(16), + traceFlags: 0, + }); + context.with(trace.setSpan(context.active(), span), () => { + expect(traceLogFields()).toEqual({}); + }); + }); + + it("returns {} with no active span", () => { + expect(traceLogFields()).toEqual({}); + }); +}); diff --git a/src/__tests__/types-without-bullmq.test.ts b/src/__tests__/types-without-bullmq.test.ts index cfc4b17..651785a 100644 --- a/src/__tests__/types-without-bullmq.test.ts +++ b/src/__tests__/types-without-bullmq.test.ts @@ -33,16 +33,19 @@ function typeErrors(source: string): string[] { // WHY: bullmq is an optional peer. An app without it must typecheck the main entry even with // skipLibCheck off; only the `./bullmq` entry may name its types. +// Each case builds a full compiler program, which a cold CI runner takes well over 5 s to do. +const CI_TIMEOUT = 60_000; + describe("the built types without bullmq installed", () => { it("typecheck the main entry", () => { const errors = typeErrors( 'import * as otel from "./dist/index.js";\nexport const start = otel.startTelemetry;\n', ); expect(errors).toEqual([]); - }); + }, CI_TIMEOUT); it("fail the ./bullmq entry, so bullmq is really hidden", () => { const errors = typeErrors('export * from "./dist/bullmq.js";\n'); expect(errors.join("\n")).toMatch(/bullmq/); - }); + }, CI_TIMEOUT); }); diff --git a/src/__tests__/url-credentials-entry.test.ts b/src/__tests__/url-credentials-entry.test.ts index fd52033..032a25c 100644 --- a/src/__tests__/url-credentials-entry.test.ts +++ b/src/__tests__/url-credentials-entry.test.ts @@ -1,9 +1,16 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; +import * as scrubEntry from "../scrub-entry.js"; +import { scrubErrorText, scrubMessage } from "../log-scrub.js"; +import { stripQuery } from "../strip-query.js"; +import { TOKEN_PATH_TEMPLATES, templateUrlPath } from "../url-path.js"; +import { stripQuery as redactStripQuery } from "../redact.js"; const SRC = new URL("../", import.meta.url); -const IMPORT = /\b(?:import|export)\s[^'"]*?from\s*["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']/g; -const NODE_GLOBALS = /\b(?:process|Buffer|require|__dirname|__filename|global)\b/; +const IMPORT = + /\b(?:import|export)\s[^'"]*?from\s*["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']|\bimport\s*["']([^"']+)["']/g; +const NODE_GLOBALS = + /\b(?:process|Buffer|require|__dirname|__filename|global)\b/; /** Every module `entry` reaches through static or dynamic imports, with its source. */ function importGraph(entry: string): Map { @@ -15,7 +22,7 @@ function importGraph(entry: string): Map { const source = readFileSync(new URL(file, SRC), "utf8"); seen.set(file, source); for (const match of source.matchAll(IMPORT)) { - const specifier = (match[1] ?? match[2])!; + const specifier = (match[1] ?? match[2] ?? match[3])!; if (!specifier.startsWith("./")) { seen.set(`external:${specifier}`, ""); continue; @@ -32,12 +39,16 @@ describe("the url-credentials entry", () => { const graph = importGraph("url-credentials.ts"); it("imports no package and no Node built-in, only its own files", () => { - expect([...graph.keys()].filter((key) => key.startsWith("external:"))).toEqual([]); + expect( + [...graph.keys()].filter((key) => key.startsWith("external:")), + ).toEqual([]); }); it("uses no Node global in any file it reaches", () => { for (const [file, source] of graph) { - const code = source.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""); + const code = source + .replace(/\/\/.*$/gm, "") + .replace(/\/\*[\s\S]*?\*\//g, ""); expect(NODE_GLOBALS.test(code), file).toBe(false); } }); @@ -46,6 +57,58 @@ describe("the url-credentials entry", () => { const manifest = JSON.parse( readFileSync(new URL("../../package.json", import.meta.url), "utf8"), ) as { exports: Record }; - expect(manifest.exports["./url-credentials"]?.default).toBe("./dist/url-credentials.js"); + expect(manifest.exports["./url-credentials"]?.default).toBe( + "./dist/url-credentials.js", + ); + }); +}); + +// WHY: the browser bundles the `./scrub` entry for client-side redaction, so it carries the same +// import limits as `./url-credentials`. +describe("the scrub entry", () => { + const graph = importGraph("scrub-entry.ts"); + + it("imports no package and no Node built-in, only its own files", () => { + expect( + [...graph.keys()].filter((key) => key.startsWith("external:")), + ).toEqual([]); + }); + + it("uses no Node global in any file it reaches", () => { + for (const [file, source] of graph) { + const code = source + .replace(/\/\/.*$/gm, "") + .replace(/\/\*[\s\S]*?\*\//g, ""); + expect(NODE_GLOBALS.test(code), file).toBe(false); + } + }); + + it("exports the same function objects the server scrub uses", () => { + expect(scrubEntry.scrubMessage).toBe(scrubMessage); + expect(scrubEntry.scrubErrorText).toBe(scrubErrorText); + expect(scrubEntry.stripQuery).toBe(stripQuery); + expect(scrubEntry.TOKEN_PATH_TEMPLATES).toBe(TOKEN_PATH_TEMPLATES); + expect(scrubEntry.templateUrlPath).toBe(templateUrlPath); + expect(redactStripQuery).toBe(stripQuery); + }); + + it("exports a token path list no consumer can change", () => { + const list = scrubEntry.TOKEN_PATH_TEMPLATES as unknown as unknown[][]; + const before = JSON.stringify(list.map(([re, to]) => [String(re), to])); + expect(() => list.push([/x/, "y"])).toThrow(TypeError); + expect(() => list.splice(0, 1)).toThrow(TypeError); + expect(() => { + list[0]![1] = "weakened"; + }).toThrow(TypeError); + expect(JSON.stringify(list.map(([re, to]) => [String(re), to]))).toBe( + before, + ); + }); + + it("is exported as its own subpath", () => { + const manifest = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf8"), + ) as { exports: Record }; + expect(manifest.exports["./scrub"]?.default).toBe("./dist/scrub-entry.js"); }); }); diff --git a/src/index.ts b/src/index.ts index 94db880..160acf9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,3 +8,5 @@ export { captureConsole } from "./console-capture.js"; export { installFatalHandlers } from "./fatal.js"; export { boundedLabel } from "./bounded.js"; export { currentTraceparent, withLinkedSpan, withRootSpan } from "./trace-context.js"; +export { serializeError } from "./log-serializer.js"; +export { traceLogFields } from "./log-fields.js"; diff --git a/src/log-fields.ts b/src/log-fields.ts index 9b422e0..69e940e 100644 --- a/src/log-fields.ts +++ b/src/log-fields.ts @@ -1,6 +1,6 @@ import { isSpanContextValid, trace } from "@opentelemetry/api"; -/** Trace correlation fields for the active span; empty outside a valid span. */ +/** Returns the active span's `trace_id` and `span_id`, or `{}` when there is no active span. */ export function traceLogFields(): { trace_id?: string; span_id?: string } { const spanContext = trace.getActiveSpan()?.spanContext(); if (!spanContext || !isSpanContextValid(spanContext)) return {}; diff --git a/src/log-serializer.ts b/src/log-serializer.ts index 2847091..57961cc 100644 --- a/src/log-serializer.ts +++ b/src/log-serializer.ts @@ -1,7 +1,7 @@ import { serializeErrorBase } from "./log-errors.js"; import { scrubErrorText } from "./log-scrub.js"; -/** The `err` serializer: an allowlisted, scrubbed copy of the error chain. Never throws. */ +/** Serializes one error value to the scrubbed shape `log` exports under `err`: message and stack scrubbed, only allowlisted fields kept, never throws. */ export function serializeError(value: unknown): unknown { try { return serializeErrorBase(value); diff --git a/src/redact.ts b/src/redact.ts index 7c6ac9b..b3a970f 100644 --- a/src/redact.ts +++ b/src/redact.ts @@ -2,9 +2,12 @@ import type { Attributes } from "@opentelemetry/api"; import { isAppAttributeKey } from "./app-keys.js"; import { scrubMessage } from "./log-scrub.js"; import { describeKeySets, describePatterns } from "./scrub-descriptor.js"; -import { stripUserinfo, URL_AUTHORITY } from "./url-credentials.js"; +import { stripQuery, URL_QUERY_START } from "./strip-query.js"; +import { URL_AUTHORITY } from "./url-credentials.js"; import { routeTemplateOf, templateUrlPath, TOKEN_PATH_TEMPLATES } from "./url-path.js"; +export { stripQuery }; + const URL_KEYS = new Set(["url.full", "url.original", "http.url", "http.target", "url.path"]); const DROPPED_KEYS = new Set([ "url.query", @@ -30,17 +33,8 @@ const STATEMENT_KEYS = ["db.statement", "db.query.text"]; // A span with either key set to this is a Redis call, whose statement quotes the values it sent. const REDIS_SYSTEM_KEYS = ["db.system.name", "db.system"]; const REDIS_SYSTEM = "redis"; -const URL_QUERY_START = /[?#;]/; const FRAME_LINE = /^\s+at /; -/** `schemeless` reads a value with no scheme and no leading `/` as starting at its authority. */ -export function stripQuery(url: string, schemeless = false): string { - // Userinfo first: a password can hold a `?` or `#`. - const withoutUserinfo = stripUserinfo(url, schemeless); - const cut = withoutUserinfo.search(URL_QUERY_START); - return cut === -1 ? withoutUserinfo : withoutUserinfo.slice(0, cut); -} - // `METHOD /path`, `METHOD scheme://host/path` or `METHOD user@host/path`. const NAMED_REQUEST = /^([A-Z]+) ((?:\/|[a-z][a-z0-9+.-]*:\/\/|[^\s/?#@]*@)\S*)$/i; const NAME_KEYS = new Set(["next.span_name"]); diff --git a/src/scrub-entry.ts b/src/scrub-entry.ts new file mode 100644 index 0000000..f0b874d --- /dev/null +++ b/src/scrub-entry.ts @@ -0,0 +1,5 @@ +// Browser-safe scrub functions: the same objects the server scrub uses, so a client can never +// drift from it. Imports nothing from Node or OpenTelemetry. +export { scrubMessage, scrubErrorText } from "./log-scrub.js"; +export { stripQuery } from "./strip-query.js"; +export { TOKEN_PATH_TEMPLATES, templateUrlPath } from "./url-path.js"; diff --git a/src/strip-query.ts b/src/strip-query.ts new file mode 100644 index 0000000..11c0ead --- /dev/null +++ b/src/strip-query.ts @@ -0,0 +1,11 @@ +import { stripUserinfo } from "./url-credentials.js"; + +export const URL_QUERY_START = /[?#;]/; + +/** `schemeless` reads a value with no scheme and no leading `/` as starting at its authority. */ +export function stripQuery(url: string, schemeless = false): string { + // Userinfo first: a password can hold a `?` or `#`. + const withoutUserinfo = stripUserinfo(url, schemeless); + const cut = withoutUserinfo.search(URL_QUERY_START); + return cut === -1 ? withoutUserinfo : withoutUserinfo.slice(0, cut); +} diff --git a/src/url-path.ts b/src/url-path.ts index 1b8b3d5..9703061 100644 --- a/src/url-path.ts +++ b/src/url-path.ts @@ -6,10 +6,10 @@ import { describePatterns } from "./scrub-descriptor.js"; * the web page (`/apply/[token]`) and the api routes it calls. Any letter case and any run of `/` * match, as a server routes them alike. */ -export const TOKEN_PATH_TEMPLATES: readonly (readonly [RegExp, string])[] = [ - [/^\/+v1\/+applications\/+by-token\/+[^/?#]+/i, "/v1/applications/by-token/:token"], - [/^\/+apply\/+[^/?#]+/i, "/apply/[token]"], -]; +export const TOKEN_PATH_TEMPLATES: readonly (readonly [RegExp, string])[] = Object.freeze([ + Object.freeze([/^\/+v1\/+applications\/+by-token\/+[^/?#]+/i, "/v1/applications/by-token/:token"] as const), + Object.freeze([/^\/+apply\/+[^/?#]+/i, "/apply/[token]"] as const), +]); // A `scheme:` chain and `//`, or a bare `//`, then the host. const ORIGIN = /^((?:[a-z][a-z0-9+.-]*:)*\/\/[^/?#]*)(.*)$/is;