@gotcos/glasses-server 7.0.27 → 7.0.29

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/CHANGELOG.md CHANGED
@@ -1,3 +1,66 @@
1
+ ## 7.0.29 - Control Web: on the folded Duo, the panes sit in a side rail at the right edge, and Back is at thumb height
2
+
3
+ 2026-11-12. 7.0.28 plus Control Web changes: a side rail for the folded Duo only, and small Sessions list changes for every screen.
4
+
5
+ - **A side rail on the folded Duo.** Today, Messages, Speakers, Meetings, Memories, Threads, Sessions, Work, Dictation and Settings are one tap away in a slim rail at the right edge of the page, with the current one marked. Before, they sat behind the pane name's menu at the top left. The rail is the same list, in the same order, with the same icons and dots, as the rail on a wide screen.
6
+ - **Back at thumb height.** Where the top bar showed Back (a session or a message you are reading), Back now sits at the foot of the rail instead. If the panes do not fit, they scroll inside the rail and Back stays put.
7
+ - **One way to switch panes.** While the rail shows, the pane name at the top left stays as a label, without its arrow and its menu.
8
+ - **The page never reloads by itself over what you typed.** Since 7.0.28 the page reloads itself when part of it failed to load and your Mac answers again. Now, while a text box holds words, you are typing in one, or a message is on its way, it shows one line instead: "Part of this page did not load." with a Reload button, and adds "Send or copy what you have typed first. A reload clears it." when a box holds words. Only your tap reloads. If the browser keeps no record of earlier reloads (storage blocked), it shows the same line rather than reloading. This is for every screen size.
9
+ - **Words you typed are kept when you leave a session.** Back, the rail or another pane no longer empties the reply box: open the session again in the same tab and your unsent words are there. Sent words do not come back, and words from one session never land in another's box. This is for every screen size (it was lost before on every size too).
10
+ - **Folding or turning the phone updates it.** The page checks the screen again whenever its size or orientation changes, so opening or folding the Duo shows or hides the rail without a reload.
11
+ - **The reply box never cuts its words.** Where the rail leaves the reply box too narrow for "Message this session (not an answer)", it says "Message (not an answer)", and an empty box is always tall enough for its words.
12
+ - **Buttons are 44 points on your phone.** Under Safari's Page Zoom the page is laid out wider than the screen draws it, so the rail sizes its buttons from the zoom: 52 px at 85 percent, 44 px at 100.
13
+ - **Who gets it:** a touch screen, a page under 600 px wide, and a screen whose long side is less than 1.6 times its short side. The folded Duo is 1.46. Every iPhone is 1.78 or more, so none gets the rail, in the Home Screen app or a Safari tab, keyboard up or down. An iPhone SE (375 by 667) keeps today's layout. Nothing changes at 600 px and wider, so the open Duo is unchanged.
14
+ - **What it costs:** the rail takes 58 of 449 layout px at 85 percent zoom (49 points), 50 of 382 at 100. A long session title shows about one character fewer at 85 percent and seven fewer at 100. Wide tables in a session still scroll inside their own box, never the page.
15
+ - The page's existing layout report says whether the rail showed and its button size, so the phone tells the Mac what it saw.
16
+ - **Sessions: Pinned folds.** The Pinned heading folds and unfolds like a folder, with its count, and this browser remembers the choice. It starts open, so nothing changes until you fold it. A pinned session that is working still shows under Working now, and one that is waiting or failed under Needs you, folded or not; it goes back under Pinned when it is idle. Needs you and Working now never fold. This is for every screen size.
17
+ - **Sessions: sending into a session that just changed.** Before, a busy session stopped almost every message with "This thread changed on your Mac" and Refresh, Continue anyway and Fork, and Refresh did not help. Now, when you press Send and the session changed only in ways you had already seen, your message goes through by itself. When a reply arrived that you had not seen (one that landed below where you were reading counts as not seen), or the session is now waiting on a question, the reply is shown, your words stay in the box, and it says so: "A new reply arrived while you were writing. Your message is not sent yet." with "Send it anyway" and "Send to a copy". It sends by itself at most once. A message waiting to send, an answer to a question card, and Today's panel work as before. This is for every screen size.
18
+ - **Sessions: Remove from folder.** A session in a folder now has Remove from folder in its menu, right under Move to folder. Before, the only way out was Move to folder, then No folder at the bottom of that list, which still works. This is for every screen size.
19
+ - Tested in a browser (WebKit) at the Duo's folded sizes (449 by 797 at 85 percent zoom, 382 by 678, and a shorter Safari tab), an iPhone SE, 16 and Pro Max (the Pro Max also in a tab), and the open Duo, in light and dark, on the Sessions list, a session, Today, Messages and with the keyboard up. Where the rail does not show, every element sits where it did in 7.0.28. Not yet tested on a real device.
20
+
21
+ **Default-on for every install:** yes, with no switch: the rail only on a screen of that shape; the folding Pinned heading everywhere, open until folded; Remove from folder, the kept reply words, the changed-session send and no reload over typed words everywhere.
22
+
23
+ ### If you roll back
24
+
25
+ - To 7.0.28: the folded Duo goes back to the pane menu at the top left and Back in the top bar; Pinned no longer folds; Remove from folder goes (No folder still works); words in the reply box are lost on Back again; a changed session stops every send with the old box; the page can reload itself over what you typed again. The only thing stored is the Pinned fold choice, in the browser, which 7.0.28 does not read. Unsent words are kept in the tab's memory only.
26
+ - Further back: as in 7.0.28 below.
27
+
28
+ ## 7.0.28 - Control Web: the sign-in screen is styled even before the page can reach your Mac, and the page fixes itself when it can; page errors are reported; phone approvals get a trusted certificate (off by default)
29
+
30
+ 2026-11-11. 7.0.27 plus two Control Web changes, and server code for phone approvals that stays off unless you turn it on.
31
+
32
+ - **No more unstyled "Connect this browser" page.** Opening the web app before your phone could reach your Mac (Tailscale still connecting, or the Mac waking) could draw the page without its stylesheets: a plain black or white page with default text and buttons, and the COS mark drawn large twice. It stayed that way, because nothing asked for the stylesheets again. Now the sign-in and "not answering" screens carry their own styling inside the page, so they look right from the first moment. While a stylesheet or script is missing, the page checks after 1, 2, 4 and 8 seconds, then every 30 seconds, and as soon as your Mac answers it loads what was missing, reloading itself if a script was missing (never more than twice in 5 minutes). The "not answering" screen also checks again after 1, 2, 4 and 8 seconds before settling on every 30 seconds.
33
+ - **Page errors are reported.** A script error or an unhandled failure in Control Web now sends one short, numbers-only report to your Mac (what kind, the file, the line), at most 10 per page load. It never includes what you typed, a message or any text from the page. A page that had to bring back its styling reports that too, the same way.
34
+ - Tested in a browser with the page's files really blocked and then unblocked, at the open and folded phone sizes, in light and dark: 7.0.27 shows the unstyled page and stays broken, and this build shows the styled screen and recovers within seconds. The Sessions list, the reader and the reply box are unchanged.
35
+
36
+ **Phone approvals: the phone's signer page gets a publicly trusted certificate (off by default).** Every change sits behind the two phone-approval switches, `COS_WEB_KEY_AUTHORITY` and `COS_WEB_KEY_ANCHOR_BOOTSTRAP`, both off by default.
37
+
38
+ - **Both switches off (every install by default): nothing changes.** No Tailscale call, no new file, no new listener, no new status field. 3141 and 3143 serve exactly what they served before.
39
+ - **Only `COS_WEB_KEY_ANCHOR_BOOTSTRAP` on (the Mac-key setup):** if this Mac's tailnet name was missed at startup, the authenticated `/api/web-key/status` read looks it up again. That is one DNS lookup through Tailscale, at most once per 30 seconds, waiting at most 2 seconds. Before, it answered `origin: null` until a restart.
40
+ - **With `COS_WEB_KEY_AUTHORITY` on:**
41
+ - **The certificate.** The signer page on 3144 gets a certificate for this Mac's own tailnet name from Tailscale's HTTPS Certificates (Let's Encrypt). The server fetches it, keeps it in `web-key-tls/` in its data folder (key 0600, folder 0700), and shows it only to a connection that names exactly that host. It renews when fewer than 30 days remain. Any other name, no name, or an IP keeps the existing local certificate. 3143 never changes.
42
+ - **What `trusted` means.** `signerTls.trusted` means **publicly trusted**. The certificate chains, link by link, to one of Node's bundled public root certificates. It is inside its validity window, notBefore included. Its key matches the certificate (the public keys are compared, because a mismatched key type is not otherwise caught). A pair that fails any of these is never served and never stops the server from fetching the real one. Each refusal is named: `cert_untrusted`, `cert_not_yet_valid`, `cert_invalid`, `cert_expired`.
43
+ - **The fetch works on macOS.** Every Tailscale command the server ran used to answer "The Tailscale GUI failed to start", because a process started by the server's LaunchAgent has no SHLVL. The certificate commands now run with a small fixed env: HOME, PATH, TMPDIR, USER and LANG when set, plus SHLVL=1. They never see the server's own env. If that answer ever comes back, the status says `tailscale_cli_gui_unavailable`. The status call asks without peers, so a large tailnet cannot overflow it; an overflow says `tailscale_output_too_large`.
44
+ - **Bounded against Let's Encrypt's limit.** At most 3 `tailscale cert` calls per UTC day, so up to 21 in a week, and up to 6 within minutes across midnight UTC. Each call is counted on disk, in `web-key-tls/fetch-state.json`, before it runs, and the count survives a restart. If that file is missing or unreadable, the count starts again at 0. Over the cap the status says `cert_daily_cap`.
45
+ - **Turning on HTTPS Certificates is picked up within seconds.** An authenticated status read re-checks at once (at most every 15 s) when the last check said HTTPS Certificates was off or the name was unknown. It no longer waits out a backoff of up to an hour.
46
+ - **A certificate put in that folder by hand** serves at the next connection or status read, with no restart. It passes exactly the same checks. The folder and both files must be owned by this user and must not be writable by group or others; the folder must be a real folder and each file a regular file, not a link, each at most 64 KiB. Read permission for group or others is not checked. A file that breaks a rule is refused, and the status names why in `signerTls.storedRefused`.
47
+ - **After a tailnet rename,** 3144 no longer answers the old name with the old certificate.
48
+ - **`/api/web-key/status` reports `signerTls`:** trusted, reason, host, expiry, the last error, the last and next check, and `storedRefused`.
49
+ - **Needs before it can work:** Tailscale HTTPS Certificates turned on for the tailnet, in the Tailscale admin console. The Mac's name then appears in public certificate logs. Real issuance is limited to 5 per week for the same name.
50
+ - **Not changed:** Tailscale's link check (tailscale-nudge) has the same SHLVL gap and is left as it is. Fixing it changes behaviour for everyone and needs Miles's go.
51
+ - **Not yet tested on a real device:** a real `tailscale cert`, iPhone Safari against the Let's Encrypt certificate, and COS Control's signer check against it.
52
+
53
+ **Default-on for every install:** the two Control Web changes, yes, with no switch. The phone-approval code, no: both of its switches are off by default.
54
+
55
+ ### If you roll back
56
+
57
+ - To 7.0.27: an open that cannot reach your Mac can show the unstyled page again until you close and reopen it, and page errors are no longer reported. With the phone-approval switches off, nothing else changes and nothing is stored.
58
+ - Phone approvals, if you had turned them on:
59
+ - **Both switches off:** nothing to undo.
60
+ - **Bootstrap on:** a startup miss of the tailnet name sticks until a restart again.
61
+ - **Authority on:** 3144 goes back to the local certificate, so iPhone Safari refuses the signer page. `web-key-tls/` (the pair and `fetch-state.json`) stays on disk, unread.
62
+ - To 7.0.18: as in 7.0.27 below.
63
+
1
64
  ## 7.0.27 - Control Web: the Sessions list fits its column at every width
2
65
 
3
66
  2026-11-11. 7.0.26 plus one Control Web layout fix.
@@ -47,7 +47,7 @@ canonical, so one message has exactly one signable form.
47
47
  `signer_origin_required` unless the request reached the 3144 listener (socket local port). A page on 3143
48
48
  can neither make a key nor fetch a handoff.
49
49
  - **rpId:** `selfDnsName`, the full lower-case MagicDNS host, no trailing dot (for example
50
- `ukaoma-mac-studio.tail38457f.ts.net`; `ts.net` is a public suffix). The verifier derives it as
50
+ `example-mac.tail00000a.ts.net`; `ts.net` is a public suffix). The verifier derives it as
51
51
  the host of `envelope.origin`.
