hippo-memory 1.63.2 → 1.64.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (280) hide show
  1. package/README.md +11 -5
  2. package/dist/agent-memories/claude-code.js +3 -3
  3. package/dist/ambient-store.d.ts +2 -0
  4. package/dist/ambient-store.js +4 -3
  5. package/dist/api/assemble.d.ts +2 -0
  6. package/dist/api/assemble.js +11 -7
  7. package/dist/api/auth.d.ts +15 -11
  8. package/dist/api/auth.js +116 -22
  9. package/dist/api/context-select.d.ts +5 -3
  10. package/dist/api/context-select.js +6 -11
  11. package/dist/api/context-types.d.ts +2 -1
  12. package/dist/api/context.d.ts +1 -1
  13. package/dist/api/context.js +79 -55
  14. package/dist/api/dormant.d.ts +5 -5
  15. package/dist/api/dormant.js +13 -9
  16. package/dist/api/drill-down.d.ts +2 -0
  17. package/dist/api/drill-down.js +12 -7
  18. package/dist/api/forget.d.ts +1 -1
  19. package/dist/api/forget.js +8 -4
  20. package/dist/api/learn.js +5 -3
  21. package/dist/api/outcome.d.ts +1 -1
  22. package/dist/api/outcome.js +2 -1
  23. package/dist/api/promote.js +8 -5
  24. package/dist/api/recall-types.d.ts +4 -2
  25. package/dist/api/recall.js +51 -36
  26. package/dist/api/remember.d.ts +7 -0
  27. package/dist/api/remember.js +26 -2
  28. package/dist/api/sleep-run.d.ts +28 -0
  29. package/dist/api/sleep-run.js +267 -0
  30. package/dist/api/sleep.d.ts +5 -45
  31. package/dist/api/sleep.js +5 -252
  32. package/dist/api/types.d.ts +5 -0
  33. package/dist/api/types.js +4 -0
  34. package/dist/audit-prune.js +1 -1
  35. package/dist/audit.d.ts +1 -3
  36. package/dist/audit.js +7 -103
  37. package/dist/auth.d.ts +48 -4
  38. package/dist/auth.js +111 -42
  39. package/dist/autolearn.js +2 -2
  40. package/dist/capture/caller-session.d.ts +16 -0
  41. package/dist/capture/caller-session.js +58 -0
  42. package/dist/capture/command.d.ts +19 -1
  43. package/dist/capture/command.js +28 -21
  44. package/dist/capture/compact-resume-caller.d.ts +11 -0
  45. package/dist/capture/compact-resume-caller.js +35 -0
  46. package/dist/capture/compact.d.ts +0 -14
  47. package/dist/capture/compact.js +7 -160
  48. package/dist/capture/compaction-items-caller.d.ts +16 -0
  49. package/dist/capture/compaction-items-caller.js +32 -0
  50. package/dist/capture/extract.d.ts +2 -14
  51. package/dist/capture/extract.js +135 -346
  52. package/dist/capture/failure-caller.d.ts +21 -0
  53. package/dist/capture/failure-caller.js +74 -0
  54. package/dist/capture/failure-reading.d.ts +44 -0
  55. package/dist/capture/failure-reading.js +78 -0
  56. package/dist/capture/pre-compact-caller.d.ts +16 -0
  57. package/dist/capture/pre-compact-caller.js +39 -0
  58. package/dist/capture/session-end-caller.d.ts +21 -0
  59. package/dist/capture/session-end-caller.js +30 -0
  60. package/dist/capture/session-texts.d.ts +14 -0
  61. package/dist/capture/session-texts.js +42 -0
  62. package/dist/capture/transcript.d.ts +6 -1
  63. package/dist/capture/transcript.js +20 -16
  64. package/dist/capture/working-state.d.ts +21 -0
  65. package/dist/capture/working-state.js +154 -0
  66. package/dist/capture-error.d.ts +8 -22
  67. package/dist/capture-error.js +9 -61
  68. package/dist/cli/audit.js +25 -0
  69. package/dist/cli/auth.js +4 -3
  70. package/dist/cli/context.js +13 -158
  71. package/dist/cli/continuity.js +2 -1
  72. package/dist/cli/init.js +5 -3
  73. package/dist/cli/last-sleep.d.ts +6 -0
  74. package/dist/cli/last-sleep.js +68 -0
  75. package/dist/cli/maintenance.d.ts +2 -1
  76. package/dist/cli/maintenance.js +17 -11
  77. package/dist/cli/quality-repair-once.d.ts +3 -0
  78. package/dist/cli/quality-repair-once.js +36 -0
  79. package/dist/cli/recall.js +2 -1
  80. package/dist/cli/remember.d.ts +2 -0
  81. package/dist/cli/remember.js +4 -1
  82. package/dist/cli/session-hooks.d.ts +0 -3
  83. package/dist/cli/session-hooks.js +12 -49
  84. package/dist/cli/setup.js +10 -2
  85. package/dist/cli/shared.d.ts +7 -14
  86. package/dist/cli/shared.js +19 -78
  87. package/dist/cli/sleep.js +6 -3
  88. package/dist/cli/usage.js +18 -9
  89. package/dist/cli.d.ts +1 -1
  90. package/dist/cli.js +3 -2
  91. package/dist/client.js +1 -1
  92. package/dist/compaction-record.d.ts +19 -1
  93. package/dist/compaction-record.js +61 -132
  94. package/dist/compaction-spool.d.ts +45 -0
  95. package/dist/compaction-spool.js +454 -0
  96. package/dist/config.d.ts +8 -0
  97. package/dist/config.js +49 -0
  98. package/dist/connectors/github/deletion.d.ts +1 -1
  99. package/dist/connectors/github/deletion.js +5 -17
  100. package/dist/consolidate/conflicts.js +4 -0
  101. package/dist/consolidate/flush-units.d.ts +9 -0
  102. package/dist/consolidate/flush-units.js +63 -0
  103. package/dist/consolidate/llm-passes.js +5 -4
  104. package/dist/consolidate/merge.js +5 -1
  105. package/dist/consolidate/run.d.ts +2 -1
  106. package/dist/consolidate/sleep.d.ts +2 -0
  107. package/dist/consolidate/sleep.js +82 -15
  108. package/dist/context-render.d.ts +14 -0
  109. package/dist/context-render.js +28 -0
  110. package/dist/dag.js +6 -0
  111. package/dist/dashboard-actions.js +3 -2
  112. package/dist/dashboard.js +4 -3
  113. package/dist/db/backup.d.ts +6 -0
  114. package/dist/db/backup.js +29 -0
  115. package/dist/db/busy.d.ts +9 -0
  116. package/dist/db/busy.js +35 -0
  117. package/dist/db/continuity.d.ts +1 -0
  118. package/dist/db/continuity.js +31 -4
  119. package/dist/db/meta.d.ts +2 -0
  120. package/dist/db/meta.js +7 -0
  121. package/dist/db/migrate.js +2 -3
  122. package/dist/db/migrations/index.d.ts +1 -1
  123. package/dist/db/migrations/index.js +5 -1
  124. package/dist/db/migrations/v39.js +2 -7
  125. package/dist/db/migrations/v53.d.ts +3 -0
  126. package/dist/db/migrations/v53.js +16 -0
  127. package/dist/db/migrations/v54.d.ts +3 -0
  128. package/dist/db/migrations/v54.js +25 -0
  129. package/dist/db/open.d.ts +11 -0
  130. package/dist/db/open.js +27 -0
  131. package/dist/db/tables.d.ts +2 -0
  132. package/dist/db/tables.js +6 -2
  133. package/dist/db.d.ts +2 -2
  134. package/dist/db.js +2 -2
  135. package/dist/doctor.d.ts +1 -1
  136. package/dist/doctor.js +31 -15
  137. package/dist/dormant.d.ts +16 -20
  138. package/dist/dormant.js +24 -30
  139. package/dist/embedding-provider.js +4 -3
  140. package/dist/entry/json-hooks.d.ts +3 -0
  141. package/dist/entry/json-hooks.js +4 -0
  142. package/dist/entry/project-identity.d.ts +2 -0
  143. package/dist/entry/project-identity.js +3 -0
  144. package/dist/entry/session-text.d.ts +7 -0
  145. package/dist/entry/session-text.js +8 -0
  146. package/dist/extract.js +6 -0
  147. package/dist/failure-log.d.ts +10 -2
  148. package/dist/failure-log.js +19 -2
  149. package/dist/gated-write.js +1 -1
  150. package/dist/graph/delta.d.ts +44 -0
  151. package/dist/graph/delta.js +109 -0
  152. package/dist/graph/types.d.ts +5 -0
  153. package/dist/graph/write.d.ts +20 -20
  154. package/dist/graph/write.js +103 -36
  155. package/dist/graph-extract.d.ts +43 -9
  156. package/dist/graph-extract.js +140 -157
  157. package/dist/graph-recall.d.ts +1 -0
  158. package/dist/graph-recall.js +2 -2
  159. package/dist/graph-view.d.ts +1 -0
  160. package/dist/graph-view.js +25 -2
  161. package/dist/half-life-migration.js +5 -1
  162. package/dist/handoff-evidence.d.ts +5 -0
  163. package/dist/handoff-evidence.js +30 -0
  164. package/dist/home-path.d.ts +5 -0
  165. package/dist/home-path.js +24 -0
  166. package/dist/hooks/json-hooks.d.ts +13 -2
  167. package/dist/hooks/json-hooks.js +246 -117
  168. package/dist/hooks/opencode.js +1 -1
  169. package/dist/hooks/shared.d.ts +2 -0
  170. package/dist/hooks/shared.js +6 -1
  171. package/dist/http-util.d.ts +16 -7
  172. package/dist/http-util.js +65 -20
  173. package/dist/importers/vault.js +2 -0
  174. package/dist/index.d.ts +2 -1
  175. package/dist/index.js +1 -0
  176. package/dist/json.d.ts +2 -0
  177. package/dist/json.js +7 -0
  178. package/dist/ledger-db.d.ts +10 -0
  179. package/dist/ledger-db.js +42 -0
  180. package/dist/local-embedding.d.ts +2 -0
  181. package/dist/local-embedding.js +20 -4
  182. package/dist/mcp/admin-tools.d.ts +2 -2
  183. package/dist/mcp/admin-tools.js +47 -5
  184. package/dist/mcp/memory-tools.js +7 -2
  185. package/dist/mcp/protocol.d.ts +6 -0
  186. package/dist/mcp/protocol.js +4 -2
  187. package/dist/mcp/recall-tools.js +19 -6
  188. package/dist/mcp/request.js +4 -0
  189. package/dist/mcp/session-state.d.ts +4 -0
  190. package/dist/mcp/session-state.js +15 -6
  191. package/dist/mcp/shared-gate.d.ts +4 -0
  192. package/dist/mcp/shared-gate.js +22 -0
  193. package/dist/mcp/tools.js +1 -0
  194. package/dist/memory-quality.d.ts +35 -0
  195. package/dist/memory-quality.js +191 -0
  196. package/dist/memory.d.ts +4 -4
  197. package/dist/physics-state.js +1 -1
  198. package/dist/pilot-arm.d.ts +14 -3
  199. package/dist/pilot-arm.js +32 -10
  200. package/dist/postinstall.js +2 -2
  201. package/dist/project-briefs.js +4 -4
  202. package/dist/project-identity.d.ts +14 -10
  203. package/dist/project-identity.js +29 -11
  204. package/dist/project-merge.d.ts +0 -2
  205. package/dist/project-merge.js +9 -13
  206. package/dist/prompt-hook.d.ts +65 -0
  207. package/dist/prompt-hook.js +213 -0
  208. package/dist/prompt-recall.js +1 -1
  209. package/dist/quality-repair.d.ts +27 -0
  210. package/dist/quality-repair.js +220 -0
  211. package/dist/rate-limit.d.ts +1 -0
  212. package/dist/rate-limit.js +5 -3
  213. package/dist/raw-archive.d.ts +1 -1
  214. package/dist/raw-archive.js +6 -16
  215. package/dist/recall-scope.d.ts +28 -5
  216. package/dist/recall-scope.js +52 -7
  217. package/dist/recall-trace.js +1 -1
  218. package/dist/reject-flow.d.ts +2 -0
  219. package/dist/reject-flow.js +20 -6
  220. package/dist/secret-detect.js +7 -8
  221. package/dist/server/auth.d.ts +8 -6
  222. package/dist/server/auth.js +85 -38
  223. package/dist/server/client-ip.d.ts +2 -0
  224. package/dist/server/client-ip.js +16 -1
  225. package/dist/server/mcp-http.d.ts +9 -3
  226. package/dist/server/mcp-http.js +56 -13
  227. package/dist/server/request.d.ts +1 -1
  228. package/dist/server/request.js +5 -5
  229. package/dist/server/routes/admin.js +12 -6
  230. package/dist/server/routes/memories.js +9 -1
  231. package/dist/server/routes/recall.js +14 -2
  232. package/dist/server/types.d.ts +42 -5
  233. package/dist/server/validation.d.ts +5 -2
  234. package/dist/server/validation.js +19 -2
  235. package/dist/server.d.ts +21 -4
  236. package/dist/server.js +207 -49
  237. package/dist/session-digest.d.ts +0 -1
  238. package/dist/session-digest.js +3 -12
  239. package/dist/session-owners.d.ts +7 -0
  240. package/dist/session-owners.js +54 -0
  241. package/dist/share-scrub.d.ts +3 -0
  242. package/dist/share-scrub.js +8 -0
  243. package/dist/shared.d.ts +1 -0
  244. package/dist/shared.js +26 -18
  245. package/dist/store/candidates.d.ts +14 -3
  246. package/dist/store/candidates.js +48 -26
  247. package/dist/store/conflicts.d.ts +15 -0
  248. package/dist/store/conflicts.js +68 -44
  249. package/dist/store/delete-and-batch.d.ts +25 -1
  250. package/dist/store/delete-and-batch.js +121 -69
  251. package/dist/store/entry-reads.d.ts +5 -6
  252. package/dist/store/entry-reads.js +24 -12
  253. package/dist/store/entry-row.d.ts +7 -10
  254. package/dist/store/entry-row.js +40 -18
  255. package/dist/store/entry-writes.d.ts +3 -3
  256. package/dist/store/entry-writes.js +9 -23
  257. package/dist/store/handoffs.d.ts +9 -6
  258. package/dist/store/handoffs.js +72 -59
  259. package/dist/store/index-and-stats.js +2 -2
  260. package/dist/store/open.d.ts +3 -1
  261. package/dist/store/open.js +3 -3
  262. package/dist/store/search-rows.d.ts +8 -5
  263. package/dist/store/search-rows.js +15 -18
  264. package/dist/store/sessions.d.ts +25 -6
  265. package/dist/store/sessions.js +61 -19
  266. package/dist/store/summaries.d.ts +1 -1
  267. package/dist/store/summaries.js +3 -18
  268. package/dist/store/tenant-lookup.d.ts +7 -3
  269. package/dist/store/tenant-lookup.js +9 -9
  270. package/dist/store-port.d.ts +13 -0
  271. package/dist/store-port.js +20 -0
  272. package/dist/version.d.ts +5 -1
  273. package/dist/version.js +5 -1
  274. package/dist/working-memory.js +1 -1
  275. package/dist/write-budget.d.ts +15 -0
  276. package/dist/write-budget.js +15 -0
  277. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  278. package/extensions/openclaw-plugin/package.json +1 -1
  279. package/openclaw.plugin.json +1 -1
  280. package/package.json +8 -4
