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' };