@100xprompt/brain 0.0.0-stage → 0.1.1

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.
Files changed (260) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +235 -2
  3. package/package.json +48 -4
  4. package/src/adapters/aider/index.ts +8 -0
  5. package/src/adapters/aider/reader.ts +129 -0
  6. package/src/adapters/aider/records.ts +220 -0
  7. package/src/adapters/aider/writer.ts +269 -0
  8. package/src/adapters/amazon-q-cli/index.ts +8 -0
  9. package/src/adapters/amazon-q-cli/reader.ts +84 -0
  10. package/src/adapters/amazon-q-cli/records.ts +191 -0
  11. package/src/adapters/amazon-q-cli/writer.ts +370 -0
  12. package/src/adapters/amazon-q-ide/index.ts +9 -0
  13. package/src/adapters/amazon-q-ide/reader.ts +131 -0
  14. package/src/adapters/amazon-q-ide/records.ts +141 -0
  15. package/src/adapters/amazon-q-ide/writer.ts +325 -0
  16. package/src/adapters/antigravity/index.ts +9 -0
  17. package/src/adapters/antigravity/proto.ts +194 -0
  18. package/src/adapters/antigravity/reader.ts +424 -0
  19. package/src/adapters/antigravity/records.ts +236 -0
  20. package/src/adapters/antigravity/writer.ts +463 -0
  21. package/src/adapters/auggie/index.ts +36 -0
  22. package/src/adapters/claude-code/index.ts +8 -0
  23. package/src/adapters/claude-code/reader.ts +639 -0
  24. package/src/adapters/claude-code/records.ts +88 -0
  25. package/src/adapters/claude-code/writer.ts +329 -0
  26. package/src/adapters/cline/index.ts +12 -0
  27. package/src/adapters/cline/reader.ts +432 -0
  28. package/src/adapters/cline/records.ts +151 -0
  29. package/src/adapters/cline/vscdb.ts +56 -0
  30. package/src/adapters/cline/writer.ts +656 -0
  31. package/src/adapters/cline/xml.ts +177 -0
  32. package/src/adapters/cline4/index.ts +9 -0
  33. package/src/adapters/cline4/reader.ts +179 -0
  34. package/src/adapters/cline4/records.ts +92 -0
  35. package/src/adapters/cline4/writer.ts +361 -0
  36. package/src/adapters/codebuff/index.ts +8 -0
  37. package/src/adapters/codebuff/project.ts +207 -0
  38. package/src/adapters/codebuff/reader.ts +215 -0
  39. package/src/adapters/codebuff/records.ts +173 -0
  40. package/src/adapters/codebuff/writer.ts +383 -0
  41. package/src/adapters/codex/index.ts +8 -0
  42. package/src/adapters/codex/native.ts +239 -0
  43. package/src/adapters/codex/reader.ts +747 -0
  44. package/src/adapters/codex/records.ts +114 -0
  45. package/src/adapters/codex/writer.ts +479 -0
  46. package/src/adapters/continue/index.ts +9 -0
  47. package/src/adapters/continue/reader.ts +68 -0
  48. package/src/adapters/continue/records.ts +418 -0
  49. package/src/adapters/continue/writer.ts +162 -0
  50. package/src/adapters/copilot-cli/index.ts +9 -0
  51. package/src/adapters/copilot-cli/reader.ts +186 -0
  52. package/src/adapters/copilot-cli/records.ts +241 -0
  53. package/src/adapters/copilot-cli/writer.ts +353 -0
  54. package/src/adapters/crush/index.ts +8 -0
  55. package/src/adapters/crush/reader.ts +180 -0
  56. package/src/adapters/crush/records.ts +169 -0
  57. package/src/adapters/crush/writer.ts +333 -0
  58. package/src/adapters/cursor-cli/index.ts +405 -0
  59. package/src/adapters/cursor-ide/agentkv.ts +95 -0
  60. package/src/adapters/cursor-ide/index.ts +501 -0
  61. package/src/adapters/cursor-ide/transcripts.ts +122 -0
  62. package/src/adapters/cursor-ide/workspaces.ts +119 -0
  63. package/src/adapters/cursor-ide/writer.ts +170 -0
  64. package/src/adapters/devin/index.ts +8 -0
  65. package/src/adapters/devin/reader.ts +305 -0
  66. package/src/adapters/devin/records.ts +277 -0
  67. package/src/adapters/devin/writer.ts +523 -0
  68. package/src/adapters/factory-droid/index.ts +12 -0
  69. package/src/adapters/factory-droid/reader.ts +184 -0
  70. package/src/adapters/factory-droid/records.ts +79 -0
  71. package/src/adapters/factory-droid/writer.ts +350 -0
  72. package/src/adapters/gemini-cli/exchanges.ts +228 -0
  73. package/src/adapters/gemini-cli/index.ts +396 -0
  74. package/src/adapters/gemini-cli/records.ts +521 -0
  75. package/src/adapters/gemini-cli/writer.ts +354 -0
  76. package/src/adapters/goose/index.ts +8 -0
  77. package/src/adapters/goose/reader.ts +138 -0
  78. package/src/adapters/goose/records.ts +199 -0
  79. package/src/adapters/goose/writer.ts +340 -0
  80. package/src/adapters/grok-cli/image.ts +44 -0
  81. package/src/adapters/grok-cli/index.ts +8 -0
  82. package/src/adapters/grok-cli/reader.ts +302 -0
  83. package/src/adapters/grok-cli/records.ts +172 -0
  84. package/src/adapters/grok-cli/writer.ts +329 -0
  85. package/src/adapters/hermes/index.ts +8 -0
  86. package/src/adapters/hermes/reader.ts +249 -0
  87. package/src/adapters/hermes/records.ts +285 -0
  88. package/src/adapters/hermes/writer.ts +452 -0
  89. package/src/adapters/iflow-cli/index.ts +72 -0
  90. package/src/adapters/iflow-cli/records.ts +215 -0
  91. package/src/adapters/iflow-cli/writer.ts +204 -0
  92. package/src/adapters/index.ts +112 -0
  93. package/src/adapters/kilo-cli/index.ts +21 -0
  94. package/src/adapters/kilo-code/index.ts +11 -0
  95. package/src/adapters/kilo-code/writer.ts +92 -0
  96. package/src/adapters/kimi/index.ts +9 -0
  97. package/src/adapters/kimi/reader.ts +92 -0
  98. package/src/adapters/kimi/records.ts +221 -0
  99. package/src/adapters/kimi/writer.ts +367 -0
  100. package/src/adapters/kiro-cli/index.ts +8 -0
  101. package/src/adapters/kiro-cli/reader.ts +87 -0
  102. package/src/adapters/kiro-cli/records.ts +260 -0
  103. package/src/adapters/kiro-cli/writer.ts +305 -0
  104. package/src/adapters/kiro-ide/index.ts +8 -0
  105. package/src/adapters/kiro-ide/writer.ts +52 -0
  106. package/src/adapters/letta/index.ts +8 -0
  107. package/src/adapters/letta/reader.ts +177 -0
  108. package/src/adapters/letta/records.ts +119 -0
  109. package/src/adapters/letta/writer.ts +413 -0
  110. package/src/adapters/mastracode/index.ts +8 -0
  111. package/src/adapters/mastracode/reader.ts +323 -0
  112. package/src/adapters/mastracode/records.ts +336 -0
  113. package/src/adapters/mastracode/writer.ts +385 -0
  114. package/src/adapters/mistral-vibe/index.ts +9 -0
  115. package/src/adapters/mistral-vibe/reader.ts +62 -0
  116. package/src/adapters/mistral-vibe/records.ts +104 -0
  117. package/src/adapters/mistral-vibe/writer.ts +359 -0
  118. package/src/adapters/muse/index.ts +10 -0
  119. package/src/adapters/muse/reader.ts +332 -0
  120. package/src/adapters/muse/records.ts +104 -0
  121. package/src/adapters/muse/writer.ts +387 -0
  122. package/src/adapters/omp/index.ts +13 -0
  123. package/src/adapters/omp/reader.ts +314 -0
  124. package/src/adapters/omp/records.ts +153 -0
  125. package/src/adapters/omp/writer.ts +216 -0
  126. package/src/adapters/opencode/ids.ts +52 -0
  127. package/src/adapters/opencode/index.ts +31 -0
  128. package/src/adapters/opencode/note.ts +8 -0
  129. package/src/adapters/opencode/reader.ts +452 -0
  130. package/src/adapters/opencode/tools.ts +174 -0
  131. package/src/adapters/opencode/v2.ts +367 -0
  132. package/src/adapters/opencode/writer.ts +379 -0
  133. package/src/adapters/openhands/index.ts +8 -0
  134. package/src/adapters/openhands/reader.ts +222 -0
  135. package/src/adapters/openhands/records.ts +136 -0
  136. package/src/adapters/openhands/writer.ts +404 -0
  137. package/src/adapters/pearai/index.ts +10 -0
  138. package/src/adapters/pi/index.ts +28 -0
  139. package/src/adapters/pi/reader.ts +261 -0
  140. package/src/adapters/pi/records.ts +99 -0
  141. package/src/adapters/pi/writer.ts +362 -0
  142. package/src/adapters/qoder-cli/index.ts +8 -0
  143. package/src/adapters/qoder-cli/reader.ts +83 -0
  144. package/src/adapters/qoder-cli/records.ts +45 -0
  145. package/src/adapters/qoder-cli/writer.ts +72 -0
  146. package/src/adapters/qwen-code/index.ts +84 -0
  147. package/src/adapters/qwen-code/records.ts +194 -0
  148. package/src/adapters/qwen-code/writer.ts +224 -0
  149. package/src/adapters/tabnine-cli/index.ts +8 -0
  150. package/src/adapters/tabnine-cli/reader.ts +106 -0
  151. package/src/adapters/tabnine-cli/records.ts +33 -0
  152. package/src/adapters/tabnine-cli/writer.ts +156 -0
  153. package/src/adapters/types.ts +73 -0
  154. package/src/adapters/void/index.ts +9 -0
  155. package/src/adapters/void/reader.ts +48 -0
  156. package/src/adapters/void/records.ts +285 -0
  157. package/src/adapters/void/writer.ts +93 -0
  158. package/src/adapters/vscode-copilot/index.ts +9 -0
  159. package/src/adapters/vscode-copilot/reader.ts +212 -0
  160. package/src/adapters/vscode-copilot/records.ts +388 -0
  161. package/src/adapters/vscode-copilot/shared.ts +68 -0
  162. package/src/adapters/vscode-copilot/writer.ts +546 -0
  163. package/src/adapters/zed/index.ts +8 -0
  164. package/src/adapters/zed/reader.ts +198 -0
  165. package/src/adapters/zed/records.ts +87 -0
  166. package/src/adapters/zed/writer.ts +341 -0
  167. package/src/brief-writers.ts +71 -0
  168. package/src/brief.ts +238 -0
  169. package/src/cli.ts +351 -0
  170. package/src/dashboard/cli.ts +148 -0
  171. package/src/dashboard/conversions.ts +170 -0
  172. package/src/dashboard/report.ts +261 -0
  173. package/src/dashboard/send.ts +213 -0
  174. package/src/dashboard/state.ts +95 -0
  175. package/src/fsutil.ts +133 -0
  176. package/src/gateway/common.ts +232 -0
  177. package/src/gateway/connect.ts +847 -0
  178. package/src/gateway/connectors/aider.ts +356 -0
  179. package/src/gateway/connectors/amazon-q-cli.ts +6 -0
  180. package/src/gateway/connectors/amazon-q-ide.ts +6 -0
  181. package/src/gateway/connectors/antigravity.ts +6 -0
  182. package/src/gateway/connectors/auggie.ts +6 -0
  183. package/src/gateway/connectors/cline-sdk.ts +85 -0
  184. package/src/gateway/connectors/cline.ts +34 -0
  185. package/src/gateway/connectors/codebuff.ts +6 -0
  186. package/src/gateway/connectors/continue.ts +579 -0
  187. package/src/gateway/connectors/copilot-cli.ts +138 -0
  188. package/src/gateway/connectors/crush.ts +313 -0
  189. package/src/gateway/connectors/cursor-cli.ts +6 -0
  190. package/src/gateway/connectors/cursor.ts +6 -0
  191. package/src/gateway/connectors/devin.ts +6 -0
  192. package/src/gateway/connectors/factory-droid.ts +308 -0
  193. package/src/gateway/connectors/goose.ts +79 -0
  194. package/src/gateway/connectors/grok-cli.ts +82 -0
  195. package/src/gateway/connectors/hermes.ts +230 -0
  196. package/src/gateway/connectors/iflow-cli.ts +50 -0
  197. package/src/gateway/connectors/index.ts +86 -0
  198. package/src/gateway/connectors/kilo-cli.ts +434 -0
  199. package/src/gateway/connectors/kilo-code.ts +32 -0
  200. package/src/gateway/connectors/kimi-code.ts +241 -0
  201. package/src/gateway/connectors/kiro-cli.ts +6 -0
  202. package/src/gateway/connectors/kiro-ide.ts +6 -0
  203. package/src/gateway/connectors/letta-code.ts +170 -0
  204. package/src/gateway/connectors/mastracode.ts +91 -0
  205. package/src/gateway/connectors/mistral-vibe.ts +495 -0
  206. package/src/gateway/connectors/muse.ts +27 -0
  207. package/src/gateway/connectors/omp.ts +279 -0
  208. package/src/gateway/connectors/openhands.ts +296 -0
  209. package/src/gateway/connectors/pearai.ts +52 -0
  210. package/src/gateway/connectors/pi.ts +357 -0
  211. package/src/gateway/connectors/qoder-cli.ts +6 -0
  212. package/src/gateway/connectors/qwen-code.ts +411 -0
  213. package/src/gateway/connectors/roo.ts +43 -0
  214. package/src/gateway/connectors/tabnine-cli.ts +6 -0
  215. package/src/gateway/connectors/void.ts +6 -0
  216. package/src/gateway/connectors/vscode-copilot.ts +286 -0
  217. package/src/gateway/connectors/windsurf.ts +6 -0
  218. package/src/gateway/connectors/zed.ts +29 -0
  219. package/src/gateway/signin.ts +323 -0
  220. package/src/handoff.ts +104 -0
  221. package/src/hooks.ts +455 -0
  222. package/src/memory/audit.ts +514 -0
  223. package/src/memory/blobs.ts +94 -0
  224. package/src/memory/cli.ts +117 -0
  225. package/src/memory/distill.ts +381 -0
  226. package/src/memory/externalize.ts +435 -0
  227. package/src/memory/lookups.ts +70 -0
  228. package/src/memory/mcp-install.ts +401 -0
  229. package/src/memory/mcp-server.ts +296 -0
  230. package/src/memory/prepare.ts +179 -0
  231. package/src/memory/preview.ts +154 -0
  232. package/src/memory/project.ts +35 -0
  233. package/src/memory/redact.ts +72 -0
  234. package/src/memory/relevance.ts +187 -0
  235. package/src/memory/repomap.ts +516 -0
  236. package/src/memory/store.ts +270 -0
  237. package/src/memory/sync.ts +166 -0
  238. package/src/memory/tidy.ts +193 -0
  239. package/src/memory/types.ts +118 -0
  240. package/src/org/admin-ops.ts +96 -0
  241. package/src/org/auth.ts +112 -0
  242. package/src/org/backup.ts +115 -0
  243. package/src/org/cli.ts +438 -0
  244. package/src/org/client.ts +404 -0
  245. package/src/org/config.ts +44 -0
  246. package/src/org/crypto.ts +537 -0
  247. package/src/org/db.ts +435 -0
  248. package/src/org/graph.ts +318 -0
  249. package/src/org/oidc.ts +435 -0
  250. package/src/org/policy.ts +398 -0
  251. package/src/org/server.ts +920 -0
  252. package/src/org/sso.ts +316 -0
  253. package/src/org/tls.ts +66 -0
  254. package/src/org/types.ts +193 -0
  255. package/src/self.ts +68 -0
  256. package/src/session/attachments.ts +53 -0
  257. package/src/session/peekcache.ts +50 -0
  258. package/src/session/types.ts +104 -0
  259. package/src/session/util.ts +130 -0
  260. package/src/sqlite.ts +48 -0
