@weatherboard/annotation 0.1.0 → 0.2.1

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 CHANGED
@@ -128,6 +128,42 @@ Four details, each of which has cost somebody a day:
128
128
  plain http — so a local `next dev` would set a cookie the browser silently
129
129
  discards. Fall back to `Lax`, which is same-origin anyway.
130
130
 
131
+ ### First-party, the cookie is an ordinary one — use `armedCookie()` (A-241)
132
+
133
+ Everything above describes the **framed** surface. When the console links out to
134
+ your product instead of framing it, the reviewer is top-level at your own origin
135
+ and none of the cross-site reasoning applies: `SameSite=None` is wrong there,
136
+ and it is the spelling that would break on its own under Chrome's third-party
137
+ cookie work.
138
+
139
+ Do not re-derive the attributes. `@weatherboard/annotation/protocol` exports the
140
+ cookie itself, so the two halves cannot disagree:
141
+
142
+ ```ts
143
+ import { armedCookie, clearedCookie, ANNOTATION_ARMED_COOKIE } from "@weatherboard/annotation/protocol";
144
+
145
+ // after YOUR comparison against YOUR secret has succeeded, and only then:
146
+ response.headers.append("set-cookie", armedCookie());
147
+ ```
148
+
149
+ It takes no value argument on purpose — the cookie holds a marker, never the
150
+ token, and the first bullet above is the reason. The page half calls
151
+ `readArming()`, which reads that cookie, strips the tokens out of the URL with
152
+ `replaceState`, and hands the capture token to `httpTransport`.
153
+
154
+ *Done* is a full teardown, not just a disarm: it writes
155
+ `clearedCookie(...)` for both spellings, drops the stored capture token, removes
156
+ the toolbar host and the SDK's overlay from your DOM, and leaves nothing on the
157
+ page that can capture again without re-arming through the deep link. Mount it
158
+ once per page load and let *Done* end the session; there is nothing for you to
159
+ clean up.
160
+
161
+ **Send `Referrer-Policy: strict-origin` too.** `readArming` strips the query as
162
+ early as script can run, and there is a window before that: anything in your
163
+ document's `<head>` is requested first and carries the full token-bearing URL to
164
+ your own origin as `Referer`. Measured, not assumed — see the header of
165
+ `arming.ts`. Only a response header closes it, and only you can send one.
166
+
131
167
  `readAnnotationToken` returns the **value**, not a boolean, and returns `null`
132
168
  for an absent *or empty* parameter. Compare it yourself: only you hold the
133
169
  secret.
