diff --git a/TODO.md b/TODO.md
index e4f247a..2d30e9d 100644
--- a/TODO.md
+++ b/TODO.md
@@ -94,7 +94,7 @@ Every new surface should feel like a GNOME 2 app that happens to speak `at://`.
- [x] Add canonical `/repos/:did/...` routing.
- [ ] Deepen Nautilus and gedit with collection controls plus record JSON/schema/info tabs.
-- [ ] Add Identity Inspector.
+- [x] Add Identity Inspector.
- [ ] Add System Monitor with Jetstream first.
- [ ] Add Network Map.
- [ ] Add Archive Manager.
diff --git a/src/lib/atproto/identity-inspector.test.ts b/src/lib/atproto/identity-inspector.test.ts
new file mode 100644
index 0000000..7753820
--- /dev/null
+++ b/src/lib/atproto/identity-inspector.test.ts
@@ -0,0 +1,119 @@
+/** Move to test/inspector.test.ts */
+import { describe, expect, it } from 'vitest';
+import {
+ findHandleAlias,
+ findPdsEndpoint,
+ inspectIdentity,
+ normalizeHandle,
+ resolveDidDocumentUrl,
+ validateHandle
+} from './identity-inspector';
+import type { DidDocument } from './types';
+
+describe('identity inspector helpers', () => {
+ it('resolves supported DID document URLs', () => {
+ expect(resolveDidDocumentUrl('did:plc:xg2vq45muivyy3xwatcehspu')).toBe(
+ 'https://plc.directory/did%3Aplc%3Axg2vq45muivyy3xwatcehspu'
+ );
+ expect(resolveDidDocumentUrl('did:web:example.com:user')).toBe('https://example.com/user/.well-known/did.json');
+ });
+
+ it('normalizes handle aliases and PDS services', () => {
+ expect(normalizeHandle('@DesertThunder.Dev')).toBe('desertthunder.dev');
+ expect(findHandleAlias(['at://desertthunder.dev'])).toBe('desertthunder.dev');
+ expect(
+ findPdsEndpoint([
+ { id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.example.com' }
+ ])
+ ).toBe('https://pds.example.com');
+ });
+
+ it('classifies handle validation responses', async () => {
+ const client = {
+ get: async (name: string, _init?: unknown) => {
+ if (name === 'com.atproto.identity.resolveHandle') {
+ return { ok: true, data: { did: 'did:plc:example' } };
+ }
+
+ throw new Error('Unexpected XRPC call');
+ }
+ };
+ const fetcher = async (input: RequestInfo | URL) => {
+ const url = String(input);
+
+ if (url.includes('/.well-known/atproto-did')) {
+ return new Response('did:plc:other');
+ }
+
+ return Response.json({ Answer: [{ data: '"did=did:plc:example"' }] });
+ };
+
+ const validation = await validateHandle('desertthunder.dev', 'did:plc:example', client, fetcher);
+
+ expect(validation.resolveHandle.status).toBe('valid');
+ expect(validation.wellKnown.status).toBe('mismatch');
+ expect(validation.dnsTxt.status).toBe('valid');
+ });
+
+ it('builds an inspection model from atcute DID resolution and PLC audit', async () => {
+ const didDocument: DidDocument = {
+ id: 'did:plc:example',
+ alsoKnownAs: ['at://desertthunder.dev'],
+ verificationMethod: [
+ {
+ id: 'did:plc:example#atproto',
+ type: 'Multikey',
+ controller: 'did:plc:example',
+ publicKeyMultibase: 'zExample'
+ }
+ ],
+ service: [{ id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.example.com' }]
+ };
+ const client = {
+ get: async (name: string, _init?: unknown) => {
+ if (name === 'com.atproto.identity.resolveDid') {
+ return { ok: true, data: { didDoc: didDocument } };
+ }
+
+ if (name === 'com.atproto.identity.resolveHandle') {
+ return { ok: true, data: { did: 'did:plc:example' } };
+ }
+
+ throw new Error('Unexpected XRPC call');
+ }
+ };
+ const fetcher = async (input: RequestInfo | URL) => {
+ const url = String(input);
+
+ if (url.endsWith('/log/audit')) {
+ return Response.json([
+ {
+ cid: 'bafyold',
+ nullified: false,
+ createdAt: '2024-01-01T00:00:00.000Z',
+ operation: { rotationKeys: ['did:key:old'] }
+ },
+ {
+ cid: 'bafynew',
+ nullified: false,
+ createdAt: '2024-02-01T00:00:00.000Z',
+ operation: { rotationKeys: ['did:key:new'] }
+ }
+ ]);
+ }
+
+ if (url.includes('/.well-known/atproto-did')) {
+ return new Response('did:plc:example');
+ }
+
+ return Response.json({ Answer: [] });
+ };
+
+ const inspection = await inspectIdentity('did:plc:example', null, { client, fetcher });
+
+ expect(inspection.handle).toBe('desertthunder.dev');
+ expect(inspection.pds).toBe('https://pds.example.com');
+ expect(inspection.rotationKeys).toEqual(['did:key:new']);
+ expect(inspection.handleValidation?.wellKnown.status).toBe('valid');
+ });
+});
diff --git a/src/lib/atproto/identity-inspector.ts b/src/lib/atproto/identity-inspector.ts
new file mode 100644
index 0000000..38b9c66
--- /dev/null
+++ b/src/lib/atproto/identity-inspector.ts
@@ -0,0 +1,270 @@
+/** TODO: this should be identity/inspector.ts */
+import { Client, simpleFetchHandler } from '@atcute/client';
+import type { Did, Handle } from '@atcute/lexicons/syntax';
+import type {} from '@atcute/atproto';
+import type { DidDocument, DidService, DidVerificationMethod } from './types';
+
+export type IdentityValidationStatus = 'valid' | 'mismatch' | 'unavailable' | 'unsupported';
+
+export type HandleValidation = {
+ handle: string;
+ resolveHandle: IdentityCheck;
+ wellKnown: IdentityCheck;
+ dnsTxt: IdentityCheck;
+};
+
+export type IdentityCheck = { status: IdentityValidationStatus; label: string; detail: string; value: string | null };
+
+export type PlcAuditEntry = {
+ cid: string;
+ createdAt: string;
+ nullified: boolean;
+ operation: {
+ alsoKnownAs?: string[];
+ rotationKeys?: string[];
+ verificationMethods?: Record;
+ services?: Record;
+ };
+};
+
+export type IdentityInspection = {
+ did: string;
+ handle: string | null;
+ didDocument: DidDocument;
+ aliases: string[];
+ services: DidService[];
+ verificationMethods: DidVerificationMethod[];
+ rotationKeys: string[];
+ pds: string | null;
+ didDocumentUrl: string;
+ plcAuditUrl: string | null;
+ handleValidation: HandleValidation | null;
+ rawJson: string;
+};
+
+type IdentityClient = { get: (name: string, init?: unknown) => Promise };
+
+export type ResolveDidDocumentOptions = { client?: IdentityClient; fetcher?: typeof fetch };
+
+type InspectIdentityOptions = ResolveDidDocumentOptions;
+
+const publicApi = new Client({ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) });
+const publicIdentityApi = publicApi as unknown as IdentityClient;
+
+export async function inspectIdentity(
+ did: string,
+ fallbackHandle: string | null = null,
+ options: InspectIdentityOptions = {}
+): Promise {
+ if (!did.startsWith('did:')) {
+ throw new Error('Identity Inspector requires a DID.');
+ }
+
+ const client = options.client ?? publicIdentityApi;
+ const fetcher = options.fetcher ?? fetch;
+ const didDocumentUrl = resolveDidDocumentUrl(did);
+ const didDocument = await resolveDidDocument(did, { client, fetcher });
+ const aliases = didDocument.alsoKnownAs ?? [];
+ const handle = findHandleAlias(aliases) ?? normalizeHandle(fallbackHandle);
+ const services = didDocument.service ?? [];
+ const verificationMethods = didDocument.verificationMethod ?? [];
+ const pds = findPdsEndpoint(services);
+ const audit = did.startsWith('did:plc:') ? await fetchPlcAudit(did, fetcher).catch(() => []) : [];
+ const latestOperation = audit.at(-1)?.operation;
+ const rotationKeys = latestOperation?.rotationKeys ?? [];
+
+ return {
+ did,
+ handle,
+ didDocument,
+ aliases,
+ services,
+ verificationMethods,
+ rotationKeys,
+ pds,
+ didDocumentUrl,
+ plcAuditUrl: did.startsWith('did:plc:') ? `${didDocumentUrl}/log/audit` : null,
+ handleValidation: handle ? await validateHandle(handle, did, client, fetcher) : null,
+ rawJson: JSON.stringify(didDocument, null, 2)
+ };
+}
+
+export async function validateHandle(
+ handle: string,
+ did: string,
+ client: IdentityClient = publicIdentityApi,
+ fetcher: typeof fetch = fetch
+): Promise {
+ const normalizedHandle = normalizeHandle(handle);
+
+ if (!normalizedHandle) {
+ throw new Error('Handle is empty.');
+ }
+
+ const [resolveHandle, wellKnown, dnsTxt] = await Promise.all([
+ validateResolveHandle(normalizedHandle, did, client),
+ validateWellKnown(normalizedHandle, did, fetcher),
+ validateDnsTxt(normalizedHandle, did, fetcher)
+ ]);
+
+ return { handle: normalizedHandle, resolveHandle, wellKnown, dnsTxt };
+}
+
+export function resolveDidDocumentUrl(did: string) {
+ if (did.startsWith('did:plc:')) {
+ return `https://plc.directory/${encodeURIComponent(did)}`;
+ }
+
+ if (did.startsWith('did:web:')) {
+ const domain = did.slice('did:web:'.length).replaceAll(':', '/');
+ return `https://${domain}/.well-known/did.json`;
+ }
+
+ throw new Error(`Unsupported DID method: ${did}`);
+}
+
+export function findPdsEndpoint(services: DidService[]) {
+ const pdsService = services.find(
+ (service) => service.id === '#atproto_pds' || service.type === 'AtprotoPersonalDataServer'
+ );
+ const endpoint = pdsService?.serviceEndpoint;
+
+ return typeof endpoint === 'string' ? endpoint : null;
+}
+
+export function findHandleAlias(aliases: string[]) {
+ const alias = aliases.find((value) => value.startsWith('at://'));
+ return alias ? normalizeHandle(alias.slice('at://'.length)) : null;
+}
+
+export function normalizeHandle(handle: string | null | undefined) {
+ const normalized = handle?.trim().replace(/^@/, '').toLowerCase();
+ return normalized || null;
+}
+
+export async function resolveDidDocument(did: string, options: ResolveDidDocumentOptions = {}): Promise {
+ const client = options.client ?? publicIdentityApi;
+ const fetcher = options.fetcher ?? fetch;
+
+ try {
+ const result = unwrapXrpcData(
+ await client.get('com.atproto.identity.resolveDid', { params: { did: did as Did } })
+ ) as { didDoc: unknown };
+ return result.didDoc as DidDocument;
+ } catch {
+ const response = await fetcher(resolveDidDocumentUrl(did));
+
+ if (!response.ok) {
+ throw new Error(`Could not fetch DID document (${response.status}).`);
+ }
+
+ return (await response.json()) as DidDocument;
+ }
+}
+
+async function validateResolveHandle(handle: string, did: string, client: IdentityClient): Promise {
+ try {
+ const result = unwrapXrpcData(
+ await client.get('com.atproto.identity.resolveHandle', { params: { handle: handle as Handle } })
+ ) as { did: string };
+ const status = result.did === did ? 'valid' : 'mismatch';
+
+ return {
+ status,
+ label: 'XRPC resolveHandle',
+ detail:
+ status === 'valid'
+ ? 'Public ATProto resolver matches this DID.'
+ : 'Public ATProto resolver returned a different DID.',
+ value: result.did
+ };
+ } catch {
+ return unavailableCheck('XRPC resolveHandle', 'Could not resolve this handle through public.api.bsky.app.');
+ }
+}
+
+async function validateWellKnown(handle: string, did: string, fetcher: typeof fetch): Promise {
+ try {
+ const response = await fetcher(`https://${handle}/.well-known/atproto-did`);
+
+ if (!response.ok) {
+ return unavailableCheck('.well-known', `HTTP ${response.status} from the handle well-known endpoint.`);
+ }
+
+ const value = (await response.text()).trim();
+ const status = value === did ? 'valid' : 'mismatch';
+
+ return {
+ status,
+ label: '.well-known',
+ detail:
+ status === 'valid'
+ ? 'Handle well-known file asserts this DID.'
+ : 'Endpoint responded, but did not assert this DID.',
+ value: value.slice(0, 160)
+ };
+ } catch {
+ return unavailableCheck('.well-known', 'Could not fetch the handle well-known endpoint.');
+ }
+}
+
+async function validateDnsTxt(handle: string, did: string, fetcher: typeof fetch): Promise {
+ try {
+ const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(`_atproto.${handle}`)}&type=TXT`;
+ const response = await fetcher(url, { headers: { accept: 'application/dns-json' } });
+
+ if (!response.ok) {
+ return unavailableCheck('DNS TXT', `DNS-over-HTTPS returned HTTP ${response.status}.`);
+ }
+
+ const data = (await response.json()) as { Answer?: Array<{ data?: string }> };
+ const values = data.Answer?.map((answer) => answer.data ?? '').map(stripDnsTxtQuotes) ?? [];
+ const assertion = values.find((value) => value.startsWith('did='));
+
+ if (!assertion) {
+ return unavailableCheck('DNS TXT', 'No _atproto TXT DID assertion was found.');
+ }
+
+ const value = assertion.slice('did='.length);
+ const status = value === did ? 'valid' : 'mismatch';
+
+ return {
+ status,
+ label: 'DNS TXT',
+ detail: status === 'valid' ? 'DNS TXT record asserts this DID.' : 'DNS TXT record asserts a different DID.',
+ value
+ };
+ } catch {
+ return unavailableCheck('DNS TXT', 'Could not query DNS-over-HTTPS for this handle.');
+ }
+}
+
+async function fetchPlcAudit(did: string, fetcher: typeof fetch): Promise {
+ const response = await fetcher(`${resolveDidDocumentUrl(did)}/log/audit`);
+
+ if (!response.ok) {
+ return [];
+ }
+
+ return (await response.json()) as PlcAuditEntry[];
+}
+
+function unavailableCheck(label: string, detail: string): IdentityCheck {
+ return { status: 'unavailable', label, detail, value: null };
+}
+
+function stripDnsTxtQuotes(value: string) {
+ return value.replace(/^"|"$/g, '').replaceAll('" "', '');
+}
+
+function unwrapXrpcData(response: unknown) {
+ if (typeof response !== 'object' || response === null) {
+ throw new Error('Invalid XRPC response.');
+ }
+
+ if (!('ok' in response) || response.ok !== true || !('data' in response)) {
+ throw new Error('XRPC request failed.');
+ }
+
+ return response.data;
+}
diff --git a/src/lib/atproto/identity.ts b/src/lib/atproto/identity.ts
index 4c32be3..d723f88 100644
--- a/src/lib/atproto/identity.ts
+++ b/src/lib/atproto/identity.ts
@@ -3,9 +3,8 @@ import type { ActorIdentifier } from '@atcute/lexicons/syntax';
import type { Handle } from '@atcute/lexicons/syntax';
import type {} from '@atcute/atproto';
import type {} from '@atcute/bluesky';
-import type { AccountIdentity, ActorTypeaheadResult, DidDocument } from './types';
-
-export type { AccountIdentity, ActorTypeaheadResult } from './types';
+import { findPdsEndpoint, resolveDidDocument } from './identity-inspector';
+import type { AccountIdentity, ActorTypeaheadResult } from './types';
export const defaultIdentity = { handle: 'desertthunder.dev', did: 'did:plc:xg2vq45muivyy3xwatcehspu' };
@@ -76,30 +75,6 @@ async function getPublicProfile(actor: string) {
}
async function resolvePds(did: string): Promise {
- const response = await fetch(resolveDidDocumentUrl(did));
-
- if (!response.ok) {
- return null;
- }
-
- const document = (await response.json()) as DidDocument;
- const pdsService = document.service?.find(
- (service) => service.id === '#atproto_pds' || service.type === 'AtprotoPersonalDataServer'
- );
- const endpoint = pdsService?.serviceEndpoint;
-
- return typeof endpoint === 'string' ? endpoint : null;
-}
-
-function resolveDidDocumentUrl(did: string) {
- if (did.startsWith('did:plc:')) {
- return `https://plc.directory/${encodeURIComponent(did)}`;
- }
-
- if (did.startsWith('did:web:')) {
- const domain = did.slice('did:web:'.length).replaceAll(':', '/');
- return `https://${domain}/.well-known/did.json`;
- }
-
- throw new Error(`Unsupported DID method: ${did}`);
+ const document = await resolveDidDocument(did);
+ return findPdsEndpoint(document.service ?? []);
}
diff --git a/src/lib/atproto/routes.test.ts b/src/lib/atproto/routes.test.ts
index de201e7..77f00d5 100644
--- a/src/lib/atproto/routes.test.ts
+++ b/src/lib/atproto/routes.test.ts
@@ -1,5 +1,6 @@
+/** Move to test/routes.test.ts */
import { describe, expect, it } from 'vitest';
-import { collectionPath, recordPath, repoPath } from './routes';
+import { collectionPath, identityPath, recordPath, repoPath } from './routes';
describe('ATProto route helpers', () => {
it('builds canonical repo routes', () => {
@@ -12,6 +13,10 @@ describe('ATProto route helpers', () => {
);
});
+ it('builds canonical identity inspector routes', () => {
+ expect(identityPath('did:plc:abc123')).toBe('/repos/did:plc:abc123/identity');
+ });
+
it('encodes record keys in canonical record routes', () => {
expect(recordPath({ did: 'did:plc:abc123', collection: 'app.bsky.feed.post', rkey: 'post/key' })).toBe(
'/repos/did:plc:abc123/collections/app.bsky.feed.post/post%2Fkey'
diff --git a/src/lib/atproto/routes.ts b/src/lib/atproto/routes.ts
index 1814f22..97fcce0 100644
--- a/src/lib/atproto/routes.ts
+++ b/src/lib/atproto/routes.ts
@@ -6,6 +6,10 @@ export function repoPath(did: string) {
return `/repos/${did}`;
}
+export function identityPath(did: string) {
+ return `${repoPath(did)}/identity`;
+}
+
export function collectionPath({ did, collection }: CollectionRouteParams) {
return `${repoPath(did)}/collections/${collection}`;
}
diff --git a/src/lib/atproto/types.ts b/src/lib/atproto/types.ts
index 0f8a253..d99eeed 100644
--- a/src/lib/atproto/types.ts
+++ b/src/lib/atproto/types.ts
@@ -10,7 +10,24 @@ export type AccountIdentity = {
export type ActorTypeaheadResult = Pick;
export type DidDocument = {
- service?: Array<{ id?: string; type?: string; serviceEndpoint?: string | string[] | Record }>;
+ '@context'?: unknown;
+ id?: string;
+ alsoKnownAs?: string[];
+ verificationMethod?: DidVerificationMethod[];
+ service?: DidService[];
+};
+
+export type DidService = {
+ id?: string;
+ type?: string;
+ serviceEndpoint?: string | string[] | Record;
+};
+
+export type DidVerificationMethod = {
+ id?: string;
+ type?: string;
+ controller?: string;
+ publicKeyMultibase?: string;
};
export type CollectionSummary = { name: string; icon: string; appLabel: string | null; loadedCount: number | null };
diff --git a/src/lib/components/CollectionBrowser.svelte b/src/lib/components/CollectionBrowser.svelte
index 74a1baa..4b38d43 100644
--- a/src/lib/components/CollectionBrowser.svelte
+++ b/src/lib/components/CollectionBrowser.svelte
@@ -3,7 +3,7 @@
import { onMount } from 'svelte';
import { accountSetup } from '$lib/atproto/setup.svelte';
import { repoBrowser } from '$lib/atproto/repo.svelte';
- import { recordPath } from '$lib/atproto/routes';
+ import { identityPath, recordPath } from '$lib/atproto/routes';
import type { CollectionSummary, RepoRecordSummary } from '$lib/atproto/types';
import { windowManager } from '$lib/window-manager.svelte';
import { SvelteMap } from 'svelte/reactivity';
@@ -57,11 +57,16 @@
}
}
+ function openIdentityInspector() {
+ const identity = accountSetup.identity;
+ if (!identity) return;
+
+ void goto(identityPath(identity.did), { keepFocus: true, noScroll: true });
+ }
+
function groupCollections(collections: CollectionSummary[]) {
- const groups = new SvelteMap<
- string,
- { namespace: string; label: string; icon: string; collections: CollectionSummary[] }
- >();
+ type M = { namespace: string; label: string; icon: string; collections: CollectionSummary[] };
+ const groups = new SvelteMap();
for (const collection of collections) {
const namespace = namespaceForCollection(collection.name);
@@ -153,26 +158,36 @@
{/if}
-
+
+
+
+
+ Identity
+
+
@@ -188,9 +203,11 @@
{repoBrowser.isSearching ? 'Searching cached records…' : 'Loading records…'}
{:else if repoBrowser.records.length === 0}
- {repoBrowser.searchQuery
- ? 'No cached records matched that search.'
- : 'No public records found for this collection.'}
+ {#if repoBrowser.searchQuery}
+ No cached records matched that search.
+ {:else}
+ No public records found for this collection.
+ {/if}
{:else}
{#each repoBrowser.records as record (record.uri)}
@@ -378,6 +395,7 @@
border-bottom: 1px solid #b29366;
}
+ .summary-controls,
.search-box {
display: grid;
gap: var(--space-1);
@@ -410,7 +428,8 @@
font-size: var(--text-1);
}
- .search-box button {
+ .search-box button,
+ .identity-launcher {
padding: 0.35rem 0.55rem;
color: #2c180d;
background: linear-gradient(#fff8e8, #d3b17d);
@@ -422,8 +441,22 @@
font-weight: 700;
}
+ .identity-launcher {
+ display: inline-flex;
+ align-items: center;
+ justify-self: end;
+ gap: var(--space-1);
+ min-height: 1.9rem;
+ cursor: default;
+ }
+
+ .identity-launcher:active {
+ scale: 0.96;
+ }
+
.search-box button:disabled,
- .search-box input:disabled {
+ .search-box input:disabled,
+ .identity-launcher:disabled {
opacity: 0.62;
}
diff --git a/src/lib/components/IdentityInspector.svelte b/src/lib/components/IdentityInspector.svelte
new file mode 100644
index 0000000..a7e135b
--- /dev/null
+++ b/src/lib/components/IdentityInspector.svelte
@@ -0,0 +1,599 @@
+
+
+
+
+
+
+
+
+ {#if error}
+
+
+
{error}
+
+ {:else if isLoading || !inspection}
+
+
+
Unlocking public identity material...
+
+ {:else}
+
+
+
+
Default public keyring
+
{inspection.handle ? `@${inspection.handle}` : inspection.did}
+
{inspection.did}
+
+
+
+ {#if selectedSection === 'overview'}
+
+ Identity Properties
+
+ {@render PropertyRow('DID', inspection.did, copyValue, copiedValue === inspection.did)}
+ {@render PropertyRow(
+ 'Handle',
+ inspection.handle ?? 'No handle alias',
+ copyValue,
+ copiedValue === inspection.handle
+ )}
+ {@render PropertyRow('PDS', inspection.pds ?? 'Not advertised', copyValue, copiedValue === inspection.pds)}
+ {@render PropertyRow(
+ 'DID document',
+ inspection.didDocumentUrl,
+ copyValue,
+ copiedValue === inspection.didDocumentUrl
+ )}
+
+
+ {#if inspection.handleValidation}
+
+ {#each [inspection.handleValidation.resolveHandle, inspection.handleValidation.wellKnown, inspection.handleValidation.dnsTxt] as check (check.label)}
+
+
+
+
{check.label}
+
{check.detail}
+ {#if check.value}
+
{check.value}
+ {/if}
+
+
{statusLabel(check.status)}
+
+ {/each}
+
+ {/if}
+
+ {:else if selectedSection === 'aliases'}
+
+ Aliases
+
+ {#each inspection.aliases as alias (alias)}
+ {@render PropertyRow('alsoKnownAs', alias, copyValue, copiedValue === alias)}
+ {:else}
+
No aliases are listed in the DID document.
+ {/each}
+
+
+ {:else if selectedSection === 'services'}
+
+ Services
+
+ {#each inspection.services as service, index (`${service.id}-${index}`)}
+
+
+
+
{service.id ?? 'Unnamed service'}
+
{service.type ?? 'Unknown service type'}
+
{typeof service.serviceEndpoint === 'string' ? service.serviceEndpoint : JSON.stringify(service.serviceEndpoint)}
+
+
+ {:else}
+
No services are listed in the DID document.
+ {/each}
+
+
+ {:else if selectedSection === 'keys'}
+
+ Verification Methods
+
+ {#each inspection.verificationMethods as method, index (`${method.id}-${index}`)}
+
+
+
+
{method.id ?? 'Unnamed verification method'}
+
{method.type ?? 'Unknown key type'}
+
{method.publicKeyMultibase ?? method.controller ?? 'No public key material'}
+
+
+ {:else}
+
No verification methods are listed in the DID document.
+ {/each}
+
+
+ Rotation Keys
+
+ {#each inspection.rotationKeys as key (key)}
+ {@render PropertyRow('PLC rotation key', key, copyValue, copiedValue === key)}
+ {:else}
+
No PLC rotation keys were available.
+ {/each}
+
+
+ {:else}
+
+ Raw DID Document
+ {inspection.rawJson}
+
+ {/if}
+ {/if}
+
+
+
+{#snippet PropertyRow(label: string, value: string, oncopy: (value: string) => unknown, copied = false)}
+
+
{label}
+
+ {value}
+ oncopy(value)}>
+
+ {copied ? 'Copied' : 'Copy'}
+
+
+
+{/snippet}
+
+
diff --git a/src/lib/window-manager.svelte.ts b/src/lib/window-manager.svelte.ts
index 97ddcf5..0ba6c11 100644
--- a/src/lib/window-manager.svelte.ts
+++ b/src/lib/window-manager.svelte.ts
@@ -1,4 +1,6 @@
-export type WindowId = 'main' | 'about-computer' | 'gedit' | 'document-viewer';
+/* Split up this file into windows/types.ts & windows/manager.svelte.ts */
+
+export type WindowId = 'main' | 'about-computer' | 'gedit' | 'document-viewer' | 'identity-inspector';
export type ManagedWindow = {
id: WindowId;
@@ -47,10 +49,19 @@ class WindowManager {
isMinimized: false,
isMaximized: false,
zIndex: 4
+ },
+ {
+ id: 'identity-inspector',
+ title: 'Identity Inspector',
+ icon: '/icons/humanity/apps/identity-inspector.svg',
+ isOpen: false,
+ isMinimized: false,
+ isMaximized: false,
+ zIndex: 5
}
]);
- private nextZIndex = 4;
+ private nextZIndex = 5;
get openWindows() {
return this.windows.filter((window) => window.isOpen);
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 725c55c..5e5e2e3 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,3 +1,5 @@
+