granola-toolkit 0.24.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 +13 -0
  2. package/dist/cli.js +361 -51
  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,6 +195,8 @@ 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
@@ -211,6 +215,14 @@ The initial server API includes:
211
215
 
212
216
  This is the foundation for the future `granola web` client and any attachable TUI flows.
213
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
+
214
226
  ### Web
215
227
 
216
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`.
@@ -228,6 +240,7 @@ The initial browser client includes:
228
240
  - note and transcript export actions backed by the same local API
229
241
  - a recent export-jobs panel with rerun actions
230
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
231
244
 
232
245
  ### Local Meeting Index
233
246
 
package/dist/cli.js CHANGED
@@ -2355,6 +2355,22 @@ function parsePort(value) {
2355
2355
  function pickHostname(value, fallback = "127.0.0.1") {
2356
2356
  return typeof value === "string" && value.trim() ? value.trim() : fallback;
2357
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
+ }
2358
2374
  async function waitForShutdown(close) {
2359
2375
  await new Promise((resolve, reject) => {
2360
2376
  let closing = false;
@@ -2845,6 +2861,8 @@ function resolveNoteFormat(value) {
2845
2861
  //#endregion
2846
2862
  //#region src/web/client-script.ts
2847
2863
  const granolaWebClientScript = String.raw`