@@ -0,0 +1,97 @@
1
+ # Compatibility
2
+
3
+ **At one consumer, a protocol change is a coordinated deploy. At a hundred, it is
4
+ a hundred upgrades that will not happen at the same time — and some that will not
5
+ happen at all.**
6
+
7
+ This page is the promise that makes that survivable, and the rules are enforced
8
+ by tests rather than by good intentions. See `fixtures/README.md`.
9
+
10
+ ---
11
+
12
+ ## The four rules
13
+
14
+ ### 1. Fields are added, never repurposed
15
+
16
+ A new field is **optional**, and **absent means what every existing capture
17
+ already meant**.
18
+
19
+ ```ts
20
+ selection?: "element" | "region"; // absent === "element"
21
+ ```
22
+
23
+ Required-with-a-default has the same shape and a worse failure: an older bundle
24
+ omits it, the type says it is there, and every reader believes a value nobody
25
+ sent. That is not a bug anyone catches — it is a confident wrong answer.
26
+
27
+ Changing what an existing field *means*, while leaving its type alone, is the
28
+ same violation wearing a disguise. It is invisible to a type checker, which is
29
+ why the fixtures exist.
30
+
31
+ ### 2. A reader accepts every version it has ever accepted
32
+
33
+ Until a deliberate, separate removal — its own change, with its own argument.
34
+
35
+ Dropping support is not a side effect of adding something.
36
+
37
+ ### 3. A writer emits both spellings during a migration, never only the new one
38
+
39
+ Until every consumer is known to be past it. "Known", not assumed: for a
40
+ published package that means evidence, and for anything less than evidence it
41
+ means keep writing both.
42
+
43
+ `ANNOTATION_TOKEN_PARAMS` and `ANNOTATION_ARMED_COOKIES` are this rule in code —
44
+ ordered lists, current first, and readers take the first match.
45
+
46
+ ### 4. `schemaVersion` bumps only for what the rules above cannot express
47
+
48
+ And a bump is an **event**, not a routine. A consumer on the old version is
49
+ refused rather than guessed at — see `validateCapture`, which fails a mismatched
50
+ version *before reading any field*, because a newer payload may have moved the
51
+ one field the capture exists to carry.
52
+
53
+ If you are reaching for a bump, check first that you are not really reaching for
54
+ rule 1.
55
+
56
+ ---
57
+
58
+ ## The rename that is not happening again
59
+
60
+ `ai_annotate` and `ai_annotate_armed` replaced a product-specific spelling, and
61
+ readers still accept the old one. **There will not be a third.**
62
+
63
+ Publishing as `@weatherboard/annotation` makes it tempting to match the names to
64
+ the package. A third spelling does not *replace* the second while the migration
65
+ is unfinished — it **joins** it. Three names accepted, three emitted, and the
66
+ window in which a consumer can be on any of them widens instead of closing.
67
+
68
+ Two is a migration. Three is a habit, and the third would be paid for by
69
+ consumers who never asked for either. A published package needs names that name
70
+ no product; it does not need names that name the package.
71
+
72
+ There is a test asserting both lists are exactly two entries long, so a third
73
+ fails there — with this argument attached — rather than in somebody's bundle six
74
+ weeks later.
75
+
76
+ ---
77
+
78
+ ## Why this is enforced with fixtures
79
+
80
+ Every failure in this protocol is silent. A reader that stops accepting a shape
81
+ does not throw: the page never announces itself, the frame waits forever, and
82
+ nothing anywhere says why.
83
+
84
+ So the enforcement is a **recorded payload per shipped shape**, replayed through
85
+ every reader on every change. A type checker cannot see a meaning change that
86
+ keeps the shape; a fixture that stops parsing can only mean one thing.
87
+
88
+ Removing a legacy name fails those tests until its fixture is deliberately
89
+ retired — which is the point. The cost of dropping support should be paid by
90
+ whoever drops it, in the same change, and not by whoever finds out.
91
+
92
+ ---
93
+
94
+ ## Versioning
95
+
96
+ `0.x` while the wire protocol is still moving. **1.0 is the statement that a
97
+ hundred consumers can rely on it**, not a way to look finished.
package/README.md CHANGED
@@ -98,12 +98,18 @@ captures all report no source ref, the transform is not running.
98
98
 
99
99
  ## Compatibility
100
100
 
101
+ **[COMPATIBILITY.md](./COMPATIBILITY.md)** is the policy, and it is enforced by
102
+ tests rather than by good intentions: fields are added and never repurposed, a
103
+ reader accepts every version it has ever accepted, a writer emits both spellings
104
+ through a migration, and `schemaVersion` bumps only for what those rules cannot
105
+ express.
106
+
107
+ Every shape ever sent is recorded in `fixtures/` — shipped in this package, so
108
+ if you write your own reader you can replay them too.
109
+
101
110
  `0.x`. The wire protocol is still moving, and the version says so. The move to
102
111
  1.0 will be the statement that it has stopped — not a decision to look finished.
103
112
 
104
- Two of the wire names have a legacy spelling that readers still accept. See
105
- `protocol.ts`; they are not changing again.
106
-
107
113
  ## Licence
108
114
 
109
115
  MIT.
