granola-toolkit 0.23.0 → 0.25.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.
Files changed (3) hide show
  1. package/README.md +27 -0
  2. package/dist/cli.js +589 -66
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -103,9 +103,11 @@ Run the local API server:
103
103
  granola serve
104
104
  granola serve --port 4096
105
105
  granola serve --hostname 0.0.0.0 --port 4096
106
+ granola serve --network lan --password "change-me"
106
107
 
107
108
  granola web
108
109
  granola web --open=false --port 4096
110
+ granola web --network lan --password "change-me" --trusted-origins "https://trusted.example"
109
111
  ```
110
112
 
111
113
  ## How It Works
@@ -193,10 +195,13 @@ The machine-readable `export` command includes:
193
195
  The initial server API includes:
194
196
 
195
197
  - `GET /health`
198
+ - `POST /auth/unlock` for password-protected servers
199
+ - `POST /auth/lock` to clear the browser/API unlock cookie
196
200
  - `GET /auth/status`
197
201
  - `GET /state`
198
202
  - `GET /events` for server-sent state updates
199
203
  - `GET /meetings`
204
+ - `GET /meetings?refresh=true` to bypass the local meeting index and force a live refresh
200
205
  - `GET /meetings/resolve?q=<query>`
201
206
  - `GET /meetings/:id`
202
207
  - `GET /exports/jobs`
@@ -210,6 +215,14 @@ The initial server API includes:
210
215
 
211
216
  This is the foundation for the future `granola web` client and any attachable TUI flows.
212
217
 
218
+ Server hardening now includes:
219
+
220
+ - `local` network mode by default, which binds to `127.0.0.1`
221
+ - `lan` network mode when you explicitly want other devices to connect
222
+ - optional password protection for API routes and the browser client
223
+ - trusted-origin checks for browser requests, with CORS headers only for allowed origins
224
+ - a warning when you expose the server on `lan` without a password
225
+
213
226
  ### Web
214
227
 
215
228
  `web` starts the same local server as `serve`, enables the browser client at `/`, and opens that workspace in your default browser unless you pass `--open=false`.
@@ -217,6 +230,7 @@ This is the foundation for the future `granola web` client and any attachable TU
217
230
  The initial browser client includes:
218
231
 
219
232
  - a searchable meeting list
233
+ - a fast local-index warm start for meeting browsing before live documents finish loading
220
234
  - sort and updated-date filters
221
235
  - quick open by meeting id or title
222
236
  - a focused meeting workspace with notes, transcript, metadata, and raw tabs
@@ -226,6 +240,19 @@ The initial browser client includes:
226
240
  - note and transcript export actions backed by the same local API
227
241
  - a recent export-jobs panel with rerun actions
228
242
  - stronger empty and error states for list/detail failures
243
+ - a server-access panel that can unlock or lock a password-protected local server
244
+
245
+ ### Local Meeting Index
246
+
247
+ Interactive meeting browsing now keeps a local index of meeting summaries and metadata.
248
+
249
+ That index is used to:
250
+
251
+ - make the web meeting list available quickly on startup
252
+ - keep search, sort, and date filtering useful before every live document payload is fetched again
253
+ - refresh itself after successful live loads so the next run starts warm
254
+
255
+ The web client uses the index as a fast path and upgrades to live data automatically when the background refresh completes. The manual Refresh button bypasses the index and forces a live meeting fetch immediately.
229
256
 
230
257
  ### Export Jobs
231
258
 
package/dist/cli.js CHANGED
@@ -1419,6 +1419,20 @@ function compareMeetingDocumentsBySort(left, right, sort) {
1419
1419
  default: return compareMeetingDocuments(left, right);
1420
1420
  }
1421
1421
  }
1422
+ function compareMeetingSummariesByUpdated(left, right) {
1423
+ return compareTimestampsDescending(left.updatedAt, right.updatedAt) || compareTimestampsDescending(left.createdAt, right.createdAt) || compareStrings(left.title || left.id, right.title || right.id) || compareStrings(left.id, right.id);
1424
+ }
1425
+ function compareMeetingSummariesByTitle(left, right) {
1426
+ return compareStrings(left.title || left.id, right.title || right.id) || compareTimestampsDescending(left.updatedAt, right.updatedAt) || compareStrings(left.id, right.id);
1427
+ }
1428
+ function compareMeetingSummariesBySort(left, right, sort) {
1429
+ switch (sort) {
1430
+ case "title-asc": return compareMeetingSummariesByTitle(left, right);
1431
+ case "title-desc": return -compareMeetingSummariesByTitle(left, right);
1432
+ case "updated-asc": return -compareMeetingSummariesByUpdated(left, right);
1433
+ default: return compareMeetingSummariesByUpdated(left, right);
1434
+ }
1435
+ }
1422
1436
  function serialiseNote(note) {
1423
1437
  return {
1424
1438
  content: note.content,
@@ -1482,6 +1496,15 @@ function matchesMeetingSearch(document, search) {
1482
1496
  ...document.tags
1483
1497
  ].some((value) => value.toLowerCase().includes(query));
1484
1498
  }
1499
+ function matchesMeetingSummarySearch(meeting, search) {
1500
+ const query = search.trim().toLowerCase();
1501
+ if (!query) return true;
1502
+ return [
1503
+ meeting.id,
1504
+ meeting.title,
1505
+ ...meeting.tags
1506
+ ].some((value) => value.toLowerCase().includes(query));
1507
+ }
1485
1508
  function parseDateFilter(value, label) {
1486
1509
  const trimmed = value?.trim();
1487
1510
  if (!trimmed) return;
@@ -1499,6 +1522,15 @@ function matchesUpdatedRange(document, updatedFrom, updatedTo) {
1499
1522
  if (to != null && updatedAt > to) return false;
1500
1523
  return true;
1501
1524
  }
1525
+ function matchesMeetingSummaryUpdatedRange(meeting, updatedFrom, updatedTo) {
1526
+ const from = parseDateFilter(updatedFrom, "updatedFrom");
1527
+ const to = parseDateFilter(updatedTo, "updatedTo");
1528
+ const updatedAt = parseTimestamp(meeting.updatedAt || meeting.createdAt);
1529
+ if (updatedAt == null) return from == null && to == null;
1530
+ if (from != null && updatedAt < from) return false;
1531
+ if (to != null && updatedAt > to) return false;
1532
+ return true;
1533
+ }
1502
1534
  function truncate(value, width) {
1503
1535
  if (value.length <= width) return value.padEnd(width);
1504
1536
  return `${value.slice(0, Math.max(0, width - 1))}…`;
@@ -1554,6 +1586,14 @@ function listMeetings(documents, options = {}) {
1554
1586
  const sort = options.sort ?? "updated-desc";
1555
1587
  return documents.filter((document) => options.search ? matchesMeetingSearch(document, options.search) : true).filter((document) => matchesUpdatedRange(document, options.updatedFrom, options.updatedTo)).sort((left, right) => compareMeetingDocumentsBySort(left, right, sort)).slice(0, limit).map((document) => buildMeetingSummary(document, options.cacheData));
1556
1588
  }
1589
+ function filterMeetingSummaries(meetings, options = {}) {
1590
+ const limit = options.limit ?? 20;
1591
+ const sort = options.sort ?? "updated-desc";
1592
+ return meetings.filter((meeting) => options.search ? matchesMeetingSummarySearch(meeting, options.search) : true).filter((meeting) => matchesMeetingSummaryUpdatedRange(meeting, options.updatedFrom, options.updatedTo)).sort((left, right) => compareMeetingSummariesBySort(left, right, sort)).slice(0, limit).map((meeting) => ({
1593
+ ...meeting,
1594
+ tags: [...meeting.tags]
1595
+ }));
1596
+ }
1557
1597
  function resolveMeetingQuery(documents, query) {
1558
1598
  const trimmed = query.trim();
1559
1599
  if (!trimmed) throw new Error("meeting query is required");
@@ -1640,6 +1680,48 @@ function renderMeetingTranscript(document, cacheData, format = "text") {
1640
1680
  return renderTranscriptExport(transcript, format);
1641
1681
  }
1642
1682
  //#endregion
1683
+ //#region src/meeting-index.ts
1684
+ const MEETING_INDEX_VERSION = 1;
1685
+ var FileMeetingIndexStore = class {
1686
+ constructor(filePath = defaultMeetingIndexFilePath()) {
1687
+ this.filePath = filePath;
1688
+ }
1689
+ async readIndex() {
1690
+ try {
1691
+ const parsed = parseJsonString(await readFile(this.filePath, "utf8"));
1692
+ if (!parsed || parsed.version !== MEETING_INDEX_VERSION || !Array.isArray(parsed.meetings)) return [];
1693
+ return parsed.meetings.map((meeting) => ({
1694
+ ...meeting,
1695
+ tags: [...meeting.tags]
1696
+ }));
1697
+ } catch {
1698
+ return [];
1699
+ }
1700
+ }
1701
+ async writeIndex(meetings) {
1702
+ await mkdir(dirname(this.filePath), { recursive: true });
1703
+ const payload = {
1704
+ meetings: meetings.map((meeting) => ({
1705
+ ...meeting,
1706
+ tags: [...meeting.tags]
1707
+ })),
1708
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1709
+ version: MEETING_INDEX_VERSION
1710
+ };
1711
+ await writeFile(this.filePath, `${JSON.stringify(payload, null, 2)}\n`, {
1712
+ encoding: "utf8",
1713
+ mode: 384
1714
+ });
1715
+ }
1716
+ };
1717
+ function defaultMeetingIndexFilePath() {
1718
+ const home = homedir();
1719
+ return platform() === "darwin" ? join(home, "Library", "Application Support", "granola-toolkit", "meeting-index.json") : join(home, ".config", "granola-toolkit", "meeting-index.json");
1720
+ }
1721
+ function createDefaultMeetingIndexStore() {
1722
+ return new FileMeetingIndexStore();
1723
+ }
1724
+ //#endregion
1643
1725
  //#region src/app/core.ts
1644
1726
  function transcriptCount(cacheData) {
1645
1727
  return Object.values(cacheData.transcripts).filter((segments) => segments.length > 0).length;
@@ -1665,6 +1747,7 @@ function cloneState(state) {
1665
1747
  notes: cloneExportState(state.exports.notes),
1666
1748
  transcripts: cloneExportState(state.exports.transcripts)
1667
1749
  },
1750
+ index: { ...state.index },
1668
1751
  ui: { ...state.ui }
1669
1752
  };
1670
1753
  }
@@ -1688,6 +1771,12 @@ function defaultState(config, auth, surface) {
1688
1771
  loaded: false
1689
1772
  },
1690
1773
  exports: { jobs: [] },
1774
+ index: {
1775
+ available: false,
1776
+ filePath: defaultMeetingIndexFilePath(),
1777
+ loaded: false,
1778
+ meetingCount: 0
1779
+ },
1691
1780
  ui: {
1692
1781
  surface,
1693
1782
  view: "idle"
@@ -1699,13 +1788,26 @@ var GranolaApp = class {
1699
1788
  #cacheResolved = false;
1700
1789
  #granolaClient;
1701
1790
  #documents;
1791
+ #meetingIndex;
1702
1792
  #listeners = /* @__PURE__ */ new Set();
1793
+ #refreshingMeetingIndex;
1703
1794
  #state;
1704
1795
  constructor(config, deps, options = {}) {
1705
1796
  this.config = config;
1706
1797
  this.deps = deps;
1707
1798
  this.#state = defaultState(config, deps.auth, options.surface ?? "cli");
1708
1799
  this.#state.exports.jobs = (deps.exportJobs ?? []).map((job) => cloneExportJob(job));
1800
+ this.#meetingIndex = (deps.meetingIndex ?? []).map((meeting) => ({
1801
+ ...meeting,
1802
+ tags: [...meeting.tags]
1803
+ }));
1804
+ this.#state.index = {
1805
+ available: this.#meetingIndex.length > 0,
1806
+ filePath: defaultMeetingIndexFilePath(),
1807
+ loaded: this.#meetingIndex.length > 0,
1808
+ loadedAt: this.#meetingIndex.length > 0 ? this.nowIso() : void 0,
1809
+ meetingCount: this.#meetingIndex.length
1810
+ };
1709
1811
  }
1710
1812
  getState() {
1711
1813
  return cloneState(this.#state);
@@ -1742,6 +1844,41 @@ var GranolaApp = class {
1742
1844
  this.emitStateUpdate();
1743
1845
  return { ...auth };
1744
1846
  }
1847
+ async persistMeetingIndex(meetings) {
1848
+ this.#meetingIndex = meetings.map((meeting) => ({
1849
+ ...meeting,
1850
+ tags: [...meeting.tags]
1851
+ }));
1852
+ this.#state.index = {
1853
+ available: this.#meetingIndex.length > 0,
1854
+ filePath: this.#state.index.filePath,
1855
+ loaded: this.#meetingIndex.length > 0,
1856
+ loadedAt: this.#meetingIndex.length > 0 ? this.nowIso() : void 0,
1857
+ meetingCount: this.#meetingIndex.length
1858
+ };
1859
+ if (this.deps.meetingIndexStore) await this.deps.meetingIndexStore.writeIndex(this.#meetingIndex);
1860
+ this.emitStateUpdate();
1861
+ }
1862
+ async refreshMeetingIndexFromLiveData() {
1863
+ const cacheData = await this.loadCache();
1864
+ const documents = await this.listDocuments();
1865
+ const meetings = listMeetings(documents, {
1866
+ cacheData,
1867
+ limit: documents.length || 1,
1868
+ sort: "updated-desc"
1869
+ });
1870
+ await this.persistMeetingIndex(meetings);
1871
+ }
1872
+ triggerMeetingIndexRefresh() {
1873
+ if (this.#refreshingMeetingIndex) return;
1874
+ this.#refreshingMeetingIndex = (async () => {
1875
+ try {
1876
+ await this.refreshMeetingIndexFromLiveData();
1877
+ } catch {} finally {
1878
+ this.#refreshingMeetingIndex = void 0;
1879
+ }
1880
+ })();
1881
+ }
1745
1882
  nowIso() {
1746
1883
  return (this.deps.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
1747
1884
  }
@@ -1875,7 +2012,16 @@ var GranolaApp = class {
1875
2012
  throw error;
1876
2013
  }
1877
2014
  }
1878
- async listDocuments() {
2015
+ async listDocuments(options = {}) {
2016
+ if (options.forceRefresh) {
2017
+ this.#granolaClient = void 0;
2018
+ this.#documents = void 0;
2019
+ this.#state.documents = {
2020
+ count: 0,
2021
+ loaded: false
2022
+ };
2023
+ this.emitStateUpdate();
2024
+ }
1879
2025
  if (this.#documents) return this.#documents;
1880
2026
  const documents = await (await this.getGranolaClient()).listDocuments({ timeoutMs: this.config.notes.timeoutMs });
1881
2027
  this.#documents = documents;
@@ -1915,15 +2061,41 @@ var GranolaApp = class {
1915
2061
  return cacheData;
1916
2062
  }
1917
2063
  async listMeetings(options = {}) {
1918
- const meetings = listMeetings(await this.listDocuments(), {
1919
- cacheData: await this.loadCache(),
2064
+ const preferIndex = options.preferIndex ?? (this.#state.ui.surface === "web" || this.#state.ui.surface === "server");
2065
+ if (!options.forceRefresh && preferIndex && !this.#documents && this.#meetingIndex.length > 0) {
2066
+ const meetings = filterMeetingSummaries(this.#meetingIndex, options);
2067
+ this.setUiState({
2068
+ meetingListSource: "index",
2069
+ meetingSearch: options.search,
2070
+ meetingSort: options.sort,
2071
+ meetingUpdatedFrom: options.updatedFrom,
2072
+ meetingUpdatedTo: options.updatedTo,
2073
+ selectedMeetingId: void 0,
2074
+ view: "meeting-list"
2075
+ });
2076
+ this.triggerMeetingIndexRefresh();
2077
+ return {
2078
+ meetings,
2079
+ source: "index"
2080
+ };
2081
+ }
2082
+ const cacheData = await this.loadCache();
2083
+ const documents = await this.listDocuments({ forceRefresh: options.forceRefresh });
2084
+ const meetings = listMeetings(documents, {
2085
+ cacheData,
1920
2086
  limit: options.limit,
1921
2087
  search: options.search,
1922
2088
  sort: options.sort,
1923
2089
  updatedFrom: options.updatedFrom,
1924
2090
  updatedTo: options.updatedTo
1925
2091
  });
2092
+ await this.persistMeetingIndex(listMeetings(documents, {
2093
+ cacheData,
2094
+ limit: Math.max(documents.length, 1),
2095
+ sort: "updated-desc"
2096
+ }));
1926
2097
  this.setUiState({
2098
+ meetingListSource: "live",
1927
2099
  meetingSearch: options.search,
1928
2100
  meetingSort: options.sort,
1929
2101
  meetingUpdatedFrom: options.updatedFrom,
@@ -1931,7 +2103,10 @@ var GranolaApp = class {
1931
2103
  selectedMeetingId: void 0,
1932
2104
  view: "meeting-list"
1933
2105
  });
1934
- return meetings;
2106
+ return {
2107
+ meetings,
2108
+ source: "live"
2109
+ };
1935
2110
  }
1936
2111
  async getMeeting(id, options = {}) {
1937
2112
  const documents = await this.listDocuments();
@@ -2076,13 +2251,17 @@ async function createGranolaApp(config, options = {}) {
2076
2251
  const auth = await inspectDefaultGranolaAuth(config);
2077
2252
  const authController = createDefaultGranolaAuthController(config);
2078
2253
  const exportJobStore = createDefaultExportJobStore();
2254
+ const exportJobs = await exportJobStore.readJobs();
2255
+ const meetingIndexStore = createDefaultMeetingIndexStore();
2079
2256
  return new GranolaApp(config, {
2080
2257
  auth,
2081
2258
  authController,
2082
2259
  cacheLoader: loadOptionalGranolaCache,
2083
2260
  createGranolaClient: async (mode) => await createDefaultGranolaRuntime(config, options.logger, { preferredMode: mode }),
2084
- exportJobs: await exportJobStore.readJobs(),
2261
+ exportJobs,
2085
2262
  exportJobStore,
2263
+ meetingIndex: await meetingIndexStore.readIndex(),
2264
+ meetingIndexStore,
2086
2265
  now: options.now
2087
2266
  }, { surface: options.surface });
2088
2267
  }
@@ -2176,6 +2355,22 @@ function parsePort(value) {
2176
2355
  function pickHostname(value, fallback = "127.0.0.1") {
2177
2356
  return typeof value === "string" && value.trim() ? value.trim() : fallback;
2178
2357
  }
2358
+ function parseNetworkMode(value, fallback = "local") {
2359
+ switch (value) {
2360
+ case void 0: return fallback;
2361
+ case "lan":
2362
+ case "local": return value;
2363
+ default: throw new Error("invalid network mode: expected local or lan");
2364
+ }
2365
+ }
2366
+ function resolveServerHostname(networkMode, hostnameFlag) {
2367
+ if (hostnameFlag !== void 0) return pickHostname(hostnameFlag, networkMode === "lan" ? "0.0.0.0" : "127.0.0.1");
2368
+ return networkMode === "lan" ? "0.0.0.0" : "127.0.0.1";
2369
+ }
2370
+ function parseTrustedOrigins(value) {
2371
+ if (typeof value !== "string" || !value.trim()) return [];
2372
+ return value.split(",").map((origin) => origin.trim()).filter(Boolean);
2373
+ }
2179
2374
  async function waitForShutdown(close) {
2180
2375
  await new Promise((resolve, reject) => {
2181
2376
  let closing = false;
@@ -2527,12 +2722,13 @@ async function list(commandFlags, globalFlags) {
2527
2722
  debug(config.debug, "timeoutMs", config.notes.timeoutMs);
2528
2723
  const app = await createGranolaApp(config);
2529
2724
  debug(config.debug, "authMode", app.getState().auth.mode);
2530
- console.log("Fetching meetings from Granola API...");
2531
- const meetings = await app.listMeetings({
2725
+ console.log("Loading meetings...");
2726
+ const result = await app.listMeetings({
2532
2727
  limit,
2533
2728
  search
2534
2729
  });
2535
- console.log(renderMeetingList(meetings, format).trimEnd());
2730
+ console.log(result.source === "index" ? "Loaded meetings from the local index" : "Fetched meetings from Granola API");
2731
+ console.log(renderMeetingList(result.meetings, format).trimEnd());
2536
2732
  return 0;
2537
2733
  }
2538
2734
  async function view(id, commandFlags, globalFlags) {
@@ -2665,6 +2861,8 @@ function resolveNoteFormat(value) {
2665
2861
  //#endregion
2666
2862
  //#region src/web/client-script.ts
2667
2863
  const granolaWebClientScript = String.raw`