package/src/org/sso.ts ADDED
@@ -0,0 +1,316 @@
1
+ // brain org: single sign-on (OpenID Connect, authorization code + PKCE). The server routes every /v1/auth/* request here, before
2
+ // authentication, because these endpoints are how a person obtains a bearer token.
3
+ //
4
+ // GET /v1/auth/sso/config (no auth) -> {enabled, issuer?, authorizationEndpoint?, clientId?, scopes?}
5
+ // POST /v1/auth/sso/exchange (no auth) {code, codeVerifier, redirectUri, nonce} -> {token, user, expiresAt}
6
+ // GET/PUT /v1/admin/sso (admin; routed from server.ts to getSsoAdmin / putSsoAdmin) <-> SsoConfig
7
+ //
8
+ // The CLIENT only ever talks to the identity provider through the browser. The SERVER trades the code for tokens, VERIFIES the ID
9
+ // token itself (oidc.ts: signature, iss, aud, exp, iat, nonce), then applies the organisation's rules (verified email, allowed
10
+ // domain, role, disabled user) and issues an ordinary brain bearer token. The ID token and the access token are used in memory
11
+ // for one request and never stored, logged, audited or returned. The caller gets one generic answer for every rejection; the real
12
+ // reason goes to the audit log as a short fixed code, with only a keyed hash of the email's domain (never the email, never a token).
13
+ //
14
+ // Decisions: roles from `roleMap`/`defaultRole` apply when a user is CREATED by sign-in; an existing user's role is never changed
15
+ // by a sign-in (admins own roles; an IdP group change cannot silently promote someone). `defaultRole: none` = only existing
16
+ // users may sign in. A user id is the lower-cased email and must already be a valid id (so emails with `+`, non-ASCII, or over 64
17
+ // characters are refused rather than mapped to something ambiguous). The redirect URI must be an http loopback address (the CLI's
18
+ // own listener), so the exchange cannot be pointed at anyone else's page.
19
+ import { createHash, createHmac } from "node:crypto"
20
+ import type { OrgDb } from "./db.ts"
21
+ import { isId } from "./db.ts"
22
+ import { KeyCache, MAX_ID_TOKEN_CHARS, OidcError, basicAuth, emailVerified, endpointOk, fetchJson, isLoopbackHostname, tokenAuthFor, verifyIdToken, type Discovery, type IdClaims } from "./oidc.ts"
23
+ import type { Role, SsoConfig } from "./types.ts"
24
+
25
+ export interface SsoContext {
26
+ db: OrgDb
27
+ now: () => number
28
+ /** The caller's address (already rate limited by the server). */
29
+ ip: string
30
+ }
31
+
32
+ /** A bad admin input: the server turns it into a 400. */
33
+ export class SsoInputError extends Error {}
34
+
35
+ const ROLES: Role[] = ["viewer", "editor", "admin"]
36
+ const roleRank = (r: Role): number => ROLES.indexOf(r)
37
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
38
+ const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/
39
+ const DEFAULT_SCOPES = "openid email profile"
40
+
41
+ // ---- stored configuration ---------------------------------------------------------------------------------------------------
42
+
43
+ export type StoredSso = Omit<SsoConfig, "clientSecret"> & { enabled: boolean }
44
+
45
+ function load(db: OrgDb): StoredSso | undefined {
46
+ const raw = db.ssoRaw()
47
+ if (!raw) return undefined
48
+ try {
49
+ const v = JSON.parse(raw) as StoredSso
50
+ return isObj(v) && typeof v.issuer === "string" ? v : undefined
51
+ } catch {
52
+ return undefined
53
+ }
54
+ }
55
+
56
+ const str = (b: Record<string, unknown>, k: string, max: number): string => {
57
+ const v = b[k]
58
+ if (typeof v !== "string" || !v.trim() || v.length > max || /[\u0000-\u001f\u007f\s]/.test(v)) throw new SsoInputError(`${k} must be a string of 1-${max} characters without spaces or control characters`)
59
+ return v
60
+ }
61
+
62
+ /** Validates an admin's SsoConfig; returns what is stored plus the secret (undefined = keep the old one, "" = remove it). */
63
+ export function validateSsoConfig(body: unknown): { cfg: StoredSso; secret?: string } {
64
+ if (!isObj(body)) throw new SsoInputError("the body must be a JSON object")
65
+ const enabled = body.enabled === undefined ? true : body.enabled
66
+ if (typeof enabled !== "boolean") throw new SsoInputError("enabled must be true or false")
67
+ const issuer = str(body, "issuer", 500)
68
+ const u = endpointOk(issuer)
69
+ if (!u || u.search) throw new SsoInputError("issuer must be an https:// URL (http:// only for a loopback identity provider), without a query or fragment")
70
+ const clientId = str(body, "clientId", 256)
71
+ let secret: string | undefined
72
+ if (body.clientSecret !== undefined) {
73
+ if (typeof body.clientSecret !== "string" || body.clientSecret.length > 2048 || /[\u0000-\u001f\u007f]/.test(body.clientSecret)) throw new SsoInputError("clientSecret must be a string of at most 2048 characters")
74
+ secret = body.clientSecret
75
+ }
76
+ let scopes = DEFAULT_SCOPES
77
+ if (body.scopes !== undefined) {
78
+ const s = typeof body.scopes === "string" ? body.scopes.trim().replace(/\s+/g, " ") : ""
79
+ if (!s || s.length > 500 || !/^[\x21-\x7e]+( [\x21-\x7e]+)*$/.test(s) || !s.split(" ").includes("openid")) throw new SsoInputError("scopes must be space separated and include openid")
80
+ scopes = s
81
+ }
82
+ if (!Array.isArray(body.allowedDomains) || !body.allowedDomains.length || body.allowedDomains.length > 50) throw new SsoInputError("allowedDomains must list 1-50 domains (an empty list would let nobody in)")
83
+ const allowedDomains = [...new Set(body.allowedDomains.map((d) => (typeof d === "string" ? d.trim().toLowerCase() : "")))]
84
+ for (const d of allowedDomains) if (!DOMAIN_RE.test(d) || d.length > 253) throw new SsoInputError(`"${String(d).slice(0, 60)}" is not a domain name (no wildcards, no '@'; e.g. example.com)`)
85
+ const defaultRole = body.defaultRole === undefined ? "viewer" : body.defaultRole
86
+ if (defaultRole !== "none" && !ROLES.includes(defaultRole as Role)) throw new SsoInputError("defaultRole must be viewer, editor, admin or none")
87
+ let roleMap: SsoConfig["roleMap"]
88
+ if (body.roleMap !== undefined) {
89
+ const rm = body.roleMap
90
+ if (!isObj(rm) || typeof rm.claim !== "string" || !/^[A-Za-z0-9_:./-]{1,100}$/.test(rm.claim) || !isObj(rm.map)) throw new SsoInputError("roleMap must be {claim: string, map: {value: role}}")
91
+ const entries = Object.entries(rm.map)
92
+ if (!entries.length || entries.length > 200) throw new SsoInputError("roleMap.map must hold 1-200 entries")
93
+ const map: Record<string, Role> = Object.create(null)
94
+ for (const [k, r] of entries) {
95
+ if (k.length > 256 || !ROLES.includes(r as Role)) throw new SsoInputError("roleMap.map values must be viewer, editor or admin")
96
+ map[k] = r as Role
97
+ }
98
+ roleMap = { claim: rm.claim, map: { ...map } }
99
+ }
100
+ let tokenDays = 30
101
+ if (body.tokenDays !== undefined) {
102
+ if (typeof body.tokenDays !== "number" || !Number.isInteger(body.tokenDays) || body.tokenDays < 1 || body.tokenDays > 365) throw new SsoInputError("tokenDays must be a whole number from 1 to 365")
103
+ tokenDays = body.tokenDays
104
+ }
105
+ if (body.tokenAuth !== undefined && body.tokenAuth !== "client_secret_basic" && body.tokenAuth !== "client_secret_post") throw new SsoInputError("tokenAuth must be client_secret_basic or client_secret_post")
106
+ const tokenAuth = body.tokenAuth as SsoConfig["tokenAuth"]
107
+ return { cfg: { issuer, clientId, scopes, allowedDomains, defaultRole: defaultRole as Role | "none", ...(roleMap ? { roleMap } : {}), tokenDays, ...(tokenAuth ? { tokenAuth } : {}), enabled }, ...(secret !== undefined ? { secret } : {}) }
108
+ }
109
+
110
+ /** GET /v1/admin/sso: the configuration, without the secret (only whether one is set). */
111
+ export function getSsoAdmin(db: OrgDb): Record<string, unknown> {
112
+ const c = load(db)
113
+ if (!c) return { enabled: false, configured: false }
114
+ return { ...c, configured: true, clientSecretSet: !!db.ssoSecret() }
115
+ }
116
+
117
+ /** PUT /v1/admin/sso: validates, stores, audits. A missing clientSecret keeps the old one; "" removes it. */
118
+ export function putSsoAdmin(db: OrgDb, actor: string, body: unknown): Record<string, unknown> {
119
+ const { cfg, secret } = validateSsoConfig(body)
120
+ db.tx(() => {
121
+ db.setSsoRaw(JSON.stringify(cfg))
122
+ if (secret !== undefined) db.setSsoSecret(secret)
123
+ })
124
+ db.audit({ actor, action: cfg.enabled ? "admin.sso.set" : "admin.sso.disable", detail: `issuer=${cfg.issuer} domains=${cfg.allowedDomains.length} defaultRole=${cfg.defaultRole}${secret !== undefined ? " secret=updated" : ""}` })
125
+ return getSsoAdmin(db)
126
+ }
127
+
128
+ // ---- per-database runtime state ---------------------------------------------------------------------------------------------
129
+
130
+ interface Runtime {
131
+ keys: KeyCache
132
+ usedCodes: Map<string, number>
133
+ }
134
+ const runtimes = new WeakMap<OrgDb, Runtime>()
135
+ const CODE_TTL_MS = 10 * 60_000
136
+ function runtime(ctx: SsoContext): Runtime {
137
+ let r = runtimes.get(ctx.db)
138
+ if (!r) runtimes.set(ctx.db, (r = { keys: new KeyCache(ctx.now), usedCodes: new Map() }))
139
+ return r
140
+ }
141
+
142
+ // ---- responses -------------------------------------------------------------------------------------------------------------
143
+
144
+ const headers = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" }
145
+ const reply = (status: number, body: unknown): Response => new Response(JSON.stringify(body), { status, headers })
146
+ const generic = (): Response => reply(401, { error: "sign-in failed", code: "sso_failed" })
147
+
148
+ // ---- endpoints -------------------------------------------------------------------------------------------------------------
149
+
150
+ export async function handleSso(req: Request, ctx: SsoContext): Promise<Response | undefined> {
151
+ const path = new URL(req.url).pathname
152
+ if (path === "/v1/auth/sso/config") {
153
+ if (req.method !== "GET") return reply(405, { error: "use GET", code: "method_not_allowed" })
154
+ return await config(ctx)
155
+ }
156
+ if (path === "/v1/auth/sso/exchange") {
157
+ if (req.method !== "POST") return reply(405, { error: "use POST", code: "method_not_allowed" })
158
+ return await exchange(req, ctx)
159
+ }
160
+ return undefined
161
+ }
162
+
163
+ async function config(ctx: SsoContext): Promise<Response> {
164
+ const c = load(ctx.db)
165
+ if (!c || !c.enabled) return reply(200, { enabled: false })
166
+ try {
167
+ const d = await runtime(ctx).keys.discovery(c.issuer)
168
+ return reply(200, { enabled: true, issuer: c.issuer, authorizationEndpoint: d.authorization_endpoint, clientId: c.clientId, scopes: c.scopes ?? DEFAULT_SCOPES })
169
+ } catch {
170
+ return reply(502, { error: "the identity provider is not reachable", code: "sso_unavailable" })
171
+ }
172
+ }
173
+
174
+ const readBody = async (req: Request): Promise<Record<string, unknown> | undefined> => {
175
+ const declared = Number(req.headers.get("content-length") ?? "0")
176
+ if (Number.isFinite(declared) && declared > 16_384) return undefined
177
+ const text = await req.text()
178
+ if (text.length > 16_384) return undefined
179
+ try {
180
+ const v = JSON.parse(text)
181
+ return isObj(v) ? v : undefined
182
+ } catch {
183
+ return undefined
184
+ }
185
+ }
186
+
187
+ /** Highest role any value of the configured claim maps to. */
188
+ function mappedRole(c: StoredSso, claims: Record<string, unknown>): Role | undefined {
189
+ if (!c.roleMap) return undefined
190
+ const v = claims[c.roleMap.claim]
191
+ const vals = typeof v === "string" ? [v] : Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []
192
+ let best: Role | undefined
193
+ for (const x of vals) {
194
+ const r = Object.hasOwn(c.roleMap.map, x) ? c.roleMap.map[x] : undefined
195
+ if (r && (!best || roleRank(r) > roleRank(best))) best = r
196
+ }
197
+ return best
198
+ }
199
+
200
+ /** The user id for an email, or undefined when the email cannot be mapped to a valid, unambiguous id. */
201
+ export function emailToId(email: string): string | undefined {
202
+ const e = email.toLowerCase() // no trimming: whitespace in an email is a refusal, not something to clean up
203
+ const m = /^([a-z0-9]([a-z0-9._-]*[a-z0-9])?)@([a-z0-9.-]+)$/.exec(e)
204
+ if (!m || e.length > 64 || m[1]!.includes("..") || !DOMAIN_RE.test(m[3]!)) return undefined
205
+ return isId(e) ? e : undefined
206
+ }
207
+
208
+ const cleanName = (v: unknown, fallback: string): string => {
209
+ if (typeof v !== "string") return fallback
210
+ const s = v.replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, " ").replace(/\s+/g, " ").trim().slice(0, 200)
211
+ return s || fallback
212
+ }
213
+
214
+ async function exchange(req: Request, ctx: SsoContext): Promise<Response> {
215
+ const { db } = ctx
216
+ const hmac = (s: string): string => createHmac("sha256", db.auditKey()).update(s).digest("hex").slice(0, 12)
217
+ const fail = (reason: string, domain?: string): Response => {
218
+ db.audit({ actor: "-", action: "auth.sso.fail", detail: `reason=${reason}${domain ? ` domain=${hmac(domain)}` : ""}` })
219
+ return generic()
220
+ }
221
+ const c = load(db)
222
+ if (!c || !c.enabled) return fail("disabled")
223
+ const b = await readBody(req)
224
+ if (!b) return reply(400, { error: "the body must be a JSON object of at most 16 KB", code: "invalid_request" })
225
+ const { code, codeVerifier, redirectUri, nonce } = b
226
+ if (typeof code !== "string" || !code || code.length > 2048) return reply(400, { error: "code must be a string", code: "invalid_request" })
227
+ if (typeof codeVerifier !== "string" || !/^[A-Za-z0-9._~-]{43,128}$/.test(codeVerifier)) return reply(400, { error: "codeVerifier must be 43-128 URL-safe characters", code: "invalid_request" })
228
+ if (typeof nonce !== "string" || !/^[A-Za-z0-9_-]{16,128}$/.test(nonce)) return reply(400, { error: "nonce must be 16-128 URL-safe characters", code: "invalid_request" })
229
+ let ru: URL | undefined
230
+ try {
231
+ ru = typeof redirectUri === "string" && redirectUri.length <= 200 ? new URL(redirectUri) : undefined
232
+ } catch {}
233
+ if (!ru || ru.protocol !== "http:" || !isLoopbackHostname(ru.hostname) || ru.username || ru.password || ru.hash || ru.search) return reply(400, { error: "redirectUri must be an http loopback address (http://127.0.0.1:<port>/callback)", code: "invalid_request" })
234
+
235
+ const rt = runtime(ctx)
236
+ const t = ctx.now()
237
+ const codeKey = createHash("sha256").update(code).digest("hex")
238
+ for (const [k, at] of rt.usedCodes) if (t - at > CODE_TTL_MS) rt.usedCodes.delete(k)
239
+ if (rt.usedCodes.has(codeKey)) return fail("replayed_code")
240
+ if (rt.usedCodes.size > 5_000) rt.usedCodes.clear()
241
+ rt.usedCodes.set(codeKey, t) // a code is spent the moment it is presented, whatever happens next
242
+
243
+ let disc: Discovery
244
+ try {
245
+ disc = await rt.keys.discovery(c.issuer)
246
+ } catch (e) {
247
+ return fail(e instanceof OidcError ? e.reason : "discovery")
248
+ }
249
+
250
+ let idToken: string
251
+ let accessToken: string | undefined
252
+ try {
253
+ const form = new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: redirectUri as string, code_verifier: codeVerifier })
254
+ const headers: Record<string, string> = { "content-type": "application/x-www-form-urlencoded" }
255
+ const secret = db.ssoSecret()
256
+ if (!secret) form.set("client_id", c.clientId) // a public client identifies itself; PKCE is its proof
257
+ else if (tokenAuthFor(disc, c.tokenAuth) === "client_secret_post") {
258
+ form.set("client_id", c.clientId)
259
+ form.set("client_secret", secret)
260
+ } else headers.authorization = basicAuth(c.clientId, secret)
261
+ const r = await fetchJson(disc.token_endpoint, { method: "POST", headers, body: form.toString() }, "token")
262
+ if (!isObj(r) || typeof r.id_token !== "string" || !r.id_token || r.id_token.length > MAX_ID_TOKEN_CHARS) return fail("token_response")
263
+ idToken = r.id_token
264
+ if (typeof r.access_token === "string" && r.access_token && r.access_token.length <= 8192) accessToken = r.access_token
265
+ } catch (e) {
266
+ return fail(e instanceof OidcError ? e.reason : "token_endpoint")
267
+ }
268
+
269
+ let claims: IdClaims
270
+ try {
271
+ claims = await verifyIdToken({ token: idToken, issuer: disc.issuer, clientId: c.clientId, nonce, nowMs: t, keys: (force) => rt.keys.keys(disc.jwks_uri, force) })
272
+ } catch (e) {
273
+ return fail(e instanceof OidcError ? e.reason : "verify")
274
+ }
275
+
276
+ // The code flow lets a provider keep profile claims out of the ID token and serve them from userinfo (OIDC Core 5.4; the
277
+ // certified oidc-provider does by default). Ask userinfo only for what the verified ID token lacks, and only about the same
278
+ // subject (5.3.2: its sub MUST match). The ID token's own claims always win; userinfo never overrides them.
279
+ const wanted = ["email", "email_verified", "name", ...(c.roleMap ? [c.roleMap.claim] : [])]
280
+ if (wanted.some((k) => claims[k] === undefined) && disc.userinfo_endpoint && accessToken) {
281
+ let info: unknown
282
+ try {
283
+ info = await fetchJson(disc.userinfo_endpoint, { method: "GET", headers: { authorization: `Bearer ${accessToken}` } }, "userinfo")
284
+ } catch (e) {
285
+ return fail(e instanceof OidcError ? e.reason : "userinfo")
286
+ }
287
+ if (!isObj(info) || typeof info.sub !== "string" || info.sub !== claims.sub) return fail("userinfo_sub_mismatch")
288
+ for (const k of wanted) if (claims[k] === undefined && info[k] !== undefined) (claims as Record<string, unknown>)[k] = info[k]
289
+ }
290
+
291
+ const email = typeof claims.email === "string" ? claims.email.toLowerCase() : ""
292
+ const domain = email.includes("@") ? email.slice(email.lastIndexOf("@") + 1) : ""
293
+ if (!email) return fail("no_email")
294
+ if (!emailVerified(claims)) return fail("email_not_verified", domain)
295
+ if (!c.allowedDomains.map((d) => d.toLowerCase()).includes(domain)) return fail("domain_not_allowed", domain)
296
+ const id = emailToId(email)
297
+ if (!id) return fail("email_not_mappable", domain)
298
+
299
+ const outcome = db.tx(() => {
300
+ const existing = db.user(id)
301
+ if (existing) return existing.disabled ? ("disabled" as const) : ("existing" as const)
302
+ if (c.defaultRole === "none") return "unknown" as const
303
+ const role = mappedRole(c, claims) ?? (c.defaultRole as Role)
304
+ db.addUser(id, cleanName(claims.name, id), role)
305
+ db.audit({ actor: "-", action: "auth.sso.user.create", detail: `role=${role} domain=${hmac(domain)}` })
306
+ return "created" as const
307
+ })
308
+ if (outcome === "disabled") return fail("user_disabled", domain)
309
+ if (outcome === "unknown") return fail("user_unknown", domain)
310
+
311
+ const days = c.tokenDays ?? 30
312
+ const tok = db.createToken(id, days, "sso")
313
+ db.audit({ actor: id, action: "auth.sso.ok", detail: `domain=${hmac(domain)} token=${tok.id}` })
314
+ const user = db.orgUser(db.user(id)!)
315
+ return reply(200, { token: tok.token, user, expiresAt: new Date(t + days * 86_400_000).toISOString() })
316
+ }
package/src/org/tls.ts ADDED
@@ -0,0 +1,66 @@
1
+ // brain org: TLS for the server itself. `admin serve --tls-cert <pem> --tls-key <pem>` makes Bun.serve terminate TLS, so the bearer
2
+ // tokens never cross the network in clear text even without a proxy in front.
3
+ //
4
+ // This file only LOADS and CHECKS the certificate and key; Bun does the handshake. The check exists so a wrong pair or an
5
+ // expired certificate stops the server at startup with a sentence, instead of every client failing later with a handshake error.
6
+ // Refused: a missing or unreadable file; a file that is not a PEM certificate / private key; a private key protected by a
7
+ // passphrase (not supported: use a key file the service account alone can read, or terminate TLS in a proxy); a key that does
8
+ // not belong to the certificate; a certificate that has expired or is not valid yet.
9
+ // NOT checked: that the certificate names the host clients use, or that it chains to a root clients trust (a client says so itself:
10
+ // `brain org login --ca <pem>` for a private CA). `cert` may be a chain (the server's certificate first, then intermediates).
11
+ import { X509Certificate, createPrivateKey } from "node:crypto"
12
+ import { readFileSync } from "node:fs"
13
+
14
+ export interface TlsFiles {
15
+ certFile: string
16
+ keyFile: string
17
+ }
18
+ export interface LoadedTls {
19
+ cert: string
20
+ key: string
21
+ /** When the certificate stops being valid. */
22
+ notAfter: Date
23
+ /** A non-fatal note for the operator (the certificate expires soon). */
24
+ warning?: string
25
+ }
26
+
27
+ export class TlsError extends Error {
28
+ constructor(message: string) {
29
+ super(message)
30
+ this.name = "TlsError"
31
+ }
32
+ }
33
+
34
+ function read(path: string, what: string): string {
35
+ try {
36
+ return readFileSync(path, "utf8")
37
+ } catch {
38
+ throw new TlsError(`cannot read the TLS ${what} file ${path}`)
39
+ }
40
+ }
41
+
42
+ export function loadTls(f: TlsFiles, now: () => number = Date.now): LoadedTls {
43
+ const cert = read(f.certFile, "certificate")
44
+ const key = read(f.keyFile, "private key")
45
+ let x: X509Certificate
46
+ try {
47
+ x = new X509Certificate(cert)
48
+ } catch {
49
+ throw new TlsError(`${f.certFile} does not hold a PEM certificate`)
50
+ }
51
+ let priv: ReturnType<typeof createPrivateKey>
52
+ try {
53
+ priv = createPrivateKey(key)
54
+ } catch (e) {
55
+ const msg = String((e as Error)?.message ?? e)
56
+ if (/passphrase|encrypted/i.test(msg)) throw new TlsError(`the private key in ${f.keyFile} is protected by a passphrase, which is not supported: use an unencrypted key file only the service account can read`)
57
+ throw new TlsError(`${f.keyFile} does not hold a PEM private key`)
58
+ }
59
+ if (!x.checkPrivateKey(priv)) throw new TlsError(`the private key in ${f.keyFile} does not belong to the certificate in ${f.certFile}`)
60
+ const t = now()
61
+ const notAfter = new Date(x.validTo)
62
+ if (Date.parse(x.validFrom) > t) throw new TlsError(`the certificate in ${f.certFile} is not valid until ${new Date(x.validFrom).toISOString()}`)
63
+ if (notAfter.getTime() <= t) throw new TlsError(`the certificate in ${f.certFile} expired on ${notAfter.toISOString()}`)
64
+ const days = Math.floor((notAfter.getTime() - t) / 86_400_000)
65
+ return { cert, key, notAfter, ...(days < 14 ? { warning: `the TLS certificate expires in ${days} day${days === 1 ? "" : "s"} (${notAfter.toISOString().slice(0, 10)}): renew it, then restart the server` } : {}) }
66
+ }
@@ -0,0 +1,193 @@
1
+ // brain org: shared memory for an organisation, with who-may-see-what enforced by a server.
2
+ //
3
+ // What crosses the wire is only distilled, redacted MemoryItems (src/memory/types.ts): never blobs (full tool outputs), never raw
4
+ // turns, never attachments. Those stay on the person's machine. Every item has a SCOPE; the default is private.
5
+ //
6
+ // private only its owner
7
+ // project members of the item's project (explicit membership)
8
+ // team members of a named team
9
+ // org every member of the organisation
10
+ //
11
+ // ROLES (one per user, per organisation; one organisation per server database):
12
+ // viewer recalls what its scopes allow; cannot add, promote or forget
13
+ // editor viewer + adds items, promotes/demotes/forgets its OWN items (never above policy.maxScope)
14
+ // admin editor + manages users, tokens, teams, project membership, policy, audit and retention; may forget any item by id;
15
+ // an admin does NOT see other people's private items (private means private)
16
+ //
17
+ // Identity is a bearer token (an admin creates it for a user; it is shown once, stored hashed, revocable, may expire).
18
+ // Transport: the client refuses http:// to a non-loopback host unless BRAIN_ORG_INSECURE=1; run the server behind TLS.
19
+
20
+ import type { MemoryItem, MemoryKind, ProjectKey, Recalled } from "../memory/types.ts"
21
+
22
+ export type Scope = "private" | "project" | "team" | "org"
23
+ export type Role = "viewer" | "editor" | "admin"
24
+
25
+ /** Scopes from narrowest to widest. */
26
+ export const SCOPES: Scope[] = ["private", "project", "team", "org"]
27
+ export const scopeRank = (s: Scope): number => SCOPES.indexOf(s)
28
+
29
+ export interface Policy {
30
+ /** The widest scope an item may be given. Default "team". */
31
+ maxScope: Scope
32
+ /** The scope of an item whose sender does not say. Default "private". */
33
+ defaultScope: Scope
34
+ /** Items that mention a path matching any of these globs (`**`, `*`) are dropped before they leave the machine and refused by the server. */
35
+ denyPaths: string[]
36
+ /** Items whose text contains any of these (case-insensitive substrings, or `/regex/i`) are dropped the same way. */
37
+ denyTerms: string[]
38
+ /** When set, only these kinds are accepted. */
39
+ allowedKinds?: MemoryKind[]
40
+ /** Items older than this many days (by when they were stored) are deleted by the retention run. Unset: kept. */
41
+ retentionDays?: number
42
+ }
43
+
44
+ export const DEFAULT_POLICY: Policy = { maxScope: "team", defaultScope: "private", denyPaths: [], denyTerms: [] }
45
+
46
+ export interface OrgUser {
47
+ id: string
48
+ name: string
49
+ role: Role
50
+ /** Team ids the user belongs to. */
51
+ teams: string[]
52
+ disabled?: boolean
53
+ }
54
+
55
+ export interface WhoAmI {
56
+ user: OrgUser
57
+ org: { id: string; name: string }
58
+ policy: Policy
59
+ /** The server's API version. */
60
+ api: 1
61
+ }
62
+
63
+ /** An item as the server returns it: the recalled text plus who it belongs to and who may see it. */
64
+ export interface OrgRecalled extends Recalled {
65
+ id?: string
66
+ scope: Scope
67
+ /** The owner's user id. */
68
+ owner: string
69
+ project?: string
70
+ team?: string
71
+ }
72
+
73
+ // ---- requests and responses (JSON over HTTP, `Authorization: Bearer <token>`) ----
74
+
75
+ export interface IngestRequest {
76
+ project: ProjectKey
77
+ items: MemoryItem[]
78
+ /** Default: the policy's defaultScope. Not above the policy's maxScope, or the call is refused. */
79
+ scope?: Scope
80
+ /** Required for scope "team". */
81
+ team?: string
82
+ }
83
+ export interface IngestResponse {
84
+ stored: number
85
+ dropped: Array<{ id: string; reason: string }>
86
+ scope: Scope
87
+ }
88
+
89
+ export interface RecallRequest {
90
+ project: ProjectKey
91
+ query: string
92
+ limit?: number
93
+ maxTokens?: number
94
+ }
95
+ export interface RecallResponse {
96
+ items: OrgRecalled[]
97
+ }
98
+
99
+ export interface PromoteRequest {
100
+ /** Item ids (as stored by ingest), all owned by the caller. Either `ids`, or `source` (with `project`), is required. */
101
+ ids?: string[]
102
+ /** All the caller's items from one source session in `project` (the portable session id). */
103
+ source?: string
104
+ project?: ProjectKey
105
+ to: Scope
106
+ team?: string
107
+ }
108
+ export interface PromoteResponse {
109
+ changed: number
110
+ }
111
+
112
+ export interface ForgetRequest {
113
+ /** Which items: those of a project, or of one source session; `mine` limits it to the caller's own (the default for non-admins). */
114
+ project?: ProjectKey
115
+ source?: string
116
+ ids?: string[]
117
+ mine?: boolean
118
+ }
119
+ export interface ForgetResponse {
120
+ removed: number
121
+ }
122
+
123
+ export interface ProjectsResponse {
124
+ projects: Array<{ id: string; label: string; items: number }>
125
+ }
126
+
127
+ export interface AuditEvent {
128
+ at: string
129
+ /** The acting user id, or "-" when no one was identified. */
130
+ actor: string
131
+ action: string
132
+ project?: string
133
+ scope?: Scope
134
+ count?: number
135
+ /** Never the text of an item; for a recall, only a hash of the query. */
136
+ detail?: string
137
+ }
138
+
139
+ /** How a failed call comes back: HTTP status 400/401/403/404/413/429 and this body. */
140
+ export interface OrgError {
141
+ error: string
142
+ /** A short stable code: "unauthorized", "forbidden", "scope_above_policy", "denied_by_policy", "rate_limited", ... */
143
+ code: string
144
+ }
145
+
146
+ /** The user-side configuration, <home>/.brain/org.json (mode 0600). */
147
+ export interface OrgConfig {
148
+ server: string
149
+ token: string
150
+ /** Path to a PEM file with the certificate authority that signed the server's certificate (a private or internal CA). */
151
+ ca?: string
152
+ /** Applied when a command does not say: usually "private". */
153
+ defaultScope?: Scope
154
+ }
155
+
156
+ // ---- admin API shapes (role admin only) ----
157
+ // POST /v1/admin/users {id, name, role, teams?} -> OrgUser
158
+ // GET /v1/admin/users -> OrgUser[] (or {users})
159
+ // POST /v1/admin/users/disable {id} -> {ok: true}
160
+ // POST /v1/admin/tokens {user, expiresInDays?} -> {id, token} (the token is returned ONCE)
161
+ // GET /v1/admin/tokens -> [{id, user, created, expires?, lastUsed?, revoked}] (never the token)
162
+ // POST /v1/admin/tokens/revoke {id} -> {ok: true}
163
+ // POST /v1/admin/teams {id, name, members: string[]} -> {ok: true}
164
+ // POST /v1/admin/projects/members {project: string, add?: {users?: string[], teams?: string[]}, remove?: {users?: string[], teams?: string[]}} -> {ok: true}
165
+ // GET /v1/admin/policy -> Policy
166
+ // PUT /v1/admin/policy Policy -> Policy
167
+ // GET /v1/admin/audit?since=<iso>&limit=<n> -> AuditEvent[] (or {events})
168
+ // POST /v1/admin/retention/run {} -> {removed: number}
169
+
170
+ // ---- single sign-on (OIDC authorization code + PKCE; the server turns a verified identity into a brain bearer token) ----
171
+ export interface SsoConfig {
172
+ /** The identity provider's issuer URL (its `/.well-known/openid-configuration` is fetched from it): Google, Okta, Azure AD, Auth0, ... */
173
+ issuer: string
174
+ clientId: string
175
+ /** Optional: public clients using PKCE need none. Never returned by the API after it is set. */
176
+ clientSecret?: string
177
+ /** Default "openid email profile". */
178
+ scopes?: string
179
+ /** Only identities with a verified email in one of these domains may sign in. Required: an empty list means nobody can. */
180
+ allowedDomains: string[]
181
+ /** Create the user on first sign-in with this role; "none" means the user must already exist. Default "viewer". */
182
+ defaultRole: Role | "none"
183
+ /** Optional: a claim (e.g. "groups") whose values map to roles; the highest matching role wins. */
184
+ roleMap?: { claim: string; map: Record<string, Role> }
185
+ /** How long an issued token lasts, in days. Default 30. */
186
+ tokenDays?: number
187
+ /** How the server authenticates with the client secret at the token endpoint. Default client_secret_basic (the OAuth default),
188
+ * or client_secret_post when the provider supports only that. */
189
+ tokenAuth?: "client_secret_basic" | "client_secret_post"
190
+ }
191
+ // GET /v1/auth/sso/config (no auth) -> {enabled: boolean, issuer?, authorizationEndpoint?, clientId?, scopes?}
192
+ // POST /v1/auth/sso/exchange (no auth) {code, codeVerifier, redirectUri, nonce} -> {token, user: OrgUser, expiresAt}
193
+ // GET/PUT /v1/admin/sso (admin) <-> SsoConfig (the secret is write-only)
package/src/self.ts ADDED
@@ -0,0 +1,68 @@
1
+ // How brain refers to itself in what it writes for other programs: AI tools' MCP entries and session hooks run brain
2
+ // later, long after this process is gone.
3
+ //
4
+ // From a checkout or a global install (`bun add -g @100xprompt/brain`), that is `<bun> <absolute path to cli.ts>`: the
5
+ // file stays where it is. Run through `bunx @100xprompt/brain` (or `bun x`), this cli.ts is a temporary copy that a reboot
6
+ // or a cache clean removes, so the entry would break; then it is `<bun> x @100xprompt/brain@<this version>` instead, pinned
7
+ // so a later release does not change what an installed hook runs. The absolute bun stays either way: a tool started from
8
+ // a desktop launcher often has no ~/.bun/bin on its PATH.
9
+ import { chmodSync, readFileSync, realpathSync, statSync } from "node:fs"
10
+ import { join } from "node:path"
11
+
12
+ export const CLI_PATH = join(import.meta.dir, "cli.ts")
13
+
14
+ function readPackage(): { name: string; version: string } {
15
+ try {
16
+ const p = JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8"))
17
+ if (typeof p?.name === "string" && typeof p?.version === "string") return { name: p.name, version: p.version }
18
+ } catch {}
19
+ return { name: "@100xprompt/brain", version: "0.0.0" }
20
+ }
21
+ const pkg = readPackage()
22
+ export const PACKAGE = pkg.name
23
+ export const VERSION = pkg.version
24
+
25
+ /** A copy that will not be there later: bunx's temporary install, Bun's or npm's package-runner cache. */
26
+ export function isEphemeral(path: string = CLI_PATH): boolean {
27
+ return /[\\/](?:bunx-[^\\/]*|_npx|\.bun[\\/]install[\\/]cache|dlx-[^\\/]*)[\\/]/.test(path)
28
+ }
29
+
30
+ /** The package spec an ephemeral copy is started with later. */
31
+ export const pinnedSpec = (): string => `${PACKAGE}@${VERSION}`
32
+
33
+ /** What follows the bun executable to start this brain later: [cli.ts] or ["x", "@100xprompt/brain@<version>"]. */
34
+ export function selfArgs(path: string = CLI_PATH): string[] {
35
+ return isEphemeral(path) ? ["x", pinnedSpec()] : [path]
36
+ }
37
+
38
+ /**
39
+ * The bun that installed entries start brain with: absolute, because a tool started from a desktop launcher often has no
40
+ * ~/.bun/bin on its PATH. Where the bun on PATH is this same executable through a symlink (Homebrew's
41
+ * /opt/homebrew/bin/bun pointing into Cellar/<version>/, proto's shims), the symlink is used: it survives an upgrade and
42
+ * the versioned path does not. BRAIN_BUN overrides it (tests pin "bun").
43
+ */
44
+ export function stableBun(execPath: string = process.execPath, onPath: string | null = Bun.which("bun")): string {
45
+ if (process.env.BRAIN_BUN) return process.env.BRAIN_BUN
46
+ const exec = /(?:^|[\\/])bun(?:\.exe)?$/.test(execPath) ? execPath : undefined
47
+ if (onPath && exec) {
48
+ try {
49
+ if (realpathSync(onPath) === realpathSync(exec)) return onPath
50
+ } catch {}
51
+ }
52
+ return exec ?? onPath ?? "bun"
53
+ }
54
+
55
+ /**
56
+ * A global install can leave cli.ts writable by everyone (a package's bin is made executable with mode 0777), and the
57
+ * hooks and MCP entries brain installs run this file on their own. Brain takes the write bits for others off its own
58
+ * files when it starts; only files the running user owns, and never an error.
59
+ */
60
+ export function secureSelf(files: string[] = [CLI_PATH]): void {
61
+ if (typeof process.getuid !== "function") return
62
+ for (const f of files) {
63
+ try {
64
+ const st = statSync(f)
65
+ if (st.uid === process.getuid() && st.mode & 0o022) chmodSync(f, st.mode & 0o7755)
66
+ } catch {}
67
+ }
68
+ }