@gmickel/gno 1.39.2 → 1.40.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.
@@ -54,6 +54,10 @@ import {
54
54
  type QueryModeType,
55
55
  type TagMode,
56
56
  } from "../lib/retrieval-filters";
57
+ import {
58
+ fetchServerCapabilities,
59
+ type ServerCapabilities,
60
+ } from "../lib/server-capabilities";
57
61
  import { cn } from "../lib/utils";
58
62
 
59
63
  interface PageProps {
@@ -104,13 +108,6 @@ interface AskResponse {
104
108
  verification?: AskVerification;
105
109
  }
106
110
 
107
- interface Capabilities {
108
- bm25: boolean;
109
- vector: boolean;
110
- hybrid: boolean;
111
- answer: boolean;
112
- }
113
-
114
111
  interface ConversationEntry {
115
112
  id: string;
116
113
  query: string;
@@ -195,7 +192,9 @@ function renderAnswer(
195
192
  export default function Ask({ navigate }: PageProps) {
196
193
  const [query, setQuery] = useState("");
197
194
  const [conversation, setConversation] = useState<ConversationEntry[]>([]);
198
- const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
195
+ const [capabilities, setCapabilities] = useState<ServerCapabilities | null>(
196
+ null
197
+ );
199
198
  const [collections, setCollections] = useState<Collection[]>([]);
200
199
  const [thoroughness, setThoroughness] = useState<Thoroughness>("balanced");
201
200
  const [activePreset, setActivePreset] = useState("slim-tuned");
@@ -233,7 +232,7 @@ export default function Ask({ navigate }: PageProps) {
233
232
  useEffect(() => {
234
233
  async function bootstrap(): Promise<void> {
235
234
  const [capsResult, collectionsResult, presetsResult] = await Promise.all([
236
- apiFetch<Capabilities>("/api/capabilities"),
235
+ fetchServerCapabilities(),
237
236
  apiFetch<Collection[]>("/api/collections"),
238
237
  apiFetch<PresetsResponse>("/api/presets"),
239
238
  ]);
@@ -94,6 +94,10 @@ import {
94
94
  stripSectionTargetLinkParam,
95
95
  type SectionLinkNoticeKind,
96
96
  } from "../lib/section-links";
97
+ import {
98
+ fetchServerCapabilities,
99
+ type ServerCapabilities,
100
+ } from "../lib/server-capabilities";
97
101
  import { subscribeWorkspaceActionRequest } from "../lib/workspace-events";
98
102
  import {
99
103
  FrontmatterDisplay,
@@ -361,6 +365,11 @@ function getParentPath(relPath: string): string {
361
365
 
362
366
  export default function DocView({ navigate }: PageProps) {
363
367
  const [doc, setDoc] = useState<DocData | null>(null);
368
+ // undefined = not answered yet (offer neither locality-dependent action),
369
+ // null = fetch failed (fail closed: treat the client as remote).
370
+ const [serverCapabilities, setServerCapabilities] = useState<
371
+ ServerCapabilities | null | undefined
372
+ >(undefined);
364
373
  const [error, setError] = useState<string | null>(null);
365
374
  const [loading, setLoading] = useState(true);
366
375
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -483,6 +492,20 @@ export default function DocView({ navigate }: PageProps) {
483
492
  });
484
493
  }, [currentUri]);
485
494
 
495
+ // Server capabilities decide which host-local actions may render. A failed
496
+ // fetch leaves them null, which the view reads as a remote client.
497
+ useEffect(() => {
498
+ let cancelled = false;
499
+ void fetchServerCapabilities().then(({ data }) => {
500
+ if (!cancelled) {
501
+ setServerCapabilities(data);
502
+ }
503
+ });
504
+ return () => {
505
+ cancelled = true;
506
+ };
507
+ }, []);
508
+
486
509
  // Fetch document when URI changes
487
510
  useEffect(() => {
488
511
  loadDocument();
@@ -542,6 +565,11 @@ export default function DocView({ navigate }: PageProps) {
542
565
 
543
566
  const isPdf = Boolean(doc && isPdfDocument(doc.source));
544
567
 
568
+ const localClient = serverCapabilities?.localClient ?? false;
569
+ // Until /api/capabilities answers, a same-host user would otherwise see the
570
+ // remote "Open original" flash in and then swap to Reveal.
571
+ const localityKnown = serverCapabilities !== undefined;
572
+
545
573
  // Spec predicate — evaluated per render, never from mime/ext.
546
574
  const extractedTextAvailable = Boolean(doc && isExtractedTextAvailable(doc));
547
575
 
@@ -552,6 +580,16 @@ export default function DocView({ navigate }: PageProps) {
552
580
  return buildDocAssetUrl(doc.uri, doc.relPath);
553
581
  }, [doc, isPdf]);
554
582
 
583
+ // Remote "Open original" target for any document that has a source file:
584
+ // /api/doc-asset serves any collection file inline, so this keeps the
585
+ // previous file:// scope (every read-only source) for remote clients.
586
+ const sourceAssetUrl = useMemo(() => {
587
+ if (!doc?.source.absPath) {
588
+ return null;
589
+ }
590
+ return buildDocAssetUrl(doc.uri, doc.relPath);
591
+ }, [doc]);
592
+
555
593
  // Parse frontmatter for markdown files
556
594
  const parsedContent = useMemo(() => {
557
595
  if (!doc?.content || !isMarkdown) {
@@ -1690,10 +1728,10 @@ export default function DocView({ navigate }: PageProps) {
1690
1728
  );
1691
1729
 
1692
1730
  return (
1693
- <div className="min-h-screen">
1731
+ <div className="min-h-screen min-w-0 overflow-x-clip">
1694
1732
  {/* Header */}
1695
1733
  <header className="glass sticky top-0 z-10 border-border/50 border-b">
1696
- <div className="flex items-center gap-4 px-8 py-4">
1734
+ <div className="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2 px-4 py-4 sm:px-8">
1697
1735
  {/* Home button - Scholarly Dusk brass accent */}
1698
1736
  <Button
1699
1737
  aria-label="Go to dashboard"
@@ -1730,8 +1768,14 @@ export default function DocView({ navigate }: PageProps) {
1730
1768
  )}
1731
1769
  {doc && (
1732
1770
  <>
1733
- <Separator className="h-6" orientation="vertical" />
1734
- <div className="flex items-center gap-2">
1771
+ <Separator
1772
+ className="hidden h-6 sm:block"
1773
+ orientation="vertical"
1774
+ />
1775
+ <div
1776
+ className="flex min-w-0 flex-wrap items-center gap-2"
1777
+ data-testid="doc-header-actions"
1778
+ >
1735
1779
  {doc.capabilities.editable ? (
1736
1780
  <>
1737
1781
  <Button className="gap-1.5" onClick={handleEdit} size="sm">
@@ -1817,10 +1861,11 @@ export default function DocView({ navigate }: PageProps) {
1817
1861
  )}
1818
1862
  Export for gno.sh
1819
1863
  </Button>
1820
- {doc.source.absPath && (
1864
+ {localClient && doc.source.absPath && (
1821
1865
  <>
1822
1866
  <Button
1823
1867
  className="gap-1.5"
1868
+ data-testid="doc-reveal"
1824
1869
  onClick={() => {
1825
1870
  void handleReveal();
1826
1871
  }}
@@ -1832,6 +1877,7 @@ export default function DocView({ navigate }: PageProps) {
1832
1877
  </Button>
1833
1878
  <Button asChild size="sm" variant="outline">
1834
1879
  <a
1880
+ data-testid="doc-open-original"
1835
1881
  href={`file://${doc.source.absPath}`}
1836
1882
  rel="noopener noreferrer"
1837
1883
  target="_blank"
@@ -1842,21 +1888,37 @@ export default function DocView({ navigate }: PageProps) {
1842
1888
  </Button>
1843
1889
  </>
1844
1890
  )}
1891
+ {localityKnown && !localClient && sourceAssetUrl && (
1892
+ <Button asChild size="sm" variant="outline">
1893
+ <a
1894
+ data-testid="doc-open-original"
1895
+ href={sourceAssetUrl}
1896
+ rel="noopener"
1897
+ target="_blank"
1898
+ >
1899
+ <SquareArrowOutUpRightIcon className="mr-1.5 size-4" />
1900
+ Open original
1901
+ </a>
1902
+ </Button>
1903
+ )}
1845
1904
  </>
1846
1905
  )}
1847
- {doc.capabilities.editable && doc.source.absPath && (
1848
- <Button
1849
- className="gap-1.5"
1850
- onClick={() => {
1851
- void handleReveal();
1852
- }}
1853
- size="sm"
1854
- variant="outline"
1855
- >
1856
- <FolderOpen className="size-4" />
1857
- Reveal
1858
- </Button>
1859
- )}
1906
+ {localClient &&
1907
+ doc.capabilities.editable &&
1908
+ doc.source.absPath && (
1909
+ <Button
1910
+ className="gap-1.5"
1911
+ data-testid="doc-reveal"
1912
+ onClick={() => {
1913
+ void handleReveal();
1914
+ }}
1915
+ size="sm"
1916
+ variant="outline"
1917
+ >
1918
+ <FolderOpen className="size-4" />
1919
+ Reveal
1920
+ </Button>
1921
+ )}
1860
1922
  {isPdf && pdfAssetUrl ? (
1861
1923
  <Button
1862
1924
  asChild
@@ -44,6 +44,10 @@ import {
44
44
  type QueryModeType,
45
45
  type TagMode,
46
46
  } from "../lib/retrieval-filters";
47
+ import {
48
+ fetchServerCapabilities,
49
+ type ServerCapabilities,
50
+ } from "../lib/server-capabilities";
47
51
  import { cn } from "../lib/utils";
48
52
  import { AIModelSelector, TagFacets } from "./search-page-widgets";
49
53
 
@@ -121,13 +125,6 @@ interface SearchResponse {
121
125
  };
122
126
  }
123
127
 
124
- interface Capabilities {
125
- bm25: boolean;
126
- vector: boolean;
127
- hybrid: boolean;
128
- answer: boolean;
129
- }
130
-
131
128
  interface Collection {
132
129
  name: string;
133
130
  }
@@ -157,7 +154,9 @@ export default function Search({ navigate }: PageProps) {
157
154
  const [loading, setLoading] = useState(false);
158
155
  const [error, setError] = useState<string | null>(null);
159
156
  const [searched, setSearched] = useState(false);
160
- const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
157
+ const [capabilities, setCapabilities] = useState<ServerCapabilities | null>(
158
+ null
159
+ );
161
160
  const [collections, setCollections] = useState<Collection[]>([]);
162
161
  const [activePreset, setActivePreset] = useState("slim-tuned");
163
162
 
@@ -257,7 +256,7 @@ export default function Search({ navigate }: PageProps) {
257
256
  async function bootstrap(): Promise<void> {
258
257
  const [capabilitiesResult, collectionsResult, presetsResult] =
259
258
  await Promise.all([
260
- apiFetch<Capabilities>("/api/capabilities"),
259
+ fetchServerCapabilities(),
261
260
  apiFetch<Collection[]>("/api/collections"),
262
261
  apiFetch<PresetsResponse>("/api/presets"),
263
262
  ]);
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Request locality: is the caller a same-host browser?
3
+ *
4
+ * `gno serve` binds to loopback only, so every remote client reaches it through
5
+ * a same-host proxy or forwarder whose socket peer is loopback. A peer-only
6
+ * check would call those clients local; this rule also requires a loopback
7
+ * Host header and the absence of forwarding headers, and it fails closed.
8
+ */
9
+
10
+ import type { HttpMcpPeerServer } from "../mcp/http-security";
11
+
12
+ export type RequestPeerServer = Pick<HttpMcpPeerServer, "requestIP">;
13
+
14
+ const FORWARDING_HEADERS = [
15
+ "forwarded",
16
+ "via",
17
+ "x-forwarded-for",
18
+ "x-forwarded-host",
19
+ "x-forwarded-proto",
20
+ ] as const;
21
+
22
+ const IPV4_MAPPED_PREFIX = "::ffff:";
23
+ const IPV4_OCTET = /^\d{1,3}$/;
24
+ const HOST_PORT = /^:\d{1,5}$/;
25
+ const LOOPBACK_IPV4_FIRST_OCTET = 127;
26
+ const MAX_OCTET = 255;
27
+ const IPV4_OCTET_COUNT = 4;
28
+
29
+ /** Loopback socket address: 127.0.0.0/8, ::1, or an IPv4-mapped form. */
30
+ export function isLoopbackAddress(address: string): boolean {
31
+ const normalized = address.toLowerCase();
32
+ if (normalized === "::1") return true;
33
+ const mapped = normalized.startsWith(IPV4_MAPPED_PREFIX)
34
+ ? normalized.slice(IPV4_MAPPED_PREFIX.length)
35
+ : normalized;
36
+ const octets = mapped.split(".");
37
+ return (
38
+ octets.length === IPV4_OCTET_COUNT &&
39
+ octets.every((octet) => IPV4_OCTET.test(octet)) &&
40
+ Number(octets[0]) === LOOPBACK_IPV4_FIRST_OCTET &&
41
+ octets.every((octet) => Number(octet) <= MAX_OCTET)
42
+ );
43
+ }
44
+
45
+ /** Host part of a Host header (`name`, `name:port`, `[v6]`, `[v6]:port`). */
46
+ function hostHeaderName(host: string): string | null {
47
+ const trimmed = host.trim().toLowerCase();
48
+ if (trimmed.startsWith("[")) {
49
+ const end = trimmed.indexOf("]");
50
+ if (end === -1) return null;
51
+ const rest = trimmed.slice(end + 1);
52
+ if (rest !== "" && !HOST_PORT.test(rest)) return null;
53
+ return trimmed.slice(1, end) || null;
54
+ }
55
+ const [name, ...portParts] = trimmed.split(":");
56
+ if (portParts.length > 1) return null;
57
+ if (portParts.length === 1 && !HOST_PORT.test(`:${portParts[0]}`)) {
58
+ return null;
59
+ }
60
+ return name || null;
61
+ }
62
+
63
+ /** Host header naming a loopback host: `localhost`, `127.x.x.x`, or `[::1]`. */
64
+ export function isLoopbackHostHeader(host: string | null): boolean {
65
+ if (!host) return false;
66
+ const name = hostHeaderName(host);
67
+ if (!name) return false;
68
+ return name === "localhost" || isLoopbackAddress(name);
69
+ }
70
+
71
+ /**
72
+ * True only when the socket peer is loopback, the Host header names a loopback
73
+ * host, and no forwarding header is present. Any other combination, a missing
74
+ * server, or an unknown peer yields false.
75
+ */
76
+ export function isLocalClientRequest(
77
+ request: Request,
78
+ server: RequestPeerServer | undefined
79
+ ): boolean {
80
+ const peer = server?.requestIP(request) ?? null;
81
+ if (!peer || !isLoopbackAddress(peer.address)) return false;
82
+ if (!isLoopbackHostHeader(request.headers.get("host"))) return false;
83
+ return !FORWARDING_HEADERS.some((header) => request.headers.has(header));
84
+ }
@@ -177,6 +177,10 @@ import {
177
177
  } from "../file-refactor-http";
178
178
  import { analyzeImportPath } from "../import-preview";
179
179
  import { getActiveJob, getJobStatus, startJob } from "../jobs";
180
+ import {
181
+ isLocalClientRequest,
182
+ type RequestPeerServer,
183
+ } from "../request-locality";
180
184
  import {
181
185
  requestRetrievalTraceId,
182
186
  withRetrievalTraceHeader,
@@ -2179,6 +2183,53 @@ function parseSingleByteRange(
2179
2183
  return { ok: true, start, end };
2180
2184
  }
2181
2185
 
2186
+ /**
2187
+ * Revalidate-on-every-open: the browser asks once per open and reuses the
2188
+ * cached bytes on 304. Deliberately replaces fn-112's `no-store` (fn-136 R3).
2189
+ */
2190
+ export const DOC_ASSET_CACHE_CONTROL = "private, max-age=0, must-revalidate";
2191
+
2192
+ const WEAK_ETAG_PREFIX = "W/";
2193
+
2194
+ /** Strong, quoted validator from file size and mtime (fn-136 R3). */
2195
+ export function docAssetEtag(file: {
2196
+ size: number;
2197
+ lastModified: number;
2198
+ }): string {
2199
+ return `"${file.size.toString(16)}-${file.lastModified.toString(16)}"`;
2200
+ }
2201
+
2202
+ /**
2203
+ * RFC 9110 §13.1.2 If-None-Match: `*` or any listed validator matching under
2204
+ * weak comparison (a `W/` prefix is ignored on the client's side).
2205
+ *
2206
+ * The list is split on a bare comma. Our validators (`"<size-hex>-<mtime-hex>"`)
2207
+ * never contain one; a foreign tag that does would only produce a false
2208
+ * negative (full body instead of 304), never a false match.
2209
+ */
2210
+ export function etagMatches(
2211
+ ifNoneMatch: string | null | undefined,
2212
+ etag: string
2213
+ ): boolean {
2214
+ if (!ifNoneMatch) {
2215
+ return false;
2216
+ }
2217
+ const trimmed = ifNoneMatch.trim();
2218
+ if (trimmed === "*") {
2219
+ return true;
2220
+ }
2221
+ for (const candidate of trimmed.split(",")) {
2222
+ const value = candidate.trim();
2223
+ const strong = value.startsWith(WEAK_ETAG_PREFIX)
2224
+ ? value.slice(WEAK_ETAG_PREFIX.length)
2225
+ : value;
2226
+ if (strong === etag) {
2227
+ return true;
2228
+ }
2229
+ }
2230
+ return false;
2231
+ }
2232
+
2182
2233
  /**
2183
2234
  * GET|HEAD /api/doc-asset
2184
2235
  * Query params:
@@ -2187,6 +2238,8 @@ function parseSingleByteRange(
2187
2238
  *
2188
2239
  * Supports single-range Range requests (206/416). Multi-range → 416 (I1-03).
2189
2240
  * HEAD mirrors GET status/headers with empty body (I1-02).
2241
+ * Strong ETag from size + mtime; a matching If-None-Match answers 304 before
2242
+ * any Range handling, so full and ranged GETs revalidate alike (fn-136 R3).
2190
2243
  */
2191
2244
  export async function handleDocAsset(
2192
2245
  store: SqliteAdapter,
@@ -2269,13 +2322,24 @@ export async function handleDocAsset(
2269
2322
 
2270
2323
  const isHead = (request?.method ?? "GET").toUpperCase() === "HEAD";
2271
2324
  const filename = resolvedPath.split(/[\\/]/u).at(-1) ?? "document";
2325
+ const etag = docAssetEtag(file);
2272
2326
  const headers = new Headers({
2273
2327
  "Accept-Ranges": "bytes",
2274
- "Cache-Control": "no-store",
2328
+ "Cache-Control": DOC_ASSET_CACHE_CONTROL,
2275
2329
  "Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(filename)}`,
2276
2330
  "Content-Type": file.type || "application/octet-stream",
2331
+ ETag: etag,
2277
2332
  });
2278
2333
 
2334
+ if (etagMatches(request?.headers.get("If-None-Match"), etag)) {
2335
+ // 304 carries the revalidation headers of the 200 set (RFC 9110 §15.4.5)
2336
+ // and nothing that describes a body.
2337
+ const notModified = new Headers(headers);
2338
+ notModified.delete("Content-Disposition");
2339
+ notModified.delete("Content-Type");
2340
+ return new Response(null, { status: 304, headers: notModified });
2341
+ }
2342
+
2279
2343
  const rangeHeader = request?.headers.get("Range");
2280
2344
  if (!rangeHeader) {
2281
2345
  headers.set("Content-Length", String(file.size));
@@ -3114,6 +3178,12 @@ export async function handleCreateFolder(
3114
3178
  }
3115
3179
  }
3116
3180
 
3181
+ /**
3182
+ * POST /api/docs/:id/reveal
3183
+ * Opens the source file in the host's file manager. Only a local client may
3184
+ * open windows on the server host: a request judged remote by the locality
3185
+ * rule is refused before the document is resolved.
3186
+ */
3117
3187
  export async function handleRevealDoc(
3118
3188
  ctxHolder: ContextHolder,
3119
3189
  store: SqliteAdapter,
@@ -3121,8 +3191,17 @@ export async function handleRevealDoc(
3121
3191
  req?: Request,
3122
3192
  deps?: {
3123
3193
  revealFilePath?: typeof revealFilePath;
3194
+ server?: RequestPeerServer;
3124
3195
  }
3125
3196
  ): Promise<Response> {
3197
+ // Fail closed: no request (no peer to judge) is treated as remote.
3198
+ if (!req || !isLocalClientRequest(req, deps?.server)) {
3199
+ return errorResponse(
3200
+ "FORBIDDEN",
3201
+ "Reveal is only available to a local client",
3202
+ 403
3203
+ );
3204
+ }
3126
3205
  const docResult = await resolveDocumentReference(
3127
3206
  store,
3128
3207
  docId,
@@ -4878,14 +4957,20 @@ export async function handleAsk(
4878
4957
 
4879
4958
  /**
4880
4959
  * GET /api/capabilities
4881
- * Returns server capabilities (what features are available).
4960
+ * Returns server capabilities (what features are available) plus whether the
4961
+ * caller is a same-host client (see request-locality).
4882
4962
  */
4883
- export function handleCapabilities(ctx: ServerContext): Response {
4963
+ export function handleCapabilities(
4964
+ ctx: ServerContext,
4965
+ req: Request,
4966
+ server: RequestPeerServer | undefined
4967
+ ): Response {
4884
4968
  return jsonResponse({
4885
4969
  bm25: ctx.capabilities.bm25,
4886
4970
  vector: ctx.capabilities.vector,
4887
4971
  hybrid: ctx.capabilities.hybrid,
4888
4972
  answer: ctx.capabilities.answer,
4973
+ localClient: isLocalClientRequest(req, server),
4889
4974
  });
4890
4975
  }
4891
4976