codepulse-engine 0.1.0__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 (56) hide show
  1. codepulse/__init__.py +5 -0
  2. codepulse/api/app.py +1336 -0
  3. codepulse/api/errors.py +172 -0
  4. codepulse/api/security.py +189 -0
  5. codepulse/auth.py +406 -0
  6. codepulse/cli.py +1052 -0
  7. codepulse/config.py +475 -0
  8. codepulse/data_location.py +78 -0
  9. codepulse/embeddings/chunker.py +138 -0
  10. codepulse/embeddings/embedder.py +120 -0
  11. codepulse/git_diff.py +201 -0
  12. codepulse/indexer/engine.py +2655 -0
  13. codepulse/indexer/ignore.py +120 -0
  14. codepulse/ingestion_safety.py +169 -0
  15. codepulse/jobs.py +257 -0
  16. codepulse/mcp_server.py +269 -0
  17. codepulse/models.py +140 -0
  18. codepulse/observability.py +130 -0
  19. codepulse/parser/__init__.py +8 -0
  20. codepulse/parser/base.py +58 -0
  21. codepulse/parser/factory.py +37 -0
  22. codepulse/parser/graph.py +502 -0
  23. codepulse/parser/javascript.py +428 -0
  24. codepulse/parser/module_graph.py +232 -0
  25. codepulse/parser/python.py +331 -0
  26. codepulse/rate_limit.py +142 -0
  27. codepulse/repository_identity.py +83 -0
  28. codepulse/search/bm25.py +342 -0
  29. codepulse/search/ranker.py +382 -0
  30. codepulse/search/tokenizer.py +80 -0
  31. codepulse/search/vector.py +357 -0
  32. codepulse/storage/db.py +526 -0
  33. codepulse/storage/migrations.py +347 -0
  34. codepulse/storage/schema_migrations/README +1 -0
  35. codepulse/storage/schema_migrations/env.py +70 -0
  36. codepulse/storage/schema_migrations/script.py.mako +28 -0
  37. codepulse/storage/schema_migrations/versions/135a7b064ea7_add_base_classes_and_relationship_type.py +82 -0
  38. codepulse/storage/schema_migrations/versions/20fc2d0607e2_add_file_content_hash_and_natural_key_.py +106 -0
  39. codepulse/storage/schema_migrations/versions/21a7cb7f1a85_initial_schema.py +105 -0
  40. codepulse/storage/schema_migrations/versions/2cd38eb12a7e_add_call_edge_repo_id_isolation_fix.py +112 -0
  41. codepulse/storage/schema_migrations/versions/3ba396e9c78f_add_symbol_natural_key_uniqueness.py +80 -0
  42. codepulse/storage/schema_migrations/versions/49572f834366_add_embedding_persistence_fields.py +102 -0
  43. codepulse/storage/schema_migrations/versions/7c3e5a91d2f4_add_module_edges.py +61 -0
  44. codepulse/storage/schema_migrations/versions/a41f0d2c6b8e_canonicalize_repository_urls.py +94 -0
  45. codepulse/storage/schema_migrations/versions/b0bdd63949f4_add_api_key_model.py +71 -0
  46. codepulse/storage/schema_migrations/versions/b9014c049e33_add_api_key_repository_role.py +65 -0
  47. codepulse/storage/schema_migrations/versions/c286d277ea21_add_repository_snapshot_model.py +62 -0
  48. codepulse/storage/schema_migrations/versions/de22df2fc3ce_add_index_job_model.py +59 -0
  49. codepulse/storage/schema_migrations/versions/f6e481353553_rename_dependency_to_call_edge_add_confidence.py +80 -0
  50. codepulse/symbol_identity.py +29 -0
  51. codepulse_engine-0.1.0.dist-info/METADATA +191 -0
  52. codepulse_engine-0.1.0.dist-info/RECORD +56 -0
  53. codepulse_engine-0.1.0.dist-info/WHEEL +5 -0
  54. codepulse_engine-0.1.0.dist-info/entry_points.txt +2 -0
  55. codepulse_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
  56. codepulse_engine-0.1.0.dist-info/top_level.txt +1 -0
