| name | drphub-cards |
| description | Manage DRP Hub Digital Research Products via the production REST API at drp-term.kube.aip.de/api/v1/. Supports full CRUD, clone, maturity, publish, audit, lineage, human-review, bookmarks, likes, sharing, and SSE event streaming. |
| version | 2.1.1 |
| author | Ori (Hermes Agent) |
| license | MIT |
| platforms | ["linux"] |
| metadata | {"drphub":{"tags":["drp","hub","cards","products","rest-api","punch4nfdi"],"homepage":"https://drphub-p4n.aip.de","api_base":"https://drp-term.kube.aip.de/api/v1","docs":"https://drp-term.kube.aip.de/api/v1/docs#/","related_skills":["reana-aip","reana-serial-python","docs-mcp-at-aip"]}} |
DRP Hub Card/Product Management
Manage DRP Hub Digital Research Products (DRPs) via the production REST API. The API is backed by drp_products table and bidirectionally mirrored with the legacy drp_cards table used by the web UI.
API base URL: https://drp-term.kube.aip.de/api/v1
OpenAPI spec: https://drp-term.kube.aip.de/api/v1/openapi.json
Full API reference (parsed): references/api-spec.md
Web-UI share link (the URL you give to HUMANS):
https://drphub-p4n.aip.de/share/<product-id>
This is the Hub's only public card-view route (/share/:cardId). Do NOT
construct https://drphub-p4n.aip.de/product/<id> — no such web route exists
(it 404s); the /products/{id} path is REST-API-only, not a web page.
Authentication
Two auth modes — end-user JWT or Hermes service token:
- End-user JWT (Supabase or Keycloak): Send
Authorization: Bearer <token>
- Hermes service token: Send
Authorization: Bearer <service_token> + X-Acting-User-Id: <user-uuid>
For end-user auth, set the env var DRPHUB_TOKEN:
hermes config env set DRPHUB_TOKEN <your_supabase_or_keycloak_jwt>
For service-token auth:
hermes config env set DRPHUB_SERVICE_TOKEN <service_token>
hermes config env set DRPHUB_ACTING_USER_ID <user-uuid>
All mutating endpoints accept an optional Idempotency-Key header (client-generated UUID, unique per actor+route+body). Replays return the original response.
All 14 API Endpoints
| Method | Endpoint | Description | Auth |
|---|
| GET | /products | List products (cursor-paginated) | JWT / Service |
| POST | /products | Create product | JWT / Service |
| GET | /products/{id} | Get single product | JWT / Service |
| PATCH | /products/{id} | Update product (owner/admin) | JWT / Service |
| DELETE | /products/{id} | Soft-delete product | JWT / Service |
| POST | /products/{id}/clone | Deep clone a product | JWT / Service |
| GET | /products/{id}/maturity | Maturity level + missing gates | JWT / Service |
| POST | /products/{id}/publish | Publish (requires L4 gates) | JWT / Service |
| POST | /products/{id}/human-review | Mark human-reviewed (humans only) | JWT only |
| GET | /products/{id}/lineage | Ancestors + children | JWT / Service |
| GET | /products/{id}/audit | Audit log (owner/admin) | JWT / Service |
| GET | /products/{id}/events | SSE stream of audit events | JWT / Service |
| GET | /config | API capabilities | JWT / Service |
| GET | /health | Liveness + DB probe | None (public) |
Product Schema (full fields)
Read fields (returned by GET/PATCH):
| Field | Type | Notes |
|---|
id | uuid | Primary key |
owner_user_id | uuid | Owner |
title | string | |
description | string/null | |
avatar | string/null | URL |
category | string | |
tags | string[] | |
visibility | enum | private, internal, shared, public |
product_status | enum | draft, generated, validating, validated, failed_validation, human_reviewed, published, archived |
source_type | enum | manual, repo, clone, template, ai_generated, imported |
git_url | string/null | |
git_branch | string/null | |
git_commit | string/null | |
entry_command | string/null | |
workflow_file | string/null | |
license | string/null | |
citation_cff_url | string/null | |
release_tag | string/null | |
env_image |
Create fields (POST /products):
Required:
title — Product title
category — One of: analysis, tool, data, workflow, service, publication
visibility — One of: private, shared, public
source_type — One of: manual, repo, clone, template, ai_generated, imported
git_url — Repository URL
git_branch — Branch name (e.g. main)
git_commit — Commit SHA (or HEAD for latest)
env_image — Container image (e.g. docker.io/user/reana-env:latest)
reproducibility_depth — One of: D0, D1, D2, D3, D4
Optional:
description — Longer description (≥80 chars recommended for L1 maturity)
tags — Comma-separated tags array
entry_command — Command to run workflow
workflow_file — Workflow file name (e.g. reana.yaml)
license — SPDX license identifier
citation_cff_url — URL to CITATION.cff
release_tag — Version tag (e.g. v1.0.0)
env_image_digest — SHA256 digest of the image
authors_orcid — Array of ORCID IDs
validation_scope — Object with test/reproducibility flags
provenance_url — DOI or provenance link
expected_outputs — Array of expected output files
has_s3 — Has S3 storage (boolean)
has_hpc — Has HPC execution (boolean)
has_reana — Has REANA workflow (boolean)
Patch fields (PATCH /products/{id} — all optional, supply only what you want to change): same as create plus maturity_override, product_status, doi, archive_url, harvest_endpoint
Helper Function
Use this as your base for all API calls:
import os, json, uuid, urllib.request, urllib.error
BASE = os.environ.get("DRPHUB_BASE", "https://drp-term.kube.aip.de/api/v1")
TOKEN = os.environ.get("DRPHUB_TOKEN", "")
SERVICE_TOKEN = os.environ.get("DRPHUB_SERVICE_TOKEN", "")
ACTING_USER_ID = os.environ.get("DRPHUB_ACTING_USER_ID", "")
def drphub_request(method, path, body=None, headers_extra=None, dry_run=False):
"""
Make a DRP Hub API request.
Args:
method: "GET", "POST", "PATCH", "DELETE"
path: endpoint path (e.g. "/products", "/products/{id}/clone")
body: dict with data for POST/PATCH/clone (or None)
headers_extra: extra dict of headers (optional)
dry_run: if True, append ?dry_run=true to PATCH/clone/publish
Returns:
dict/list from API, or None on error
"""
url = f"{BASE}{path}"
if dry_run:
sep = "&" if "?" in url else "?"
url = f"{url}{sep}dry_run=true"
auth_headers = {}
if SERVICE_TOKEN:
auth_headers["Authorization"] = f"Bearer "
ACTING_USER_ID:
auth_headers[] = ACTING_USER_ID
TOKEN:
auth_headers[] =
headers_extra:
auth_headers.update(headers_extra)
method (, , ) method != :
auth_headers[] = (uuid.uuid4())
headers = {
: ,
**auth_headers,
}
data = json.dumps(body).encode() body
req = urllib.request.Request(url, data=data, headers=headers, method=method)
:
urllib.request.urlopen(req) resp:
resp_body = resp.read().decode()
json.loads(resp_body) resp_body
urllib.error.HTTPError e:
err_body = e.read().decode()
Exception()
Operations
Health Check (no auth needed)
health = drphub_request("GET", "/health")
List Products
products = drphub_request("GET", "/products")
items = products.get("items", [])
next_cursor = products.get("next_cursor")
public = drphub_request("GET", "/products?visibility=public")
mine = drphub_request("GET", "/products?mine=true")
galaxy = drphub_request("GET", "/products?q=galaxy&q_in=all")
astro = drphub_request("GET", "/products?q=astro&q_in=title")
minimal = drphub_request("GET", "/products?limit=10&fields=id,title,maturity_level,updated_at")
with_links = drphub_request("GET", "/products?include=links&limit=5")
⚠️ RUNNABLE-card pre-flight (do this BEFORE creating a card with has_reana)
A card's "Run on REANA" button executes workflow_file from
git_url@git_branch/git_commit on the clicking user's REANA — the REPO
content runs, not your workspace. A card created from an unvalidated repo is
broken for every future user. Checklist:
- Author the workflow with the
reana-aip skill (canonical reana.yaml
template + the approved AIP environment images). Never hand-invent the
yaml structure or environment refs; for spec questions use the
docs-mcp-at-aip server if available.
reana-client validate -f reana.yaml MUST pass (all three checks) on
the exact file that is pushed to the repo.
- Repo reality check:
reana.yaml sits at the REPO ROOT (or exactly at
the workflow_file path); every script in inputs.files is committed;
the project on gitlab-p4n.aip.de has internal or public visibility so
DRP-Hub can clone it; git_branch exists and git_commit (or HEAD)
resolves.
- Card fields must MATCH the repo:
workflow_file = the yaml's path;
env_image = the environment ref used inside reana.yaml (from the
approved list — not a made-up docker.io path); entry_command = the real
run command (e.g. reana-client run -w <name>); set has_reana: true.
- After POST, GET the product back and confirm the git/env fields round-trip
correctly — then tell the user the card's SHARE link
https://drphub-p4n.aip.de/share/<product-id> (NOT /product/<id> — that
route does not exist) and that Run on REANA is ready.
Create Product (via Web Form API)
Create a product by matching the DRP Hub web form structure:
product = drphub_request("POST", "/products", body={
"title": "My Analysis Workflow",
"tags": ["reana", "analysis", "astrophysics"],
"category": "analysis",
"visibility": "private",
"source_type": "repo",
"git_url": "https://gitlab-p4n.aip.de/<namespace>/<project>",
"git_branch": "main",
"git_commit": "HEAD",
"workflow_file": "reana.yaml",
"entry_command": "reana-client run -w my-workflow",
"env_image": "gitlab-p4n.aip.de:5005/p4nreana/reana-env:py311-astro.9845",
"env_image_digest": "",
"license": "MIT",
: ,
: ,
: [],
: ,
: {
: ,
: ,
: ,
: ,
:
},
: ,
: ,
: ,
: [
{: , : , : }
],
: ,
})
product_id = product[]
()
Notes:
description is set separately via PATCH after creation (not in create form)
provenance_url, doi, archive_url are set after validation passes (for L3/L4)
- To update description/title after creation:
drphub_request("PATCH", f"/products/{product_id}", body={
"description": "A longer description of at least 80 characters for L1 maturity.",
})
Get Single Product
product = drphub_request("GET", f"/products/{product_id}")
with_links = drphub_request("GET", f"/products/{product_id}?include=links")
Update Product (PATCH — all fields optional)
updated = drphub_request("PATCH", f"/products/{product_id}", body={
"title": "Updated Title",
"visibility": "public",
"tags": ["x-ray", "spectroscopy", "reana", "updated"],
"product_status": "validated",
})
dry = drphub_request("PATCH", f"/products/{product_id}", body={
"visibility": "public",
}, dry_run=True)
Delete Product (Soft Delete)
drphub_request("DELETE", f"/products/{product_id}")
IMPORTANT — soft-delete GET behavior: After a soft delete, GET /products/{id} returns HTTP 200 (not 404). The record persists with deleted_at and deleted_by fields populated. To confirm deletion, check for deleted_at in the response. Soft-deleted products are automatically filtered from listing endpoints (e.g., GET /products?mine=true).
resp, body = drphub_request("DELETE", f"/products/{product_id}")
resp, body = drphub_request("GET", f"/products/{product_id}")
assert body.get("deleted_at") is not None, "Product not soft-deleted!"
Clone Product
cloned = drphub_request("POST", f"/products/{product_id}/clone", body={
"title": "Clone of My Analysis",
"clone_mode": "metadata_only",
})
cloned_id = cloned["id"]
dry = drphub_request("POST", f"/products/{product_id}/clone", body={
"title": "Clone preview",
"clone_mode": "template",
}, dry_run=True)
Maturity Check
maturity = drphub_request("GET", f"/products/{product_id}/maturity")
Publish Product (requires L4 gates passing)
published = drphub_request("POST", f"/products/{product_id}/publish")
dry = drphub_request("POST", f"/products/{product_id}/publish", dry_run=True)
Human Review
reviewed = drphub_request("POST", f"/products/{product_id}/human-review", body={
})
Lineage (Clone History)
lineage = drphub_request("GET", f"/products/{product_id}/lineage")
Audit Log
audit = drphub_request("GET", f"/products/{product_id}/audit")
events = audit.get("items", [])
audit_page2 = drphub_request("GET", f"/products/{product_id}/audit?cursor=<next_cursor>")
SSE Event Stream (Real-time Audit)
import json, time
req = urllib.request.Request(
f"{BASE}/products/{product_id}/events",
headers={"Authorization": f"Bearer {TOKEN}"}
)
with urllib.request.urlopen(req) as resp:
for line in resp:
text = line.decode().strip()
if text.startswith("data:"):
event = json.loads(text[5:])
print(f"Event: {event}")
elif text == ":heartbeat":
pass
else:
pass
API Config (Capabilities)
config = drphub_request("GET", "/config")
print(json.dumps(config, indent=2))
Tools.json (AI Function Descriptors)
tools = drphub_request("GET", "/tools.json")
Social Features (via Supabase sidecar)
The REST API is mirrored with the legacy drp_cards table. Bookmarks, likes, and sharing are managed via the Supabase sidecar:
SUPABASE_URL = os.environ.get("DRPHUB_SUPABASE_URL", "https://rrgnjinkabvqavwwzyfs.supabase.co")
def supabase_request(table, path="", body=None, token=TOKEN):
url = f"{SUPABASE_URL}/rest/v1/{table}{path}"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"apikey": os.environ.get("SUPABASE_ANON_KEY", ""),
"Prefer": "return=representation",
}
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode())
supabase_request("drp_card_bookmarks", body={
"user_id": "<your-user-id>",
"card_id": product_id,
})
supabase_request("rpc/toggle_card_like", body={"card_id": product_id})
supabase_request("drp_card_shared_with_me", body={
"user_id": "<your-user-id>",
"card_id": product_id,
})
shared = supabase_request("drp_card_shared_with_me", )
Common Patterns
Search All Public Products in Category
def search_category(category, q=""):
params = f"?visibility=public&q_in=title&q={category}"
if q:
params += f"&q_in=all&q={q}"
products = drphub_request("GET", f"/products{params}")
return products.get("items", [])
Batch Update Multiple Products
def batch_update(product_ids, patch_body):
results = []
for pid in product_ids:
try:
result = drphub_request("PATCH", f"/products/{pid}", body=patch_body)
results.append({"id": pid, "status": "ok"})
except Exception as e:
results.append({"id": pid, "error": str(e)})
return results
Check Maturity Before Publish
def can_publish(product_id):
maturity = drphub_request("GET", f"/products/{product_id}/maturity")
gates = maturity.get("gates", {})
return gates.get("l4Ok", False)
if can_publish(product_id):
drphub_request("POST", f"/products/{product_id}/publish")
else:
maturity = drphub_request("GET", f"/products/{product_id}/maturity")
print("Cannot publish — missing gates:")
for level, items in maturity["missing"].items():
if items:
print(f" {level}: {', '.join(items)}")
Troubleshooting
- 401 Unauthorized: Token expired or invalid. Refresh your JWT or service token.
- 403 Forbidden: Token lacks permission for this resource. Check Keycloak roles/claims.
- 409 Conflict: Idempotency key reused with different body, or resource already exists.
- 412 Precondition Failed: Etag mismatch (use
If-Match header with current etag).
- 422 Unprocessable Entity: Validation failure — check request body against schema.
- 429 Too Many Requests: Rate limited — back off and retry.
- Empty results: Verify your token has visibility permissions. Public products are visible to all; private/internal require appropriate claims.
- SSE connection drops: Reconnect after 5-minute cap or network interruption. Heartbeats are sent every 15s.
Token Handling Pitfall (CRITICAL)
The Hermes tool system (write_file, execute_code, terminal) truncates strings longer than ~48 characters when they contain drp_pat_ prefix tokens. Always write the token to a .txt file, then read it in your script — never pass the token directly in tool call arguments or Python string literals inside tool calls.
TOKEN = "drp_pat_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
with open('/path/to/drphub_token.txt') as f:
TOKEN = f.read().strip()
General rule: Any secret/token 40+ characters that gets truncated in tool calls should be written to a file and read at runtime — not passed directly in tool arguments. This applies to GitHub PATs, API keys, and any long string secrets.
Quick Reference: Common Field Values
Visibility: private → internal → shared → public
Source type: manual, repo, clone, template, ai_generated, imported
Product status: draft → generated → validating → validated → human_reviewed → published → archived
Reproducibility depth: D0 (none) → D1 → D2 → D3 → D4 (full)
Validation: not_validated → pending → running → passed/failed/waived
Clone mode: metadata_only, template, snapshot, fork