@weatherboard/annotation 0.4.0 → 0.7.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/ADOPTING.md +147 -10
- package/COMPATIBILITY.md +29 -0
- package/README.md +84 -10
- package/dist/anchor.cjs +1 -1
- package/dist/anchor.cjs.map +2 -2
- package/dist/anchor.mjs.map +2 -2
- package/dist/arming.d.cts +18 -21
- package/dist/arming.d.mts +18 -21
- package/dist/arming.d.ts +18 -21
- package/dist/bootstrap-runtime.d.cts +30 -0
- package/dist/bootstrap-runtime.d.mts +30 -0
- package/dist/bootstrap-runtime.d.ts +30 -0
- package/dist/bootstrap.cjs +2 -0
- package/dist/bootstrap.cjs.map +7 -0
- package/dist/bootstrap.d.cts +8 -0
- package/dist/bootstrap.d.mts +8 -0
- package/dist/bootstrap.d.ts +8 -0
- package/dist/bootstrap.mjs +2 -0
- package/dist/bootstrap.mjs.map +7 -0
- package/dist/host.cjs +1 -1
- package/dist/host.cjs.map +3 -3
- package/dist/host.mjs +1 -1
- package/dist/host.mjs.map +3 -3
- package/dist/index.cjs +4 -4
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.cts +10 -4
- package/dist/index.d.mts +10 -4
- package/dist/index.d.ts +10 -4
- package/dist/index.mjs +4 -4
- package/dist/index.mjs.map +4 -4
- package/dist/markers.cjs +2 -2
- package/dist/markers.cjs.map +2 -2
- package/dist/markers.mjs +2 -2
- package/dist/markers.mjs.map +2 -2
- package/dist/page-capture.cjs +2 -0
- package/dist/page-capture.cjs.map +7 -0
- package/dist/page-capture.d.cts +115 -0
- package/dist/page-capture.d.mts +115 -0
- package/dist/page-capture.d.ts +115 -0
- package/dist/page-capture.mjs +2 -0
- package/dist/page-capture.mjs.map +7 -0
- package/dist/protocol.cjs +1 -1
- package/dist/protocol.cjs.map +3 -3
- package/dist/protocol.d.cts +19 -0
- package/dist/protocol.d.mts +19 -0
- package/dist/protocol.d.ts +19 -0
- package/dist/protocol.mjs +1 -1
- package/dist/protocol.mjs.map +3 -3
- package/dist/read.cjs +1 -1
- package/dist/read.cjs.map +2 -2
- package/dist/read.mjs +1 -1
- package/dist/read.mjs.map +2 -2
- package/dist/screenshot.d.cts +56 -7
- package/dist/screenshot.d.mts +56 -7
- package/dist/screenshot.d.ts +56 -7
- package/dist/selection.cjs +5 -0
- package/dist/selection.cjs.map +7 -0
- package/dist/selection.d.cts +27 -0
- package/dist/selection.d.mts +27 -0
- package/dist/selection.d.ts +27 -0
- package/dist/selection.mjs +5 -0
- package/dist/selection.mjs.map +7 -0
- package/dist/session.d.cts +17 -0
- package/dist/session.d.mts +17 -0
- package/dist/session.d.ts +17 -0
- package/dist/shortcut.d.cts +52 -0
- package/dist/shortcut.d.mts +52 -0
- package/dist/shortcut.d.ts +52 -0
- package/dist/toolbar.d.cts +13 -0
- package/dist/toolbar.d.mts +13 -0
- package/dist/toolbar.d.ts +13 -0
- package/dist/transport.d.cts +10 -0
- package/dist/transport.d.mts +10 -0
- package/dist/transport.d.ts +10 -0
- package/package.json +45 -10
package/dist/protocol.d.cts
CHANGED
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
* here is a change to both — say so in the PR.
|
|
8
8
|
*/
|
|
9
9
|
export declare const SDK_VERSION = "1";
|
|
10
|
+
/**
|
|
11
|
+
* A hosted document declares first-party annotation support with this exact
|
|
12
|
+
* response header. It describes the BUILD, never a token, cookie or attempt.
|
|
13
|
+
*/
|
|
14
|
+
export declare const ANNOTATION_MODE_HEADER = "x-weatherboard-annotation-mode";
|
|
15
|
+
export declare const FIRST_PARTY_ANNOTATION_MODE = "first-party";
|
|
10
16
|
/**
|
|
11
17
|
* ===========================================================================
|
|
12
18
|
* THE ATTRIBUTES — THE OTHER WIRE (A-186)
|
|
@@ -162,9 +168,21 @@ export interface AnnotationPayload {
|
|
|
162
168
|
failedRequests: NetworkEntry[];
|
|
163
169
|
featureFlags: Record<string, unknown>;
|
|
164
170
|
};
|
|
171
|
+
/**
|
|
172
|
+
* `w`/`h` and `canvases` arrive with the in-page viewport capture (A-2385);
|
|
173
|
+
* `canvases` counts the canvases in view and how many read back blank or
|
|
174
|
+
* unreadable, so a black rectangle in the picture is explained, not silent.
|
|
175
|
+
*/
|
|
165
176
|
screenshot?: {
|
|
166
177
|
dataUrl: string;
|
|
167
178
|
region: "element" | "viewport";
|
|
179
|
+
w?: number;
|
|
180
|
+
h?: number;
|
|
181
|
+
canvases?: {
|
|
182
|
+
total: number;
|
|
183
|
+
blank: number;
|
|
184
|
+
unreadable: number;
|
|
185
|
+
};
|
|
168
186
|
};
|
|
169
187
|
/** The human's words, verbatim. The parent frame fills this in; we never rewrite it. */
|
|
170
188
|
comment: string;
|
|
@@ -189,6 +207,7 @@ export type FrameToParent = {
|
|
|
189
207
|
} | {
|
|
190
208
|
type: "annotate:cancel";
|
|
191
209
|
};
|
|
210
|
+
export declare function unhandledMessage(half: "host" | "frame", message: unknown): void;
|
|
192
211
|
/**
|
|
193
212
|
* Is this a message we recognise?
|
|
194
213
|
*
|
package/dist/protocol.d.mts
CHANGED
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
* here is a change to both — say so in the PR.
|
|
8
8
|
*/
|
|
9
9
|
export declare const SDK_VERSION = "1";
|
|
10
|
+
/**
|
|
11
|
+
* A hosted document declares first-party annotation support with this exact
|
|
12
|
+
* response header. It describes the BUILD, never a token, cookie or attempt.
|
|
13
|
+
*/
|
|
14
|
+
export declare const ANNOTATION_MODE_HEADER = "x-weatherboard-annotation-mode";
|
|
15
|
+
export declare const FIRST_PARTY_ANNOTATION_MODE = "first-party";
|
|
10
16
|
/**
|
|
11
17
|
* ===========================================================================
|
|
12
18
|
* THE ATTRIBUTES — THE OTHER WIRE (A-186)
|
|
@@ -162,9 +168,21 @@ export interface AnnotationPayload {
|
|
|
162
168
|
failedRequests: NetworkEntry[];
|
|
163
169
|
featureFlags: Record<string, unknown>;
|
|
164
170
|
};
|
|
171
|
+
/**
|
|
172
|
+
* `w`/`h` and `canvases` arrive with the in-page viewport capture (A-2385);
|
|
173
|
+
* `canvases` counts the canvases in view and how many read back blank or
|
|
174
|
+
* unreadable, so a black rectangle in the picture is explained, not silent.
|
|
175
|
+
*/
|
|
165
176
|
screenshot?: {
|
|
166
177
|
dataUrl: string;
|
|
167
178
|
region: "element" | "viewport";
|
|
179
|
+
w?: number;
|
|
180
|
+
h?: number;
|
|
181
|
+
canvases?: {
|
|
182
|
+
total: number;
|
|
183
|
+
blank: number;
|
|
184
|
+
unreadable: number;
|
|
185
|
+
};
|
|
168
186
|
};
|
|
169
187
|
/** The human's words, verbatim. The parent frame fills this in; we never rewrite it. */
|
|
170
188
|
comment: string;
|
|
@@ -189,6 +207,7 @@ export type FrameToParent = {
|
|
|
189
207
|
} | {
|
|
190
208
|
type: "annotate:cancel";
|
|
191
209
|
};
|
|
210
|
+
export declare function unhandledMessage(half: "host" | "frame", message: unknown): void;
|
|
192
211
|
/**
|
|
193
212
|
* Is this a message we recognise?
|
|
194
213
|
*
|
package/dist/protocol.d.ts
CHANGED
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
* here is a change to both — say so in the PR.
|
|
8
8
|
*/
|
|
9
9
|
export declare const SDK_VERSION = "1";
|
|
10
|
+
/**
|
|
11
|
+
* A hosted document declares first-party annotation support with this exact
|
|
12
|
+
* response header. It describes the BUILD, never a token, cookie or attempt.
|
|
13
|
+
*/
|
|
14
|
+
export declare const ANNOTATION_MODE_HEADER = "x-weatherboard-annotation-mode";
|
|
15
|
+
export declare const FIRST_PARTY_ANNOTATION_MODE = "first-party";
|
|
10
16
|
/**
|
|
11
17
|
* ===========================================================================
|
|
12
18
|
* THE ATTRIBUTES — THE OTHER WIRE (A-186)
|
|
@@ -162,9 +168,21 @@ export interface AnnotationPayload {
|
|
|
162
168
|
failedRequests: NetworkEntry[];
|
|
163
169
|
featureFlags: Record<string, unknown>;
|
|
164
170
|
};
|
|
171
|
+
/**
|
|
172
|
+
* `w`/`h` and `canvases` arrive with the in-page viewport capture (A-2385);
|
|
173
|
+
* `canvases` counts the canvases in view and how many read back blank or
|
|
174
|
+
* unreadable, so a black rectangle in the picture is explained, not silent.
|
|
175
|
+
*/
|
|
165
176
|
screenshot?: {
|
|
166
177
|
dataUrl: string;
|
|
167
178
|
region: "element" | "viewport";
|
|
179
|
+
w?: number;
|
|
180
|
+
h?: number;
|
|
181
|
+
canvases?: {
|
|
182
|
+
total: number;
|
|
183
|
+
blank: number;
|
|
184
|
+
unreadable: number;
|
|
185
|
+
};
|
|
168
186
|
};
|
|
169
187
|
/** The human's words, verbatim. The parent frame fills this in; we never rewrite it. */
|
|
170
188
|
comment: string;
|
|
@@ -189,6 +207,7 @@ export type FrameToParent = {
|
|
|
189
207
|
} | {
|
|
190
208
|
type: "annotate:cancel";
|
|
191
209
|
};
|
|
210
|
+
export declare function unhandledMessage(half: "host" | "frame", message: unknown): void;
|
|
192
211
|
/**
|
|
193
212
|
* Is this a message we recognise?
|
|
194
213
|
*
|
package/dist/protocol.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
var
|
|
1
|
+
var A="1",m="x-weatherboard-annotation-mode",d="first-party",f="data-src",T="data-src-name";function N(t){return t.selection==="region"?"region":"element"}function a(t){try{return JSON.stringify(t)??String(t)}catch{return"[unserialisable]"}}function _(t,e){console.error(`[annotation] ${t}: protocol drift, nothing was acted on: ${a(e)}`)}var i=new Set(["annotate:enable","annotate:disable","annotate:ping"]);function x(t){if(typeof t!="object"||t===null)return!1;let e=t.type;return typeof e=="string"&&i.has(e)}function O(t){if(!t||t==="*")throw new Error("[annotation] a concrete parent origin is required; '*' would broadcast annotation payloads to any embedder.");return t}var s="ai_annotate",r="ai_annotate_armed",E="ai_capture",b="ai_run",R="ai_read",c="1";function y(t=r){return`${t}=${c}; Path=/; SameSite=Lax; Secure`}function h(t){return`${t}=; Path=/; SameSite=Lax; Secure; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT`}var u="pits_annotate",p="pits_annotate_armed",g=[s,u],l=[r,p];function I(t){return t.split(";").some(e=>{let n=e.trimStart();return l.some(o=>n.startsWith(`${o}=`))})}function S(t){for(let e of g){let n=t.get(e);if(n!==null&&n.length>0)return n}return null}export{r as ANNOTATION_ARMED_COOKIE,l as ANNOTATION_ARMED_COOKIES,c as ANNOTATION_ARMED_VALUE,m as ANNOTATION_MODE_HEADER,s as ANNOTATION_TOKEN_PARAM,g as ANNOTATION_TOKEN_PARAMS,E as CAPTURE_TOKEN_PARAM,d as FIRST_PARTY_ANNOTATION_MODE,p as LEGACY_ANNOTATION_ARMED_COOKIE,u as LEGACY_ANNOTATION_TOKEN_PARAM,T as NAME_ATTR,R as READ_TOKEN_PARAM,b as REVIEW_RUN_PARAM,A as SDK_VERSION,f as SRC_ATTR,y as armedCookie,O as assertConcreteOrigin,h as clearedCookie,I as isAnnotationArmed,x as isParentMessage,S as readAnnotationToken,N as selectionOf,_ as unhandledMessage};
|
|
2
2
|
//# sourceMappingURL=protocol.mjs.map
|
package/dist/protocol.mjs.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/protocol.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * The annotation postMessage protocol (A-36).\n *\n * Mirrors `docs/contracts/types.ts`. That file is the\n * cross-component contract; this is the runtime copy the product bundle ships,\n * because the product cannot import from a docs directory. Changing a shape\n * here is a change to both \u2014 say so in the PR.\n */\n\nexport const SDK_VERSION = \"1\";\n\n/**\n * ===========================================================================\n * THE ATTRIBUTES \u2014 THE OTHER WIRE (A-186)\n * ===========================================================================\n *\n * These live in `protocol.ts`, not in `target.ts` where they are read, because\n * they are a contract between TWO PACKAGES that a consumer installs separately:\n * `@weatherboard/annotation-next` WRITES them at build time and this package\n * READS them at runtime. Two spellings of `data-src` is not a bug anybody\n * catches \u2014 the transform runs, the attributes appear, and every capture comes\n * back with no source ref at all, which is indistinguishable from the transform\n * not running.\n *\n * `annotation-next` imports these from `@weatherboard/annotation/protocol`\n * rather than declaring its own, which is why the subpath has no DOM in it: a\n * build tool importing the page SDK to learn two strings would be absurd.\n */\nexport const SRC_ATTR = \"data-src\";\nexport const NAME_ATTR = \"data-src-name\";\n\nexport interface SourceRef {\n file: string;\n line: number;\n column?: number;\n}\n\nexport interface BuildInfo {\n /** Mandatory. Feedback not pinned to a revision is ambiguous the moment someone merges. */\n gitSha: string;\n /**\n * Where this build came from.\n *\n * `\"development\"` was added in A-37, when the SDK was first mounted in a\n * real app and the two halves turned out not to agree: `resolveBuildInfo` in\n * `resolveBuildInfo` in the product's own Next config package \u2014 the module\n * that stamps the build-env variable, and the only producer of this field \u2014\n * emits\n * `production | preview | development`, mirroring `VERCEL_ENV`. This union\n * instead offered `\"staging\"`, which nothing produces: a staging deploy IS a\n * Vercel preview and reports itself as one. Widening rather than forcing the\n * mount to lie: a locally-annotated build calling itself a preview would put\n * someone's laptop in the corpus as though it were a deployment.\n */\n env: \"production\" | \"preview\" | \"staging\" | \"development\";\n branch?: string;\n /** Set when reviewing an agent's preview deploy, so feedback routes to that run's session. */\n previewRunId?: string;\n}\n\nexport interface ConsoleEntry {\n level: \"error\" | \"warn\";\n message: string;\n stack?: string;\n at: string;\n}\n\nexport interface NetworkEntry {\n url: string;\n method: string;\n status: number;\n at: string;\n}\n\nexport interface TargetInfo {\n /**\n * What the reviewer selected \u2014 one element, or an area (A-179).\n *\n * -------------------------------------------------------------------------\n * WHY THIS EXISTS RATHER THAN BEING INFERRED\n * -------------------------------------------------------------------------\n *\n * A region capture and an element capture are the same SHAPE: a bounding box,\n * a chain of source refs, a DOM path. Nothing in the values distinguishes\n * them, and the two mean opposite things about the chain \u2014 for an element it\n * is ANCESTORS, innermost first, so entry two contains entry one; for a\n * region it is SIBLINGS the rectangle happened to cross, which contain\n * nothing of each other.\n *\n * A reader that guesses will describe a region's siblings as a call-site\n * chain, which is a confident false statement about the page. `describeCapture`\n * hands that description to a model that writes acceptance criteria from it.\n *\n * -------------------------------------------------------------------------\n * WHY IT IS OPTIONAL, AND WHY ABSENT MEANS `element`\n * -------------------------------------------------------------------------\n *\n * The two halves of this protocol live in different repositories and deploy\n * independently (see the note on the legacy parameter names below). Every\n * capture in the store today, and every capture from a product bundle that\n * predates region selection, has no such field \u2014 and all of them are element\n * captures. Absent therefore has to mean `element`, or this field would\n * retroactively make the entire history ambiguous.\n *\n * Required-with-a-default would have the same effect and a worse failure: an\n * older bundle omits it, the type says it is there, and every reader believes\n * a value nobody sent.\n */\n selection?: \"element\" | \"region\";\n source?: SourceRef;\n /**\n * Every `data-src` from the clicked element up to the document root, innermost\n * first \u2014 an addition to the contract, and the reason is structural.\n *\n * Only host elements carry a ref (A-33), so `source` names the file that\n * *writes the markup*. For a composed design-system component that is\n * `MediaCard.tsx`, not the `GuideDetailCard.tsx` a human would say they\n * clicked. The chain is what lets the PM agent and the coding agent see both,\n * and pick the call site over the leaf when the feedback is about content\n * rather than the component itself.\n */\n sourceChain?: SourceRef[];\n componentName?: string;\n /** CSS selector fallback, for when no ancestor carries a ref at all. */\n domPath: string;\n boundingBox: { x: number; y: number; w: number; h: number };\n /** Truncated to 200 characters. */\n textContent?: string;\n}\n\n/**\n * What kind of capture this is, with the default applied once (A-189).\n *\n * `selection` is optional and **absent means `element`** \u2014 every capture taken\n * before A-179, and everything the managed product's live bundle emits today,\n * has no such field. Any other default would retroactively reclassify the whole\n * history as regions, silently.\n *\n * A function rather than a documented convention because the default is the\n * kind of thing two readers implement slightly differently and neither notices:\n * one writes `=== \"region\"`, another writes `?? \"element\"`, a third forgets and\n * treats `undefined` as falsy in a branch that meant something else. This is\n * the protocol's answer, and `compatibility.test.ts` replays every recorded\n * payload through it.\n */\nexport function selectionOf(target: Pick<TargetInfo, \"selection\">): \"element\" | \"region\" {\n return target.selection === \"region\" ? \"region\" : \"element\";\n}\n\nexport interface AnnotationPayload {\n schemaVersion: 1;\n capturedAt: string;\n build: BuildInfo;\n page: {\n url: string;\n /** The matched route pattern, not the resolved URL. */\n route: string;\n viewport: { w: number; h: number; dpr: number };\n userAgent: string;\n };\n target: TargetInfo;\n state: {\n consoleErrors: ConsoleEntry[];\n failedRequests: NetworkEntry[];\n featureFlags: Record<string, unknown>;\n };\n screenshot?: { dataUrl: string; region: \"element\" | \"viewport\" };\n /** The human's words, verbatim. The parent frame fills this in; we never rewrite it. */\n comment: string;\n}\n\nexport type ParentToFrame =\n | { type: \"annotate:enable\" }\n | { type: \"annotate:disable\" }\n | { type: \"annotate:ping\" };\n\nexport type FrameToParent =\n | { type: \"annotate:ready\"; sdkVersion: string; build: BuildInfo }\n | { type: \"annotate:hover\"; target: TargetInfo }\n | { type: \"annotate:capture\"; payload: AnnotationPayload }\n | { type: \"annotate:cancel\" };\n\nconst PARENT_TYPES = new Set([\"annotate:enable\", \"annotate:disable\", \"annotate:ping\"]);\n\n/**\n * Is this a message we recognise?\n *\n * Origin is checked separately and first \u2014 a well-formed message from the wrong\n * origin is the case this guard must not be trusted to catch.\n */\nexport function isParentMessage(data: unknown): data is ParentToFrame {\n if (typeof data !== \"object\" || data === null) return false;\n const type = (data as { type?: unknown }).type;\n return typeof type === \"string\" && PARENT_TYPES.has(type);\n}\n\n/**\n * Reject wildcards outright rather than treating them as \"any origin\".\n *\n * `postMessage(msg, \"*\")` broadcasts the payload to whatever happens to be\n * framing us, and an annotation payload carries page content, console errors and\n * failed request URLs. The spec's rule is no wildcards in either direction; this\n * makes it impossible to configure one by accident.\n */\nexport function assertConcreteOrigin(origin: string): string {\n if (!origin || origin === \"*\") {\n throw new Error(\n \"[annotation] a concrete parent origin is required; '*' would broadcast \" +\n \"annotation payloads to any embedder.\",\n );\n }\n return origin;\n}\n\n/**\n * The query parameter that arms the SDK in a managed product, and the cookie\n * that records it.\n *\n * THESE LIVE IN THE PROTOCOL BECAUSE THE TWO HALVES ARE NOW IN DIFFERENT\n * REPOSITORIES. The managed product's middleware looks for the parameter and\n * sets the cookie; the management app appends the parameter and the SDK reads\n * the cookie. While both halves were one monorepo, a test could read the other\n * side's source and assert the strings matched \u2014 that gate does not survive a\n * repository split, and a mismatch produces no error at all: the frame loads,\n * the cookie is never set, the SDK never boots, and the reviewer sees \"waiting\n * for the page to announce itself\" with nothing to go on.\n *\n * So they stop being two spellings that agree and become one exported\n * constant. A managed product that imports this package cannot drift from it.\n */\nexport const ANNOTATION_TOKEN_PARAM = \"ai_annotate\";\nexport const ANNOTATION_ARMED_COOKIE = \"ai_annotate_armed\";\n\n/**\n * The CAPTURE token's query parameter (A-240).\n *\n * -------------------------------------------------------------------------\n * WHY IT IS HERE AND NOT WHERE IT IS WRITTEN\n * -------------------------------------------------------------------------\n *\n * A second name on the same wire, and a second credential \u2014 see\n * `docs/plans/first-party-annotation.md` \u00A7 *Authentication*. `ai_annotate`\n * answers *should this page offer an annotation UI at all*; this one answers\n * *may this capture become an issue*. Different secrets, different lifetimes,\n * different blast radii, and deliberately not folded into one token.\n *\n * It lives beside `ANNOTATION_TOKEN_PARAM` for that constant's own reason,\n * stated at length above: the console WRITES it into a deep link and the\n * product's bundle READS it, the two halves ship from different repositories,\n * and a mismatch produces no error anywhere \u2014 the reviewer writes a post-it,\n * clicks send, and the ingest rejects an unsigned capture. One exported\n * constant is the only thing that makes those two spellings unable to drift.\n *\n * It has no legacy spelling because it has never had another one: nothing has\n * shipped it yet. It is exported here BEFORE its writer needs it precisely so\n * the writer never has a reason to spell it itself.\n */\nexport const CAPTURE_TOKEN_PARAM = \"ai_capture\";\n\n/**\n * The run under review, when the console knows of one (A-241).\n *\n * Third name on the same wire, here for the reason the two above are: the\n * console writes it and the product's bundle reads it, from two repositories.\n * `apps/manager/src/lib/request-changes.ts` spells it itself today and its own\n * comment asks for exactly this \u2014 the constant to move into the package so the\n * writer has no reason to spell it. **That deletion is not this slice's**\n * (A-243/A-244 are merged and settled), so for now there are two definitions of\n * one string, and `protocol.test.ts` is the only thing standing between them\n * and a silent divergence. Nothing about it is signed: the ingest already\n * treats it as attacker-supplied text and files on it rather than routing on it.\n */\nexport const REVIEW_RUN_PARAM = \"ai_run\";\n\n/**\n * The READ token's query parameter (A-277).\n *\n * ===========================================================================\n * WHY THE MARKER LAYER GETS ITS OWN CREDENTIAL AND NOT `ai_capture`\n * ===========================================================================\n *\n * Fourth name on the same wire, and the reason it is a fourth is A-276's\n * disclosure decision rather than tidiness. The capture token is minted for\n * WRITING and lives four hours so a reviewer can work through a session; reusing\n * it for the read would make one leaked deep link a write capability AND a read\n * capability over every reviewer's comments on that origin for the same window.\n * The scope is a signed field, `verifyCaptureToken` requires an expectation at\n * every call site, and the read endpoint refuses a write claim \u2014 so handing\n * `ai_capture` to the reader does not degrade to a read, it fails.\n *\n * The read claim's TTL is ONE hour rather than four (`READ_TOKEN_TTL_S`), which\n * is the practical consequence a reader of this constant should know: the\n * toolbar outlives the markers, so a reviewer who leaves a tab open all\n * afternoon will find the count line saying it cannot read before they find the\n * Send button broken. That is the intended ordering \u2014 the shorter credential is\n * the one that discloses other people's words.\n *\n * Exported here for the reason the three above are: the console writes it into a\n * deep link and the product's bundle reads it, from two repositories, and a\n * mismatch produces no error anywhere \u2014 just a page with no markers, which is\n * indistinguishable from nine other causes.\n */\nexport const READ_TOKEN_PARAM = \"ai_read\";\n\n/**\n * The arming cookie, as a `Set-Cookie` value (A-241).\n *\n * ===========================================================================\n * IT GOT SIMPLER BY MOVING FIRST-PARTY, AND THAT IS THE WHOLE POINT\n * ===========================================================================\n *\n * Framed, this had to be `SameSite=None; Secure` with no `Partitioned` \u2014 a\n * genuine cross-site cookie, which A-211 records would have broken on its own\n * under Chrome's third-party cookie work, independently of the auth problem\n * that killed the frame. Top-level at the product's own origin it is an\n * ordinary cookie and none of that applies.\n *\n * Each attribute, and why:\n *\n * - **`SameSite=Lax`.** The reviewer arrives by clicking *Request changes* in\n * the console, which is a top-level cross-site GET navigation \u2014 the one case\n * `Lax` still sends on. `Strict` would withhold the cookie on that very first\n * render, so the toolbar would be missing on the page the link opened and\n * appear only after an internal click; that reads as a broken deploy.\n * - **`Secure`.** It is a marker rather than a credential, but a marker that\n * travels in clear is a marker an attacker on the path can set.\n * - **`Path=/`.** Arming survives the reviewer navigating the whole app, which\n * is the point of a cookie rather than a URL parameter.\n * - **No `Max-Age` / `Expires`.** Session-scoped: closing the tab disarms.\n *\n * ===========================================================================\n * WHAT IS *NOT* HERE: `HttpOnly`, AND THE TOKEN\n * ===========================================================================\n *\n * No `HttpOnly`, deliberately. Page script both READS this (`isAnnotationArmed`\n * decides whether a toolbar exists) and CLEARS it (*Done* disarms). An\n * `HttpOnly` arming cookie makes *Done* impossible from the page, which is the\n * one control the reviewer has.\n *\n * That is affordable only because THE VALUE IS A MARKER AND NEVER THE TOKEN.\n * The value is a constant and there is no argument that supplies one: the\n * middleware compares the URL parameter against its secret and then calls this,\n * and what it stores is *yes*, not *what was presented*. The single parameter\n * is the cookie's NAME, and it exists only so a product mid-migration can write\n * the legacy spelling \u2014 it is not a value slot wearing a different label, and\n * `arming.test.ts` pins which side of the `=` it lands on.\n *\n * This is a shape argument, not a guarantee about callers. Nothing stops a\n * determined caller passing a secret as the name; what the signature removes is\n * the ACCIDENT \u2014 the middleware author with a verified token in hand and one\n * parameter available, reaching for the obvious.\n */\nexport const ANNOTATION_ARMED_VALUE = \"1\";\n\nexport function armedCookie(name: string = ANNOTATION_ARMED_COOKIE): string {\n return `${name}=${ANNOTATION_ARMED_VALUE}; Path=/; SameSite=Lax; Secure`;\n}\n\n/**\n * The same cookie, expired \u2014 what *Done* and a disarm write.\n *\n * `Max-Age=0` AND an `Expires` in the past, because they are read by different\n * things and the pair is what every browser honours. The path must match the\n * one set above or the browser stores a second cookie and the first survives \u2014\n * a *Done* button that does nothing and reports success.\n */\nexport function clearedCookie(name: string): string {\n return `${name}=; Path=/; SameSite=Lax; Secure; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT`;\n}\n\n/**\n * The names these used to have (A-98).\n *\n * -------------------------------------------------------------------------\n * WHY THE RENAME, AND WHY IT COULD NOT JUST BE DONE\n * -------------------------------------------------------------------------\n *\n * They were `pits_annotate` and `pits_annotate_armed` \u2014 one managed product's\n * initialism, in a contract every managed product's bundle has to implement.\n * Onboarding a second product meant asking an unrelated codebase to adopt the\n * first product's name in its cookies and its query strings. That is the bug\n * A-98 is about, and it is a bug in a wire protocol rather than in a variable\n * name, which is what makes it expensive.\n *\n * The two halves live in DIFFERENT REPOSITORIES and deploy independently. A\n * clean rename is therefore a breaking change to every product bundle\n * simultaneously, and its failure mode is silence: the frame loads, the cookie\n * is never set, the SDK never boots, and the reviewer sees \"waiting for the\n * page to announce itself\" with nothing to go on. Nothing throws, nothing logs,\n * and the natural reading is that the agent broke the site.\n *\n * -------------------------------------------------------------------------\n * THE MIGRATION: READ BOTH, WRITE BOTH, THEN STOP WRITING THE OLD ONE\n * -------------------------------------------------------------------------\n *\n * 1. **Now.** Every reader accepts either name. Every writer emits BOTH, so a\n * product still running the old bundle arms on the legacy name and one\n * running the new bundle arms on the new one. Deploy order stops mattering,\n * which is the whole point \u2014 there is no window in which a reviewer sees the\n * silent failure above.\n * 2. **After every managed product's bundle is on this version** (A-102). Drop\n * the legacy names from the writers, then from the readers. Two steps, not\n * one: a reader that stops accepting the old name before the writers stop\n * emitting it re-creates the outage in the other direction.\n *\n * The legacy names are exported rather than inlined so the deletion in step 2\n * is `git grep LEGACY_` rather than an archaeology exercise.\n */\nexport const LEGACY_ANNOTATION_TOKEN_PARAM = \"pits_annotate\";\nexport const LEGACY_ANNOTATION_ARMED_COOKIE = \"pits_annotate_armed\";\n\n/**\n * ===========================================================================\n * AND THE NAMES ARE NOT CHANGING AGAIN FOR THE PUBLISHED PACKAGE (A-184)\n * ===========================================================================\n *\n * When this becomes `@weatherboard/annotation` the obvious next thought is that\n * the wire names should match the package \u2014 `weatherboard_annotate`. They\n * should not, and the reason is the migration described above rather than\n * taste.\n *\n * That migration is STILL IN FLIGHT. Readers accept both spellings; writers\n * emit both; step 2 waits on every managed product's bundle being current.\n * A third spelling introduced now does not replace the second, it joins it:\n * every reader accepts three names, every writer emits three, and the window in\n * which a product can be on any of them widens instead of closing.\n *\n * The cost is not the code. It is that each rename buys another instance of the\n * failure this file exists to prevent \u2014 the frame loads, the cookie is never\n * set, the SDK never boots, and the reviewer sees \"waiting for the page to\n * announce itself\" with nothing to go on. Twice is a migration. Three times is\n * a habit, and the third one would be paid by consumers who never asked for\n * either.\n *\n * `ai_annotate` is already product-neutral, which was the entire point of A-98.\n * A published package needs names that name no product; it does not need names\n * that name the package. Nobody reading a query string cares which npm scope\n * the library came from.\n *\n * The one real objection is collision: `ai_` is a common prefix and some other\n * tool could plausibly use it. That is survivable by construction \u2014 arming\n * requires the parameter to carry a token the product compares exactly, so a\n * coincidental `?ai_annotate=` from an unrelated tool arms nothing. A colliding\n * COOKIE cannot false-positive either; `isAnnotationArmed` matches on the\n * delimiter rather than by substring, for exactly this reason.\n *\n * If a rename is ever genuinely warranted, it happens ONCE, after step 2 has\n * landed and the reader accepts one name again \u2014 not layered on top of an\n * unfinished one.\n */\n\n/** Both spellings of the query parameter, current first. */\nexport const ANNOTATION_TOKEN_PARAMS = [\n ANNOTATION_TOKEN_PARAM,\n LEGACY_ANNOTATION_TOKEN_PARAM,\n] as const;\n\n/** Both spellings of the cookie, current first. */\nexport const ANNOTATION_ARMED_COOKIES = [\n ANNOTATION_ARMED_COOKIE,\n LEGACY_ANNOTATION_ARMED_COOKIE,\n] as const;\n\n/**\n * Whether a cookie jar carries the armed cookie, under either name.\n *\n * Delimiter-aware rather than a substring test: a cookie named\n * `not_ai_annotate_armed`, or another cookie whose VALUE contains the name,\n * satisfies `includes` \u2014 and arming an annotation channel on a coincidence is\n * not a failure anyone would think to look for.\n */\nexport function isAnnotationArmed(cookie: string): boolean {\n return cookie\n .split(\";\")\n .some((entry) => {\n const trimmed = entry.trimStart();\n return ANNOTATION_ARMED_COOKIES.some((name) => trimmed.startsWith(`${name}=`));\n });\n}\n\n/**\n * The arming token from a URL's query string, under either name.\n *\n * Current name wins when both are present. They should never disagree \u2014 the\n * management app writes both from one value \u2014 but if they ever do, the one this\n * version of the protocol owns is the one to believe.\n */\nexport function readAnnotationToken(params: URLSearchParams): string | null {\n for (const name of ANNOTATION_TOKEN_PARAMS) {\n const value = params.get(name);\n if (value !== null && value.length > 0) return value;\n }\n return null;\n}\n"],
|
|
5
|
-
"mappings": "AASO,IAAMA,EAAc,
|
|
6
|
-
"names": ["SDK_VERSION", "SRC_ATTR", "NAME_ATTR", "selectionOf", "target", "PARENT_TYPES", "isParentMessage", "data", "type", "assertConcreteOrigin", "origin", "ANNOTATION_TOKEN_PARAM", "ANNOTATION_ARMED_COOKIE", "CAPTURE_TOKEN_PARAM", "REVIEW_RUN_PARAM", "READ_TOKEN_PARAM", "ANNOTATION_ARMED_VALUE", "armedCookie", "name", "clearedCookie", "LEGACY_ANNOTATION_TOKEN_PARAM", "LEGACY_ANNOTATION_ARMED_COOKIE", "ANNOTATION_TOKEN_PARAMS", "ANNOTATION_ARMED_COOKIES", "isAnnotationArmed", "cookie", "entry", "trimmed", "readAnnotationToken", "params", "value"]
|
|
4
|
+
"sourcesContent": ["/**\n * The annotation postMessage protocol (A-36).\n *\n * Mirrors `docs/contracts/types.ts`. That file is the\n * cross-component contract; this is the runtime copy the product bundle ships,\n * because the product cannot import from a docs directory. Changing a shape\n * here is a change to both \u2014 say so in the PR.\n */\n\nexport const SDK_VERSION = \"1\";\n\n/**\n * A hosted document declares first-party annotation support with this exact\n * response header. It describes the BUILD, never a token, cookie or attempt.\n */\nexport const ANNOTATION_MODE_HEADER = \"x-weatherboard-annotation-mode\";\nexport const FIRST_PARTY_ANNOTATION_MODE = \"first-party\";\n\n/**\n * ===========================================================================\n * THE ATTRIBUTES \u2014 THE OTHER WIRE (A-186)\n * ===========================================================================\n *\n * These live in `protocol.ts`, not in `target.ts` where they are read, because\n * they are a contract between TWO PACKAGES that a consumer installs separately:\n * `@weatherboard/annotation-next` WRITES them at build time and this package\n * READS them at runtime. Two spellings of `data-src` is not a bug anybody\n * catches \u2014 the transform runs, the attributes appear, and every capture comes\n * back with no source ref at all, which is indistinguishable from the transform\n * not running.\n *\n * `annotation-next` imports these from `@weatherboard/annotation/protocol`\n * rather than declaring its own, which is why the subpath has no DOM in it: a\n * build tool importing the page SDK to learn two strings would be absurd.\n */\nexport const SRC_ATTR = \"data-src\";\nexport const NAME_ATTR = \"data-src-name\";\n\nexport interface SourceRef {\n file: string;\n line: number;\n column?: number;\n}\n\nexport interface BuildInfo {\n /** Mandatory. Feedback not pinned to a revision is ambiguous the moment someone merges. */\n gitSha: string;\n /**\n * Where this build came from.\n *\n * `\"development\"` was added in A-37, when the SDK was first mounted in a\n * real app and the two halves turned out not to agree: `resolveBuildInfo` in\n * `resolveBuildInfo` in the product's own Next config package \u2014 the module\n * that stamps the build-env variable, and the only producer of this field \u2014\n * emits\n * `production | preview | development`, mirroring `VERCEL_ENV`. This union\n * instead offered `\"staging\"`, which nothing produces: a staging deploy IS a\n * Vercel preview and reports itself as one. Widening rather than forcing the\n * mount to lie: a locally-annotated build calling itself a preview would put\n * someone's laptop in the corpus as though it were a deployment.\n */\n env: \"production\" | \"preview\" | \"staging\" | \"development\";\n branch?: string;\n /** Set when reviewing an agent's preview deploy, so feedback routes to that run's session. */\n previewRunId?: string;\n}\n\nexport interface ConsoleEntry {\n level: \"error\" | \"warn\";\n message: string;\n stack?: string;\n at: string;\n}\n\nexport interface NetworkEntry {\n url: string;\n method: string;\n status: number;\n at: string;\n}\n\nexport interface TargetInfo {\n /**\n * What the reviewer selected \u2014 one element, or an area (A-179).\n *\n * -------------------------------------------------------------------------\n * WHY THIS EXISTS RATHER THAN BEING INFERRED\n * -------------------------------------------------------------------------\n *\n * A region capture and an element capture are the same SHAPE: a bounding box,\n * a chain of source refs, a DOM path. Nothing in the values distinguishes\n * them, and the two mean opposite things about the chain \u2014 for an element it\n * is ANCESTORS, innermost first, so entry two contains entry one; for a\n * region it is SIBLINGS the rectangle happened to cross, which contain\n * nothing of each other.\n *\n * A reader that guesses will describe a region's siblings as a call-site\n * chain, which is a confident false statement about the page. `describeCapture`\n * hands that description to a model that writes acceptance criteria from it.\n *\n * -------------------------------------------------------------------------\n * WHY IT IS OPTIONAL, AND WHY ABSENT MEANS `element`\n * -------------------------------------------------------------------------\n *\n * The two halves of this protocol live in different repositories and deploy\n * independently (see the note on the legacy parameter names below). Every\n * capture in the store today, and every capture from a product bundle that\n * predates region selection, has no such field \u2014 and all of them are element\n * captures. Absent therefore has to mean `element`, or this field would\n * retroactively make the entire history ambiguous.\n *\n * Required-with-a-default would have the same effect and a worse failure: an\n * older bundle omits it, the type says it is there, and every reader believes\n * a value nobody sent.\n */\n selection?: \"element\" | \"region\";\n source?: SourceRef;\n /**\n * Every `data-src` from the clicked element up to the document root, innermost\n * first \u2014 an addition to the contract, and the reason is structural.\n *\n * Only host elements carry a ref (A-33), so `source` names the file that\n * *writes the markup*. For a composed design-system component that is\n * `MediaCard.tsx`, not the `GuideDetailCard.tsx` a human would say they\n * clicked. The chain is what lets the PM agent and the coding agent see both,\n * and pick the call site over the leaf when the feedback is about content\n * rather than the component itself.\n */\n sourceChain?: SourceRef[];\n componentName?: string;\n /** CSS selector fallback, for when no ancestor carries a ref at all. */\n domPath: string;\n boundingBox: { x: number; y: number; w: number; h: number };\n /** Truncated to 200 characters. */\n textContent?: string;\n}\n\n/**\n * What kind of capture this is, with the default applied once (A-189).\n *\n * `selection` is optional and **absent means `element`** \u2014 every capture taken\n * before A-179, and everything the managed product's live bundle emits today,\n * has no such field. Any other default would retroactively reclassify the whole\n * history as regions, silently.\n *\n * A function rather than a documented convention because the default is the\n * kind of thing two readers implement slightly differently and neither notices:\n * one writes `=== \"region\"`, another writes `?? \"element\"`, a third forgets and\n * treats `undefined` as falsy in a branch that meant something else. This is\n * the protocol's answer, and `compatibility.test.ts` replays every recorded\n * payload through it.\n */\nexport function selectionOf(target: Pick<TargetInfo, \"selection\">): \"element\" | \"region\" {\n return target.selection === \"region\" ? \"region\" : \"element\";\n}\n\nexport interface AnnotationPayload {\n schemaVersion: 1;\n capturedAt: string;\n build: BuildInfo;\n page: {\n url: string;\n /** The matched route pattern, not the resolved URL. */\n route: string;\n viewport: { w: number; h: number; dpr: number };\n userAgent: string;\n };\n target: TargetInfo;\n state: {\n consoleErrors: ConsoleEntry[];\n failedRequests: NetworkEntry[];\n featureFlags: Record<string, unknown>;\n };\n /**\n * `w`/`h` and `canvases` arrive with the in-page viewport capture (A-2385);\n * `canvases` counts the canvases in view and how many read back blank or\n * unreadable, so a black rectangle in the picture is explained, not silent.\n */\n screenshot?: {\n dataUrl: string;\n region: \"element\" | \"viewport\";\n w?: number;\n h?: number;\n canvases?: { total: number; blank: number; unreadable: number };\n };\n /** The human's words, verbatim. The parent frame fills this in; we never rewrite it. */\n comment: string;\n}\n\nexport type ParentToFrame =\n | { type: \"annotate:enable\" }\n | { type: \"annotate:disable\" }\n | { type: \"annotate:ping\" };\n\nexport type FrameToParent =\n | { type: \"annotate:ready\"; sdkVersion: string; build: BuildInfo }\n | { type: \"annotate:hover\"; target: TargetInfo }\n | { type: \"annotate:capture\"; payload: AnnotationPayload }\n | { type: \"annotate:cancel\" };\n\n/**\n * A message that got past both guards and matched no `case` (A-359).\n *\n * ===========================================================================\n * THE DEFECT\n * ===========================================================================\n *\n * `host.ts` and `sdk.ts` hold the two halves of this protocol, one `switch`\n * each, and neither had a `default`. Measured before the fix: adding\n * `| { type: \"annotate:probe\" }` to `ParentToFrame` and\n * `| { type: \"annotate:pong\" }` to `FrameToParent`, then running\n * `tsc --noEmit` in this package, gave exit 0 and zero errors. A message type\n * added to the union is dropped on the floor by whichever half was not\n * updated, and the two halves are the only things in the world that can\n * disagree about what a message means.\n *\n * ===========================================================================\n * WHY IT DOES NOT THROW, WHERE THE MANAGER'S TWO EQUIVALENTS DO\n * ===========================================================================\n *\n * This is the house pattern with one deliberate departure, and the departure is\n * the boundary rather than taste.\n *\n * Both switches run inside a `message` listener, reached from `postMessage`.\n * The host's listener runs in the management console over a frame that may be\n * serving anything; the SDK's runs in a product page we do not own. A throw\n * there does not propagate to a caller \u2014 there is no caller \u2014 it becomes an\n * uncaught error on somebody else's page, once per message. Any party that can\n * reach this branch can therefore reach it repeatedly, and the loudest\n * available answer would be handing them a way to fill a product's error\n * reporting with our noise.\n *\n * The narrower reason the throw would buy little: `isFrameMessage` and\n * `isParentMessage` gate on fixed sets of type strings, so a message an\n * outsider invents never reaches the switch at all. What DOES reach it is a\n * type in the guard's set with no `case` \u2014 which is our own two lists having\n * drifted apart, in this repository, in the commit that widened one of them.\n *\n * So the answer is: act on nothing, and say so. Acting on nothing is what a\n * trust boundary owes an input it does not understand; saying so is what makes\n * this different from the silent drop that was there before, which is\n * indistinguishable from the message never having been sent.\n *\n * `console.error` rather than a callback, because both call sites are inside\n * listeners with no error channel of their own, and because in the frame the\n * SDK's own `StateCollector` patches `console.error` \u2014 so the drift is\n * collected into the very next annotation payload, where somebody debugging\n * exactly this will already be looking.\n *\n * The type-level half of the guard is NOT here. It is `const unhandled: never`\n * in each `default` branch, which is what makes a widened union a compile\n * error; this function only makes the runtime half observable, and it is\n * exported so `exhaustive.test.ts` can call it. Both halves are pinned\n * separately, because deleting either leaves the other looking correct.\n */\n/**\n * `JSON.stringify` that cannot throw, for any input at all (A-359 cycle 2).\n *\n * ===========================================================================\n * WHY THIS IS NEEDED \u2014 THE DEFECT IT REPLACES\n * ===========================================================================\n *\n * `unhandledMessage` interpolated `JSON.stringify(message)` unguarded, and so\n * became the thing it was written to prevent. `postMessage` serialises with\n * the STRUCTURED CLONE algorithm, and structured clone PRESERVES CYCLES where\n * JSON cannot represent them \u2014 so `{a: {b: a}}` survives a `postMessage`\n * intact, arrives at that branch, and `JSON.stringify` throws `Converting\n * circular structure to JSON` from inside a `message` listener on a page this\n * SDK is a guest on, once per message, at the sender's discretion.\n *\n * Every word of the argument on `unhandledMessage` was correct, and the\n * implementation defeated it on its own terms. Both cases are pinned in\n * `exhaustive.test.ts` and both were confirmed RED before this existed.\n *\n * ===========================================================================\n * WHY NOT `describeArg`, WHICH DOES THIS ALREADY \u2014 TWO REASONS, ONE MEASURED\n * ===========================================================================\n *\n * `describeArg` in `state.ts` solves the same problem and its comment names\n * the same hazard, so reusing it was the first thing tried rather than a road\n * not taken. It was reverted for two reasons, and the second is the one that\n * decided it:\n *\n * 1. MEASURED, by running `node build.mjs` on both: reuse costs protocol\n * 869 -> 1008 (+139) and host 649 -> 793 (+144). This local version costs\n * +39 and +37. `describeArg` is built for console ARGUMENTS \u2014 strings,\n * Errors, a prototype walk for named objects \u2014 and almost none of that is\n * reachable for a wire message, so the extra 100 bytes per entry point buy\n * nothing here. In a package with a per-entry gzip budget that is not a\n * rounding error.\n * 2. THIS MODULE HAS NO IMPORTS AND THAT IS A PROPERTY WORTH KEEPING.\n * `protocol.ts` is the pure wire contract \u2014 it mirrors\n * `docs/contracts/types.ts`, `annotation-next` imports it so a build tool\n * can learn two strings without pulling in a DOM, and `state.ts` already\n * imports FROM it. Importing back the other way makes the contract depend\n * on the console-patching state collector and closes a module cycle that\n * is only invisible because `state.ts`'s side of it is `import type`.\n *\n * The cost of the choice, stated rather than left for somebody to find: there\n * are now two safe-stringify helpers in this package. They cannot drift in a\n * way that matters \u2014 each is a total function with one behaviour and its own\n * test \u2014 but if a third appears, that is the moment to put one in a module\n * both can depend on, rather than making the contract depend on the collector.\n *\n * The fallback is a constant, deliberately. Reaching for `String(message)` or\n * a constructor name in the `catch` is the obvious way to say something more\n * useful, and it is how this function would learn to throw again: the input\n * that got us here is one whose property access already threw.\n */\nfunction safeJson(message: unknown): string {\n try {\n return JSON.stringify(message) ?? String(message);\n } catch {\n return \"[unserialisable]\";\n }\n}\n\nexport function unhandledMessage(half: \"host\" | \"frame\", message: unknown): void {\n /**\n * `safeJson` RATHER THAN A BARE `JSON.stringify`, AND THAT IS THE WHOLE\n * DIFFERENCE BETWEEN THIS FUNCTION AND ITS FIRST DRAFT (A-359 cycle 2).\n *\n * The first draft interpolated `JSON.stringify(message)` unguarded, which\n * made this function do the thing it exists to avoid. The full argument, the\n * measurement, and why `describeArg` was tried and reverted are on\n * `safeJson` above rather than repeated here.\n *\n * Short on purpose too, and the shortness is measured rather than stylistic.\n * This package ships inside a product's bundle and this package's own\n * `build.mjs` enforces a gzip budget per entry point; the first draft of this\n * sentence was 230 characters and put `protocol`, `host` and `index` over\n * theirs at once. What it must still carry is which half, what arrived, and\n * that nothing was done with it \u2014 the last being the only thing that\n * distinguishes this from the silent drop it replaced.\n */\n console.error(`[annotation] ${half}: protocol drift, nothing was acted on: ${safeJson(message)}`);\n}\n\nconst PARENT_TYPES = new Set([\"annotate:enable\", \"annotate:disable\", \"annotate:ping\"]);\n\n/**\n * Is this a message we recognise?\n *\n * Origin is checked separately and first \u2014 a well-formed message from the wrong\n * origin is the case this guard must not be trusted to catch.\n */\nexport function isParentMessage(data: unknown): data is ParentToFrame {\n if (typeof data !== \"object\" || data === null) return false;\n const type = (data as { type?: unknown }).type;\n return typeof type === \"string\" && PARENT_TYPES.has(type);\n}\n\n/**\n * Reject wildcards outright rather than treating them as \"any origin\".\n *\n * `postMessage(msg, \"*\")` broadcasts the payload to whatever happens to be\n * framing us, and an annotation payload carries page content, console errors and\n * failed request URLs. The spec's rule is no wildcards in either direction; this\n * makes it impossible to configure one by accident.\n */\nexport function assertConcreteOrigin(origin: string): string {\n if (!origin || origin === \"*\") {\n throw new Error(\n \"[annotation] a concrete parent origin is required; '*' would broadcast \" +\n \"annotation payloads to any embedder.\",\n );\n }\n return origin;\n}\n\n/**\n * The query parameter that arms the SDK in a managed product, and the cookie\n * that records it.\n *\n * THESE LIVE IN THE PROTOCOL BECAUSE THE TWO HALVES ARE NOW IN DIFFERENT\n * REPOSITORIES. The managed product's middleware looks for the parameter and\n * sets the cookie; the management app appends the parameter and the SDK reads\n * the cookie. While both halves were one monorepo, a test could read the other\n * side's source and assert the strings matched \u2014 that gate does not survive a\n * repository split, and a mismatch produces no error at all: the frame loads,\n * the cookie is never set, the SDK never boots, and the reviewer sees \"waiting\n * for the page to announce itself\" with nothing to go on.\n *\n * So they stop being two spellings that agree and become one exported\n * constant. A managed product that imports this package cannot drift from it.\n */\nexport const ANNOTATION_TOKEN_PARAM = \"ai_annotate\";\nexport const ANNOTATION_ARMED_COOKIE = \"ai_annotate_armed\";\n\n/**\n * The CAPTURE token's query parameter (A-240).\n *\n * -------------------------------------------------------------------------\n * WHY IT IS HERE AND NOT WHERE IT IS WRITTEN\n * -------------------------------------------------------------------------\n *\n * A second name on the same wire, and a second credential \u2014 see\n * `docs/plans/first-party-annotation.md` \u00A7 *Authentication*. `ai_annotate`\n * answers *should this page offer an annotation UI at all*; this one answers\n * *may this capture become an issue*. Different secrets, different lifetimes,\n * different blast radii, and deliberately not folded into one token.\n *\n * It lives beside `ANNOTATION_TOKEN_PARAM` for that constant's own reason,\n * stated at length above: the console WRITES it into a deep link and the\n * product's bundle READS it, the two halves ship from different repositories,\n * and a mismatch produces no error anywhere \u2014 the reviewer writes a post-it,\n * clicks send, and the ingest rejects an unsigned capture. One exported\n * constant is the only thing that makes those two spellings unable to drift.\n *\n * It has no legacy spelling because it has never had another one: nothing has\n * shipped it yet. It is exported here BEFORE its writer needs it precisely so\n * the writer never has a reason to spell it itself.\n */\nexport const CAPTURE_TOKEN_PARAM = \"ai_capture\";\n\n/**\n * The run under review, when the console knows of one (A-241).\n *\n * Third name on the same wire, here for the reason the two above are: the\n * console writes it and the product's bundle reads it, from two repositories.\n * `apps/manager/src/lib/request-changes.ts` spells it itself today and its own\n * comment asks for exactly this \u2014 the constant to move into the package so the\n * writer has no reason to spell it. **That deletion is not this slice's**\n * (A-243/A-244 are merged and settled), so for now there are two definitions of\n * one string, and `protocol.test.ts` is the only thing standing between them\n * and a silent divergence. Nothing about it is signed: the ingest already\n * treats it as attacker-supplied text and files on it rather than routing on it.\n */\nexport const REVIEW_RUN_PARAM = \"ai_run\";\n\n/**\n * The READ token's query parameter (A-277).\n *\n * ===========================================================================\n * WHY THE MARKER LAYER GETS ITS OWN CREDENTIAL AND NOT `ai_capture`\n * ===========================================================================\n *\n * Fourth name on the same wire, and the reason it is a fourth is A-276's\n * disclosure decision rather than tidiness. The capture token is minted for\n * WRITING and lives four hours so a reviewer can work through a session; reusing\n * it for the read would make one leaked deep link a write capability AND a read\n * capability over every reviewer's comments on that origin for the same window.\n * The scope is a signed field, `verifyCaptureToken` requires an expectation at\n * every call site, and the read endpoint refuses a write claim \u2014 so handing\n * `ai_capture` to the reader does not degrade to a read, it fails.\n *\n * The read claim's TTL is ONE hour rather than four (`READ_TOKEN_TTL_S`), which\n * is the practical consequence a reader of this constant should know: the\n * toolbar outlives the markers, so a reviewer who leaves a tab open all\n * afternoon will find the count line saying it cannot read before they find the\n * Send button broken. That is the intended ordering \u2014 the shorter credential is\n * the one that discloses other people's words.\n *\n * Exported here for the reason the three above are: the console writes it into a\n * deep link and the product's bundle reads it, from two repositories, and a\n * mismatch produces no error anywhere \u2014 just a page with no markers, which is\n * indistinguishable from nine other causes.\n */\nexport const READ_TOKEN_PARAM = \"ai_read\";\n\n/**\n * The arming cookie, as a `Set-Cookie` value (A-241).\n *\n * ===========================================================================\n * IT GOT SIMPLER BY MOVING FIRST-PARTY, AND THAT IS THE WHOLE POINT\n * ===========================================================================\n *\n * Framed, this had to be `SameSite=None; Secure` with no `Partitioned` \u2014 a\n * genuine cross-site cookie, which A-211 records would have broken on its own\n * under Chrome's third-party cookie work, independently of the auth problem\n * that killed the frame. Top-level at the product's own origin it is an\n * ordinary cookie and none of that applies.\n *\n * Each attribute, and why:\n *\n * - **`SameSite=Lax`.** The reviewer arrives by clicking *Request changes* in\n * the console, which is a top-level cross-site GET navigation \u2014 the one case\n * `Lax` still sends on. `Strict` would withhold the cookie on that very first\n * render, so the toolbar would be missing on the page the link opened and\n * appear only after an internal click; that reads as a broken deploy.\n * - **`Secure`.** It is a marker rather than a credential, but a marker that\n * travels in clear is a marker an attacker on the path can set.\n * - **`Path=/`.** Arming survives the reviewer navigating the whole app, which\n * is the point of a cookie rather than a URL parameter.\n * - **No `Max-Age` / `Expires`.** Session-scoped: closing the tab disarms.\n *\n * ===========================================================================\n * WHAT IS *NOT* HERE: `HttpOnly`, AND THE TOKEN\n * ===========================================================================\n *\n * No `HttpOnly`, deliberately. Page script both READS this (`isAnnotationArmed`\n * decides whether a toolbar exists) and CLEARS it (*Done* disarms). An\n * `HttpOnly` arming cookie makes *Done* impossible from the page, which is the\n * one control the reviewer has.\n *\n * That is affordable only because THE VALUE IS A MARKER AND NEVER THE TOKEN.\n * The value is a constant and there is no argument that supplies one: the\n * middleware compares the URL parameter against its secret and then calls this,\n * and what it stores is *yes*, not *what was presented*. The single parameter\n * is the cookie's NAME, and it exists only so a product mid-migration can write\n * the legacy spelling \u2014 it is not a value slot wearing a different label, and\n * `arming.test.ts` pins which side of the `=` it lands on.\n *\n * This is a shape argument, not a guarantee about callers. Nothing stops a\n * determined caller passing a secret as the name; what the signature removes is\n * the ACCIDENT \u2014 the middleware author with a verified token in hand and one\n * parameter available, reaching for the obvious.\n */\nexport const ANNOTATION_ARMED_VALUE = \"1\";\n\nexport function armedCookie(name: string = ANNOTATION_ARMED_COOKIE): string {\n return `${name}=${ANNOTATION_ARMED_VALUE}; Path=/; SameSite=Lax; Secure`;\n}\n\n/**\n * The same cookie, expired \u2014 what *Done* and a disarm write.\n *\n * `Max-Age=0` AND an `Expires` in the past, because they are read by different\n * things and the pair is what every browser honours. The path must match the\n * one set above or the browser stores a second cookie and the first survives \u2014\n * a *Done* button that does nothing and reports success.\n */\nexport function clearedCookie(name: string): string {\n return `${name}=; Path=/; SameSite=Lax; Secure; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT`;\n}\n\n/**\n * The names these used to have (A-98).\n *\n * -------------------------------------------------------------------------\n * WHY THE RENAME, AND WHY IT COULD NOT JUST BE DONE\n * -------------------------------------------------------------------------\n *\n * They were `pits_annotate` and `pits_annotate_armed` \u2014 one managed product's\n * initialism, in a contract every managed product's bundle has to implement.\n * Onboarding a second product meant asking an unrelated codebase to adopt the\n * first product's name in its cookies and its query strings. That is the bug\n * A-98 is about, and it is a bug in a wire protocol rather than in a variable\n * name, which is what makes it expensive.\n *\n * The two halves live in DIFFERENT REPOSITORIES and deploy independently. A\n * clean rename is therefore a breaking change to every product bundle\n * simultaneously, and its failure mode is silence: the frame loads, the cookie\n * is never set, the SDK never boots, and the reviewer sees \"waiting for the\n * page to announce itself\" with nothing to go on. Nothing throws, nothing logs,\n * and the natural reading is that the agent broke the site.\n *\n * -------------------------------------------------------------------------\n * THE MIGRATION: READ BOTH, WRITE BOTH, THEN STOP WRITING THE OLD ONE\n * -------------------------------------------------------------------------\n *\n * 1. **Now.** Every reader accepts either name. Every writer emits BOTH, so a\n * product still running the old bundle arms on the legacy name and one\n * running the new bundle arms on the new one. Deploy order stops mattering,\n * which is the whole point \u2014 there is no window in which a reviewer sees the\n * silent failure above.\n * 2. **After every managed product's bundle is on this version** (A-102). Drop\n * the legacy names from the writers, then from the readers. Two steps, not\n * one: a reader that stops accepting the old name before the writers stop\n * emitting it re-creates the outage in the other direction.\n *\n * The legacy names are exported rather than inlined so the deletion in step 2\n * is `git grep LEGACY_` rather than an archaeology exercise.\n */\nexport const LEGACY_ANNOTATION_TOKEN_PARAM = \"pits_annotate\";\nexport const LEGACY_ANNOTATION_ARMED_COOKIE = \"pits_annotate_armed\";\n\n/**\n * ===========================================================================\n * AND THE NAMES ARE NOT CHANGING AGAIN FOR THE PUBLISHED PACKAGE (A-184)\n * ===========================================================================\n *\n * When this becomes `@weatherboard/annotation` the obvious next thought is that\n * the wire names should match the package \u2014 `weatherboard_annotate`. They\n * should not, and the reason is the migration described above rather than\n * taste.\n *\n * That migration is STILL IN FLIGHT. Readers accept both spellings; writers\n * emit both; step 2 waits on every managed product's bundle being current.\n * A third spelling introduced now does not replace the second, it joins it:\n * every reader accepts three names, every writer emits three, and the window in\n * which a product can be on any of them widens instead of closing.\n *\n * The cost is not the code. It is that each rename buys another instance of the\n * failure this file exists to prevent \u2014 the frame loads, the cookie is never\n * set, the SDK never boots, and the reviewer sees \"waiting for the page to\n * announce itself\" with nothing to go on. Twice is a migration. Three times is\n * a habit, and the third one would be paid by consumers who never asked for\n * either.\n *\n * `ai_annotate` is already product-neutral, which was the entire point of A-98.\n * A published package needs names that name no product; it does not need names\n * that name the package. Nobody reading a query string cares which npm scope\n * the library came from.\n *\n * The one real objection is collision: `ai_` is a common prefix and some other\n * tool could plausibly use it. That is survivable by construction \u2014 arming\n * requires the parameter to carry a token the product compares exactly, so a\n * coincidental `?ai_annotate=` from an unrelated tool arms nothing. A colliding\n * COOKIE cannot false-positive either; `isAnnotationArmed` matches on the\n * delimiter rather than by substring, for exactly this reason.\n *\n * If a rename is ever genuinely warranted, it happens ONCE, after step 2 has\n * landed and the reader accepts one name again \u2014 not layered on top of an\n * unfinished one.\n */\n\n/** Both spellings of the query parameter, current first. */\nexport const ANNOTATION_TOKEN_PARAMS = [\n ANNOTATION_TOKEN_PARAM,\n LEGACY_ANNOTATION_TOKEN_PARAM,\n] as const;\n\n/** Both spellings of the cookie, current first. */\nexport const ANNOTATION_ARMED_COOKIES = [\n ANNOTATION_ARMED_COOKIE,\n LEGACY_ANNOTATION_ARMED_COOKIE,\n] as const;\n\n/**\n * Whether a cookie jar carries the armed cookie, under either name.\n *\n * Delimiter-aware rather than a substring test: a cookie named\n * `not_ai_annotate_armed`, or another cookie whose VALUE contains the name,\n * satisfies `includes` \u2014 and arming an annotation channel on a coincidence is\n * not a failure anyone would think to look for.\n */\nexport function isAnnotationArmed(cookie: string): boolean {\n return cookie\n .split(\";\")\n .some((entry) => {\n const trimmed = entry.trimStart();\n return ANNOTATION_ARMED_COOKIES.some((name) => trimmed.startsWith(`${name}=`));\n });\n}\n\n/**\n * The arming token from a URL's query string, under either name.\n *\n * Current name wins when both are present. They should never disagree \u2014 the\n * management app writes both from one value \u2014 but if they ever do, the one this\n * version of the protocol owns is the one to believe.\n */\nexport function readAnnotationToken(params: URLSearchParams): string | null {\n for (const name of ANNOTATION_TOKEN_PARAMS) {\n const value = params.get(name);\n if (value !== null && value.length > 0) return value;\n }\n return null;\n}\n"],
|
|
5
|
+
"mappings": "AASO,IAAMA,EAAc,IAMdC,EAAyB,iCACzBC,EAA8B,cAmB9BC,EAAW,WACXC,EAAY,gBAoHlB,SAASC,EAAYC,EAA6D,CACvF,OAAOA,EAAO,YAAc,SAAW,SAAW,SACpD,CA2JA,SAASC,EAASC,EAA0B,CAC1C,GAAI,CACF,OAAO,KAAK,UAAUA,CAAO,GAAK,OAAOA,CAAO,CAClD,MAAQ,CACN,MAAO,kBACT,CACF,CAEO,SAASC,EAAiBC,EAAwBF,EAAwB,CAkB/E,QAAQ,MAAM,gBAAgBE,CAAI,2CAA2CH,EAASC,CAAO,CAAC,EAAE,CAClG,CAEA,IAAMG,EAAe,IAAI,IAAI,CAAC,kBAAmB,mBAAoB,eAAe,CAAC,EAQ9E,SAASC,EAAgBC,EAAsC,CACpE,GAAI,OAAOA,GAAS,UAAYA,IAAS,KAAM,MAAO,GACtD,IAAMC,EAAQD,EAA4B,KAC1C,OAAO,OAAOC,GAAS,UAAYH,EAAa,IAAIG,CAAI,CAC1D,CAUO,SAASC,EAAqBC,EAAwB,CAC3D,GAAI,CAACA,GAAUA,IAAW,IACxB,MAAM,IAAI,MACR,6GAEF,EAEF,OAAOA,CACT,CAkBO,IAAMC,EAAyB,cACzBC,EAA0B,oBA0B1BC,EAAsB,aAetBC,EAAmB,SA8BnBC,EAAmB,UAkDnBC,EAAyB,IAE/B,SAASC,EAAYC,EAAeN,EAAiC,CAC1E,MAAO,GAAGM,CAAI,IAAIF,CAAsB,gCAC1C,CAUO,SAASG,EAAcD,EAAsB,CAClD,MAAO,GAAGA,CAAI,mFAChB,CAwCO,IAAME,EAAgC,gBAChCC,EAAiC,sBA2CjCC,EAA0B,CACrCX,EACAS,CACF,EAGaG,EAA2B,CACtCX,EACAS,CACF,EAUO,SAASG,EAAkBC,EAAyB,CACzD,OAAOA,EACJ,MAAM,GAAG,EACT,KAAMC,GAAU,CACf,IAAMC,EAAUD,EAAM,UAAU,EAChC,OAAOH,EAAyB,KAAML,GAASS,EAAQ,WAAW,GAAGT,CAAI,GAAG,CAAC,CAC/E,CAAC,CACL,CASO,SAASU,EAAoBC,EAAwC,CAC1E,QAAWX,KAAQI,EAAyB,CAC1C,IAAMQ,EAAQD,EAAO,IAAIX,CAAI,EAC7B,GAAIY,IAAU,MAAQA,EAAM,OAAS,EAAG,OAAOA,CACjD,CACA,OAAO,IACT",
|
|
6
|
+
"names": ["SDK_VERSION", "ANNOTATION_MODE_HEADER", "FIRST_PARTY_ANNOTATION_MODE", "SRC_ATTR", "NAME_ATTR", "selectionOf", "target", "safeJson", "message", "unhandledMessage", "half", "PARENT_TYPES", "isParentMessage", "data", "type", "assertConcreteOrigin", "origin", "ANNOTATION_TOKEN_PARAM", "ANNOTATION_ARMED_COOKIE", "CAPTURE_TOKEN_PARAM", "REVIEW_RUN_PARAM", "READ_TOKEN_PARAM", "ANNOTATION_ARMED_VALUE", "armedCookie", "name", "clearedCookie", "LEGACY_ANNOTATION_TOKEN_PARAM", "LEGACY_ANNOTATION_ARMED_COOKIE", "ANNOTATION_TOKEN_PARAMS", "ANNOTATION_ARMED_COOKIES", "isAnnotationArmed", "cookie", "entry", "trimmed", "readAnnotationToken", "params", "value"]
|
|
7
7
|
}
|
package/dist/read.cjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
"use strict";var
|
|
1
|
+
"use strict";var h=Object.defineProperty;var A=Object.getOwnPropertyDescriptor;var b=Object.getOwnPropertyNames;var R=Object.prototype.hasOwnProperty;var T=(e,n)=>{for(var t in n)h(e,t,{get:n[t],enumerable:!0})},E=(e,n,t,r)=>{if(n&&typeof n=="object"||typeof n=="function")for(let o of b(n))!R.call(e,o)&&o!==t&&h(e,o,{get:()=>n[o],enumerable:!(r=A(n,o))||r.enumerable});return e};var x=e=>E(h({},"__esModule",{value:!0}),e);var _={};T(_,{ANNOTATION_READ_WIRE_VERSION:()=>M,READ_TIMEOUT_MS:()=>k,RESOLVED_INCLUDE:()=>y,createAnnotationReader:()=>P,parseAnnotationsResponse:()=>w});module.exports=x(_);function m(e){if(!e||e==="*")throw new Error("[annotation] a concrete ingest endpoint is required; '*' is not an address and an empty one posts nowhere.");let n;try{n=new URL(e)}catch{throw new Error(`[annotation] the ingest endpoint must be an absolute URL, got ${JSON.stringify(e)}; a relative one resolves against the product's own origin and the capture is lost there.`)}if(n.protocol!=="https:"&&n.protocol!=="http:")throw new Error(`[annotation] the ingest endpoint must be http(s), got ${n.protocol}`);return n.toString()}var M=1,O=["open","in-progress","done","cancelled","unknown"],S=4e3,l=200;function f(e,n){if(typeof e!="string")return;let t=e.replace(/[\u0000-\u001f\u007f-\u009f]+/g," ").replace(/\s+/g," ").trim();if(t.length!==0)return t.length>n?`${t.slice(0,n-1).trimEnd()}\u2026`:t}function p(e){return typeof e=="number"&&Number.isFinite(e)&&e>=0?Math.floor(e):0}function U(e){if(typeof e!="object"||e===null)return;let n=e,t=n.kind;if(t==="route"){let r=typeof n.path=="string"?n.path:void 0;return r===void 0?{kind:"route"}:{kind:"route",path:r}}if(t==="dom-path"){let r=typeof n.path=="string"?n.path:void 0;return r===void 0?void 0:{kind:"dom-path",path:r}}if(t==="source-ref"){let r=n.file,o=n.line,i=n.chain;if(typeof r!="string"||typeof o!="number"||!Array.isArray(i))return;let s=i.flatMap(u=>{if(typeof u!="object"||u===null)return[];let a=u;if(typeof a.file!="string"||typeof a.line!="number")return[];let d={file:a.file,line:a.line};return typeof a.column=="number"&&(d.column=a.column),[d]}),c={kind:"source-ref",file:r,line:o,chain:s};return typeof n.column=="number"&&(c.column=n.column),typeof n.ordinal=="number"&&(c.ordinal=n.ordinal),c}}function F(e){if(typeof e!="object"||e===null)return;let n=e,t=f(n.id,l),r=f(n.comment,S),o=U(n.anchor);if(t===void 0||r===void 0||o===void 0)return;let i=typeof n.status=="object"&&n.status!==null?n.status:{},s=i.state;return{id:t,anchor:o,comment:r,reviewer:f(n.reviewer,l)??"A reviewer",createdAt:f(n.createdAt,l)??"",status:{state:typeof s=="string"&&O.includes(s)?s:"unknown",display:f(i.display,l)??"Unknown"}}}function w(e){let n=typeof e=="object"&&e!==null?e:{},r=(Array.isArray(n.annotations)?n.annotations:[]).flatMap(i=>{let s=F(i);return s===void 0?[]:[s]}),o=n.reason;return{ok:n.ok===!0,wireVersion:p(n.wireVersion),asOf:f(n.asOf,l)??"",total:p(n.total),anchored:p(n.anchored),anchorless:p(n.anchorless),resolvedHidden:p(n.resolvedHidden),...typeof o=="string"?{reason:o}:{},annotations:r}}var k=8e3,y="include";function P(e){let n=m(e.endpoint);if(!e.readToken)throw new Error("[annotation] a read token is required; the console refuses an unsigned read and the page would render an empty marker layer with no way to say why.");let t=e.readToken,r=e.timeoutMs??k,o=e.fetch??globalThis.fetch;return{async read(i,s){if(!o)throw new Error("[annotation] no fetch implementation is available for the read");let c=new URL(n);c.searchParams.set("path",i),s?.includeResolved===!0&&c.searchParams.set("resolved",y);let u=new AbortController,a=setTimeout(()=>u.abort(),r);try{let d=await o(c.toString(),{method:"GET",headers:{authorization:`Bearer ${t}`},credentials:"omit",signal:u.signal}),g;try{g=await d.json?.()}catch{g=void 0}return w(g)}catch(d){throw u.signal.aborted?new Error(`[annotation] the read timed out after ${r}ms`,{cause:d}):d}finally{clearTimeout(a)}}}}
|
|
2
2
|
//# sourceMappingURL=read.cjs.map
|
package/dist/read.cjs.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/read.ts", "../src/transport.ts"],
|
|
4
|
-
"sourcesContent": ["import type { Anchor } from \"./anchor\";\nimport { assertConcreteEndpoint, type FetchLike } from \"./transport\";\n\n/**\n * Reading the captures already made on this page (A-276).\n *\n * ===========================================================================\n * WHY THIS IS A FIFTH ENTRY POINT AND NOT PART OF `.`\n * ===========================================================================\n *\n * `./anchor`'s note applies unchanged: this runs on the product page and only\n * for a product that RENDERS markers, while the SDK that only captures does\n * not need a byte of it. Folding it into `index` would tax every page of every\n * managed product with code most of them never call.\n *\n * ===========================================================================\n * WHY THE CLIENT LIVES IN THE PACKAGE AND NOT IN THE MARKER LAYER\n * ===========================================================================\n *\n * A-268 is the reason, and it is worth restating because it cost a night. The\n * SDK posted `{type, payload}` with the comment nested inside `payload`; the\n * ingest read `comment` as a sibling of it. Both halves were correct. Both were\n * tested. The two suites had different ideas of the bytes, and nothing anywhere\n * held the two ends of the same string \u2014 a human clicking Send on staging is\n * what found it.\n *\n * So the request this file builds and the response it parses are the ones the\n * console's `read-wire.test.ts` runs against the REAL handler: SDK client \u2192\n * real URL and headers \u2192 real `GET` export \u2192 real response \u2192 this parser. If\n * the two ends disagree about a field name, that test is red on the commit that\n * did it rather than on a staging page a week later.\n *\n * ===========================================================================\n * EVERYTHING THAT COMES BACK IS UNTRUSTED, AND THIS IS SOMEBODY ELSE'S PAGE\n * ===========================================================================\n *\n * `transport.ts`'s `safeIssueUrl` header has the full argument; the same one\n * applies here with more surface. A marker's `comment` and `reviewer` are\n * rendered inside the CUSTOMER'S OWN SITE, so a hostile or merely broken\n * console must not be able to put a screenful of control characters into a\n * panel nobody can dismiss. Strings only, bounded, control characters dropped;\n * anything that fails is dropped rather than repaired, and a marker that cannot\n * be read is SKIPPED rather than rendered half-built.\n *\n * ===========================================================================\n * THE RESPONSE IS ADDITIVE-ONLY FOREVER, SO AN UNKNOWN `anchor.kind` IS SKIPPED\n * ===========================================================================\n *\n * The console and the product deploy on different cadences from different\n * repositories, so an SDK meeting a newer console is the normal state. A marker\n * whose `anchor.kind` this version does not recognise is kept in the counts and\n * placed by nothing: `resolveAnchor` already answers `\"none\"` for an unknown\n * kind. It must never be guessed at \u2014 a marker on the wrong element does not\n * degrade this feature, it inverts it.\n */\n\n/** The wire version this client was written against. */\nexport const ANNOTATION_READ_WIRE_VERSION = 1;\n\nexport type AnnotationStatus = \"open\" | \"in-progress\" | \"done\" | \"cancelled\" | \"unknown\";\n\n/**\n * The statuses this version knows.\n *\n * A status the console adds later parses to `\"unknown\"` and keeps its display\n * string, which is the additive-only rule applied to an enum: the SDK renders\n * the console's words and switches on a value it understands, rather than\n * dropping a marker because a vocabulary grew.\n */\nconst KNOWN_STATUSES: readonly string[] = [\"open\", \"in-progress\", \"done\", \"cancelled\", \"unknown\"];\n\nexport interface AnnotationStatusView {\n state: AnnotationStatus;\n display: string;\n}\n\nexport interface AnnotationMarker {\n id: string;\n anchor: Anchor;\n comment: string;\n /** A display name. The console never sends an email; this never expects one. */\n reviewer: string;\n createdAt: string;\n status: AnnotationStatusView;\n}\n\nexport type AnnotationsReason =\n | \"no-path\"\n | \"scan-truncated\"\n | \"capped\"\n | \"rate-limited\"\n | \"not-authorised\"\n /** The token is a format the console has retired (A-277). Press the button again. */\n | \"retired-link\";\n\n/**\n * The counted envelope, never a bare array.\n *\n * On a read path zero markers is exactly what \"working, nothing here\" looks\n * like, and at least nine causes produce zero. A caller renders these counts \u2014\n * *\"3 annotations here, 1 shown\"* \u2014 so that a failure disagrees with itself\n * instead of being absent. The numerator is the PAGE's tally and the denominator\n * is `total`; `anchored` is a diagnostic and not the page's answer. See\n * `./markers`.\n */\nexport interface AnnotationsResponse {\n ok: boolean;\n wireVersion: number;\n asOf: string;\n total: number;\n /**\n * The CONSOLE's count of servable anchors, not a count of markers on screen.\n *\n * Named `anchored` and not `placed` (A-277): the server has no DOM, so what it\n * counts is whether an anchor COULD resolve to something \u2014 a chain, a\n * dom-path, or a route path \u2014 while whether one DID is `tallyAnchors` in\n * `./anchor`, measured against this document. The two disagree routinely; a\n * listing page yields about 25% unique resolution. The word `placed` belongs\n * to the page, because that is what a human means by it.\n */\n anchored: number;\n anchorless: number;\n /**\n * Captures the console held back because they are `done` or `cancelled`\n * (A-288), and the reason this field is not optional.\n *\n * The default read SUPPRESSES a resolved capture, so without this number a\n * marker that was made, answered and fixed is indistinguishable on the page\n * from a marker that was never made \u2014 which is the exact failure the counted\n * envelope exists to prevent, reintroduced by a display default.\n *\n * It is not a count of anything on screen and it is never added to `total`:\n * `total` counts what the response is ABOUT, so a suppressed capture is\n * outside it. This says only \"there is history here\", which is what makes the\n * toggle a control somebody knows to press rather than one nobody finds.\n *\n * A console older than A-288 does not send it. `parseAnnotationsResponse`\n * answers `0` then \u2014 \"nothing is being hidden from you\" \u2014 which is the honest\n * reading of an older console that hides nothing.\n */\n resolvedHidden: number;\n reason?: AnnotationsReason;\n annotations: AnnotationMarker[];\n}\n\n/** Long enough for any comment a reviewer writes, short enough not to be a payload. */\nconst MAX_COMMENT = 4000;\nconst MAX_SHORT = 200;\n\nfunction cleanText(value: unknown, max: number): string | undefined {\n if (typeof value !== \"string\") return undefined;\n const cleaned = value\n .replace(/[\\u0000-\\u001f\\u007f-\\u009f]+/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n if (cleaned.length === 0) return undefined;\n return cleaned.length > max ? `${cleaned.slice(0, max - 1).trimEnd()}\u2026` : cleaned;\n}\n\nfunction cleanCount(value: unknown): number {\n return typeof value === \"number\" && Number.isFinite(value) && value >= 0\n ? Math.floor(value)\n : 0;\n}\n\n/**\n * An anchor from the network, or `undefined`.\n *\n * Shape-checked rather than cast. `resolveAnchor` reads `chain` as an array and\n * `path` as a string, and a marker whose anchor is `null` would take the\n * placement call down for every other marker on the page.\n */\nfunction parseAnchor(value: unknown): Anchor | undefined {\n if (typeof value !== \"object\" || value === null) return undefined;\n const raw = value as Record<string, unknown>;\n const kind = raw[\"kind\"];\n\n if (kind === \"route\") {\n const path = typeof raw[\"path\"] === \"string\" ? raw[\"path\"] : undefined;\n return path === undefined ? { kind: \"route\" } : { kind: \"route\", path };\n }\n if (kind === \"dom-path\") {\n const path = typeof raw[\"path\"] === \"string\" ? raw[\"path\"] : undefined;\n return path === undefined ? undefined : { kind: \"dom-path\", path };\n }\n if (kind === \"source-ref\") {\n const file = raw[\"file\"];\n const line = raw[\"line\"];\n const chain = raw[\"chain\"];\n if (typeof file !== \"string\" || typeof line !== \"number\" || !Array.isArray(chain)) {\n return undefined;\n }\n const frames = chain.flatMap((frame) => {\n if (typeof frame !== \"object\" || frame === null) return [];\n const f = frame as Record<string, unknown>;\n if (typeof f[\"file\"] !== \"string\" || typeof f[\"line\"] !== \"number\") return [];\n const out: { file: string; line: number; column?: number } = {\n file: f[\"file\"],\n line: f[\"line\"],\n };\n if (typeof f[\"column\"] === \"number\") out.column = f[\"column\"];\n return [out];\n });\n const anchor: Anchor = { kind: \"source-ref\", file, line, chain: frames };\n if (typeof raw[\"column\"] === \"number\") anchor.column = raw[\"column\"];\n if (typeof raw[\"ordinal\"] === \"number\") anchor.ordinal = raw[\"ordinal\"];\n return anchor;\n }\n\n /**\n * A kind this version does not know. Skipped \u2014 never placed on a guess. The\n * console's own `wireVersion` is what tells a caller this happened, and\n * `resolveAnchor` would answer `\"none\"` even if one got through.\n */\n return undefined;\n}\n\nfunction parseMarker(value: unknown): AnnotationMarker | undefined {\n if (typeof value !== \"object\" || value === null) return undefined;\n const raw = value as Record<string, unknown>;\n\n const id = cleanText(raw[\"id\"], MAX_SHORT);\n const comment = cleanText(raw[\"comment\"], MAX_COMMENT);\n const anchor = parseAnchor(raw[\"anchor\"]);\n if (id === undefined || comment === undefined || anchor === undefined) return undefined;\n\n const statusRaw = (\n typeof raw[\"status\"] === \"object\" && raw[\"status\"] !== null ? raw[\"status\"] : {}\n ) as Record<string, unknown>;\n const state = statusRaw[\"state\"];\n\n return {\n id,\n anchor,\n comment,\n reviewer: cleanText(raw[\"reviewer\"], MAX_SHORT) ?? \"A reviewer\",\n createdAt: cleanText(raw[\"createdAt\"], MAX_SHORT) ?? \"\",\n status: {\n state: (typeof state === \"string\" && KNOWN_STATUSES.includes(state)\n ? state\n : \"unknown\") as AnnotationStatus,\n display: cleanText(statusRaw[\"display\"], MAX_SHORT) ?? \"Unknown\",\n },\n };\n}\n\n/**\n * Parse a response body into an envelope, whatever it turns out to be.\n *\n * NEVER THROWS, and never returns `undefined`. A caller on a product page has to\n * render something, and \"the console said something I could not read\" is a state\n * with an answer \u2014 an envelope whose counts are zero and whose `ok` is false \u2014\n * rather than an exception that takes the marker layer down.\n */\nexport function parseAnnotationsResponse(value: unknown): AnnotationsResponse {\n const raw = (typeof value === \"object\" && value !== null ? value : {}) as Record<string, unknown>;\n const list = Array.isArray(raw[\"annotations\"]) ? raw[\"annotations\"] : [];\n const annotations = list.flatMap((entry) => {\n const marker = parseMarker(entry);\n return marker === undefined ? [] : [marker];\n });\n const reason = raw[\"reason\"];\n\n return {\n ok: raw[\"ok\"] === true,\n wireVersion: cleanCount(raw[\"wireVersion\"]),\n asOf: cleanText(raw[\"asOf\"], MAX_SHORT) ?? \"\",\n total: cleanCount(raw[\"total\"]),\n anchored: cleanCount(raw[\"anchored\"]),\n anchorless: cleanCount(raw[\"anchorless\"]),\n resolvedHidden: cleanCount(raw[\"resolvedHidden\"]),\n ...(typeof reason === \"string\" ? { reason: reason as AnnotationsReason } : {}),\n annotations,\n };\n}\n\nexport interface AnnotationReaderOptions {\n /** The console's ingest URL \u2014 the same one `httpTransport` posts to. */\n endpoint: string;\n /**\n * A READ-scoped claim, and never the capture token.\n *\n * The console refuses a write claim here and refuses this one at the ingest;\n * the two are structurally different credentials with different lifetimes (one\n * hour against four). Handing the capture token to this function does not\n * degrade to a read \u2014 it fails, which is the intended outcome.\n */\n readToken: string;\n /** Own timeout, own catch. A hung read must not hang the page. */\n timeoutMs?: number;\n fetch?: FetchLike;\n}\n\nexport const READ_TIMEOUT_MS = 8_000;\n\n/**\n * The literal the console compares against, and the only one (A-288).\n *\n * The handler is `params.get(\"resolved\") === \"include\"` \u2014 an equality against\n * this exact string, deliberately, with tests in `route.test.ts` pinning that\n * `true`, `1`, `yes`, `Include`, `INCLUDE` and a trailing space all get the\n * DEFAULT rather than the wider response. So this is a constant rather than a\n * literal typed at the call site: a client that sent `true` would widen nothing\n * and report nothing wrong, and the page would say \"2 resolved\" beside a button\n * that does not work.\n */\nexport const RESOLVED_INCLUDE = \"include\";\n\nexport interface AnnotationReadOptions {\n /**\n * Ask for the `done` and `cancelled` captures as well.\n *\n * Default `false`, matching the console's default rather than overriding it:\n * a month of daily use leaves every reviewed component under a stack of\n * markers for things fixed weeks ago, and the layer becomes noise while every\n * individual marker remains a true statement.\n *\n * Suppression is not removal, and the number held back comes back as\n * `resolvedHidden` whether or not this is set.\n */\n includeResolved?: boolean;\n}\n\nexport interface AnnotationReader {\n /**\n * The markers for one resolved path.\n *\n * `path` is `location.pathname` and NOT the route pattern. The pattern is one\n * value for every page of a dynamic route, so asking by pattern returns\n * another page's captures \u2014 measured, and the reason this parameter exists at\n * all rather than being derived from the payload.\n *\n * Rejects only on a transport failure. Every answer the console gives,\n * including a refusal and a 429, comes back as an envelope with a `reason`,\n * because a caller cannot tell \"your budget is spent\" from \"nothing here\"\n * out of a status code.\n */\n read(path: string, options?: AnnotationReadOptions): Promise<AnnotationsResponse>;\n}\n\n/**\n * The read client. `httpTransport`'s sibling, pointed the other way.\n */\nexport function createAnnotationReader(options: AnnotationReaderOptions): AnnotationReader {\n const endpoint = assertConcreteEndpoint(options.endpoint);\n if (!options.readToken) {\n throw new Error(\n \"[annotation] a read token is required; the console refuses an unsigned read \" +\n \"and the page would render an empty marker layer with no way to say why.\",\n );\n }\n const token = options.readToken;\n const timeoutMs = options.timeoutMs ?? READ_TIMEOUT_MS;\n const doFetch: FetchLike | undefined = options.fetch ?? globalThis.fetch;\n\n return {\n async read(path: string, options?: AnnotationReadOptions): Promise<AnnotationsResponse> {\n if (!doFetch) {\n throw new Error(\"[annotation] no fetch implementation is available for the read\");\n }\n const url = new URL(endpoint);\n /**\n * `searchParams.set` and not string concatenation, so a path containing a\n * `&`, a `#` or a space is encoded once and correctly. A hand-built query\n * string is how one page's markers end up requested for another.\n */\n url.searchParams.set(\"path\", path);\n /**\n * ONLY when asked, and only ever this exact string.\n *\n * The parameter is omitted rather than sent as `exclude` on the default\n * path: the console's default IS suppression, and inventing a second\n * literal for it would be a value no handler compares against \u2014 which\n * reads, on the wire and in a log, exactly like a request that asked for\n * something.\n */\n if (options?.includeResolved === true) {\n url.searchParams.set(\"resolved\", RESOLVED_INCLUDE);\n }\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const response = await doFetch(url.toString(), {\n method: \"GET\",\n headers: { authorization: `Bearer ${token}` },\n // Never the reviewer's cookies; the token is the whole credential.\n credentials: \"omit\",\n signal: controller.signal,\n });\n let body: unknown;\n try {\n body = await response.json?.();\n } catch {\n body = undefined;\n }\n return parseAnnotationsResponse(body);\n } catch (error) {\n if (controller.signal.aborted) {\n throw new Error(`[annotation] the read timed out after ${timeoutMs}ms`, { cause: error });\n }\n throw error;\n } finally {\n clearTimeout(timer);\n }\n },\n };\n}\n", "import { assertConcreteOrigin, type FrameToParent } from \"./protocol\";\n\n/**\n * Egress (A-239).\n *\n * ===========================================================================\n * WHY THIS IS A SEAM AND NOT A BRANCH IN `sdk.ts`\n * ===========================================================================\n *\n * `sdk.ts` had exactly one way out \u2014 `win.parent.postMessage(...)` \u2014 and\n * first-party there is no parent: `win.parent === win`. Every capture then\n * posts a message to the page that sent it and nothing receives it. **That\n * fails silently**, which is the shape to be careful of here: the overlay\n * behaves correctly, the reviewer clicks, and no issue is ever filed.\n *\n * See `docs/plans/first-party-annotation.md`. The console stops framing the\n * product and links out to it instead, so the capture has to leave the page\n * over HTTP. The frame is NOT being deleted \u2014 `postMessageTransport` is\n * today's behaviour, extracted unchanged, and it is still the default.\n *\n * ===========================================================================\n * TWO PROPERTIES THAT HAD TO SURVIVE\n * ===========================================================================\n *\n * 1. **Never a wildcard, in either direction.** `assertConcreteOrigin` still\n * guards the message path, and `assertConcreteEndpoint` is its equivalent\n * for HTTP. Both throw AT CONSTRUCTION rather than at first capture,\n * because a misconfiguration that only surfaces when a reviewer finally\n * clicks something is a misconfiguration nobody finds in review.\n *\n * 2. **The screenshot never costs the capture**, and neither may the network.\n * `attachScreenshot` resolves within its own timeout and never rejects, so\n * the payload goes out either way. An HTTP transport would reintroduce\n * exactly the failure mode that was designed out \u2014 a slow network losing a\n * capture \u2014 unless it carries its own timeout and its own `catch`, and\n * unless a failed send is REPORTABLE rather than swallowed. A dropped\n * capture that looks like a successful one is the worst outcome available\n * here, so `send` rejects loudly and `createEgress` routes that to the\n * caller.\n */\n\n/** Every message the page half can emit. */\nexport type FrameMessageKind = FrameToParent[\"type\"];\n\n/**\n * What the far end said about a capture it accepted (A-240).\n *\n * Everything on it is optional and always will be. `postMessageTransport`\n * cannot answer at all \u2014 `postMessage` has no reply \u2014 and the ingest may\n * answer with nothing. A reviewer who is told *sent* and given no link is in a\n * strictly better position than one told nothing, so an absent `issueUrl` is a\n * successful send and never a failed one.\n */\nexport interface SendReceipt {\n /**\n * The issue this capture became, when the far end names one.\n *\n * ANY VALUE HERE ENDS UP IN A THIRD PARTY'S DOM as an `href`, so the type\n * alone is not the contract \u2014 see `safeIssueUrl`, which is what every value\n * crossing the network boundary is put through before it gets to be one.\n */\n issueUrl?: string;\n}\n\n/** Longer than any issue URL and short enough not to be a payload. */\nconst MAX_ISSUE_URL = 2048;\n\n/**\n * A URL from the network that is safe to put in somebody else's page (A-240).\n *\n * ===========================================================================\n * WHY THIS IS NOT PARANOIA\n * ===========================================================================\n *\n * `httpTransport` reads `issueUrl` out of a RESPONSE BODY and the toolbar sets\n * it as an `<a href>` INSIDE THE CUSTOMER'S PAGE. `javascript:alert(1)` there\n * executes in the product's origin the moment the reviewer clicks *View\n * issue* \u2014 a script injection into a third party's site, shipped by us, on\n * every page of every managed product.\n *\n * `assertConcreteEndpoint` does not cover this and was never going to:\n * it constrains WHERE WE SEND, and this is WHAT COMES BACK. An ingest that is\n * misconfigured, compromised, or simply behind a proxy that rewrites bodies is\n * exactly the case a transport has to survive, and \"our own console would\n * never\" is not a security boundary.\n *\n * It lives HERE and not in the toolbar so the guarantee travels with the\n * receipt rather than with one renderer of it. The toolbar calls it too \u2014 see\n * `showResult` \u2014 because it renders receipts from ANY transport, including one\n * a consumer wrote, which never passes through this function.\n *\n * Anything not `http(s)` is dropped rather than repaired. A dropped link is a\n * toast that says the capture was sent and offers no link, which is the\n * outcome the receipt is already designed to survive.\n */\nexport function safeIssueUrl(value: unknown): string | undefined {\n if (typeof value !== \"string\" || value.length === 0 || value.length > MAX_ISSUE_URL) {\n return undefined;\n }\n let url: URL;\n try {\n // Absolute only: `new URL` throws on a relative one, and a relative href in\n // somebody else's page points somewhere in THEIR app.\n url = new URL(value);\n } catch {\n return undefined;\n }\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") return undefined;\n return url.href;\n}\n\n/** Long enough for any refusal the ingest writes, short enough not to be a body. */\nconst MAX_REFUSAL_REASON = 300;\n\n/**\n * A refusal sentence from the network that is safe to put in front of a\n * reviewer (A-285).\n *\n * `safeIssueUrl`'s argument, applied to prose. The ingest composes refusals a\n * human can act on \u2014 a link issued for a different deployment, an empty\n * comment \u2014 and until this slice the transport threw `HTTP 403` and discarded\n * every one of them, so **not one of those sentences had ever reached a\n * human.**\n *\n * The value is still untrusted, and it ends up as `textContent` in the\n * CUSTOMER'S OWN PAGE. `textContent` is not an injection seam the way `href`\n * is, so the risk here is not script execution \u2014 it is a hostile or broken\n * ingest writing a screenful of control characters, ANSI escapes or a whole\n * JSON document into a toast the reviewer cannot dismiss. So: strings only,\n * control characters dropped, whitespace collapsed, and a hard ceiling.\n *\n * Anything that fails is dropped rather than repaired, and the caller falls\n * back to the HTTP status \u2014 which is what every refusal rendered as before\n * this existed, so the floor cannot get worse.\n */\nexport function safeRefusalReason(value: unknown): string | undefined {\n if (typeof value !== \"string\") return undefined;\n const cleaned = value\n .replace(/[\\u0000-\\u001f\\u007f-\\u009f]+/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n if (cleaned.length === 0) return undefined;\n return cleaned.length > MAX_REFUSAL_REASON\n ? `${cleaned.slice(0, MAX_REFUSAL_REASON - 1).trimEnd()}\u2026`\n : cleaned;\n}\n\n/**\n * The error a refused capture throws, carrying the reason as a FIELD.\n *\n * The reason is in the `message` too, prefixed for a log. A renderer that had\n * to recover it by string-slicing that message would be parsing our own prose,\n * which breaks the first time somebody edits the prefix \u2014 so the sentence a\n * human should see is a property, and `refusalReason` is the one way to read\n * it.\n */\nexport interface IngestRefusal extends Error {\n /** The ingest's own words, already through `safeRefusalReason`. */\n reason?: string;\n /** The status, always \u2014 it is the fallback and it is never absent. */\n status?: number;\n}\n\n/**\n * The reviewer-facing sentence for a failed capture, or `undefined`.\n *\n * SANITISED AGAIN, deliberately, exactly as `safeIssueUrl` is called at both\n * ends: this reads an `EgressOutcome.error` from WHATEVER transport produced\n * it, including one a consumer wrote, which never passed through\n * `httpTransport`. One function, so the two ends cannot disagree.\n */\nexport function refusalReason(error: unknown): string | undefined {\n return safeRefusalReason((error as IngestRefusal | null | undefined)?.reason);\n}\n\nexport interface AnnotationTransport {\n /** For diagnostics and for tests; never sent anywhere. */\n readonly name: string;\n /**\n * May this transport carry this kind of message?\n *\n * The interface needs this because `annotate:hover` fires once per animation\n * frame while the reviewer moves the mouse. Over `postMessage` that is a\n * structured clone to a frame that is already listening; over HTTP it is one\n * request per frame, which is a denial of service aimed at our own console.\n *\n * A transport that DROPPED such a message silently would be the same bug\n * this issue exists to remove, one layer down. So declining is an explicit\n * answer the SDK asks for and respects, and `send` is never called for a\n * kind that was declined.\n */\n carries(kind: FrameMessageKind): boolean;\n /**\n * Deliver the message. May be synchronous.\n *\n * A returned promise MUST reject on failure. Resolving on a failed send is\n * the one thing a transport may not do.\n */\n send(message: FrameToParent): void | Promise<void | SendReceipt>;\n}\n\n/** What went wrong, handed back to whoever constructed the SDK. */\nexport interface EgressFailure {\n message: FrameToParent;\n transport: string;\n error: unknown;\n}\n\nexport type EgressErrorHandler = (failure: EgressFailure) => void;\n\n/**\n * The parent-window transport \u2014 today's behaviour, extracted unchanged.\n *\n * It carries everything: the frame is a live listener on the other end of a\n * cheap channel, and the handshake (`annotate:ready`) and the hover highlight\n * both belong to it.\n */\nexport function postMessageTransport(\n parentOrigin: string,\n win: Pick<Window, \"parent\"> = window,\n): AnnotationTransport {\n const origin = assertConcreteOrigin(parentOrigin);\n return {\n name: \"postMessage\",\n carries: () => true,\n send(message) {\n // Never a wildcard, in either direction.\n win.parent.postMessage(message, origin);\n },\n };\n}\n\n/**\n * Reject anything that is not one concrete `http(s)` URL.\n *\n * `assertConcreteOrigin`'s argument, applied to an endpoint. A relative URL\n * resolves against whatever page the SDK happens to be on \u2014 so a capture\n * POSTs into the CUSTOMER'S OWN app, 404s, and is lost; and a `javascript:`\n * or `data:` endpoint is a script-injection seam handed to whoever controls\n * the deep link. Neither is a thing to discover at first capture.\n */\nexport function assertConcreteEndpoint(endpoint: string): string {\n if (!endpoint || endpoint === \"*\") {\n throw new Error(\n \"[annotation] a concrete ingest endpoint is required; \" +\n \"'*' is not an address and an empty one posts nowhere.\",\n );\n }\n let url: URL;\n try {\n url = new URL(endpoint);\n } catch {\n throw new Error(\n `[annotation] the ingest endpoint must be an absolute URL, got ${JSON.stringify(endpoint)}; ` +\n \"a relative one resolves against the product's own origin and the capture is lost there.\",\n );\n }\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") {\n throw new Error(\n `[annotation] the ingest endpoint must be http(s), got ${url.protocol}`,\n );\n }\n return url.toString();\n}\n\n/** Node's `fetch` and the browser's, narrowed to what we use. */\nexport type FetchLike = (\n input: string,\n init: {\n method: string;\n headers: Record<string, string>;\n /**\n * Optional since A-276: the read client issues a `GET`, which has no body,\n * and a required field would make it pass `undefined` explicitly \u2014 a value\n * a stub asserting on `init` then has to know to ignore. `httpTransport`\n * still always sends one.\n */\n body?: string;\n credentials?: \"omit\";\n signal?: AbortSignal;\n },\n) => Promise<{\n ok: boolean;\n status: number;\n /**\n * Optional because `serve()`'s loopback responses are read through the\n * platform `fetch`, which has it, while a stub written to assert the `init`\n * need not. A transport that made the body mandatory would be asserting\n * something about the ingest that the ingest does not promise.\n */\n json?: () => Promise<unknown>;\n}>;\n\nexport interface HttpTransportOptions {\n /** The console's ingest URL. Absolute, `http(s)`, never `*`. */\n endpoint: string;\n /**\n * The capture token from the deep link \u2014 signed by the console, carried by\n * the product, never validated by it. See the plan's *Authentication*\n * section for why this is a second credential and not the arming one.\n */\n captureToken: string;\n /**\n * Own timeout, own catch. The default is deliberately short: a capture the\n * reviewer is told failed can be retried, and a request that hangs is\n * indistinguishable from one that worked.\n */\n timeoutMs?: number;\n /** Injectable only so the tests can drive a transport with no `fetch` at all. */\n fetch?: FetchLike;\n}\n\nexport const HTTP_TRANSPORT_TIMEOUT_MS = 8_000;\n\n/**\n * The first-party transport: POST the capture to the console's ingest.\n *\n * It carries `annotate:capture` AND NOTHING ELSE. `annotate:hover` is one\n * request per animation frame, and `annotate:ready` / `annotate:cancel` are\n * handshake with a parent window that does not exist first-party \u2014 sending\n * them would be filing HTTP requests nobody asked for.\n */\nexport function httpTransport(options: HttpTransportOptions): AnnotationTransport {\n const endpoint = assertConcreteEndpoint(options.endpoint);\n if (!options.captureToken) {\n throw new Error(\n \"[annotation] a capture token is required; the ingest rejects an unsigned capture \" +\n \"and the reviewer would only find out after writing one.\",\n );\n }\n const token = options.captureToken;\n const timeoutMs = options.timeoutMs ?? HTTP_TRANSPORT_TIMEOUT_MS;\n const doFetch: FetchLike | undefined = options.fetch ?? globalThis.fetch;\n\n return {\n name: \"http\",\n carries: (kind) => kind === \"annotate:capture\",\n async send(message) {\n if (!doFetch) {\n throw new Error(\"[annotation] no fetch implementation is available for the ingest\");\n }\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const response = await doFetch(endpoint, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${token}`,\n },\n // Never the reviewer's cookies. The token is the whole credential,\n // and an ambient one would make this endpoint CSRF-shaped.\n credentials: \"omit\",\n body: JSON.stringify(message),\n signal: controller.signal,\n });\n if (!response.ok) {\n /**\n * THE REFUSAL IS READ, AND THE STATUS IS THE FALLBACK (A-285).\n *\n * The ingest composes sentences a reviewer can act on \u2014 \"this link\n * was issued for a different deployment\", \"an empty comment files an\n * issue nobody can action\" \u2014 and this line used to throw `HTTP 403`\n * and drop every one of them on the floor. A status code is not a\n * refusal a human can do anything with; it is what we say when the\n * far end gave us nothing better.\n *\n * The body read is inside its own `catch` for the same reason the\n * success path's is: a refusal we could not parse must still be a\n * refusal, never a `TypeError` from `json()` masquerading as one.\n * Unlike the success path, this branch was ALREADY a failure, so a\n * parse problem loses information rather than a capture.\n */\n let reason: string | undefined;\n try {\n const refusal = (await response.json?.()) as { error?: unknown } | undefined;\n reason = safeRefusalReason(refusal?.error);\n } catch {\n reason = undefined;\n }\n const failure: IngestRefusal = new Error(\n `[annotation] the ingest rejected the capture: ${reason ?? `HTTP ${response.status}`}`,\n );\n failure.status = response.status;\n if (reason !== undefined) failure.reason = reason;\n throw failure;\n }\n /**\n * The capture is delivered at this point and nothing below may undo\n * that. A body that is empty, not JSON, or JSON of some other shape is\n * an ingest that chose not to name an issue \u2014 telling the reviewer\n * their capture failed because we could not parse a courtesy would be\n * the same lie as a silent drop, pointing the other way.\n *\n * A-285 read a body on the REFUSAL path above and this property is\n * untouched by that: the two branches are opposites. Up there the\n * capture was refused and an unparseable body costs only the wording;\n * down here the capture is DELIVERED, and no failure to read a\n * courtesy may ever turn it into a failed one.\n */\n try {\n const body = (await response.json?.()) as { issueUrl?: unknown } | undefined;\n // Untrusted, all of it. `safeIssueUrl` returns undefined for a\n // non-string, a relative URL, an over-long one and any scheme that\n // is not http(s) \u2014 so a hostile body degrades to a capture that was\n // sent and named no issue.\n const issueUrl = safeIssueUrl(body?.issueUrl);\n if (issueUrl !== undefined) return { issueUrl };\n } catch {}\n return {};\n } catch (error) {\n if (controller.signal.aborted) {\n throw new Error(`[annotation] the capture timed out after ${timeoutMs}ms`, {\n cause: error,\n });\n }\n throw error;\n } finally {\n clearTimeout(timer);\n }\n },\n };\n}\n\n/**\n * What became of one message (A-240).\n *\n * `createEgress` used to return `void`, which was enough while the only reader\n * of a failure was `console.error`. The composer has to TELL THE REVIEWER \u2014 the\n * issue link on success, a retry on failure \u2014 and it cannot do that from a\n * function that answers nothing. So the outcome is returned as a value, and the\n * promise NEVER REJECTS: a rejection here would be a second way for a lost\n * capture to disappear, one `await`-less caller away from silence.\n *\n * `declined` is its own case rather than a failure, because it is not one:\n * `annotate:hover` over HTTP is a kind the transport correctly refuses, and\n * calling that a failed capture would put a red toast in front of a reviewer\n * who did nothing wrong. It is only a problem if `annotate:capture` is what was\n * declined, and that is the caller's judgement rather than this function's.\n */\nexport type EgressOutcome =\n | { status: \"sent\"; receipt: SendReceipt }\n | { status: \"declined\" }\n | { status: \"failed\"; error: unknown };\n\n/**\n * The SDK's one way out, given a transport.\n *\n * Pure and DOM-free on purpose, so the two things worth arguing about \u2014 that a\n * declined kind is never sent, and that a failed send reaches the caller \u2014 are\n * testable without a browser. `sdk.ts` holds no other egress path.\n */\nexport function createEgress(\n transport: AnnotationTransport,\n onError?: EgressErrorHandler,\n): (message: FrameToParent) => Promise<EgressOutcome> {\n const report = (message: FrameToParent, error: unknown) => {\n const failure: EgressFailure = { message, transport: transport.name, error };\n if (onError) {\n onError(failure);\n return;\n }\n /**\n * No handler is not a licence to swallow it. Until A-240 puts a toast in\n * front of the reviewer, the console is the only surface there is, and a\n * lost capture must leave a trace somewhere.\n */\n console.error(\"[annotation] a capture could not be delivered\", failure);\n };\n\n /**\n * `async`, and the synchronous half still runs synchronously \u2014 everything up\n * to the first `await` does, which is `transport.send` itself. So\n * `postMessageTransport` still posts before this returns, and a transport\n * that throws is still reported before this returns. That is not an\n * incidental detail: `sdk.test.ts` asserts the frame path's handshake has\n * been posted the instant `createAnnotationSdk` returns, and it must stay\n * true, because the frame is not being deleted.\n */\n return async (message: FrameToParent): Promise<EgressOutcome> => {\n if (!transport.carries(message.type)) return { status: \"declined\" };\n try {\n const receipt = await transport.send(message);\n return { status: \"sent\", receipt: receipt ?? {} };\n } catch (error) {\n report(message, error);\n return { status: \"failed\", error };\n }\n };\n}\n"],
|
|
5
|
-
"mappings": "yaAAA,IAAAA,EAAA,GAAAC,EAAAD,EAAA,kCAAAE,EAAA,oBAAAC,EAAA,qBAAAC,EAAA,2BAAAC,EAAA,6BAAAC,IAAA,eAAAC,EAAAP,
|
|
4
|
+
"sourcesContent": ["import type { Anchor } from \"./anchor\";\nimport { assertConcreteEndpoint, type FetchLike } from \"./transport\";\n\n/**\n * Reading the captures already made on this page (A-276).\n *\n * ===========================================================================\n * WHY THIS IS A FIFTH ENTRY POINT AND NOT PART OF `.`\n * ===========================================================================\n *\n * `./anchor`'s note applies unchanged: this runs on the product page and only\n * for a product that RENDERS markers, while the SDK that only captures does\n * not need a byte of it. Folding it into `index` would tax every page of every\n * managed product with code most of them never call.\n *\n * ===========================================================================\n * WHY THE CLIENT LIVES IN THE PACKAGE AND NOT IN THE MARKER LAYER\n * ===========================================================================\n *\n * A-268 is the reason, and it is worth restating because it cost a night. The\n * SDK posted `{type, payload}` with the comment nested inside `payload`; the\n * ingest read `comment` as a sibling of it. Both halves were correct. Both were\n * tested. The two suites had different ideas of the bytes, and nothing anywhere\n * held the two ends of the same string \u2014 a human clicking Send on staging is\n * what found it.\n *\n * So the request this file builds and the response it parses are the ones the\n * console's `read-wire.test.ts` runs against the REAL handler: SDK client \u2192\n * real URL and headers \u2192 real `GET` export \u2192 real response \u2192 this parser. If\n * the two ends disagree about a field name, that test is red on the commit that\n * did it rather than on a staging page a week later.\n *\n * ===========================================================================\n * EVERYTHING THAT COMES BACK IS UNTRUSTED, AND THIS IS SOMEBODY ELSE'S PAGE\n * ===========================================================================\n *\n * `transport.ts`'s `safeIssueUrl` header has the full argument; the same one\n * applies here with more surface. A marker's `comment` and `reviewer` are\n * rendered inside the CUSTOMER'S OWN SITE, so a hostile or merely broken\n * console must not be able to put a screenful of control characters into a\n * panel nobody can dismiss. Strings only, bounded, control characters dropped;\n * anything that fails is dropped rather than repaired, and a marker that cannot\n * be read is SKIPPED rather than rendered half-built.\n *\n * ===========================================================================\n * THE RESPONSE IS ADDITIVE-ONLY FOREVER, SO AN UNKNOWN `anchor.kind` IS SKIPPED\n * ===========================================================================\n *\n * The console and the product deploy on different cadences from different\n * repositories, so an SDK meeting a newer console is the normal state. A marker\n * whose `anchor.kind` this version does not recognise is kept in the counts and\n * placed by nothing: `resolveAnchor` already answers `\"none\"` for an unknown\n * kind. It must never be guessed at \u2014 a marker on the wrong element does not\n * degrade this feature, it inverts it.\n */\n\n/** The wire version this client was written against. */\nexport const ANNOTATION_READ_WIRE_VERSION = 1;\n\nexport type AnnotationStatus = \"open\" | \"in-progress\" | \"done\" | \"cancelled\" | \"unknown\";\n\n/**\n * The statuses this version knows.\n *\n * A status the console adds later parses to `\"unknown\"` and keeps its display\n * string, which is the additive-only rule applied to an enum: the SDK renders\n * the console's words and switches on a value it understands, rather than\n * dropping a marker because a vocabulary grew.\n */\nconst KNOWN_STATUSES: readonly string[] = [\"open\", \"in-progress\", \"done\", \"cancelled\", \"unknown\"];\n\nexport interface AnnotationStatusView {\n state: AnnotationStatus;\n display: string;\n}\n\nexport interface AnnotationMarker {\n id: string;\n anchor: Anchor;\n comment: string;\n /** A display name. The console never sends an email; this never expects one. */\n reviewer: string;\n createdAt: string;\n status: AnnotationStatusView;\n}\n\nexport type AnnotationsReason =\n | \"no-path\"\n | \"scan-truncated\"\n | \"capped\"\n | \"rate-limited\"\n | \"not-authorised\"\n /** The token is a format the console has retired (A-277). Press the button again. */\n | \"retired-link\";\n\n/**\n * The counted envelope, never a bare array.\n *\n * On a read path zero markers is exactly what \"working, nothing here\" looks\n * like, and at least nine causes produce zero. A caller renders these counts \u2014\n * *\"3 annotations here, 1 shown\"* \u2014 so that a failure disagrees with itself\n * instead of being absent. The numerator is the PAGE's tally and the denominator\n * is `total`; `anchored` is a diagnostic and not the page's answer. See\n * `./markers`.\n */\nexport interface AnnotationsResponse {\n ok: boolean;\n wireVersion: number;\n asOf: string;\n total: number;\n /**\n * The CONSOLE's count of servable anchors, not a count of markers on screen.\n *\n * Named `anchored` and not `placed` (A-277): the server has no DOM, so what it\n * counts is whether an anchor COULD resolve to something \u2014 a chain, a\n * dom-path, or a route path \u2014 while whether one DID is `tallyAnchors` in\n * `./anchor`, measured against this document. The two disagree routinely; a\n * listing page yields about 25% unique resolution. The word `placed` belongs\n * to the page, because that is what a human means by it.\n */\n anchored: number;\n anchorless: number;\n /**\n * Captures the console held back because they are `done` or `cancelled`\n * (A-288), and the reason this field is not optional.\n *\n * The default read SUPPRESSES a resolved capture, so without this number a\n * marker that was made, answered and fixed is indistinguishable on the page\n * from a marker that was never made \u2014 which is the exact failure the counted\n * envelope exists to prevent, reintroduced by a display default.\n *\n * It is not a count of anything on screen and it is never added to `total`:\n * `total` counts what the response is ABOUT, so a suppressed capture is\n * outside it. This says only \"there is history here\", which is what makes the\n * toggle a control somebody knows to press rather than one nobody finds.\n *\n * A console older than A-288 does not send it. `parseAnnotationsResponse`\n * answers `0` then \u2014 \"nothing is being hidden from you\" \u2014 which is the honest\n * reading of an older console that hides nothing.\n */\n resolvedHidden: number;\n reason?: AnnotationsReason;\n annotations: AnnotationMarker[];\n}\n\n/** Long enough for any comment a reviewer writes, short enough not to be a payload. */\nconst MAX_COMMENT = 4000;\nconst MAX_SHORT = 200;\n\nfunction cleanText(value: unknown, max: number): string | undefined {\n if (typeof value !== \"string\") return undefined;\n const cleaned = value\n .replace(/[\\u0000-\\u001f\\u007f-\\u009f]+/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n if (cleaned.length === 0) return undefined;\n return cleaned.length > max ? `${cleaned.slice(0, max - 1).trimEnd()}\u2026` : cleaned;\n}\n\nfunction cleanCount(value: unknown): number {\n return typeof value === \"number\" && Number.isFinite(value) && value >= 0\n ? Math.floor(value)\n : 0;\n}\n\n/**\n * An anchor from the network, or `undefined`.\n *\n * Shape-checked rather than cast. `resolveAnchor` reads `chain` as an array and\n * `path` as a string, and a marker whose anchor is `null` would take the\n * placement call down for every other marker on the page.\n */\nfunction parseAnchor(value: unknown): Anchor | undefined {\n if (typeof value !== \"object\" || value === null) return undefined;\n const raw = value as Record<string, unknown>;\n const kind = raw[\"kind\"];\n\n if (kind === \"route\") {\n const path = typeof raw[\"path\"] === \"string\" ? raw[\"path\"] : undefined;\n return path === undefined ? { kind: \"route\" } : { kind: \"route\", path };\n }\n if (kind === \"dom-path\") {\n const path = typeof raw[\"path\"] === \"string\" ? raw[\"path\"] : undefined;\n return path === undefined ? undefined : { kind: \"dom-path\", path };\n }\n if (kind === \"source-ref\") {\n const file = raw[\"file\"];\n const line = raw[\"line\"];\n const chain = raw[\"chain\"];\n if (typeof file !== \"string\" || typeof line !== \"number\" || !Array.isArray(chain)) {\n return undefined;\n }\n const frames = chain.flatMap((frame) => {\n if (typeof frame !== \"object\" || frame === null) return [];\n const f = frame as Record<string, unknown>;\n if (typeof f[\"file\"] !== \"string\" || typeof f[\"line\"] !== \"number\") return [];\n const out: { file: string; line: number; column?: number } = {\n file: f[\"file\"],\n line: f[\"line\"],\n };\n if (typeof f[\"column\"] === \"number\") out.column = f[\"column\"];\n return [out];\n });\n const anchor: Anchor = { kind: \"source-ref\", file, line, chain: frames };\n if (typeof raw[\"column\"] === \"number\") anchor.column = raw[\"column\"];\n if (typeof raw[\"ordinal\"] === \"number\") anchor.ordinal = raw[\"ordinal\"];\n return anchor;\n }\n\n /**\n * A kind this version does not know. Skipped \u2014 never placed on a guess. The\n * console's own `wireVersion` is what tells a caller this happened, and\n * `resolveAnchor` would answer `\"none\"` even if one got through.\n */\n return undefined;\n}\n\nfunction parseMarker(value: unknown): AnnotationMarker | undefined {\n if (typeof value !== \"object\" || value === null) return undefined;\n const raw = value as Record<string, unknown>;\n\n const id = cleanText(raw[\"id\"], MAX_SHORT);\n const comment = cleanText(raw[\"comment\"], MAX_COMMENT);\n const anchor = parseAnchor(raw[\"anchor\"]);\n if (id === undefined || comment === undefined || anchor === undefined) return undefined;\n\n const statusRaw = (\n typeof raw[\"status\"] === \"object\" && raw[\"status\"] !== null ? raw[\"status\"] : {}\n ) as Record<string, unknown>;\n const state = statusRaw[\"state\"];\n\n return {\n id,\n anchor,\n comment,\n reviewer: cleanText(raw[\"reviewer\"], MAX_SHORT) ?? \"A reviewer\",\n createdAt: cleanText(raw[\"createdAt\"], MAX_SHORT) ?? \"\",\n status: {\n state: (typeof state === \"string\" && KNOWN_STATUSES.includes(state)\n ? state\n : \"unknown\") as AnnotationStatus,\n display: cleanText(statusRaw[\"display\"], MAX_SHORT) ?? \"Unknown\",\n },\n };\n}\n\n/**\n * Parse a response body into an envelope, whatever it turns out to be.\n *\n * NEVER THROWS, and never returns `undefined`. A caller on a product page has to\n * render something, and \"the console said something I could not read\" is a state\n * with an answer \u2014 an envelope whose counts are zero and whose `ok` is false \u2014\n * rather than an exception that takes the marker layer down.\n */\nexport function parseAnnotationsResponse(value: unknown): AnnotationsResponse {\n const raw = (typeof value === \"object\" && value !== null ? value : {}) as Record<string, unknown>;\n const list = Array.isArray(raw[\"annotations\"]) ? raw[\"annotations\"] : [];\n const annotations = list.flatMap((entry) => {\n const marker = parseMarker(entry);\n return marker === undefined ? [] : [marker];\n });\n const reason = raw[\"reason\"];\n\n return {\n ok: raw[\"ok\"] === true,\n wireVersion: cleanCount(raw[\"wireVersion\"]),\n asOf: cleanText(raw[\"asOf\"], MAX_SHORT) ?? \"\",\n total: cleanCount(raw[\"total\"]),\n anchored: cleanCount(raw[\"anchored\"]),\n anchorless: cleanCount(raw[\"anchorless\"]),\n resolvedHidden: cleanCount(raw[\"resolvedHidden\"]),\n ...(typeof reason === \"string\" ? { reason: reason as AnnotationsReason } : {}),\n annotations,\n };\n}\n\nexport interface AnnotationReaderOptions {\n /** The console's ingest URL \u2014 the same one `httpTransport` posts to. */\n endpoint: string;\n /**\n * A READ-scoped claim, and never the capture token.\n *\n * The console refuses a write claim here and refuses this one at the ingest;\n * the two are structurally different credentials with different lifetimes (one\n * hour against four). Handing the capture token to this function does not\n * degrade to a read \u2014 it fails, which is the intended outcome.\n */\n readToken: string;\n /** Own timeout, own catch. A hung read must not hang the page. */\n timeoutMs?: number;\n fetch?: FetchLike;\n}\n\nexport const READ_TIMEOUT_MS = 8_000;\n\n/**\n * The literal the console compares against, and the only one (A-288).\n *\n * The handler is `params.get(\"resolved\") === \"include\"` \u2014 an equality against\n * this exact string, deliberately, with tests in `route.test.ts` pinning that\n * `true`, `1`, `yes`, `Include`, `INCLUDE` and a trailing space all get the\n * DEFAULT rather than the wider response. So this is a constant rather than a\n * literal typed at the call site: a client that sent `true` would widen nothing\n * and report nothing wrong, and the page would say \"2 resolved\" beside a button\n * that does not work.\n */\nexport const RESOLVED_INCLUDE = \"include\";\n\nexport interface AnnotationReadOptions {\n /**\n * Ask for the `done` and `cancelled` captures as well.\n *\n * Default `false`, matching the console's default rather than overriding it:\n * a month of daily use leaves every reviewed component under a stack of\n * markers for things fixed weeks ago, and the layer becomes noise while every\n * individual marker remains a true statement.\n *\n * Suppression is not removal, and the number held back comes back as\n * `resolvedHidden` whether or not this is set.\n */\n includeResolved?: boolean;\n}\n\nexport interface AnnotationReader {\n /**\n * The markers for one resolved path.\n *\n * `path` is `location.pathname` and NOT the route pattern. The pattern is one\n * value for every page of a dynamic route, so asking by pattern returns\n * another page's captures \u2014 measured, and the reason this parameter exists at\n * all rather than being derived from the payload.\n *\n * Rejects only on a transport failure. Every answer the console gives,\n * including a refusal and a 429, comes back as an envelope with a `reason`,\n * because a caller cannot tell \"your budget is spent\" from \"nothing here\"\n * out of a status code.\n */\n read(path: string, options?: AnnotationReadOptions): Promise<AnnotationsResponse>;\n}\n\n/**\n * The read client. `httpTransport`'s sibling, pointed the other way.\n */\nexport function createAnnotationReader(options: AnnotationReaderOptions): AnnotationReader {\n const endpoint = assertConcreteEndpoint(options.endpoint);\n if (!options.readToken) {\n throw new Error(\n \"[annotation] a read token is required; the console refuses an unsigned read \" +\n \"and the page would render an empty marker layer with no way to say why.\",\n );\n }\n const token = options.readToken;\n const timeoutMs = options.timeoutMs ?? READ_TIMEOUT_MS;\n const doFetch: FetchLike | undefined = options.fetch ?? globalThis.fetch;\n\n return {\n async read(path: string, options?: AnnotationReadOptions): Promise<AnnotationsResponse> {\n if (!doFetch) {\n throw new Error(\"[annotation] no fetch implementation is available for the read\");\n }\n const url = new URL(endpoint);\n /**\n * `searchParams.set` and not string concatenation, so a path containing a\n * `&`, a `#` or a space is encoded once and correctly. A hand-built query\n * string is how one page's markers end up requested for another.\n */\n url.searchParams.set(\"path\", path);\n /**\n * ONLY when asked, and only ever this exact string.\n *\n * The parameter is omitted rather than sent as `exclude` on the default\n * path: the console's default IS suppression, and inventing a second\n * literal for it would be a value no handler compares against \u2014 which\n * reads, on the wire and in a log, exactly like a request that asked for\n * something.\n */\n if (options?.includeResolved === true) {\n url.searchParams.set(\"resolved\", RESOLVED_INCLUDE);\n }\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const response = await doFetch(url.toString(), {\n method: \"GET\",\n headers: { authorization: `Bearer ${token}` },\n // Never the reviewer's cookies; the token is the whole credential.\n credentials: \"omit\",\n signal: controller.signal,\n });\n let body: unknown;\n try {\n body = await response.json?.();\n } catch {\n body = undefined;\n }\n return parseAnnotationsResponse(body);\n } catch (error) {\n if (controller.signal.aborted) {\n throw new Error(`[annotation] the read timed out after ${timeoutMs}ms`, { cause: error });\n }\n throw error;\n } finally {\n clearTimeout(timer);\n }\n },\n };\n}\n", "import { assertConcreteOrigin, type FrameToParent } from \"./protocol\";\n\n/**\n * Egress (A-239).\n *\n * ===========================================================================\n * WHY THIS IS A SEAM AND NOT A BRANCH IN `sdk.ts`\n * ===========================================================================\n *\n * `sdk.ts` had exactly one way out \u2014 `win.parent.postMessage(...)` \u2014 and\n * first-party there is no parent: `win.parent === win`. Every capture then\n * posts a message to the page that sent it and nothing receives it. **That\n * fails silently**, which is the shape to be careful of here: the overlay\n * behaves correctly, the reviewer clicks, and no issue is ever filed.\n *\n * See `docs/plans/first-party-annotation.md`. The console stops framing the\n * product and links out to it instead, so the capture has to leave the page\n * over HTTP. The frame is NOT being deleted \u2014 `postMessageTransport` is\n * today's behaviour, extracted unchanged, and it is still the default.\n *\n * ===========================================================================\n * TWO PROPERTIES THAT HAD TO SURVIVE\n * ===========================================================================\n *\n * 1. **Never a wildcard, in either direction.** `assertConcreteOrigin` still\n * guards the message path, and `assertConcreteEndpoint` is its equivalent\n * for HTTP. Both throw AT CONSTRUCTION rather than at first capture,\n * because a misconfiguration that only surfaces when a reviewer finally\n * clicks something is a misconfiguration nobody finds in review.\n *\n * 2. **The screenshot never costs the capture**, and neither may the network.\n * `attachScreenshot` resolves within its own timeout and never rejects, so\n * the payload goes out either way. An HTTP transport would reintroduce\n * exactly the failure mode that was designed out \u2014 a slow network losing a\n * capture \u2014 unless it carries its own timeout and its own `catch`, and\n * unless a failed send is REPORTABLE rather than swallowed. A dropped\n * capture that looks like a successful one is the worst outcome available\n * here, so `send` rejects loudly and `createEgress` routes that to the\n * caller.\n */\n\n/** Every message the page half can emit. */\nexport type FrameMessageKind = FrameToParent[\"type\"];\n\n/**\n * What the far end said about a capture it accepted (A-240).\n *\n * Everything on it is optional and always will be. `postMessageTransport`\n * cannot answer at all \u2014 `postMessage` has no reply \u2014 and the ingest may\n * answer with nothing. A reviewer who is told *sent* and given no link is in a\n * strictly better position than one told nothing, so an absent `issueUrl` is a\n * successful send and never a failed one.\n */\nexport interface SendReceipt {\n /**\n * Durable console status for this capture's workflow. This is an opaque URL\n * supplied by the console, not a client-derived run identifier.\n */\n progressUrl?: string;\n /**\n * The issue this capture became, when the far end names one.\n *\n * ANY VALUE HERE ENDS UP IN A THIRD PARTY'S DOM as an `href`, so the type\n * alone is not the contract \u2014 see `safeIssueUrl`, which is what every value\n * crossing the network boundary is put through before it gets to be one.\n */\n issueUrl?: string;\n saved?: { feedbackId: string; recoveryUrl?: string; message: string };\n}\n\n/** Longer than any issue URL and short enough not to be a payload. */\nconst MAX_ISSUE_URL = 2048;\n\n/**\n * A URL from the network that is safe to put in somebody else's page (A-240).\n *\n * ===========================================================================\n * WHY THIS IS NOT PARANOIA\n * ===========================================================================\n *\n * `httpTransport` reads `issueUrl` out of a RESPONSE BODY and the toolbar sets\n * it as an `<a href>` INSIDE THE CUSTOMER'S PAGE. `javascript:alert(1)` there\n * executes in the product's origin the moment the reviewer clicks *View\n * issue* \u2014 a script injection into a third party's site, shipped by us, on\n * every page of every managed product.\n *\n * `assertConcreteEndpoint` does not cover this and was never going to:\n * it constrains WHERE WE SEND, and this is WHAT COMES BACK. An ingest that is\n * misconfigured, compromised, or simply behind a proxy that rewrites bodies is\n * exactly the case a transport has to survive, and \"our own console would\n * never\" is not a security boundary.\n *\n * It lives HERE and not in the toolbar so the guarantee travels with the\n * receipt rather than with one renderer of it. The toolbar calls it too \u2014 see\n * `showResult` \u2014 because it renders receipts from ANY transport, including one\n * a consumer wrote, which never passes through this function.\n *\n * Anything not `http(s)` is dropped rather than repaired. A dropped link is a\n * toast that says the capture was sent and offers no link, which is the\n * outcome the receipt is already designed to survive.\n */\nexport function safeIssueUrl(value: unknown): string | undefined {\n if (typeof value !== \"string\" || value.length === 0 || value.length > MAX_ISSUE_URL) {\n return undefined;\n }\n let url: URL;\n try {\n // Absolute only: `new URL` throws on a relative one, and a relative href in\n // somebody else's page points somewhere in THEIR app.\n url = new URL(value);\n } catch {\n return undefined;\n }\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") return undefined;\n return url.href;\n}\n\n/** Long enough for any refusal the ingest writes, short enough not to be a body. */\nconst MAX_REFUSAL_REASON = 300;\n\n/**\n * A refusal sentence from the network that is safe to put in front of a\n * reviewer (A-285).\n *\n * `safeIssueUrl`'s argument, applied to prose. The ingest composes refusals a\n * human can act on \u2014 a link issued for a different deployment, an empty\n * comment \u2014 and until this slice the transport threw `HTTP 403` and discarded\n * every one of them, so **not one of those sentences had ever reached a\n * human.**\n *\n * The value is still untrusted, and it ends up as `textContent` in the\n * CUSTOMER'S OWN PAGE. `textContent` is not an injection seam the way `href`\n * is, so the risk here is not script execution \u2014 it is a hostile or broken\n * ingest writing a screenful of control characters, ANSI escapes or a whole\n * JSON document into a toast the reviewer cannot dismiss. So: strings only,\n * control characters dropped, whitespace collapsed, and a hard ceiling.\n *\n * Anything that fails is dropped rather than repaired, and the caller falls\n * back to the HTTP status \u2014 which is what every refusal rendered as before\n * this existed, so the floor cannot get worse.\n */\nexport function safeRefusalReason(value: unknown): string | undefined {\n if (typeof value !== \"string\") return undefined;\n const cleaned = value\n .replace(/[\\u0000-\\u001f\\u007f-\\u009f]+/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n if (cleaned.length === 0) return undefined;\n return cleaned.length > MAX_REFUSAL_REASON\n ? `${cleaned.slice(0, MAX_REFUSAL_REASON - 1).trimEnd()}\u2026`\n : cleaned;\n}\n\n/**\n * The error a refused capture throws, carrying the reason as a FIELD.\n *\n * The reason is in the `message` too, prefixed for a log. A renderer that had\n * to recover it by string-slicing that message would be parsing our own prose,\n * which breaks the first time somebody edits the prefix \u2014 so the sentence a\n * human should see is a property, and `refusalReason` is the one way to read\n * it.\n */\nexport interface IngestRefusal extends Error {\n /** The ingest's own words, already through `safeRefusalReason`. */\n reason?: string;\n /** The status, always \u2014 it is the fallback and it is never absent. */\n status?: number;\n}\n\n/**\n * The reviewer-facing sentence for a failed capture, or `undefined`.\n *\n * SANITISED AGAIN, deliberately, exactly as `safeIssueUrl` is called at both\n * ends: this reads an `EgressOutcome.error` from WHATEVER transport produced\n * it, including one a consumer wrote, which never passed through\n * `httpTransport`. One function, so the two ends cannot disagree.\n */\nexport function refusalReason(error: unknown): string | undefined {\n return safeRefusalReason((error as IngestRefusal | null | undefined)?.reason);\n}\n\nexport interface AnnotationTransport {\n /** For diagnostics and for tests; never sent anywhere. */\n readonly name: string;\n /**\n * May this transport carry this kind of message?\n *\n * The interface needs this because `annotate:hover` fires once per animation\n * frame while the reviewer moves the mouse. Over `postMessage` that is a\n * structured clone to a frame that is already listening; over HTTP it is one\n * request per frame, which is a denial of service aimed at our own console.\n *\n * A transport that DROPPED such a message silently would be the same bug\n * this issue exists to remove, one layer down. So declining is an explicit\n * answer the SDK asks for and respects, and `send` is never called for a\n * kind that was declined.\n */\n carries(kind: FrameMessageKind): boolean;\n /**\n * Deliver the message. May be synchronous.\n *\n * A returned promise MUST reject on failure. Resolving on a failed send is\n * the one thing a transport may not do.\n */\n send(message: FrameToParent): void | Promise<void | SendReceipt>;\n}\n\n/** What went wrong, handed back to whoever constructed the SDK. */\nexport interface EgressFailure {\n message: FrameToParent;\n transport: string;\n error: unknown;\n}\n\nexport type EgressErrorHandler = (failure: EgressFailure) => void;\n\n/**\n * The parent-window transport \u2014 today's behaviour, extracted unchanged.\n *\n * It carries everything: the frame is a live listener on the other end of a\n * cheap channel, and the handshake (`annotate:ready`) and the hover highlight\n * both belong to it.\n */\nexport function postMessageTransport(\n parentOrigin: string,\n win: Pick<Window, \"parent\"> = window,\n): AnnotationTransport {\n const origin = assertConcreteOrigin(parentOrigin);\n return {\n name: \"postMessage\",\n carries: () => true,\n send(message) {\n // Never a wildcard, in either direction.\n win.parent.postMessage(message, origin);\n },\n };\n}\n\n/**\n * Reject anything that is not one concrete `http(s)` URL.\n *\n * `assertConcreteOrigin`'s argument, applied to an endpoint. A relative URL\n * resolves against whatever page the SDK happens to be on \u2014 so a capture\n * POSTs into the CUSTOMER'S OWN app, 404s, and is lost; and a `javascript:`\n * or `data:` endpoint is a script-injection seam handed to whoever controls\n * the deep link. Neither is a thing to discover at first capture.\n */\nexport function assertConcreteEndpoint(endpoint: string): string {\n if (!endpoint || endpoint === \"*\") {\n throw new Error(\n \"[annotation] a concrete ingest endpoint is required; \" +\n \"'*' is not an address and an empty one posts nowhere.\",\n );\n }\n let url: URL;\n try {\n url = new URL(endpoint);\n } catch {\n throw new Error(\n `[annotation] the ingest endpoint must be an absolute URL, got ${JSON.stringify(endpoint)}; ` +\n \"a relative one resolves against the product's own origin and the capture is lost there.\",\n );\n }\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") {\n throw new Error(\n `[annotation] the ingest endpoint must be http(s), got ${url.protocol}`,\n );\n }\n return url.toString();\n}\n\n/** Node's `fetch` and the browser's, narrowed to what we use. */\nexport type FetchLike = (\n input: string,\n init: {\n method: string;\n headers: Record<string, string>;\n /**\n * Optional since A-276: the read client issues a `GET`, which has no body,\n * and a required field would make it pass `undefined` explicitly \u2014 a value\n * a stub asserting on `init` then has to know to ignore. `httpTransport`\n * still always sends one.\n */\n body?: string;\n credentials?: \"omit\";\n signal?: AbortSignal;\n },\n) => Promise<{\n ok: boolean;\n status: number;\n /**\n * Optional because `serve()`'s loopback responses are read through the\n * platform `fetch`, which has it, while a stub written to assert the `init`\n * need not. A transport that made the body mandatory would be asserting\n * something about the ingest that the ingest does not promise.\n */\n json?: () => Promise<unknown>;\n}>;\n\nexport interface HttpTransportOptions {\n /** The console's ingest URL. Absolute, `http(s)`, never `*`. */\n endpoint: string;\n /**\n * The capture token from the deep link \u2014 signed by the console, carried by\n * the product, never validated by it. See the plan's *Authentication*\n * section for why this is a second credential and not the arming one.\n */\n captureToken: string;\n /**\n * Own timeout, own catch. The default is deliberately short: a capture the\n * reviewer is told failed can be retried, and a request that hangs is\n * indistinguishable from one that worked.\n */\n timeoutMs?: number;\n /** Injectable only so the tests can drive a transport with no `fetch` at all. */\n fetch?: FetchLike;\n}\n\nexport const HTTP_TRANSPORT_TIMEOUT_MS = 8_000;\n\n/**\n * The first-party transport: POST the capture to the console's ingest.\n *\n * It carries `annotate:capture` AND NOTHING ELSE. `annotate:hover` is one\n * request per animation frame, and `annotate:ready` / `annotate:cancel` are\n * handshake with a parent window that does not exist first-party \u2014 sending\n * them would be filing HTTP requests nobody asked for.\n */\nexport function httpTransport(options: HttpTransportOptions): AnnotationTransport {\n const endpoint = assertConcreteEndpoint(options.endpoint);\n if (!options.captureToken) {\n throw new Error(\n \"[annotation] a capture token is required; the ingest rejects an unsigned capture \" +\n \"and the reviewer would only find out after writing one.\",\n );\n }\n const token = options.captureToken;\n const timeoutMs = options.timeoutMs ?? HTTP_TRANSPORT_TIMEOUT_MS;\n const doFetch: FetchLike | undefined = options.fetch ?? globalThis.fetch;\n\n return {\n name: \"http\",\n carries: (kind) => kind === \"annotate:capture\",\n async send(message) {\n if (!doFetch) {\n throw new Error(\"[annotation] no fetch implementation is available for the ingest\");\n }\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const response = await doFetch(endpoint, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n authorization: `Bearer ${token}`,\n },\n // Never the reviewer's cookies. The token is the whole credential,\n // and an ambient one would make this endpoint CSRF-shaped.\n credentials: \"omit\",\n body: JSON.stringify(message),\n signal: controller.signal,\n });\n if (!response.ok) {\n /**\n * THE REFUSAL IS READ, AND THE STATUS IS THE FALLBACK (A-285).\n *\n * The ingest composes sentences a reviewer can act on \u2014 \"this link\n * was issued for a different deployment\", \"an empty comment files an\n * issue nobody can action\" \u2014 and this line used to throw `HTTP 403`\n * and drop every one of them on the floor. A status code is not a\n * refusal a human can do anything with; it is what we say when the\n * far end gave us nothing better.\n *\n * The body read is inside its own `catch` for the same reason the\n * success path's is: a refusal we could not parse must still be a\n * refusal, never a `TypeError` from `json()` masquerading as one.\n * Unlike the success path, this branch was ALREADY a failure, so a\n * parse problem loses information rather than a capture.\n */\n let reason: string | undefined;\n try {\n const refusal = (await response.json?.()) as { error?: unknown } | undefined;\n reason = safeRefusalReason(refusal?.error);\n } catch {\n reason = undefined;\n }\n const failure: IngestRefusal = new Error(\n `[annotation] the ingest rejected the capture: ${reason ?? `HTTP ${response.status}`}`,\n );\n failure.status = response.status;\n if (reason !== undefined) failure.reason = reason;\n throw failure;\n }\n /**\n * The capture is delivered at this point and nothing below may undo\n * that. A body that is empty, not JSON, or JSON of some other shape is\n * an ingest that chose not to name an issue \u2014 telling the reviewer\n * their capture failed because we could not parse a courtesy would be\n * the same lie as a silent drop, pointing the other way.\n *\n * A-285 read a body on the REFUSAL path above and this property is\n * untouched by that: the two branches are opposites. Up there the\n * capture was refused and an unparseable body costs only the wording;\n * down here the capture is DELIVERED, and no failure to read a\n * courtesy may ever turn it into a failed one.\n */\n try {\n const body = (await response.json?.()) as { issueUrl?: unknown; url?: unknown; progressUrl?: unknown; kind?: unknown; feedbackId?: unknown; recoveryUrl?: unknown; error?: unknown } | undefined;\n // Untrusted, all of it. `safeIssueUrl` returns undefined for a\n // non-string, a relative URL, an over-long one and any scheme that\n // is not http(s) \u2014 so a hostile body degrades to a capture that was\n // sent and named no issue.\n const progressUrl = safeIssueUrl(body?.progressUrl);\n if (body?.kind === \"saved\" && typeof body.feedbackId === \"string\" && /^[a-zA-Z0-9-]{1,80}$/.test(body.feedbackId)) {\n const recoveryUrl = safeIssueUrl(body.recoveryUrl);\n return {\n ...(progressUrl ? { progressUrl } : {}),\n saved: { feedbackId: body.feedbackId,\n ...(recoveryUrl ? { recoveryUrl } : {}),\n message: safeRefusalReason(body.error) ?? \"Capture saved. Finish filing in the console.\" },\n };\n }\n const issueUrl = safeIssueUrl(body?.issueUrl ?? body?.url);\n if (issueUrl !== undefined || progressUrl !== undefined) {\n return { ...(issueUrl ? { issueUrl } : {}), ...(progressUrl ? { progressUrl } : {}) };\n }\n } catch {}\n return {};\n } catch (error) {\n if (controller.signal.aborted) {\n throw new Error(`[annotation] the capture timed out after ${timeoutMs}ms`, {\n cause: error,\n });\n }\n throw error;\n } finally {\n clearTimeout(timer);\n }\n },\n };\n}\n\n/**\n * What became of one message (A-240).\n *\n * `createEgress` used to return `void`, which was enough while the only reader\n * of a failure was `console.error`. The composer has to TELL THE REVIEWER \u2014 the\n * issue link on success, a retry on failure \u2014 and it cannot do that from a\n * function that answers nothing. So the outcome is returned as a value, and the\n * promise NEVER REJECTS: a rejection here would be a second way for a lost\n * capture to disappear, one `await`-less caller away from silence.\n *\n * `declined` is its own case rather than a failure, because it is not one:\n * `annotate:hover` over HTTP is a kind the transport correctly refuses, and\n * calling that a failed capture would put a red toast in front of a reviewer\n * who did nothing wrong. It is only a problem if `annotate:capture` is what was\n * declined, and that is the caller's judgement rather than this function's.\n */\nexport type EgressOutcome =\n | { status: \"sent\"; receipt: SendReceipt }\n | { status: \"declined\" }\n | { status: \"failed\"; error: unknown };\n\n/**\n * The SDK's one way out, given a transport.\n *\n * Pure and DOM-free on purpose, so the two things worth arguing about \u2014 that a\n * declined kind is never sent, and that a failed send reaches the caller \u2014 are\n * testable without a browser. `sdk.ts` holds no other egress path.\n */\nexport function createEgress(\n transport: AnnotationTransport,\n onError?: EgressErrorHandler,\n): (message: FrameToParent) => Promise<EgressOutcome> {\n const report = (message: FrameToParent, error: unknown) => {\n const failure: EgressFailure = { message, transport: transport.name, error };\n if (onError) {\n onError(failure);\n return;\n }\n /**\n * No handler is not a licence to swallow it. Until A-240 puts a toast in\n * front of the reviewer, the console is the only surface there is, and a\n * lost capture must leave a trace somewhere.\n */\n console.error(\"[annotation] a capture could not be delivered\", failure);\n };\n\n /**\n * `async`, and the synchronous half still runs synchronously \u2014 everything up\n * to the first `await` does, which is `transport.send` itself. So\n * `postMessageTransport` still posts before this returns, and a transport\n * that throws is still reported before this returns. That is not an\n * incidental detail: `sdk.test.ts` asserts the frame path's handshake has\n * been posted the instant `createAnnotationSdk` returns, and it must stay\n * true, because the frame is not being deleted.\n */\n return async (message: FrameToParent): Promise<EgressOutcome> => {\n if (!transport.carries(message.type)) return { status: \"declined\" };\n try {\n const receipt = await transport.send(message);\n return { status: \"sent\", receipt: receipt ?? {} };\n } catch (error) {\n report(message, error);\n return { status: \"failed\", error };\n }\n };\n}\n"],
|
|
5
|
+
"mappings": "yaAAA,IAAAA,EAAA,GAAAC,EAAAD,EAAA,kCAAAE,EAAA,oBAAAC,EAAA,qBAAAC,EAAA,2BAAAC,EAAA,6BAAAC,IAAA,eAAAC,EAAAP,GCuPO,SAASQ,EAAuBC,EAA0B,CAC/D,GAAI,CAACA,GAAYA,IAAa,IAC5B,MAAM,IAAI,MACR,4GAEF,EAEF,IAAIC,EACJ,GAAI,CACFA,EAAM,IAAI,IAAID,CAAQ,CACxB,MAAQ,CACN,MAAM,IAAI,MACR,iEAAiE,KAAK,UAAUA,CAAQ,CAAC,2FAE3F,CACF,CACA,GAAIC,EAAI,WAAa,UAAYA,EAAI,WAAa,QAChD,MAAM,IAAI,MACR,yDAAyDA,EAAI,QAAQ,EACvE,EAEF,OAAOA,EAAI,SAAS,CACtB,CDpNO,IAAMC,EAA+B,EAYtCC,EAAoC,CAAC,OAAQ,cAAe,OAAQ,YAAa,SAAS,EA6E1FC,EAAc,IACdC,EAAY,IAElB,SAASC,EAAUC,EAAgBC,EAAiC,CAClE,GAAI,OAAOD,GAAU,SAAU,OAC/B,IAAME,EAAUF,EACb,QAAQ,iCAAkC,GAAG,EAC7C,QAAQ,OAAQ,GAAG,EACnB,KAAK,EACR,GAAIE,EAAQ,SAAW,EACvB,OAAOA,EAAQ,OAASD,EAAM,GAAGC,EAAQ,MAAM,EAAGD,EAAM,CAAC,EAAE,QAAQ,CAAC,SAAMC,CAC5E,CAEA,SAASC,EAAWH,EAAwB,CAC1C,OAAO,OAAOA,GAAU,UAAY,OAAO,SAASA,CAAK,GAAKA,GAAS,EACnE,KAAK,MAAMA,CAAK,EAChB,CACN,CASA,SAASI,EAAYJ,EAAoC,CACvD,GAAI,OAAOA,GAAU,UAAYA,IAAU,KAAM,OACjD,IAAMK,EAAML,EACNM,EAAOD,EAAI,KAEjB,GAAIC,IAAS,QAAS,CACpB,IAAMC,EAAO,OAAOF,EAAI,MAAY,SAAWA,EAAI,KAAU,OAC7D,OAAOE,IAAS,OAAY,CAAE,KAAM,OAAQ,EAAI,CAAE,KAAM,QAAS,KAAAA,CAAK,CACxE,CACA,GAAID,IAAS,WAAY,CACvB,IAAMC,EAAO,OAAOF,EAAI,MAAY,SAAWA,EAAI,KAAU,OAC7D,OAAOE,IAAS,OAAY,OAAY,CAAE,KAAM,WAAY,KAAAA,CAAK,CACnE,CACA,GAAID,IAAS,aAAc,CACzB,IAAME,EAAOH,EAAI,KACXI,EAAOJ,EAAI,KACXK,EAAQL,EAAI,MAClB,GAAI,OAAOG,GAAS,UAAY,OAAOC,GAAS,UAAY,CAAC,MAAM,QAAQC,CAAK,EAC9E,OAEF,IAAMC,EAASD,EAAM,QAASE,GAAU,CACtC,GAAI,OAAOA,GAAU,UAAYA,IAAU,KAAM,MAAO,CAAC,EACzD,IAAMC,EAAID,EACV,GAAI,OAAOC,EAAE,MAAY,UAAY,OAAOA,EAAE,MAAY,SAAU,MAAO,CAAC,EAC5E,IAAMC,EAAuD,CAC3D,KAAMD,EAAE,KACR,KAAMA,EAAE,IACV,EACA,OAAI,OAAOA,EAAE,QAAc,WAAUC,EAAI,OAASD,EAAE,QAC7C,CAACC,CAAG,CACb,CAAC,EACKC,EAAiB,CAAE,KAAM,aAAc,KAAAP,EAAM,KAAAC,EAAM,MAAOE,CAAO,EACvE,OAAI,OAAON,EAAI,QAAc,WAAUU,EAAO,OAASV,EAAI,QACvD,OAAOA,EAAI,SAAe,WAAUU,EAAO,QAAUV,EAAI,SACtDU,CACT,CAQF,CAEA,SAASC,EAAYhB,EAA8C,CACjE,GAAI,OAAOA,GAAU,UAAYA,IAAU,KAAM,OACjD,IAAMK,EAAML,EAENiB,EAAKlB,EAAUM,EAAI,GAAOP,CAAS,EACnCoB,EAAUnB,EAAUM,EAAI,QAAYR,CAAW,EAC/CkB,EAASX,EAAYC,EAAI,MAAS,EACxC,GAAIY,IAAO,QAAaC,IAAY,QAAaH,IAAW,OAAW,OAEvE,IAAMI,EACJ,OAAOd,EAAI,QAAc,UAAYA,EAAI,SAAc,KAAOA,EAAI,OAAY,CAAC,EAE3Ee,EAAQD,EAAU,MAExB,MAAO,CACL,GAAAF,EACA,OAAAF,EACA,QAAAG,EACA,SAAUnB,EAAUM,EAAI,SAAaP,CAAS,GAAK,aACnD,UAAWC,EAAUM,EAAI,UAAcP,CAAS,GAAK,GACrD,OAAQ,CACN,MAAQ,OAAOsB,GAAU,UAAYxB,EAAe,SAASwB,CAAK,EAC9DA,EACA,UACJ,QAASrB,EAAUoB,EAAU,QAAYrB,CAAS,GAAK,SACzD,CACF,CACF,CAUO,SAASuB,EAAyBrB,EAAqC,CAC5E,IAAMK,EAAO,OAAOL,GAAU,UAAYA,IAAU,KAAOA,EAAQ,CAAC,EAE9DsB,GADO,MAAM,QAAQjB,EAAI,WAAc,EAAIA,EAAI,YAAiB,CAAC,GAC9C,QAASkB,GAAU,CAC1C,IAAMC,EAASR,EAAYO,CAAK,EAChC,OAAOC,IAAW,OAAY,CAAC,EAAI,CAACA,CAAM,CAC5C,CAAC,EACKC,EAASpB,EAAI,OAEnB,MAAO,CACL,GAAIA,EAAI,KAAU,GAClB,YAAaF,EAAWE,EAAI,WAAc,EAC1C,KAAMN,EAAUM,EAAI,KAASP,CAAS,GAAK,GAC3C,MAAOK,EAAWE,EAAI,KAAQ,EAC9B,SAAUF,EAAWE,EAAI,QAAW,EACpC,WAAYF,EAAWE,EAAI,UAAa,EACxC,eAAgBF,EAAWE,EAAI,cAAiB,EAChD,GAAI,OAAOoB,GAAW,SAAW,CAAE,OAAQA,CAA4B,EAAI,CAAC,EAC5E,YAAAH,CACF,CACF,CAmBO,IAAMI,EAAkB,IAalBC,EAAmB,UAqCzB,SAASC,EAAuBC,EAAoD,CACzF,IAAMC,EAAWC,EAAuBF,EAAQ,QAAQ,EACxD,GAAI,CAACA,EAAQ,UACX,MAAM,IAAI,MACR,qJAEF,EAEF,IAAMG,EAAQH,EAAQ,UAChBI,EAAYJ,EAAQ,WAAaH,EACjCQ,EAAiCL,EAAQ,OAAS,WAAW,MAEnE,MAAO,CACL,MAAM,KAAKtB,EAAcsB,EAA+D,CACtF,GAAI,CAACK,EACH,MAAM,IAAI,MAAM,gEAAgE,EAElF,IAAMC,EAAM,IAAI,IAAIL,CAAQ,EAM5BK,EAAI,aAAa,IAAI,OAAQ5B,CAAI,EAU7BsB,GAAS,kBAAoB,IAC/BM,EAAI,aAAa,IAAI,WAAYR,CAAgB,EAGnD,IAAMS,EAAa,IAAI,gBACjBC,EAAQ,WAAW,IAAMD,EAAW,MAAM,EAAGH,CAAS,EAC5D,GAAI,CACF,IAAMK,EAAW,MAAMJ,EAAQC,EAAI,SAAS,EAAG,CAC7C,OAAQ,MACR,QAAS,CAAE,cAAe,UAAUH,CAAK,EAAG,EAE5C,YAAa,OACb,OAAQI,EAAW,MACrB,CAAC,EACGG,EACJ,GAAI,CACFA,EAAO,MAAMD,EAAS,OAAO,CAC/B,MAAQ,CACNC,EAAO,MACT,CACA,OAAOlB,EAAyBkB,CAAI,CACtC,OAASC,EAAO,CACd,MAAIJ,EAAW,OAAO,QACd,IAAI,MAAM,yCAAyCH,CAAS,KAAM,CAAE,MAAOO,CAAM,CAAC,EAEpFA,CACR,QAAE,CACA,aAAaH,CAAK,CACpB,CACF,CACF,CACF",
|
|
6
6
|
"names": ["read_exports", "__export", "ANNOTATION_READ_WIRE_VERSION", "READ_TIMEOUT_MS", "RESOLVED_INCLUDE", "createAnnotationReader", "parseAnnotationsResponse", "__toCommonJS", "assertConcreteEndpoint", "endpoint", "url", "ANNOTATION_READ_WIRE_VERSION", "KNOWN_STATUSES", "MAX_COMMENT", "MAX_SHORT", "cleanText", "value", "max", "cleaned", "cleanCount", "parseAnchor", "raw", "kind", "path", "file", "line", "chain", "frames", "frame", "f", "out", "anchor", "parseMarker", "id", "comment", "statusRaw", "state", "parseAnnotationsResponse", "annotations", "entry", "marker", "reason", "READ_TIMEOUT_MS", "RESOLVED_INCLUDE", "createAnnotationReader", "options", "endpoint", "assertConcreteEndpoint", "token", "timeoutMs", "doFetch", "url", "controller", "timer", "response", "body", "error"]
|
|
7
7
|
}
|
package/dist/read.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
function
|
|
1
|
+
function h(e){if(!e||e==="*")throw new Error("[annotation] a concrete ingest endpoint is required; '*' is not an address and an empty one posts nowhere.");let n;try{n=new URL(e)}catch{throw new Error(`[annotation] the ingest endpoint must be an absolute URL, got ${JSON.stringify(e)}; a relative one resolves against the product's own origin and the capture is lost there.`)}if(n.protocol!=="https:"&&n.protocol!=="http:")throw new Error(`[annotation] the ingest endpoint must be http(s), got ${n.protocol}`);return n.toString()}var x=1,m=["open","in-progress","done","cancelled","unknown"],w=4e3,l=200;function f(e,n){if(typeof e!="string")return;let r=e.replace(/[\u0000-\u001f\u007f-\u009f]+/g," ").replace(/\s+/g," ").trim();if(r.length!==0)return r.length>n?`${r.slice(0,n-1).trimEnd()}\u2026`:r}function p(e){return typeof e=="number"&&Number.isFinite(e)&&e>=0?Math.floor(e):0}function k(e){if(typeof e!="object"||e===null)return;let n=e,r=n.kind;if(r==="route"){let t=typeof n.path=="string"?n.path:void 0;return t===void 0?{kind:"route"}:{kind:"route",path:t}}if(r==="dom-path"){let t=typeof n.path=="string"?n.path:void 0;return t===void 0?void 0:{kind:"dom-path",path:t}}if(r==="source-ref"){let t=n.file,o=n.line,i=n.chain;if(typeof t!="string"||typeof o!="number"||!Array.isArray(i))return;let s=i.flatMap(u=>{if(typeof u!="object"||u===null)return[];let a=u;if(typeof a.file!="string"||typeof a.line!="number")return[];let d={file:a.file,line:a.line};return typeof a.column=="number"&&(d.column=a.column),[d]}),c={kind:"source-ref",file:t,line:o,chain:s};return typeof n.column=="number"&&(c.column=n.column),typeof n.ordinal=="number"&&(c.ordinal=n.ordinal),c}}function y(e){if(typeof e!="object"||e===null)return;let n=e,r=f(n.id,l),t=f(n.comment,w),o=k(n.anchor);if(r===void 0||t===void 0||o===void 0)return;let i=typeof n.status=="object"&&n.status!==null?n.status:{},s=i.state;return{id:r,anchor:o,comment:t,reviewer:f(n.reviewer,l)??"A reviewer",createdAt:f(n.createdAt,l)??"",status:{state:typeof s=="string"&&m.includes(s)?s:"unknown",display:f(i.display,l)??"Unknown"}}}function A(e){let n=typeof e=="object"&&e!==null?e:{},t=(Array.isArray(n.annotations)?n.annotations:[]).flatMap(i=>{let s=y(i);return s===void 0?[]:[s]}),o=n.reason;return{ok:n.ok===!0,wireVersion:p(n.wireVersion),asOf:f(n.asOf,l)??"",total:p(n.total),anchored:p(n.anchored),anchorless:p(n.anchorless),resolvedHidden:p(n.resolvedHidden),...typeof o=="string"?{reason:o}:{},annotations:t}}var b=8e3,R="include";function M(e){let n=h(e.endpoint);if(!e.readToken)throw new Error("[annotation] a read token is required; the console refuses an unsigned read and the page would render an empty marker layer with no way to say why.");let r=e.readToken,t=e.timeoutMs??b,o=e.fetch??globalThis.fetch;return{async read(i,s){if(!o)throw new Error("[annotation] no fetch implementation is available for the read");let c=new URL(n);c.searchParams.set("path",i),s?.includeResolved===!0&&c.searchParams.set("resolved",R);let u=new AbortController,a=setTimeout(()=>u.abort(),t);try{let d=await o(c.toString(),{method:"GET",headers:{authorization:`Bearer ${r}`},credentials:"omit",signal:u.signal}),g;try{g=await d.json?.()}catch{g=void 0}return A(g)}catch(d){throw u.signal.aborted?new Error(`[annotation] the read timed out after ${t}ms`,{cause:d}):d}finally{clearTimeout(a)}}}}export{x as ANNOTATION_READ_WIRE_VERSION,b as READ_TIMEOUT_MS,R as RESOLVED_INCLUDE,M as createAnnotationReader,A as parseAnnotationsResponse};
|
|
2
2
|
//# sourceMappingURL=read.mjs.map
|