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.
- package/README.md +11 -5
- package/dist/agent-memories/claude-code.js +3 -3
- package/dist/ambient-store.d.ts +2 -0
- package/dist/ambient-store.js +4 -3
- package/dist/api/assemble.d.ts +2 -0
- package/dist/api/assemble.js +11 -7
- package/dist/api/auth.d.ts +15 -11
- package/dist/api/auth.js +116 -22
- package/dist/api/context-select.d.ts +5 -3
- package/dist/api/context-select.js +6 -11
- package/dist/api/context-types.d.ts +2 -1
- package/dist/api/context.d.ts +1 -1
- package/dist/api/context.js +79 -55
- package/dist/api/dormant.d.ts +5 -5
- package/dist/api/dormant.js +13 -9
- package/dist/api/drill-down.d.ts +2 -0
- package/dist/api/drill-down.js +12 -7
- package/dist/api/forget.d.ts +1 -1
- package/dist/api/forget.js +8 -4
- package/dist/api/learn.js +5 -3
- package/dist/api/outcome.d.ts +1 -1
- package/dist/api/outcome.js +2 -1
- package/dist/api/promote.js +8 -5
- package/dist/api/recall-types.d.ts +4 -2
- package/dist/api/recall.js +51 -36
- package/dist/api/remember.d.ts +7 -0
- package/dist/api/remember.js +26 -2
- package/dist/api/sleep-run.d.ts +28 -0
- package/dist/api/sleep-run.js +267 -0
- package/dist/api/sleep.d.ts +5 -45
- package/dist/api/sleep.js +5 -252
- package/dist/api/types.d.ts +5 -0
- package/dist/api/types.js +4 -0
- package/dist/audit-prune.js +1 -1
- package/dist/audit.d.ts +1 -3
- package/dist/audit.js +7 -103
- package/dist/auth.d.ts +48 -4
- package/dist/auth.js +111 -42
- package/dist/autolearn.js +2 -2
- package/dist/capture/caller-session.d.ts +16 -0
- package/dist/capture/caller-session.js +58 -0
- package/dist/capture/command.d.ts +19 -1
- package/dist/capture/command.js +28 -21
- package/dist/capture/compact-resume-caller.d.ts +11 -0
- package/dist/capture/compact-resume-caller.js +35 -0
- package/dist/capture/compact.d.ts +0 -14
- package/dist/capture/compact.js +7 -160
- package/dist/capture/compaction-items-caller.d.ts +16 -0
- package/dist/capture/compaction-items-caller.js +32 -0
- package/dist/capture/extract.d.ts +2 -14
- package/dist/capture/extract.js +135 -346
- package/dist/capture/failure-caller.d.ts +21 -0
- package/dist/capture/failure-caller.js +74 -0
- package/dist/capture/failure-reading.d.ts +44 -0
- package/dist/capture/failure-reading.js +78 -0
- package/dist/capture/pre-compact-caller.d.ts +16 -0
- package/dist/capture/pre-compact-caller.js +39 -0
- package/dist/capture/session-end-caller.d.ts +21 -0
- package/dist/capture/session-end-caller.js +30 -0
- package/dist/capture/session-texts.d.ts +14 -0
- package/dist/capture/session-texts.js +42 -0
- package/dist/capture/transcript.d.ts +6 -1
- package/dist/capture/transcript.js +20 -16
- package/dist/capture/working-state.d.ts +21 -0
- package/dist/capture/working-state.js +154 -0
- package/dist/capture-error.d.ts +8 -22
- package/dist/capture-error.js +9 -61
- package/dist/cli/audit.js +25 -0
- package/dist/cli/auth.js +4 -3
- package/dist/cli/context.js +13 -158
- package/dist/cli/continuity.js +2 -1
- package/dist/cli/init.js +5 -3
- package/dist/cli/last-sleep.d.ts +6 -0
- package/dist/cli/last-sleep.js +68 -0
- package/dist/cli/maintenance.d.ts +2 -1
- package/dist/cli/maintenance.js +17 -11
- package/dist/cli/quality-repair-once.d.ts +3 -0
- package/dist/cli/quality-repair-once.js +36 -0
- package/dist/cli/recall.js +2 -1
- package/dist/cli/remember.d.ts +2 -0
- package/dist/cli/remember.js +4 -1
- package/dist/cli/session-hooks.d.ts +0 -3
- package/dist/cli/session-hooks.js +12 -49
- package/dist/cli/setup.js +10 -2
- package/dist/cli/shared.d.ts +7 -14
- package/dist/cli/shared.js +19 -78
- package/dist/cli/sleep.js +6 -3
- package/dist/cli/usage.js +18 -9
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +3 -2
- package/dist/client.js +1 -1
- package/dist/compaction-record.d.ts +19 -1
- package/dist/compaction-record.js +61 -132
- package/dist/compaction-spool.d.ts +45 -0
- package/dist/compaction-spool.js +454 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +49 -0
- package/dist/connectors/github/deletion.d.ts +1 -1
- package/dist/connectors/github/deletion.js +5 -17
- package/dist/consolidate/conflicts.js +4 -0
- package/dist/consolidate/flush-units.d.ts +9 -0
- package/dist/consolidate/flush-units.js +63 -0
- package/dist/consolidate/llm-passes.js +5 -4
- package/dist/consolidate/merge.js +5 -1
- package/dist/consolidate/run.d.ts +2 -1
- package/dist/consolidate/sleep.d.ts +2 -0
- package/dist/consolidate/sleep.js +82 -15
- package/dist/context-render.d.ts +14 -0
- package/dist/context-render.js +28 -0
- package/dist/dag.js +6 -0
- package/dist/dashboard-actions.js +3 -2
- package/dist/dashboard.js +4 -3
- package/dist/db/backup.d.ts +6 -0
- package/dist/db/backup.js +29 -0
- package/dist/db/busy.d.ts +9 -0
- package/dist/db/busy.js +35 -0
- package/dist/db/continuity.d.ts +1 -0
- package/dist/db/continuity.js +31 -4
- package/dist/db/meta.d.ts +2 -0
- package/dist/db/meta.js +7 -0
- package/dist/db/migrate.js +2 -3
- package/dist/db/migrations/index.d.ts +1 -1
- package/dist/db/migrations/index.js +5 -1
- package/dist/db/migrations/v39.js +2 -7
- package/dist/db/migrations/v53.d.ts +3 -0
- package/dist/db/migrations/v53.js +16 -0
- package/dist/db/migrations/v54.d.ts +3 -0
- package/dist/db/migrations/v54.js +25 -0
- package/dist/db/open.d.ts +11 -0
- package/dist/db/open.js +27 -0
- package/dist/db/tables.d.ts +2 -0
- package/dist/db/tables.js +6 -2
- package/dist/db.d.ts +2 -2
- package/dist/db.js +2 -2
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +31 -15
- package/dist/dormant.d.ts +16 -20
- package/dist/dormant.js +24 -30
- package/dist/embedding-provider.js +4 -3
- package/dist/entry/json-hooks.d.ts +3 -0
- package/dist/entry/json-hooks.js +4 -0
- package/dist/entry/project-identity.d.ts +2 -0
- package/dist/entry/project-identity.js +3 -0
- package/dist/entry/session-text.d.ts +7 -0
- package/dist/entry/session-text.js +8 -0
- package/dist/extract.js +6 -0
- package/dist/failure-log.d.ts +10 -2
- package/dist/failure-log.js +19 -2
- package/dist/gated-write.js +1 -1
- package/dist/graph/delta.d.ts +44 -0
- package/dist/graph/delta.js +109 -0
- package/dist/graph/types.d.ts +5 -0
- package/dist/graph/write.d.ts +20 -20
- package/dist/graph/write.js +103 -36
- package/dist/graph-extract.d.ts +43 -9
- package/dist/graph-extract.js +140 -157
- package/dist/graph-recall.d.ts +1 -0
- package/dist/graph-recall.js +2 -2
- package/dist/graph-view.d.ts +1 -0
- package/dist/graph-view.js +25 -2
- package/dist/half-life-migration.js +5 -1
- package/dist/handoff-evidence.d.ts +5 -0
- package/dist/handoff-evidence.js +30 -0
- package/dist/home-path.d.ts +5 -0
- package/dist/home-path.js +24 -0
- package/dist/hooks/json-hooks.d.ts +13 -2
- package/dist/hooks/json-hooks.js +246 -117
- package/dist/hooks/opencode.js +1 -1
- package/dist/hooks/shared.d.ts +2 -0
- package/dist/hooks/shared.js +6 -1
- package/dist/http-util.d.ts +16 -7
- package/dist/http-util.js +65 -20
- package/dist/importers/vault.js +2 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/json.d.ts +2 -0
- package/dist/json.js +7 -0
- package/dist/ledger-db.d.ts +10 -0
- package/dist/ledger-db.js +42 -0
- package/dist/local-embedding.d.ts +2 -0
- package/dist/local-embedding.js +20 -4
- package/dist/mcp/admin-tools.d.ts +2 -2
- package/dist/mcp/admin-tools.js +47 -5
- package/dist/mcp/memory-tools.js +7 -2
- package/dist/mcp/protocol.d.ts +6 -0
- package/dist/mcp/protocol.js +4 -2
- package/dist/mcp/recall-tools.js +19 -6
- package/dist/mcp/request.js +4 -0
- package/dist/mcp/session-state.d.ts +4 -0
- package/dist/mcp/session-state.js +15 -6
- package/dist/mcp/shared-gate.d.ts +4 -0
- package/dist/mcp/shared-gate.js +22 -0
- package/dist/mcp/tools.js +1 -0
- package/dist/memory-quality.d.ts +35 -0
- package/dist/memory-quality.js +191 -0
- package/dist/memory.d.ts +4 -4
- package/dist/physics-state.js +1 -1
- package/dist/pilot-arm.d.ts +14 -3
- package/dist/pilot-arm.js +32 -10
- package/dist/postinstall.js +2 -2
- package/dist/project-briefs.js +4 -4
- package/dist/project-identity.d.ts +14 -10
- package/dist/project-identity.js +29 -11
- package/dist/project-merge.d.ts +0 -2
- package/dist/project-merge.js +9 -13
- package/dist/prompt-hook.d.ts +65 -0
- package/dist/prompt-hook.js +213 -0
- package/dist/prompt-recall.js +1 -1
- package/dist/quality-repair.d.ts +27 -0
- package/dist/quality-repair.js +220 -0
- package/dist/rate-limit.d.ts +1 -0
- package/dist/rate-limit.js +5 -3
- package/dist/raw-archive.d.ts +1 -1
- package/dist/raw-archive.js +6 -16
- package/dist/recall-scope.d.ts +28 -5
- package/dist/recall-scope.js +52 -7
- package/dist/recall-trace.js +1 -1
- package/dist/reject-flow.d.ts +2 -0
- package/dist/reject-flow.js +20 -6
- package/dist/secret-detect.js +7 -8
- package/dist/server/auth.d.ts +8 -6
- package/dist/server/auth.js +85 -38
- package/dist/server/client-ip.d.ts +2 -0
- package/dist/server/client-ip.js +16 -1
- package/dist/server/mcp-http.d.ts +9 -3
- package/dist/server/mcp-http.js +56 -13
- package/dist/server/request.d.ts +1 -1
- package/dist/server/request.js +5 -5
- package/dist/server/routes/admin.js +12 -6
- package/dist/server/routes/memories.js +9 -1
- package/dist/server/routes/recall.js +14 -2
- package/dist/server/types.d.ts +42 -5
- package/dist/server/validation.d.ts +5 -2
- package/dist/server/validation.js +19 -2
- package/dist/server.d.ts +21 -4
- package/dist/server.js +207 -49
- package/dist/session-digest.d.ts +0 -1
- package/dist/session-digest.js +3 -12
- package/dist/session-owners.d.ts +7 -0
- package/dist/session-owners.js +54 -0
- package/dist/share-scrub.d.ts +3 -0
- package/dist/share-scrub.js +8 -0
- package/dist/shared.d.ts +1 -0
- package/dist/shared.js +26 -18
- package/dist/store/candidates.d.ts +14 -3
- package/dist/store/candidates.js +48 -26
- package/dist/store/conflicts.d.ts +15 -0
- package/dist/store/conflicts.js +68 -44
- package/dist/store/delete-and-batch.d.ts +25 -1
- package/dist/store/delete-and-batch.js +121 -69
- package/dist/store/entry-reads.d.ts +5 -6
- package/dist/store/entry-reads.js +24 -12
- package/dist/store/entry-row.d.ts +7 -10
- package/dist/store/entry-row.js +40 -18
- package/dist/store/entry-writes.d.ts +3 -3
- package/dist/store/entry-writes.js +9 -23
- package/dist/store/handoffs.d.ts +9 -6
- package/dist/store/handoffs.js +72 -59
- package/dist/store/index-and-stats.js +2 -2
- package/dist/store/open.d.ts +3 -1
- package/dist/store/open.js +3 -3
- package/dist/store/search-rows.d.ts +8 -5
- package/dist/store/search-rows.js +15 -18
- package/dist/store/sessions.d.ts +25 -6
- package/dist/store/sessions.js +61 -19
- package/dist/store/summaries.d.ts +1 -1
- package/dist/store/summaries.js +3 -18
- package/dist/store/tenant-lookup.d.ts +7 -3
- package/dist/store/tenant-lookup.js +9 -9
- package/dist/store-port.d.ts +13 -0
- package/dist/store-port.js +20 -0
- package/dist/version.d.ts +5 -1
- package/dist/version.js +5 -1
- package/dist/working-memory.js +1 -1
- package/dist/write-budget.d.ts +15 -0
- package/dist/write-budget.js +15 -0
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +8 -4
package/dist/raw-archive.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
70
|
-
//
|
|
71
|
-
db
|
|
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
|
-
|
|
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
|
package/dist/recall-scope.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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;
|
package/dist/recall-scope.js
CHANGED
|
@@ -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
|
-
/**
|
|
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))
|
package/dist/recall-trace.js
CHANGED
|
@@ -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)
|
package/dist/reject-flow.d.ts
CHANGED
|
@@ -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;
|
package/dist/reject-flow.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
190
|
+
removeDormantCopies(db, opts, digest, holdsValue, removal);
|
|
177
191
|
auditRejectValue(db, opts, digest, removal.removedIds);
|
|
178
192
|
db.exec('COMMIT');
|
|
179
193
|
}
|
package/dist/secret-detect.js
CHANGED
|
@@ -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
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
/**
|
|
92
|
-
const EMAIL =
|
|
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]');
|
package/dist/server/auth.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage } from 'node:http';
|
|
2
|
-
import type
|
|
3
|
-
import type {
|
|
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<
|
|
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.
|
|
36
|
-
* the
|
|
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
|
|
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;
|
package/dist/server/auth.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
|
192
|
-
if (
|
|
193
|
-
|
|
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
|
|
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
|
|
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
|
package/dist/server/client-ip.js
CHANGED
|
@@ -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 {
|
|
3
|
-
|
|
4
|
-
|
|
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
|