*Status: DRAFT — design only, no code changed. DEV suffix: not yet approved.*
This document designs the account and key layer that turns portal.axe.onl from
an operator-only console into a self-service skills distributor — the
Cursor model applied to AI skills: sign up, get a key,
publish a skill, install it from any agent or CLI.
Everything described here is additive. The 83,409-tool read-only catalogue stays
public and free. The changes add a layer on top of the existing code;
the existing behaviour is not altered as a precondition of any step.
The recon conducted 2026-09-12 established the following baselines, which every
design decision below takes as given.
Two processes, one module. hub/serve.py runs twice: once on :8741
(portal.axe.onl, writable, Cloudflare Access gate) and once on :8742
(skills.axe.onl / operator.axetechnologies.ca, READ_ONLY=True, public
internet). The only thing separating them is a flag passed to serve() in
axeskills-run-operator-portal.py and axeskills-run-public-portal.py. The
READ_ONLY flag refuses every POST before the body is read and removes the
audit route — it is the correct isolation mechanism and must remain in place on
the public port regardless of what this spec adds.
No accounts, no keys. key_map (hub/serve.py:22, serve.py:705) is a
dict[str, str] mapping raw key strings to tenant IDs. Both launchers pass
key_map={}. The lookup path exists (_resolve_tenant_for_write, serve.py:56),
but nothing generates or stores keys. As a result, the write routes on :8741
are technically enabled but practically unreachable: _resolve_tenant_for_write
returns None for every request without an explicit X-AXE-Tenant header
(serve.py:60–64), and the gate returns 403.
Schema. axeskills-federation.db has six tables: tenants, skills,
audit, skill_outcomes, skill_candidates, promotion_decisions, and a
derived skill_categories. There is no users table, no api_keys table, no
sessions table. The tenants table has two rows: community-quarantine (the
federated public catalogue, 111,142 rows) and axe (first-party skills, 82 rows).
skills.name is both PK and address. The primary key is `(tenant_id, name,
version). The /v1/skills/<name>` route resolves to the latest non-yanked
version of that name within the caller's tenant. Publishing a skill under a name
that already exists in the same tenant creates a new version, not a new row. This
constraint matters to the account model: two accounts sharing a tenant would
collide on names.
The identity layer — signup, login, token issuance, session management — is a
hard boundary in this design. The hub does not implement OAuth flows, password
hashing, email verification, or MFA. It trusts a single artefact: a short-lived
JWT or a hashed API key, both of which the identity provider mints and the hub
verifies without talking to the provider on the hot path.
This keeps the hub stateless with respect to identity (the DB holds hashed key
material, not session state) and means the identity provider can be swapped
without touching hub/serve.py.
AuthGate (authgate.cloud) is the fleet-native identity service. It has a
keychain plane that already manages secrets for other AXE services, and it is
the only candidate that is already under our operational control. Integration
cost is real: there are zero AuthGate hooks in this codebase today. The hub
would need to add a token-verification call on sign-in, a webhook receiver for
account-created events, and a shared secret between the hub and AuthGate. The
keychain plane is the right home for API key issuance metadata (not the key
itself, which lives hashed in the hub DB).
Cloudflare Access is the current gate on portal.axe.onl. It is an
allowlist, not a self-service signup system. It has no account creation flow, no
per-account API keys, and no programmatic user management in the CF Access API
that would let a new visitor register themselves. CF Access is **not a candidate
for public signup** and must not be presented as one. It will remain in front of
the operator console as a defense-in-depth layer on top of whatever identity
provider we add, but it cannot be the identity provider itself.
WorkOS is a hosted B2B auth provider (SSO, directory sync, magic links). It
adds developer portal quality and handles email verification and MFA out of the
box. Cost scales with monthly active users. It has no existing relationship with
the AXE fleet.
| AuthGate | Cloudflare Access | WorkOS | |
|---|---|---|---|
| Self-service signup | Yes (can build) | No | Yes (built-in) |
| Fleet-native | Yes | Partial | No |
| API key plane | Yes (keychain) | No | No (must build) |
| MFA / email verify | Must build | Email only | Yes (built-in) |
| Operational cost | Ours to run | Included in CF plan | Per-MAU pricing |
| Integration effort here | High (zero hooks today) | None (already in place) | Medium |
| Programmable user mgmt | Yes | No | Yes |
Recommendation: AuthGate. The fleet already trusts it; the keychain plane
solves API key issuance as a first-class problem; and paying per-MAU for WorkOS
is premature before we know signup volume. The integration work is real — a
sign-in endpoint, a JWT verification middleware, and a webhook handler — but it
is one-time, and it keeps all identity data on infrastructure we control.
The remainder of this spec assumes AuthGate. Where the interface is pluggable,
the section is labelled [IDP BOUNDARY] to mark where an alternative provider
would substitute.
DECISION NEEDED: James must confirm the identity provider before any
account-related code is written.
The existing tenants table is not touched. It records the two current tenants
and will record new per-account tenants as they are created. No column needs to
be added to it for this feature; the foreign-key relationship runs the other way.
usersCREATE TABLE users (
id TEXT PRIMARY KEY, -- UUID v4
email TEXT NOT NULL UNIQUE,
display_name TEXT,
idp_subject TEXT NOT NULL UNIQUE, -- AuthGate subject claim [IDP BOUNDARY]
tier TEXT NOT NULL DEFAULT 'free'
CHECK (tier IN ('free', 'paid')),
created_at TEXT NOT NULL,
suspended_at TEXT -- NULL = active
);
CREATE INDEX users_by_email ON users (email);
Why a separate table: the existing tenants table records a catalogue namespace,
not a human identity. A user may own more than one tenant (a team account
scenario), and a tenant may eventually be shared by more than one user. Folding
the identity into tenants would make that impossible without a schema
rewrite later.
idp_subject is the stable claim from AuthGate (its equivalent of sub in
OIDC). It must be stable across password resets and email changes. Storing it
here means the hub can validate a JWT without a round-trip to AuthGate on every
request — the subject is looked up once at sign-in and cached in the session or
the API key.
account_tenantsCREATE TABLE account_tenants (
user_id TEXT NOT NULL REFERENCES users (id),
tenant_id TEXT NOT NULL REFERENCES tenants (tenant_id),
role TEXT NOT NULL DEFAULT 'owner'
CHECK (role IN ('owner', 'member')),
granted_at TEXT NOT NULL,
PRIMARY KEY (user_id, tenant_id)
);
Why a separate table: the many-to-many relationship between users and tenants
cannot live in either parent table without either duplicating rows or adding an
array column to a SQLite schema that currently has no arrays.
A new tenant is created at the moment a user signs up, named after the user's
chosen namespace. The signup handler inserts into tenants and into
account_tenants in the same transaction.
api_keysCREATE TABLE api_keys (
id TEXT PRIMARY KEY, -- UUID v4, returned to the caller once at issuance
user_id TEXT NOT NULL REFERENCES users (id),
tenant_id TEXT NOT NULL REFERENCES tenants (tenant_id),
key_hash TEXT NOT NULL UNIQUE, -- SHA-256 of the raw key, hex-encoded
prefix TEXT NOT NULL, -- first 8 chars of the raw key, for display
label TEXT, -- human name, e.g. "CI deploy key"
created_at TEXT NOT NULL,
last_used_at TEXT,
revoked_at TEXT -- NULL = active
);
CREATE INDEX api_keys_by_user ON api_keys (user_id);
CREATE INDEX api_keys_by_tenant ON api_keys (tenant_id);
Why a separate table: a user may hold more than one key (different machines,
different CI pipelines), each independently revocable. Folding keys into users
would allow only one key per user. Folding them into tenants would obscure
which user issued which key. The api_keys table is also where revocation
lives; an audit trail that names an actor at publish time (skills.created_by)
has to be able to trace back to a specific key, not just a tenant.
The raw key is never stored. At issuance the hub generates a random value,
returns it once to the caller, and stores only SHA-256(key) in key_hash.
There is no recovery path; a lost key must be revoked and reissued. This is the
same model used by GitHub personal access tokens and the AXE fleet's own
bnk_-prefixed keys.
Keys follow the fleet naming convention: a short prefix identifying the issuer,
followed by an underscore and 32 random hex characters:
axehub_<32 random hex chars>
Example (placeholder): axehub_a3f7c2d9e1b4f6a8c0e2d4f6a8b0c2d4
The axehub_ prefix distinguishes hub keys from fleet keys (bnk_, axe_) in
logs and secret scanners. 32 hex characters is 128 bits of entropy — beyond
brute-force. The prefix is stored in api_keys.prefix (the first 8 characters
of the full key, i.e. axehub_a3) for display in the account dashboard without
exposing the secret.
DECISION NEEDED: confirm axehub_ as the prefix convention, or align with
whatever the fleet's key registry uses.
os.urandom(16)) and hex-encodethem.
axehub_ + hex string.key_hash = hashlib.sha256(raw_key.encode()).hexdigest().api_keys with key_hash and prefix. The id column is anew UUID. The user_id and tenant_id come from the authenticated session.
It never appears again in any log, DB column, or API response.
hub/serve.py currently loads key_map at startup as a static dict
(serve.py:705). With persistent keys that dict cannot be pre-loaded — it would
be stale the moment a new key is issued or a key is revoked.
Two options:
X-AXE-Key, computeSHA-256(header_value) and query `api_keys WHERE key_hash = ? AND revoked_at
IS NULL`. This is one indexed read per request. At the current write-request
volume (near zero) this is acceptable and the safest default.
invalidated every N seconds. Adds revocation lag equal to TTL. Not recommended
until profiling shows the per-request DB read is a bottleneck.
The key_map parameter in make_server() (serve.py:739) becomes a DB path
reference rather than a static dict. The handler's _resolve_tenant_for_write
method (serve.py:56) becomes a DB call.
POST /account/keys
Authorization: Bearer <idp_jwt> [IDP BOUNDARY]
Content-Type: application/json
{ "tenant_id": "<namespace>", "label": "my-ci-key" }
Response (201, returned once only):
{
"id": "<uuid>",
"key": "axehub_<32 hex chars>",
"prefix": "axehub_a3",
"tenant_id": "<namespace>",
"label": "my-ci-key",
"created_at": "<iso8601>",
"warning": "Store this key now. It will not be shown again."
}
Revocation:
DELETE /account/keys/<id>
Authorization: Bearer <idp_jwt>
Sets revoked_at in api_keys. The key is immediately dead. No grace period.
This endpoint family lives on :8741 only. The public port's READ_ONLY mode
covers it by refusing all POSTs before the body is read (serve.py:592).
Issuing working keys changes the security surface in a way that is worth stating
plainly: today the write routes on :8741 exist in code but are unreachable
because key_map is empty. Once real keys exist, those routes are **reachable
by anyone who holds a key**. The gate that matters becomes the strength of key
issuance, not the presence of a 403.
:8741 only)| Route | What it does | Key required |
|---|---|---|
POST /v1/skills | Publish a new skill version to the key's tenant | Yes |
POST /v1/skills/<name>/outcome | Record a usage outcome | Yes |
GET /v1/audit | Read the audit log for the key's tenant | Yes |
These three routes are the complete write surface. DELETE and PUT return 405
unconditionally (serve.py:162, serve.py:165). There is no route to delete a
tenant, read another tenant's skills, or modify the federation ledger.
A stolen key can:
execution happens at publish time. The damage is namespace pollution and
potential supply-chain confusion if a downstream agent installs from the
compromised tenant.
A stolen key cannot:
community-quarantine tenant's write path (it has no writablesurface — the federation ledger is append-only via the cron job, not via the
API).
:8742 in any way.compromised key can fill the DB. Add a counter in skill_outcomes or a
separate rate_counters table; the simplest form is `MAX(n publishes per
tenant per hour)`.
it, a free key is unlimited storage. The users.tier column is the gate;
the publish handler checks it before inserting.
audit table already records every publish with actor andtimestamp. At minimum, key IDs must appear in audit.actor so a revocation
event can be traced.
skills.created_by stores a free-form actorstring. Going forward it should store the api_keys.id UUID so the audit
trail survives key revocation.
**The public origin's READ_ONLY posture is James's call and must not be
lifted as a side effect of adding signup.** Adding account tables and key
issuance to the DB has no effect on READ_ONLY — it is a flag passed at
process startup, not read from the DB. But the plist for the public process
(axeskills-public-portal.plist) must not be edited as part of this work.
Any change to the public posture requires an explicit decision, separate from
this spec.
Reads are free forever. The 83,409-tool catalogue is the distribution moat.
An agent that cannot read the catalogue for free has no reason to use this hub
over any other source. Gating reads would shrink the funnel before a single
skill is published. This is not up for debate; it is the premise of the
distributor model.
at 100 signups).
Paid gates the things that cost us or that create asymmetric advantage:
search results from it are not surfaced in the public catalogue unless the user
explicitly publishes to community-quarantine (a separate action). Paid adds
no change here — namespace privacy is the default for any tenant — but paid
may eventually gate federated indexing (appearing in the public catalogue
with a verified badge).
under one billing relationship.
DECISION NEEDED: pricing. A flat monthly fee per account (Cursor's model) is
simpler to reason about than per-skill or per-install billing. Per-install billing
requires install tracking infrastructure that does not exist.
The users.tier column is the gate. The publish handler reads users.tier for
the key's owner before accepting a publish. No other route is tier-gated; reads
and search are always open.
Today hub/install.py installs a skill from a local DB into
~/.claude/skills/<name>/SKILL.md (hub/install.py:20). It has no concept of
a remote server. The provenance file (.axe-provenance.json) records the source
DB path, not a URL. This works for operators with local access to the DB; it
does not work for anyone who signed up via the web.
The remote install path has one addition: replace Registry(db_path).fetch()
with GET https://portal.axe.onl/v1/skills/<name>. The rest of install.py
(provenance file, checksum, directory layout) is unchanged. The HTTP call
returns the same shape as the local Skill object.
No auth is required to install from the public catalogue (reads are free). Auth
is only required if the caller wants to install from a private tenant, in which
case they pass --key axehub_<...> and the server resolves the tenant from the
key.
The shared Operator MCP (mcp.axe.onl, the PUBLIC shared MCP) exposes
hub_* read tools today. A write-capable install tool (hub_install_skill)
would be appropriate there — installing is a read from the hub's perspective, and
no key is needed for the public catalogue.
A publish tool must never go behind the public Operator MCP name. The
Operator MCP is public and shared; a hub_publish_skill tool behind it would
mean any agent on any session could publish to the hub using the Operator's
identity. Write-capable tools belong in a private, authenticated MCP — either
behind AuthGate directly, or behind a per-account MCP endpoint that validates
the caller's key before dispatching to the hub. This is a hard rule that follows
from the existing security boundary.
no MCP server changes. Add a hub install <name> --from https://portal.axe.onl
subcommand to hub/cli.py that calls the remote API. Ship this before any
MCP work.
hub_install_skill to the Operator MCP — aread from the public catalogue into the agent's skill directory. This is safe
and has no auth complexity.
--key <axehub_key> to the CLI for privatetenant installs and for hub publish. This is the first step that touches key
handling in the client.
hub_publishbehind key auth. This is the highest-complexity step and should not be
sequenced until the key issuance and account DB are proven stable.
Each step is independently shippable. No step is a prerequisite for the next
unless noted.
/v1/skills currently returns all versions of every skill — 111,141 rows for a
83,409-tool catalogue (store.py:190, confirmed live on
operator.axetechnologies.ca). Any client that builds a tool map from the list
endpoint will see duplicate name values and inflated counts. Fix this first:
deduplicate /v1/skills and /v1/skills/search using the same `GROUP BY name,
MAX(rowid) CTE that OperatorCatalog already uses (axeskills_operator_portal.py:642`).
No existing test depends on the multi-version listing. This is a bug fix, not a
feature, and it ships to the live service without any account work.
Add users, account_tenants, and api_keys tables. This is additive — no
existing row or column is touched. The migration runs against the federation DB.
Verified by: .schema output shows the three new tables; existing row counts are
unchanged.
Add POST /account/keys behind a hardcoded shared secret (a temporary bootstrap
credential stored in the environment, not in code). This lets us test the full
key-generation, storage, and lookup path before AuthGate integration is done.
Verified by: a key issued via the endpoint is accepted by POST /v1/skills.
This step is deliberately behind a temporary auth mechanism, not the real IDP.
It ships nothing to users.
Wire the JWT verification middleware. The /account/keys endpoint moves from
shared-secret auth to AuthGate JWT validation. The signup flow (account creation,
tenant provisioning) is implemented here. Verified by: a new signup through the
AuthGate flow receives a working key.
Add the publish-handler checks against users.tier and per-tenant quota
counters. Verified by: a free-tier key is rejected after N publishes; a paid-tier
key is not.
Add hub install --from <url> to hub/cli.py. Verified by: a skill in the
public catalogue is installed to ~/.claude/skills/ via the CLI without a local
DB.
Add hub_install_skill to the shared Operator MCP. Verified by: an agent session
can install a skill from the catalogue into its own skill directory in one call.
Everything before this step is free-tier infrastructure. This step gates the paid
features and connects to a payment processor. It requires a separate decision on
billing provider and pricing before it can be designed further.
axehub_ proposed; confirm alignment with fleet key registry.Step 7.
READ_ONLY on :8742 must not change as a sideeffect of any step; any relaxation is a separate, explicit decision by James.