This article was co-written with a generative AI. Facts have been cross-checked against official documentation where possible, but errors may remain. Please verify important details with primary sources before acting on them.

Account names and IDs are written as placeholders. Examples:

  • PythonAnywhere username: <pa-user> (so the web-app URL is https://<pa-user>.pythonanywhere.com/)
  • Firebase project ID: <firebase-pid>
  • PythonAnywhere API token: <PA_TOKEN>
  • GitHub Pages serving domain: <gh-user>.github.io

Replace each with your own values when following along. Shell commands below reference them as environment variables.

This article focuses on standing up the JSONkeeper server side on your own infrastructure. The companion piece on serving the Viewer itself as a read-only Wayback mirror on GitHub Pages (the codh-mirror repo) is in a separate article; this one picks up where that left off and walks through getting the save path working again.

TL;DR

  • JSONkeeper is IllDepence/JSONkeeper, a small MIT-licensed Flask + SQLAlchemy + Firebase Admin SDK app. The service that used to run at mp.ex.nii.ac.jp/api/curation/json is the same codebase.
  • Its pinned Flask==1.0.2 / apscheduler==3.5.3 fails to import on Python 3.10+ (collections.MutableMapping removed; pkg_resources no longer ships by default). Bumping to Flask==2.0.3 and apscheduler>=3.10 is sufficient — everything else in requirements.txt is still compatible.
  • PythonAnywhere's free Beginner plan is enough. The constraints that actually bite are (1) 100 CPU-seconds/day on consoles, (2) an outbound HTTP(S) whitelist, and (3) no SSH. Web request handling does not count against the CPU cap, so day-to-day usage is unaffected.
  • Even on the free plan, almost every operation (file upload/download, web-app creation/configuration, reload, console I/O) is reachable via the HTTP API. The browser is needed exactly twice: to issue an API token and to attach the API-created Bash console the first time.
  • Firebase splits cleanly across the two sides: the public Web SDK config (apiKey etc.) is fine to commit to the Viewer's authFirebase.js; the secret service-account JSON lives only on PythonAnywhere, referenced from [firebase] in config.ini.
  • Gotcha: when POSTing input to the PythonAnywhere consoles/{id}/send_input/ endpoint via curl, & gets eaten as a form-data separator, so use --data-urlencode. Sending raw 2>&1 in a command line silently truncates at 2> and leaves the shell at a continuation prompt.

Starting point

ItemValue
Viewer hostingGitHub Pages (https://<gh-user>.github.io/codh-mirror/iiif-curation-viewer/) — static rehost of Wayback-captured assets
Viewer authauthFirebase.js plugin using Firebase JS SDK v5 (original codh-81041 project hardcoded)
Viewer save destinationindex.js references curationJsonExportUrl: 'https://mp.ex.nii.ac.jp/api/curation/json' in five places (the endpoint is currently unresponsive)
SymptomThe original Firebase project's authDomain doesn't list <gh-user>.github.io, so login fails; even if it succeeded, the save backend is unreachable

A "view-only" mirror works on GitHub Pages, but the full Curation editing workflow needs (1) a Firebase project you own plus (2) a JSONkeeper instance you run.

Landing state

ItemValue
FirebaseYour own project <firebase-pid>, with <gh-user>.github.io added to Authorized domains
JSONkeeperLong-running web app on PythonAnywhere Beginner at https://<pa-user>.pythonanywhere.com/, with server-side Firebase ID-token verification via the Admin SDK
Viewer-side patchfirebaseConfig in authFirebase.js swapped to your project; curationJsonExportUrl in index.js swapped to the PA URL
Deployment surfaceHTTP API + curl only. Browser is touched twice: PA API-token issuance + first-time Bash console attach
RollbackWhen reverting to the upstream URL, restore mp.ex.nii.ac.jp in index.js with a one-line sed; the PA web app can be deleted via one API call

1. Why JSONkeeper specifically — confirming compatibility

The Viewer's export plugin (icv.exportJsonKeeper.js) expects a strongly-typed server contract. Reading the relevant exportJsonKeeper.js:79-180 region:

ConcernRequired behavior
EndpointOne URL configured as curationJsonExportUrl (e.g. https://<host>/api)
CreatePOST, Content-Type: application/ld+json, body is the Curation JSON as text
UpdatePUT, Content-Type: application/ld+json, against the per-document URL returned earlier
DeleteDELETE
AuthX-Firebase-ID-Token: <Firebase ID token> in Firebase mode; anonymous POSTs may also be allowed
VisibilityX-Unlisted: true/false on POSTs
Success responseA Location header carrying the saved URL — the Viewer redirects the user to ?curation=<Location>
CORSCross-origin access from the Viewer domain plus the custom request headers

You could meet this with any framework in ~100 lines of code, but the implementation that ran at mp.ex.nii.ac.jp is IllDepence/JSONkeeper — confirmed by the original JSONkeeper software page and IIIF Curation Platform overview (both via the Wayback Machine), which credit the JSONkeeper / Canvas Indexer components to Tarek Saier (Albert Ludwigs University of Freiburg, intern at the National Institute of Informatics). The same author publishes the implementation on GitHub. The config.ini's default rewrite_types even hardcodes http://codh.rois.ac.jp/iiif/curation/1#Curation, so this is a Viewer-specific server.

[json-ld]
rewrite_types = http://codh.rois.ac.jp/iiif/curation/1#Curation,
                http://iiif.io/api/presentation/2#Range

That option makes JSONkeeper rewrite an incoming @id to its eventual storage URL when the document is a cr:Curation — exactly what the Viewer needs.

2. Choosing the host — what "free tier" actually means in 2026

ProviderFit for JSONkeeperCaveats
PythonAnywhere Beginner◎ Flask + SQLite work out of the box, default-persistent 512 MB disk, HTTPS by defaultNo SSH, 100 CPU-sec/day, outbound HTTP(S) whitelist
Oracle Cloud Always Free◎ Most performant, fully unconstrainedReal-VM ops burden (Nginx + certbot + gunicorn + systemd)
Fly.ioPost-2024 the "free allowance" remains but new accounts need a card; functionally pay-as-you-go
Render FreeSleeps on idle; no persistent disk on free; free Postgres is now 90-day trial only
Hugging Face Spaces (Docker)Persistent storage is a paid add-on
Cloud Run + Firestore×JSONkeeper is SQLAlchemy-backed; porting to Firestore is a rewrite
Vercel / Netlify / Workers×Serverless edge, no native persistent SQLite (Workers + D1 is a separate path — see closing section)

Of the three Beginner constraints, only two have practical impact:

  • 100 CPU-sec/day: easy to burn through during the first pip install. Web request handling is not counted, so this never bites once it's running.
  • Outbound whitelist: Firebase ID-token verification needs to reach securetoken.googleapis.com to fetch RS256 public keys. PA's default whitelist already covers Google API endpoints; in practice this worked without any change. If you do need an extra host opened, PA support adds entries within 1–2 business days.

"No SSH" turns out to be a non-issue because the HTTP API covers nearly all operations.

3. Making upstream JSONkeeper run on modern Python

Excerpt from requirements.txt:

Flask==1.0.2
apscheduler==3.5.3
Werkzeug==2.0.2
SQLAlchemy==1.3.23
Jinja2==3.0.3
itsdangerous==2.0.1
firebase_admin==2.12.0
PyLD==1.0.3

These pins date from 2018 (last push 2023). Trying python3 -c "from jsonkeeper import create_app; create_app()" on Python 3.11 fails twice:

ImportError: cannot import name 'MutableMapping' from 'collections'

flask/sessions.py in Flask 1.0.2 imports from collections import MutableMapping, but in Python 3.10 it was moved to collections.abc.

ModuleNotFoundError: No module named 'pkg_resources'

apscheduler/__init__.py in 3.5.3 imports from pkg_resources import …, but Python 3.12+ no longer ships setuptools (and thus pkg_resources) by default.

Fix — exactly two extra pip installs on top of the original requirements:

pip install -r requirements.txt
pip install 'Flask==2.0.3' 'apscheduler>=3.10'

Flask==2.0.3 is the oldest 2.x that's consistent with the other pins (Werkzeug 2.0.2 / Jinja2 3.0.3 / itsdangerous 2.0.1). With this combination both local (macOS, Python 3.11) and PythonAnywhere (Python 3.11) boot cleanly.

Local sanity check:

$ python3.11 -m venv venv && source venv/bin/activate
$ pip install -r requirements.txt 'Flask==2.0.3' 'apscheduler>=3.10'
$ python3 run.py
 * Running on http://127.0.0.1:5000/

# in another terminal
$ curl -i -X POST -H 'Content-Type: application/json' -d '{"hello":"local"}' http://127.0.0.1:5000/api
HTTP/1.0 201 CREATED
Location: http://localhost:5000/api/211490b0-1060-47f6-8498-a4279f2dee36
…
{"hello":"local"}

4. Firebase preparation

The Firebase Web SDK config (apiKey and friends) is not a secret — the real authorization gate is the Authorized-domains list in the Firebase console — so it can be committed to the Viewer source as-is. The service-account JSON for the Admin SDK, on the other hand, is a full secret and must be handled separately.

PurposeWhere to get itWhere to put itSensitivity
Web SDK configFirebase Console → "Add a web app"Viewer repo's authFirebase.jsPublic (commit OK)
Service-account JSONFirebase Console → Project settings → Service accounts → "Generate new private key"PA only, at ~/JSONkeeper/firebase-adminsdk.jsonSecret. Never commit. Never paste into chat.

Also: in Firebase Console → Authentication → Settings → Authorized domains, add <gh-user>.github.io. Forgetting this is the single biggest stumbling block for the GitHub-Pages-hosted mirror — the Viewer's login popup will die with "unauthorized domain" otherwise. (The original codh-81041 project, of course, does not list <gh-user>.github.io.)

5. Getting a PythonAnywhere API token

The first of two browser visits.

  1. Open https://www.pythonanywhere.com/account/#api_token
  2. Click Create a new API token
  3. Copy the token immediately — you cannot view it again after closing the dialog

Then in your local shell:

export PA_USER=<pa-user>
export PA_TOKEN=<PA_TOKEN>
export PA_API="https://www.pythonanywhere.com/api/v0/user/${PA_USER}"
export PA_DOMAIN="${PA_USER}.pythonanywhere.com"

Read-only smoke test (no CPU cost):

$ curl -sS -H "Authorization: Token ${PA_TOKEN}" "${PA_API}/cpu/" | jq
{
  "daily_cpu_limit_seconds": 100,
  "daily_cpu_total_usage_seconds": 0.0,
  "next_reset_time": "2026-05-13T12:39:29"
}

A daily_cpu_limit_seconds: 100 response confirms both that the token is valid and that you are on the free Beginner plan.

6. Bootstrap on PythonAnywhere via the Consoles API

The Beginner plan does not include SSH, but the Consoles API is equivalent for our purposes. Create a console via the API, attach to it from a browser once (the second browser visit), then drive everything afterwards via send_input + get_latest_output.

6-1. Create the console

curl -sS -H "Authorization: Token ${PA_TOKEN}" \
  -X POST "${PA_API}/consoles/" \
  -d "executable=bash" \
  -d "arguments=" \
  -d "working_directory=/home/${PA_USER}"
# => { "id": 46738749, "console_url": "/user/<pa-user>/consoles/46738749/", ... }

Remember the returned id (we'll call it $CID).

6-2. Attach the console from a browser, exactly once

curl -sS -H "Authorization: Token ${PA_TOKEN}" \
  "${PA_API}/consoles/${CID}/get_latest_output/"
# => { "error": "Console not yet started.  Please load it (or its iframe) in a browser first" }

On the free plan, a console created over the API is not the same as one that has been attached — until somebody loads the console URL in a browser, send_input / get_latest_output return that error. Open https://www.pythonanywhere.com/user/<pa-user>/consoles/<CID>/ in your browser, wait for the prompt, then close the tab. No commands need to be typed; the page load is enough.

Re-checking get_latest_output afterwards now returns something like Loading Bash interpreter… <pa-user>$, confirming the attach worked.

6-3. Send the bootstrap script as a file

Gotcha: & is eaten as a form-data separator

The first try was:

curl ... -X POST "${PA_API}/consoles/${CID}/send_input/" \
  -d $'input=bash ~/bootstrap.sh 2>&1\n'

Only bash ~/bootstrap.sh 2> reached the shell. It hung at the > continuation prompt for nine minutes, idle. The cause: with -d (i.e. application/x-www-form-urlencoded), curl interprets & as a field separator, so the server saw input=bash ~/bootstrap.sh 2> and 1\n as two fields, and the second (unnamed) field was discarded.

The right form is --data-urlencode:

curl ... -X POST "${PA_API}/consoles/${CID}/send_input/" \
  --data-urlencode "input=bash ~/bootstrap.sh
"

(The trailing newline inside the quoted string is the Enter that submits the command. 2>&1 is not needed — under a TTY-attached bash, both stdout and stderr appear in get_latest_output.)

If you do end up with a stuck console, send Ctrl-C and a newline:

curl ... -X POST "${PA_API}/consoles/${CID}/send_input/" --data "input=%03%0A"

%03 is byte 0x03 (Ctrl-C); %0A is LF.

The bootstrap script

Write it locally, then upload to PA via the Files API.

#!/bin/bash
set -e
cd ~
rm -rf JSONkeeper
git clone https://github.com/IllDepence/JSONkeeper.git
cd JSONkeeper
python3.11 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
pip install 'Flask==2.0.3' 'apscheduler>=3.10'
python -c "from jsonkeeper import create_app; app=create_app(); print('==BOOTSTRAP DONE==', len(list(app.url_map.iter_rules())), 'routes')"
# File API uploads use multipart-form with a `content` field
curl -sS -f -H "Authorization: Token ${PA_TOKEN}" -X POST \
  -F "content=@/tmp/bootstrap.sh" \
  "${PA_API}/files/path/home/${PA_USER}/bootstrap.sh"

Tell the console to run it:

curl -sS -H "Authorization: Token ${PA_TOKEN}" -X POST \
  "${PA_API}/consoles/${CID}/send_input/" \
  --data-urlencode "input=bash ~/bootstrap.sh
"

Poll for the completion marker:

URL_OUT="${PA_API}/consoles/${CID}/get_latest_output/"
START=$(date +%s)
until curl -sS -H "Authorization: Token ${PA_TOKEN}" "$URL_OUT" \
        | grep -q "==BOOTSTRAP DONE=="; do
  NOW=$(date +%s)
  [ $((NOW - START)) -gt 540 ] && { echo TIMEOUT; break; }
  sleep 20
done

In my run, git clone + pip install took 191 wall-clock seconds and consumed 30.7 CPU-seconds — well within the 100-second daily cap. The bulk of that comes from firebase_admin pulling in google-cloud-* extras.

7. Uploading the three configuration files

Three files go to PA via the Files API.

7-1. config.ini

[environment]
db_uri = sqlite:////home/<pa-user>/JSONkeeper/keep.db
server_url = https://<pa-user>.pythonanywhere.com
log_file = /home/<pa-user>/JSONkeeper/jk_log.txt

[api]
api_path = api

[firebase]
service_account_key_file = /home/<pa-user>/JSONkeeper/firebase-adminsdk.json

[json-ld]
rewrite_types = http://codh.rois.ac.jp/iiif/curation/1#Curation,
                http://iiif.io/api/presentation/2#Range

[activity_stream]
collection_endpoint = as/collection.json
activity_generating_types = http://codh.rois.ac.jp/iiif/curation/1#Curation,
                            http://iiif.io/api/presentation/2#Range

Notes:

  • db_uri has four slashes because SQLAlchemy's URI format is sqlite:/// + an absolute path that itself starts with /home/..., giving sqlite:////home/....
  • server_url is scheme + host with no trailing slash and no path.

7-2. wsgi_pythonanywhere.py (the entry point inside the project)

import sys, os

PROJECT_DIR = '/home/<pa-user>/JSONkeeper'
if PROJECT_DIR not in sys.path:
    sys.path.insert(0, PROJECT_DIR)
os.chdir(PROJECT_DIR)

from jsonkeeper import create_app
application = create_app()

7-3. PA's default WSGI file at /var/www/<pa-user>_pythonanywhere_com_wsgi.py

import sys
sys.path.insert(0, '/home/<pa-user>/JSONkeeper')
from wsgi_pythonanywhere import application  # noqa

PA auto-generates /var/www/<domain-with-underscores>_wsgi.py when a web app is created. I keep this file as a thin shim and put the real entry point inside the project directory, so it can be edited and version-controlled alongside the rest of the source.

Upload commands

BASE="${PA_API}/files/path"
# Firebase service-account JSON (secret — local → PA, never via Git)
curl -sS -f -H "Authorization: Token ${PA_TOKEN}" -X POST \
  -F "content=@/path/to/<firebase-pid>-firebase-adminsdk.json" \
  "${BASE}/home/${PA_USER}/JSONkeeper/firebase-adminsdk.json"

# config.ini
curl -sS -f -H "Authorization: Token ${PA_TOKEN}" -X POST \
  -F "content=@/local/path/config.ini" \
  "${BASE}/home/${PA_USER}/JSONkeeper/config.ini"

# wsgi_pythonanywhere.py
curl -sS -f -H "Authorization: Token ${PA_TOKEN}" -X POST \
  -F "content=@/local/path/wsgi_pythonanywhere.py" \
  "${BASE}/home/${PA_USER}/JSONkeeper/wsgi_pythonanywhere.py"

8. Create the web app → swap the default WSGI → reload

Webapps API time. On the Beginner plan the domain is forced to <pa-user>.pythonanywhere.com.

# Create
curl -sS -H "Authorization: Token ${PA_TOKEN}" -X POST "${PA_API}/webapps/" \
  -d "domain_name=${PA_DOMAIN}" \
  -d "python_version=python311"
# => { "status": "OK", "webapp_config_path": "..." }

# Replace PA's default WSGI with my thin shim
curl -sS -f -H "Authorization: Token ${PA_TOKEN}" -X POST \
  -F "content=@/local/path/pa_default_wsgi.py" \
  "${PA_API}/files/path/var/www/${PA_USER}_pythonanywhere_com_wsgi.py"

# Set source_directory + virtualenv_path on the webapp
curl -sS -H "Authorization: Token ${PA_TOKEN}" -X PATCH \
  "${PA_API}/webapps/${PA_DOMAIN}/" \
  -d "source_directory=/home/${PA_USER}/JSONkeeper" \
  -d "virtualenv_path=/home/${PA_USER}/JSONkeeper/venv"

# Reload
curl -sS -H "Authorization: Token ${PA_TOKEN}" -X POST \
  "${PA_API}/webapps/${PA_DOMAIN}/reload/"
# => { "status": "OK" }

9. Smoke test

# Root — liveness + stored document count
$ curl -sS -i "https://${PA_DOMAIN}/"
HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: *
...
{"message":"Storing 0 JSON documents."}

# Anonymous POST → 201 + Location
$ curl -sS -i -X POST -H 'Content-Type: application/json' \
       -d '{"hello":"pa"}' "https://${PA_DOMAIN}/api"
HTTP/1.1 201 CREATED
Location: https://<pa-user>.pythonanywhere.com/api/c3881e3f-26ed-4a2d-92f3-bfca76ce0826
Access-Control-Allow-Origin: *
...
{"hello":"pa"}

# GET back the same URL → 200 + original JSON
$ curl -sS "https://<pa-user>.pythonanywhere.com/api/c3881e3f-26ed-4a2d-92f3-bfca76ce0826"
{"hello":"pa"}

Confirm Firebase Admin SDK initialization via JSONkeeper's own log:

$ curl -sS -H "Authorization: Token ${PA_TOKEN}" \
       "${PA_API}/files/path/home/${PA_USER}/JSONkeeper/jk_log.txt" | tail -10
[2026-05-12 13:00:41]   starting
[2026-05-12 13:00:41]   using Firebase
[2026-05-12 13:00:58]   posted/put document:
[2026-05-12 13:00:58]       is JSON-LD: False
[2026-05-12 13:00:58]       received/has a dereferencable @id: False
[2026-05-12 13:00:58]       is unlisted: False
[2026-05-12 13:00:58]       access token: 

The using Firebase line means the service-account JSON loaded cleanly and a credential for <firebase-pid> was constructed; failures look like Firebase config error: … here.

CORS is fine out of the box: JSONkeeper writes Access-Control-Allow-Origin: * on every response (jsonkeeper/subroutines.py:419 add_CORS_headers), and reflects the requested Access-Control-Request-Headers on preflight responses (handled separately in CORS_preflight_response), so custom headers like X-Firebase-ID-Token flow through automatically.

10. Patching the Viewer (codh-mirror)

Two Viewer-side files change.

authFirebase.js

var firebaseConfig = {
    apiKey: '<firebase-web-api-key>',
    authDomain: '<firebase-pid>.firebaseapp.com',
    projectId: '<firebase-pid>',
    storageBucket: '<firebase-pid>.appspot.com',
    messagingSenderId: '<sender-id>',
    appId: '<app-id>'
};

Stick with the legacy *.firebaseapp.com and *.appspot.com forms for authDomain and storageBucket — Firebase's newer *.firebasestorage.app form is not always recognized by the v5-era Web SDK that this Viewer bundles.

index.js's curationJsonExportUrl

cd <codh-mirror-repo>
sed -i.bak \
  "s|https://mp.ex.nii.ac.jp/api/curation/json|https://<pa-user>.pythonanywhere.com/api|g" \
  iiif-curation-viewer/index.js
rm iiif-curation-viewer/index.js.bak

There are five occurrences of the same URL in index.js; the global replace catches all of them. Commit and push to ship via GitHub Pages.

11. Troubleshooting

The things I actually hit (or that are most likely for somebody else to hit):

SymptomCauseFix
send_input returns OK but the console shows no echoFree-plan consoles must be attached in the browser before sending inputOpen the console URL once in a browser. The prompt appearing is enough.
Part of a sent command vanishes (2>&1 truncates at 2>)curl -d treats & as a form-data separatorUse --data-urlencode "input=…". Send Ctrl-C with --data "input=%03%0A".
ImportError: cannot import name 'MutableMapping' from 'collections'Flask 1.0.2 is incompatible with Python 3.10+pip install 'Flask==2.0.3'
ModuleNotFoundError: No module named 'pkg_resources'apscheduler 3.5.3 depends on pkg_resources, which is no longer shipped on Python 3.12+pip install 'apscheduler>=3.10'
502 or "Something went wrong" on the web appWSGI import errorFetch PA's error log: curl -sS -H "Authorization: Token ${PA_TOKEN}" "${PA_API}/files/path/var/log/${PA_DOMAIN}.error.log"
POST 403 "Token verification failed"Outbound whitelist doesn't allow securetoken.googleapis.com etc.Email PA support: "Please add securetoken.googleapis.com to the free-tier whitelist for backend Firebase Admin SDK token verification"
Viewer login popup says "unauthorized domain"Firebase Console's Authorized domains list is missing the GitHub Pages domainAuthentication → Settings → Authorized domains → add <gh-user>.github.io
pip install cut off by the 100 CPU-seconds limitDaily cap on the Beginner planRe-run after the UTC reset; pip's cache makes the second run shorter
PyLD returns 400 Bad Request: No valid JSON-LD provided (this can be due to a context that can not be resolved)JSONkeeper tried to expand the JSON-LD @context over HTTP and failed (e.g. an external context URL like codh.rois.ac.jp/iiif/curation/1/context.json is unreachable)Either drop Content-Type to application/json, inline the full @context, or add a caching document loader to PyLD

12. Rollback (restoring the upstream URL)

# Restore the upstream URL in the Viewer
sed -i.bak \
  "s|https://<pa-user>.pythonanywhere.com/api|https://mp.ex.nii.ac.jp/api/curation/json|g" \
  <codh-mirror-repo>/iiif-curation-viewer/index.js

# Optionally tear down the PA web app
curl -sS -X DELETE -H "Authorization: Token ${PA_TOKEN}" \
  "${PA_API}/webapps/${PA_DOMAIN}/"

config.ini and firebase-adminsdk.json can be deleted individually with the Files API's DELETE method.

13. Why keep this "in cold storage"

After verifying the deployment in this article, I switched the primary save backend to a custom Cloudflare Workers + D1 reimplementation; the PA install is kept around but no longer in the hot path. The reasoning:

AxisPA (upstream Flask)Workers + D1
Practical free quota100 CPU-sec/day (request handling is exempt, but dependency updates burn through pip install)100k requests/day + D1 5 GB / 5 M reads / 100 k writes per day
Latency to JP clientsPA web workers run in EU/US regions (~200 ms RTT)Cloudflare edges in Tokyo/Osaka (~10–30 ms RTT)
Cold startPossible after long idleAlways warm at the edge
Outbound networkWhitelistedUnrestricted
DurabilityPA SQLite (file, account-bound)D1 (Cloudflare-managed, replicated)
Upstream parity◎ Full (X-Unlisted, /<id>/status, Activity Stream change discovery, GC, Range sub-URLs)△ Covers the Viewer's path (POST/GET/PUT/DELETE/userdocs/CORS/JSON-LD @id rewrite/Firebase) — but not X-Unlisted, Activity Stream pagination, GC, or Range sub-URLs

The Viewer's export flow only needs POST/PUT + Location header + JSON-LD @id rewrite + Firebase token verification, so Workers + D1 is sufficient there. Given the open-ended timeline before the upstream returns, I prefer the path that takes less hands-on attention to keep running for months at a time.

That said, the PA deployment is still valuable for use cases where strict upstream-behavior parity matters — for example, an external system that wants to subscribe to JSONkeeper's Activity Stream, or one that depends on the exact X-Unlisted semantics. So the setup from this article is exactly what I want to leave running side-by-side, and I run a two-backend deployment for the foreseeable future.

14. Security hygiene

The PythonAnywhere API token grants strong, account-wide permissions. As deployment work transitions into steady-state operation, revoke and reissue the token once, and keep the new one only on your local machine:

  1. Open https://www.pythonanywhere.com/account/#api_token
  2. Click Revoke API token
  3. Click Create a new API token to issue a fresh one

The service-account JSON works the same way: if it has been exposed in any unintended place, delete it in the Firebase Console's Service accounts page, generate a new key, replace the file on PA, and reload the web app.

Closing thoughts

The kernel of this project — "the IIIF Curation Viewer's save backend is unreachable" — is a small one, but having a reproducible self-hosted path back to a working setup felt worth writing down.

Three takeaways:

  • Upstream JSONkeeper is only two extra pip install commands away from working under Python 3.11.
  • PythonAnywhere's Beginner plan looks heavily constrained on paper ("no SSH", "100 CPU-sec/day"), but combined with the rule that web-request handling is outside the CPU cap, it's a perfectly reasonable home for a small-to-medium Flask app long-term.
  • If the workflow can be reduced to two browser actions (issue an API token, attach a console once), the rest of a Beginner-plan deployment fits in a small curl script — a big win for reproducibility and for handing the setup to somebody else.

When the upstream URL returns, the plan is to archive the whole codh-mirror repository (along with this PA-hosted JSONkeeper) and switch all consumers back to the upstream URL. Until then, the two-backend approach — PA in cold storage as the upstream-faithful reference, Workers + D1 as the hot path — has been working out cleanly.

If you're in a similar position and want to keep the save side of IIIF Curation tooling alive, I hope this is useful.