@@ -0,0 +1,145 @@
1
+ import { type BuildInfo } from "./protocol.cjs";
2
+ /**
3
+ * Arming, and the production kill-switch (A-241).
4
+ *
5
+ * ===========================================================================
6
+ * THE SEQUENCE THIS FILE IS
7
+ * ===========================================================================
8
+ *
9
+ * The reviewer lands on `?ai_annotate=<arming>&ai_capture=<signed>&ai_run=<id>`
10
+ * from the console's *Request changes* button. This module is what happens in
11
+ * the first few milliseconds after that:
12
+ *
13
+ * 1. read the three parameters
14
+ * 2. **strip them from the URL** with `replaceState`, before anything else
15
+ * 3. answer *is this page armed* — from the COOKIE, never from the URL
16
+ * 4. only then, and only if armed, keep the capture token for the tab
17
+ *
18
+ * Step 3 is the runtime half of the kill-switch and step 2 is not decoration;
19
+ * both are argued at length below. Steps 3 and 4 were the other way round
20
+ * until A-254, which is how an unarmed page came to persist an
21
+ * attacker-supplied token — the argument, and the measurement that settled it,
22
+ * are at the gate itself in `readArming`.
23
+ *
24
+ * ===========================================================================
25
+ * TWO CREDENTIALS, AND THIS FILE TOUCHES THEM DIFFERENTLY ON PURPOSE
26
+ * ===========================================================================
27
+ *
28
+ * `docs/plans/first-party-annotation.md` § *Authentication* is the argument;
29
+ * what it means for this file is a table:
30
+ *
31
+ * | | who verifies it | what this module does with it |
32
+ * | --- | --- | --- |
33
+ * | arming token (`ai_annotate`) | the product's own middleware, against its `ANNOTATION_TOKEN_<PRODUCT>` | **nothing.** Reads the middleware's answer out of the cookie and drops the token |
34
+ * | capture token (`ai_capture`) | the console, on the way back in | couriers it: keeps it for the tab, hands it to `httpTransport` |
35
+ *
36
+ * **The URL parameter is not arming and this module never treats it as such.**
37
+ * That is the runtime gate, stated as a single sentence, and it is the reason
38
+ * `readArming` does not compare anything: page script holds no secret, so a
39
+ * page that armed itself because a token-shaped string was in its own query
40
+ * string would be armed by anyone who could type one. The middleware compares
41
+ * and sets a cookie; this reads the cookie. A wrong token therefore produces no
42
+ * cookie, and no cookie produces no toolbar — `arming.test.ts` asserts exactly
43
+ * that, and asserts it with a token-bearing URL present so it cannot pass by
44
+ * accident.
45
+ *
46
+ * ===========================================================================
47
+ * WHY THE TOKENS COME OUT OF THE URL IMMEDIATELY
48
+ * ===========================================================================
49
+ *
50
+ * A capture token in an address bar is a capture token in a screenshot, a
51
+ * `Referer` header, a pasted link and a server access log — and this one
52
+ * authorises filing issues as the reviewer it was minted for. `replaceState`
53
+ * rewrites the entry in place: no navigation and no new history entry.
54
+ *
55
+ * **MEASURED, not assumed** — chromium 151.0.7922.34 driven by playwright
56
+ * against a loopback server that logged every `Referer` it received. The page
57
+ * did the same `searchParams.delete` + `replaceState` this module does, with
58
+ * one stylesheet in the initial `<head>` and a second appended by script
59
+ * afterwards:
60
+ *
61
+ * ```
62
+ * /early.css Referer: http://…/page?tab=menu&ai_annotate=…&ai_capture=…&ai_run=…
63
+ * /late.css Referer: http://…/page?tab=menu
64
+ * ```
65
+ *
66
+ * Two things come out of that run, and both are the point:
67
+ *
68
+ * 1. **The residual window is real, and it is bounded.** Anything the page
69
+ * requests before this function runs — the document's own scripts,
70
+ * stylesheets, fonts, images — is a SAME-ORIGIN subresource, and under the
71
+ * default `Referrer-Policy` (`strict-origin-when-cross-origin`) a
72
+ * same-origin request carries the FULL URL, query string included. It
73
+ * reaches only the product's own origin, which has already seen the tokens
74
+ * on the request line of the navigation itself, and everything requested
75
+ * after this call sees the stripped URL. That is the case a reviewer of
76
+ * A-244 named and deferred to this slice, answered with a measurement.
77
+ * 2. **Back does not restore the tokens.** The same run then called
78
+ * `goBack()` and landed on the URL from BEFORE the deep link rather than on
79
+ * the token-bearing one — which is what `replaceState` rather than
80
+ * `pushState` buys, and is why that choice is not stylistic.
81
+ *
82
+ * Closing the window entirely is a RESPONSE HEADER on the product
83
+ * (`Referrer-Policy: strict-origin`, which drops path and query for same-origin
84
+ * requests too), so it belongs to the mount in the product's own repository —
85
+ * N-905, not this package. **This module cannot close it**: by the time any
86
+ * bundled script runs, the head's subresources are already in flight. What is
87
+ * ours is being callable early, which is why nothing here awaits anything and
88
+ * the strip happens before the token is stored or the cookie is read.
89
+ *
90
+ * ===========================================================================
91
+ * SESSION STORAGE FOR THE CAPTURE TOKEN, AND NOT A COOKIE
92
+ * ===========================================================================
93
+ *
94
+ * The arming marker is a cookie because the product's MIDDLEWARE sets it, on
95
+ * the server, before any script runs. The capture token is deliberately not
96
+ * one: a cookie is attached to every request the browser makes to that origin,
97
+ * so it would put the credential into the product's own access logs on every
98
+ * page, image and API call for the rest of the session — the exact exposure
99
+ * step 2 exists to end. `sessionStorage` is per-tab, survives internal
100
+ * navigation (which is what arming has to survive), is never sent anywhere by
101
+ * the browser, and is gone when the tab closes.
102
+ *
103
+ * The cost, stated: a link opened in a NEW tab has no stored token, so a
104
+ * reviewer who middle-clicks their way around the app gets a toolbar that
105
+ * cannot send. That is the correct failure — `httpTransport` refuses to
106
+ * construct without a token — and it is recoverable by using the deep link
107
+ * again.
108
+ */
109
+ /** Per-tab, never sent anywhere by the browser. Namespaced, not product-named. */
110
+ export declare const CAPTURE_TOKEN_STORAGE_KEY = "ai.annotation.capture";
111
+ export declare const REVIEW_RUN_STORAGE_KEY = "ai.annotation.run";
112
+ /** The subset of `Storage` this needs. `null` disables persistence entirely. */
113
+ export type StorageLike = Pick<Storage, "getItem" | "setItem" | "removeItem">;
114
+ export interface ArmingOptions {
115
+ /** Defaults to the ambient `document`. Needs `cookie`, `URL` and `defaultView`. */
116
+ doc?: Document;
117
+ /** Defaults to `doc.defaultView.history`. Only `replaceState` is used. */
118
+ history?: Pick<History, "replaceState">;
119
+ /**
120
+ * Defaults to `sessionStorage`. Pass `null` for a tab that should keep
121
+ * nothing — arming then lasts exactly as long as the query string does.
122
+ */
123
+ storage?: StorageLike | null;
124
+ /** Overrides the URL read from the document. Tests and server renders only. */
125
+ url?: string;
126
+ }
127
+ export interface Arming {
128
+ /** May this page offer an annotation UI at all? */
129
+ armed: boolean;
130
+ /** The console's signed token, for `httpTransport`. Never validated here. */
131
+ captureToken: string | null;
132
+ /** The run under review, unsigned, couriered. */
133
+ runId: string | null;
134
+ /** *Done*, or an explicit stop. Clears the cookie and the stored token. */
135
+ disarm: () => void;
136
+ }
137
+ /**
138
+ * Read the deep link, strip it, and answer whether this page is armed.
139
+ *
140
+ * Safe to call more than once and safe to call on a page that was never
141
+ * linked to: with no parameters and no cookie it does nothing at all, touches
142
+ * no history entry, and returns `armed: false`.
143
+ */
144
+ export declare function readArming(options?: ArmingOptions): Arming;
145
+ export declare function isAnnotatableBuild(build: Pick<BuildInfo, "env">): boolean;
@@ -0,0 +1,145 @@
1
+ import { type BuildInfo } from "./protocol.mjs";
2
+ /**
3
+ * Arming, and the production kill-switch (A-241).
4
+ *
5
+ * ===========================================================================
6
+ * THE SEQUENCE THIS FILE IS
7
+ * ===========================================================================
8
+ *
9
+ * The reviewer lands on `?ai_annotate=<arming>&ai_capture=<signed>&ai_run=<id>`
10
+ * from the console's *Request changes* button. This module is what happens in
11
+ * the first few milliseconds after that:
12
+ *
13
+ * 1. read the three parameters
14
+ * 2. **strip them from the URL** with `replaceState`, before anything else
15
+ * 3. answer *is this page armed* — from the COOKIE, never from the URL
16
+ * 4. only then, and only if armed, keep the capture token for the tab
17
+ *
18
+ * Step 3 is the runtime half of the kill-switch and step 2 is not decoration;
19
+ * both are argued at length below. Steps 3 and 4 were the other way round
20
+ * until A-254, which is how an unarmed page came to persist an
21
+ * attacker-supplied token — the argument, and the measurement that settled it,
22
+ * are at the gate itself in `readArming`.
23
+ *
24
+ * ===========================================================================
25
+ * TWO CREDENTIALS, AND THIS FILE TOUCHES THEM DIFFERENTLY ON PURPOSE
26
+ * ===========================================================================
27
+ *
28
+ * `docs/plans/first-party-annotation.md` § *Authentication* is the argument;
29
+ * what it means for this file is a table:
30
+ *
31
+ * | | who verifies it | what this module does with it |
32
+ * | --- | --- | --- |
33
+ * | arming token (`ai_annotate`) | the product's own middleware, against its `ANNOTATION_TOKEN_<PRODUCT>` | **nothing.** Reads the middleware's answer out of the cookie and drops the token |
34
+ * | capture token (`ai_capture`) | the console, on the way back in | couriers it: keeps it for the tab, hands it to `httpTransport` |
35
+ *
36
+ * **The URL parameter is not arming and this module never treats it as such.**
37
+ * That is the runtime gate, stated as a single sentence, and it is the reason
38
+ * `readArming` does not compare anything: page script holds no secret, so a
39
+ * page that armed itself because a token-shaped string was in its own query
40
+ * string would be armed by anyone who could type one. The middleware compares
41
+ * and sets a cookie; this reads the cookie. A wrong token therefore produces no
42
+ * cookie, and no cookie produces no toolbar — `arming.test.ts` asserts exactly
43
+ * that, and asserts it with a token-bearing URL present so it cannot pass by
44
+ * accident.
45
+ *
46
+ * ===========================================================================
47
+ * WHY THE TOKENS COME OUT OF THE URL IMMEDIATELY
48
+ * ===========================================================================
49
+ *
50
+ * A capture token in an address bar is a capture token in a screenshot, a
51
+ * `Referer` header, a pasted link and a server access log — and this one
52
+ * authorises filing issues as the reviewer it was minted for. `replaceState`
53
+ * rewrites the entry in place: no navigation and no new history entry.
54
+ *
55
+ * **MEASURED, not assumed** — chromium 151.0.7922.34 driven by playwright
56
+ * against a loopback server that logged every `Referer` it received. The page
57
+ * did the same `searchParams.delete` + `replaceState` this module does, with
58
+ * one stylesheet in the initial `<head>` and a second appended by script
59
+ * afterwards:
60
+ *
61
+ * ```
62
+ * /early.css Referer: http://…/page?tab=menu&ai_annotate=…&ai_capture=…&ai_run=…
63
+ * /late.css Referer: http://…/page?tab=menu
64
+ * ```
65
+ *
66
+ * Two things come out of that run, and both are the point:
67
+ *
68
+ * 1. **The residual window is real, and it is bounded.** Anything the page
69
+ * requests before this function runs — the document's own scripts,
70
+ * stylesheets, fonts, images — is a SAME-ORIGIN subresource, and under the
71
+ * default `Referrer-Policy` (`strict-origin-when-cross-origin`) a
72
+ * same-origin request carries the FULL URL, query string included. It
73
+ * reaches only the product's own origin, which has already seen the tokens
74
+ * on the request line of the navigation itself, and everything requested
75
+ * after this call sees the stripped URL. That is the case a reviewer of
76
+ * A-244 named and deferred to this slice, answered with a measurement.
77
+ * 2. **Back does not restore the tokens.** The same run then called
78
+ * `goBack()` and landed on the URL from BEFORE the deep link rather than on
79
+ * the token-bearing one — which is what `replaceState` rather than
80
+ * `pushState` buys, and is why that choice is not stylistic.
81
+ *
82
+ * Closing the window entirely is a RESPONSE HEADER on the product
83
+ * (`Referrer-Policy: strict-origin`, which drops path and query for same-origin
84
+ * requests too), so it belongs to the mount in the product's own repository —
85
+ * N-905, not this package. **This module cannot close it**: by the time any
86
+ * bundled script runs, the head's subresources are already in flight. What is
87
+ * ours is being callable early, which is why nothing here awaits anything and
88
+ * the strip happens before the token is stored or the cookie is read.
89
+ *
90
+ * ===========================================================================
91
+ * SESSION STORAGE FOR THE CAPTURE TOKEN, AND NOT A COOKIE
92
+ * ===========================================================================
93
+ *
94
+ * The arming marker is a cookie because the product's MIDDLEWARE sets it, on
95
+ * the server, before any script runs. The capture token is deliberately not
96
+ * one: a cookie is attached to every request the browser makes to that origin,
97
+ * so it would put the credential into the product's own access logs on every
98
+ * page, image and API call for the rest of the session — the exact exposure
99
+ * step 2 exists to end. `sessionStorage` is per-tab, survives internal
100
+ * navigation (which is what arming has to survive), is never sent anywhere by
101
+ * the browser, and is gone when the tab closes.
102
+ *
103
+ * The cost, stated: a link opened in a NEW tab has no stored token, so a
104
+ * reviewer who middle-clicks their way around the app gets a toolbar that
105
+ * cannot send. That is the correct failure — `httpTransport` refuses to
106
+ * construct without a token — and it is recoverable by using the deep link
107
+ * again.
108
+ */
109
+ /** Per-tab, never sent anywhere by the browser. Namespaced, not product-named. */
110
+ export declare const CAPTURE_TOKEN_STORAGE_KEY = "ai.annotation.capture";
111
+ export declare const REVIEW_RUN_STORAGE_KEY = "ai.annotation.run";
112
+ /** The subset of `Storage` this needs. `null` disables persistence entirely. */
113
+ export type StorageLike = Pick<Storage, "getItem" | "setItem" | "removeItem">;
114
+ export interface ArmingOptions {
115
+ /** Defaults to the ambient `document`. Needs `cookie`, `URL` and `defaultView`. */
116
+ doc?: Document;
117
+ /** Defaults to `doc.defaultView.history`. Only `replaceState` is used. */
118
+ history?: Pick<History, "replaceState">;
119
+ /**
120
+ * Defaults to `sessionStorage`. Pass `null` for a tab that should keep
121
+ * nothing — arming then lasts exactly as long as the query string does.
122
+ */
123
+ storage?: StorageLike | null;
124
+ /** Overrides the URL read from the document. Tests and server renders only. */
125
+ url?: string;
126
+ }
127
+ export interface Arming {
128
+ /** May this page offer an annotation UI at all? */
129
+ armed: boolean;
130
+ /** The console's signed token, for `httpTransport`. Never validated here. */
131
+ captureToken: string | null;
132
+ /** The run under review, unsigned, couriered. */
133
+ runId: string | null;
134
+ /** *Done*, or an explicit stop. Clears the cookie and the stored token. */
135
+ disarm: () => void;
136
+ }
137
+ /**
138
+ * Read the deep link, strip it, and answer whether this page is armed.
139
+ *
140
+ * Safe to call more than once and safe to call on a page that was never
141
+ * linked to: with no parameters and no cookie it does nothing at all, touches
142
+ * no history entry, and returns `armed: false`.
143
+ */
144
+ export declare function readArming(options?: ArmingOptions): Arming;
145
+ export declare function isAnnotatableBuild(build: Pick<BuildInfo, "env">): boolean;
@@ -0,0 +1,145 @@
1
+ import { type BuildInfo } from "./protocol.js";
2
+ /**
3
+ * Arming, and the production kill-switch (A-241).
4
+ *
5
+ * ===========================================================================
6
+ * THE SEQUENCE THIS FILE IS
7
+ * ===========================================================================
8
+ *
9
+ * The reviewer lands on `?ai_annotate=<arming>&ai_capture=<signed>&ai_run=<id>`
10
+ * from the console's *Request changes* button. This module is what happens in
11
+ * the first few milliseconds after that:
12
+ *
13
+ * 1. read the three parameters
14
+ * 2. **strip them from the URL** with `replaceState`, before anything else
15
+ * 3. answer *is this page armed* — from the COOKIE, never from the URL
16
+ * 4. only then, and only if armed, keep the capture token for the tab
17
+ *
18
+ * Step 3 is the runtime half of the kill-switch and step 2 is not decoration;
19
+ * both are argued at length below. Steps 3 and 4 were the other way round
20
+ * until A-254, which is how an unarmed page came to persist an
21
+ * attacker-supplied token — the argument, and the measurement that settled it,
22
+ * are at the gate itself in `readArming`.
23
+ *
24
+ * ===========================================================================
25
+ * TWO CREDENTIALS, AND THIS FILE TOUCHES THEM DIFFERENTLY ON PURPOSE
26
+ * ===========================================================================
27
+ *
28
+ * `docs/plans/first-party-annotation.md` § *Authentication* is the argument;
29
+ * what it means for this file is a table:
30
+ *
31
+ * | | who verifies it | what this module does with it |
32
+ * | --- | --- | --- |
33
+ * | arming token (`ai_annotate`) | the product's own middleware, against its `ANNOTATION_TOKEN_<PRODUCT>` | **nothing.** Reads the middleware's answer out of the cookie and drops the token |
34
+ * | capture token (`ai_capture`) | the console, on the way back in | couriers it: keeps it for the tab, hands it to `httpTransport` |
35
+ *
36
+ * **The URL parameter is not arming and this module never treats it as such.**
37
+ * That is the runtime gate, stated as a single sentence, and it is the reason
38
+ * `readArming` does not compare anything: page script holds no secret, so a
39
+ * page that armed itself because a token-shaped string was in its own query
40
+ * string would be armed by anyone who could type one. The middleware compares
41
+ * and sets a cookie; this reads the cookie. A wrong token therefore produces no
42
+ * cookie, and no cookie produces no toolbar — `arming.test.ts` asserts exactly
43
+ * that, and asserts it with a token-bearing URL present so it cannot pass by
44
+ * accident.
45
+ *
46
+ * ===========================================================================
47
+ * WHY THE TOKENS COME OUT OF THE URL IMMEDIATELY
48
+ * ===========================================================================
49
+ *
50
+ * A capture token in an address bar is a capture token in a screenshot, a
51
+ * `Referer` header, a pasted link and a server access log — and this one
52
+ * authorises filing issues as the reviewer it was minted for. `replaceState`
53
+ * rewrites the entry in place: no navigation and no new history entry.
54
+ *
55
+ * **MEASURED, not assumed** — chromium 151.0.7922.34 driven by playwright
56
+ * against a loopback server that logged every `Referer` it received. The page
57
+ * did the same `searchParams.delete` + `replaceState` this module does, with
58
+ * one stylesheet in the initial `<head>` and a second appended by script
59
+ * afterwards:
60
+ *
61
+ * ```
62
+ * /early.css Referer: http://…/page?tab=menu&ai_annotate=…&ai_capture=…&ai_run=…
63
+ * /late.css Referer: http://…/page?tab=menu
64
+ * ```
65
+ *
66
+ * Two things come out of that run, and both are the point:
67
+ *
68
+ * 1. **The residual window is real, and it is bounded.** Anything the page
69
+ * requests before this function runs — the document's own scripts,
70
+ * stylesheets, fonts, images — is a SAME-ORIGIN subresource, and under the
71
+ * default `Referrer-Policy` (`strict-origin-when-cross-origin`) a
72
+ * same-origin request carries the FULL URL, query string included. It
73
+ * reaches only the product's own origin, which has already seen the tokens
74
+ * on the request line of the navigation itself, and everything requested
75
+ * after this call sees the stripped URL. That is the case a reviewer of
76
+ * A-244 named and deferred to this slice, answered with a measurement.
77
+ * 2. **Back does not restore the tokens.** The same run then called
78
+ * `goBack()` and landed on the URL from BEFORE the deep link rather than on
79
+ * the token-bearing one — which is what `replaceState` rather than
80
+ * `pushState` buys, and is why that choice is not stylistic.
81
+ *
82
+ * Closing the window entirely is a RESPONSE HEADER on the product
83
+ * (`Referrer-Policy: strict-origin`, which drops path and query for same-origin
84
+ * requests too), so it belongs to the mount in the product's own repository —
85
+ * N-905, not this package. **This module cannot close it**: by the time any
86
+ * bundled script runs, the head's subresources are already in flight. What is
87
+ * ours is being callable early, which is why nothing here awaits anything and
88
+ * the strip happens before the token is stored or the cookie is read.
89
+ *
90
+ * ===========================================================================
91
+ * SESSION STORAGE FOR THE CAPTURE TOKEN, AND NOT A COOKIE
92
+ * ===========================================================================
93
+ *
94
+ * The arming marker is a cookie because the product's MIDDLEWARE sets it, on
95
+ * the server, before any script runs. The capture token is deliberately not
96
+ * one: a cookie is attached to every request the browser makes to that origin,
97
+ * so it would put the credential into the product's own access logs on every
98
+ * page, image and API call for the rest of the session — the exact exposure
99
+ * step 2 exists to end. `sessionStorage` is per-tab, survives internal
100
+ * navigation (which is what arming has to survive), is never sent anywhere by
101
+ * the browser, and is gone when the tab closes.
102
+ *
103
+ * The cost, stated: a link opened in a NEW tab has no stored token, so a
104
+ * reviewer who middle-clicks their way around the app gets a toolbar that
105
+ * cannot send. That is the correct failure — `httpTransport` refuses to
106
+ * construct without a token — and it is recoverable by using the deep link
107
+ * again.
108
+ */
109
+ /** Per-tab, never sent anywhere by the browser. Namespaced, not product-named. */
110
+ export declare const CAPTURE_TOKEN_STORAGE_KEY = "ai.annotation.capture";
111
+ export declare const REVIEW_RUN_STORAGE_KEY = "ai.annotation.run";
112
+ /** The subset of `Storage` this needs. `null` disables persistence entirely. */
113
+ export type StorageLike = Pick<Storage, "getItem" | "setItem" | "removeItem">;
114
+ export interface ArmingOptions {
115
+ /** Defaults to the ambient `document`. Needs `cookie`, `URL` and `defaultView`. */
116
+ doc?: Document;
117
+ /** Defaults to `doc.defaultView.history`. Only `replaceState` is used. */
118
+ history?: Pick<History, "replaceState">;
119
+ /**
120
+ * Defaults to `sessionStorage`. Pass `null` for a tab that should keep
121
+ * nothing — arming then lasts exactly as long as the query string does.
122
+ */
123
+ storage?: StorageLike | null;
124
+ /** Overrides the URL read from the document. Tests and server renders only. */
125
+ url?: string;
126
+ }
127
+ export interface Arming {
128
+ /** May this page offer an annotation UI at all? */
129
+ armed: boolean;
130
+ /** The console's signed token, for `httpTransport`. Never validated here. */
131
+ captureToken: string | null;
132
+ /** The run under review, unsigned, couriered. */
133
+ runId: string | null;
134
+ /** *Done*, or an explicit stop. Clears the cookie and the stored token. */
135
+ disarm: () => void;
136
+ }
137
+ /**
138
+ * Read the deep link, strip it, and answer whether this page is armed.
139
+ *
140
+ * Safe to call more than once and safe to call on a page that was never
141
+ * linked to: with no parameters and no cookie it does nothing at all, touches
142
+ * no history entry, and returns `armed: false`.
143
+ */
144
+ export declare function readArming(options?: ArmingOptions): Arming;
145
+ export declare function isAnnotatableBuild(build: Pick<BuildInfo, "env">): boolean;
package/dist/host.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";var s=Object.defineProperty;var c=Object.getOwnPropertyDescriptor;var p=Object.getOwnPropertyNames;var l=Object.prototype.hasOwnProperty;var g=(e,t)=>{for(var a in t)s(e,a,{get:t[a],enumerable:!0})},f=(e,t,a,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of p(t))!l.call(e,r)&&r!==a&&s(e,r,{get:()=>t[r],enumerable:!(o=c(t,r))||o.enumerable});return e};var m=e=>f(s({},"__esModule",{value:!0}),e);var T={};g(T,{createAnnotationHost:()=>A,isFrameMessage:()=>d});module.exports=m(T);function u(e){if(!e||e==="*")throw new Error("[annotation] a concrete parent origin is required; '*' would broadcast annotation payloads to any embedder.");return e}var y=new Set(["annotate:ready","annotate:hover","annotate:capture","annotate:cancel"]);function d(e){if(typeof e!="object"||e===null)return!1;let t=e.type;return typeof t=="string"&&y.has(t)}function A(e){let t=u(e.frameOrigin),a=!1,o=!1,r=n=>{o||e.frame.postMessage(n,t)},i=n=>{if(n.origin===t&&n.source===e.frame&&d(n.data))switch(n.data.type){case"annotate:ready":a=!0,e.onReady({sdkVersion:n.data.sdkVersion,build:n.data.build});return;case"annotate:hover":e.onHover(n.data.target);return;case"annotate:capture":e.onCapture(n.data.payload);return;case"annotate:cancel":e.onCancel();return}};return e.hostWindow.addEventListener("message",i),{enable:()=>r({type:"annotate:enable"}),disable:()=>r({type:"annotate:disable"}),ping:()=>r({type:"annotate:ping"}),isReady:()=>a,destroy(){o=!0,a=!1,e.hostWindow.removeEventListener("message",i)}}}
1
+ "use strict";var i=Object.defineProperty;var p=Object.getOwnPropertyDescriptor;var d=Object.getOwnPropertyNames;var l=Object.prototype.hasOwnProperty;var g=(e,t)=>{for(var o in t)i(e,o,{get:t[o],enumerable:!0})},f=(e,t,o,a)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of d(t))!l.call(e,r)&&r!==o&&i(e,r,{get:()=>t[r],enumerable:!(a=p(t,r))||a.enumerable});return e};var m=e=>f(i({},"__esModule",{value:!0}),e);var T={};g(T,{createAnnotationHost:()=>A,isFrameMessage:()=>u});module.exports=m(T);function c(e){if(!e||e==="*")throw new Error("[annotation] a concrete parent origin is required; '*' would broadcast annotation payloads to any embedder.");return e}var y=new Set(["annotate:ready","annotate:hover","annotate:capture","annotate:cancel"]);function u(e){if(typeof e!="object"||e===null)return!1;let t=e.type;return typeof t=="string"&&y.has(t)}function A(e){let t=c(e.frameOrigin),o=!1,a=!1,r=n=>{a||e.frame.postMessage(n,t)},s=n=>{if(n.origin===t&&n.source===e.frame&&u(n.data))switch(n.data.type){case"annotate:ready":o=!0,e.onReady({sdkVersion:n.data.sdkVersion,build:n.data.build});return;case"annotate:hover":e.onHover(n.data.target);return;case"annotate:capture":e.onCapture(n.data.payload);return;case"annotate:cancel":e.onCancel();return}};return e.hostWindow.addEventListener("message",s),{enable:()=>r({type:"annotate:enable"}),disable:()=>r({type:"annotate:disable"}),ping:()=>r({type:"annotate:ping"}),isReady:()=>o,destroy(){a=!0,o=!1,e.hostWindow.removeEventListener("message",s)}}}
2
2
  //# sourceMappingURL=host.cjs.map