@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
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
// brain org: the organisation memory server. One process, one database file (see db.ts), Chitta for the text and the ranking.
|
|
2
|
+
//
|
|
3
|
+
// WHAT RELIES ON WHAT
|
|
4
|
+
// * Chitta's own permission engine (graph ACL: `permissions` edges from principals to records, `belongsTo` edges from users to
|
|
5
|
+
// groups, an `anyone` node for org-wide records) decides which records a user's SEARCH may rank at all: the search is
|
|
6
|
+
// scoped to the accessible set BEFORE ranking, so other people's records can neither appear nor crowd out the top-k.
|
|
7
|
+
// private -> a permission edge from `u:<owner>` only
|
|
8
|
+
// project -> `u:<owner>` plus the group `proj:<hash of project id>` (members have `belongsTo` edges to it)
|
|
9
|
+
// team -> `u:<owner>` plus the group `team:<team id>`
|
|
10
|
+
// org -> `u:<owner>` plus the `anyone:<record>` node for this organisation
|
|
11
|
+
// The owner always keeps a permission edge on their own records (so they can still recall, demote and forget what they
|
|
12
|
+
// promoted, even after being removed from a project). An admin gets NO special edge: roles in Chitta only gate writes.
|
|
13
|
+
// * The SERVER (this file + db.ts) owns everything else: who a token belongs to, roles, rate limits, size limits, policy and
|
|
14
|
+
// redaction, who may promote/forget what, membership (the source of truth: `syncGroups` rebuilds Chitta's group edges from
|
|
15
|
+
// the membership tables after every change), retention and the audit log. The visibility rule is ALSO enforced a second,
|
|
16
|
+
// independent time in SQL (db.VISIBLE_SQL) on every record a search returns, so a mistake in either layer alone cannot leak.
|
|
17
|
+
// * Fail closed: a record is first written visible to its owner only; its real ACL is applied in the same synchronous step
|
|
18
|
+
// that records it; on an update the old record is removed before the new one is written.
|
|
19
|
+
//
|
|
20
|
+
// ENTITIES, RELATIONS, PROCEDURES: Chitta's entity graph is shared by everyone in the store (names are folded onto one canonical
|
|
21
|
+
// entity across records), so it is NEVER fed (`extractGraph: false`). An item's entities and relations are kept in the server's
|
|
22
|
+
// own tables keyed by the item and used only through the visibility predicate (see graph.ts). Procedures are validated and
|
|
23
|
+
// discarded. Ranking is project-aware BEFORE ranking: recall searches Chitta as a synthetic (user, project) principal that can
|
|
24
|
+
// see only that project's items the user may see (see graph.ts), so other projects can never crowd the answer out.
|
|
25
|
+
//
|
|
26
|
+
// DECISIONS the brief left open
|
|
27
|
+
// * Ingesting at scope `private` is allowed into any project id (a project id is just a label until something is shared in
|
|
28
|
+
// it). Scope `project` requires membership of the project, scope `team` membership of the team; there is no "open project".
|
|
29
|
+
// * Re-ingesting an item that already exists updates its text but NEVER changes its scope or team (promotion is explicit; a
|
|
30
|
+
// sync must not widen or re-widen anything). New items get the requested (or default) scope.
|
|
31
|
+
// * Item ids from clients are per owner: two users may use the same item id; promote/forget by id act on the CALLER'S rows.
|
|
32
|
+
// * Another user's id in promote behaves exactly like an unknown id (no "forbidden"): existence of other people's items is
|
|
33
|
+
// never revealed to non-admins. Admin forget by explicit ids may remove any owner's item (it still cannot read it).
|
|
34
|
+
// * `forget` is limited to the caller's own items unless an admin names ids, or passes `mine:false`. At least one of
|
|
35
|
+
// project/source/ids is required (an empty filter is refused, never "everything").
|
|
36
|
+
// * Retention measures age from when an item's CONTENT was last stored or changed (an unchanged re-sync does not renew it).
|
|
37
|
+
// * The audit log hashes queries and (for private/recall events) project ids with a per-organisation secret, so an admin
|
|
38
|
+
// cannot confirm a guessed query or learn which project someone keeps private notes about.
|
|
39
|
+
//
|
|
40
|
+
// API: exactly src/org/types.ts (the admin shapes are at the bottom of that file). Extras: PromoteResponse may carry
|
|
41
|
+
// `dropped: [{id, reason}]` for items the (tightened) policy no longer allows to be shared; `disable` also accepts {id, disabled?:false} to re-enable.
|
|
42
|
+
import type { Database } from "bun:sqlite"
|
|
43
|
+
import { existsSync } from "node:fs"
|
|
44
|
+
import { createHmac } from "node:crypto"
|
|
45
|
+
import { pathToFileURL } from "node:url"
|
|
46
|
+
import { chittaCandidates } from "../memory/store.ts"
|
|
47
|
+
import { redact } from "../memory/redact.ts"
|
|
48
|
+
import { approxTokens, type MemoryItem, type MemoryKind, type ProjectKey } from "../memory/types.ts"
|
|
49
|
+
import { authenticate, sha256Hex, WindowLimiter } from "./auth.ts"
|
|
50
|
+
import { OrgDb, isId, type ItemRow, type UserRow } from "./db.ts"
|
|
51
|
+
import { OrgKeyError, chittaWithLibsql, claimServerLock, dbState, resolveKey, withKeyEnv } from "./crypto.ts"
|
|
52
|
+
import { loadTls } from "./tls.ts"
|
|
53
|
+
import { OrgGraph, aclPrincipals, cleanExtras, expansionScore, type Extras } from "./graph.ts"
|
|
54
|
+
import { MEMORY_KINDS, checkItem, effectiveScope, normalizePolicy } from "./policy.ts"
|
|
55
|
+
import { SsoInputError, getSsoAdmin, putSsoAdmin } from "./sso.ts"
|
|
56
|
+
import {
|
|
57
|
+
SCOPES,
|
|
58
|
+
scopeRank,
|
|
59
|
+
type AuditEvent,
|
|
60
|
+
type ForgetResponse,
|
|
61
|
+
type IngestResponse,
|
|
62
|
+
type OrgError,
|
|
63
|
+
type OrgRecalled,
|
|
64
|
+
type Policy,
|
|
65
|
+
type ProjectsResponse,
|
|
66
|
+
type PromoteResponse,
|
|
67
|
+
type RecallResponse,
|
|
68
|
+
type Role,
|
|
69
|
+
type Scope,
|
|
70
|
+
type WhoAmI,
|
|
71
|
+
} from "./types.ts"
|
|
72
|
+
|
|
73
|
+
export { initOrg, openOrgDb, isId } from "./db.ts"
|
|
74
|
+
|
|
75
|
+
export interface OrgServerOptions {
|
|
76
|
+
dbPath: string
|
|
77
|
+
/** Default 4500; 0 picks a free port. */
|
|
78
|
+
port?: number
|
|
79
|
+
/** Default 127.0.0.1. Put TLS in front before listening on anything else. */
|
|
80
|
+
host?: string
|
|
81
|
+
/** Default "auto" (a real embedder when available, else hashing); tests use "hash". */
|
|
82
|
+
embeddings?: "hash" | "auto"
|
|
83
|
+
now?: () => number
|
|
84
|
+
/** Requests per minute per token. Default 120. */
|
|
85
|
+
rateLimitPerMinute?: number
|
|
86
|
+
/** Items one user may own. Default 50,000. */
|
|
87
|
+
maxItemsPerUser?: number
|
|
88
|
+
/**
|
|
89
|
+
* Behind a TLS-terminating proxy every request comes from the proxy's address, so the per-IP failed-authentication limit would be
|
|
90
|
+
* shared by everyone. With this on, the address is read from the LAST entry of X-Forwarded-For (the one the trusted proxy itself
|
|
91
|
+
* appended; earlier entries are supplied by the client and can be forged). Leave it off unless a proxy you control is in front:
|
|
92
|
+
* without one, a client could pick its own "address" and escape the limit.
|
|
93
|
+
*/
|
|
94
|
+
trustProxy?: boolean
|
|
95
|
+
/** Encryption at rest (crypto.ts). The key itself, or the path of a file holding it; else `BRAIN_ORG_KEY`. An encrypted database
|
|
96
|
+
* cannot be opened without it, and a key given for a plain database is refused. */
|
|
97
|
+
key?: string
|
|
98
|
+
keyFile?: string
|
|
99
|
+
/** TLS in the server itself (tls.ts): PEM file paths. The URL is then https://. Both or neither. */
|
|
100
|
+
tlsCertFile?: string
|
|
101
|
+
tlsKeyFile?: string
|
|
102
|
+
/** How often the retention job runs while the server is up (default one hour; 0 turns the timer off). It also runs at startup. */
|
|
103
|
+
retentionIntervalMs?: number
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const IP_LIKE = /^[0-9a-fA-F:.]{3,45}$/
|
|
107
|
+
|
|
108
|
+
/** The client's address for rate limiting: the socket's, or with `trust` the proxy-appended last X-Forwarded-For entry. */
|
|
109
|
+
export function clientAddress(req: Request, socketAddress: string | undefined, trust: boolean): string {
|
|
110
|
+
if (trust) {
|
|
111
|
+
const parts = (req.headers.get("x-forwarded-for") ?? "").split(",").map((x) => x.trim()).filter(Boolean)
|
|
112
|
+
const last = parts[parts.length - 1]
|
|
113
|
+
if (last && IP_LIKE.test(last)) return last
|
|
114
|
+
}
|
|
115
|
+
return socketAddress ?? "-"
|
|
116
|
+
}
|
|
117
|
+
export interface OrgServerHandle {
|
|
118
|
+
url: string
|
|
119
|
+
port: number
|
|
120
|
+
stop(): Promise<void>
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const MAX_BODY_BYTES = 1_000_000
|
|
124
|
+
export const MAX_ITEM_TEXT = 2_000
|
|
125
|
+
export const MAX_ITEMS_PER_CALL = 200
|
|
126
|
+
const ROLES: Role[] = ["viewer", "editor", "admin"]
|
|
127
|
+
const roleRank = (r: Role): number => ROLES.indexOf(r)
|
|
128
|
+
|
|
129
|
+
class HttpError extends Error {
|
|
130
|
+
constructor(
|
|
131
|
+
readonly status: number,
|
|
132
|
+
readonly code: string,
|
|
133
|
+
message: string,
|
|
134
|
+
readonly headers?: Record<string, string>,
|
|
135
|
+
) {
|
|
136
|
+
super(message)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const bad = (message: string) => new HttpError(400, "invalid_request", message)
|
|
140
|
+
const forbidden = (message: string) => new HttpError(403, "forbidden", message)
|
|
141
|
+
|
|
142
|
+
// ---- request validation (strict about types, sizes and enums; unknown keys are ignored and never used) -----------------------
|
|
143
|
+
|
|
144
|
+
type Obj = Record<string, unknown>
|
|
145
|
+
const isObj = (x: unknown): x is Obj => typeof x === "object" && x !== null && !Array.isArray(x)
|
|
146
|
+
// eslint-disable-next-line no-control-regex
|
|
147
|
+
const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/
|
|
148
|
+
|
|
149
|
+
function str(o: Obj, key: string, opt: { min?: number; max: number; required?: boolean; control?: boolean }): string | undefined {
|
|
150
|
+
const v = o[key]
|
|
151
|
+
if (v === undefined || v === null) {
|
|
152
|
+
if (opt.required) throw bad(`${key} is required`)
|
|
153
|
+
return undefined
|
|
154
|
+
}
|
|
155
|
+
if (typeof v !== "string") throw bad(`${key} must be a string`)
|
|
156
|
+
if (v.length > opt.max) throw bad(`${key} is too long (at most ${opt.max} characters)`)
|
|
157
|
+
if (v.length < (opt.min ?? 0)) throw bad(`${key} is too short`)
|
|
158
|
+
if (v.includes("\u0000") || (opt.control && CONTROL.test(v))) throw bad(`${key} contains control characters`)
|
|
159
|
+
return v
|
|
160
|
+
}
|
|
161
|
+
const reqStr = (o: Obj, key: string, max: number, min = 1): string => str(o, key, { max, min, required: true })!
|
|
162
|
+
|
|
163
|
+
function scopeOf(x: unknown, key: string): Scope | undefined {
|
|
164
|
+
if (x === undefined || x === null) return undefined
|
|
165
|
+
if (typeof x !== "string" || !(SCOPES as string[]).includes(x)) throw bad(`${key} must be one of ${SCOPES.join(", ")}`)
|
|
166
|
+
return x as Scope
|
|
167
|
+
}
|
|
168
|
+
function intOf(o: Obj, key: string, min: number, max: number): number | undefined {
|
|
169
|
+
const v = o[key]
|
|
170
|
+
if (v === undefined || v === null) return undefined
|
|
171
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < min || v > max) throw bad(`${key} must be an integer from ${min} to ${max}`)
|
|
172
|
+
return v
|
|
173
|
+
}
|
|
174
|
+
function strList(x: unknown, key: string, maxLen: number, maxEach: number): string[] {
|
|
175
|
+
if (x === undefined || x === null) return []
|
|
176
|
+
if (!Array.isArray(x) || x.length > maxLen) throw bad(`${key} must be a list of at most ${maxLen} strings`)
|
|
177
|
+
for (const s of x) if (typeof s !== "string" || !s.length || s.length > maxEach || s.includes("\u0000")) throw bad(`${key} must hold non-empty strings of at most ${maxEach} characters`)
|
|
178
|
+
return x as string[]
|
|
179
|
+
}
|
|
180
|
+
function projectOf(x: unknown): ProjectKey {
|
|
181
|
+
if (!isObj(x)) throw bad("project must be an object {id, label}")
|
|
182
|
+
const id = reqStr(x, "project.id" in x ? "project.id" : "id", 500)
|
|
183
|
+
const label = str(x, "label", { max: 200, control: true }) ?? id
|
|
184
|
+
return { id, label: label || id }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseItem(x: unknown, index: number): MemoryItem {
|
|
188
|
+
if (!isObj(x)) throw bad(`items[${index}] must be an object`)
|
|
189
|
+
const where = `items[${index}]`
|
|
190
|
+
const wrap = <T>(f: () => T): T => {
|
|
191
|
+
try {
|
|
192
|
+
return f()
|
|
193
|
+
} catch (e) {
|
|
194
|
+
throw e instanceof HttpError ? bad(`${where}: ${e.message}`) : e
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return wrap(() => {
|
|
198
|
+
const id = str(x, "id", { min: 1, max: 200, required: true, control: true })!
|
|
199
|
+
const kind = x.kind
|
|
200
|
+
if (typeof kind !== "string" || !(MEMORY_KINDS as string[]).includes(kind)) throw bad(`kind must be one of ${MEMORY_KINDS.join(", ")}`)
|
|
201
|
+
const name = str(x, "name", { max: 300, control: true }) ?? ""
|
|
202
|
+
const text = str(x, "text", { min: 1, max: MAX_ITEM_TEXT, required: true })!
|
|
203
|
+
const source = str(x, "source", { min: 1, max: 300, required: true, control: true })!
|
|
204
|
+
const occurredAt = str(x, "occurredAt", { max: 64, control: true })
|
|
205
|
+
// Validated for shape and size, then discarded (see the header comment): they never reach the store.
|
|
206
|
+
const list = (k: string, max: number) => {
|
|
207
|
+
const v = x[k]
|
|
208
|
+
if (v === undefined || v === null) return
|
|
209
|
+
if (!Array.isArray(v) || v.length > max) throw bad(`${k} must be a list of at most ${max} entries`)
|
|
210
|
+
for (const e of v) if (!isObj(e) || JSON.stringify(e).length > 2_000) throw bad(`${k} entries must be small objects`)
|
|
211
|
+
}
|
|
212
|
+
list("entities", 100)
|
|
213
|
+
list("relations", 100)
|
|
214
|
+
list("procedures", 50)
|
|
215
|
+
// entities and relations are kept as sent (the policy reads them as sent; graph.ts cleans and redacts them before storing)
|
|
216
|
+
const ents = Array.isArray(x.entities) ? { entities: x.entities as MemoryItem["entities"] } : {}
|
|
217
|
+
const rels = Array.isArray(x.relations) ? { relations: x.relations as MemoryItem["relations"] } : {}
|
|
218
|
+
return { id, kind: kind as MemoryKind, name, text, source, ...(occurredAt ? { occurredAt } : {}), ...ents, ...rels }
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---- the server ------------------------------------------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
interface ChittaLike {
|
|
225
|
+
ctx: {
|
|
226
|
+
store: { db: Database; addNode(id: string, coll: string, data?: Record<string, unknown>): void; addEdge(src: string, dst: string, label: string): void; bumpVersion(): void }
|
|
227
|
+
ingestor: { registerUser(id: string, org: string, email?: string, role?: "admin" | "editor" | "viewer"): void; registerGroup(id: string): void }
|
|
228
|
+
authorizedIngest(actingUserId: string, doc: Record<string, unknown>): Promise<unknown>
|
|
229
|
+
deleteRecord(actingUserId: string, recordId: string): void
|
|
230
|
+
searchWithGraph(query: string, userId: string, orgId: string, trace?: undefined, limit?: number): Promise<{ searchResults: Array<{ score: number; metadata: { recordId?: string } }> }>
|
|
231
|
+
}
|
|
232
|
+
user(id: string, opts?: { role?: Role; org?: string }): { recall(query: string, opts?: { limit?: number }): Promise<Array<{ score: number; recordId?: string }>> }
|
|
233
|
+
close(): void
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function loadChitta(only?: string): Promise<new (o: Record<string, unknown>) => ChittaLike> {
|
|
237
|
+
const tried: string[] = []
|
|
238
|
+
for (const file of only ? [only] : chittaCandidates()) {
|
|
239
|
+
if (!existsSync(file)) continue
|
|
240
|
+
try {
|
|
241
|
+
const mod = await import(pathToFileURL(file).href)
|
|
242
|
+
if (typeof mod.Chitta === "function") return mod.Chitta
|
|
243
|
+
tried.push(`${file} (no Chitta export)`)
|
|
244
|
+
} catch (e) {
|
|
245
|
+
tried.push(`${file} (${(e as Error).message.split("\n")[0]})`)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
throw new Error(tried.length ? `Chitta could not be loaded: ${tried.join("; ")}` : "Chitta is not installed (set BRAIN_CHITTA to its folder, or install @100xprompt/chitta)")
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const userP = (id: string): string => `u:${id}`
|
|
252
|
+
const teamP = (id: string): string => `team:${id}`
|
|
253
|
+
const projP = (project: string): string => `proj:${sha256Hex(project).slice(0, 32)}`
|
|
254
|
+
const ridOf = (owner: string, project: string, source: string, itemId: string): string => `o~${sha256Hex(`${owner}\u0000${project}\u0000${source}\u0000${itemId}`).slice(0, 32)}`
|
|
255
|
+
|
|
256
|
+
export async function startOrgServer(opts: OrgServerOptions): Promise<OrgServerHandle> {
|
|
257
|
+
const now = opts.now ?? Date.now
|
|
258
|
+
const { dbPath } = opts
|
|
259
|
+
if (!existsSync(dbPath)) throw new Error(`no organisation database at ${dbPath}: create one with \`brain org admin init\``)
|
|
260
|
+
if ((opts.tlsCertFile === undefined) !== (opts.tlsKeyFile === undefined)) throw new Error("TLS needs both a certificate and a private key (tlsCertFile and tlsKeyFile)")
|
|
261
|
+
const tls = opts.tlsCertFile && opts.tlsKeyFile ? loadTls({ certFile: opts.tlsCertFile, keyFile: opts.tlsKeyFile }, now) : undefined
|
|
262
|
+
// encryption at rest: an encrypted file needs its key, a key needs an encrypted file (crypto.ts says which and how to fix it)
|
|
263
|
+
const { key } = resolveKey({ key: opts.key, keyFile: opts.keyFile })
|
|
264
|
+
if (dbState(dbPath) === "encrypted" && !key) throw new OrgKeyError(`${dbPath} is encrypted: give the key with BRAIN_ORG_KEY or --key-file <path>`)
|
|
265
|
+
const probe = OrgDb.open(dbPath, now, key)
|
|
266
|
+
const orgMaybe = probe.org()
|
|
267
|
+
probe.close()
|
|
268
|
+
if (!orgMaybe) throw new Error(`${dbPath} holds no organisation: create one with \`brain org admin init\``)
|
|
269
|
+
|
|
270
|
+
const org: { id: string; name: string } = orgMaybe
|
|
271
|
+
// Chitta switches to libSQL (needed for the cipher) when it sees CONTEXT_DB_KEY at construction: use a Chitta whose folder can load libsql
|
|
272
|
+
const chittaFile = key ? chittaWithLibsql() : undefined
|
|
273
|
+
if (key && !chittaFile) throw new OrgKeyError("an encrypted database needs the `libsql` package next to Chitta: `cd <chitta folder> && bun add libsql` (BRAIN_CHITTA points to that folder)")
|
|
274
|
+
const Chitta = await loadChitta(chittaFile)
|
|
275
|
+
const chitta = withKeyEnv(key, () => new Chitta({ path: dbPath, embeddings: opts.embeddings ?? "auto", rerank: false, org: org.id }))
|
|
276
|
+
const releaseLock = claimServerLock(dbPath)
|
|
277
|
+
const store = chitta.ctx.store
|
|
278
|
+
try {
|
|
279
|
+
store.db.exec("PRAGMA busy_timeout = 5000")
|
|
280
|
+
} catch {}
|
|
281
|
+
const db = new OrgDb(store.db, now)
|
|
282
|
+
// libSQL's handle has no `transaction`: give Chitta's own handle the adapted one so every `store.db.transaction(...)` below works on both drivers
|
|
283
|
+
if (typeof (store.db as { transaction?: unknown }).transaction !== "function") (store.db as unknown as { transaction: unknown }).transaction = db.db.transaction
|
|
284
|
+
db.migrate()
|
|
285
|
+
const graph = new OrgGraph(db, store, (id) => chitta.ctx.ingestor.registerUser(id, org.id, undefined, "editor"))
|
|
286
|
+
const auditKey = db.auditKey()
|
|
287
|
+
const rateLimit = new WindowLimiter(opts.rateLimitPerMinute ?? 120)
|
|
288
|
+
const failLimit = new WindowLimiter(30) // failed authentications per IP per minute
|
|
289
|
+
const signInLimit = new WindowLimiter(60) // single sign-on requests per IP per minute
|
|
290
|
+
const maxItems = opts.maxItemsPerUser ?? 50_000
|
|
291
|
+
|
|
292
|
+
// ---- serialised writes: everything that touches Chitta's records goes one at a time ----
|
|
293
|
+
let chain: Promise<unknown> = Promise.resolve()
|
|
294
|
+
const locked = <T>(fn: () => Promise<T> | T): Promise<T> => {
|
|
295
|
+
const run = chain.then(fn)
|
|
296
|
+
chain = run.then(() => undefined, () => undefined)
|
|
297
|
+
return run
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---- audit ----
|
|
301
|
+
const hmac = (s: string): string => createHmac("sha256", auditKey).update(s).digest("hex")
|
|
302
|
+
const audit = (actor: string, action: string, e: { project?: string; scope?: Scope; count?: number; detail?: string } = {}) => db.audit({ actor, action, ...e })
|
|
303
|
+
/** A project id as an admin may see it: in the clear when something was shared there, hashed when it is private/unknown. */
|
|
304
|
+
const auditProject = (project: string, scope?: Scope): string => (scope && scope !== "private" ? project : `p:${hmac(project).slice(0, 12)}`)
|
|
305
|
+
let authFailWindow = { start: 0, n: 0, suppressed: 0 }
|
|
306
|
+
const auditAuthFail = (reason: string, actor: string | undefined) => {
|
|
307
|
+
const t = now()
|
|
308
|
+
if (t - authFailWindow.start >= 60_000) {
|
|
309
|
+
if (authFailWindow.suppressed) audit("-", "auth.fail.suppressed", { count: authFailWindow.suppressed })
|
|
310
|
+
authFailWindow = { start: t, n: 0, suppressed: 0 }
|
|
311
|
+
}
|
|
312
|
+
if (++authFailWindow.n > 20) authFailWindow.suppressed++
|
|
313
|
+
else audit(actor ?? "-", "auth.fail", { detail: reason })
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---- Chitta plumbing ----
|
|
317
|
+
const provisioned = new Set<string>()
|
|
318
|
+
const ensureUser = (userId: string) => {
|
|
319
|
+
if (provisioned.has(userId)) return
|
|
320
|
+
chitta.ctx.ingestor.registerUser(userP(userId), org.id, undefined, "editor")
|
|
321
|
+
provisioned.add(userId)
|
|
322
|
+
}
|
|
323
|
+
const chittaUser = (userId: string) => {
|
|
324
|
+
ensureUser(userId)
|
|
325
|
+
return chitta.user(userP(userId), { role: "editor", org: org.id })
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Membership is NOT copied into Chitta any more: the synthetic (user, project) principal a recall searches as is brought up to
|
|
329
|
+
* date from the membership tables at every recall (graph.ts), so a change applies on the next call and nothing is rebuilt in bulk.
|
|
330
|
+
* Called after every membership change (kept so each change still invalidates Chitta's caches at once). */
|
|
331
|
+
function syncGroups() {
|
|
332
|
+
store.bumpVersion()
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Makes a record's Chitta ACL exactly what its row says. Synchronous (no await between the checks and the write). The groups
|
|
336
|
+
* are per project (see graph.ts), which is what lets Chitta rank one project's items only. */
|
|
337
|
+
function applyAcl(row: ItemRow) {
|
|
338
|
+
const rid = row.rid
|
|
339
|
+
store.db.query("DELETE FROM edges WHERE dst = ? AND label = 'permissions'").run(rid)
|
|
340
|
+
store.db.query("DELETE FROM nodes WHERE id = ? AND coll = 'anyone'").run(`anyone:${rid}`)
|
|
341
|
+
ensureUser(row.owner)
|
|
342
|
+
for (const principal of aclPrincipals(row)) store.addEdge(principal, rid, "permissions")
|
|
343
|
+
store.bumpVersion()
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function chittaWrite(row: { rid: string; owner: string; kind: MemoryKind; name: string; text: string }) {
|
|
347
|
+
ensureUser(row.owner)
|
|
348
|
+
// owner-only until applyAcl runs (fail closed); no graph (see header)
|
|
349
|
+
await chitta.ctx.authorizedIngest(userP(row.owner), {
|
|
350
|
+
recordId: row.rid,
|
|
351
|
+
orgId: org.id,
|
|
352
|
+
recordName: `${row.kind}: ${row.name}`,
|
|
353
|
+
text: row.text,
|
|
354
|
+
permittedPrincipals: [],
|
|
355
|
+
extractGraph: false,
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
function chittaRemove(rid: string, owner: string) {
|
|
359
|
+
try {
|
|
360
|
+
chitta.ctx.deleteRecord(userP(owner), rid)
|
|
361
|
+
} catch {
|
|
362
|
+
// not there (an orphaned row) or not ours: remove whatever carries this id, the same statements deleteRecord runs
|
|
363
|
+
store.db.query("DELETE FROM nodes WHERE id = ?").run(rid)
|
|
364
|
+
store.db.query("DELETE FROM edges WHERE src = ? OR dst = ?").run(rid, rid)
|
|
365
|
+
store.db.query("DELETE FROM chunks WHERE point_id LIKE ?").run(`${rid}#%`)
|
|
366
|
+
store.db.query("DELETE FROM chunks WHERE virtual_record_id = ?").run(rid)
|
|
367
|
+
store.bumpVersion()
|
|
368
|
+
}
|
|
369
|
+
store.db.query("DELETE FROM nodes WHERE id = ? AND coll = 'anyone'").run(`anyone:${rid}`)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function removeRows(rows: ItemRow[]): number {
|
|
373
|
+
for (const r of rows) {
|
|
374
|
+
chittaRemove(r.rid, r.owner)
|
|
375
|
+
db.deleteItem(r.rid)
|
|
376
|
+
}
|
|
377
|
+
return rows.length
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function runRetention(actor: string, via?: string): Promise<number> {
|
|
381
|
+
return locked(() => {
|
|
382
|
+
const days = db.policy().retentionDays
|
|
383
|
+
if (!days) return 0
|
|
384
|
+
const rows = db.expiredItems(now() - days * 86_400_000)
|
|
385
|
+
removeRows(rows)
|
|
386
|
+
if (rows.length) audit(actor, "retention", { count: rows.length, detail: `retentionDays=${days}${via ? ` via=${via}` : ""}` })
|
|
387
|
+
return rows.length
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
syncGroups()
|
|
392
|
+
if (graph.aclIsLegacy()) {
|
|
393
|
+
// a database made before per-project groups: rewrite every item's permission edges once, then drop the old global groups
|
|
394
|
+
store.db.transaction(() => {
|
|
395
|
+
for (const r of graph.allItemRows()) applyAcl(r)
|
|
396
|
+
graph.dropLegacyGroups()
|
|
397
|
+
graph.markAclCurrent()
|
|
398
|
+
})()
|
|
399
|
+
}
|
|
400
|
+
await runRetention("-", "startup")
|
|
401
|
+
// The retention timer: the same job, every `retentionIntervalMs`, for as long as the server runs. unref'd (it never keeps the process
|
|
402
|
+
// alive) and cleared in stop(). A run that throws is logged in one terse line (never a message that could hold item text) and the
|
|
403
|
+
// timer keeps going; a run that removed something is audited by runRetention itself.
|
|
404
|
+
const retentionEvery = opts.retentionIntervalMs ?? 3_600_000
|
|
405
|
+
const retentionTimer =
|
|
406
|
+
retentionEvery > 0
|
|
407
|
+
? setInterval(() => {
|
|
408
|
+
runRetention("-", "timer").catch((e) => process.stderr.write(`brain org: retention run failed: ${(e as Error)?.name ?? "Error"}\n`))
|
|
409
|
+
}, retentionEvery)
|
|
410
|
+
: undefined
|
|
411
|
+
retentionTimer?.unref?.()
|
|
412
|
+
|
|
413
|
+
// ---- handlers ----------------------------------------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
const needRole = (u: UserRow, min: Role, what: string) => {
|
|
416
|
+
if (roleRank(u.role) < roleRank(min)) throw forbidden(`your role (${u.role}) may not ${what}`)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function ingest(u: UserRow, body: Obj): Promise<IngestResponse> {
|
|
420
|
+
needRole(u, "editor", "add memory")
|
|
421
|
+
const project = projectOf(body.project)
|
|
422
|
+
const requested = scopeOf(body.scope, "scope")
|
|
423
|
+
const team = str(body, "team", { max: 64 })
|
|
424
|
+
if (!Array.isArray(body.items)) throw bad("items must be a list")
|
|
425
|
+
if (body.items.length > MAX_ITEMS_PER_CALL) throw bad(`too many items (at most ${MAX_ITEMS_PER_CALL} per call)`)
|
|
426
|
+
const items = body.items.map(parseItem)
|
|
427
|
+
const policy = db.policy()
|
|
428
|
+
const eff = effectiveScope(requested, policy)
|
|
429
|
+
if (!eff.ok) throw new HttpError(403, eff.code, eff.reason)
|
|
430
|
+
const scope = eff.scope
|
|
431
|
+
if (scope === "project" && !db.isProjectMember(u.id, project.id)) throw forbidden("you are not a member of this project, so nothing can be shared into it")
|
|
432
|
+
if (scope === "team") {
|
|
433
|
+
if (!team) throw bad("team is required for scope team")
|
|
434
|
+
if (!isId(team) || !db.isTeamMember(u.id, team)) throw forbidden("you are not a member of that team")
|
|
435
|
+
}
|
|
436
|
+
const dropped: IngestResponse["dropped"] = []
|
|
437
|
+
const ready: Array<{ item: MemoryItem; text: string; name: string; source: string; extras: Extras }> = []
|
|
438
|
+
for (const item of items) {
|
|
439
|
+
const verdict = checkItem(item, policy) // on the text as sent: the stricter reading
|
|
440
|
+
if (!verdict.ok) {
|
|
441
|
+
dropped.push({ id: item.id, reason: verdict.reason })
|
|
442
|
+
continue
|
|
443
|
+
}
|
|
444
|
+
// the server never trusts the client's redaction
|
|
445
|
+
ready.push({ item, text: redact(item.text), name: redact(item.name), source: redact(item.source), extras: cleanExtras(item.entities, item.relations) })
|
|
446
|
+
}
|
|
447
|
+
let stored = 0
|
|
448
|
+
await locked(async () => {
|
|
449
|
+
for (const { item, text, name, source, extras } of ready) {
|
|
450
|
+
const rid = ridOf(u.id, project.id, source, item.id)
|
|
451
|
+
const existing = db.item(rid)
|
|
452
|
+
if (!existing && db.countOwned(u.id) >= maxItems) {
|
|
453
|
+
dropped.push({ id: item.id, reason: "quota_exceeded" })
|
|
454
|
+
continue
|
|
455
|
+
}
|
|
456
|
+
const unchanged = existing && existing.text === text && existing.name === name && existing.kind === item.kind
|
|
457
|
+
if (existing && !unchanged) chittaRemove(rid, u.id)
|
|
458
|
+
if (!unchanged) await chittaWrite({ rid, owner: u.id, kind: item.kind, name, text })
|
|
459
|
+
// from here to the end of the iteration there is no await: the row and the ACL appear together
|
|
460
|
+
if (existing) {
|
|
461
|
+
if (!unchanged) db.updateItemContent(rid, { label: project.label, kind: item.kind, name, text, occurred_at: item.occurredAt ?? null, stored_at: now() })
|
|
462
|
+
applyAcl(db.item(rid)!)
|
|
463
|
+
} else {
|
|
464
|
+
const row: ItemRow = { rid, item_id: item.id, project: project.id, label: project.label, scope, owner: u.id, team: scope === "team" ? team! : null, source, kind: item.kind, name, text, occurred_at: item.occurredAt ?? null, stored_at: now() }
|
|
465
|
+
db.insertItem(row)
|
|
466
|
+
applyAcl(row)
|
|
467
|
+
}
|
|
468
|
+
graph.saveExtras(rid, extras) // the item's entities and relations: the server's own tables, never Chitta's shared graph
|
|
469
|
+
stored++
|
|
470
|
+
}
|
|
471
|
+
})
|
|
472
|
+
audit(u.id, "ingest", { project: auditProject(project.id, scope), scope, count: stored, ...(dropped.length ? { detail: `dropped=${dropped.length}` } : {}) })
|
|
473
|
+
return { stored, dropped, scope }
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function recall(u: UserRow, body: Obj): Promise<RecallResponse> {
|
|
477
|
+
const project = projectOf(body.project)
|
|
478
|
+
const query = reqStr(body, "query", 2_000, 0)
|
|
479
|
+
const limit = intOf(body, "limit", 1, 50) ?? 8
|
|
480
|
+
const maxTokens = intOf(body, "maxTokens", 1, 20_000) ?? 1_500
|
|
481
|
+
const hash = hmac(query).slice(0, 16)
|
|
482
|
+
// Chitta's keyword leg parses its input: keep letters, digits and plain punctuation only
|
|
483
|
+
const q = query.replace(/[^\p{L}\p{N}\s_.-]/gu, " ").replace(/\s+/g, " ").trim()
|
|
484
|
+
const out: OrgRecalled[] = []
|
|
485
|
+
if (q) {
|
|
486
|
+
// Chitta ranks as the synthetic (user, project) principal: only this project's items the user may see are even candidates
|
|
487
|
+
const principal = graph.syncSynthetic(u.id, project.id)
|
|
488
|
+
const res = await chitta.ctx.searchWithGraph(q, principal, org.id, undefined, Math.min(200, Math.max(limit * 10, 50)))
|
|
489
|
+
const hits = res.searchResults.map((r) => ({ score: r.score, recordId: r.metadata.recordId }))
|
|
490
|
+
const rids = [...new Set(hits.map((h) => h.recordId).filter((r): r is string => !!r))]
|
|
491
|
+
const visible = db.visibleRows(u.id, rids) // the second, independent gate
|
|
492
|
+
const seen = new Set<string>()
|
|
493
|
+
const rows: ItemRow[] = []
|
|
494
|
+
const costs: number[] = []
|
|
495
|
+
let tokens = 0
|
|
496
|
+
for (const hit of hits) {
|
|
497
|
+
const row = hit.recordId ? visible.get(hit.recordId) : undefined
|
|
498
|
+
if (!row || seen.has(row.rid) || !(hit.score > 0) || row.project !== project.id) continue
|
|
499
|
+
seen.add(row.rid)
|
|
500
|
+
const text = redact(row.text)
|
|
501
|
+
const cost = approxTokens(text)
|
|
502
|
+
if (out.length && tokens + cost > maxTokens) continue
|
|
503
|
+
out.push({ text, kind: row.kind, name: row.name, score: hit.score, source: row.source, id: row.item_id, scope: row.scope, owner: row.owner, project: row.project, ...(row.team ? { team: row.team } : {}) })
|
|
504
|
+
rows.push(row)
|
|
505
|
+
costs.push(cost)
|
|
506
|
+
tokens += cost
|
|
507
|
+
if (out.length >= limit) break
|
|
508
|
+
}
|
|
509
|
+
// graph expansion: other VISIBLE items of this project that share an entity with the top hits, below every direct hit,
|
|
510
|
+
// within `limit` and the token budget (graph.ts: the visibility predicate is part of the statement that finds them)
|
|
511
|
+
if (out.length) {
|
|
512
|
+
const extra = graph.expansion(u.id, project.id, rows.slice(0, 5), seen, limit)
|
|
513
|
+
const extraRows = extra.length ? db.visibleRows(u.id, extra.map((e) => e.rid)) : new Map<string, ItemRow>()
|
|
514
|
+
const wanted = extra.filter((e) => extraRows.has(e.rid))
|
|
515
|
+
if (wanted.length) {
|
|
516
|
+
// Make room for extras only when they can actually be used: giving up a direct hit must buy an extra that fits the token budget
|
|
517
|
+
// (otherwise a tight budget would lose a direct hit for nothing).
|
|
518
|
+
const cand = wanted.map((e) => extraRows.get(e.rid)!).filter((r) => r.project === project.id).map((r) => ({ cost: approxTokens(redact(r.text)) }))
|
|
519
|
+
const slotsWanted = Math.min(cand.length, Math.floor(limit / 4))
|
|
520
|
+
const fits = (pop: number) => {
|
|
521
|
+
let t = tokens - costs.slice(costs.length - pop).reduce((a, b) => a + b, 0)
|
|
522
|
+
let n = 0
|
|
523
|
+
for (const c of cand) {
|
|
524
|
+
if (n >= limit - out.length + pop) break
|
|
525
|
+
if (t + c.cost > maxTokens) continue
|
|
526
|
+
t += c.cost
|
|
527
|
+
n++
|
|
528
|
+
}
|
|
529
|
+
return n
|
|
530
|
+
}
|
|
531
|
+
let pop = 0
|
|
532
|
+
while (limit - out.length + pop < slotsWanted && out.length - pop > 1 && fits(pop + 1) > fits(pop)) pop++
|
|
533
|
+
for (let i = 0; i < pop; i++) (tokens -= costs.pop()!, out.pop(), rows.pop())
|
|
534
|
+
const lowest = out[out.length - 1]!.score
|
|
535
|
+
for (const e of wanted) {
|
|
536
|
+
if (out.length >= limit) break
|
|
537
|
+
const row = extraRows.get(e.rid)!
|
|
538
|
+
if (row.project !== project.id) continue
|
|
539
|
+
const text = redact(row.text)
|
|
540
|
+
const cost = approxTokens(text)
|
|
541
|
+
if (tokens + cost > maxTokens) continue
|
|
542
|
+
out.push({ text, kind: row.kind, name: row.name, score: expansionScore(lowest, e.overlap), source: row.source, id: row.item_id, scope: row.scope, owner: row.owner, project: row.project, ...(row.team ? { team: row.team } : {}) })
|
|
543
|
+
tokens += cost
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
audit(u.id, "recall", { project: auditProject(project.id), count: out.length, detail: `q=${hash}` })
|
|
549
|
+
return { items: out }
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function promote(u: UserRow, body: Obj): Promise<PromoteResponse> {
|
|
553
|
+
needRole(u, "editor", "promote or demote memory")
|
|
554
|
+
const ids = body.ids === undefined || body.ids === null ? undefined : strList(body.ids, "ids", MAX_ITEMS_PER_CALL, 200)
|
|
555
|
+
const source = str(body, "source", { max: 300 })
|
|
556
|
+
const bySource = source !== undefined ? projectOf(body.project) : undefined
|
|
557
|
+
if (ids ? !ids.length : !bySource) throw bad("give ids, or source with project")
|
|
558
|
+
const to = scopeOf(body.to, "to")
|
|
559
|
+
if (!to) throw bad("to is required")
|
|
560
|
+
const team = str(body, "team", { max: 64 })
|
|
561
|
+
const policy = db.policy()
|
|
562
|
+
if (scopeRank(to) > scopeRank(policy.maxScope)) throw new HttpError(403, "scope_above_policy", `scope ${to} is above what this organisation allows (${policy.maxScope})`)
|
|
563
|
+
if (to === "team") {
|
|
564
|
+
if (!team) throw bad("team is required for scope team")
|
|
565
|
+
if (!isId(team) || !db.isTeamMember(u.id, team)) throw forbidden("you are not a member of that team")
|
|
566
|
+
}
|
|
567
|
+
return await locked(async () => {
|
|
568
|
+
const rows = ids ? db.ownedByItemIds(u.id, ids) : db.itemsWhere({ owner: u.id, project: bySource!.id, source: source! }) // someone else's id is simply not here: same answer as an unknown id
|
|
569
|
+
if (to === "project") for (const r of rows) if (!db.isProjectMember(u.id, r.project)) throw forbidden("you are not a member of the project of one of these items")
|
|
570
|
+
let changed = 0
|
|
571
|
+
let widened = 0
|
|
572
|
+
let narrowed = 0
|
|
573
|
+
const dropped: Array<{ id: string; reason: string }> = []
|
|
574
|
+
for (const r of rows) {
|
|
575
|
+
const newTeam = to === "team" ? team! : null
|
|
576
|
+
if (r.scope === to && r.team === newTeam) continue
|
|
577
|
+
if (to !== "private") {
|
|
578
|
+
// the policy may have tightened since the item was stored
|
|
579
|
+
const x = graph.loadExtras(r.rid) // its entities and relations are part of what would be shared
|
|
580
|
+
const v = checkItem({ id: r.item_id, kind: r.kind, name: r.name, text: r.text, source: r.source, entities: x.entities, relations: x.relations }, policy)
|
|
581
|
+
if (!v.ok) {
|
|
582
|
+
dropped.push({ id: r.item_id, reason: v.reason })
|
|
583
|
+
continue
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (scopeRank(to) > scopeRank(r.scope)) widened++
|
|
587
|
+
else narrowed++
|
|
588
|
+
db.setItemScope(r.rid, to, newTeam)
|
|
589
|
+
applyAcl({ ...r, scope: to, team: newTeam })
|
|
590
|
+
changed++
|
|
591
|
+
}
|
|
592
|
+
const project = rows[0]?.project
|
|
593
|
+
const pj = project && rows.every((r) => r.project === project) ? auditProject(project, to === "private" ? "private" : to) : undefined
|
|
594
|
+
if (widened) audit(u.id, "promote", { scope: to, count: widened, ...(pj ? { project: pj } : {}) })
|
|
595
|
+
if (narrowed) audit(u.id, "demote", { scope: to, count: narrowed, ...(pj ? { project: pj } : {}) })
|
|
596
|
+
return { changed, ...(dropped.length ? { dropped } : {}) }
|
|
597
|
+
})
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function forget(u: UserRow, body: Obj): Promise<ForgetResponse> {
|
|
601
|
+
needRole(u, "editor", "forget memory")
|
|
602
|
+
const isAdmin = u.role === "admin"
|
|
603
|
+
if (body.mine === false && !isAdmin) throw forbidden("only an admin may forget other people's items")
|
|
604
|
+
const project = body.project === undefined || body.project === null ? undefined : projectOf(body.project)
|
|
605
|
+
const source = str(body, "source", { max: 300 })
|
|
606
|
+
const ids = body.ids === undefined || body.ids === null ? undefined : strList(body.ids, "ids", MAX_ITEMS_PER_CALL, 200)
|
|
607
|
+
if (!project && source === undefined && !ids) throw bad("say what to forget: ids, a project or a source")
|
|
608
|
+
const ownOnly = !isAdmin || body.mine === true || (ids === undefined && body.mine !== false)
|
|
609
|
+
return await locked(() => {
|
|
610
|
+
const rows = db.itemsWhere({ ...(ownOnly ? { owner: u.id } : {}), ...(project ? { project: project.id } : {}), ...(source !== undefined ? { source } : {}), ...(ids ? { ids } : {}) })
|
|
611
|
+
const removed = removeRows(rows)
|
|
612
|
+
const others = rows.filter((r) => r.owner !== u.id).length
|
|
613
|
+
audit(u.id, "forget", {
|
|
614
|
+
...(project ? { project: auditProject(project.id, "private") } : {}),
|
|
615
|
+
count: removed,
|
|
616
|
+
...(others ? { detail: `others=${others}` } : {}),
|
|
617
|
+
})
|
|
618
|
+
return { removed }
|
|
619
|
+
})
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function projects(u: UserRow): ProjectsResponse {
|
|
623
|
+
return { projects: db.visibleProjects(u.id) }
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** GET /v1/graph?project=<id>[&entity=<name>]: the entities of a project as the caller may see them (shapes: graph.ts). */
|
|
627
|
+
function graphView(u: UserRow, url: URL) {
|
|
628
|
+
const project = url.searchParams.get("project")
|
|
629
|
+
const entity = url.searchParams.get("entity")
|
|
630
|
+
if (!project || project.length > 500 || project.includes("\u0000")) throw bad("project is required (at most 500 characters)")
|
|
631
|
+
if (entity !== null && (!entity.length || entity.length > 200 || CONTROL.test(entity))) throw bad("entity must be 1-200 characters")
|
|
632
|
+
const view = graph.view(u.id, project, entity ?? undefined)
|
|
633
|
+
const count = "entities" in view ? view.entities.length : view.items.length
|
|
634
|
+
audit(u.id, "graph", { project: auditProject(project), count, ...(entity !== null ? { detail: `e=${hmac(entity.normalize("NFKC").toLowerCase().trim()).slice(0, 16)}` } : {}) })
|
|
635
|
+
return view
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function whoami(u: UserRow): WhoAmI {
|
|
639
|
+
return { user: db.orgUser(u), org, policy: db.policy(), api: 1 }
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ---- admin ----
|
|
643
|
+
function addUser(a: UserRow, body: Obj) {
|
|
644
|
+
const id = reqStr(body, "id", 64)
|
|
645
|
+
if (!isId(id)) throw bad("id must be 1-64 characters: letters, digits, . _ @ - (starting with a letter or digit)")
|
|
646
|
+
const name = reqStr(body, "name", 200)
|
|
647
|
+
const role = body.role
|
|
648
|
+
if (typeof role !== "string" || !(ROLES as string[]).includes(role)) throw bad(`role must be one of ${ROLES.join(", ")}`)
|
|
649
|
+
const teams = strList(body.teams, "teams", 50, 64)
|
|
650
|
+
for (const t of teams) if (!db.team(t)) throw new HttpError(404, "not_found", `no such team: ${t}`)
|
|
651
|
+
if (db.user(id)) throw bad("a user with that id already exists")
|
|
652
|
+
db.tx(() => {
|
|
653
|
+
db.addUser(id, name.trim() || id, role as Role)
|
|
654
|
+
for (const t of teams) db.addTeamMember(t, id)
|
|
655
|
+
})
|
|
656
|
+
if (teams.length) syncGroups()
|
|
657
|
+
audit(a.id, "admin.user.add", { detail: `user=${id} role=${role}${teams.length ? ` teams=${teams.join(",")}` : ""}` })
|
|
658
|
+
return db.orgUser(db.user(id)!)
|
|
659
|
+
}
|
|
660
|
+
function disableUser(a: UserRow, body: Obj) {
|
|
661
|
+
const id = reqStr(body, body.id === undefined ? "user" : "id", 64)
|
|
662
|
+
const disabled = body.disabled === undefined ? true : body.disabled
|
|
663
|
+
if (typeof disabled !== "boolean") throw bad("disabled must be true or false")
|
|
664
|
+
const target = db.user(id)
|
|
665
|
+
if (!target) throw new HttpError(404, "not_found", "no such user")
|
|
666
|
+
if (disabled && target.role === "admin" && !target.disabled && db.activeAdmins() <= 1) throw new HttpError(400, "last_admin", "that is the last active admin: create another admin first")
|
|
667
|
+
db.setDisabled(id, disabled)
|
|
668
|
+
audit(a.id, disabled ? "admin.user.disable" : "admin.user.enable", { detail: `user=${id}` })
|
|
669
|
+
return { ok: true }
|
|
670
|
+
}
|
|
671
|
+
function createToken(a: UserRow, body: Obj) {
|
|
672
|
+
const user = reqStr(body, "user", 64)
|
|
673
|
+
const days = intOf(body, "expiresInDays", 1, 3650)
|
|
674
|
+
const target = db.user(user)
|
|
675
|
+
if (!target) throw new HttpError(404, "not_found", "no such user")
|
|
676
|
+
if (target.disabled) throw bad("that user is disabled")
|
|
677
|
+
const t = db.createToken(user, days)
|
|
678
|
+
audit(a.id, "admin.token.create", { detail: `user=${user} token=${t.id}${days ? ` expiresInDays=${days}` : ""}` })
|
|
679
|
+
return t
|
|
680
|
+
}
|
|
681
|
+
function revokeToken(a: UserRow, body: Obj) {
|
|
682
|
+
const id = reqStr(body, "id", 64)
|
|
683
|
+
if (!db.revokeToken(id)) throw new HttpError(404, "not_found", "no such token")
|
|
684
|
+
audit(a.id, "admin.token.revoke", { detail: `token=${id}` })
|
|
685
|
+
return { ok: true }
|
|
686
|
+
}
|
|
687
|
+
function setTeam(a: UserRow, body: Obj) {
|
|
688
|
+
const id = reqStr(body, "id", 64)
|
|
689
|
+
if (!isId(id)) throw bad("team id must be 1-64 characters: letters, digits, . _ @ - (starting with a letter or digit)")
|
|
690
|
+
const name = reqStr(body, "name", 200)
|
|
691
|
+
const members = strList(body.members, "members", 1_000, 64)
|
|
692
|
+
for (const m of members) if (!db.user(m)) throw new HttpError(404, "not_found", `no such user: ${m}`)
|
|
693
|
+
db.setTeam(id, name.trim() || id, members)
|
|
694
|
+
syncGroups()
|
|
695
|
+
audit(a.id, "admin.team.set", { detail: `team=${id} members=${new Set(members).size}` })
|
|
696
|
+
return { ok: true }
|
|
697
|
+
}
|
|
698
|
+
function projectMembers(a: UserRow, body: Obj) {
|
|
699
|
+
const project = reqStr(body, "project", 500)
|
|
700
|
+
const parse = (x: unknown, key: string): { users: string[]; teams: string[] } => {
|
|
701
|
+
if (x === undefined || x === null) return { users: [], teams: [] }
|
|
702
|
+
if (Array.isArray(x)) {
|
|
703
|
+
const list = strList(x, key, 1_000, 70)
|
|
704
|
+
return { users: list.filter((s) => !s.startsWith("team:")).map((s) => s.replace(/^user:/, "")), teams: list.filter((s) => s.startsWith("team:")).map((s) => s.slice(5)) }
|
|
705
|
+
}
|
|
706
|
+
if (!isObj(x)) throw bad(`${key} must be {users?: string[], teams?: string[]}`)
|
|
707
|
+
return { users: strList(x.users, `${key}.users`, 1_000, 64), teams: strList(x.teams, `${key}.teams`, 1_000, 64) }
|
|
708
|
+
}
|
|
709
|
+
const add = parse(body.add, "add")
|
|
710
|
+
const remove = parse(body.remove, "remove")
|
|
711
|
+
for (const u of add.users) if (!db.user(u)) throw new HttpError(404, "not_found", `no such user: ${u}`)
|
|
712
|
+
for (const t of add.teams) if (!db.team(t)) throw new HttpError(404, "not_found", `no such team: ${t}`)
|
|
713
|
+
db.tx(() => {
|
|
714
|
+
for (const u of add.users) db.addProjectMember(project, "user", u)
|
|
715
|
+
for (const t of add.teams) db.addProjectMember(project, "team", t)
|
|
716
|
+
for (const u of remove.users) db.removeProjectMember(project, "user", u)
|
|
717
|
+
for (const t of remove.teams) db.removeProjectMember(project, "team", t)
|
|
718
|
+
})
|
|
719
|
+
syncGroups()
|
|
720
|
+
audit(a.id, "admin.project.members", {
|
|
721
|
+
project,
|
|
722
|
+
detail: `add=${add.users.length + add.teams.length} remove=${remove.users.length + remove.teams.length}`,
|
|
723
|
+
})
|
|
724
|
+
return { ok: true }
|
|
725
|
+
}
|
|
726
|
+
function putPolicy(a: UserRow, body: unknown): Policy {
|
|
727
|
+
let p: Policy
|
|
728
|
+
try {
|
|
729
|
+
p = normalizePolicy(body)
|
|
730
|
+
} catch (e) {
|
|
731
|
+
throw new HttpError(400, "invalid_policy", (e as Error).message)
|
|
732
|
+
}
|
|
733
|
+
db.setPolicy(p)
|
|
734
|
+
audit(a.id, "admin.policy.set", { detail: `maxScope=${p.maxScope} defaultScope=${p.defaultScope}` })
|
|
735
|
+
return p
|
|
736
|
+
}
|
|
737
|
+
function auditLog(url: URL): AuditEvent[] {
|
|
738
|
+
const sinceRaw = url.searchParams.get("since")
|
|
739
|
+
let since: string | undefined
|
|
740
|
+
if (sinceRaw) {
|
|
741
|
+
const t = /^\d+$/.test(sinceRaw) ? Number(sinceRaw) : Date.parse(sinceRaw)
|
|
742
|
+
if (!Number.isFinite(t)) throw bad("since must be an ISO date or a millisecond timestamp")
|
|
743
|
+
since = new Date(t).toISOString()
|
|
744
|
+
}
|
|
745
|
+
const lim = url.searchParams.get("limit")
|
|
746
|
+
const limit = lim === null ? 200 : Number(lim)
|
|
747
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1_000) throw bad("limit must be an integer from 1 to 1000")
|
|
748
|
+
return db.auditEvents(since, limit)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ---- HTTP --------------------------------------------------------------------------------------------------------------
|
|
752
|
+
|
|
753
|
+
const baseHeaders = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" }
|
|
754
|
+
const json = (status: number, body: unknown, extra?: Record<string, string>): Response => new Response(JSON.stringify(body), { status, headers: { ...baseHeaders, ...extra } })
|
|
755
|
+
const fail = (e: HttpError): Response => json(e.status, { error: e.message, code: e.code } satisfies OrgError, e.headers)
|
|
756
|
+
|
|
757
|
+
async function readJson(req: Request): Promise<Obj> {
|
|
758
|
+
const declared = Number(req.headers.get("content-length") ?? "0")
|
|
759
|
+
const tooLarge = () => new HttpError(413, "too_large", `request body is too large (at most ${MAX_BODY_BYTES} bytes)`, { connection: "close" })
|
|
760
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
761
|
+
// Read and discard (Bun's own backstop limits this to 2x the cap): answering while the client is still sending can stall it.
|
|
762
|
+
if (req.body) {
|
|
763
|
+
const r = req.body.getReader()
|
|
764
|
+
try {
|
|
765
|
+
while (!(await r.read()).done);
|
|
766
|
+
} catch {}
|
|
767
|
+
}
|
|
768
|
+
throw tooLarge()
|
|
769
|
+
}
|
|
770
|
+
const chunks: Uint8Array[] = []
|
|
771
|
+
let total = 0
|
|
772
|
+
if (req.body) {
|
|
773
|
+
const reader = req.body.getReader()
|
|
774
|
+
for (;;) {
|
|
775
|
+
const { done, value } = await reader.read()
|
|
776
|
+
if (done) break
|
|
777
|
+
total += value.byteLength
|
|
778
|
+
if (total > MAX_BODY_BYTES) {
|
|
779
|
+
void reader.cancel().catch(() => {})
|
|
780
|
+
throw tooLarge()
|
|
781
|
+
}
|
|
782
|
+
chunks.push(value)
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
if (!total) return {}
|
|
786
|
+
let parsed: unknown
|
|
787
|
+
try {
|
|
788
|
+
parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"))
|
|
789
|
+
} catch {
|
|
790
|
+
throw new HttpError(400, "invalid_json", "the request body is not valid JSON")
|
|
791
|
+
}
|
|
792
|
+
if (!isObj(parsed)) throw new HttpError(400, "invalid_json", "the request body must be a JSON object")
|
|
793
|
+
return parsed
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
type Route = { admin?: boolean; body?: boolean; run: (u: UserRow, body: Obj, url: URL) => unknown | Promise<unknown> }
|
|
797
|
+
const routes: Record<string, Route> = {
|
|
798
|
+
"GET /v1/whoami": { run: (u) => whoami(u) },
|
|
799
|
+
"POST /v1/ingest": { body: true, run: (u, b) => ingest(u, b) },
|
|
800
|
+
"POST /v1/recall": { body: true, run: (u, b) => recall(u, b) },
|
|
801
|
+
"POST /v1/promote": { body: true, run: (u, b) => promote(u, b) },
|
|
802
|
+
"POST /v1/forget": { body: true, run: (u, b) => forget(u, b) },
|
|
803
|
+
"GET /v1/projects": { run: (u) => projects(u) },
|
|
804
|
+
"GET /v1/graph": { run: (u, _b, url) => graphView(u, url) },
|
|
805
|
+
"POST /v1/admin/users": { admin: true, body: true, run: (u, b) => addUser(u, b) },
|
|
806
|
+
"GET /v1/admin/users": { admin: true, run: () => db.users().map((r) => db.orgUser(r)) },
|
|
807
|
+
"POST /v1/admin/users/disable": { admin: true, body: true, run: (u, b) => disableUser(u, b) },
|
|
808
|
+
"POST /v1/admin/tokens": { admin: true, body: true, run: (u, b) => createToken(u, b) },
|
|
809
|
+
"GET /v1/admin/tokens": { admin: true, run: () => db.tokens() },
|
|
810
|
+
"POST /v1/admin/tokens/revoke": { admin: true, body: true, run: (u, b) => revokeToken(u, b) },
|
|
811
|
+
"POST /v1/admin/teams": { admin: true, body: true, run: (u, b) => setTeam(u, b) },
|
|
812
|
+
"POST /v1/admin/projects/members": { admin: true, body: true, run: (u, b) => projectMembers(u, b) },
|
|
813
|
+
"GET /v1/admin/policy": { admin: true, run: () => db.policy() },
|
|
814
|
+
"PUT /v1/admin/policy": { admin: true, body: true, run: (u, b) => putPolicy(u, b) },
|
|
815
|
+
"GET /v1/admin/sso": { admin: true, run: () => getSsoAdmin(db) },
|
|
816
|
+
"PUT /v1/admin/sso": {
|
|
817
|
+
admin: true,
|
|
818
|
+
body: true,
|
|
819
|
+
run: (u, b) => {
|
|
820
|
+
try {
|
|
821
|
+
return putSsoAdmin(db, u.id, b)
|
|
822
|
+
} catch (e) {
|
|
823
|
+
if (e instanceof SsoInputError) throw new HttpError(400, "invalid_sso", e.message)
|
|
824
|
+
throw e
|
|
825
|
+
}
|
|
826
|
+
},
|
|
827
|
+
},
|
|
828
|
+
"GET /v1/admin/audit": { admin: true, run: (_u, _b, url) => auditLog(url) },
|
|
829
|
+
"POST /v1/admin/retention/run": { admin: true, run: async (u) => ({ removed: await runRetention(u.id) }) },
|
|
830
|
+
}
|
|
831
|
+
const paths = new Set(Object.keys(routes).map((k) => k.split(" ")[1]!))
|
|
832
|
+
|
|
833
|
+
async function handle(req: Request, ip: string): Promise<Response> {
|
|
834
|
+
const url = new URL(req.url)
|
|
835
|
+
const path = url.pathname
|
|
836
|
+
if (path === "/v1/health") {
|
|
837
|
+
if (req.method !== "GET") throw new HttpError(405, "method_not_allowed", "use GET")
|
|
838
|
+
return json(200, { ok: true, api: 1 })
|
|
839
|
+
}
|
|
840
|
+
// Single sign-on endpoints come before authentication: they are how a person gets a token (see sso.ts).
|
|
841
|
+
if (path.startsWith("/v1/auth/")) {
|
|
842
|
+
const t0 = now()
|
|
843
|
+
if (!signInLimit.hit(ip, t0)) throw new HttpError(429, "rate_limited", "too many sign-in attempts; wait a minute", { "retry-after": String(signInLimit.retryAfter(ip, t0)) })
|
|
844
|
+
const { handleSso } = await import("./sso.ts")
|
|
845
|
+
const r = await handleSso(req, { db, now, ip })
|
|
846
|
+
if (r) return r
|
|
847
|
+
throw new HttpError(404, "not_found", "no such endpoint")
|
|
848
|
+
}
|
|
849
|
+
const t = now()
|
|
850
|
+
const auth = authenticate(db, req.headers.get("authorization"), t)
|
|
851
|
+
if (!auth.ok) {
|
|
852
|
+
// a valid token is never throttled by someone else's failures from the same address; failures are
|
|
853
|
+
if (failLimit.blocked(ip, t)) throw new HttpError(429, "rate_limited", "too many failed attempts; wait a minute", { "retry-after": String(failLimit.retryAfter(ip, t)) })
|
|
854
|
+
failLimit.hit(ip, t)
|
|
855
|
+
auditAuthFail(auth.reason, auth.actor)
|
|
856
|
+
throw new HttpError(401, "unauthorized", "missing, invalid, expired or revoked token")
|
|
857
|
+
}
|
|
858
|
+
if (!rateLimit.hit(auth.tokenId, t)) throw new HttpError(429, "rate_limited", "too many requests; slow down", { "retry-after": String(rateLimit.retryAfter(auth.tokenId, t)) })
|
|
859
|
+
const route = routes[`${req.method} ${path}`]
|
|
860
|
+
if (!route) {
|
|
861
|
+
if (paths.has(path)) throw new HttpError(405, "method_not_allowed", "wrong method for this path")
|
|
862
|
+
throw new HttpError(404, "not_found", "no such endpoint")
|
|
863
|
+
}
|
|
864
|
+
// the user row is re-read per request: a role or disabled flag changed a moment ago applies now
|
|
865
|
+
const u = auth.user
|
|
866
|
+
if (route.admin && u.role !== "admin") throw forbidden("this needs the admin role")
|
|
867
|
+
const body = route.body ? await readJson(req) : {}
|
|
868
|
+
const result = await route.run(u, body, url)
|
|
869
|
+
return json(200, result)
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
let server: ReturnType<typeof Bun.serve>
|
|
873
|
+
try {
|
|
874
|
+
server = Bun.serve({
|
|
875
|
+
port: opts.port ?? 4500,
|
|
876
|
+
hostname: opts.host ?? "127.0.0.1",
|
|
877
|
+
...(tls ? { tls: { cert: tls.cert, key: tls.key } } : {}),
|
|
878
|
+
maxRequestBodySize: MAX_BODY_BYTES * 2, // backstop; the handler enforces the real cap with a proper error body
|
|
879
|
+
idleTimeout: 30,
|
|
880
|
+
async fetch(req, srv) {
|
|
881
|
+
try {
|
|
882
|
+
return await handle(req, clientAddress(req, srv.requestIP(req)?.address, !!opts.trustProxy))
|
|
883
|
+
} catch (e) {
|
|
884
|
+
if (e instanceof HttpError) return fail(e)
|
|
885
|
+
// never a stack trace or a message to the client; one terse line for the operator
|
|
886
|
+
process.stderr.write(`brain org: internal error: ${(e as Error)?.name ?? "Error"}\n`)
|
|
887
|
+
return json(500, { error: "internal error", code: "internal" } satisfies OrgError)
|
|
888
|
+
}
|
|
889
|
+
},
|
|
890
|
+
error() {
|
|
891
|
+
return json(500, { error: "internal error", code: "internal" } satisfies OrgError)
|
|
892
|
+
},
|
|
893
|
+
})
|
|
894
|
+
} catch (e) {
|
|
895
|
+
// the port is taken or the address is invalid: leave nothing behind (timer, lock file, open database)
|
|
896
|
+
if (retentionTimer) clearInterval(retentionTimer)
|
|
897
|
+
try {
|
|
898
|
+
chitta.close()
|
|
899
|
+
} catch {}
|
|
900
|
+
releaseLock()
|
|
901
|
+
throw e
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
let stopped = false
|
|
905
|
+
return {
|
|
906
|
+
url: `${tls ? "https" : "http"}://${server.hostname}:${server.port}`,
|
|
907
|
+
port: server.port ?? 0,
|
|
908
|
+
async stop() {
|
|
909
|
+
if (stopped) return
|
|
910
|
+
stopped = true
|
|
911
|
+
if (retentionTimer) clearInterval(retentionTimer)
|
|
912
|
+
await server.stop(true)
|
|
913
|
+
await chain
|
|
914
|
+
try {
|
|
915
|
+
chitta.close()
|
|
916
|
+
} catch {}
|
|
917
|
+
releaseLock()
|
|
918
|
+
},
|
|
919
|
+
}
|
|
920
|
+
}
|