memgres 0.7.2__tar.gz → 0.9.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 (108) hide show
  1. {memgres-0.7.2 → memgres-0.9.0}/PKG-INFO +8 -6
  2. {memgres-0.7.2 → memgres-0.9.0}/README.md +7 -5
  3. {memgres-0.7.2 → memgres-0.9.0}/memgres/_version.py +1 -1
  4. {memgres-0.7.2 → memgres-0.9.0}/memgres/admin.py +69 -3
  5. {memgres-0.7.2 → memgres-0.9.0}/memgres/config.py +12 -0
  6. {memgres-0.7.2 → memgres-0.9.0}/memgres/identity.py +199 -0
  7. {memgres-0.7.2 → memgres-0.9.0}/memgres/info.py +24 -2
  8. {memgres-0.7.2 → memgres-0.9.0}/memgres/mcp_server.py +304 -156
  9. memgres-0.9.0/memgres/migrations/0009_create_namespace_right.sql +37 -0
  10. memgres-0.9.0/memgres/migrations/0021_enrollment_key.sql +40 -0
  11. {memgres-0.7.2 → memgres-0.9.0}/memgres/schema.py +1 -1
  12. {memgres-0.7.2 → memgres-0.9.0}/memgres/server.py +54 -1
  13. memgres-0.9.0/memgres/token_cli.py +206 -0
  14. {memgres-0.7.2 → memgres-0.9.0}/memgres.egg-info/PKG-INFO +8 -6
  15. {memgres-0.7.2 → memgres-0.9.0}/memgres.egg-info/SOURCES.txt +4 -0
  16. {memgres-0.7.2 → memgres-0.9.0}/memgres.egg-info/entry_points.txt +1 -0
  17. {memgres-0.7.2 → memgres-0.9.0}/pyproject.toml +1 -0
  18. memgres-0.9.0/tests/test_enrollment.py +338 -0
  19. {memgres-0.7.2 → memgres-0.9.0}/tests/test_mcp_admin_tools.py +112 -83
  20. {memgres-0.7.2 → memgres-0.9.0}/tests/test_mcp_tool_visibility.py +21 -3
  21. {memgres-0.7.2 → memgres-0.9.0}/tests/test_mcp_tool_visibility_http.py +95 -3
  22. {memgres-0.7.2 → memgres-0.9.0}/tests/test_server_info.py +45 -1
  23. {memgres-0.7.2 → memgres-0.9.0}/tests/test_server_integration.py +6 -1
  24. memgres-0.9.0/tests/test_token_sink.py +204 -0
  25. memgres-0.7.2/memgres/migrations/0009_create_namespace_right.sql +0 -23
  26. {memgres-0.7.2 → memgres-0.9.0}/LICENSE +0 -0
  27. {memgres-0.7.2 → memgres-0.9.0}/memgres/__init__.py +0 -0
  28. {memgres-0.7.2 → memgres-0.9.0}/memgres/admin_cli.py +0 -0
  29. {memgres-0.7.2 → memgres-0.9.0}/memgres/blame.py +0 -0
  30. {memgres-0.7.2 → memgres-0.9.0}/memgres/bootstrap.py +0 -0
  31. {memgres-0.7.2 → memgres-0.9.0}/memgres/delimiters.py +0 -0
  32. {memgres-0.7.2 → memgres-0.9.0}/memgres/diffing.py +0 -0
  33. {memgres-0.7.2 → memgres-0.9.0}/memgres/embed_worker.py +0 -0
  34. {memgres-0.7.2 → memgres-0.9.0}/memgres/embeddings.py +0 -0
  35. {memgres-0.7.2 → memgres-0.9.0}/memgres/healthcheck.py +0 -0
  36. {memgres-0.7.2 → memgres-0.9.0}/memgres/indexing.py +0 -0
  37. {memgres-0.7.2 → memgres-0.9.0}/memgres/lines.py +0 -0
  38. {memgres-0.7.2 → memgres-0.9.0}/memgres/links.py +0 -0
  39. {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0001_core.sql +0 -0
  40. {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0002_identity.sql +0 -0
  41. {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0003_history_author.sql +0 -0
  42. {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0004_title.sql +0 -0
  43. {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0005_chunk_index.sql +0 -0
  44. {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0006_reader_floor.sql +0 -0
  45. {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0007_embed_retry.sql +0 -0
  46. {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0008_service_roles.sql +0 -0
  47. {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0010_namespace_alias.sql +0 -0
  48. {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
  49. {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0012_user_profile.sql +0 -0
  50. {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0013_hash_version.sql +0 -0
  51. {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
  52. {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0015_normalize_tags.sql +0 -0
  53. {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0016_valid_at.sql +0 -0
  54. {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0017_memory_link.sql +0 -0
  55. {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0018_links_built.sql +0 -0
  56. {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0019_memory_usage.sql +0 -0
  57. {memgres-0.7.2 → memgres-0.9.0}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
  58. {memgres-0.7.2 → memgres-0.9.0}/memgres/periodic.py +0 -0
  59. {memgres-0.7.2 → memgres-0.9.0}/memgres/reembed.py +0 -0
  60. {memgres-0.7.2 → memgres-0.9.0}/memgres/relink.py +0 -0
  61. {memgres-0.7.2 → memgres-0.9.0}/memgres/search.py +0 -0
  62. {memgres-0.7.2 → memgres-0.9.0}/memgres/segments.py +0 -0
  63. {memgres-0.7.2 → memgres-0.9.0}/memgres/store.py +0 -0
  64. {memgres-0.7.2 → memgres-0.9.0}/memgres/tags.py +0 -0
  65. {memgres-0.7.2 → memgres-0.9.0}/memgres/vector/__init__.py +0 -0
  66. {memgres-0.7.2 → memgres-0.9.0}/memgres/vector/base.py +0 -0
  67. {memgres-0.7.2 → memgres-0.9.0}/memgres/vector/pgvector.py +0 -0
  68. {memgres-0.7.2 → memgres-0.9.0}/memgres/vector/qdrant.py +0 -0
  69. {memgres-0.7.2 → memgres-0.9.0}/memgres/worker.py +0 -0
  70. {memgres-0.7.2 → memgres-0.9.0}/memgres.egg-info/dependency_links.txt +0 -0
  71. {memgres-0.7.2 → memgres-0.9.0}/memgres.egg-info/requires.txt +0 -0
  72. {memgres-0.7.2 → memgres-0.9.0}/memgres.egg-info/top_level.txt +0 -0
  73. {memgres-0.7.2 → memgres-0.9.0}/setup.cfg +0 -0
  74. {memgres-0.7.2 → memgres-0.9.0}/tests/test_blame_integration.py +0 -0
  75. {memgres-0.7.2 → memgres-0.9.0}/tests/test_chunk_index.py +0 -0
  76. {memgres-0.7.2 → memgres-0.9.0}/tests/test_claim_and_reembed.py +0 -0
  77. {memgres-0.7.2 → memgres-0.9.0}/tests/test_config.py +0 -0
  78. {memgres-0.7.2 → memgres-0.9.0}/tests/test_diffing.py +0 -0
  79. {memgres-0.7.2 → memgres-0.9.0}/tests/test_embed_worker.py +0 -0
  80. {memgres-0.7.2 → memgres-0.9.0}/tests/test_embeddings.py +0 -0
  81. {memgres-0.7.2 → memgres-0.9.0}/tests/test_healthcheck.py +0 -0
  82. {memgres-0.7.2 → memgres-0.9.0}/tests/test_identity_integration.py +0 -0
  83. {memgres-0.7.2 → memgres-0.9.0}/tests/test_lexical_match.py +0 -0
  84. {memgres-0.7.2 → memgres-0.9.0}/tests/test_limits.py +0 -0
  85. {memgres-0.7.2 → memgres-0.9.0}/tests/test_links.py +0 -0
  86. {memgres-0.7.2 → memgres-0.9.0}/tests/test_list.py +0 -0
  87. {memgres-0.7.2 → memgres-0.9.0}/tests/test_mcp_http_transport.py +0 -0
  88. {memgres-0.7.2 → memgres-0.9.0}/tests/test_mcp_instructions.py +0 -0
  89. {memgres-0.7.2 → memgres-0.9.0}/tests/test_mcp_recall_schema.py +0 -0
  90. {memgres-0.7.2 → memgres-0.9.0}/tests/test_migration_upgrade.py +0 -0
  91. {memgres-0.7.2 → memgres-0.9.0}/tests/test_multi_space_search.py +0 -0
  92. {memgres-0.7.2 → memgres-0.9.0}/tests/test_path_addressing.py +0 -0
  93. {memgres-0.7.2 → memgres-0.9.0}/tests/test_qdrant_ca.py +0 -0
  94. {memgres-0.7.2 → memgres-0.9.0}/tests/test_qdrant_integration.py +0 -0
  95. {memgres-0.7.2 → memgres-0.9.0}/tests/test_replace_build.py +0 -0
  96. {memgres-0.7.2 → memgres-0.9.0}/tests/test_require_title.py +0 -0
  97. {memgres-0.7.2 → memgres-0.9.0}/tests/test_retention.py +0 -0
  98. {memgres-0.7.2 → memgres-0.9.0}/tests/test_roles_bootstrap.py +0 -0
  99. {memgres-0.7.2 → memgres-0.9.0}/tests/test_search_integration.py +0 -0
  100. {memgres-0.7.2 → memgres-0.9.0}/tests/test_security_integration.py +0 -0
  101. {memgres-0.7.2 → memgres-0.9.0}/tests/test_segments.py +0 -0
  102. {memgres-0.7.2 → memgres-0.9.0}/tests/test_segments_store.py +0 -0
  103. {memgres-0.7.2 → memgres-0.9.0}/tests/test_snippets.py +0 -0
  104. {memgres-0.7.2 → memgres-0.9.0}/tests/test_store_integration.py +0 -0
  105. {memgres-0.7.2 → memgres-0.9.0}/tests/test_tags.py +0 -0
  106. {memgres-0.7.2 → memgres-0.9.0}/tests/test_usage.py +0 -0
  107. {memgres-0.7.2 → memgres-0.9.0}/tests/test_valid_at.py +0 -0
  108. {memgres-0.7.2 → memgres-0.9.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.7.2
3
+ Version: 0.9.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
@@ -214,6 +214,7 @@ Everything is env, all optional (defaults suit a single-user embed). Full list i
214
214
  | `MEMGRES_KEY_MODE` | `single` | `single` (no auth, one space) · `open` (bring-your-own token, self-registers) · `managed` (admin-provisioned). See [docs/TENANCY.md](docs/TENANCY.md) |
215
215
  | `MEMGRES_ADMIN_TOKEN` | — | global admin bearer for provisioning (managed mode) |
216
216
  | `MEMGRES_TOKEN` | — | default token used when a call passes none (single-tenant endpoints) |
217
+ | `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) |
217
218
  | `MEMGRES_TREE` | `true` | `ltree` path column + GiST index (fast subtree select) |
218
219
  | `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) |
219
220
  | `MEMGRES_REQUIRE_PARENT` | `false` | `true` = a node's parent path must already exist |
@@ -351,11 +352,12 @@ secret and can't switch user):
351
352
  "headers": { "Authorization": "Bearer mgk_…" } } } }
352
353
  ```
353
354
 
354
- A *namespace-scoped* token also locks the agent to one space. Only a genuinely
355
- multi-tenant endpoint (open/managed, **no** pinned token) exposes a `token` tool
356
- argument for the model to supply force it either way with
357
- `MEMGRES_MCP_TOKEN_ARG=on|off`. Single mode needs no token. Full model in
358
- [docs/TENANCY.md](docs/TENANCY.md).
355
+ A *namespace-scoped* token also locks the agent to one space. **No tool takes a
356
+ `token` argument**: a deployment names its caller in configuration — an
357
+ `Authorization` header on http, `MEMGRES_TOKEN` in the env of a stdio server's
358
+ client entry and both transports can carry one, so an argument could only ever
359
+ name an identity the deployment did not choose. Single mode needs no token at
360
+ all. Full model in [docs/TENANCY.md](docs/TENANCY.md).
359
361
 
360
362
  **B. From your own agent code** — your loop calls the HTTP API or the `Store`
361
363
  library after the model produces text (see the examples above). Use this when you
@@ -178,6 +178,7 @@ Everything is env, all optional (defaults suit a single-user embed). Full list i
178
178
  | `MEMGRES_KEY_MODE` | `single` | `single` (no auth, one space) · `open` (bring-your-own token, self-registers) · `managed` (admin-provisioned). See [docs/TENANCY.md](docs/TENANCY.md) |
179
179
  | `MEMGRES_ADMIN_TOKEN` | — | global admin bearer for provisioning (managed mode) |
180
180
  | `MEMGRES_TOKEN` | — | default token used when a call passes none (single-tenant endpoints) |
181
+ | `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) |
181
182
  | `MEMGRES_TREE` | `true` | `ltree` path column + GiST index (fast subtree select) |
182
183
  | `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) |
183
184
  | `MEMGRES_REQUIRE_PARENT` | `false` | `true` = a node's parent path must already exist |
@@ -315,11 +316,12 @@ secret and can't switch user):
315
316
  "headers": { "Authorization": "Bearer mgk_…" } } } }
316
317
  ```
317
318
 
318
- A *namespace-scoped* token also locks the agent to one space. Only a genuinely
319
- multi-tenant endpoint (open/managed, **no** pinned token) exposes a `token` tool
320
- argument for the model to supply force it either way with
321
- `MEMGRES_MCP_TOKEN_ARG=on|off`. Single mode needs no token. Full model in
322
- [docs/TENANCY.md](docs/TENANCY.md).
319
+ A *namespace-scoped* token also locks the agent to one space. **No tool takes a
320
+ `token` argument**: a deployment names its caller in configuration — an
321
+ `Authorization` header on http, `MEMGRES_TOKEN` in the env of a stdio server's
322
+ client entry and both transports can carry one, so an argument could only ever
323
+ name an identity the deployment did not choose. Single mode needs no token at
324
+ all. Full model in [docs/TENANCY.md](docs/TENANCY.md).
323
325
 
324
326
  **B. From your own agent code** — your loop calls the HTTP API or the `Store`
325
327
  library after the model produces text (see the examples above). Use this when you
@@ -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.7.2"
11
+ __version__ = "0.9.0"
@@ -409,11 +409,17 @@ def list_spaces(conn, p: Principal) -> List[dict]:
409
409
 
410
410
  def issue_token(conn, p: Principal, *, user_id: str,
411
411
  namespace_id: Optional[str] = None, permission: str = "write",
412
- label: str = "", expires_days: Optional[int] = None) -> dict:
412
+ label: str = "", expires_days: Optional[int] = None,
413
+ sink_dir: str = "") -> dict:
413
414
  """Mint a token for `user_id`. The secret is returned once and never again.
414
415
 
415
416
  `expires_days` rather than a timestamp: both doors were converting the same
416
417
  way, so the conversion belongs here.
418
+
419
+ `sink_dir` (the deployment's `MEMGRES_TOKEN_SINK`) diverts the secret to a
420
+ 0600 file on the server and returns its path instead — see
421
+ `identity.stash_secret`. The caller then never holds the secret at all,
422
+ which is the point when the caller is an agent.
417
423
  """
418
424
  require_manage_users(p)
419
425
  _require_target_is_plain_user(conn, p, user_id, "issuing a token")
@@ -423,8 +429,68 @@ def issue_token(conn, p: Principal, *, user_id: str,
423
429
  secret, tid = identity.issue_token(conn, user_id, namespace_id=namespace_id,
424
430
  permission=permission, label=label,
425
431
  expires_at=expires_at)
426
- return {"token": secret, "id": tid,
427
- "note": "store this now — it is not recoverable"}
432
+ return deliver_secret(secret, tid, sink_dir)
433
+
434
+
435
+ def deliver_secret(secret: str, token_id: str, sink_dir: str) -> dict:
436
+ """The reply for a freshly minted token: the secret itself, or — when the
437
+ deployment set a sink — only where it was put. One function so both minting
438
+ doors (this module and the MCP self-service tool) cannot disagree about
439
+ whether a secret is allowed into a response body."""
440
+ if sink_dir:
441
+ path = identity.stash_secret(sink_dir, token_id, secret)
442
+ return {"id": token_id, "delivered": "file", "path": path,
443
+ "exposed": False,
444
+ "note": "the secret was written to that file on the server and "
445
+ "deliberately NOT returned here — read it there"}
446
+ return {"token": secret, "id": token_id, "exposed": True,
447
+ "note": "store this now — it is not recoverable. This secret was "
448
+ "returned in a reply: if that reply reached an LLM, treat "
449
+ "the token as exposed and rotate it once delivered. Set "
450
+ "MEMGRES_TOKEN_SINK to stop returning secrets at all."}
451
+
452
+
453
+ def create_enrollment(conn, p: Principal, *, user_id: str,
454
+ namespace_id: Optional[str] = None,
455
+ permission: str = "write", label: str = "",
456
+ expires_minutes: Optional[int] = None) -> dict:
457
+ """Mint a one-time key that lets `user_id` bind a token THEY generate.
458
+
459
+ Provisioning-tier, and gated exactly like `issue_token`, because it grants
460
+ the same thing by another route: whoever redeems it ends up holding a
461
+ credential for that account. What it does not do is put a secret anywhere —
462
+ the key is worthless after one use and after `expires_minutes`, and the
463
+ token itself is created on the far side and never travels.
464
+ """
465
+ require_manage_users(p)
466
+ _require_target_is_plain_user(conn, p, user_id, "issuing an enrollment key")
467
+ kw = {} if expires_minutes is None else {"expires_minutes": expires_minutes}
468
+ out = identity.create_enrollment(
469
+ conn, user_id, namespace_id=namespace_id, permission=permission,
470
+ label=label, created_by=p.user_id, **kw)
471
+ out["note"] = ("give this key to its owner over any channel you would use "
472
+ "for a meeting link — it is single-use and short-lived. They "
473
+ "generate their own token, put it in their client's config, "
474
+ "and call memory_enroll with this key.")
475
+ return out
476
+
477
+
478
+ def list_enrollments(conn, p: Principal, *,
479
+ user_id: Optional[str] = None) -> List[dict]:
480
+ """Enrollment keys and what became of them — metadata only, never a key."""
481
+ require_manage_users(p)
482
+ if user_id is not None:
483
+ _require_target_is_plain_user(conn, p, user_id, "listing enrollment keys")
484
+ return identity.list_enrollments(conn, user_id=user_id)
485
+
486
+
487
+ def revoke_enrollment(conn, p: Principal, *, enrollment_id: str) -> bool:
488
+ """Kill an unredeemed key. False if it was already spent, revoked or absent."""
489
+ require_manage_users(p)
490
+ _require_target_is_plain_user(conn, p,
491
+ identity.enrollment_owner(conn, enrollment_id),
492
+ "revoking an enrollment key")
493
+ return identity.revoke_enrollment(conn, enrollment_id)
428
494
 
429
495
 
430
496
  def revoke_token(conn, p: Principal, *, token_id: str) -> bool:
@@ -70,6 +70,12 @@ class Config:
70
70
  # path only. Mutually exclusive with admin_token.
71
71
  admin_role: str # role the bootstrap admin is seeded with:
72
72
  # user_manager (default) | superadmin
73
+ token_sink: str # directory a freshly minted token secret is
74
+ # WRITTEN to (0600) instead of being returned in
75
+ # the reply. Set it when the caller is an agent:
76
+ # a secret in a tool result is a secret in a chat
77
+ # transcript, and every transcript is copied,
78
+ # summarized and stored. Empty = return it.
73
79
  # organization
74
80
  tree_enabled: bool # ltree path column + GiST index for fast subtree selection
75
81
  require_title: bool # True = a write that stores CONTENT must caption it
@@ -182,6 +188,11 @@ class Config:
182
188
  raise ValueError(
183
189
  "MEMGRES_ADMIN_ROLE must be user_manager or superadmin "
184
190
  f"(got {self.admin_role!r})")
191
+ if self.token_sink and not os.path.isabs(self.token_sink):
192
+ # A relative sink resolves against each process's CWD, so the server
193
+ # and the CLI would write the same operator's secrets to different
194
+ # directories — and neither would say so.
195
+ raise ValueError("MEMGRES_TOKEN_SINK must be an absolute path")
185
196
  if self.admin_token and self.admin_token_file:
186
197
  raise ValueError(
187
198
  "set only one of MEMGRES_ADMIN_TOKEN / MEMGRES_ADMIN_TOKEN_FILE")
@@ -208,6 +219,7 @@ def load() -> Config:
208
219
  admin_token=_str("MEMGRES_ADMIN_TOKEN", ""),
209
220
  admin_token_file=_str("MEMGRES_ADMIN_TOKEN_FILE", ""),
210
221
  admin_role=_str("MEMGRES_ADMIN_ROLE", "user_manager"),
222
+ token_sink=_str("MEMGRES_TOKEN_SINK", ""),
211
223
  tree_enabled=_bool("MEMGRES_TREE", True),
212
224
  require_title=_bool("MEMGRES_REQUIRE_TITLE", True),
213
225
  require_parent=_bool("MEMGRES_REQUIRE_PARENT", False),
@@ -26,6 +26,7 @@ from __future__ import annotations
26
26
 
27
27
  import hashlib
28
28
  import hmac
29
+ import os
29
30
  import re
30
31
  import secrets
31
32
  from dataclasses import dataclass
@@ -34,6 +35,21 @@ from typing import List, Optional, Sequence, Tuple
34
35
  # ─── token format: mgk_ + 43 url-safe chars (256-bit) ────────────────────────
35
36
  TOKEN_RE = re.compile(r"^mgk_[A-Za-z0-9_-]{43}$")
36
37
 
38
+ # ─── enrollment key format: mge_ + the same 256 bits ─────────────────────────
39
+ # A DIFFERENT prefix on purpose. The two are handled by different code paths and
40
+ # have opposite lifetimes — one is durable, one dies in half an hour — so a
41
+ # credential that leaks should be identifiable on sight, by a human reading a
42
+ # log and by the server refusing it in the wrong slot.
43
+ ENROLL_RE = re.compile(r"^mge_[A-Za-z0-9_-]{43}$")
44
+
45
+ # An enrollment key is a credential for as long as it lives, so the default is
46
+ # short. There is deliberately NO upper limit: an operator provisioning someone
47
+ # who is on holiday for two weeks needs a key that survives until they are back,
48
+ # and a server-side cap would only push them into re-issuing keys by hand. The
49
+ # risk of a long window is real and belongs to whoever chooses it, which is why
50
+ # the listing reports every key's expiry.
51
+ DEFAULT_ENROLL_MINUTES = 30
52
+
37
53
 
38
54
  def bearer_token(authorization: Optional[str],
39
55
  x_memgres_token: Optional[str]) -> Optional[str]:
@@ -1064,6 +1080,189 @@ def issue_token(conn, user_id: str, *, namespace_id: Optional[str] = None,
1064
1080
  return secret, str(cur.fetchone()[0])
1065
1081
 
1066
1082
 
1083
+ def stash_secret(sink_dir: str, token_id: str, secret: str) -> str:
1084
+ """Write a freshly minted secret to ``<sink_dir>/<token_id>.token`` (0600)
1085
+ and return the path.
1086
+
1087
+ The out-of-band delivery channel for a deployment whose callers are agents.
1088
+ Returning the secret in the reply is fine for a program; for an LLM it means
1089
+ the secret is now in a transcript that gets logged, summarized and shipped
1090
+ to a model provider. Handing back a path instead keeps the operator's own
1091
+ shell as the only place the secret is ever rendered.
1092
+
1093
+ The directory is created 0700 if missing, and the file is opened with
1094
+ ``O_CREAT`` at 0600 so the secret is never briefly world-readable.
1095
+ """
1096
+ os.makedirs(sink_dir, mode=0o700, exist_ok=True)
1097
+ path = os.path.join(sink_dir, f"{token_id}.token")
1098
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
1099
+ with os.fdopen(fd, "w") as fh:
1100
+ fh.write(secret + "\n")
1101
+ os.chmod(path, 0o600) # tighten even if the file pre-existed
1102
+ return path
1103
+
1104
+
1105
+ # ─── enrollment: bind a token the client generated itself ────────────────────
1106
+ def new_enrollment_key() -> str:
1107
+ """A fresh one-time key: ``mge_`` + 43 url-safe chars."""
1108
+ return "mge_" + secrets.token_urlsafe(32)
1109
+
1110
+
1111
+ def valid_enrollment_format(key: str) -> bool:
1112
+ return bool(key) and bool(ENROLL_RE.match(key))
1113
+
1114
+
1115
+ def is_unbound(conn, cfg, secret: Optional[str]) -> bool:
1116
+ """Is this a well-formed credential that simply doesn't belong to anyone yet?
1117
+
1118
+ The question `resolve` cannot answer: it raises ``AuthError`` for an unknown
1119
+ token, a revoked one and an expired one alike, and only the first of those
1120
+ may enroll. Asked separately rather than folded into `resolve` so that
1121
+ authentication keeps exactly one outcome — a Principal or a refusal — and no
1122
+ caller can accidentally treat "may enroll" as "is authenticated".
1123
+ """
1124
+ if cfg.key_mode != "managed" or not valid_format(secret or ""):
1125
+ return False
1126
+ with conn.cursor() as cur:
1127
+ cur.execute("SELECT 1 FROM token WHERE token_hash=%s",
1128
+ (token_hash(secret),))
1129
+ return cur.fetchone() is None
1130
+
1131
+
1132
+ def create_enrollment(conn, user_id: str, *, namespace_id: Optional[str] = None,
1133
+ permission: str = "write", label: str = "",
1134
+ created_by: Optional[str] = None,
1135
+ expires_minutes: int = DEFAULT_ENROLL_MINUTES) -> dict:
1136
+ """Mint a one-time key that lets its holder bind ONE self-generated token to
1137
+ `user_id`. Returns the key (once) plus its id and expiry.
1138
+
1139
+ The ceiling and the scope are decided HERE, by whoever provisions — not by
1140
+ the person redeeming it. Otherwise enrolling would be a way to ask for more
1141
+ authority than you were given.
1142
+
1143
+ `expires_minutes` defaults to 30 and accepts anything above zero — a key for
1144
+ someone who will not be at their desk today is a legitimate need, and the
1145
+ window is the issuer's judgement, not the server's.
1146
+ """
1147
+ if permission not in _RANK:
1148
+ raise ValueError(f"bad permission: {permission}")
1149
+ minutes = int(expires_minutes or DEFAULT_ENROLL_MINUTES)
1150
+ if minutes < 1:
1151
+ raise ValueError("expires_minutes must be at least 1")
1152
+ key = new_enrollment_key()
1153
+ with conn.cursor() as cur:
1154
+ cur.execute(
1155
+ "INSERT INTO enrollment_key (key_hash, user_id, namespace_id, "
1156
+ "permission, label, created_by, expires_at) VALUES "
1157
+ "(%s, %s, %s, %s, %s, %s, now() + make_interval(mins => %s)) "
1158
+ "RETURNING id, expires_at",
1159
+ (token_hash(key), user_id, namespace_id, permission, label,
1160
+ created_by, minutes))
1161
+ eid, exp = cur.fetchone()
1162
+ return {"key": key, "id": str(eid), "expires_at": exp,
1163
+ "user_id": user_id, "permission": permission,
1164
+ "namespace_id": namespace_id}
1165
+
1166
+
1167
+ def redeem_enrollment(conn, key: str, secret: str) -> dict:
1168
+ """Bind `secret` — a token the caller generated — to the account `key` names.
1169
+
1170
+ Every refusal here is deliberate and distinct:
1171
+
1172
+ * a spent key says so, because that is the ONLY signal a stolen key gives.
1173
+ Its rightful holder finding it already redeemed is how the theft is
1174
+ noticed at all; collapsing it into "invalid key" would erase the alarm.
1175
+ * a secret the server already knows is refused rather than re-bound. A
1176
+ revoked token could otherwise be revived by enrolling it somewhere else,
1177
+ which would make revocation a suggestion.
1178
+ """
1179
+ if not valid_enrollment_format(key):
1180
+ raise AuthError("malformed enrollment key (expected mge_ + 43 url-safe chars)")
1181
+ if not valid_format(secret or ""):
1182
+ raise AuthError("the credential to bind must be mgk_ + 43 url-safe chars")
1183
+ with conn.cursor() as cur:
1184
+ cur.execute(
1185
+ "SELECT id, user_id, namespace_id, permission, label, "
1186
+ " used_at IS NOT NULL, revoked_at IS NOT NULL, "
1187
+ " expires_at <= now() "
1188
+ "FROM enrollment_key WHERE key_hash=%s FOR UPDATE",
1189
+ (token_hash(key),))
1190
+ row = cur.fetchone()
1191
+ if row is None:
1192
+ raise AuthError("unknown enrollment key")
1193
+ eid, uid, nsid, perm, label, used, revoked, expired = row
1194
+ if used:
1195
+ raise AuthError("this enrollment key was already redeemed — if that "
1196
+ "was not you, tell whoever issued it: the token it "
1197
+ "created belongs to someone else")
1198
+ if revoked:
1199
+ raise AuthError("enrollment key revoked")
1200
+ if expired:
1201
+ raise AuthError("enrollment key expired")
1202
+ cur.execute("SELECT 1 FROM token WHERE token_hash=%s", (token_hash(secret),))
1203
+ if cur.fetchone() is not None:
1204
+ raise AuthError("that credential is already known to this server — "
1205
+ "generate a fresh one")
1206
+ tid = register_token(conn, str(uid), secret, namespace_id=
1207
+ str(nsid) if nsid else None,
1208
+ permission=perm, label=label or "enrolled")
1209
+ cur.execute("UPDATE enrollment_key SET used_at=now(), used_token_id=%s "
1210
+ "WHERE id=%s", (tid, eid))
1211
+ return {"user_id": str(uid), "token_id": tid, "permission": perm,
1212
+ "namespace_id": str(nsid) if nsid else None,
1213
+ "enrollment_id": str(eid)}
1214
+
1215
+
1216
+ def list_enrollments(conn, *, user_id: Optional[str] = None) -> List[dict]:
1217
+ """Pending and spent keys — metadata only, never the key itself."""
1218
+ # `expired` is computed by the DATABASE: the key's clock and the reader's
1219
+ # are not the same clock, and a listing that disagreed with what redeeming
1220
+ # does would be worse than no listing.
1221
+ sql = ("SELECT id, user_id, namespace_id, permission, label, created_by, "
1222
+ "created_at, expires_at, used_at, used_token_id, revoked_at, "
1223
+ "expires_at <= now() AS expired FROM enrollment_key")
1224
+ args: list = []
1225
+ if user_id is not None:
1226
+ sql += " WHERE user_id=%s"
1227
+ args.append(user_id)
1228
+ sql += " ORDER BY created_at DESC, id"
1229
+ with conn.cursor() as cur:
1230
+ cur.execute(sql, args)
1231
+ cols = [c.name for c in cur.description]
1232
+ out = []
1233
+ for r in cur.fetchall():
1234
+ d = dict(zip(cols, r))
1235
+ for k in ("id", "user_id", "namespace_id", "created_by",
1236
+ "used_token_id"):
1237
+ d[k] = str(d[k]) if d[k] else None
1238
+ d["state"] = ("revoked" if d["revoked_at"] else
1239
+ "redeemed" if d["used_at"] else
1240
+ "expired" if d.pop("expired") else "pending")
1241
+ d.pop("expired", None)
1242
+ out.append(d)
1243
+ return out
1244
+
1245
+
1246
+ def revoke_enrollment(conn, enrollment_id: str) -> bool:
1247
+ """Kill an unredeemed key. False if it was already spent, revoked or absent —
1248
+ a redeemed key is NOT revocable, because what needs killing then is the token
1249
+ it produced, and pretending otherwise would leave that token alive."""
1250
+ with conn.cursor() as cur:
1251
+ cur.execute("UPDATE enrollment_key SET revoked_at=now() WHERE id=%s "
1252
+ "AND revoked_at IS NULL AND used_at IS NULL RETURNING id",
1253
+ (enrollment_id,))
1254
+ return cur.fetchone() is not None
1255
+
1256
+
1257
+ def enrollment_owner(conn, enrollment_id: str) -> Optional[str]:
1258
+ """Whose account a key would bind to — the target for an authority check."""
1259
+ with conn.cursor() as cur:
1260
+ cur.execute("SELECT user_id FROM enrollment_key WHERE id=%s",
1261
+ (enrollment_id,))
1262
+ r = cur.fetchone()
1263
+ return str(r[0]) if r else None
1264
+
1265
+
1067
1266
  def register_token(conn, user_id: str, secret: str, *,
1068
1267
  namespace_id: Optional[str] = None, permission: str = "write",
1069
1268
  label: str = "", expires_at=None) -> str:
@@ -1,7 +1,7 @@
1
1
  """Effective server configuration, exposed read-only for introspection.
2
2
 
3
- An agent shouldn't have to guess the write ceilings, which recall modes are
4
- available, or how memories are embedded. ``server_info`` distills the loaded
3
+ An agent shouldn't have to guess the write ceilings, how long a memory is kept,
4
+ which recall modes are available, or how memories are embedded. ``server_info`` distills the loaded
5
5
  ``Config`` (plus the live embedder's dimension, if one is built) into a small,
6
6
  non-sensitive dict. It deliberately carries **no** secrets — no token, no api
7
7
  key, no database url — so it is safe to return unauthenticated.
@@ -16,6 +16,15 @@ from .config import Config
16
16
  from .schema import SCHEMA_VERSION
17
17
 
18
18
 
19
+ def _retention_policy(days: int, renew_on_read: bool) -> str:
20
+ """One plain sentence a human (or an agent explaining itself) can quote."""
21
+ if days <= 0:
22
+ return "kept indefinitely — memories do not expire"
23
+ touch = ("any touch, a read included, starts the window again"
24
+ if renew_on_read else "only a write restarts the window")
25
+ return f"deleted {days} days after the last touch ({touch})"
26
+
27
+
19
28
  def server_info(cfg: Config, embed_dim: Optional[int] = None) -> dict:
20
29
  """Effective limits + capabilities, from the loaded config (and the live
21
30
  embedder's dimension when available). Never includes secrets.
@@ -25,6 +34,7 @@ def server_info(cfg: Config, embed_dim: Optional[int] = None) -> dict:
25
34
  layout this build migrates to."""
26
35
  lexical_only = cfg.embed_provider == "none"
27
36
  dim = embed_dim if embed_dim is not None else (cfg.embed_dim or None)
37
+ days = cfg.retention_days
28
38
  return {
29
39
  "version": __version__,
30
40
  "schema_version": SCHEMA_VERSION,
@@ -43,6 +53,18 @@ def server_info(cfg: Config, embed_dim: Optional[int] = None) -> dict:
43
53
  "model": cfg.embed_model or None,
44
54
  "dim": dim,
45
55
  },
56
+ # How long a memory survives, said out loud. A client that cannot see
57
+ # this has to guess whether what it stores today will still be there in a
58
+ # month — and "kept indefinitely" is the answer it guesses WRONG about
59
+ # most often, because nothing in a reply hints that an expiry exists at
60
+ # all. `days: null` is the unlimited case, spelled again in `policy`.
61
+ "retention": {
62
+ "days": days if days > 0 else None,
63
+ "expires": days > 0,
64
+ # A read renewing the window only means anything if there is one.
65
+ "renew_on_read": bool(days > 0 and cfg.renew_on_read),
66
+ "policy": _retention_policy(days, cfg.renew_on_read),
67
+ },
46
68
  "recall_modes": ["lexical"] if lexical_only
47
69
  else ["lexical", "semantic", "hybrid", "auto"],
48
70
  "vector_backend": cfg.vector_backend,