@@ -1,5 +1,5 @@
1
1
  import { BadRequestError, NotFoundError } from './api-errors.js';
2
- import { isFtsAvailable } from './db.js';
2
+ import { isFtsAvailable, withWriteScope } from './db.js';
3
3
  import { appendAuditEvent, reportAuditWriteFailure } from './audit.js';
4
4
  import { markSummaryDirtyInTx } from './summary-dirty.js';
5
5
  function loadRawRow(db, id) {
@@ -60,16 +60,15 @@ function auditArchive(db, id, row, opts) {
60
60
  *
61
61
  * Snapshots the full row into `raw_archive`, flips `kind` to `'archived'` so the
62
62
  * append-only trigger lets the delete through, then deletes the row. All in one
63
- * SAVEPOINT so it can be nested inside an outer transaction (e.g. batchWriteAndDelete).
63
+ * write scope, which nests inside an outer transaction (e.g. batchWriteAndDelete).
64
64
  *
65
65
  * Throws if the row does not exist or is not `kind='raw'`.
66
66
  */
67
67
  export function archiveRawMemory(db, id, opts) {
68
68
  const row = loadRawRow(db, id);
69
- // SAVEPOINT (not BEGIN) so this works whether or not we're already inside a
70
- // transaction. SQLite refuses BEGIN within a transaction; SAVEPOINT nests safely.
71
- db.exec('SAVEPOINT archive_raw');
72
- try {
69
+ // A SAVEPOINT when already inside a transaction (e.g. batchWriteAndDelete), so a throw here
70
+ // rolls back only this archive.
71
+ withWriteScope(db, 'archive_raw', () => {
73
72
  moveRowToArchive(db, id, row, opts);
74
73
  auditArchive(db, id, row, opts);
75
74
  // Archiving a child under a level-2 summary marks the parent dirty, inside the
@@ -85,15 +84,6 @@ export function archiveRawMemory(db, id, opts) {
85
84
  if (opts.afterArchive) {
86
85
  opts.afterArchive(db, id);
87
86
  }
88
- db.exec('RELEASE SAVEPOINT archive_raw');
89
- }
90
- catch (e) {
91
- try {
92
- db.exec('ROLLBACK TO SAVEPOINT archive_raw');
93
- db.exec('RELEASE SAVEPOINT archive_raw');
94
- }
95
- catch { /* savepoint already discarded; keep the original error */ }
96
- throw e;
97
- }
87
+ });
98
88
  }
99
89
  //# sourceMappingURL=raw-archive.js.map
@@ -46,6 +46,20 @@ export declare function assertNonEmpty<T>(arr: readonly T[], name: string): void
46
46
  export declare const PRIVATE_SCOPE_RE: RegExp;
47
47
  /** True when `scope` matches the `<source>:private:*` shape. */
48
48
  export declare function isPrivateScope(scope: string | null | undefined): boolean;
49
+ export declare const PERSONAL_SCOPE_PREFIX = "personal:private:";
50
+ export declare const PERSONAL_OWNER_MAX: number;
51
+ /** True for a `personal:private:<owner>` scope, any case, so a case variant is never mistaken for a team scope. */
52
+ export declare function isPersonalScope(scope: string | null | undefined): boolean;
53
+ /** The caller's own personal scope, or null when it has no owner that can hold one. */
54
+ export declare function personalScopeOf(actor: {
55
+ owner?: string;
56
+ } | undefined): string | null;
57
+ /** Refuses a client-sent `personal:` scope: only the server stamps one, from the caller's owner. */
58
+ export declare function assertClientScope(scope: string | null | undefined): void;
59
+ /** True when `actor` may change or delete a row in `scope`: any non-personal scope, or its own personal one. */
60
+ export declare function canTouchScope(actor: {
61
+ owner?: string;
62
+ }, scope: string | null): boolean;
49
63
  /**
50
64
  * Recall-side scope filter — the canonical JS half of the recall default-deny
51
65
  * rule (the SQL half lives in `loadSearchRows` via `loadRecallSearchEntries`).
@@ -53,14 +67,22 @@ export declare function isPrivateScope(scope: string | null | undefined): boolea
53
67
  * - When `requested` is set and non-empty: exact match required.
54
68
  * - When `requested` is undefined/empty: default-deny on any
55
69
  * `<source>:private:*` scope and on the `RECALL_DEFAULT_DENY_SCOPES`
56
- * quarantine buckets. `null` and public scopes pass.
70
+ * quarantine buckets. `null` and public scopes pass, and so does `ownScope`, the caller's own personal scope.
57
71
  *
58
72
  * @internal Exported for test parity with
59
73
  * `RECALL_DEFAULT_DENY_SCOPES` (single-source-of-truth verification). NOT part
60
74
  * of the public API surface; not re-exported from `src/index.ts`. Subject to
61
75
  * change without semver bump.
62
76
  */
63
- export declare function passesScopeFilterForRecall(scope: string | null, requested: string | undefined): boolean;
77
+ export declare function passesScopeFilterForRecall(scope: string | null, requested: string | undefined, ownScope?: string | null): boolean;
78
+ export interface SqlFragment {
79
+ sql: string;
80
+ params: string[];
81
+ }
82
+ /** SQL twin of the no-request arm of passesScopeFilterForRecall; `ownScope` is bound and compared with `=`, so `%` or `_` in an owner match nothing extra. */
83
+ export declare function scopeAdmitSql(col: '' | 'm.', ownScope?: string | null): SqlFragment;
84
+ /** SQL twin of canTouchScope, which is also canReadScope for an admin: every row but another person's personal one. LIKE folds ASCII case as isPersonalScope's /i does. */
85
+ export declare function touchableScopeSql(col: '' | 'm.', ownScope?: string | null): SqlFragment;
64
86
  /**
65
87
  * The CLI `--scope` variant of the recall filter (JS half of the
66
88
  * SQL 'default-deny-or-exact' mode in loadSearchRows).
@@ -76,7 +98,7 @@ export declare function passesScopeFilterForRecall(scope: string | null, request
76
98
  * a private scope or a quarantine bucket (`--scope unknown:legacy`). That is
77
99
  * deliberate owner access, identical in reach to api.recall's exact-match
78
100
  * for the same input; only NON-requested private/quarantine scopes stay
79
- * denied.
101
+ * denied. A named personal scope never unlocks: the CLI has no caller identity to check it against.
80
102
  */
81
103
  export declare function passesCliRecallScopeFilter(scope: string | null, requested: string | undefined): boolean;
82
104
  /**
@@ -93,12 +115,13 @@ export declare class ScopeForbiddenError extends ForbiddenError {
93
115
  * is the act that needs authorization.
94
116
  */
95
117
  export declare function isRestrictedScope(scope: string | null | undefined): boolean;
96
- /** The identity a scope check runs against: a role plus any scope grants. */
118
+ /** The identity a scope check runs against: a role, any scope grants, and the person who owns the key. */
97
119
  export interface ScopeActor {
98
120
  role: 'admin' | 'member';
99
121
  scopes?: readonly string[];
122
+ owner?: string;
100
123
  }
101
- /** True when `actor` may read `scope`: admin always; member needs an exact grant on a restricted scope. */
124
+ /** Personal rows answer to their owner alone: role, key grants and resolver scopes never open one. Else admin reads all; a member needs an exact grant on a restricted scope. */
102
125
  export declare function canReadScope(actor: ScopeActor, scope: string): boolean;
103
126
  /** Authorize an explicitly requested scope before any read honours it (member scope grants). */
104
127
  export declare function assertScopeRequestAllowed(actor: ScopeActor, requested: string | undefined): void;
@@ -5,7 +5,8 @@
5
5
  * these for its own call sites AND re-exports them for back-compat
6
6
  * (`api.isPrivateScope`, test imports of `passesScopeFilterForRecall`).
7
7
  */
8
- import { ForbiddenError } from './api-errors.js';
8
+ import { BadRequestError, ForbiddenError } from './api-errors.js';
9
+ import { MAX_ID_LEN } from './http-util.js';
9
10
  /**
10
11
  * Literal scopes excluded from recall by default-deny when the
11
12
  * caller passes no `scope`. The SQL clause in `loadSearchRows` and the JS
@@ -58,6 +59,32 @@ export function isPrivateScope(scope) {
58
59
  return false;
59
60
  return PRIVATE_SCOPE_RE.test(scope);
60
61
  }
62
+ export const PERSONAL_SCOPE_PREFIX = 'personal:private:';
63
+ // The scope must fit the 256-character scope caps, so the owner gets what the prefix leaves.
64
+ export const PERSONAL_OWNER_MAX = MAX_ID_LEN - PERSONAL_SCOPE_PREFIX.length;
65
+ /** True for a `personal:private:<owner>` scope, any case, so a case variant is never mistaken for a team scope. */
66
+ export function isPersonalScope(scope) {
67
+ if (!isScopeString(scope))
68
+ return false;
69
+ return /^personal:private:/i.test(scope);
70
+ }
71
+ /** The caller's own personal scope, or null when it has no owner that can hold one. */
72
+ export function personalScopeOf(actor) {
73
+ const owner = actor?.owner;
74
+ if (!owner || owner.length > PERSONAL_OWNER_MAX || /\p{Cc}/u.test(owner))
75
+ return null;
76
+ return `${PERSONAL_SCOPE_PREFIX}${owner}`;
77
+ }
78
+ /** Refuses a client-sent `personal:` scope: only the server stamps one, from the caller's owner. */
79
+ export function assertClientScope(scope) {
80
+ if (isScopeString(scope) && /^personal:/i.test(scope)) {
81
+ throw new BadRequestError(`scope ${scope} is reserved: the server sets personal scopes itself`);
82
+ }
83
+ }
84
+ /** True when `actor` may change or delete a row in `scope`: any non-personal scope, or its own personal one. */
85
+ export function canTouchScope(actor, scope) {
86
+ return !isPersonalScope(scope) || scope === personalScopeOf(actor);
87
+ }
61
88
  /**
62
89
  * Recall-side scope filter — the canonical JS half of the recall default-deny
63
90
  * rule (the SQL half lives in `loadSearchRows` via `loadRecallSearchEntries`).
@@ -65,18 +92,34 @@ export function isPrivateScope(scope) {
65
92
  * - When `requested` is set and non-empty: exact match required.
66
93
  * - When `requested` is undefined/empty: default-deny on any
67
94
  * `<source>:private:*` scope and on the `RECALL_DEFAULT_DENY_SCOPES`
68
- * quarantine buckets. `null` and public scopes pass.
95
+ * quarantine buckets. `null` and public scopes pass, and so does `ownScope`, the caller's own personal scope.
69
96
  *
70
97
  * @internal Exported for test parity with
71
98
  * `RECALL_DEFAULT_DENY_SCOPES` (single-source-of-truth verification). NOT part
72
99
  * of the public API surface; not re-exported from `src/index.ts`. Subject to
73
100
  * change without semver bump.
74
101
  */
75
- export function passesScopeFilterForRecall(scope, requested) {
102
+ export function passesScopeFilterForRecall(scope, requested, ownScope) {
76
103
  if (requested !== undefined && requested !== '') {
77
104
  return scope === requested;
78
105
  }
79
- return !isRestrictedScope(scope);
106
+ return !isRestrictedScope(scope) || (ownScope != null && scope === ownScope);
107
+ }
108
+ /** SQL twin of the no-request arm of passesScopeFilterForRecall; `ownScope` is bound and compared with `=`, so `%` or `_` in an owner match nothing extra. */
109
+ export function scopeAdmitSql(col, ownScope) {
110
+ const placeholders = RECALL_DEFAULT_DENY_SCOPES.map(() => '?').join(', ');
111
+ const admitted = `${col}scope IS NULL OR (${col}scope NOT IN (${placeholders}) AND ${col}scope NOT LIKE '%:private:%')`;
112
+ if (ownScope == null)
113
+ return { sql: `(${admitted})`, params: [...RECALL_DEFAULT_DENY_SCOPES] };
114
+ // The own arm sits inside the outer parentheses so a caller's `AND ${sql}` cannot split it off.
115
+ return { sql: `(${admitted} OR ${col}scope = ?)`, params: [...RECALL_DEFAULT_DENY_SCOPES, ownScope] };
116
+ }
117
+ /** SQL twin of canTouchScope, which is also canReadScope for an admin: every row but another person's personal one. LIKE folds ASCII case as isPersonalScope's /i does. */
118
+ export function touchableScopeSql(col, ownScope) {
119
+ const notPersonal = `${col}scope IS NULL OR ${col}scope NOT LIKE '${PERSONAL_SCOPE_PREFIX}%'`;
120
+ if (ownScope == null)
121
+ return { sql: `(${notPersonal})`, params: [] };
122
+ return { sql: `(${notPersonal} OR ${col}scope = ?)`, params: [ownScope] };
80
123
  }
81
124
  /**
82
125
  * The CLI `--scope` variant of the recall filter (JS half of the
@@ -93,10 +136,10 @@ export function passesScopeFilterForRecall(scope, requested) {
93
136
  * a private scope or a quarantine bucket (`--scope unknown:legacy`). That is
94
137
  * deliberate owner access, identical in reach to api.recall's exact-match
95
138
  * for the same input; only NON-requested private/quarantine scopes stay
96
- * denied.
139
+ * denied. A named personal scope never unlocks: the CLI has no caller identity to check it against.
97
140
  */
98
141
  export function passesCliRecallScopeFilter(scope, requested) {
99
- if (requested !== undefined && requested !== '' && scope === requested) {
142
+ if (requested !== undefined && requested !== '' && !isPersonalScope(requested) && scope === requested) {
100
143
  return true;
101
144
  }
102
145
  return passesScopeFilterForRecall(scope, undefined);
@@ -126,8 +169,10 @@ export function isRestrictedScope(scope) {
126
169
  // `:private:` anywhere, any case, matches the store's SQL default-deny (store/search-rows.ts) so JS never admits what SQL hides.
127
170
  return isPrivateScope(scope) || /:private:/i.test(scope) || RECALL_DEFAULT_DENY_SCOPES.includes(scope);
128
171
  }
129
- /** True when `actor` may read `scope`: admin always; member needs an exact grant on a restricted scope. */
172
+ /** Personal rows answer to their owner alone: role, key grants and resolver scopes never open one. Else admin reads all; a member needs an exact grant on a restricted scope. */
130
173
  export function canReadScope(actor, scope) {
174
+ if (isPersonalScope(scope))
175
+ return scope === personalScopeOf(actor);
131
176
  if (actor.role === 'admin')
132
177
  return true;
133
178
  if (!isRestrictedScope(scope))
@@ -49,7 +49,7 @@ export function writeRecallTrace(db, input) {
49
49
  try {
50
50
  const queryHash = createHash('sha256').update(input.query).digest('hex').slice(0, 16);
51
51
  const ts = new Date().toISOString();
52
- db.exec('BEGIN');
52
+ db.exec('BEGIN IMMEDIATE');
53
53
  try {
54
54
  const insertTrace = db.prepare(`
55
55
  INSERT INTO recall_traces (ts, tenant_id, session_id, pipeline, query_hash, query_length, result_count, explain_mode)
@@ -20,6 +20,8 @@ export interface RejectFlowOpts {
20
20
  memoryId?: string;
21
21
  /** Pre-emptive form: reject a value not currently stored (or already gone). */
22
22
  value?: string;
23
+ /** The caller's own personal scope: the only personal rows the sweep may remove. Unset (the CLI) skips every personal row. */
24
+ ownScope?: string;
23
25
  }
24
26
  export interface RejectFlowResult {
25
27
  digest: string;
@@ -11,7 +11,9 @@
11
11
  * cli.ts and api.ts, so it introduces no cycle.
12
12
  */
13
13
  import { closeHippoDb } from './db.js';
14
+ import { BadRequestError } from './api-errors.js';
14
15
  import { appendAuditEvent, reportAuditWriteFailure } from './audit.js';
16
+ import { isPersonalScope } from './recall-scope.js';
15
17
  import { archiveRawMemory } from './raw-archive.js';
16
18
  import { deleteDormantRow, listDormantSnapshots, purgeDormantByDigest, replaceDormantEntry } from './dormant.js';
17
19
  import { stampOriginProject } from './store/entry-row.js';
@@ -45,19 +47,28 @@ function assertRejectOpts(opts) {
45
47
  function contentToReject(db, opts) {
46
48
  if (opts.memoryId === undefined)
47
49
  return opts.value;
48
- // SAFETY: row's shape matches the two columns named in the SELECT above.
50
+ // SAFETY: row's shape matches the three columns named in the SELECT above.
49
51
  const row = db
50
- .prepare(`SELECT content, tenant_id FROM memories WHERE id = ?`)
52
+ .prepare(`SELECT content, tenant_id, scope FROM memories WHERE id = ?`)
51
53
  .get(opts.memoryId);
52
54
  if (!row || row.tenant_id !== opts.tenantId) {
53
55
  throw new Error(`memory not found: ${opts.memoryId}`);
54
56
  }
57
+ // A tombstone is tenant-wide, so one made from personal text would show its reason to everyone.
58
+ if (isPersonalScope(row.scope))
59
+ throw new BadRequestError("Personal memories can't be rejected. Use forget to remove it.");
55
60
  return row.content;
56
61
  }
62
+ /** Every row but another person's personal one, which is outside the caller's recall and so outside its reject. */
63
+ function inReach(opts, scope) {
64
+ return !isPersonalScope(scope) || scope === opts.ownScope;
65
+ }
57
66
  function removeLiveRows(db, opts, holdsValue, removal) {
58
67
  const { removedIds, removedRawIds, successors } = removal;
59
68
  const merged = [];
60
69
  for (const row of selectAllEntries(db, opts.tenantId)) {
70
+ if (!inReach(opts, row.scope))
71
+ continue;
61
72
  if (!holdsValue(row.content)) {
62
73
  if (heldTexts(row).some(holdsValue))
63
74
  merged.push(row);
@@ -93,10 +104,13 @@ function removeLiveRows(db, opts, holdsValue, removal) {
93
104
  // bring it back. They have no markdown mirror, so the post-commit
94
105
  // mirror purge below is a no-op for them; they join removedIds for the
95
106
  // audit trail and the caller's report.
96
- function removeDormantCopies(db, tenantId, digest, holdsValue, removal) {
107
+ function removeDormantCopies(db, opts, digest, holdsValue, removal) {
108
+ const { tenantId } = opts;
97
109
  const { removedIds, dormantSuccessorIds } = removal;
98
- removedIds.push(...purgeDormantByDigest(db, tenantId, digest));
110
+ removedIds.push(...purgeDormantByDigest(db, tenantId, digest, (scope) => inReach(opts, scope)));
99
111
  for (const dormant of listDormantSnapshots(db, tenantId)) {
112
+ if (!inReach(opts, dormant.entry.scope))
113
+ continue;
100
114
  const successor = mergedSuccessor(dormant.entry, holdsValue, new Set(removedIds));
101
115
  if (successor === undefined)
102
116
  continue;
@@ -157,7 +171,7 @@ export function rejectValue(opts) {
157
171
  const digest = rejectionDigest(content);
158
172
  const now = new Date().toISOString();
159
173
  const removal = { removedIds: [], removedRawIds: [], successors: [], dormantSuccessorIds: [] };
160
- db.exec('BEGIN');
174
+ db.exec('BEGIN IMMEDIATE');
161
175
  try {
162
176
  insertRejectedValue(db, {
163
177
  tenantId: opts.tenantId,
@@ -173,7 +187,7 @@ export function rejectValue(opts) {
173
187
  // memories is the escape if stores grow 100x; not needed now.
174
188
  const holdsValue = (text) => rejectionDigest(text) === digest;
175
189
  removeLiveRows(db, opts, holdsValue, removal);
176
- removeDormantCopies(db, opts.tenantId, digest, holdsValue, removal);
190
+ removeDormantCopies(db, opts, digest, holdsValue, removal);
177
191
  auditRejectValue(db, opts, digest, removal.removedIds);
178
192
  db.exec('COMMIT');
179
193
  }
@@ -44,12 +44,10 @@ const SECRET_PATTERNS = [
44
44
  // does.
45
45
  { name: 'sk-style-key', re: /\bsk-[A-Za-z0-9_-]{20,}\b/ },
46
46
  { name: 'sk-underscore-key', re: /\bsk_[A-Za-z0-9]+_[A-Za-z0-9_]{6,}\b/ },
47
- // Generic assignment: api_key=..., password: ..., token = "..." where the
48
- // value actually LOOKS like a credential: 12+ chars drawn only from
49
- // token-safe characters AND containing at least one digit. Without both
50
- // constraints this pattern flags ordinary code snippets and prose
51
- // (`token = estimateTokens(...)`), silently hiding code-lesson memories from ambient context.
52
- { name: 'secret-assignment', re: /\b(?:api[_-]?key|secret|token|password)\s*[:=]\s*['"]?(?=[A-Za-z0-9_\-+/]*\d)[A-Za-z0-9_\-+/=]{12,}/i },
47
+ // The value needs 12+ token-safe chars and a digit, or `token = estimateTokens(...)` and doc templates like user:password@ would hide code lessons from ambient context.
48
+ // Secret names end in the keyword (dbPassword, PGPASSWORD) and token_url does not, so the match opens there and scans no prefix; pwd and pass need a _ as OLDPWD and bypass are not secrets.
49
+ { name: 'secret-assignment', re: /(?:(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|token|passw(?:or)?d|(?<=_)(?:pwd|pass))(?:[_-]?(?:key(?:[_-]?base)?|value|secret))?['"]?\s*(?::=?|=>?)\s*['"]?|(?<=--)password(?:=|\s+))(?=[A-Za-z0-9_\-+/]*\d)[A-Za-z0-9_\-+/=]{12,}/i },
50
+ { name: 'url-password', re: /(?<=[A-Za-z0-9]:\/\/)[^\s/:@?#]*:(?=[^\s/@?#]*\d)[^\s/@?#]+(?=@)/ },
53
51
  ];
54
52
  const KEYISH_CONTEXT_RE = /key|token|secret|credential|bearer|auth|password/i;
55
53
  const CO_OCCURRENCE_GUARDED = new Set(['sk-style-key', 'sk-underscore-key']);
@@ -58,6 +56,7 @@ const STRICT_ONLY_PATTERNS = [
58
56
  /\bbearer\s+[A-Za-z0-9._~+/-]{16,}=*/gi,
59
57
  /\bauthorization["']?\s*[:=]\s*["']?basic\s+[A-Za-z0-9+/]{8,}={0,2}/gi,
60
58
  /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/g,
59
+ /(?<=[A-Za-z0-9]:\/\/)[^\s/:@?#]*:[^\s/@?#]+(?=@)/g,
61
60
  ];
62
61
  /**
63
62
  * Scan a memory's tags + content for secret material.
@@ -88,8 +87,8 @@ export function detectSecret(entry) {
88
87
  export function redactSecrets(text) {
89
88
  return redactText(text, false);
90
89
  }
91
- /** A domain's first label must open on a letter, so asset names like logo@2x.png and pins like react@18.2.0 stay. */
92
- const EMAIL = /\b[A-Za-z0-9._%+-]+@[A-Za-z][A-Za-z0-9-]*(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}\b/g;
90
+ /** Only a run's first character starts a match, so a long run is scanned once; a domain's first label opens on a letter, so logo@2x.png and react@18.2.0 stay. */
91
+ const EMAIL = /(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]{1,64}@[A-Za-z][A-Za-z0-9-]*(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}\b/g;
93
92
  /** Memories never hold a raw email address (AGENTS.md); phone numbers are left alone, as their patterns misfire on ids. */
94
93
  export function maskEmails(text) {
95
94
  return text.replace(EMAIL, '[email]');
@@ -1,6 +1,6 @@
1
1
  import type { IncomingMessage } from 'node:http';
2
- import type { Context } from '../api.js';
3
- import type { ServeOpts } from './types.js';
2
+ import { type Context } from '../api.js';
3
+ import type { ResolvedServeOpts } from './types.js';
4
4
  /**
5
5
  * Recognise loopback remote addresses. Node reports IPv6-mapped IPv4 as
6
6
  * '::ffff:127.0.0.1' on dual-stack sockets, so we accept that alongside
@@ -27,13 +27,15 @@ type AuthHeader = {
27
27
  token: string;
28
28
  };
29
29
  export declare function readAuthHeader(req: IncomingMessage): AuthHeader;
30
- type AuthOpts = Pick<ServeOpts, 'hippoRoot' | 'authResolver' | 'authResolverTimeoutMs'>;
30
+ type AuthOpts = Pick<ResolvedServeOpts, 'hippoRoot' | 'authResolver' | 'authResolverTimeoutMs' | 'store' | 'callerLimiter'> & Partial<Pick<ResolvedServeOpts, 'failedAuthLimiter'>>;
31
31
  /** Add-ons call this to refuse a subject that would collide with a built-in actor. */
32
32
  export declare function isReservedActor(subject: string): boolean;
33
+ /** Key cap for every serve() bucket, shared by the warn map so it never tracks more callers than the buckets do. */
34
+ export declare const LIMITER_MAX_KEYS = 10000;
33
35
  /**
34
36
  * Build a per-request Context from the Authorization header and remote
35
- * address. Throws HttpError(401) for invalid / missing credentials. Opens
36
- * the DB only for an API-key-shaped Bearer token (or any Bearer token when no
37
+ * address. Throws HttpError(401) for invalid / missing credentials. Reads
38
+ * the store only for an API-key-shaped Bearer token (or any Bearer token when no
37
39
  * auth resolver is registered), so loopback no-auth requests stay cheap.
38
40
  */
39
41
  export declare function buildContextWithAuth(req: IncomingMessage, opts: AuthOpts): Promise<Context>;
@@ -44,7 +46,7 @@ export declare function buildContextWithAuth(req: IncomingMessage, opts: AuthOpt
44
46
  * envelope. Loopback no-auth still passes.
45
47
  */
46
48
  export declare function requireAuth(req: IncomingMessage, opts: AuthOpts): Promise<void>;
47
- /** Never rejects: an outage (5xx) skips one heartbeat tick, only a definite 4xx denial closes the stream. */
49
+ /** Never rejects or charges a caller bucket: an outage (5xx) or a throttle (429) skips one tick; only a definite 4xx denial closes the stream. */
48
50
  export declare function heartbeatVerdict(req: IncomingMessage, opts: AuthOpts): Promise<'ok' | 'revoked' | 'unavailable'>;
49
51
  /** Gate for any action beyond the caller's own tenant: a tenant's admin, by key or resolver, is never a host admin. */
50
52
  export declare function assertCrossTenantAdmin(ctx: Context, what: string): void;
@@ -3,7 +3,9 @@ import { envRequireAuth } from '../env.js';
3
3
  import { resolveTenantId } from '../tenant.js';
4
4
  import { log } from '../log.js';
5
5
  import { API_KEY_PREFIX, verifyApiKeyCached } from '../auth.js';
6
+ import { ownerOrSubject } from '../api.js';
6
7
  import { HttpError, isCrossSite, isHeaderString, LOOPBACK_HOST_HEADER, MAX_ID_LEN } from '../http-util.js';
8
+ import { clientIpForRateLimit, subscriberKey } from './client-ip.js';
7
9
  import { requestIds } from './request.js';
8
10
  import { isJsonString } from '../json.js';
9
11
  /**
@@ -134,51 +136,105 @@ async function askResolver(resolver, token, deadlineMs) {
134
136
  return clean;
135
137
  }
136
138
  const DEFAULT_RESOLVER_DEADLINE_MS = 5000;
139
+ /** Runs only before scrypt, so junk, unknown, revoked and expired tokens and a proven key's re-check never spend a colleague's budget. */
140
+ function chargeScryptRun(req, opts) {
141
+ const limiter = opts.failedAuthLimiter;
142
+ // Reserving rather than peeking bounds scrypt runs exactly, even when concurrent misses await a slow store.
143
+ if (limiter && !limiter.check(subscriberKey(clientIpForRateLimit(req)))) {
144
+ throw new HttpError(429, 'too many key checks from this address', limiter.retryAfterSec);
145
+ }
146
+ }
137
147
  /** Shared by buildContextWithAuth and requireAuth so the two cannot drift. */
138
- async function resolveBearer(token, opts) {
148
+ async function resolveBearer(req, token, opts) {
139
149
  // Routing by shape keeps key plaintext out of plugin code and stops a resolver overriding a key's identity.
140
150
  if (opts.authResolver && !token.startsWith(API_KEY_PREFIX)) {
141
151
  const t = opts.authResolverTimeoutMs;
142
152
  const deadlineMs = t !== undefined && Number.isFinite(t) && t > 0 ? t : DEFAULT_RESOLVER_DEADLINE_MS;
143
- return { ...(await askResolver(opts.authResolver, token, deadlineMs)), viaAuthResolver: true };
153
+ const clean = await askResolver(opts.authResolver, token, deadlineMs);
154
+ return { ...clean, viaAuthResolver: true, owner: clean.subject }; // a resolver vouches for a person, never names one
144
155
  }
145
- const key = verifyApiKeyCached(opts.hippoRoot, token);
156
+ const key = await verifyApiKeyCached(opts.hippoRoot, token, opts.store, () => chargeScryptRun(req, opts));
146
157
  if (!key)
147
158
  throw new HttpError(401, 'invalid api key');
148
- return { tenantId: key.tenantId, subject: `api_key:${key.keyId}`, role: key.role, scopes: key.scopes };
159
+ const id = { tenantId: key.tenantId, subject: `api_key:${key.keyId}`, role: key.role, scopes: key.scopes };
160
+ if (key.ownerSubject)
161
+ id.owner = key.ownerSubject;
162
+ return id;
149
163
  }
150
- /**
151
- * Build a per-request Context from the Authorization header and remote
152
- * address. Throws HttpError(401) for invalid / missing credentials. Opens
153
- * the DB only for an API-key-shaped Bearer token (or any Bearer token when no
154
- * auth resolver is registered), so loopback no-auth requests stay cheap.
155
- */
156
- export async function buildContextWithAuth(req, opts) {
164
+ /** The auth check without a charge: the bearer's identity, or null for the loopback fallback. */
165
+ async function checkAuth(req, opts) {
157
166
  const auth = readAuthHeader(req);
158
167
  if (auth.kind === 'malformed') {
159
168
  throw new HttpError(401, 'invalid api key');
160
169
  }
161
- if (auth.kind === 'bearer') {
162
- const id = await resolveBearer(auth.token, opts);
163
- const actor = { subject: id.subject, role: id.role, scopes: id.scopes };
164
- if (id.viaAuthResolver)
165
- actor.viaAuthResolver = true;
166
- // Only the server's own tenant owns the host; any other tenant's admin key is a tenant admin.
167
- else if (id.role === 'admin' && id.tenantId === resolveTenantId({}))
168
- actor.hostAdmin = true;
169
- return { hippoRoot: opts.hippoRoot, tenantId: id.tenantId, actor };
170
- }
171
- // No Authorization header. Loopback-only fallback for a direct local caller (no proxy headers),
172
- // unless HIPPO_REQUIRE_AUTH=1 forbids the local-CLI escape hatch.
170
+ if (auth.kind === 'bearer')
171
+ return resolveBearer(req, auth.token, opts);
172
+ // No Authorization header: the loopback fallback, unless HIPPO_REQUIRE_AUTH=1 forbids the local-CLI escape hatch.
173
173
  if (envRequireAuth()) {
174
174
  throw new HttpError(401, 'auth required');
175
175
  }
176
176
  assertLocalCaller(req);
177
+ return null;
178
+ }
179
+ function bearerActor(id) {
180
+ const actor = { subject: id.subject, role: id.role, scopes: id.scopes };
181
+ if (id.owner !== undefined)
182
+ actor.owner = id.owner;
183
+ if (id.viaAuthResolver)
184
+ actor.viaAuthResolver = true;
185
+ // Only the server's own tenant owns the host; any other tenant's admin key is a tenant admin.
186
+ else if (id.role === 'admin' && id.tenantId === resolveTenantId({}))
187
+ actor.hostAdmin = true;
188
+ return actor;
189
+ }
190
+ /** Key cap for every serve() bucket, shared by the warn map so it never tracks more callers than the buckets do. */
191
+ export const LIMITER_MAX_KEYS = 10_000;
192
+ const CALLER_WARN_EVERY_MS = 60_000;
193
+ const callerWarnedAt = new Map();
194
+ function warnCallerLimited(req, key, tenantId, person) {
195
+ const now = Date.now();
196
+ const last = callerWarnedAt.get(key);
197
+ if (last !== undefined && now - last < CALLER_WARN_EVERY_MS)
198
+ return;
199
+ callerWarnedAt.delete(key);
200
+ if (callerWarnedAt.size >= LIMITER_MAX_KEYS) {
201
+ const oldest = callerWarnedAt.keys().next();
202
+ if (!oldest.done)
203
+ callerWarnedAt.delete(oldest.value);
204
+ }
205
+ callerWarnedAt.set(key, now);
206
+ log.warn('caller over its rate limit; its further 429s this minute are not logged', { tenant: tenantId, person, requestId: requestIds.get(req) });
207
+ }
208
+ function chargeCaller(req, tenantId, actor, opts) {
209
+ const limiter = opts.callerLimiter;
210
+ if (!limiter)
211
+ return;
212
+ const person = ownerOrSubject(actor);
213
+ const key = `${tenantId}\u0000${person}`;
214
+ if (limiter.check(key))
215
+ return;
216
+ warnCallerLimited(req, key, tenantId, person);
217
+ throw new HttpError(429, 'rate limit exceeded for this caller', limiter.retryAfterSec);
218
+ }
219
+ /**
220
+ * Build a per-request Context from the Authorization header and remote
221
+ * address. Throws HttpError(401) for invalid / missing credentials. Reads
222
+ * the store only for an API-key-shaped Bearer token (or any Bearer token when no
223
+ * auth resolver is registered), so loopback no-auth requests stay cheap.
224
+ */
225
+ export async function buildContextWithAuth(req, opts) {
226
+ const id = await checkAuth(req, opts);
227
+ if (id !== null) {
228
+ const actor = bearerActor(id);
229
+ chargeCaller(req, id.tenantId, actor, opts);
230
+ return { hippoRoot: opts.hippoRoot, tenantId: id.tenantId, actor, store: opts.store };
231
+ }
177
232
  // Loopback fallback is process-local, treat as admin.
178
233
  return {
179
234
  hippoRoot: opts.hippoRoot,
180
235
  tenantId: resolveTenantId({}),
181
236
  actor: { subject: 'localhost:cli', role: 'admin', hostAdmin: true },
237
+ store: opts.store,
182
238
  };
183
239
  }
184
240
  /**
@@ -188,27 +244,18 @@ export async function buildContextWithAuth(req, opts) {
188
244
  * envelope. Loopback no-auth still passes.
189
245
  */
190
246
  export async function requireAuth(req, opts) {
191
- const auth = readAuthHeader(req);
192
- if (auth.kind === 'malformed') {
193
- throw new HttpError(401, 'invalid api key');
194
- }
195
- if (auth.kind === 'bearer') {
196
- await resolveBearer(auth.token, opts);
197
- return;
198
- }
199
- if (envRequireAuth()) {
200
- throw new HttpError(401, 'auth required');
201
- }
202
- assertLocalCaller(req);
247
+ const id = await checkAuth(req, opts);
248
+ if (id !== null)
249
+ chargeCaller(req, id.tenantId, bearerActor(id), opts);
203
250
  }
204
- /** Never rejects: an outage (5xx) skips one heartbeat tick, only a definite 4xx denial closes the stream. */
251
+ /** Never rejects or charges a caller bucket: an outage (5xx) or a throttle (429) skips one tick; only a definite 4xx denial closes the stream. */
205
252
  export async function heartbeatVerdict(req, opts) {
206
253
  try {
207
- await requireAuth(req, opts);
254
+ await checkAuth(req, opts);
208
255
  return 'ok';
209
256
  }
210
257
  catch (err) {
211
- return err instanceof HttpError && err.status < 500 ? 'revoked' : 'unavailable';
258
+ return err instanceof HttpError && err.status < 500 && err.status !== 429 ? 'revoked' : 'unavailable';
212
259
  }
213
260
  }
214
261
  /** Gate for any action beyond the caller's own tenant: a tenant's admin, by key or resolver, is never a host admin. */
@@ -21,5 +21,7 @@ import type { RateLimiter } from '../rate-limit.js';
21
21
  * the client sent, so they never pick the bucket.
22
22
  */
23
23
  export declare function clientIpForRateLimit(req: IncomingMessage): string;
24
+ /** The key for one subscriber: an IPv6 address's /64, since one host may hold the whole /64; any other key as is. */
25
+ export declare function subscriberKey(ip: string): string;
24
26
  export declare function enforceRateLimit(req: IncomingMessage, path: string, limiter?: RateLimiter): void;
25
27
  //# sourceMappingURL=client-ip.d.ts.map
@@ -67,6 +67,21 @@ function trustedProxyList(raw) {
67
67
  trustedProxyCache = { raw, list };
68
68
  return list;
69
69
  }
70
+ /** The key for one subscriber: an IPv6 address's /64, since one host may hold the whole /64; any other key as is. */
71
+ export function subscriberKey(ip) {
72
+ const bare = ip.split('%')[0];
73
+ if (bare.toLowerCase().startsWith('::ffff:') && isIP(bare.slice(7)) === 4)
74
+ return bare.slice(7);
75
+ if (isIP(bare) !== 6)
76
+ return ip;
77
+ const [head = '', tail] = bare.toLowerCase().split('::');
78
+ const left = head ? head.split(':') : [];
79
+ const right = tail ? tail.split(':') : [];
80
+ // A trailing dotted quad holds two groups; isIP has already checked the shape.
81
+ const rightGroups = right.length + (right.at(-1)?.includes('.') ? 1 : 0);
82
+ const groups = tail === undefined ? left : [...left, ...Array(8 - left.length - rightGroups).fill('0'), ...right];
83
+ return `${groups.slice(0, 4).map((g) => parseInt(g, 16).toString(16)).join(':')}::/64`;
84
+ }
70
85
  function isTrustedProxy(list, ip) {
71
86
  const bare = ip.startsWith('::ffff:') && isIP(ip.slice(7)) === 4 ? ip.slice(7) : ip;
72
87
  const family = isIP(bare);
@@ -85,7 +100,7 @@ export function enforceRateLimit(req, path, limiter) {
85
100
  if (limiter && (path.startsWith('/v1/') || path === '/mcp' || path === '/mcp/stream')) {
86
101
  const ip = clientIpForRateLimit(req);
87
102
  if (!limiter.check(ip)) {
88
- throw new HttpError(429, 'rate limit exceeded');
103
+ throw new HttpError(429, 'rate limit exceeded', limiter.retryAfterSec);
89
104
  }
90
105
  }
91
106
  }
@@ -1,5 +1,11 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
- import type { ServeOpts } from './types.js';
3
- export declare function handleMcpPost(req: IncomingMessage, res: ServerResponse, opts: ServeOpts): Promise<void>;
4
- export declare function handleMcpStream(req: IncomingMessage, res: ServerResponse, opts: ServeOpts, streamSlots: Map<string, number>): Promise<void>;
2
+ import type { Context } from '../api.js';
3
+ import { type McpContext } from '../mcp/server.js';
4
+ import type { CallerProject } from '../prompt-hook.js';
5
+ import type { ResolvedServeOpts } from './types.js';
6
+ /** On a shared store, the caller's project from X-Hippo-Project and the comma-separated X-Hippo-Project-Aliases; any other store ignores both. */
7
+ export declare function callerProjectFromHeaders(req: IncomingMessage, hippoRoot: string): CallerProject | undefined;
8
+ export declare function mcpContextFor(ctx: Context, clientKey: string, autoSleep: McpContext['autoSleep'], project?: CallerProject): McpContext;
9
+ export declare function handleMcpPost(req: IncomingMessage, res: ServerResponse, opts: ResolvedServeOpts): Promise<void>;
10
+ export declare function handleMcpStream(req: IncomingMessage, res: ServerResponse, opts: ResolvedServeOpts, streamSlots: Map<string, number>): Promise<void>;
5
11
  //# sourceMappingURL=mcp-http.d.ts.map