@andespindola/brainlink 1.6.11 → 1.6.13

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.13
4
+
5
+ - **Admin impersonation for the exposed graph.** New opt-in `GET /api/admin-session?token=<jwt>` on `brainlink server --expose`: a control-plane can open a tenant's graph authenticated WITHOUT the web password by presenting a short-lived HS256 JWT signed with `BRAINLINK_ADMIN_SESSION_SECRET` (claims bind `sub`/`aud` to the server's web user; `exp` ≤ 600s). Off by default — when the secret is unset the endpoint 404s and nothing changes. On success it mints the same session cookie as `/api/login` and redirects to `/`.
6
+
7
+ ## 1.6.12
8
+
9
+ - **Wiki links keep a `#` that is part of the title.** A link target such as `[[note (PR #34)]]` was truncated at the `#` — the parser always treated `#` as the start of a heading anchor (`[[Note#Heading]]`) — so the link resolved to a non-existent `note (PR ` title and surfaced as a broken link. A `#` is now treated as a heading anchor only when it directly follows the note name with no whitespace before it; a `#` preceded by a space is kept as part of the title. Existing `[[Note#Heading]]` anchor links keep resolving to `Note`.
10
+
3
11
  ## 1.6.1
4
12
 
5
13
  - **Fix the broken README logo on npm.** The logo was referenced via a `raw.githubusercontent.com` URL, which returns 404 for anonymous requests because the source repository is private — so it never rendered on the npm package page. Point it at the public npm CDN (`cdn.jsdelivr.net/npm/...`), which serves the published package's assets regardless of repository visibility, and refresh the brain-as-a-graph artwork.
