AXe Skills HubSearch /

AXe Skills Hub — Distributor Accounts Design

*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.

What exists today

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.

Identity provider: a pluggable boundary

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.

The three candidates

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.

AuthGateCloudflare AccessWorkOS
Self-service signupYes (can build)NoYes (built-in)
Fleet-nativeYesPartialNo
API key planeYes (keychain)NoNo (must build)
MFA / email verifyMust buildEmail onlyYes (built-in)
Operational costOurs to runIncluded in CF planPer-MAU pricing
Integration effort hereHigh (zero hooks today)None (already in place)Medium
Programmable user mgmtYesNoYes

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.

1. Schema

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.

users

CREATE 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_tenants

CREATE 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_keys

CREATE 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.

2. Key issuance

Format

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.

Generation and storage

them.

new UUID. The user_id and tenant_id come from the authenticated session.

It never appears again in any log, DB column, or API response.

Hot-path lookup

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:

SHA-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.

Issuance endpoint

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).

3. What a key authorizes

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.

Routes a key unlocks (on :8741 only)

RouteWhat it doesKey required
POST /v1/skillsPublish a new skill version to the key's tenantYes
POST /v1/skills/<name>/outcomeRecord a usage outcomeYes
GET /v1/auditRead the audit log for the key's tenantYes

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.

What a compromised key can do

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:

surface — the federation ledger is append-only via the cron job, not via the

API).

What must be in place before keys are issued

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.

timestamp. At minimum, key IDs must appear in audit.actor so a revocation

event can be traced.

string. 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.

4. Tiers

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.

Free tier

at 100 signups).

Paid tier

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.

5. The client

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.

Remote install path

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.

MCP surface

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.

Sequencing: CLI before MCP

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.

read from the public catalogue into the agent's skill directory. This is safe

and has no auth complexity.

tenant installs and for hub publish. This is the first step that touches key

handling in the client.

behind key auth. This is the highest-complexity step and should not be

sequenced until the key issuance and account DB are proven stable.

6. Build order

Each step is independently shippable. No step is a prerequisite for the next

unless noted.

Step 0: Fix the API dedupe defect (unblocks reliable client builds)

/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.

Step 1: Schema migration (requires James's approval to run)

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.

Step 2: Key issuance endpoint (no identity provider yet)

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.

Step 3: AuthGate integration [IDP BOUNDARY] (requires James's approval)

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.

Step 4: Tier enforcement

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.

Step 5: Remote install CLI

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.

Step 6: Operator MCP read tool

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.

Step 7: Paid tier and billing (DECISION NEEDED: billing provider)

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.

Open decisions

Step 7.

effect of any step; any relaxation is a separate, explicit decision by James.