Persistable CMS

Content Management System with Substrate-based Access Control

Overview

Roles

Series

Content is organized into series — named collections identified by a slug. Series names must match the pattern ^[a-z0-9][a-z0-9_-]{0,62}[a-z0-9]$ (2–64 chars, lowercase alphanumeric, hyphens, and underscores).

Lookup Paths

Each upload may optionally have a lookup_path — a human-readable path within its series (e.g., images/logo.png). Lookup paths are unique within a series and match ^(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+\.[A-Za-z0-9]+$.

UUIDs

Every upload receives a globally unique UUID (auto-generated or provided in extra_metadata.uuid). UUIDs enable cross-series references and are the primary identifiers used in graph relationships.

Graph Relationships

Uploads can be linked using semantic predicates (e.g., skos:broader, rel:derivedFrom). Relationships are directional (source → target) and support soft-deletion. This forms a knowledge graph across all CMS content.

Authentication

All API endpoints (except /docs, /_ah/health, and the auth endpoints themselves) require a valid Bearer token in the Authorization header.

1. Request Challenge

POST /api/auth/challenge

Request a challenge to begin authentication. The address must be an authorized admin or scribe.

Request

{
  "address": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
}

Response (200)

{
  "challenge": {
    "address": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY",
    "timestamp": 1700000000,
    "nonce": "550e8400-e29b-41d4-a716-446655440000",
    "message": "Sign this message to authenticate with Persistable CMS"
  }
}

2. Verify Signature

POST /api/auth/verify

Submit the signed challenge to receive access and refresh tokens.

Signing Format

  1. Receive the challenge JSON object from /api/auth/challenge.
  2. Serialize: message_str = json.dumps(challenge, separators=(',', ':'))
  3. Encode: message_bytes = message_str.encode('utf-8')
  4. Hex-encode: message_hex = message_bytes.hex()
  5. Sign: signature = keypair.sign(message_bytes)
  6. Hex-encode signature: signature_hex = signature.hex()

Request

{
  "address": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY",
  "message": "7b2261646472657373223a2235...hex_encoded_json...",
  "signature": "0xabc123...hex_encoded_signature..."
}

Response (200)

{
  "access_token": "a1b2c3d4...blake3_hex...",
  "refresh_token": "e5f6a7b8...blake3_hex...",
  "expires_in": 3600,
  "role": "admin",
  "address": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
}

3. Refresh Token

POST /api/auth/refresh

Exchange a valid refresh token for new access and refresh tokens.

Request

{
  "refresh_token": "e5f6a7b8...blake3_hex..."
}

Response (200)

{
  "access_token": "new_access_token...",
  "refresh_token": "new_refresh_token...",
  "expires_in": 3600,
  "role": "admin",
  "address": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
}

4. Logout

POST /api/auth/logout

Invalidate the current session tokens. Requires Authorization: Bearer <token>.

Response (200)

{
  "message": "Logged out"
}

Admin API

All admin endpoints require authentication and the caller must be the configured administrator.

Add Scribe

POST /api/admin/scribes

Grant scribe access to a Substrate address.

Request

{
  "address": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"
}

Response (201)

{
  "message": "Scribe added",
  "address": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"
}

Remove Scribe

DELETE /api/admin/scribes

Revoke scribe access.

Request

{
  "address": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"
}

Response (200)

{
  "message": "Scribe removed",
  "address": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"
}

List Scribes

GET /api/admin/scribes

List all authorized scribes.

Response (200)

{
  "scribes": [
    {
      "address": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty",
      "granted_by": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY",
      "created_at": "2024-01-15T10:30:00"
    }
  ]
}

Get Config

GET /api/admin/config

View the current CMS configuration.

Response (200)

{
  "admin_address": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY",
  "created_at": "2024-01-15T09:00:00"
}

CMS API

All CMS endpoints require authentication. The base path is /api/cms.

Upload Content

POST /api/cms/<series>/upload

Upload content to a series. Provide either base64-encoded binary (content) or plain text (content_text). Max binary size: 1 MB.

Request (text)

{
  "content_text": "{\"name\": \"Example\"}",
  "content_type": "application/json",
  "lookup_path": "data/example.json",
  "extra_metadata": {"tags": ["demo"]},
  "signature": "optional_hex_sig"
}

Request (binary)

{
  "content": "iVBORw0KGgo...base64...",
  "content_type": "image/png",
  "lookup_path": "images/logo.png"
}

Response (201)

{
  "message": "Upload successful",
  "upload_id": 1,
  "uuid": "550e8400-e29b-41d4-a716-446655440000",
  "series": "my-series",
  "lookup_path": "data/example.json"
}

Download by Upload ID

GET /api/cms/<series>/download/<upload_id>

Retrieve an upload by its numeric ID.

Response (200)

{
  "data": {
    "upload_id": 1,
    "uuid": "550e8400-e29b-41d4-a716-446655440000",
    "series": "my-series",
    "lookup_path": "data/example.json",
    "content_type": "application/json",
    "content": "{\"name\": \"Example\"}",
    "extra_metadata": {"tags": ["demo"]},
    "source_ip": "127.0.0.1",
    "user_agent": "python-requests/2.31.0",
    "signature": null,
    "created_at": "2024-01-15T10:30:00",
    "updated_at": "2024-01-15T10:30:00",
    "timestamp": "2024-01-15T10:30:00"
  }
}

Download by UUID

GET /api/cms/<series>/uuid/<uuid>

Retrieve an upload by its UUID. The upload must belong to the specified series.

Download by Lookup Path

GET /api/cms/<series>/path/<lookup_path>

Retrieve an upload by its lookup path within a series.

List Uploads

GET /api/cms/<series>/list

List uploads in a series with cursor-based pagination.

Query Parameters

ParameterTypeDefaultDescription
limitint100Results per page (1–200)
cursorstringPagination cursor from previous response

Response (200)

{
  "uploads": [ ... ],
  "series": "my-series",
  "next_cursor": "CjsSNWo..."
}

Graph API

Graph endpoints manage semantic relationships between uploads. All are under /api/cms and require authentication.

Add Relationship

POST /api/cms/<series>/<upload_id>/graph/add

Create a directed relationship from the source upload to a target identified by UUID.

Request

{
  "predicate": "skos:broader",
  "target_uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}

Response (201)

{
  "message": "Relationship created",
  "relationship": {
    "relationship_id": 1,
    "source_uuid": "550e8400-e29b-41d4-a716-446655440000",
    "predicate": "skos:broader",
    "target_uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "target_series": "other-series",
    "target_content_type": "application/json",
    "target_lookup_path": "data/parent.json",
    "status": "active",
    "created_by": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY",
    "created_at": "2024-01-15T11:00:00",
    "last_updated": "2024-01-15T11:00:00"
  }
}

Error Responses

StatusConditionBody
400Invalid predicate{"error": "Invalid predicate", "allowed_predicates": {...}}
400Self-link{"error": "Cannot create relationship to self"}
404Target not found{"error": "Target UUID not found in CMS"}

List Relationships

GET /api/cms/<series>/<upload_id>/graph/list

List all active relationships from a source upload.

Response (200)

{
  "relationships": [ ... ],
  "upload_id": 1,
  "uuid": "550e8400-e29b-41d4-a716-446655440000"
}

Remove Relationship

POST /api/cms/<series>/<upload_id>/graph/remove

Soft-delete a relationship (sets status to removed).

Request

{
  "relationship_id": 1
}

Response (200)

{
  "message": "Relationship removed",
  "relationship": { ... }
}

List Removed Relationships

GET /api/cms/<series>/<upload_id>/graph/removed

List all soft-deleted relationships from a source upload.

List Relationships by UUID

GET /api/cms/<series>/graph/uuid/<uuid>

List active relationships for an upload identified by UUID. The upload must belong to the specified series.

List Removed Relationships by UUID

GET /api/cms/<series>/graph/uuid/<uuid>/removed

List soft-deleted relationships for an upload identified by UUID.

List Allowed Predicates

GET /api/cms/<series>/<upload_id>/graph/predicates

Convenience endpoint returning all allowed predicate strings, organized by namespace.

Response (200)

{
  "predicates": {
    "owl": ["owl:differentFrom", "owl:equivalentTo", "owl:sameAs"],
    "rdfs": ["rdfs:isDefinedBy", "rdfs:seeAlso", "rdfs:subClassOf"],
    "skos": ["skos:broadMatch", "skos:broader", "skos:closeMatch", ...],
    ...
  }
}

Predicate Reference

NamespacePredicates
owl owl:differentFrom, owl:equivalentTo, owl:sameAs
rdfs rdfs:isDefinedBy, rdfs:seeAlso, rdfs:subClassOf
skos skos:broadMatch, skos:broader, skos:closeMatch, skos:exactMatch, skos:narrowMatch, skos:narrower, skos:related, skos:relatedMatch
cause cause:directCause, cause:enables, cause:indirectCause, cause:prerequisite, cause:prevents, cause:trigger
intent intent:achieves, intent:aimsTo, intent:facilitates, intent:intendedFor, intent:motivates
axiom axiom:contradicts, axiom:derivedFrom, axiom:implies, axiom:mutuallyExclusive, axiom:necessaryFor, axiom:sufficientFor
spatial spatial:connects, spatial:contains, spatial:locatedIn, spatial:near, spatial:within
temporal temporal:after, temporal:before, temporal:during, temporal:hasVersion, temporal:overlaps, temporal:versionOf
part part:hasComponent, part:hasPart, part:isPartOf, part:partOf
rel rel:archivedFrom, rel:creates, rel:dependsOn, rel:derivedFrom, rel:hasPart, rel:influences, rel:interactsWith, rel:linkedFrom, rel:linksTo, rel:modifies, rel:partOf, rel:referencedBy, rel:references, rel:relatedTo, rel:sourceOf, rel:usedDevice

Substrate Signing

Generate a Keypair

from substrateinterface import Keypair

# Generate new mnemonic and keypair
mnemonic = Keypair.generate_mnemonic()
keypair = Keypair.create_from_mnemonic(mnemonic)

print("Mnemonic:", mnemonic)
print("Address: ", keypair.ss58_address)

Full Authentication Example

import json
import requests
from substrateinterface import Keypair

BASE_URL = "https://your-cms-instance.appspot.com"
keypair = Keypair.create_from_mnemonic("your twelve word mnemonic ...")

# Step 1: Request challenge
resp = requests.post(f"{BASE_URL}/api/auth/challenge", json={
    "address": keypair.ss58_address
})
challenge = resp.json()["challenge"]

# Step 2: Sign the challenge
message_str = json.dumps(challenge, separators=(',', ':'))
message_bytes = message_str.encode('utf-8')
message_hex = message_bytes.hex()
signature = keypair.sign(message_bytes)
signature_hex = signature.hex()

# Step 3: Verify and get tokens
resp = requests.post(f"{BASE_URL}/api/auth/verify", json={
    "address": keypair.ss58_address,
    "message": message_hex,
    "signature": signature_hex
})
tokens = resp.json()
access_token = tokens["access_token"]

# Step 4: Use the token
headers = {"Authorization": f"Bearer {access_token}"}
resp = requests.get(f"{BASE_URL}/api/cms/my-series/list", headers=headers)
print(resp.json())

Refresh Tokens

# When access token expires (after 1 hour), use the refresh token:
resp = requests.post(f"{BASE_URL}/api/auth/refresh", json={
    "refresh_token": tokens["refresh_token"]
})
new_tokens = resp.json()
# Refresh tokens last 30 days