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.
Files changed (161) hide show
  1. {memgres-0.12.2 → memgres-0.13.0}/PKG-INFO +7 -2
  2. {memgres-0.12.2 → memgres-0.13.0}/README.md +3 -1
  3. {memgres-0.12.2 → memgres-0.13.0}/memgres/_version.py +1 -1
  4. {memgres-0.12.2 → memgres-0.13.0}/memgres/admin.py +8 -1
  5. {memgres-0.12.2 → memgres-0.13.0}/memgres/config.py +39 -0
  6. {memgres-0.12.2 → memgres-0.13.0}/memgres/identity.py +45 -81
  7. {memgres-0.12.2 → memgres-0.13.0}/memgres/mcp_server.py +1 -1
  8. memgres-0.13.0/memgres/migrations/0024_web_panel.sql +134 -0
  9. {memgres-0.12.2 → memgres-0.13.0}/memgres/schema.py +4 -1
  10. {memgres-0.12.2 → memgres-0.13.0}/memgres/server.py +11 -1
  11. {memgres-0.12.2 → memgres-0.13.0}/memgres/store.py +104 -11
  12. memgres-0.13.0/memgres/web/__init__.py +5 -0
  13. memgres-0.13.0/memgres/web/admission.py +325 -0
  14. memgres-0.13.0/memgres/web/auth_routes.py +231 -0
  15. memgres-0.13.0/memgres/web/memory.py +206 -0
  16. memgres-0.13.0/memgres/web/oidc.py +224 -0
  17. memgres-0.13.0/memgres/web/oidc_config.py +191 -0
  18. memgres-0.13.0/memgres/web/people.py +374 -0
  19. memgres-0.13.0/memgres/web/routes.py +260 -0
  20. memgres-0.13.0/memgres/web/sessions.py +253 -0
  21. memgres-0.13.0/memgres/web/spaces.py +459 -0
  22. memgres-0.13.0/memgres/web/static/app.css +434 -0
  23. memgres-0.13.0/memgres/web/static/fonts/OFL-JetBrainsMono.txt +93 -0
  24. memgres-0.13.0/memgres/web/static/fonts/OFL-Onest.txt +93 -0
  25. memgres-0.13.0/memgres/web/static/fonts/jetbrainsmono-cyrillic-ext.woff2 +0 -0
  26. memgres-0.13.0/memgres/web/static/fonts/jetbrainsmono-cyrillic.woff2 +0 -0
  27. memgres-0.13.0/memgres/web/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
  28. memgres-0.13.0/memgres/web/static/fonts/jetbrainsmono-latin.woff2 +0 -0
  29. memgres-0.13.0/memgres/web/static/fonts/onest-cyrillic-ext.woff2 +0 -0
  30. memgres-0.13.0/memgres/web/static/fonts/onest-cyrillic.woff2 +0 -0
  31. memgres-0.13.0/memgres/web/static/fonts/onest-latin-ext.woff2 +0 -0
  32. memgres-0.13.0/memgres/web/static/fonts/onest-latin.woff2 +0 -0
  33. memgres-0.13.0/memgres/web/static/fonts.css +66 -0
  34. memgres-0.13.0/memgres/web/static/index.html +93 -0
  35. memgres-0.13.0/memgres/web/static/js/account.js +51 -0
  36. memgres-0.13.0/memgres/web/static/js/admin.js +132 -0
  37. memgres-0.13.0/memgres/web/static/js/api.js +36 -0
  38. memgres-0.13.0/memgres/web/static/js/app.js +200 -0
  39. memgres-0.13.0/memgres/web/static/js/gate.js +122 -0
  40. memgres-0.13.0/memgres/web/static/js/i18n.js +97 -0
  41. memgres-0.13.0/memgres/web/static/js/memory.js +692 -0
  42. memgres-0.13.0/memgres/web/static/js/people.js +284 -0
  43. memgres-0.13.0/memgres/web/static/js/signins.js +71 -0
  44. memgres-0.13.0/memgres/web/static/js/space.js +171 -0
  45. memgres-0.13.0/memgres/web/static/js/tokens.js +143 -0
  46. memgres-0.13.0/memgres/web/static/js/ui.js +74 -0
  47. memgres-0.13.0/memgres/web/static/js/viz.js +255 -0
  48. memgres-0.13.0/memgres/web/static/locales/en.json +394 -0
  49. memgres-0.13.0/memgres/web/static/locales/ru.json +416 -0
  50. memgres-0.13.0/memgres/web/static/vendor/LICENSE-d3.txt +13 -0
  51. memgres-0.13.0/memgres/web/static/vendor/d3.min.js +2 -0
  52. memgres-0.13.0/memgres/web/tokens.py +141 -0
  53. {memgres-0.12.2 → memgres-0.13.0}/memgres.egg-info/PKG-INFO +7 -2
  54. {memgres-0.12.2 → memgres-0.13.0}/memgres.egg-info/SOURCES.txt +47 -0
  55. {memgres-0.12.2 → memgres-0.13.0}/memgres.egg-info/requires.txt +6 -0
  56. {memgres-0.12.2 → memgres-0.13.0}/pyproject.toml +2 -1
  57. {memgres-0.12.2 → memgres-0.13.0}/tests/test_multi_space_search.py +50 -31
  58. {memgres-0.12.2 → memgres-0.13.0}/tests/test_require_title.py +34 -1
  59. {memgres-0.12.2 → memgres-0.13.0}/tests/test_store_integration.py +32 -0
  60. memgres-0.13.0/tests/test_web_memory.py +213 -0
  61. memgres-0.13.0/tests/test_web_oidc.py +635 -0
  62. memgres-0.13.0/tests/test_web_people.py +544 -0
  63. memgres-0.13.0/tests/test_web_session.py +379 -0
  64. memgres-0.13.0/tests/test_web_tokens.py +179 -0
  65. {memgres-0.12.2 → memgres-0.13.0}/LICENSE +0 -0
  66. {memgres-0.12.2 → memgres-0.13.0}/memgres/__init__.py +0 -0
  67. {memgres-0.12.2 → memgres-0.13.0}/memgres/admin_cli.py +0 -0
  68. {memgres-0.12.2 → memgres-0.13.0}/memgres/blame.py +0 -0
  69. {memgres-0.12.2 → memgres-0.13.0}/memgres/bootstrap.py +0 -0
  70. {memgres-0.12.2 → memgres-0.13.0}/memgres/delimiters.py +0 -0
  71. {memgres-0.12.2 → memgres-0.13.0}/memgres/diffing.py +0 -0
  72. {memgres-0.12.2 → memgres-0.13.0}/memgres/embed_worker.py +0 -0
  73. {memgres-0.12.2 → memgres-0.13.0}/memgres/embeddings.py +0 -0
  74. {memgres-0.12.2 → memgres-0.13.0}/memgres/healthcheck.py +0 -0
  75. {memgres-0.12.2 → memgres-0.13.0}/memgres/indexing.py +0 -0
  76. {memgres-0.12.2 → memgres-0.13.0}/memgres/info.py +0 -0
  77. {memgres-0.12.2 → memgres-0.13.0}/memgres/lines.py +0 -0
  78. {memgres-0.12.2 → memgres-0.13.0}/memgres/links.py +0 -0
  79. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0001_core.sql +0 -0
  80. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0002_identity.sql +0 -0
  81. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0003_history_author.sql +0 -0
  82. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0004_title.sql +0 -0
  83. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0005_chunk_index.sql +0 -0
  84. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0006_reader_floor.sql +0 -0
  85. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0007_embed_retry.sql +0 -0
  86. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0008_service_roles.sql +0 -0
  87. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0009_create_namespace_right.sql +0 -0
  88. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0010_namespace_alias.sql +0 -0
  89. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
  90. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0012_user_profile.sql +0 -0
  91. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0013_hash_version.sql +0 -0
  92. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
  93. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0015_normalize_tags.sql +0 -0
  94. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0016_valid_at.sql +0 -0
  95. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0017_memory_link.sql +0 -0
  96. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0018_links_built.sql +0 -0
  97. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0019_memory_usage.sql +0 -0
  98. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
  99. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0021_enrollment_key.sql +0 -0
  100. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0022_user_disabled.sql +0 -0
  101. {memgres-0.12.2 → memgres-0.13.0}/memgres/migrations/0023_relink_after_parser_fix.sql +0 -0
  102. {memgres-0.12.2 → memgres-0.13.0}/memgres/paths.py +0 -0
  103. {memgres-0.12.2 → memgres-0.13.0}/memgres/periodic.py +0 -0
  104. {memgres-0.12.2 → memgres-0.13.0}/memgres/reembed.py +0 -0
  105. {memgres-0.12.2 → memgres-0.13.0}/memgres/relink.py +0 -0
  106. {memgres-0.12.2 → memgres-0.13.0}/memgres/search.py +0 -0
  107. {memgres-0.12.2 → memgres-0.13.0}/memgres/segments.py +0 -0
  108. {memgres-0.12.2 → memgres-0.13.0}/memgres/tags.py +0 -0
  109. {memgres-0.12.2 → memgres-0.13.0}/memgres/token_cli.py +0 -0
  110. {memgres-0.12.2 → memgres-0.13.0}/memgres/vector/__init__.py +0 -0
  111. {memgres-0.12.2 → memgres-0.13.0}/memgres/vector/base.py +0 -0
  112. {memgres-0.12.2 → memgres-0.13.0}/memgres/vector/pgvector.py +0 -0
  113. {memgres-0.12.2 → memgres-0.13.0}/memgres/vector/qdrant.py +0 -0
  114. {memgres-0.12.2 → memgres-0.13.0}/memgres/worker.py +0 -0
  115. {memgres-0.12.2 → memgres-0.13.0}/memgres.egg-info/dependency_links.txt +0 -0
  116. {memgres-0.12.2 → memgres-0.13.0}/memgres.egg-info/entry_points.txt +0 -0
  117. {memgres-0.12.2 → memgres-0.13.0}/memgres.egg-info/top_level.txt +0 -0
  118. {memgres-0.12.2 → memgres-0.13.0}/setup.cfg +0 -0
  119. {memgres-0.12.2 → memgres-0.13.0}/tests/test_admin_two_way.py +0 -0
  120. {memgres-0.12.2 → memgres-0.13.0}/tests/test_blame_integration.py +0 -0
  121. {memgres-0.12.2 → memgres-0.13.0}/tests/test_chunk_index.py +0 -0
  122. {memgres-0.12.2 → memgres-0.13.0}/tests/test_claim_and_reembed.py +0 -0
  123. {memgres-0.12.2 → memgres-0.13.0}/tests/test_config.py +0 -0
  124. {memgres-0.12.2 → memgres-0.13.0}/tests/test_diffing.py +0 -0
  125. {memgres-0.12.2 → memgres-0.13.0}/tests/test_embed_worker.py +0 -0
  126. {memgres-0.12.2 → memgres-0.13.0}/tests/test_embeddings.py +0 -0
  127. {memgres-0.12.2 → memgres-0.13.0}/tests/test_enrollment.py +0 -0
  128. {memgres-0.12.2 → memgres-0.13.0}/tests/test_healthcheck.py +0 -0
  129. {memgres-0.12.2 → memgres-0.13.0}/tests/test_identity_integration.py +0 -0
  130. {memgres-0.12.2 → memgres-0.13.0}/tests/test_lexical_match.py +0 -0
  131. {memgres-0.12.2 → memgres-0.13.0}/tests/test_limits.py +0 -0
  132. {memgres-0.12.2 → memgres-0.13.0}/tests/test_links.py +0 -0
  133. {memgres-0.12.2 → memgres-0.13.0}/tests/test_list.py +0 -0
  134. {memgres-0.12.2 → memgres-0.13.0}/tests/test_mcp_admin_tools.py +0 -0
  135. {memgres-0.12.2 → memgres-0.13.0}/tests/test_mcp_error_messages.py +0 -0
  136. {memgres-0.12.2 → memgres-0.13.0}/tests/test_mcp_http_transport.py +0 -0
  137. {memgres-0.12.2 → memgres-0.13.0}/tests/test_mcp_instructions.py +0 -0
  138. {memgres-0.12.2 → memgres-0.13.0}/tests/test_mcp_recall_schema.py +0 -0
  139. {memgres-0.12.2 → memgres-0.13.0}/tests/test_mcp_tool_visibility.py +0 -0
  140. {memgres-0.12.2 → memgres-0.13.0}/tests/test_mcp_tool_visibility_http.py +0 -0
  141. {memgres-0.12.2 → memgres-0.13.0}/tests/test_migration_upgrade.py +0 -0
  142. {memgres-0.12.2 → memgres-0.13.0}/tests/test_path_addressing.py +0 -0
  143. {memgres-0.12.2 → memgres-0.13.0}/tests/test_qdrant_ca.py +0 -0
  144. {memgres-0.12.2 → memgres-0.13.0}/tests/test_qdrant_integration.py +0 -0
  145. {memgres-0.12.2 → memgres-0.13.0}/tests/test_replace_build.py +0 -0
  146. {memgres-0.12.2 → memgres-0.13.0}/tests/test_required_fields.py +0 -0
  147. {memgres-0.12.2 → memgres-0.13.0}/tests/test_retention.py +0 -0
  148. {memgres-0.12.2 → memgres-0.13.0}/tests/test_roles_bootstrap.py +0 -0
  149. {memgres-0.12.2 → memgres-0.13.0}/tests/test_search_integration.py +0 -0
  150. {memgres-0.12.2 → memgres-0.13.0}/tests/test_security_followups.py +0 -0
  151. {memgres-0.12.2 → memgres-0.13.0}/tests/test_security_integration.py +0 -0
  152. {memgres-0.12.2 → memgres-0.13.0}/tests/test_segments.py +0 -0
  153. {memgres-0.12.2 → memgres-0.13.0}/tests/test_segments_store.py +0 -0
  154. {memgres-0.12.2 → memgres-0.13.0}/tests/test_server_info.py +0 -0
  155. {memgres-0.12.2 → memgres-0.13.0}/tests/test_server_integration.py +0 -0
  156. {memgres-0.12.2 → memgres-0.13.0}/tests/test_snippets.py +0 -0
  157. {memgres-0.12.2 → memgres-0.13.0}/tests/test_tags.py +0 -0
  158. {memgres-0.12.2 → memgres-0.13.0}/tests/test_token_sink.py +0 -0
  159. {memgres-0.12.2 → memgres-0.13.0}/tests/test_usage.py +0 -0
  160. {memgres-0.12.2 → memgres-0.13.0}/tests/test_valid_at.py +0 -0
  161. {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.12.2
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. **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`.
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. **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`.
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) |
@@ -8,4 +8,4 @@ here at release; nowhere else carries the number.
8
8
  PEP 440: a ``.devN`` suffix marks an unreleased build ahead of the last tag.
