footprinter-cli 1.0.0rc1__py3-none-any.whl

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 (138) hide show
  1. footprinter/__init__.py +8 -0
  2. footprinter/access.py +431 -0
  3. footprinter/api/__init__.py +1 -0
  4. footprinter/api/db.py +61 -0
  5. footprinter/api/entities.py +250 -0
  6. footprinter/api/search.py +47 -0
  7. footprinter/api/semantic.py +33 -0
  8. footprinter/api/server.py +66 -0
  9. footprinter/api/status.py +15 -0
  10. footprinter/bundled/__init__.py +0 -0
  11. footprinter/bundled/config.example.yaml +161 -0
  12. footprinter/bundled/patterns/context_patterns.yaml +18 -0
  13. footprinter/bundled/patterns/extensions.yaml +283 -0
  14. footprinter/bundled/patterns/filename_patterns.yaml +61 -0
  15. footprinter/bundled/patterns/mime_mappings.yaml +68 -0
  16. footprinter/bundled/patterns/salesforce_rules.yaml +84 -0
  17. footprinter/bundled/patterns/security_patterns.yaml +27 -0
  18. footprinter/bundled/samples/hidden-client-file-sample.txt +2 -0
  19. footprinter/bundled/samples/opaque-project-file-sample.txt +2 -0
  20. footprinter/bundled/samples/visible-file-sample.txt +2 -0
  21. footprinter/cli/__init__.py +135 -0
  22. footprinter/cli/__main__.py +6 -0
  23. footprinter/cli/_common.py +327 -0
  24. footprinter/cli/_policy_helpers.py +646 -0
  25. footprinter/cli/_prompt.py +220 -0
  26. footprinter/cli/_sample_seed.py +204 -0
  27. footprinter/cli/api_cmd.py +32 -0
  28. footprinter/cli/connect.py +591 -0
  29. footprinter/cli/data.py +879 -0
  30. footprinter/cli/delete.py +128 -0
  31. footprinter/cli/ingest.py +543 -0
  32. footprinter/cli/mcp_cmd.py +750 -0
  33. footprinter/cli/mcp_setup.py +306 -0
  34. footprinter/cli/search.py +393 -0
  35. footprinter/cli/search_cmd.py +69 -0
  36. footprinter/cli/setup.py +2001 -0
  37. footprinter/cli/status.py +747 -0
  38. footprinter/cli/status_cmd.py +104 -0
  39. footprinter/cli/upsert.py +794 -0
  40. footprinter/cli/vectorize_cmd.py +215 -0
  41. footprinter/cli/view.py +322 -0
  42. footprinter/connectors/__init__.py +171 -0
  43. footprinter/connectors/config_utils.py +141 -0
  44. footprinter/db/__init__.py +37 -0
  45. footprinter/db/browser.py +198 -0
  46. footprinter/db/chats.py +602 -0
  47. footprinter/db/clients.py +307 -0
  48. footprinter/db/emails.py +279 -0
  49. footprinter/db/files.py +724 -0
  50. footprinter/db/folders.py +659 -0
  51. footprinter/db/messages.py +192 -0
  52. footprinter/db/policies.py +151 -0
  53. footprinter/db/projects.py +673 -0
  54. footprinter/db/search.py +573 -0
  55. footprinter/db/sql_utils.py +168 -0
  56. footprinter/db/status.py +320 -0
  57. footprinter/db/uploads.py +70 -0
  58. footprinter/ingest/__init__.py +0 -0
  59. footprinter/ingest/adapters/__init__.py +33 -0
  60. footprinter/ingest/adapters/browser.py +54 -0
  61. footprinter/ingest/adapters/chat.py +57 -0
  62. footprinter/ingest/adapters/ingest.py +146 -0
  63. footprinter/ingest/adapters/local_files.py +68 -0
  64. footprinter/ingest/adapters/local_folders.py +52 -0
  65. footprinter/ingest/adapters/protocol.py +174 -0
  66. footprinter/ingest/browser_indexer.py +216 -0
  67. footprinter/ingest/chat_dedup.py +156 -0
  68. footprinter/ingest/chat_indexer.py +487 -0
  69. footprinter/ingest/chat_parsers/__init__.py +8 -0
  70. footprinter/ingest/chat_parsers/chatgpt_parser.py +229 -0
  71. footprinter/ingest/chat_parsers/claude_parser.py +161 -0
  72. footprinter/ingest/cli.py +827 -0
  73. footprinter/ingest/content_extractors.py +117 -0
  74. footprinter/ingest/database.py +36 -0
  75. footprinter/ingest/db/__init__.py +1 -0
  76. footprinter/ingest/db/connector_schema.py +47 -0
  77. footprinter/ingest/db/migration.py +315 -0
  78. footprinter/ingest/db/schema.py +1043 -0
  79. footprinter/ingest/db/security.py +6 -0
  80. footprinter/ingest/file_indexer.py +223 -0
  81. footprinter/ingest/file_scanner.py +277 -0
  82. footprinter/ingest/folder_indexer.py +226 -0
  83. footprinter/ingest/full_content_extractor.py +321 -0
  84. footprinter/ingest/orchestrator.py +112 -0
  85. footprinter/ingest/pipe_runner.py +200 -0
  86. footprinter/ingest/processing.py +165 -0
  87. footprinter/ingest/registry.py +186 -0
  88. footprinter/ingest/run_record.py +91 -0
  89. footprinter/ingest/status.py +346 -0
  90. footprinter/mcp/__init__.py +0 -0
  91. footprinter/mcp/__main__.py +5 -0
  92. footprinter/mcp/db.py +67 -0
  93. footprinter/mcp/errors.py +105 -0
  94. footprinter/mcp/extraction.py +226 -0
  95. footprinter/mcp/server.py +39 -0
  96. footprinter/mcp/tools/__init__.py +0 -0
  97. footprinter/mcp/tools/navigation.py +70 -0
  98. footprinter/mcp/tools/read.py +75 -0
  99. footprinter/mcp/tools/search.py +158 -0
  100. footprinter/mcp/tools/semantic.py +79 -0
  101. footprinter/mcp/tools/status.py +19 -0
  102. footprinter/paths.py +117 -0
  103. footprinter/permissions.py +1152 -0
  104. footprinter/semantic/__init__.py +13 -0
  105. footprinter/semantic/chunking.py +52 -0
  106. footprinter/semantic/embeddings.py +23 -0
  107. footprinter/semantic/hybrid_search.py +273 -0
  108. footprinter/semantic/vector_store.py +471 -0
  109. footprinter/services/__init__.py +49 -0
  110. footprinter/services/access_service.py +342 -0
  111. footprinter/services/chat_service.py +85 -0
  112. footprinter/services/client_service.py +267 -0
  113. footprinter/services/content_service.py +181 -0
  114. footprinter/services/email_service.py +89 -0
  115. footprinter/services/file_service.py +83 -0
  116. footprinter/services/folder_service.py +122 -0
  117. footprinter/services/includes.py +19 -0
  118. footprinter/services/ingest_service.py +231 -0
  119. footprinter/services/project_service.py +262 -0
  120. footprinter/services/roles.py +25 -0
  121. footprinter/services/search_service.py +177 -0
  122. footprinter/services/semantic_service.py +360 -0
  123. footprinter/services/status_service.py +18 -0
  124. footprinter/services/visit_service.py +65 -0
  125. footprinter/source_registry.py +194 -0
  126. footprinter/utils/__init__.py +7 -0
  127. footprinter/utils/hash_utils.py +59 -0
  128. footprinter/utils/logging_config.py +68 -0
  129. footprinter/utils/mime.py +30 -0
  130. footprinter/utils/text.py +6 -0
  131. footprinter/utils/time.py +11 -0
  132. footprinter/visibility.py +1264 -0
  133. footprinter_cli-1.0.0rc1.dist-info/LICENSE +21 -0
  134. footprinter_cli-1.0.0rc1.dist-info/METADATA +223 -0
  135. footprinter_cli-1.0.0rc1.dist-info/RECORD +138 -0
  136. footprinter_cli-1.0.0rc1.dist-info/WHEEL +5 -0
  137. footprinter_cli-1.0.0rc1.dist-info/entry_points.txt +2 -0
  138. footprinter_cli-1.0.0rc1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,342 @@
