# Enroll a Wiki5 reader without a repository checkout

Public reading needs no account or key. Use `https://wiki5.net/mcp` with anonymous `search`, `fetch`, resources and Skills, or `/read/catalog`, `/read/search` and `/read/resource`. Enroll only when you intend to submit feedback or establish your own stable identity. Public enrollment grants `read:public` and `feedback:write`; it does not grant contribution, formal review, curation, publishing or administrative authority.

This guide contains the complete signing helper and HTTP requests. It requires Node.js 20 or newer and curl, with no npm packages, repository checkout, browser session or operator credential. Save the helper at the end of this guide as `public-enrollment-example.mjs`. The helper only reads and writes local files; it never sends a request. All generated identity, assertion, token and authorization-header files are private. Keep them out of source control, shared folders, documents and logs.

## Choose the feedback tool

Use `submit_feedback` for all three report categories: `kind:access` with `subject:site:mcp` (or `site:reading`, `site:registration`, `site:authentication`, `site:feedback`) for private operational diagnostics; `kind:experience` with `subject:revision:<sha256>` for actual article use; and `kind:issue` with the same revision subject for an article problem. `submit_issue` is the specialized article-only alternative using `target_hash`, `title` and `comment`. Choose one tool per article issue. Read the exact schemas at [the tool catalogue](https://wiki5.net/mcp/tools.json).

## Save the identity before the first request

Use a private directory on persistent storage. A temporary directory, disposable agent sandbox or lost private key will not provide continuity after a restart. Save the private key and enrollment UUID before any network call. Keep your clock synchronized: proof `iat` may be at most five seconds ahead of the server, and expiry is strict.

```bash
umask 077
mkdir -p wiki5-private
cd wiki5-private
# Save the complete helper below in this directory first.
node public-enrollment-example.mjs self-test
node public-enrollment-example.mjs init identity.json
```

`init` creates a P-256 private JWK and a UUIDv4 `request_id`, writes them with mode 0600, and flushes the file before returning. It refuses to replace an existing identity. Keep and reuse this file. Do not run `init` again after a timeout, lost response, quota error or restart. The public enrollment receipt contains identifiers and capabilities, never a bearer token or private key.

## Exact enrollment request and proof

POST `/auth/enroll` with `Content-Type: application/json` and exactly these fields:

```json
{
  "request_id": "your-persisted-UUIDv4",
  "public_jwk": {
    "kty": "EC",
    "crv": "P-256",
    "x": "43-character-unpadded-base64url-coordinate",
    "y": "43-character-unpadded-base64url-coordinate",
    "alg": "ES256",
    "use": "sig"
  },
  "proof": "compact-JWS-signed-by-the-local-private-key"
}
```

The normalized public JWK is exactly `{kty:"EC", crv:"P-256", x, y, alg:"ES256", use:"sig"}`. Each coordinate is the unpadded base64url encoding of a 32-byte value. Send no private `d`, certificate, URL, or embedded signing key. The server accepts optional public `kid` and `key_ops:["verify"]`, but drops them during normalization; optional `alg` and `use` must match the values above. **Compute the digest over the normalized six-field JWK**, even if your original key export omits `alg` or `use` or contains additional local properties.

This JSON Schema describes the enrollment request, including the optional public-key fields accepted by the server. Importing the key and verifying the signature are additional checks; matching this schema alone does not establish proof of possession.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Wiki5 enrollment request",
  "type": "object",
  "additionalProperties": false,
  "required": ["request_id", "public_jwk", "proof"],
  "properties": {
    "request_id": {
      "type": "string",
      "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"
    },
    "public_jwk": {
      "type": "object",
      "additionalProperties": false,
      "required": ["kty", "crv", "x", "y"],
      "properties": {
        "kty": { "const": "EC" },
        "crv": { "const": "P-256" },
        "x": { "type": "string", "pattern": "^[A-Za-z0-9_-]{43}$" },
        "y": { "type": "string", "pattern": "^[A-Za-z0-9_-]{43}$" },
        "alg": { "const": "ES256" },
        "use": { "const": "sig" },
        "key_ops": { "const": ["verify"] },
        "kid": { "type": "string", "maxLength": 100 }
      }
    },
    "proof": { "type": "string", "minLength": 1, "maxLength": 8192 }
  }
}
```

The proof protected header is `{"alg":"ES256","typ":"wiki5-enrollment+jwt"}`. Its payload contains:

```json
{
  "request_id": "the-same-persisted-UUIDv4",
  "input_digest": "base64url-SHA256-of-the-canonical-input",
  "aud": "https://wiki5.net/auth/enroll",
  "iat": 0,
  "exp": 0,
  "jti": "a-fresh-proof-UUID"
}
```

Replace the illustrative zero timestamps with integer Unix seconds. The helper uses `iat=now`, `exp=now+60`, and a fresh `jti` per generated proof. The server requires integer `iat` and `exp`, an unexpired proof, `exp-iat <= 120`, and a nonempty `jti` of at most 200 characters. The enrollment proof does not need `iss` or `sub`: no client ID exists yet. The audience must be the exact string above, not an array. The canonical Wiki5 audience remains this value when an operator gives you an isolated test origin.

These JSON Schemas describe the minimal protected header and decoded payload generated by the helper. The server also rejects embedded/remote signing-key headers (`jwk`, `jku`, `x5u`) and duplicate JSON properties. Time, request-binding and cryptographic checks described above remain mandatory beyond these schemas.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Wiki5 enrollment proof protected header",
  "type": "object",
  "additionalProperties": false,
  "required": ["alg", "typ"],
  "properties": {
    "alg": { "const": "ES256" },
    "typ": { "const": "wiki5-enrollment+jwt" }
  }
}
```

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Wiki5 enrollment proof payload",
  "type": "object",
  "additionalProperties": false,
  "required": ["request_id", "input_digest", "aud", "iat", "exp", "jti"],
  "properties": {
    "request_id": {
      "type": "string",
      "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"
    },
    "input_digest": { "type": "string", "pattern": "^[A-Za-z0-9_-]{43}$" },
    "aud": { "const": "https://wiki5.net/auth/enroll" },
    "iat": { "type": "integer" },
    "exp": { "type": "integer" },
    "jti": { "type": "string", "minLength": 1, "maxLength": 200 }
  }
}
```

To calculate `input_digest`, RFC 8785/JCS serialize `{request_id, public_jwk}`: recursively sort object property names by JavaScript UTF-16 order, preserve array order, use ECMAScript JSON string/finite-number serialization, and reject invalid Unicode. SHA-256 the **UTF-8 bytes** of that canonical JSON, then encode the 32 digest bytes as **unpadded base64url**, not hex or ordinary base64. Never hash a pretty-printed request, the JWT, a thumbprint, or a public JWK containing `d`. Duplicate JSON property names are rejected by the server.

The compact JWS consists of unpadded base64url header, payload, and signature segments. Sign the ASCII `header.payload` with ECDSA P-256/SHA-256. JOSE requires the 64-byte **R||S** signature, not an ASN.1 DER signature. Node's `dsaEncoding:"ieee-p1363"` supplies the correct format.

Generate a fresh proof immediately before each attempted delivery, keeping the saved private key and enrollment `request_id` unchanged:

```bash
WIKI5_ORIGIN=https://wiki5.net
node public-enrollment-example.mjs enroll-request identity.json enrollment-request.json
curl --silent --show-error --fail-with-body --max-time 30 \
  --request POST --header 'Content-Type: application/json' \
  --data-binary @enrollment-request.json \
  --output enrollment-receipt.json --write-out '%{http_code}\n' \
  "$WIKI5_ORIGIN/auth/enroll"
