diff --git a/.github/workflows/python-unit-tests.yml b/.github/workflows/python-unit-tests.yml
index 6aedfa48..31d5db70 100644
--- a/.github/workflows/python-unit-tests.yml
+++ b/.github/workflows/python-unit-tests.yml
@@ -41,4 +41,4 @@ jobs:
- name: Smoke test keymaster CLI
run: |
keymaster --help > /dev/null
- python -c "import argparse; from keymaster.cli import build_parser; p=build_parser(); sp=[a for a in p._actions if isinstance(a, argparse._SubParsersAction)][0]; assert len(sp.choices)==132, f'expected 132 commands, got {len(sp.choices)}'; print(f'keymaster CLI OK ({len(sp.choices)} commands)')"
+ python -c "import argparse; from keymaster.cli import build_parser; p=build_parser(); sp=[a for a in p._actions if isinstance(a, argparse._SubParsersAction)][0]; assert len(sp.choices)==134, f'expected 134 commands, got {len(sp.choices)}'; print(f'keymaster CLI OK ({len(sp.choices)} commands)')"
diff --git a/AGENTS.md b/AGENTS.md
index 00a060b3..5f091266 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -24,6 +24,8 @@ These rules apply to coding agents working in this repository.
- When generating or updating npm lockfiles, use the repo-pinned npm version from the root `package.json` so lockfiles stay compatible with CI.
- Internal service-to-service admin auth should use `X-Archon-Admin-Key` consistently. Reserve `Authorization` for user/session/OAuth-style flows unless a file explicitly documents a different scheme.
- For Herald agent guidance, prefer Keymaster address commands (`check-address`, `add-address`, `remove-address`, etc.) in quick starts while keeping direct API endpoint documentation available for lower-level integrations.
+- Keymaster address metadata should stay in parity across TypeScript and Python implementations; when Herald exposes a domain relay agent, store it with the address as `relay` and surface it through list/get address APIs.
+- Publishing a Keymaster address always sets `didDocumentData.address`; it adds the `#email` service endpoint with `type: "Email"` and `serviceEndpoint: "mailto:
"` only when the stored address has a Herald `relay`. Unpublishing removes both the property and the service.
- After a PR is merged, always do the standard local cleanup unless the user says otherwise: switch to `main`, fast-forward from `origin/main`, and delete the merged local branch.
- Do not use stash-based branch juggling as the default workflow.
- Never run mutating git operations in parallel. Serialize `git add`, `git commit`, `git push`, branch moves, stash operations, and any command that writes to `.git`.
@@ -35,3 +37,4 @@ These rules apply to coding agents working in this repository.
- Python keymaster flavor runs in CLI CI use `ARCHON_KEYMASTER_DB=redis` exactly like the TypeScript service; no override is needed.
- The Python keymaster service MUST be a drop-in replacement for the TypeScript keymaster. `docker-compose.keymaster-py.yml` and `docker-compose.keymaster-ts.yml` must agree on env, ports, healthcheck behaviour, volumes, and `user:` overrides. The data dir is a host bind mount of `./data` running as `${ARCHON_UID}:${ARCHON_GID}`, identical to the ts flavor — do not switch py to a named volume to dodge UID issues; fix the UID setup instead.
- For Python package publishing prep, build and check artifacts locally, but do not upload to TestPyPI or PyPI unless the user explicitly asks for publication.
+- When reproducing Python CI checks locally, prefer the repo `.venv` Python so helper scripts and imports run in the same prepared environment.
diff --git a/apps/browser-extension/src/components/IdentitiesTab.tsx b/apps/browser-extension/src/components/IdentitiesTab.tsx
index 5a26e3b8..faadc20c 100644
--- a/apps/browser-extension/src/components/IdentitiesTab.tsx
+++ b/apps/browser-extension/src/components/IdentitiesTab.tsx
@@ -801,7 +801,7 @@ function IdentitiesTab() {
setError("Select an address to publish");
return;
}
- await keymaster.mergeData(currentId, { address: normalizedAddress });
+ await keymaster.publishAddress(normalizedAddress, currentId);
setPublishedAddress(normalizedAddress);
await refreshCurrentIdDocs();
setSuccess(`${normalizedAddress} published`);
@@ -822,7 +822,7 @@ function IdentitiesTab() {
setError("Select an identity first");
return;
}
- await keymaster.mergeData(currentId, { address: null });
+ await keymaster.unpublishAddress(currentId);
setPublishedAddress("");
await refreshCurrentIdDocs();
setSuccess("Address unpublished");
@@ -846,7 +846,7 @@ function IdentitiesTab() {
}
await keymaster.removeAddress(normalizedAddress);
if (publishedAddress === normalizedAddress && currentId) {
- await keymaster.mergeData(currentId, { address: null });
+ await keymaster.unpublishAddress(currentId);
setPublishedAddress("");
await refreshCurrentIdDocs();
}
diff --git a/apps/gatekeeper-client/src/KeymasterUI.jsx b/apps/gatekeeper-client/src/KeymasterUI.jsx
index e494452e..d84f2d4d 100644
--- a/apps/gatekeeper-client/src/KeymasterUI.jsx
+++ b/apps/gatekeeper-client/src/KeymasterUI.jsx
@@ -1879,7 +1879,7 @@ function KeymasterUI({ keymaster, title, challengeDID, onWalletUpload, hasLightn
return;
}
- await keymaster.mergeData(selectedId, { address: normalizedAddress });
+ await keymaster.publishAddress(normalizedAddress, selectedId);
setPublishedAddress(normalizedAddress);
showSuccess(`${normalizedAddress} published`);
await resolveId();
@@ -1898,7 +1898,7 @@ function KeymasterUI({ keymaster, title, challengeDID, onWalletUpload, hasLightn
return;
}
- await keymaster.mergeData(selectedId, { address: null });
+ await keymaster.unpublishAddress(selectedId);
setPublishedAddress('');
showSuccess('Address unpublished');
await resolveId();
@@ -1922,7 +1922,7 @@ function KeymasterUI({ keymaster, title, challengeDID, onWalletUpload, hasLightn
if (await showConfirm(`Are you sure you want to remove ${normalizedAddress}?`)) {
await keymaster.removeAddress(normalizedAddress);
if (publishedAddress === normalizedAddress && selectedId) {
- await keymaster.mergeData(selectedId, { address: null });
+ await keymaster.unpublishAddress(selectedId);
setPublishedAddress('');
await resolveId();
}
diff --git a/apps/keymaster-client/src/KeymasterUI.jsx b/apps/keymaster-client/src/KeymasterUI.jsx
index e494452e..d84f2d4d 100644
--- a/apps/keymaster-client/src/KeymasterUI.jsx
+++ b/apps/keymaster-client/src/KeymasterUI.jsx
@@ -1879,7 +1879,7 @@ function KeymasterUI({ keymaster, title, challengeDID, onWalletUpload, hasLightn
return;
}
- await keymaster.mergeData(selectedId, { address: normalizedAddress });
+ await keymaster.publishAddress(normalizedAddress, selectedId);
setPublishedAddress(normalizedAddress);
showSuccess(`${normalizedAddress} published`);
await resolveId();
@@ -1898,7 +1898,7 @@ function KeymasterUI({ keymaster, title, challengeDID, onWalletUpload, hasLightn
return;
}
- await keymaster.mergeData(selectedId, { address: null });
+ await keymaster.unpublishAddress(selectedId);
setPublishedAddress('');
showSuccess('Address unpublished');
await resolveId();
@@ -1922,7 +1922,7 @@ function KeymasterUI({ keymaster, title, challengeDID, onWalletUpload, hasLightn
if (await showConfirm(`Are you sure you want to remove ${normalizedAddress}?`)) {
await keymaster.removeAddress(normalizedAddress);
if (publishedAddress === normalizedAddress && selectedId) {
- await keymaster.mergeData(selectedId, { address: null });
+ await keymaster.unpublishAddress(selectedId);
setPublishedAddress('');
await resolveId();
}
diff --git a/apps/react-wallet/src/components/IdentitiesTab.tsx b/apps/react-wallet/src/components/IdentitiesTab.tsx
index 8cff61c0..ffcb2385 100644
--- a/apps/react-wallet/src/components/IdentitiesTab.tsx
+++ b/apps/react-wallet/src/components/IdentitiesTab.tsx
@@ -798,7 +798,7 @@ function IdentitiesTab() {
setError("Select an address to publish");
return;
}
- await keymaster.mergeData(currentId, { address: normalizedAddress });
+ await keymaster.publishAddress(normalizedAddress, currentId);
setPublishedAddress(normalizedAddress);
await refreshCurrentIdDocs();
setSuccess(`${normalizedAddress} published`);
@@ -819,7 +819,7 @@ function IdentitiesTab() {
setError("Select an identity first");
return;
}
- await keymaster.mergeData(currentId, { address: null });
+ await keymaster.unpublishAddress(currentId);
setPublishedAddress("");
await refreshCurrentIdDocs();
setSuccess("Address unpublished");
@@ -843,7 +843,7 @@ function IdentitiesTab() {
}
await keymaster.removeAddress(normalizedAddress);
if (publishedAddress === normalizedAddress && currentId) {
- await keymaster.mergeData(currentId, { address: null });
+ await keymaster.unpublishAddress(currentId);
setPublishedAddress("");
await refreshCurrentIdDocs();
}
diff --git a/docs/keymaster-api.json b/docs/keymaster-api.json
index 373676ed..74225ac7 100644
--- a/docs/keymaster-api.json
+++ b/docs/keymaster-api.json
@@ -2199,6 +2199,116 @@
}
}
},
+ "/addresses/publish": {
+ "post": {
+ "summary": "Publish a stored address to the current identity DID document.",
+ "description": "Sets `didDocumentData.address` to the selected stored address. If the stored address has a Herald relay, also publishes an `Email` DID service endpoint using `mailto:`.",
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "address": {
+ "type": "string",
+ "description": "Optional `name@domain` address to publish. Required when the identity has more than one stored address."
+ },
+ "name": {
+ "type": "string",
+ "description": "Optional identity name. Defaults to the current identity."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Indicates whether the address was successfully published.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "summary": "Remove the published address from the current identity DID document.",
+ "description": "Removes `didDocumentData.address` and the `#email` DID service endpoint from the selected identity.",
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Optional identity name. Defaults to the current identity."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Indicates whether the address was successfully unpublished.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/addresses/{address}": {
"delete": {
"summary": "Remove the stored address for the current identity and revoke it remotely.",
diff --git a/docs/services/herald/README.md b/docs/services/herald/README.md
index df0ab03c..7d1fffa1 100644
--- a/docs/services/herald/README.md
+++ b/docs/services/herald/README.md
@@ -69,7 +69,7 @@ several namespaces:
| Method | Path | Notes |
| --- | --- | --- |
| `GET` | `/api/version` | `1` (the literal integer). Stable schema version of the API. |
-| `GET` | `/api/config` | `{ serviceName, serviceDomain, publicUrl, walletUrl }`. |
+| `GET` | `/api/config` | `{ serviceName, serviceDID, serviceDomain, publicUrl, walletUrl }`, plus `relayAgent` when the email bridge is enabled. `relayAgent` is the Herald service DID clients can address for dmail/email relay. |
### 2.2 Login (challenge-response)
diff --git a/packages/keymaster/README.md b/packages/keymaster/README.md
index 9411e02d..e3138d39 100644
--- a/packages/keymaster/README.md
+++ b/packages/keymaster/README.md
@@ -199,6 +199,8 @@ It then uses `ARCHON_NODE_URL` and `ARCHON_PASSPHRASE` during setup, creates the
| Addresses | `check-address ` | Check whether an address is available |
| Addresses | `add-address ` | Claim an address for the current ID |
| Addresses | `remove-address ` | Remove an address from the current ID |
+| Addresses | `publish-address [address] [id]` | Publish an address for the current ID |
+| Addresses | `unpublish-address [id]` | Remove the published address |
| Groups | `create-group ` | Create a group |
| Groups | `list-groups` | List owned groups |
| Groups | `get-group ` | Get group details |
diff --git a/packages/keymaster/src/cli.ts b/packages/keymaster/src/cli.ts
index e5873f60..07180123 100644
--- a/packages/keymaster/src/cli.ts
+++ b/packages/keymaster/src/cli.ts
@@ -898,6 +898,32 @@ program
}
});
+program
+ .command('publish-address [address] [id]')
+ .description('Publish a stored address for a DID')
+ .action(async (address, id) => {
+ try {
+ await keymaster.publishAddress(address, id);
+ console.log(UPDATE_OK);
+ }
+ catch (error: any) {
+ console.error(error.error || error.message || error);
+ }
+ });
+
+program
+ .command('unpublish-address [id]')
+ .description('Remove the published address from a DID')
+ .action(async (id) => {
+ try {
+ await keymaster.unpublishAddress(id);
+ console.log(UPDATE_OK);
+ }
+ catch (error: any) {
+ console.error(error.error || error.message || error);
+ }
+ });
+
// Nostr commands
program
.command('add-nostr [id]')
diff --git a/packages/keymaster/src/keymaster-client.ts b/packages/keymaster/src/keymaster-client.ts
index 8c2481b4..32486e33 100644
--- a/packages/keymaster/src/keymaster-client.ts
+++ b/packages/keymaster/src/keymaster-client.ts
@@ -525,6 +525,26 @@ export default class KeymasterClient implements KeymasterInterface {
}
}
+ async publishAddress(address?: string, name?: string): Promise {
+ try {
+ const response = await this.axios.post(`${this.API}/addresses/publish`, { address, name });
+ return response.data.ok;
+ }
+ catch (error) {
+ throwError(error);
+ }
+ }
+
+ async unpublishAddress(name?: string): Promise {
+ try {
+ const response = await this.axios.delete(`${this.API}/addresses/publish`, { data: { name } });
+ return response.data.ok;
+ }
+ catch (error) {
+ throwError(error);
+ }
+ }
+
async addNostr(id?: string): Promise {
try {
const response = await this.axios.post(`${this.API}/nostr`, { id });
diff --git a/packages/keymaster/src/keymaster.ts b/packages/keymaster/src/keymaster.ts
index d3c64f64..dd15ba4e 100644
--- a/packages/keymaster/src/keymaster.ts
+++ b/packages/keymaster/src/keymaster.ts
@@ -67,6 +67,7 @@ import {
WalletFile,
WalletEncFile,
Seed,
+ StoredAddressInfo,
} from '@didcid/keymaster/types';
import {
isWalletEncFile,
@@ -1958,11 +1959,41 @@ export default class Keymaster implements KeymasterInterface {
throw new KeymasterError(lastError);
}
+ private async fetchAddressRelayAgent(domain: string): Promise {
+ for (const endpoint of this.addressApiEndpoints(domain, 'config')) {
+ try {
+ const response = await fetch(endpoint);
+
+ if (!response.ok) {
+ continue;
+ }
+
+ const data = await this.getResponseData(response);
+ const candidate = data?.relayAgent ?? data?.heraldRelayAgent;
+
+ if (typeof candidate === 'string' && isValidDID(candidate)) {
+ return candidate;
+ }
+ }
+ catch {
+ // Relay discovery is optional; address claims should still succeed.
+ }
+ }
+
+ return null;
+ }
+
+ private addressMetadata(info: StoredAddressInfo): AddressInfo {
+ const metadata: Record = { ...info };
+ delete metadata.name;
+ return metadata as AddressInfo;
+ }
+
private collectAddresses(id: IDInfo | undefined): Record {
const addresses: Record = {};
for (const [domain, info] of Object.entries(id?.addresses || {})) {
- addresses[`${info.name}@${domain}`] = { added: info.added };
+ addresses[`${info.name}@${domain}`] = this.addressMetadata(info);
}
return addresses;
@@ -1991,7 +2022,7 @@ export default class Keymaster implements KeymasterInterface {
domain: normalizedDomain,
name: stored.name,
address: `${stored.name}@${normalizedDomain}`,
- added: stored.added,
+ ...this.addressMetadata(stored),
};
}
@@ -2008,6 +2039,7 @@ export default class Keymaster implements KeymasterInterface {
const names = data?.names && typeof data.names === 'object' ? data.names : {};
const imported: Record = {};
const added = new Date().toISOString();
+ const relay = await this.fetchAddressRelayAgent(normalizedDomain);
await this.mutateWallet((wallet) => {
const id = wallet.ids[wallet.current!];
@@ -2024,8 +2056,12 @@ export default class Keymaster implements KeymasterInterface {
id.addresses[normalizedDomain] = {
name: String(name).toLowerCase(),
added,
+ ...(relay ? { relay } : {}),
+ };
+ imported[address] = {
+ added,
+ ...(relay ? { relay } : {}),
};
- imported[address] = { added };
}
});
@@ -2105,6 +2141,7 @@ export default class Keymaster implements KeymasterInterface {
},
body: JSON.stringify({ name: parsed.name }),
}, 'Failed to add address');
+ const relay = await this.fetchAddressRelayAgent(parsed.domain);
await this.mutateWallet((wallet) => {
const id = wallet.ids[wallet.current!];
@@ -2115,6 +2152,7 @@ export default class Keymaster implements KeymasterInterface {
id.addresses[parsed.domain] = {
name: parsed.name,
added: new Date().toISOString(),
+ ...(relay ? { relay } : {}),
};
});
@@ -2152,6 +2190,89 @@ export default class Keymaster implements KeymasterInterface {
return true;
}
+ private resolveStoredAddressForPublish(id: IDInfo, address?: string): {
+ address: string;
+ info: StoredAddressInfo;
+ } {
+ if (address !== undefined) {
+ const parsed = this.parseAddress(address);
+ const stored = id.addresses?.[parsed.domain];
+
+ if (!stored || stored.name !== parsed.name) {
+ throw new InvalidParameterError('address');
+ }
+
+ return {
+ address: parsed.address,
+ info: stored,
+ };
+ }
+
+ const entries = Object.entries(id.addresses || {});
+
+ if (entries.length !== 1) {
+ throw new InvalidParameterError('address');
+ }
+
+ const [domain, info] = entries[0];
+ return {
+ address: `${info.name}@${domain}`,
+ info,
+ };
+ }
+
+ async publishAddress(address?: string, name?: string): Promise {
+ const id = await this.fetchIdInfo(name);
+ const did = id.did;
+ const stored = this.resolveStoredAddressForPublish(id, address);
+ const doc = await this.resolveDID(did);
+ const didDocument = { ...doc.didDocument! };
+ const serviceId = `${did}#email`;
+ const services = (didDocument.service || []).filter(s => s.id !== serviceId);
+ const didDocumentData = {
+ ...(doc.didDocumentData as Record || {}),
+ address: stored.address,
+ };
+
+ if (stored.info.relay) {
+ services.push({
+ id: serviceId,
+ type: 'Email',
+ serviceEndpoint: `mailto:${stored.address}`,
+ });
+ }
+
+ if (services.length > 0) {
+ didDocument.service = services;
+ }
+ else {
+ delete didDocument.service;
+ }
+
+ return this.updateDID(did, { didDocument, didDocumentData });
+ }
+
+ async unpublishAddress(name?: string): Promise {
+ const id = await this.fetchIdInfo(name);
+ const did = id.did;
+ const doc = await this.resolveDID(did);
+ const didDocument = { ...doc.didDocument! };
+ const serviceId = `${did}#email`;
+ const services = (didDocument.service || []).filter(s => s.id !== serviceId);
+ const didDocumentData = { ...(doc.didDocumentData as Record || {}) };
+
+ delete didDocumentData.address;
+
+ if (services.length > 0) {
+ didDocument.service = services;
+ }
+ else {
+ delete didDocument.service;
+ }
+
+ return this.updateDID(did, { didDocument, didDocumentData });
+ }
+
async addAlias(
alias: string,
did: string
diff --git a/packages/keymaster/src/types.ts b/packages/keymaster/src/types.ts
index 7d2640a0..41c639ff 100644
--- a/packages/keymaster/src/types.ts
+++ b/packages/keymaster/src/types.ts
@@ -44,6 +44,7 @@ export interface StoredNostrInfo {
export interface StoredAddressInfo {
name: string;
added: string;
+ relay?: string;
[key: string]: any;
}
@@ -395,6 +396,8 @@ export interface KeymasterInterface {
checkAddress(address: string): Promise;
addAddress(address: string): Promise;
removeAddress(address: string): Promise;
+ publishAddress(address?: string, name?: string): Promise;
+ unpublishAddress(name?: string): Promise;
// Nostr
addNostr(id?: string): Promise;
diff --git a/python/keymaster/PUBLISHING.md b/python/keymaster/PUBLISHING.md
index 185db4df..85f7012d 100644
--- a/python/keymaster/PUBLISHING.md
+++ b/python/keymaster/PUBLISHING.md
@@ -38,7 +38,7 @@ python3 -m venv /tmp/archon-keymaster-install-test
--extra-index-url https://pypi.org/simple/ \
archon-keymaster
/tmp/archon-keymaster-install-test/bin/keymaster --help
-/tmp/archon-keymaster-install-test/bin/python -c "import argparse; from keymaster.cli import build_parser; p=build_parser(); sp=[a for a in p._actions if isinstance(a, argparse._SubParsersAction)][0]; assert len(sp.choices)==132"
+/tmp/archon-keymaster-install-test/bin/python -c "import argparse; from keymaster.cli import build_parser; p=build_parser(); sp=[a for a in p._actions if isinstance(a, argparse._SubParsersAction)][0]; assert len(sp.choices)==134"
```
## PyPI
diff --git a/python/keymaster/src/keymaster/cli.py b/python/keymaster/src/keymaster/cli.py
index 69de98c6..09f1325d 100644
--- a/python/keymaster/src/keymaster/cli.py
+++ b/python/keymaster/src/keymaster/cli.py
@@ -443,6 +443,16 @@ async def cmd_remove_address(km: Keymaster, args: argparse.Namespace) -> None:
print(UPDATE_OK)
+async def cmd_publish_address(km: Keymaster, args: argparse.Namespace) -> None:
+ await km.publish_address(args.address, args.id)
+ print(UPDATE_OK)
+
+
+async def cmd_unpublish_address(km: Keymaster, args: argparse.Namespace) -> None:
+ await km.unpublish_address(args.id)
+ print(UPDATE_OK)
+
+
# Nostr -----------------------------------------------------------------------
async def cmd_add_nostr(km: Keymaster, args: argparse.Namespace) -> None:
@@ -1082,6 +1092,11 @@ def add(name: str, description: str, handler: Callable[..., Any]) -> argparse.Ar
sp.add_argument("address")
sp = add("remove-address", "Remove an address for the current ID", cmd_remove_address)
sp.add_argument("address")
+ sp = add("publish-address", "Publish a stored address for a DID", cmd_publish_address)
+ sp.add_argument("address", nargs="?")
+ sp.add_argument("id", nargs="?")
+ sp = add("unpublish-address", "Remove the published address from a DID", cmd_unpublish_address)
+ sp.add_argument("id", nargs="?")
# Nostr
sp = add("add-nostr", "Derive and add nostr keys to an agent DID", cmd_add_nostr)
diff --git a/python/keymaster/src/keymaster/core.py b/python/keymaster/src/keymaster/core.py
index c27faab7..8096ab63 100644
--- a/python/keymaster/src/keymaster/core.py
+++ b/python/keymaster/src/keymaster/core.py
@@ -859,10 +859,35 @@ async def create_address_bearer_token(self, domain: str) -> str:
raise KeymasterError(last_error)
+ async def fetch_address_relay_agent(self, domain: str) -> str | None:
+ for endpoint in self.address_api_endpoints(domain, "config"):
+ try:
+ response = await self._http_request("GET", endpoint)
+ if not (200 <= response.status_code < 300):
+ continue
+
+ data = await self.get_response_data(response)
+ if not isinstance(data, dict):
+ continue
+
+ candidate = data.get("relayAgent") or data.get("heraldRelayAgent")
+ if isinstance(candidate, str) and candidate.startswith("did:"):
+ return candidate
+ except Exception:
+ # Relay discovery is optional; address claims should still succeed.
+ continue
+
+ return None
+
+ def address_metadata(self, info: dict[str, Any]) -> dict[str, Any]:
+ metadata = dict(info)
+ metadata.pop("name", None)
+ return metadata
+
def collect_addresses(self, id_info: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
addresses = {}
for domain, info in (id_info or {}).get("addresses", {}).items():
- addresses[f"{info['name']}@{domain}"] = {"added": info["added"]}
+ addresses[f"{info['name']}@{domain}"] = self.address_metadata(info)
return addresses
async def list_addresses(self) -> dict[str, dict[str, Any]]:
@@ -882,7 +907,7 @@ async def get_address(self, domain: str) -> dict[str, Any] | None:
"domain": normalized_domain,
"name": stored["name"],
"address": f"{stored['name']}@{normalized_domain}",
- "added": stored["added"],
+ **self.address_metadata(stored),
}
async def import_address(self, domain: str) -> dict[str, dict[str, Any]]:
@@ -898,6 +923,7 @@ async def import_address(self, domain: str) -> dict[str, dict[str, Any]]:
names = {}
imported = {}
added = __import__("datetime").datetime.utcnow().isoformat() + "Z"
+ relay = await self.fetch_address_relay_agent(normalized_domain)
async with self._lock:
wallet = await self.load_wallet()
@@ -907,8 +933,15 @@ async def import_address(self, domain: str) -> dict[str, dict[str, Any]]:
if did != current["did"]:
continue
address = f"{str(name).lower()}@{normalized_domain}"
- id_info["addresses"][normalized_domain] = {"name": str(name).lower(), "added": added}
- imported[address] = {"added": added}
+ id_info["addresses"][normalized_domain] = {
+ "name": str(name).lower(),
+ "added": added,
+ **({"relay": relay} if relay else {}),
+ }
+ imported[address] = {
+ "added": added,
+ **({"relay": relay} if relay else {}),
+ }
await self._save_loaded_wallet(wallet, overwrite=True)
return imported
@@ -974,6 +1007,7 @@ async def add_address(self, address: str) -> bool:
{"name": parsed["name"]},
"Failed to add address",
)
+ relay = await self.fetch_address_relay_agent(parsed["domain"])
async with self._lock:
wallet = await self.load_wallet()
@@ -982,6 +1016,7 @@ async def add_address(self, address: str) -> bool:
id_info["addresses"][parsed["domain"]] = {
"name": parsed["name"],
"added": __import__("datetime").datetime.utcnow().isoformat() + "Z",
+ **({"relay": relay} if relay else {}),
}
await self._save_loaded_wallet(wallet, overwrite=True)
@@ -1015,6 +1050,64 @@ async def remove_address(self, address: str) -> bool:
return True
+ def resolve_stored_address_for_publish(self, id_info: dict[str, Any], address: str | None = None) -> dict[str, Any]:
+ if address is not None:
+ parsed = self.parse_address(address)
+ stored = id_info.get("addresses", {}).get(parsed["domain"])
+ if not stored or stored.get("name") != parsed["name"]:
+ raise KeymasterError("Invalid parameter: address")
+ return {"address": parsed["address"], "info": stored}
+
+ entries = list(id_info.get("addresses", {}).items())
+ if len(entries) != 1:
+ raise KeymasterError("Invalid parameter: address")
+
+ domain, info = entries[0]
+ return {"address": f"{info['name']}@{domain}", "info": info}
+
+ async def publish_address(self, address: str | None = None, name: str | None = None) -> bool:
+ id_info = await self.fetch_id_info(name)
+ did = id_info["did"]
+ stored = self.resolve_stored_address_for_publish(id_info, address)
+ doc = await self.resolve_did(did)
+ did_document = dict(doc.get("didDocument") or {})
+ service_id = f"{did}#email"
+ services = [service for service in did_document.get("service", []) if service.get("id") != service_id]
+ did_document_data = {**(doc.get("didDocumentData") or {}), "address": stored["address"]}
+
+ if stored["info"].get("relay"):
+ services.append(
+ {
+ "id": service_id,
+ "type": "Email",
+ "serviceEndpoint": f"mailto:{stored['address']}",
+ }
+ )
+
+ if services:
+ did_document["service"] = services
+ else:
+ did_document.pop("service", None)
+
+ return await self.update_did(did, {"didDocument": did_document, "didDocumentData": did_document_data})
+
+ async def unpublish_address(self, name: str | None = None) -> bool:
+ id_info = await self.fetch_id_info(name)
+ did = id_info["did"]
+ doc = await self.resolve_did(did)
+ did_document = dict(doc.get("didDocument") or {})
+ service_id = f"{did}#email"
+ services = [service for service in did_document.get("service", []) if service.get("id") != service_id]
+ did_document_data = dict(doc.get("didDocumentData") or {})
+ did_document_data.pop("address", None)
+
+ if services:
+ did_document["service"] = services
+ else:
+ did_document.pop("service", None)
+
+ return await self.update_did(did, {"didDocument": did_document, "didDocumentData": did_document_data})
+
async def fetch_key_pair(self, name: str | None = None) -> dict[str, dict[str, str]] | None:
wallet = await self.load_wallet()
id_info = await self.fetch_id_info(name, wallet)
diff --git a/python/keymaster/tests/test_address.py b/python/keymaster/tests/test_address.py
index 4c7b761a..bf84f0cf 100644
--- a/python/keymaster/tests/test_address.py
+++ b/python/keymaster/tests/test_address.py
@@ -45,7 +45,12 @@ async def fake_request(method: str, url: str, headers=None, json_body=None):
assert url == "https://archon.social/.well-known/names"
return FakeResponse(200, {"names": {"alice": alice, "bob": "did:test:9999"}})
+ async def fake_relay(domain: str):
+ assert domain == "archon.social"
+ return None
+
monkeypatch.setattr(testbed.keymaster, "_http_request", fake_request)
+ monkeypatch.setattr(testbed.keymaster, "fetch_address_relay_agent", fake_relay)
imported = run(testbed.keymaster.import_address("archon.social"))
assert list(imported.keys()) == ["alice@archon.social"]
@@ -95,11 +100,13 @@ async def fake_fetch(domain: str, path: str, method: str, headers, json_body, fa
monkeypatch.setattr(testbed.keymaster, "create_address_bearer_token", fake_bearer)
monkeypatch.setattr(testbed.keymaster, "fetch_address_api_response", fake_fetch)
+ monkeypatch.setattr(testbed.keymaster, "fetch_address_relay_agent", fake_bearer)
assert run(testbed.keymaster.add_address("alice@archon.social")) is True
stored = run(testbed.keymaster.get_address("archon.social"))
assert stored is not None
assert stored["address"] == "alice@archon.social"
+ assert stored["relay"] == "did:test:response"
assert run(testbed.keymaster.remove_address("alice@archon.social")) is True
assert run(testbed.keymaster.list_addresses()) == {}
@@ -112,4 +119,79 @@ def test_remove_address_rejects_mismatched_stored_name(testbed):
assert run(testbed.keymaster.save_wallet(wallet, True)) is True
with pytest.raises(KeymasterError, match="Invalid parameter: address"):
- run(testbed.keymaster.remove_address("alice@archon.social"))
\ No newline at end of file
+ run(testbed.keymaster.remove_address("alice@archon.social"))
+
+
+def test_publish_address_without_relay_only_adds_profile_data(testbed):
+ did = run(testbed.keymaster.create_id("Alice"))
+ wallet = run(testbed.keymaster.load_wallet())
+ wallet["ids"]["Alice"]["addresses"] = {"archon.social": {"name": "alice", "added": "2026-04-04T13:00:00.000Z"}}
+ assert run(testbed.keymaster.save_wallet(wallet, True)) is True
+
+ assert run(testbed.keymaster.publish_address("alice@archon.social")) is True
+ doc = run(testbed.keymaster.resolve_did(did))
+
+ assert doc["didDocumentData"]["address"] == "alice@archon.social"
+ assert "service" not in doc["didDocument"]
+
+
+def test_publish_address_with_relay_adds_email_service(testbed):
+ did = run(testbed.keymaster.create_id("Alice"))
+ relay = run(testbed.keymaster.create_id("Herald"))
+ run(testbed.keymaster.set_current_id("Alice"))
+ wallet = run(testbed.keymaster.load_wallet())
+ wallet["ids"]["Alice"]["addresses"] = {
+ "archon.social": {"name": "alice", "added": "2026-04-04T13:00:00.000Z", "relay": relay}
+ }
+ assert run(testbed.keymaster.save_wallet(wallet, True)) is True
+
+ assert run(testbed.keymaster.publish_address("alice@archon.social")) is True
+ doc = run(testbed.keymaster.resolve_did(did))
+
+ assert doc["didDocumentData"]["address"] == "alice@archon.social"
+ assert {
+ "id": f"{did}#email",
+ "type": "Email",
+ "serviceEndpoint": "mailto:alice@archon.social",
+ } in doc["didDocument"]["service"]
+
+
+def test_publish_address_rejects_unstored_address(testbed):
+ run(testbed.keymaster.create_id("Alice"))
+
+ with pytest.raises(KeymasterError, match="Invalid parameter: address"):
+ run(testbed.keymaster.publish_address("alice@archon.social"))
+
+
+def test_unpublish_address_removes_profile_data_and_email_service(testbed):
+ did = run(testbed.keymaster.create_id("Alice"))
+ relay = run(testbed.keymaster.create_id("Herald"))
+ run(testbed.keymaster.set_current_id("Alice"))
+ wallet = run(testbed.keymaster.load_wallet())
+ wallet["ids"]["Alice"]["addresses"] = {
+ "archon.social": {"name": "alice", "added": "2026-04-04T13:00:00.000Z", "relay": relay}
+ }
+ assert run(testbed.keymaster.save_wallet(wallet, True)) is True
+ assert run(testbed.keymaster.publish_address("alice@archon.social")) is True
+
+ doc = run(testbed.keymaster.resolve_did(did))
+ doc["didDocument"]["service"].append(
+ {
+ "id": f"{did}#example",
+ "type": "Example",
+ "serviceEndpoint": "https://example.com",
+ }
+ )
+ assert run(testbed.keymaster.update_did(did, {"didDocument": doc["didDocument"]})) is True
+
+ assert run(testbed.keymaster.unpublish_address()) is True
+ doc = run(testbed.keymaster.resolve_did(did))
+
+ assert "address" not in doc.get("didDocumentData", {})
+ assert doc["didDocument"].get("service") == [
+ {
+ "id": f"{did}#example",
+ "type": "Example",
+ "serviceEndpoint": "https://example.com",
+ }
+ ]
diff --git a/services/herald/server/src/index.ts b/services/herald/server/src/index.ts
index 9b21eca1..b26073fe 100644
--- a/services/herald/server/src/index.ts
+++ b/services/herald/server/src/index.ts
@@ -443,6 +443,8 @@ app.get('/api/version', async (_: Request, res: Response) => {
app.get('/api/config', (_: Request, res: Response) => {
res.json({
serviceName: SERVICE_NAME,
+ serviceDID,
+ ...(SENDGRID_API_KEY ? { relayAgent: serviceDID } : {}),
serviceDomain: SERVICE_DOMAIN,
publicUrl: PUBLIC_URL,
walletUrl: WALLET_URL,
diff --git a/services/keymaster/server/src/keymaster-api.ts b/services/keymaster/server/src/keymaster-api.ts
index cfce7c9d..e66807cc 100644
--- a/services/keymaster/server/src/keymaster-api.ts
+++ b/services/keymaster/server/src/keymaster-api.ts
@@ -75,6 +75,7 @@ function normalizePath(path: string): string {
.replace(/\/aliases\/[^/]+/g, '/aliases/:alias')
.replace(/\/addresses\/check\/[^/]+/g, '/addresses/check/:address')
.replace(/\/addresses\/import/g, '/addresses/import')
+ .replace(/\/addresses\/publish/g, '/addresses/publish')
.replace(/\/addresses\/[^/]+/g, '/addresses/:address')
.replace(/\/groups\/[^/]+/g, '/groups/:name')
.replace(/\/schemas\/[^/]+/g, '/schemas/:id')
@@ -1995,6 +1996,101 @@ v1router.post('/addresses', async (req, res) => {
}
});
+/**
+ * @swagger
+ * /addresses/publish:
+ * post:
+ * summary: Publish a stored address to the current identity DID document.
+ * description: Sets `didDocumentData.address` to the selected stored address. If the stored address has a Herald relay, also publishes an `Email` DID service endpoint using `mailto:`.
+ * requestBody:
+ * required: false
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * address:
+ * type: string
+ * description: Optional `name@domain` address to publish. Required when the identity has more than one stored address.
+ * name:
+ * type: string
+ * description: Optional identity name. Defaults to the current identity.
+ * responses:
+ * 200:
+ * description: Indicates whether the address was successfully published.
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * ok:
+ * type: boolean
+ * 400:
+ * description: Bad request.
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * error:
+ * type: string
+ */
+v1router.post('/addresses/publish', async (req, res) => {
+ try {
+ const { address, name } = req.body || {};
+ const ok = await keymaster.publishAddress(address, name);
+ res.json({ ok });
+ } catch (error: any) {
+ res.status(400).send({ error: error.toString() });
+ }
+});
+
+/**
+ * @swagger
+ * /addresses/publish:
+ * delete:
+ * summary: Remove the published address from the current identity DID document.
+ * description: Removes `didDocumentData.address` and the `#email` DID service endpoint from the selected identity.
+ * requestBody:
+ * required: false
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * name:
+ * type: string
+ * description: Optional identity name. Defaults to the current identity.
+ * responses:
+ * 200:
+ * description: Indicates whether the address was successfully unpublished.
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * ok:
+ * type: boolean
+ * 400:
+ * description: Bad request.
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * error:
+ * type: string
+ */
+v1router.delete('/addresses/publish', async (req, res) => {
+ try {
+ const { name } = req.body || {};
+ const ok = await keymaster.unpublishAddress(name);
+ res.json({ ok });
+ } catch (error: any) {
+ res.status(400).send({ error: error.toString() });
+ }
+});
+
/**
* @swagger
* /addresses/{address}:
diff --git a/tests/keymaster/address.test.ts b/tests/keymaster/address.test.ts
index b418d6e2..72c468dd 100644
--- a/tests/keymaster/address.test.ts
+++ b/tests/keymaster/address.test.ts
@@ -266,6 +266,64 @@ describe('addAddress', () => {
);
});
+ it('should discover and save the Herald relay agent when the address domain advertises one', async () => {
+ const relay = await keymaster.createId('Herald');
+ await keymaster.createId('Alice');
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date('2026-04-04T13:15:00.000Z'));
+
+ jest.spyOn(keymaster, 'createResponse').mockResolvedValue('did:cid:response');
+ jest.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(mockFetchResponse(true, { challenge: 'did:cid:challenge' }))
+ .mockResolvedValueOnce(mockFetchResponse(true, { ok: true, name: 'alice' }))
+ .mockResolvedValueOnce(mockFetchResponse(true, { serviceDID: relay, relayAgent: relay }));
+
+ const ok = await keymaster.addAddress('alice@archon.social');
+ const addresses = await keymaster.listAddresses();
+ const walletData = await keymaster.loadWallet();
+ const info = {
+ added: '2026-04-04T13:15:00.000Z',
+ relay,
+ };
+
+ expect(ok).toBe(true);
+ expect(addresses).toStrictEqual({ 'alice@archon.social': info });
+ expect(walletData.ids.Alice.addresses).toStrictEqual({
+ 'archon.social': {
+ name: 'alice',
+ ...info,
+ },
+ });
+ expect(globalThis.fetch).toHaveBeenNthCalledWith(3, 'https://archon.social/names/api/config');
+ });
+
+ it('should not treat a Herald service DID as an email relay unless relay is advertised', async () => {
+ const serviceDID = await keymaster.createId('Herald');
+ await keymaster.createId('Alice');
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date('2026-04-04T13:20:00.000Z'));
+
+ jest.spyOn(keymaster, 'createResponse').mockResolvedValue('did:cid:response');
+ jest.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(mockFetchResponse(true, { challenge: 'did:cid:challenge' }))
+ .mockResolvedValueOnce(mockFetchResponse(true, { ok: true, name: 'alice' }))
+ .mockResolvedValueOnce(mockFetchResponse(true, { serviceDID }));
+
+ const ok = await keymaster.addAddress('alice@archon.social');
+ const addresses = await keymaster.listAddresses();
+ const walletData = await keymaster.loadWallet();
+ const info = { added: '2026-04-04T13:20:00.000Z' };
+
+ expect(ok).toBe(true);
+ expect(addresses).toStrictEqual({ 'alice@archon.social': info });
+ expect(walletData.ids.Alice.addresses).toStrictEqual({
+ 'archon.social': {
+ name: 'alice',
+ ...info,
+ },
+ });
+ });
+
it('should fall back to direct Herald endpoints when drawbridge paths are unavailable', async () => {
await keymaster.createId('Alice');
jest.useFakeTimers();
@@ -404,8 +462,12 @@ describe('removeAddress', () => {
jest.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(mockFetchResponse(true, { challenge: 'did:cid:challenge' }))
.mockResolvedValueOnce(mockFetchResponse(true, { ok: true, name: 'alice' }))
+ .mockResolvedValueOnce(mockFetchResponse(false, { error: 'not found' }, 404))
+ .mockResolvedValueOnce(mockFetchResponse(false, { error: 'not found' }, 404))
.mockResolvedValueOnce(mockFetchResponse(true, { challenge: 'did:cid:challenge-2' }))
- .mockResolvedValueOnce(mockFetchResponse(true, { ok: true, name: 'alice2' }));
+ .mockResolvedValueOnce(mockFetchResponse(true, { ok: true, name: 'alice2' }))
+ .mockResolvedValueOnce(mockFetchResponse(false, { error: 'not found' }, 404))
+ .mockResolvedValueOnce(mockFetchResponse(false, { error: 'not found' }, 404));
await keymaster.addAddress('alice@archon.social');
jest.setSystemTime(new Date('2026-04-04T14:05:00.000Z'));
@@ -464,3 +526,97 @@ describe('removeAddress', () => {
);
});
});
+
+describe('publishAddress', () => {
+ it('should publish one stored address as profile data without an Email service endpoint when no relay is stored', async () => {
+ const did = await keymaster.createId('Alice');
+ const walletData = await keymaster.loadWallet();
+ walletData.ids.Alice.addresses = {
+ 'archon.social': {
+ name: 'alice',
+ added: '2026-04-04T13:00:00.000Z',
+ },
+ };
+ await keymaster.saveWallet(walletData, true);
+
+ const ok = await keymaster.publishAddress('alice@archon.social');
+ const doc = await keymaster.resolveDID(did);
+
+ expect(ok).toBe(true);
+ expect(doc.didDocumentData).toMatchObject({
+ address: 'alice@archon.social',
+ });
+ expect(doc.didDocument?.service).toBeUndefined();
+ });
+
+ it('should publish an Email service endpoint when the stored address has a relay', async () => {
+ const did = await keymaster.createId('Alice');
+ const relay = await keymaster.createId('Herald');
+ await keymaster.setCurrentId('Alice');
+ const walletData = await keymaster.loadWallet();
+ walletData.ids.Alice.addresses = {
+ 'archon.social': {
+ name: 'alice',
+ added: '2026-04-04T13:00:00.000Z',
+ relay,
+ },
+ };
+ await keymaster.saveWallet(walletData, true);
+
+ const ok = await keymaster.publishAddress('alice@archon.social');
+ const doc = await keymaster.resolveDID(did);
+
+ expect(ok).toBe(true);
+ expect(doc.didDocumentData).toMatchObject({
+ address: 'alice@archon.social',
+ });
+ expect(doc.didDocument?.service).toContainEqual({
+ id: `${did}#email`,
+ type: 'Email',
+ serviceEndpoint: 'mailto:alice@archon.social',
+ });
+ });
+
+ it('should reject publishing an address that is not stored for the ID', async () => {
+ await keymaster.createId('Alice');
+
+ await expect(keymaster.publishAddress('alice@archon.social')).rejects.toThrow('Invalid parameter: address');
+ });
+
+ it('should unpublish the address and preserve unrelated service endpoints', async () => {
+ const did = await keymaster.createId('Alice');
+ const relay = await keymaster.createId('Herald');
+ await keymaster.setCurrentId('Alice');
+ const walletData = await keymaster.loadWallet();
+ walletData.ids.Alice.addresses = {
+ 'archon.social': {
+ name: 'alice',
+ added: '2026-04-04T13:00:00.000Z',
+ relay,
+ },
+ };
+ await keymaster.saveWallet(walletData, true);
+ await keymaster.publishAddress('alice@archon.social');
+
+ let doc = await keymaster.resolveDID(did);
+ doc.didDocument!.service!.push({
+ id: `${did}#example`,
+ type: 'Example',
+ serviceEndpoint: 'https://example.com',
+ });
+ await keymaster.updateDID(did, { didDocument: doc.didDocument });
+
+ const ok = await keymaster.unpublishAddress();
+ doc = await keymaster.resolveDID(did);
+
+ expect(ok).toBe(true);
+ expect(doc.didDocumentData).not.toHaveProperty('address');
+ expect(doc.didDocument?.service).toStrictEqual([
+ {
+ id: `${did}#example`,
+ type: 'Example',
+ serviceEndpoint: 'https://example.com',
+ },
+ ]);
+ });
+});
diff --git a/tests/keymaster/client.test.ts b/tests/keymaster/client.test.ts
index b1898390..300d2bac 100644
--- a/tests/keymaster/client.test.ts
+++ b/tests/keymaster/client.test.ts
@@ -1309,6 +1309,66 @@ describe('removeAddress', () => {
});
});
+describe('publishAddress', () => {
+ const mockAddress = 'alice@archon.social';
+
+ it('should publish address', async () => {
+ nock(KeymasterURL)
+ .post(`${Endpoints.addresses}/publish`, (body: any) => body.address === mockAddress)
+ .reply(200, { ok: true });
+
+ const keymaster = await KeymasterClient.create({ url: KeymasterURL });
+ const ok = await keymaster.publishAddress(mockAddress);
+
+ expect(ok).toStrictEqual(true);
+ });
+
+ it('should throw exception on publishAddress server error', async () => {
+ nock(KeymasterURL)
+ .post(`${Endpoints.addresses}/publish`)
+ .reply(500, ServerError);
+
+ const keymaster = await KeymasterClient.create({ url: KeymasterURL });
+
+ try {
+ await keymaster.publishAddress(mockAddress);
+ throw new ExpectedExceptionError();
+ }
+ catch (error: any) {
+ expect(error.message).toBe(ServerError.message);
+ }
+ });
+});
+
+describe('unpublishAddress', () => {
+ it('should unpublish address', async () => {
+ nock(KeymasterURL)
+ .delete(`${Endpoints.addresses}/publish`)
+ .reply(200, { ok: true });
+
+ const keymaster = await KeymasterClient.create({ url: KeymasterURL });
+ const ok = await keymaster.unpublishAddress();
+
+ expect(ok).toStrictEqual(true);
+ });
+
+ it('should throw exception on unpublishAddress server error', async () => {
+ nock(KeymasterURL)
+ .delete(`${Endpoints.addresses}/publish`)
+ .reply(500, ServerError);
+
+ const keymaster = await KeymasterClient.create({ url: KeymasterURL });
+
+ try {
+ await keymaster.unpublishAddress();
+ throw new ExpectedExceptionError();
+ }
+ catch (error: any) {
+ expect(error.message).toBe(ServerError.message);
+ }
+ });
+});
+
describe('addNostr', () => {
const mockKeys = { npub: 'npub1test', nsec: 'nsec1test' };