diff --git a/Makefile b/Makefile index 3bc977c15a3..d7a1720a60e 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,11 @@ codegen: ## Re-generate packages from lexicon/ files # clean up codegen output pnpm format +.PHONY: docs +docs: ## Re-generate API documentation (eg, READMEs) + pnpm run --filter @atproto/identity docs + pnpm prettier --write packages/identity/API.md + .PHONY: lint lint: ## Run style checks and verify syntax pnpm verify diff --git a/packages/identity/API.md b/packages/identity/API.md new file mode 100644 index 00000000000..e7118e9b102 --- /dev/null +++ b/packages/identity/API.md @@ -0,0 +1,157 @@ +# @atproto/identity API Documentation + + + +## :toolbox: Functions + +- [getDid](#gear-getdid) +- [getKey](#gear-getkey) +- [getHandle](#gear-gethandle) +- [getPds](#gear-getpds) +- [getFeedGen](#gear-getfeedgen) +- [getNotif](#gear-getnotif) +- [parseToAtprotoDocument](#gear-parsetoatprotodocument) +- [ensureAtpDocument](#gear-ensureatpdocument) + +### :gear: getDid + +| Function | Type | +| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `getDid` | `(doc: { id?: string; alsoKnownAs?: string[]; verificationMethod?: { id?: string; type?: string; controller?: string; publicKeyMultibase?: string; }[]; service?: { id?: string; type?: string; serviceEndpoint?: string or Record<...>; }[]; }) => string` | + +### :gear: getKey + +| Function | Type | +| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `getKey` | `(doc: { id?: string; alsoKnownAs?: string[]; verificationMethod?: { id?: string; type?: string; controller?: string; publicKeyMultibase?: string; }[]; service?: { id?: string; type?: string; serviceEndpoint?: string or Record<...>; }[]; }) => string` | + +### :gear: getHandle + +| Function | Type | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `getHandle` | `(doc: { id?: string; alsoKnownAs?: string[]; verificationMethod?: { id?: string; type?: string; controller?: string; publicKeyMultibase?: string; }[]; service?: { id?: string; type?: string; serviceEndpoint?: string or Record<...>; }[]; }) => string` | + +### :gear: getPds + +| Function | Type | +| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `getPds` | `(doc: { id?: string; alsoKnownAs?: string[]; verificationMethod?: { id?: string; type?: string; controller?: string; publicKeyMultibase?: string; }[]; service?: { id?: string; type?: string; serviceEndpoint?: string or Record<...>; }[]; }) => string` | + +### :gear: getFeedGen + +| Function | Type | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `getFeedGen` | `(doc: { id?: string; alsoKnownAs?: string[]; verificationMethod?: { id?: string; type?: string; controller?: string; publicKeyMultibase?: string; }[]; service?: { id?: string; type?: string; serviceEndpoint?: string or Record<...>; }[]; }) => string` | + +### :gear: getNotif + +| Function | Type | +| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `getNotif` | `(doc: { id?: string; alsoKnownAs?: string[]; verificationMethod?: { id?: string; type?: string; controller?: string; publicKeyMultibase?: string; }[]; service?: { id?: string; type?: string; serviceEndpoint?: string or Record<...>; }[]; }) => string` | + +### :gear: parseToAtprotoDocument + +| Function | Type | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `parseToAtprotoDocument` | `(doc: { id?: string; alsoKnownAs?: string[]; verificationMethod?: { id?: string; type?: string; controller?: string; publicKeyMultibase?: string; }[]; service?: { id?: string; type?: string; serviceEndpoint?: string or Record<...>; }[]; }) => Partial<...>` | + +### :gear: ensureAtpDocument + +| Function | Type | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ensureAtpDocument` | `(doc: { id?: string; alsoKnownAs?: string[]; verificationMethod?: { id?: string; type?: string; controller?: string; publicKeyMultibase?: string; }[]; service?: { id?: string; type?: string; serviceEndpoint?: string or Record<...>; }[]; }) => AtprotoData` | + +## :wrench: Constants + +- [verificationMethod](#gear-verificationmethod) +- [service](#gear-service) +- [didDocument](#gear-diddocument) + +### :gear: verificationMethod + +| Constant | Type | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `verificationMethod` | `ZodObject<{ id: ZodString; type: ZodString; controller: ZodString; publicKeyMultibase: ZodOptional; }, "strip", ZodTypeAny, { ...; }, { ...; }>` | + +### :gear: service + +| Constant | Type | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `service` | `ZodObject<{ id: ZodString; type: ZodString; serviceEndpoint: ZodUnion<[ZodString, ZodRecord]>; }, "strip", ZodTypeAny, { ...; }, { ...; }>` | + +### :gear: didDocument + +| Constant | Type | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `didDocument` | `ZodObject<{ id: ZodString; alsoKnownAs: ZodOptional>; verificationMethod: ZodOptional; }, "strip", ZodTypeAny, { ...; }, { ...; }>, "many">>; service: ZodOptional<...>; }, "str...` | + +## :factory: DidNotFoundError + +## :factory: PoorlyFormattedDidError + +## :factory: UnsupportedDidMethodError + +## :factory: PoorlyFormattedDidDocumentError + +## :factory: UnsupportedDidWebPathError + +## :factory: HandleResolver + +Resolves a handle (domain name) to a DID. + +Calling code must validate handle/DID pariing against the DID document itself. + +### Methods + +- [parseDnsResult](#gear-parsednsresult) + +#### :gear: parseDnsResult + +| Method | Type | +| ---------------- | ---------------------------------------- | +| `parseDnsResult` | `(chunkedResults: string[][]) => string` | + +## :factory: BaseResolver + +### Methods + +- [validateDidDoc](#gear-validatediddoc) + +#### :gear: validateDidDoc + +Throws if argument is not a valid DidDocument. + +Only checks type structure, does not parse internal fields. + +| Method | Type | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `validateDidDoc` | `(did: string, val: unknown) => { id?: string; alsoKnownAs?: string[]; verificationMethod?: { id?: string; type?: string; controller?: string; publicKeyMultibase?: string; }[]; service?: { id?: string; type?: string; serviceEndpoint?: string or Record<...>; }[]; }` | + +Parameters: + +- `did`: - DID to verify against field in the document itself +- `val`: - object to verify structure as a DidDocument + +## :factory: DidWebResolver + +Resolves did:web DIDs. + +Supports only top-level (domain) DIDs, not paths (with additional ":" segments in the DID). UnsupportedDidWebPathError will be thrown if path segments are detected. + +## :factory: DidPlcResolver + +## :factory: DidResolver + +## :factory: IdResolver + +Wrapper of a DID resolver and handle resolver. + +Calling code is responsible for cross-validate handle/DID pairing. + +## :factory: MemoryCache + +Tiered in-memory cache. + +Entries older than maxTTL are considered invalid and not returned. Entries older than staleTTL are returned, marked as stale. + + diff --git a/packages/identity/package.json b/packages/identity/package.json index 59de4cc414c..de600807933 100644 --- a/packages/identity/package.json +++ b/packages/identity/package.json @@ -23,6 +23,7 @@ "test": "jest", "test:log": "cat test.log | pino-pretty", "build": "node ./build.js", + "docs": "tsdoc --src=src/*,src/handle/*,src/did/* --dest=API.md", "postbuild": "tsc --build tsconfig.build.json", "update-main-to-dist": "node ../../update-main-to-dist.js packages/identity" }, @@ -37,6 +38,7 @@ "@did-plc/server": "^0.0.1", "cors": "^2.8.5", "express": "^4.18.2", - "get-port": "^6.1.2" + "get-port": "^6.1.2", + "tsdoc-markdown": "^0.0.4" } } diff --git a/packages/identity/src/did/base-resolver.ts b/packages/identity/src/did/base-resolver.ts index fb3d7bc57f8..1046e0d8c3a 100644 --- a/packages/identity/src/did/base-resolver.ts +++ b/packages/identity/src/did/base-resolver.ts @@ -9,6 +9,13 @@ export abstract class BaseResolver { abstract resolveNoCheck(did: string): Promise + /** Throws if argument is not a valid DidDocument. + * + * Only checks type structure, does not parse internal fields. + * + * @param did - DID to verify against field in the document itself + * @param val - object to verify structure as a DidDocument + */ validateDidDoc(did: string, val: unknown): DidDocument { if (!check.is(val, didDocument)) { throw new PoorlyFormattedDidDocumentError(did, val) @@ -29,6 +36,12 @@ export abstract class BaseResolver { await this.cache?.refreshCache(did, () => this.resolveNoCache(did)) } + /** + * Resolves DID, possibly from cached. Stale entries are refreshed; never returns a stale entry. + * + * @param forceRefresh - force update of cache + * @returns DID document if successful, null if successful not found (eg, HTTP 404), throws on resolution error + */ async resolve( did: string, forceRefresh = false, @@ -52,6 +65,7 @@ export abstract class BaseResolver { return got } + /** Variant of resolve() which throws if DID does not exist, instead of returning null */ async ensureResolve(did: string, forceRefresh = false): Promise { const result = await this.resolve(did, forceRefresh) if (result === null) { @@ -68,6 +82,7 @@ export abstract class BaseResolver { return atprotoData.ensureAtpDocument(didDocument) } + /** Helper to do DID resolution and extract repo public key */ async resolveAtprotoKey(did: string, forceRefresh = false): Promise { if (did.startsWith('did:key:')) { return did @@ -77,6 +92,7 @@ export abstract class BaseResolver { } } + /** Helper to do DID resolution, extract repo public key, and verify signature */ async verifySignature( did: string, data: Uint8Array, diff --git a/packages/identity/src/did/memory-cache.ts b/packages/identity/src/did/memory-cache.ts index c5ab8c4ec8d..8d9b68fb23f 100644 --- a/packages/identity/src/did/memory-cache.ts +++ b/packages/identity/src/did/memory-cache.ts @@ -6,6 +6,11 @@ type CacheVal = { updatedAt: number } +/** + * Tiered in-memory cache. + * + * Entries older than maxTTL are considered invalid and not returned. Entries older than staleTTL are returned, marked as stale. + */ export class MemoryCache implements DidCache { public staleTTL: number public maxTTL: number diff --git a/packages/identity/src/did/web-resolver.ts b/packages/identity/src/did/web-resolver.ts index d93c886d57f..9574e605d84 100644 --- a/packages/identity/src/did/web-resolver.ts +++ b/packages/identity/src/did/web-resolver.ts @@ -3,8 +3,13 @@ import BaseResolver from './base-resolver' import { DidCache } from '../types' import { PoorlyFormattedDidError, UnsupportedDidWebPathError } from '../errors' -export const DOC_PATH = '/.well-known/did.json' +const DIDWEB_WELLKNOWN_PATH = '/.well-known/did.json' +/** + * Resolves did:web DIDs. + * + * Supports only top-level (domain) DIDs, not paths (with additional ":" segments in the DID). UnsupportedDidWebPathError will be thrown if path segments are detected. + */ export class DidWebResolver extends BaseResolver { constructor(public timeout: number, public cache?: DidCache) { super(cache) @@ -17,7 +22,7 @@ export class DidWebResolver extends BaseResolver { if (parts.length < 1) { throw new PoorlyFormattedDidError(did) } else if (parts.length === 1) { - path = parts[0] + DOC_PATH + path = parts[0] + DIDWEB_WELLKNOWN_PATH } else { // how we *would* resolve a did:web with path, if atproto supported it //path = parts.join('/') + '/did.json' diff --git a/packages/identity/src/handle/index.ts b/packages/identity/src/handle/index.ts index 4048f963cd5..3ca741544a5 100644 --- a/packages/identity/src/handle/index.ts +++ b/packages/identity/src/handle/index.ts @@ -4,7 +4,19 @@ import { HandleResolverOpts } from '../types' const SUBDOMAIN = '_atproto' const PREFIX = 'did=' +/** + * Resolves a handle (domain name) to a DID. + * + * Calling code must validate handle/DID pariing against the DID document itself. + * + * @link https://atproto.com/specs/handle#handle-resolution + */ export class HandleResolver { + /** + * Resolution process timeout in miliseconds. + * + * TODO: not actually implemented for either resolution method? + */ public timeout: number private backupNameservers: string[] | undefined private backupNameserverIps: string[] | undefined @@ -14,6 +26,11 @@ export class HandleResolver { this.backupNameservers = opts.backupNameservers } + /** + * Attempts DNS and HTTP resolution in parallel. + * + * Returns the DNS result first, HTTP if it fails or is not found, and falls back to DNS backup (if configured). + */ async resolve(handle: string): Promise { const dnsPromise = this.resolveDns(handle) const httpAbort = new AbortController() diff --git a/packages/identity/src/id-resolver.ts b/packages/identity/src/id-resolver.ts index ccf42ca9574..1c484131271 100644 --- a/packages/identity/src/id-resolver.ts +++ b/packages/identity/src/id-resolver.ts @@ -2,6 +2,11 @@ import { HandleResolver } from './handle' import DidResolver from './did/did-resolver' import { IdentityResolverOpts } from './types' +/** + * Wrapper of a DID resolver and handle resolver. + * + * Calling code is responsible for cross-validate handle/DID pairing. + */ export class IdResolver { public handle: HandleResolver public did: DidResolver diff --git a/packages/identity/src/types.ts b/packages/identity/src/types.ts index f1d983e6742..dd16d294acb 100644 --- a/packages/identity/src/types.ts +++ b/packages/identity/src/types.ts @@ -1,6 +1,7 @@ import * as z from 'zod' export type IdentityResolverOpts = { + /** Resolution timeout in miliseconds */ timeout?: number plcUrl?: string didCache?: DidCache @@ -8,11 +9,13 @@ export type IdentityResolverOpts = { } export type HandleResolverOpts = { + /** Resolution timeout in miliseconds */ timeout?: number backupNameservers?: string[] } export type DidResolverOpts = { + /** Resolution timeout in miliseconds */ timeout?: number plcUrl?: string didCache?: DidCache @@ -20,6 +23,7 @@ export type DidResolverOpts = { export type AtprotoData = { did: string + /** Public key of repo signing key, as multibase (as included in the DID document) */ signingKey: string handle: string pds: string @@ -33,13 +37,16 @@ export type CacheResult = { } export interface DidCache { + /** Inserts a single entry to the cache */ cacheDid(did: string, doc: DidDocument): Promise checkCache(did: string): Promise refreshCache( did: string, getDoc: () => Promise, ): Promise + /** Removes a single entry from the cache */ clearEntry(did: string): Promise + /** Removes all entries from the cache */ clear(): Promise } @@ -63,4 +70,9 @@ export const didDocument = z.object({ service: z.array(service).optional(), }) +/** + * Represents the subset of DID Document format used by atproto + * + * @link https://www.w3.org/TR/did-core/ + */ export type DidDocument = z.infer diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c899913626..5ac2ec9e7fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -423,6 +423,9 @@ importers: get-port: specifier: ^6.1.2 version: 6.1.2 + tsdoc-markdown: + specifier: ^0.0.4 + version: 0.0.4(typescript@4.9.5) packages/lex-cli: dependencies: @@ -10816,6 +10819,15 @@ packages: yn: 3.1.1 dev: true + /tsdoc-markdown@0.0.4(typescript@4.9.5): + resolution: {integrity: sha512-/z9E0gniO2KzM5vLh2Wq7wc2RYkWxeBzlCfuISoJTRYkZgyWKhI299AiXkNy7TgvFnMxx7522wuTyHO1tS5vIg==} + hasBin: true + peerDependencies: + typescript: 4.X + dependencies: + typescript: 4.9.5 + dev: true + /tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}