@@ -0,0 +1,125 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ // Admin impersonation for the exposed graph server.
3
+ //
4
+ // An external control-plane (the brainlink-saas admin) can open a tenant's
5
+ // graph authenticated WITHOUT the tenant's web password by redirecting the
6
+ // admin's browser to `GET /api/admin-session?token=<jwt>`. The token is a
7
+ // short-lived, compact HS256 JWT that the server verifies against a shared
8
+ // secret. This is OPT-IN and OFF by default: with no secret configured the
9
+ // endpoint is disabled and nothing about the existing auth changes.
10
+ //
11
+ // Token contract (so the SaaS can mint compatible tokens):
12
+ // - Format: compact JWT `base64url(header).base64url(payload).base64url(sig)`.
13
+ // - Header: { "alg": "HS256", "typ": "JWT" } — alg MUST be HS256.
14
+ // - Payload claims:
15
+ // - `sub` and/or `aud`: MUST equal the server's configured web user
16
+ // (BRAINLINK_WEB_USER). At least one must be present and match — this
17
+ // binds the token to THIS tenant so it cannot be replayed elsewhere.
18
+ // - `iat`: issued-at, seconds since epoch.
19
+ // - `exp`: expiry, seconds since epoch. REQUIRED, must be in the future,
20
+ // and MUST be within `maxTtlSeconds` (600s) of `iat` — long-lived
21
+ // tokens are rejected server-side.
22
+ // - Signature: HMAC-SHA256 over `base64url(header).base64url(payload)` using
23
+ // the raw UTF-8 bytes of BRAINLINK_ADMIN_SESSION_SECRET.
24
+ export const ADMIN_SESSION_SECRET_ENV = 'BRAINLINK_ADMIN_SESSION_SECRET';
25
+ // Server-side cap on how far in the future an admin token may be valid. A token
26
+ // whose lifetime (exp - iat) exceeds this is rejected regardless of signature.
27
+ export const adminSessionMaxTtlSeconds = 600;
28
+ const isNonEmptyString = (value) => typeof value === 'string' && value.length > 0;
29
+ // The signing secret for admin impersonation, or null when the feature is
30
+ // disabled (no secret configured). Read from env exactly like the other
31
+ // exposed-server auth material, so no new wiring through the CLI is required.
32
+ export const getAdminSessionSecret = () => {
33
+ const secret = process.env[ADMIN_SESSION_SECRET_ENV]?.trim();
34
+ return isNonEmptyString(secret) ? secret : null;
35
+ };
36
+ const constantTimeEqual = (a, b) => {
37
+ const actual = Buffer.from(a, 'utf8');
38
+ const expected = Buffer.from(b, 'utf8');
39
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
40
+ };
41
+ const parseHeader = (value) => {
42
+ if (typeof value !== 'object' || value === null) {
43
+ return null;
44
+ }
45
+ const candidate = value;
46
+ return isNonEmptyString(candidate.alg) ? { alg: candidate.alg } : null;
47
+ };
48
+ const parseClaims = (value) => {
49
+ if (typeof value !== 'object' || value === null) {
50
+ return null;
51
+ }
52
+ const candidate = value;
53
+ if (typeof candidate.iat !== 'number' || typeof candidate.exp !== 'number') {
54
+ return null;
55
+ }
56
+ if (!Number.isFinite(candidate.iat) || !Number.isFinite(candidate.exp)) {
57
+ return null;
58
+ }
59
+ const sub = isNonEmptyString(candidate.sub) ? candidate.sub : undefined;
60
+ const aud = isNonEmptyString(candidate.aud) ? candidate.aud : undefined;
61
+ return {
62
+ ...(sub === undefined ? {} : { sub }),
63
+ ...(aud === undefined ? {} : { aud }),
64
+ iat: candidate.iat,
65
+ exp: candidate.exp
66
+ };
67
+ };
68
+ const decodeSegment = (segment) => {
69
+ const json = Buffer.from(segment, 'base64url').toString('utf8');
70
+ return JSON.parse(json);
71
+ };
72
+ // Verify a compact HS256 admin-session token. Returns true only when the
73
+ // signature is valid, the algorithm is HS256, the token is bound to
74
+ // `expectedUser` via `sub`/`aud`, it is unexpired, and its lifetime is within
75
+ // the server-side cap. Never throws and never leaks token/secret detail.
76
+ export const verifyAdminSessionToken = (token, secret, expectedUser, nowMs, maxTtlSeconds = adminSessionMaxTtlSeconds) => {
77
+ try {
78
+ if (!isNonEmptyString(token) || !isNonEmptyString(secret) || !isNonEmptyString(expectedUser)) {
79
+ return false;
80
+ }
81
+ const parts = token.split('.');
82
+ if (parts.length !== 3) {
83
+ return false;
84
+ }
85
+ const [headerB64, payloadB64, signature] = parts;
86
+ if (!isNonEmptyString(headerB64) || !isNonEmptyString(payloadB64) || !isNonEmptyString(signature)) {
87
+ return false;
88
+ }
89
+ const header = parseHeader(decodeSegment(headerB64));
90
+ if (header === null || header.alg !== 'HS256') {
91
+ return false;
92
+ }
93
+ const expectedSignature = createHmac('sha256', secret)
94
+ .update(`${headerB64}.${payloadB64}`)
95
+ .digest('base64url');
96
+ if (!constantTimeEqual(signature, expectedSignature)) {
97
+ return false;
98
+ }
99
+ const claims = parseClaims(decodeSegment(payloadB64));
100
+ if (claims === null) {
101
+ return false;
102
+ }
103
+ // Bind the token to this server's web user: at least one of sub/aud must be
104
+ // present and equal to the configured user, so a token minted for one tenant
105
+ // cannot authenticate a different tenant's server.
106
+ const audienceMatches = (claims.sub !== undefined && constantTimeEqual(claims.sub, expectedUser)) ||
107
+ (claims.aud !== undefined && constantTimeEqual(claims.aud, expectedUser));
108
+ if (!audienceMatches) {
109
+ return false;
110
+ }
111
+ // exp must be present, in the future, and the token lifetime must not exceed
112
+ // the server-side cap (reject long-lived tokens even if correctly signed).
113
+ const nowSeconds = Math.floor(nowMs / 1000);
114
+ if (claims.exp <= nowSeconds) {
115
+ return false;
116
+ }
117
+ if (claims.exp - claims.iat > maxTtlSeconds) {
118
+ return false;
119
+ }
120
+ return true;
121
+ }
122
+ catch {
123
+ return false;
124
+ }
125
+ };
@@ -24,6 +24,7 @@ import { createClientWorkerJs } from '../frontend/client-worker-js.js';
24
24
  import { createClientRenderWorkerJs } from '../frontend/client-render-worker-js.js';
25
25
  import { createLoginPageHtml, createChangePasswordPageHtml } from '../frontend/login-page.js';
26
26
  import { buildClearSessionCookie, buildSessionSetCookie, changeWebPassword, createSessionToken, parseCookies, resolveWebAuth, SESSION_COOKIE_NAME, verifySessionToken, verifyWebPassword } from './web-auth.js';
27
+ import { getAdminSessionSecret, verifyAdminSessionToken } from './admin-session.js';
27
28
  import { getGraphVersion } from './graph-version.js';
28
29
  import { readAuthStatus, readEditableSettings, updateEditableSettings } from './settings-store.js';
29
30
  import { connectVaultRemote, ensureDeployKey, readVersioningStatus } from './versioning.js';
@@ -124,6 +125,26 @@ const runAuthGate = async (request, url) => {
124
125
  }
125
126
  return createResponse(createJsonResponse({ error: 'Invalid username or password' }), 401, contentTypes['.json']);
126
127
  }