2864
+ const serverConfig = window.__GRANOLA_SERVER__ || { passwordRequired: false };
2865
+
2848
2866
  const state = {
2849
2867
  appState: null,
2850
2868
  detailError: "",
@@ -2856,6 +2874,7 @@ const state = {
2856
2874
  selectedMeetingBundle: null,
2857
2875
  selectedMeetingId: null,
2858
2876
  meetingSource: "live",
2877
+ serverLocked: Boolean(serverConfig.passwordRequired),
2859
2878
  sort: "updated-desc",
2860
2879
  updatedFrom: "",
2861
2880
  updatedTo: "",
@@ -2875,9 +2894,13 @@ const els = {
2875
2894
  quickOpenButton: document.querySelector("[data-quick-open-button]"),
2876
2895
  refreshButton: document.querySelector("[data-refresh]"),
2877
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]"),
2878
2900
  sort: document.querySelector("[data-sort]"),
2879
2901
  stateBadge: document.querySelector("[data-state-badge]"),
2880
2902
  transcriptButton: document.querySelector("[data-export-transcripts]"),
2903
+ unlockServerButton: document.querySelector("[data-unlock-server]"),
2881
2904
  updatedFrom: document.querySelector("[data-updated-from]"),
2882
2905
  updatedTo: document.querySelector("[data-updated-to]"),
2883
2906
  workspaceTabs: document.querySelectorAll("[data-workspace-tab]"),
@@ -2932,6 +2955,7 @@ function renderAppState() {
2932
2955
  if (!state.appState) {
2933
2956
  els.appState.innerHTML = "<p>Waiting for server state…</p>";
2934
2957
  els.authPanel.innerHTML = "<p>Waiting for auth state…</p>";
2958
+ renderSecurityPanel();
2935
2959
  return;
2936
2960
  }
2937
2961
 
@@ -2960,10 +2984,15 @@ function renderAppState() {
2960
2984
  "</div>",
2961
2985
  ].join("");
2962
2986
 
2987
+ renderSecurityPanel();
2963
2988
  renderAuthPanel();
2964
2989
  renderExportJobs();
2965
2990
  }
2966
2991
 
2992
+ function renderSecurityPanel() {
2993
+ els.securityPanel.hidden = !state.serverLocked;
2994
+ }
2995
+
2967
2996
  function authActionButton(label, action, disabled) {
2968
2997
  return (
2969
2998
  '<button class="button button--secondary" data-auth-action="' +
@@ -3181,7 +3210,14 @@ async function fetchJson(path, init) {
3181
3210
  const response = await fetch(path, init);
3182
3211
  const payload = await response.json().catch(() => ({}));
3183
3212
  if (!response.ok) {
3184
- 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;
3185
3221
  }
3186
3222
  return payload;
3187
3223
  }
@@ -3289,17 +3325,28 @@ async function quickOpenMeeting() {
3289
3325
 
3290
3326
  async function refreshAll(forceLiveMeetings = false) {
3291
3327
  setStatus("Refreshing…", "busy");
3292
- const [appState, authState] = await Promise.all([
3293
- fetchJson("/state"),
3294
- fetchJson("/auth/status"),
3295
- loadMeetings({ refresh: forceLiveMeetings }),
3296
- ]);
3297
- state.appState = {
3298
- ...appState,
3299
- auth: authState,
3300
- };
3301
- renderAppState();
3302
- setStatus(forceLiveMeetings ? "Live data refreshed" : state.meetingSource === "index" ? "Loaded from index" : "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
+ }
3303
3350
  }
3304
3351
 
3305
3352
  async function syncAuthState() {
@@ -3401,6 +3448,50 @@ async function switchAuthMode(mode) {
3401
3448
  }
3402
3449
  }
3403
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
+
3404
3495
  els.list.addEventListener("click", (event) => {
3405
3496
  if (!(event.target instanceof Element)) {
3406
3497
  return;
@@ -3454,6 +3545,25 @@ els.authPanel.addEventListener("click", (event) => {
3454
3545
  void switchAuthMode(modeButton.dataset.authMode);
3455
3546
  });
3456
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
+
3457
3567
  els.refreshButton.addEventListener("click", () => {
3458
3568
  void refreshAll(true);
3459
3569
  });
@@ -3600,6 +3710,7 @@ events.addEventListener("error", () => {
3600
3710
  });
3601
3711
 
3602
3712
  syncFilterInputs();
3713
+ renderSecurityPanel();
3603
3714
 
3604
3715
  void refreshAll().catch((error) => {
3605
3716
  setStatus("Error", "error");
@@ -3663,6 +3774,19 @@ const granolaWebMarkup = String.raw`
3663
3774
  </div>
3664
3775
  <p>Initial beta web client. It speaks to the same local API that future TUI and attach flows will use.</p>
3665
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>
3666
3790
  <section class="auth-panel">
3667
3791
  <div class="auth-panel__head">
3668
3792
  <h3>Auth Session</h3>
@@ -3900,10 +4024,12 @@ body {
3900
4024
  }
3901
4025
 
3902
4026
  .auth-panel,
4027
+ .security-panel,
3903
4028
  .jobs-panel {
3904
4029
  padding: 0 24px 18px;
3905
4030
  }
3906
4031
 
4032
+ .security-panel__head h3,
3907
4033
  .auth-panel__head h3,
3908
4034
  .jobs-panel__head h3 {
3909
4035
  margin: 0;
@@ -3912,6 +4038,7 @@ body {
3912
4038
  text-transform: uppercase;
3913
4039
  }
3914
4040
 
4041
+ .security-panel__head p,
3915
4042
  .auth-panel__head p,
3916
4043
  .jobs-panel__head p {
3917
4044
  margin: 6px 0 0;
@@ -3919,6 +4046,7 @@ body {
3919
4046
  font-size: 0.9rem;
3920
4047
  }
3921
4048
 
4049
+ .security-panel__body,
3922
4050
  .auth-panel__body {
3923
4051
  display: grid;
3924
4052
  gap: 12px;
@@ -4169,7 +4297,7 @@ body {
4169
4297
  `;
4170
4298
  //#endregion
4171
4299
  //#region src/server/web.ts
4172
- function renderGranolaWebPage() {
4300
+ function renderGranolaWebPage(options = {}) {
4173
4301
  return `<!doctype html>
4174
4302
  <html lang="en">
4175
4303
  <head>
@@ -4183,6 +4311,7 @@ ${granolaWebStyles}
4183
4311
  <body>
4184
4312
  ${granolaWebMarkup}
4185
4313
  <script type="module">
4314
+ window.__GRANOLA_SERVER__ = ${JSON.stringify({ passwordRequired: options.serverPasswordRequired ?? false })};
4186
4315
  ${granolaWebClientScript}
4187
4316
  <\/script>
4188
4317
  </body>
@@ -4190,6 +4319,7 @@ ${granolaWebClientScript}
4190
4319
  }
4191
4320
  //#endregion
4192
4321
  //#region src/server/http.ts
4322
+ const PASSWORD_COOKIE_NAME = "granola_toolkit_password";
4193
4323
  function parseInteger(value) {
4194
4324
  if (!value?.trim()) return;
4195
4325
  if (!/^\d+$/.test(value)) throw new Error("invalid limit: expected a positive integer");
@@ -4219,24 +4349,31 @@ function sendJson(response, body, init = {}) {
4219
4349
  const payload = `${JSON.stringify(body, null, 2)}\n`;
4220
4350
  response.writeHead(init.status ?? 200, {
4221
4351
  "content-length": Buffer.byteLength(payload),
4222
- "content-type": "application/json; charset=utf-8"
4352
+ "content-type": "application/json; charset=utf-8",
4353
+ ...init.headers
4223
4354
  });
4224
4355
  response.end(payload);
4225
4356
  }
4226
- function sendText(response, body, status = 200) {
4357
+ function sendText(response, body, status = 200, headers = {}) {
4227
4358
  response.writeHead(status, {
4228
4359
  "content-length": Buffer.byteLength(body),
4229
- "content-type": "text/plain; charset=utf-8"
4360
+ "content-type": "text/plain; charset=utf-8",
4361
+ ...headers
4230
4362
  });
4231
4363
  response.end(body);
4232
4364
  }
4233
- function sendHtml(response, body, status = 200) {
4365
+ function sendHtml(response, body, status = 200, headers = {}) {
4234
4366
  response.writeHead(status, {
4235
4367
  "content-length": Buffer.byteLength(body),
4236
- "content-type": "text/html; charset=utf-8"
4368
+ "content-type": "text/html; charset=utf-8",
4369
+ ...headers
4237
4370
  });
4238
4371
  response.end(body);
4239
4372
  }
4373
+ function sendNoContent(response, status = 204, headers = {}) {
4374
+ response.writeHead(status, headers);
4375
+ response.end();
4376
+ }
4240
4377
  async function readJsonBody(request) {
4241
4378
  const chunks = [];
4242
4379
  for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
@@ -4272,17 +4409,90 @@ function transcriptFormatFromBody(value) {
4272
4409
  default: throw new Error("invalid transcript format: expected text, json, yaml, or raw");
4273
4410
  }
4274
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
+ }
4275
4459
  async function startGranolaServer(app, options = {}) {
4276
4460
  const enableWebClient = options.enableWebClient ?? false;
4277
4461
  const hostname = options.hostname ?? "127.0.0.1";
4278
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
+ };
4279
4467
  const server = createServer(async (request, response) => {
4280
4468
  const method = request.method ?? "GET";
4281
4469
  const url = new URL(request.url ?? "/", `http://${hostname}`);
4282
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) : {};
4283
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
+ }
4284
4494
  if (method === "GET" && path === "/" && enableWebClient) {
4285
- sendHtml(response, renderGranolaWebPage());
4495
+ sendHtml(response, renderGranolaWebPage({ serverPasswordRequired: Boolean(security.password) }), 200, originHeaders);
4286
4496
  return;
4287
4497
  }
4288
4498
  if (method === "GET" && path === "/health") {
@@ -4290,22 +4500,69 @@ async function startGranolaServer(app, options = {}) {
4290
4500
  ok: true,
4291
4501
  service: "granola-toolkit",
4292
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
4293
4542
  });
4294
4543
  return;
4295
4544
  }
4296
4545
  if (method === "GET" && path === "/state") {
4297
- sendJson(response, app.getState());
4546
+ sendJson(response, app.getState(), { headers: originHeaders });
4298
4547
  return;
4299
4548
  }
4300
4549
  if (method === "GET" && path === "/auth/status") {
4301
- 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
+ } });
4302
4558
  return;
4303
4559
  }
4304
4560
  if (method === "GET" && path === "/events") {
4305
4561
  response.writeHead(200, {
4306
4562
  "cache-control": "no-cache, no-transform",
4307
4563
  connection: "keep-alive",
4308
- "content-type": "text/event-stream; charset=utf-8"
4564
+ "content-type": "text/event-stream; charset=utf-8",
4565
+ ...originHeaders
4309
4566
  });
4310
4567
  response.write(formatSseEvent({
4311
4568
  state: app.getState(),
@@ -4344,64 +4601,76 @@ async function startGranolaServer(app, options = {}) {
4344
4601
  sort,
4345
4602
  updatedFrom,
4346
4603
  updatedTo
4347
- });
4604
+ }, { headers: originHeaders });
4348
4605
  return;
4349
4606
  }
4350
4607
  if (method === "GET" && path === "/meetings/resolve") {
4351
4608
  const query = url.searchParams.get("q")?.trim();
4352
4609
  if (!query) throw new Error("meeting query is required");
4353
- 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 });
4354
4611
  return;
4355
4612
  }
4356
4613
  if (method === "GET" && path.startsWith("/meetings/")) {
4357
4614
  const id = decodeURIComponent(path.slice(10));
4358
4615
  if (!id) throw new Error("meeting id is required");
4359
- 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 });
4360
4617
  return;
4361
4618
  }
4362
4619
  if (method === "POST" && path === "/auth/login") {
4363
4620
  const body = await readJsonBody(request);
4364
4621
  const supabasePath = typeof body.supabasePath === "string" && body.supabasePath.trim() ? body.supabasePath.trim() : void 0;
4365
- sendJson(response, await app.loginAuth({ supabasePath }));
4622
+ sendJson(response, await app.loginAuth({ supabasePath }), { headers: originHeaders });
4366
4623
  return;
4367
4624
  }
4368
4625
  if (method === "POST" && path === "/auth/logout") {
4369
- sendJson(response, await app.logoutAuth());
4626
+ sendJson(response, await app.logoutAuth(), { headers: originHeaders });
4370
4627
  return;
4371
4628
  }
4372
4629
  if (method === "POST" && path === "/auth/refresh") {
4373
- sendJson(response, await app.refreshAuth());
4630
+ sendJson(response, await app.refreshAuth(), { headers: originHeaders });
4374
4631
  return;
4375
4632
  }
4376
4633
  if (method === "POST" && path === "/auth/mode") {
4377
4634
  const body = await readJsonBody(request);
4378
- sendJson(response, await app.switchAuthMode(parseAuthMode(body.mode)));
4635
+ sendJson(response, await app.switchAuthMode(parseAuthMode(body.mode)), { headers: originHeaders });
4379
4636
  return;
4380
4637
  }
4381
4638
  if (method === "POST" && path === "/exports/notes") {
4382
4639
  const body = await readJsonBody(request);
4383
- 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
+ });
4384
4644
  return;
4385
4645
  }
