@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 +36 -0
- package/COMPATIBILITY.md +97 -0
- package/README.md +9 -3
- package/dist/arming.d.cts +145 -0
- package/dist/arming.d.mts +145 -0
- package/dist/arming.d.ts +145 -0
- package/dist/host.cjs +1 -1
- package/dist/host.cjs.map +2 -2
- package/dist/host.mjs +1 -1
- package/dist/host.mjs.map +2 -2
- package/dist/index.cjs +64 -1
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.cts +5 -2
- package/dist/index.d.mts +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.mjs +64 -1
- package/dist/index.mjs.map +4 -4
- package/dist/protocol.cjs +1 -1
- package/dist/protocol.cjs.map +3 -3
- package/dist/protocol.d.cts +114 -0
- package/dist/protocol.d.mts +114 -0
- package/dist/protocol.d.ts +114 -0
- package/dist/protocol.mjs +1 -1
- package/dist/protocol.mjs.map +3 -3
- package/dist/sdk.d.cts +92 -3
- package/dist/sdk.d.mts +92 -3
- package/dist/sdk.d.ts +92 -3
- package/dist/toolbar.d.cts +185 -0
- package/dist/toolbar.d.mts +185 -0
- package/dist/toolbar.d.ts +185 -0
- package/dist/transport.d.cts +218 -0
- package/dist/transport.d.mts +218 -0
- package/dist/transport.d.ts +218 -0
- package/fixtures/README.md +35 -0
- package/fixtures/v1-minimal.json +17 -0
- package/fixtures/v1-no-source-ref.json +22 -0
- package/fixtures/v1-region-selection.json +23 -0
- package/fixtures/v1-screenshot.json +19 -0
- package/fixtures/v1-source-chain.json +24 -0
- package/package.json +11 -4
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.
|
package/COMPATIBILITY.md
ADDED
|
@@ -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;
|
package/dist/arming.d.ts
ADDED
|
@@ -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
|
|
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
|