Build on the Mesh

portal.js

Two lines put a verified portal on any web page. portal.js resolves the name from PNS, checks every signature against the pinned Jubilee root key, checks live status, and opens a signed session. It fails closed.

On this page
  1. Two lines
  2. What it does, in order
  3. Events
  4. Error codes
  5. Attributes
  6. The pinned root key
  7. The Ed25519 fallback
  8. From JavaScript: PortalMesh

Two lines

When a name is activated, the registry hands its owner two lines to paste into any web page. This is the snippet for the example name:

The snippethtml
<link rel="portal" href="activate://oasis.character">
<script src="https://files.theportalmesh.com/portal.js" defer></script>

The link names the portal; the script, served from files.theportalmesh.com, does the rest when the page has loaded. The address in href may use any of the three schemes, or none: portal.js applies the scheme lock itself.

What it does, in order

  1. Find the portal. Read the first <link rel="portal"> on the page, its href and its data- attributes. A page without one is left alone.
  2. Lock the scheme. Parse the address and correct the scheme to the one its namespace locks in. An address that is not a Mesh address at all is refused with invalid_address.
  3. Resolve. Ask the PNS servers in order, pns1 first, for /resolve/ and the address, waiting up to 8 seconds for each. A server that does not answer, or answers with an error other than 404, is skipped for the next. A 404 is final: not_found.
  4. Verify every signature against the pinned Jubilee root key, with the full verification chain. A bundle that fails is refused outright with verification_failed; portal.js does not go looking for a different answer elsewhere. It also refuses a bundle for a different name than the one asked for.
  5. Check the signed state. The bundle’s STATUS must say active; otherwise not_active. A retired name’s tombstone is therefore reported as not active, never as incomplete.
  6. Check completeness. Every record the namespace requires must be present; otherwise incomplete.
  7. Check status live. Call /status/ on the same server. Fail closed unless it answers, names the same permanent ID, and says active.
  8. Open the session at the ENDPOINT from the verified bundle, passing the persona hash, and verify the endpoint’s signed answer against the name’s key from its certificate. See Sessions.
  9. Announce it. Emit portal:verified, then portal:open.

Any failure at any step stops everything and emits portal:refused. portal.js never opens a portal that did not verify.

Events

portal.js dispatches three events on document, each a CustomEvent whose detail is shown below.

EventWhendetail
portal:verifiedThe portal verified. By default it fires once the session has also opened and verified, just before portal:open; with data-portal-manual it fires as soon as the live status check passes.{ address, name, pid, state }
portal:openA session opened and its signed answer verified. Not dispatched with data-portal-manual.{ sessionId, endpoint, mode, direction, time }
portal:refusedAnything failed.{ address, code, message }

If the endpoint fails, only portal:refused fires: the default mode never announces a portal it could not open. time is the time frame selection from the resolver (or null); its notice, when fallback is true, is what you should show the visitor. message is written for people, and is fine to show as it is.

Error codes

Every refusal has a code for programs and a message for people.

codeMeansExample message
invalid_addressThe link's href is not a Mesh address.https:// is not a Mesh scheme. Mesh addresses use activate://, portal:// or experience://.
not_foundPNS has no such name, or it is not born yet.oasis.character is not a registered portal name.
unreachableNo PNS server answered, or live status could not be checked.No PNS server answered. https://pns4.portalmesh.net: HTTP 503
verification_failedA signature or the ID did not check out, or live status named a different permanent ID.The bundle signature does not verify against the Jubilee root key.
incompleteThe bundle verifies but a required record is missing.Missing before activation: PERSONA, ENDPOINT.
not_activeThe signed STATUS, or the live status, is not active: born, sleeping, suspended, dormant, or a tombstone.oasis.character is suspended. Only an active portal opens.
endpoint_failedThe ENDPOINT did not answer with a session.The portal's endpoint did not open a session: HTTP 503
session_unverifiedThe endpoint's answer was for another session, or its signature did not verify.The endpoint's session signature does not verify against the name's certificate.
errorAnything unexpected (events only; the message is the error itself).

Attributes

AttributeValuesEffect
hrefA portal addressThe portal to open. Required. Any scheme, or none.
data-modetext, voice, vr, hologramThe mode to open in, if the ENDPOINT offers it; otherwise its first mode.
data-directiongo, bringThe direction to open in, if the ENDPOINT allows it; otherwise the direction the resolver granted.
data-portal-manual(present or not)Verify only: resolve, verify and check status, emit portal:verified, and stop. Open it yourself with PortalMesh.open().

An unsupported mode or direction is not an error; portal.js picks what the portal offers.

Verify on load, open when the visitor askshtml
<link rel="portal" href="activate://oasis.character" data-mode="voice" data-portal-manual>
<script>
  document.addEventListener("portal:verified", (event) => {
    document.querySelector("#talk").hidden = false;
  });
  document.addEventListener("portal:refused", (event) => {
    document.querySelector("#why").textContent = event.detail.message;
  });
</script>
<script src="https://files.theportalmesh.com/portal.js" defer></script>

<button id="talk" hidden>Talk to Oasis</button>
<p id="why"></p>
<script>
  document.querySelector("#talk").addEventListener("click", async () => {
    try {
      const session = await PortalMesh.open("activate://oasis.character", { mode: "voice" });
      console.log("Session", session.sessionId, "at", session.endpoint);
    } catch (err) {
      document.querySelector("#why").textContent = err.message; // err.code is one of the codes above
    }
  });
</script>

The pinned root key

The Jubilee root public key, and the list of PNS servers, are compiled into portal.js when it is built. They are never read from the page, from portal.json, or from a PNS server: a page that could hand portal.js its own root key could vouch for anything.

The build takes a list of root keys, each with its kid, and a bundle is checked against the one its kid names. A new root key can therefore be added beside the old one in a new build of the library, and because pages load the script from files.theportalmesh.com, they need no change.

The Ed25519 fallback

portal.js verifies with the browser’s native Web Crypto Ed25519 where it exists. It tests for it once, by importing a known public key (the RFC 8032 test 1 key). Where it does not exist, a bundled JavaScript implementation, @noble/ed25519, performs the same check. Either way a malformed key or signature is a failed verification, never an exception.

From JavaScript: PortalMesh

portal.js also defines a global, PortalMesh, that uses the same pinned key and servers:

MemberReturnsDoes
PortalMesh.versionstringThe library version, "0.1.0".
PortalMesh.resolve(address)Promise of a resolutionSteps 2 to 7: resolve, verify, check the signed state, completeness and live status. Rejects with a PortalError.
PortalMesh.open(address, { mode, direction })Promise of a sessionSteps 2 to 8. Both options are optional. Rejects with a PortalError.

Calls to resolve and open do not dispatch events; you get the result or the error directly. A PortalError has code, message and sometimes detail (for example the full list of verification problems).

FieldMeaning
addressThe canonical address.
correctedWhether the scheme was corrected.
resolverThe PNS server that answered.
bundleThe verified bundle.
verificationThe verifier’s result: name, pid, serial, state, keyId, persona, certificate, endpoint, records and more.
status{ state, active, serial, checkedAt }
direction{ requested, granted, notice? } or null
timeThe time frame selection, or null.
FieldMeaning
sessionIdThe endpoint's session id.
endpointThe ENDPOINT url the session opened at.
modeThe mode it opened in.
directionThe direction it opened in.
resolvedThe resolution above.
answerThe endpoint’s signed answer, verified.