@100xprompt/brain 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +48 -0
- package/README.md +172 -2
- package/package.json +48 -4
- package/src/adapters/aider/index.ts +8 -0
- package/src/adapters/aider/reader.ts +129 -0
- package/src/adapters/aider/records.ts +220 -0
- package/src/adapters/aider/writer.ts +269 -0
- package/src/adapters/amazon-q-cli/index.ts +8 -0
- package/src/adapters/amazon-q-cli/reader.ts +84 -0
- package/src/adapters/amazon-q-cli/records.ts +191 -0
- package/src/adapters/amazon-q-cli/writer.ts +370 -0
- package/src/adapters/amazon-q-ide/index.ts +9 -0
- package/src/adapters/amazon-q-ide/reader.ts +131 -0
- package/src/adapters/amazon-q-ide/records.ts +141 -0
- package/src/adapters/amazon-q-ide/writer.ts +325 -0
- package/src/adapters/antigravity/index.ts +9 -0
- package/src/adapters/antigravity/proto.ts +194 -0
- package/src/adapters/antigravity/reader.ts +424 -0
- package/src/adapters/antigravity/records.ts +236 -0
- package/src/adapters/antigravity/writer.ts +463 -0
- package/src/adapters/auggie/index.ts +36 -0
- package/src/adapters/claude-code/index.ts +8 -0
- package/src/adapters/claude-code/reader.ts +639 -0
- package/src/adapters/claude-code/records.ts +88 -0
- package/src/adapters/claude-code/writer.ts +329 -0
- package/src/adapters/cline/index.ts +12 -0
- package/src/adapters/cline/reader.ts +432 -0
- package/src/adapters/cline/records.ts +151 -0
- package/src/adapters/cline/vscdb.ts +56 -0
- package/src/adapters/cline/writer.ts +656 -0
- package/src/adapters/cline/xml.ts +177 -0
- package/src/adapters/cline4/index.ts +9 -0
- package/src/adapters/cline4/reader.ts +179 -0
- package/src/adapters/cline4/records.ts +92 -0
- package/src/adapters/cline4/writer.ts +361 -0
- package/src/adapters/codebuff/index.ts +8 -0
- package/src/adapters/codebuff/project.ts +207 -0
- package/src/adapters/codebuff/reader.ts +215 -0
- package/src/adapters/codebuff/records.ts +173 -0
- package/src/adapters/codebuff/writer.ts +383 -0
- package/src/adapters/codex/index.ts +8 -0
- package/src/adapters/codex/native.ts +239 -0
- package/src/adapters/codex/reader.ts +747 -0
- package/src/adapters/codex/records.ts +114 -0
- package/src/adapters/codex/writer.ts +479 -0
- package/src/adapters/continue/index.ts +9 -0
- package/src/adapters/continue/reader.ts +68 -0
- package/src/adapters/continue/records.ts +418 -0
- package/src/adapters/continue/writer.ts +162 -0
- package/src/adapters/copilot-cli/index.ts +9 -0
- package/src/adapters/copilot-cli/reader.ts +186 -0
- package/src/adapters/copilot-cli/records.ts +241 -0
- package/src/adapters/copilot-cli/writer.ts +353 -0
- package/src/adapters/crush/index.ts +8 -0
- package/src/adapters/crush/reader.ts +180 -0
- package/src/adapters/crush/records.ts +169 -0
- package/src/adapters/crush/writer.ts +333 -0
- package/src/adapters/cursor-cli/index.ts +405 -0
- package/src/adapters/cursor-ide/agentkv.ts +95 -0
- package/src/adapters/cursor-ide/index.ts +501 -0
- package/src/adapters/cursor-ide/transcripts.ts +122 -0
- package/src/adapters/cursor-ide/workspaces.ts +119 -0
- package/src/adapters/cursor-ide/writer.ts +170 -0
- package/src/adapters/devin/index.ts +8 -0
- package/src/adapters/devin/reader.ts +305 -0
- package/src/adapters/devin/records.ts +277 -0
- package/src/adapters/devin/writer.ts +523 -0
- package/src/adapters/factory-droid/index.ts +12 -0
- package/src/adapters/factory-droid/reader.ts +184 -0
- package/src/adapters/factory-droid/records.ts +79 -0
- package/src/adapters/factory-droid/writer.ts +350 -0
- package/src/adapters/gemini-cli/exchanges.ts +228 -0
- package/src/adapters/gemini-cli/index.ts +396 -0
- package/src/adapters/gemini-cli/records.ts +521 -0
- package/src/adapters/gemini-cli/writer.ts +354 -0
- package/src/adapters/goose/index.ts +8 -0
- package/src/adapters/goose/reader.ts +138 -0
- package/src/adapters/goose/records.ts +199 -0
- package/src/adapters/goose/writer.ts +340 -0
- package/src/adapters/grok-cli/image.ts +44 -0
- package/src/adapters/grok-cli/index.ts +8 -0
- package/src/adapters/grok-cli/reader.ts +302 -0
- package/src/adapters/grok-cli/records.ts +172 -0
- package/src/adapters/grok-cli/writer.ts +329 -0
- package/src/adapters/hermes/index.ts +8 -0
- package/src/adapters/hermes/reader.ts +249 -0
- package/src/adapters/hermes/records.ts +285 -0
- package/src/adapters/hermes/writer.ts +452 -0
- package/src/adapters/iflow-cli/index.ts +72 -0
- package/src/adapters/iflow-cli/records.ts +215 -0
- package/src/adapters/iflow-cli/writer.ts +204 -0
- package/src/adapters/index.ts +112 -0
- package/src/adapters/kilo-cli/index.ts +21 -0
- package/src/adapters/kilo-code/index.ts +11 -0
- package/src/adapters/kilo-code/writer.ts +92 -0
- package/src/adapters/kimi/index.ts +9 -0
- package/src/adapters/kimi/reader.ts +92 -0
- package/src/adapters/kimi/records.ts +221 -0
- package/src/adapters/kimi/writer.ts +367 -0
- package/src/adapters/kiro-cli/index.ts +8 -0
- package/src/adapters/kiro-cli/reader.ts +87 -0
- package/src/adapters/kiro-cli/records.ts +260 -0
- package/src/adapters/kiro-cli/writer.ts +305 -0
- package/src/adapters/kiro-ide/index.ts +8 -0
- package/src/adapters/kiro-ide/writer.ts +52 -0
- package/src/adapters/letta/index.ts +8 -0
- package/src/adapters/letta/reader.ts +177 -0
- package/src/adapters/letta/records.ts +119 -0
- package/src/adapters/letta/writer.ts +413 -0
- package/src/adapters/mastracode/index.ts +8 -0
- package/src/adapters/mastracode/reader.ts +323 -0
- package/src/adapters/mastracode/records.ts +336 -0
- package/src/adapters/mastracode/writer.ts +385 -0
- package/src/adapters/mistral-vibe/index.ts +9 -0
- package/src/adapters/mistral-vibe/reader.ts +62 -0
- package/src/adapters/mistral-vibe/records.ts +104 -0
- package/src/adapters/mistral-vibe/writer.ts +359 -0
- package/src/adapters/muse/index.ts +10 -0
- package/src/adapters/muse/reader.ts +332 -0
- package/src/adapters/muse/records.ts +104 -0
- package/src/adapters/muse/writer.ts +387 -0
- package/src/adapters/omp/index.ts +13 -0
- package/src/adapters/omp/reader.ts +314 -0
- package/src/adapters/omp/records.ts +153 -0
- package/src/adapters/omp/writer.ts +216 -0
- package/src/adapters/opencode/ids.ts +52 -0
- package/src/adapters/opencode/index.ts +31 -0
- package/src/adapters/opencode/note.ts +8 -0
- package/src/adapters/opencode/reader.ts +452 -0
- package/src/adapters/opencode/tools.ts +174 -0
- package/src/adapters/opencode/v2.ts +367 -0
- package/src/adapters/opencode/writer.ts +379 -0
- package/src/adapters/openhands/index.ts +8 -0
- package/src/adapters/openhands/reader.ts +222 -0
- package/src/adapters/openhands/records.ts +136 -0
- package/src/adapters/openhands/writer.ts +404 -0
- package/src/adapters/pearai/index.ts +10 -0
- package/src/adapters/pi/index.ts +28 -0
- package/src/adapters/pi/reader.ts +261 -0
- package/src/adapters/pi/records.ts +99 -0
- package/src/adapters/pi/writer.ts +362 -0
- package/src/adapters/qoder-cli/index.ts +8 -0
- package/src/adapters/qoder-cli/reader.ts +83 -0
- package/src/adapters/qoder-cli/records.ts +45 -0
- package/src/adapters/qoder-cli/writer.ts +72 -0
- package/src/adapters/qwen-code/index.ts +84 -0
- package/src/adapters/qwen-code/records.ts +194 -0
- package/src/adapters/qwen-code/writer.ts +224 -0
- package/src/adapters/tabnine-cli/index.ts +8 -0
- package/src/adapters/tabnine-cli/reader.ts +106 -0
- package/src/adapters/tabnine-cli/records.ts +33 -0
- package/src/adapters/tabnine-cli/writer.ts +156 -0
- package/src/adapters/types.ts +73 -0
- package/src/adapters/void/index.ts +9 -0
- package/src/adapters/void/reader.ts +48 -0
- package/src/adapters/void/records.ts +285 -0
- package/src/adapters/void/writer.ts +93 -0
- package/src/adapters/vscode-copilot/index.ts +9 -0
- package/src/adapters/vscode-copilot/reader.ts +212 -0
- package/src/adapters/vscode-copilot/records.ts +388 -0
- package/src/adapters/vscode-copilot/shared.ts +68 -0
- package/src/adapters/vscode-copilot/writer.ts +546 -0
- package/src/adapters/zed/index.ts +8 -0
- package/src/adapters/zed/reader.ts +198 -0
- package/src/adapters/zed/records.ts +87 -0
- package/src/adapters/zed/writer.ts +341 -0
- package/src/brief-writers.ts +71 -0
- package/src/brief.ts +238 -0
- package/src/cli.ts +326 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/connect.ts +845 -0
- package/src/gateway/signin.ts +323 -0
- package/src/handoff.ts +104 -0
- package/src/hooks.ts +455 -0
- package/src/memory/audit.ts +514 -0
- package/src/memory/blobs.ts +94 -0
- package/src/memory/cli.ts +117 -0
- package/src/memory/distill.ts +381 -0
- package/src/memory/externalize.ts +435 -0
- package/src/memory/lookups.ts +70 -0
- package/src/memory/mcp-install.ts +401 -0
- package/src/memory/mcp-server.ts +296 -0
- package/src/memory/prepare.ts +179 -0
- package/src/memory/preview.ts +154 -0
- package/src/memory/project.ts +35 -0
- package/src/memory/redact.ts +72 -0
- package/src/memory/relevance.ts +187 -0
- package/src/memory/repomap.ts +516 -0
- package/src/memory/store.ts +270 -0
- package/src/memory/sync.ts +166 -0
- package/src/memory/tidy.ts +193 -0
- package/src/memory/types.ts +118 -0
- package/src/org/admin-ops.ts +96 -0
- package/src/org/auth.ts +112 -0
- package/src/org/backup.ts +115 -0
- package/src/org/cli.ts +438 -0
- package/src/org/client.ts +404 -0
- package/src/org/config.ts +44 -0
- package/src/org/crypto.ts +537 -0
- package/src/org/db.ts +435 -0
- package/src/org/graph.ts +318 -0
- package/src/org/oidc.ts +435 -0
- package/src/org/policy.ts +398 -0
- package/src/org/server.ts +920 -0
- package/src/org/sso.ts +316 -0
- package/src/org/tls.ts +66 -0
- package/src/org/types.ts +193 -0
- package/src/self.ts +68 -0
- package/src/session/attachments.ts +53 -0
- package/src/session/peekcache.ts +50 -0
- package/src/session/types.ts +104 -0
- package/src/session/util.ts +130 -0
- 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
|
+
}
|
package/src/org/types.ts
ADDED
|
@@ -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
|
+
}
|