@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/LICENSE +21 -0
- package/README.md +104 -2
- package/check.mjs +72 -0
- package/core.mjs +239 -0
- package/index.mjs +4 -0
- package/notion.svg +25 -0
- package/package.json +48 -4
- package/protocol.mjs +826 -0
- package/session.mjs +659 -0
- package/transcript.mjs +190 -0
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 }
|