@mulmoclaude/common 0.1.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,5 +29,38 @@ duplicated," so they live here once.
29
29
  | `hasStringProp(v, k)` | `Record<k, string>` | key present with a string value |
30
30
  | `hasNumberProp(v, k)` | `Record<k, number>` | key present with a number value |
31
31
 
32
+ Plus CSV/env helpers `parseCsvList(raw, { lowercase? })` and
33
+ `parseCsvSet(raw, { lowercase? })` (empty set = "allow all" sentinel), and the
34
+ helpers below.
35
+
36
+ | Helper | Returns | Notes |
37
+ |---|---|---|
38
+ | `errorMessage(v, fallback?)` | `string` | unknown caught value → human-readable string; **isomorphic**, so Vue/browser surfaces use it too |
39
+ | `toUtcIsoDate(timestamp)` | `string` | `Date` → `YYYY-MM-DD` in UTC — for dates that must not shift with the host's local timezone |
40
+
41
+ `errorMessage` surfaces a non-empty string `details` (gRPC convention) or
42
+ `message` field of a non-Error object (`details` wins) instead of
43
+ `[object Object]`; `fallback` covers a thrown non-Error at an error boundary.
44
+ This is the single home the #2217 consolidation could not reach, because
45
+ `@mulmoclaude/core/utils` is server-only — core now re-exports this one.
46
+
32
47
  `isRecord` vs `isObj`: use `isRecord` whenever you go on to index string keys —
33
48
  `isObj` lets arrays through, which is rarely what you want for a JSON payload.