1
+ """access_service — access gating, visibility filtering, and permission resolution.
2
+
3
+ Combines the former ``read_service`` (3-stage gating) and ``visibility``
4
+ (list filtering, inherit resolution, opaque field sets) into one module.
5
+
6
+ Gating stages (for non-ADMIN roles):
7
+ 1. Existence — item must exist in DB
8
+ 2. Visibility — ``mcp_view`` must not be hidden/opaque
9
+ 3. Permission — ``mcp_read`` must not be deny
10
+
11
+ Visibility values: 'hidden' -> exclude, 'opaque' -> minimal fields,
12
+ 'visible' -> full. 'inherit' -> resolves to the global policy at query
13
+ time (loaded by ``load_globals``). Missing (None) -> treated as 'opaque'
14
+ (fail-closed).
15
+ """
16
+
17
+ import logging
18
+ import sqlite3
19
+ from typing import Any, Dict, List, Optional, Tuple
20
+
21
+ from footprinter.db.chats import get_chat_detail
22
+ from footprinter.db.emails import get_email
23
+ from footprinter.db.files import get_file
24
+ from footprinter.services.roles import Role
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ __all__ = [
29
+ # Gating
30
+ "gate_access",
31
+ "VALID_TYPES",
32
+ # Visibility resolution
33
+ "load_globals",
34
+ "resolve_inherit_visibility",
35
+ "resolve_inherit_permission",
36
+ "has_global_permission",
37
+ "is_global_policy_loaded",
38
+ # List filtering
39
+ "filter_result",
40
+ "filter_results_list",
41
+ "strip_content_for_denied",
42
+ "get_opaque_metadata",
43
+ # Opaque field sets
44
+ "OPAQUE_FILE_FIELDS",
45
+ "OPAQUE_EMAIL_FIELDS",
46
+ "OPAQUE_CHAT_FIELDS",
47
+ "OPAQUE_FOLDER_FIELDS",
48
+ "OPAQUE_BROWSER_FIELDS",
49
+ "OPAQUE_PROJECT_FIELDS",
50
+ "OPAQUE_CLIENT_FIELDS",
51
+ "_read_visibility",
52
+ "_filter_to_opaque",
53
+ "_CONTENT_FIELDS",
54
+ ]
55
+
56
+ VALID_TYPES = frozenset({"file", "email", "chat"})
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Global policy cache — refreshed per-request by load_globals()
60
+ # ---------------------------------------------------------------------------
61
+
62
+ _global_visibility: Optional[str] = None
63
+ _global_permission: Optional[str] = None
64
+
65
+
66
+ def load_globals(conn: sqlite3.Connection) -> None:
67
+ """Read global visibility and permission policies and cache them.
68
+
69
+ Called once per MCP request (from ``get_db()``). Two PK lookups.
70
+ """
71
+ global _global_visibility, _global_permission
72
+
73
+ row = conn.execute("SELECT setting FROM visibility_policies WHERE scope = 'global'").fetchone()
74
+ _global_visibility = row["setting"] if row else None
75
+
76
+ row = conn.execute("SELECT setting FROM permission_policies WHERE scope = 'global'").fetchone()
77
+ _global_permission = row["setting"] if row else None
78
+
79
+
80
+ def has_global_permission() -> bool:
81
+ """Return whether the cached global permission is 'allow'.
82
+
83
+ Public replacement for direct ``_global_permission`` access.
84
+ """
85
+ return _global_permission == "allow"
86
+
87
+
88
+ def is_global_policy_loaded() -> bool:
89
+ """Return whether a global permission policy has been loaded.
90
+
91
+ Unlike ``has_global_permission()`` which checks if the policy is
92
+ specifically ``'allow'``, this checks whether *any* policy exists.
93
+ """
94
+ return _global_permission is not None
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Inherit resolution
99
+ # ---------------------------------------------------------------------------
100
+
101
+
102
+ def resolve_inherit_visibility(value: Optional[str]) -> str:
103
+ """Resolve a visibility value, mapping ``inherit`` to the global policy.
104
+
105
+ - ``None`` -> ``'opaque'`` (fail-closed: truly missing data)
106
+ - ``'inherit'`` -> cached global visibility, or ``'opaque'`` baseline
107
+ - Explicit values (``'hidden'``, ``'opaque'``, ``'visible'``) pass through
108
+ """
109
+ if value is None:
110
+ return "opaque"
111
+ if value == "inherit":
112
+ return _global_visibility or "opaque"
113
+ return value
114
+
115
+
116
+ def resolve_inherit_permission(value: Optional[str]) -> str:
117
+ """Resolve a permission value, mapping ``inherit`` to the global policy.
118
+
119
+ - ``None`` -> ``'deny'`` (fail-closed: truly missing data)
120
+ - ``'inherit'`` -> cached global permission, or ``'allow'`` baseline
121
+ (``BASELINE_PERMISSION = True`` in permissions.py)
122
+ - Explicit values (``'allow'``, ``'deny'``) pass through
123
+ """
124
+ if value is None:
125
+ return "deny"
126
+ if value == "inherit":
127
+ return _global_permission or "allow"
128
+ return value
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Opaque field sets
133
+ # ---------------------------------------------------------------------------
134
+
135
+ OPAQUE_FILE_FIELDS = {"id", "content_type", "source", "project_id"}
136
+ OPAQUE_EMAIL_FIELDS = {"id", "account", "project_id", "client_id"}
137
+ OPAQUE_CHAT_FIELDS = {"id", "account", "project_id", "client_id"}
138
+ OPAQUE_FOLDER_FIELDS = {"id", "direct_files", "direct_file_count", "source", "project_id"}
139
+ OPAQUE_BROWSER_FIELDS = {"id", "browser", "project_id"}
140
+ OPAQUE_PROJECT_FIELDS = {"id", "type", "project_type", "status", "client_id"}
141
+ OPAQUE_CLIENT_FIELDS = {"id", "client_type", "status"}
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # List filtering
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ def _read_visibility(result: Dict[str, Any]) -> str:
150
+ """Read mcp_view from a result dict, resolving ``inherit`` via global policy."""
151
+ return resolve_inherit_visibility(result.get("mcp_view"))
152
+
153
+
154
+ def filter_result(item_type: str, full_result: Dict[str, Any]) -> Optional[Dict[str, Any]]:
155
+ """Filter a single result dict based on its ``mcp_view`` value.
156
+
157
+ Returns None if hidden, minimal dict if opaque, full dict if visible.
158
+ """
159
+ visibility = _read_visibility(full_result)
160
+
161
+ if visibility == "hidden":
162
+ return None
163
+
164
+ if visibility == "opaque":
165
+ return _filter_to_opaque(item_type, full_result)
166
+
167
+ return full_result # visible
168
+
169
+
170
+ def filter_results_list(
171
+ item_type: str, results: List[Dict[str, Any]], id_key: str = "id"
172
+ ) -> Tuple[List[Dict[str, Any]], int]:
173
+ """Filter a list of results, returning filtered list and suppressed count.
174
+
175
+ Reads ``mcp_view`` from each result dict instead of querying the database.
176
+ """
177
+ filtered = []
178
+ suppressed = 0
179
+
180
+ for result in results:
181
+ visibility = _read_visibility(result)
182
+
183
+ if visibility == "hidden":
184
+ suppressed += 1
185
+ continue
186
+
187
+ if visibility == "opaque":
188
+ filtered.append(_filter_to_opaque(item_type, result))
189
+ else:
190
+ filtered.append(result)
191
+
192
+ return filtered, suppressed
193
+
194
+
195
+ def _filter_to_opaque(item_type: str, result: Dict[str, Any]) -> Dict[str, Any]:
196
+ """Filter a result dict to only include opaque-allowed fields."""
197
+ if item_type == "file":
198
+ allowed = OPAQUE_FILE_FIELDS
199
+ elif item_type == "email":
200
+ allowed = OPAQUE_EMAIL_FIELDS
201
+ elif item_type == "chat":
202
+ allowed = OPAQUE_CHAT_FIELDS
203
+ elif item_type == "folder":
204
+ allowed = OPAQUE_FOLDER_FIELDS
205
+ elif item_type == "visit":
206
+ allowed = OPAQUE_BROWSER_FIELDS
207
+ elif item_type == "project":
208
+ allowed = OPAQUE_PROJECT_FIELDS
209
+ elif item_type == "client":
210
+ allowed = OPAQUE_CLIENT_FIELDS
211
+ else:
212
+ allowed = {"id"}
213
+
214
+ return {k: v for k, v in result.items() if k in allowed}
215
+
216
+
217
+ # Content fields that listing tools must strip when mcp_read != 'allow'
218
+ _CONTENT_FIELDS: Dict[str, List[str]] = {
219
+ "chat": ["snippet", "summary"],
220
+ "email": ["snippet"],
221
+ "file": ["snippet"],
222
+ }
223
+
224
+
225
+ def strip_content_for_denied(item_type: str, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
226
+ """Strip content fields from results where resolved permission is not 'allow'.
227
+
228
+ Uses ``resolve_inherit_permission`` so that ``inherit`` resolves to the
229
+ global policy (or baseline ``'allow'`` when no global policy is loaded).
230
+ ``NULL`` / missing values fail closed to ``'deny'``. Items are NOT
231
+ removed — only content keys are deleted, preserving the "you matched
232
+ something" signal.
233
+ """
234
+ fields = _CONTENT_FIELDS.get(item_type, [])
235
+ if not fields:
236
+ return results
237
+
238
+ for result in results:
239
+ if resolve_inherit_permission(result.get("mcp_read")) != "allow":
240
+ for field in fields:
241
+ result.pop(field, None)
242
+ return results
243
+
244
+
245
+ def get_opaque_metadata(conn: sqlite3.Connection, item_type: str, item_id: int) -> Dict[str, Any]:
246
+ """Get opaque metadata for an item, filtered to opaque-allowed fields.
247
+
248
+ Used by footprinter_read when returning visibility-restricted errors.
249
+ Delegates to db/ layer for the fetch, then filters to opaque fields.
250
+ """
251
+ if item_type == "file":
252
+ row = get_file(conn, item_id)
253
+ elif item_type == "email":
254
+ row = get_email(conn, item_id)
255
+ elif item_type == "chat":
256
+ row = get_chat_detail(conn, item_id)
257
+ else:
258
+ return {}
259
+
260
+ if not row:
261
+ return {}
262
+ return _filter_to_opaque(item_type, row)
263
+
264
+
265
+ # ---------------------------------------------------------------------------
266
+ # 3-stage access gating
267
+ # ---------------------------------------------------------------------------
268
+
269
+
270
+ def gate_access(
271
+ conn: sqlite3.Connection,
272
+ item_type: str,
273
+ item_id: int,
274
+ *,
275
+ role: Role = Role.ADMIN,
276
+ ) -> dict:
277
+ """3-stage access gating + content for a single item.
278
+
279
+ Returns dict with ``status`` key:
280
+
281
+ - ``ok`` — access granted; includes ``metadata`` (+ ``content`` for email/chat)
282
+ - ``hidden`` — item hidden from this role
283
+ - ``opaque`` — minimal ``metadata`` only
284
+ - ``denied`` — permission denied
285
+ - ``not_found`` — item doesn't exist
286
+ - ``invalid_type`` — unrecognised item_type
287
+ """
288
+ if item_type not in VALID_TYPES:
289
+ return {"status": "invalid_type"}
290
+
291
+ # Stage 1: Existence — fetch full detail via db/ layer
292
+ if item_type == "file":
293
+ metadata = get_file(conn, item_id)
294
+ elif item_type == "email":
295
+ metadata = get_email(conn, item_id)
296
+ else:
297
+ metadata = get_chat_detail(conn, item_id)
298
+
299
+ if not metadata:
300
+ return {"status": "not_found"}
301
+
302
+ # Stage 2: Visibility (ADMIN bypasses)
303
+ if not role.sees_all:
304
+ visibility = resolve_inherit_visibility(metadata.get("mcp_view"))
305
+ if visibility == "hidden":
306
+ return {"status": "hidden"}
307
+ if visibility == "opaque":
308
+ return {
309
+ "status": "opaque",
310
+ "metadata": _filter_to_opaque(item_type, metadata),
311
+ }
312
+
313
+ # Stage 3: Permission (ADMIN bypasses)
314
+ if not role.sees_all:
315
+ if resolve_inherit_permission(metadata.get("mcp_read")) == "deny":
316
+ return {
317
+ "status": "denied",
318
+ "metadata": _filter_to_opaque(item_type, metadata),
319
+ }
320
+
321
+ # Stage 4: Return content — reuse metadata already fetched
322
+ if item_type == "file":
323
+ return {"status": "ok", "metadata": metadata}
324
+ elif item_type == "email":
325
+ content = metadata.pop("body_preview", "") or ""
326
+ return {"status": "ok", "metadata": metadata, "content": content}
327
+ else:
328
+ messages = metadata.pop("messages", [])
329
+ summary = metadata.pop("summary", None) or ""
330
+ content_parts = []
331
+ if summary:
332
+ content_parts.append(f"Summary: {summary}")
333
+ for msg in messages:
334
+ role_name = msg.get("role") or "unknown"
335
+ text = msg.get("content") or ""
336
+ timestamp = msg.get("created_at") or ""
337
+ if timestamp:
338
+ content_parts.append(f"[{timestamp}] {role_name}: {text}")
339
+ else:
340
+ content_parts.append(f"{role_name}: {text}")
341
+ content = "\n\n".join(content_parts)
342
+ return {"status": "ok", "metadata": metadata, "content": content}
@@ -0,0 +1,85 @@
1
+ """Chat read service — get/list with role-based visibility filtering."""
2
+
3
+ import sqlite3
4
+ from typing import Optional
5
+
6
+ from footprinter.db import chats as db
7
+ from footprinter.services.access_service import (
8
+ filter_result,
9
+ filter_results_list,
10
+ strip_content_for_denied,
11
+ )
12
+ from footprinter.services.roles import Role
13
+
14
+
15
+ def get(conn: sqlite3.Connection, chat_id: int, *, role: Role = Role.ADMIN) -> dict | None:
16
+ """Fetch a single chat with messages by ID, filtered by role."""
17
+ result = db.get_chat_detail(conn, chat_id)
18
+ if result is None:
19
+ return None
20
+ if role.sees_all:
21
+ return result
22
+ return filter_result("chat", result)
23
+
24
+
25
+ def assign(
26
+ conn: sqlite3.Connection,
27
+ chat_id: int,
28
+ *,
29
+ role: Role = Role.ADMIN,
30
+ project_id: int | None = None,
31
+ client_id: int | None = None,
32
+ ) -> dict | None:
33
+ """Assign a chat to a project and/or client.
34
+
35
+ Returns result dict on success, None if not found.
36
+ Raises PermissionError if role cannot write.
37
+ """
38
+ if not role.can_write:
39
+ raise PermissionError("Role does not permit write operations")
40
+ result = db.update_chat_relationships(
41
+ conn,
42
+ chat_id,
43
+ project_id=project_id,
44
+ client_id=client_id,
45
+ )
46
+ if result is None:
47
+ return None
48
+ resp: dict = {"id": chat_id}
49
+ if project_id is not None:
50
+ resp["project_id"] = project_id
51
+ if client_id is not None:
52
+ resp["client_id"] = client_id
53
+ return resp
54
+
55
+
56
+ def list_(
57
+ conn: sqlite3.Connection,
58
+ *,
59
+ role: Role = Role.ADMIN,
60
+ account: Optional[str] = None,
61
+ query: Optional[str] = None,
62
+ sort_by: str = "modified_at",
63
+ order: str = "desc",
64
+ status: Optional[str | list[str]] = None,
65
+ limit: int = 50,
66
+ page: int = 1,
67
+ ) -> dict:
68
+ """List chats with pagination, filtered by role."""
69
+ response = db.list_chats(
70
+ conn,
71
+ account=account,
72
+ query=query,
73
+ sort_by=sort_by,
74
+ order=order,
75
+ status=status,
76
+ limit=limit,
77
+ page=page,
78
+ )
79
+ if role.sees_all:
80
+ return response
81
+ filtered, suppressed = filter_results_list("chat", response["chats"])
82
+ filtered = strip_content_for_denied("chat", filtered)
83
+ response["chats"] = filtered
84
+ response["suppressed"] = suppressed
85
+ return response
@@ -0,0 +1,267 @@
1
+ """Client service — get/list with role-based visibility, upsert and soft delete."""
2
+
3
+ import sqlite3
4
+ from typing import Optional
5
+
6
+ from footprinter.db import clients as db
7
+ from footprinter.services.access_service import (
8
+ _read_visibility,
9
+ filter_result,
10
+ filter_results_list,
11
+ )
12
+ from footprinter.services.includes import validate_include
13
+ from footprinter.services.roles import Role
14
+
15
+ VALID_INCLUDES = frozenset({"projects", "aggregates"})
16
+
17
+
18
+ def _get_client_aggregates(client_name: str, conn: sqlite3.Connection, *, role: Role) -> dict:
19
+ """Compute per-project file counts for a client, respecting visibility.
20
+
21
+ Derives aggregates from the visibility-filtered project list rather
22
+ than raw SQL, so hidden/opaque projects are excluded for non-admin roles.
23
+ """
24
+ from footprinter.services import project_service
25
+
26
+ resp = project_service.list_(conn, role=role, client=client_name)
27
+ per_project = [
28
+ {
29
+ "project_id": p["id"],
30
+ "project_name": p["name"],
31
+ "file_count": p.get("file_count", 0),
32
+ }
33
+ for p in resp["projects"]
34
+ if "name" in p # Exclude opaque projects (minimal dicts lack "name")
35
+ ]
36
+ return {
37
+ "project_count": len(per_project),
38
+ "file_count": sum(p["file_count"] for p in per_project),
39
+ "per_project": per_project,
40
+ }
41
+
42
+
43
+ def get(
44
+ conn: sqlite3.Connection,
45
+ client_id: int,
46
+ *,
47
+ role: Role = Role.ADMIN,
48
+ include: list[str] | None = None,
49
+ ) -> dict | None:
50
+ """Fetch a single client by ID, filtered by role.
51
+
52
+ Pass ``include`` to attach nested data:
53
+ - ``"projects"`` — list of projects belonging to this client
54
+ - ``"aggregates"`` — file counts per project
55
+ """
56
+ includes = validate_include(include, VALID_INCLUDES)
57
+ result = db.get_client(conn, client_id)
58
+ if result is None:
59
+ return None
60
+
61
+ # Strip nested data that db layer embeds by default
62
+ result.pop("projects", None)
63
+ result.pop("file_count", None)
64
+
65
+ # Attach includes only when caller has full access to this entity
66
+ is_full = role.sees_all or _read_visibility(result) == "visible"
67
+ if is_full and includes:
68
+ if "projects" in includes:
69
+ from footprinter.services import project_service
70
+
71
+ resp = project_service.list_(conn, role=role, client=result["name"])
72
+ result["projects"] = resp["projects"]
73
+ if "aggregates" in includes:
74
+ result["aggregates"] = _get_client_aggregates(
75
+ result["name"],
76
+ conn,
77
+ role=role,
78
+ )
79
+
80
+ if role.sees_all:
81
+ return result
82
+ return filter_result("client", result)
83
+
84
+
85
+ def list_(
86
+ conn: sqlite3.Connection,
87
+ *,
88
+ role: Role = Role.ADMIN,
89
+ include: list[str] | None = None,
90
+ status: Optional[str | list[str]] = None,
91
+ limit: int = 50,
92
+ page: int = 1,
93
+ ) -> dict:
94
+ """List clients with pagination, filtered by role."""
95
+ includes = validate_include(include, VALID_INCLUDES)
96
+ response = db.list_clients(conn, status=status, limit=limit, page=page)
97
+
98
+ # Track which items are fully visible before filtering strips fields
99
+ visible_ids: set[int] = set()
100
+ if includes and not role.sees_all:
101
+ visible_ids = {c["id"] for c in response["clients"] if _read_visibility(c) == "visible"}
102
+
103
+ if not role.sees_all:
104
+ filtered, suppressed = filter_results_list("client", response["clients"])
105
+ response["clients"] = filtered
106
+ response["suppressed"] = suppressed
107
+
108
+ if includes:
109
+ for client in response["clients"]:
110
+ # Only attach to fully-visible items (admin sees all)
111
+ if not role.sees_all and client["id"] not in visible_ids:
112
+ continue
113
+ if "projects" in includes:
114
+ from footprinter.services import project_service
115
+
116
+ resp = project_service.list_(conn, role=role, client=client["name"])
117
+ client["projects"] = resp["projects"]
118
+ if "aggregates" in includes:
119
+ client["aggregates"] = _get_client_aggregates(
120
+ client["name"],
121
+ conn,
122
+ role=role,
123
+ )
124
+
125
+ return response
126
+
127
+
128
+ def resolve_by_name(
129
+ conn: sqlite3.Connection,
130
+ name: str,
131
+ *,
132
+ role: Role = Role.ADMIN,
133
+ ) -> dict | None:
134
+ """Resolve a client by fuzzy name match, with navigation data.
135
+
136
+ Returns full client dict with projects and aggregates for single match,
137
+ disambiguation dict for multiple ambiguous matches, or None.
138
+ """
139
+ rows = db.find_by_name_fuzzy(conn, name)
140
+ if not rows:
141
+ return None
142
+
143
+ # Filter hidden for VIEWER
144
+ if not role.sees_all:
145
+ rows = [r for r in rows if _read_visibility(r) != "hidden"]
146
+ if not rows:
147
+ return None
148
+
149
+ if len(rows) == 1:
150
+ return _build_client_navigation(conn, rows[0], role=role)
151
+
152
+ # Check exact match
153
+ exact = [r for r in rows if r["name"].lower() == name.lower()]
154
+ if len(exact) == 1:
155
+ return _build_client_navigation(conn, exact[0], role=role)
156
+
157
+ # Disambiguation
158
+ from footprinter.services.access_service import resolve_inherit_visibility
159
+
160
+ matches = []
161
+ for r in rows:
162
+ vis = resolve_inherit_visibility(r.get("mcp_view"))
163
+ if vis == "opaque":
164
+ matches.append({"id": r["id"], "visibility": "restricted"})
165
+ else:
166
+ matches.append({"id": r["id"], "name": r["name"]})
167
+ return {
168
+ "disambiguation": True,
169
+ "message": f"Multiple matches for '{name}'. Please be more specific.",
170
+ "matches": matches,
171
+ }
172
+
173
+
174
+ def _build_client_navigation(conn: sqlite3.Connection, row: dict, *, role: Role) -> dict:
175
+ """Build full client navigation dict from a client row."""
176
+ visibility = _read_visibility(row)
177
+ if not role.sees_all and visibility == "opaque":
178
+ return filter_result("client", row)
179
+
180
+ # Get projects for this client (hidden filtered)
181
+ from footprinter.services import project_service
182
+
183
+ proj_resp = project_service.list_(conn, role=role, client=row["name"])
184
+ projects = proj_resp["projects"]
185
+
186
+ result = {**row}
187
+ result["projects"] = projects
188
+
189
+ # Aggregate stats across all projects
190
+ project_ids = [p["id"] for p in projects if "id" in p]
191
+ nav = db.get_client_navigation(conn, row["id"], project_ids)
192
+ result.update(nav)
193
+
194
+ return result
195
+
196
+
197
+ def upsert(
198
+ conn: sqlite3.Connection,
199
+ *,
200
+ name: str,
201
+ client_type: str,
202
+ role: Role = Role.ADMIN,
203
+ path_pattern: Optional[str] = None,
204
+ status: Optional[str] = None,
205
+ status_reason: Optional[str] = None,
206
+ slug: Optional[str] = None,
207
+ ) -> dict:
208
+ """Insert or update a client by name.
209
+
210
+ Matches on ``name`` (UNIQUE constraint). Returns dict with ``id``,
211
+ ``action`` ("created"|"updated"), and ``slug`` on create.
212
+ Raises PermissionError if role cannot write, ValueError on bad input.
213
+ """
214
+ if not role.can_write:
215
+ raise PermissionError("Role does not permit write operations")
216
+
217
+ name = (name or "").strip()
218
+ if not name:
219
+ raise ValueError("Name cannot be empty")
220
+
221
+ existing_id = db.find_client_id_by_name(conn, name)
222
+
223
+ if existing_id is None:
224
+ result = db.create_client(
225
+ conn,
226
+ name=name,
227
+ client_type=client_type,
228
+ path_pattern=path_pattern,
229
+ )
230
+ new_id = result["id"]
231
+ # Apply optional fields that create_client doesn't accept
232
+ post_update: dict = {}
233
+ if status is not None:
234
+ post_update["status"] = status
235
+ if post_update:
236
+ db.update_client(conn, new_id, **post_update)
237
+ return {"id": new_id, "slug": result["slug"], "action": "created"}
238
+
239
+ update_fields: dict = {"client_type": client_type}
240
+ if path_pattern is not None:
241
+ update_fields["path_pattern"] = path_pattern
242
+ if status is not None:
243
+ update_fields["status"] = status
244
+ if status_reason is not None:
245
+ update_fields["status_reason"] = status_reason
246
+ db.update_client(conn, existing_id, **update_fields)
247
+ return {"id": existing_id, "action": "updated"}
248
+
249
+
250
+ def delete(
251
+ conn: sqlite3.Connection,
252
+ client_id: int,
253
+ *,
254
+ role: Role = Role.ADMIN,
255
+ ) -> dict | None:
256
+ """Soft-delete a client by setting status to 'removed'.
257
+
258
+ Returns ``{"id", "status"}`` on success, ``None`` if not found.
259
+ Raises PermissionError if role cannot write.
260
+ """
261
+ if not role.can_write:
262
+ raise PermissionError("Role does not permit write operations")
263
+
264
+ result = db.update_client(conn, client_id, status="removed", status_reason="cli:delete")
265
+ if result is None:
266
+ return None
267
+ return {"id": client_id, "status": "removed"}