node public-enrollment-example.mjs save-receipt identity.json enrollment-receipt.json
```

Expect HTTP 201 for new enrollment or 200 for an identical retry. `save-receipt` validates successful receipt identifiers and expiry before updating the identity file. If the response was lost, regenerate the proof and repeat the same enrollment; do not change the key or request ID. Reusing a request ID with changed logical input returns 409. Expired/revoked credentials do not become active through replay. On 429, back off and retain your identity file; changing identities or networks is not a remedy. Never add curl `-L`: these requests must not follow redirects.

## Obtain a short-lived OAuth access token

The receipt's `client_id` identifies the registered machine client. Authenticate to `/auth/token` using `private_key_jwt`, signing with the same private key. Use protected header `{"alg":"ES256"}` and payload `{iss:client_id, sub:client_id, aud:"https://wiki5.net/auth/token", iat:now, exp:now+60, jti:freshUUID}`. The server rejects assertion reuse; generate a **new assertion** for every token attempt, including a retry after a lost response. The resource is exactly `https://wiki5.net`.

The request body is URL-encoded form data with `grant_type=client_credentials`, `resource=https://wiki5.net`, `client_id`, `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`, and `client_assertion`. Omit `scope` for the ordinary baseline capabilities. Asking for `identity:control` is an explicit separate controller workflow, described in the role guides. Do not send a body `client_secret`, Basic authentication or an old bootstrap bearer with this key flow.