128
+ // Admin impersonation (opt-in, off by default). When BRAINLINK_ADMIN_SESSION_SECRET
129
+ // is configured, an external control-plane can open this tenant's graph by
130
+ // redirecting the admin's browser to /api/admin-session?token=<jwt>. A valid,
131
+ // short-lived, audience-bound token mints a normal session cookie — identical
132
+ // to /api/login — and 302-redirects to the graph. Without the secret the
133
+ // endpoint is disabled and indistinguishable from any other unknown path.
134
+ if (isReadMethod(request) && url.pathname === '/api/admin-session') {
135
+ const secret = getAdminSessionSecret();
136
+ if (secret === null) {
137
+ return createResponse(createJsonResponse({ error: 'Not found' }), 404, contentTypes['.json']);
138
+ }
139
+ const token = url.searchParams.get('token') ?? '';
140
+ if (!verifyAdminSessionToken(token, secret, auth.user, Date.now())) {
141
+ return createResponse(createJsonResponse({ error: 'Unauthorized' }), 401, contentTypes['.json']);
142
+ }
143
+ const sessionToken = createSessionToken(auth, sessionTtlMs, Date.now());
144
+ return withHeaders(redirectResponse('/'), {
145
+ 'set-cookie': buildSessionSetCookie(sessionToken, { secure: requestIsSecure(request), ttlMs: sessionTtlMs })
146
+ });
147
+ }
127
148
  if (request.method === 'POST' && url.pathname === '/api/logout') {
128
149
  return withHeaders(createResponse(createJsonResponse({ ok: true }), 200, contentTypes['.json']), {
129
150
  'set-cookie': buildClearSessionCookie()
@@ -3,7 +3,11 @@ import { resolveAgentIdFromPath, sanitizeAgentId } from './agents.js';
3
3
  import { createStableId } from './ids.js';
4
4
  import { estimateTokenCount } from './tokens.js';
5
5
  const frontmatterPattern = /^---\n([\s\S]*?)\n---\n?/;
6
- const wikiLinkPattern = /\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]/g;
6
+ // Target captures everything up to an optional "|alias" or the closing "]]",
7
+ // INCLUDING "#" — the heading anchor is stripped later, and only when it is a
8
+ // real anchor (see stripHeadingAnchor). Keeping "#" here means a title such as
9
+ // "note (PR #34)" is not truncated at the "#".
10
+ const wikiLinkPattern = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
7
11
  const tagPattern = /(^|\s)#([A-Za-z0-9][A-Za-z0-9_-]*)/g;
8
12
  const headingPattern = /^#\s+(.+)$/m;
9
13
  const contextLinksHeadingPattern = /^(#{1,6})\s+(?:context\s+links?|links?\s+de\s+contexto)\b/i;
@@ -30,7 +34,16 @@ const priorityPatterns = [
30
34
  ['low', /\b(?:priority|prioridade|importance|importancia|importância)\s*[:=]\s*(?:low|baixa|p3)\b/i],
31
35
  ['low', /#(?:low-priority|baixa-prioridade|p3)\b/i]
32
36
  ];
33
- const normalizeTitle = (title) => title.trim().replace(/\.md$/i, '');
37
+ // A "#" starts a heading anchor ([[Note#Heading]]) only when it directly
38
+ // follows the note name with NO whitespace before it. A "#" preceded by a space
39
+ // (e.g. "note (PR #34)") is part of the title itself and must not be stripped,
40
+ // otherwise the link target is truncated and can never resolve.
41
+ const headingAnchorPattern = /(?<!\s)#[^\]|]*$/;
42
+ const stripHeadingAnchor = (title) => {
43
+ const stripped = title.replace(headingAnchorPattern, '');
44
+ return stripped.trim().length > 0 ? stripped : title;
45
+ };
46
+ const normalizeTitle = (title) => stripHeadingAnchor(title.trim()).trim().replace(/\.md$/i, '');
34
47
  const unique = (values) => Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
35
48
  const maxPriority = (left, right) => priorityRanks[left] >= priorityRanks[right] ? left : right;
36
49
  const parseFrontmatter = (content) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andespindola/brainlink",
3
- "version": "1.6.11",
3
+ "version": "1.6.13",
4
4
  "description": "Local-first knowledge memory for agents with Markdown, backlinks, indexing and context retrieval.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -64,9 +64,10 @@
64
64
  "zod": "^4.3.6"
65
65
  },
66
66
  "overrides": {
67
- "hono": "4.12.26",
67
+ "hono": "4.12.28",
68
68
  "qs": "6.15.2",
69
- "fast-uri": "^4.1.0"
69
+ "fast-uri": "^4.1.0",
70
+ "body-parser": "2.3.0"
70
71
  },
71
72
  "devDependencies": {
72
73
  "@types/node": "^24.9.2",