Capability-link engineering
Single-use invite links are capabilities: at-most-once admission without broken reconnects
A permanent room URL is easy to share and impossible to unshare. A safer invitation separates the authority to locate a room, enter it once, decrypt its content, reconnect, and administer it.
Do not make one URL carry every kind of authority
A reusable secret link usually answers several questions at once: which room, who may enter, and which key decrypts the conversation. Forwarding that link forwards every capability it contains. Babili instead gives each concern a different value:
- Room ID: a path segment used to route the request.
- Invite token: a query value the room validates for one admission.
- Room secret: a URL fragment used by browsers to derive the encryption key.
- Creator token: a separate capability for issuing invites, revoking them, removing participants, and destroying the room.
/c/{roomId}?invite={inviteToken}#{roomSecret}
Browsers do not send the fragment in a normal HTTP request, so the relay receives the room ID and invite token but not the room secret through ordinary navigation. This narrows the relay's authority; it does not make the connection anonymous.
Represent an invite as a state machine
An invite is not a Boolean named valid. It has time and ownership semantics. The current Babili record contains a random token, creation and expiry times, and optional consumption, consuming-session, and revocation fields.
type Invite = {
token: string;
createdAt: number;
expiresAt: number;
consumedAt?: number;
consumedBySessionId?: string;
revokedAt?: number;
};
Creation requires the creator capability. The room generates the token, applies a ten-minute default lifetime with a one-minute floor, persists the record, and returns it only after storage succeeds.
Consume before admitting
The join transition rejects a missing, revoked, consumed, or expired invite. For the winning session, it writes consumedAt and consumedBySessionId before the first storage await. That ordering matters: another join handled by the same room sees the in-memory consumed state immediately, even while persistence is in progress.
if (!invite || invite.revokedAt || invite.consumedAt || invite.expiresAt <= now) {
rejectJoin();
}
invite.consumedAt = now;
invite.consumedBySessionId = sessionId;
invites.set(invite.token, invite);
await persistInvites();
admitSocket();
Keeping this transition inside the room's single Durable Object avoids a separate read-then-write race across stateless workers or database replicas. If your platform does not provide single-owner coordination, use a conditional write or transaction that changes exactly one unconsumed record.
Cloudflare's current Durable Objects rules describe each object as a globally unique, single-threaded coordination point and explain the input and output gates around storage. The practical guarantee here is at most one new session admitted, not magical exactly-once delivery: a failure after persistence can burn an invite without completing the join.
Reconnect is not a second redemption
At-most-once admission without reconnect semantics creates a brittle product: a page reload consumes a second invite or locks out the intended participant. Babili stores a random room-scoped session ID in browser session storage. The session that consumed the invite may reconnect with the same ID while the invite remains unrevoked; a new session is rejected.
This is continuity, not identity verification. Anyone who obtains the invite before redemption can still win the race, and browser state can be copied or compromised.
Revocation should end current authority too
Revoking an unused invite prevents a future join. Revoking a consumed invite also disconnects its currently connected consuming session. Otherwise a creator-facing “revoke” button changes a database flag while leaving the admitted participant online.
Room destruction is a broader transition: it closes every socket and ends the room lifecycle. Neither action can erase plaintext a participant already saved, copied, photographed, forwarded, or backed up.
Test the negative paths
- Redeem the same invite concurrently from two new sessions; only one may join.
- Reload the winning browser; the same session should reconnect without a new redemption.
- Open the consumed link in a different session; it must fail.
- Revoke an unused invite; later redemption must fail.
- Revoke a consumed invite; its connected session must be removed.
- Advance beyond expiry; redemption must fail even if the token was never used.
- Restart or hibernate the room owner; consumed and revoked state must survive.
Count creation and redemption only in aggregate if you need funnel evidence. Do not put room IDs, invite tokens, participant IDs, or message data into growth analytics.
Inspect the working implementation
The complete TypeScript path is public: the browser assembles the capability URL, the Worker routes requests, and the room-scoped Durable Object owns invite state and join ordering. The project is AGPL-3.0-or-later and includes a one-click Cloudflare deploy path for independent testing.
- Browser invite and reconnect flow
- Invite state machine and join transition
- Threat model and known limits
Babili has not had an independent security audit. Sender identity, file-event authentication, and replay protection after refresh are unfinished; the Cloudflare relay sees ordinary connection metadata, and endpoints can retain copies. Do not use it as an anonymity, high-risk, regulated-data, or production-finance system.