codepulse/auth.py ADDED
@@ -0,0 +1,406 @@
1
+ """
2
+ Phase 1.1 — API key model + hashing + creation flow.
3
+
4
+ Scope, precisely (roadmap §37 item 1.1; see storage/db.py's APIKeyModel
5
+ docstring for the schema-level rationale):
6
+
7
+ - Generate a cryptographically random API key.
8
+ - Hash it for storage (never persist the raw key anywhere).
9
+ - A creation flow that returns the raw key to the caller exactly once.
10
+ - A verification PRIMITIVE (hash a candidate key, look up the matching
11
+ non-revoked row) -- proves the hashing/storage actually works and is
12
+ usable, but is NOT wired into any API endpoint, FastAPI dependency, or
13
+ request/response flow. That wiring -- "central authorization
14
+ dependency injected into every route" -- is explicitly Phase 1.2, not
15
+ implemented here.
16
+
17
+ Deliberately NOT in scope for Phase 1.1 (do not infer these from later
18
+ Phase 1 milestones): request-header parsing, HTTP 401/403 responses,
19
+ FastAPI `Depends()` wiring, rate limiting (roadmap §15's viewer/
20
+ indexer/admin roles were Phase 7 / B25 scope -- `create_api_key` below
21
+ now accepts a `role` per repository grant, see its own docstring), key
22
+ expiry, audit logging.
23
+
24
+ Backend-hardening addition (not a roadmap sprint item): a real,
25
+ mutating `revoke_api_key` now exists below, plus `APIKeySummary`/
26
+ `APIKeyGrant`/`get_api_key_repository_ids`/
27
+ `list_api_keys_for_repositories` -- the key-management primitives
28
+ `codepulse/api/app.py`'s new `/v1/admin/keys` endpoints are thin
29
+ wrappers over. `create_api_key` itself is unchanged by this addition.
30
+
31
+ This module lives outside `codepulse/indexer/` deliberately: per CLAUDE.md
32
+ §3, "authentication belongs at the API boundary; the intelligence core
33
+ must remain independent of HTTP/auth concerns" -- `IndexingEngine` and the
34
+ rest of the intelligence core are untouched by this milestone and do not
35
+ import from this module.
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import hashlib
40
+ import secrets
41
+ from dataclasses import dataclass
42
+ from datetime import datetime
43
+
44
+ from sqlalchemy.exc import IntegrityError
45
+
46
+ from codepulse.repository_identity import canonical_repository_url, find_repository
47
+ from codepulse.storage.db import APIKeyModel, APIKeyRepositoryModel, RepositoryModel, StorageManager
48
+
49
+ API_KEY_PREFIX = "cpk_"
50
+ _RAW_KEY_ENTROPY_BYTES = 32 # 256 bits, via secrets.token_urlsafe
51
+ _KEY_PREFIX_DISPLAY_LENGTH = len(API_KEY_PREFIX) + 8 # e.g. "cpk_a1b2c3d4"
52
+
53
+ # Phase 7.2 (roadmap §37 item 7.2; §15's literal minimal-viable model):
54
+ # "roles `viewer` (search/read), `indexer` (viewer + trigger indexing),
55
+ # `admin` (all + manage keys/repos)." Ordered ranking used by
56
+ # `AuthorizedKey.authorize_role` (codepulse/api/security.py) --
57
+ # defined here, once, so both modules share the exact same vocabulary
58
+ # and ordering rather than each hardcoding its own copy.
59
+ VALID_ROLES = ("viewer", "indexer", "admin")
60
+ ROLE_RANK = {role: rank for rank, role in enumerate(VALID_ROLES)}
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class CreatedAPIKey:
65
+ """
66
+ Result of `create_api_key`. `raw_key` is populated exactly once, here,
67
+ at creation time -- it is never persisted and cannot be recovered
68
+ later (the standard, expected lifecycle for API keys, matching how
69
+ GitHub/AWS/Stripe-style tokens work). Every other field mirrors a
70
+ column on the persisted `APIKeyModel` row.
71
+ """
72
+ id: int
73
+ name: str
74
+ key_prefix: str
75
+ created_at: datetime
76
+ raw_key: str
77
+
78
+
79
+ def generate_raw_api_key() -> str:
80
+ """Generates a new, cryptographically random API key string."""
81
+ return f"{API_KEY_PREFIX}{secrets.token_urlsafe(_RAW_KEY_ENTROPY_BYTES)}"
82
+
83
+
84
+ def hash_api_key(raw_key: str) -> str:
85
+ """
86
+ Hashes a raw API key for storage/lookup.
87
+
88
+ Uses the same SHA-256 hex-digest convention already established
89
+ throughout this project (see codepulse.models.compute_content_hash) --
90
+ a fast cryptographic hash, not a slow/salted password hash (bcrypt/
91
+ argon2), is the correct choice here: the input is already a
92
+ high-entropy, randomly generated secret (not a user-chosen low-entropy
93
+ password), so there is no offline-guessing risk a slow hash would
94
+ mitigate, and a fast hash keeps future lookup-by-hash authentication
95
+ (Phase 1.2) a simple indexed equality query rather than requiring a
96
+ per-row comparison.
97
+ """
98
+ return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
99
+
100
+
101
+ def create_api_key(
102
+ storage: StorageManager,
103
+ name: str,
104
+ repository_ids: list[int] | None = None,
105
+ role: str = "viewer",
106
+ ) -> CreatedAPIKey:
107
+ """
108
+ Creates a new API key: generates a random raw key, persists only its
109
+ hash (+ a short non-secret prefix) and optional repository scoping,
110
+ and returns the raw key to the caller exactly once.
111
+
112
+ `repository_ids`, if given, must all reference existing repositories
113
+ -- this is validated (raising ValueError otherwise) so a key can never
114
+ silently claim to be scoped to a repository that doesn't exist.
115
+
116
+ Phase 7.2 (roadmap §37 item 7.2): `role` is applied uniformly to
117
+ every one of `repository_ids`' grant rows created in this one call
118
+ (a caller wanting DIFFERENT roles per repository calls this more
119
+ than once -- §15's "enforced at the repository-permission level,
120
+ not globally" means role lives on the grant row, it does not mean
121
+ one call must support a mixed batch; no roadmap text asks for
122
+ that). Must be one of `VALID_ROLES` (`ValueError` otherwise).
123
+ Defaults to `"viewer"` -- the SAFE, least-privilege choice for
124
+ every NEWLY created grant, deliberately different from
125
+ `APIKeyRepositoryModel.role`'s own schema-level default (`"admin"`,
126
+ which exists only to honestly backfill grants that predate this
127
+ role concept -- see that column's own docstring).
128
+ """
129
+ if role not in VALID_ROLES:
130
+ raise ValueError(f"Invalid role {role!r}; must be one of {VALID_ROLES}")
131
+
132
+ session = storage.get_session()
133
+ try:
134
+ if repository_ids:
135
+ existing_ids = {
136
+ r.id
137
+ for r in session.query(RepositoryModel.id)
138
+ .filter(RepositoryModel.id.in_(repository_ids))
139
+ .all()
140
+ }
141
+ missing = set(repository_ids) - existing_ids
142
+ if missing:
143
+ raise ValueError(
144
+ f"Cannot scope API key to nonexistent repository id(s): {sorted(missing)}"
145
+ )
146
+
147
+ raw_key = generate_raw_api_key()
148
+ now = datetime.utcnow()
149
+
150
+ api_key = APIKeyModel(
151
+ name=name,
152
+ key_hash=hash_api_key(raw_key),
153
+ key_prefix=raw_key[:_KEY_PREFIX_DISPLAY_LENGTH],
154
+ created_at=now,
155
+ )
156
+ session.add(api_key)
157
+ session.flush() # assign api_key.id
158
+
159
+ for repo_id in repository_ids or []:
160
+ session.add(APIKeyRepositoryModel(api_key_id=api_key.id, repo_id=repo_id, role=role))
161
+
162
+ session.commit()
163
+
164
+ return CreatedAPIKey(
165
+ id=api_key.id,
166
+ name=api_key.name,
167
+ key_prefix=api_key.key_prefix,
168
+ created_at=api_key.created_at,
169
+ raw_key=raw_key,
170
+ )
171
+ except Exception:
172
+ session.rollback()
173
+ raise
174
+ finally:
175
+ session.close()
176
+
177
+
178
+ @dataclass(frozen=True)
179
+ class APIKeyGrant:
180
+ """One (repository, role) grant for an API key -- mirrors one row of
181
+ `APIKeyRepositoryModel`."""
182
+ repository_id: int
183
+ role: str
184
+
185
+
186
+ @dataclass(frozen=True)
187
+ class APIKeySummary:
188
+ """
189
+ A non-secret view of a persisted API key -- backend-hardening
190
+ addition (key-management REST endpoints), never includes the raw
191
+ key or its hash, only what `APIKeyModel` itself already exposes for
192
+ display purposes (see that model's own `key_prefix` rationale).
193
+ `grants` is the key's COMPLETE grant list, unfiltered -- a caller
194
+ that must not disclose a key's access to repositories it doesn't
195
+ itself administer (e.g. an admin-role-gated REST endpoint) is
196
+ responsible for filtering `.grants` down to its own visible set
197
+ before returning it to a client; the functions below make no
198
+ assumption about who is allowed to see what.
199
+ """
200
+ id: int
201
+ name: str
202
+ key_prefix: str
203
+ created_at: datetime
204
+ revoked_at: datetime | None
205
+ grants: list[APIKeyGrant]
206
+
207
+
208
+ def get_api_key_repository_ids(storage: StorageManager, key_id: int) -> list[int] | None:
209
+ """
210
+ Returns the repository ids `key_id` is granted to (possibly empty,
211
+ for an unscoped key), or `None` if no `APIKeyModel` with this id
212
+ exists at all -- the `None`/non-`None` distinction is what lets a
213
+ caller (an admin-role-gated REST endpoint) return a real 404
214
+ separately from an authorization decision.
215
+ """
216
+ session = storage.get_session()
217
+ try:
218
+ api_key = session.query(APIKeyModel).filter(APIKeyModel.id == key_id).first()
219
+ if api_key is None:
220
+ return None
221
+ return [
222
+ row.repo_id
223
+ for row in session.query(APIKeyRepositoryModel)
224
+ .filter(APIKeyRepositoryModel.api_key_id == key_id)
225
+ .all()
226
+ ]
227
+ finally:
228
+ session.close()
229
+
230
+
231
+ def list_api_keys_for_repositories(storage: StorageManager, repository_ids: set[int]) -> list[APIKeySummary]:
232
+ """
233
+ Returns every API key that holds AT LEAST ONE grant among
234
+ `repository_ids`, ordered by id. Each returned `APIKeySummary`
235
+ carries its FULL grant list (see that class's own docstring for why
236
+ filtering it down is the caller's job, not this function's).
237
+ """
238
+ if not repository_ids:
239
+ return []
240
+ session = storage.get_session()
241
+ try:
242
+ key_ids = {
243
+ row.api_key_id
244
+ for row in session.query(APIKeyRepositoryModel.api_key_id)
245
+ .filter(APIKeyRepositoryModel.repo_id.in_(repository_ids))
246
+ .all()
247
+ }
248
+ if not key_ids:
249
+ return []
250
+ keys = (
251
+ session.query(APIKeyModel)
252
+ .filter(APIKeyModel.id.in_(key_ids))
253
+ .order_by(APIKeyModel.id)
254
+ .all()
255
+ )
256
+ grants_by_key: dict[int, list[APIKeyGrant]] = {k.id: [] for k in keys}
257
+ for row in (
258
+ session.query(APIKeyRepositoryModel)
259
+ .filter(APIKeyRepositoryModel.api_key_id.in_(key_ids))
260
+ .all()
261
+ ):
262
+ grants_by_key[row.api_key_id].append(APIKeyGrant(repository_id=row.repo_id, role=row.role))
263
+ return [
264
+ APIKeySummary(
265
+ id=k.id,
266
+ name=k.name,
267
+ key_prefix=k.key_prefix,
268
+ created_at=k.created_at,
269
+ revoked_at=k.revoked_at,
270
+ grants=grants_by_key[k.id],
271
+ )
272
+ for k in keys
273
+ ]
274
+ finally:
275
+ session.close()
276
+
277
+
278
+ def register_repository(storage: StorageManager, repo_name: str, repo_url: str) -> tuple[int, bool]:
279
+ """
280
+ Makes sure a repository row exists for `repo_url` and returns `(repository_id, created)`, granting NOTHING to
281
+ anyone. It is the same empty row indexing would create (no files, no symbols), found under any spelling of the
282
+ same directory, so a later index run reuses it. Used by `codepulse key create` so a key can be scoped to a folder
283
+ that has not been indexed yet: the first-run flow is "make a key for this folder, then index it with that key".
284
+ Contrast `create_repository_owned_by`, which also writes the creating key's admin grant.
285
+ """
286
+ session = storage.get_session()
287
+ try:
288
+ existing = find_repository(session, repo_url)
289
+ if existing is not None:
290
+ return existing.id, False
291
+ repo = RepositoryModel(name=repo_name, url=canonical_repository_url(repo_url), file_count=0, symbol_count=0)
292
+ session.add(repo)
293
+ session.commit()
294
+ return repo.id, True
295
+ except IntegrityError:
296
+ # Lost a race to create the same url: use the row the other call made.
297
+ session.rollback()
298
+ existing = find_repository(session, repo_url)
299
+ if existing is None:
300
+ raise
301
+ return existing.id, False
302
+ finally:
303
+ session.close()
304
+
305
+
306
+ def create_repository_owned_by(
307
+ storage: StorageManager, repo_name: str, repo_url: str, api_key_id: int
308
+ ) -> tuple[int, bool]:
309
+ """
310
+ Creates the repository row for `repo_url` AND the creating key's `admin`
311
+ grant on it in ONE transaction, and returns `(repository_id, created)`.
312
+
313
+ Owner semantics (CODEPULSE_FUTURE_CHANGES.md item 18, user-approved): a
314
+ key that creates a new repository must be able to follow its own indexing
315
+ job and see the result, which needs a grant on it. Only the call that
316
+ actually CREATES the row gets the grant: if the repository already exists
317
+ (including when another request created it a moment earlier and this one
318
+ lost the race on the unique `url`), `created` is False and NO grant is
319
+ written, so a second key never inherits admin on someone else's
320
+ repository. Callers decide who may create (the `indexer`-somewhere rule
321
+ lives at the endpoint); this function only makes creation and ownership
322
+ atomic.
323
+ """
324
+ session = storage.get_session()
325
+ try:
326
+ existing = find_repository(session, repo_url)
327
+ if existing is not None:
328
+ return existing.id, False
329
+ repo = RepositoryModel(name=repo_name, url=canonical_repository_url(repo_url), file_count=0, symbol_count=0)
330
+ session.add(repo)
331
+ session.flush()
332
+ session.add(APIKeyRepositoryModel(api_key_id=api_key_id, repo_id=repo.id, role="admin"))
333
+ session.commit()
334
+ return repo.id, True
335
+ except IntegrityError:
336
+ # Lost a race to create the same url: the other request owns it.
337
+ session.rollback()
338
+ existing = find_repository(session, repo_url)
339
+ if existing is None:
340
+ raise
341
+ return existing.id, False
342
+ finally:
343
+ session.close()
344
+
345
+
346
+ def revoke_api_key(storage: StorageManager, key_id: int) -> APIKeySummary | None:
347
+ """
348
+ Sets `revoked_at` on the given key if it exists and is not already
349
+ revoked -- the mutating revoke() flow this module's own docstring
350
+ has disclosed as missing since Phase 1.1 (only the schema column
351
+ existed; `verify_api_key` already respects it defensively, so a
352
+ revoked key stops authenticating immediately once this runs).
353
+ Idempotent: calling this again on an already-revoked key leaves its
354
+ original `revoked_at` untouched rather than overwriting it with a
355
+ later timestamp. Returns the key's up-to-date `APIKeySummary`
356
+ (full, unfiltered grant list -- same caller responsibility as
357
+ `list_api_keys_for_repositories`), or `None` if no key with this id
358
+ exists.
359
+ """
360
+ session = storage.get_session()
361
+ try:
362
+ api_key = session.query(APIKeyModel).filter(APIKeyModel.id == key_id).first()
363
+ if api_key is None:
364
+ return None
365
+ if api_key.revoked_at is None:
366
+ api_key.revoked_at = datetime.utcnow()
367
+ session.commit()
368
+ session.refresh(api_key)
369
+ grants = [
370
+ APIKeyGrant(repository_id=row.repo_id, role=row.role)
371
+ for row in session.query(APIKeyRepositoryModel)
372
+ .filter(APIKeyRepositoryModel.api_key_id == key_id)
373
+ .all()
374
+ ]
375
+ return APIKeySummary(
376
+ id=api_key.id,
377
+ name=api_key.name,
378
+ key_prefix=api_key.key_prefix,
379
+ created_at=api_key.created_at,
380
+ revoked_at=api_key.revoked_at,
381
+ grants=grants,
382
+ )
383
+ finally:
384
+ session.close()
385
+
386
+
387
+ def verify_api_key(storage: StorageManager, raw_key: str) -> int | None:
388
+ """
389
+ Looks up the id of the APIKeyModel row matching `raw_key`, if any and
390
+ only if it has not been revoked. Returns None for an unknown, wrong,
391
+ or revoked key.
392
+
393
+ This is a verification PRIMITIVE only -- proving the hash-and-lookup
394
+ mechanism genuinely works -- not a FastAPI dependency, not wired into
395
+ any endpoint or request path. That integration is Phase 1.2.
396
+ """
397
+ session = storage.get_session()
398
+ try:
399
+ api_key = (
400
+ session.query(APIKeyModel).filter(APIKeyModel.key_hash == hash_api_key(raw_key)).first()
401
+ )
402
+ if api_key is None or api_key.revoked_at is not None:
403
+ return None
404
+ return api_key.id
405
+ finally:
406
+ session.close()