@digithings-ai/catalog 0.0.0-stage → 0.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 +34 -2
- package/package.json +34 -4
- package/src/byok-providers.ts +124 -0
- package/src/index.ts +3 -0
- package/src/openapi.ts +31 -0
- package/src/page-context-sanitize.ts +371 -0
package/README.md
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @digithings-ai/catalog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Framework-neutral shared catalogs — no React, no Next.js, no app aliases, so
|
|
4
|
+
any runtime can import them.
|
|
5
|
+
|
|
6
|
+
| Entry | What it is |
|
|
7
|
+
|---|---|
|
|
8
|
+
| `@digithings-ai/catalog` | Every catalog, re-exported |
|
|
9
|
+
| `@digithings-ai/catalog/byok-providers` | The BYOK provider catalog (ids, labels, key hints) |
|
|
10
|
+
| `@digithings-ai/catalog/openapi` | The OpenAPI service registry |
|
|
11
|
+
| `@digithings-ai/catalog/page-context-sanitize` | The page-context sanitiser shared by the surfaces that ship it |
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { BYOK_PROVIDERS } from "@digithings-ai/catalog/byok-providers";
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Publishing
|
|
18
|
+
|
|
19
|
+
This package is published to npm as part of the digithings R0 release. Its
|
|
20
|
+
`exports` point at **TypeScript source**, not a build output, so the published
|
|
21
|
+
tarball is the same code the workspace consumes.
|
|
22
|
+
|
|
23
|
+
A Next.js consumer must therefore transpile it — add the package to
|
|
24
|
+
`transpilePackages` in `next.config.mjs`:
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
const nextConfig = {
|
|
28
|
+
transpilePackages: ["@digithings-ai/catalog"],
|
|
29
|
+
};
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
A bundler that already compiles TS (Vite, tsup, Bun) resolves the source
|
|
33
|
+
directly and needs no extra configuration.
|
|
34
|
+
|
|
35
|
+
Tests: `npm test --workspace @digithings-ai/catalog`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@digithings-ai/catalog",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-neutral shared catalogs: BYOK provider catalog + OpenAPI service registry. No React, no Next.js, no app aliases \u2014 importable from any runtime.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://digithings.ai",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/digithings-ai/digithings.git",
|
|
10
|
+
"directory": "packages/catalog"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts",
|
|
15
|
+
"./byok-providers": "./src/byok-providers.ts",
|
|
16
|
+
"./openapi": "./src/openapi.ts",
|
|
17
|
+
"./page-context-sanitize": "./src/page-context-sanitize.ts"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"build": "tsup"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"tsup": "^8.5.0",
|
|
33
|
+
"typescript": "^6.0.2",
|
|
34
|
+
"vitest": "^4.1.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-neutral BYOK provider catalog + validation predicates.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately carries NO "use client" directive: this module is imported by
|
|
5
|
+
* the client-side `useBYOKKey()` hook (`hooks/use-byok-key.ts`) AND by two
|
|
6
|
+
* server-side Next.js Route Handlers (`api/chat/route.ts`,
|
|
7
|
+
* `api/byok/test/route.ts`) that cannot import a "use client" module. Before
|
|
8
|
+
* this module existed, all three sites hand-maintained their own copy of the
|
|
9
|
+
* provider list / prefix checks — a 6th provider added only to
|
|
10
|
+
* `config/byok-providers.json` could silently be forwarded with no model
|
|
11
|
+
* header, or a server route could silently treat an unrecognized provider
|
|
12
|
+
* value as `"openai"`. See #2351.
|
|
13
|
+
*
|
|
14
|
+
* The catalog below mirrors (but is not generated from — see
|
|
15
|
+
* `apps/digichat/ARCHITECTURE.md`'s BYOK section) `config/byok-providers.json`.
|
|
16
|
+
* Moved to `@digithings-ai/catalog` (DIG-3695); digichat re-exports it.
|
|
17
|
+
* `use-byok-key.catalog-parity.test.ts` fails if the two drift apart.
|
|
18
|
+
*
|
|
19
|
+
* That mirror stays deliberately hand-written. `config/byok-providers.json`
|
|
20
|
+
* carries the routing half of a provider (baseUrl, keyPrefix, requiresModel),
|
|
21
|
+
* which is house policy and changes rarely. The *model* half moved to a
|
|
22
|
+
* different mechanism in #4994: `config/model-catalog.json` (models.dev
|
|
23
|
+
* metadata) is generated into `src/lib/model-catalog.generated.ts` by
|
|
24
|
+
* `scripts/refresh_model_catalog.py`, and `config/byok-providers.json`'s
|
|
25
|
+
* `fallbackModels` are only *validated* against it, never replaced by it.
|
|
26
|
+
* The two halves are linked by `MODEL_CATALOG_BYOK_PROVIDER_MAP` (D7's
|
|
27
|
+
* BYOK-id -> models.dev-provider map), not by generation.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** OpenRouter keys start with sk-or- (inlined: the digichat alias is not portable). */
|
|
31
|
+
function isOpenRouterKey(key: string): boolean {
|
|
32
|
+
return key.startsWith("sk-or-");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type BYOKProvider = "openai" | "anthropic" | "openrouter" | "gemini" | "xai";
|
|
36
|
+
|
|
37
|
+
type ByokProviderCatalogEntry = {
|
|
38
|
+
id: BYOKProvider;
|
|
39
|
+
keyPrefix: string;
|
|
40
|
+
requiresModel: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Single source of truth for provider ids/order/requiresModel — every other
|
|
44
|
+
* export below (BYOK_PROVIDER_LIST, byokRequiresModel, isByokProvider, ...)
|
|
45
|
+
* is derived from this one array, not a second hand-kept copy of it. */
|
|
46
|
+
const BYOK_PROVIDER_CATALOG: readonly ByokProviderCatalogEntry[] = [
|
|
47
|
+
{ id: "openrouter", keyPrefix: "sk-or-", requiresModel: true },
|
|
48
|
+
{ id: "openai", keyPrefix: "sk-", requiresModel: false },
|
|
49
|
+
{ id: "anthropic", keyPrefix: "sk-ant-", requiresModel: true },
|
|
50
|
+
{ id: "gemini", keyPrefix: "AI", requiresModel: true },
|
|
51
|
+
{ id: "xai", keyPrefix: "xai-", requiresModel: true },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
export const BYOK_PROVIDER_LIST: readonly BYOKProvider[] = BYOK_PROVIDER_CATALOG.map(
|
|
55
|
+
(entry) => entry.id,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
function findCatalogEntry(provider: string): ByokProviderCatalogEntry | undefined {
|
|
59
|
+
return BYOK_PROVIDER_CATALOG.find((entry) => entry.id === provider);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Type guard: does `value` name a known BYOK provider? Driven entirely by
|
|
64
|
+
* `BYOK_PROVIDER_LIST` so a provider added there (and to the catalog above)
|
|
65
|
+
* is recognized everywhere this guard is used, with no separate list to keep
|
|
66
|
+
* in sync.
|
|
67
|
+
*/
|
|
68
|
+
export function isByokProvider(value: string): value is BYOKProvider {
|
|
69
|
+
return (BYOK_PROVIDER_LIST as readonly string[]).includes(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Parse a raw request-header/query value into a `BYOKProvider`, or `null`
|
|
74
|
+
* when it names no known provider. Never silently coerces an unrecognized
|
|
75
|
+
* value to a default provider (e.g. `"openai"`) — the caller must handle the
|
|
76
|
+
* `null` case explicitly. Replaces the historical `readProvider` pattern that
|
|
77
|
+
* fell through to `"openai"` for anything it didn't recognize.
|
|
78
|
+
*/
|
|
79
|
+
export function readByokProvider(raw: string): BYOKProvider | null {
|
|
80
|
+
return isByokProvider(raw) ? raw : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Non-OpenAI BYOK requires an explicit model slug (digigraph spend path).
|
|
85
|
+
* Accepts a raw `string` — not narrowed to `BYOKProvider` — so an untyped
|
|
86
|
+
* request-header value (e.g. `chat/route.ts`'s `x-byok-provider`) can be
|
|
87
|
+
* checked directly with no unsafe cast. A value naming no known provider
|
|
88
|
+
* returns `false`, the same as `"openai"` — this predicate is a pure gate on
|
|
89
|
+
* whether a model header is required, not a provider-existence check (see
|
|
90
|
+
* `readByokProvider`/`isByokProvider` for that).
|
|
91
|
+
*/
|
|
92
|
+
export function byokRequiresModel(provider: string): boolean {
|
|
93
|
+
return findCatalogEntry(provider)?.requiresModel ?? false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Returns `null` if `key` matches `provider`'s required key prefix, or a
|
|
98
|
+
* human-readable validation error otherwise. Does NOT check for an empty
|
|
99
|
+
* key — callers that need that check do it themselves (see
|
|
100
|
+
* `use-byok-key.ts`'s `validateBYOKKey`, which layers it on top). Shared by
|
|
101
|
+
* the client hook's `validateBYOKKey` and `api/byok/test/route.ts`'s
|
|
102
|
+
* prefix checks — previously two independently hand-maintained copies of
|
|
103
|
+
* this rule (plus a third informal copy in the JSON catalog's own
|
|
104
|
+
* `keyPrefix` field, checked for parity by
|
|
105
|
+
* `use-byok-key.catalog-parity.test.ts`).
|
|
106
|
+
*/
|
|
107
|
+
export function byokKeyPrefixError(key: string, provider: BYOKProvider): string | null {
|
|
108
|
+
if (provider === "openai" && !key.startsWith("sk-")) {
|
|
109
|
+
return "OpenAI keys must start with sk-.";
|
|
110
|
+
}
|
|
111
|
+
if (provider === "anthropic" && !key.startsWith("sk-ant-")) {
|
|
112
|
+
return "Anthropic keys must start with sk-ant-.";
|
|
113
|
+
}
|
|
114
|
+
if (provider === "openrouter" && !isOpenRouterKey(key)) {
|
|
115
|
+
return "OpenRouter keys must start with sk-or-.";
|
|
116
|
+
}
|
|
117
|
+
if (provider === "gemini" && !key.startsWith("AI")) {
|
|
118
|
+
return "Gemini keys must start with AI.";
|
|
119
|
+
}
|
|
120
|
+
if (provider === "xai" && !key.startsWith("xai-")) {
|
|
121
|
+
return "x.ai keys must start with xai-.";
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
package/src/index.ts
ADDED
package/src/openapi.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared OpenAPI service registry — one entry per committed spec under
|
|
3
|
+
* `docs/openapi/*.json`. Moved from `apps/digithings-web/lib/openapiCatalog.ts`
|
|
4
|
+
* (DIG-3695). The per-app public path mapping (`openApiSpecPath`) stays in
|
|
5
|
+
* digithings-web: same-origin serving is an app concern, not a catalog one.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface OpenApiService {
|
|
9
|
+
id: string;
|
|
10
|
+
port?: number;
|
|
11
|
+
role: string;
|
|
12
|
+
/** True when the committed JSON is authored rather than FastAPI-exported. */
|
|
13
|
+
authored?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const OPENAPI_SERVICES: OpenApiService[] = [
|
|
17
|
+
{ id: "digigraph", port: 8000, role: "LangGraph orchestration · OpenAI-compatible API" },
|
|
18
|
+
{ id: "digikey", port: 8005, role: "JWT + scoped API keys · JWKS" },
|
|
19
|
+
{ id: "digisearch", port: 8002, role: "RAG ingest · query · research" },
|
|
20
|
+
{ id: "digivault", port: 8004, role: "Markdown vault · wikilinks · lint" },
|
|
21
|
+
{ id: "digiquant", port: 8001, role: "NautilusTrader backtest · optimize" },
|
|
22
|
+
{ id: "digitrace", port: 8003, role: "Observability · health · status" },
|
|
23
|
+
{
|
|
24
|
+
id: "digichat",
|
|
25
|
+
port: 3005,
|
|
26
|
+
role: "Next.js BFF · chat · Auth.js",
|
|
27
|
+
authored: true,
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export const OPENAPI_SERVICE_IDS = OPENAPI_SERVICES.map((s) => s.id);
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural page-context sanitizer (#3602).
|
|
3
|
+
*
|
|
4
|
+
* Moved to `@digithings-ai/catalog` (DIG-3695); digichat re-exports it.
|
|
5
|
+
*
|
|
6
|
+
* Sender (dashboard popup / widget.js) clones the live DOM, drops non-visible and
|
|
7
|
+
* sensitive nodes, then serializes an allowlisted fragment. Receiver (embed)
|
|
8
|
+
* parses the HTML payload with DOMParser and applies the same allowlist — never
|
|
9
|
+
* a regex tag strip. CSS-hidden nodes are dropped only on a live document
|
|
10
|
+
* (computed style); the receiver still honors hidden / inert / aria-hidden /
|
|
11
|
+
* inline display:none / the opt-out marker.
|
|
12
|
+
*
|
|
13
|
+
* Privacy contract: see apps/digichat/ARCHITECTURE.md (page-context).
|
|
14
|
+
* Hosts exclude regions with `data-digichat-private`.
|
|
15
|
+
*
|
|
16
|
+
* Keep `public/widget.js` in sync with this walk.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const PAGE_CONTEXT_PRIVATE_ATTR = "data-digichat-private";
|
|
20
|
+
|
|
21
|
+
/** Keep in sync with embed-page-context-messages caps. */
|
|
22
|
+
export const DEFAULT_PAGE_CONTEXT_HTML_CHARS = 12_000;
|
|
23
|
+
export const DEFAULT_PAGE_CONTEXT_TEXT_CHARS = 8_000;
|
|
24
|
+
|
|
25
|
+
const ALLOWED_TAGS = new Set([
|
|
26
|
+
"a",
|
|
27
|
+
"abbr",
|
|
28
|
+
"article",
|
|
29
|
+
"aside",
|
|
30
|
+
"b",
|
|
31
|
+
"blockquote",
|
|
32
|
+
"br",
|
|
33
|
+
"caption",
|
|
34
|
+
"code",
|
|
35
|
+
"dd",
|
|
36
|
+
"details",
|
|
37
|
+
"dfn",
|
|
38
|
+
"div",
|
|
39
|
+
"dl",
|
|
40
|
+
"dt",
|
|
41
|
+
"em",
|
|
42
|
+
"figcaption",
|
|
43
|
+
"figure",
|
|
44
|
+
"footer",
|
|
45
|
+
"h1",
|
|
46
|
+
"h2",
|
|
47
|
+
"h3",
|
|
48
|
+
"h4",
|
|
49
|
+
"h5",
|
|
50
|
+
"h6",
|
|
51
|
+
"header",
|
|
52
|
+
"hr",
|
|
53
|
+
"i",
|
|
54
|
+
"label",
|
|
55
|
+
"li",
|
|
56
|
+
"main",
|
|
57
|
+
"mark",
|
|
58
|
+
"nav",
|
|
59
|
+
"ol",
|
|
60
|
+
"p",
|
|
61
|
+
"pre",
|
|
62
|
+
"s",
|
|
63
|
+
"section",
|
|
64
|
+
"small",
|
|
65
|
+
"span",
|
|
66
|
+
"strong",
|
|
67
|
+
"sub",
|
|
68
|
+
"summary",
|
|
69
|
+
"sup",
|
|
70
|
+
"table",
|
|
71
|
+
"tbody",
|
|
72
|
+
"td",
|
|
73
|
+
"tfoot",
|
|
74
|
+
"th",
|
|
75
|
+
"thead",
|
|
76
|
+
"time",
|
|
77
|
+
"tr",
|
|
78
|
+
"u",
|
|
79
|
+
"ul",
|
|
80
|
+
"button",
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
const ALLOWED_ATTRS = new Set([
|
|
84
|
+
"id",
|
|
85
|
+
"class",
|
|
86
|
+
"role",
|
|
87
|
+
"title",
|
|
88
|
+
"lang",
|
|
89
|
+
"dir",
|
|
90
|
+
"aria-label",
|
|
91
|
+
"aria-labelledby",
|
|
92
|
+
"aria-describedby",
|
|
93
|
+
"aria-expanded",
|
|
94
|
+
"aria-current",
|
|
95
|
+
"aria-level",
|
|
96
|
+
"colspan",
|
|
97
|
+
"rowspan",
|
|
98
|
+
"scope",
|
|
99
|
+
"headers",
|
|
100
|
+
"datetime",
|
|
101
|
+
"for",
|
|
102
|
+
"href",
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
const DROP_TAGS = new Set([
|
|
106
|
+
"script",
|
|
107
|
+
"style",
|
|
108
|
+
"noscript",
|
|
109
|
+
"iframe",
|
|
110
|
+
"object",
|
|
111
|
+
"embed",
|
|
112
|
+
"link",
|
|
113
|
+
"meta",
|
|
114
|
+
"base",
|
|
115
|
+
"template",
|
|
116
|
+
"svg",
|
|
117
|
+
"math",
|
|
118
|
+
"canvas",
|
|
119
|
+
"video",
|
|
120
|
+
"audio",
|
|
121
|
+
"source",
|
|
122
|
+
"track",
|
|
123
|
+
"picture",
|
|
124
|
+
"param",
|
|
125
|
+
"applet",
|
|
126
|
+
"frame",
|
|
127
|
+
"frameset",
|
|
128
|
+
"img",
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
const SECRET_AUTOCOMPLETE = new Set([
|
|
132
|
+
"current-password",
|
|
133
|
+
"new-password",
|
|
134
|
+
"password",
|
|
135
|
+
"cc-number",
|
|
136
|
+
"cc-csc",
|
|
137
|
+
"cc-exp",
|
|
138
|
+
"cc-exp-month",
|
|
139
|
+
"cc-exp-year",
|
|
140
|
+
"one-time-code",
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
type WalkOpts = { useComputedStyle: boolean };
|
|
144
|
+
|
|
145
|
+
function defaultDocument(): Document | null {
|
|
146
|
+
return typeof document !== "undefined" ? document : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function tagName(el: Element): string {
|
|
150
|
+
return el.tagName.toLowerCase();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function inlineStyleHides(style: string | null): boolean {
|
|
154
|
+
if (!style || typeof document === "undefined") return false;
|
|
155
|
+
const probe = document.createElement("div");
|
|
156
|
+
probe.setAttribute("style", style);
|
|
157
|
+
if (probe.style.display === "none" || probe.style.visibility === "hidden") {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
return probe.style.opacity === "0";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function computedStyleHides(el: Element): boolean {
|
|
164
|
+
const view = el.ownerDocument.defaultView;
|
|
165
|
+
if (!view) return false;
|
|
166
|
+
const cs = view.getComputedStyle(el);
|
|
167
|
+
if (cs.display === "none" || cs.visibility === "hidden") return true;
|
|
168
|
+
const opacity = Number.parseFloat(cs.opacity);
|
|
169
|
+
return opacity === 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isSensitiveControl(el: Element): boolean {
|
|
173
|
+
const tag = tagName(el);
|
|
174
|
+
if (tag === "input" || tag === "textarea" || tag === "select") return true;
|
|
175
|
+
const ac = (el.getAttribute("autocomplete") ?? "").trim().toLowerCase();
|
|
176
|
+
if (!ac) return false;
|
|
177
|
+
return ac.split(/\s+/).some((t) => SECRET_AUTOCOMPLETE.has(t));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isHiddenFromPresentation(el: Element, useComputed: boolean): boolean {
|
|
181
|
+
if (el.hasAttribute("hidden") || el.hasAttribute("inert")) return true;
|
|
182
|
+
if ((el.getAttribute("aria-hidden") ?? "").trim().toLowerCase() === "true") {
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
if (el.hasAttribute(PAGE_CONTEXT_PRIVATE_ATTR)) return true;
|
|
186
|
+
if (el.closest("[data-digichat-popup]")) return true;
|
|
187
|
+
if (inlineStyleHides(el.getAttribute("style"))) return true;
|
|
188
|
+
return useComputed && computedStyleHides(el);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function shouldDrop(el: Element, opts: WalkOpts): boolean {
|
|
192
|
+
if (DROP_TAGS.has(tagName(el))) return true;
|
|
193
|
+
if (isSensitiveControl(el)) return true;
|
|
194
|
+
return isHiddenFromPresentation(el, opts.useComputedStyle);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function sanitizeHref(raw: string): string | null {
|
|
198
|
+
const trimmed = raw.trim();
|
|
199
|
+
if (!trimmed) return null;
|
|
200
|
+
const lower = trimmed.toLowerCase();
|
|
201
|
+
if (
|
|
202
|
+
lower.startsWith("javascript:") ||
|
|
203
|
+
lower.startsWith("data:") ||
|
|
204
|
+
lower.startsWith("vbscript:") ||
|
|
205
|
+
lower.startsWith("blob:") ||
|
|
206
|
+
lower.startsWith("file:")
|
|
207
|
+
) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
const u = new URL(trimmed, "https://page-context.invalid");
|
|
212
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
|
213
|
+
if (u.hostname === "page-context.invalid") return u.pathname || "/";
|
|
214
|
+
return `${u.origin}${u.pathname}`;
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function stripDisallowedAttrs(el: Element): void {
|
|
221
|
+
const tag = tagName(el);
|
|
222
|
+
for (const attr of Array.from(el.attributes)) {
|
|
223
|
+
const name = attr.name.toLowerCase();
|
|
224
|
+
if (name.startsWith("on")) {
|
|
225
|
+
el.removeAttribute(attr.name);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (!ALLOWED_ATTRS.has(name)) {
|
|
229
|
+
el.removeAttribute(attr.name);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (name === "href") {
|
|
233
|
+
if (tag !== "a") {
|
|
234
|
+
el.removeAttribute(attr.name);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const safe = sanitizeHref(attr.value);
|
|
238
|
+
if (safe) el.setAttribute("href", safe);
|
|
239
|
+
else el.removeAttribute(attr.name);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function unwrap(el: Element): void {
|
|
245
|
+
const parent = el.parentNode;
|
|
246
|
+
if (!parent) {
|
|
247
|
+
el.remove();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
while (el.firstChild) parent.insertBefore(el.firstChild, el);
|
|
251
|
+
parent.removeChild(el);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function sanitizeElementInPlace(el: Element, opts: WalkOpts): void {
|
|
255
|
+
const kids = Array.from(el.childNodes);
|
|
256
|
+
for (const child of kids) {
|
|
257
|
+
if (child.nodeType === Node.COMMENT_NODE) {
|
|
258
|
+
child.parentNode?.removeChild(child);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (child.nodeType === Node.TEXT_NODE) continue;
|
|
262
|
+
if (child.nodeType !== Node.ELEMENT_NODE) {
|
|
263
|
+
child.parentNode?.removeChild(child);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const childEl = child as Element;
|
|
267
|
+
if (shouldDrop(childEl, opts)) {
|
|
268
|
+
childEl.remove();
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
stripDisallowedAttrs(childEl);
|
|
272
|
+
sanitizeElementInPlace(childEl, opts);
|
|
273
|
+
if (!ALLOWED_TAGS.has(tagName(childEl))) unwrap(childEl);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function pruneCloneFromLive(live: Element, clone: Element): void {
|
|
278
|
+
const liveKids = Array.from(live.childNodes);
|
|
279
|
+
const cloneKids = Array.from(clone.childNodes);
|
|
280
|
+
for (let i = liveKids.length - 1; i >= 0; i -= 1) {
|
|
281
|
+
const liveNode = liveKids[i];
|
|
282
|
+
const cloneNode = cloneKids[i];
|
|
283
|
+
if (!cloneNode || liveNode.nodeType !== Node.ELEMENT_NODE) continue;
|
|
284
|
+
if (cloneNode.nodeType !== Node.ELEMENT_NODE) continue;
|
|
285
|
+
const liveEl = liveNode as Element;
|
|
286
|
+
const cloneEl = cloneNode as Element;
|
|
287
|
+
if (shouldDrop(liveEl, { useComputedStyle: true })) {
|
|
288
|
+
cloneEl.remove();
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
pruneCloneFromLive(liveEl, cloneEl);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function capHtml(html: string, maxChars: number): string {
|
|
296
|
+
if (html.length <= maxChars) return html;
|
|
297
|
+
let sliced = html.slice(0, maxChars);
|
|
298
|
+
const lastLt = sliced.lastIndexOf("<");
|
|
299
|
+
const lastGt = sliced.lastIndexOf(">");
|
|
300
|
+
if (lastLt > lastGt) sliced = sliced.slice(0, lastLt);
|
|
301
|
+
return sliced.trimEnd();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function normalizeWs(html: string): string {
|
|
305
|
+
return html.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function collapseWs(text: string): string {
|
|
309
|
+
return text.replace(/\s+/g, " ").trim();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function parseHtmlBody(raw: string): Element | null {
|
|
313
|
+
if (typeof DOMParser === "undefined") return null;
|
|
314
|
+
// The DOMParser document below is inert: it is never attached to a live
|
|
315
|
+
// document, so no script in it runs and no subresource in it loads, and every
|
|
316
|
+
// element and attribute is filtered by the allowlist walk before this module
|
|
317
|
+
// returns any markup. The file is byte-identical to develop's pre-existing
|
|
318
|
+
// apps/digichat/src/lib/page-context-sanitize.ts apart from this comment.
|
|
319
|
+
// The alert appeared only because the module became an exported library
|
|
320
|
+
// package; it is dismissed as a false positive (code scanning alert #189).
|
|
321
|
+
const doc = new DOMParser().parseFromString(raw, "text/html"); // codeql[js/xss]
|
|
322
|
+
return doc.body;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function pageContextRoot(doc: Document): Element | null {
|
|
326
|
+
return doc.querySelector("main") ?? doc.querySelector("[role=main]") ?? doc.body;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Parse HTML and return an allowlisted fragment. Fail closed when DOMParser
|
|
331
|
+
* is missing (non-browser). Never re-hydrate the result as live DOM.
|
|
332
|
+
*/
|
|
333
|
+
export function sanitizePageHtml(
|
|
334
|
+
raw: string,
|
|
335
|
+
maxChars: number = DEFAULT_PAGE_CONTEXT_HTML_CHARS,
|
|
336
|
+
): string {
|
|
337
|
+
const body = parseHtmlBody(raw);
|
|
338
|
+
if (!body) return "";
|
|
339
|
+
sanitizeElementInPlace(body, { useComputedStyle: false });
|
|
340
|
+
return capHtml(normalizeWs(body.innerHTML), maxChars);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Visible-page HTML + text from a live document. Uses computed style so
|
|
345
|
+
* stylesheet-hidden nodes never enter the payload.
|
|
346
|
+
*/
|
|
347
|
+
export function extractPageContext(
|
|
348
|
+
doc: Document | null = defaultDocument(),
|
|
349
|
+
maxHtml: number = DEFAULT_PAGE_CONTEXT_HTML_CHARS,
|
|
350
|
+
maxText: number = DEFAULT_PAGE_CONTEXT_TEXT_CHARS,
|
|
351
|
+
): { html: string; text: string } {
|
|
352
|
+
if (!doc) return { html: "", text: "" };
|
|
353
|
+
const liveRoot = pageContextRoot(doc);
|
|
354
|
+
if (!liveRoot) return { html: "", text: "" };
|
|
355
|
+
if (shouldDrop(liveRoot, { useComputedStyle: true })) {
|
|
356
|
+
return { html: "", text: "" };
|
|
357
|
+
}
|
|
358
|
+
const clone = liveRoot.cloneNode(true) as Element;
|
|
359
|
+
pruneCloneFromLive(liveRoot, clone);
|
|
360
|
+
sanitizeElementInPlace(clone, { useComputedStyle: false });
|
|
361
|
+
const html = capHtml(normalizeWs(clone.innerHTML), maxHtml);
|
|
362
|
+
const text = collapseWs(clone.textContent ?? "").slice(0, maxText);
|
|
363
|
+
return { html, text };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function extractPageHtml(
|
|
367
|
+
maxChars: number = DEFAULT_PAGE_CONTEXT_HTML_CHARS,
|
|
368
|
+
doc: Document | null = defaultDocument(),
|
|
369
|
+
): string {
|
|
370
|
+
return extractPageContext(doc, maxChars).html;
|
|
371
|
+
}
|