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.
- package/README.md +27 -0
- package/dist/cli.js +589 -66
- 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
|
|
1919
|
-
|
|
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
|
|
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
|
|
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("
|
|
2531
|
-
const
|
|
2725
|
+
console.log("Loading meetings...");
|
|
2726
|
+
const result = await app.listMeetings({
|
|
2532
2727
|
limit,
|
|
2533
2728
|
search
|
|
2534
2729
|
});
|
|
2535
|
-
console.log(
|
|
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
|
-
|
|
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
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
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
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
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)), {
|
|
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), {
|
|
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)), {
|
|
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) }, {
|
|
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
|
-
--
|
|
4234
|
-
--
|
|
4235
|
-
--
|
|
4236
|
-
--
|
|
4237
|
-
--
|
|
4238
|
-
--
|
|
4239
|
-
--
|
|
4240
|
-
|
|
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
|
|
4264
|
-
|
|
4265
|
-
|
|
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
|
-
--
|
|
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
|
|
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");
|