memgres 0.8.0__tar.gz → 0.10.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 (109) hide show
  1. {memgres-0.8.0 → memgres-0.10.0}/PKG-INFO +2 -1
  2. {memgres-0.8.0 → memgres-0.10.0}/README.md +1 -0
  3. {memgres-0.8.0 → memgres-0.10.0}/memgres/_version.py +1 -1
  4. {memgres-0.8.0 → memgres-0.10.0}/memgres/admin.py +176 -8
  5. {memgres-0.8.0 → memgres-0.10.0}/memgres/config.py +12 -0
  6. {memgres-0.8.0 → memgres-0.10.0}/memgres/identity.py +390 -13
  7. {memgres-0.8.0 → memgres-0.10.0}/memgres/info.py +24 -2
  8. {memgres-0.8.0 → memgres-0.10.0}/memgres/mcp_server.py +265 -15
  9. memgres-0.10.0/memgres/migrations/0021_enrollment_key.sql +40 -0
  10. memgres-0.10.0/memgres/migrations/0022_user_disabled.sql +12 -0
  11. {memgres-0.8.0 → memgres-0.10.0}/memgres/schema.py +1 -1
  12. {memgres-0.8.0 → memgres-0.10.0}/memgres/server.py +85 -2
  13. memgres-0.10.0/memgres/token_cli.py +206 -0
  14. {memgres-0.8.0 → memgres-0.10.0}/memgres.egg-info/PKG-INFO +2 -1
  15. {memgres-0.8.0 → memgres-0.10.0}/memgres.egg-info/SOURCES.txt +6 -0
  16. {memgres-0.8.0 → memgres-0.10.0}/memgres.egg-info/entry_points.txt +1 -0
  17. {memgres-0.8.0 → memgres-0.10.0}/pyproject.toml +1 -0
  18. memgres-0.10.0/tests/test_admin_two_way.py +402 -0
  19. memgres-0.10.0/tests/test_enrollment.py +338 -0
  20. {memgres-0.8.0 → memgres-0.10.0}/tests/test_mcp_admin_tools.py +9 -0
  21. {memgres-0.8.0 → memgres-0.10.0}/tests/test_mcp_tool_visibility.py +21 -3
  22. {memgres-0.8.0 → memgres-0.10.0}/tests/test_mcp_tool_visibility_http.py +75 -0
  23. {memgres-0.8.0 → memgres-0.10.0}/tests/test_server_info.py +45 -1
  24. {memgres-0.8.0 → memgres-0.10.0}/tests/test_server_integration.py +6 -1
  25. memgres-0.10.0/tests/test_token_sink.py +204 -0
  26. {memgres-0.8.0 → memgres-0.10.0}/LICENSE +0 -0
  27. {memgres-0.8.0 → memgres-0.10.0}/memgres/__init__.py +0 -0
  28. {memgres-0.8.0 → memgres-0.10.0}/memgres/admin_cli.py +0 -0
  29. {memgres-0.8.0 → memgres-0.10.0}/memgres/blame.py +0 -0
  30. {memgres-0.8.0 → memgres-0.10.0}/memgres/bootstrap.py +0 -0
  31. {memgres-0.8.0 → memgres-0.10.0}/memgres/delimiters.py +0 -0
  32. {memgres-0.8.0 → memgres-0.10.0}/memgres/diffing.py +0 -0
  33. {memgres-0.8.0 → memgres-0.10.0}/memgres/embed_worker.py +0 -0
  34. {memgres-0.8.0 → memgres-0.10.0}/memgres/embeddings.py +0 -0
  35. {memgres-0.8.0 → memgres-0.10.0}/memgres/healthcheck.py +0 -0
  36. {memgres-0.8.0 → memgres-0.10.0}/memgres/indexing.py +0 -0
  37. {memgres-0.8.0 → memgres-0.10.0}/memgres/lines.py +0 -0
  38. {memgres-0.8.0 → memgres-0.10.0}/memgres/links.py +0 -0
  39. {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0001_core.sql +0 -0
  40. {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0002_identity.sql +0 -0
  41. {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0003_history_author.sql +0 -0
  42. {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0004_title.sql +0 -0
  43. {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0005_chunk_index.sql +0 -0
  44. {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0006_reader_floor.sql +0 -0
  45. {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0007_embed_retry.sql +0 -0
  46. {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0008_service_roles.sql +0 -0
  47. {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0009_create_namespace_right.sql +0 -0
  48. {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0010_namespace_alias.sql +0 -0
  49. {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
  50. {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0012_user_profile.sql +0 -0
  51. {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0013_hash_version.sql +0 -0
  52. {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
  53. {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0015_normalize_tags.sql +0 -0
  54. {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0016_valid_at.sql +0 -0
  55. {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0017_memory_link.sql +0 -0
  56. {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0018_links_built.sql +0 -0
  57. {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0019_memory_usage.sql +0 -0
  58. {memgres-0.8.0 → memgres-0.10.0}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
  59. {memgres-0.8.0 → memgres-0.10.0}/memgres/periodic.py +0 -0
  60. {memgres-0.8.0 → memgres-0.10.0}/memgres/reembed.py +0 -0
  61. {memgres-0.8.0 → memgres-0.10.0}/memgres/relink.py +0 -0
  62. {memgres-0.8.0 → memgres-0.10.0}/memgres/search.py +0 -0
  63. {memgres-0.8.0 → memgres-0.10.0}/memgres/segments.py +0 -0
  64. {memgres-0.8.0 → memgres-0.10.0}/memgres/store.py +0 -0
  65. {memgres-0.8.0 → memgres-0.10.0}/memgres/tags.py +0 -0
  66. {memgres-0.8.0 → memgres-0.10.0}/memgres/vector/__init__.py +0 -0
  67. {memgres-0.8.0 → memgres-0.10.0}/memgres/vector/base.py +0 -0
  68. {memgres-0.8.0 → memgres-0.10.0}/memgres/vector/pgvector.py +0 -0
  69. {memgres-0.8.0 → memgres-0.10.0}/memgres/vector/qdrant.py +0 -0
  70. {memgres-0.8.0 → memgres-0.10.0}/memgres/worker.py +0 -0
  71. {memgres-0.8.0 → memgres-0.10.0}/memgres.egg-info/dependency_links.txt +0 -0
  72. {memgres-0.8.0 → memgres-0.10.0}/memgres.egg-info/requires.txt +0 -0
  73. {memgres-0.8.0 → memgres-0.10.0}/memgres.egg-info/top_level.txt +0 -0
  74. {memgres-0.8.0 → memgres-0.10.0}/setup.cfg +0 -0
  75. {memgres-0.8.0 → memgres-0.10.0}/tests/test_blame_integration.py +0 -0
  76. {memgres-0.8.0 → memgres-0.10.0}/tests/test_chunk_index.py +0 -0
  77. {memgres-0.8.0 → memgres-0.10.0}/tests/test_claim_and_reembed.py +0 -0
  78. {memgres-0.8.0 → memgres-0.10.0}/tests/test_config.py +0 -0
  79. {memgres-0.8.0 → memgres-0.10.0}/tests/test_diffing.py +0 -0
  80. {memgres-0.8.0 → memgres-0.10.0}/tests/test_embed_worker.py +0 -0
  81. {memgres-0.8.0 → memgres-0.10.0}/tests/test_embeddings.py +0 -0
  82. {memgres-0.8.0 → memgres-0.10.0}/tests/test_healthcheck.py +0 -0
  83. {memgres-0.8.0 → memgres-0.10.0}/tests/test_identity_integration.py +0 -0
  84. {memgres-0.8.0 → memgres-0.10.0}/tests/test_lexical_match.py +0 -0
  85. {memgres-0.8.0 → memgres-0.10.0}/tests/test_limits.py +0 -0
  86. {memgres-0.8.0 → memgres-0.10.0}/tests/test_links.py +0 -0
  87. {memgres-0.8.0 → memgres-0.10.0}/tests/test_list.py +0 -0
  88. {memgres-0.8.0 → memgres-0.10.0}/tests/test_mcp_http_transport.py +0 -0
  89. {memgres-0.8.0 → memgres-0.10.0}/tests/test_mcp_instructions.py +0 -0
  90. {memgres-0.8.0 → memgres-0.10.0}/tests/test_mcp_recall_schema.py +0 -0
  91. {memgres-0.8.0 → memgres-0.10.0}/tests/test_migration_upgrade.py +0 -0
  92. {memgres-0.8.0 → memgres-0.10.0}/tests/test_multi_space_search.py +0 -0
  93. {memgres-0.8.0 → memgres-0.10.0}/tests/test_path_addressing.py +0 -0
  94. {memgres-0.8.0 → memgres-0.10.0}/tests/test_qdrant_ca.py +0 -0
  95. {memgres-0.8.0 → memgres-0.10.0}/tests/test_qdrant_integration.py +0 -0
  96. {memgres-0.8.0 → memgres-0.10.0}/tests/test_replace_build.py +0 -0
  97. {memgres-0.8.0 → memgres-0.10.0}/tests/test_require_title.py +0 -0
  98. {memgres-0.8.0 → memgres-0.10.0}/tests/test_retention.py +0 -0
  99. {memgres-0.8.0 → memgres-0.10.0}/tests/test_roles_bootstrap.py +0 -0
  100. {memgres-0.8.0 → memgres-0.10.0}/tests/test_search_integration.py +0 -0
  101. {memgres-0.8.0 → memgres-0.10.0}/tests/test_security_integration.py +0 -0
  102. {memgres-0.8.0 → memgres-0.10.0}/tests/test_segments.py +0 -0
  103. {memgres-0.8.0 → memgres-0.10.0}/tests/test_segments_store.py +0 -0
  104. {memgres-0.8.0 → memgres-0.10.0}/tests/test_snippets.py +0 -0
  105. {memgres-0.8.0 → memgres-0.10.0}/tests/test_store_integration.py +0 -0
  106. {memgres-0.8.0 → memgres-0.10.0}/tests/test_tags.py +0 -0
  107. {memgres-0.8.0 → memgres-0.10.0}/tests/test_usage.py +0 -0
  108. {memgres-0.8.0 → memgres-0.10.0}/tests/test_valid_at.py +0 -0
  109. {memgres-0.8.0 → memgres-0.10.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.8.0
3
+ Version: 0.10.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 |
@@ -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 |
@@ -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.8.0"
11
+ __version__ = "0.10.0"
@@ -353,7 +353,8 @@ def create_namespace(conn, p: Principal, *, owner_user_id: str, name: str,
353
353
 
354
354
  def edit_namespace(conn, p: Principal, *, namespace_id: str,
355
355
  description: Optional[str] = None,
356
- instruction: Optional[str] = None) -> dict:
356
+ instruction: Optional[str] = None,
357
+ name: Optional[str] = None) -> dict:
357
358
  """Amend a namespace's description or routing instruction.
358
359
 
359
360
  `create_namespace` is an idempotent upsert that ignores conflicts, so
@@ -363,19 +364,92 @@ def edit_namespace(conn, p: Principal, *, namespace_id: str,
363
364
  """
364
365
  require_namespace_admin(conn, p, namespace_id)
365
366
  identity.edit_namespace(conn, namespace_id, description=description,
366
- instruction=instruction)
367
- return {"namespace_id": namespace_id}
367
+ instruction=instruction, name=name)
368
+ out = {"namespace_id": namespace_id}
369
+ if name is not None:
370
+ out["name"] = name
371
+ out["warning"] = ("renamed — anyone addressing it by the old name now "
372
+ "gets 'no such namespace'. Ids and aliases still work")
373
+ return out
374
+
375
+
376
+ def require_namespace_owner(conn, p: Principal, namespace_id: str) -> None:
377
+ """Owner-or-superadmin, a tier narrower than `require_namespace_admin`.
378
+
379
+ For the acts that dispose of the namespace itself rather than work inside
380
+ it. An admin MEMBER was given authority over the contents; giving away
381
+ somebody else's namespace is not part of that, and the difference only
382
+ matters here — which is why this is its own check rather than a flag.
383
+ """
384
+ require_namespace_admin(conn, p, namespace_id) # ceiling + scope + exists
385
+ if p.is_admin:
386
+ return
387
+ if identity.namespace_owner(conn, namespace_id) != p.user_id:
388
+ raise Forbidden("only the owner (or a superadmin) may do this")
368
389
 
369
390
 
370
391
  def add_member(conn, p: Principal, *, namespace_id: str, user_id: str,
371
392
  permission: str = "read") -> dict:
372
- """Share a namespace with another user — cross-tenant, so superadmin only."""
373
- require_superadmin(p)
393
+ """Share a namespace with another user.
394
+
395
+ Authorized per-NAMESPACE, not deployment-wide. It used to demand superadmin,
396
+ on the reasoning that sharing reaches across tenants — but the thing being
397
+ shared is the caller's OWN namespace, and requiring the deployment's root
398
+ for that made "let a colleague into my cabinet" an operator ticket. What it
399
+ still demands is an admin-ceiling credential for that namespace, so a
400
+ read-only or differently-scoped token cannot hand out access.
401
+ """
402
+ require_namespace_admin(conn, p, namespace_id)
374
403
  identity.add_member(conn, namespace_id, user_id, permission)
375
404
  return {"namespace_id": namespace_id, "user_id": user_id,
376
405
  "permission": permission}
377
406
 
378
407
 
408
+ def remove_member(conn, p: Principal, *, namespace_id: str, user_id: str) -> dict:
409
+ """Un-share a namespace. `removed: false` means they were not a member.
410
+
411
+ The other half of `add_member`, and its absence was the sharpest gap in this
412
+ control plane: access could be granted and never taken back except by
413
+ revoking every token the person held, which cuts them off from everything
414
+ rather than from this.
415
+ """
416
+ require_namespace_admin(conn, p, namespace_id)
417
+ return {"namespace_id": namespace_id, "user_id": user_id,
418
+ "removed": identity.remove_member(conn, namespace_id, user_id)}
419
+
420
+
421
+ def transfer_namespace(conn, p: Principal, *, namespace_id: str,
422
+ new_owner_user_id: str,
423
+ keep_previous_owner: Optional[str] = "admin") -> dict:
424
+ """Hand a namespace to another account — owner or superadmin.
425
+
426
+ The outgoing owner stays behind as an `admin` member unless
427
+ `keep_previous_owner` is null. Defaulting to keeping them is the safer
428
+ footing: the alternative is a single call that removes the caller from a
429
+ namespace whose contents they may be the only one who knows.
430
+ """
431
+ require_namespace_owner(conn, p, namespace_id)
432
+ return identity.transfer_namespace(
433
+ conn, namespace_id, new_owner_user_id,
434
+ keep_previous_owner=keep_previous_owner)
435
+
436
+
437
+ def set_disabled(conn, p: Principal, *, user_id: str, disabled: bool) -> dict:
438
+ """Switch an account off, or back on. Every token it holds stops at once.
439
+
440
+ Offboarding as ONE act. Doing it by revoking tokens one at a time is a loop
441
+ that has to be complete to be correct, and nothing stops a new token being
442
+ issued afterwards. Reversible and destructive of nothing — authorship,
443
+ namespaces and memberships all survive, which is what makes it usable for
444
+ "gone for now" as well as "gone".
445
+ """
446
+ require_manage_users(p)
447
+ _require_target_is_plain_user(conn, p, user_id,
448
+ "disabling an account" if disabled
449
+ else "re-enabling an account")
450
+ return identity.set_disabled(conn, user_id, disabled)
451
+
452
+
379
453
  def list_namespaces(conn, p: Principal, *, owner_user_id: Optional[str] = None,
380
454
  limit: Optional[int] = None, offset: int = 0) -> List[dict]:
381
455
  """Deployment-wide namespace inventory.
@@ -407,24 +481,118 @@ def list_spaces(conn, p: Principal) -> List[dict]:
407
481
 
408
482
  # ─── tokens ──────────────────────────────────────────────────────────────────
409
483
 
484
+ def _unreachable_warning(conn, user_id: str,
485
+ namespace_id: Optional[str]) -> Optional[str]:
486
+ """Warn when a credential is about to be scoped to a namespace its owner
487
+ cannot reach.
488
+
489
+ Scoping is not granting: reach comes from ownership or membership, and a
490
+ token pinned to a namespace the account is not in is perfectly valid and
491
+ reaches nothing. The person enrols, everything answers "no namespace", and
492
+ it reads as a broken server rather than an unfinished provisioning. A
493
+ warning rather than a refusal, because issuing the credential first and
494
+ adding the membership after is a legitimate order.
495
+ """
496
+ if not namespace_id:
497
+ return None
498
+ if identity.reaches(conn, user_id, namespace_id) is not None:
499
+ return None
500
+ return ("that user cannot reach this namespace yet, so the token will be "
501
+ "valid and see NOTHING — add them with memory_admin_add_member "
502
+ "(or hand the namespace over) before they try to use it")
503
+
504
+
410
505
  def issue_token(conn, p: Principal, *, user_id: str,
411
506
  namespace_id: Optional[str] = None, permission: str = "write",
412
- label: str = "", expires_days: Optional[int] = None) -> dict:
507
+ label: str = "", expires_days: Optional[int] = None,
508
+ sink_dir: str = "") -> dict:
413
509
  """Mint a token for `user_id`. The secret is returned once and never again.
414
510
 
415
511
  `expires_days` rather than a timestamp: both doors were converting the same
416
512
  way, so the conversion belongs here.
513
+
514
+ `sink_dir` (the deployment's `MEMGRES_TOKEN_SINK`) diverts the secret to a
515
+ 0600 file on the server and returns its path instead — see
516
+ `identity.stash_secret`. The caller then never holds the secret at all,
517
+ which is the point when the caller is an agent.
417
518
  """
418
519
  require_manage_users(p)
419
520
  _require_target_is_plain_user(conn, p, user_id, "issuing a token")
420
521
  expires_at = None
421
522
  if expires_days:
422
523
  expires_at = dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=expires_days)
524
+ warning = _unreachable_warning(conn, user_id, namespace_id)
423
525
  secret, tid = identity.issue_token(conn, user_id, namespace_id=namespace_id,
424
526
  permission=permission, label=label,
425
527
  expires_at=expires_at)
426
- return {"token": secret, "id": tid,
427
- "note": "store this now — it is not recoverable"}
528
+ out = deliver_secret(secret, tid, sink_dir)
529
+ if warning:
530
+ out["warning"] = warning
531
+ return out
532
+
533
+
534
+ def deliver_secret(secret: str, token_id: str, sink_dir: str) -> dict:
535
+ """The reply for a freshly minted token: the secret itself, or — when the
536
+ deployment set a sink — only where it was put. One function so both minting
537
+ doors (this module and the MCP self-service tool) cannot disagree about
538
+ whether a secret is allowed into a response body."""
539
+ if sink_dir:
540
+ path = identity.stash_secret(sink_dir, token_id, secret)
541
+ return {"id": token_id, "delivered": "file", "path": path,
542
+ "exposed": False,
543
+ "note": "the secret was written to that file on the server and "
544
+ "deliberately NOT returned here — read it there"}
545
+ return {"token": secret, "id": token_id, "exposed": True,
546
+ "note": "store this now — it is not recoverable. This secret was "
547
+ "returned in a reply: if that reply reached an LLM, treat "
548
+ "the token as exposed and rotate it once delivered. Set "
549
+ "MEMGRES_TOKEN_SINK to stop returning secrets at all."}
550
+
551
+
552
+ def create_enrollment(conn, p: Principal, *, user_id: str,
553
+ namespace_id: Optional[str] = None,
554
+ permission: str = "write", label: str = "",
555
+ expires_minutes: Optional[int] = None) -> dict:
556
+ """Mint a one-time key that lets `user_id` bind a token THEY generate.
557
+
558
+ Provisioning-tier, and gated exactly like `issue_token`, because it grants
559
+ the same thing by another route: whoever redeems it ends up holding a
560
+ credential for that account. What it does not do is put a secret anywhere —
561
+ the key is worthless after one use and after `expires_minutes`, and the
562
+ token itself is created on the far side and never travels.
563
+ """
564
+ require_manage_users(p)
565
+ _require_target_is_plain_user(conn, p, user_id, "issuing an enrollment key")
566
+ kw = {} if expires_minutes is None else {"expires_minutes": expires_minutes}
567
+ out = identity.create_enrollment(
568
+ conn, user_id, namespace_id=namespace_id, permission=permission,
569
+ label=label, created_by=p.user_id, **kw)
570
+ out["note"] = ("give this key to its owner over any channel you would use "
571
+ "for a meeting link — it is single-use and short-lived. They "
572
+ "generate their own token, put it in their client's config, "
573
+ "and call memory_enroll with this key.")
574
+ warning = _unreachable_warning(conn, user_id, namespace_id)
575
+ if warning:
576
+ out["warning"] = warning
577
+ return out
578
+
579
+
580
+ def list_enrollments(conn, p: Principal, *,
581
+ user_id: Optional[str] = None) -> List[dict]:
582
+ """Enrollment keys and what became of them — metadata only, never a key."""
583
+ require_manage_users(p)
584
+ if user_id is not None:
585
+ _require_target_is_plain_user(conn, p, user_id, "listing enrollment keys")
586
+ return identity.list_enrollments(conn, user_id=user_id)
587
+
588
+
589
+ def revoke_enrollment(conn, p: Principal, *, enrollment_id: str) -> bool:
590
+ """Kill an unredeemed key. False if it was already spent, revoked or absent."""
591
+ require_manage_users(p)
592
+ _require_target_is_plain_user(conn, p,
593
+ identity.enrollment_owner(conn, enrollment_id),
594
+ "revoking an enrollment key")
595
+ return identity.revoke_enrollment(conn, enrollment_id)
428
596
 
429
597
 
430
598
  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),