49
+
50
+ ## Shared types
51
+
52
+ `src/logger.ts` (re-exported from the root entry) holds the canonical logger
53
+ interface family:
54
+
55
+ | Type | Shape | Notes |
56
+ |---|---|---|
57
+ | `StructuredLogger` | `error`/`warn`/`info`/`debug` `(prefix, message, data?)` | The host logger, and every engine/plugin logger injected from it |
58
+ | `MinimalLogger` | `info`/`warn`/`error` `(message, data?)` | For a package that already namespaces its own entries |
59
+
60
+ They live here because the shape spans all three tiers — host `server/`, the
61
+ plugins, and `@mulmoclaude/core` — so no tier above the leaf can own the
62
+ declaration without becoming an uphill import for the others (#2486). Domains
63
+ keep their own exported name by **aliasing** rather than re-declaring
64
+ (`export type FeedsLogger = StructuredLogger`, or a
65
+ `Pick<MinimalLogger, "warn" | "error">` subset): TypeScript is structurally
66
+ typed, so the alias preserves the public name and the emitted `.d.ts` exactly.
@@ -0,0 +1,11 @@
1
+ export interface ScanEnvOptionsConfig {
2
+ /** Ordered LOW → HIGH precedence — on a key clash the later prefix wins. */
3
+ prefixes: readonly string[];
4
+ /** Closed set of emitted keys (lowerCamel). Absent = emit every key. */
5
+ allowKeys?: ReadonlySet<string>;
6
+ }
7
+ /** Convert `UPPER_SNAKE_CASE` → `lowerCamelCase`. Adjacent underscores
8
+ * collapse to a single word break; empty / all-underscore input → `""`. */
9
+ export declare function snakeToLowerCamel(snake: string): string;
10
+ /** Scrape prefixed env vars into a lowerCamel-keyed string bag. */
11
+ export declare function scanEnvOptions(env: Readonly<Record<string, string | undefined>>, config: ScanEnvOptionsConfig): Record<string, string>;
@@ -0,0 +1,62 @@
1
+ // The one env-scrape algorithm (#2487) behind the prefixed options bags:
2
+ // `readBridgeEnvOptions` (@mulmobridge/client) and the host's
3
+ // `resolveRelayBridgeOptions` are thin wrappers over `scanEnvOptions`.
4
+ //
5
+ // Semantics both wrappers rely on:
6
+ // - `prefixes` are ordered LOW → HIGH precedence: when two vars yield the
7
+ // same key, the value from the later prefix wins.
8
+ // - The highest-precedence prefix that matches a name CLAIMS it. A claimed
9
+ // name with an empty tail is dropped outright — never retried against a
10
+ // lower-precedence prefix.
11
+ // - Empty-string values are dropped so a stray `FOO=""` doesn't shadow
12
+ // another var's match.
13
+ // - Tails convert `UPPER_SNAKE` → `lowerCamel`; an all-underscore tail
14
+ // camelises to `""` and is dropped.
15
+ // - `allowKeys` (lowerCamel form), when present, drops every other key.
16
+ // This is how the relay wrapper keeps `RELAY_TOKEN` / `RELAY_URL`
17
+ // infrastructure secrets out of a bag that is forwarded to the agent
18
+ // and may be logged — treat the filter as a security boundary.
19
+ /** Convert `UPPER_SNAKE_CASE` → `lowerCamelCase`. Adjacent underscores
20
+ * collapse to a single word break; empty / all-underscore input → `""`. */
21
+ export function snakeToLowerCamel(snake) {
22
+ const parts = snake
23
+ .toLowerCase()
24
+ .split("_")
25
+ .filter((segment) => segment.length > 0);
26
+ if (parts.length === 0)
27
+ return "";
28
+ const [head, ...rest] = parts;
29
+ return head + rest.map((part) => part[0].toUpperCase() + part.slice(1)).join("");
30
+ }
31
+ function claimByHighestPrefix(name, prefixes) {
32
+ const claimed = prefixes
33
+ .map((prefix, precedence) => ({ prefix, precedence }))
34
+ .reverse()
35
+ .find(({ prefix }) => name.startsWith(prefix));
36
+ if (claimed === undefined)
37
+ return null;
38
+ return { precedence: claimed.precedence, tail: name.slice(claimed.prefix.length) };
39
+ }
40
+ function scanEnvEntry(name, value, config) {
41
+ if (typeof value !== "string" || value.length === 0)
42
+ return null;
43
+ const claim = claimByHighestPrefix(name, config.prefixes);
44
+ if (claim === null || claim.tail.length === 0)
45
+ return null;
46
+ const key = snakeToLowerCamel(claim.tail);
47
+ if (key.length === 0)
48
+ return null;
49
+ if (config.allowKeys !== undefined && !config.allowKeys.has(key))
50
+ return null;
51
+ return { precedence: claim.precedence, key, value };
52
+ }
53
+ /** Scrape prefixed env vars into a lowerCamel-keyed string bag. */
54
+ export function scanEnvOptions(env, config) {
55
+ return Object.entries(env)
56
+ .flatMap(([name, value]) => {
57
+ const scanned = scanEnvEntry(name, value, config);
58
+ return scanned === null ? [] : [scanned];
59
+ })
60
+ .sort((left, right) => left.precedence - right.precedence)
61
+ .reduce((bag, { key, value }) => ({ ...bag, [key]: value }), {});
62
+ }
package/dist/index.d.ts CHANGED
@@ -22,3 +22,45 @@ export declare function isErrorWithCode(value: unknown): value is {
22
22
  export declare function hasStringProp<K extends string>(value: unknown, key: K): value is Record<K, string> & Record<string, unknown>;
23
23
  /** Check that a record has a specific key with a number value. */
24
24
  export declare function hasNumberProp<K extends string>(value: unknown, key: K): value is Record<K, number> & Record<string, unknown>;
25
+ /** Split a comma-separated env value into trimmed, non-empty entries.
26
+ * `lowercase` folds case for identifiers compared case-insensitively
27
+ * (JIDs, email addresses, hex pubkeys). Absent/empty input → empty list. */
28
+ export declare function parseCsvList(raw: string | undefined, opts?: {
29
+ lowercase?: boolean;
30
+ }): string[];
31
+ /** A comma-separated env value as a Set — the canonical allowlist shape,
32
+ * where an empty set is the "allow all" sentinel (`set.size === 0`). */
33
+ export declare function parseCsvSet(raw: string | undefined, opts?: {
34
+ lowercase?: boolean;
35
+ }): Set<string>;
36
+ /** Normalise an unknown thrown value into a human-readable string. Isomorphic
37
+ * (host, bridges, plugins, Vue) — this is the single home for the helper that
38
+ * #2217 could only consolidate for server code, since `@mulmoclaude/core/utils`
39
+ * is server-only.
40
+ *
41
+ * A non-Error object with a non-empty string `details` (gRPC convention) or
42
+ * `message` field surfaces that field — `details` wins — instead of the
43
+ * `[object Object]` a bare `String(err)` would print; an empty-string field
44
+ * falls through. `fallback` covers the error-boundary idiom where a thrown
45
+ * non-Error should read as a descriptive message rather than `String(err)`
46
+ * noise; omit it in logging contexts where `String(err)` is fine. */
47
+ export declare function errorMessage(err: unknown, fallback?: string): string;
48
+ /** `Date` → `YYYY-MM-DD` in UTC — for dates that must not shift with the
49
+ * host's local timezone (tool-trace search dirs, API date keys). Isomorphic
50
+ * single source (#2480): the host re-exports it from `server/utils/date.ts`,
51
+ * x-plugin imports it directly. The `@receptron/task-scheduler` copy stays
52
+ * local on purpose — that leaf package is published independently and kept
53
+ * dependency-free. Wall-clock questions use the host's `toLocalIsoDate`. */
54
+ export declare function toUtcIsoDate(timestamp: Date): string;
55
+ /** HTML-escape text destined for markup or an attribute value — the fixed
56
+ * five-character map, nothing more. Lives here rather than in
57
+ * `@mulmoclaude/core/wiki` (#2483) because `@mulmoclaude/markdown-utils` is a
58
+ * leaf that core depends on and so cannot import back up; core/wiki
59
+ * re-exports this, keeping its consumers' import path unchanged.
60
+ *
61
+ * Escaping `&` first is what makes a single pass safe — the entities this
62
+ * introduces contain none of the other four characters, so nothing is
63
+ * double-escaped. Not a sanitiser: it neither strips tags nor validates URLs. */
64
+ export declare function escapeHtml(value: string): string;
65
+ export { scanEnvOptions, snakeToLowerCamel, type ScanEnvOptionsConfig } from "./envScan.js";
66
+ export type { MinimalLogger, StructuredLogger } from "./logger.js";
package/dist/index.js CHANGED
@@ -46,3 +46,74 @@ export function hasStringProp(value, key) {
46
46
  export function hasNumberProp(value, key) {
47
47
  return isRecord(value) && typeof value[key] === "number";
48
48
  }
49
+ /** Split a comma-separated env value into trimmed, non-empty entries.
50
+ * `lowercase` folds case for identifiers compared case-insensitively
51
+ * (JIDs, email addresses, hex pubkeys). Absent/empty input → empty list. */
52
+ export function parseCsvList(raw, opts) {
53
+ return (raw ?? "")
54
+ .split(",")
55
+ .map((entry) => (opts?.lowercase ? entry.trim().toLowerCase() : entry.trim()))
56
+ .filter(Boolean);
57
+ }
58
+ /** A comma-separated env value as a Set — the canonical allowlist shape,
59
+ * where an empty set is the "allow all" sentinel (`set.size === 0`). */
60
+ export function parseCsvSet(raw, opts) {
61
+ return new Set(parseCsvList(raw, opts));
62
+ }
63
+ /** Normalise an unknown thrown value into a human-readable string. Isomorphic
64
+ * (host, bridges, plugins, Vue) — this is the single home for the helper that
65
+ * #2217 could only consolidate for server code, since `@mulmoclaude/core/utils`
66
+ * is server-only.
67
+ *
68
+ * A non-Error object with a non-empty string `details` (gRPC convention) or
69
+ * `message` field surfaces that field — `details` wins — instead of the
70
+ * `[object Object]` a bare `String(err)` would print; an empty-string field
71
+ * falls through. `fallback` covers the error-boundary idiom where a thrown
72
+ * non-Error should read as a descriptive message rather than `String(err)`
73
+ * noise; omit it in logging contexts where `String(err)` is fine. */
74
+ export function errorMessage(err, fallback) {
75
+ if (err instanceof Error)
76
+ return err.message;
77
+ if (hasStringProp(err, "details") && err.details)
78
+ return err.details;
79
+ if (hasStringProp(err, "message") && err.message)
80
+ return err.message;
81
+ if (fallback !== undefined)
82
+ return fallback;
83
+ return String(err);
84
+ }
85
+ /** `Date` → `YYYY-MM-DD` in UTC — for dates that must not shift with the
86
+ * host's local timezone (tool-trace search dirs, API date keys). Isomorphic
87
+ * single source (#2480): the host re-exports it from `server/utils/date.ts`,
88
+ * x-plugin imports it directly. The `@receptron/task-scheduler` copy stays
89
+ * local on purpose — that leaf package is published independently and kept
90
+ * dependency-free. Wall-clock questions use the host's `toLocalIsoDate`. */
91
+ export function toUtcIsoDate(timestamp) {
92
+ const year = timestamp.getUTCFullYear();
93
+ const month = String(timestamp.getUTCMonth() + 1).padStart(2, "0");
94
+ const day = String(timestamp.getUTCDate()).padStart(2, "0");
95
+ return `${year}-${month}-${day}`;
96
+ }
97
+ // A Map, not an object literal: `{}[char]` reads through the prototype chain,
98
+ // so a future caller widening the regex would silently get `[object Object]`
99
+ // for keys like `constructor`.
100
+ const HTML_ESCAPES = new Map([
101
+ ["&", "&amp;"],
102
+ ["<", "&lt;"],
103
+ [">", "&gt;"],
104
+ ['"', "&quot;"],
105
+ ["'", "&#39;"],
106
+ ]);
107
+ /** HTML-escape text destined for markup or an attribute value — the fixed
108
+ * five-character map, nothing more. Lives here rather than in
109
+ * `@mulmoclaude/core/wiki` (#2483) because `@mulmoclaude/markdown-utils` is a
110
+ * leaf that core depends on and so cannot import back up; core/wiki
111
+ * re-exports this, keeping its consumers' import path unchanged.
112
+ *
113
+ * Escaping `&` first is what makes a single pass safe — the entities this
114
+ * introduces contain none of the other four characters, so nothing is
115
+ * double-escaped. Not a sanitiser: it neither strips tags nor validates URLs. */
116
+ export function escapeHtml(value) {
117
+ return value.replace(/[&<>"']/g, (char) => HTML_ESCAPES.get(char) ?? char);
118
+ }
119
+ export { scanEnvOptions, snakeToLowerCamel } from "./envScan.js";
@@ -0,0 +1,14 @@
1
+ /** 4-method host-logger shape — `(prefix, message, data?)`. */
2
+ export interface StructuredLogger {
3
+ error: (prefix: string, message: string, data?: Record<string, unknown>) => void;
4
+ warn: (prefix: string, message: string, data?: Record<string, unknown>) => void;
5
+ info: (prefix: string, message: string, data?: Record<string, unknown>) => void;
6
+ debug: (prefix: string, message: string, data?: Record<string, unknown>) => void;
7
+ }
8
+ /** 3-method pre-namespaced logger — `(message, data?)`; the logging package
9
+ * binds its own prefix, so hosts only supply the transport. */
10
+ export interface MinimalLogger {
11
+ info: (message: string, data?: Record<string, unknown>) => void;
12
+ warn: (message: string, data?: Record<string, unknown>) => void;
13
+ error: (message: string, data?: Record<string, unknown>) => void;
14
+ }
package/dist/logger.js ADDED
@@ -0,0 +1,6 @@
1
+ // Canonical logger interface family. Lives in this zero-dep leaf — the only
2
+ // tier the host, @mulmoclaude/core domains, and plugins can all reach — so each
3
+ // consumer aliases ONE declaration (`export type CollectionLogger =
4
+ // StructuredLogger`) instead of re-declaring the shape: structural typing keeps
5
+ // every alias's public name and d.ts surface identical to a re-declaration.
6
+ export {};
@@ -0,0 +1,16 @@
1
+ export interface MessengerTextMessage {
2
+ senderId: string;
3
+ text: string;
4
+ }
5
+ /** Every text message across all `entry[].messaging[]` events in a Messenger
6
+ * webhook body, skipping non-text / empty / malformed events. */
7
+ export declare function extractMessengerMessages(body: unknown): MessengerTextMessage[];
8
+ export interface WhatsAppTextMessage {
9
+ from: string;
10
+ text: {
11
+ body: string;
12
+ };
13
+ }
14
+ /** Every inbound text message across all `entry[].changes[].value.messages[]`
15
+ * in a WhatsApp Cloud API webhook body, skipping non-text / malformed. */
16
+ export declare function extractWhatsAppMessages(body: unknown): WhatsAppTextMessage[];
@@ -0,0 +1,58 @@
1
+ // Pure inbound-payload parsers for the Meta messaging platforms (Facebook
2
+ // Messenger, WhatsApp Cloud API). Both the Node/Express bridges and the
3
+ // Cloudflare Workers relay receive the same webhook JSON shapes and used to
4
+ // carry byte-identical copies of these extractors. They live here — a
5
+ // zero-dependency, no-node leaf — because that is the only tier both runtimes
6
+ // can import: no crypto, no fetch, no Node built-ins, just `isRecord`.
7
+ //
8
+ // Signature verification is deliberately NOT here: the bridges use node:crypto
9
+ // and the relay uses Web Crypto (`crypto.subtle`), so it cannot be shared.
10
+ import { isRecord, isUnknownArray } from "./index.js";
11
+ function parseMessengerEvent(event) {
12
+ if (!isRecord(event) || !isRecord(event.sender) || typeof event.sender.id !== "string")
13
+ return null;
14
+ if (!isRecord(event.message) || typeof event.message.text !== "string")
15
+ return null;
16
+ const text = event.message.text.trim();
17
+ if (!text)
18
+ return null;
19
+ return { senderId: event.sender.id, text };
20
+ }
21
+ function messengerEventsOf(entry) {
22
+ return isRecord(entry) && isUnknownArray(entry.messaging) ? entry.messaging : [];
23
+ }
24
+ /** Every text message across all `entry[].messaging[]` events in a Messenger
25
+ * webhook body, skipping non-text / empty / malformed events. */
26
+ export function extractMessengerMessages(body) {
27
+ if (!isRecord(body) || !isUnknownArray(body.entry))
28
+ return [];
29
+ return body.entry
30
+ .flatMap(messengerEventsOf)
31
+ .map(parseMessengerEvent)
32
+ .filter((msg) => msg !== null);
33
+ }
34
+ function parseWhatsAppMessage(msg) {
35
+ if (!isRecord(msg) || msg.type !== "text" || typeof msg.from !== "string")
36
+ return null;
37
+ if (!isRecord(msg.text) || typeof msg.text.body !== "string" || !msg.text.body.trim())
38
+ return null;
39
+ return { from: msg.from, text: { body: msg.text.body } };
40
+ }
41
+ function whatsAppMessagesOf(change) {
42
+ if (!isRecord(change) || !isRecord(change.value) || !isUnknownArray(change.value.messages))
43
+ return [];
44
+ return change.value.messages;
45
+ }
46
+ function whatsAppRawMessagesOf(entry) {
47
+ return isRecord(entry) && isUnknownArray(entry.changes) ? entry.changes.flatMap(whatsAppMessagesOf) : [];
48
+ }
49
+ /** Every inbound text message across all `entry[].changes[].value.messages[]`
50
+ * in a WhatsApp Cloud API webhook body, skipping non-text / malformed. */
51
+ export function extractWhatsAppMessages(body) {
52
+ if (!isRecord(body) || !isUnknownArray(body.entry))
53
+ return [];
54
+ return body.entry
55
+ .flatMap(whatsAppRawMessagesOf)
56
+ .map(parseWhatsAppMessage)
57
+ .filter((msg) => msg !== null);
58
+ }
package/dist/ssrf.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ /** IPv4 ranges that must never be fetched, as [firstAddress, prefixLength].
2
+ * The union of the pre-#2459 per-guard tables — removing an entry weakens
3
+ * every guard in the repo at once, so treat edits as security changes. */
4
+ export declare const BLOCKED_IPV4_RANGES: readonly (readonly [string, number])[];
5
+ /** Dotted-decimal IPv4 → unsigned 32-bit value; null for anything else
6
+ * (hex / octal octets, whitespace, wrong part count). */
7
+ export declare function ipv4ToInt(address: string): number | null;
8
+ /** True when this parses as IPv4 AND falls inside a blocked range. Non-IPv4
9
+ * input is false — "not a blocked literal", not "safe"; a caller that must
10
+ * reject non-IP input entirely does so at its own boundary. */
11
+ export declare function isBlockedIpv4(address: string): boolean;
12
+ /** `URL.hostname` keeps the brackets on an IPv6 literal (`[::1]`); strip them
13
+ * so the address checks see the bare address. */
14
+ export declare function stripIpv6Brackets(hostname: string): string;
15
+ /** Blocks loopback/unspecified, fc00::/7 unique-local, fe80::/10 link-local,
16
+ * and IPv4-mapped forms of blocked v4 addresses. Mask-based on the leading
17
+ * group — prefix string matching would miss e.g. fe9a:: (inside fe80::/10). */
18
+ export declare function isBlockedIpv6(address: string): boolean;
19
+ /** True when this literal address must never be fetched. Non-IP input is
20
+ * false — see `isBlockedIpv4`. */
21
+ export declare function isBlockedIp(address: string): boolean;
22
+ /** Names that are internal by convention (localhost, *.local, *.internal…),
23
+ * rejected without waiting for DNS to prove it. */
24
+ export declare function isBlockedHostname(hostname: string): boolean;
25
+ /** Parse and apply every check that doesn't need the network: shape, scheme,
26
+ * obviously-internal hostname, and literal addresses. Returns null on reject.
27
+ * Callers must still vet what non-literal hostnames resolve to. */
28
+ export declare function parseSafeUrlShape(raw: string): URL | null;
package/dist/ssrf.js ADDED
@@ -0,0 +1,139 @@
1
+ // The single SSRF deny-list for the monorepo: address classification and
2
+ // URL-shape checks shared by every guard that fetches model-authored /
3
+ // remote-user-supplied URLs (mastodon bridge urlGuard, core feeds httpClient).
4
+ // Before #2459 the CIDR table was hand-copied per guard and had drifted.
5
+ //
6
+ // Everything here is pure and isomorphic — no `node:` builtins. The parts that
7
+ // need the network (DNS resolution, per-redirect re-checks) stay in each
8
+ // consumer's thin wrapper.
9
+ /* eslint-disable sonarjs/no-hardcoded-ip -- the blocked ranges ARE this module's
10
+ specification; writing them as literals is the point, not an oversight. */
11
+ const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
12
+ const BLOCKED_HOSTNAMES = new Set(["localhost", "ip6-localhost", "ip6-loopback"]);
13
+ const BLOCKED_HOSTNAME_SUFFIXES = [".localhost", ".local", ".internal"];
14
+ /** IPv4 ranges that must never be fetched, as [firstAddress, prefixLength].
15
+ * The union of the pre-#2459 per-guard tables — removing an entry weakens
16
+ * every guard in the repo at once, so treat edits as security changes. */
17
+ export const BLOCKED_IPV4_RANGES = [
18
+ ["0.0.0.0", 8], // "this network"
19
+ ["10.0.0.0", 8], // RFC1918
20
+ ["100.64.0.0", 10], // CGNAT
21
+ ["127.0.0.0", 8], // loopback
22
+ ["169.254.0.0", 16], // link-local — includes the 169.254.169.254 metadata endpoint
23
+ ["172.16.0.0", 12], // RFC1918
24
+ ["192.0.0.0", 24], // IETF protocol assignments
25
+ ["192.168.0.0", 16], // RFC1918
26
+ ["198.18.0.0", 15], // benchmarking
27
+ ["224.0.0.0", 4], // multicast
28
+ ["240.0.0.0", 4], // reserved / broadcast
29
+ ];
30
+ /** Dotted-decimal IPv4 → unsigned 32-bit value; null for anything else
31
+ * (hex / octal octets, whitespace, wrong part count). */
32
+ export function ipv4ToInt(address) {
33
+ const parts = address.split(".");
34
+ if (parts.length !== 4)
35
+ return null;
36
+ const octets = parts.map((part) => (/^\d{1,3}$/.test(part) ? Number(part) : Number.NaN));
37
+ if (octets.some((octet) => Number.isNaN(octet) || octet > 255))
38
+ return null;
39
+ return octets.reduce((value, octet) => value * 256 + octet, 0);
40
+ }
41
+ function isBlockedIpv4Value(value) {
42
+ return BLOCKED_IPV4_RANGES.some(([base, prefix]) => {
43
+ const baseValue = ipv4ToInt(base);
44
+ if (baseValue === null)
45
+ return false;
46
+ // `>>> 0` keeps the mask unsigned; a /0 shift would be a no-op anyway.
47
+ const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;
48
+ return (value & mask) === (baseValue & mask);
49
+ });
50
+ }
51
+ /** True when this parses as IPv4 AND falls inside a blocked range. Non-IPv4
52
+ * input is false — "not a blocked literal", not "safe"; a caller that must
53
+ * reject non-IP input entirely does so at its own boundary. */
54
+ export function isBlockedIpv4(address) {
55
+ const value = ipv4ToInt(address);
56
+ if (value === null)
57
+ return false;
58
+ return isBlockedIpv4Value(value);
59
+ }
60
+ /** `URL.hostname` keeps the brackets on an IPv6 literal (`[::1]`); strip them
61
+ * so the address checks see the bare address. */
62
+ export function stripIpv6Brackets(hostname) {
63
+ return hostname.replace(/^\[|\]$/g, "");
64
+ }
65
+ /** The 32-bit value inside an IPv4-mapped IPv6 literal, or null when the
66
+ * address is not v4-mapped. The hex spelling matters: WHATWG URL serializes
67
+ * `[::ffff:127.0.0.1]` as `::ffff:7f00:1`, so matching only the dotted form
68
+ * would wave the mapped loopback through any URL-sourced check. */
69
+ function mappedIpv4Value(lower) {
70
+ const dotted = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
71
+ if (dotted)
72
+ return ipv4ToInt(dotted[1]);
73
+ const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(lower);
74
+ if (hex)
75
+ return Number.parseInt(hex[1], 16) * 0x10000 + Number.parseInt(hex[2], 16);
76
+ return null;
77
+ }
78
+ /** Blocks loopback/unspecified, fc00::/7 unique-local, fe80::/10 link-local,
79
+ * and IPv4-mapped forms of blocked v4 addresses. Mask-based on the leading
80
+ * group — prefix string matching would miss e.g. fe9a:: (inside fe80::/10). */
81
+ export function isBlockedIpv6(address) {
82
+ const lower = stripIpv6Brackets(address.toLowerCase());
83
+ if (lower === "::1" || lower === "::")
84
+ return true;
85
+ // IPv4-mapped re-enters the v4 rules.
86
+ const mapped = mappedIpv4Value(lower);
87
+ if (mapped !== null)
88
+ return isBlockedIpv4Value(mapped);
89
+ const [head] = lower.split(":");
90
+ if (head.length === 0)
91
+ return false;
92
+ const leading = Number.parseInt(head, 16);
93
+ if (Number.isNaN(leading))
94
+ return false;
95
+ if ((leading & 0xfe00) === 0xfc00)
96
+ return true; // fc00::/7 unique-local
97
+ if ((leading & 0xffc0) === 0xfe80)
98
+ return true; // fe80::/10 link-local
99
+ return false;
100
+ }
101
+ /** True when this literal address must never be fetched. Non-IP input is
102
+ * false — see `isBlockedIpv4`. */
103
+ export function isBlockedIp(address) {
104
+ return address.includes(":") ? isBlockedIpv6(address) : isBlockedIpv4(address);
105
+ }
106
+ /** Names that are internal by convention (localhost, *.local, *.internal…),
107
+ * rejected without waiting for DNS to prove it. */
108
+ export function isBlockedHostname(hostname) {
109
+ const lower = hostname.toLowerCase().replace(/\.$/, "");
110
+ if (BLOCKED_HOSTNAMES.has(lower))
111
+ return true;
112
+ return BLOCKED_HOSTNAME_SUFFIXES.some((suffix) => lower.endsWith(suffix));
113
+ }
114
+ function parseUrl(raw) {
115
+ try {
116
+ return new URL(raw);
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ }
122
+ /** Parse and apply every check that doesn't need the network: shape, scheme,
123
+ * obviously-internal hostname, and literal addresses. Returns null on reject.
124
+ * Callers must still vet what non-literal hostnames resolve to. */
125
+ export function parseSafeUrlShape(raw) {
126
+ const url = parseUrl(raw);
127
+ if (url === null)
128
+ return null;
129
+ if (!ALLOWED_PROTOCOLS.has(url.protocol))
130
+ return null;
131
+ const hostname = stripIpv6Brackets(url.hostname);
132
+ if (hostname.length === 0)
133
+ return null;
134
+ if (isBlockedHostname(hostname))
135
+ return null;
136
+ if (isBlockedIp(hostname))
137
+ return null;
138
+ return url;
139
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmoclaude/common",
3
- "version": "0.1.0",
3
+ "version": "1.1.0",
4
4
  "description": "General-purpose pure utilities (type guards, etc.) shared across the MulmoClaude host, bridges, and plugins",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -11,6 +11,18 @@
11
11
  "import": "./dist/index.js",
12
12
  "require": "./dist/index.js",
13
13
  "default": "./dist/index.js"
14
+ },
15
+ "./meta-webhook": {
16
+ "types": "./dist/meta-webhook.d.ts",
17
+ "import": "./dist/meta-webhook.js",
18
+ "require": "./dist/meta-webhook.js",
19
+ "default": "./dist/meta-webhook.js"
20
+ },
21
+ "./ssrf": {
22
+ "types": "./dist/ssrf.d.ts",
23
+ "import": "./dist/ssrf.js",
24
+ "require": "./dist/ssrf.js",
25
+ "default": "./dist/ssrf.js"
14
26
  }
15
27
  },
16
28
  "files": [