4386
4646
  if (method === "GET" && path === "/exports/jobs") {
4387
4647
  const limit = parseInteger(url.searchParams.get("limit"));
4388
- sendJson(response, await app.listExportJobs({ limit }));
4648
+ sendJson(response, await app.listExportJobs({ limit }), { headers: originHeaders });
4389
4649
  return;
4390
4650
  }
4391
4651
  if (method === "POST" && path.startsWith("/exports/jobs/") && path.endsWith("/rerun")) {
4392
4652
  const id = decodeURIComponent(path.slice(14, -6));
4393
4653
  if (!id) throw new Error("export job id is required");
4394
- sendJson(response, await app.rerunExportJob(id), { status: 202 });
4654
+ sendJson(response, await app.rerunExportJob(id), {
4655
+ headers: originHeaders,
4656
+ status: 202
4657
+ });
4395
4658
  return;
4396
4659
  }
4397
4660
  if (method === "POST" && path === "/exports/transcripts") {
4398
4661
  const body = await readJsonBody(request);
4399
- 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
+ });
4400
4666
  return;
4401
4667
  }
4402
- sendText(response, "Not found\n", 404);
4668
+ sendText(response, "Not found\n", 404, originHeaders);
4403
4669
  } catch (error) {
4404
- 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
+ });
4405
4674
  }
