Render a Germ DM Button in Your AppView

Last year, we began testing our end-to-end encrypted (E2EE) messenger Germ DM's integration with AT Protocol in a private beta. The integration used a link in users’ Bluesky/Blacksky bios that served two purposes: to prove the binding of a public key to their atproto accounts, and to let people quickly start a conversation from viewing an atproto profile.

It was quickly apparent to us that the link wasn't a long-term solution; it took up valuable space in the limited characters available in the bio. Everyone could click the link, even if they didn’t have permission to start a chat with you. And since it was editable, users could point the link to any other user.

From the beginning, we expected that the ideal long-term solution was for the AppView to render this link to E2EE messaging as a button for people you've invited to chat with you, and provide transparency to you that someone is claiming to be you in Germ.

We're happy to announce our new com.germnetwork.declaration record, designed for AppViews to natively render messaging buttons for atproto users. Here's the app logic we recommend for rendering a button on a user's profile to message them on Germ.

Guidance for AppViews

Presence of the com.germnetwork.declaration indicates that a user Alice has bound their DID to key(s) from GermDM for use in messaging. This record further specifies how a link can be presented to another ATProto user (e.g. Bob) to allow Bob to message Alice.

When rendering Bob’s profile for Alice, an AppView should follow this logic to determine if it should render a Germ DM button:

If Bob does not have a com.germnetwork.declaration record → Do not render a button

In Bob’s com.germnetwork.declaration record

If the messageMe property is nil, → Do not render a button

Within the messageMe object,

If showButtonTo == "everyone" , → continue

If showButtonTo == "usersIFollow" , and Bob follows Alice → continue

(otherwise, unrecognized showButtonTo case, → Do not render a button)

Use the messageMeUrl within the messageMe object in Bob’s "com.germnetwork.declaration" record:

→ Complete the messageMeUrl (as described below) and render a button for Alice that directs to the completed URL

Completing the URL

The string in messageMeUrl should parse to a valid URL with no fragment.

The AppView should fill the URL fragment with Alice and Bob’s DID’s, so that the client app processing the link knows the user’s intent to message Bob, as Alice.

We use the RFC 3986 reserved sub-elimination character + to separate the string encoded DID’s.

The fragment should be of the form

[profile's DID]+[viewer's DID]

E.g., if the messageMeUrl is https://landing.ger.mx/newUser, then a correctly formatted link would be

https://landing.ger.mx/newUser#did:plc:lbu36k4mysk5g6gcrpw4dbwm+did:plc:ad4m72ykh2evfdqen3qowxmg

The viewer’s did (and + delimiter) may be omitted for a non-logged in user, e.g:

https://landing.ger.mx/newUser#did:plc:lbu36k4mysk5g6gcrpw4dbwm

(Optional) originating platform.

The AppView can help the link destination customize behavior for the platform the link was rendered on, by adding a platform hint as an additional path segment. The currently supported platform hints are "android", "iOS", and "web"

For example, if the messageMeUrl is https://landing.ger.mx/newUser, then a correctly formatted link with a platform of iOS would be

https://landing.ger.mx/newUser/iOS#did:plc:lbu36k4mysk5g6gcrpw4dbwm+did:plc:ad4m72ykh2evfdqen3qowxmg

Guidance for PDS’s

Germ treats the DID, and by extension, the current value of the "com.germnetwork.declaration" record in the user’s repository as the source of truth for the messaging identity currently representing the underlying DID, ordered by the repository revision.

Clients will periodically obtain authenticated copies of the record to ensure they are talking to the DID’s current messaging delegate.

If the client receives a newer (by repository revision) value of this record with a different key than it currently knows, it will immediately direct new messages to the newer key.

The client may choose to process queued ciphertexts from the previous key before deleting session state with the previous key.

If a newer repository has an empty value for this field, the client considers the ATProto DID to no longer be bound to any key for messaging.

It’s the client’s discretion how to handle this at the application layer.

If clients are unable to obtain freshly fetched copies of the record from any source (relay, appView, or PDS), clients will assume the last known value is still valid. Clients may choose to warn the user if they have not been able to fetch a copy of the record in some time.

We can adopt work to take signals (error codes, or whatever format) from the PDS about temporary or permanent unavailability of the user’s repo and message this to the user.

Reference

https://lexicon.garden/lexicon/did:plc:qyqmmncrm6qx33kpy7vqndik/com.germnetwork.declaration

Field definitions

"version": { "type": "string" }

Required, Opaque.

Expected to parse to a SemVer. While the lexicon is fixed, the version applies to the format of opaque content

"currentKey": { "type": "bytes" }

Required

Opaque to AppViews (possible future - parse this and validate signature over the DID in invitation)

(is an ed25519 public key prefixed with a byte enum)

“messageMe” : { "type": "ref", "ref": "#messageMe" }

Optional

encapsulates the required url and "showButtonTo" properties to show a button to other users

"showButtonTo": { "type": "string", "knownValues": [ "none", "usersIFollow", "everyone"]}

required within the “messageMe” object

The policy of who can message the user is contained in the invitation and is covered by a signature by the currentKey. Lifting this out of the opaque invitation so the AppView can use it to decide when to render a link when others view this user’s profile

"messageMeUrl": { "type": "string", "format": "uri" }

required within the “messageMe” object

This is the url to present to a user Bob who does not have a "com.germnetwork.declaration" record of their own

This should parse as a URI with empty fragment, where the app should fill in the fragment with Alice and Bob’s DID’s (see above).

"invitation": { "type": "bytes" }

Optional

Opaque to the AppView, contains MLS KeyPackage(s), and other signature data, and is signed by the currentKey

"continuityProofs": { "type" : "array", "items": { "type": "bytes" } }

Optional, Opaque. Allows for key rolling