Building a tool¶
A tool proposes a share to the owner of a vault and, once it has been connected, can see what became of that share. The vault itself is never read, the owner's credentials are never held, and nothing is stored on the owner's server for the tool. The user-facing side is described in Tools & link proposals; Notfallkarte is a complete tool built this way.
The whole process has three parts, and a tool may stop after any of them:
- Proposal of a share through a
revoked://plink — no server call and no token involved. - Connection, through PKCE, which results in a connection token.
- Use of the token (
X-Revoked-Connection) for reading link status, receiving handed-over links, revoking, or disconnecting.
Which server a user runs is not known in advance, since every instance is self-hosted. The address reaches the tool when the app returns to it, or when the owner enters it in the tool.
1. The proposal¶
A proposal is a revoked://p link, opened by the installed app. Values are
percent-encoded, with a space written as %20 rather than +.
| Parameter | Required | Meaning |
|---|---|---|
label |
yes | Names the share, up to 120 characters. |
keys |
yes | Comma-separated record keys, 1–50, each [a-z0-9_-]{1,64}. |
from |
The name the tool gives itself, up to 40 characters. Shown as a claim. | |
template |
A template name, up to 80 characters, describing the keys — so the app can ask for a key the vault does not hold yet. | |
days |
Expiry, 1–365 days. Left out: no expiry. | |
stamp |
Watermark text for files, up to 120 characters. | |
purpose |
application (requires stamp). |
|
client |
The tool's origin: https://host[:port], or http://localhost / 127.0.0.1 in development. Nothing else — no path. |
|
redirect |
needs client |
The address the owner is sent back to; on client's origin. |
state |
needs redirect |
The tool's own value, [A-Za-z0-9._~-]{1,128}, handed back unchanged. |
ref |
The tool's own reference for this link, up to 80 characters. Returned with the link's status once connected. | |
challenge |
needs state |
A PKCE S256 challenge, for connecting along the way (see below). |
revoke_reason, links_reason |
Why the tool wants each permission, up to 120 characters each. Shown when it connects. | |
poll |
needs client |
The server origin the tool will ask for the answer itself. |
A link with any invalid part is refused as a whole. A label and keys are the minimum; with a template named as well, the app can ask for any key the vault lacks:
revoked://p?label=Wi-Fi%20for%20guests&keys=network_name,wifi_password&template=Wi-Fi%20access
wifi_password is a hidden record in the Wi-Fi access template. A share
that includes a hidden record or a file is never handed to a tool, connected
or not — such a link is copied by the owner.
2. The connection¶
A connection is always the owner's decision, made in their app. The tool sends a PKCE challenge, and the one-time code it receives in return can only be redeemed with the matching verifier.
The challenge travels either as part of a proposal (challenge, together
with client, redirect and state) or in a connect link of its own:
revoked://connect?client=https://tool.example
&name=Guestbook
&redirect=https://tool.example/
&state=<random>
&challenge=<base64url(SHA-256(verifier))>
Answer through the return address¶
The app opens redirect with these query parameters added:
| Parameter | When |
|---|---|
state |
Always; compared by the tool with the value it sent. |
status |
connected, created or declined. |
server |
The owner's server origin — the one trustworthy source for it. |
code |
When the tool was just connected. Valid once, for five minutes. |
link |
With created: the id of the new link. |
The code is exchanged at the owner's server:
curl -X POST "$SERVER/api/connect/token" \
-H "Content-Type: application/json" \
-d '{"code":"…","verifier":"…","redirectUri":"https://tool.example/"}'
{
"token": "…",
"connection": { "id": "…", "clientName": "Guestbook", "expiresAt": "…",
"allowRevoke": false, "allowHandOver": true }
}
The token is returned once, and only its hash is stored on the server. A code is spent the moment it is presented, whether right or wrong, so a stolen code cannot be tried again against the verifier.
Answer through polling¶
The return address is opened in whichever browser the system picks, which is
not necessarily the tab that asked. A tool that already knows the owner's
server can collect the answer itself instead: with poll=<server origin> in
the link, the app opens nothing, and the page asks the server with its
verifier alone, every few seconds:
const res = await fetch(server + '/api/connect/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ verifier, redirectUri: location.origin + '/' }),
});
// 202 {"pending": true}: not answered yet — ask again
// 200 {"token": …}: connected
// 400: the answer lapsed or was not this page's
The answer is only handed to a request whose Origin is the tool's own, and
it is kept for five minutes. Since nothing ties a polled answer to the owner's
browser, the app never gives one unasked: a check code is shown on the tool's
page and compared by the owner with the one in the app. Both sides derive it
the same way — the first three bytes of SHA-256(challenge), as upper-case
hex, split 3-3:
const d = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(challenge)));
const hex = [...d.slice(0, 3)].map((b) => b.toString(16).padStart(2, '0')).join('').toUpperCase();
const code = hex.slice(0, 3) + '-' + hex.slice(3); // "83C-047"
3. The token¶
The token is sent in X-Revoked-Connection, not in Authorization. It is
not a user session and no collection rule admits it, so neither the vault nor
anything else outside these endpoints can be read with it.
| Endpoint | Does |
|---|---|
GET /api/connection |
The connection and the links its proposals became, newest first: id, ref, label, status, viewCount, maxViews, expiresAt, created, handedOver, and url — only on a handed-over, active link while the owner allows allowHandOver. |
POST /api/connection/links/{id}/revoke |
Revokes one of those links, where the owner allowed allowRevoke. |
DELETE /api/connection |
Disconnects the tool. The links remain the owner's. |
curl "$SERVER/api/connection" -H "X-Revoked-Connection: $TOKEN"
A 401 with connection_unauthorized means the tool was disconnected;
connection_expired means its 90 days have run out. In both cases the token
is of no further use, and a new connection can only be made by the owner,
from the app. The full schemas are in the
Connections reference.
A minimal tool¶
A single HTML file that proposes a share, is connected along the way, and
lists what became of its links. It works from any https origin (or from
http://localhost) on a machine with the app installed.
<!doctype html>
<meta charset="utf-8">
<title>Guestbook</title>
<button id="propose">Register for the summer party</button>
<ul id="links"></ul>
<script type="module">
const here = location.origin + '/';
const b64url = (bytes) => btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const random = (n) => b64url(crypto.getRandomValues(new Uint8Array(n)));
const sha256 = async (s) =>
new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)));
const enc = encodeURIComponent;
// 1. The proposal, with a request to be connected along the way.
document.querySelector('#propose').onclick = async () => {
const state = random(16), verifier = random(32);
sessionStorage.setItem('pending:' + state, verifier);
const q = {
label: 'Summer party', keys: 'full_name,email,dietary_restrictions',
template: 'Event registration', from: 'Guestbook',
client: location.origin, redirect: here, state, ref: 'summer-2026',
challenge: b64url(await sha256(verifier)),
links_reason: 'To put your registration on the guest list',
};
location.href = 'revoked://p?' + Object.entries(q).map(([k, v]) => k + '=' + enc(v)).join('&');
};
// 2. Back from the app: the state is checked and the code redeemed.
const p = new URLSearchParams(location.search);
const verifier = p.get('state') && sessionStorage.getItem('pending:' + p.get('state'));
history.replaceState(null, '', location.pathname);
if (verifier && p.get('code') && p.get('server')) {
sessionStorage.removeItem('pending:' + p.get('state'));
const res = await fetch(p.get('server') + '/api/connect/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: p.get('code'), verifier, redirectUri: here }),
});
if (res.ok) {
const { token } = await res.json();
localStorage.setItem('connection', JSON.stringify({ server: p.get('server'), token }));
}
}
// 3. With a token, the status of the proposed links is read.
const conn = JSON.parse(localStorage.getItem('connection') || 'null');
if (conn) {
const res = await fetch(conn.server + '/api/connection', {
headers: { 'X-Revoked-Connection': conn.token },
});
if (res.status === 401) localStorage.removeItem('connection');
else for (const l of (await res.json()).links) {
const li = document.createElement('li');
li.textContent = `${l.label} (${l.ref}): ${l.status}, opened ${l.viewCount}× ${l.url ?? ''}`;
document.querySelector('#links').append(li);
}
}
</script>
Left out here, and covered by Notfallkarte: polling with a check code, admitting a second browser into an existing connection, revoking, and the case of an unreachable server.
Notes¶
- The server address is only taken from the app (
serverin the return address) or from what the owner entered — never from the tool's own link or from a third party. stateties an answer to a link the page actually sent; answers without a matching state are ignored.- Links open data, so they are best read from
/api/connectionwhen needed rather than kept as a copy. - CORS: the owner's server is called from the browser. A server started
with a restricted
--originslist needs the tool's origin on it. - Rate limits:
/api/connect/tokenallows 60 requests per minute per IP by default (RATELIMIT_CONNECT_REQUESTS), enough for polling every few seconds from a couple of tabs.