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
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:
<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
- Find the portal. Read the first
<link rel="portal">on the page, itshrefand itsdata-attributes. A page without one is left alone. - 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. - 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. - 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. - Check the signed state. The bundle’s STATUS must say
active; otherwisenot_active. A retired name’s tombstone is therefore reported as not active, never as incomplete. - Check completeness. Every record the namespace requires must be present; otherwise
incomplete. - Check status live. Call
/status/on the same server. Fail closed unless it answers, names the same permanent ID, and saysactive. - 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.
- Announce it. Emit
portal:verified, thenportal: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.
| Event | When | detail |
|---|---|---|
portal:verified | The 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:open | A session opened and its signed answer verified. Not dispatched with data-portal-manual. | { sessionId, endpoint, mode, direction, time } |
portal:refused | Anything 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.
| code | Means | Example message |
|---|---|---|
invalid_address | The link's href is not a Mesh address. | https:// is not a Mesh scheme. Mesh addresses use activate://, portal:// or experience://. |
not_found | PNS has no such name, or it is not born yet. | oasis.character is not a registered portal name. |
unreachable | No PNS server answered, or live status could not be checked. | No PNS server answered. https://pns4.portalmesh.net: HTTP 503 |
verification_failed | A 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. |
incomplete | The bundle verifies but a required record is missing. | Missing before activation: PERSONA, ENDPOINT. |
not_active | The 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_failed | The ENDPOINT did not answer with a session. | The portal's endpoint did not open a session: HTTP 503 |
session_unverified | The 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. |
error | Anything unexpected (events only; the message is the error itself). |
Attributes
| Attribute | Values | Effect |
|---|---|---|
href | A portal address | The portal to open. Required. Any scheme, or none. |
data-mode | text, voice, vr, hologram | The mode to open in, if the ENDPOINT offers it; otherwise its first mode. |
data-direction | go, bring | The 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.
<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:
| Member | Returns | Does |
|---|---|---|
PortalMesh.version | string | The library version, "0.1.0". |
PortalMesh.resolve(address) | Promise of a resolution | Steps 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 session | Steps 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).
| Field | Meaning |
|---|---|
address | The canonical address. |
corrected | Whether the scheme was corrected. |
resolver | The PNS server that answered. |
bundle | The verified bundle. |
verification | The 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 |
time | The time frame selection, or null. |
| Field | Meaning |
|---|---|
sessionId | The endpoint's session id. |
endpoint | The ENDPOINT url the session opened at. |
mode | The mode it opened in. |
direction | The direction it opened in. |
resolved | The resolution above. |
answer | The endpoint’s signed answer, verified. |