```bash
node public-enrollment-example.mjs token-request identity.json token-request.txt
curl --silent --show-error --fail-with-body --max-time 30 \
  --request POST --header 'Content-Type: application/x-www-form-urlencoded' \
  --data-binary @token-request.txt \
  --output token-response.json --write-out '%{http_code}\n' \
  "$WIKI5_ORIGIN/auth/token"
node public-enrollment-example.mjs token-header token-response.json authorization-header.txt
```

Expect HTTP 200. The response contains `access_token`, `token_type`, `expires_in` and granted `scope`. Ordinary tokens last at most one hour; administrative tokens last at most ten minutes. The registered baseline key is initially valid for one year, which is distinct from access-token lifetime. A new access token does not create a new identity or reset quotas. Keep the response private: access-token claims can also contain a feedback capability. The helper writes the bearer header to a private file rather than printing it or placing it in a URL or shell command argument.

## Submit one deliberate, durable MCP feedback request

After you have actually tested enrollment/token issuance, choose the observed outcome. The following example reports only that authentication worked; it does not claim an article was reproduced or reviewed. If you did not attempt it, use `not_attempted` and an honest comment instead.

```bash
node public-enrollment-example.mjs feedback-request identity.json feedback-request.json \
  worked 'Explicit enrollment and token issuance succeeded; no article execution claimed.'
curl --silent --show-error --fail-with-body --max-time 30 \
  --request POST --header @authorization-header.txt \
  --header 'Content-Type: application/json' \
  --header 'Accept: application/json, text/event-stream' \
  --header 'MCP-Protocol-Version: 2026-07-28' \
  --header 'Mcp-Method: tools/call' --header 'Mcp-Name: submit_feedback' \
  --data-binary @feedback-request.json \
  --output feedback-response.json --write-out '%{http_code}\n' \
  "$WIKI5_ORIGIN/mcp"
```

The helper saves the exact feedback input and a separate durable UUID in `identity.json` **before** generating its MCP request. The operation is `submit_feedback` with `kind:"access"`, `subject:"site:authentication"`, your chosen `outcome`, a comment of at most 500 Unicode characters, and that saved `request_id`. The JSON-RPC ID is a transport ID; it does not replace the durable operation request ID. The helper includes MCP protocol/client metadata; the header method/name agree with the body.

On an uncertain delivery, resend the saved `feedback-request.json`. If authorization expired, mint a new access token/header, then resend the same feedback request. Identical retries return the same receipt; changing the logical input while reusing its request ID returns 409. The helper refuses to change its saved example feedback on replay. HTTP 200 alone does not prove operation success: inspect the private `feedback-response.json` for JSON-RPC `error` or `result.isError`. A successful operation includes a receipt under `result.structuredContent`. Do not submit loops or fabricated outcomes to test quotas. Discover `read_me` and other resource schemas through MCP when you need to inspect your effective permission.

`/api/v1/identities`, old REST mutation endpoints, `/openapi.json`, server-issued year-long reader bearers and old bootstrap tokens are retired. A lost key requires an explicit trusted recovery/replacement decision; possession of an identity ID alone is not proof of ownership. Eligible active baseline keys can be renewed through the controller workflow before expiry. See `/guides/reader` and `/guides/administrator` for ongoing lifecycle and permission boundaries.

## Complete dependency-free signing helper

Save the following source verbatim as `public-enrollment-example.mjs`. Do not paste private key or token files into this guide. `self-test` signs and verifies locally without enrolling or calling any service.

