diff --git a/docs/auth.md b/docs/auth.md
index 5b5a16b..0f39dbf 100644
--- a/docs/auth.md
+++ b/docs/auth.md
@@ -65,8 +65,8 @@ receive the code, so `https:` and non-loopback `http:` are both refused.
### Minting a token directly
-Open in a browser and press the button. The token is blurred
-until you point at it, and one click selects the whole value to copy.
+Open in a browser and press the button. The token is covered by
+asterisks until you point at it, and one click selects the whole value to copy.
The endpoint is built for that page and always answers in HTML — `POST` mints and returns
`201` with the token in the page body; there is no JSON representation. A script therefore
diff --git a/src/http/token-issue.ts b/src/http/token-issue.ts
index 5d4d509..ac3a9a6 100644
--- a/src/http/token-issue.ts
+++ b/src/http/token-issue.ts
@@ -45,17 +45,21 @@ button:hover{filter:brightness(1.08)}
:focus-visible{outline:2px solid var(--warn);outline-offset:3px}
.token{margin:26px 0;border:1px solid var(--rule-2);background:var(--sheet);padding:18px 20px;
font-family:var(--mono);font-size:14px;color:var(--ink);word-break:break-all;user-select:all}
-/* The secret stays unreadable until asked for, so a screen share or a
- screenshot of the rest of the page does not carry it away. The blur is
- presentation only — the text underneath is still selectable, which is what
- lets one click select the whole value with no script on the page. */
-.token.secret{filter:blur(5px);transition:filter .12s ease-out;cursor:pointer}
-.token.secret:hover,.token.secret:focus,.token.secret:active{filter:none}
+/* The secret stays covered until asked for, so a screen share or a screenshot
+ of the rest of the page does not carry it away. Only one of the two spans is
+ ever in the layout: the stand-in is dropped on reveal so it can never land in
+ a copy, and the value is dropped while covered so it cannot be read. The
+ swap hangs off :hover and :focus because this page bans script and has no
+ other way to respond to a reader. */
+.token.secret{cursor:pointer}
+.token.secret .real{display:none}
+.token.secret:hover .mask,.token.secret:focus .mask,.token.secret:active .mask{display:none}
+.token.secret:hover .real,.token.secret:focus .real,.token.secret:active .real{display:inline}
+.mask{color:var(--ink-2)}
.token::selection{background:var(--warn);color:var(--stock)}
.hint{font-size:13px;margin:-18px 0 26px}
.hint kbd{font-family:var(--mono);font-size:11px;border:1px solid var(--rule-2);border-radius:2px;
padding:1px 5px;color:var(--ink)}
-@media (prefers-reduced-motion:reduce){.token.secret{transition:none}}
.once{border-left:2px solid var(--warn);background:color-mix(in srgb,var(--warn) 5%,var(--sheet));
padding:16px 20px;margin-bottom:26px}
.once b{display:block;font-family:var(--mono);font-size:10px;text-transform:uppercase;
@@ -82,17 +86,23 @@ comparing Base pools. No wallet, no account, no email.
`,
);
+/** Matches the value's width so revealing it does not reflow the page. */
+function cover(value: string): string {
+ return "*".repeat(value.length);
+}
+
function issuedPage(token: string): string {
+ const stars = cover(token);
return page(
"Your DeepTrace token",
`Your token
Shown onceCopy it now. It is stored only as a hash, so it cannot be
shown again. Losing it costs nothing — come back and take another.
-${token}
-Blurred until you point at it. One click selects the whole token, then
+
${stars}${token}
+Hidden until you point at it. One click selects the whole token, then
Cmd/Ctrl+C copies it.
Export it, then follow the setup guide for your client:
-export DEEPTRACE_TOKEN="${token}"
+export DEEPTRACE_TOKEN="${stars}${token}"
Never paste it into a chat, put it in a URL, or commit it.
`,
);
}
diff --git a/tests/unit/token-issue.test.ts b/tests/unit/token-issue.test.ts
index cd05afa..fadfe79 100644
--- a/tests/unit/token-issue.test.ts
+++ b/tests/unit/token-issue.test.ts
@@ -130,20 +130,26 @@ describe("self-serve token issuance", () => {
}
});
- it("keeps the issued token blurred until the reader asks for it", async () => {
+ it("covers the issued token with asterisks until the reader asks for it", async () => {
const { runtime, origin } = await startServer();
try {
const body = await (await fetch(`${origin}/auth`, { method: "POST" })).text();
+ const token = extractToken(body);
- // Blur is presentation only. The value must stay selectable underneath,
- // or the click-then-copy path this page documents stops working, and it
- // must stay script-free so the page keeps its own CSP.
- expect(body).toContain('class="token secret"');
- expect(body).toContain("filter:blur(");
+ // Only one of the two spans is ever laid out: the asterisks leave on
+ // reveal so they can never land in a copy, and the value stays out of the
+ // layout while covered so it cannot be read off a screen share. Both
+ // swaps have to stay script-free, or the page loses its own CSP.
+ expect(token).not.toBe("");
+ expect(body).toContain(
+ `${"*".repeat(token.length)}`,
+ );
+ expect(body).toContain(`${token}`);
+ expect(body).toContain(".token.secret .real{display:none}");
+ expect(body).toMatch(/\.token\.secret:hover \.mask[^{]*\{display:none\}/u);
+ expect(body).toMatch(/\.token\.secret:hover \.real[^{]*\{display:inline\}/u);
expect(body).toContain("user-select:all");
- expect(body).toMatch(/\.token\.secret:hover[^{]*\{filter:none\}/u);
expect(body).not.toMatch(/