52
52
  - **aud:** `hex(sha256(anchor SPKI DER || utf8(lower(rpId))))`. The verifier computes it from ITS
53
53
  pinned anchor, never from a key in the answer.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "7.0.27",
3
+ "version": "7.0.29",
4
4
  "description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -245,6 +245,7 @@ import { webControlOffnetResponse } from './lib/web-control-offnet-page.js'
245
245
  import { createWebControlStatic } from './lib/web-control-static.js'
246
246
  import { isWebControlSocketAllowed, isWebSessionAuthorized, setWebSessionEpochFile } from './lib/web-session-auth.js'
247
247
  import { getTailnetSelfName, installTailnetSelfName } from './lib/tailnet-self-name.js'
248
+ import { createSignerTlsForBoot, signerHttpsOptions, SIGNER_TLS_DIR, type SignerTls } from './lib/web-key-signer-tls.js'
248
249
  import { isManagedRuntime, managedServerVersion } from './lib/managed-runtime.js'
249
250
  import { reportClaudeExtraToolConfiguration } from './lib/claude-tool-access.js'
250
251
  import {
@@ -440,6 +441,10 @@ const webKeySignHandoffs = new WebKeySignHandoffs()
440
441
  const webKeySignerIntegrity: WebControlIntegrity | null = webKeyAuthorityEnabled()
441
442
  ? new WebControlIntegrity(path.join(path.dirname(fileURLToPath(import.meta.url)), 'web-signer'), { log: line => console.error(line.replace('[web-control-integrity]', '[web-signer-integrity]').replace('/control is', 'the 3144 signer is')) })
442
443
  : null
444
+ // B2 (QA 2026-11-09): the 3144 signer's trusted Tailscale certificate (lib/web-key-signer-tls.ts). Built in the
445
+ // listener loop below ONLY when the 3144 listener is (flag on at boot, HTTPS available); otherwise null, and no
446
+ // Tailscale call, file or listener change happens. 3143 is never given SNI selection.
447
+ let webKeySignerTls: SignerTls | null = null
443
448
  // Review W6: enrollment starts only from a device on COS_WEB_KEY_DEVICES (empty: none).
444
449
  const webKeyEnrollments = new WebKeyEnrollments(webKeyRegistry, { deviceAllowed: deviceAllowlistFrom() })
445
450
  // The ports a COS Control Web cookie is honoured on. Flag off: exactly 6.99.29's [3141, 3143].
@@ -490,8 +495,10 @@ const pairingState = new PairingState({
490
495
  // address as a paired phone. Notes only, never answers; noteRequest never throws.
491
496
  const tailscaleNudger = installTailscaleNudger({ activeMeetings: () => getActiveTranscriptionSessionCount() })
492
497
  // COS Control Web: this Mac's MagicDNS name, from the nudger when it has one and otherwise
493
- // from a reverse lookup at MagicDNS (the CLI does not answer under this LaunchAgent; see
494
- // lib/tailnet-self-name.ts). Learned at boot so the first phone sign-in is not refused.
498
+ // from a reverse lookup at MagicDNS (the nudger's Tailscale CLI calls fail under this LaunchAgent:
499
+ // node passes its child no SHLVL, so the macOS CLI answers "The Tailscale GUI failed to start";
500
+ // probe 2026-11-10, see lib/tailnet-self-name.ts and lib/web-key-signer-tls.ts signerTlsCliEnv).
501
+ // Learned at boot so the first phone sign-in is not refused.
495
502
  const tailnetSelf = installTailnetSelfName({ nudgerName: () => tailscaleNudger.currentSelfDnsName() })
496
503
  if (webControlEnabled()) void tailnetSelf.refresh()
497
504
  // Browser sessions sign this Mac's session epoch; "Sign out every browser" bumps it.
@@ -1002,8 +1009,12 @@ app.use('/api', createWebKeyRouter({
1002
1009
  isWebRequest: req => g2Provenance.originOf(req).origin.client === 'web',
1003
1010
  apiToken: () => API_TOKEN,
1004
1011
  selfDnsName: webKeySelfDnsName,
1012
+ // 7.0.14: a boot-time miss of the tailnet name is looked up again (bounded) instead of answering `origin: null`.
1013
+ refreshSelfDnsName: () => tailnetSelf.refreshIfMissing(),
1005
1014
  expectedOrigin: () => webKeyAuthority.deps.expectedOrigin(),
1006
1015
  integrityStatus: () => ({ ...(webControlIntegrity?.status() ?? { enabled: false }), signer: webKeySignerIntegrity?.status() ?? null }),
1016
+ // SR-04: an authenticated status read may start one bounded re-check (lib/web-key-signer-tls.ts statusRead).
1017
+ signerTls: () => webKeySignerTls?.statusRead() ?? null,
1007
1018
  // Phase 3: the phone's key steps and the handoff claim answer only on the 3144 signer listener.
1008
1019
  isSignerRequest: req => req.socket?.localPort === WEB_KEY_SIGNER_PORT,
1009
1020
  handoffs: webKeySignHandoffs,
@@ -1600,6 +1611,7 @@ async function gracefulShutdown(): Promise<void> {
1600
1611
  stopMorningBriefScheduler()
1601
1612
  stopRoutineScheduler()
1602
1613
  tailscaleNudger.stop()
1614
+ webKeySignerTls?.stop()
1603
1615
  await workReviewRuntime?.close()
1604
1616
  dropP2.stop()
1605
1617
  workIntakeStore?.close()
@@ -1742,16 +1754,27 @@ if (!httpsAvailable) console.log('[COS API] No certs found — HTTPS disabled (d
1742
1754
  for (const entry of listenerPlan({ httpsAvailable, webKeyAuthority: webKeyAuthorityEnabled(), httpPort: PORT, httpsPort: HTTPS_PORT, signerPort: WEB_KEY_SIGNER_PORT })) {
1743
1755
  // Phase 3: 3144 serves the signer page (verified bytes only) and an allowlist of /api routes,
1744
1756
  // never the big app (lib/web-key-signer-app.ts). It exists only with the flag on.
1745
- const handler = entry.label === 'HTTPS signer'
1746
- ? createWebKeySignerHandler({ integrity: webKeySignerIntegrity, api: app, origin: () => webKeySignerOrigin(webKeySelfDnsName()) })
1757
+ const signer = entry.label === 'HTTPS signer'
1758
+ const handler = signer
1759
+ ? createWebKeySignerHandler({ integrity: webKeySignerIntegrity, api: app, origin: () => webKeySignerOrigin(webKeySelfDnsName()), refreshOrigin: () => tailnetSelf.refreshIfMissing() })
1747
1760
  : app
1748
- const server = entry.tls
1749
- ? withRequestTimeout(createHttpsServer({ cert: readFileSync(path.join(certDir, 'cert.pem')), key: readFileSync(path.join(certDir, 'key.pem')) }, handler))
1761
+ // B2: on 3144 only, the trusted Tailscale pair for exactly this Mac's tailnet name (SNI), mkcert for anything else.
1762
+ if (signer) {
1763
+ webKeySignerTls = createSignerTlsForBoot({ authorityEnabled: webKeyAuthorityEnabled(), signerListener: true }, {
1764
+ dir: dataPath(SIGNER_TLS_DIR),
1765
+ selfDnsName: () => tailnetSelf.refresh(),
1766
+ })
1767
+ }
1768
+ const mkcertPair = entry.tls ? { cert: readFileSync(path.join(certDir, 'cert.pem')), key: readFileSync(path.join(certDir, 'key.pem')) } : null
1769
+ const server = mkcertPair
1770
+ ? withRequestTimeout(createHttpsServer(signer ? signerHttpsOptions(mkcertPair, webKeySignerTls) : mkcertPair, handler))
1750
1771
  : withRequestTimeout(createHttpServer(app))
1751
1772
  listeners.push({ server, port: entry.port, host: BIND_HOST, label: entry.label })
1752
1773
  }
1753
1774
 
1754
1775
  listenRequiredServers(listeners).then(() => {
1776
+ // B2: the boot check of the 3144 certificate, then daily (null unless the signer listener exists).
1777
+ webKeySignerTls?.start()
1755
1778
  const serverInstanceId = initializeServerInstanceId()
1756
1779
  const startupAdmissionsOpen = maintenanceAdmissionsOpen()
1757
1780
  if (listeners.some(listener => listener.label === 'HTTPS')) {
@@ -1,8 +1,9 @@
1
1
  // This Mac's own Tailscale MagicDNS name, for COS Control Web's origin checks.
2
2
  //
3
3
  // WHY NOT THE TAILSCALE CLI. Under the server's LaunchAgent the `tailscale status --json` call
4
- // the nudger makes does not answer (server.log: "trigger=boot dropped: Tailscale did not answer
5
- // for 120s"), so the nudger never learns Self.DNSName and a phone at
4
+ // the nudger makes fails: node passes its child no SHLVL, and the macOS CLI then answers "The
5
+ // Tailscale GUI failed to start" instead of the JSON (probe 2026-11-10; server.log's "Tailscale did
6
+ // not answer for 120s" is the event's age, not a hang). So the nudger never learns Self.DNSName and a phone at
6
7
  // http://<mac>.<tailnet>.ts.net:3141/control was refused by CORS on every write (6.99.1).
7
8
  // MagicDNS itself answers a reverse lookup of this Mac's own 100.x address at 100.100.100.100
8
9
  // in about a millisecond, with no CLI involved. The nudger's answer still wins when it has one.
@@ -10,6 +11,13 @@
10
11
  // EXACT NAME ONLY. The answer is normalised by normalizeTailscaleDnsName (lowercase, one
11
12
  // trailing dot dropped, must end in .ts.net) and compared for equality; never a suffix match.
12
13
  // No answer means no name: the tailnet NAME is refused and the tailnet IP URL keeps working.
14
+ //
15
+ // A MISS IS NOT FINAL (7.0.14). The boot lookup can miss (a startup race), and readers that only
16
+ // call currentSelfDnsName() then saw null forever: /api/web-key/status answered `origin: null` (COS
17
+ // Control hid Phone approvals) and the 3144 signer refused every phone write `origin_refused`, until
18
+ // some other route happened to call refresh(). refreshIfMissing() is for exactly those readers: with
19
+ // a name known it returns it and looks nothing up; with none it joins the lookup in flight or starts
20
+ // ONE, at most once per TAILNET_NAME_ON_DEMAND_MIN_MS, and waits at most a bounded time.
13
21
 
14
22
  import { Resolver } from 'node:dns/promises'
15
23
  import { networkInterfaces } from 'node:os'
@@ -18,6 +26,10 @@ import { normalizeTailscaleDnsName, reachableIpv4Addresses } from './network-pol
18
26
  export const TAILNET_NAME_HIT_TTL_MS = 10 * 60_000
19
27
  export const TAILNET_NAME_MISS_TTL_MS = 60_000
20
28
  export const TAILNET_REVERSE_TIMEOUT_MS = 1_500
29
+ /** refreshIfMissing(): while the name is unknown, at most one lookup it starts per this window. */
30
+ export const TAILNET_NAME_ON_DEMAND_MIN_MS = 30_000
31
+ /** refreshIfMissing(): the longest a request waits for that lookup (the lookup itself is bounded by the resolver timeout). */
32
+ export const TAILNET_NAME_ON_DEMAND_WAIT_MS = 2_000
21
33
  const MAGIC_DNS_RESOLVER = '100.100.100.100'
22
34
 
23
35
  export interface TailnetSelfNameDeps {
@@ -37,6 +49,13 @@ export interface TailnetSelfName {
37
49
  refresh(): Promise<string | null>
38
50
  /** `SelfDnsSource` for isAllowedCorsOrigin: same as refresh(). */
39
51
  queryStatus(): Promise<string | null>
52
+ /**
53
+ * For a request that must not act on a stale miss (7.0.14). A name known now: that name, no lookup.
54
+ * None: joins the lookup in flight, or starts one unless this method started one in the last
55
+ * TAILNET_NAME_ON_DEMAND_MIN_MS (then null at once). Waits at most `waitMs`; a slower lookup keeps
56
+ * running and caches its answer for the next reader. Never throws.
57
+ */
58
+ refreshIfMissing(waitMs?: number): Promise<string | null>
40
59
  }
41
60
 
42
61
  function defaultTailscaleIpv4(): string | null {
@@ -62,13 +81,8 @@ export function createTailnetSelfName(deps: TailnetSelfNameDeps = {}): TailnetSe
62
81
 
63
82
  const currentSelfDnsName = (): string | null => fromNudger() ?? cached?.name ?? null
64
83
 
65
- const refresh = (): Promise<string | null> => {
66
- const known = fromNudger()
67
- if (known) return Promise.resolve(known)
68
- const at = now()
69
- if (cached && at - cached.at < (cached.name ? TAILNET_NAME_HIT_TTL_MS : TAILNET_NAME_MISS_TTL_MS)) {
70
- return Promise.resolve(cached.name)
71
- }
84
+ // The one lookup (single-flight): every caller below shares the promise in flight.
85
+ const lookup = (): Promise<string | null> => {
72
86
  if (inflight) return inflight
73
87
  inflight = (async () => {
74
88
  let name: string | null = null
@@ -88,7 +102,40 @@ export function createTailnetSelfName(deps: TailnetSelfNameDeps = {}): TailnetSe
88
102
  return inflight
89
103
  }
90
104
 
91
- return { currentSelfDnsName, refresh, queryStatus: refresh }
105
+ const refresh = (): Promise<string | null> => {
106
+ const known = fromNudger()
107
+ if (known) return Promise.resolve(known)
108
+ const at = now()
109
+ if (cached && at - cached.at < (cached.name ? TAILNET_NAME_HIT_TTL_MS : TAILNET_NAME_MISS_TTL_MS)) {
110
+ return Promise.resolve(cached.name)
111
+ }
112
+ return lookup()
113
+ }
114
+
115
+ // When refreshIfMissing() last STARTED a lookup. Its own clock: the boot lookup and refresh()'s TTL
116
+ // do not count, so the first reader after a boot miss looks again at once.
117
+ let onDemandAt: number | null = null
118
+ const refreshIfMissing = (waitMs: number = TAILNET_NAME_ON_DEMAND_WAIT_MS): Promise<string | null> => {
119
+ const known = currentSelfDnsName()
120
+ if (known) return Promise.resolve(known)
121
+ let pending: Promise<string | null>
122
+ if (inflight) {
123
+ pending = inflight
124
+ } else {
125
+ const at = now()
126
+ if (onDemandAt !== null && at - onDemandAt < TAILNET_NAME_ON_DEMAND_MIN_MS) return Promise.resolve(null)
127
+ onDemandAt = at
128
+ pending = lookup()
129
+ }
130
+ const wait = Math.max(0, waitMs)
131
+ return new Promise<string | null>(resolve => {
132
+ const timer = setTimeout(() => resolve(null), wait)
133
+ timer.unref?.()
134
+ pending.then(name => { clearTimeout(timer); resolve(name) }, () => { clearTimeout(timer); resolve(null) })
135
+ })
136
+ }
137
+
138
+ return { currentSelfDnsName, refresh, queryStatus: refresh, refreshIfMissing }
92
139
  }
93
140
 
94
141
  let singleton: TailnetSelfName | null = null
@@ -92,6 +92,13 @@ export interface WebKeySignerDeps {
92
92
  api: (req: IncomingMessage, res: ServerResponse) => void
93
93
  /** `https://<MagicDNS name>:3144` (lib/web-key-origin.ts), or null when unknown. */
94
94
  origin: () => string | null
95
+ /**
96
+ * 7.0.14: called (and awaited) only when origin() is null on an /api request, before the origin
97
+ * check, so a boot-time miss of the tailnet name does not refuse the phone `origin_refused` until
98
+ * something else refreshes it (lib/tailnet-self-name.ts refreshIfMissing: bounded, single-flight,
99
+ * rate-limited). Absent: the null origin stands, as before.
100
+ */
101
+ refreshOrigin?: () => Promise<unknown>
95
102
  /** How often the signer's files are re-checked. 0: never on a timer (tests call integrity.check()). */
96
103
  recheckMs?: number
97
104
  }
@@ -128,13 +135,25 @@ export function createWebKeySignerHandler(deps: WebKeySignerDeps): ((req: Incomi
128
135
  const method = String(req.method ?? 'GET').toUpperCase()
129
136
 
130
137
  if (pathname === '/api' || pathname.startsWith('/api/')) {
138
+ const decide = (expected: string | null): void => {
139
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined
140
+ // Another origin's page never drives this one. A write must name this origin outright.
141
+ if (origin !== undefined && origin !== expected) { json(res, 403, { ok: false, error: 'origin_refused' }); return }
142
+ if (method !== 'GET' && method !== 'HEAD' && (expected === null || origin !== expected)) { json(res, 403, { ok: false, error: 'origin_refused' }); return }
143
+ if (!signerApiAllowed(method === 'HEAD' ? 'GET' : method, pathname)) { json(res, 404, { ok: false, error: 'not_found' }); return }
144
+ deps.api(req, res)
145
+ }
131
146
  const expected = deps.origin()
132
- const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined
133
- // Another origin's page never drives this one. A write must name this origin outright.
134
- if (origin !== undefined && origin !== expected) { json(res, 403, { ok: false, error: 'origin_refused' }); return }
135
- if (method !== 'GET' && method !== 'HEAD' && (expected === null || origin !== expected)) { json(res, 403, { ok: false, error: 'origin_refused' }); return }
136
- if (!signerApiAllowed(method === 'HEAD' ? 'GET' : method, pathname)) { json(res, 404, { ok: false, error: 'not_found' }); return }
137
- deps.api(req, res)
147
+ if (expected !== null || !deps.refreshOrigin) { decide(expected); return }
148
+ // Name unknown: one bounded lookup, then the same checks against whatever it found (null still refuses).
149
+ let refreshing: Promise<unknown>
150
+ try { refreshing = Promise.resolve(deps.refreshOrigin()) } catch { refreshing = Promise.resolve() }
151
+ const after = (): void => {
152
+ let found: string | null = null
153
+ try { found = deps.origin() } catch { found = null }
154
+ decide(found)
155
+ }
156
+ void refreshing.then(after, after)
138
157
  return
139
158
  }
140
159