@tonysbb/magpie-notion 0.0.0-stage → 1.0.6

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/session.mjs ADDED
@@ -0,0 +1,659 @@
1
+ // Signing in with the browser that is already signed in to Notion, the way
2
+ // other magpie plugins sign in with the client they belong to (Grok's
3
+ // ~/.grok/auth.json, kiro-cli's database, Cursor's keychain entry). A Notion
4
+ // web session lives in the browser's cookie store, so that is what this reads:
5
+ // the Chromium family's Cookies database and Firefox's cookies.sqlite.
6
+ //
7
+ // The store is copied before it is opened, because a running browser holds it
8
+ // open. Chromium encrypts the values. Windows protects the AES key with DPAPI
9
+ // (the key is in the profile's Local State), macOS and Linux keep it in the
10
+ // keychain. Chrome and Edge may write v20 values, which are bound to the
11
+ // application by the elevation service; those are reported, never guessed, so
12
+ // the caller can fall back to pasting the Cookie header.
13
+
14
+ import { spawn } from "node:child_process"
15
+ import { createDecipheriv, pbkdf2Sync } from "node:crypto"
16
+ import { copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"
17
+ import { homedir, tmpdir } from "node:os"
18
+ import { delimiter, join, resolve, sep } from "node:path"
19
+
20
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
21
+ // The cookies a Notion web session is made of, by the domains the web client
22
+ // serves them from. token_v2 is the session; notion_user_id names the user.
23
+ // The client moved from notion.so to notion.com, and sets its session cookie on
24
+ // .app.notion.com, so a domain counts when it is one of these or below one of
25
+ // them: matching exact hosts alone would miss the cookie that matters.
26
+ const HOSTS = ["notion.so", "notion.com"]
27
+ const NAMES = ["token_v2", "notion_user_id", "notion_browser_id", "notion_check_cookie_consent", "notion_device_id", "notion_os"]
28
+ // A busy browser can take seconds to answer for its own cookies - a machine has
29
+ // been seen taking half a minute on the socket alone - so the read over a
30
+ // debugging port is given longer than a file that is already there.
31
+ const DEVTOOLS_TIMEOUT = 30_000
32
+ // A container sidecar on another host can be absent or unreachable. Failing to
33
+ // open the socket should be quick; only a socket that did open gets the longer
34
+ // time above to answer.
35
+ const DEVTOOLS_CONNECT_TIMEOUT = 3_000
36
+ const BASIC_STORE_PASSPHRASE = "peanuts"
37
+ // A Chromium user data directory named in the environment is read before the
38
+ // desktop's own browsers: a container has no desktop profile, but an operator
39
+ // can put one where this build can reach it, and then that one is meant. The
40
+ // directory is the one --user-data-dir names: Local State and
41
+ // DevToolsActivePort sit in it, and the profiles (Default, Profile 1, ...)
42
+ // live below it. Either name takes one directory or a list of them, separated
43
+ // the way the platform separates paths.
44
+ const PROFILE_ENV = ["NOTION_BROWSER_PROFILE", "MAGPIE_NOTION_BROWSER_PROFILE"]
45
+
46
+ // sameRoot compares a directory named in the environment with the root a store
47
+ // was actually built from. The two are the same directory when one is written
48
+ // with a trailing separator, the other is not, or one is relative: comparing
49
+ // the strings as typed would drop the named directory out of the priority
50
+ // group and let a default browser answer first.
51
+ export function sameRoot(left, right) {
52
+ const key = (value) => {
53
+ const resolved = resolve(String(value))
54
+ const trimmed = resolved.length > 1 && (resolved.endsWith(sep) || resolved.endsWith("/")) ? resolved.slice(0, -1) : resolved
55
+ return process.platform === "win32" ? trimmed.toLowerCase() : trimmed
56
+ }
57
+ return key(left) === key(right)
58
+ }
59
+
60
+ // configuredRoots turns that environment into the stores' roots, most
61
+ // explicit first: each named directory is one user data directory, and a name
62
+ // repeated in both variables is used once.
63
+ export function configuredRoots(env) {
64
+ const roots = []
65
+ for (const name of PROFILE_ENV) {
66
+ for (const part of String(env?.[name] ?? "").split(delimiter)) {
67
+ const root = part.trim()
68
+ if (root && !roots.some((entry) => entry.root === root)) roots.push({ browser: "Chromium", root })
69
+ }
70
+ }
71
+ return roots
72
+ }
73
+
74
+ export class SessionError extends Error {
75
+ constructor(message, reason) {
76
+ super(message)
77
+ this.name = "SessionError"
78
+ this.reason = reason
79
+ }
80
+ }
81
+
82
+ // notionHost says whether a cookie's domain belongs to Notion: the domain
83
+ // itself, or any subdomain of it. Cookies carry a leading dot for the
84
+ // subdomain form, which is not part of the comparison.
85
+ export function notionHost(domain) {
86
+ const host = String(domain ?? "").toLowerCase().replace(/^\./, "")
87
+ return HOSTS.some((suffix) => host === suffix || host.endsWith(`.${suffix}`))
88
+ }
89
+
90
+ // browserStores lists every store on this machine that could hold a Notion
91
+ // session, most likely first: the browser the user signed in to Notion with is
92
+ // whichever one has the cookie, and the reading stops at the first that does.
93
+ // A root is a user data directory: Local State names its profiles, and a
94
+ // profile keeps its store at Network/Cookies (or Cookies).
95
+ export function browserStores(options = {}) {
96
+ const platform = options.platform ?? process.platform
97
+ const env = options.env ?? process.env
98
+ const home = options.home ?? homedir()
99
+ const stores = []
100
+ for (const { browser, root } of chromiumRoots(platform, env, home)) {
101
+ if (!existsSync(root)) continue
102
+ const localState = join(root, "Local State")
103
+ let profiles = []
104
+ let lastUsed
105
+ try {
106
+ const state = JSON.parse(readFileSync(localState, "utf8"))
107
+ profiles = Object.keys(state?.profile?.info_cache ?? {})
108
+ if (typeof state?.profile?.last_used === "string") lastUsed = state.profile.last_used
109
+ } catch {
110
+ profiles = []
111
+ }
112
+ if (!profiles.length) profiles = ["Default"]
113
+ // The profile the browser last used is the one most likely to hold the
114
+ // sign-in, and the information is already there for the taking.
115
+ if (lastUsed && profiles.includes(lastUsed) && profiles[0] !== lastUsed) profiles = [lastUsed, ...profiles.filter((profile) => profile !== lastUsed)]
116
+ for (const profile of profiles) {
117
+ for (const relative of [["Network", "Cookies"], ["Cookies"]]) {
118
+ const db = join(root, profile, ...relative)
119
+ if (existsSync(db)) {
120
+ stores.push({ kind: "chromium", browser, root, profile, db, localState })
121
+ break
122
+ }
123
+ }
124
+ }
125
+ // A browser this build drives itself - a headless Chromium started with a
126
+ // debugging port beside the container it serves - writes that port in the
127
+ // user data directory's root, and may have run long enough to write it
128
+ // before its store: the store is then read over that port, so the profile
129
+ // still counts. This only adds a profile whose store is not there at all.
130
+ if (!stores.some((store) => store.root === root) && existsSync(join(root, "DevToolsActivePort"))) {
131
+ stores.push({ kind: "chromium", browser, root, profile: "Default", db: undefined, localState })
132
+ }
133
+ }
134
+ for (const { browser, root } of firefoxRoots(platform, env, home)) {
135
+ if (!existsSync(root)) continue
136
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
137
+ const db = join(root, entry.name, "cookies.sqlite")
138
+ if (entry.isDirectory() && existsSync(db)) stores.push({ kind: "firefox", browser, root, profile: entry.name, db })
139
+ }
140
+ }
141
+ return stores
142
+ }
143
+
144
+ function chromiumRoots(platform, env, home) {
145
+ if (platform === "win32") {
146
+ const local = env.LOCALAPPDATA ?? join(home, "AppData", "Local")
147
+ return [
148
+ ...configuredRoots(env),
149
+ { browser: "Chrome", root: join(local, "Google", "Chrome", "User Data") },
150
+ { browser: "Edge", root: join(local, "Microsoft", "Edge", "User Data") },
151
+ { browser: "Brave", root: join(local, "BraveSoftware", "Brave-Browser", "User Data") },
152
+ { browser: "Chromium", root: join(local, "Chromium", "User Data") },
153
+ ]
154
+ }
155
+ if (platform === "darwin") {
156
+ const support = join(home, "Library", "Application Support")
157
+ return [
158
+ ...configuredRoots(env),
159
+ { browser: "Chrome", root: join(support, "Google", "Chrome") },
160
+ { browser: "Edge", root: join(support, "Microsoft Edge") },
161
+ { browser: "Brave", root: join(support, "BraveSoftware", "Brave-Browser") },
162
+ { browser: "Chromium", root: join(support, "Chromium") },
163
+ ]
164
+ }
165
+ const config = env.XDG_CONFIG_HOME ?? join(home, ".config")
166
+ return [
167
+ ...configuredRoots(env),
168
+ { browser: "Chrome", root: join(config, "google-chrome") },
169
+ { browser: "Chromium", root: join(config, "chromium") },
170
+ { browser: "Edge", root: join(config, "microsoft-edge") },
171
+ { browser: "Brave", root: join(config, "BraveSoftware", "Brave-Browser") },
172
+ // Flatpak and Snap keep their profiles outside the configuration directory.
173
+ { browser: "Chrome", root: join(home, ".var", "app", "com.google.Chrome", "config", "google-chrome") },
174
+ { browser: "Brave", root: join(home, ".var", "app", "com.brave.Browser", "config", "BraveSoftware", "Brave-Browser") },
175
+ { browser: "Chromium", root: join(home, "snap", "chromium", "current", ".config", "chromium") },
176
+ ]
177
+ }
178
+
179
+ function firefoxRoots(platform, env, home) {
180
+ if (platform === "win32") return [{ browser: "Firefox", root: join(env.APPDATA ?? join(home, "AppData", "Roaming"), "Mozilla", "Firefox", "Profiles") }]
181
+ if (platform === "darwin") return [{ browser: "Firefox", root: join(home, "Library", "Application Support", "Firefox", "Profiles") }]
182
+ const config = env.XDG_CONFIG_HOME ?? join(home, ".config")
183
+ return [
184
+ { browser: "Firefox", root: join(home, ".mozilla", "firefox") },
185
+ { browser: "Firefox", root: join(config, "mozilla", "firefox") },
186
+ ]
187
+ }
188
+
189
+ // findSession reads every store until one holds a Notion session, and reports
190
+ // why each one that did not was left out: a locked store, an encryption this
191
+ // build cannot open, or a browser that was simply never signed in to Notion.
192
+ // A browser that is running can also be asked for its own cookies over the
193
+ // debugging port it names, which is the only way in when the store is locked or
194
+ // its values are bound to the browser. A user data directory named in the
195
+ // environment is an override: its store, and then its port, are read before
196
+ // any default browser's store, so a default sign-in cannot outrank it.
197
+ export async function findSession(options = {}) {
198
+ const stores = options.stores ?? browserStores(options)
199
+ if (!stores.length) throw new SessionError("No Chrome, Edge, Brave, Chromium or Firefox profile was found on this machine. In a container, set NOTION_BROWSER_PROFILE to a Chromium profile directory, or sign in with the Notion web session (paste the Cookie header).", "no-browser")
200
+ const problems = []
201
+ const readDisk = async (group) => {
202
+ for (const store of group) {
203
+ // A profile listed for its debugging port alone has no store to open;
204
+ // the port is asked instead, below, and asking it is not this loop's job.
205
+ if (!store.db) continue
206
+ const where = `${store.browser} (${store.profile})`
207
+ try {
208
+ const cookies = await readStore(store, options)
209
+ const best = bestCookies(cookies)
210
+ const session = best.find((cookie) => cookie.name === "token_v2")
211
+ if (!session) {
212
+ problems.push(`${where} has no Notion session`)
213
+ continue
214
+ }
215
+ const user = best.find((cookie) => cookie.name === "notion_user_id")
216
+ return {
217
+ cookie: cookieHeader(best),
218
+ browser: store.browser,
219
+ profile: store.profile,
220
+ ...(UUID.test(user?.value ?? "") ? { activeUserId: user.value } : {}),
221
+ }
222
+ } catch (error) {
223
+ problems.push(`${where}: ${error instanceof SessionError ? error.message : "could not be read"}`)
224
+ }
225
+ }
226
+ return undefined
227
+ }
228
+ const readLive = async (group) => {
229
+ // Ports are asked per group, so no root is asked twice and a slow explicit
230
+ // port does not add the default browser's timeout on top of its own.
231
+ if (!group.length) return undefined
232
+ try {
233
+ return await findDevtoolsSession({ ...options, stores: group })
234
+ } catch (error) {
235
+ problems.push(error instanceof SessionError ? error.message : "the browser's debugging port could not be read")
236
+ return undefined
237
+ }
238
+ }
239
+ // A directory named in the environment overrides the desktop's own browsers
240
+ // entirely for its turn: its store first, then its port, before any default
241
+ // store is opened. Without a named directory the order is unchanged.
242
+ const named = configuredRoots(options.env ?? process.env).map((entry) => entry.root)
243
+ const priority = stores.filter((store) => named.some((root) => sameRoot(store.root, root)))
244
+ const rest = stores.filter((store) => !named.some((root) => sameRoot(store.root, root)))
245
+ for (const group of [priority, rest]) {
246
+ const disk = await readDisk(group)
247
+ if (disk) return disk
248
+ const live = await readLive(group)
249
+ if (live) return live
250
+ }
251
+ throw new SessionError(problems.join("; "), "no-session")
252
+ }
253
+
254
+ // findDevtoolsSession asks a running Chromium for its own cookies, over the
255
+ // port the user data directory's DevToolsActivePort names. It reads, never
256
+ // writes, and only answers for a directory that has such a port; otherwise it
257
+ // says nothing.
258
+ export async function findDevtoolsSession(options = {}) {
259
+ const stores = (options.stores ?? browserStores(options)).filter((store) => store.kind === "chromium")
260
+ const problems = []
261
+ const asked = new Set()
262
+ for (const store of stores) {
263
+ // One answer per browser: the port belongs to the profile the browser
264
+ // opened first, and every other profile is read from its own store anyway.
265
+ if (asked.has(store.root)) continue
266
+ asked.add(store.root)
267
+ const endpoint = devtoolsEndpoint(store)
268
+ if (!endpoint) continue
269
+ const named = stores.find((other) => other.root === store.root && other.profile === "Default") ?? store
270
+ const where = `${named.browser} (${named.profile})`
271
+ try {
272
+ const cookies = (await (options.connect ?? connectDevtools)(endpoint, options.devtoolsTimeout ?? DEVTOOLS_TIMEOUT, options.devtoolsConnectTimeout ?? DEVTOOLS_CONNECT_TIMEOUT))
273
+ .filter((cookie) => NAMES.includes(cookie?.name) && notionHost(cookie?.domain) && cookie.value)
274
+ const best = bestCookies(cookies)
275
+ if (!best.some((cookie) => cookie.name === "token_v2")) {
276
+ problems.push(`${where} is signed in to no Notion account`)
277
+ continue
278
+ }
279
+ const user = best.find((cookie) => cookie.name === "notion_user_id")
280
+ return {
281
+ cookie: cookieHeader(best),
282
+ browser: named.browser,
283
+ profile: named.profile,
284
+ via: "devtools",
285
+ ...(UUID.test(user?.value ?? "") ? { activeUserId: user.value } : {}),
286
+ }
287
+ } catch (error) {
288
+ problems.push(`${where}: ${error instanceof SessionError ? error.message : "the browser's debugging port did not answer"}`)
289
+ }
290
+ }
291
+ if (!problems.length) return undefined
292
+ throw new SessionError(problems.join("; "), "no-session")
293
+ }
294
+
295
+ // devtoolsEndpoint is the address of the user data directory's DevTools server,
296
+ // which the browser writes down while it runs: the port on one line, the path
297
+ // on the next. A container sidecar can be on another host, so it may write a
298
+ // DevToolsHost beside the port; without that file the server is local.
299
+ function devtoolsEndpoint(store) {
300
+ try {
301
+ const [port, path] = readFileSync(join(store.root, "DevToolsActivePort"), "utf8").split(/\r?\n/)
302
+ if (!/^\d+$/.test(String(port).trim()) || !String(path ?? "").startsWith("/devtools/")) return undefined
303
+ let host = "127.0.0.1"
304
+ try {
305
+ const named = readFileSync(join(store.root, "DevToolsHost"), "utf8").split(/\r?\n/, 1)[0].trim()
306
+ if (/^[A-Za-z0-9._:[\]-]+$/.test(named)) host = named
307
+ } catch {
308
+ // The default local host is the desktop browser's address.
309
+ }
310
+ const authority = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host
311
+ return `ws://${authority}:${String(port).trim()}${String(path).trim()}`
312
+ } catch {
313
+ return undefined
314
+ }
315
+ }
316
+
317
+ // connectDevtools asks the browser for every cookie it holds for the notion
318
+ // hosts. The reply is the browser's own answer, so the values are plain: no
319
+ // store, no key and no decryption are involved.
320
+ //
321
+ // A browser that has been sitting idle can take several seconds to answer on
322
+ // this port even though the socket opens at once, and an answer that never
323
+ // comes is not a refusal: the read is tried twice before the caller is told,
324
+ // so one slow moment does not read as "not signed in".
325
+ async function connectDevtools(endpoint, timeout, connectTimeout = DEVTOOLS_CONNECT_TIMEOUT) {
326
+ let failure
327
+ for (let attempt = 0; attempt < 2; attempt++) {
328
+ try {
329
+ return await askDevtools(endpoint, timeout, connectTimeout)
330
+ } catch (error) {
331
+ failure = error
332
+ if (error?.reason !== "devtools-timeout") break
333
+ }
334
+ }
335
+ throw failure
336
+ }
337
+
338
+ function askDevtools(endpoint, timeout, connectTimeout) {
339
+ return new Promise((resolve, reject) => {
340
+ if (typeof WebSocket !== "function") return reject(new SessionError("this runtime cannot open a browser's debugging port", "unsupported-runtime"))
341
+ let socket
342
+ try { socket = new WebSocket(endpoint) } catch { return reject(new SessionError("the browser's debugging port could not be opened", "devtools")) }
343
+ let settled = false
344
+ let timer
345
+ const finish = (value) => {
346
+ if (settled) return
347
+ settled = true
348
+ clearTimeout(timer)
349
+ socket.close?.()
350
+ value instanceof Error ? reject(value) : resolve(value)
351
+ }
352
+ const wait = (ms, message) => {
353
+ clearTimeout(timer)
354
+ timer = setTimeout(() => finish(new SessionError(message, "devtools-timeout")), ms)
355
+ }
356
+ wait(connectTimeout, "the browser's debugging port could not be reached")
357
+ socket.addEventListener("error", () => finish(new SessionError("the browser's debugging port refused the read", "devtools")))
358
+ socket.addEventListener("message", (event) => {
359
+ let message
360
+ try { message = JSON.parse(event.data) } catch { return }
361
+ if (message?.id !== 1) return
362
+ if (message.error) finish(new SessionError(`the browser refused to name its cookies (${message.error.message})`, "devtools"))
363
+ else finish(message.result?.cookies ?? [])
364
+ })
365
+ socket.addEventListener("open", () => {
366
+ if (settled) return
367
+ wait(timeout, "the browser's debugging port did not answer")
368
+ socket.send(JSON.stringify({ id: 1, method: "Storage.getCookies", params: {} }))
369
+ })
370
+ })
371
+ }
372
+
373
+ // notionPriority ranks the domains a Notion cookie can come from. The web
374
+ // client's session lives on app.notion.com; cookie rows from the older
375
+ // notion.so site can still be beside it, and two values for one name must
376
+ // never both be sent, since nothing would say which one wins.
377
+ function notionPriority(host) {
378
+ const value = String(host ?? "").toLowerCase().replace(/^\./, "")
379
+ if (value === "app.notion.com") return 0
380
+ if (value === "notion.com" || value.endsWith(".notion.com")) return 1
381
+ return 2
382
+ }
383
+
384
+ // bestCookies keeps one cookie per name - the one from the domain closest to
385
+ // the web client - in the order the names were first found.
386
+ export function bestCookies(cookies) {
387
+ const chosen = new Map()
388
+ for (const cookie of cookies) {
389
+ if (!cookie?.name || !cookie.value) continue
390
+ const best = chosen.get(cookie.name)
391
+ if (!best || notionPriority(cookie.host ?? cookie.domain) < notionPriority(best.host ?? best.domain)) chosen.set(cookie.name, cookie)
392
+ }
393
+ const emitted = new Set()
394
+ const result = []
395
+ for (const cookie of cookies) {
396
+ if (!chosen.has(cookie?.name) || emitted.has(cookie.name)) continue
397
+ emitted.add(cookie.name)
398
+ result.push(chosen.get(cookie.name))
399
+ }
400
+ return result
401
+ }
402
+
403
+ // cookieHeader is the Cookie request header the web client would send: the
404
+ // Notion cookies the store holds, once per name, in the order they were found.
405
+ export function cookieHeader(cookies) {
406
+ return bestCookies(cookies).map((cookie) => `${cookie.name}=${cookie.value}`).join("; ")
407
+ }
408
+
409
+ async function readStore(store, options) {
410
+ if (!store.db) throw new SessionError("this profile has no cookie store to read", "no-store")
411
+ const copy = options.copyStore ?? copyToTemporary
412
+ const temporary = copy(store)
413
+ try {
414
+ const database = await openDatabase(temporary.path, options)
415
+ try {
416
+ const rows = store.kind === "firefox" ? all(database, "SELECT name, value, host FROM moz_cookies WHERE host LIKE '%notion%' AND name IN ('token_v2','notion_user_id','notion_browser_id','notion_device_id','notion_os','notion_check_cookie_consent')") : all(database, "SELECT name, encrypted_value, host_key AS host FROM cookies WHERE host_key LIKE '%notion%' AND name IN ('token_v2','notion_user_id','notion_browser_id','notion_device_id','notion_os','notion_check_cookie_consent')")
417
+ const key = store.kind === "firefox" || rows.every((row) => !row.encrypted_value) ? undefined : await valueKey(store, options)
418
+ const cookies = []
419
+ for (const row of rows) {
420
+ if (!notionHost(row.host) || !NAMES.includes(row.name)) continue
421
+ let value
422
+ try {
423
+ value = store.kind === "firefox" ? String(row.value ?? "") : decryptValue(toBuffer(row.encrypted_value), key, options)
424
+ } catch (error) {
425
+ // One row this build cannot open - a v20 value beside v10 ones - must
426
+ // not throw away the rows that did open. Only the session itself is
427
+ // allowed to fail the whole store.
428
+ if (row.name === "token_v2") throw error
429
+ continue
430
+ }
431
+ if (value) cookies.push({ name: row.name, value, host: row.host })
432
+ }
433
+ return cookies
434
+ } finally {
435
+ database.close?.()
436
+ }
437
+ } finally {
438
+ if (temporary.remove) temporary.remove()
439
+ }
440
+ }
441
+
442
+ // discard removes a copy the reader is done with. A browser, an indexer or an
443
+ // antivirus can hold a file for a moment longer; failing to take a copy back is
444
+ // never a reason for a sign-in to fail, so a failed attempt is retried in the
445
+ // background instead of leaving the session copy on disk for good.
446
+ function discard(directory) {
447
+ const attempt = () => {
448
+ try {
449
+ rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
450
+ return true
451
+ } catch {
452
+ return false
453
+ }
454
+ }
455
+ if (attempt()) return true
456
+ let tries = 0
457
+ const retry = () => {
458
+ if (attempt() || ++tries >= 10) return
459
+ const timer = setTimeout(retry, 1000)
460
+ timer.unref?.()
461
+ }
462
+ const timer = setTimeout(retry, 250)
463
+ timer.unref?.()
464
+ return false
465
+ }
466
+
467
+ function toBuffer(value) {
468
+ if (value instanceof Uint8Array) return Buffer.from(value)
469
+ if (typeof value === "string") return Buffer.from(value, "latin1")
470
+ return Buffer.alloc(0)
471
+ }
472
+
473
+ // copyToTemporary copies a store out of the browser's data directory. Windows
474
+ // browsers open the Cookies database so that nothing else may read it, which a
475
+ // running browser reports as EBUSY/EPERM; that is the one failure the caller
476
+ // can fix by closing the browser, so it gets its own words.
477
+ export function copyToTemporary(store, options = {}) {
478
+ const copy = options.copyFile ?? copyFileSync
479
+ const directory = mkdtempSync(join(tmpdir(), "magpie-notion-"))
480
+ const path = join(directory, "cookies.db")
481
+ try {
482
+ copy(store.db, path)
483
+ } catch (error) {
484
+ discard(directory)
485
+ const code = error?.code
486
+ if (code === "EBUSY" || code === "EPERM" || code === "EACCES") {
487
+ throw new SessionError(`${store.browser} is running and holds its cookie store open; close ${store.browser} and sign in again`, "locked")
488
+ }
489
+ throw new SessionError(error?.message ?? "the cookie store could not be copied", "unreadable")
490
+ }
491
+ return { path, remove: () => discard(directory) }
492
+ }
493
+
494
+ // A store is read with whichever SQLite the runtime has: Bun's Database
495
+ // answers query(), node:sqlite's DatabaseSync answers prepare(). Reading a
496
+ // store must not depend on being hosted by one of them.
497
+ function all(database, sql) {
498
+ return typeof database.query === "function" ? database.query(sql).all() : database.prepare(sql).all()
499
+ }
500
+
501
+ export async function openDatabase(path, options = {}) {
502
+ if (options.openDatabase) return options.openDatabase(path)
503
+ try {
504
+ const { Database } = await import("bun:sqlite")
505
+ return new Database(path, { readonly: true })
506
+ } catch (error) {
507
+ try {
508
+ const { DatabaseSync } = await import("node:sqlite")
509
+ return new DatabaseSync(path, { readOnly: true })
510
+ } catch {
511
+ throw new SessionError(order(error), "unreadable")
512
+ }
513
+ }
514
+ }
515
+
516
+ function order(error) {
517
+ return error?.message ? `the cookie store could not be opened: ${error.message}` : "the cookie store could not be opened"
518
+ }
519
+
520
+ // valueKey is the AES key the store's values were written with, taken from
521
+ // Local State (Windows) or from the desktop's keychain (macOS, Linux).
522
+ async function valueKey(store, options) {
523
+ const platform = options.platform ?? process.platform
524
+ const run = options.run ?? runFile
525
+ let state = {}
526
+ try {
527
+ state = JSON.parse(readFileSync(store.localState, "utf8"))
528
+ } catch {
529
+ state = {}
530
+ }
531
+ if (platform === "win32") {
532
+ const sealed = String(state?.os_crypt?.encrypted_key ?? "")
533
+ if (!sealed) throw new SessionError("the profile's Local State holds no cookie key", "unsupported-encryption")
534
+ const raw = Buffer.from(sealed, "base64")
535
+ if (raw.subarray(0, 5).toString("latin1") !== "DPAPI") throw new SessionError("the profile's cookie key is not DPAPI", "unsupported-encryption")
536
+ return dpapi(raw.subarray(5), run)
537
+ }
538
+ const secret = platform === "darwin" ? await keychainSecret(store, run) : await secretServiceKey(store, run)
539
+ if (secret) return { cipher: "cbc", key: pbkdf2Sync(secret, "saltysalt", platform === "darwin" ? 1003 : 1, 16, "sha1") }
540
+ const keychainError = new SessionError("the desktop's keychain did not give up its cookie key", "unsupported-encryption")
541
+ if (platform !== "linux") throw keychainError
542
+ // Chromium's basic store uses this fixed passphrase and the same Linux CBC
543
+ // format. Keep the original reason if it cannot open the value, because the
544
+ // store may still need the desktop's own sign-in service.
545
+ return { cipher: "cbc", key: pbkdf2Sync(BASIC_STORE_PASSPHRASE, "saltysalt", 1, 16, "sha1"), fallbackError: keychainError }
546
+ }
547
+
548
+ async function dpapi(blob, run) {
549
+ // The script is written to PowerShell's standard input. PowerShell's
550
+ // command-line parsing is not involved, so nothing in it can be misread as
551
+ // part of the script and the sealed key never reaches a command line.
552
+ const script = `Add-Type -AssemblyName System.Security; [Convert]::ToBase64String([System.Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String('${blob.toString("base64")}'),$null,'CurrentUser'))`
553
+ let out
554
+ try {
555
+ out = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "-"], { input: script, windowsHide: true })
556
+ } catch {
557
+ throw new SessionError("Windows refused to unprotect the profile's cookie key", "unsupported-encryption")
558
+ }
559
+ const key = Buffer.from(String(out).trim(), "base64")
560
+ if (key.length !== 32) throw new SessionError("the profile's cookie key is not 32 bytes", "unsupported-encryption")
561
+ return { cipher: "gcm", key }
562
+ }
563
+
564
+ // The name each desktop files a browser's cookie key under: the product name in
565
+ // the macOS keychain, the package's application name for secret-tool on Linux.
566
+ // Neither is the browser's display name, and asking under the wrong one either
567
+ // finds nothing or finds another browser's key.
568
+ const SAFE_STORAGE = {
569
+ Chrome: { darwin: "Chrome Safe Storage", linux: "chrome" },
570
+ Chromium: { darwin: "Chromium Safe Storage", linux: "chromium" },
571
+ Brave: { darwin: "Brave Safe Storage", linux: "brave" },
572
+ Edge: { darwin: "Microsoft Edge Safe Storage", linux: "microsoft-edge" },
573
+ }
574
+
575
+ async function keychainSecret(store, run) {
576
+ const service = SAFE_STORAGE[store.browser]?.darwin ?? `${store.browser} Safe Storage`
577
+ try {
578
+ return (await run("/usr/bin/security", ["find-generic-password", "-w", service])).trim()
579
+ } catch {
580
+ return ""
581
+ }
582
+ }
583
+
584
+ async function secretServiceKey(store, run) {
585
+ const application = SAFE_STORAGE[store.browser]?.linux ?? store.browser.toLowerCase()
586
+ try {
587
+ return (await run("secret-tool", ["lookup", "application", application])).trim()
588
+ } catch {
589
+ return ""
590
+ }
591
+ }
592
+
593
+ // decryptValue opens one Chromium cookie value: "v10"/"v11" as Windows and the
594
+ // desktops write them, a bare DPAPI blob as Chrome wrote before v80, and
595
+ // nothing at all for the v20 app-bound values Chrome and Edge write now.
596
+ export function decryptValue(value, secret, options = {}) {
597
+ const platform = options.platform ?? process.platform
598
+ if (!value?.length) return ""
599
+ const prefix = value.subarray(0, 3).toString("latin1")
600
+ if (prefix === "v20") {
601
+ throw new SessionError("this store's cookies are bound to the browser by the elevation service (v20), which needs administrator rights to open; paste the Cookie header instead", "unsupported-encryption")
602
+ }
603
+ if (!secret) throw new SessionError("the store's cookie key is missing", "unsupported-encryption")
604
+ if (prefix === "v10" || prefix === "v11") {
605
+ if (platform === "win32" || secret.cipher === "gcm") return gcm(value.subarray(3), secret.key)
606
+ try {
607
+ return cbc(value.subarray(3), secret.key)
608
+ } catch (error) {
609
+ throw secret.fallbackError ?? error
610
+ }
611
+ }
612
+ // Chrome before v80 sealed each value with DPAPI on its own. Those builds
613
+ // are years old; a value that is not marked with a version is reported, not
614
+ // guessed at.
615
+ throw new SessionError("the cookie value uses an encryption this build does not know", "unsupported-encryption")
616
+ }
617
+
618
+ function gcm(payload, key) {
619
+ if (payload.length < 28) throw new SessionError("the cookie value is too short", "unsupported-encryption")
620
+ const nonce = payload.subarray(0, 12)
621
+ const tag = payload.subarray(payload.length - 16)
622
+ const body = payload.subarray(12, payload.length - 16)
623
+ try {
624
+ const decipher = createDecipheriv("aes-256-gcm", key, nonce)
625
+ decipher.setAuthTag(tag)
626
+ return Buffer.concat([decipher.update(body), decipher.final()]).toString("utf8")
627
+ } catch {
628
+ throw new SessionError("the cookie value could not be unsealed", "unsupported-encryption")
629
+ }
630
+ }
631
+
632
+ function cbc(payload, key) {
633
+ try {
634
+ const decipher = createDecipheriv("aes-128-cbc", key, Buffer.alloc(16, " "))
635
+ return Buffer.concat([decipher.update(payload), decipher.final()]).toString("utf8")
636
+ } catch {
637
+ throw new SessionError("the cookie value could not be unsealed", "unsupported-encryption")
638
+ }
639
+ }
640
+
641
+ // runFile runs a helper program and hands it `input` on standard input where it
642
+ // takes one. Its output is the answer; a helper that ends unsuccessfully is a
643
+ // refusal, with nothing of what it printed of its own kept.
644
+ function runFile(file, args, options = {}) {
645
+ const { input = "", ...rest } = options
646
+ return new Promise((resolve, reject) => {
647
+ const child = spawn(file, args, { ...rest, windowsHide: true })
648
+ let out = ""
649
+ child.stdout.setEncoding("utf8")
650
+ child.stdout.on("data", (chunk) => { out += chunk })
651
+ child.stderr?.resume()
652
+ child.on("error", reject)
653
+ child.on("close", (code) => (code === 0 ? resolve(out) : reject(new Error(`${file} refused (${code})`))))
654
+ child.stdin.on("error", () => {})
655
+ child.stdin.end(input)
656
+ })
657
+ }
658
+
659
+ export const _internal = { UUID, HOSTS, NAMES, notionHost, sameRoot, configuredRoots, chromiumRoots, firefoxRoots, readStore, valueKey, devtoolsEndpoint, connectDevtools, BASIC_STORE_PASSPHRASE, gcm, cbc, runFile, toBuffer, all }