4406
4675
  });
4407
4676
  await new Promise((resolve, reject) => {
@@ -4443,14 +4712,17 @@ Usage:
4443
4712
  granola serve [options]
4444
4713
 
4445
4714
  Options:
4446
- --hostname <value> Hostname to bind (default: 127.0.0.1)
4447
- --port <value> Port to bind (default: 0 for any available port)
4448
- --cache <path> Path to Granola cache JSON
4449
- --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
4450
- --supabase <path> Path to supabase.json
4451
- --debug Enable debug logging
4452
- --config <path> Path to .granola.toml
4453
- -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
4454
4726
  `;
4455
4727
  }
4456
4728
  const serveCommand = {
@@ -4459,8 +4731,11 @@ const serveCommand = {
4459
4731
  cache: { type: "string" },
4460
4732
  help: { type: "boolean" },
4461
4733
  hostname: { type: "string" },
4734
+ network: { type: "string" },
4735
+ password: { type: "string" },
4462
4736
  port: { type: "string" },
4463
- timeout: { type: "string" }
4737
+ timeout: { type: "string" },
4738
+ "trusted-origins": { type: "string" }
4464
4739
  },
4465
4740
  help: serveHelp,
4466
4741
  name: "serve",
@@ -4473,13 +4748,29 @@ const serveCommand = {
4473
4748
  debug(config.debug, "supabase", config.supabase);
4474
4749
  debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
4475
4750
  debug(config.debug, "timeoutMs", config.notes.timeoutMs);
4476
- const server = await startGranolaServer(await createGranolaApp(config, { surface: "server" }), {
4477
- hostname: pickHostname(commandFlags.hostname),
4478
- 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
+ }
4479
4764
  });
4480
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(", ")}`);
4481
4770
  console.log("Endpoints:");
4482
4771
  console.log(" GET /health");
4772
+ console.log(" POST /auth/unlock");
4773
+ console.log(" POST /auth/lock");
4483
4774
  console.log(" GET /auth/status");
4484
4775
  console.log(" GET /state");
4485
4776
  console.log(" GET /events");
@@ -4592,8 +4883,11 @@ Usage:
4592
4883
  granola web [options]
4593
4884
 
4594
4885
  Options:
4595
- --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)
4596
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
4597
4891
  --cache <path> Path to Granola cache JSON
