Lordkiki commited on
Commit
2e39262
·
verified ·
1 Parent(s): f4de30c

Deploy URAAS — African Research Archival & Analytics System

Browse files
docs/PARTNER_API.md ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # URAAS Partner API
2
+
3
+ Server-to-server read access to URAAS (University of Lagos Academic Archive &
4
+ Special Collections) data — built for external partners like the Africa PID
5
+ Alliance / DOCiD to integrate against, without needing a browser session.
6
+
7
+ This is separate from the dashboard's human login (username/password +
8
+ session cookie). Partner access uses a long-lived API key instead.
9
+
10
+ ---
11
+
12
+ ## Base URL
13
+
14
+ ```
15
+ https://lordkiki-apa-uraas.hf.space
16
+ ```
17
+
18
+ ## Authentication
19
+
20
+ Every request must include the key in a header:
21
+
22
+ ```
23
+ X-API-Key: uraas_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
24
+ ```
25
+
26
+ - Missing or invalid key → `401 Unauthorized`
27
+ - Valid key used against an endpoint outside this document → `403 Forbidden`
28
+ (partner keys are read-only by construction — no key can reach crawler
29
+ controls, admin exports, or anything that mutates data, regardless of what
30
+ path is requested)
31
+ - Keys are issued manually by a URAAS admin. To request one, contact the
32
+ URAAS team directly — there is no self-service signup.
33
+
34
+ ## Rate limits
35
+
36
+ **120 requests per rolling 60-second window, per key.** Exceeding it returns
37
+ `429 Too Many Requests`. Contact us if your integration needs a higher limit.
38
+
39
+ ## Errors
40
+
41
+ All errors are JSON:
42
+
43
+ ```json
44
+ { "status": "error", "message": "..." }
45
+ ```
46
+
47
+ | Status | Meaning |
48
+ |---|---|
49
+ | `401` | Missing or invalid/revoked API key |
50
+ | `403` | Valid key, but this endpoint isn't in the partner allowlist |
51
+ | `429` | Rate limit exceeded |
52
+ | `404` | Resource not found (e.g. unknown paper id) |
53
+ | `500` | Server error — please report it to us |
54
+
55
+ ---
56
+
57
+ ## Endpoints
58
+
59
+ ### `GET /api/stats`
60
+ Network/collaboration summary: co-authorship edges between departments, and
61
+ top authors by paper count.
62
+
63
+ ```json
64
+ {
65
+ "status": "success",
66
+ "network_edges": [{ "source": "Botany", "target": "Chemistry", "weight": 1 }],
67
+ "top_authors": [{ "author": "...", "count": 2, "orcid": "", "ror": "" }]
68
+ }
69
+ ```
70
+
71
+ ### `GET /api/papers/tree`
72
+ Every paper, grouped by faculty then department.
73
+
74
+ ```json
75
+ {
76
+ "data": {
77
+ "College of Medicine": {
78
+ "Community Health and Primary Care": [
79
+ {
80
+ "id": 22,
81
+ "title": "...",
82
+ "doi": "10.1093/hmg/ddw104",
83
+ "url": "https://doi.org/10.1093/hmg/ddw104",
84
+ "docid": "",
85
+ "download_url": null,
86
+ "has_local_pdf": false,
87
+ "access_policy": null
88
+ }
89
+ ]
90
+ }
91
+ }
92
+ }
93
+ ```
94
+ `docid` is populated once a paper has a real, registered DOCiD — empty string
95
+ otherwise. This is the field to poll if you want to detect which URAAS papers
96
+ already carry a DOCiD vs. which don't yet.
97
+
98
+ ### `GET /api/papers/<id>`
99
+ Full metadata for one paper — title, abstract, authors, DOI, publication
100
+ date, Special Collections category/score, and identifiers (`docid`, `ark`,
101
+ `ror`) where present.
102
+
103
+ ### `GET /api/papers/<id>/download`
104
+ Redirects to or streams the PDF, only when the item is open access.
105
+
106
+ ### `GET /api/papers/<id>/bibtex`
107
+ BibTeX citation export for one paper (`Content-Type: text/plain`).
108
+
109
+ ### `GET /api/analytics/special-collections`
110
+ The Special Collections subset specifically — the papers classified into one
111
+ or more of the 8 categories (Indigenous Knowledge, Cultural Heritage, Oral
112
+ Tradition, Ethnomusicology, African Philosophy, Traditional Medicine, Ethnic
113
+ Languages & Groups, Pan-African Studies).
114
+
115
+ ### `GET /api/analytics/special-collections/overview`
116
+ Aggregate view over that same set: category co-occurrence matrix, country
117
+ breakdown, custodian institutions, and the most-cited/influential papers per
118
+ category.
119
+
120
+ ```json
121
+ {
122
+ "co_occurrence": { "labels": [...], "matrix": [[...]] },
123
+ "countries": [{ "code": "NG", "name": "Nigeria", "papers": 54 }],
124
+ "custodians": [{ "institution": "University of Lagos", "count": 54 }],
125
+ "influential": [{ "id": 1, "title": "...", "categories": [...], "citations": 856, "sc_score": 3.0 }]
126
+ }
127
+ ```
128
+
129
+ ### `GET /api/university-registry`
130
+ The full registry of institutions URAAS crawls against (52 African
131
+ universities as of this writing) — name, ROR, country.
132
+
133
+ ### `GET /api/institution/info?institution=unilag`
134
+ Summary for one institution from the registry above. `institution` is the
135
+ registry key (e.g. `unilag`); omitting it returns null fields.
136
+
137
+ ```json
138
+ { "status": "success", "data": { "institution_name": "University of Lagos", "country_name": "Nigeria", "country_code": "NG" } }
139
+ ```
140
+
141
+ ---
142
+
143
+ ## Example
144
+
145
+ ```bash
146
+ curl -H "X-API-Key: uraas_live_..." \
147
+ "https://lordkiki-apa-uraas.hf.space/api/analytics/special-collections/overview"
148
+ ```
149
+
150
+ ---
151
+
152
+ ## What this API does *not* do (yet)
153
+
154
+ There is currently no inbound path — nothing lets DOCID's platform push data
155
+ or notifications back into URAAS (e.g. "here's the DocID we just assigned,
156
+ sync your record" or a webhook on registration). Every endpoint above is
157
+ URAAS serving data out. If two-way sync is needed, that's new work to scope
158
+ separately, not something already exposed here.
159
+
160
+ ## For URAAS admins — issuing/revoking keys
161
+
162
+ ```bash
163
+ python scripts/manage_api_keys.py create --name "Africa PID Alliance / DOCiD"
164
+ python scripts/manage_api_keys.py list
165
+ python scripts/manage_api_keys.py revoke --prefix uraas_live_AbCd1234
166
+ ```
167
+
168
+ The full key value is shown exactly once, at creation — only its hash is
169
+ stored. If a key is lost or compromised, revoke it and issue a new one; there
170
+ is no way to recover a lost key's value.
171
+
172
+ To add a new endpoint to partner access, add its Flask endpoint (function)
173
+ name to `PARTNER_ENDPOINTS` in `uraas/dashboard/app.py` — nothing is reachable
174
+ via API key unless explicitly listed there.
scripts/manage_api_keys.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Manage API keys for external partner access to the URAAS dashboard API
2
+ (e.g. Africa PID Alliance / DOCiD reading papers/analytics endpoints
3
+ server-to-server, instead of using the browser session-cookie login).
4
+
5
+ The plaintext key is shown exactly once, at creation — it is never stored;
6
+ only its SHA-256 hash is, so a stolen database dump can't be used to
7
+ authenticate as a partner. If a key is lost, revoke it and create a new one.
8
+
9
+ Usage:
10
+ python scripts/manage_api_keys.py create --name "Africa PID Alliance / DOCiD"
11
+ python scripts/manage_api_keys.py list
12
+ python scripts/manage_api_keys.py revoke --prefix uraas_live_AbCd
13
+ """
14
+
15
+ import argparse
16
+ import hashlib
17
+ import os
18
+ import secrets
19
+ import sys
20
+ from datetime import datetime
21
+
22
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
23
+
24
+ from uraas.database import ApiKey, SessionLocal
25
+
26
+ _PREFIX = "uraas_live_"
27
+
28
+
29
+ def _hash(key: str) -> str:
30
+ return hashlib.sha256(key.encode("utf-8")).hexdigest()
31
+
32
+
33
+ def cmd_create(args):
34
+ raw = _PREFIX + secrets.token_urlsafe(32)
35
+ session = SessionLocal()
36
+ try:
37
+ row = ApiKey(
38
+ name=args.name,
39
+ key_hash=_hash(raw),
40
+ key_prefix=raw[: len(_PREFIX) + 8],
41
+ scope="read",
42
+ created_by=args.created_by or "cli",
43
+ )
44
+ session.add(row)
45
+ session.commit()
46
+ print("Created API key — this is the ONLY time the full value is shown:\n")
47
+ print(f" {raw}\n")
48
+ print(f"Name: {row.name}")
49
+ print(f"Prefix: {row.key_prefix} (safe to log/reference later)")
50
+ print(f"Scope: {row.scope}")
51
+ print("\nGive the partner this header format:")
52
+ print(f' X-API-Key: {raw}')
53
+ finally:
54
+ session.close()
55
+
56
+
57
+ def cmd_list(args):
58
+ session = SessionLocal()
59
+ try:
60
+ rows = session.query(ApiKey).order_by(ApiKey.created_at.desc()).all()
61
+ if not rows:
62
+ print("No API keys yet.")
63
+ return
64
+ for r in rows:
65
+ status = "REVOKED" if r.revoked else "active"
66
+ last_used = r.last_used_at.isoformat() if r.last_used_at else "never"
67
+ print(f"[{r.id}] {r.key_prefix}... {r.name!r} scope={r.scope} "
68
+ f"status={status} created={r.created_at} last_used={last_used}")
69
+ finally:
70
+ session.close()
71
+
72
+
73
+ def cmd_revoke(args):
74
+ session = SessionLocal()
75
+ try:
76
+ q = session.query(ApiKey)
77
+ row = None
78
+ if args.id:
79
+ row = q.filter(ApiKey.id == args.id).first()
80
+ elif args.prefix:
81
+ row = q.filter(ApiKey.key_prefix == args.prefix).first()
82
+ if not row:
83
+ print("No matching key found.")
84
+ return
85
+ row.revoked = True
86
+ row.revoked_at = datetime.utcnow()
87
+ session.commit()
88
+ print(f"Revoked key [{row.id}] {row.key_prefix}... ({row.name!r})")
89
+ finally:
90
+ session.close()
91
+
92
+
93
+ def main():
94
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
95
+ sub = parser.add_subparsers(dest="command", required=True)
96
+
97
+ p_create = sub.add_parser("create", help="Issue a new partner API key")
98
+ p_create.add_argument("--name", required=True, help="Partner label, e.g. 'Africa PID Alliance / DOCiD'")
99
+ p_create.add_argument("--created-by", default=None)
100
+ p_create.set_defaults(func=cmd_create)
101
+
102
+ p_list = sub.add_parser("list", help="List all keys (never shows the full value)")
103
+ p_list.set_defaults(func=cmd_list)
104
+
105
+ p_revoke = sub.add_parser("revoke", help="Revoke a key")
106
+ p_revoke.add_argument("--id", type=int, default=None)
107
+ p_revoke.add_argument("--prefix", default=None)
108
+ p_revoke.set_defaults(func=cmd_revoke)
109
+
110
+ args = parser.parse_args()
111
+ if args.command == "revoke" and not (args.id or args.prefix):
112
+ parser.error("revoke requires --id or --prefix")
113
+ args.func(args)
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
scripts/push_to_hf.py CHANGED
@@ -123,16 +123,39 @@ def main():
123
  print(_SEP)
124
 
125
  # ── Auth ───────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
126
  token = os.getenv("HF_TOKEN")
127
  if token:
128
  login(token=token, add_to_git_credential=True)
129
  print(" Logged in via HF_TOKEN env var.")
130
  else:
131
- print()
132
- print(" Paste your HF write token below.")
133
- print(" (Get one at: https://huggingface.co/settings/tokens)")
134
- print()
135
- login(add_to_git_credential=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
 
137
  api = HfApi()
138
 
 
123
  print(_SEP)
124
 
125
  # ── Auth ───────────────────────────────────────────────────────────────
126
+ # Live-verified 2026-08-04: with no HF_TOKEN env var, this used to always
127
+ # call login() with no token, which prompts interactively for one — in
128
+ # any non-interactive shell (CI, a background task, this script run
129
+ # without a TTY) that blocks forever waiting for input that can never
130
+ # arrive, even when a valid token is already cached from a previous
131
+ # `huggingface-cli login` on the same machine. Try the cached credential
132
+ # first; only fall back to an interactive prompt when actually attached
133
+ # to a terminal.
134
  token = os.getenv("HF_TOKEN")
135
  if token:
136
  login(token=token, add_to_git_credential=True)
137
  print(" Logged in via HF_TOKEN env var.")
138
  else:
139
+ from huggingface_hub import HfFolder
140
+ cached = HfFolder.get_token()
141
+ if cached:
142
+ try:
143
+ HfApi().whoami(token=cached)
144
+ print(" Logged in via cached HF credential.")
145
+ except Exception:
146
+ cached = None
147
+ if not cached:
148
+ if not sys.stdin.isatty():
149
+ print()
150
+ print(" No HF_TOKEN env var, no cached credential, and no terminal to prompt on.")
151
+ print(" Set HF_TOKEN=hf_... and re-run, e.g.:")
152
+ print(f" HF_TOKEN=hf_... python {os.path.basename(__file__)}")
153
+ sys.exit(1)
154
+ print()
155
+ print(" Paste your HF write token below.")
156
+ print(" (Get one at: https://huggingface.co/settings/tokens)")
157
+ print()
158
+ login(add_to_git_credential=True)
159
 
160
  api = HfApi()
161
 
uraas/dashboard/app.py CHANGED
@@ -1,11 +1,15 @@
1
  import csv
 
2
  import io
3
  import json
4
  import logging
5
  import os
6
  import re
 
7
  import subprocess
8
  import threading
 
 
9
 
10
  from flask import (
11
  Flask,
@@ -25,6 +29,7 @@ from sqlalchemy.orm import selectinload
25
  from uraas.analytics.engine import analytics
26
  from uraas.config import config
27
  from uraas.database import (
 
28
  Author,
29
  Collection,
30
  Community,
@@ -109,8 +114,71 @@ ADMIN_ENDPOINTS = {
109
  "citations_velocity_csv",
110
  "staff_directory",
111
  "clear_half_and_recrawl",
 
 
 
112
  }
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
  @app.before_request
116
  def _enforce_authentication():
@@ -118,6 +186,18 @@ def _enforce_authentication():
118
  # Unknown endpoint (404s) and explicit public routes pass through.
119
  if endpoint is None or endpoint in PUBLIC_ENDPOINTS:
120
  return None
 
 
 
 
 
 
 
 
 
 
 
 
121
  role = current_role()
122
  if not role:
123
  if request.path.startswith("/api/"):
@@ -1827,6 +1907,79 @@ def clear_half_and_recrawl():
1827
  )
1828
 
1829
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1830
  @app.route("/api/author/<int:author_id>/metrics")
1831
  def get_author_metrics(author_id):
1832
  """Get bibliometric indicators for an author (h-index, citations, etc.)."""
 
1
  import csv
2
+ import hashlib
3
  import io
4
  import json
5
  import logging
6
  import os
7
  import re
8
+ import secrets
9
  import subprocess
10
  import threading
11
+ import time
12
+ from datetime import datetime
13
 
14
  from flask import (
15
  Flask,
 
29
  from uraas.analytics.engine import analytics
30
  from uraas.config import config
31
  from uraas.database import (
32
+ ApiKey,
33
  Author,
34
  Collection,
35
  Community,
 
114
  "citations_velocity_csv",
115
  "staff_directory",
116
  "clear_half_and_recrawl",
117
+ "admin_list_api_keys",
118
+ "admin_create_api_key",
119
+ "admin_revoke_api_key",
120
  }
121
 
122
+ # Read-only data endpoints external partners (e.g. Africa PID Alliance /
123
+ # DOCiD) may reach with a server-to-server API key instead of a browser
124
+ # session — see uraas.dashboard.app._check_api_key and
125
+ # scripts/manage_api_keys.py. Deliberately a small, explicit allowlist: a
126
+ # valid key never grants anything outside it, including ADMIN_ENDPOINTS —
127
+ # partner keys are read-only by construction, not just by convention.
128
+ PARTNER_ENDPOINTS = {
129
+ "get_stats",
130
+ "papers_tree",
131
+ "get_paper",
132
+ "download_paper",
133
+ "export_single_bibtex",
134
+ "special_collections",
135
+ "special_collections_overview",
136
+ "get_university_registry",
137
+ "institution_info",
138
+ }
139
+
140
+ # Simple in-memory sliding-window limiter for API-key traffic, separate from
141
+ # the IP-keyed `limiter` above (a partner integration calls from a small,
142
+ # fixed set of IPs the per-IP limiter wasn't designed to distinguish from
143
+ # abuse). Process-local only — fine for a handful of trusted partner keys on
144
+ # a single dashboard instance; would need a shared store (e.g. Redis) behind
145
+ # multiple worker processes.
146
+ _API_KEY_RATE_LIMIT = 120 # requests per rolling 60s window, per key
147
+ _api_key_hits: dict[str, list] = {}
148
+
149
+
150
+ def _check_api_key():
151
+ """Validate the X-API-Key header against uraas.database.ApiKey.
152
+
153
+ Returns (True, partner_name) if valid, unrevoked, in-quota, and the
154
+ caller updates ApiKey.last_used_at as a side effect. Returns
155
+ (False, (response, status)) otherwise, ready to return directly from
156
+ the before_request hook.
157
+ """
158
+ raw_key = request.headers.get("X-API-Key")
159
+ if not raw_key:
160
+ return False, None
161
+
162
+ key_hash = hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
163
+ session_db = SessionLocal()
164
+ try:
165
+ row = session_db.query(ApiKey).filter(ApiKey.key_hash == key_hash).first()
166
+ if not row or row.revoked:
167
+ return False, (jsonify({"status": "error", "message": "Invalid or revoked API key"}), 401)
168
+
169
+ now = time.time()
170
+ hits = [t for t in _api_key_hits.get(key_hash, []) if now - t < 60]
171
+ if len(hits) >= _API_KEY_RATE_LIMIT:
172
+ return False, (jsonify({"status": "error", "message": "Rate limit exceeded"}), 429)
173
+ hits.append(now)
174
+ _api_key_hits[key_hash] = hits
175
+
176
+ row.last_used_at = datetime.utcnow()
177
+ session_db.commit()
178
+ return True, row.name
179
+ finally:
180
+ session_db.close()
181
+
182
 
183
  @app.before_request
184
  def _enforce_authentication():
 
186
  # Unknown endpoint (404s) and explicit public routes pass through.
187
  if endpoint is None or endpoint in PUBLIC_ENDPOINTS:
188
  return None
189
+
190
+ if request.headers.get("X-API-Key"):
191
+ # Check the allowlist before touching the DB/rate-limit counter at
192
+ # all — an API key is never a path to an endpoint outside
193
+ # PARTNER_ENDPOINTS, so there's nothing to gain by validating first.
194
+ if endpoint not in PARTNER_ENDPOINTS:
195
+ return jsonify({"status": "error", "message": "This endpoint is not available via API key"}), 403
196
+ ok, result = _check_api_key()
197
+ if not ok:
198
+ return result
199
+ return None # valid partner key, allowlisted endpoint
200
+
201
  role = current_role()
202
  if not role:
203
  if request.path.startswith("/api/"):
 
1907
  )
1908
 
1909
 
1910
+ @app.route("/api/admin/api-keys", methods=["GET"])
1911
+ def admin_list_api_keys():
1912
+ """List partner API keys (admin only). Never returns full key values —
1913
+ only the prefix stored at creation, same as scripts/manage_api_keys.py."""
1914
+ session_db = SessionLocal()
1915
+ try:
1916
+ rows = session_db.query(ApiKey).order_by(ApiKey.created_at.desc()).all()
1917
+ return api_ok({
1918
+ "keys": [
1919
+ {
1920
+ "id": r.id,
1921
+ "name": r.name,
1922
+ "prefix": r.key_prefix,
1923
+ "scope": r.scope,
1924
+ "revoked": r.revoked,
1925
+ "created_at": r.created_at.isoformat() if r.created_at else None,
1926
+ "last_used_at": r.last_used_at.isoformat() if r.last_used_at else None,
1927
+ }
1928
+ for r in rows
1929
+ ]
1930
+ })
1931
+ finally:
1932
+ session_db.close()
1933
+
1934
+
1935
+ @app.route("/api/admin/api-keys", methods=["POST"])
1936
+ def admin_create_api_key():
1937
+ """Issue a new partner API key (admin only). The plaintext value is
1938
+ returned exactly once, in this response — only its hash is persisted, so
1939
+ it can never be retrieved again after this call."""
1940
+ data = request.get_json(silent=True) or {}
1941
+ name = (data.get("name") or "").strip()
1942
+ if not name:
1943
+ return api_error("name is required", status=400)
1944
+
1945
+ raw = "uraas_live_" + secrets.token_urlsafe(32)
1946
+ session_db = SessionLocal()
1947
+ try:
1948
+ row = ApiKey(
1949
+ name=name,
1950
+ key_hash=hashlib.sha256(raw.encode("utf-8")).hexdigest(),
1951
+ key_prefix=raw[:20],
1952
+ scope="read",
1953
+ created_by=session.get("user"),
1954
+ )
1955
+ session_db.add(row)
1956
+ session_db.commit()
1957
+ return api_ok({
1958
+ "id": row.id,
1959
+ "name": row.name,
1960
+ "prefix": row.key_prefix,
1961
+ "key": raw,
1962
+ }, narrative="Save this key now — it will not be shown again.")
1963
+ finally:
1964
+ session_db.close()
1965
+
1966
+
1967
+ @app.route("/api/admin/api-keys/<int:key_id>/revoke", methods=["POST"])
1968
+ def admin_revoke_api_key(key_id):
1969
+ """Revoke a partner API key (admin only)."""
1970
+ session_db = SessionLocal()
1971
+ try:
1972
+ row = session_db.query(ApiKey).filter(ApiKey.id == key_id).first()
1973
+ if not row:
1974
+ return api_error("Key not found", status=404)
1975
+ row.revoked = True
1976
+ row.revoked_at = datetime.utcnow()
1977
+ session_db.commit()
1978
+ return api_ok({"id": row.id, "revoked": True})
1979
+ finally:
1980
+ session_db.close()
1981
+
1982
+
1983
  @app.route("/api/author/<int:author_id>/metrics")
1984
  def get_author_metrics(author_id):
1985
  """Get bibliometric indicators for an author (h-index, citations, etc.)."""
uraas/database.py CHANGED
@@ -368,6 +368,30 @@ class DepositBatch(Base):
368
  deposit_log = Column(Text, default="[]")
369
 
370
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
  # ── Indexes for query performance ─────────────────────────────────────────────
372
  # ix_items_docid is auto-created by index=True on Item.docid — no duplicate needed
373
  Index("ix_items_language", Item.language_code)
 
368
  deposit_log = Column(Text, default="[]")
369
 
370
 
371
+ class ApiKey(Base):
372
+ """Server-to-server credential for external partners (e.g. Africa PID
373
+ Alliance / DOCiD) reading URAAS data programmatically — distinct from the
374
+ dashboard's session-cookie login, which only suits a human in a browser.
375
+
376
+ The plaintext key is shown exactly once at creation time (see
377
+ scripts/manage_api_keys.py) and never stored — only its SHA-256 hash is
378
+ persisted, so a stolen database dump can't be used to authenticate.
379
+ """
380
+
381
+ __tablename__ = "api_keys"
382
+
383
+ id = Column(Integer, primary_key=True)
384
+ name = Column(String(255), nullable=False) # partner label, e.g. "Africa PID Alliance / DOCiD"
385
+ key_hash = Column(String(64), unique=True, index=True, nullable=False) # sha256 hex digest
386
+ key_prefix = Column(String(12), nullable=False) # first chars only, for display/audit
387
+ scope = Column(String(20), default="read", nullable=False) # "read" is the only scope for now — never admin
388
+ created_at = Column(DateTime, default=datetime.utcnow)
389
+ created_by = Column(String(100)) # admin session username that issued it
390
+ last_used_at = Column(DateTime)
391
+ revoked = Column(Boolean, default=False, nullable=False)
392
+ revoked_at = Column(DateTime)
393
+
394
+
395
  # ── Indexes for query performance ─────────────────────────────────────────────
396
  # ix_items_docid is auto-created by index=True on Item.docid — no duplicate needed
397
  Index("ix_items_language", Item.language_code)