9
9
  """
10
10
 
11
- __version__ = "0.12.2"
11
+ __version__ = "0.13.0"
@@ -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
- return {
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 reach as a member. For a superadmin that is
439
- # NOT everything it can read its role reaches any namespace by id so for
440
- # that one caller the word asks two different questions, and answering the
441
- # narrow one silently is a partial result wearing the shape of a complete one.
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
- # There is deliberately no second word for "the ones I belong to": a namespace
445
- # can be called anything, and the obvious candidates (`mine`, `own`) are names
446
- # people actually use the first draft of this shadowed a namespace literally
447
- # named `mine` in this repo's own tests. `*` survives that objection because a
448
- # namespace named `*` is not something anyone types by accident, and the
449
- # collision is still checked rather than assumed away.
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 reaches, capped by the token ceiling."""
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([s["name"] for s in reachable], keyword)
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
- The counterpart to refusing `all` for a superadmin: having said that the
621
- narrow answer must not be given silently, there has to be a way to ask for
622
- the wide one. It is the same reach `resolve_space(space_id=…)` already grants
623
- that role one namespace at a time, so it adds no authority — only a way to
624
- spend it in one call instead of N.
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 reachable set.
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([s["name"] for s in list_spaces(conn, principal.user_id)],
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 reach")
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. It carries
663
- the same superadmin refusal as `all`, and for the same reason: with one
664
- membership and other namespaces on the deployment, "your only namespace"
665
- silently answers a narrower question than the caller asked, and an empty
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 reach; `space_id` takes ids (the only way
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 = 24
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