4598
4892
  --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
4599
4893
  --supabase <path> Path to supabase.json
@@ -4618,9 +4912,12 @@ const commands = [
4618
4912
  cache: { type: "string" },
4619
4913
  help: { type: "boolean" },
4620
4914
  hostname: { type: "string" },
4915
+ network: { type: "string" },
4621
4916
  open: { type: "boolean" },
4917
+ password: { type: "string" },
4622
4918
  port: { type: "string" },
4623
- timeout: { type: "string" }
4919
+ timeout: { type: "string" },
4920
+ "trusted-origins": { type: "string" }
4624
4921
  },
4625
4922
  help: webHelp,
4626
4923
  name: "web",
@@ -4634,18 +4931,31 @@ const commands = [
4634
4931
  debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
4635
4932
  debug(config.debug, "timeoutMs", config.notes.timeoutMs);
4636
4933
  const app = await createGranolaApp(config, { surface: "web" });
4637
- const hostname = pickHostname(commandFlags.hostname);
4934
+ const networkMode = parseNetworkMode(commandFlags.network);
4935
+ const hostname = resolveServerHostname(networkMode, commandFlags.hostname);
4638
4936
  const port = parsePort(commandFlags.port);
4639
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"]);
4640
4940
  const server = await startGranolaServer(app, {
4641
4941
  enableWebClient: true,
4642
4942
  hostname,
4643
- port
4943
+ port,
4944
+ security: {
4945
+ password,
4946
+ trustedOrigins
4947
+ }
4644
4948
  });
4645
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(", ")}`);
4646
4954
  console.log("Routes:");
4647
4955
  console.log(" GET /");
4648
4956
  console.log(" GET /health");
4957
+ console.log(" POST /auth/unlock");
4958
+ console.log(" POST /auth/lock");
4649
4959
  console.log(" GET /auth/status");
4650
4960
  console.log(" GET /state");
4651
4961
  console.log(" GET /events");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.24.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",