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.
- package/README.md +13 -0
- package/dist/cli.js +361 -51
- 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
|
-
|
|
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
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
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)), {
|
|
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), {
|
|
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)), {
|
|
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) }, {
|
|
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
|
-
--
|
|
4447
|
-
--
|
|
4448
|
-
--
|
|
4449
|
-
--
|
|
4450
|
-
--
|
|
4451
|
-
--
|
|
4452
|
-
--
|
|
4453
|
-
|
|
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
|
|
4477
|
-
|
|
4478
|
-
|
|
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
|
-
--
|
|
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
|
|
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");
|