2864
+ const serverConfig = window.__GRANOLA_SERVER__ || { passwordRequired: false };
2865
+
2668
2866
  const state = {
2669
2867
  appState: null,
2670
2868
  detailError: "",
@@ -2675,6 +2873,8 @@ const state = {
2675
2873
  selectedMeeting: null,
2676
2874
  selectedMeetingBundle: null,
2677
2875
  selectedMeetingId: null,
2876
+ meetingSource: "live",
2877
+ serverLocked: Boolean(serverConfig.passwordRequired),
2678
2878
  sort: "updated-desc",
2679
2879
  updatedFrom: "",
2680
2880
  updatedTo: "",
@@ -2694,9 +2894,13 @@ const els = {
2694
2894
  quickOpenButton: document.querySelector("[data-quick-open-button]"),
2695
2895
  refreshButton: document.querySelector("[data-refresh]"),
2696
2896
  search: document.querySelector("[data-search]"),
2897
+ securityPanel: document.querySelector("[data-security-panel]"),
2898
+ serverPassword: document.querySelector("[data-server-password]"),
2899
+ lockServerButton: document.querySelector("[data-lock-server]"),
2697
2900
  sort: document.querySelector("[data-sort]"),
2698
2901
  stateBadge: document.querySelector("[data-state-badge]"),
2699
2902
  transcriptButton: document.querySelector("[data-export-transcripts]"),
2903
+ unlockServerButton: document.querySelector("[data-unlock-server]"),
2700
2904
  updatedFrom: document.querySelector("[data-updated-from]"),
2701
2905
  updatedTo: document.querySelector("[data-updated-to]"),
2702
2906
  workspaceTabs: document.querySelectorAll("[data-workspace-tab]"),
@@ -2751,6 +2955,7 @@ function renderAppState() {
2751
2955
  if (!state.appState) {
2752
2956
  els.appState.innerHTML = "<p>Waiting for server state…</p>";
2753
2957
  els.authPanel.innerHTML = "<p>Waiting for auth state…</p>";
2958
+ renderSecurityPanel();
2754
2959
  return;
2755
2960
  }
2756
2961
 
@@ -2762,6 +2967,11 @@ function renderAppState() {
2762
2967
  : appState.cache.configured
2763
2968
  ? "configured"
2764
2969
  : "not configured";
2970
+ const indexStatus = appState.index.loaded
2971
+ ? appState.index.meetingCount + " meetings"
2972
+ : appState.index.available
2973
+ ? "available"
2974
+ : "not built";
2765
2975
 
2766
2976
  els.appState.innerHTML = [
2767
2977
  '<div class="status-grid">',
@@ -2770,13 +2980,19 @@ function renderAppState() {
2770
2980
  '<div><span class="status-label">Auth</span><strong>' + escapeHtml(authMode) + "</strong></div>",
2771
2981
  '<div><span class="status-label">Documents</span><strong>' + escapeHtml(docs) + "</strong></div>",
2772
2982
  '<div><span class="status-label">Cache</span><strong>' + escapeHtml(cache) + "</strong></div>",
2983
+ '<div><span class="status-label">Index</span><strong>' + escapeHtml(indexStatus) + "</strong></div>",
2773
2984
  "</div>",
2774
2985
  ].join("");
2775
2986
 
2987
+ renderSecurityPanel();
2776
2988
  renderAuthPanel();
2777
2989
  renderExportJobs();
2778
2990
  }
2779
2991
 
2992
+ function renderSecurityPanel() {
2993
+ els.securityPanel.hidden = !state.serverLocked;
2994
+ }
2995
+
2780
2996
  function authActionButton(label, action, disabled) {
2781
2997
  return (
2782
2998
  '<button class="button button--secondary" data-auth-action="' +
@@ -2994,12 +3210,19 @@ async function fetchJson(path, init) {
2994
3210
  const response = await fetch(path, init);
2995
3211
  const payload = await response.json().catch(() => ({}));
2996
3212
  if (!response.ok) {
2997
- throw new Error(payload.error || response.statusText || "Request failed");
3213
+ if (payload.authRequired) {
3214
+ state.serverLocked = true;
3215
+ renderSecurityPanel();
3216
+ }
3217
+
3218
+ const error = new Error(payload.error || response.statusText || "Request failed");
3219
+ error.authRequired = Boolean(payload.authRequired);
3220
+ throw error;
2998
3221
  }
2999
3222
  return payload;
3000
3223
  }
3001
3224
 
3002
- function buildMeetingsQuery(limit = 100) {
3225
+ function buildMeetingsQuery(limit = 100, refresh = false) {
3003
3226
  const params = new URLSearchParams();
3004
3227
  params.set("limit", String(limit));
3005
3228
  params.set("sort", state.sort);
@@ -3016,16 +3239,22 @@ function buildMeetingsQuery(limit = 100) {
3016
3239
  params.set("updatedTo", state.updatedTo);
3017
3240
  }
3018
3241
 
3242
+ if (refresh) {
3243
+ params.set("refresh", "true");
3244
+ }
3245
+
3019
3246
  return "?" + params.toString();
3020
3247
  }
3021
3248
 
3022
3249
  async function loadMeetings(options = {}) {
3023
3250
  const preferredMeetingId = options.preferredMeetingId || state.selectedMeetingId;
3251
+ const refresh = options.refresh === true;
3024
3252
 
3025
3253
  try {
3026
3254
  state.listError = "";
3027
- const payload = await fetchJson("/meetings" + buildMeetingsQuery());
3255
+ const payload = await fetchJson("/meetings" + buildMeetingsQuery(100, refresh));
3028
3256
  state.meetings = payload.meetings || [];
3257
+ state.meetingSource = payload.source || "live";
3029
3258
 
3030
3259
  if (preferredMeetingId && state.meetings.some((meeting) => meeting.id === preferredMeetingId)) {
3031
3260
  state.selectedMeetingId = preferredMeetingId;
@@ -3094,15 +3323,30 @@ async function quickOpenMeeting() {
3094
3323
  }
3095
3324
  }
3096
3325
 
3097
- async function refreshAll() {
3326
+ async function refreshAll(forceLiveMeetings = false) {
3098
3327
  setStatus("Refreshing…", "busy");
3099
- const [appState, authState] = await Promise.all([fetchJson("/state"), fetchJson("/auth/status"), loadMeetings()]);
3100
- state.appState = {
3101
- ...appState,
3102
- auth: authState,
3103
- };
3104
- renderAppState();
3105
- setStatus("Connected", "ok");
3328
+ try {
3329
+ const [appState, authState] = await Promise.all([
3330
+ fetchJson("/state"),
3331
+ fetchJson("/auth/status"),
3332
+ loadMeetings({ refresh: forceLiveMeetings }),
3333
+ ]);
3334
+ state.serverLocked = false;
3335
+ state.appState = {
3336
+ ...appState,
3337
+ auth: authState,
3338
+ };
3339
+ renderAppState();
3340
+ setStatus(forceLiveMeetings ? "Live data refreshed" : state.meetingSource === "index" ? "Loaded from index" : "Connected", "ok");
3341
+ } catch (error) {
3342
+ if (error.authRequired) {
3343
+ setStatus("Server locked", "error");
3344
+ renderSecurityPanel();
3345
+ return;
3346
+ }
3347
+
3348
+ throw error;
3349
+ }
3106
3350
  }
3107
3351
 
3108
3352
  async function syncAuthState() {
@@ -3204,6 +3448,50 @@ async function switchAuthMode(mode) {
3204
3448
  }
3205
3449
  }
3206
3450
 
3451
+ async function unlockServer() {
3452
+ const password = els.serverPassword.value;
3453
+ if (!password.trim()) {
3454
+ setStatus("Enter the server password", "error");
3455
+ return;
3456
+ }
3457
+
3458
+ setStatus("Unlocking server…", "busy");
3459
+ try {
3460
+ await fetchJson("/auth/unlock", {
3461
+ body: JSON.stringify({ password }),
3462
+ headers: { "content-type": "application/json" },
3463
+ method: "POST",
3464
+ });
3465
+ els.serverPassword.value = "";
3466
+ state.serverLocked = false;
3467
+ await refreshAll(true);
3468
+ } catch (error) {
3469
+ setStatus("Unlock failed", "error");
3470
+ state.detailError = error instanceof Error ? error.message : String(error);
3471
+ renderMeetingDetail();
3472
+ }
3473
+ }
3474
+
3475
+ async function lockServer() {
3476
+ try {
3477
+ await fetchJson("/auth/lock", {
3478
+ method: "POST",
3479
+ });
3480
+ } catch {}
3481
+
3482
+ state.serverLocked = true;
3483
+ state.appState = null;
3484
+ state.meetings = [];
3485
+ state.selectedMeeting = null;
3486
+ state.selectedMeetingBundle = null;
3487
+ state.detailError = "";
3488
+ els.serverPassword.value = "";
3489
+ renderSecurityPanel();
3490
+ renderMeetingList();
3491
+ renderMeetingDetail();
3492
+ setStatus("Server locked", "error");
3493
+ }
3494
+
3207
3495
  els.list.addEventListener("click", (event) => {
3208
3496
  if (!(event.target instanceof Element)) {
3209
3497
  return;
@@ -3257,8 +3545,27 @@ els.authPanel.addEventListener("click", (event) => {
3257
3545
  void switchAuthMode(modeButton.dataset.authMode);
3258
3546
  });
3259
3547
 
3548
+ els.unlockServerButton.addEventListener("click", () => {
3549
+ void unlockServer();
3550
+ });
3551
+
3552
+ els.lockServerButton.addEventListener("click", () => {
3553
+ void lockServer();
3554
+ });
3555
+
3556
+ els.serverPassword.addEventListener("keydown", (event) => {
3557
+ if (!(event.target instanceof HTMLInputElement)) {
3558
+ return;
3559
+ }
3560
+
3561
+ if (event.key === "Enter") {
3562
+ event.preventDefault();
3563
+ void unlockServer();
3564
+ }
3565
+ });
3566
+
3260
3567
  els.refreshButton.addEventListener("click", () => {
3261
- void refreshAll();
3568
+ void refreshAll(true);
3262
3569
  });
3263
3570
 
3264
3571
  els.noteButton.addEventListener("click", () => {
@@ -3383,15 +3690,27 @@ document.addEventListener("keydown", (event) => {
3383
3690
 
3384
3691
  const events = new EventSource("/events");
3385
3692
  events.addEventListener("state.updated", (event) => {
3693
+ const previousLoadedAt = state.appState?.documents?.loadedAt;
3386
3694
  const payload = JSON.parse(event.data);
3387
3695
  state.appState = payload.state;
3388
3696
  renderAppState();
3697
+
3698
+ if (
3699
+ state.meetingSource === "index" &&
3700
+ payload.state.documents?.loadedAt &&
3701
+ payload.state.documents.loadedAt !== previousLoadedAt
3702
+ ) {
3703
+ void loadMeetings({
3704
+ preferredMeetingId: state.selectedMeetingId,
3705
+ });
3706
+ }
3389
3707
  });
3390
3708
  events.addEventListener("error", () => {
3391
3709
  setStatus("Disconnected", "error");
3392
3710
  });
3393
3711
 
3394
3712
  syncFilterInputs();
3713
+ renderSecurityPanel();
3395
3714
 
3396
3715
  void refreshAll().catch((error) => {
3397
3716
  setStatus("Error", "error");
@@ -3455,6 +3774,19 @@ const granolaWebMarkup = String.raw`
3455
3774
  </div>
3456
3775
  <p>Initial beta web client. It speaks to the same local API that future TUI and attach flows will use.</p>
3457
3776
  </section>
3777
+ <section class="security-panel" data-security-panel hidden>
3778
+ <div class="security-panel__head">
3779
+ <h3>Server Access</h3>
3780
+ <p>This server is locked with a password. Unlock it to load meetings and live state.</p>
3781
+ </div>
3782
+ <div class="security-panel__body">
3783
+ <input class="field-input" data-server-password type="password" placeholder="Server password" />
3784
+ <div class="toolbar-actions">
3785
+ <button class="button button--primary" data-unlock-server>Unlock</button>
3786
+ <button class="button button--secondary" data-lock-server>Lock</button>
3787
+ </div>
3788
+ </div>
3789
+ </section>
3458
3790
  <section class="auth-panel">
3459
3791
  <div class="auth-panel__head">
3460
3792
  <h3>Auth Session</h3>
@@ -3692,10 +4024,12 @@ body {
3692
4024
  }
3693
4025
 
3694
4026
  .auth-panel,
4027
+ .security-panel,
3695
4028
  .jobs-panel {
3696
4029
  padding: 0 24px 18px;
3697
4030
  }
3698
4031
 
4032
+ .security-panel__head h3,
3699
4033
  .auth-panel__head h3,
3700
4034
  .jobs-panel__head h3 {
3701
4035
  margin: 0;
@@ -3704,6 +4038,7 @@ body {
3704
4038
  text-transform: uppercase;
3705
4039
  }
3706
4040
 
4041
+ .security-panel__head p,
3707
4042
  .auth-panel__head p,
3708
4043
  .jobs-panel__head p {
3709
4044
  margin: 6px 0 0;
@@ -3711,6 +4046,7 @@ body {
3711
4046
  font-size: 0.9rem;
3712
4047
  }
3713
4048
 
4049
+ .security-panel__body,
3714
4050
  .auth-panel__body {
3715
4051
  display: grid;
3716
4052
  gap: 12px;
@@ -3961,7 +4297,7 @@ body {
3961
4297
  `;
3962
4298
  //#endregion
3963
4299
  //#region src/server/web.ts
3964
- function renderGranolaWebPage() {
4300
+ function renderGranolaWebPage(options = {}) {
3965
4301
  return `<!doctype html>
3966
4302
  <html lang="en">
3967
4303
  <head>
@@ -3975,6 +4311,7 @@ ${granolaWebStyles}
3975
4311
  <body>
3976
4312
  ${granolaWebMarkup}
3977
4313
  <script type="module">
4314
+ window.__GRANOLA_SERVER__ = ${JSON.stringify({ passwordRequired: options.serverPasswordRequired ?? false })};
3978
4315
  ${granolaWebClientScript}
3979
4316
  <\/script>
3980
4317
  </body>
@@ -3982,6 +4319,7 @@ ${granolaWebClientScript}
3982
4319
  }
3983
4320
  //#endregion
3984
4321
  //#region src/server/http.ts
4322
+ const PASSWORD_COOKIE_NAME = "granola_toolkit_password";
3985
4323
  function parseInteger(value) {
3986
4324
  if (!value?.trim()) return;
3987
4325
  if (!/^\d+$/.test(value)) throw new Error("invalid limit: expected a positive integer");
@@ -4011,24 +4349,31 @@ function sendJson(response, body, init = {}) {
4011
4349
  const payload = `${JSON.stringify(body, null, 2)}\n`;
4012
4350
  response.writeHead(init.status ?? 200, {
4013
4351
  "content-length": Buffer.byteLength(payload),
4014
- "content-type": "application/json; charset=utf-8"
4352
+ "content-type": "application/json; charset=utf-8",
4353
+ ...init.headers
4015
4354
  });
4016
4355
  response.end(payload);
4017
4356
  }
4018
- function sendText(response, body, status = 200) {
4357
+ function sendText(response, body, status = 200, headers = {}) {
4019
4358
  response.writeHead(status, {
4020
4359
  "content-length": Buffer.byteLength(body),
4021
- "content-type": "text/plain; charset=utf-8"
4360
+ "content-type": "text/plain; charset=utf-8",
4361
+ ...headers
4022
4362
  });
4023
4363
  response.end(body);
4024
4364
  }
4025
- function sendHtml(response, body, status = 200) {
4365
+ function sendHtml(response, body, status = 200, headers = {}) {
4026
4366
  response.writeHead(status, {
4027
4367
  "content-length": Buffer.byteLength(body),
4028
- "content-type": "text/html; charset=utf-8"
4368
+ "content-type": "text/html; charset=utf-8",
4369
+ ...headers
4029
4370
  });
4030
4371
  response.end(body);
4031
4372
  }
4373
+ function sendNoContent(response, status = 204, headers = {}) {
4374
+ response.writeHead(status, headers);
4375
+ response.end();
4376
+ }
4032
4377
  async function readJsonBody(request) {
4033
4378
  const chunks = [];
4034
4379
  for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
@@ -4064,17 +4409,90 @@ function transcriptFormatFromBody(value) {
4064
4409
  default: throw new Error("invalid transcript format: expected text, json, yaml, or raw");
4065
4410
  }
4066
4411
  }
4412
+ function parseCookies(request) {
4413
+ const header = request.headers.cookie;
4414
+ if (!header) return {};
4415
+ const cookies = {};
4416
+ for (const chunk of header.split(";")) {
4417
+ const [name, ...valueParts] = chunk.trim().split("=");
4418
+ if (!name) continue;
4419
+ cookies[name] = decodeURIComponent(valueParts.join("="));
4420
+ }
4421
+ return cookies;
4422
+ }
4423
+ function passwordCookieHeader(password) {
4424
+ return `${PASSWORD_COOKIE_NAME}=${encodeURIComponent(password)}; HttpOnly; Path=/; SameSite=Strict`;
4425
+ }
4426
+ function clearPasswordCookieHeader() {
4427
+ return `${PASSWORD_COOKIE_NAME}=; HttpOnly; Path=/; Max-Age=0; SameSite=Strict`;
4428
+ }
4429
+ function allowedOriginHeaders(origin) {
4430
+ return {
4431
+ "access-control-allow-credentials": "true",
4432
+ "access-control-allow-headers": "content-type, x-granola-password",
4433
+ "access-control-allow-methods": "GET, POST, OPTIONS",
4434
+ "access-control-allow-origin": origin,
4435
+ vary: "Origin"
4436
+ };
4437
+ }
4438
+ function isTrustedOrigin(origin, request, trustedOrigins) {
4439
+ if (!origin) return true;
4440
+ try {
4441
+ const parsed = new URL(origin);
4442
+ const host = request.headers.host;
4443
+ if (host && parsed.host === host) return true;
4444
+ } catch {
4445
+ return false;
4446
+ }
4447
+ return trustedOrigins.includes(origin);
4448
+ }
4449
+ function isPasswordAuthenticated(request, password) {
4450
+ const headerPassword = request.headers["x-granola-password"];
4451
+ if (typeof headerPassword === "string" && headerPassword === password) return true;
4452
+ const authorization = request.headers.authorization;
4453
+ if (authorization?.startsWith("Bearer ")) return authorization.slice(7) === password;
4454
+ return parseCookies(request)[PASSWORD_COOKIE_NAME] === password;
4455
+ }
4456
+ function publicRoute(path, enableWebClient) {
4457
+ return path === "/health" || path === "/auth/unlock" || enableWebClient && path === "/";
4458
+ }
4067
4459
  async function startGranolaServer(app, options = {}) {
4068
4460
  const enableWebClient = options.enableWebClient ?? false;
4069
4461
  const hostname = options.hostname ?? "127.0.0.1";
4070
4462
  const port = options.port ?? 0;
4463
+ const security = {
4464
+ password: options.security?.password?.trim() || void 0,
4465
+ trustedOrigins: (options.security?.trustedOrigins ?? []).map((origin) => origin.trim()).filter(Boolean)
4466
+ };
4071
4467
  const server = createServer(async (request, response) => {
4072
4468
  const method = request.method ?? "GET";
4073
4469
  const url = new URL(request.url ?? "/", `http://${hostname}`);
4074
4470
  const path = url.pathname;
4471
+ const origin = request.headers.origin?.trim();
4472
+ const trustedOrigin = isTrustedOrigin(origin, request, security.trustedOrigins);
4473
+ const originHeaders = origin && trustedOrigin ? allowedOriginHeaders(origin) : {};
4075
4474
  try {
4475
+ if (origin && !trustedOrigin) {
4476
+ sendJson(response, { error: `origin not trusted: ${origin}` }, {
4477
+ headers: originHeaders,
4478
+ status: 403
4479
+ });
4480
+ return;
4481
+ }
4482
+ if (method === "OPTIONS") {
4483
+ if (!origin) {
4484
+ sendNoContent(response, 204);
4485
+ return;
4486
+ }
4487
+ if (!trustedOrigin) {
4488
+ sendNoContent(response, 403);
4489
+ return;
4490
+ }
4491
+ sendNoContent(response, 204, originHeaders);
4492
+ return;
4493
+ }
4076
4494
  if (method === "GET" && path === "/" && enableWebClient) {
4077
- sendHtml(response, renderGranolaWebPage());
4495
+ sendHtml(response, renderGranolaWebPage({ serverPasswordRequired: Boolean(security.password) }), 200, originHeaders);
4078
4496
  return;
4079
4497
  }
4080
4498
  if (method === "GET" && path === "/health") {
@@ -4082,22 +4500,69 @@ async function startGranolaServer(app, options = {}) {
4082
4500
  ok: true,
4083
4501
  service: "granola-toolkit",
4084
4502
  version: app.config ? void 0 : void 0
4503
+ }, { headers: originHeaders });
4504
+ return;
4505
+ }
4506
+ if (method === "POST" && path === "/auth/unlock") {
4507
+ if (!security.password) {
4508
+ sendJson(response, {
4509
+ ok: true,
4510
+ passwordRequired: false
4511
+ }, { headers: originHeaders });
4512
+ return;
4513
+ }
4514
+ const body = await readJsonBody(request);
4515
+ const password = typeof body.password === "string" && body.password.trim() ? body.password : void 0;
4516
+ if (!password || password !== security.password) {
4517
+ sendJson(response, {
4518
+ authRequired: true,
4519
+ error: "invalid server password"
4520
+ }, {
4521
+ headers: originHeaders,
4522
+ status: 401
4523
+ });
4524
+ return;
4525
+ }
4526
+ sendJson(response, {
4527
+ ok: true,
4528
+ passwordRequired: true
4529
+ }, { headers: {
4530
+ ...originHeaders,
4531
+ "set-cookie": passwordCookieHeader(security.password)
4532
+ } });
4533
+ return;
4534
+ }
4535
+ if (security.password && !publicRoute(path, enableWebClient) && !isPasswordAuthenticated(request, security.password)) {
4536
+ sendJson(response, {
4537
+ authRequired: true,
4538
+ error: "server password required"
4539
+ }, {
4540
+ headers: originHeaders,
4541
+ status: 401
4085
4542
  });
4086
4543
  return;
4087
4544
  }
4088
4545
  if (method === "GET" && path === "/state") {
4089
- sendJson(response, app.getState());
4546
+ sendJson(response, app.getState(), { headers: originHeaders });
4090
4547
  return;
4091
4548
  }
4092
4549
  if (method === "GET" && path === "/auth/status") {
4093
- sendJson(response, await app.inspectAuth());
4550
+ sendJson(response, await app.inspectAuth(), { headers: originHeaders });
4551
+ return;
4552
+ }
4553
+ if (method === "POST" && path === "/auth/lock") {
4554
+ sendJson(response, { ok: true }, { headers: {
4555
+ ...originHeaders,
4556
+ "set-cookie": clearPasswordCookieHeader()
4557
+ } });
4094
4558
  return;
4095
4559
  }
4096
4560
  if (method === "GET" && path === "/events") {
4097
4561
  response.writeHead(200, {
4098
4562
  "cache-control": "no-cache, no-transform",
4099
4563
  connection: "keep-alive",
4100
- "content-type": "text/event-stream; charset=utf-8"
4564
+ "content-type": "text/event-stream; charset=utf-8",
4565
+ ...originHeaders
4101
4566
  });
4102
4567
  response.write(formatSseEvent({
4103
4568
  state: app.getState(),
@@ -4115,80 +4580,97 @@ async function startGranolaServer(app, options = {}) {
4115
4580
  }
4116
4581
  if (method === "GET" && path === "/meetings") {
4117
4582
  const limit = parseInteger(url.searchParams.get("limit"));
4583
+ const refresh = url.searchParams.get("refresh") === "true";
4118
4584
  const search = url.searchParams.get("search")?.trim() || void 0;
4119
4585
  const sort = parseMeetingSort(url.searchParams.get("sort"));
4120
4586
  const updatedFrom = url.searchParams.get("updatedFrom")?.trim() || void 0;
4121
4587
  const updatedTo = url.searchParams.get("updatedTo")?.trim() || void 0;
4122
- sendJson(response, {
4123
- meetings: await app.listMeetings({
4124
- limit,
4125
- search,
4126
- sort,
4127
- updatedFrom,
4128
- updatedTo
4129
- }),
4588
+ const result = await app.listMeetings({
4589
+ forceRefresh: refresh,
4590
+ limit,
4130
4591
  search,
4131
4592
  sort,
4132
4593
  updatedFrom,
4133
4594
  updatedTo
4134
4595
  });
4596
+ sendJson(response, {
4597
+ meetings: result.meetings,
4598
+ refresh,
4599
+ search,
4600
+ source: result.source,
4601
+ sort,
4602
+ updatedFrom,
4603
+ updatedTo
4604
+ }, { headers: originHeaders });
4135
4605
  return;
4136
4606
  }
4137
4607
  if (method === "GET" && path === "/meetings/resolve") {
4138
4608
  const query = url.searchParams.get("q")?.trim();
4139
4609
  if (!query) throw new Error("meeting query is required");
4140
- sendJson(response, await app.findMeeting(query, { requireCache: url.searchParams.get("includeTranscript") === "true" }));
4610
+ sendJson(response, await app.findMeeting(query, { requireCache: url.searchParams.get("includeTranscript") === "true" }), { headers: originHeaders });
4141
4611
  return;
4142
4612
  }
4143
4613
  if (method === "GET" && path.startsWith("/meetings/")) {
4144
4614
  const id = decodeURIComponent(path.slice(10));
4145
4615
  if (!id) throw new Error("meeting id is required");
4146
- sendJson(response, await app.getMeeting(id, { requireCache: url.searchParams.get("includeTranscript") === "true" }));
4616
+ sendJson(response, await app.getMeeting(id, { requireCache: url.searchParams.get("includeTranscript") === "true" }), { headers: originHeaders });
4147
4617
  return;
4148
4618
  }
4149
4619
  if (method === "POST" && path === "/auth/login") {
4150
4620
  const body = await readJsonBody(request);
4151
4621
  const supabasePath = typeof body.supabasePath === "string" && body.supabasePath.trim() ? body.supabasePath.trim() : void 0;
4152
- sendJson(response, await app.loginAuth({ supabasePath }));
4622
+ sendJson(response, await app.loginAuth({ supabasePath }), { headers: originHeaders });
4153
4623
  return;
4154
4624
  }
4155
4625
  if (method === "POST" && path === "/auth/logout") {
4156
- sendJson(response, await app.logoutAuth());
4626
+ sendJson(response, await app.logoutAuth(), { headers: originHeaders });
4157
4627
  return;
4158
4628
  }
4159
4629
  if (method === "POST" && path === "/auth/refresh") {
4160
- sendJson(response, await app.refreshAuth());
4630
+ sendJson(response, await app.refreshAuth(), { headers: originHeaders });
4161
4631
  return;
4162
4632
  }
4163
4633
  if (method === "POST" && path === "/auth/mode") {
4164
4634
  const body = await readJsonBody(request);
4165
- sendJson(response, await app.switchAuthMode(parseAuthMode(body.mode)));
4635
+ sendJson(response, await app.switchAuthMode(parseAuthMode(body.mode)), { headers: originHeaders });
4166
4636
  return;
4167
4637
  }
4168
4638
  if (method === "POST" && path === "/exports/notes") {
4169
4639
  const body = await readJsonBody(request);
4170
- sendJson(response, await app.exportNotes(noteFormatFromBody(body.format)), { status: 202 });
4640
+ sendJson(response, await app.exportNotes(noteFormatFromBody(body.format)), {
4641
+ headers: originHeaders,
4642
+ status: 202
4643
+ });
4171
4644
  return;
4172
4645
  }
4173
4646
  if (method === "GET" && path === "/exports/jobs") {
4174
4647
  const limit = parseInteger(url.searchParams.get("limit"));
4175
- sendJson(response, await app.listExportJobs({ limit }));
4648
+ sendJson(response, await app.listExportJobs({ limit }), { headers: originHeaders });
4176
4649
  return;
4177
4650
  }
4178
4651
  if (method === "POST" && path.startsWith("/exports/jobs/") && path.endsWith("/rerun")) {
4179
4652
  const id = decodeURIComponent(path.slice(14, -6));
4180
4653
  if (!id) throw new Error("export job id is required");
4181
- sendJson(response, await app.rerunExportJob(id), { status: 202 });
4654
+ sendJson(response, await app.rerunExportJob(id), {
4655
+ headers: originHeaders,
4656
+ status: 202
4657
+ });
4182
4658
  return;
4183
4659
  }
4184
4660
  if (method === "POST" && path === "/exports/transcripts") {
4185
4661
  const body = await readJsonBody(request);
4186
- sendJson(response, await app.exportTranscripts(transcriptFormatFromBody(body.format)), { status: 202 });
4662
+ sendJson(response, await app.exportTranscripts(transcriptFormatFromBody(body.format)), {
4663
+ headers: originHeaders,
4664
+ status: 202
4665
+ });
4187
4666
  return;
4188
4667
  }
4189
- sendText(response, "Not found\n", 404);
4668
+ sendText(response, "Not found\n", 404, originHeaders);
4190
4669
  } catch (error) {
4191
- sendJson(response, { error: error instanceof Error ? error.message : String(error) }, { status: 400 });
4670
+ sendJson(response, { error: error instanceof Error ? error.message : String(error) }, {
4671
+ headers: originHeaders,
4672
+ status: 400
4673
+ });
4192
4674
  }
4193
4675
  });
4194
4676
  await new Promise((resolve, reject) => {
@@ -4230,14 +4712,17 @@ Usage:
4230
4712
  granola serve [options]
4231
4713
 
4232
4714
  Options:
4233
- --hostname <value> Hostname to bind (default: 127.0.0.1)
4234
- --port <value> Port to bind (default: 0 for any available port)
4235
- --cache <path> Path to Granola cache JSON
4236
- --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
4237
- --supabase <path> Path to supabase.json
4238
- --debug Enable debug logging
4239
- --config <path> Path to .granola.toml
4240
- -h, --help Show help
4715
+ --network <mode> Network mode: local or lan (default: local)
4716
+ --hostname <value> Hostname to bind (overrides network default)
4717
+ --port <value> Port to bind (default: 0 for any available port)
4718
+ --password <value> Optional server password for API and browser access
4719
+ --trusted-origins <v> Comma-separated extra browser origins to trust
4720
+ --cache <path> Path to Granola cache JSON
4721
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
4722
+ --supabase <path> Path to supabase.json
4723
+ --debug Enable debug logging
4724
+ --config <path> Path to .granola.toml
4725
+ -h, --help Show help
4241
4726
  `;
4242
4727
  }
4243
4728
  const serveCommand = {
@@ -4246,8 +4731,11 @@ const serveCommand = {
4246
4731
  cache: { type: "string" },
4247
4732
  help: { type: "boolean" },
4248
4733
  hostname: { type: "string" },
4734
+ network: { type: "string" },
4735
+ password: { type: "string" },
4249
4736
  port: { type: "string" },
4250
- timeout: { type: "string" }
4737
+ timeout: { type: "string" },
4738
+ "trusted-origins": { type: "string" }
4251
4739
  },
4252
4740
  help: serveHelp,
4253
4741
  name: "serve",
@@ -4260,13 +4748,29 @@ const serveCommand = {
4260
4748
  debug(config.debug, "supabase", config.supabase);
4261
4749
  debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
4262
4750
  debug(config.debug, "timeoutMs", config.notes.timeoutMs);
4263
- const server = await startGranolaServer(await createGranolaApp(config, { surface: "server" }), {
4264
- hostname: pickHostname(commandFlags.hostname),
4265
- port: parsePort(commandFlags.port)
4751
+ const app = await createGranolaApp(config, { surface: "server" });
4752
+ const networkMode = parseNetworkMode(commandFlags.network);
4753
+ const hostname = resolveServerHostname(networkMode, commandFlags.hostname);
4754
+ const port = parsePort(commandFlags.port);
4755
+ const password = typeof commandFlags.password === "string" && commandFlags.password.trim() ? commandFlags.password : void 0;
4756
+ const trustedOrigins = parseTrustedOrigins(commandFlags["trusted-origins"]);
4757
+ const server = await startGranolaServer(app, {
4758
+ hostname,
4759
+ port,
4760
+ security: {
4761
+ password,
4762
+ trustedOrigins
4763
+ }
4266
4764
  });
4267
4765
  console.log(`Granola server listening on ${server.url.href}`);
4766
+ console.log(`Network mode: ${networkMode}`);
4767
+ if (password) console.log("Server password protection: enabled");
4768
+ else if (networkMode === "lan") console.log("Warning: LAN mode is enabled without a server password");
4769
+ if (trustedOrigins.length > 0) console.log(`Trusted origins: ${trustedOrigins.join(", ")}`);
4268
4770
  console.log("Endpoints:");
4269
4771
  console.log(" GET /health");
4772
+ console.log(" POST /auth/unlock");
4773
+ console.log(" POST /auth/lock");
4270
4774
  console.log(" GET /auth/status");
4271
4775
  console.log(" GET /state");
4272
4776
  console.log(" GET /events");
@@ -4379,8 +4883,11 @@ Usage:
4379
4883
  granola web [options]
4380
4884
 
4381
4885
  Options:
4382
- --hostname <value> Hostname to bind (default: 127.0.0.1)
4886
+ --network <mode> Network mode: local or lan (default: local)
4887
+ --hostname <value> Hostname to bind (overrides network default)
4383
4888
  --port <value> Port to bind (default: 0 for any available port)
4889
+ --password <value> Optional server password for API and browser access
4890
+ --trusted-origins <v> Comma-separated extra browser origins to trust
4384
4891
  --cache <path> Path to Granola cache JSON
4385
4892
  --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
4386
4893
  --supabase <path> Path to supabase.json
@@ -4405,9 +4912,12 @@ const commands = [
4405
4912
  cache: { type: "string" },
4406
4913
  help: { type: "boolean" },
4407
4914
  hostname: { type: "string" },
4915
+ network: { type: "string" },
4408
4916
  open: { type: "boolean" },
4917
+ password: { type: "string" },
4409
4918
  port: { type: "string" },
4410
- timeout: { type: "string" }
4919
+ timeout: { type: "string" },
4920
+ "trusted-origins": { type: "string" }
4411
4921
  },
4412
4922
  help: webHelp,
4413
4923
  name: "web",
@@ -4421,18 +4931,31 @@ const commands = [
4421
4931
  debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
4422
4932
  debug(config.debug, "timeoutMs", config.notes.timeoutMs);
4423
4933
  const app = await createGranolaApp(config, { surface: "web" });
4424
- const hostname = pickHostname(commandFlags.hostname);
4934
+ const networkMode = parseNetworkMode(commandFlags.network);
4935
+ const hostname = resolveServerHostname(networkMode, commandFlags.hostname);
4425
4936
  const port = parsePort(commandFlags.port);
4426
4937
  const openBrowser = commandFlags.open !== false;
4938
+ const password = typeof commandFlags.password === "string" && commandFlags.password.trim() ? commandFlags.password : void 0;
4939
+ const trustedOrigins = parseTrustedOrigins(commandFlags["trusted-origins"]);
4427
4940
  const server = await startGranolaServer(app, {
4428
4941
  enableWebClient: true,
4429
4942
  hostname,
4430
- port
4943
+ port,
4944
+ security: {
4945
+ password,
4946
+ trustedOrigins
4947
+ }
4431
4948
  });
4432
4949
  console.log(`Granola Toolkit web workspace listening on ${server.url.href}`);
4950
+ console.log(`Network mode: ${networkMode}`);
4951
+ if (password) console.log("Server password protection: enabled");
4952
+ else if (networkMode === "lan") console.log("Warning: LAN mode is enabled without a server password");
4953
+ if (trustedOrigins.length > 0) console.log(`Trusted origins: ${trustedOrigins.join(", ")}`);
4433
4954
  console.log("Routes:");
4434
4955
  console.log(" GET /");
4435
4956
  console.log(" GET /health");
4957
+ console.log(" POST /auth/unlock");
4958
+ console.log(" POST /auth/lock");
4436
4959
  console.log(" GET /auth/status");
4437
4960
  console.log(" GET /state");
4438
4961
  console.log(" GET /events");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",