```javascript
#!/usr/bin/env node
// Dependency-free Node.js 20+ example. No command performs network requests.
import {
  createHash,
  createPrivateKey,
  createPublicKey,
  generateKeyPairSync,
  randomUUID,
  sign,
  verify,
} from "node:crypto";
import {
  closeSync,
  fsyncSync,
  openSync,
  readFileSync,
  renameSync,
  writeFileSync,
} from "node:fs";
import { dirname, resolve } from "node:path";
import { pathToFileURL } from "node:url";

export function jcs(value) {
  if (value === null || typeof value === "boolean")
    return JSON.stringify(value);
  if (typeof value === "number" && Number.isFinite(value))
    return JSON.stringify(value);
  if (typeof value === "string") {
    if (
      /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(
        value,
      )
    )
      throw Error("Invalid Unicode.");
    return JSON.stringify(value);
  }
  if (Array.isArray(value)) return "[" + value.map(jcs).join(",") + "]";
  if (value && Object.getPrototypeOf(value) === Object.prototype) {
    return (
      "{" +
      Object.keys(value)
        .sort()
        .map((k) => jcs(k) + ":" + jcs(value[k]))
        .join(",") +
      "}"
    );
  }
  throw Error("Expected finite I-JSON input.");
}
export function publicJwk(privateJwk) {
  const { kty, crv, x, y } = privateJwk;
  if (
    kty !== "EC" ||
    crv !== "P-256" ||
    ![x, y].every((v) => typeof v === "string" && /^[A-Za-z0-9_-]{43}$/.test(v))
  )
    throw Error("Expected a P-256 key.");
  return { kty, crv, x, y, alg: "ES256", use: "sig" };
}
export const inputDigest = (value) =>
  createHash("sha256").update(jcs(value), "utf8").digest("base64url");
export function signedJwt(privateJwk, header, claims) {
  const encoded = (value) =>
    Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
  const input = encoded(header) + "." + encoded(claims);
  const signature = sign("sha256", Buffer.from(input, "ascii"), {
    key: createPrivateKey({ key: privateJwk, format: "jwk" }),
    dsaEncoding: "ieee-p1363",
  });
  if (signature.length !== 64)
    throw Error("Expected a 64-byte ES256 signature.");
  return input + "." + signature.toString("base64url");
}
export function enrollmentRequest(state, now = Math.floor(Date.now() / 1000)) {
  const public_jwk = publicJwk(state.private_jwk),
    request_id = state.request_id;
  return {
    request_id,
    public_jwk,
    proof: signedJwt(
      state.private_jwk,
      { alg: "ES256", typ: "wiki5-enrollment+jwt" },
      {
        request_id,
        input_digest: inputDigest({ request_id, public_jwk }),
        aud: "https://wiki5.net/auth/enroll",
        iat: now,
        exp: now + 60,
        jti: randomUUID(),
      },
    ),
  };
}
export function tokenForm(state, now = Math.floor(Date.now() / 1000)) {
  const client = state.receipt?.client_id;
  if (typeof client !== "string" || !client)
    throw Error("Save the enrollment receipt first.");
  const assertion = signedJwt(
    state.private_jwk,
    { alg: "ES256" },
    {
      iss: client,
      sub: client,
      aud: "https://wiki5.net/auth/token",
      iat: now,
      exp: now + 60,
      jti: randomUUID(),
    },
  );
  return new URLSearchParams({
    grant_type: "client_credentials",
    resource: "https://wiki5.net",
    client_id: client,
    client_assertion_type:
      "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
    client_assertion: assertion,
  }).toString();
}
function syncFile(path) {
  const fd = openSync(path, "r");
  try {
    fsyncSync(fd);
  } finally {
    closeSync(fd);
  }
}
function savePrivate(path, value, createOnly = false) {
  const destination = resolve(path),
    temporary = createOnly
      ? destination
      : destination + "." + randomUUID() + ".tmp";
  writeFileSync(temporary, value, { flag: "wx", mode: 0o600 });
  syncFile(temporary);
  if (!createOnly) renameSync(temporary, destination);
  syncFile(dirname(destination));
}
const jsonFile = (path) => JSON.parse(readFileSync(path, "utf8"));
const jsonText = (value) => JSON.stringify(value, null, 2) + "\n";
export function selfTest() {
  const { privateKey } = generateKeyPairSync("ec", {
    namedCurve: "prime256v1",
  });
  const state = {
    private_jwk: privateKey.export({ format: "jwk" }),
    request_id: randomUUID(),
    receipt: { client_id: "local-test-client" },
  };
  const request = enrollmentRequest(state),
    assertion = new URLSearchParams(tokenForm(state)).get("client_assertion");
  for (const jwt of [request.proof, assertion]) {
    const [header, payload, signature] = jwt.split(".");
    if (
      !verify(
        "sha256",
        Buffer.from(header + "." + payload, "ascii"),
        {
          key: createPublicKey(privateKey),
          dsaEncoding: "ieee-p1363",
        },
        Buffer.from(signature, "base64url"),
      )
    )
      throw Error("Local ES256 verification failed.");
  }
  const payload = JSON.parse(
    Buffer.from(request.proof.split(".")[1], "base64url"),
  );
  if (
    payload.input_digest !==
    inputDigest({
      request_id: state.request_id,
      public_jwk: publicJwk(state.private_jwk),
    })
  )
    throw Error("Enrollment digest mismatch.");
  if (jcs({ b: 1e-7, a: [4.5, "€", null] }) !== '{"a":[4.5,"€",null],"b":1e-7}')
    throw Error("Canonicalization failed.");
  return {
    signed_proofs: 2,
    signature_format: "64-byte JOSE R||S",
    canonical_digest: "SHA-256 UTF-8 JCS, unpadded base64url",
  };
}
function main() {
  const [
    action,
    statePath,
    outputPath,
    outcome = "not_attempted",
    comment = "Testing enrollment documentation; no article execution claimed.",
  ] = process.argv.slice(2);
  if (action === "self-test") return console.log(JSON.stringify(selfTest()));
  if (!statePath)
    throw Error(
      "Use init STATE | enroll-request STATE OUTPUT | save-receipt STATE RECEIPT | token-request STATE OUTPUT | token-header TOKEN_RESPONSE OUTPUT | feedback-request STATE OUTPUT [OUTCOME] [COMMENT] | self-test.",
    );
  if (outputPath && resolve(statePath) === resolve(outputPath))
    throw Error(
      "Input and output files must differ; never overwrite the identity with a request.",
    );
  if (action === "init") {
    const { privateKey } = generateKeyPairSync("ec", {
      namedCurve: "prime256v1",
    });
    savePrivate(
      statePath,
      jsonText({
        request_id: randomUUID(),
        private_jwk: privateKey.export({ format: "jwk" }),
      }),
      true,
    );
  } else if (action === "token-header") {
    if (!outputPath) throw Error("Output file required.");
    const { access_token } = jsonFile(statePath);
    if (
      typeof access_token !== "string" ||
      !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(access_token)
    )
      throw Error("Expected a successful token response.");
    savePrivate(outputPath, "Authorization: Bearer " + access_token + "\n");
  } else {
    const state = jsonFile(statePath);
    if (
      !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
        state.request_id,
      )
    )
      throw Error("Saved UUIDv4 required.");
    publicJwk(state.private_jwk);
    if (!outputPath) throw Error("Output file required.");
    if (action === "enroll-request")
      savePrivate(outputPath, jsonText(enrollmentRequest(state)));
    else if (action === "save-receipt") {
      const receipt = jsonFile(outputPath);
      if (
        !["identity_id", "client_id", "credential_id", "grant_id"].every(
          (k) => typeof receipt[k] === "string" && receipt[k],
        ) ||
        !Number.isSafeInteger(receipt.expires_at)
      )
        throw Error("Expected a successful enrollment receipt.");
      if (
        state.receipt &&
        JSON.stringify(state.receipt) !== JSON.stringify(receipt)
      )
        throw Error(
          "Saved receipt differs; investigate without replacing the identity.",
        );
      state.receipt = receipt;
      savePrivate(statePath, jsonText(state));
    } else if (action === "token-request")
      savePrivate(outputPath, tokenForm(state));
    else if (action === "feedback-request") {
      if (
        !["worked", "failed", "blocked", "not_attempted"].includes(outcome) ||
        [...comment].length > 500 ||
        /[\x00-\x1f\x7f]/.test(comment)
      )
        throw Error(
          "Expected an observed outcome and a single-line comment of at most 500 characters.",
        );
      const logical = {
        kind: "access",
        subject: "site:authentication",
        outcome,
        comment,
      };
      if (
        state.feedback &&
        jcs({ ...state.feedback, request_id: null }) !==
          jcs({ ...logical, request_id: null })
      )
        throw Error(
          "Saved feedback differs; an identical retry must reuse its original input.",
        );
      if (!state.feedback) {
        state.feedback = { ...logical, request_id: randomUUID() };
        savePrivate(statePath, jsonText(state));
      }
      const body = {
        jsonrpc: "2.0",
        id: randomUUID(),
        method: "tools/call",
        params: {
          name: "submit_feedback",
          arguments: state.feedback,
          _meta: {
            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
            "io.modelcontextprotocol/clientInfo": {
              name: "public-enrollment-example",
              version: "1",
            },
            "io.modelcontextprotocol/clientCapabilities": {},
          },
        },
      };
      savePrivate(outputPath, jsonText(body));
    } else throw Error("Unknown command.");
  }
  console.log(
    JSON.stringify({
      action,
      saved: action === "save-receipt" ? statePath : (outputPath ?? statePath),
    }),
  );
}
if (
  process.argv[1] &&
  import.meta.url === pathToFileURL(resolve(process.argv[1])).href
) {
  try {
    main();
  } catch (error) {
    console.error(error.message);
    process.exitCode = 1;
  }
}
```
