@reposkein/mcp 0.4.0 → 0.6.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 +6 -1
- package/binary-digests.json +4 -4
- package/dist/ads/auditLog.d.ts +23 -0
- package/dist/ads/auditLog.js +33 -0
- package/dist/ads/auditLog.js.map +1 -0
- package/dist/ads/config.d.ts +83 -0
- package/dist/ads/config.js +107 -0
- package/dist/ads/config.js.map +1 -0
- package/dist/ads/luluSource.d.ts +32 -0
- package/dist/ads/luluSource.js +60 -0
- package/dist/ads/luluSource.js.map +1 -0
- package/dist/ads/sanitize.d.ts +16 -0
- package/dist/ads/sanitize.js +187 -0
- package/dist/ads/sanitize.js.map +1 -0
- package/dist/ads/slot.d.ts +63 -0
- package/dist/ads/slot.js +159 -0
- package/dist/ads/slot.js.map +1 -0
- package/dist/ads/supporter.d.ts +73 -0
- package/dist/ads/supporter.js +119 -0
- package/dist/ads/supporter.js.map +1 -0
- package/dist/ads/supporterKey.d.ts +45 -0
- package/dist/ads/supporterKey.js +51 -0
- package/dist/ads/supporterKey.js.map +1 -0
- package/dist/ads/supporterStore.d.ts +61 -0
- package/dist/ads/supporterStore.js +113 -0
- package/dist/ads/supporterStore.js.map +1 -0
- package/dist/ads/supporterToken.d.ts +133 -0
- package/dist/ads/supporterToken.js +233 -0
- package/dist/ads/supporterToken.js.map +1 -0
- package/dist/ads/types.d.ts +53 -0
- package/dist/ads/types.js +15 -0
- package/dist/ads/types.js.map +1 -0
- package/dist/cli/support.d.ts +46 -0
- package/dist/cli/support.js +276 -0
- package/dist/cli/support.js.map +1 -0
- package/dist/cli/view.js +27 -6
- package/dist/cli/view.js.map +1 -1
- package/dist/index.d.ts +8 -56
- package/dist/index.js +45 -402
- package/dist/index.js.map +1 -1
- package/dist/indexer/indexLock.d.ts +13 -0
- package/dist/indexer/indexLock.js +66 -0
- package/dist/indexer/indexLock.js.map +1 -0
- package/dist/indexer/runIndexer.d.ts +8 -0
- package/dist/indexer/runIndexer.js +12 -0
- package/dist/indexer/runIndexer.js.map +1 -1
- package/dist/serve/serve.d.ts +95 -0
- package/dist/serve/serve.js +581 -0
- package/dist/serve/serve.js.map +1 -0
- package/dist/serve/tokens.d.ts +49 -0
- package/dist/serve/tokens.js +108 -0
- package/dist/serve/tokens.js.map +1 -0
- package/dist/serve/watch.d.ts +73 -0
- package/dist/serve/watch.js +135 -0
- package/dist/serve/watch.js.map +1 -0
- package/dist/server/createMcpServer.d.ts +134 -0
- package/dist/server/createMcpServer.js +473 -0
- package/dist/server/createMcpServer.js.map +1 -0
- package/dist/store/JsonlGraphStore.d.ts +5 -1
- package/dist/store/JsonlGraphStore.js +6 -2
- package/dist/store/JsonlGraphStore.js.map +1 -1
- package/dist/store/teamConfig.d.ts +20 -6
- package/dist/store/teamConfig.js +70 -17
- package/dist/store/teamConfig.js.map +1 -1
- package/dist/tools/recordDecision.d.ts +3 -0
- package/dist/tools/recordDecision.js +1 -1
- package/dist/tools/recordDecision.js.map +1 -1
- package/dist/tools/writeSemanticSummary.d.ts +4 -1
- package/dist/tools/writeSemanticSummary.js +5 -2
- package/dist/tools/writeSemanticSummary.js.map +1 -1
- package/dist/viz/assets/index-BnqIVYFE.css +1 -0
- package/dist/viz/assets/index-IhtszTp0.js +5 -0
- package/dist/viz/assets/{r3f-BZY3QR6_.js → r3f-Bn9htB92.js} +1 -1
- package/dist/viz/assets/{tanstack-CjXr-L-s.js → tanstack-CSzx6NDA.js} +7 -7
- package/dist/viz/index.html +4 -4
- package/package.json +3 -2
- package/dist/viz/assets/index-CxqeGuRS.css +0 -1
- package/dist/viz/assets/index-DiNZyrXx.js +0 -9
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
# @reposkein/mcp
|
|
6
6
|
|
|
7
|
-
**
|
|
7
|
+
**RepoSkein gives your AI coding agent a map of your codebase — so it navigates structure instead of grepping and guessing.**
|
|
8
8
|
|
|
9
9
|
[](https://www.npmjs.com/package/@reposkein/mcp)
|
|
10
10
|
[](https://www.npmjs.com/package/@reposkein/mcp)
|
|
@@ -75,6 +75,7 @@ Then ask your agent *"what calls this function?"* or *"what breaks if I change X
|
|
|
75
75
|
- `reposkein-mcp index` — rebuild the committed graph after big changes.
|
|
76
76
|
- `reposkein-mcp stats [--last | --session <id> | --all] [--json]` — session usage report: calls by tool, top queried nodes/files, ADRs/summaries written, session duration, and an *estimated* context-tokens-saved-vs-grep number. See [Session usage stats](#session-usage-stats) below.
|
|
77
77
|
- `reposkein-mcp view [path]` — open the **constellation viewer**: a local, read-only, zero-infra web app (bound to `127.0.0.1`) that renders the committed `.reposkein` graph as an interactive 3D astronomy-style map. `--export <dir>` instead writes a self-contained static site (works from `file://` or any static host). See the [viewer section in the main README](https://github.com/reposkein/reposkein#visualize-the-graph--the-constellation-viewer), or **[try the live demo](https://reposkein.github.io/reposkein/)** (RepoSkein viewing its own graph).
|
|
78
|
+
- `reposkein-mcp serve --http [path]` — **advanced, optional**: one shared server for agents that have no clone. Speaks MCP over Streamable HTTP and serves the viewer + `/api/*` from the same process, behind bearer-token auth, read-only by default, re-indexing when the served checkout's HEAD moves. Nothing binds a socket unless you run it; stdio remains the default transport. See [`docs/REMOTE.md`](https://github.com/reposkein/reposkein/blob/main/docs/REMOTE.md).
|
|
78
79
|
|
|
79
80
|
## How your agent uses it
|
|
80
81
|
|
|
@@ -170,6 +171,8 @@ a resolved repo. See `mcp/src/store/resolveRepoPath.ts` and
|
|
|
170
171
|
| `REPOSKEIN_REPO_PATH` | pins the repository the server operates on — optional, see repo resolution above |
|
|
171
172
|
| `REPOSKEIN_SESSION_ID` | override the session id used for `reposkein-mcp stats` logging (default: start-timestamp + pid) |
|
|
172
173
|
| `REPOSKEIN_STORE` | `auto` (default) · `jsonl` (zero-infra) · `neo4j` |
|
|
174
|
+
| `REPOSKEIN_AGENT` | names the writer in `summary_by` / `decided_by` and picks its `local/summaries-<agent>.jsonl` sidecar (default `agent`) |
|
|
175
|
+
| `REPOSKEIN_SERVE_TOKENS` | `serve --http` only — `name:secret[:write]` entries, comma- or whitespace-separated. Required for that mode; see [`docs/REMOTE.md`](https://github.com/reposkein/reposkein/blob/main/docs/REMOTE.md) |
|
|
173
176
|
| `REPOSKEIN_INDEXER_BIN` | override the `reposkein-indexer` binary path (unsupported platforms) |
|
|
174
177
|
| `NEO4J_URI` / `NEO4J_USER` / `NEO4J_PASSWORD` | optional Neo4j backend (large graphs / Cypher at scale) |
|
|
175
178
|
| `REPOSKEIN_EMBED_PROVIDER` | `none` (default) · `voyage` · `http` — see below |
|
|
@@ -214,6 +217,8 @@ Full documentation, architecture, supported-language details, benchmarks, and th
|
|
|
214
217
|
|
|
215
218
|
👉 **https://github.com/reposkein/reposkein**
|
|
216
219
|
|
|
220
|
+
**Support:** [Ko-fi](https://ko-fi.com/mongx) — funds hosted-constellation infra + indexer maintenance.
|
|
221
|
+
|
|
217
222
|
## License
|
|
218
223
|
|
|
219
224
|
[Apache-2.0](https://github.com/reposkein/reposkein/blob/main/LICENSE).
|
package/binary-digests.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"reposkein-indexer-darwin-arm64": "
|
|
3
|
-
"reposkein-indexer-linux-arm64": "
|
|
4
|
-
"reposkein-indexer-linux-x64": "
|
|
5
|
-
"reposkein-indexer-win32-x64.exe": "
|
|
2
|
+
"reposkein-indexer-darwin-arm64": "bb460f99a92c15210c5d1d2a9d865846ea4bc168bf864204e20436e3bdb0bc53",
|
|
3
|
+
"reposkein-indexer-linux-arm64": "0a2ed10e489d8ce56f1387d8531d023f1f7ff2b0a2f7aea078f48011c6f70e5e",
|
|
4
|
+
"reposkein-indexer-linux-x64": "27577d103498e5984c4c6161f856f1c9333f15789e7d2202ae12b71bf913f59c",
|
|
5
|
+
"reposkein-indexer-win32-x64.exe": "73dd8a828deff6a802c9ec8b199ea3d146669dc184fb595904b13a2cc144dc85"
|
|
6
6
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Local, gitignored audit trail of OUTBOUND slot requests.
|
|
2
|
+
*
|
|
3
|
+
* One line per request actually sent — written after the gating chain passed
|
|
4
|
+
* and before the response is known — so an operator who opted in can answer
|
|
5
|
+
* "did this thing phone home, when, and from which tool?" after the fact,
|
|
6
|
+
* without a proxy and without trusting this codebase's own docs.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately narrow: `{ts, tool}` and nothing else. No response, no ad
|
|
9
|
+
* copy, no URL, no credentials, no arguments — the file records that a
|
|
10
|
+
* request happened, not what came back, so it can never become a place where
|
|
11
|
+
* sponsor-supplied bytes accumulate. It lives under `.reposkein/local/`
|
|
12
|
+
* (gitignored, outside the graph, alongside the session logs and caches), so
|
|
13
|
+
* it is never committed and `doctor` never treats it as graph state. */
|
|
14
|
+
export declare function adsAuditPath(repoPath: string): string;
|
|
15
|
+
export interface AdsAuditRecord {
|
|
16
|
+
ts: string;
|
|
17
|
+
tool: string;
|
|
18
|
+
}
|
|
19
|
+
/** Appends one record. Best-effort in exactly the way `sessionLog`'s writer
|
|
20
|
+
* is: an unwritable directory, a full disk or a permission error is
|
|
21
|
+
* swallowed, because an audit line must never fail or slow the tool call it
|
|
22
|
+
* is describing. */
|
|
23
|
+
export declare function appendAdsRequest(repoPath: string, record: AdsAuditRecord): void;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
/** Local, gitignored audit trail of OUTBOUND slot requests.
|
|
4
|
+
*
|
|
5
|
+
* One line per request actually sent — written after the gating chain passed
|
|
6
|
+
* and before the response is known — so an operator who opted in can answer
|
|
7
|
+
* "did this thing phone home, when, and from which tool?" after the fact,
|
|
8
|
+
* without a proxy and without trusting this codebase's own docs.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately narrow: `{ts, tool}` and nothing else. No response, no ad
|
|
11
|
+
* copy, no URL, no credentials, no arguments — the file records that a
|
|
12
|
+
* request happened, not what came back, so it can never become a place where
|
|
13
|
+
* sponsor-supplied bytes accumulate. It lives under `.reposkein/local/`
|
|
14
|
+
* (gitignored, outside the graph, alongside the session logs and caches), so
|
|
15
|
+
* it is never committed and `doctor` never treats it as graph state. */
|
|
16
|
+
export function adsAuditPath(repoPath) {
|
|
17
|
+
return join(repoPath, ".reposkein", "local", "ads-requests.jsonl");
|
|
18
|
+
}
|
|
19
|
+
/** Appends one record. Best-effort in exactly the way `sessionLog`'s writer
|
|
20
|
+
* is: an unwritable directory, a full disk or a permission error is
|
|
21
|
+
* swallowed, because an audit line must never fail or slow the tool call it
|
|
22
|
+
* is describing. */
|
|
23
|
+
export function appendAdsRequest(repoPath, record) {
|
|
24
|
+
try {
|
|
25
|
+
const path = adsAuditPath(repoPath);
|
|
26
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
27
|
+
appendFileSync(path, `${JSON.stringify(record)}\n`, "utf8");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// never surfaces
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=auditLog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auditLog.js","sourceRoot":"","sources":["../../src/ads/auditLog.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACpD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAE1C;;;;;;;;;;;;yEAYyE;AACzE,MAAM,UAAU,YAAY,CAAC,QAAgB;IAC3C,OAAO,IAAI,CAAC,QAAQ,EAAE,YAAY,EAAE,OAAO,EAAE,oBAAoB,CAAC,CAAC;AACrE,CAAC;AAOD;;;qBAGqB;AACrB,MAAM,UAAU,gBAAgB,CAAC,QAAgB,EAAE,MAAsB;IACvE,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAC;QACpC,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC9C,cAAc,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAC9D,CAAC;IAAC,MAAM,CAAC;QACP,iBAAiB;IACnB,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** The ad network's default API host. Its click links are always
|
|
2
|
+
* `https://<host>/c/{token}` (getlulu.dev/docs/contract: "never a raw
|
|
3
|
+
* advertiser URL"), which is why the click-host allowlist is derived from
|
|
4
|
+
* whatever base URL we are actually talking to rather than hardcoded twice. */
|
|
5
|
+
export declare const DEFAULT_ADS_BASE_URL = "https://ads.getlulu.dev";
|
|
6
|
+
/** Hard wall-clock budget for one slot request. The SDK's own default is
|
|
7
|
+
* 1500ms (3000ms when the backend may classify) — far too long to sit in
|
|
8
|
+
* front of an agent's tool result, so we impose ours on every call and treat
|
|
9
|
+
* the deadline as a no-slot, never an error. */
|
|
10
|
+
export declare const SLOT_TIMEOUT_MS = 800;
|
|
11
|
+
/** Tools allowed to carry a sponsored slot. An ALLOWLIST, not a denylist: a
|
|
12
|
+
* tool added to this server later carries no ad until someone puts it here on
|
|
13
|
+
* purpose.
|
|
14
|
+
*
|
|
15
|
+
* `get_context_profile` only, and deliberately:
|
|
16
|
+
* - It is the highest-volume tool in the intended workflow (one
|
|
17
|
+
* `semantic_find` seeds several profile expansions — see docs/TOOLS.md) and
|
|
18
|
+
* it is a deterministic neighbourhood expansion, so an envelope field
|
|
19
|
+
* cannot bias, reorder, or displace anything the caller asked for.
|
|
20
|
+
* - `semantic_find` is excluded PERMANENTLY, not by oversight: the governing
|
|
21
|
+
* ADR (.reposkein/decisions/2026-08-21-sponsorship-placement-*.json,
|
|
22
|
+
* ruling 2) rules that "an agent (or a person) calling semantic_find must
|
|
23
|
+
* get back what best matches the query, never what a sponsor paid to
|
|
24
|
+
* surface". Ranked retrieval stays uncontaminated even at the envelope
|
|
25
|
+
* level.
|
|
26
|
+
* - Every mutating tool (see WRITE_TOOLS) and every error path is excluded:
|
|
27
|
+
* ads never ride along with a write or a failure. */
|
|
28
|
+
export declare const AD_ELIGIBLE_TOOLS: readonly string[];
|
|
29
|
+
/** Why no slot will be requested. Diagnostic only — never surfaced to a
|
|
30
|
+
* caller, never logged with credential values. */
|
|
31
|
+
export type AdsOffReason = "kill_switch" | "not_opted_in" | "config_not_confirmed" | "no_credentials" | "supporter" | "bad_base_url";
|
|
32
|
+
export type AdsVerdict = {
|
|
33
|
+
enabled: false;
|
|
34
|
+
reason: AdsOffReason;
|
|
35
|
+
} | {
|
|
36
|
+
enabled: true;
|
|
37
|
+
publisherId: string;
|
|
38
|
+
apiKey: string;
|
|
39
|
+
baseUrl: string;
|
|
40
|
+
/** Hosts a click URL may point at. */
|
|
41
|
+
clickHosts: string[];
|
|
42
|
+
};
|
|
43
|
+
export interface ResolveAdsOptions {
|
|
44
|
+
/** Active repo root, for `[ads] enabled` in `.reposkein/config.toml`.
|
|
45
|
+
* Undefined (no repo resolved) simply means that opt-in source is absent. */
|
|
46
|
+
repoPath?: string | undefined;
|
|
47
|
+
env?: NodeJS.ProcessEnv;
|
|
48
|
+
/** Supplied by `createAdsHook`, which binds `ads/supporter.ts`'s
|
|
49
|
+
* `isSupporter` to its own env. Defaulted to "nobody" here rather than
|
|
50
|
+
* imported, so `resolveAdsVerdict` stays a pure function of its arguments
|
|
51
|
+
* and a gating test never depends on what is in the developer's home
|
|
52
|
+
* directory. */
|
|
53
|
+
isSupporter?: () => boolean;
|
|
54
|
+
/** Test seam for the config.toml read, and the connection-lifetime memo in
|
|
55
|
+
* `slot.ts` (one filesystem read per repo, not one per tool call). */
|
|
56
|
+
readOptIn?: (repoPath: string) => boolean | null;
|
|
57
|
+
}
|
|
58
|
+
/** The whole gating chain, in one place, evaluated in this order:
|
|
59
|
+
*
|
|
60
|
+
* 1. `REPOSKEIN_ADS=off` — an unconditional kill switch that outranks
|
|
61
|
+
* config, credentials, everything. Checked first so "off" can never be
|
|
62
|
+
* overridden by a repo's committed config.
|
|
63
|
+
* 2. Opt-in — `REPOSKEIN_ADS=on` in the ENVIRONMENT. A repo's
|
|
64
|
+
* `[ads] enabled = true` declares the repo's willingness, but is not
|
|
65
|
+
* sufficient on its own: config.toml is committed and travels with a
|
|
66
|
+
* clone, so honouring it alone would let whoever wrote it opt in every
|
|
67
|
+
* person who later checks the repo out. The environment is the only
|
|
68
|
+
* place the operator running THIS process can speak for themselves, so
|
|
69
|
+
* it must confirm. Absent the env switch, ads are off — which is the
|
|
70
|
+
* default for every install.
|
|
71
|
+
* 3. Credentials — `LULU_ADS_PUBLISHER_ID` + `LULU_ADS_API_KEY`, env only,
|
|
72
|
+
* never config, never argv, never logged. Absent either, the integration
|
|
73
|
+
* is inert: no network call is even attempted.
|
|
74
|
+
* 4. Supporter — a verified supporter never sees a slot (REP-29). The check
|
|
75
|
+
* is a local Ed25519 signature verification over
|
|
76
|
+
* `~/.config/reposkein/supporter.jwt`; it makes no network call, so
|
|
77
|
+
* consulting it here cannot itself be the thing that phones home.
|
|
78
|
+
* It runs BEFORE any slot is requested, which is the whole point: a
|
|
79
|
+
* supporter's machine must not even ask.
|
|
80
|
+
*
|
|
81
|
+
* Nothing in this function touches the network; it is the only thing that
|
|
82
|
+
* decides whether anything ever will. */
|
|
83
|
+
export declare function resolveAdsVerdict(opts?: ResolveAdsOptions): AdsVerdict;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { readConfigBool } from "../store/teamConfig.js";
|
|
2
|
+
/** The ad network's default API host. Its click links are always
|
|
3
|
+
* `https://<host>/c/{token}` (getlulu.dev/docs/contract: "never a raw
|
|
4
|
+
* advertiser URL"), which is why the click-host allowlist is derived from
|
|
5
|
+
* whatever base URL we are actually talking to rather than hardcoded twice. */
|
|
6
|
+
export const DEFAULT_ADS_BASE_URL = "https://ads.getlulu.dev";
|
|
7
|
+
/** Hard wall-clock budget for one slot request. The SDK's own default is
|
|
8
|
+
* 1500ms (3000ms when the backend may classify) — far too long to sit in
|
|
9
|
+
* front of an agent's tool result, so we impose ours on every call and treat
|
|
10
|
+
* the deadline as a no-slot, never an error. */
|
|
11
|
+
export const SLOT_TIMEOUT_MS = 800;
|
|
12
|
+
/** Tools allowed to carry a sponsored slot. An ALLOWLIST, not a denylist: a
|
|
13
|
+
* tool added to this server later carries no ad until someone puts it here on
|
|
14
|
+
* purpose.
|
|
15
|
+
*
|
|
16
|
+
* `get_context_profile` only, and deliberately:
|
|
17
|
+
* - It is the highest-volume tool in the intended workflow (one
|
|
18
|
+
* `semantic_find` seeds several profile expansions — see docs/TOOLS.md) and
|
|
19
|
+
* it is a deterministic neighbourhood expansion, so an envelope field
|
|
20
|
+
* cannot bias, reorder, or displace anything the caller asked for.
|
|
21
|
+
* - `semantic_find` is excluded PERMANENTLY, not by oversight: the governing
|
|
22
|
+
* ADR (.reposkein/decisions/2026-08-21-sponsorship-placement-*.json,
|
|
23
|
+
* ruling 2) rules that "an agent (or a person) calling semantic_find must
|
|
24
|
+
* get back what best matches the query, never what a sponsor paid to
|
|
25
|
+
* surface". Ranked retrieval stays uncontaminated even at the envelope
|
|
26
|
+
* level.
|
|
27
|
+
* - Every mutating tool (see WRITE_TOOLS) and every error path is excluded:
|
|
28
|
+
* ads never ride along with a write or a failure. */
|
|
29
|
+
export const AD_ELIGIBLE_TOOLS = ["get_context_profile"];
|
|
30
|
+
/** The whole gating chain, in one place, evaluated in this order:
|
|
31
|
+
*
|
|
32
|
+
* 1. `REPOSKEIN_ADS=off` — an unconditional kill switch that outranks
|
|
33
|
+
* config, credentials, everything. Checked first so "off" can never be
|
|
34
|
+
* overridden by a repo's committed config.
|
|
35
|
+
* 2. Opt-in — `REPOSKEIN_ADS=on` in the ENVIRONMENT. A repo's
|
|
36
|
+
* `[ads] enabled = true` declares the repo's willingness, but is not
|
|
37
|
+
* sufficient on its own: config.toml is committed and travels with a
|
|
38
|
+
* clone, so honouring it alone would let whoever wrote it opt in every
|
|
39
|
+
* person who later checks the repo out. The environment is the only
|
|
40
|
+
* place the operator running THIS process can speak for themselves, so
|
|
41
|
+
* it must confirm. Absent the env switch, ads are off — which is the
|
|
42
|
+
* default for every install.
|
|
43
|
+
* 3. Credentials — `LULU_ADS_PUBLISHER_ID` + `LULU_ADS_API_KEY`, env only,
|
|
44
|
+
* never config, never argv, never logged. Absent either, the integration
|
|
45
|
+
* is inert: no network call is even attempted.
|
|
46
|
+
* 4. Supporter — a verified supporter never sees a slot (REP-29). The check
|
|
47
|
+
* is a local Ed25519 signature verification over
|
|
48
|
+
* `~/.config/reposkein/supporter.jwt`; it makes no network call, so
|
|
49
|
+
* consulting it here cannot itself be the thing that phones home.
|
|
50
|
+
* It runs BEFORE any slot is requested, which is the whole point: a
|
|
51
|
+
* supporter's machine must not even ask.
|
|
52
|
+
*
|
|
53
|
+
* Nothing in this function touches the network; it is the only thing that
|
|
54
|
+
* decides whether anything ever will. */
|
|
55
|
+
export function resolveAdsVerdict(opts = {}) {
|
|
56
|
+
const env = opts.env ?? process.env;
|
|
57
|
+
const switchValue = (env.REPOSKEIN_ADS ?? "").trim().toLowerCase();
|
|
58
|
+
if (switchValue === "off" || switchValue === "0" || switchValue === "false") {
|
|
59
|
+
return { enabled: false, reason: "kill_switch" };
|
|
60
|
+
}
|
|
61
|
+
const envOptIn = switchValue === "on" || switchValue === "1" || switchValue === "true";
|
|
62
|
+
if (!envOptIn) {
|
|
63
|
+
// Distinguish "a repo asked, the operator hasn't confirmed" from "nobody
|
|
64
|
+
// asked at all" — the first is a state an operator may want to notice.
|
|
65
|
+
const readOptIn = opts.readOptIn ?? ((p) => readConfigBool(p, "ads", "enabled"));
|
|
66
|
+
const configAsked = opts.repoPath ? readOptIn(opts.repoPath) === true : false;
|
|
67
|
+
return { enabled: false, reason: configAsked ? "config_not_confirmed" : "not_opted_in" };
|
|
68
|
+
}
|
|
69
|
+
const publisherId = (env.LULU_ADS_PUBLISHER_ID ?? "").trim();
|
|
70
|
+
const apiKey = (env.LULU_ADS_API_KEY ?? "").trim();
|
|
71
|
+
if (publisherId === "" || apiKey === "") {
|
|
72
|
+
return { enabled: false, reason: "no_credentials" };
|
|
73
|
+
}
|
|
74
|
+
const isSupporter = opts.isSupporter ?? (() => false);
|
|
75
|
+
if (isSupporter()) {
|
|
76
|
+
return { enabled: false, reason: "supporter" };
|
|
77
|
+
}
|
|
78
|
+
const baseUrl = (env.LULU_ADS_BASE_URL ?? "").trim() || DEFAULT_ADS_BASE_URL;
|
|
79
|
+
let host;
|
|
80
|
+
try {
|
|
81
|
+
const parsed = new URL(baseUrl);
|
|
82
|
+
// Plaintext is refused for any REMOTE host: an ad request carries the
|
|
83
|
+
// publisher's API key, and the click URL comes back over the same
|
|
84
|
+
// connection, so http:// off-machine would hand both to anyone on the
|
|
85
|
+
// path. Loopback is the one exception, and only because it cannot leave
|
|
86
|
+
// the machine: it exists so a test or a local mock ad server can be
|
|
87
|
+
// pointed at without inventing TLS for it. Note the click-host allowlist
|
|
88
|
+
// is derived from this host, so a loopback base URL also means click URLs
|
|
89
|
+
// must point at loopback — a mock cannot smuggle in a real destination.
|
|
90
|
+
const loopback = parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" || parsed.hostname === "[::1]";
|
|
91
|
+
if (parsed.protocol !== "https:" && !loopback) {
|
|
92
|
+
return { enabled: false, reason: "bad_base_url" };
|
|
93
|
+
}
|
|
94
|
+
host = parsed.hostname;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return { enabled: false, reason: "bad_base_url" };
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
enabled: true,
|
|
101
|
+
publisherId,
|
|
102
|
+
apiKey,
|
|
103
|
+
baseUrl: baseUrl.replace(/\/$/, ""),
|
|
104
|
+
clickHosts: [host],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/ads/config.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SponsoredSource } from "./types.js";
|
|
2
|
+
/** Adapter over the `lulu-ads` npm package's LOW-LEVEL client.
|
|
3
|
+
*
|
|
4
|
+
* What this uses, and nothing else:
|
|
5
|
+
* `new LuluAds({publisherId, apiKey, baseUrl}).sponsoredSlot({context, timeoutMs})`
|
|
6
|
+
* → one `POST {baseUrl}/slot` with header `x-api-key` and body
|
|
7
|
+
* `{"context": {...}}`, hard-aborted by the SDK's own
|
|
8
|
+
* `AbortSignal.timeout(timeoutMs)`, returning data or null, never throwing.
|
|
9
|
+
*
|
|
10
|
+
* What this deliberately does NOT use, after reading the package (v0.9.0):
|
|
11
|
+
* - `enableLuluAds` / `withLuluAds`: they proxy `registerTool` and MUTATE
|
|
12
|
+
* the tool result — appending a rendered text card to `content` for CLI
|
|
13
|
+
* clients and, when `content` is a single text block, REPLACING it with
|
|
14
|
+
* `JSON.stringify(structuredContent)`. That rewrites our answer, which
|
|
15
|
+
* both the governing ADR ("never concatenated into result text") and the
|
|
16
|
+
* byte-identical fail-open guarantee forbid.
|
|
17
|
+
* - `warmUp()`: it POSTs `/telemetry/init` at startup. An opt-in ad slot
|
|
18
|
+
* must make ZERO network calls until a tool call actually passes the
|
|
19
|
+
* gating chain, so nothing here pings anything at boot.
|
|
20
|
+
* - `formatSuffix` / `formatCliCard`: both exist to concatenate sponsor
|
|
21
|
+
* copy into prose. Not on this codebase's response path, ever.
|
|
22
|
+
* - the widget/MCP-Apps exports: no rendered ad surface is in scope for
|
|
23
|
+
* REP-28.
|
|
24
|
+
*
|
|
25
|
+
* The import is DYNAMIC and lazy: with ads off (the default) the package is
|
|
26
|
+
* never even loaded, which also keeps its ~270KB bundled widget HTML out of
|
|
27
|
+
* the stdio server's startup path. */
|
|
28
|
+
export declare function luluAdsSource(cfg: {
|
|
29
|
+
publisherId: string;
|
|
30
|
+
apiKey: string;
|
|
31
|
+
baseUrl: string;
|
|
32
|
+
}): SponsoredSource;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Adapter over the `lulu-ads` npm package's LOW-LEVEL client.
|
|
2
|
+
*
|
|
3
|
+
* What this uses, and nothing else:
|
|
4
|
+
* `new LuluAds({publisherId, apiKey, baseUrl}).sponsoredSlot({context, timeoutMs})`
|
|
5
|
+
* → one `POST {baseUrl}/slot` with header `x-api-key` and body
|
|
6
|
+
* `{"context": {...}}`, hard-aborted by the SDK's own
|
|
7
|
+
* `AbortSignal.timeout(timeoutMs)`, returning data or null, never throwing.
|
|
8
|
+
*
|
|
9
|
+
* What this deliberately does NOT use, after reading the package (v0.9.0):
|
|
10
|
+
* - `enableLuluAds` / `withLuluAds`: they proxy `registerTool` and MUTATE
|
|
11
|
+
* the tool result — appending a rendered text card to `content` for CLI
|
|
12
|
+
* clients and, when `content` is a single text block, REPLACING it with
|
|
13
|
+
* `JSON.stringify(structuredContent)`. That rewrites our answer, which
|
|
14
|
+
* both the governing ADR ("never concatenated into result text") and the
|
|
15
|
+
* byte-identical fail-open guarantee forbid.
|
|
16
|
+
* - `warmUp()`: it POSTs `/telemetry/init` at startup. An opt-in ad slot
|
|
17
|
+
* must make ZERO network calls until a tool call actually passes the
|
|
18
|
+
* gating chain, so nothing here pings anything at boot.
|
|
19
|
+
* - `formatSuffix` / `formatCliCard`: both exist to concatenate sponsor
|
|
20
|
+
* copy into prose. Not on this codebase's response path, ever.
|
|
21
|
+
* - the widget/MCP-Apps exports: no rendered ad surface is in scope for
|
|
22
|
+
* REP-28.
|
|
23
|
+
*
|
|
24
|
+
* The import is DYNAMIC and lazy: with ads off (the default) the package is
|
|
25
|
+
* never even loaded, which also keeps its ~270KB bundled widget HTML out of
|
|
26
|
+
* the stdio server's startup path. */
|
|
27
|
+
export function luluAdsSource(cfg) {
|
|
28
|
+
// Built on first successful use and reused: the SDK client is a plain
|
|
29
|
+
// object holding config plus an in-memory cache; constructing it performs
|
|
30
|
+
// no I/O (see its constructor — warmUp is the only network side effect and
|
|
31
|
+
// we never call it).
|
|
32
|
+
let client = null;
|
|
33
|
+
return {
|
|
34
|
+
async requestSlot(ctx, opts) {
|
|
35
|
+
if (opts.signal.aborted)
|
|
36
|
+
return null;
|
|
37
|
+
if (!client) {
|
|
38
|
+
const mod = (await import("lulu-ads"));
|
|
39
|
+
client = new mod.LuluAds({
|
|
40
|
+
publisherId: cfg.publisherId,
|
|
41
|
+
apiKey: cfg.apiKey,
|
|
42
|
+
baseUrl: cfg.baseUrl,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
// `context` is built here, key by key, from `SlotContext` alone. The
|
|
46
|
+
// SDK's own allowlist would also accept `prompt`, `route`, `locale`,
|
|
47
|
+
// `country` and `category`; we never populate them, so they cannot leak
|
|
48
|
+
// by accident. `query` carries coarse keywords ONLY when the caller
|
|
49
|
+
// supplied them (no tool on the eligible list does today).
|
|
50
|
+
return client.sponsoredSlot({
|
|
51
|
+
context: {
|
|
52
|
+
tool: ctx.tool,
|
|
53
|
+
...(ctx.keywords ? { query: ctx.keywords } : {}),
|
|
54
|
+
},
|
|
55
|
+
timeoutMs: opts.timeoutMs,
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=luluSource.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"luluSource.js","sourceRoot":"","sources":["../../src/ads/luluSource.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;uCAyBuC;AACvC,MAAM,UAAU,aAAa,CAAC,GAI7B;IACC,sEAAsE;IACtE,0EAA0E;IAC1E,2EAA2E;IAC3E,qBAAqB;IACrB,IAAI,MAAM,GAA8D,IAAI,CAAC;IAE7E,OAAO;QACL,KAAK,CAAC,WAAW,CAAC,GAAgB,EAAE,IAAgD;YAClF,IAAI,IAAI,CAAC,MAAM,CAAC,OAAO;gBAAE,OAAO,IAAI,CAAC;YACrC,IAAI,CAAC,MAAM,EAAE,CAAC;gBACZ,MAAM,GAAG,GAAG,CAAC,MAAM,MAAM,CAAC,UAAU,CAAC,CAIpC,CAAC;gBACF,MAAM,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC;oBACvB,WAAW,EAAE,GAAG,CAAC,WAAW;oBAC5B,MAAM,EAAE,GAAG,CAAC,MAAM;oBAClB,OAAO,EAAE,GAAG,CAAC,OAAO;iBACrB,CAAC,CAAC;YACL,CAAC;YACD,qEAAqE;YACrE,qEAAqE;YACrE,wEAAwE;YACxE,oEAAoE;YACpE,2DAA2D;YAC3D,OAAO,MAAM,CAAC,aAAa,CAAC;gBAC1B,OAAO,EAAE;oBACP,IAAI,EAAE,GAAG,CAAC,IAAI;oBACd,GAAG,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,GAAG,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;iBACjD;gBACD,SAAS,EAAE,IAAI,CAAC,SAAS;aAC1B,CAAC,CAAC;QACL,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type SponsoredSlot } from "./types.js";
|
|
2
|
+
/** Projects an untrusted payload onto `SponsoredSlot`, or returns null.
|
|
3
|
+
*
|
|
4
|
+
* Null is the normal, safe answer: every rejection path (missing field, over
|
|
5
|
+
* cap, wrong host, hostile shape, unparseable input) means "no slot", which
|
|
6
|
+
* the caller treats as identical to no ad having been offered at all. There
|
|
7
|
+
* is no partial acceptance and no truncation — truncating sponsor copy to fit
|
|
8
|
+
* a cap would silently reshape someone's paid message and truncating a URL
|
|
9
|
+
* would break it, so both are refusals instead.
|
|
10
|
+
*
|
|
11
|
+
* Field mapping accepts the ad network's wire name (`text`) as an alias for
|
|
12
|
+
* body copy, but the OUTPUT is built key by key, so no unexpected field —
|
|
13
|
+
* including a payload's own `label` — can ride along. */
|
|
14
|
+
export declare function sanitizeSponsored(raw: unknown, opts: {
|
|
15
|
+
clickHosts: readonly string[];
|
|
16
|
+
}): SponsoredSlot | null;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { CODE_FENCE, MD_LINK } from "../guard/summaryValidation.js";
|
|
2
|
+
import { BODY_MAX, PAYLOAD_MAX_BYTES, SPONSORED_LABEL, TITLE_MAX, URL_MAX, } from "./types.js";
|
|
3
|
+
/** Invisible characters: soft hyphen, zero-width spaces/joiners, bidi
|
|
4
|
+
* overrides, word joiners, byte-order mark. Stripped BEFORE the injection
|
|
5
|
+
* denylist runs, so `ig<ZWSP>nore previous instructions` can't smuggle a
|
|
6
|
+
* phrase past a regex, and so a right-to-left override can't reorder what a
|
|
7
|
+
* human sees relative to what a model reads. */
|
|
8
|
+
const INVISIBLE_SOURCE = "[\\u00AD\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF]";
|
|
9
|
+
const INVISIBLE_G = new RegExp(INVISIBLE_SOURCE, "g");
|
|
10
|
+
/** Same class without `g`: `.test()` on a global regex is stateful. */
|
|
11
|
+
const INVISIBLE = new RegExp(INVISIBLE_SOURCE);
|
|
12
|
+
/** Line/tab whitespace that must never survive into a one-line field:
|
|
13
|
+
* ASCII line/tab breaks, NEL (U+0085, the C1 newline), and the Unicode
|
|
14
|
+
* line/paragraph separators. */
|
|
15
|
+
const LINEBREAKS = /[\r\n\t\v\f\u0085\u2028\u2029]/g;
|
|
16
|
+
/** Control characters, ads edition: the shared `CONTROL_CHARS` class (C0 minus
|
|
17
|
+
* tab/newline/CR, plus DEL) EXTENDED to the whole C1 block, U+0080-U+009F.
|
|
18
|
+
*
|
|
19
|
+
* C1 matters here in a way it doesn't for summaries: those bytes are legal in
|
|
20
|
+
* a JSON string, invisible in most terminals, and include their own control
|
|
21
|
+
* vocabulary (NEL at U+0085 breaks a line, and CSI at U+009B opens an escape
|
|
22
|
+
* sequence a terminal may act on). Ad copy has no use for any of them, so
|
|
23
|
+
* they are stripped from prose and rejected in a URL rather than passed
|
|
24
|
+
* through. Line breaks in that range are already handled by LINEBREAKS above,
|
|
25
|
+
* which runs first.
|
|
26
|
+
*
|
|
27
|
+
* Written out rather than derived from `CONTROL_CHARS.source` so a future
|
|
28
|
+
* edit to the shared class cannot silently reshape (or fail to compile) this
|
|
29
|
+
* one; `adsSanitize.test.ts` asserts the two stay in agreement about C0. */
|
|
30
|
+
const ADS_CONTROL_SOURCE = "[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F-\\u009F]";
|
|
31
|
+
const ADS_CONTROL_G = new RegExp(ADS_CONTROL_SOURCE, "g");
|
|
32
|
+
/** Same class without `g`: `.test()` on a global regex is stateful. */
|
|
33
|
+
const CONTROL_TEST = new RegExp(ADS_CONTROL_SOURCE);
|
|
34
|
+
/** Shapes that are not ad copy. Defence in depth, NOT the boundary — the
|
|
35
|
+
* boundary is that a slot is fixed-schema, length-capped data in a labelled
|
|
36
|
+
* envelope field, never prose the tool emits. But sponsor copy has no
|
|
37
|
+
* legitimate reason to carry any of these, so a payload containing one is
|
|
38
|
+
* treated as hostile and the whole slot is dropped (fail-open = no slot).
|
|
39
|
+
*
|
|
40
|
+
* Ordered by what an injection attempt actually looks like: instruction
|
|
41
|
+
* overrides, role/turn markers, special-token and template syntax, markup,
|
|
42
|
+
* tool-call bait. */
|
|
43
|
+
const INJECTION_SHAPES = [
|
|
44
|
+
/\b(ignore|disregard|forget|override)\b[^.]{0,40}\b(previous|prior|above|earlier|all)\b/i,
|
|
45
|
+
/\b(previous|prior|above|earlier|all)\b[^.]{0,40}\b(instructions?|prompts?|rules?)\b/i,
|
|
46
|
+
/\bsystem\s+(prompt|message|instructions?)\b/i,
|
|
47
|
+
/\bnew\s+instructions?\b/i,
|
|
48
|
+
/\byou\s+are\s+(now|a|an)\b/i,
|
|
49
|
+
/\b(do\s+not|don't)\s+(tell|mention|reveal|disclose)\b/i,
|
|
50
|
+
/^\s*(system|assistant|user|human|developer)\s*:/i,
|
|
51
|
+
/<\||\|>|<<[A-Z]|\[\/?INST\]/,
|
|
52
|
+
/\{\{|\}\}|\$\{/,
|
|
53
|
+
/<[a-z/!?]/i,
|
|
54
|
+
/\b(tool_call|function_call|tool_use|end_turn)\b/i,
|
|
55
|
+
];
|
|
56
|
+
/** Cleans one candidate prose field, or null if it isn't usable.
|
|
57
|
+
*
|
|
58
|
+
* Strip-then-reject: invisibles and control characters are stripped (they
|
|
59
|
+
* carry no meaning a sponsor could intend) and line breaks collapse to single
|
|
60
|
+
* spaces, but anything still carrying markdown-link or code-fence syntax or
|
|
61
|
+
* an injection shape is refused outright rather than "sanitized" into
|
|
62
|
+
* something that merely looks safe. */
|
|
63
|
+
function cleanText(value) {
|
|
64
|
+
if (typeof value !== "string")
|
|
65
|
+
return null;
|
|
66
|
+
// Invisibles are a refusal, not a repair. Ad copy has no legitimate use for
|
|
67
|
+
// a bidi override or a zero-width joiner, and both exist here for exactly
|
|
68
|
+
// one reason: to make what a human sees differ from what a model reads (an
|
|
69
|
+
// RLO renders "ignore previous instructions" backwards; a ZWSP splits the
|
|
70
|
+
// phrase past a regex). They are also stripped below so no later check can
|
|
71
|
+
// be fooled by one that slips through a class update.
|
|
72
|
+
if (INVISIBLE.test(value))
|
|
73
|
+
return null;
|
|
74
|
+
const flat = value
|
|
75
|
+
.replace(INVISIBLE_G, "")
|
|
76
|
+
.replace(LINEBREAKS, " ")
|
|
77
|
+
.replace(ADS_CONTROL_G, "")
|
|
78
|
+
.replace(/\s{2,}/g, " ")
|
|
79
|
+
.trim();
|
|
80
|
+
if (flat.length === 0)
|
|
81
|
+
return null;
|
|
82
|
+
if (CODE_FENCE.test(flat))
|
|
83
|
+
return null;
|
|
84
|
+
if (MD_LINK.test(flat) || flat.includes("]("))
|
|
85
|
+
return null;
|
|
86
|
+
for (const shape of INJECTION_SHAPES) {
|
|
87
|
+
if (shape.test(flat))
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
return flat;
|
|
91
|
+
}
|
|
92
|
+
/** Validates the click URL: https only, host on the network's click-domain
|
|
93
|
+
* allowlist, no embedded credentials, canonicalized before it is handed on.
|
|
94
|
+
*
|
|
95
|
+
* The allowlist matters more than the scheme check: per getlulu.dev/docs the
|
|
96
|
+
* network always returns its own signed redirect (`/c/{token}` on its click
|
|
97
|
+
* host) and never a raw advertiser URL, so a payload pointing anywhere else
|
|
98
|
+
* is either a misconfiguration or someone else's link — both fail-open. */
|
|
99
|
+
function cleanUrl(value, clickHosts) {
|
|
100
|
+
if (typeof value !== "string")
|
|
101
|
+
return null;
|
|
102
|
+
const raw = value.trim();
|
|
103
|
+
if (raw.length === 0 || raw.length > URL_MAX)
|
|
104
|
+
return null;
|
|
105
|
+
if (/\s/.test(raw))
|
|
106
|
+
return null;
|
|
107
|
+
if (CONTROL_TEST.test(raw))
|
|
108
|
+
return null;
|
|
109
|
+
if (INVISIBLE.test(raw))
|
|
110
|
+
return null;
|
|
111
|
+
let parsed;
|
|
112
|
+
try {
|
|
113
|
+
parsed = new URL(raw);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
if (parsed.protocol !== "https:")
|
|
119
|
+
return null;
|
|
120
|
+
if (parsed.username !== "" || parsed.password !== "")
|
|
121
|
+
return null;
|
|
122
|
+
const host = parsed.hostname.toLowerCase();
|
|
123
|
+
if (!clickHosts.some((allowed) => allowed.toLowerCase() === host))
|
|
124
|
+
return null;
|
|
125
|
+
const canonical = parsed.toString();
|
|
126
|
+
return canonical.length <= URL_MAX ? canonical : null;
|
|
127
|
+
}
|
|
128
|
+
/** Projects an untrusted payload onto `SponsoredSlot`, or returns null.
|
|
129
|
+
*
|
|
130
|
+
* Null is the normal, safe answer: every rejection path (missing field, over
|
|
131
|
+
* cap, wrong host, hostile shape, unparseable input) means "no slot", which
|
|
132
|
+
* the caller treats as identical to no ad having been offered at all. There
|
|
133
|
+
* is no partial acceptance and no truncation — truncating sponsor copy to fit
|
|
134
|
+
* a cap would silently reshape someone's paid message and truncating a URL
|
|
135
|
+
* would break it, so both are refusals instead.
|
|
136
|
+
*
|
|
137
|
+
* Field mapping accepts the ad network's wire name (`text`) as an alias for
|
|
138
|
+
* body copy, but the OUTPUT is built key by key, so no unexpected field —
|
|
139
|
+
* including a payload's own `label` — can ride along. */
|
|
140
|
+
export function sanitizeSponsored(raw, opts) {
|
|
141
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
142
|
+
return null;
|
|
143
|
+
if (opts.clickHosts.length === 0)
|
|
144
|
+
return null;
|
|
145
|
+
// Cheap size gate before touching fields: a real slot is a few hundred
|
|
146
|
+
// bytes, so anything larger is not one.
|
|
147
|
+
let serialized;
|
|
148
|
+
try {
|
|
149
|
+
serialized = JSON.stringify(raw) ?? "";
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
if (Buffer.byteLength(serialized, "utf8") > PAYLOAD_MAX_BYTES)
|
|
155
|
+
return null;
|
|
156
|
+
const payload = raw;
|
|
157
|
+
// A field that is PRESENT but unusable rejects the whole slot; a field that
|
|
158
|
+
// is absent is simply absent. "Present but unusable" is the interesting
|
|
159
|
+
// case — it means the payload tried something.
|
|
160
|
+
let title;
|
|
161
|
+
if (payload.title !== undefined && payload.title !== null) {
|
|
162
|
+
const cleaned = cleanText(payload.title);
|
|
163
|
+
if (cleaned === null || cleaned.length > TITLE_MAX)
|
|
164
|
+
return null;
|
|
165
|
+
title = cleaned;
|
|
166
|
+
}
|
|
167
|
+
const bodySrc = payload.body ?? payload.text ?? payload.description ?? undefined;
|
|
168
|
+
let body;
|
|
169
|
+
if (bodySrc !== undefined && bodySrc !== null) {
|
|
170
|
+
const cleaned = cleanText(bodySrc);
|
|
171
|
+
if (cleaned === null || cleaned.length > BODY_MAX)
|
|
172
|
+
return null;
|
|
173
|
+
body = cleaned;
|
|
174
|
+
}
|
|
175
|
+
if (title === undefined && body === undefined)
|
|
176
|
+
return null;
|
|
177
|
+
const url = cleanUrl(payload.url, opts.clickHosts);
|
|
178
|
+
if (url === null)
|
|
179
|
+
return null;
|
|
180
|
+
return {
|
|
181
|
+
label: SPONSORED_LABEL,
|
|
182
|
+
...(title !== undefined ? { title } : {}),
|
|
183
|
+
...(body !== undefined ? { body } : {}),
|
|
184
|
+
url,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
//# sourceMappingURL=sanitize.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sanitize.js","sourceRoot":"","sources":["../../src/ads/sanitize.ts"],"names":[],"mappings":"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"}
|