memgres 0.12.2__tar.gz → 0.13.0__tar.gz
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.
- {memgres-0.12.2 → memgres-0.13.0}/PKG-INFO +7 -2
- {memgres-0.12.2 → memgres-0.13.0}/README.md +3 -1
- {memgres-0.12.2 → memgres-0.13.0}/memgres/_version.py +1 -1
- {memgres-0.12.2 → memgres-0.13.0}/memgres/admin.py +8 -1
- {memgres-0.12.2 → memgres-0.13.0}/memgres/config.py +39 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/identity.py +45 -81
- {memgres-0.12.2 → memgres-0.13.0}/memgres/mcp_server.py +1 -1
- memgres-0.13.0/memgres/migrations/0024_web_panel.sql +134 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/schema.py +4 -1
- {memgres-0.12.2 → memgres-0.13.0}/memgres/server.py +11 -1
- {memgres-0.12.2 → memgres-0.13.0}/memgres/store.py +104 -11
- memgres-0.13.0/memgres/web/__init__.py +5 -0
- memgres-0.13.0/memgres/web/admission.py +325 -0
- memgres-0.13.0/memgres/web/auth_routes.py +231 -0
- memgres-0.13.0/memgres/web/memory.py +206 -0
- memgres-0.13.0/memgres/web/oidc.py +224 -0
- memgres-0.13.0/memgres/web/oidc_config.py +191 -0
- memgres-0.13.0/memgres/web/people.py +374 -0
- memgres-0.13.0/memgres/web/routes.py +260 -0
- memgres-0.13.0/memgres/web/sessions.py +253 -0
- memgres-0.13.0/memgres/web/spaces.py +459 -0
- memgres-0.13.0/memgres/web/static/app.css +434 -0
- memgres-0.13.0/memgres/web/static/fonts/OFL-JetBrainsMono.txt +93 -0
- memgres-0.13.0/memgres/web/static/fonts/OFL-Onest.txt +93 -0
- memgres-0.13.0/memgres/web/static/fonts/jetbrainsmono-cyrillic-ext.woff2 +0 -0
- memgres-0.13.0/memgres/web/static/fonts/jetbrainsmono-cyrillic.woff2 +0 -0
- memgres-0.13.0/memgres/web/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
- memgres-0.13.0/memgres/web/static/fonts/jetbrainsmono-latin.woff2 +0 -0
- memgres-0.13.0/memgres/web/static/fonts/onest-cyrillic-ext.woff2 +0 -0
- memgres-0.13.0/memgres/web/static/fonts/onest-cyrillic.woff2 +0 -0
- memgres-0.13.0/memgres/web/static/fonts/onest-latin-ext.woff2 +0 -0
- memgres-0.13.0/memgres/web/static/fonts/onest-latin.woff2 +0 -0
- memgres-0.13.0/memgres/web/static/fonts.css +66 -0
- memgres-0.13.0/memgres/web/static/index.html +93 -0
- memgres-0.13.0/memgres/web/static/js/account.js +51 -0
- memgres-0.13.0/memgres/web/static/js/admin.js +132 -0
- memgres-0.13.0/memgres/web/static/js/api.js +36 -0
- memgres-0.13.0/memgres/web/static/js/app.js +200 -0
- memgres-0.13.0/memgres/web/static/js/gate.js +122 -0
- memgres-0.13.0/memgres/web/static/js/i18n.js +97 -0
- memgres-0.13.0/memgres/web/static/js/memory.js +692 -0
- memgres-0.13.0/memgres/web/static/js/people.js +284 -0
- memgres-0.13.0/memgres/web/static/js/signins.js +71 -0
- memgres-0.13.0/memgres/web/static/js/space.js +171 -0
- memgres-0.13.0/memgres/web/static/js/tokens.js +143 -0
- memgres-0.13.0/memgres/web/static/js/ui.js +74 -0
- memgres-0.13.0/memgres/web/static/js/viz.js +255 -0
- memgres-0.13.0/memgres/web/static/locales/en.json +394 -0
- memgres-0.13.0/memgres/web/static/locales/ru.json +416 -0
- memgres-0.13.0/memgres/web/static/vendor/LICENSE-d3.txt +13 -0
- memgres-0.13.0/memgres/web/static/vendor/d3.min.js +2 -0
- memgres-0.13.0/memgres/web/tokens.py +141 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres.egg-info/PKG-INFO +7 -2
- {memgres-0.12.2 → memgres-0.13.0}/memgres.egg-info/SOURCES.txt +47 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres.egg-info/requires.txt +6 -0
- {memgres-0.12.2 → memgres-0.13.0}/pyproject.toml +2 -1
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_multi_space_search.py +50 -31
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_require_title.py +34 -1
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_store_integration.py +32 -0
- memgres-0.13.0/tests/test_web_memory.py +213 -0
- memgres-0.13.0/tests/test_web_oidc.py +635 -0
- memgres-0.13.0/tests/test_web_people.py +544 -0
- memgres-0.13.0/tests/test_web_session.py +379 -0
- memgres-0.13.0/tests/test_web_tokens.py +179 -0
- {memgres-0.12.2 → memgres-0.13.0}/LICENSE +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/__init__.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/admin_cli.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/blame.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/bootstrap.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/delimiters.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/diffing.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/embed_worker.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/embeddings.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/healthcheck.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/indexing.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/info.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/lines.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/links.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0001_core.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0002_identity.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0003_history_author.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0004_title.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0005_chunk_index.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0006_reader_floor.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0007_embed_retry.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0008_service_roles.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0009_create_namespace_right.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0010_namespace_alias.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0012_user_profile.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0013_hash_version.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0015_normalize_tags.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0016_valid_at.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0017_memory_link.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0018_links_built.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0019_memory_usage.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0021_enrollment_key.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0022_user_disabled.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0023_relink_after_parser_fix.sql +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/paths.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/periodic.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/reembed.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/relink.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/search.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/segments.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/tags.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/token_cli.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/vector/__init__.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/vector/base.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/vector/pgvector.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/vector/qdrant.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres/worker.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres.egg-info/dependency_links.txt +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres.egg-info/entry_points.txt +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/memgres.egg-info/top_level.txt +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/setup.cfg +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_admin_two_way.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_blame_integration.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_chunk_index.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_claim_and_reembed.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_config.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_diffing.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_embed_worker.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_embeddings.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_enrollment.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_healthcheck.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_identity_integration.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_lexical_match.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_limits.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_links.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_list.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_mcp_admin_tools.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_mcp_error_messages.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_mcp_http_transport.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_mcp_instructions.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_mcp_recall_schema.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_mcp_tool_visibility.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_mcp_tool_visibility_http.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_migration_upgrade.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_path_addressing.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_qdrant_ca.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_qdrant_integration.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_replace_build.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_required_fields.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_retention.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_roles_bootstrap.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_search_integration.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_security_followups.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_security_integration.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_segments.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_segments_store.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_server_info.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_server_integration.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_snippets.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_tags.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_token_sink.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_usage.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_valid_at.py +0 -0
- {memgres-0.12.2 → memgres-0.13.0}/tests/test_write_ergonomics.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memgres
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
4
4
|
Summary: Drop-in memory for AI agents: one Postgres, lexical + semantic recall, diff-versioned history, GDPR-erasable.
|
|
5
5
|
Author: mozgsml
|
|
6
6
|
License-Expression: MIT
|
|
@@ -30,6 +30,9 @@ Requires-Dist: psycopg-pool>=3.2; extra == "server"
|
|
|
30
30
|
Provides-Extra: mcp
|
|
31
31
|
Requires-Dist: mcp>=1.2; extra == "mcp"
|
|
32
32
|
Requires-Dist: psycopg-pool>=3.2; extra == "mcp"
|
|
33
|
+
Provides-Extra: web
|
|
34
|
+
Requires-Dist: pyjwt[crypto]>=2.8; extra == "web"
|
|
35
|
+
Requires-Dist: tomli>=2.0; python_version < "3.11" and extra == "web"
|
|
33
36
|
Provides-Extra: dev
|
|
34
37
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
35
38
|
Dynamic: license-file
|
|
@@ -191,7 +194,8 @@ Not sure which fits? Start with the decision guide: [docs/CHOOSING.md](docs/CHOO
|
|
|
191
194
|
1. **`docker compose up`** — `pgvector` + service, nothing to configure. For a dedicated vector service instead, `docker compose --profile qdrant up` and set `MEMGRES_VECTOR_BACKEND=qdrant` (Qdrant ranks vectors; Postgres still holds bodies and does tag/subtree/TTL filtering).
|
|
192
195
|
2. **Your own Postgres** — install the `[server]` extra (above), point `MEMGRES_DATABASE_URL` at it, run `memgres-server` (migrates on startup).
|
|
193
196
|
3. **Embedded library** — install the core package, use `Store` directly, no HTTP at all.
|
|
194
|
-
4. **
|
|
197
|
+
4. **A web panel for people** — `MEMGRES_WEB_ENABLED=true` on `memgres-server`: sign in through OpenID Connect providers (or an admin token), browse and search memory as a graph or a tree, issue tokens for your own AI clients, run your spaces' members and invitations, see who wrote what; administrators get the sign-in queue and a people directory. Memory itself is read-only in the panel — writing stays with agents. See [docs/WEB.md](docs/WEB.md) and [docs/OIDC.md](docs/OIDC.md).
|
|
198
|
+
5. **Split service (many clients)** — a stateless API tier that only flags writes plus a scalable `memgres-worker` tier that embeds; see [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) and `deploy/docker-compose.yml`. Switch the embedding model later with `memgres-reembed`; rebuild the link graph with `memgres-relink`.
|
|
195
199
|
|
|
196
200
|
Semantic recall is optional: the default `MEMGRES_EMBED_PROVIDER=none` gives you lexical FTS with zero models. Turn on `local` (sentence-transformers), a cloud API (`openai`/`jina`), or any OpenAI-compatible server (LM Studio, Ollama, …) when you want meaning-based search — see [docs/EMBEDDINGS.md](docs/EMBEDDINGS.md) for choosing local vs cloud and [docs/BACKENDS.md](docs/BACKENDS.md) for copy-paste setups. The model id + dimension get stamped into the schema and a later mismatch hard-fails instead of silently returning garbage.
|
|
197
201
|
|
|
@@ -215,6 +219,7 @@ Everything is env, all optional (defaults suit a single-user embed). Full list i
|
|
|
215
219
|
| `MEMGRES_ADMIN_TOKEN` | — | global admin bearer for provisioning (managed mode) |
|
|
216
220
|
| `MEMGRES_TOKEN` | — | default token used when a call passes none (single-tenant endpoints) |
|
|
217
221
|
| `MEMGRES_TOKEN_SINK` | — | absolute directory a minted secret is **written to** (`<token-id>.token`, `0600`) instead of being returned. Set it when provisioning is done by an agent — a secret in a tool result is a secret in a transcript. See [docs/TENANCY.md](docs/TENANCY.md) |
|
|
222
|
+
| `MEMGRES_WEB_ENABLED` | `false` | serve the web panel from `memgres-server` (needs `open` or `managed`). With it: `MEMGRES_PUBLIC_URL`, `MEMGRES_MCP_PUBLIC_URL`, `MEMGRES_OIDC_CONFIG`, `MEMGRES_WEB_SESSION_HOURS` (`12`), `MEMGRES_WEB_COOKIE_SECURE` (`true`), and behind a reverse proxy `MEMGRES_FORWARDED_ALLOW_IPS`. See [docs/WEB.md](docs/WEB.md) |
|
|
218
223
|
| `MEMGRES_TREE` | `true` | `ltree` path column + GiST index (fast subtree select) |
|
|
219
224
|
| `MEMGRES_REQUIRED_FIELDS` | — | comma-separated fields a content-storing write must carry (`source`, `reason`), refused by name when absent. A policy about a corpus, not a property of the software: a scratch database wants none, a corporate one wants `source`. `move`/`retag` are exempt. Announced in `server_info.write_requirements` |
|
|
220
225
|
| `MEMGRES_REQUIRE_TITLE` | `true` | `true` = a write that stores content must supply `title`. Captions are what name a memory in results and what title-weighted ranking weighs; `move`/`retag` are exempt (they store no content) |
|
|
@@ -155,7 +155,8 @@ Not sure which fits? Start with the decision guide: [docs/CHOOSING.md](docs/CHOO
|
|
|
155
155
|
1. **`docker compose up`** — `pgvector` + service, nothing to configure. For a dedicated vector service instead, `docker compose --profile qdrant up` and set `MEMGRES_VECTOR_BACKEND=qdrant` (Qdrant ranks vectors; Postgres still holds bodies and does tag/subtree/TTL filtering).
|
|
156
156
|
2. **Your own Postgres** — install the `[server]` extra (above), point `MEMGRES_DATABASE_URL` at it, run `memgres-server` (migrates on startup).
|
|
157
157
|
3. **Embedded library** — install the core package, use `Store` directly, no HTTP at all.
|
|
158
|
-
4. **
|
|
158
|
+
4. **A web panel for people** — `MEMGRES_WEB_ENABLED=true` on `memgres-server`: sign in through OpenID Connect providers (or an admin token), browse and search memory as a graph or a tree, issue tokens for your own AI clients, run your spaces' members and invitations, see who wrote what; administrators get the sign-in queue and a people directory. Memory itself is read-only in the panel — writing stays with agents. See [docs/WEB.md](docs/WEB.md) and [docs/OIDC.md](docs/OIDC.md).
|
|
159
|
+
5. **Split service (many clients)** — a stateless API tier that only flags writes plus a scalable `memgres-worker` tier that embeds; see [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) and `deploy/docker-compose.yml`. Switch the embedding model later with `memgres-reembed`; rebuild the link graph with `memgres-relink`.
|
|
159
160
|
|
|
160
161
|
Semantic recall is optional: the default `MEMGRES_EMBED_PROVIDER=none` gives you lexical FTS with zero models. Turn on `local` (sentence-transformers), a cloud API (`openai`/`jina`), or any OpenAI-compatible server (LM Studio, Ollama, …) when you want meaning-based search — see [docs/EMBEDDINGS.md](docs/EMBEDDINGS.md) for choosing local vs cloud and [docs/BACKENDS.md](docs/BACKENDS.md) for copy-paste setups. The model id + dimension get stamped into the schema and a later mismatch hard-fails instead of silently returning garbage.
|
|
161
162
|
|
|
@@ -179,6 +180,7 @@ Everything is env, all optional (defaults suit a single-user embed). Full list i
|
|
|
179
180
|
| `MEMGRES_ADMIN_TOKEN` | — | global admin bearer for provisioning (managed mode) |
|
|
180
181
|
| `MEMGRES_TOKEN` | — | default token used when a call passes none (single-tenant endpoints) |
|
|
181
182
|
| `MEMGRES_TOKEN_SINK` | — | absolute directory a minted secret is **written to** (`<token-id>.token`, `0600`) instead of being returned. Set it when provisioning is done by an agent — a secret in a tool result is a secret in a transcript. See [docs/TENANCY.md](docs/TENANCY.md) |
|
|
183
|
+
| `MEMGRES_WEB_ENABLED` | `false` | serve the web panel from `memgres-server` (needs `open` or `managed`). With it: `MEMGRES_PUBLIC_URL`, `MEMGRES_MCP_PUBLIC_URL`, `MEMGRES_OIDC_CONFIG`, `MEMGRES_WEB_SESSION_HOURS` (`12`), `MEMGRES_WEB_COOKIE_SECURE` (`true`), and behind a reverse proxy `MEMGRES_FORWARDED_ALLOW_IPS`. See [docs/WEB.md](docs/WEB.md) |
|
|
182
184
|
| `MEMGRES_TREE` | `true` | `ltree` path column + GiST index (fast subtree select) |
|
|
183
185
|
| `MEMGRES_REQUIRED_FIELDS` | — | comma-separated fields a content-storing write must carry (`source`, `reason`), refused by name when absent. A policy about a corpus, not a property of the software: a scratch database wants none, a corporate one wants `source`. `move`/`retag` are exempt. Announced in `server_info.write_requirements` |
|
|
184
186
|
| `MEMGRES_REQUIRE_TITLE` | `true` | `true` = a write that stores content must supply `title`. Captions are what name a memory in results and what title-weighted ranking weighs; `move`/`retag` are exempt (they store no content) |
|
|
@@ -230,7 +230,7 @@ def whoami(conn, p: Principal) -> dict:
|
|
|
230
230
|
|
|
231
231
|
Never echoes the credential; `token_id` identifies it without revealing it.
|
|
232
232
|
"""
|
|
233
|
-
|
|
233
|
+
out = {
|
|
234
234
|
"user_id": p.user_id,
|
|
235
235
|
"role": p.role,
|
|
236
236
|
"permission": p.permission, # this credential's ceiling
|
|
@@ -238,6 +238,13 @@ def whoami(conn, p: Principal) -> dict:
|
|
|
238
238
|
"token_id": p.token_id,
|
|
239
239
|
"capabilities": capabilities(conn, p),
|
|
240
240
|
}
|
|
241
|
+
if p.is_admin and p.scope_namespace_id is None and p.user_id is not None:
|
|
242
|
+
# The one caller for whom `all` is narrower than what it can read. Said
|
|
243
|
+
# here rather than in every tool's description, which everybody's agent
|
|
244
|
+
# reads and only a superadmin could use.
|
|
245
|
+
out["search_hint"] = ("space='all' searches the namespaces you belong to; "
|
|
246
|
+
"space='*' searches every namespace in this deployment")
|
|
247
|
+
return out
|
|
241
248
|
|
|
242
249
|
|
|
243
250
|
# ─── users ───────────────────────────────────────────────────────────────────
|
|
@@ -140,6 +140,22 @@ class Config:
|
|
|
140
140
|
# database
|
|
141
141
|
database_url: str
|
|
142
142
|
pool_size: int # max pooled connections (HTTP + http-MCP servers)
|
|
143
|
+
# web panel (served by memgres-server; see docs/WEB.md)
|
|
144
|
+
web_enabled: bool = False # mount the panel and its /ui/api on the REST server
|
|
145
|
+
web_cookie_secure: bool = True # session cookie only over HTTPS. Off is for a
|
|
146
|
+
# plain-http LAN, where the cookie can be read off
|
|
147
|
+
# the wire by anyone on it — say so in the docs
|
|
148
|
+
web_session_hours: int = 12 # a session ends this long after sign-in, used or not
|
|
149
|
+
public_url: str = "" # where people reach the panel, e.g.
|
|
150
|
+
# https://memory.example.com — the one origin state-
|
|
151
|
+
# changing calls are accepted from, and the base of
|
|
152
|
+
# the OIDC redirect URI. Empty = the request's own Host
|
|
153
|
+
oidc_config: str = "" # path of the TOML file listing sign-in providers
|
|
154
|
+
# (see memgres/web/oidc_config.py and docs/OIDC.md)
|
|
155
|
+
mcp_public_url: str = "" # the MCP endpoint people put in their clients, e.g.
|
|
156
|
+
# https://memory.example.com/mcp — shown in the panel's
|
|
157
|
+
# config snippets. The panel cannot know it: MCP is
|
|
158
|
+
# usually another container, port or host
|
|
143
159
|
|
|
144
160
|
def validate(self) -> None:
|
|
145
161
|
if self.pool_size < 1:
|
|
@@ -195,6 +211,23 @@ class Config:
|
|
|
195
211
|
# and the CLI would write the same operator's secrets to different
|
|
196
212
|
# directories — and neither would say so.
|
|
197
213
|
raise ValueError("MEMGRES_TOKEN_SINK must be an absolute path")
|
|
214
|
+
if self.web_session_hours < 1:
|
|
215
|
+
raise ValueError("MEMGRES_WEB_SESSION_HOURS must be >= 1")
|
|
216
|
+
if self.public_url and not self.public_url.startswith(("http://", "https://")):
|
|
217
|
+
raise ValueError("MEMGRES_PUBLIC_URL must start with http:// or https://")
|
|
218
|
+
if self.web_enabled and not self.public_url:
|
|
219
|
+
# Without it the allowed Origin and the OIDC redirect URI would come
|
|
220
|
+
# from the request's Host header — wrong behind a proxy, and
|
|
221
|
+
# attacker-chosen in front of none.
|
|
222
|
+
raise ValueError("the web panel needs MEMGRES_PUBLIC_URL (e.g. https://memory.example.com)")
|
|
223
|
+
if self.web_enabled and self.oidc_config and not self.web_cookie_secure:
|
|
224
|
+
from urllib.parse import urlsplit
|
|
225
|
+
if (urlsplit(self.public_url).hostname or "") not in ("localhost", "127.0.0.1", "::1"):
|
|
226
|
+
raise ValueError("OIDC sign-in needs MEMGRES_WEB_COOKIE_SECURE=true "
|
|
227
|
+
"(insecure cookies are allowed only on localhost)")
|
|
228
|
+
if self.web_enabled and self.key_mode == "single":
|
|
229
|
+
# Nobody to sign in as: single mode has no accounts and no tokens.
|
|
230
|
+
raise ValueError("the web panel needs MEMGRES_KEY_MODE=open or managed")
|
|
198
231
|
if self.admin_token and self.admin_token_file:
|
|
199
232
|
raise ValueError(
|
|
200
233
|
"set only one of MEMGRES_ADMIN_TOKEN / MEMGRES_ADMIN_TOKEN_FILE")
|
|
@@ -259,6 +292,12 @@ def load() -> Config:
|
|
|
259
292
|
embed_max_seq=_int("MEMGRES_EMBED_MAX_SEQ", 0),
|
|
260
293
|
database_url=_str("MEMGRES_DATABASE_URL", ""),
|
|
261
294
|
pool_size=_int("MEMGRES_POOL_SIZE", 4),
|
|
295
|
+
web_enabled=_bool("MEMGRES_WEB_ENABLED", False),
|
|
296
|
+
web_cookie_secure=_bool("MEMGRES_WEB_COOKIE_SECURE", True),
|
|
297
|
+
web_session_hours=_int("MEMGRES_WEB_SESSION_HOURS", 12),
|
|
298
|
+
public_url=_str("MEMGRES_PUBLIC_URL", "").rstrip("/"),
|
|
299
|
+
mcp_public_url=_str("MEMGRES_MCP_PUBLIC_URL", ""),
|
|
300
|
+
oidc_config=_str("MEMGRES_OIDC_CONFIG", ""),
|
|
262
301
|
)
|
|
263
302
|
cfg.validate()
|
|
264
303
|
return cfg
|
|
@@ -435,18 +435,23 @@ def resolve_space(conn, principal: Principal, *, space_id: Optional[str] = None,
|
|
|
435
435
|
|
|
436
436
|
# The two words that address a SET of namespaces rather than one.
|
|
437
437
|
#
|
|
438
|
-
# `all` means every namespace you
|
|
439
|
-
#
|
|
440
|
-
#
|
|
441
|
-
#
|
|
442
|
-
# `*` is the wide answer, said out loud.
|
|
438
|
+
# `all` means every namespace you belong to — you own it, or you were added to
|
|
439
|
+
# it — for everyone, superadmin included. `*` means every namespace in the
|
|
440
|
+
# deployment, and only a superadmin may say it: its role reaches any namespace
|
|
441
|
+
# by id, and `*` spends that reach in one call instead of N.
|
|
443
442
|
#
|
|
444
|
-
#
|
|
445
|
-
#
|
|
446
|
-
#
|
|
447
|
-
#
|
|
448
|
-
#
|
|
449
|
-
#
|
|
443
|
+
# A superadmin's `all` used to be refused while namespaces existed outside its
|
|
444
|
+
# memberships, on the grounds that "mine" and "everything I can read" differ for
|
|
445
|
+
# that one caller. That left a superadmin no way to say "mine" at all, and a
|
|
446
|
+
# refusal is the wrong tool for a question that has a plain answer: `all` is the
|
|
447
|
+
# namespaces you are in, the same sentence for every caller, and `*` is there
|
|
448
|
+
# for the wider one.
|
|
449
|
+
#
|
|
450
|
+
# There is deliberately no second WORD for "the ones I belong to" beyond `all`:
|
|
451
|
+
# a namespace can be called anything, and the obvious candidates (`mine`, `own`)
|
|
452
|
+
# are names people actually use. `*` survives that objection because a namespace
|
|
453
|
+
# named `*` is not something anyone types by accident, and the collision is still
|
|
454
|
+
# checked rather than assumed away.
|
|
450
455
|
ALL_SPACES = "all"
|
|
451
456
|
EVERY_SPACE = "*"
|
|
452
457
|
|
|
@@ -509,7 +514,6 @@ def resolve_spaces(conn, principal: Principal, *, space=None,
|
|
|
509
514
|
"— drop `space_id`, or list the namespaces you want explicitly")
|
|
510
515
|
if _wants(space, EVERY_SPACE):
|
|
511
516
|
return _every_namespace(conn, principal)
|
|
512
|
-
_refuse_ambiguous_all(conn, principal, ALL_SPACES)
|
|
513
517
|
return _all_reachable(conn, principal)
|
|
514
518
|
|
|
515
519
|
if not names and not ids:
|
|
@@ -533,6 +537,18 @@ def _collect(out: List[Tuple[str, str]], seen: set, resolved: Tuple[str, str]) -
|
|
|
533
537
|
out.append((nsid, perm))
|
|
534
538
|
|
|
535
539
|
|
|
540
|
+
def _own_words(conn, user_id: str) -> List[str]:
|
|
541
|
+
"""The names this caller chose: its own namespaces and its aliases.
|
|
542
|
+
|
|
543
|
+
What a keyword may collide with. A namespace SHARED with you was named by
|
|
544
|
+
somebody else, and letting that name decide what your `all` or `*` means
|
|
545
|
+
would hand a stranger a switch on your words — add you to a namespace
|
|
546
|
+
called `all`, and your `all` stops working. An alias you set is your word,
|
|
547
|
+
so it counts."""
|
|
548
|
+
return [s["name"] for s in list_spaces(conn, user_id) if s["mine"]] + \
|
|
549
|
+
list(list_aliases(conn, user_id).values())
|
|
550
|
+
|
|
551
|
+
|
|
536
552
|
def _no_such_name(names: Sequence[str], keyword: str) -> None:
|
|
537
553
|
"""Refuse a set keyword that is ALSO the name of a namespace in play.
|
|
538
554
|
|
|
@@ -544,58 +560,11 @@ def _no_such_name(names: Sequence[str], keyword: str) -> None:
|
|
|
544
560
|
f"ambiguous — address that one by `space_id`")
|
|
545
561
|
|
|
546
562
|
|
|
547
|
-
def _unreached_count(conn, principal: Principal) -> int:
|
|
548
|
-
"""How many namespaces the caller could read but is not a member of.
|
|
549
|
-
|
|
550
|
-
Zero for everyone but a superadmin, whose reach is defined by its role
|
|
551
|
-
rather than by membership rows — which is precisely why `all` has to be
|
|
552
|
-
disambiguated for it and for nobody else.
|
|
553
|
-
"""
|
|
554
|
-
if not principal.is_admin or principal.user_id is None:
|
|
555
|
-
return 0
|
|
556
|
-
if principal.scope_namespace_id is not None:
|
|
557
|
-
return 0 # pinned to one; nothing is outside
|
|
558
|
-
with conn.cursor() as cur:
|
|
559
|
-
cur.execute(
|
|
560
|
-
"SELECT count(*) FROM namespace n WHERE n.owner_user_id <> %(u)s "
|
|
561
|
-
"AND NOT EXISTS (SELECT 1 FROM namespace_member m "
|
|
562
|
-
" WHERE m.namespace_id=n.id AND m.user_id=%(u)s)",
|
|
563
|
-
{"u": principal.user_id})
|
|
564
|
-
return int(cur.fetchone()[0])
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
def _refuse_ambiguous_all(conn, principal: Principal,
|
|
568
|
-
said: Optional[str] = None) -> None:
|
|
569
|
-
"""A superadmin read that would answer with less than the role can read.
|
|
570
|
-
|
|
571
|
-
For every other caller `all` IS everything, and the word stays untouched.
|
|
572
|
-
For a superadmin it is two different questions, and the narrow answer looks
|
|
573
|
-
exactly like the wide one — a search returning nothing reads as "there is
|
|
574
|
-
nothing", not as "not where I looked". So it is refused, the wide word is
|
|
575
|
-
named, and it is refused only while the two answers differ: a superadmin
|
|
576
|
-
whose memberships already cover the deployment sees no change.
|
|
577
|
-
|
|
578
|
-
``said`` is the word the caller used, or None when they named no namespace
|
|
579
|
-
at all — the same trap, reached by saying nothing.
|
|
580
|
-
"""
|
|
581
|
-
outside = _unreached_count(conn, principal)
|
|
582
|
-
if not outside:
|
|
583
|
-
return
|
|
584
|
-
own = [s["name"] for s in list_spaces(conn, principal.user_id)]
|
|
585
|
-
listed = ", ".join(repr(n) for n in sorted(own)) or "none"
|
|
586
|
-
opening = (f"'{said}' is ambiguous here" if said
|
|
587
|
-
else "naming no namespace would answer too narrowly here")
|
|
588
|
-
raise SpaceAmbiguous(
|
|
589
|
-
f"you are a superadmin, so {opening}: you belong to {len(own)} "
|
|
590
|
-
f"namespace(s) ({listed}), and {outside} more exist that your role can "
|
|
591
|
-
f"also read. Say `space='{EVERY_SPACE}'` for every namespace in this "
|
|
592
|
-
f"deployment, or name the ones you mean with `space=[…]` / "
|
|
593
|
-
f"`space_id=[…]`")
|
|
594
|
-
|
|
595
|
-
|
|
596
563
|
def _all_reachable(conn, principal: Principal,
|
|
597
564
|
keyword: str = ALL_SPACES) -> List[Tuple[str, str]]:
|
|
598
|
-
"""Every namespace the caller
|
|
565
|
+
"""Every namespace the caller belongs to — owns or was added to — capped by
|
|
566
|
+
the token ceiling. For a superadmin this is NOT everything its role can
|
|
567
|
+
read; that is `*`."""
|
|
599
568
|
if principal.user_id is None:
|
|
600
569
|
# An env break-glass root has no membership rows to enumerate, and a
|
|
601
570
|
# provisional open-mode token owns nothing yet. Neither can say "all".
|
|
@@ -610,20 +579,21 @@ def _all_reachable(conn, principal: Principal,
|
|
|
610
579
|
reachable = list_spaces(conn, principal.user_id)
|
|
611
580
|
if not reachable:
|
|
612
581
|
raise SpaceNotFound("you can reach no namespaces yet")
|
|
613
|
-
_no_such_name(
|
|
582
|
+
_no_such_name(_own_words(conn, principal.user_id), keyword)
|
|
614
583
|
return [(s["id"], perm_min(s["permission"], ceiling)) for s in reachable]
|
|
615
584
|
|
|
616
585
|
|
|
617
586
|
def _every_namespace(conn, principal: Principal) -> List[Tuple[str, str]]:
|
|
618
587
|
"""Every namespace in the deployment — the superadmin's explicit wide read.
|
|
619
588
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
589
|
+
`all` is the namespaces you belong to, for a superadmin as for anyone; this
|
|
590
|
+
is the wider answer, asked for by its own word. It is the same reach
|
|
591
|
+
`resolve_space(space_id=…)` already grants that role one namespace at a
|
|
592
|
+
time, so it adds no authority — only a way to spend it in one call instead
|
|
593
|
+
of N.
|
|
625
594
|
"""
|
|
626
|
-
# The name check comes FIRST, and against the caller's OWN
|
|
595
|
+
# The name check comes FIRST, and against the caller's OWN words (see
|
|
596
|
+
# `_own_words`: namespaces it owns and its aliases).
|
|
627
597
|
# Two reasons, both learned the hard way:
|
|
628
598
|
# * a caller who owns a namespace literally named `*` most likely means
|
|
629
599
|
# that one, and telling them the keyword is superadmin-only would be an
|
|
@@ -636,12 +606,11 @@ def _every_namespace(conn, principal: Principal) -> List[Tuple[str, str]]:
|
|
|
636
606
|
# uuids. A stranger's choice of name must not reach into what this
|
|
637
607
|
# caller's words mean.
|
|
638
608
|
if principal.user_id is not None:
|
|
639
|
-
_no_such_name(
|
|
640
|
-
EVERY_SPACE)
|
|
609
|
+
_no_such_name(_own_words(conn, principal.user_id), EVERY_SPACE)
|
|
641
610
|
if not principal.is_admin:
|
|
642
611
|
raise AuthError(
|
|
643
612
|
f"`space='{EVERY_SPACE}'` means every namespace in this deployment "
|
|
644
|
-
f"and is superadmin-only — use '{ALL_SPACES}' for the ones you
|
|
613
|
+
f"and is superadmin-only — use '{ALL_SPACES}' for the ones you belong to")
|
|
645
614
|
if principal.scope_namespace_id is not None:
|
|
646
615
|
# The pin is a property of THIS credential and outranks the role: a
|
|
647
616
|
# token deliberately narrowed to one namespace does not widen back.
|
|
@@ -659,14 +628,10 @@ def _every_namespace(conn, principal: Principal) -> List[Tuple[str, str]]:
|
|
|
659
628
|
def _sole_reachable(conn, principal: Principal) -> Tuple[str, str]:
|
|
660
629
|
"""The caller's only namespace, or an error naming the candidates.
|
|
661
630
|
|
|
662
|
-
This is the READ path — a search that named no namespace at all.
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
result reads as "there is nothing". (The WRITE path deliberately keeps
|
|
667
|
-
resolving to the single membership: a write has to land somewhere, the one
|
|
668
|
-
namespace you belong to is the only sane target, and nothing is silently
|
|
669
|
-
left out of an answer.)
|
|
631
|
+
This is the READ path — a search that named no namespace at all. One
|
|
632
|
+
namespace you belong to is used; several are an error naming them. For a
|
|
633
|
+
superadmin, too, this is about membership: the namespaces it reaches only by
|
|
634
|
+
its role are searched when it says `*`.
|
|
670
635
|
"""
|
|
671
636
|
if principal.user_id is None:
|
|
672
637
|
if principal.is_admin: # env break-glass root owns nothing
|
|
@@ -675,7 +640,6 @@ def _sole_reachable(conn, principal: Principal) -> Tuple[str, str]:
|
|
|
675
640
|
if principal.scope_namespace_id is not None:
|
|
676
641
|
return resolve_space(conn, principal,
|
|
677
642
|
space_id=principal.scope_namespace_id)
|
|
678
|
-
_refuse_ambiguous_all(conn, principal)
|
|
679
643
|
reachable = list_spaces(conn, principal.user_id)
|
|
680
644
|
if len(reachable) == 1:
|
|
681
645
|
only = reachable[0]
|
|
@@ -552,7 +552,7 @@ def build_server(cfg: Optional[Config] = None):
|
|
|
552
552
|
choosing what to read in full with `memory_get`.
|
|
553
553
|
|
|
554
554
|
WHERE to search: `space` takes a namespace name, a list of names, or
|
|
555
|
-
`"all"` for every namespace you
|
|
555
|
+
`"all"` for every namespace you belong to (own or were added to); `space_id` takes ids (the only way
|
|
556
556
|
to name a namespace shared WITH you). Omit both and your single namespace
|
|
557
557
|
is used — but if you reach several, naming one is REQUIRED, because
|
|
558
558
|
searching just one of them would return "nothing found" and read like an
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
-- The web panel: browser sessions, sign-in identities, a language (schema v25).
|
|
2
|
+
--
|
|
3
|
+
-- Everything here is additive. An older client neither reads nor writes these
|
|
4
|
+
-- tables, and nothing it relies on changes shape.
|
|
5
|
+
|
|
6
|
+
-- A browser session. The cookie carries a random id; only its sha256 is stored,
|
|
7
|
+
-- so a copy of this table does not let anyone walk in as the people listed in
|
|
8
|
+
-- it — the same reason tokens are stored as hashes.
|
|
9
|
+
--
|
|
10
|
+
-- user_id NULL is the env break-glass root (MEMGRES_ADMIN_TOKEN resolving to no
|
|
11
|
+
-- account). Such a session is tied to that secret by `root_fp`, its sha256: if
|
|
12
|
+
-- the operator rotates the env token, every session it opened ends with it.
|
|
13
|
+
--
|
|
14
|
+
-- `token_id` is the credential a token sign-in used. A session opened with a
|
|
15
|
+
-- token must not outlive the token, so it is checked again on every request
|
|
16
|
+
-- rather than trusted from the moment of sign-in.
|
|
17
|
+
CREATE TABLE IF NOT EXISTS web_session (
|
|
18
|
+
id_hash text PRIMARY KEY,
|
|
19
|
+
user_id uuid REFERENCES app_user(id) ON DELETE CASCADE,
|
|
20
|
+
via text NOT NULL, -- 'token' | 'oidc:<provider>'
|
|
21
|
+
token_id uuid REFERENCES token(id) ON DELETE CASCADE,
|
|
22
|
+
root_fp text,
|
|
23
|
+
csrf text NOT NULL,
|
|
24
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
25
|
+
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
|
26
|
+
expires_at timestamptz NOT NULL,
|
|
27
|
+
revoked_at timestamptz
|
|
28
|
+
);
|
|
29
|
+
CREATE INDEX IF NOT EXISTS web_session_user ON web_session (user_id);
|
|
30
|
+
|
|
31
|
+
-- A way of signing in, attached to an account. The identity is (issuer,
|
|
32
|
+
-- subject) — never the email, which is only how a FIRST link may be found.
|
|
33
|
+
-- One account may hold any number of these; one (issuer, subject) belongs to
|
|
34
|
+
-- exactly one account.
|
|
35
|
+
CREATE TABLE IF NOT EXISTS app_user_identity (
|
|
36
|
+
issuer text NOT NULL,
|
|
37
|
+
subject text NOT NULL,
|
|
38
|
+
user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
|
|
39
|
+
provider text NOT NULL, -- the config key it came through
|
|
40
|
+
email text,
|
|
41
|
+
linked_at timestamptz NOT NULL DEFAULT now(),
|
|
42
|
+
last_login_at timestamptz,
|
|
43
|
+
PRIMARY KEY (issuer, subject)
|
|
44
|
+
);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS app_user_identity_user ON app_user_identity (user_id);
|
|
46
|
+
|
|
47
|
+
-- The panel language a person chose. NULL = follow the browser.
|
|
48
|
+
ALTER TABLE app_user ADD COLUMN IF NOT EXISTS ui_language text;
|
|
49
|
+
|
|
50
|
+
-- A handle for one sign-in method that is not its (issuer, subject): the panel
|
|
51
|
+
-- shows and unlinks methods by it, and an issuer URL in a path is awkward.
|
|
52
|
+
ALTER TABLE app_user_identity ADD COLUMN IF NOT EXISTS id uuid NOT NULL DEFAULT gen_random_uuid();
|
|
53
|
+
CREATE UNIQUE INDEX IF NOT EXISTS app_user_identity_id ON app_user_identity (id);
|
|
54
|
+
|
|
55
|
+
-- One OIDC sign-in in progress: between the redirect to the provider and its
|
|
56
|
+
-- redirect back. Single use, short-lived. `state` is looked up by hash, and the
|
|
57
|
+
-- flow is bound to the browser that started it (`browser_hash` = sha256 of a
|
|
58
|
+
-- cookie set at the start), so a callback URL carried to another browser — or
|
|
59
|
+
-- planted in a victim's — completes nothing.
|
|
60
|
+
--
|
|
61
|
+
-- `link_user_id` is set when a signed-in person is adding a method to their own
|
|
62
|
+
-- account. It is recorded here at the start because the callback arrives as a
|
|
63
|
+
-- cross-site navigation, which does not carry the SameSite=Strict session.
|
|
64
|
+
CREATE TABLE IF NOT EXISTS oidc_flow (
|
|
65
|
+
state_hash text PRIMARY KEY,
|
|
66
|
+
browser_hash text NOT NULL,
|
|
67
|
+
provider text NOT NULL,
|
|
68
|
+
nonce text NOT NULL,
|
|
69
|
+
code_verifier text NOT NULL,
|
|
70
|
+
link_user_id uuid REFERENCES app_user(id) ON DELETE CASCADE,
|
|
71
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
72
|
+
expires_at timestamptz NOT NULL
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
-- Someone a provider vouched for, whom no rule let straight in. No account is
|
|
76
|
+
-- created until an administrator decides: link them to an existing account
|
|
77
|
+
-- (the one whose email matched is suggested), create one, or reject.
|
|
78
|
+
CREATE TABLE IF NOT EXISTS signin_request (
|
|
79
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
80
|
+
provider text NOT NULL,
|
|
81
|
+
issuer text NOT NULL,
|
|
82
|
+
subject text NOT NULL,
|
|
83
|
+
email text,
|
|
84
|
+
email_verified boolean NOT NULL DEFAULT false,
|
|
85
|
+
name text NOT NULL DEFAULT '',
|
|
86
|
+
suggested_user_id uuid REFERENCES app_user(id) ON DELETE SET NULL,
|
|
87
|
+
status text NOT NULL DEFAULT 'pending'
|
|
88
|
+
CHECK (status IN ('pending', 'approved', 'rejected')),
|
|
89
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
90
|
+
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
|
91
|
+
decided_at timestamptz,
|
|
92
|
+
decided_by uuid REFERENCES app_user(id) ON DELETE SET NULL,
|
|
93
|
+
user_id uuid REFERENCES app_user(id) ON DELETE SET NULL
|
|
94
|
+
);
|
|
95
|
+
CREATE UNIQUE INDEX IF NOT EXISTS signin_request_open
|
|
96
|
+
ON signin_request (issuer, subject) WHERE status = 'pending';
|
|
97
|
+
CREATE INDEX IF NOT EXISTS signin_request_seen ON signin_request (issuer, subject, decided_at);
|
|
98
|
+
|
|
99
|
+
-- The sign-in method a provider session was opened with, so unlinking one
|
|
100
|
+
-- method ends exactly its sessions and not those of another method from the
|
|
101
|
+
-- same provider.
|
|
102
|
+
ALTER TABLE web_session ADD COLUMN IF NOT EXISTS identity_id uuid;
|
|
103
|
+
|
|
104
|
+
-- A link flow is authorised by the session that started it, and that session
|
|
105
|
+
-- must still be alive when the provider sends the person back: signing out,
|
|
106
|
+
-- revoking the token or disabling the account in those minutes stops the link.
|
|
107
|
+
ALTER TABLE oidc_flow ADD COLUMN IF NOT EXISTS link_session_hash text;
|
|
108
|
+
|
|
109
|
+
-- An invitation into a space for an address that has no account yet. Someone
|
|
110
|
+
-- who already has an account with that email is added straight away and no row
|
|
111
|
+
-- is written. The invitation waits until a sign-in arrives whose provider
|
|
112
|
+
-- vouches for the address (email_verified), and is applied then — it opens the
|
|
113
|
+
-- space, never the server: whether that sign-in is let in at all is still
|
|
114
|
+
-- decided by the provider's rules and the administrators.
|
|
115
|
+
CREATE TABLE IF NOT EXISTS space_invite (
|
|
116
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
117
|
+
namespace_id uuid NOT NULL REFERENCES namespace(id) ON DELETE CASCADE,
|
|
118
|
+
email text NOT NULL,
|
|
119
|
+
permission text NOT NULL CHECK (permission IN ('read', 'write', 'admin')),
|
|
120
|
+
invited_by uuid REFERENCES app_user(id) ON DELETE SET NULL,
|
|
121
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
122
|
+
expires_at timestamptz NOT NULL,
|
|
123
|
+
accepted_at timestamptz,
|
|
124
|
+
user_id uuid REFERENCES app_user(id) ON DELETE SET NULL
|
|
125
|
+
);
|
|
126
|
+
CREATE UNIQUE INDEX IF NOT EXISTS space_invite_open
|
|
127
|
+
ON space_invite (namespace_id, lower(email)) WHERE accepted_at IS NULL;
|
|
128
|
+
CREATE INDEX IF NOT EXISTS space_invite_email
|
|
129
|
+
ON space_invite (lower(email)) WHERE accepted_at IS NULL;
|
|
130
|
+
|
|
131
|
+
-- A person's write activity, by day: the profile chart reads one author's
|
|
132
|
+
-- history over a window, which the author-only index answers with a sort.
|
|
133
|
+
CREATE INDEX IF NOT EXISTS memory_history_author_time
|
|
134
|
+
ON memory_history (author_user_id, created_at) WHERE author_user_id IS NOT NULL;
|
|
@@ -18,7 +18,7 @@ from pathlib import Path
|
|
|
18
18
|
from .config import Config
|
|
19
19
|
|
|
20
20
|
# The version this build migrates the database TO (the latest migration it carries).
|
|
21
|
-
SCHEMA_VERSION =
|
|
21
|
+
SCHEMA_VERSION = 25
|
|
22
22
|
|
|
23
23
|
# The compatibility FLOOR: the schema version of the most recent backward-
|
|
24
24
|
# INCOMPATIBLE migration — one that changed the shape/semantics old code relied on
|
|
@@ -78,6 +78,9 @@ SCHEMA_VERSION = 24
|
|
|
78
78
|
# link PARSER was fixed (hyphens in paths were dropped, indented code was
|
|
79
79
|
# parsed) → additive, floor stays 16. It rebuilds a derived index from text
|
|
80
80
|
# that is already stored; an older client neither writes nor reads differently.
|
|
81
|
+
# v25 (0024): added web_session, app_user_identity and app_user.ui_language for
|
|
82
|
+
# the web panel → additive, floor stays 16. An older client ignores all three;
|
|
83
|
+
# it simply has no panel.
|
|
81
84
|
SCHEMA_BREAKING_VERSION = 16
|
|
82
85
|
|
|
83
86
|
# Dev layout: repo/migrations next to the package. When packaged, migrations are
|
|
@@ -732,14 +732,24 @@ def create_app(cfg: Optional[Config] = None):
|
|
|
732
732
|
return _guard(lambda: admin.revoke_superadmin(
|
|
733
733
|
conn, p, user_id=user_id, demote_to=req.demote_to))
|
|
734
734
|
|
|
735
|
+
if cfg.web_enabled:
|
|
736
|
+
from .web.routes import mount
|
|
737
|
+
mount(app, cfg, pool, _store)
|
|
738
|
+
|
|
735
739
|
return app
|
|
736
740
|
|
|
737
741
|
|
|
738
742
|
def main(): # pragma: no cover - entrypoint
|
|
739
743
|
import os
|
|
740
744
|
import uvicorn
|
|
745
|
+
# Behind a reverse proxy, the client address (used to throttle sign-in
|
|
746
|
+
# attempts) is only real if the proxy's X-Forwarded-For is trusted:
|
|
747
|
+
# MEMGRES_FORWARDED_ALLOW_IPS lists the proxy addresses (uvicorn's
|
|
748
|
+
# forwarded_allow_ips; unset = uvicorn's own default, 127.0.0.1).
|
|
741
749
|
uvicorn.run(create_app(), host=os.environ.get("MEMGRES_HTTP_HOST", "0.0.0.0"),
|
|
742
|
-
port=int(os.environ.get("MEMGRES_HTTP_PORT", "8080"))
|
|
750
|
+
port=int(os.environ.get("MEMGRES_HTTP_PORT", "8080")),
|
|
751
|
+
proxy_headers=True,
|
|
752
|
+
forwarded_allow_ips=os.environ.get("MEMGRES_FORWARDED_ALLOW_IPS") or None)
|
|
743
753
|
|
|
744
754
|
|
|
745
755
|
if __name__ == "__main__": # pragma: no cover
|