annotepage-client 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -26
- package/dist/HASHES.txt +2 -0
- package/dist/annotepage.js +339 -100
- package/labels/fr.json +13 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,25 +27,25 @@ described in `FORMAT.md`, at the root of the repository. When this file and
|
|
|
27
27
|
|
|
28
28
|
## Putting the tool on a site
|
|
29
29
|
|
|
30
|
-
### 1. Generate the
|
|
30
|
+
### 1. Generate the key, and put it away
|
|
31
31
|
|
|
32
32
|
Load the client once, on a page of the site, with `data-setup` and **without**
|
|
33
33
|
`data-project`:
|
|
34
34
|
|
|
35
35
|
```html
|
|
36
|
-
<script src="https://<your-cdn>/annotepage-client@2.
|
|
37
|
-
integrity="sha384-
|
|
36
|
+
<script src="https://<your-cdn>/annotepage-client@2.3.0/dist/annotepage.js"
|
|
37
|
+
integrity="sha384-oZAAGkI1KL5aAJCD+v7QPGqxcg5fzizz8hsnUHfNJPXeNVccDX251y5+EZRDCHEl"
|
|
38
38
|
crossorigin="anonymous"
|
|
39
39
|
data-server="https://<your-server>/annotepage/api.php"
|
|
40
40
|
data-setup
|
|
41
41
|
defer></script>
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
The setup screen generates a **256-bit
|
|
45
|
-
copy: the
|
|
44
|
+
The setup screen generates a **256-bit key** and gives you four things to
|
|
45
|
+
copy: the key, the project id, the final tag, and the three lines to declare
|
|
46
46
|
on the server. No network request is made at that point.
|
|
47
47
|
|
|
48
|
-
> **
|
|
48
|
+
> **KEY LOST = NOTES LOST.** The key is the only secret of the project. It
|
|
49
49
|
> never leaves the browser, the server receives it in no form whatsoever, and
|
|
50
50
|
> nobody can give it back to you. There is no recovery, no security question,
|
|
51
51
|
> no escrow third party. Put it where your team keeps its passwords **before**
|
|
@@ -54,8 +54,8 @@ on the server. No network request is made at that point.
|
|
|
54
54
|
### 2. Paste the final tag, at the end of `<body>`
|
|
55
55
|
|
|
56
56
|
```html
|
|
57
|
-
<script src="https://<your-cdn>/annotepage-client@2.
|
|
58
|
-
integrity="sha384-
|
|
57
|
+
<script src="https://<your-cdn>/annotepage-client@2.3.0/dist/annotepage.js"
|
|
58
|
+
integrity="sha384-oZAAGkI1KL5aAJCD+v7QPGqxcg5fzizz8hsnUHfNJPXeNVccDX251y5+EZRDCHEl"
|
|
59
59
|
crossorigin="anonymous"
|
|
60
60
|
data-server="https://<your-server>/annotepage/api.php"
|
|
61
61
|
data-project="7Qb1kZ3xNvA9dLpEqKf2Zt"
|
|
@@ -66,7 +66,7 @@ on the server. No network request is made at that point.
|
|
|
66
66
|
|
|
67
67
|
**`integrity` is not decorative.** As soon as the client goes to a CDN, the
|
|
68
68
|
real risk of this architecture is the supply chain: a file swapped at the CDN's
|
|
69
|
-
host runs in your page, with access to `localStorage` -- hence to the
|
|
69
|
+
host runs in your page, with access to `localStorage` -- hence to the key. The
|
|
70
70
|
SRI digest is what makes that swap useless. `crossorigin="anonymous"` goes with
|
|
71
71
|
it: without it the browser does not check the digest of a cross-origin
|
|
72
72
|
resource.
|
|
@@ -80,12 +80,12 @@ that is exactly its job.
|
|
|
80
80
|
`document.currentScript`: the client could no longer read its own attributes
|
|
81
81
|
and would stand down in silence.
|
|
82
82
|
|
|
83
|
-
### 3. Declare the project on the server, and give the
|
|
83
|
+
### 3. Declare the project on the server, and give the key to the team
|
|
84
84
|
|
|
85
85
|
The server receives the project id (public) and the list of allowed origins.
|
|
86
|
-
The
|
|
86
|
+
The key travels **out of band** -- the tool provides no channel for it. Each
|
|
87
87
|
reviewer pastes it once: the tool shows them the pasting screen, checks that
|
|
88
|
-
the
|
|
88
|
+
the key really derives the id declared by the page, and remembers it in their
|
|
89
89
|
browser.
|
|
90
90
|
|
|
91
91
|
## The tag attributes
|
|
@@ -190,7 +190,7 @@ it is.
|
|
|
190
190
|
|
|
191
191
|
## What the server never sees, and what it sees anyway
|
|
192
192
|
|
|
193
|
-
It receives neither the
|
|
193
|
+
It receives neither the key, nor the key, nor the path of your pages: it
|
|
194
194
|
groups by **blind index**, an HMAC of the path that it cannot invert. It does
|
|
195
195
|
see the number of projects and notes, the number of distinct pages, the time of
|
|
196
196
|
every write, the shape of the threads, the approximate length of each remark,
|
|
@@ -203,7 +203,7 @@ remarks".
|
|
|
203
203
|
The server's domain lock is an **anti-abuse** measure: it stops another site
|
|
204
204
|
from consuming a project id found in the source of a page. **It is not a
|
|
205
205
|
protection against XSS**: an XSS runs INSIDE the target page, so with the
|
|
206
|
-
legitimate origin, and it has access to `localStorage`, hence to the
|
|
206
|
+
legitimate origin, and it has access to `localStorage`, hence to the key.
|
|
207
207
|
|
|
208
208
|
The path prefix (`data-path`) is checked **by the client** -- the server does
|
|
209
209
|
not see paths. It is **tidiness**, not a security boundary.
|
|
@@ -264,19 +264,19 @@ variables nor the theme of the host site.
|
|
|
264
264
|
`textContent` everywhere, `innerHTML` nowhere: the text of a note is typed by a
|
|
265
265
|
human and is never interpreted as markup.
|
|
266
266
|
|
|
267
|
-
## The interface says "key", the format says "
|
|
267
|
+
## The interface says "key", the format says "key"
|
|
268
268
|
|
|
269
269
|
Since 2.0.2 every string a person reads says **key** -- "The key of this
|
|
270
270
|
project is needed", "The project key (43 characters)". Cryptographically that
|
|
271
271
|
is what it is: 32 bytes of key material, from which HKDF derives the project
|
|
272
|
-
id, the AES-256-GCM key and the blind-index key. A
|
|
272
|
+
id, the AES-256-GCM key and the blind-index key. A key, by definition, is
|
|
273
273
|
public; this is the only secret there is, and the word invited people to treat
|
|
274
274
|
it as if it were not.
|
|
275
275
|
|
|
276
|
-
What did NOT change, deliberately: the label KEYS are still `
|
|
277
|
-
`
|
|
276
|
+
What did NOT change, deliberately: the label KEYS are still `key.title`,
|
|
277
|
+
`key.help` and so on -- renaming them would silently break every translation
|
|
278
278
|
file already written against them -- and `FORMAT.md`, the storage key
|
|
279
|
-
`annotepage/
|
|
279
|
+
`annotepage/key/<project>`, and the MCP's `key` configuration field are
|
|
280
280
|
unchanged, because those are a format and a contract rather than prose.
|
|
281
281
|
|
|
282
282
|
## Translating, or changing a word
|
|
@@ -329,7 +329,7 @@ something one can read.
|
|
|
329
329
|
|
|
330
330
|
`npm test` cross-checks the format vectors against a second implementation of
|
|
331
331
|
HKDF-SHA-256 written by hand from RFC 5869. That is what guarantees that the
|
|
332
|
-
|
|
332
|
+
key is the input keying material and `annotepage/1` the HKDF salt, and not the
|
|
333
333
|
other way round: both "work", only one is the format. The PHP server and the
|
|
334
334
|
MCP package can copy these vectors to check that they speak the same format.
|
|
335
335
|
|
|
@@ -349,12 +349,12 @@ repository:
|
|
|
349
349
|
src/00-preamble.js reading the tag: server, project, scope, limits
|
|
350
350
|
src/10-utils.js labels, base64url, dates, versions
|
|
351
351
|
src/15-labels.js EVERY text shown, English by default
|
|
352
|
-
src/20-crypto.js
|
|
352
|
+
src/20-crypto.js key, HKDF, blind index, AES-256-GCM envelope
|
|
353
353
|
src/30-state.js state, browser memory, scope
|
|
354
354
|
src/40-api.js the calls, the refusals, what goes out encrypted or plain
|
|
355
355
|
src/50-anchors.js finding the element of a note, or calling it orphaned
|
|
356
356
|
src/60-ui.js all the DOM, inside the shadow root
|
|
357
|
-
src/70-setup.js the two screens that show or ask for the
|
|
357
|
+
src/70-setup.js the two screens that show or ask for the key
|
|
358
358
|
src/90-boot.js the order of ignition, and the silences
|
|
359
359
|
src/styles.css confined styles, inlined by the build
|
|
360
360
|
tools/build.mjs the assembly, and the SRI digest
|
|
@@ -371,16 +371,16 @@ This is a choice, not an oversight:
|
|
|
371
371
|
|
|
372
372
|
- **no authentication.** The name typed in is a convenience, not an identity.
|
|
373
373
|
The project id is a bearer token: whoever has it can read and write. In
|
|
374
|
-
encrypted mode, what they read is useless without the
|
|
374
|
+
encrypted mode, what they read is useless without the key;
|
|
375
375
|
- **no moderation, and no deletion.** A note that is posted stays. The only
|
|
376
376
|
state it can change is "resolved", and that state can be taken back;
|
|
377
|
-
- **no
|
|
377
|
+
- **no key rotation.** There is no mechanism: a leaked key means starting
|
|
378
378
|
from a fresh project, abandoning the notes;
|
|
379
|
-
- **no channel for handing the
|
|
379
|
+
- **no channel for handing the key** to the second reviewer;
|
|
380
380
|
- **no masking of the length** of the remarks: the size of the envelope gives
|
|
381
381
|
it away to within a few bytes.
|
|
382
382
|
|
|
383
|
-
The
|
|
383
|
+
The key is remembered **per browser and per origin**. The day staging becomes
|
|
384
384
|
production, every reviewer pastes it once more on the new domain -- the notes
|
|
385
385
|
themselves do not move. That is exactly what the rule "the domain is not in the
|
|
386
386
|
key" buys.
|
package/dist/HASHES.txt
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
2.3.0 sha384-oZAAGkI1KL5aAJCD+v7QPGqxcg5fzizz8hsnUHfNJPXeNVccDX251y5+EZRDCHEl 154823 bytes
|
|
2
|
+
2.2.0 sha384-+/SJuJv8IJRDsV5d5yXvlmLRiVmZHmX1NMph76iwNJuqFbQgQgbdDmseIxUl+DWz 154823 bytes
|
|
1
3
|
2.1.0 sha384-tMQlD7gryDE13ShW/GCVLMhJU8tqODcREl9RksXuf67eectM4YPTUZXLfkmakFoO 142816 bytes
|
|
2
4
|
2.0.2 sha384-IeG681tUVluUYNg632FP8SMWIHZp1pEEP5/s6XT+PIJpbp7JJDkGThQPVx1xf31p 142816 bytes
|
|
3
5
|
2.0.1 sha384-aSkx1XQLhcYpDQrpoD8yUn0Wu00BHbi1KktWiXqMQufioNtReQw+tm804/ZzNfo8 134184 bytes
|
package/dist/annotepage.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* ============================================================================
|
|
2
2
|
annotepage -- the annotation layer, browser side.
|
|
3
3
|
|
|
4
|
-
Package version : 2.
|
|
4
|
+
Package version : 2.3.0
|
|
5
5
|
Format version : 2 (see FORMAT.md)
|
|
6
6
|
Licence : MIT
|
|
7
7
|
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
/* Injected by the build: they come from package.json and from
|
|
17
17
|
src/styles.css, so that no value is written in two places and can
|
|
18
18
|
therefore diverge. */
|
|
19
|
-
const TOOL_VERSION = "2.
|
|
19
|
+
const TOOL_VERSION = "2.3.0";
|
|
20
20
|
const FORMAT = 2;
|
|
21
|
-
const STYLES = "/* ============================================================================\n styles.css -- THE STYLES OF THE TOOL, AND OF NO OTHER ELEMENT.\n\n This sheet is INLINED into the served file by the build, then put into the\n tool's shadow root -- as a constructed sheet when the browser can do it, in\n a <style> otherwise. It was loaded by a <link> in the original tool; the\n move to a CDN under SRI brought it inside the file, so that there is only\n one digest to keep up to date. The containment itself has not changed, and\n is still twofold:\n\n - from the tool towards the site: no rule from here can reach an element\n of the host site, the browser sees to that. That is what makes the\n claim \"the layer touches nothing\" checkable rather than promised;\n - from the site towards the tool: no rule of the site can reach an\n element here. A redesign of the site's stylesheet therefore cannot\n distort the tool, nor the other way round.\n\n The \"ap-\" prefix on every class is the third safeguard: the day somebody\n loads these styles WITHOUT a shadow root -- by mistake, or to debug --\n nothing would answer a selector of the site.\n\n NO RULE TARGETS html, body, * OR ANY SELECTOR OF THE SITE. That is the one\n absolute prohibition of this file.\n\n COLOURS: the tool has its OWN palette, defined on the shadow root. It\n reads neither the site's variables nor its theme attribute: it has no\n reason to know how the site names its colours, and it must stay readable\n on a light site as on a dark one. The switch follows the system\n preference, the only information the tool has without asking anyone.\n ============================================================================ */\n\n\n:host {\n --ap-bg: #ffffff;\n --ap-bg-soft: #f4f6f8;\n --ap-bg-raised: #e9edf2;\n --ap-text: #1a1d21;\n --ap-text-soft: #5b6570;\n --ap-border: #d5dbe2;\n --ap-accent: #2f6fed;\n --ap-accent-dark: #1d55c8;\n --ap-accent-text: #ffffff;\n --ap-accent-veil: rgba(47, 111, 237, 0.14);\n --ap-alert-bg: #fdeceb;\n --ap-alert-border: #e3a9a4;\n --ap-alert-text: #8a1f16;\n --ap-shadow: 0 6px 24px rgba(16, 24, 40, 0.18);\n --ap-radius: 10px;\n --ap-font: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\",\n Arial, sans-serif;\n}\n\n@media (prefers-color-scheme: dark) {\n :host {\n --ap-bg: #1d2126;\n --ap-bg-soft: #262b32;\n --ap-bg-raised: #323942;\n --ap-text: #e9ecf0;\n --ap-text-soft: #a4adb8;\n --ap-border: #3a424c;\n --ap-accent: #6d9bff;\n --ap-accent-dark: #8fb4ff;\n --ap-accent-text: #10151c;\n --ap-accent-veil: rgba(109, 155, 255, 0.18);\n --ap-alert-bg: #3a1f1c;\n --ap-alert-border: #7c3a33;\n --ap-alert-text: #ffb9b1;\n --ap-shadow: 0 6px 24px rgba(0, 0, 0, 0.55);\n }\n}\n\n/* ----------------------------------------------------------------------------\n The layer.\n\n It covers the viewport and receives NO click: that is what lets the page\n behave exactly as usual as long as the tool is not in annotation mode.\n Each widget re-enables clicks for itself alone.\n ---------------------------------------------------------------------------- */\n\n.ap-layer {\n position: absolute;\n inset: 0;\n pointer-events: none;\n font-family: var(--ap-font);\n font-size: 14px;\n line-height: 1.45;\n color: var(--ap-text);\n text-align: left;\n -webkit-font-smoothing: antialiased;\n}\n\n.ap-layer button,\n.ap-layer input,\n.ap-layer textarea {\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: inherit;\n margin: 0;\n box-sizing: border-box;\n}\n\n/* ----------------------------------------------------------------------------\n The button: the only thing visible when the tool is at rest.\n ---------------------------------------------------------------------------- */\n\n.ap-button {\n position: fixed;\n right: 16px;\n bottom: 16px;\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 9px 14px;\n border: 1px solid var(--ap-border);\n border-radius: 999px;\n background: var(--ap-bg);\n color: var(--ap-text);\n box-shadow: var(--ap-shadow);\n cursor: pointer;\n pointer-events: auto;\n opacity: 0.92;\n transition: opacity 0.15s ease, transform 0.15s ease;\n}\n\n.ap-button:hover,\n.ap-button:focus-visible {\n opacity: 1;\n transform: translateY(-1px);\n}\n\n.ap-button:focus-visible {\n outline: 2px solid var(--ap-accent);\n outline-offset: 2px;\n}\n\n.ap-button[aria-pressed=\"true\"] {\n background: var(--ap-accent);\n border-color: var(--ap-accent);\n color: var(--ap-accent-text);\n opacity: 1;\n}\n\n.ap-button-dot {\n display: inline-block;\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: var(--ap-accent);\n flex: none;\n}\n\n.ap-button[aria-pressed=\"true\"] .ap-button-dot {\n background: var(--ap-accent-text);\n}\n\n.ap-button-count {\n padding: 1px 7px;\n border-radius: 999px;\n background: var(--ap-bg-raised);\n color: var(--ap-text-soft);\n font-size: 12px;\n}\n\n.ap-button[aria-pressed=\"true\"] .ap-button-count {\n background: rgba(255, 255, 255, 0.22);\n color: var(--ap-accent-text);\n}\n\n/* ----------------------------------------------------------------------------\n The pointing highlight.\n\n It is DRAWN HERE, from the coordinates of the element being pointed at.\n Nothing is put on the element itself: no class, no attribute, no style. So\n the site cannot move by a single pixel because of the pointing.\n ---------------------------------------------------------------------------- */\n\n.ap-highlight {\n position: fixed;\n border: 2px solid var(--ap-accent);\n border-radius: 3px;\n background: var(--ap-accent-veil);\n pointer-events: none;\n display: none;\n}\n\n.ap-highlight-label {\n position: fixed;\n max-width: 320px;\n padding: 4px 8px;\n border-radius: 6px;\n background: var(--ap-accent);\n color: var(--ap-accent-text);\n font-size: 12px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n pointer-events: none;\n display: none;\n box-shadow: var(--ap-shadow);\n}\n\n/* ----------------------------------------------------------------------------\n The markers: \"there are already notes here\".\n ---------------------------------------------------------------------------- */\n\n.ap-marker {\n position: fixed;\n min-width: 22px;\n height: 22px;\n padding: 0 6px;\n border: 2px solid var(--ap-bg);\n border-radius: 999px;\n background: var(--ap-accent);\n color: var(--ap-accent-text);\n font-size: 12px;\n font-weight: 700;\n line-height: 18px;\n text-align: center;\n cursor: pointer;\n pointer-events: auto;\n box-shadow: var(--ap-shadow);\n}\n\n.ap-marker:focus-visible {\n outline: 2px solid var(--ap-accent-dark);\n outline-offset: 2px;\n}\n\n/* ----------------------------------------------------------------------------\n The panel.\n ---------------------------------------------------------------------------- */\n\n.ap-panel {\n position: fixed;\n top: 12px;\n right: 12px;\n bottom: 72px;\n width: 360px;\n max-width: calc(100vw - 24px);\n display: none;\n flex-direction: column;\n border: 1px solid var(--ap-border);\n border-radius: var(--ap-radius);\n background: var(--ap-bg);\n box-shadow: var(--ap-shadow);\n pointer-events: auto;\n overflow: hidden;\n}\n\n.ap-panel.ap-open {\n display: flex;\n}\n\n.ap-panel-header {\n display: flex;\n align-items: baseline;\n gap: 8px;\n padding: 12px 14px;\n border-bottom: 1px solid var(--ap-border);\n background: var(--ap-bg-soft);\n}\n\n.ap-panel-title {\n font-size: 15px;\n font-weight: 600;\n flex: 1 1 auto;\n}\n\n.ap-panel-instructions {\n padding: 10px 14px;\n border-bottom: 1px solid var(--ap-border);\n color: var(--ap-text-soft);\n font-size: 13px;\n}\n\n.ap-panel-body {\n flex: 1 1 auto;\n overflow-y: auto;\n overscroll-behavior: contain;\n padding: 4px 14px 14px;\n}\n\n.ap-panel-footer {\n padding: 8px 14px;\n border-top: 1px solid var(--ap-border);\n background: var(--ap-bg-soft);\n color: var(--ap-text-soft);\n font-size: 12px;\n display: flex;\n align-items: center;\n gap: 8px;\n}\n\n.ap-section-title {\n margin: 14px 0 6px;\n color: var(--ap-text-soft);\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n}\n\n.ap-section-help {\n margin: 0 0 8px;\n color: var(--ap-text-soft);\n font-size: 12px;\n}\n\n.ap-empty {\n margin: 16px 0;\n color: var(--ap-text-soft);\n}\n\n/* The standing mention of a public key. Deliberately NOT the alert colours:\n this is not a failure and it is on screen for ever -- an alarm that never\n goes away stops being read. It is a fact about the project, stated in the\n panel's own tone, and it stays at the top of every draw. */\n.ap-public {\n margin: 0 0 10px;\n padding: 8px 10px;\n border: 1px solid var(--ap-border);\n border-radius: var(--ap-radius);\n background: var(--ap-bg-soft);\n color: var(--ap-text-soft);\n font-size: 12px;\n line-height: 1.45;\n}\n\n/* ----------------------------------------------------------------------------\n A note, and its replies.\n ---------------------------------------------------------------------------- */\n\n.ap-note {\n margin: 8px 0;\n padding: 10px 12px;\n border: 1px solid var(--ap-border);\n border-radius: var(--ap-radius);\n background: var(--ap-bg);\n}\n\n.ap-note.ap-orphan {\n background: var(--ap-bg-soft);\n}\n\n.ap-note.ap-focused {\n border-color: var(--ap-accent);\n box-shadow: 0 0 0 3px var(--ap-accent-veil);\n}\n\n.ap-note-header {\n display: flex;\n align-items: baseline;\n gap: 8px;\n flex-wrap: wrap;\n}\n\n.ap-note-author {\n font-weight: 600;\n}\n\n.ap-note-date {\n color: var(--ap-text-soft);\n font-size: 12px;\n}\n\n.ap-note-target {\n margin: 4px 0 0;\n color: var(--ap-text-soft);\n font-size: 12px;\n font-style: italic;\n overflow-wrap: anywhere;\n}\n\n.ap-note-text {\n margin: 6px 0 0;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n\n.ap-note-actions {\n margin-top: 8px;\n display: flex;\n gap: 8px;\n flex-wrap: wrap;\n}\n\n.ap-replies {\n margin: 8px 0 0;\n padding-left: 10px;\n border-left: 2px solid var(--ap-border);\n}\n\n.ap-reply {\n margin: 8px 0 0;\n}\n\n/* ----------------------------------------------------------------------------\n The form, anchored near the element pointed at.\n ---------------------------------------------------------------------------- */\n\n.ap-form {\n position: fixed;\n width: 340px;\n max-width: calc(100vw - 24px);\n display: none;\n flex-direction: column;\n gap: 8px;\n padding: 14px;\n border: 1px solid var(--ap-border);\n border-radius: var(--ap-radius);\n background: var(--ap-bg);\n box-shadow: var(--ap-shadow);\n pointer-events: auto;\n}\n\n.ap-form.ap-open {\n display: flex;\n}\n\n.ap-form-title {\n font-size: 15px;\n font-weight: 600;\n}\n\n.ap-form-target {\n color: var(--ap-text-soft);\n font-size: 12px;\n font-style: italic;\n overflow-wrap: anywhere;\n}\n\n.ap-label {\n display: block;\n margin-bottom: 3px;\n font-size: 12px;\n font-weight: 600;\n color: var(--ap-text-soft);\n}\n\n.ap-help {\n margin: 3px 0 0;\n font-size: 12px;\n color: var(--ap-text-soft);\n}\n\n.ap-field,\n.ap-area {\n width: 100%;\n padding: 8px 10px;\n border: 1px solid var(--ap-border);\n border-radius: 8px;\n background: var(--ap-bg-soft);\n color: var(--ap-text);\n}\n\n.ap-field:focus,\n.ap-area:focus {\n outline: 2px solid var(--ap-accent);\n outline-offset: 1px;\n}\n\n.ap-area {\n min-height: 92px;\n resize: vertical;\n}\n\n.ap-actions {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n}\n\n.ap-counter {\n margin-left: auto;\n font-size: 12px;\n color: var(--ap-text-soft);\n}\n\n/* ----------------------------------------------------------------------------\n Buttons.\n ---------------------------------------------------------------------------- */\n\n.ap-primary,\n.ap-secondary,\n.ap-link {\n border-radius: 8px;\n cursor: pointer;\n pointer-events: auto;\n}\n\n.ap-primary {\n padding: 8px 14px;\n border: 1px solid var(--ap-accent);\n background: var(--ap-accent);\n color: var(--ap-accent-text);\n font-weight: 600;\n}\n\n.ap-primary:hover {\n background: var(--ap-accent-dark);\n border-color: var(--ap-accent-dark);\n}\n\n.ap-secondary {\n padding: 8px 14px;\n border: 1px solid var(--ap-border);\n background: var(--ap-bg);\n color: var(--ap-text);\n}\n\n.ap-secondary:hover {\n background: var(--ap-bg-raised);\n}\n\n.ap-link {\n padding: 2px 4px;\n border: 0;\n background: none;\n color: var(--ap-accent);\n text-decoration: underline;\n font-size: 13px;\n}\n\n.ap-primary:disabled,\n.ap-secondary:disabled,\n.ap-link:disabled {\n opacity: 0.6;\n cursor: default;\n}\n\n.ap-primary:focus-visible,\n.ap-secondary:focus-visible,\n.ap-link:focus-visible {\n outline: 2px solid var(--ap-accent);\n outline-offset: 2px;\n}\n\n/* ----------------------------------------------------------------------------\n The failures.\n\n They are RED, at the top of the block concerned, and carry the message the\n server returned as it stands: that is how a non-technical team learns that\n its remark is not saved, instead of believing it is.\n ---------------------------------------------------------------------------- */\n\n.ap-error {\n margin: 8px 0;\n padding: 10px 12px;\n border: 1px solid var(--ap-alert-border);\n border-radius: var(--ap-radius);\n background: var(--ap-alert-bg);\n color: var(--ap-alert-text);\n}\n\n.ap-error-title {\n font-weight: 700;\n margin-bottom: 4px;\n}\n\n.ap-error-detail {\n margin: 6px 0 0;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n font-size: 13px;\n}\n\n.ap-error .ap-link {\n color: var(--ap-alert-text);\n}\n\n/* ----------------------------------------------------------------------------\n Narrow: the panel takes the full width, and so does the form.\n ---------------------------------------------------------------------------- */\n\n/* ----------------------------------------------------------------------------\n Narrow.\n\n DEFECT OBSERVED at 375 px wide: a panel taking the full height covers the\n whole page, and no element can be pointed at any more -- every click lands\n on the panel. So it becomes a bottom band, which leaves the top half of\n the viewport free; one scrolls the page there to bring the wanted element\n into view. The form, for its part, hides the panel while typing (see\n notes.js): on a screen that size, writing and reading the list at the same\n time does not hold.\n ---------------------------------------------------------------------------- */\n\n/* On a narrow screen the panel becomes a bottom band and the form takes the\n full width.\n\n THE WIDTH CEILING IS KEPT, and it comes from a measured defect: \"left: 8;\n right: 8\" sizes the element against its CONTAINING BLOCK, which the host\n site's horizontal overflow can make wider than the visible window.\n Measured, in mobile emulation at 390 px: the site overflows to 407 px\n (with the tool and without it), and the panel came out 391 px wide\n starting at 8, that is 9 px off screen. \"100vw\" is the window, not the\n containing block: the ceiling therefore does nothing when the site does\n not overflow, and pulls the width back when it does. */\n@media (max-width: 560px) {\n .ap-panel {\n top: auto;\n right: 8px;\n left: 8px;\n bottom: 66px;\n height: 52vh;\n width: auto;\n max-width: calc(100vw - 16px);\n }\n\n .ap-form {\n left: 8px;\n right: 8px;\n width: auto;\n max-width: calc(100vw - 16px);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .ap-button {\n transition: none;\n }\n}\n\n/* The failure shows without opening the panel: the button's dot changes\n colour. A team that does not click must be able to see that something is\n wrong. */\n.ap-button.ap-failed .ap-button-dot {\n background: var(--ap-alert-text);\n}\n\n.ap-button.ap-failed {\n border-color: var(--ap-alert-border);\n}\n\n/* Signature reminder, in the note form.\n The name was shown at the foot of the panel only: invisible at the moment\n one writes. A user reported not knowing which name they were writing\n under. */\n.ap-form-signature {\n display: flex; align-items: center; gap: .5rem; flex-wrap: wrap;\n margin: 0 0 .6rem; font-size: .85rem; opacity: .8;\n}\n\n/* Resolution state, said on the card.\n Two cases NOT to be confused: resolved and online, resolved but not\n deployed yet. The second keeps the defect on the reviewer's screen; hiding\n it or announcing it as fixed would cost them their trust in the tool. */\n.ap-state-mark {\n display: inline-block; margin: 0 0 .5rem;\n padding: .15rem .55rem; border-radius: 4px;\n font-size: .75rem; font-weight: 600; letter-spacing: .02em;\n}\n.ap-note.ap-resolved { opacity: .72; }\n.ap-note.ap-resolved .ap-state-mark {\n color: #0f7a52; background: rgba(16, 185, 129, .14);\n}\n.ap-note.ap-resolved-pending .ap-state-mark {\n color: #8a5a00; background: rgba(245, 158, 11, .16);\n}\n/* The \"it is fixed\" / \"reopen\" block, opened under the card. Same shape as\n the reply block: it is the same gesture, one answers a remark. */\n.ap-resolve,\n.ap-reply-form {\n margin-top: .6rem;\n padding-top: .6rem;\n border-top: 1px solid var(--ap-border);\n}\n\n.ap-history-toggle {\n display: block; width: 100%; margin: 1rem 0 .25rem;\n padding: .5rem .75rem; border: 1px dashed currentColor; border-radius: 6px;\n background: none; color: inherit; font: inherit; opacity: .7; cursor: pointer;\n}\n.ap-history-toggle:hover { opacity: 1; }\n\n\n/* ----------------------------------------------------------------------------\n Setup and pasting the salt.\n\n These are the only screens where something is copied by hand. Everything\n there is SELECTABLE and monospaced: a 43-character salt copied wrong\n cannot be recovered, and nothing helps less than a font that confuses I, l\n and 1.\n ---------------------------------------------------------------------------- */\n\n.ap-panel-wide {\n width: 560px;\n}\n\n.ap-copy {\n display: flex;\n align-items: flex-start;\n gap: 8px;\n margin: 0 0 12px;\n}\n\n.ap-code {\n flex: 1 1 auto;\n width: 100%;\n padding: 8px 10px;\n border: 1px solid var(--ap-border);\n border-radius: 8px;\n background: var(--ap-bg-soft);\n color: var(--ap-text);\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\",\n monospace;\n font-size: 12.5px;\n line-height: 1.5;\n resize: vertical;\n white-space: pre;\n overflow-x: auto;\n}\n\n.ap-code:focus-visible {\n outline: 2px solid var(--ap-accent);\n outline-offset: 1px;\n}\n\n@media (max-width: 560px) {\n .ap-panel-wide {\n width: auto;\n }\n\n .ap-copy {\n flex-direction: column;\n }\n}\n";
|
|
21
|
+
const STYLES = "/* ============================================================================\n styles.css -- THE STYLES OF THE TOOL, AND OF NO OTHER ELEMENT.\n\n This sheet is INLINED into the served file by the build, then put into the\n tool's shadow root -- as a constructed sheet when the browser can do it, in\n a <style> otherwise. It was loaded by a <link> in the original tool; the\n move to a CDN under SRI brought it inside the file, so that there is only\n one digest to keep up to date. The containment itself has not changed, and\n is still twofold:\n\n - from the tool towards the site: no rule from here can reach an element\n of the host site, the browser sees to that. That is what makes the\n claim \"the layer touches nothing\" checkable rather than promised;\n - from the site towards the tool: no rule of the site can reach an\n element here. A redesign of the site's stylesheet therefore cannot\n distort the tool, nor the other way round.\n\n The \"ap-\" prefix on every class is the third safeguard: the day somebody\n loads these styles WITHOUT a shadow root -- by mistake, or to debug --\n nothing would answer a selector of the site.\n\n NO RULE TARGETS html, body, * OR ANY SELECTOR OF THE SITE. That is the one\n absolute prohibition of this file.\n\n COLOURS: the tool has its OWN palette, defined on the shadow root. It\n reads neither the site's variables nor its theme attribute: it has no\n reason to know how the site names its colours, and it must stay readable\n on a light site as on a dark one. The switch follows the system\n preference, the only information the tool has without asking anyone.\n ============================================================================ */\n\n\n:host {\n --ap-bg: #ffffff;\n --ap-bg-soft: #f4f6f8;\n --ap-bg-raised: #e9edf2;\n --ap-text: #1a1d21;\n --ap-text-soft: #5b6570;\n --ap-border: #d5dbe2;\n --ap-accent: #2f6fed;\n --ap-accent-dark: #1d55c8;\n --ap-accent-text: #ffffff;\n --ap-accent-veil: rgba(47, 111, 237, 0.14);\n --ap-alert-bg: #fdeceb;\n --ap-alert-border: #e3a9a4;\n --ap-alert-text: #8a1f16;\n --ap-shadow: 0 6px 24px rgba(16, 24, 40, 0.18);\n --ap-radius: 10px;\n --ap-font: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\",\n Arial, sans-serif;\n}\n\n@media (prefers-color-scheme: dark) {\n :host {\n --ap-bg: #1d2126;\n --ap-bg-soft: #262b32;\n --ap-bg-raised: #323942;\n --ap-text: #e9ecf0;\n --ap-text-soft: #a4adb8;\n --ap-border: #3a424c;\n --ap-accent: #6d9bff;\n --ap-accent-dark: #8fb4ff;\n --ap-accent-text: #10151c;\n --ap-accent-veil: rgba(109, 155, 255, 0.18);\n --ap-alert-bg: #3a1f1c;\n --ap-alert-border: #7c3a33;\n --ap-alert-text: #ffb9b1;\n --ap-shadow: 0 6px 24px rgba(0, 0, 0, 0.55);\n }\n}\n\n/* ----------------------------------------------------------------------------\n The layer.\n\n It covers the viewport and receives NO click: that is what lets the page\n behave exactly as usual as long as the tool is not in annotation mode.\n Each widget re-enables clicks for itself alone.\n ---------------------------------------------------------------------------- */\n\n.ap-layer {\n position: absolute;\n inset: 0;\n pointer-events: none;\n font-family: var(--ap-font);\n font-size: 14px;\n line-height: 1.45;\n color: var(--ap-text);\n text-align: left;\n -webkit-font-smoothing: antialiased;\n}\n\n.ap-layer button,\n.ap-layer input,\n.ap-layer textarea {\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n color: inherit;\n margin: 0;\n box-sizing: border-box;\n}\n\n/* ----------------------------------------------------------------------------\n The button: the only thing visible when the tool is at rest.\n ---------------------------------------------------------------------------- */\n\n.ap-button {\n position: fixed;\n right: 16px;\n bottom: 16px;\n display: inline-flex;\n align-items: center;\n gap: 8px;\n padding: 9px 14px;\n border: 1px solid var(--ap-border);\n border-radius: 999px;\n background: var(--ap-bg);\n color: var(--ap-text);\n box-shadow: var(--ap-shadow);\n cursor: pointer;\n pointer-events: auto;\n opacity: 0.92;\n transition: opacity 0.15s ease, transform 0.15s ease;\n}\n\n.ap-button:hover,\n.ap-button:focus-visible {\n opacity: 1;\n transform: translateY(-1px);\n}\n\n.ap-button:focus-visible {\n outline: 2px solid var(--ap-accent);\n outline-offset: 2px;\n}\n\n.ap-button[aria-pressed=\"true\"] {\n background: var(--ap-accent);\n border-color: var(--ap-accent);\n color: var(--ap-accent-text);\n opacity: 1;\n}\n\n.ap-button-dot {\n display: inline-block;\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: var(--ap-accent);\n flex: none;\n}\n\n.ap-button[aria-pressed=\"true\"] .ap-button-dot {\n background: var(--ap-accent-text);\n}\n\n.ap-button-count {\n padding: 1px 7px;\n border-radius: 999px;\n background: var(--ap-bg-raised);\n color: var(--ap-text-soft);\n font-size: 12px;\n}\n\n.ap-button[aria-pressed=\"true\"] .ap-button-count {\n background: rgba(255, 255, 255, 0.22);\n color: var(--ap-accent-text);\n}\n\n/* ----------------------------------------------------------------------------\n The pointing highlight.\n\n It is DRAWN HERE, from the coordinates of the element being pointed at.\n Nothing is put on the element itself: no class, no attribute, no style. So\n the site cannot move by a single pixel because of the pointing.\n ---------------------------------------------------------------------------- */\n\n.ap-highlight {\n position: fixed;\n border: 2px solid var(--ap-accent);\n border-radius: 3px;\n background: var(--ap-accent-veil);\n pointer-events: none;\n display: none;\n}\n\n.ap-highlight-label {\n position: fixed;\n max-width: 320px;\n padding: 4px 8px;\n border-radius: 6px;\n background: var(--ap-accent);\n color: var(--ap-accent-text);\n font-size: 12px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n pointer-events: none;\n display: none;\n box-shadow: var(--ap-shadow);\n}\n\n/* ----------------------------------------------------------------------------\n The markers: \"there are already notes here\".\n ---------------------------------------------------------------------------- */\n\n.ap-marker {\n position: fixed;\n min-width: 22px;\n height: 22px;\n padding: 0 6px;\n border: 2px solid var(--ap-bg);\n border-radius: 999px;\n background: var(--ap-accent);\n color: var(--ap-accent-text);\n font-size: 12px;\n font-weight: 700;\n line-height: 18px;\n text-align: center;\n cursor: pointer;\n pointer-events: auto;\n box-shadow: var(--ap-shadow);\n}\n\n.ap-marker:focus-visible {\n outline: 2px solid var(--ap-accent-dark);\n outline-offset: 2px;\n}\n\n/* ----------------------------------------------------------------------------\n The panel.\n ---------------------------------------------------------------------------- */\n\n.ap-panel {\n position: fixed;\n top: 12px;\n right: 12px;\n bottom: 72px;\n width: 360px;\n max-width: calc(100vw - 24px);\n display: none;\n flex-direction: column;\n border: 1px solid var(--ap-border);\n border-radius: var(--ap-radius);\n background: var(--ap-bg);\n box-shadow: var(--ap-shadow);\n pointer-events: auto;\n overflow: hidden;\n}\n\n.ap-panel.ap-open {\n display: flex;\n}\n\n.ap-panel-header {\n display: flex;\n align-items: baseline;\n gap: 8px;\n padding: 12px 14px;\n border-bottom: 1px solid var(--ap-border);\n background: var(--ap-bg-soft);\n}\n\n.ap-panel-title {\n font-size: 15px;\n font-weight: 600;\n flex: 1 1 auto;\n}\n\n.ap-panel-instructions {\n padding: 10px 14px;\n border-bottom: 1px solid var(--ap-border);\n color: var(--ap-text-soft);\n font-size: 13px;\n}\n\n.ap-panel-body {\n flex: 1 1 auto;\n overflow-y: auto;\n overscroll-behavior: contain;\n padding: 4px 14px 14px;\n}\n\n.ap-panel-footer {\n padding: 8px 14px;\n border-top: 1px solid var(--ap-border);\n background: var(--ap-bg-soft);\n color: var(--ap-text-soft);\n font-size: 12px;\n display: flex;\n align-items: center;\n gap: 8px;\n}\n\n.ap-section-title {\n margin: 14px 0 6px;\n color: var(--ap-text-soft);\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n}\n\n.ap-section-help {\n margin: 0 0 8px;\n color: var(--ap-text-soft);\n font-size: 12px;\n}\n\n.ap-empty {\n margin: 16px 0;\n color: var(--ap-text-soft);\n}\n\n/* The standing mention of a public key. Deliberately NOT the alert colours:\n this is not a failure and it is on screen for ever -- an alarm that never\n goes away stops being read. It is a fact about the project, stated in the\n panel's own tone, and it stays at the top of every draw. */\n/* The public-key notice and the \"a newer client exists\" line are the same\n object on screen: a standing statement about what one is looking at, above\n the notes and above the failures. One rule, so they cannot drift apart. */\n.ap-public,\n.ap-upgrade {\n margin: 0 0 10px;\n padding: 8px 10px;\n border: 1px solid var(--ap-border);\n border-radius: var(--ap-radius);\n background: var(--ap-bg-soft);\n color: var(--ap-text-soft);\n font-size: 12px;\n line-height: 1.45;\n}\n\n/* ----------------------------------------------------------------------------\n A note, and its replies.\n ---------------------------------------------------------------------------- */\n\n.ap-note {\n margin: 8px 0;\n padding: 10px 12px;\n border: 1px solid var(--ap-border);\n border-radius: var(--ap-radius);\n background: var(--ap-bg);\n}\n\n.ap-note.ap-orphan {\n background: var(--ap-bg-soft);\n}\n\n.ap-note.ap-focused {\n border-color: var(--ap-accent);\n box-shadow: 0 0 0 3px var(--ap-accent-veil);\n}\n\n.ap-note-header {\n display: flex;\n align-items: baseline;\n gap: 8px;\n flex-wrap: wrap;\n}\n\n.ap-note-author {\n font-weight: 600;\n}\n\n.ap-note-date {\n color: var(--ap-text-soft);\n font-size: 12px;\n}\n\n.ap-note-target {\n margin: 4px 0 0;\n color: var(--ap-text-soft);\n font-size: 12px;\n font-style: italic;\n overflow-wrap: anywhere;\n}\n\n.ap-note-text {\n margin: 6px 0 0;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n\n.ap-note-actions {\n margin-top: 8px;\n display: flex;\n gap: 8px;\n flex-wrap: wrap;\n}\n\n.ap-replies {\n margin: 8px 0 0;\n padding-left: 10px;\n border-left: 2px solid var(--ap-border);\n}\n\n.ap-reply {\n margin: 8px 0 0;\n}\n\n/* ----------------------------------------------------------------------------\n The form, anchored near the element pointed at.\n ---------------------------------------------------------------------------- */\n\n.ap-form {\n position: fixed;\n width: 340px;\n max-width: calc(100vw - 24px);\n display: none;\n flex-direction: column;\n gap: 8px;\n padding: 14px;\n border: 1px solid var(--ap-border);\n border-radius: var(--ap-radius);\n background: var(--ap-bg);\n box-shadow: var(--ap-shadow);\n pointer-events: auto;\n}\n\n.ap-form.ap-open {\n display: flex;\n}\n\n.ap-form-title {\n font-size: 15px;\n font-weight: 600;\n}\n\n.ap-form-target {\n color: var(--ap-text-soft);\n font-size: 12px;\n font-style: italic;\n overflow-wrap: anywhere;\n}\n\n.ap-label {\n display: block;\n margin-bottom: 3px;\n font-size: 12px;\n font-weight: 600;\n color: var(--ap-text-soft);\n}\n\n.ap-help {\n margin: 3px 0 0;\n font-size: 12px;\n color: var(--ap-text-soft);\n}\n\n.ap-field,\n.ap-area {\n width: 100%;\n padding: 8px 10px;\n border: 1px solid var(--ap-border);\n border-radius: 8px;\n background: var(--ap-bg-soft);\n color: var(--ap-text);\n}\n\n.ap-field:focus,\n.ap-area:focus {\n outline: 2px solid var(--ap-accent);\n outline-offset: 1px;\n}\n\n.ap-area {\n min-height: 92px;\n resize: vertical;\n}\n\n.ap-actions {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n}\n\n.ap-counter {\n margin-left: auto;\n font-size: 12px;\n color: var(--ap-text-soft);\n}\n\n/* ----------------------------------------------------------------------------\n Buttons.\n ---------------------------------------------------------------------------- */\n\n.ap-primary,\n.ap-secondary,\n.ap-link {\n border-radius: 8px;\n cursor: pointer;\n pointer-events: auto;\n}\n\n.ap-primary {\n padding: 8px 14px;\n border: 1px solid var(--ap-accent);\n background: var(--ap-accent);\n color: var(--ap-accent-text);\n font-weight: 600;\n}\n\n.ap-primary:hover {\n background: var(--ap-accent-dark);\n border-color: var(--ap-accent-dark);\n}\n\n.ap-secondary {\n padding: 8px 14px;\n border: 1px solid var(--ap-border);\n background: var(--ap-bg);\n color: var(--ap-text);\n}\n\n.ap-secondary:hover {\n background: var(--ap-bg-raised);\n}\n\n.ap-link {\n padding: 2px 4px;\n border: 0;\n background: none;\n color: var(--ap-accent);\n text-decoration: underline;\n font-size: 13px;\n}\n\n.ap-primary:disabled,\n.ap-secondary:disabled,\n.ap-link:disabled {\n opacity: 0.6;\n cursor: default;\n}\n\n.ap-primary:focus-visible,\n.ap-secondary:focus-visible,\n.ap-link:focus-visible {\n outline: 2px solid var(--ap-accent);\n outline-offset: 2px;\n}\n\n/* ----------------------------------------------------------------------------\n The failures.\n\n They are RED, at the top of the block concerned, and carry the message the\n server returned as it stands: that is how a non-technical team learns that\n its remark is not saved, instead of believing it is.\n ---------------------------------------------------------------------------- */\n\n.ap-error {\n margin: 8px 0;\n padding: 10px 12px;\n border: 1px solid var(--ap-alert-border);\n border-radius: var(--ap-radius);\n background: var(--ap-alert-bg);\n color: var(--ap-alert-text);\n}\n\n.ap-error-title {\n font-weight: 700;\n margin-bottom: 4px;\n}\n\n.ap-error-detail {\n margin: 6px 0 0;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n font-size: 13px;\n}\n\n.ap-error .ap-link {\n color: var(--ap-alert-text);\n}\n\n/* ----------------------------------------------------------------------------\n Narrow: the panel takes the full width, and so does the form.\n ---------------------------------------------------------------------------- */\n\n/* ----------------------------------------------------------------------------\n Narrow.\n\n DEFECT OBSERVED at 375 px wide: a panel taking the full height covers the\n whole page, and no element can be pointed at any more -- every click lands\n on the panel. So it becomes a bottom band, which leaves the top half of\n the viewport free; one scrolls the page there to bring the wanted element\n into view. The form, for its part, hides the panel while typing (see\n notes.js): on a screen that size, writing and reading the list at the same\n time does not hold.\n ---------------------------------------------------------------------------- */\n\n/* On a narrow screen the panel becomes a bottom band and the form takes the\n full width.\n\n THE WIDTH CEILING IS KEPT, and it comes from a measured defect: \"left: 8;\n right: 8\" sizes the element against its CONTAINING BLOCK, which the host\n site's horizontal overflow can make wider than the visible window.\n Measured, in mobile emulation at 390 px: the site overflows to 407 px\n (with the tool and without it), and the panel came out 391 px wide\n starting at 8, that is 9 px off screen. \"100vw\" is the window, not the\n containing block: the ceiling therefore does nothing when the site does\n not overflow, and pulls the width back when it does. */\n@media (max-width: 560px) {\n .ap-panel {\n top: auto;\n right: 8px;\n left: 8px;\n bottom: 66px;\n height: 52vh;\n width: auto;\n max-width: calc(100vw - 16px);\n }\n\n .ap-form {\n left: 8px;\n right: 8px;\n width: auto;\n max-width: calc(100vw - 16px);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .ap-button {\n transition: none;\n }\n}\n\n/* The failure shows without opening the panel: the button's dot changes\n colour. A team that does not click must be able to see that something is\n wrong. */\n.ap-button.ap-failed .ap-button-dot {\n background: var(--ap-alert-text);\n}\n\n.ap-button.ap-failed {\n border-color: var(--ap-alert-border);\n}\n\n/* Signature reminder, in the note form.\n The name was shown at the foot of the panel only: invisible at the moment\n one writes. A user reported not knowing which name they were writing\n under. */\n.ap-form-signature {\n display: flex; align-items: center; gap: .5rem; flex-wrap: wrap;\n margin: 0 0 .6rem; font-size: .85rem; opacity: .8;\n}\n\n/* Resolution state, said on the card.\n Two cases NOT to be confused: resolved and online, resolved but not\n deployed yet. The second keeps the defect on the reviewer's screen; hiding\n it or announcing it as fixed would cost them their trust in the tool. */\n.ap-state-mark {\n display: inline-block; margin: 0 0 .5rem;\n padding: .15rem .55rem; border-radius: 4px;\n font-size: .75rem; font-weight: 600; letter-spacing: .02em;\n}\n.ap-note.ap-resolved { opacity: .72; }\n.ap-note.ap-resolved .ap-state-mark {\n color: #0f7a52; background: rgba(16, 185, 129, .14);\n}\n.ap-note.ap-resolved-pending .ap-state-mark {\n color: #8a5a00; background: rgba(245, 158, 11, .16);\n}\n/* The \"it is fixed\" / \"reopen\" block, opened under the card. Same shape as\n the reply block: it is the same gesture, one answers a remark. */\n.ap-resolve,\n.ap-reply-form {\n margin-top: .6rem;\n padding-top: .6rem;\n border-top: 1px solid var(--ap-border);\n}\n\n.ap-history-toggle {\n display: block; width: 100%; margin: 1rem 0 .25rem;\n padding: .5rem .75rem; border: 1px dashed currentColor; border-radius: 6px;\n background: none; color: inherit; font: inherit; opacity: .7; cursor: pointer;\n}\n.ap-history-toggle:hover { opacity: 1; }\n\n\n/* ----------------------------------------------------------------------------\n Setup and pasting the salt.\n\n These are the only screens where something is copied by hand. Everything\n there is SELECTABLE and monospaced: a 43-character salt copied wrong\n cannot be recovered, and nothing helps less than a font that confuses I, l\n and 1.\n ---------------------------------------------------------------------------- */\n\n.ap-panel-wide {\n width: 560px;\n}\n\n.ap-copy {\n display: flex;\n align-items: flex-start;\n gap: 8px;\n margin: 0 0 12px;\n}\n\n.ap-code {\n flex: 1 1 auto;\n width: 100%;\n padding: 8px 10px;\n border: 1px solid var(--ap-border);\n border-radius: 8px;\n background: var(--ap-bg-soft);\n color: var(--ap-text);\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\",\n monospace;\n font-size: 12.5px;\n line-height: 1.5;\n resize: vertical;\n white-space: pre;\n overflow-x: auto;\n}\n\n.ap-code:focus-visible {\n outline: 2px solid var(--ap-accent);\n outline-offset: 1px;\n}\n\n@media (max-width: 560px) {\n .ap-panel-wide {\n width: auto;\n }\n\n .ap-copy {\n flex-direction: column;\n }\n}\n";
|
|
22
22
|
|
|
23
23
|
/* ==== 00-preamble.js ==== */
|
|
24
24
|
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
encrypted. The key is random, it lives in the page, and the page is the
|
|
90
90
|
one thing the server never sees.
|
|
91
91
|
|
|
92
|
-
The SHAPE is not checked here:
|
|
92
|
+
The SHAPE is not checked here: keyFromText() in 20-crypto is the single
|
|
93
93
|
judge of what a key looks like, and it lives in the section that owns the
|
|
94
94
|
format. What is recorded here is whether the attribute was WRITTEN at all
|
|
95
95
|
-- an empty data-key is a tag somebody meant to fill in, and it gets said
|
|
@@ -117,7 +117,7 @@
|
|
|
117
117
|
FORMAT.md section 4). So it is TIDINESS -- the tag can stay at the foot of
|
|
118
118
|
every page of the site without the online documentation collecting the
|
|
119
119
|
staging notes -- and NOT a security boundary: whoever has the project id
|
|
120
|
-
and the
|
|
120
|
+
and the key writes wherever they like. */
|
|
121
121
|
const PATH_PREFIX = read('path');
|
|
122
122
|
|
|
123
123
|
/* The project origins. The real lock is the server's (FORMAT.md section
|
|
@@ -506,33 +506,33 @@
|
|
|
506
506
|
'marker.one': '1 note here',
|
|
507
507
|
'marker.n': '{n} notes here',
|
|
508
508
|
|
|
509
|
-
/* -- The
|
|
510
|
-
'
|
|
511
|
-
'
|
|
509
|
+
/* -- The key: the only secret, and it cannot be recovered ---------- */
|
|
510
|
+
'key.title': 'The key of this project is needed',
|
|
511
|
+
'key.help':
|
|
512
512
|
'The notes of this project are encrypted in your browser. Without the '
|
|
513
513
|
+ 'project key, this browser can neither read them nor write any. Ask '
|
|
514
514
|
+ 'whoever installed the tool for it, and paste it below. It will be '
|
|
515
515
|
+ 'remembered by this browser, for this site.',
|
|
516
|
-
'
|
|
517
|
-
'
|
|
518
|
-
'
|
|
519
|
-
'
|
|
516
|
+
'key.label': 'The project key (43 characters)',
|
|
517
|
+
'key.confirm': 'Use this key',
|
|
518
|
+
'key.empty': 'Paste the key before confirming.',
|
|
519
|
+
'key.shape':
|
|
520
520
|
'This is not a key: 43 characters are expected, from A-Z a-z 0-9 - _, '
|
|
521
521
|
+ 'with no space and no decorative dash. Copy it in one block.',
|
|
522
|
-
'
|
|
522
|
+
'key.wrong':
|
|
523
523
|
'This key is not the one for this project. Nothing was sent, nothing '
|
|
524
524
|
+ 'was decrypted. Check that you are pasting the key of the right '
|
|
525
525
|
+ 'project.',
|
|
526
|
-
'
|
|
526
|
+
'key.origin_changed':
|
|
527
527
|
'This key is remembered per browser AND per domain. The day staging '
|
|
528
528
|
+ 'becomes production, it has to be pasted once more on the new domain '
|
|
529
529
|
+ '-- the notes themselves do not move.',
|
|
530
|
-
'
|
|
530
|
+
'key.not_kept':
|
|
531
531
|
'This browser refuses to remember the key (private browsing, or '
|
|
532
532
|
+ 'storage blocked). The tool works for this page, but the key will '
|
|
533
533
|
+ 'have to be pasted again on the next load.',
|
|
534
|
-
'
|
|
535
|
-
'
|
|
534
|
+
'key.replace': 'Paste another key',
|
|
535
|
+
'key.forget': 'Forget the key on this browser',
|
|
536
536
|
|
|
537
537
|
/* -- A project whose key is IN the page ---------------------------
|
|
538
538
|
Said where the notes are, at every draw, and not once at startup: it
|
|
@@ -545,6 +545,15 @@
|
|
|
545
545
|
+ 'notes AND write them -- the key gives both, and this format has no '
|
|
546
546
|
+ 'reader-only role.',
|
|
547
547
|
|
|
548
|
+
/* -- A newer client exists, and this copy is not going to fetch it -
|
|
549
|
+
Shown ONLY when the file is served by the site itself: a copy served
|
|
550
|
+
by a CDN replaces itself instead of talking about it (80-upgrade).
|
|
551
|
+
So the sentence has to say what was NOT done, and whose call it is. */
|
|
552
|
+
'upgrade.available':
|
|
553
|
+
'A more recent annotepage client exists: {version}. This page is '
|
|
554
|
+
+ 'running {current}, served by the site itself -- nothing was fetched '
|
|
555
|
+
+ 'to replace it, and when to update the file is the owner\'s call.',
|
|
556
|
+
|
|
548
557
|
/* -- A tag that cannot be used as it stands ------------------------
|
|
549
558
|
Somebody put that tag there on purpose, so we speak instead of staying
|
|
550
559
|
silent -- and we refuse exactly as a wrong pasted key is refused
|
|
@@ -572,7 +581,7 @@
|
|
|
572
581
|
+ 'it: not the server, not the author of the tool, nobody you can ask. '
|
|
573
582
|
+ 'KEY LOST = NOTES LOST, for good, with no recovery. Put it away now, '
|
|
574
583
|
+ 'where your team keeps its passwords, before continuing.',
|
|
575
|
-
'setup.
|
|
584
|
+
'setup.key': 'The project key -- keep it',
|
|
576
585
|
'setup.project': 'The project id -- public, it goes into the page',
|
|
577
586
|
'setup.tag': 'The tag to paste at the end of <body>, on the pages to annotate',
|
|
578
587
|
'setup.server': 'To declare in the server configuration',
|
|
@@ -608,18 +617,18 @@
|
|
|
608
617
|
|
|
609
618
|
/* ==== 20-crypto.js ==== */
|
|
610
619
|
|
|
611
|
-
/* -- 6. The
|
|
620
|
+
/* -- 6. The key, the three derivations, the envelope --------------------
|
|
612
621
|
|
|
613
622
|
This whole file implements FORMAT.md sections 1, 3 and 4, and nothing
|
|
614
623
|
else. When a line here contradicts FORMAT.md, this line is wrong.
|
|
615
624
|
|
|
616
|
-
THE
|
|
625
|
+
THE KEY NEVER LEAVES THE BROWSER. It is not sent to the server in any
|
|
617
626
|
form, in any mode, derived forms included. The only path out of here is
|
|
618
627
|
the setup screen, which shows it to the person who has just generated it
|
|
619
628
|
so that they can put it away. */
|
|
620
629
|
|
|
621
630
|
const HKDF_SALT_STRING = 'annotepage/1';
|
|
622
|
-
const
|
|
631
|
+
const KEY_LENGTH = 43; // 32 bytes in base64url without padding
|
|
623
632
|
const NONCE_LENGTH = 16; // 12 bytes in base64url without padding
|
|
624
633
|
|
|
625
634
|
/* WebCrypto only exists in a SECURE context: https, or localhost. On a
|
|
@@ -637,14 +646,14 @@
|
|
|
637
646
|
};
|
|
638
647
|
|
|
639
648
|
/**
|
|
640
|
-
* The text of a
|
|
649
|
+
* The text of a key -> its 32 bytes, or null.
|
|
641
650
|
*
|
|
642
651
|
* We refuse anything that has not exactly the right shape rather than
|
|
643
|
-
* "cleaning up" spaces or dashes: an almost-right
|
|
644
|
-
* project id, and the message "this
|
|
652
|
+
* "cleaning up" spaces or dashes: an almost-right key derives a wrong
|
|
653
|
+
* project id, and the message "this key is not the key of this project"
|
|
645
654
|
* would then send someone looking in the wrong place.
|
|
646
655
|
*/
|
|
647
|
-
const
|
|
656
|
+
const keyFromText = (text) => {
|
|
648
657
|
const t = String(text == null ? '' : text).trim();
|
|
649
658
|
if (!/^[A-Za-z0-9_-]{43}$/.test(t)) return null;
|
|
650
659
|
const bytes = fromB64url(t);
|
|
@@ -655,26 +664,26 @@
|
|
|
655
664
|
* The three derivations, in one go.
|
|
656
665
|
*
|
|
657
666
|
* TRAP, named because it costs dearly: HKDF's "salt" parameter is NOT our
|
|
658
|
-
*
|
|
667
|
+
* key. Our key is the input keying material (IKM); HKDF's salt is the
|
|
659
668
|
* fixed, public string "annotepage/1", which separates this tool from any
|
|
660
669
|
* other software one might one day trust with the same secret. Swapping them
|
|
661
670
|
* produces a system that works, that encrypts, and whose notes become
|
|
662
671
|
* unreadable on the first reimplementation.
|
|
663
672
|
*/
|
|
664
|
-
const derive = (
|
|
673
|
+
const derive = (keyBytes) => {
|
|
665
674
|
const params = (label) => ({
|
|
666
675
|
name: 'HKDF',
|
|
667
676
|
hash: 'SHA-256',
|
|
668
|
-
salt: utf8(HKDF_SALT_STRING), // NOT
|
|
677
|
+
salt: utf8(HKDF_SALT_STRING), // HKDF's salt, NOT our key: see above
|
|
669
678
|
info: utf8(label)
|
|
670
679
|
});
|
|
671
680
|
|
|
672
681
|
return CRYPTO.subtle
|
|
673
|
-
.importKey('raw',
|
|
682
|
+
.importKey('raw', keyBytes, 'HKDF', false, ['deriveBits', 'deriveKey'])
|
|
674
683
|
.then((master) => Promise.all([
|
|
675
684
|
CRYPTO.subtle.deriveBits(params('id'), master, 256),
|
|
676
685
|
// The encryption key is generated NON-EXTRACTABLE. That is
|
|
677
|
-
// hygiene, not a barrier: the
|
|
686
|
+
// hygiene, not a barrier: the key sleeps in localStorage right
|
|
678
687
|
// next to it, and whoever reads one rebuilds the other in three
|
|
679
688
|
// lines. We write it down so that nobody takes this "false" for
|
|
680
689
|
// a protection it is not.
|
|
@@ -774,7 +783,7 @@
|
|
|
774
783
|
* 'newer' the envelope carries a format number above ours. We do not
|
|
775
784
|
* guess at cryptography: flat refusal, the note is skipped
|
|
776
785
|
* and counted, and the tool SAYS that it exists.
|
|
777
|
-
* 'unreadable' invalid shape, or decryption failed -- wrong
|
|
786
|
+
* 'unreadable' invalid shape, or decryption failed -- wrong key, note
|
|
778
787
|
* moved by the server, damaged bytes. All three are worth
|
|
779
788
|
* the same to the reader: there is nothing to read.
|
|
780
789
|
*/
|
|
@@ -841,10 +850,10 @@
|
|
|
841
850
|
it: a note skipped in silence is a remark that disappears. */
|
|
842
851
|
let skipped = { newer: 0, unreadable: 0, unknown: 0 };
|
|
843
852
|
|
|
844
|
-
/* The
|
|
845
|
-
null as long as the
|
|
853
|
+
/* The key of this project, and everything derived from it. "keys" stays
|
|
854
|
+
null as long as the key is unknown: no request, no decryption goes out
|
|
846
855
|
before then. */
|
|
847
|
-
let
|
|
856
|
+
let keyText = '';
|
|
848
857
|
let keys = null; // { id, encryptionKey, indexKey }
|
|
849
858
|
let PAGE_INDEX = ''; // blind index of the current page
|
|
850
859
|
|
|
@@ -860,20 +869,20 @@
|
|
|
860
869
|
// the name is there to know who to talk to, not to prove who one is.
|
|
861
870
|
const AUTHOR_KEY = 'annotepage/author';
|
|
862
871
|
|
|
863
|
-
/* The
|
|
872
|
+
/* The key is stored UNDER THE PROJECT ID. That naming is not cosmetic: two
|
|
864
873
|
projects reviewed from the same browser must not overwrite each other.
|
|
865
874
|
|
|
866
875
|
An unpleasant consequence, to be stated: localStorage is PER ORIGIN. The
|
|
867
|
-
day staging becomes production, every reviewer has to paste the
|
|
876
|
+
day staging becomes production, every reviewer has to paste the key once
|
|
868
877
|
more on the new domain. The notes themselves do not move -- and that is
|
|
869
878
|
exactly what the rule "the domain is not in the key" buys. */
|
|
870
|
-
const
|
|
879
|
+
const keyKey = (project) => 'annotepage/key/' + project;
|
|
871
880
|
|
|
872
881
|
const readSalt = (project) => {
|
|
873
882
|
try {
|
|
874
|
-
return String(window.localStorage.getItem(
|
|
883
|
+
return String(window.localStorage.getItem(keyKey(project)) || '').trim();
|
|
875
884
|
} catch (e) {
|
|
876
|
-
// Without storage the
|
|
885
|
+
// Without storage the key will be asked for on every visit: that is
|
|
877
886
|
// less comfortable, it is not a failure.
|
|
878
887
|
return '';
|
|
879
888
|
}
|
|
@@ -881,10 +890,10 @@
|
|
|
881
890
|
|
|
882
891
|
const writeSalt = (project, text) => {
|
|
883
892
|
try {
|
|
884
|
-
window.localStorage.setItem(
|
|
893
|
+
window.localStorage.setItem(keyKey(project), text);
|
|
885
894
|
return true;
|
|
886
895
|
} catch (e) {
|
|
887
|
-
// We return false so the screen can SAY it: a
|
|
896
|
+
// We return false so the screen can SAY it: a key that is not kept
|
|
888
897
|
// will have to be pasted again on every page, and it is better to
|
|
889
898
|
// know that straight away than on the third time.
|
|
890
899
|
return false;
|
|
@@ -893,7 +902,7 @@
|
|
|
893
902
|
|
|
894
903
|
const forgetSalt = (project) => {
|
|
895
904
|
try {
|
|
896
|
-
window.localStorage.removeItem(
|
|
905
|
+
window.localStorage.removeItem(keyKey(project));
|
|
897
906
|
} catch (e) {
|
|
898
907
|
// Nothing to do: there was no storage in the first place.
|
|
899
908
|
}
|
|
@@ -1377,11 +1386,11 @@
|
|
|
1377
1386
|
* once the texts are available.
|
|
1378
1387
|
*/
|
|
1379
1388
|
const buildHost = () => {
|
|
1380
|
-
// IDEMPOTENT, and this is not a stylistic precaution: the
|
|
1389
|
+
// IDEMPOTENT, and this is not a stylistic precaution: the key-pasting
|
|
1381
1390
|
// screen built the host BEFORE the normal startup asked for it in turn.
|
|
1382
1391
|
// Without this guard, the site received TWO elements, one of them empty
|
|
1383
1392
|
// and orphaned -- the promise "one single element added" fell over at the
|
|
1384
|
-
// first pasted
|
|
1393
|
+
// first pasted key.
|
|
1385
1394
|
if (host) return;
|
|
1386
1395
|
host = document.createElement('annotepage-notes');
|
|
1387
1396
|
// These properties are set INLINE and with !important, on our own
|
|
@@ -1871,6 +1880,21 @@
|
|
|
1871
1880
|
ui.body.appendChild(notice);
|
|
1872
1881
|
}
|
|
1873
1882
|
|
|
1883
|
+
/* A NEWER CLIENT EXISTS, AND WE ARE NOT GOING TO FETCH IT. This copy is
|
|
1884
|
+
served by the site itself -- somebody took the file off a CDN on
|
|
1885
|
+
purpose -- so it says so and stops there (80-upgrade). A copy served
|
|
1886
|
+
BY a CDN never gets here: it replaced itself before the panel existed.
|
|
1887
|
+
|
|
1888
|
+
Said at every draw and not once at load, for the same reason as the
|
|
1889
|
+
notice above: a message shown at load time is read by whoever happened
|
|
1890
|
+
to be looking, and by nobody who opens this panel a week later. */
|
|
1891
|
+
if (upgradeAvailable) {
|
|
1892
|
+
const notice = create('div', 'ap-upgrade',
|
|
1893
|
+
T('upgrade.available', { version: upgradeAvailable, current: TOOL_VERSION }));
|
|
1894
|
+
notice.setAttribute('role', 'note');
|
|
1895
|
+
ui.body.appendChild(notice);
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1874
1898
|
if (currentFailure) {
|
|
1875
1899
|
ui.body.appendChild(failureBlock(currentFailure, () => {
|
|
1876
1900
|
currentFailure = null;
|
|
@@ -1949,18 +1973,18 @@
|
|
|
1949
1973
|
ui.footer.appendChild(change);
|
|
1950
1974
|
}
|
|
1951
1975
|
|
|
1952
|
-
/* The
|
|
1976
|
+
/* The key gets pasted again from here. This is not a convenience
|
|
1953
1977
|
setting: the day staging becomes production, localStorage changes
|
|
1954
|
-
origin and the
|
|
1978
|
+
origin and the key has to be pasted once more, on every browser.
|
|
1955
1979
|
Without this button, one would have to clear the storage by hand to
|
|
1956
1980
|
get there. */
|
|
1957
1981
|
/* Not offered when the key comes from the tag: there is nothing stored
|
|
1958
1982
|
to replace, and a key pasted here would be overruled by the tag on the
|
|
1959
1983
|
next load -- while quietly leaving a copy in localStorage. */
|
|
1960
|
-
if (PROJECT &&
|
|
1961
|
-
const changeSalt = create('button', 'ap-link', T('
|
|
1984
|
+
if (PROJECT && keyText && !PUBLIC_KEY) {
|
|
1985
|
+
const changeSalt = create('button', 'ap-link', T('key.replace'));
|
|
1962
1986
|
changeSalt.type = 'button';
|
|
1963
|
-
changeSalt.title = T('
|
|
1987
|
+
changeSalt.title = T('key.origin_changed');
|
|
1964
1988
|
changeSalt.addEventListener('click', () => openSaltScreen());
|
|
1965
1989
|
ui.footer.appendChild(changeSalt);
|
|
1966
1990
|
}
|
|
@@ -2249,18 +2273,18 @@
|
|
|
2249
2273
|
|
|
2250
2274
|
/* ==== 70-setup.js ==== */
|
|
2251
2275
|
|
|
2252
|
-
/* -- 18. Setup, and the
|
|
2276
|
+
/* -- 18. Setup, and the key one pastes ----------------------------------
|
|
2253
2277
|
|
|
2254
|
-
These screens are the only places where the
|
|
2255
|
-
They are BLOCKING: as long as the
|
|
2278
|
+
These screens are the only places where the key is shown or typed in.
|
|
2279
|
+
They are BLOCKING: as long as the key is unknown, the tool shows neither
|
|
2256
2280
|
an annotation button nor a panel of notes. There is nothing to annotate
|
|
2257
|
-
without a
|
|
2281
|
+
without a key -- not even in plain mode, where the page index is already
|
|
2258
2282
|
an HMAC.
|
|
2259
2283
|
|
|
2260
2284
|
None of these screens makes a network request. A consequence to be
|
|
2261
2285
|
stated: a page carrying a tag with a project, on a site whose server is
|
|
2262
|
-
not configured yet, will still show the "paste the
|
|
2263
|
-
accepted: without a
|
|
2286
|
+
not configured yet, will still show the "paste the key" screen. That is
|
|
2287
|
+
accepted: without a key we cannot even ask for the list of notes, so we
|
|
2264
2288
|
cannot check that the server answers. The tag, on the other hand, was put
|
|
2265
2289
|
there by somebody. */
|
|
2266
2290
|
|
|
@@ -2383,23 +2407,23 @@
|
|
|
2383
2407
|
+ ' origins ' + location.origin + '\n'
|
|
2384
2408
|
+ ' mode ' + MODE;
|
|
2385
2409
|
|
|
2386
|
-
/* -- The "paste the
|
|
2410
|
+
/* -- The "paste the key" screen ---------------------------------------- */
|
|
2387
2411
|
|
|
2388
2412
|
const openSaltScreen = () => {
|
|
2389
|
-
const screen = blockingScreen(T('
|
|
2390
|
-
screen.body.appendChild(create('p', 'ap-help', T('
|
|
2391
|
-
screen.body.appendChild(create('p', 'ap-help', T('
|
|
2413
|
+
const screen = blockingScreen(T('key.title'), false);
|
|
2414
|
+
screen.body.appendChild(create('p', 'ap-help', T('key.help')));
|
|
2415
|
+
screen.body.appendChild(create('p', 'ap-help', T('key.origin_changed')));
|
|
2392
2416
|
|
|
2393
|
-
screen.body.appendChild(create('div', 'ap-label', T('
|
|
2417
|
+
screen.body.appendChild(create('div', 'ap-label', T('key.label')));
|
|
2394
2418
|
const field = create('input', 'ap-field');
|
|
2395
2419
|
field.type = 'text';
|
|
2396
2420
|
field.setAttribute('autocomplete', 'off');
|
|
2397
2421
|
field.setAttribute('spellcheck', 'false');
|
|
2398
|
-
field.setAttribute('maxlength', String(
|
|
2422
|
+
field.setAttribute('maxlength', String(KEY_LENGTH + 8));
|
|
2399
2423
|
screen.body.appendChild(field);
|
|
2400
2424
|
|
|
2401
2425
|
const actions = create('div', 'ap-actions');
|
|
2402
|
-
const confirm = create('button', 'ap-primary', T('
|
|
2426
|
+
const confirm = create('button', 'ap-primary', T('key.confirm'));
|
|
2403
2427
|
confirm.type = 'button';
|
|
2404
2428
|
actions.appendChild(confirm);
|
|
2405
2429
|
screen.body.appendChild(actions);
|
|
@@ -2409,30 +2433,30 @@
|
|
|
2409
2433
|
if (previous) previous.remove();
|
|
2410
2434
|
if (detail) {
|
|
2411
2435
|
screen.body.insertBefore(
|
|
2412
|
-
failureBlock({ title: T('
|
|
2436
|
+
failureBlock({ title: T('key.title'), detail: detail }), screen.body.firstChild);
|
|
2413
2437
|
}
|
|
2414
2438
|
};
|
|
2415
2439
|
|
|
2416
2440
|
confirm.addEventListener('click', () => {
|
|
2417
2441
|
const raw = normalize(field.value).replace(/\s+/g, '');
|
|
2418
|
-
if (!raw) return say(T('
|
|
2419
|
-
const bytes =
|
|
2420
|
-
if (!bytes) return say(T('
|
|
2442
|
+
if (!raw) return say(T('key.empty'));
|
|
2443
|
+
const bytes = keyFromText(raw);
|
|
2444
|
+
if (!bytes) return say(T('key.shape'));
|
|
2421
2445
|
say(null);
|
|
2422
2446
|
confirm.disabled = true;
|
|
2423
2447
|
|
|
2424
2448
|
/* The check happens HERE: we re-derive the project id and compare it
|
|
2425
|
-
with the tag's. Equal, the
|
|
2449
|
+
with the tag's. Equal, the key is the right one. Nothing is sent
|
|
2426
2450
|
to the network and nothing is decrypted before this test -- which
|
|
2427
|
-
is what saves us from carrying a checksum alongside the
|
|
2451
|
+
is what saves us from carrying a checksum alongside the key: the
|
|
2428
2452
|
project id already plays that part, and it is public. */
|
|
2429
2453
|
derive(bytes).then((derived) => {
|
|
2430
2454
|
confirm.disabled = false;
|
|
2431
|
-
if (derived.id !== PROJECT) return say(T('
|
|
2455
|
+
if (derived.id !== PROJECT) return say(T('key.wrong'));
|
|
2432
2456
|
if (!writeSalt(PROJECT, raw)) {
|
|
2433
2457
|
// Storage refuses: we carry on for this page anyway, but we
|
|
2434
2458
|
// do not let anyone believe it is remembered.
|
|
2435
|
-
say(T('
|
|
2459
|
+
say(T('key.not_kept'));
|
|
2436
2460
|
}
|
|
2437
2461
|
startWithSalt(raw, derived);
|
|
2438
2462
|
}, () => {
|
|
@@ -2459,11 +2483,11 @@
|
|
|
2459
2483
|
generate.addEventListener('click', () => {
|
|
2460
2484
|
generate.disabled = true;
|
|
2461
2485
|
const fresh = generateSalt();
|
|
2462
|
-
const bytes =
|
|
2486
|
+
const bytes = keyFromText(fresh);
|
|
2463
2487
|
derive(bytes).then((derived) => {
|
|
2464
2488
|
empty(screen.body);
|
|
2465
2489
|
|
|
2466
|
-
/* The warning comes BEFORE the
|
|
2490
|
+
/* The warning comes BEFORE the key, and before the button that
|
|
2467
2491
|
continues. It is spelled out in full, not in a footnote: it is
|
|
2468
2492
|
the only secret of the project, and there is no recovery. */
|
|
2469
2493
|
const warning = create('div', 'ap-error');
|
|
@@ -2472,7 +2496,7 @@
|
|
|
2472
2496
|
warning.appendChild(create('p', 'ap-error-detail', T('setup.warning')));
|
|
2473
2497
|
screen.body.appendChild(warning);
|
|
2474
2498
|
|
|
2475
|
-
copyBlock(screen.body, T('setup.
|
|
2499
|
+
copyBlock(screen.body, T('setup.key'), fresh);
|
|
2476
2500
|
copyBlock(screen.body, T('setup.project'), derived.id);
|
|
2477
2501
|
copyBlock(screen.body, T('setup.tag'), tagToPaste(derived.id));
|
|
2478
2502
|
copyBlock(screen.body, T('setup.server'), serverConfig(derived.id));
|
|
@@ -2491,7 +2515,7 @@
|
|
|
2491
2515
|
proceed.addEventListener('click', () => {
|
|
2492
2516
|
const kept = writeSalt(derived.id, fresh);
|
|
2493
2517
|
const done = create('p', 'ap-help',
|
|
2494
|
-
kept ? T('setup.done') : T('
|
|
2518
|
+
kept ? T('setup.done') : T('key.not_kept'));
|
|
2495
2519
|
actions.replaceWith(done);
|
|
2496
2520
|
});
|
|
2497
2521
|
actions.appendChild(proceed);
|
|
@@ -2511,7 +2535,7 @@
|
|
|
2511
2535
|
reason said out loud rather than a tool that quietly does not appear.
|
|
2512
2536
|
|
|
2513
2537
|
There is no field to correct here, and that is the difference with the
|
|
2514
|
-
|
|
2538
|
+
key screen: the mistake is in the page's source, not in this browser. So
|
|
2515
2539
|
the screen names what has to change in the tag, and stops. */
|
|
2516
2540
|
|
|
2517
2541
|
const openTagScreen = (detail) => {
|
|
@@ -2529,9 +2553,182 @@
|
|
|
2529
2553
|
screen.body.appendChild(create('p', 'ap-help', T('context.help')));
|
|
2530
2554
|
};
|
|
2531
2555
|
|
|
2556
|
+
/* ==== 80-upgrade.js ==== */
|
|
2557
|
+
|
|
2558
|
+
/* -- 19. A stale copy replaces itself ------------------------------------
|
|
2559
|
+
The problem, in one line: the distributed tag points at a RANGE on a CDN
|
|
2560
|
+
(annotepage-client@2), and jsDelivr serves that range with
|
|
2561
|
+
max-age=604800. A fix published today does not reach a visitor who came
|
|
2562
|
+
back within seven days. The lifetime is the CDN's to set and no attribute
|
|
2563
|
+
of <script> touches it -- integrity, crossorigin, defer, async, none of
|
|
2564
|
+
them. Cache-Control is a response header; the requester cannot overrule
|
|
2565
|
+
it.
|
|
2566
|
+
|
|
2567
|
+
So we do not fight the cache, we WALK AROUND IT. The stale copy does not
|
|
2568
|
+
refresh its own URL: it loads a DIFFERENT one, the pinned
|
|
2569
|
+
annotepage-client@X.Y.Z/dist/annotepage.js, which this browser has never
|
|
2570
|
+
fetched and which therefore no cache entry can answer.
|
|
2571
|
+
|
|
2572
|
+
WHERE THE CURRENT VERSION COMES FROM: the answer the client already asks
|
|
2573
|
+
for. `list` runs before the DOM is touched, and the server names the
|
|
2574
|
+
current client version in it. No second request, no extra file to host,
|
|
2575
|
+
and the answer lands exactly at the seam where the check belongs.
|
|
2576
|
+
|
|
2577
|
+
IT ANNOUNCES, IT NEVER GATES. The announced version says "there is
|
|
2578
|
+
something newer" and nothing else. It must never decide whether a request
|
|
2579
|
+
is allowed, and nothing here compares it to authorise anything:
|
|
2580
|
+
compatibility belongs to the FORMAT number and only to it (FORMAT.md
|
|
2581
|
+
section 7). 2.1.0 and 2.2.0 speak the same format by construction, and a
|
|
2582
|
+
client that refused to talk to a server one release ahead would break a
|
|
2583
|
+
pair that works.
|
|
2584
|
+
|
|
2585
|
+
WHY EVERY DOUBT IS A SILENCE. The announcement rides on an answer from a
|
|
2586
|
+
server that may be anybody's. A self-hosted server announces whatever it
|
|
2587
|
+
was installed with, which can be older than what is on this page. So:
|
|
2588
|
+
absent, unreadable, malformed, EQUAL or OLDER than ours -- carry on, say
|
|
2589
|
+
nothing. We only ever move forward, never back. A server cannot push a
|
|
2590
|
+
client downhill. */
|
|
2591
|
+
|
|
2592
|
+
/* THE SHAPE OF A VERSION, and it is the only gate that matters here.
|
|
2593
|
+
|
|
2594
|
+
This string arrives over the network from a server we do not own. It is
|
|
2595
|
+
never concatenated into a URL as it stands: it is matched against this
|
|
2596
|
+
expression, and the URL is then REBUILT from a base written here, in this
|
|
2597
|
+
file. A compromised or hostile server can therefore make us load a version
|
|
2598
|
+
of the official package that does not exist -- which fails, and is handled
|
|
2599
|
+
-- and nothing else. No host, no path, no scheme, no protocol-relative
|
|
2600
|
+
"//evil", no "../", no query, no "@" of its own.
|
|
2601
|
+
|
|
2602
|
+
Strict on purpose: three numbers, nothing around them. No pre-release
|
|
2603
|
+
suffix (a pre-release is not what one pushes to every visitor of every
|
|
2604
|
+
site), no leading zero, at most four digits per part. */
|
|
2605
|
+
const ANNOUNCED_SHAPE = /^(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})$/;
|
|
2606
|
+
|
|
2607
|
+
/* The CDNs whose exact-version address we know how to write ourselves, and
|
|
2608
|
+
only those. `base` is the whole beginning of the URL: nothing of what the
|
|
2609
|
+
server said ever appears before the version number.
|
|
2610
|
+
|
|
2611
|
+
`prefix` is matched against the PATHNAME of the tag's own src, and the host
|
|
2612
|
+
is compared whole -- indexOf on the full URL would accept
|
|
2613
|
+
"cdn.jsdelivr.net.example.com". Both entries are the npm package, which is
|
|
2614
|
+
the only package this file will ever point at. */
|
|
2615
|
+
const CDNS = [
|
|
2616
|
+
{
|
|
2617
|
+
host: 'cdn.jsdelivr.net',
|
|
2618
|
+
prefix: '/npm/annotepage-client@',
|
|
2619
|
+
base: 'https://cdn.jsdelivr.net/npm/annotepage-client@'
|
|
2620
|
+
},
|
|
2621
|
+
{
|
|
2622
|
+
host: 'unpkg.com',
|
|
2623
|
+
prefix: '/annotepage-client@',
|
|
2624
|
+
base: 'https://unpkg.com/annotepage-client@'
|
|
2625
|
+
}
|
|
2626
|
+
];
|
|
2627
|
+
|
|
2628
|
+
/**
|
|
2629
|
+
* Which CDN is serving THIS copy, or null -- and null is the interesting
|
|
2630
|
+
* case, because it means the site serves the file itself.
|
|
2631
|
+
*/
|
|
2632
|
+
const cdnServing = (src) => {
|
|
2633
|
+
let url;
|
|
2634
|
+
try {
|
|
2635
|
+
url = new URL(String(src));
|
|
2636
|
+
} catch (e) {
|
|
2637
|
+
return null;
|
|
2638
|
+
}
|
|
2639
|
+
if (url.protocol !== 'https:') return null;
|
|
2640
|
+
for (let i = 0; i < CDNS.length; i += 1) {
|
|
2641
|
+
const cdn = CDNS[i];
|
|
2642
|
+
if (url.host === cdn.host && url.pathname.indexOf(cdn.prefix) === 0) return cdn;
|
|
2643
|
+
}
|
|
2644
|
+
return null;
|
|
2645
|
+
};
|
|
2646
|
+
|
|
2647
|
+
/** The address of one exact version, BUILT HERE. Null if anything is off. */
|
|
2648
|
+
const officialUrl = (cdn, version) => {
|
|
2649
|
+
// Checked again, on the very line that builds the string: the caller
|
|
2650
|
+
// already checked, and a second reader of this file should not have to
|
|
2651
|
+
// go and verify that it did.
|
|
2652
|
+
if (!cdn || !ANNOUNCED_SHAPE.test(String(version))) return null;
|
|
2653
|
+
return cdn.base + version + '/dist/annotepage.js';
|
|
2654
|
+
};
|
|
2655
|
+
|
|
2656
|
+
/**
|
|
2657
|
+
* The version the server announces, IF it is newer than ours. Null in every
|
|
2658
|
+
* other case, and that includes every case of doubt: no field, not a string,
|
|
2659
|
+
* not three numbers, equal to ours, older than ours.
|
|
2660
|
+
*/
|
|
2661
|
+
const announcedVersion = (data) => {
|
|
2662
|
+
if (!data || typeof data !== 'object') return null;
|
|
2663
|
+
const announced = data.client_version;
|
|
2664
|
+
if (typeof announced !== 'string' || !ANNOUNCED_SHAPE.test(announced)) return null;
|
|
2665
|
+
|
|
2666
|
+
const theirs = versionNumbers(announced);
|
|
2667
|
+
const mine = versionNumbers(TOOL_VERSION);
|
|
2668
|
+
if (!theirs || !mine) return null;
|
|
2669
|
+
for (let i = 0; i < 3; i += 1) {
|
|
2670
|
+
if (theirs[i] !== mine[i]) return theirs[i] > mine[i] ? announced : null;
|
|
2671
|
+
}
|
|
2672
|
+
// Equal: nothing to say, and nothing to load.
|
|
2673
|
+
return null;
|
|
2674
|
+
};
|
|
2675
|
+
|
|
2676
|
+
/* Set when we are behind and we are NOT going to do anything about it --
|
|
2677
|
+
the file is served by the site itself. The panel says so, at every draw,
|
|
2678
|
+
the way the public-key notice does: a message shown once at load is read
|
|
2679
|
+
by whoever happened to be looking. */
|
|
2680
|
+
let upgradeAvailable = '';
|
|
2681
|
+
|
|
2682
|
+
/* True from the moment the replacement tag is in the document. It stops the
|
|
2683
|
+
old copy building anything, and it stops a second injection. */
|
|
2684
|
+
let handingOver = false;
|
|
2685
|
+
|
|
2686
|
+
/**
|
|
2687
|
+
* Injects the pinned version and stands down.
|
|
2688
|
+
*
|
|
2689
|
+
* The new tag CARRIES THE data- ATTRIBUTES OF THE OLD ONE, and it has to:
|
|
2690
|
+
* the client reads everything it knows from its own tag
|
|
2691
|
+
* (document.currentScript, 00-preamble), so a bare tag would produce a copy
|
|
2692
|
+
* with no server, no project and no key -- which is a copy that does
|
|
2693
|
+
* strictly nothing. The integrity attribute is deliberately NOT carried
|
|
2694
|
+
* over: it is the digest of the version we are leaving, and it would refuse
|
|
2695
|
+
* the version we are fetching.
|
|
2696
|
+
*
|
|
2697
|
+
* `onFailure` is called if that tag never loads -- a version announced but
|
|
2698
|
+
* never published, a CDN that is down. We are then back to being merely old,
|
|
2699
|
+
* which is the state we started in, and the tool boots normally. Losing the
|
|
2700
|
+
* tool entirely because a number was wrong somewhere would be a worse
|
|
2701
|
+
* outcome than being one release behind.
|
|
2702
|
+
*/
|
|
2703
|
+
const handOverTo = (cdn, version, onFailure) => {
|
|
2704
|
+
const url = officialUrl(cdn, version);
|
|
2705
|
+
if (!url || handingOver) return false;
|
|
2706
|
+
handingOver = true;
|
|
2707
|
+
|
|
2708
|
+
// BEFORE the new copy builds anything: its element and its listeners go
|
|
2709
|
+
// first, or the page ends up carrying two pills. At the seam there is
|
|
2710
|
+
// usually nothing to remove -- which is the whole point of checking
|
|
2711
|
+
// before the work rather than after it.
|
|
2712
|
+
withdraw();
|
|
2713
|
+
|
|
2714
|
+
const fresh = document.createElement('script');
|
|
2715
|
+
const attributes = script.attributes;
|
|
2716
|
+
for (let i = 0; i < attributes.length; i += 1) {
|
|
2717
|
+
const name = attributes[i].name;
|
|
2718
|
+
if (name.indexOf('data-') === 0) fresh.setAttribute(name, attributes[i].value);
|
|
2719
|
+
}
|
|
2720
|
+
fresh.src = url;
|
|
2721
|
+
fresh.addEventListener('error', () => {
|
|
2722
|
+
handingOver = false;
|
|
2723
|
+
onFailure();
|
|
2724
|
+
});
|
|
2725
|
+
(document.head || document.documentElement).appendChild(fresh);
|
|
2726
|
+
return true;
|
|
2727
|
+
};
|
|
2728
|
+
|
|
2532
2729
|
/* ==== 90-boot.js ==== */
|
|
2533
2730
|
|
|
2534
|
-
/* --
|
|
2731
|
+
/* -- 20. Reading the notes ----------------------------------------------- */
|
|
2535
2732
|
|
|
2536
2733
|
const redraw = () => {
|
|
2537
2734
|
if (!ui) return;
|
|
@@ -2560,12 +2757,12 @@
|
|
|
2560
2757
|
});
|
|
2561
2758
|
});
|
|
2562
2759
|
|
|
2563
|
-
/* --
|
|
2760
|
+
/* -- 21. Startup ----------------------------------------------------------
|
|
2564
2761
|
The order matters: we ask the API BEFORE touching the DOM. If it does not
|
|
2565
2762
|
answer what it should, the site never saw anything go by.
|
|
2566
2763
|
|
|
2567
|
-
One exception, accepted: the setup and
|
|
2568
|
-
ask the API -- without a
|
|
2764
|
+
One exception, accepted: the setup and key-pasting screens, which CANNOT
|
|
2765
|
+
ask the API -- without a key there is no page index to give it. They are
|
|
2569
2766
|
declared (data-setup) or asked for by a tag that already carries a
|
|
2570
2767
|
project: either way, somebody put that tag here on purpose. */
|
|
2571
2768
|
|
|
@@ -2589,7 +2786,22 @@
|
|
|
2589
2786
|
});
|
|
2590
2787
|
};
|
|
2591
2788
|
|
|
2789
|
+
/**
|
|
2790
|
+
* The tool leaves the page, and leaves NOTHING behind.
|
|
2791
|
+
*
|
|
2792
|
+
* Removing the host element is not enough on its own and never was: in
|
|
2793
|
+
* annotation mode the listeners sit on `document` and on `window`, not on the
|
|
2794
|
+
* host, and a repeating timer is running. Dropping the element would leave
|
|
2795
|
+
* them hovering, clicking and measuring a layer that no longer exists. That
|
|
2796
|
+
* did not show while withdrawal only ever happened before anything was built;
|
|
2797
|
+
* it does the moment a copy withdraws in favour of a newer one (80-upgrade).
|
|
2798
|
+
*
|
|
2799
|
+
* leaveMode() is the one place that knows the whole list, and it is called
|
|
2800
|
+
* rather than copied: two lists drift, and the one that drifts is the one
|
|
2801
|
+
* nobody runs.
|
|
2802
|
+
*/
|
|
2592
2803
|
const withdraw = () => {
|
|
2804
|
+
if (ui && mode) leaveMode();
|
|
2593
2805
|
if (host) host.remove();
|
|
2594
2806
|
host = null;
|
|
2595
2807
|
root = null;
|
|
@@ -2619,11 +2831,11 @@
|
|
|
2619
2831
|
r.cause === 'server' || r.cause === 'failure' || String(r.cause).indexOf('refused') === 0;
|
|
2620
2832
|
|
|
2621
2833
|
/**
|
|
2622
|
-
* The
|
|
2834
|
+
* The key is known and checked: we derive the page index, we ask the
|
|
2623
2835
|
* server, and the tool takes its normal shape.
|
|
2624
2836
|
*/
|
|
2625
2837
|
function startWithSalt(text, derived) {
|
|
2626
|
-
|
|
2838
|
+
keyText = text;
|
|
2627
2839
|
keys = derived;
|
|
2628
2840
|
|
|
2629
2841
|
return indexOfPath(keys.indexKey, pagePath())
|
|
@@ -2633,30 +2845,57 @@
|
|
|
2633
2845
|
})
|
|
2634
2846
|
.then((first) => {
|
|
2635
2847
|
if (!first.ok && !speaksAtStartup(first)) {
|
|
2636
|
-
// Complete silence: no node, no pixel, no message. If a
|
|
2848
|
+
// Complete silence: no node, no pixel, no message. If a key
|
|
2637
2849
|
// screen was open, it goes away with the rest.
|
|
2638
2850
|
withdraw();
|
|
2639
2851
|
return null;
|
|
2640
2852
|
}
|
|
2641
2853
|
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2854
|
+
/* THE SEAM. This is the one moment where a copy can discover it
|
|
2855
|
+
is out of date having drawn nothing, listened to nothing and
|
|
2856
|
+
decrypted nothing -- so it withdraws instead of undoing. The
|
|
2857
|
+
answer that carries the announcement is the one we were
|
|
2858
|
+
waiting for anyway: nothing was added in front of the boot,
|
|
2859
|
+
and a page that is up to date pays exactly nothing.
|
|
2860
|
+
|
|
2861
|
+
WHAT HAPPENS NEXT DEPENDS ON WHERE THIS FILE CAME FROM, and
|
|
2862
|
+
that distinction is the whole design:
|
|
2863
|
+
|
|
2864
|
+
from a CDN -- the seven-day cache is what put us here, and
|
|
2865
|
+
pulling the pinned version walks around it. We hand over.
|
|
2866
|
+
|
|
2867
|
+
from anywhere else -- the site serves its own copy, which
|
|
2868
|
+
somebody CHOSE to do, and going to a CDN behind their back
|
|
2869
|
+
would add the dependency they deliberately removed. We say
|
|
2870
|
+
it in the panel and we load nothing. */
|
|
2871
|
+
const newer = first.ok ? announcedVersion(first.data) : null;
|
|
2872
|
+
const cdn = newer ? cdnServing(script.src) : null;
|
|
2873
|
+
if (cdn && handOverTo(cdn, newer, () => { proceed(first); })) return null;
|
|
2874
|
+
if (newer) upgradeAvailable = newer;
|
|
2875
|
+
|
|
2876
|
+
return proceed(first);
|
|
2877
|
+
});
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
/** Everything the tool does once it has decided to stay. */
|
|
2881
|
+
function proceed(first) {
|
|
2882
|
+
// From here on the tool EXISTS, and will no longer keep quiet
|
|
2883
|
+
// about its failures.
|
|
2884
|
+
buildHost();
|
|
2885
|
+
return loadLocalLabels().then(() => {
|
|
2886
|
+
clearLayer();
|
|
2887
|
+
buildUi();
|
|
2888
|
+
if (first.ok) {
|
|
2889
|
+
return readList(first.data).then((read) => {
|
|
2890
|
+
notes = read;
|
|
2656
2891
|
redraw();
|
|
2657
2892
|
return null;
|
|
2658
2893
|
});
|
|
2659
|
-
}
|
|
2894
|
+
}
|
|
2895
|
+
currentFailure = failureFrom(first, 'error.title_read');
|
|
2896
|
+
redraw();
|
|
2897
|
+
return null;
|
|
2898
|
+
});
|
|
2660
2899
|
}
|
|
2661
2900
|
|
|
2662
2901
|
const start = () => {
|
|
@@ -2680,7 +2919,7 @@
|
|
|
2680
2919
|
day the tag changes. The interface then says so at every draw
|
|
2681
2920
|
(PUBLIC_KEY, 60-ui). */
|
|
2682
2921
|
if (KEY_DECLARED) {
|
|
2683
|
-
const keyBytes =
|
|
2922
|
+
const keyBytes = keyFromText(DECLARED_KEY);
|
|
2684
2923
|
if (!keyBytes) {
|
|
2685
2924
|
/* An attribute somebody wrote on purpose, and it is not a key.
|
|
2686
2925
|
Staying silent here would be the behaviour of a tag carrying
|
|
@@ -2719,7 +2958,7 @@
|
|
|
2719
2958
|
}
|
|
2720
2959
|
|
|
2721
2960
|
const text = readSalt(PROJECT);
|
|
2722
|
-
const bytes =
|
|
2961
|
+
const bytes = keyFromText(text);
|
|
2723
2962
|
if (!bytes) {
|
|
2724
2963
|
showScreen(openSaltScreen);
|
|
2725
2964
|
return;
|
|
@@ -2727,7 +2966,7 @@
|
|
|
2727
2966
|
|
|
2728
2967
|
derive(bytes).then((derived) => {
|
|
2729
2968
|
if (derived.id !== PROJECT) {
|
|
2730
|
-
// The
|
|
2969
|
+
// The key stored under this key does not derive this id: the
|
|
2731
2970
|
// tag has changed project, or the storage was tampered with. We
|
|
2732
2971
|
// ask again, we do not guess.
|
|
2733
2972
|
showScreen(openSaltScreen);
|
package/labels/fr.json
CHANGED
|
@@ -72,18 +72,19 @@
|
|
|
72
72
|
"read.title_partial": "Certaines notes n’ont pas pu être lues",
|
|
73
73
|
"marker.one": "1 note ici",
|
|
74
74
|
"marker.n": "{n} notes ici",
|
|
75
|
-
"
|
|
76
|
-
"
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
"
|
|
82
|
-
"
|
|
83
|
-
"
|
|
84
|
-
"
|
|
85
|
-
"
|
|
75
|
+
"key.title": "La clé de ce projet est nécessaire",
|
|
76
|
+
"key.help": "Les notes de ce projet sont chiffrées dans votre navigateur. Sans la clé du projet, ce navigateur ne peut ni les lire, ni en écrire. Demandez-la à la personne qui a installé l’outil, et collez-la ci-dessous. Elle sera retenue par ce navigateur, pour ce site.",
|
|
77
|
+
"key.label": "La clé du projet (43 caractères)",
|
|
78
|
+
"key.confirm": "Utiliser cette clé",
|
|
79
|
+
"key.empty": "Collez la clé avant de valider.",
|
|
80
|
+
"key.shape": "Ce n’est pas une clé : on attend 43 caractères parmi A-Z a-z 0-9 - _, sans espace ni tiret décoratif. Recopiez-la d’un seul bloc.",
|
|
81
|
+
"key.wrong": "Cette clé n’est pas celle de ce projet. Rien n’a été envoyé, rien n’a été déchiffré. Vérifiez que vous collez la clé du bon projet.",
|
|
82
|
+
"key.origin_changed": "Cette clé est retenue par navigateur ET par domaine. Le jour où la préproduction devient la production, il faut la recoller une fois sur le nouveau domaine — les notes, elles, ne bougent pas.",
|
|
83
|
+
"key.not_kept": "Ce navigateur refuse de retenir la clé (navigation privée, ou stockage bloqué). L’outil fonctionne pour cette page, mais la clé sera à recoller au prochain chargement.",
|
|
84
|
+
"key.replace": "Coller une autre clé",
|
|
85
|
+
"key.forget": "Oublier la clé sur ce navigateur",
|
|
86
86
|
"public.notice": "Chiffré de bout en bout, et la clé de ce projet est publique : elle est écrite dans cette page. Quiconque peut ouvrir la page peut lire ces notes ET en écrire — la clé donne les deux, et ce format n’a pas de rôle lecteur seul.",
|
|
87
|
+
"upgrade.available": "Un client annotepage plus récent existe : {version}. Cette page exécute la version {current}, servie par le site lui-même — rien n’a été chargé pour la remplacer, et le moment de mettre à jour le fichier appartient au propriétaire.",
|
|
87
88
|
"tag.title": "Cette balise annotepage est inutilisable telle quelle",
|
|
88
89
|
"tag.key_shape": "L’attribut data-key de la balise de cette page n’est pas une clé : 43 caractères sont attendus, parmi A-Z a-z 0-9 - _, sans espace et sans tiret décoratif. Rien n’a été envoyé, rien n’a été déchiffré. La personne qui a installé l’outil doit recopier la clé, d’un seul bloc.",
|
|
89
90
|
"tag.key_mismatch": "La balise de cette page porte une clé et un identifiant de projet qui ne vont pas ensemble : la clé ne dérive pas cet identifiant. Rien n’a été envoyé, rien n’a été déchiffré, et l’outil ne devine pas lequel des deux a raison. Une balise publique se suffit de la clé — l’identifiant en est dérivé — donc retirez data-project, ou corrigez celui des deux qui est faux.",
|
|
@@ -91,7 +92,7 @@
|
|
|
91
92
|
"setup.generate": "Engendrer une clé et créer le projet",
|
|
92
93
|
"setup.warning_title": "À lire avant de continuer",
|
|
93
94
|
"setup.warning": "La clé ci-dessous est le SEUL secret du projet, et personne d’autre ne l’a : ni le serveur, ni l’auteur de l’outil, ni personne à qui la demander. CLÉ PERDUE = NOTES PERDUES, définitivement, sans recours. Rangez-la maintenant, là où votre équipe range ses mots de passe, avant de continuer.",
|
|
94
|
-
"setup.
|
|
95
|
+
"setup.key": "La clé du projet — à conserver",
|
|
95
96
|
"setup.project": "L’identifiant du projet — public, il va dans la page",
|
|
96
97
|
"setup.tag": "La balise à coller en fin de <body>, sur les pages à annoter",
|
|
97
98
|
"setup.server": "À déclarer dans la configuration du serveur",
|
package/package.json
CHANGED