@andespindola/brainlink 1.6.4 → 1.6.5

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 CHANGED
@@ -563,6 +563,7 @@ Available tools:
563
563
  - `brainlink_vault_provision`: create a private GitHub repository for the vault with the authenticated `gh` CLI, wire it as `origin`, push the first snapshot, and enable `autoVersion` so later indexed changes are committed and pushed automatically. By default the repository is **encrypted** (`vaultEncryption`): it holds AES-256-GCM ciphertext, decryptable only by a Brainlink instance with the local key.
564
564
  - `brainlink_vault_status` / `brainlink_vault_init` / `brainlink_vault_commit` / `brainlink_vault_history` / `brainlink_vault_pull` / `brainlink_vault_restore`: version the vault's canonical Markdown with git (the derived index is git-ignored). Pull and restore reindex changed files automatically so the vault stays immediately searchable.
565
565
  - `brainlink_vault_export` / `brainlink_vault_import`: write and read a portable `.blvault` snapshot (gzip envelope of the Markdown with per-file SHA-256). Import verifies checksums, preserves divergent local files as `*.conflict-<ts>.md`, and reindexes.
566
+ - `brainlink_vault_export_bundle` / `brainlink_vault_bundle_list` / `brainlink_vault_import_bundle`: export **every known vault** (the config vault plus `allowedVaults`) into a single portable `.blbundle`, inspect the vaults inside a bundle, and import them back — all vaults to their recorded paths, or a single one via `only` (optionally into `targetVault`). Same checksum verification and conflict preservation as the single-vault snapshot.
566
567
  - `brainlink_vault_merge_agents`: losslessly merge one agent namespace into another within the same vault (e.g. `claude-code` → `shared`). Identical notes are skipped, colliding slugs with different content are kept under a `-from-<agent>` name, everything else is moved, and merged notes are aggregated into domain `<context> Hub` notes. Source notes are kept unless `deleteSource` is set; `dryRun` previews the plan.
567
568
  - `brainlink_graph`: read indexed graph nodes and weighted links.
568
569
  - `brainlink_graph_contexts`: list the visual graph contexts used by the local server.
@@ -966,6 +967,11 @@ blink vault-status
966
967
  # Portable snapshot (self-contained, index excluded; import rebuilds it)
967
968
  blink vault-export -o vault.blvault
968
969
  blink vault-import vault.blvault # conflicting local edits preserved as *.conflict-<ts>.md
970
+
971
+ # Multi-vault bundle: every known vault (config vault + allowedVaults) in one file
972
+ blink vault-export --all -o vaults.blbundle
973
+ blink vault-import vaults.blbundle # restore all vaults to their recorded paths
974
+ blink vault-import vaults.blbundle --only shared --vault ./restored # restore just one
969
975
  ```
970
976
 
971
977
  Inline credentials in a remote URL are rejected — use SSH or a git credential helper.
@@ -1223,6 +1229,18 @@ When `BRAINLINK_MCP_TOKEN` or `--token` is set, MCP requests must include:
1223
1229
  Authorization: Bearer <token>
1224
1230
  ```
1225
1231
 
1232
+ ### OAuth (for browser clients like ChatGPT web)
1233
+
1234
+ Some clients — notably ChatGPT's web connectors — cannot present a static API key; they only authenticate via OAuth 2.0. Add `--oauth` to run Brainlink as an OAuth 2.0 authorization server (authorization code + PKCE + dynamic client registration) in front of `/mcp`:
1235
+
1236
+ ```bash
1237
+ BRAINLINK_MCP_TOKEN="change-me" \
1238
+ BRAINLINK_WEB_USER="you" BRAINLINK_WEB_PASSWORD="change-me" \
1239
+ brainlink mcp-server --vault /data/vault --host 0.0.0.0 --port 3333 --oauth
1240
+ ```
1241
+
1242
+ With `--oauth` the server also serves `/.well-known/oauth-authorization-server`, `/.well-known/oauth-protected-resource`, `/register`, `/authorize` (a branded consent page authenticated with the same `BRAINLINK_WEB_USER`/`BRAINLINK_WEB_PASSWORD` credentials), and `/token`. It signs stateless access/refresh tokens with `BRAINLINK_OAUTH_SECRET` (falling back to `BRAINLINK_MCP_TOKEN`); only the registered clients are persisted (`<brainlink-home>/oauth-clients.json`). The two schemes coexist: `/mcp` accepts a valid OAuth access token **or** the static bearer token, so ChatGPT connects via OAuth while Codex/Claude/API clients keep using the static token. Run it behind a TLS-terminating reverse proxy — the public `https` issuer is derived from the forwarded `Host`/`X-Forwarded-Proto`.
1243
+
1226
1244
  For Kubernetes, run Brainlink as a central `Deployment` with a `Service` and a mounted vault volume:
1227
1245
 
1228
1246
  ```yaml
@@ -6,6 +6,7 @@ import { startServer } from '../../../application/start-server.js';
6
6
  import { resolveWebAuth } from '../../../application/server/web-auth.js';
7
7
  import { isBucketVaultPath } from '../../../infrastructure/file-system-vault.js';
8
8
  import { startRemoteMcpServer } from '../../../mcp/http-server.js';
9
+ import { getOAuthSecret } from '../../../mcp/oauth.js';
9
10
  import { parsePositiveInteger, print, resolveOptions } from '../../runtime.js';
10
11
  const spawnDetached = (command, args, envOverrides) => {
11
12
  try {
@@ -538,12 +539,22 @@ export const registerServerCommands = (program) => {
538
539
  .option('-p, --port <port>', 'remote MCP server port', '3333')
539
540
  .option('--path <path>', 'remote MCP endpoint path', '/mcp')
540
541
  .option('--token <token>', 'bearer token required for MCP requests')
542
+ .option('--oauth', 'enable the OAuth 2.0 authorization server (auth code + PKCE + dynamic client registration) for browser clients like ChatGPT web')
541
543
  .option('--no-index', 'skip indexing before starting the MCP server')
542
544
  .option('--json', 'print machine-readable JSON')
543
545
  .description('start a remote MCP server for centralized cluster access')
544
546
  .action(async (options) => {
545
547
  const resolved = await resolveOptions(options);
546
548
  const token = options.token ?? process.env.BRAINLINK_MCP_TOKEN;
549
+ const oauth = options.oauth === true;
550
+ // OAuth needs a signing secret (BRAINLINK_OAUTH_SECRET or BRAINLINK_MCP_TOKEN)
551
+ // and the shared web-auth credentials to authenticate the consent step.
552
+ if (oauth && getOAuthSecret() === null) {
553
+ throw new Error('OAuth requires a signing secret: set BRAINLINK_OAUTH_SECRET or BRAINLINK_MCP_TOKEN.');
554
+ }
555
+ if (oauth && (await resolveWebAuth()) === null) {
556
+ throw new Error('OAuth requires login credentials for the consent screen: set BRAINLINK_WEB_USER and BRAINLINK_WEB_PASSWORD.');
557
+ }
547
558
  const server = await startRemoteMcpServer({
548
559
  vaultPath: resolved.vault,
549
560
  agent: resolved.agent,
@@ -551,7 +562,8 @@ export const registerServerCommands = (program) => {
551
562
  port: parsePositiveInteger(options.port ?? '3333', 3333),
552
563
  path: options.path ?? '/mcp',
553
564
  token,
554
- shouldIndex: options.index
565
+ shouldIndex: options.index,
566
+ oauth
555
567
  });
556
568
  print(options.json, {
557
569
  url: server.url,
@@ -559,7 +571,7 @@ export const registerServerCommands = (program) => {
559
571
  readyUrl: server.readyUrl,
560
572
  vault: resolved.vault,
561
573
  agent: resolved.agent ?? '*',
562
- auth: token === undefined ? 'disabled' : 'bearer'
574
+ auth: oauth ? (token === undefined ? 'oauth' : 'oauth+bearer') : token === undefined ? 'disabled' : 'bearer'
563
575
  }, () => `Brainlink remote MCP server running at ${server.url} (health: ${server.healthUrl}, readiness: ${server.readyUrl})`);
564
576
  });
565
577
  };
@@ -1,8 +1,13 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
3
3
  import { indexVault } from '../application/index-vault.js';
4
+ import { resolveWebAuth, verifyWebPassword } from '../application/server/web-auth.js';
4
5
  import { createBrainlinkMcpServer } from './server.js';
6
+ import { createAuthorizePageHtml } from './oauth-authorize-page.js';
7
+ import { buildAuthorizationServerMetadata, buildProtectedResourceMetadata, consumeAuthCode, getClient, getOAuthSecret, issueAuthCode, issueToken, registerClient, verifyPkceS256, verifyToken } from './oauth.js';
5
8
  import { runStartupBootstrap } from './startup.js';
9
+ const accessTokenTtlSec = 60 * 60;
10
+ const refreshTokenTtlSec = 30 * 24 * 60 * 60;
6
11
  const normalizePath = (path) => {
7
12
  const trimmed = path.trim();
8
13
  if (trimmed.length === 0) {
@@ -10,19 +15,222 @@ const normalizePath = (path) => {
10
15
  }
11
16
  return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
12
17
  };
13
- const writeJson = (response, statusCode, value) => {
18
+ const writeJson = (response, statusCode, value, extraHeaders = {}) => {
14
19
  response.writeHead(statusCode, {
15
20
  'content-type': 'application/json; charset=utf-8',
16
- 'cache-control': 'no-store'
21
+ 'cache-control': 'no-store',
22
+ ...extraHeaders
17
23
  });
18
24
  response.end(`${JSON.stringify(value)}\n`);
19
25
  };
26
+ const writeHtml = (response, statusCode, html) => {
27
+ response.writeHead(statusCode, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
28
+ response.end(html);
29
+ };
30
+ const redirect = (response, location) => {
31
+ response.writeHead(302, { location, 'cache-control': 'no-store' });
32
+ response.end();
33
+ };
34
+ const firstHeader = (value) => Array.isArray(value) ? value[0] : value;
20
35
  const readBearerToken = (authorization) => {
21
- const value = Array.isArray(authorization) ? authorization[0] : authorization;
36
+ const value = firstHeader(authorization);
22
37
  const match = value?.match(/^Bearer\s+(.+)$/i);
23
38
  return match?.[1]?.trim();
24
39
  };
25
- const isAuthorized = (expectedToken, authorization) => expectedToken === undefined || readBearerToken(authorization) === expectedToken;
40
+ // Behind a TLS-terminating proxy the process speaks http on loopback, so the
41
+ // public issuer is reconstructed from the forwarded scheme + host. This must be
42
+ // the exact origin the client reached, or OAuth discovery/redirects break.
43
+ const deriveIssuer = (request, fallbackHost) => {
44
+ const scheme = firstHeader(request.headers['x-forwarded-proto'])?.split(',')[0]?.trim() || 'http';
45
+ const host = firstHeader(request.headers['x-forwarded-host']) || firstHeader(request.headers.host) || fallbackHost;
46
+ return `${scheme}://${host}`;
47
+ };
48
+ const readBody = async (request, limitBytes = 1_000_000) => {
49
+ let body = '';
50
+ for await (const chunk of request) {
51
+ body += String(chunk);
52
+ if (Buffer.byteLength(body, 'utf8') > limitBytes) {
53
+ throw Object.assign(new Error('Request body too large'), { statusCode: 413 });
54
+ }
55
+ }
56
+ return body;
57
+ };
58
+ const parseBody = (contentType, body) => {
59
+ if (body.trim().length === 0) {
60
+ return {};
61
+ }
62
+ if ((contentType ?? '').includes('application/json')) {
63
+ const parsed = JSON.parse(body);
64
+ return typeof parsed === 'object' && parsed !== null ? parsed : {};
65
+ }
66
+ return Object.fromEntries(new URLSearchParams(body));
67
+ };
68
+ const stringField = (value) => (typeof value === 'string' ? value : '');
69
+ const buildRedirectUrl = (redirectUri, params) => {
70
+ const url = new URL(redirectUri);
71
+ for (const [key, value] of Object.entries(params)) {
72
+ if (value !== undefined && value.length > 0) {
73
+ url.searchParams.set(key, value);
74
+ }
75
+ }
76
+ return url.toString();
77
+ };
78
+ const readAuthorizeParams = (source) => {
79
+ const get = (key) => source instanceof URLSearchParams ? source.get(key) ?? '' : stringField(source[key]);
80
+ return {
81
+ responseType: get('response_type'),
82
+ clientId: get('client_id'),
83
+ redirectUri: get('redirect_uri'),
84
+ scope: get('scope') || 'mcp',
85
+ state: get('state'),
86
+ codeChallenge: get('code_challenge'),
87
+ codeChallengeMethod: get('code_challenge_method')
88
+ };
89
+ };
90
+ const hiddenFields = (params) => ({
91
+ response_type: params.responseType,
92
+ client_id: params.clientId,
93
+ redirect_uri: params.redirectUri,
94
+ scope: params.scope,
95
+ state: params.state,
96
+ code_challenge: params.codeChallenge,
97
+ code_challenge_method: params.codeChallengeMethod
98
+ });
99
+ // Validate the client + redirect_uri before trusting the redirect target. On
100
+ // failure the caller renders an HTML error — it must never redirect to an
101
+ // unvalidated URI.
102
+ const resolveAuthorizeClient = async (params) => {
103
+ if (params.clientId.length === 0 || params.redirectUri.length === 0) {
104
+ return { ok: false };
105
+ }
106
+ const client = await getClient(params.clientId);
107
+ if (!client || !client.redirect_uris.includes(params.redirectUri)) {
108
+ return { ok: false };
109
+ }
110
+ return { ok: true, clientName: client.client_name };
111
+ };
112
+ const handleOAuthRoute = async (request, response, url, issuer, mcpPath) => {
113
+ const method = request.method ?? 'GET';
114
+ if (method === 'GET' && url.pathname === '/.well-known/oauth-authorization-server') {
115
+ writeJson(response, 200, buildAuthorizationServerMetadata(issuer));
116
+ return true;
117
+ }
118
+ if (method === 'GET' &&
119
+ (url.pathname === '/.well-known/oauth-protected-resource' ||
120
+ url.pathname === `/.well-known/oauth-protected-resource${mcpPath}`)) {
121
+ writeJson(response, 200, buildProtectedResourceMetadata(issuer, mcpPath));
122
+ return true;
123
+ }
124
+ if (method === 'POST' && url.pathname === '/register') {
125
+ try {
126
+ const body = parseBody(firstHeader(request.headers['content-type']), await readBody(request));
127
+ const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris.map(String) : [];
128
+ const client = await registerClient({ redirect_uris: redirectUris, client_name: stringField(body.client_name) || undefined });
129
+ writeJson(response, 201, client);
130
+ }
131
+ catch {
132
+ writeJson(response, 400, { error: 'invalid_client_metadata' });
133
+ }
134
+ return true;
135
+ }
136
+ if (url.pathname === '/authorize') {
137
+ const source = method === 'POST'
138
+ ? parseBody(firstHeader(request.headers['content-type']), await readBody(request))
139
+ : url.searchParams;
140
+ const params = readAuthorizeParams(source);
141
+ const client = await resolveAuthorizeClient(params);
142
+ if (!client.ok) {
143
+ writeHtml(response, 400, createAuthorizePageHtml({ error: 'Invalid or unregistered client.', hidden: {} }));
144
+ return true;
145
+ }
146
+ if (params.responseType !== 'code' || params.codeChallenge.length === 0 || params.codeChallengeMethod !== 'S256') {
147
+ redirect(response, buildRedirectUrl(params.redirectUri, { error: 'invalid_request', state: params.state }));
148
+ return true;
149
+ }
150
+ if (method === 'GET') {
151
+ writeHtml(response, 200, createAuthorizePageHtml({ clientName: client.clientName, hidden: hiddenFields(params) }));
152
+ return true;
153
+ }
154
+ // POST: authenticate the user against the shared web-auth credentials.
155
+ const body = source;
156
+ const auth = await resolveWebAuth();
157
+ const user = stringField(body.user);
158
+ const password = stringField(body.password);
159
+ if (!auth || user !== auth.user || !verifyWebPassword(password, auth.passwordHash)) {
160
+ writeHtml(response, 401, createAuthorizePageHtml({ clientName: client.clientName, error: 'Invalid username or password.', hidden: hiddenFields(params) }));
161
+ return true;
162
+ }
163
+ const code = issueAuthCode({
164
+ client_id: params.clientId,
165
+ redirect_uri: params.redirectUri,
166
+ code_challenge: params.codeChallenge,
167
+ user: auth.user,
168
+ scope: params.scope
169
+ });
170
+ redirect(response, buildRedirectUrl(params.redirectUri, { code, state: params.state }));
171
+ return true;
172
+ }
173
+ if (method === 'POST' && url.pathname === '/token') {
174
+ const secret = getOAuthSecret();
175
+ if (secret === null) {
176
+ writeJson(response, 500, { error: 'server_error' });
177
+ return true;
178
+ }
179
+ const body = parseBody(firstHeader(request.headers['content-type']), await readBody(request));
180
+ const grantType = stringField(body.grant_type);
181
+ if (grantType === 'authorization_code') {
182
+ const payload = consumeAuthCode(stringField(body.code));
183
+ if (!payload ||
184
+ payload.client_id !== stringField(body.client_id) ||
185
+ payload.redirect_uri !== stringField(body.redirect_uri) ||
186
+ !verifyPkceS256(stringField(body.code_verifier), payload.code_challenge)) {
187
+ writeJson(response, 400, { error: 'invalid_grant' });
188
+ return true;
189
+ }
190
+ writeJson(response, 200, {
191
+ access_token: issueToken(secret, { sub: payload.user, client_id: payload.client_id, scope: payload.scope, typ: 'access', ttlSec: accessTokenTtlSec }),
192
+ token_type: 'Bearer',
193
+ expires_in: accessTokenTtlSec,
194
+ refresh_token: issueToken(secret, { sub: payload.user, client_id: payload.client_id, scope: payload.scope, typ: 'refresh', ttlSec: refreshTokenTtlSec }),
195
+ scope: payload.scope
196
+ });
197
+ return true;
198
+ }
199
+ if (grantType === 'refresh_token') {
200
+ const claims = verifyToken(secret, stringField(body.refresh_token), 'refresh');
201
+ if (!claims) {
202
+ writeJson(response, 400, { error: 'invalid_grant' });
203
+ return true;
204
+ }
205
+ writeJson(response, 200, {
206
+ access_token: issueToken(secret, { sub: claims.sub, client_id: claims.client_id, scope: claims.scope, typ: 'access', ttlSec: accessTokenTtlSec }),
207
+ token_type: 'Bearer',
208
+ expires_in: accessTokenTtlSec,
209
+ scope: claims.scope
210
+ });
211
+ return true;
212
+ }
213
+ writeJson(response, 400, { error: 'unsupported_grant_type' });
214
+ return true;
215
+ }
216
+ return false;
217
+ };
218
+ const isMcpAuthorized = (input, authorization) => {
219
+ const bearer = readBearerToken(authorization);
220
+ if (input.token === undefined && !input.oauth) {
221
+ return true;
222
+ }
223
+ if (input.token !== undefined && bearer !== undefined && bearer === input.token) {
224
+ return true;
225
+ }
226
+ if (input.oauth) {
227
+ const secret = getOAuthSecret();
228
+ if (secret !== null && bearer !== undefined && verifyToken(secret, bearer, 'access') !== null) {
229
+ return true;
230
+ }
231
+ }
232
+ return false;
233
+ };
26
234
  export const startRemoteMcpServer = async (input) => {
27
235
  const mcpPath = normalizePath(input.path);
28
236
  const startup = await runStartupBootstrap();
@@ -47,12 +255,27 @@ export const startRemoteMcpServer = async (input) => {
47
255
  });
48
256
  return;
49
257
  }
258
+ try {
259
+ if (input.oauth && (await handleOAuthRoute(request, response, url, deriveIssuer(request, `${input.host}:${input.port}`), mcpPath))) {
260
+ return;
261
+ }
262
+ }
263
+ catch (error) {
264
+ const message = error instanceof Error ? error.message : String(error);
265
+ writeJson(response, 500, { error: message });
266
+ return;
267
+ }
50
268
  if (url.pathname !== mcpPath) {
51
269
  writeJson(response, 404, { error: 'Not found' });
52
270
  return;
53
271
  }
54
- if (!isAuthorized(input.token, request.headers.authorization)) {
55
- writeJson(response, 401, { error: 'Unauthorized' });
272
+ if (!isMcpAuthorized(input, request.headers.authorization)) {
273
+ const wwwAuthenticate = input.oauth
274
+ ? {
275
+ 'www-authenticate': `Bearer resource_metadata="${deriveIssuer(request, `${input.host}:${input.port}`)}/.well-known/oauth-protected-resource"`
276
+ }
277
+ : {};
278
+ writeJson(response, 401, { error: 'Unauthorized' }, wwwAuthenticate);
56
279
  return;
57
280
  }
58
281
  try {
@@ -0,0 +1,199 @@
1
+ // Self-contained branded OAuth authorization/consent page for the Brainlink MCP
2
+ // server. Served as a plain string (inline <style>, no external assets, no
3
+ // framework, no JavaScript) so it works identically for a global npm install.
4
+ // The visual identity mirrors the graph client and auth pages (see
5
+ // client-css.ts / login-page.ts): dark theme on #08131d, the same grid/radial
6
+ // gradient, Crowquill Mono with a monospace fallback, and the #5aa8ff accent.
7
+ // HTML-escape dynamic values. clientName arrives from an untrusted OAuth client
8
+ // and hidden values carry OAuth params, so both must never allow HTML injection.
9
+ const escapeHtml = (value) => value
10
+ .replace(/&/g, '&amp;')
11
+ .replace(/</g, '&lt;')
12
+ .replace(/>/g, '&gt;')
13
+ .replace(/"/g, '&quot;')
14
+ .replace(/'/g, '&#39;');
15
+ const authorizeStyles = () => `:root {
16
+ color-scheme: dark;
17
+ --bg: #08131d;
18
+ --panel: #0d1823;
19
+ --panel-strong: #112130;
20
+ --line: rgba(143, 172, 204, 0.18);
21
+ --text: #edf4ff;
22
+ --muted: #97a9bd;
23
+ --accent: #5aa8ff;
24
+ --accent-weak: rgba(90, 168, 255, 0.18);
25
+ --danger: #ff6b6b;
26
+ }
27
+
28
+ * {
29
+ box-sizing: border-box;
30
+ }
31
+
32
+ html,
33
+ body {
34
+ width: 100%;
35
+ min-height: 100%;
36
+ margin: 0;
37
+ }
38
+
39
+ body {
40
+ display: flex;
41
+ align-items: center;
42
+ justify-content: center;
43
+ min-height: 100vh;
44
+ min-height: 100dvh;
45
+ padding: 24px;
46
+ color: var(--text);
47
+ font-family: "Crowquill Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
48
+ background:
49
+ linear-gradient(rgba(164, 197, 230, 0.05) 1px, transparent 1px),
50
+ linear-gradient(90deg, rgba(164, 197, 230, 0.05) 1px, transparent 1px),
51
+ radial-gradient(circle at top, rgba(43, 93, 143, 0.22), transparent 44%),
52
+ #08131d;
53
+ background-size: 28px 28px, 28px 28px, auto;
54
+ }
55
+
56
+ button,
57
+ input {
58
+ font: inherit;
59
+ }
60
+
61
+ .auth-card {
62
+ width: 100%;
63
+ max-width: 400px;
64
+ padding: 32px 28px;
65
+ border: 1px solid var(--line);
66
+ border-radius: 14px;
67
+ background: rgba(13, 24, 35, 0.92);
68
+ box-shadow: 0 24px 80px rgba(1, 6, 13, 0.48);
69
+ backdrop-filter: blur(8px);
70
+ }
71
+
72
+ .auth-brand {
73
+ display: grid;
74
+ gap: 4px;
75
+ margin-bottom: 20px;
76
+ }
77
+
78
+ .auth-brand strong {
79
+ font-size: 26px;
80
+ letter-spacing: 0.01em;
81
+ }
82
+
83
+ .auth-brand span {
84
+ color: var(--muted);
85
+ font-size: 13px;
86
+ }
87
+
88
+ .auth-consent {
89
+ margin: 0 0 20px;
90
+ padding: 12px 14px;
91
+ border: 1px solid var(--line);
92
+ border-radius: 10px;
93
+ background: rgba(12, 24, 36, 0.6);
94
+ color: var(--text);
95
+ font-size: 13px;
96
+ line-height: 1.5;
97
+ overflow-wrap: anywhere;
98
+ }
99
+
100
+ .auth-consent strong {
101
+ color: var(--accent);
102
+ }
103
+
104
+ .auth-form {
105
+ display: grid;
106
+ gap: 16px;
107
+ }
108
+
109
+ .auth-field {
110
+ display: grid;
111
+ gap: 7px;
112
+ }
113
+
114
+ .auth-field label {
115
+ color: var(--muted);
116
+ font-size: 12px;
117
+ letter-spacing: 0.02em;
118
+ }
119
+
120
+ .auth-field input {
121
+ width: 100%;
122
+ height: 42px;
123
+ padding: 0 14px;
124
+ border: 1px solid var(--line);
125
+ border-radius: 8px;
126
+ outline: none;
127
+ background: rgba(12, 24, 36, 0.94);
128
+ color: var(--text);
129
+ }
130
+
131
+ .auth-field input:focus {
132
+ border-color: var(--accent);
133
+ }
134
+
135
+ .auth-submit {
136
+ height: 44px;
137
+ margin-top: 4px;
138
+ border: 1px solid var(--accent);
139
+ border-radius: 8px;
140
+ background: var(--accent-weak);
141
+ color: var(--text);
142
+ cursor: pointer;
143
+ transition: background 120ms ease, opacity 120ms ease;
144
+ }
145
+
146
+ .auth-submit:hover,
147
+ .auth-submit:focus {
148
+ background: rgba(90, 168, 255, 0.3);
149
+ }
150
+
151
+ .auth-message {
152
+ margin: 0;
153
+ font-size: 12px;
154
+ line-height: 1.4;
155
+ overflow-wrap: anywhere;
156
+ color: var(--danger);
157
+ }`;
158
+ const consentLine = (clientName) => {
159
+ const label = clientName && clientName.length > 0 ? escapeHtml(clientName) : 'An application';
160
+ return `<strong>${label}</strong> wants to access your Brainlink knowledge graph.`;
161
+ };
162
+ const errorBlock = (error) => error && error.length > 0
163
+ ? ` <p class="auth-message" role="alert">${escapeHtml(error)}</p>\n`
164
+ : '';
165
+ const hiddenInputs = (hidden) => Object.keys(hidden)
166
+ .map((key) => ` <input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(hidden[key])}" />`)
167
+ .join('\n');
168
+ export const createAuthorizePageHtml = (input) => {
169
+ const hidden = hiddenInputs(input.hidden);
170
+ return `<!DOCTYPE html>
171
+ <html lang="en">
172
+ <head>
173
+ <meta charset="utf-8" />
174
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
175
+ <title>Authorize access · Brainlink</title>
176
+ <style>${authorizeStyles()}</style>
177
+ </head>
178
+ <body>
179
+ <main class="auth-card">
180
+ <div class="auth-brand">
181
+ <strong>Brainlink</strong>
182
+ <span>Authorize access</span>
183
+ </div>
184
+ <p class="auth-consent">${consentLine(input.clientName)}</p>
185
+ <form class="auth-form" method="post" action="/authorize">
186
+ ${hidden ? `${hidden}\n` : ''} <div class="auth-field">
187
+ <label for="user">Username</label>
188
+ <input id="user" name="user" type="text" autocomplete="username" autofocus required />
189
+ </div>
190
+ <div class="auth-field">
191
+ <label for="password">Password</label>
192
+ <input id="password" name="password" type="password" autocomplete="current-password" required />
193
+ </div>
194
+ ${errorBlock(input.error)} <button class="auth-submit" type="submit">Authorize</button>
195
+ </form>
196
+ </main>
197
+ </body>
198
+ </html>`;
199
+ };
@@ -0,0 +1,197 @@
1
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { getBrainlinkHomePath } from '../infrastructure/paths.js';
5
+ const isNonEmptyString = (value) => typeof value === 'string' && value.length > 0;
6
+ export const getOAuthSecret = () => process.env.BRAINLINK_OAUTH_SECRET?.trim() || process.env.BRAINLINK_MCP_TOKEN?.trim() || null;
7
+ export const buildAuthorizationServerMetadata = (issuer) => ({
8
+ issuer,
9
+ authorization_endpoint: `${issuer}/authorize`,
10
+ token_endpoint: `${issuer}/token`,
11
+ registration_endpoint: `${issuer}/register`,
12
+ response_types_supported: ['code'],
13
+ grant_types_supported: ['authorization_code', 'refresh_token'],
14
+ code_challenge_methods_supported: ['S256'],
15
+ token_endpoint_auth_methods_supported: ['none'],
16
+ scopes_supported: ['mcp']
17
+ });
18
+ export const buildProtectedResourceMetadata = (issuer, mcpPath) => ({
19
+ resource: `${issuer}${mcpPath}`,
20
+ authorization_servers: [issuer]
21
+ });
22
+ const constantTimeEqual = (a, b) => {
23
+ const actual = Buffer.from(a, 'utf8');
24
+ const expected = Buffer.from(b, 'utf8');
25
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
26
+ };
27
+ export const verifyPkceS256 = (verifier, challenge) => {
28
+ try {
29
+ if (!isNonEmptyString(verifier) || !isNonEmptyString(challenge)) {
30
+ return false;
31
+ }
32
+ const computed = createHash('sha256').update(verifier).digest('base64url');
33
+ return constantTimeEqual(computed, challenge);
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ };
39
+ const getClientStorePath = () => join(getBrainlinkHomePath(), 'oauth-clients.json');
40
+ const isValidRedirectUri = (value) => {
41
+ if (!isNonEmptyString(value)) {
42
+ return false;
43
+ }
44
+ try {
45
+ const url = new URL(value);
46
+ if (url.protocol === 'https:') {
47
+ return true;
48
+ }
49
+ return url.protocol === 'http:' && url.hostname === 'localhost';
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ };
55
+ const parseClient = (value) => {
56
+ if (typeof value !== 'object' || value === null) {
57
+ return null;
58
+ }
59
+ const candidate = value;
60
+ if (!isNonEmptyString(candidate.client_id)) {
61
+ return null;
62
+ }
63
+ if (!Array.isArray(candidate.redirect_uris) ||
64
+ !candidate.redirect_uris.every(isNonEmptyString)) {
65
+ return null;
66
+ }
67
+ if (candidate.token_endpoint_auth_method !== 'none') {
68
+ return null;
69
+ }
70
+ if (typeof candidate.client_id_issued_at !== 'number') {
71
+ return null;
72
+ }
73
+ const client_name = isNonEmptyString(candidate.client_name) ? candidate.client_name : undefined;
74
+ return {
75
+ client_id: candidate.client_id,
76
+ redirect_uris: candidate.redirect_uris,
77
+ ...(client_name === undefined ? {} : { client_name }),
78
+ token_endpoint_auth_method: 'none',
79
+ client_id_issued_at: candidate.client_id_issued_at
80
+ };
81
+ };
82
+ const loadClients = async () => {
83
+ try {
84
+ const raw = await readFile(getClientStorePath(), 'utf8');
85
+ const parsed = JSON.parse(raw);
86
+ if (!Array.isArray(parsed)) {
87
+ return [];
88
+ }
89
+ return parsed.map(parseClient).filter((client) => client !== null);
90
+ }
91
+ catch {
92
+ return [];
93
+ }
94
+ };
95
+ const persistClients = async (clients) => {
96
+ const storePath = getClientStorePath();
97
+ await mkdir(dirname(storePath), { recursive: true, mode: 0o700 });
98
+ await writeFile(storePath, JSON.stringify(clients, null, 2), { mode: 0o600 });
99
+ };
100
+ export const registerClient = async (input) => {
101
+ if (!Array.isArray(input.redirect_uris) ||
102
+ input.redirect_uris.length === 0 ||
103
+ !input.redirect_uris.every(isValidRedirectUri)) {
104
+ throw new Error('invalid redirect_uris');
105
+ }
106
+ const client_name = isNonEmptyString(input.client_name) ? input.client_name : undefined;
107
+ const client = {
108
+ client_id: randomBytes(16).toString('hex'),
109
+ redirect_uris: [...input.redirect_uris],
110
+ ...(client_name === undefined ? {} : { client_name }),
111
+ token_endpoint_auth_method: 'none',
112
+ client_id_issued_at: Math.floor(Date.now() / 1000)
113
+ };
114
+ const existing = await loadClients();
115
+ await persistClients([...existing, client]);
116
+ return client;
117
+ };
118
+ export const getClient = async (clientId) => {
119
+ const clients = await loadClients();
120
+ return clients.find((client) => client.client_id === clientId) ?? null;
121
+ };
122
+ const authCodes = new Map();
123
+ export const issueAuthCode = (payload, ttlMs = 60_000) => {
124
+ const code = randomBytes(24).toString('base64url');
125
+ authCodes.set(code, { payload, exp: Date.now() + ttlMs });
126
+ return code;
127
+ };
128
+ export const consumeAuthCode = (code) => {
129
+ const stored = authCodes.get(code);
130
+ if (stored === undefined) {
131
+ return null;
132
+ }
133
+ authCodes.delete(code);
134
+ return Date.now() < stored.exp ? stored.payload : null;
135
+ };
136
+ const parseTokenClaims = (value) => {
137
+ if (typeof value !== 'object' || value === null) {
138
+ return null;
139
+ }
140
+ const candidate = value;
141
+ if (!isNonEmptyString(candidate.sub) || !isNonEmptyString(candidate.client_id)) {
142
+ return null;
143
+ }
144
+ if (typeof candidate.scope !== 'string') {
145
+ return null;
146
+ }
147
+ if (candidate.typ !== 'access' && candidate.typ !== 'refresh') {
148
+ return null;
149
+ }
150
+ if (typeof candidate.iat !== 'number' || typeof candidate.exp !== 'number') {
151
+ return null;
152
+ }
153
+ return {
154
+ sub: candidate.sub,
155
+ client_id: candidate.client_id,
156
+ scope: candidate.scope,
157
+ typ: candidate.typ,
158
+ iat: candidate.iat,
159
+ exp: candidate.exp
160
+ };
161
+ };
162
+ export const issueToken = (secret, input) => {
163
+ const iat = Math.floor(Date.now() / 1000);
164
+ const claims = {
165
+ sub: input.sub,
166
+ client_id: input.client_id,
167
+ scope: input.scope,
168
+ typ: input.typ,
169
+ iat,
170
+ exp: iat + input.ttlSec
171
+ };
172
+ const payloadB64 = Buffer.from(JSON.stringify(claims)).toString('base64url');
173
+ const signature = createHmac('sha256', secret).update(payloadB64).digest('base64url');
174
+ return `${payloadB64}.${signature}`;
175
+ };
176
+ export const verifyToken = (secret, token, expectedTyp) => {
177
+ try {
178
+ const separatorIndex = token.lastIndexOf('.');
179
+ if (separatorIndex <= 0) {
180
+ return null;
181
+ }
182
+ const payloadB64 = token.slice(0, separatorIndex);
183
+ const signature = token.slice(separatorIndex + 1);
184
+ const expectedSignature = createHmac('sha256', secret).update(payloadB64).digest('base64url');
185
+ if (!constantTimeEqual(signature, expectedSignature)) {
186
+ return null;
187
+ }
188
+ const claims = parseTokenClaims(JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8')));
189
+ if (claims === null || claims.typ !== expectedTyp) {
190
+ return null;
191
+ }
192
+ return Math.floor(Date.now() / 1000) < claims.exp ? claims : null;
193
+ }
194
+ catch {
195
+ return null;
196
+ }
197
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "1.6.4",
3
+ "version": "1.6.5",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",