🚧 OAuth2 - Authorization Server (#2482)
* chore(deps): update zod * chore(deps): update pino to match entryway version * chore(tsconfig): remove truncation of types through noErrorTruncation * add support for DPoP token type when logging * fix(bsky): JSON.parse does not return value of type JSON * fix(pds): add res property to ReqCtx * fix(pds): properly type getPreferences return value * chore(tsconfig): disable noFallthroughCasesInSwitch * refactor(pds): move tracer config in own file * feat(dev-env): start with "pnpm dev" * feat(oauth): add oauth provider & client libs * feat(pds): add oauth provider * chore: changeset * feat: various fixes and improvements * chore(deps): update better-sqlite3 to version 10.0.0 for node 22 compatibility * chore(deps): drop unused tslib * fix(did): normalize service IDs before looking for duplicates * fix(did): avoid minor type casting * fix(did): improve argument validation * fix(fetch): explicit use of negation around number comparison * fix(oauth-provider): improve argument validation * feat(did): add ATPROTO specific "isAtprotoDidWeb" method * feat(rollup-plugin-bundle-manifest): add readme * feat(lint): add eqeqeq rule (only allow == and != with null) * fix(oauth-client-browser): typo in gitignore * fix(oauth-provider): properly name error class file * fix(oauth-provider): remove un-necessary useMemo * fix(did-resolver): properly build did:web document url * fix(did-resolver): remove unused types * fix(fetch): remove unused utils * fix(pds): remove unused script and dependency * fix(oauth-provider): simplify isSubPath util * fix(oauth-provider): add InvalidRedirectUriError static constructor * fix(jwk): improve JWT validation to provide better error messages and distinguish between signed and unsigned tokens * fix(pds): use "debug" log level for fetch method * fix(pds): allow access tokens to contain an unknown "typ" claim (with the exception of "dpop+jwt") * fix(jwk): remove un-necessary code * fix(pds): account for whitespace chars when checking JSON * fix(pds): remove oauth specific config * fix(pds): run all write queries through transaction or executeWithRetry fix(pds): remove outdated comments fix(pds): rename used_refresh_token columns & added primary key fix(pds): run cleanup task through backgroundQueue fix(pds): add device.id foreign key to device_account fix(pds): add comment on cleanup of used_refresh_token fix(pds): add primary key on device_account * fix(oauth-provider:time): simplify constantTime util * fix(pds): rename disableSsrf into disableSsrfProtection * fix(oauth-client-react-native): remove incomplete package * refactor(pds): remove status & active from ActorAccount * fix(pds): invalidate all oauth tokens on takedown * fix(oauth-provider): enforce token expiry * fix(pds): properly support deactivated accounts * perf(pds:db): allow transaction function to be sync * refactor(psq:account-manager): expose only query builders & data transformations utils from helpers * fix(oauth-provider): imports from self * fix(ci): add nested packages to build artifacts * style(fetch): rename TODO into @TODO * style(rollup-plugin-bundle-manifest): remove "TODO" from comment * style(oauth-client): rename TODO into @TODO * style(oauth-provider): rename TODO into @TODO * refactor(oauth-client): remove "OAuth" prefix from types * fix(oauth-client-browser): better type SessionListener * style(oauth): rename TODO into @TODO * fix(oauth-provider): enforce provider max session age * fix(oauth-provider): check authentication parameters against all client metadata * fix(api): tests * fix(pds): remove .js from imports for tests * fix(pds): change account status to match tests * chore(deps): make all packages depend on the same zod version * fix(common-web): remove un-necessary binding of Checkable to "zod" * refactor(jwk): infer jwt schema from refinement definition * fix(handle-resolver): allow resolution errors to propagate docs(handle-resolver): better handling of DNS resolution errors fix(handle-resolver): properly handle DOH responses * fix(did): service endpoint arrays must contain "one or more" element * refactor(pipe): simplify implementation * fix(pds): add missing DB indexes * feat(oauth): Resolve Authorization Server URI through Protected Resource Metadata * style:(oauth-client): import order * docs(oauth-provider:redirect-uri): add reference url * feat(oauth): implement "OAuth Client ID Metadata Document" from draft-parecki-oauth-client-id-metadata-document-latest internet draft * feat(oauth-client): backport changes from feat-oauth-client * docs(simple-store): improve comments * feat(lexicons): add iterable capabilities * fix(pds): type error in dev mode * feat(oauth-provider): improved error reporting * fix(oauth-types): allow insecure issuer during tests * fix(xrpc-server): allow upload of empty files * fix: lint * feat(fetch): keep request reference in errors feat(fetch): utilities improvements * fix(pds): allow more than one session token per user * feat(ozone): improve env validation error messages * fix(oauth-client): account for DPoP when checking for invalid_token errors * fixup! feat(fetch): keep request reference in errors feat(fetch): utilities improvements * fixup! feat(fetch): keep request reference in errors feat(fetch): utilities improvements * fix(oauth): various validation fixes feat(oauth): share client_id validation and parsing utilities between client & provider * feat(dev-env): fix ozone port number * fix(fetch-node): prevent fetch against invalid domain names * fix(oauth-provider): add typings for psl dep * feat(jwk): make type def compatible with TS 4.x * fix(oauth): fixed various spec compliance fix(oauth): return "sub" in refresh token response fix(oauth): limit token validity for third party clients fix(oauth): hide client image when not trusted * fix(oauth): lint * pds: switch changeset to patch, no breaking changes * changeset and config for new oauth deps --------- Co-authored-by: Devin Ivy <devinivy@gmail.com>
This commit is contained in:
parent
80dae83540
commit
a8d6c11235
23
.changeset/clever-monkeys-sparkle.md
Normal file
23
.changeset/clever-monkeys-sparkle.md
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
"@atproto/pds": patch
|
||||||
|
"@atproto-labs/rollup-plugin-bundle-manifest": minor
|
||||||
|
"@atproto-labs/handle-resolver-node": minor
|
||||||
|
"@atproto-labs/simple-store-memory": minor
|
||||||
|
"@atproto-labs/identity-resolver": minor
|
||||||
|
"@atproto/oauth-client-browser": minor
|
||||||
|
"@atproto-labs/handle-resolver": minor
|
||||||
|
"@atproto-labs/did-resolver": minor
|
||||||
|
"@atproto-labs/simple-store": minor
|
||||||
|
"@atproto/oauth-provider": minor
|
||||||
|
"@atproto-labs/fetch-node": minor
|
||||||
|
"@atproto/jwk-webcrypto": minor
|
||||||
|
"@atproto/oauth-client": minor
|
||||||
|
"@atproto/oauth-types": minor
|
||||||
|
"@atproto-labs/fetch": minor
|
||||||
|
"@atproto/jwk-jose": minor
|
||||||
|
"@atproto-labs/pipe": minor
|
||||||
|
"@atproto/jwk": minor
|
||||||
|
"@atproto/did": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add OAuth provider capability & support for DPoP signed tokens
|
@ -18,6 +18,7 @@
|
|||||||
"no-var": "error",
|
"no-var": "error",
|
||||||
"prefer-const": "warn",
|
"prefer-const": "warn",
|
||||||
"no-misleading-character-class": "warn",
|
"no-misleading-character-class": "warn",
|
||||||
|
"eqeqeq": ["error", "always", { "null": "ignore" }],
|
||||||
"@typescript-eslint/no-unused-vars": [
|
"@typescript-eslint/no-unused-vars": [
|
||||||
"warn",
|
"warn",
|
||||||
{ "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }
|
{ "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }
|
||||||
|
4
.github/workflows/repo.yaml
vendored
4
.github/workflows/repo.yaml
vendored
@ -27,7 +27,9 @@ jobs:
|
|||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
path: packages/*/dist
|
path: |
|
||||||
|
packages/*/dist
|
||||||
|
packages/*/*/dist
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
test:
|
test:
|
||||||
name: Test
|
name: Test
|
||||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -2,14 +2,14 @@ node_modules
|
|||||||
lerna-debug.log
|
lerna-debug.log
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
packages/*/dist
|
packages/**/dist
|
||||||
.idea
|
.idea
|
||||||
packages/*/coverage
|
packages/*/coverage
|
||||||
.vscode/
|
.vscode/
|
||||||
test.sqlite
|
test.sqlite
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
tsconfig.build.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.*.env
|
.*.env
|
||||||
.env
|
.env
|
||||||
\#*\#
|
\#*\#
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
"verify:types": "tsc --build tsconfig.json",
|
"verify:types": "tsc --build tsconfig.json",
|
||||||
"format": "pnpm lint:fix && pnpm style:fix",
|
"format": "pnpm lint:fix && pnpm style:fix",
|
||||||
"build": "pnpm --recursive --stream build",
|
"build": "pnpm --recursive --stream build",
|
||||||
"dev": "pnpm --stream '/^dev:.+$/'",
|
"dev": "NODE_ENV=development pnpm --stream '/^dev:.+$/'",
|
||||||
"dev:tsc": "tsc --build tsconfig.json --watch",
|
"dev:tsc": "tsc --build tsconfig.json --watch",
|
||||||
"dev:pkg": "pnpm --recursive --parallel --stream dev",
|
"dev:pkg": "pnpm --recursive --parallel --stream dev",
|
||||||
"test": "LOG_ENABLED=false ./packages/dev-infra/with-test-redis-and-db.sh pnpm --stream -r test",
|
"test": "LOG_ENABLED=false ./packages/dev-infra/with-test-redis-and-db.sh pnpm --stream -r test",
|
||||||
@ -51,7 +51,9 @@
|
|||||||
},
|
},
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*"
|
"packages/*",
|
||||||
|
"packages/oauth/*",
|
||||||
|
"packages/internal/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,7 @@
|
|||||||
- [Crypto](./crypto): Atproto's common cryptographic operations.
|
- [Crypto](./crypto): Atproto's common cryptographic operations.
|
||||||
- [Syntax](./syntax): A library for identifier syntax: NSID, AT URI, handles, etc.
|
- [Syntax](./syntax): A library for identifier syntax: NSID, AT URI, handles, etc.
|
||||||
- [Lexicon](./lexicon): A library for validating data using atproto's schema system.
|
- [Lexicon](./lexicon): A library for validating data using atproto's schema system.
|
||||||
|
- [OAuth Provider](./oauth/oauth-provider): A library for supporting ATPROTO's OAuth.
|
||||||
- [Repo](./repo): The "atproto repository" core implementation (a Merkle Search Tree).
|
- [Repo](./repo): The "atproto repository" core implementation (a Merkle Search Tree).
|
||||||
- [XRPC](./xrpc): An XRPC client implementation.
|
- [XRPC](./xrpc): An XRPC client implementation.
|
||||||
- [XRPC Server](./xrpc-server): An XRPC server implementation.
|
- [XRPC Server](./xrpc-server): An XRPC server implementation.
|
||||||
|
@ -355,7 +355,7 @@ describe('agent', () => {
|
|||||||
|
|
||||||
expect(events.length).toEqual(2)
|
expect(events.length).toEqual(2)
|
||||||
expect(events[0]).toEqual('create-failed')
|
expect(events[0]).toEqual('create-failed')
|
||||||
expect(events[1]).toEqual('network-error')
|
expect(events[1]).toEqual('expired')
|
||||||
expect(sessions.length).toEqual(2)
|
expect(sessions.length).toEqual(2)
|
||||||
expect(typeof sessions[0]).toEqual('undefined')
|
expect(typeof sessions[0]).toEqual('undefined')
|
||||||
expect(typeof sessions[1]).toEqual('undefined')
|
expect(typeof sessions[1]).toEqual('undefined')
|
||||||
|
@ -51,7 +51,7 @@
|
|||||||
"multiformats": "^9.9.0",
|
"multiformats": "^9.9.0",
|
||||||
"p-queue": "^6.6.2",
|
"p-queue": "^6.6.2",
|
||||||
"pg": "^8.10.0",
|
"pg": "^8.10.0",
|
||||||
"pino": "^8.15.0",
|
"pino": "^8.21.0",
|
||||||
"pino-http": "^8.2.1",
|
"pino-http": "^8.2.1",
|
||||||
"sharp": "^0.32.6",
|
"sharp": "^0.32.6",
|
||||||
"structured-headers": "^1.0.1",
|
"structured-headers": "^1.0.1",
|
||||||
|
@ -67,9 +67,7 @@ export const parseRecordBytes = <T>(
|
|||||||
return parseJsonBytes(bytes) as T
|
return parseJsonBytes(bytes) as T
|
||||||
}
|
}
|
||||||
|
|
||||||
export const parseJsonBytes = (
|
export const parseJsonBytes = (bytes: Uint8Array | undefined): unknown => {
|
||||||
bytes: Uint8Array | undefined,
|
|
||||||
): JSON | undefined => {
|
|
||||||
if (!bytes || bytes.byteLength === 0) return
|
if (!bytes || bytes.byteLength === 0) return
|
||||||
const parsed = JSON.parse(ui8.toString(bytes, 'utf8'))
|
const parsed = JSON.parse(ui8.toString(bytes, 'utf8'))
|
||||||
return parsed ?? undefined
|
return parsed ?? undefined
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
import pino from 'pino'
|
import { stdSerializers } from 'pino'
|
||||||
import pinoHttp from 'pino-http'
|
import pinoHttp from 'pino-http'
|
||||||
import * as jose from 'jose'
|
|
||||||
import { subsystemLogger } from '@atproto/common'
|
import { subsystemLogger } from '@atproto/common'
|
||||||
import { parseBasicAuth } from './auth-verifier'
|
|
||||||
|
|
||||||
export const dbLogger: ReturnType<typeof subsystemLogger> =
|
export const dbLogger: ReturnType<typeof subsystemLogger> =
|
||||||
subsystemLogger('bsky:db')
|
subsystemLogger('bsky:db')
|
||||||
@ -20,40 +18,85 @@ export const httpLogger: ReturnType<typeof subsystemLogger> =
|
|||||||
export const loggerMiddleware = pinoHttp({
|
export const loggerMiddleware = pinoHttp({
|
||||||
logger: httpLogger,
|
logger: httpLogger,
|
||||||
serializers: {
|
serializers: {
|
||||||
err: (err) => {
|
err: errSerializer,
|
||||||
return {
|
req: reqSerializer,
|
||||||
code: err?.code,
|
|
||||||
message: err?.message,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
req: (req) => {
|
|
||||||
const serialized = pino.stdSerializers.req(req)
|
|
||||||
const authHeader = serialized.headers.authorization || ''
|
|
||||||
let auth: string | undefined = undefined
|
|
||||||
if (authHeader.startsWith('Bearer ')) {
|
|
||||||
const token = authHeader.slice('Bearer '.length)
|
|
||||||
const { iss } = jose.decodeJwt(token)
|
|
||||||
if (iss) {
|
|
||||||
auth = 'Bearer ' + iss
|
|
||||||
} else {
|
|
||||||
auth = 'Bearer Invalid'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (authHeader.startsWith('Basic ')) {
|
|
||||||
const parsed = parseBasicAuth(authHeader)
|
|
||||||
if (!parsed) {
|
|
||||||
auth = 'Basic Invalid'
|
|
||||||
} else {
|
|
||||||
auth = 'Basic ' + parsed.username
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...serialized,
|
|
||||||
headers: {
|
|
||||||
...serialized.headers,
|
|
||||||
authorization: auth,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function errSerializer(err: any) {
|
||||||
|
return {
|
||||||
|
code: err?.code,
|
||||||
|
message: err?.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reqSerializer(req: any) {
|
||||||
|
const serialized = stdSerializers.req(req)
|
||||||
|
serialized.headers = obfuscateHeaders(serialized.headers)
|
||||||
|
return serialized
|
||||||
|
}
|
||||||
|
|
||||||
|
function obfuscateHeaders(headers: Record<string, string>) {
|
||||||
|
const obfuscatedHeaders: Record<string, string> = {}
|
||||||
|
for (const key in headers) {
|
||||||
|
if (key.toLowerCase() === 'authorization') {
|
||||||
|
obfuscatedHeaders[key] = obfuscateAuthHeader(headers[key])
|
||||||
|
} else if (key.toLowerCase() === 'dpop') {
|
||||||
|
obfuscatedHeaders[key] = obfuscateJws(headers[key]) || 'Invalid'
|
||||||
|
} else {
|
||||||
|
obfuscatedHeaders[key] = headers[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return obfuscatedHeaders
|
||||||
|
}
|
||||||
|
|
||||||
|
function obfuscateAuthHeader(authHeader: string): string {
|
||||||
|
// This is a hot path (runs on every request). Avoid using split() or regex.
|
||||||
|
|
||||||
|
const spaceIdx = authHeader.indexOf(' ')
|
||||||
|
if (spaceIdx === -1) return 'Invalid'
|
||||||
|
|
||||||
|
const type = authHeader.slice(0, spaceIdx)
|
||||||
|
switch (type.toLowerCase()) {
|
||||||
|
case 'bearer':
|
||||||
|
return `${type} ${obfuscateBearer(authHeader.slice(spaceIdx + 1))}`
|
||||||
|
case 'dpop':
|
||||||
|
return `${type} ${obfuscateJws(authHeader.slice(spaceIdx + 1)) || 'Invalid'}`
|
||||||
|
case 'basic':
|
||||||
|
return `${type} ${obfuscateBasic(authHeader.slice(spaceIdx + 1)) || 'Invalid'}`
|
||||||
|
default:
|
||||||
|
return `Invalid`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function obfuscateBasic(token: string): null | string {
|
||||||
|
if (!token) return null
|
||||||
|
const buffer = Buffer.from(token, 'base64')
|
||||||
|
if (!buffer.length) return null // Buffer.from will silently ignore invalid base64 chars
|
||||||
|
const authHeader = buffer.toString('utf8')
|
||||||
|
const colIdx = authHeader.indexOf(':')
|
||||||
|
if (colIdx === -1) return null
|
||||||
|
const username = authHeader.slice(0, colIdx)
|
||||||
|
return `${username}:***`
|
||||||
|
}
|
||||||
|
|
||||||
|
function obfuscateBearer(token: string): string {
|
||||||
|
return obfuscateJws(token) || obfuscateToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
function obfuscateToken(token: string): string {
|
||||||
|
return token ? '***' : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function obfuscateJws(token: string): null | string {
|
||||||
|
const firstDot = token.indexOf('.')
|
||||||
|
if (firstDot === -1) return null
|
||||||
|
|
||||||
|
const secondDot = token.indexOf('.', firstDot + 1)
|
||||||
|
if (secondDot === -1) return null
|
||||||
|
|
||||||
|
if (token.indexOf('.', secondDot + 1) !== -1) return null
|
||||||
|
|
||||||
|
// Strip the signature
|
||||||
|
return token.slice(0, secondDot) + '.obfuscated'
|
||||||
|
}
|
||||||
|
@ -22,7 +22,7 @@
|
|||||||
"graphemer": "^1.4.0",
|
"graphemer": "^1.4.0",
|
||||||
"multiformats": "^9.9.0",
|
"multiformats": "^9.9.0",
|
||||||
"uint8arrays": "3.0.0",
|
"uint8arrays": "3.0.0",
|
||||||
"zod": "^3.21.4"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"jest": "^28.1.2"
|
"jest": "^28.1.2"
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { ZodError } from 'zod'
|
// Explicitly not using "zod" types here to avoid mismatching types due to
|
||||||
|
// version differences.
|
||||||
|
|
||||||
export interface Checkable<T> {
|
export interface Checkable<T> {
|
||||||
parse: (obj: unknown) => T
|
parse: (obj: unknown) => T
|
||||||
safeParse: (
|
safeParse: (
|
||||||
obj: unknown,
|
obj: unknown,
|
||||||
) => { success: true; data: T } | { success: false; error: ZodError }
|
) => { success: true; data: T } | { success: false; error: Error }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Def<T> {
|
export interface Def<T> {
|
||||||
|
@ -24,7 +24,7 @@
|
|||||||
"cbor-x": "^1.5.1",
|
"cbor-x": "^1.5.1",
|
||||||
"iso-datestring-validator": "^2.2.2",
|
"iso-datestring-validator": "^2.2.2",
|
||||||
"multiformats": "^9.9.0",
|
"multiformats": "^9.9.0",
|
||||||
"pino": "^8.15.0"
|
"pino": "^8.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"jest": "^28.1.2",
|
"jest": "^28.1.2",
|
||||||
|
@ -17,7 +17,8 @@
|
|||||||
"bin": "dist/bin.js",
|
"bin": "dist/bin.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --build tsconfig.build.json",
|
"build": "tsc --build tsconfig.build.json",
|
||||||
"start": "../dev-infra/with-test-redis-and-db.sh node dist/bin.js"
|
"start": "../dev-infra/with-test-redis-and-db.sh node dist/bin.js",
|
||||||
|
"dev": "../dev-infra/with-test-redis-and-db.sh node --watch dist/bin.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "workspace:^",
|
"@atproto/api": "workspace:^",
|
||||||
|
@ -27,6 +27,7 @@ const run = async () => {
|
|||||||
},
|
},
|
||||||
plc: { port: 2582 },
|
plc: { port: 2582 },
|
||||||
ozone: {
|
ozone: {
|
||||||
|
port: 2587,
|
||||||
chatUrl: 'http://localhost:2590', // must run separate chat service
|
chatUrl: 'http://localhost:2590', // must run separate chat service
|
||||||
chatDid: 'did:example:chat',
|
chatDid: 'did:example:chat',
|
||||||
},
|
},
|
||||||
|
@ -45,6 +45,16 @@ export class TestPds {
|
|||||||
modServiceDid: 'did:example:invalid',
|
modServiceDid: 'did:example:invalid',
|
||||||
plcRotationKeyK256PrivateKeyHex: plcRotationPriv,
|
plcRotationKeyK256PrivateKeyHex: plcRotationPriv,
|
||||||
inviteRequired: false,
|
inviteRequired: false,
|
||||||
|
fetchDisableSsrfProtection: true,
|
||||||
|
serviceName: 'Development PDS',
|
||||||
|
primaryColor: '#ffcb1e',
|
||||||
|
errorColor: undefined,
|
||||||
|
logoUrl:
|
||||||
|
'https://uxwing.com/wp-content/themes/uxwing/download/animals-and-birds/bee-icon.png',
|
||||||
|
homeUrl: 'https://bsky.social/',
|
||||||
|
termsOfServiceUrl: 'https://bsky.social/about/support/tos',
|
||||||
|
privacyPolicyUrl: 'https://bsky.social/about/support/privacy-policy',
|
||||||
|
supportUrl: 'https://blueskyweb.zendesk.com/hc/en-us',
|
||||||
...config,
|
...config,
|
||||||
}
|
}
|
||||||
const cfg = pds.envToCfg(env)
|
const cfg = pds.envToCfg(env)
|
||||||
|
36
packages/did/package.json
Normal file
36
packages/did/package.json
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "@atproto/did",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "DID resolution and verification library",
|
||||||
|
"keywords": [
|
||||||
|
"atproto",
|
||||||
|
"did",
|
||||||
|
"validation",
|
||||||
|
"types"
|
||||||
|
],
|
||||||
|
"homepage": "https://atproto.com",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/bluesky-social/atproto",
|
||||||
|
"directory": "packages/did"
|
||||||
|
},
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --build tsconfig.build.json"
|
||||||
|
}
|
||||||
|
}
|
151
packages/did/src/did-document.ts
Normal file
151
packages/did/src/did-document.ts
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { Did, didSchema } from './did.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC3968 compliant URI
|
||||||
|
*
|
||||||
|
* @see {@link https://www.rfc-editor.org/rfc/rfc3986}
|
||||||
|
*/
|
||||||
|
const rfc3968UriSchema = z.string().refine((data) => {
|
||||||
|
try {
|
||||||
|
new URL(data)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, 'RFC3968 compliant URI')
|
||||||
|
|
||||||
|
const didControllerSchema = z.union([didSchema, z.array(didSchema)])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @note this schema might be too permissive
|
||||||
|
*/
|
||||||
|
const didRelativeUriSchema = z.union([
|
||||||
|
rfc3968UriSchema,
|
||||||
|
z.string().regex(/^#[^#]+$/),
|
||||||
|
])
|
||||||
|
|
||||||
|
const didVerificationMethodSchema = z.object({
|
||||||
|
id: didRelativeUriSchema,
|
||||||
|
type: z.string().min(1),
|
||||||
|
controller: didControllerSchema,
|
||||||
|
publicKeyJwk: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
publicKeyMultibase: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value of the id property MUST be a URI conforming to [RFC3986]. A
|
||||||
|
* conforming producer MUST NOT produce multiple service entries with the same
|
||||||
|
* id. A conforming consumer MUST produce an error if it detects multiple
|
||||||
|
* service entries with the same id.
|
||||||
|
*
|
||||||
|
* @note Normally, only rfc3968UriSchema should be allowed here. However, the
|
||||||
|
* did:plc uses relative URI. For this reason, we also allow relative URIs
|
||||||
|
* here.
|
||||||
|
*/
|
||||||
|
const didServiceIdSchema = didRelativeUriSchema
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value of the type property MUST be a string or a set of strings. In order
|
||||||
|
* to maximize interoperability, the service type and its associated properties
|
||||||
|
* SHOULD be registered in the DID Specification Registries
|
||||||
|
* [DID-SPEC-REGISTRIES].
|
||||||
|
*/
|
||||||
|
const didServiceTypeSchema = z.union([z.string(), z.array(z.string())])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value of the serviceEndpoint property MUST be a string, a map, or a set
|
||||||
|
* composed of one or more strings and/or maps. All string values MUST be valid
|
||||||
|
* URIs conforming to [RFC3986] and normalized according to the Normalization
|
||||||
|
* and Comparison rules in RFC3986 and to any normalization rules in its
|
||||||
|
* applicable URI scheme specification.
|
||||||
|
*/
|
||||||
|
const didServiceEndpointSchema = z.union([
|
||||||
|
rfc3968UriSchema,
|
||||||
|
z.record(z.string(), rfc3968UriSchema),
|
||||||
|
z
|
||||||
|
.array(z.union([rfc3968UriSchema, z.record(z.string(), rfc3968UriSchema)]))
|
||||||
|
.nonempty(),
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each service map MUST contain id, type, and serviceEndpoint properties.
|
||||||
|
* @see {@link https://www.w3.org/TR/did-core/#services}
|
||||||
|
*/
|
||||||
|
const didServiceSchema = z.object({
|
||||||
|
id: didServiceIdSchema,
|
||||||
|
type: didServiceTypeSchema,
|
||||||
|
serviceEndpoint: didServiceEndpointSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export type DidService = z.infer<typeof didServiceSchema>
|
||||||
|
|
||||||
|
const didAuthenticationSchema = z.union([
|
||||||
|
//
|
||||||
|
didRelativeUriSchema,
|
||||||
|
didVerificationMethodSchema,
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @note This schema is incomplete
|
||||||
|
* @see {@link https://www.w3.org/TR/did-core/#production-0}
|
||||||
|
*/
|
||||||
|
export const didDocumentSchema = z.object({
|
||||||
|
'@context': z.union([
|
||||||
|
z.literal('https://www.w3.org/ns/did/v1'),
|
||||||
|
z
|
||||||
|
.array(z.string().url())
|
||||||
|
.nonempty()
|
||||||
|
.refine((data) => data[0] === 'https://www.w3.org/ns/did/v1', {
|
||||||
|
message: 'First @context must be https://www.w3.org/ns/did/v1',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
id: didSchema,
|
||||||
|
controller: didControllerSchema.optional(),
|
||||||
|
alsoKnownAs: z.array(rfc3968UriSchema).optional(),
|
||||||
|
service: z.array(didServiceSchema).optional(),
|
||||||
|
authentication: z.array(didAuthenticationSchema).optional(),
|
||||||
|
verificationMethod: z
|
||||||
|
.array(z.union([didVerificationMethodSchema, didRelativeUriSchema]))
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type DidDocument<Method extends string = string> = z.infer<
|
||||||
|
typeof didDocumentSchema
|
||||||
|
> & { id: Did<Method> }
|
||||||
|
|
||||||
|
// @TODO: add other refinements ?
|
||||||
|
export const didDocumentValidator = didDocumentSchema
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.service) {
|
||||||
|
for (let i = 0; i < data.service.length; i++) {
|
||||||
|
if (data.service[i].id === data.id) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `Service id must be different from the document id`,
|
||||||
|
path: ['service', i, 'id'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.service) {
|
||||||
|
const normalizedIds = data.service.map((s) =>
|
||||||
|
s.id?.startsWith('#') ? `${data.id}${s.id}` : s.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (let i = 0; i < normalizedIds.length; i++) {
|
||||||
|
for (let j = i + 1; j < normalizedIds.length; j++) {
|
||||||
|
if (normalizedIds[i] === normalizedIds[j]) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `Duplicate service id (${normalizedIds[j]}) found in the document`,
|
||||||
|
path: ['service', j, 'id'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
49
packages/did/src/did-error.ts
Normal file
49
packages/did/src/did-error.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
export class DidError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly did: string,
|
||||||
|
message: string,
|
||||||
|
public readonly code: string,
|
||||||
|
public readonly status = 400,
|
||||||
|
cause?: unknown,
|
||||||
|
) {
|
||||||
|
super(message, { cause })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For compatibility with error handlers in common HTTP frameworks.
|
||||||
|
*/
|
||||||
|
get statusCode() {
|
||||||
|
return this.status
|
||||||
|
}
|
||||||
|
|
||||||
|
override toString() {
|
||||||
|
return `${this.constructor.name} ${this.code} (${this.did}): ${this.message}`
|
||||||
|
}
|
||||||
|
|
||||||
|
static from(cause: unknown, did: string): DidError {
|
||||||
|
if (cause instanceof DidError) {
|
||||||
|
return cause
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
cause instanceof Error
|
||||||
|
? cause.message
|
||||||
|
: typeof cause === 'string'
|
||||||
|
? cause
|
||||||
|
: 'An unknown error occurred'
|
||||||
|
|
||||||
|
const status =
|
||||||
|
(typeof cause?.['statusCode'] === 'number'
|
||||||
|
? cause['statusCode']
|
||||||
|
: undefined) ??
|
||||||
|
(typeof cause?.['status'] === 'number' ? cause['status'] : undefined)
|
||||||
|
|
||||||
|
return new DidError(did, message, 'did-unknown-error', status, cause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidDidError extends DidError {
|
||||||
|
constructor(did: string, message: string, cause?: unknown) {
|
||||||
|
super(did, message, 'did-invalid', 400, cause)
|
||||||
|
}
|
||||||
|
}
|
258
packages/did/src/did.ts
Normal file
258
packages/did/src/did.ts
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { DidError, InvalidDidError } from './did-error.js'
|
||||||
|
|
||||||
|
const DID_PREFIX = 'did:'
|
||||||
|
const DID_PREFIX_LENGTH = DID_PREFIX.length
|
||||||
|
export { DID_PREFIX }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type representation of a Did, with method.
|
||||||
|
*
|
||||||
|
* ```bnf
|
||||||
|
* did = "did:" method-name ":" method-specific-id
|
||||||
|
* method-name = 1*method-char
|
||||||
|
* method-char = %x61-7A / DIGIT
|
||||||
|
* method-specific-id = *( *idchar ":" ) 1*idchar
|
||||||
|
* idchar = ALPHA / DIGIT / "." / "-" / "_" / pct-encoded
|
||||||
|
* pct-encoded = "%" HEXDIG HEXDIG
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* type DidWeb = Did<'web'> // `did:web:${string}`
|
||||||
|
* type DidCustom = Did<'web' | 'plc'> // `did:${'web' | 'plc'}:${string}`
|
||||||
|
* type DidNever = Did<' invalid 🥴 '> // never
|
||||||
|
* type DidFoo = Did<'foo' | ' invalid 🥴 '> // `did:foo:${string}`
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @see {@link https://www.w3.org/TR/did-core/#did-syntax}
|
||||||
|
*/
|
||||||
|
export type Did<M extends string = string> = `did:${AsDidMethod<M>}:${string}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DID Method
|
||||||
|
*/
|
||||||
|
export type AsDidMethod<M> = string extends M
|
||||||
|
? string // can't know...
|
||||||
|
: AsDidMethodInternal<M, ''>
|
||||||
|
|
||||||
|
type AlphanumericChar = DigitChar | LowerAlphaChar
|
||||||
|
type DigitChar = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
|
||||||
|
type LowerAlphaChar =
|
||||||
|
| 'a'
|
||||||
|
| 'b'
|
||||||
|
| 'c'
|
||||||
|
| 'd'
|
||||||
|
| 'e'
|
||||||
|
| 'f'
|
||||||
|
| 'g'
|
||||||
|
| 'h'
|
||||||
|
| 'i'
|
||||||
|
| 'j'
|
||||||
|
| 'k'
|
||||||
|
| 'l'
|
||||||
|
| 'm'
|
||||||
|
| 'n'
|
||||||
|
| 'o'
|
||||||
|
| 'p'
|
||||||
|
| 'q'
|
||||||
|
| 'r'
|
||||||
|
| 's'
|
||||||
|
| 't'
|
||||||
|
| 'u'
|
||||||
|
| 'v'
|
||||||
|
| 'w'
|
||||||
|
| 'x'
|
||||||
|
| 'y'
|
||||||
|
| 'z'
|
||||||
|
|
||||||
|
type AsDidMethodInternal<
|
||||||
|
S,
|
||||||
|
Acc extends string,
|
||||||
|
> = S extends `${infer H}${infer T}`
|
||||||
|
? H extends AlphanumericChar
|
||||||
|
? AsDidMethodInternal<T, `${Acc}${H}`>
|
||||||
|
: never
|
||||||
|
: Acc extends ''
|
||||||
|
? never
|
||||||
|
: Acc
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DID Method-name check function.
|
||||||
|
*
|
||||||
|
* Check if the input is a valid DID method name, at the position between
|
||||||
|
* `start` (inclusive) and `end` (exclusive).
|
||||||
|
*/
|
||||||
|
export function checkDidMethod(
|
||||||
|
input: string,
|
||||||
|
start = 0,
|
||||||
|
end = input.length,
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(end) ||
|
||||||
|
!Number.isFinite(start) ||
|
||||||
|
end < start ||
|
||||||
|
end > input.length
|
||||||
|
) {
|
||||||
|
throw new TypeError('Invalid start or end position')
|
||||||
|
}
|
||||||
|
if (end === start) {
|
||||||
|
throw new InvalidDidError(input, `Empty method name`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let c: number
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
c = input.charCodeAt(i)
|
||||||
|
if (
|
||||||
|
(c < 0x61 || c > 0x7a) && // a-z
|
||||||
|
(c < 0x30 || c > 0x39) // 0-9
|
||||||
|
) {
|
||||||
|
throw new InvalidDidError(
|
||||||
|
input,
|
||||||
|
`Invalid character at position ${i} in DID method name`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method assumes the input is a valid Did
|
||||||
|
*/
|
||||||
|
export function extractDidMethod<D extends Did>(did: D) {
|
||||||
|
const msidSep = did.indexOf(':', DID_PREFIX_LENGTH)
|
||||||
|
const method = did.slice(DID_PREFIX_LENGTH, msidSep)
|
||||||
|
return method as D extends Did<infer M> ? M : string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DID Method-specific identifier check function.
|
||||||
|
*
|
||||||
|
* Check if the input is a valid DID method-specific identifier, at the position
|
||||||
|
* between `start` (inclusive) and `end` (exclusive).
|
||||||
|
*/
|
||||||
|
export function checkDidMsid(
|
||||||
|
input: string,
|
||||||
|
start = 0,
|
||||||
|
end = input.length,
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(end) ||
|
||||||
|
!Number.isFinite(start) ||
|
||||||
|
end < start ||
|
||||||
|
end > input.length
|
||||||
|
) {
|
||||||
|
throw new TypeError('Invalid start or end position')
|
||||||
|
}
|
||||||
|
if (end === start) {
|
||||||
|
throw new InvalidDidError(input, `DID method-specific id must not be empty`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let c: number
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
c = input.charCodeAt(i)
|
||||||
|
|
||||||
|
// Check for frequent chars first
|
||||||
|
if (
|
||||||
|
(c < 0x61 || c > 0x7a) && // a-z
|
||||||
|
(c < 0x41 || c > 0x5a) && // A-Z
|
||||||
|
(c < 0x30 || c > 0x39) && // 0-9
|
||||||
|
c !== 0x2e && // .
|
||||||
|
c !== 0x2d && // -
|
||||||
|
c !== 0x5f // _
|
||||||
|
) {
|
||||||
|
// Less frequent chars are checked here
|
||||||
|
|
||||||
|
// ":"
|
||||||
|
if (c === 0x3a) {
|
||||||
|
if (i === end - 1) {
|
||||||
|
throw new InvalidDidError(input, `DID cannot end with ":"`)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// pct-encoded
|
||||||
|
if (c === 0x25) {
|
||||||
|
c = input.charCodeAt(++i)
|
||||||
|
if ((c < 0x30 || c > 0x39) && (c < 0x41 || c > 0x46)) {
|
||||||
|
throw new InvalidDidError(
|
||||||
|
input,
|
||||||
|
`Invalid pct-encoded character at position ${i}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
c = input.charCodeAt(++i)
|
||||||
|
if ((c < 0x30 || c > 0x39) && (c < 0x41 || c > 0x46)) {
|
||||||
|
throw new InvalidDidError(
|
||||||
|
input,
|
||||||
|
`Invalid pct-encoded character at position ${i}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// There must always be 2 HEXDIG after a "%"
|
||||||
|
if (i >= end) {
|
||||||
|
throw new InvalidDidError(
|
||||||
|
input,
|
||||||
|
`Incomplete pct-encoded character at position ${i - 2}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidDidError(
|
||||||
|
input,
|
||||||
|
`Disallowed character in DID at position ${i}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkDid(input: unknown): asserts input is Did {
|
||||||
|
if (typeof input !== 'string') {
|
||||||
|
throw new InvalidDidError(typeof input, `DID must be a string`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { length } = input
|
||||||
|
if (length > 2048) {
|
||||||
|
throw new InvalidDidError(input, `DID is too long (2048 chars max)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.startsWith(DID_PREFIX)) {
|
||||||
|
throw new InvalidDidError(input, `DID requires "${DID_PREFIX}" prefix`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const idSep = input.indexOf(':', DID_PREFIX_LENGTH)
|
||||||
|
if (idSep === -1) {
|
||||||
|
throw new InvalidDidError(input, `Missing colon after method name`)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkDidMethod(input, DID_PREFIX_LENGTH, idSep)
|
||||||
|
checkDidMsid(input, idSep + 1, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDid(input: unknown): input is Did {
|
||||||
|
try {
|
||||||
|
checkDid(input)
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DidError) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const didSchema = z
|
||||||
|
.string()
|
||||||
|
.superRefine((value: string, ctx: z.RefinementCtx): value is Did => {
|
||||||
|
try {
|
||||||
|
checkDid(value)
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: err instanceof Error ? err.message : 'Unexpected error',
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
4
packages/did/src/index.ts
Normal file
4
packages/did/src/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './did-document.js'
|
||||||
|
export * from './did-error.js'
|
||||||
|
export * from './did.js'
|
||||||
|
export * from './methods.js'
|
2
packages/did/src/methods.ts
Normal file
2
packages/did/src/methods.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './methods/plc.js'
|
||||||
|
export * from './methods/web.js'
|
40
packages/did/src/methods/plc.ts
Normal file
40
packages/did/src/methods/plc.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { InvalidDidError } from '../did-error.js'
|
||||||
|
import { Did } from '../did.js'
|
||||||
|
|
||||||
|
const DID_PLC_PREFIX = `did:plc:`
|
||||||
|
const DID_PLC_PREFIX_LENGTH = DID_PLC_PREFIX.length
|
||||||
|
const DID_PLC_LENGTH = 32
|
||||||
|
|
||||||
|
export { DID_PLC_PREFIX }
|
||||||
|
|
||||||
|
export function isDidPlc(input: unknown): input is Did<'plc'> {
|
||||||
|
if (typeof input !== 'string') return false
|
||||||
|
try {
|
||||||
|
checkDidPlc(input)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkDidPlc(input: string): asserts input is Did<'plc'> {
|
||||||
|
if (input.length !== DID_PLC_LENGTH) {
|
||||||
|
throw new InvalidDidError(
|
||||||
|
input,
|
||||||
|
`did:plc must be ${DID_PLC_LENGTH} characters long`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.startsWith(DID_PLC_PREFIX)) {
|
||||||
|
throw new InvalidDidError(input, `Invalid did:plc prefix`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let c: number
|
||||||
|
for (let i = DID_PLC_PREFIX_LENGTH; i < DID_PLC_LENGTH; i++) {
|
||||||
|
c = input.charCodeAt(i)
|
||||||
|
// Base32 encoding ([a-z2-7])
|
||||||
|
if ((c < 0x61 || c > 0x7a) && (c < 0x32 || c > 0x37)) {
|
||||||
|
throw new InvalidDidError(input, `Invalid character at position ${i}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
78
packages/did/src/methods/web.ts
Normal file
78
packages/did/src/methods/web.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { InvalidDidError } from '../did-error.js'
|
||||||
|
import { Did, checkDidMsid } from '../did.js'
|
||||||
|
|
||||||
|
export const DID_WEB_PREFIX = `did:web:`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function checks if the input is a valid Web DID, as per DID spec.
|
||||||
|
* ATPROTO adds additional constraints to allowed DID values for the `did:web`
|
||||||
|
* method. Use {@link isAtprotoDidWeb} if that's what you need.
|
||||||
|
*/
|
||||||
|
export function isDidWeb(input: unknown): input is Did<'web'> {
|
||||||
|
if (typeof input !== 'string') return false
|
||||||
|
try {
|
||||||
|
didWebToUrl(input)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see {@link https://atproto.com/specs/did#blessed-did-methods}
|
||||||
|
*/
|
||||||
|
export function isAtprotoDidWeb(input: unknown): input is Did<'web'> {
|
||||||
|
// Optimization: make cheap checks first
|
||||||
|
if (typeof input !== 'string') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path are not allowed
|
||||||
|
if (input.includes(':', DID_WEB_PREFIX.length)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port numbers are not allowed, except for localhost
|
||||||
|
if (
|
||||||
|
input.includes('%3A', DID_WEB_PREFIX.length) &&
|
||||||
|
!input.startsWith('did:web:localhost%3A')
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return isDidWeb(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkDidWeb(input: string): asserts input is Did<'web'> {
|
||||||
|
didWebToUrl(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function didWebToUrl(did: string): URL {
|
||||||
|
if (!did.startsWith(DID_WEB_PREFIX)) {
|
||||||
|
throw new InvalidDidError(did, `did:web must start with ${DID_WEB_PREFIX}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (did.charAt(DID_WEB_PREFIX.length) === ':') {
|
||||||
|
throw new InvalidDidError(did, 'did:web MSID must not start with a colon')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure every char is valid (per DID spec)
|
||||||
|
checkDidMsid(did, DID_WEB_PREFIX.length)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const msid = did.slice(DID_WEB_PREFIX.length)
|
||||||
|
const parts = msid.split(':').map(decodeURIComponent)
|
||||||
|
return new URL(`https://${parts.join('/')}`)
|
||||||
|
} catch (cause) {
|
||||||
|
throw new InvalidDidError(did, 'Invalid Web DID', cause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function urlToDidWeb(url: URL): Did<'web'> {
|
||||||
|
const path =
|
||||||
|
url.pathname === '/'
|
||||||
|
? ''
|
||||||
|
: url.pathname.slice(1).split('/').map(encodeURIComponent).join(':')
|
||||||
|
|
||||||
|
return `did:web:${encodeURIComponent(url.host)}${path ? `:${path}` : ''}`
|
||||||
|
}
|
8
packages/did/tsconfig.build.json
Normal file
8
packages/did/tsconfig.build.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig/isomorphic.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist"
|
||||||
|
},
|
||||||
|
"include": ["./src"]
|
||||||
|
}
|
4
packages/did/tsconfig.json
Normal file
4
packages/did/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"include": [],
|
||||||
|
"references": [{ "path": "./tsconfig.build.json" }]
|
||||||
|
}
|
40
packages/internal/did-resolver/package.json
Normal file
40
packages/internal/did-resolver/package.json
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "@atproto-labs/did-resolver",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "DID resolution and verification library",
|
||||||
|
"keywords": [
|
||||||
|
"atproto",
|
||||||
|
"did",
|
||||||
|
"resolver"
|
||||||
|
],
|
||||||
|
"homepage": "https://atproto.com",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/bluesky-social/atproto",
|
||||||
|
"directory": "packages/internal/did-resolver"
|
||||||
|
},
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@atproto-labs/fetch": "workspace:*",
|
||||||
|
"@atproto-labs/pipe": "workspace:*",
|
||||||
|
"@atproto-labs/simple-store": "workspace:*",
|
||||||
|
"@atproto-labs/simple-store-memory": "workspace:*",
|
||||||
|
"@atproto/did": "workspace:*",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --build tsconfig.build.json"
|
||||||
|
}
|
||||||
|
}
|
25
packages/internal/did-resolver/src/did-cache-memory.ts
Normal file
25
packages/internal/did-resolver/src/did-cache-memory.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Did, DidDocument } from '@atproto/did'
|
||||||
|
import {
|
||||||
|
SimpleStoreMemory,
|
||||||
|
SimpleStoreMemoryOptions,
|
||||||
|
} from '@atproto-labs/simple-store-memory'
|
||||||
|
|
||||||
|
import { DidCache } from './did-cache.js'
|
||||||
|
|
||||||
|
const DEFAULT_TTL = 3600 * 1000 // 1 hour
|
||||||
|
const DEFAULT_MAX_SIZE = 50 * 1024 * 1024 // ~50MB
|
||||||
|
|
||||||
|
export type DidCacheMemoryOptions = SimpleStoreMemoryOptions<Did, DidDocument>
|
||||||
|
|
||||||
|
export class DidCacheMemory
|
||||||
|
extends SimpleStoreMemory<Did, DidDocument>
|
||||||
|
implements DidCache
|
||||||
|
{
|
||||||
|
constructor(options?: DidCacheMemoryOptions) {
|
||||||
|
super(
|
||||||
|
options?.max == null
|
||||||
|
? { ttl: DEFAULT_TTL, maxSize: DEFAULT_MAX_SIZE, ...options }
|
||||||
|
: { ttl: DEFAULT_TTL, ...options },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
31
packages/internal/did-resolver/src/did-cache.ts
Normal file
31
packages/internal/did-resolver/src/did-cache.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { CachedGetter, SimpleStore } from '@atproto-labs/simple-store'
|
||||||
|
import { Did, DidDocument } from '@atproto/did'
|
||||||
|
|
||||||
|
import { DidCacheMemory } from './did-cache-memory.js'
|
||||||
|
import { DidMethod, ResolveOptions } from './did-method.js'
|
||||||
|
import { DidResolver, ResolvedDocument } from './did-resolver.js'
|
||||||
|
|
||||||
|
export type { DidMethod, ResolveOptions, ResolvedDocument }
|
||||||
|
|
||||||
|
export type DidCache = SimpleStore<Did, DidDocument>
|
||||||
|
|
||||||
|
export type DidResolverCachedOptions = { cache?: DidCache }
|
||||||
|
|
||||||
|
export class DidResolverCached<M extends string = string>
|
||||||
|
implements DidResolver<M>
|
||||||
|
{
|
||||||
|
protected readonly getter: CachedGetter<Did, DidDocument>
|
||||||
|
constructor(
|
||||||
|
resolver: DidResolver<M>,
|
||||||
|
cache: DidCache = new DidCacheMemory(),
|
||||||
|
) {
|
||||||
|
this.getter = new CachedGetter<Did, DidDocument>(
|
||||||
|
(did, options) => resolver.resolve(did, options),
|
||||||
|
cache,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async resolve<D extends Did>(did: D, options?: ResolveOptions) {
|
||||||
|
return this.getter.get(did, options) as Promise<ResolvedDocument<D, M>>
|
||||||
|
}
|
||||||
|
}
|
17
packages/internal/did-resolver/src/did-method.ts
Normal file
17
packages/internal/did-resolver/src/did-method.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Did, DidDocument } from '@atproto/did'
|
||||||
|
|
||||||
|
export type ResolveOptions = {
|
||||||
|
signal?: AbortSignal
|
||||||
|
noCache?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DidMethod<Method extends string> {
|
||||||
|
resolve: (
|
||||||
|
did: Did<Method>,
|
||||||
|
options?: ResolveOptions,
|
||||||
|
) => DidDocument | PromiseLike<DidDocument>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DidMethods<M extends string> = {
|
||||||
|
[K in M]: DidMethod<K>
|
||||||
|
}
|
66
packages/internal/did-resolver/src/did-resolver-base.ts
Normal file
66
packages/internal/did-resolver/src/did-resolver-base.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { FetchRequestError } from '@atproto-labs/fetch'
|
||||||
|
import { Did, DidError, extractDidMethod } from '@atproto/did'
|
||||||
|
import { ZodError } from 'zod'
|
||||||
|
|
||||||
|
import { DidMethod, DidMethods, ResolveOptions } from './did-method.js'
|
||||||
|
import { DidResolver, ResolvedDocument } from './did-resolver.js'
|
||||||
|
|
||||||
|
export type { DidMethod, ResolveOptions, ResolvedDocument }
|
||||||
|
|
||||||
|
export class DidResolverBase<M extends string = string>
|
||||||
|
implements DidResolver<M>
|
||||||
|
{
|
||||||
|
protected readonly methods: Map<string, DidMethod<M>>
|
||||||
|
|
||||||
|
constructor(methods: DidMethods<M>) {
|
||||||
|
this.methods = new Map(Object.entries(methods))
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolve<D extends Did>(
|
||||||
|
did: D,
|
||||||
|
options?: ResolveOptions,
|
||||||
|
): Promise<ResolvedDocument<D, M>> {
|
||||||
|
options?.signal?.throwIfAborted()
|
||||||
|
|
||||||
|
const method = extractDidMethod(did)
|
||||||
|
const resolver = this.methods.get(method)
|
||||||
|
if (!resolver) {
|
||||||
|
throw new DidError(
|
||||||
|
did,
|
||||||
|
`Unsupported DID method`,
|
||||||
|
'did-method-invalid',
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const document = await resolver.resolve(did as Did<M>, options)
|
||||||
|
if (document.id !== did) {
|
||||||
|
throw new DidError(
|
||||||
|
did,
|
||||||
|
`DID document id (${document.id}) does not match DID`,
|
||||||
|
'did-document-id-mismatch',
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return document as ResolvedDocument<D, M>
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof FetchRequestError) {
|
||||||
|
throw new DidError(did, err.message, 'did-fetch-error', 400, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof ZodError) {
|
||||||
|
throw new DidError(
|
||||||
|
did,
|
||||||
|
err.message,
|
||||||
|
'did-document-format-error',
|
||||||
|
503,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw DidError.from(err, did)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
20
packages/internal/did-resolver/src/did-resolver-common.ts
Normal file
20
packages/internal/did-resolver/src/did-resolver-common.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { DidResolverBase } from './did-resolver-base.js'
|
||||||
|
import { DidPlcMethod, DidPlcMethodOptions } from './methods/plc.js'
|
||||||
|
import { DidWebMethod, DidWebMethodOptions } from './methods/web.js'
|
||||||
|
import { Simplify } from './util.js'
|
||||||
|
|
||||||
|
export type DidResolverCommonOptions = Simplify<
|
||||||
|
DidPlcMethodOptions & DidWebMethodOptions
|
||||||
|
>
|
||||||
|
|
||||||
|
export class DidResolverCommon
|
||||||
|
extends DidResolverBase<'plc' | 'web'>
|
||||||
|
implements DidResolverBase<'plc' | 'web'>
|
||||||
|
{
|
||||||
|
constructor(options?: DidResolverCommonOptions) {
|
||||||
|
super({
|
||||||
|
plc: new DidPlcMethod(options),
|
||||||
|
web: new DidWebMethod(options),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
15
packages/internal/did-resolver/src/did-resolver.ts
Normal file
15
packages/internal/did-resolver/src/did-resolver.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { Did, DidDocument } from '@atproto/did'
|
||||||
|
|
||||||
|
import { ResolveOptions } from './did-method.js'
|
||||||
|
|
||||||
|
export type ResolvedDocument<D extends Did, M extends string = string> =
|
||||||
|
D extends Did<infer N>
|
||||||
|
? DidDocument<N extends string ? M : N extends M ? N : never>
|
||||||
|
: never
|
||||||
|
|
||||||
|
export interface DidResolver<M extends string = string> {
|
||||||
|
resolve<D extends Did>(
|
||||||
|
did: D,
|
||||||
|
options?: ResolveOptions,
|
||||||
|
): Promise<ResolvedDocument<D, M>>
|
||||||
|
}
|
9
packages/internal/did-resolver/src/index.ts
Normal file
9
packages/internal/did-resolver/src/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export * from '@atproto/did'
|
||||||
|
|
||||||
|
export * from './did-cache-memory.js'
|
||||||
|
export * from './did-cache.js'
|
||||||
|
export * from './did-method.js'
|
||||||
|
export * from './did-resolver-common.js'
|
||||||
|
export * from './did-resolver.js'
|
||||||
|
export * from './methods.js'
|
||||||
|
export * from './util.js'
|
2
packages/internal/did-resolver/src/methods.ts
Normal file
2
packages/internal/did-resolver/src/methods.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './methods/plc.js'
|
||||||
|
export * from './methods/web.js'
|
56
packages/internal/did-resolver/src/methods/plc.ts
Normal file
56
packages/internal/did-resolver/src/methods/plc.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
Fetch,
|
||||||
|
bindFetch,
|
||||||
|
fetchJsonProcessor,
|
||||||
|
fetchJsonZodProcessor,
|
||||||
|
fetchOkProcessor,
|
||||||
|
} from '@atproto-labs/fetch'
|
||||||
|
import { pipe } from '@atproto-labs/pipe'
|
||||||
|
import { Did, checkDidPlc, didDocumentValidator } from '@atproto/did'
|
||||||
|
|
||||||
|
import { DidMethod, ResolveOptions } from '../did-method.js'
|
||||||
|
|
||||||
|
const fetchSuccessHandler = pipe(
|
||||||
|
fetchOkProcessor(),
|
||||||
|
fetchJsonProcessor(/^application\/(did\+ld\+)?json$/),
|
||||||
|
fetchJsonZodProcessor(didDocumentValidator),
|
||||||
|
)
|
||||||
|
|
||||||
|
export type DidPlcMethodOptions = {
|
||||||
|
/**
|
||||||
|
* @default globalThis.fetch
|
||||||
|
*/
|
||||||
|
fetch?: Fetch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @default 'https://plc.directory/'
|
||||||
|
*/
|
||||||
|
plcDirectoryUrl?: string | URL
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DidPlcMethod implements DidMethod<'plc'> {
|
||||||
|
protected readonly fetch: Fetch<unknown>
|
||||||
|
|
||||||
|
public readonly plcDirectoryUrl: URL
|
||||||
|
|
||||||
|
constructor(options?: DidPlcMethodOptions) {
|
||||||
|
this.plcDirectoryUrl = new URL(
|
||||||
|
options?.plcDirectoryUrl || 'https://plc.directory/',
|
||||||
|
)
|
||||||
|
this.fetch = bindFetch(options?.fetch)
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolve(did: Did<'plc'>, options?: ResolveOptions) {
|
||||||
|
// Although the did should start with `did:plc:` (thanks to typings), we
|
||||||
|
// should still check if the msid is valid.
|
||||||
|
checkDidPlc(did)
|
||||||
|
|
||||||
|
const url = new URL(`/${did}`, this.plcDirectoryUrl)
|
||||||
|
|
||||||
|
return this.fetch(url, {
|
||||||
|
redirect: 'error',
|
||||||
|
headers: { accept: 'application/did+ld+json,application/json' },
|
||||||
|
signal: options?.signal,
|
||||||
|
}).then(fetchSuccessHandler)
|
||||||
|
}
|
||||||
|
}
|
58
packages/internal/did-resolver/src/methods/web.ts
Normal file
58
packages/internal/did-resolver/src/methods/web.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
bindFetch,
|
||||||
|
Fetch,
|
||||||
|
fetchJsonProcessor,
|
||||||
|
fetchJsonZodProcessor,
|
||||||
|
fetchOkProcessor,
|
||||||
|
} from '@atproto-labs/fetch'
|
||||||
|
import { pipe } from '@atproto-labs/pipe'
|
||||||
|
import { Did, didDocumentValidator, didWebToUrl } from '@atproto/did'
|
||||||
|
|
||||||
|
import { DidMethod, ResolveOptions } from '../did-method.js'
|
||||||
|
|
||||||
|
const fetchSuccessHandler = pipe(
|
||||||
|
fetchOkProcessor(),
|
||||||
|
fetchJsonProcessor(/^application\/(did\+ld\+)?json$/),
|
||||||
|
fetchJsonZodProcessor(didDocumentValidator),
|
||||||
|
)
|
||||||
|
|
||||||
|
export type DidWebMethodOptions = {
|
||||||
|
fetch?: Fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DidWebMethod implements DidMethod<'web'> {
|
||||||
|
protected readonly fetch: Fetch<unknown>
|
||||||
|
|
||||||
|
constructor({ fetch = globalThis.fetch }: DidWebMethodOptions = {}) {
|
||||||
|
this.fetch = bindFetch(fetch)
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolve(did: Did<'web'>, options?: ResolveOptions) {
|
||||||
|
const didDocumentUrl = buildDidWebDocumentUrl(did)
|
||||||
|
|
||||||
|
return this.fetch(didDocumentUrl, {
|
||||||
|
redirect: 'error',
|
||||||
|
headers: { accept: 'application/did+ld+json,application/json' },
|
||||||
|
signal: options?.signal,
|
||||||
|
}).then(fetchSuccessHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see {@link https://datatracker.ietf.org/doc/html/rfc8615}
|
||||||
|
* @see {@link https://w3c-ccg.github.io/did-method-web/#create-register}
|
||||||
|
*/
|
||||||
|
export function buildDidWebDocumentUrl(did: Did<'web'>) {
|
||||||
|
const url = didWebToUrl(did) // Will throw if the DID is invalid
|
||||||
|
|
||||||
|
// Note: DID cannot end with an `:`, so they cannot end with a `/`. This is
|
||||||
|
// true unless when there is no path at all, in which case the URL constructor
|
||||||
|
// will set the pathname to `/`.
|
||||||
|
|
||||||
|
// https://w3c-ccg.github.io/did-method-web/#read-resolve
|
||||||
|
if (url.pathname === '/') {
|
||||||
|
return new URL(`/.well-known/did.json`, url)
|
||||||
|
} else {
|
||||||
|
return new URL(`${url.pathname}/did.json`, url)
|
||||||
|
}
|
||||||
|
}
|
1
packages/internal/did-resolver/src/util.ts
Normal file
1
packages/internal/did-resolver/src/util.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export type Simplify<T> = { [K in keyof T]: T[K] } & NonNullable<unknown>
|
8
packages/internal/did-resolver/tsconfig.build.json
Normal file
8
packages/internal/did-resolver/tsconfig.build.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": ["../../../tsconfig/isomorphic.json"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist"
|
||||||
|
},
|
||||||
|
"include": ["./src/**/*.ts"]
|
||||||
|
}
|
4
packages/internal/did-resolver/tsconfig.json
Normal file
4
packages/internal/did-resolver/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"include": [],
|
||||||
|
"references": [{ "path": "./tsconfig.build.json" }]
|
||||||
|
}
|
40
packages/internal/fetch-node/package.json
Normal file
40
packages/internal/fetch-node/package.json
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "@atproto-labs/fetch-node",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "SSRF protection for fetch() in Node.js",
|
||||||
|
"keywords": [
|
||||||
|
"atproto",
|
||||||
|
"fetch",
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"homepage": "https://atproto.com",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/bluesky-social/atproto",
|
||||||
|
"directory": "packages/internal/fetch-node"
|
||||||
|
},
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@atproto-labs/fetch": "workspace:*",
|
||||||
|
"@atproto-labs/pipe": "workspace:*",
|
||||||
|
"ipaddr.js": "^2.1.0",
|
||||||
|
"psl": "^1.9.0",
|
||||||
|
"undici": "^6.14.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/psl": "1.1.3",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --build tsconfig.json"
|
||||||
|
}
|
||||||
|
}
|
4
packages/internal/fetch-node/src/index.ts
Normal file
4
packages/internal/fetch-node/src/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from '@atproto-labs/fetch'
|
||||||
|
|
||||||
|
export * from './safe.js'
|
||||||
|
export * from './ssrf.js'
|
78
packages/internal/fetch-node/src/safe.ts
Normal file
78
packages/internal/fetch-node/src/safe.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_FORBIDDEN_DOMAIN_NAMES,
|
||||||
|
Fetch,
|
||||||
|
fetchMaxSizeProcessor,
|
||||||
|
forbiddenDomainNameRequestTransform,
|
||||||
|
protocolCheckRequestTransform,
|
||||||
|
requireHostHeaderTranform,
|
||||||
|
timedFetch,
|
||||||
|
toRequestTransformer,
|
||||||
|
} from '@atproto-labs/fetch'
|
||||||
|
import { pipe } from '@atproto-labs/pipe'
|
||||||
|
|
||||||
|
import { ssrfFetchWrap } from './ssrf.js'
|
||||||
|
|
||||||
|
export type SafeFetchWrapOptions = NonNullable<
|
||||||
|
Parameters<typeof safeFetchWrap>[0]
|
||||||
|
>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a fetch function with safety checks so that it can be safely used
|
||||||
|
* with user provided input (URL).
|
||||||
|
*/
|
||||||
|
export function safeFetchWrap({
|
||||||
|
fetch = globalThis.fetch as Fetch,
|
||||||
|
responseMaxSize = 512 * 1024, // 512kB
|
||||||
|
allowHttp = false,
|
||||||
|
allowData = false,
|
||||||
|
ssrfProtection = true,
|
||||||
|
timeout = 10e3,
|
||||||
|
forbiddenDomainNames = DEFAULT_FORBIDDEN_DOMAIN_NAMES as Iterable<string>,
|
||||||
|
} = {}): Fetch<unknown> {
|
||||||
|
return toRequestTransformer(
|
||||||
|
pipe(
|
||||||
|
/**
|
||||||
|
* Prevent using http:, file: or data: protocols.
|
||||||
|
*/
|
||||||
|
protocolCheckRequestTransform(
|
||||||
|
['https:']
|
||||||
|
.concat(allowHttp ? ['http:'] : [])
|
||||||
|
.concat(allowData ? ['data:'] : []),
|
||||||
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only requests that will be issued with a "Host" header are allowed.
|
||||||
|
*/
|
||||||
|
requireHostHeaderTranform(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disallow fetching from domains we know are not atproto/OIDC client
|
||||||
|
* implementation. Note that other domains can be blocked by providing a
|
||||||
|
* custom fetch function combined with another
|
||||||
|
* forbiddenDomainNameRequestTransform.
|
||||||
|
*/
|
||||||
|
forbiddenDomainNameRequestTransform(forbiddenDomainNames),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Since we will be fetching from the network based on user provided
|
||||||
|
* input, let's mitigate resource exhaustion attacks by setting a timeout.
|
||||||
|
*/
|
||||||
|
timedFetch(
|
||||||
|
timeout,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Since we will be fetching from the network based on user provided
|
||||||
|
* input, we need to make sure that the request is not vulnerable to SSRF
|
||||||
|
* attacks.
|
||||||
|
*/
|
||||||
|
ssrfProtection ? ssrfFetchWrap({ fetch }) : fetch,
|
||||||
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Since we will be fetching user owned data, we need to make sure that an
|
||||||
|
* attacker cannot force us to download a large amounts of data.
|
||||||
|
*/
|
||||||
|
fetchMaxSizeProcessor(responseMaxSize),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
214
packages/internal/fetch-node/src/ssrf.ts
Normal file
214
packages/internal/fetch-node/src/ssrf.ts
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
import dns, { LookupAddress } from 'node:dns'
|
||||||
|
import { LookupFunction } from 'node:net'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Fetch,
|
||||||
|
FetchContext,
|
||||||
|
FetchRequestError,
|
||||||
|
toRequestTransformer,
|
||||||
|
} from '@atproto-labs/fetch'
|
||||||
|
import ipaddr from 'ipaddr.js'
|
||||||
|
import { isValid as isValidDomain } from 'psl'
|
||||||
|
import { Agent } from 'undici'
|
||||||
|
|
||||||
|
const { IPv4, IPv6 } = ipaddr
|
||||||
|
|
||||||
|
const [NODE_VERSION] = process.versions.node.split('.').map(Number)
|
||||||
|
|
||||||
|
export type SsrfFetchWrapOptions<C = FetchContext> = {
|
||||||
|
allowCustomPort?: boolean
|
||||||
|
allowUnknownTld?: boolean
|
||||||
|
fetch?: Fetch<C>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see {@link https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/}
|
||||||
|
*/
|
||||||
|
export function ssrfFetchWrap<C = FetchContext>({
|
||||||
|
allowCustomPort = false,
|
||||||
|
allowUnknownTld = false,
|
||||||
|
fetch = globalThis.fetch,
|
||||||
|
}: SsrfFetchWrapOptions<C>): Fetch<C> {
|
||||||
|
const ssrfAgent = new Agent({ connect: { lookup } })
|
||||||
|
|
||||||
|
return toRequestTransformer(async function (
|
||||||
|
this: C,
|
||||||
|
request,
|
||||||
|
): Promise<Response> {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
|
||||||
|
if (url.protocol === 'data:') {
|
||||||
|
// No SSRF issue
|
||||||
|
return fetch.call(this, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
||||||
|
// @ts-expect-error non-standard option
|
||||||
|
if (request.dispatcher) {
|
||||||
|
throw new FetchRequestError(
|
||||||
|
request,
|
||||||
|
500,
|
||||||
|
'SSRF protection cannot be used with a custom request dispatcher',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check port (OWASP)
|
||||||
|
if (url.port && !allowCustomPort) {
|
||||||
|
throw new FetchRequestError(
|
||||||
|
request,
|
||||||
|
400,
|
||||||
|
'Request port must be omitted or standard when SSRF is enabled',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable HTTP redirections (OWASP)
|
||||||
|
if (request.redirect === 'follow') {
|
||||||
|
throw new FetchRequestError(
|
||||||
|
request,
|
||||||
|
500,
|
||||||
|
'Request redirect must be "error" or "manual" when SSRF is enabled',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the hostname is an IP address, it must be a unicast address.
|
||||||
|
const ip = parseIpHostname(url.hostname)
|
||||||
|
if (ip) {
|
||||||
|
if (ip.range() !== 'unicast') {
|
||||||
|
throw new FetchRequestError(
|
||||||
|
request,
|
||||||
|
400,
|
||||||
|
'Hostname resolved to non-unicast address',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// No additional check required
|
||||||
|
return fetch.call(this, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowUnknownTld !== true && !isValidDomain(url.hostname)) {
|
||||||
|
throw new FetchRequestError(
|
||||||
|
request,
|
||||||
|
400,
|
||||||
|
'Hostname is not a public domain',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Else hostname is a domain name, use DNS lookup to check if it resolves
|
||||||
|
// to a unicast address
|
||||||
|
|
||||||
|
if (NODE_VERSION < 21) {
|
||||||
|
// Note: due to the issue nodejs/undici#2828 (fixed in undici >=6.7.0,
|
||||||
|
// Node >=21), the "dispatcher" property of the request object will not
|
||||||
|
// be used by fetch(). As a workaround, we pass the dispatcher as second
|
||||||
|
// argument to fetch() here, and make sure it is used (which might not be
|
||||||
|
// the case if a custom fetch() function is used).
|
||||||
|
|
||||||
|
if (fetch === globalThis.fetch) {
|
||||||
|
// If the global fetch function is used, we can pass the dispatcher
|
||||||
|
// singleton directly to the fetch function as we know it will be
|
||||||
|
// used.
|
||||||
|
|
||||||
|
// @ts-expect-error non-standard option
|
||||||
|
return fetch.call(this, request, { dispatcher: ssrfAgent })
|
||||||
|
}
|
||||||
|
|
||||||
|
let didLookup = false
|
||||||
|
const dispatcher = new Agent({
|
||||||
|
connect: {
|
||||||
|
lookup(...args) {
|
||||||
|
didLookup = true
|
||||||
|
lookup(...args)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
// @ts-expect-error non-standard option
|
||||||
|
return await fetch.call(this, request, { dispatcher })
|
||||||
|
} finally {
|
||||||
|
// Free resources (we cannot await here since the response was not
|
||||||
|
// consumed yet).
|
||||||
|
void dispatcher.close().catch((err) => {
|
||||||
|
// No biggie, but let's still log it
|
||||||
|
console.warn('Failed to close dispatcher', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!didLookup) {
|
||||||
|
// If you encounter this error, either upgrade to Node.js >=21 or
|
||||||
|
// make sure that the requestInit object is passed as second
|
||||||
|
// argument to the global fetch function.
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-unsafe-finally
|
||||||
|
throw new FetchRequestError(
|
||||||
|
request,
|
||||||
|
500,
|
||||||
|
'Unable to enforce SSRF protection',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-expect-error non-standard option
|
||||||
|
return fetch(new Request(request, { dispatcher: ssrfAgent }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// blob: about: file: all should be rejected
|
||||||
|
throw new FetchRequestError(
|
||||||
|
request,
|
||||||
|
400,
|
||||||
|
`Forbidden protocol "${url.protocol}"`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIpHostname(
|
||||||
|
hostname: string,
|
||||||
|
): ipaddr.IPv4 | ipaddr.IPv6 | undefined {
|
||||||
|
if (IPv4.isIPv4(hostname)) {
|
||||||
|
return IPv4.parse(hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostname.startsWith('[') && hostname.endsWith(']')) {
|
||||||
|
return IPv6.parse(hostname.slice(1, -1))
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookup(
|
||||||
|
hostname: string,
|
||||||
|
options: dns.LookupOptions,
|
||||||
|
callback: Parameters<LookupFunction>[2],
|
||||||
|
) {
|
||||||
|
dns.lookup(hostname, options, (err, address, family) => {
|
||||||
|
if (err) {
|
||||||
|
callback(err, address, family)
|
||||||
|
} else {
|
||||||
|
const ips = Array.isArray(address)
|
||||||
|
? address.map(parseLookupAddress)
|
||||||
|
: [parseLookupAddress({ address, family })]
|
||||||
|
|
||||||
|
if (ips.some((ip) => ip.range() !== 'unicast')) {
|
||||||
|
callback(
|
||||||
|
new Error('Hostname resolved to non-unicast address'),
|
||||||
|
address,
|
||||||
|
family,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
callback(null, address, family)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLookupAddress({
|
||||||
|
address,
|
||||||
|
family,
|
||||||
|
}: LookupAddress): ipaddr.IPv4 | ipaddr.IPv6 {
|
||||||
|
const ip = family === 4 ? IPv4.parse(address) : IPv6.parse(address)
|
||||||
|
|
||||||
|
if (ip instanceof IPv6 && ip.isIPv4MappedAddress()) {
|
||||||
|
return ip.toIPv4Address()
|
||||||
|
} else {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
8
packages/internal/fetch-node/tsconfig.build.json
Normal file
8
packages/internal/fetch-node/tsconfig.build.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": ["../../../tsconfig/node.json"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
4
packages/internal/fetch-node/tsconfig.json
Normal file
4
packages/internal/fetch-node/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"include": [],
|
||||||
|
"references": [{ "path": "./tsconfig.build.json" }]
|
||||||
|
}
|
37
packages/internal/fetch/package.json
Normal file
37
packages/internal/fetch/package.json
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "@atproto-labs/fetch",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "Isomorphic wrapper utilities for fetch API",
|
||||||
|
"keywords": [
|
||||||
|
"atproto",
|
||||||
|
"fetch"
|
||||||
|
],
|
||||||
|
"homepage": "https://atproto.com",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/bluesky-social/atproto",
|
||||||
|
"directory": "packages/internal/fetch"
|
||||||
|
},
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@atproto-labs/pipe": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --build tsconfig.json"
|
||||||
|
}
|
||||||
|
}
|
59
packages/internal/fetch/src/fetch-error.ts
Normal file
59
packages/internal/fetch/src/fetch-error.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
export class FetchError extends Error {
|
||||||
|
public readonly statusCode: number
|
||||||
|
|
||||||
|
constructor(statusCode?: number, message?: string, options?: ErrorOptions) {
|
||||||
|
if (statusCode == null || !message) {
|
||||||
|
const info = extractInfo(extractRootCause(options?.cause))
|
||||||
|
statusCode = statusCode ?? info[0]
|
||||||
|
message = message || info[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
super(message, options)
|
||||||
|
|
||||||
|
this.statusCode = statusCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRootCause(err: unknown): unknown {
|
||||||
|
// Unwrap the Network error from undici (i.e. Node's internal fetch() implementation)
|
||||||
|
// https://github.com/nodejs/undici/blob/3274c975947ce11a08508743df026f73598bfead/lib/web/fetch/index.js#L223-L228
|
||||||
|
if (
|
||||||
|
err instanceof TypeError &&
|
||||||
|
err.message === 'fetch failed' &&
|
||||||
|
err.cause !== undefined
|
||||||
|
) {
|
||||||
|
return err.cause
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractInfo(err: unknown): [statusCode: number, message: string] {
|
||||||
|
if (typeof err === 'string' && err.length > 0) {
|
||||||
|
return [500, err]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(err instanceof Error)) {
|
||||||
|
return [500, 'Failed to fetch']
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = err['code']
|
||||||
|
if (typeof code === 'string') {
|
||||||
|
switch (true) {
|
||||||
|
case code === 'ENOTFOUND':
|
||||||
|
return [400, 'Invalid hostname']
|
||||||
|
case code === 'ECONNREFUSED':
|
||||||
|
return [502, 'Connection refused']
|
||||||
|
case code === 'DEPTH_ZERO_SELF_SIGNED_CERT':
|
||||||
|
return [502, 'Self-signed certificate']
|
||||||
|
case code.startsWith('ERR_TLS'):
|
||||||
|
return [502, 'TLS error']
|
||||||
|
case code.startsWith('ECONN'):
|
||||||
|
return [502, 'Connection error']
|
||||||
|
default:
|
||||||
|
return [500, `${code} error`]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [500, err.message]
|
||||||
|
}
|
118
packages/internal/fetch/src/fetch-request.ts
Normal file
118
packages/internal/fetch/src/fetch-request.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { FetchError } from './fetch-error.js'
|
||||||
|
import { asRequest } from './fetch.js'
|
||||||
|
import { isIp } from './util.js'
|
||||||
|
|
||||||
|
export class FetchRequestError extends FetchError {
|
||||||
|
constructor(
|
||||||
|
public readonly request: Request,
|
||||||
|
statusCode?: number,
|
||||||
|
message?: string,
|
||||||
|
options?: ErrorOptions,
|
||||||
|
) {
|
||||||
|
super(statusCode, message, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
static from(request: Request, cause: unknown): FetchRequestError {
|
||||||
|
if (cause instanceof FetchRequestError) return cause
|
||||||
|
return new FetchRequestError(request, undefined, undefined, { cause })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractUrl = (input: Request | string | URL) =>
|
||||||
|
typeof input === 'string'
|
||||||
|
? new URL(input)
|
||||||
|
: input instanceof URL
|
||||||
|
? input
|
||||||
|
: new URL(input.url)
|
||||||
|
|
||||||
|
export function protocolCheckRequestTransform(protocols: Iterable<string>) {
|
||||||
|
const allowedProtocols = new Set<string>(protocols)
|
||||||
|
|
||||||
|
return (input: Request | string | URL, init?: RequestInit) => {
|
||||||
|
const { protocol } = extractUrl(input)
|
||||||
|
|
||||||
|
const request = asRequest(input, init)
|
||||||
|
|
||||||
|
if (!allowedProtocols.has(protocol)) {
|
||||||
|
throw new FetchRequestError(
|
||||||
|
request,
|
||||||
|
400,
|
||||||
|
`"${protocol}" protocol is not allowed`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireHostHeaderTranform() {
|
||||||
|
return (input: Request | string | URL, init?: RequestInit) => {
|
||||||
|
// Note that fetch() will automatically add the Host header from the URL and
|
||||||
|
// discard any Host header manually set in the request.
|
||||||
|
|
||||||
|
const { protocol, hostname } = extractUrl(input)
|
||||||
|
|
||||||
|
const request = asRequest(input, init)
|
||||||
|
|
||||||
|
// "Host" header only makes sense in the context of an HTTP request
|
||||||
|
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||||
|
throw new FetchRequestError(
|
||||||
|
request,
|
||||||
|
400,
|
||||||
|
`"${protocol}" requests are not allowed`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hostname || isIp(hostname)) {
|
||||||
|
throw new FetchRequestError(request, 400, 'Invalid hostname')
|
||||||
|
}
|
||||||
|
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_FORBIDDEN_DOMAIN_NAMES = [
|
||||||
|
'example.com',
|
||||||
|
'*.example.com',
|
||||||
|
'example.org',
|
||||||
|
'*.example.org',
|
||||||
|
'example.net',
|
||||||
|
'*.example.net',
|
||||||
|
'googleusercontent.com',
|
||||||
|
'*.googleusercontent.com',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function forbiddenDomainNameRequestTransform(
|
||||||
|
denyList: Iterable<string> = DEFAULT_FORBIDDEN_DOMAIN_NAMES,
|
||||||
|
) {
|
||||||
|
const denySet = new Set<string>(denyList)
|
||||||
|
|
||||||
|
// Optimization: if no forbidden domain names are provided, we can skip the
|
||||||
|
// check entirely.
|
||||||
|
if (denySet.size === 0) {
|
||||||
|
return async (request) => request
|
||||||
|
}
|
||||||
|
|
||||||
|
return async (input: Request | string | URL, init?: RequestInit) => {
|
||||||
|
const { hostname } = extractUrl(input)
|
||||||
|
|
||||||
|
const request = asRequest(input, init)
|
||||||
|
|
||||||
|
// Full domain name check
|
||||||
|
if (denySet.has(hostname)) {
|
||||||
|
throw new FetchRequestError(request, 403, 'Forbidden hostname')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub domain name check
|
||||||
|
let curDot = hostname.indexOf('.')
|
||||||
|
while (curDot !== -1) {
|
||||||
|
const subdomain = hostname.slice(curDot + 1)
|
||||||
|
if (denySet.has(`*.${subdomain}`)) {
|
||||||
|
throw new FetchRequestError(request, 403, 'Forbidden hostname')
|
||||||
|
}
|
||||||
|
curDot = hostname.indexOf('.', curDot + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
}
|
278
packages/internal/fetch/src/fetch-response.ts
Normal file
278
packages/internal/fetch/src/fetch-response.ts
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
import { Transformer, pipe } from '@atproto-labs/pipe'
|
||||||
|
|
||||||
|
// optional dependency for typing purposes
|
||||||
|
import type { ZodTypeAny, ParseParams, TypeOf } from 'zod'
|
||||||
|
|
||||||
|
import { FetchError } from './fetch-error.js'
|
||||||
|
import { TransformedResponse } from './transformed-response.js'
|
||||||
|
import {
|
||||||
|
Json,
|
||||||
|
MaxBytesTransformStream,
|
||||||
|
cancelBody,
|
||||||
|
ifObject,
|
||||||
|
ifString,
|
||||||
|
logCancellationError,
|
||||||
|
} from './util.js'
|
||||||
|
|
||||||
|
export type ResponseTranformer = Transformer<Response>
|
||||||
|
export type ResponseMessageGetter = Transformer<Response, string | undefined>
|
||||||
|
|
||||||
|
export class FetchResponseError extends FetchError {
|
||||||
|
constructor(
|
||||||
|
public readonly response: Response,
|
||||||
|
statusCode: number = response.status,
|
||||||
|
message: string = response.statusText,
|
||||||
|
options?: ErrorOptions,
|
||||||
|
) {
|
||||||
|
super(statusCode, message, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async from(
|
||||||
|
response: Response,
|
||||||
|
customMessage: string | ResponseMessageGetter = extractResponseMessage,
|
||||||
|
statusCode = response.status,
|
||||||
|
options?: ErrorOptions,
|
||||||
|
) {
|
||||||
|
const message =
|
||||||
|
typeof customMessage === 'string'
|
||||||
|
? customMessage
|
||||||
|
: typeof customMessage === 'function'
|
||||||
|
? await customMessage(response)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return new FetchResponseError(response, statusCode, message, options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractResponseMessage: ResponseMessageGetter = async (response) => {
|
||||||
|
const mimeType = extractMime(response)
|
||||||
|
if (!mimeType) return undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mimeType === 'text/plain') {
|
||||||
|
return await response.text()
|
||||||
|
} else if (/^application\/(?:[^+]+\+)?json$/i.test(mimeType)) {
|
||||||
|
const json: unknown = await response.json()
|
||||||
|
|
||||||
|
if (typeof json === 'string') return json
|
||||||
|
|
||||||
|
const errorDescription = ifString(ifObject(json)?.['error_description'])
|
||||||
|
if (errorDescription) return errorDescription
|
||||||
|
|
||||||
|
const error = ifString(ifObject(json)?.['error'])
|
||||||
|
if (error) return error
|
||||||
|
|
||||||
|
const message = ifString(ifObject(json)?.['message'])
|
||||||
|
if (message) return message
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function peekJson(
|
||||||
|
response: Response,
|
||||||
|
maxSize = Infinity,
|
||||||
|
): Promise<undefined | Json> {
|
||||||
|
const type = extractMime(response)
|
||||||
|
if (type !== 'application/json') return undefined
|
||||||
|
checkLength(response, maxSize)
|
||||||
|
|
||||||
|
// 1) Clone the request so we can consume the body
|
||||||
|
const clonedResponse = response.clone()
|
||||||
|
|
||||||
|
// 2) Make sure the request's body is not too large
|
||||||
|
const limitedResponse =
|
||||||
|
response.body && maxSize < Infinity
|
||||||
|
? new TransformedResponse(
|
||||||
|
clonedResponse,
|
||||||
|
new MaxBytesTransformStream(maxSize),
|
||||||
|
)
|
||||||
|
: // Note: some runtimes (e.g. react-native) don't expose a body property
|
||||||
|
clonedResponse
|
||||||
|
|
||||||
|
// 3) Parse the JSON
|
||||||
|
return limitedResponse.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkLength(response: Response, maxBytes: number) {
|
||||||
|
// Note: negation accounts for invalid value types (NaN, non numbers)
|
||||||
|
if (!(maxBytes >= 0)) {
|
||||||
|
throw new TypeError('maxBytes must be a non-negative number')
|
||||||
|
}
|
||||||
|
const length = extractLength(response)
|
||||||
|
if (length != null && length > maxBytes) {
|
||||||
|
throw new FetchResponseError(response, 502, 'Response too large')
|
||||||
|
}
|
||||||
|
return length
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractLength(response: Response) {
|
||||||
|
const contentLength = response.headers.get('Content-Length')
|
||||||
|
if (contentLength == null) return undefined
|
||||||
|
if (!/^\d+$/.test(contentLength)) {
|
||||||
|
throw new FetchResponseError(response, 502, 'Invalid Content-Length')
|
||||||
|
}
|
||||||
|
const length = Number(contentLength)
|
||||||
|
if (!Number.isSafeInteger(length)) {
|
||||||
|
throw new FetchResponseError(response, 502, 'Content-Length too large')
|
||||||
|
}
|
||||||
|
return length
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMime(response: Response) {
|
||||||
|
const contentType = response.headers.get('Content-Type')
|
||||||
|
if (contentType == null) return undefined
|
||||||
|
|
||||||
|
return contentType.split(';', 1)[0]!.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the transformer results in an error, ensure that the response body is
|
||||||
|
* consumed as, in some environments (Node 👀), the response will not
|
||||||
|
* automatically be GC'd.
|
||||||
|
*
|
||||||
|
* @see {@link https://undici.nodejs.org/#/?id=garbage-collection}
|
||||||
|
* @param [onCancellationError] - Callback to handle any async body cancelling
|
||||||
|
* error. Defaults to logging the error. Do not use `null` if the request is
|
||||||
|
* cloned.
|
||||||
|
*/
|
||||||
|
export function cancelBodyOnError<T>(
|
||||||
|
transformer: Transformer<Response, T>,
|
||||||
|
onCancellationError: null | ((err: unknown) => void) = logCancellationError,
|
||||||
|
): (response: Response) => Promise<T> {
|
||||||
|
return async (response) => {
|
||||||
|
try {
|
||||||
|
return await transformer(response)
|
||||||
|
} catch (err) {
|
||||||
|
await cancelBody(response, onCancellationError ?? undefined)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchOkProcessor(
|
||||||
|
customMessage?: string | ResponseMessageGetter,
|
||||||
|
): ResponseTranformer {
|
||||||
|
return cancelBodyOnError((response) => {
|
||||||
|
return fetchOkTransformer(response, customMessage)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOkTransformer(
|
||||||
|
response: Response,
|
||||||
|
customMessage?: string | ResponseMessageGetter,
|
||||||
|
) {
|
||||||
|
if (response.ok) return response
|
||||||
|
throw await FetchResponseError.from(response, customMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchMaxSizeProcessor(maxBytes: number): ResponseTranformer {
|
||||||
|
if (maxBytes === Infinity) return (response) => response
|
||||||
|
if (!Number.isFinite(maxBytes) || maxBytes < 0) {
|
||||||
|
throw new TypeError('maxBytes must be a 0, Infinity or a positive number')
|
||||||
|
}
|
||||||
|
return cancelBodyOnError((response) => {
|
||||||
|
return fetchResponseMaxSizeChecker(response, maxBytes)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchResponseMaxSizeChecker(
|
||||||
|
response: Response,
|
||||||
|
maxBytes: number,
|
||||||
|
): Response {
|
||||||
|
if (maxBytes === Infinity) return response
|
||||||
|
checkLength(response, maxBytes)
|
||||||
|
|
||||||
|
// Some engines (react-native 👀) don't expose a body property. In that case,
|
||||||
|
// we will only rely on the Content-Length header.
|
||||||
|
if (!response.body) return response
|
||||||
|
|
||||||
|
const transform = new MaxBytesTransformStream(maxBytes)
|
||||||
|
return new TransformedResponse(response, transform)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MimeTypeCheckFn = (mimeType: string) => boolean
|
||||||
|
export type MimeTypeCheck = string | RegExp | MimeTypeCheckFn
|
||||||
|
|
||||||
|
export function fetchTypeProcessor(
|
||||||
|
expectedMime: MimeTypeCheck,
|
||||||
|
contentTypeRequired = true,
|
||||||
|
): ResponseTranformer {
|
||||||
|
const isExpected: MimeTypeCheckFn =
|
||||||
|
typeof expectedMime === 'string'
|
||||||
|
? (mimeType) => mimeType === expectedMime
|
||||||
|
: expectedMime instanceof RegExp
|
||||||
|
? (mimeType) => expectedMime.test(mimeType)
|
||||||
|
: expectedMime
|
||||||
|
|
||||||
|
return cancelBodyOnError((response) => {
|
||||||
|
return fetchResponseTypeChecker(response, isExpected, contentTypeRequired)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchResponseTypeChecker(
|
||||||
|
response: Response,
|
||||||
|
isExpectedMime: MimeTypeCheckFn,
|
||||||
|
contentTypeRequired = true,
|
||||||
|
): Promise<Response> {
|
||||||
|
const mimeType = extractMime(response)
|
||||||
|
if (mimeType) {
|
||||||
|
if (!isExpectedMime(mimeType)) {
|
||||||
|
throw await FetchResponseError.from(
|
||||||
|
response,
|
||||||
|
`Unexpected response Content-Type (${mimeType})`,
|
||||||
|
502,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (contentTypeRequired) {
|
||||||
|
throw await FetchResponseError.from(
|
||||||
|
response,
|
||||||
|
'Missing response Content-Type header',
|
||||||
|
502,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParsedJsonResponse<T = Json> = {
|
||||||
|
response: Response
|
||||||
|
json: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchResponseJsonTranformer<T = Json>(
|
||||||
|
response: Response,
|
||||||
|
): Promise<ParsedJsonResponse<T>> {
|
||||||
|
try {
|
||||||
|
const json = (await response.json()) as T
|
||||||
|
return { response, json }
|
||||||
|
} catch (cause) {
|
||||||
|
throw new FetchResponseError(
|
||||||
|
response,
|
||||||
|
502,
|
||||||
|
'Unable to parse response as JSON',
|
||||||
|
{ cause },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchJsonProcessor<T = Json>(
|
||||||
|
expectedMime: MimeTypeCheck = /^application\/(?:[^+]+\+)?json$/,
|
||||||
|
contentTypeRequired = true,
|
||||||
|
): Transformer<Response, ParsedJsonResponse<T>> {
|
||||||
|
return pipe(
|
||||||
|
fetchTypeProcessor(expectedMime, contentTypeRequired),
|
||||||
|
cancelBodyOnError(fetchResponseJsonTranformer<T>),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchJsonZodProcessor<S extends ZodTypeAny>(
|
||||||
|
schema: S,
|
||||||
|
params?: Partial<ParseParams>,
|
||||||
|
): Transformer<ParsedJsonResponse, TypeOf<S>> {
|
||||||
|
return async (jsonResponse: ParsedJsonResponse): Promise<TypeOf<S>> =>
|
||||||
|
schema.parseAsync(jsonResponse.json, params)
|
||||||
|
}
|
122
packages/internal/fetch/src/fetch-wrap.ts
Normal file
122
packages/internal/fetch/src/fetch-wrap.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { FetchRequestError } from './fetch-request.js'
|
||||||
|
import { Fetch, FetchContext, toRequestTransformer } from './fetch.js'
|
||||||
|
import { TransformedResponse } from './transformed-response.js'
|
||||||
|
import { padLines, stringifyMessage } from './util.js'
|
||||||
|
|
||||||
|
export function loggedFetch<C = FetchContext>(
|
||||||
|
fetch: Fetch<C> = globalThis.fetch,
|
||||||
|
) {
|
||||||
|
return toRequestTransformer(async function (
|
||||||
|
this: C,
|
||||||
|
request,
|
||||||
|
): Promise<Response> {
|
||||||
|
const requestMessage = await stringifyMessage(request)
|
||||||
|
console.info(
|
||||||
|
`> ${request.method} ${request.url}\n${padLines(requestMessage, ' ')}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch.call(this, request)
|
||||||
|
|
||||||
|
const responseMessage = await stringifyMessage(response.clone())
|
||||||
|
console.info(
|
||||||
|
`< HTTP/1.1 ${response.status} ${response.statusText}\n${padLines(responseMessage, ' ')}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`< Error:`, error)
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const timedFetch = <C = FetchContext>(
|
||||||
|
timeout = 60e3,
|
||||||
|
fetch: Fetch<C> = globalThis.fetch,
|
||||||
|
): Fetch<C> => {
|
||||||
|
if (timeout === Infinity) return fetch
|
||||||
|
if (!Number.isFinite(timeout) || timeout <= 0) {
|
||||||
|
throw new TypeError('Timeout must be positive')
|
||||||
|
}
|
||||||
|
return toRequestTransformer(async function (
|
||||||
|
this: C,
|
||||||
|
request,
|
||||||
|
): Promise<Response> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const signal = controller.signal
|
||||||
|
|
||||||
|
const abort = () => {
|
||||||
|
controller.abort()
|
||||||
|
}
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
request.signal?.removeEventListener('abort', abort)
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(abort, timeout)
|
||||||
|
if (typeof timer === 'object') timer.unref?.() // only on node
|
||||||
|
request.signal?.addEventListener('abort', abort)
|
||||||
|
|
||||||
|
signal.addEventListener('abort', cleanup)
|
||||||
|
|
||||||
|
const response = await fetch.call(this, request, { signal })
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
cleanup()
|
||||||
|
return response
|
||||||
|
} else {
|
||||||
|
// Cleanup the timer & event listeners when the body stream is closed
|
||||||
|
const transform = new TransformStream({ flush: cleanup })
|
||||||
|
return new TransformedResponse(response, transform)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a fetch function to bind it to a specific context, and wrap any thrown
|
||||||
|
* errors into a FetchRequestError.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* class MyClient {
|
||||||
|
* constructor(private fetch = globalThis.fetch) {}
|
||||||
|
*
|
||||||
|
* async get(url: string) {
|
||||||
|
* // This will generate an error, because the context used is not a
|
||||||
|
* // FetchContext (it's a MyClient instance).
|
||||||
|
* return this.fetch(url)
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* class MyClient {
|
||||||
|
* private fetch: Fetch<unknown>
|
||||||
|
*
|
||||||
|
* constructor(fetch = globalThis.fetch) {
|
||||||
|
* this.fetch = bindFetch(fetch)
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* async get(url: string) {
|
||||||
|
* return this.fetch(url) // no more error
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function bindFetch<C = FetchContext>(
|
||||||
|
fetch: Fetch<C> = globalThis.fetch,
|
||||||
|
context: C = globalThis as C,
|
||||||
|
) {
|
||||||
|
return toRequestTransformer(async (request) => {
|
||||||
|
try {
|
||||||
|
return await fetch.call(context, request)
|
||||||
|
} catch (err) {
|
||||||
|
throw FetchRequestError.from(request, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
39
packages/internal/fetch/src/fetch.ts
Normal file
39
packages/internal/fetch/src/fetch.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { ThisParameterOverride } from './util.js'
|
||||||
|
|
||||||
|
export type FetchContext = void | null | typeof globalThis
|
||||||
|
|
||||||
|
export type FetchBound = (
|
||||||
|
input: string | URL | Request,
|
||||||
|
init?: RequestInit,
|
||||||
|
) => Promise<Response>
|
||||||
|
|
||||||
|
// NOT using "typeof globalThis.fetch" here because "globalThis.fetch" does not
|
||||||
|
// have a "this" parameter, while runtimes do ensure that "fetch" is called with
|
||||||
|
// the correct "this" parameter (either null, undefined, or window).
|
||||||
|
|
||||||
|
export type Fetch<C = FetchContext> = ThisParameterOverride<C, FetchBound>
|
||||||
|
|
||||||
|
export type SimpleFetchBound = (input: Request) => Promise<Response>
|
||||||
|
export type SimpleFetch<C = FetchContext> = ThisParameterOverride<
|
||||||
|
C,
|
||||||
|
SimpleFetchBound
|
||||||
|
>
|
||||||
|
|
||||||
|
export function toRequestTransformer<C, O>(
|
||||||
|
requestTransformer: (this: C, input: Request) => O,
|
||||||
|
): ThisParameterOverride<
|
||||||
|
C,
|
||||||
|
(input: string | URL | Request, init?: RequestInit) => O
|
||||||
|
> {
|
||||||
|
return function (this: C, input, init) {
|
||||||
|
return requestTransformer.call(this, asRequest(input, init))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asRequest(
|
||||||
|
input: string | URL | Request,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Request {
|
||||||
|
if (!init && input instanceof Request) return input
|
||||||
|
return new Request(input, init)
|
||||||
|
}
|
6
packages/internal/fetch/src/index.ts
Normal file
6
packages/internal/fetch/src/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export * from './fetch-error.js'
|
||||||
|
export * from './fetch-request.js'
|
||||||
|
export * from './fetch-response.js'
|
||||||
|
export * from './fetch-wrap.js'
|
||||||
|
export * from './fetch.js'
|
||||||
|
export * from './util.js'
|
36
packages/internal/fetch/src/transformed-response.ts
Normal file
36
packages/internal/fetch/src/transformed-response.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
export class TransformedResponse extends Response {
|
||||||
|
#response: Response
|
||||||
|
|
||||||
|
constructor(response: Response, transform: TransformStream) {
|
||||||
|
if (!response.body) {
|
||||||
|
throw new TypeError('Response body is not available')
|
||||||
|
}
|
||||||
|
if (response.bodyUsed) {
|
||||||
|
throw new TypeError('Response body is already used')
|
||||||
|
}
|
||||||
|
|
||||||
|
super(response.body.pipeThrough(transform), {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: response.headers,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.#response = response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some props can't be set through ResponseInit, so we need to proxy them
|
||||||
|
*/
|
||||||
|
get url() {
|
||||||
|
return this.#response.url
|
||||||
|
}
|
||||||
|
get redirected() {
|
||||||
|
return this.#response.redirected
|
||||||
|
}
|
||||||
|
get type() {
|
||||||
|
return this.#response.type
|
||||||
|
}
|
||||||
|
get statusText() {
|
||||||
|
return this.#response.statusText
|
||||||
|
}
|
||||||
|
}
|
169
packages/internal/fetch/src/util.ts
Normal file
169
packages/internal/fetch/src/util.ts
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
// @TODO: Move some of these to a shared package ?
|
||||||
|
|
||||||
|
export type JsonScalar = string | number | boolean | null
|
||||||
|
export type Json = JsonScalar | Json[] | { [key: string]: undefined | Json }
|
||||||
|
export type JsonObject = { [key: string]: Json }
|
||||||
|
export type JsonArray = Json[]
|
||||||
|
|
||||||
|
export type ThisParameterOverride<
|
||||||
|
C,
|
||||||
|
Fn extends (...a: any) => any,
|
||||||
|
> = Fn extends (...args: infer P) => infer R
|
||||||
|
? ((this: C, ...args: P) => R) & {
|
||||||
|
bind(context: C): (...args: P) => R
|
||||||
|
}
|
||||||
|
: never
|
||||||
|
|
||||||
|
export function isIp(hostname: string) {
|
||||||
|
// IPv4
|
||||||
|
if (hostname.match(/^\d+\.\d+\.\d+\.\d+$/)) return true
|
||||||
|
|
||||||
|
// IPv6
|
||||||
|
if (hostname.startsWith('[') && hostname.endsWith(']')) return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainObjectProto = Object.prototype
|
||||||
|
export const ifObject = <V>(v: V) => {
|
||||||
|
if (typeof v === 'object' && v != null && !Array.isArray(v)) {
|
||||||
|
const proto = Object.getPrototypeOf(v)
|
||||||
|
if (proto === null || proto === plainObjectProto) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
return v as V extends JsonScalar | JsonArray | Function | symbol
|
||||||
|
? never
|
||||||
|
: V extends Json
|
||||||
|
? V
|
||||||
|
: // Plain object are (mostly) safe to access using a string index
|
||||||
|
Record<string, unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ifString = <V>(v: V) => (typeof v === 'string' ? v : undefined)
|
||||||
|
|
||||||
|
export class MaxBytesTransformStream extends TransformStream<
|
||||||
|
Uint8Array,
|
||||||
|
Uint8Array
|
||||||
|
> {
|
||||||
|
constructor(maxBytes: number) {
|
||||||
|
// Note: negation accounts for invalid value types (NaN, non numbers)
|
||||||
|
if (!(maxBytes >= 0)) {
|
||||||
|
throw new TypeError('maxBytes must be a non-negative number')
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytesRead = 0
|
||||||
|
|
||||||
|
super({
|
||||||
|
transform: (
|
||||||
|
chunk: Uint8Array,
|
||||||
|
ctrl: TransformStreamDefaultController<Uint8Array>,
|
||||||
|
) => {
|
||||||
|
if ((bytesRead += chunk.length) <= maxBytes) {
|
||||||
|
ctrl.enqueue(chunk)
|
||||||
|
} else {
|
||||||
|
ctrl.error(new Error('Response too large'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINE_BREAK = /\r?\n/g
|
||||||
|
export function padLines(input: string, pad: string) {
|
||||||
|
if (!input) return input
|
||||||
|
return pad + input.replace(LINE_BREAK, `$&${pad}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param [onCancellationError] - Callback that will trigger to asynchronously
|
||||||
|
* handle any error that occurs while cancelling the response body. Providing
|
||||||
|
* this will speed up the process and avoid potential deadlocks. Defaults to
|
||||||
|
* awaiting the cancellation operation. use `"log"` to log the error.
|
||||||
|
* @see {@link https://undici.nodejs.org/#/?id=garbage-collection}
|
||||||
|
* @note awaiting this function's result, when no `onCancellationError` is
|
||||||
|
* provided, might result in a dead lock. Indeed, if the response was cloned(),
|
||||||
|
* the response.body.cancel() method will not resolve until the other response's
|
||||||
|
* body is consumed/cancelled.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Make sure response was not cloned, or that every cloned response was
|
||||||
|
* // consumed/cancelled before awaiting this function's result.
|
||||||
|
* await cancelBody(response)
|
||||||
|
* ```
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* await cancelBody(response, (err) => {
|
||||||
|
* // No biggie, let's just log the error
|
||||||
|
* console.warn('Failed to cancel response body', err)
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Will generate an "unhandledRejection" if an error occurs while cancelling
|
||||||
|
* // the response body. This will likely crash the process.
|
||||||
|
* await cancelBody(response, (err) => { throw err })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function cancelBody(
|
||||||
|
body: Body,
|
||||||
|
onCancellationError?: 'log' | ((err: unknown) => void),
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
body.body &&
|
||||||
|
!body.bodyUsed &&
|
||||||
|
!body.body.locked &&
|
||||||
|
// Support for alternative fetch implementations
|
||||||
|
typeof body.body.cancel === 'function'
|
||||||
|
) {
|
||||||
|
if (typeof onCancellationError === 'function') {
|
||||||
|
void body.body.cancel().catch(onCancellationError)
|
||||||
|
} else if (onCancellationError === 'log') {
|
||||||
|
void body.body.cancel().catch(logCancellationError)
|
||||||
|
} else {
|
||||||
|
await body.body.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logCancellationError(err: unknown): void {
|
||||||
|
console.warn('Failed to cancel response body', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stringifyMessage(input: Body & { headers: Headers }) {
|
||||||
|
try {
|
||||||
|
const headers = stringifyHeaders(input.headers)
|
||||||
|
const payload = await stringifyBody(input)
|
||||||
|
return headers && payload ? `${headers}\n${payload}` : headers || payload
|
||||||
|
} finally {
|
||||||
|
void cancelBody(input, 'log')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyHeaders(headers: Headers) {
|
||||||
|
return Array.from(headers)
|
||||||
|
.map(([name, value]) => `${name}: ${value}`)
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stringifyBody(body: Body) {
|
||||||
|
try {
|
||||||
|
const blob = await body.blob()
|
||||||
|
if (blob.type?.startsWith('text/')) {
|
||||||
|
const text = await blob.text()
|
||||||
|
return JSON.stringify(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/application\/(?:\w+\+)?json/.test(blob.type)) {
|
||||||
|
const text = await blob.text()
|
||||||
|
return text.includes('\n') ? JSON.stringify(JSON.parse(text)) : text
|
||||||
|
}
|
||||||
|
|
||||||
|
return `[Body size: ${blob.size}, type: ${JSON.stringify(blob.type)} ]`
|
||||||
|
} catch {
|
||||||
|
return '[Body could not be read]'
|
||||||
|
}
|
||||||
|
}
|
8
packages/internal/fetch/tsconfig.build.json
Normal file
8
packages/internal/fetch/tsconfig.build.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": ["../../../tsconfig/isomorphic.json"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
4
packages/internal/fetch/tsconfig.json
Normal file
4
packages/internal/fetch/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"include": [],
|
||||||
|
"references": [{ "path": "./tsconfig.build.json" }]
|
||||||
|
}
|
39
packages/internal/handle-resolver-node/package.json
Normal file
39
packages/internal/handle-resolver-node/package.json
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "@atproto-labs/handle-resolver-node",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "Node specific ATProto handle to DID resolver",
|
||||||
|
"keywords": [
|
||||||
|
"atproto",
|
||||||
|
"oauth",
|
||||||
|
"handle",
|
||||||
|
"identity",
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"homepage": "https://atproto.com",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/bluesky-social/atproto",
|
||||||
|
"directory": "packages/internal/handle-resolver-node"
|
||||||
|
},
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@atproto-labs/fetch-node": "workspace:*",
|
||||||
|
"@atproto-labs/handle-resolver": "workspace:*",
|
||||||
|
"@atproto/did": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --build tsconfig.build.json"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,51 @@
|
|||||||
|
import { Fetch, safeFetchWrap } from '@atproto-labs/fetch-node'
|
||||||
|
import {
|
||||||
|
AtprotoHandleResolver,
|
||||||
|
HandleResolver,
|
||||||
|
} from '@atproto-labs/handle-resolver'
|
||||||
|
|
||||||
|
import {
|
||||||
|
nodeResolveTxtDefault,
|
||||||
|
nodeResolveTxtFactory,
|
||||||
|
} from './node-resolve-txt-factory.js'
|
||||||
|
|
||||||
|
export type AtprotoHandleResolverNodeOptions = {
|
||||||
|
/**
|
||||||
|
* List of backup nameservers to use in case the primary ones fail. Will
|
||||||
|
* default to no fallback nameservers.
|
||||||
|
*/
|
||||||
|
fallbackNameservers?: string[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch function to use for HTTP requests. Allows customizing the request
|
||||||
|
* behavior, e.g. adding headers, setting a timeout, mocking, etc. The
|
||||||
|
* provided fetch function will be wrapped with a safeFetchWrap function that
|
||||||
|
* adds SSRF protection.
|
||||||
|
*
|
||||||
|
* @default `globalThis.fetch`
|
||||||
|
*/
|
||||||
|
fetch?: Fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AtprotoHandleResolverNode
|
||||||
|
extends AtprotoHandleResolver
|
||||||
|
implements HandleResolver
|
||||||
|
{
|
||||||
|
constructor({
|
||||||
|
fetch = globalThis.fetch,
|
||||||
|
fallbackNameservers,
|
||||||
|
}: AtprotoHandleResolverNodeOptions = {}) {
|
||||||
|
super({
|
||||||
|
fetch: safeFetchWrap({
|
||||||
|
fetch,
|
||||||
|
timeout: 3000, // 3 seconds
|
||||||
|
ssrfProtection: true,
|
||||||
|
responseMaxSize: 10 * 1048, // DID are max 2048 characters, 10kb for safety
|
||||||
|
}),
|
||||||
|
resolveTxt: nodeResolveTxtDefault,
|
||||||
|
resolveTxtFallback: fallbackNameservers?.length
|
||||||
|
? nodeResolveTxtFactory(fallbackNameservers)
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
4
packages/internal/handle-resolver-node/src/index.ts
Normal file
4
packages/internal/handle-resolver-node/src/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// Main export
|
||||||
|
export * from './atproto-handle-resolver-node.js'
|
||||||
|
export * from './node-resolve-txt-factory.js'
|
||||||
|
export { AtprotoHandleResolverNode as default } from './atproto-handle-resolver-node.js'
|
@ -0,0 +1,100 @@
|
|||||||
|
import { Resolver, lookup, resolveTxt } from 'node:dns/promises'
|
||||||
|
import { isIP } from 'node:net'
|
||||||
|
|
||||||
|
import { ResolveTxt } from '@atproto-labs/handle-resolver'
|
||||||
|
|
||||||
|
export const nodeResolveTxtDefault: ResolveTxt = (hostname) =>
|
||||||
|
resolveTxt(hostname).then(groupChunks, handleError)
|
||||||
|
|
||||||
|
export function nodeResolveTxtFactory(nameservers: string[]): ResolveTxt {
|
||||||
|
// Optimization
|
||||||
|
if (!nameservers.length) return async () => null
|
||||||
|
|
||||||
|
// Build the resolver asynchronously (will be awaited on every use)
|
||||||
|
const resolverPromise: Promise<Resolver | null> = Promise.all<string[]>(
|
||||||
|
nameservers.map((nameserver) => {
|
||||||
|
const [domain, port = null] = nameserver.split(':', 2)
|
||||||
|
|
||||||
|
if (port !== null && !/^\d+$/.test(port)) {
|
||||||
|
throw new TypeError(`Invalid name server "${nameserver}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return isIP(domain) === 4 || isBracedIPv6(domain)
|
||||||
|
? [nameserver] // No need to lookup
|
||||||
|
: lookup(domain, { all: true }).then(
|
||||||
|
(r) => r.map((a) => appendPort(a.address, port)),
|
||||||
|
// Let's just ignore failed nameservers resolution
|
||||||
|
(_err) => [],
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
).then((results) => {
|
||||||
|
const backupIps = results.flat(1)
|
||||||
|
// No resolver if no valid IP
|
||||||
|
if (!backupIps.length) return null
|
||||||
|
|
||||||
|
const resolver = new Resolver()
|
||||||
|
resolver.setServers(backupIps)
|
||||||
|
return resolver
|
||||||
|
})
|
||||||
|
|
||||||
|
// Avoid uncaught promise rejection
|
||||||
|
void resolverPromise.catch(() => {
|
||||||
|
// Should never happen though...
|
||||||
|
})
|
||||||
|
|
||||||
|
return async (hostname) => {
|
||||||
|
const resolver = await resolverPromise
|
||||||
|
return resolver
|
||||||
|
? resolver.resolveTxt(hostname).then(groupChunks, handleError)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBracedIPv6(address: string): boolean {
|
||||||
|
return (
|
||||||
|
address.startsWith('[') &&
|
||||||
|
address.endsWith(']') &&
|
||||||
|
isIP(address.slice(1, -1)) === 6
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupChunks(results: string[][]): string[] {
|
||||||
|
return results.map((chunks) => chunks.join(''))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError(err: unknown) {
|
||||||
|
// Invalid argument type (e.g. hostname is a number)
|
||||||
|
if (err instanceof TypeError) throw err
|
||||||
|
|
||||||
|
// If the hostname does not resolve, return null
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err['code'] === 'ENOTFOUND') return null
|
||||||
|
|
||||||
|
// Hostname is not a valid domain name
|
||||||
|
if (err['code'] === 'EBADNAME') throw err
|
||||||
|
|
||||||
|
// DNS server unreachable
|
||||||
|
// if (err['code'] === 'ETIMEOUT') throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Historically, errors were not thrown here. A "null" value indicates to the
|
||||||
|
// AtprotoHandleResolver that it should try the fallback resolver.
|
||||||
|
|
||||||
|
// @TODO We might want to re-visit this to only apply when an unexpected error
|
||||||
|
// occurs (by throwing here). For now, let's keep the same behavior as before.
|
||||||
|
|
||||||
|
// throw err
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendPort(address: string, port: string | null): string {
|
||||||
|
switch (isIP(address)) {
|
||||||
|
case 4:
|
||||||
|
return port ? `${address}:${port}` : address
|
||||||
|
case 6:
|
||||||
|
return port ? `[${address}]:${port}` : `[${address}]`
|
||||||
|
default:
|
||||||
|
throw new TypeError(`Invalid IP address "${address}"`)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": ["../../../tsconfig/node.json"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist"
|
||||||
|
},
|
||||||
|
"include": ["./src"]
|
||||||
|
}
|
4
packages/internal/handle-resolver-node/tsconfig.json
Normal file
4
packages/internal/handle-resolver-node/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"include": [],
|
||||||
|
"references": [{ "path": "./tsconfig.build.json" }]
|
||||||
|
}
|
148
packages/internal/handle-resolver/README.md
Normal file
148
packages/internal/handle-resolver/README.md
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
# Universal Handle Resolver implementation for ATPROTO
|
||||||
|
|
||||||
|
This package provides a handle resolver implementation for ATPROTO. It is used
|
||||||
|
to resolve handles to their corresponding DID.
|
||||||
|
|
||||||
|
This package is meant to be used in any JavaScript environment that support the
|
||||||
|
`fetch()` function. Because APTORO handle resolution requires DNS resolution,
|
||||||
|
you will need to provide your own DNS resolution function when using this
|
||||||
|
package.
|
||||||
|
|
||||||
|
There are two main classes in this package:
|
||||||
|
|
||||||
|
- `AtprotoHandleResolver` This implements the official ATPROTO handle resolution
|
||||||
|
algorithm (and requires a DNS resolver).
|
||||||
|
- `AppViewHandleResolver` This uses HTTP requests to the Bluesky AppView
|
||||||
|
(bsky.app) to provide handle resolution.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### From a front-end app
|
||||||
|
|
||||||
|
Since the ATPROTO handle resolution algorithm requires DNS resolution, and the
|
||||||
|
browser does not provide a built-in DNS resolver, this package offers two
|
||||||
|
options:
|
||||||
|
|
||||||
|
- Delegate handle resolution to an AppView (`AppViewHandleResolver`). This is
|
||||||
|
the recommended approach for front-end apps.
|
||||||
|
- Use a DNS-over-HTTPS (DoH) server (`DohHandleResolver`). Prefer this method
|
||||||
|
if you don't own an AppView and already have a DoH server that you trust.
|
||||||
|
|
||||||
|
Using an AppView:
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> Use the Bluesky owned AppView (`https://api.bsky.app/`), or PDS
|
||||||
|
> (`https://bsky.social/`), at your own risk. Using these servers in a
|
||||||
|
> third-party application might expose your users' data (IP address) to Bluesky.
|
||||||
|
> Bluesky might log the data sent to it when your app is resolving handles.
|
||||||
|
> Bluesky might also change the API, or terms or use, at any time without
|
||||||
|
> notice. Make sure you are compliant with the Bluesky terms of use as well as
|
||||||
|
> any laws and regulations that apply to your use case.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { AppViewHandleResolver } from '@atproto-labs/handle-resolver'
|
||||||
|
|
||||||
|
const resolver = new AppViewHandleResolver({
|
||||||
|
service: 'https://my-app-view.com/',
|
||||||
|
})
|
||||||
|
const did = await resolver.resolve('my-handle.bsky.social')
|
||||||
|
```
|
||||||
|
|
||||||
|
Using DNS-over-HTTPS (DoH) for DNS resolution:
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> Using a DoH server that you don't own might expose your users' data to
|
||||||
|
> the DoH server provider. The DoH server provider might log the data sent to it
|
||||||
|
> by your app, allowing them to track which handles are being resolved by your
|
||||||
|
> users. In the browser, it is recommended to use a DoH server that you own and
|
||||||
|
> control. Or to implement your own AppView and use the `AppViewHandleResolver`
|
||||||
|
> class.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Using the `DohHandleResolver` requires a DNS-over-HTTPS server that
|
||||||
|
> supports the DNS-over-HTTPS protocol with "application/dns-json" responses.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { DohHandleResolver } from '@atproto-labs/handle-resolver'
|
||||||
|
|
||||||
|
// Also works with 'https://cloudflare-dns.com/dns-query'
|
||||||
|
const resolver = new DohHandleResolver('https://dns.google/resolve', {
|
||||||
|
// Optional: Custom fetch function that will be used both for DNS resolution
|
||||||
|
// and well-known resolution.
|
||||||
|
fetch: globalThis.fetch.bind(globalThis),
|
||||||
|
})
|
||||||
|
|
||||||
|
const did = await resolver.resolve('my-handle.bsky.social')
|
||||||
|
```
|
||||||
|
|
||||||
|
### From a Node.js app
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> On a Node.js backend, you will probably want to use the
|
||||||
|
> "@atproto-labs/handle-resolver-node" package. The example below applies to
|
||||||
|
> Node.js code running on a user's machine (e.g. through Electron).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { AtprotoHandleResolver } from '@atproto-labs/handle-resolver'
|
||||||
|
import { resolveTxt } from 'node:dns/promises'
|
||||||
|
|
||||||
|
const resolver = new AtprotoHandleResolver({
|
||||||
|
// Optional: Custom fetch function (used for well-known resolution)
|
||||||
|
fetch: globalThis.fetch.bind(globalThis),
|
||||||
|
|
||||||
|
resolveTxt: async (domain: string) =>
|
||||||
|
resolveTxt(domain).then((chunks) => chunks.join('')),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caching
|
||||||
|
|
||||||
|
Using a default, in-memory cache, in which items expire after 10 minutes:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
AppViewHandleResolver,
|
||||||
|
CachedHandleResolver,
|
||||||
|
HandleResolver,
|
||||||
|
HandleCache,
|
||||||
|
} from '@atproto-labs/handle-resolver'
|
||||||
|
|
||||||
|
// See previous examples for creating a resolver
|
||||||
|
declare const sourceResolver: HandleResolver
|
||||||
|
|
||||||
|
const resolver = new CachedHandleResolver(sourceResolver)
|
||||||
|
const did = await resolver.resolve('my-handle.bsky.social')
|
||||||
|
const did = await resolver.resolve('my-handle.bsky.social') // Result from cache
|
||||||
|
const did = await resolver.resolve('my-handle.bsky.social') // Result from cache
|
||||||
|
```
|
||||||
|
|
||||||
|
Using a custom cache:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
AppViewHandleResolver,
|
||||||
|
CachedHandleResolver,
|
||||||
|
HandleResolver,
|
||||||
|
HandleCache,
|
||||||
|
} from '@atproto-labs/handle-resolver'
|
||||||
|
|
||||||
|
// See previous examples for creating a resolver
|
||||||
|
declare const sourceResolver: HandleResolver
|
||||||
|
|
||||||
|
const cache: HandleCache = {
|
||||||
|
set(handle, did): Promise<void> {
|
||||||
|
/* TODO */
|
||||||
|
},
|
||||||
|
get(handle): Promise<undefined | string> {
|
||||||
|
/* TODO */
|
||||||
|
},
|
||||||
|
del(handle): Promise<void> {
|
||||||
|
/* TODO */
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolver = new CachedHandleResolver(sourceResolver, cache)
|
||||||
|
const did = await resolver.resolve('my-handle.bsky.social')
|
||||||
|
const did = await resolver.resolve('my-handle.bsky.social') // Result from cache
|
||||||
|
const did = await resolver.resolve('my-handle.bsky.social') // Result from cache
|
||||||
|
```
|
42
packages/internal/handle-resolver/package.json
Normal file
42
packages/internal/handle-resolver/package.json
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "@atproto-labs/handle-resolver",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "Isomorphic ATProto handle to DID resolver",
|
||||||
|
"keywords": [
|
||||||
|
"atproto",
|
||||||
|
"oauth",
|
||||||
|
"handle",
|
||||||
|
"identity",
|
||||||
|
"browser",
|
||||||
|
"node",
|
||||||
|
"isomorphic"
|
||||||
|
],
|
||||||
|
"homepage": "https://atproto.com",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/bluesky-social/atproto",
|
||||||
|
"directory": "packages/internal/handle-resolver"
|
||||||
|
},
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@atproto-labs/simple-store": "workspace:*",
|
||||||
|
"@atproto-labs/simple-store-memory": "workspace:*",
|
||||||
|
"@atproto/did": "workspace:*",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --build tsconfig.build.json"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,95 @@
|
|||||||
|
import z from 'zod'
|
||||||
|
|
||||||
|
import {
|
||||||
|
HandleResolver,
|
||||||
|
ResolveOptions,
|
||||||
|
ResolvedHandle,
|
||||||
|
isResolvedHandle,
|
||||||
|
} from './types.js'
|
||||||
|
|
||||||
|
export const xrpcErrorSchema = z.object({
|
||||||
|
error: z.string(),
|
||||||
|
message: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AppViewHandleResolverOptions = {
|
||||||
|
/**
|
||||||
|
* Fetch function to use for HTTP requests. Allows customizing the request
|
||||||
|
* behavior, e.g. adding headers, setting a timeout, mocking, etc.
|
||||||
|
*
|
||||||
|
* @default globalThis.fetch
|
||||||
|
*/
|
||||||
|
fetch?: typeof globalThis.fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AppViewHandleResolver implements HandleResolver {
|
||||||
|
static from(
|
||||||
|
service: URL | string | HandleResolver,
|
||||||
|
options?: AppViewHandleResolverOptions,
|
||||||
|
): HandleResolver {
|
||||||
|
if (typeof service === 'string' || service instanceof URL) {
|
||||||
|
return new AppViewHandleResolver(service, options)
|
||||||
|
}
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL of the atproto lexicon server. This is the base URL where the
|
||||||
|
* `com.atproto.identity.resolveHandle` XRPC method is located.
|
||||||
|
*/
|
||||||
|
protected readonly serviceUrl: URL
|
||||||
|
protected readonly fetch: typeof globalThis.fetch
|
||||||
|
|
||||||
|
constructor(service: URL | string, options?: AppViewHandleResolverOptions) {
|
||||||
|
this.serviceUrl = new URL(service)
|
||||||
|
this.fetch = options?.fetch ?? globalThis.fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
public async resolve(
|
||||||
|
handle: string,
|
||||||
|
options?: ResolveOptions,
|
||||||
|
): Promise<ResolvedHandle> {
|
||||||
|
const url = new URL(
|
||||||
|
'/xrpc/com.atproto.identity.resolveHandle',
|
||||||
|
this.serviceUrl,
|
||||||
|
)
|
||||||
|
url.searchParams.set('handle', handle)
|
||||||
|
|
||||||
|
const headers = new Headers()
|
||||||
|
if (options?.noCache) headers.set('cache-control', 'no-cache')
|
||||||
|
|
||||||
|
const response = await this.fetch.call(null, url, {
|
||||||
|
headers,
|
||||||
|
signal: options?.signal,
|
||||||
|
redirect: 'error',
|
||||||
|
})
|
||||||
|
const payload = await response.json()
|
||||||
|
|
||||||
|
// The response should either be
|
||||||
|
// - 400 Bad Request with { error: 'InvalidRequest', message: 'Unable to resolve handle' }
|
||||||
|
// - 200 OK with { did: NonNullable<ResolvedHandle> }
|
||||||
|
// Any other response is considered unexpected behavior an should throw an error.
|
||||||
|
|
||||||
|
if (response.status === 400) {
|
||||||
|
const data = xrpcErrorSchema.parse(payload)
|
||||||
|
if (
|
||||||
|
data.error === 'InvalidRequest' &&
|
||||||
|
data.message === 'Unable to resolve handle'
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new TypeError('Invalid response from resolveHandle method')
|
||||||
|
}
|
||||||
|
|
||||||
|
const value: unknown = payload?.did
|
||||||
|
|
||||||
|
if (!isResolvedHandle(value)) {
|
||||||
|
throw new TypeError('Invalid DID returned from resolveHandle method')
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,123 @@
|
|||||||
|
import {
|
||||||
|
AtprotoHandleResolver,
|
||||||
|
AtprotoHandleResolverOptions,
|
||||||
|
} from './atproto-handle-resolver.js'
|
||||||
|
import { HandleResolver } from './types.js'
|
||||||
|
import { ResolveTxt } from './internal-resolvers/dns-handle-resolver.js'
|
||||||
|
|
||||||
|
export type AtprotoDohHandleResolverOptions = Omit<
|
||||||
|
AtprotoHandleResolverOptions,
|
||||||
|
'resolveTxt' | 'resolveTxtFallback'
|
||||||
|
> & {
|
||||||
|
dohEndpoint: string | URL
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AtprotoDohHandleResolver
|
||||||
|
extends AtprotoHandleResolver
|
||||||
|
implements HandleResolver
|
||||||
|
{
|
||||||
|
constructor(options: AtprotoDohHandleResolverOptions) {
|
||||||
|
super({
|
||||||
|
...options,
|
||||||
|
resolveTxt: dohResolveTxtFactory(options),
|
||||||
|
resolveTxtFallback: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver for DNS-over-HTTPS (DoH) handles. Only works with servers supporting
|
||||||
|
* Google Flavoured "application/dns-json" queries.
|
||||||
|
*
|
||||||
|
* @see {@link https://developers.google.com/speed/public-dns/docs/doh/json}
|
||||||
|
* @see {@link https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json/}
|
||||||
|
* @todo Add support for DoH using application/dns-message (?)
|
||||||
|
*/
|
||||||
|
function dohResolveTxtFactory({
|
||||||
|
dohEndpoint,
|
||||||
|
fetch = globalThis.fetch,
|
||||||
|
}: AtprotoDohHandleResolverOptions): ResolveTxt {
|
||||||
|
return async (hostname) => {
|
||||||
|
const url = new URL(dohEndpoint)
|
||||||
|
url.searchParams.set('type', 'TXT')
|
||||||
|
url.searchParams.set('name', hostname)
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { accept: 'application/dns-json' },
|
||||||
|
redirect: 'follow',
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const contentType = response.headers.get('content-type')?.trim()
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = contentType?.startsWith('text/plain')
|
||||||
|
? await response.text()
|
||||||
|
: `Failed to resolve ${hostname}`
|
||||||
|
throw new TypeError(message)
|
||||||
|
} else if (contentType !== 'application/dns-json') {
|
||||||
|
throw new TypeError('Unexpected response from DoH server')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = asResult(await response.json())
|
||||||
|
return result.Answer?.filter(isAnswerTxt).map(extractTxtData) ?? null
|
||||||
|
} finally {
|
||||||
|
// Make sure to always cancel the response body as some engines (Node 👀)
|
||||||
|
// do not do this automatically.
|
||||||
|
// https://undici.nodejs.org/#/?id=garbage-collection
|
||||||
|
if (response.bodyUsed === false) {
|
||||||
|
// Handle rejection asynchronously
|
||||||
|
void response.body?.cancel().catch(onCancelError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancelError(err: unknown) {
|
||||||
|
if (!(err instanceof DOMException) || err.name !== 'AbortError') {
|
||||||
|
console.error('An error occurred while cancelling the response body:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result = { Status: number; Answer?: Answer[] }
|
||||||
|
function isResult(result: unknown): result is Result {
|
||||||
|
if (typeof result !== 'object' || result === null) return false
|
||||||
|
if (!('Status' in result) || typeof result.Status !== 'number') return false
|
||||||
|
if ('Answer' in result && !isArrayOf(result.Answer, isAnswer)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
function asResult(result: unknown): Result {
|
||||||
|
if (isResult(result)) return result
|
||||||
|
throw new TypeError('Invalid DoH response')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isArrayOf<T>(
|
||||||
|
value: unknown,
|
||||||
|
predicate: (v: unknown) => v is T,
|
||||||
|
): value is T[] {
|
||||||
|
return Array.isArray(value) && value.every(predicate)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Answer = { name: string; type: number; data: string; TTL: number }
|
||||||
|
function isAnswer(answer: unknown): answer is Answer {
|
||||||
|
return (
|
||||||
|
typeof answer === 'object' &&
|
||||||
|
answer !== null &&
|
||||||
|
'name' in answer &&
|
||||||
|
typeof answer.name === 'string' &&
|
||||||
|
'type' in answer &&
|
||||||
|
typeof answer.type === 'number' &&
|
||||||
|
'data' in answer &&
|
||||||
|
typeof answer.data === 'string' &&
|
||||||
|
'TTL' in answer &&
|
||||||
|
typeof answer.TTL === 'number'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnswerTxt = Answer & { type: 16 }
|
||||||
|
function isAnswerTxt(answer: Answer): answer is AnswerTxt {
|
||||||
|
return answer.type === 16
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTxtData(answer: AnswerTxt): string {
|
||||||
|
return answer.data.replace(/^"|"$/g, '').replace(/\\"/g, '"')
|
||||||
|
}
|
@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
DnsHandleResolver,
|
||||||
|
ResolveTxt,
|
||||||
|
} from './internal-resolvers/dns-handle-resolver.js'
|
||||||
|
import {
|
||||||
|
WellKnownHandleResolver,
|
||||||
|
WellKnownHandleResolverOptions,
|
||||||
|
} from './internal-resolvers/well-known-handler-resolver.js'
|
||||||
|
import { HandleResolver, ResolveOptions, ResolvedHandle } from './types.js'
|
||||||
|
|
||||||
|
export type { ResolveTxt }
|
||||||
|
export type AtprotoHandleResolverOptions = WellKnownHandleResolverOptions & {
|
||||||
|
resolveTxt: ResolveTxt
|
||||||
|
resolveTxtFallback?: ResolveTxt
|
||||||
|
}
|
||||||
|
|
||||||
|
const noop = () => {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of the official ATPROTO handle resolution strategy.
|
||||||
|
* This implementation relies on two primitives:
|
||||||
|
* - HTTP Well-Known URI resolution (requires a `fetch()` implementation)
|
||||||
|
* - DNS TXT record resolution (requires a `resolveTxt()` function)
|
||||||
|
*/
|
||||||
|
export class AtprotoHandleResolver implements HandleResolver {
|
||||||
|
private readonly httpResolver: HandleResolver
|
||||||
|
private readonly dnsResolver: HandleResolver
|
||||||
|
private readonly dnsResolverFallback?: HandleResolver
|
||||||
|
|
||||||
|
constructor(options: AtprotoHandleResolverOptions) {
|
||||||
|
this.httpResolver = new WellKnownHandleResolver(options)
|
||||||
|
this.dnsResolver = new DnsHandleResolver(options.resolveTxt)
|
||||||
|
this.dnsResolverFallback = options.resolveTxtFallback
|
||||||
|
? new DnsHandleResolver(options.resolveTxtFallback)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolve(
|
||||||
|
handle: string,
|
||||||
|
options?: ResolveOptions,
|
||||||
|
): Promise<ResolvedHandle> {
|
||||||
|
options?.signal?.throwIfAborted()
|
||||||
|
|
||||||
|
const abortController = new AbortController()
|
||||||
|
const { signal } = abortController
|
||||||
|
options?.signal?.addEventListener('abort', () => abortController.abort(), {
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrappedOptions = { ...options, signal }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dnsPromise = this.dnsResolver.resolve(handle, wrappedOptions)
|
||||||
|
const httpPromise = this.httpResolver.resolve(handle, wrappedOptions)
|
||||||
|
|
||||||
|
// Prevent uncaught promise rejection
|
||||||
|
httpPromise.catch(noop)
|
||||||
|
|
||||||
|
const dnsRes = await dnsPromise
|
||||||
|
if (dnsRes) return dnsRes
|
||||||
|
|
||||||
|
signal.throwIfAborted()
|
||||||
|
|
||||||
|
const res = await httpPromise
|
||||||
|
if (res) return res
|
||||||
|
|
||||||
|
signal.throwIfAborted()
|
||||||
|
|
||||||
|
return this.dnsResolverFallback?.resolve(handle, wrappedOptions) ?? null
|
||||||
|
} finally {
|
||||||
|
// Cancel pending requests, and remove "abort" listener on incoming signal
|
||||||
|
abortController.abort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,32 @@
|
|||||||
|
import { CachedGetter, SimpleStore } from '@atproto-labs/simple-store'
|
||||||
|
import { SimpleStoreMemory } from '@atproto-labs/simple-store-memory'
|
||||||
|
import { ResolveOptions, HandleResolver, ResolvedHandle } from './types.js'
|
||||||
|
|
||||||
|
export type HandleCache = SimpleStore<string, ResolvedHandle>
|
||||||
|
|
||||||
|
export class CachedHandleResolver implements HandleResolver {
|
||||||
|
private getter: CachedGetter<string, ResolvedHandle>
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
/**
|
||||||
|
* The resolver that will be used to resolve handles.
|
||||||
|
*/
|
||||||
|
resolver: HandleResolver,
|
||||||
|
cache: HandleCache = new SimpleStoreMemory<string, ResolvedHandle>({
|
||||||
|
max: 1000,
|
||||||
|
ttl: 10 * 60e3,
|
||||||
|
}),
|
||||||
|
) {
|
||||||
|
this.getter = new CachedGetter<string, ResolvedHandle>(
|
||||||
|
(handle, options) => resolver.resolve(handle, options),
|
||||||
|
cache,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolve(
|
||||||
|
handle: string,
|
||||||
|
options?: ResolveOptions,
|
||||||
|
): Promise<ResolvedHandle> {
|
||||||
|
return this.getter.get(handle, options)
|
||||||
|
}
|
||||||
|
}
|
9
packages/internal/handle-resolver/src/index.ts
Normal file
9
packages/internal/handle-resolver/src/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export * from './types.js'
|
||||||
|
|
||||||
|
// Main Handle Resolvers strategies
|
||||||
|
export * from './app-view-handle-resolver.js'
|
||||||
|
export * from './atproto-doh-handle-resolver.js'
|
||||||
|
export * from './atproto-handle-resolver.js'
|
||||||
|
|
||||||
|
// Handle Resolver Caching utility
|
||||||
|
export * from './cached-handle-resolver.js'
|
@ -0,0 +1,38 @@
|
|||||||
|
import { HandleResolver, ResolvedHandle, isResolvedHandle } from '../types'
|
||||||
|
|
||||||
|
const SUBDOMAIN = '_atproto'
|
||||||
|
const PREFIX = 'did='
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DNS TXT record resolver. Return `null` if the hostname successfully does not
|
||||||
|
* resolve to a valid DID. Throw an error if an unexpected error occurs.
|
||||||
|
*/
|
||||||
|
export type ResolveTxt = (hostname: string) => Promise<null | string[]>
|
||||||
|
|
||||||
|
export class DnsHandleResolver implements HandleResolver {
|
||||||
|
constructor(protected resolveTxt: ResolveTxt) {}
|
||||||
|
|
||||||
|
async resolve(handle: string): Promise<ResolvedHandle> {
|
||||||
|
const results = await this.resolveTxt.call(null, `${SUBDOMAIN}.${handle}`)
|
||||||
|
|
||||||
|
if (!results) return null
|
||||||
|
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
|
// If the line does not start with "did=", skip it
|
||||||
|
if (!results[i].startsWith(PREFIX)) continue
|
||||||
|
|
||||||
|
// Ensure no other entry starting with "did=" follows
|
||||||
|
for (let j = i + 1; j < results.length; j++) {
|
||||||
|
if (results[j].startsWith(PREFIX)) return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: No trimming (to be consistent with spec)
|
||||||
|
const did = results[i].slice(PREFIX.length)
|
||||||
|
|
||||||
|
// Invalid DBS record
|
||||||
|
return isResolvedHandle(did) ? did : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
ResolveOptions,
|
||||||
|
HandleResolver,
|
||||||
|
ResolvedHandle,
|
||||||
|
isResolvedHandle,
|
||||||
|
} from '../types.js'
|
||||||
|
|
||||||
|
export type WellKnownHandleResolverOptions = {
|
||||||
|
/**
|
||||||
|
* Fetch function to use for HTTP requests. Allows customizing the request
|
||||||
|
* behavior, e.g. adding headers, setting a timeout, mocking, etc. The
|
||||||
|
* provided fetch function will be wrapped with a safeFetchWrap function that
|
||||||
|
* adds SSRF protection.
|
||||||
|
*
|
||||||
|
* @default `globalThis.fetch`
|
||||||
|
*/
|
||||||
|
fetch?: typeof globalThis.fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WellKnownHandleResolver implements HandleResolver {
|
||||||
|
protected readonly fetch: typeof globalThis.fetch
|
||||||
|
|
||||||
|
constructor(options?: WellKnownHandleResolverOptions) {
|
||||||
|
this.fetch = options?.fetch ?? globalThis.fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
public async resolve(
|
||||||
|
handle: string,
|
||||||
|
options?: ResolveOptions,
|
||||||
|
): Promise<ResolvedHandle> {
|
||||||
|
const url = new URL('/.well-known/atproto-did', `https://${handle}`)
|
||||||
|
|
||||||
|
const headers = new Headers()
|
||||||
|
if (options?.noCache) headers.set('cache-control', 'no-cache')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.fetch.call(null, url, {
|
||||||
|
headers,
|
||||||
|
signal: options?.signal,
|
||||||
|
redirect: 'error',
|
||||||
|
})
|
||||||
|
const text = await response.text()
|
||||||
|
const firstLine = text.split('\n')[0]!.trim()
|
||||||
|
|
||||||
|
if (isResolvedHandle(firstLine)) return firstLine
|
||||||
|
|
||||||
|
return null
|
||||||
|
} catch (err) {
|
||||||
|
// The the request failed, assume the handle does not resolve to a DID,
|
||||||
|
// unless the failure was due to the signal being aborted.
|
||||||
|
options?.signal?.throwIfAborted()
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
33
packages/internal/handle-resolver/src/types.ts
Normal file
33
packages/internal/handle-resolver/src/types.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { Did, isAtprotoDidWeb, isDidPlc } from '@atproto/did'
|
||||||
|
|
||||||
|
export type ResolveOptions = {
|
||||||
|
signal?: AbortSignal
|
||||||
|
noCache?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see {@link https://atproto.com/specs/did#blessed-did-methods}
|
||||||
|
*/
|
||||||
|
export type ResolvedHandle = null | Did<'plc' | 'web'>
|
||||||
|
|
||||||
|
export { type Did }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see {@link https://atproto.com/specs/did#blessed-did-methods}
|
||||||
|
*/
|
||||||
|
export function isResolvedHandle<T = unknown>(
|
||||||
|
value: T,
|
||||||
|
): value is T & ResolvedHandle {
|
||||||
|
return value === null || isDidPlc(value) || isAtprotoDidWeb(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HandleResolver {
|
||||||
|
/**
|
||||||
|
* @returns the DID that corresponds to the given handle, or `null` if no DID
|
||||||
|
* is found. `null` should only be returned if no unexpected behavior occurred
|
||||||
|
* during the resolution process.
|
||||||
|
* @throws Error if the resolution method fails due to an unexpected error, or
|
||||||
|
* if the resolution is aborted ({@link ResolveOptions}).
|
||||||
|
*/
|
||||||
|
resolve(handle: string, options?: ResolveOptions): Promise<ResolvedHandle>
|
||||||
|
}
|
8
packages/internal/handle-resolver/tsconfig.build.json
Normal file
8
packages/internal/handle-resolver/tsconfig.build.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig/isomorphic.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist"
|
||||||
|
},
|
||||||
|
"include": ["./src"]
|
||||||
|
}
|
4
packages/internal/handle-resolver/tsconfig.json
Normal file
4
packages/internal/handle-resolver/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"include": [],
|
||||||
|
"references": [{ "path": "./tsconfig.build.json" }]
|
||||||
|
}
|
38
packages/internal/identity-resolver/package.json
Normal file
38
packages/internal/identity-resolver/package.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "@atproto-labs/identity-resolver",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "A library resolving ATPROTO identities",
|
||||||
|
"keywords": [
|
||||||
|
"atproto",
|
||||||
|
"identity",
|
||||||
|
"isomorphic",
|
||||||
|
"resolver"
|
||||||
|
],
|
||||||
|
"homepage": "https://atproto.com",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/bluesky-social/atproto",
|
||||||
|
"directory": "packages/internal/identity-resolver"
|
||||||
|
},
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@atproto-labs/did-resolver": "workspace:*",
|
||||||
|
"@atproto-labs/handle-resolver": "workspace:*",
|
||||||
|
"@atproto/syntax": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --build tsconfig.json"
|
||||||
|
}
|
||||||
|
}
|
78
packages/internal/identity-resolver/src/identity-resolver.ts
Normal file
78
packages/internal/identity-resolver/src/identity-resolver.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
Did,
|
||||||
|
DidDocument,
|
||||||
|
ResolveOptions as DidResolveOptions,
|
||||||
|
DidResolver,
|
||||||
|
DidService,
|
||||||
|
} from '@atproto-labs/did-resolver'
|
||||||
|
import {
|
||||||
|
ResolveOptions as HandleResolveOptions,
|
||||||
|
HandleResolver,
|
||||||
|
ResolvedHandle,
|
||||||
|
isResolvedHandle,
|
||||||
|
} from '@atproto-labs/handle-resolver'
|
||||||
|
import { normalizeAndEnsureValidHandle } from '@atproto/syntax'
|
||||||
|
|
||||||
|
export type ResolvedIdentity = {
|
||||||
|
did: NonNullable<ResolvedHandle>
|
||||||
|
pds: URL
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResolveOptions = DidResolveOptions & HandleResolveOptions
|
||||||
|
|
||||||
|
export class IdentityResolver {
|
||||||
|
constructor(
|
||||||
|
readonly didResolver: DidResolver<'plc' | 'web'>,
|
||||||
|
readonly handleResolver: HandleResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async resolve(
|
||||||
|
input: string,
|
||||||
|
options?: ResolveOptions,
|
||||||
|
): Promise<ResolvedIdentity> {
|
||||||
|
const did = isResolvedHandle(input)
|
||||||
|
? input // Already a did
|
||||||
|
: await this.handleResolver.resolve(
|
||||||
|
normalizeAndEnsureValidHandle(input),
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
|
||||||
|
options?.signal?.throwIfAborted()
|
||||||
|
|
||||||
|
if (!did) {
|
||||||
|
throw new TypeError(`Handle "${input}" does not resolve to a DID`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await this.didResolver.resolve(did, options)
|
||||||
|
|
||||||
|
const service = document.service?.find(
|
||||||
|
isAtprotoPersonalDataServerService<'plc' | 'web'>,
|
||||||
|
document,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!service) {
|
||||||
|
throw new TypeError(
|
||||||
|
`No valid "AtprotoPersonalDataServer" service found in "${did}" DID document`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pds = new URL(service.serviceEndpoint)
|
||||||
|
|
||||||
|
return { did, pds }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAtprotoPersonalDataServerService<M extends string>(
|
||||||
|
this: DidDocument<M>,
|
||||||
|
s: DidService,
|
||||||
|
): s is {
|
||||||
|
id: '#atproto_pds' | `${Did<M>}#atproto_pds`
|
||||||
|
type: 'AtprotoPersonalDataServer'
|
||||||
|
serviceEndpoint: string
|
||||||
|
} {
|
||||||
|
return (
|
||||||
|
typeof s.serviceEndpoint === 'string' &&
|
||||||
|
s.type === 'AtprotoPersonalDataServer' &&
|
||||||
|
(s.id === '#atproto_pds' || s.id === `${this.id}#atproto_pds`)
|
||||||
|
)
|
||||||
|
}
|
1
packages/internal/identity-resolver/src/index.ts
Normal file
1
packages/internal/identity-resolver/src/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './identity-resolver.js'
|
8
packages/internal/identity-resolver/tsconfig.build.json
Normal file
8
packages/internal/identity-resolver/tsconfig.build.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": ["../../../tsconfig/isomorphic.json"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
4
packages/internal/identity-resolver/tsconfig.json
Normal file
4
packages/internal/identity-resolver/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"include": [],
|
||||||
|
"references": [{ "path": "./tsconfig.build.json" }]
|
||||||
|
}
|
32
packages/internal/pipe/package.json
Normal file
32
packages/internal/pipe/package.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@atproto-labs/pipe",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "Library for combining multiple functions into a single function.",
|
||||||
|
"keywords": [
|
||||||
|
"atproto",
|
||||||
|
"transformer"
|
||||||
|
],
|
||||||
|
"homepage": "https://atproto.com",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/bluesky-social/atproto",
|
||||||
|
"directory": "packages/internal/pipe"
|
||||||
|
},
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --build tsconfig.json"
|
||||||
|
}
|
||||||
|
}
|
2
packages/internal/pipe/src/index.ts
Normal file
2
packages/internal/pipe/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { pipe, pipeTwo } from './pipe.js'
|
||||||
|
export { type Transformer } from './transformer.js'
|
63
packages/internal/pipe/src/pipe.ts
Normal file
63
packages/internal/pipe/src/pipe.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { Transformer } from './transformer.js'
|
||||||
|
|
||||||
|
type PipelineInput<T extends readonly Transformer<any>[]> = T extends [
|
||||||
|
Transformer<infer I, any>,
|
||||||
|
...any[],
|
||||||
|
]
|
||||||
|
? I
|
||||||
|
: T extends Transformer<infer I, any>[]
|
||||||
|
? I
|
||||||
|
: never
|
||||||
|
|
||||||
|
type PipelineOutput<T extends readonly Transformer<any>[]> = T extends [
|
||||||
|
...any[],
|
||||||
|
Transformer<any, infer O>,
|
||||||
|
]
|
||||||
|
? O
|
||||||
|
: T extends Transformer<any, infer O>[]
|
||||||
|
? O
|
||||||
|
: never
|
||||||
|
|
||||||
|
type Pipeline<
|
||||||
|
F extends readonly Transformer<any>[],
|
||||||
|
Acc extends readonly Transformer<any>[] = [],
|
||||||
|
> = F extends [Transformer<infer I, infer O>]
|
||||||
|
? [...Acc, Transformer<I, O>]
|
||||||
|
: F extends [Transformer<infer A, any>, ...infer Tail]
|
||||||
|
? Tail extends [Transformer<infer B, any>, ...any[]]
|
||||||
|
? Pipeline<Tail, [...Acc, Transformer<A, B>]>
|
||||||
|
: Acc
|
||||||
|
: Acc
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This utility function allows to properly type a pipeline of transformers.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Will be typed as "(input: string) => Promise<number>"
|
||||||
|
* const parse = pipe(
|
||||||
|
* async (input: string) => JSON.parse(input),
|
||||||
|
* async (input: unknown) => {
|
||||||
|
* if (typeof input === 'number') return input
|
||||||
|
* throw new TypeError('Invalid input')
|
||||||
|
* },
|
||||||
|
* (input: number) => input * 2,
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function pipe(): never
|
||||||
|
export function pipe<T extends readonly Transformer<any>[]>(
|
||||||
|
...pipeline: Pipeline<T> extends T ? T : Pipeline<T>
|
||||||
|
): (input: PipelineInput<T>) => Promise<PipelineOutput<T>>
|
||||||
|
export function pipe<T extends readonly Transformer<any>[]>(
|
||||||
|
...pipeline: Pipeline<T> extends T ? T : Pipeline<T>
|
||||||
|
): (input: PipelineInput<T>) => Promise<PipelineOutput<T>> {
|
||||||
|
return pipeline.reduce(pipeTwo)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pipeTwo<I, O, X = unknown>(
|
||||||
|
first: Transformer<I, X>,
|
||||||
|
second: Transformer<X, O>,
|
||||||
|
): (input: I) => Promise<O> {
|
||||||
|
return async (input) => second(await first(input))
|
||||||
|
}
|
1
packages/internal/pipe/src/transformer.ts
Normal file
1
packages/internal/pipe/src/transformer.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export type Transformer<I, O = I> = (input: I) => O | PromiseLike<O>
|
8
packages/internal/pipe/tsconfig.build.json
Normal file
8
packages/internal/pipe/tsconfig.build.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": ["../../../tsconfig/isomorphic.json"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
4
packages/internal/pipe/tsconfig.json
Normal file
4
packages/internal/pipe/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"include": [],
|
||||||
|
"references": [{ "path": "./tsconfig.build.json" }]
|
||||||
|
}
|
99
packages/internal/rollup-plugin-bundle-manifest/README.md
Normal file
99
packages/internal/rollup-plugin-bundle-manifest/README.md
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
# @atproto-labs/rollup-plugin-bundle-manifest
|
||||||
|
|
||||||
|
This Rollup plugin allows to generate a (JSON) manifest containing the output
|
||||||
|
files of a Rollup build. The manifest will look as follows:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"main.js": {
|
||||||
|
"type": "chunk",
|
||||||
|
"mime": "application/javascript",
|
||||||
|
"dynamicImports": [],
|
||||||
|
"isDynamicEntry": false,
|
||||||
|
"isEntry": true,
|
||||||
|
"isImplicitEntry": false,
|
||||||
|
"name": "main",
|
||||||
|
"sha256": "<sha256-hash>",
|
||||||
|
"data": "<base64-encoded-contents>"
|
||||||
|
},
|
||||||
|
"main.js.map": {
|
||||||
|
"type": "asset",
|
||||||
|
"mime": "application/json",
|
||||||
|
"sha256": "<sha256-hash>",
|
||||||
|
"data": "<base64-encoded-contents>"
|
||||||
|
},
|
||||||
|
"main.css": {
|
||||||
|
"type": "asset",
|
||||||
|
"mime": "text/css",
|
||||||
|
"sha256": "<sha256-hash>",
|
||||||
|
"data": "<base64-encoded-contents>"
|
||||||
|
}
|
||||||
|
// ... more entries as needed
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This manifest will typically be useful for a backend service that serves the
|
||||||
|
frontend assets, as it can be used to determine the correct `Content-Type` and
|
||||||
|
and file integrity (via the SHA-256 hash), without having to read the files
|
||||||
|
themselves.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```js
|
||||||
|
// rollup.config.js
|
||||||
|
|
||||||
|
import bundleManifest from '@atproto-labs/rollup-plugin-bundle-manifest'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
input: 'src/index.js',
|
||||||
|
output: {
|
||||||
|
dir: 'dist',
|
||||||
|
format: 'es',
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
bundleManifest({
|
||||||
|
name: 'bundle-manifest.json',
|
||||||
|
|
||||||
|
// Optional: should the asset data be embedded (as base64 string) in the manifest?
|
||||||
|
data: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
- `name` (string): The name of the manifest file. Defaults to `bundle-manifest.json`.
|
||||||
|
- `data` (boolean): Whether to embed the asset data in the manifest. Defaults to `false`.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```js
|
||||||
|
const assetManifest = require('./dist/bundle-manifest.json')
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const asset = assetManifest[req.path.slice(1)]
|
||||||
|
if (!asset) return next()
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', asset.mime)
|
||||||
|
res.setHeader('Content-Length', asset.data.length)
|
||||||
|
|
||||||
|
res.end(Buffer.from(asset.data, 'base64'))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Security-Policy',
|
||||||
|
buildCSP(assetManifest), // Not provided here
|
||||||
|
)
|
||||||
|
|
||||||
|
// Serve the index.html file
|
||||||
|
res.sendFile('index.html')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
39
packages/internal/rollup-plugin-bundle-manifest/package.json
Normal file
39
packages/internal/rollup-plugin-bundle-manifest/package.json
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "@atproto-labs/rollup-plugin-bundle-manifest",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "Library for generating a manifest of bundled files from a Rollup build",
|
||||||
|
"keywords": [
|
||||||
|
"atproto",
|
||||||
|
"rollup",
|
||||||
|
"manifest"
|
||||||
|
],
|
||||||
|
"homepage": "https://atproto.com",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/bluesky-social/atproto",
|
||||||
|
"directory": "packages/internal/rollup-plugin-bundle-manifest"
|
||||||
|
},
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"mime": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"rollup": "^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"rollup": "^4.10.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --build tsconfig.json"
|
||||||
|
}
|
||||||
|
}
|
76
packages/internal/rollup-plugin-bundle-manifest/src/index.ts
Normal file
76
packages/internal/rollup-plugin-bundle-manifest/src/index.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { extname } from 'node:path'
|
||||||
|
|
||||||
|
import mime from 'mime'
|
||||||
|
import { Plugin } from 'rollup'
|
||||||
|
|
||||||
|
type AssetItem = {
|
||||||
|
type: 'asset'
|
||||||
|
mime?: string
|
||||||
|
sha256: string
|
||||||
|
data?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChunkItem = {
|
||||||
|
type: 'chunk'
|
||||||
|
mime: string
|
||||||
|
sha256: string
|
||||||
|
dynamicImports: string[]
|
||||||
|
isDynamicEntry: boolean
|
||||||
|
isEntry: boolean
|
||||||
|
isImplicitEntry: boolean
|
||||||
|
name: string
|
||||||
|
data?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ManifestItem = AssetItem | ChunkItem
|
||||||
|
|
||||||
|
export type Manifest = Record<string, ManifestItem>
|
||||||
|
|
||||||
|
export default function bundleManifest({
|
||||||
|
name = 'bundle-manifest.json',
|
||||||
|
data = false,
|
||||||
|
}: {
|
||||||
|
name?: string
|
||||||
|
data?: boolean
|
||||||
|
} = {}): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'bundle-manifest',
|
||||||
|
generateBundle(outputOptions, bundle) {
|
||||||
|
const manifest: Manifest = {}
|
||||||
|
|
||||||
|
for (const [fileName, chunk] of Object.entries(bundle)) {
|
||||||
|
if (chunk.type === 'asset') {
|
||||||
|
manifest[fileName] = {
|
||||||
|
type: chunk.type,
|
||||||
|
data: data
|
||||||
|
? Buffer.from(chunk.source).toString('base64')
|
||||||
|
: undefined,
|
||||||
|
mime: mime.getType(extname(fileName)) || undefined,
|
||||||
|
sha256: createHash('sha256').update(chunk.source).digest('base64'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunk.type === 'chunk') {
|
||||||
|
manifest[fileName] = {
|
||||||
|
type: chunk.type,
|
||||||
|
data: data ? Buffer.from(chunk.code).toString('base64') : undefined,
|
||||||
|
mime: 'application/javascript',
|
||||||
|
sha256: createHash('sha256').update(chunk.code).digest('base64'),
|
||||||
|
dynamicImports: chunk.dynamicImports,
|
||||||
|
isDynamicEntry: chunk.isDynamicEntry,
|
||||||
|
isEntry: chunk.isEntry,
|
||||||
|
isImplicitEntry: chunk.isImplicitEntry,
|
||||||
|
name: chunk.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitFile({
|
||||||
|
type: 'asset',
|
||||||
|
fileName: name,
|
||||||
|
source: JSON.stringify(manifest, null, 2),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": ["../../../tsconfig/node.json"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"include": [],
|
||||||
|
"references": [{ "path": "./tsconfig.build.json" }]
|
||||||
|
}
|
36
packages/internal/simple-store-memory/package.json
Normal file
36
packages/internal/simple-store-memory/package.json
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "@atproto-labs/simple-store-memory",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "Memory based simple-store implementation",
|
||||||
|
"keywords": [
|
||||||
|
"cache",
|
||||||
|
"isomorphic",
|
||||||
|
"memory"
|
||||||
|
],
|
||||||
|
"homepage": "https://atproto.com",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/bluesky-social/atproto",
|
||||||
|
"directory": "packages/internal/simple-store-memory"
|
||||||
|
},
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@atproto-labs/simple-store": "workspace:*",
|
||||||
|
"lru-cache": "^10.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --build tsconfig.build.json"
|
||||||
|
}
|
||||||
|
}
|
99
packages/internal/simple-store-memory/src/index.ts
Normal file
99
packages/internal/simple-store-memory/src/index.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { SimpleStore, Key, Value } from '@atproto-labs/simple-store'
|
||||||
|
import { LRUCache } from 'lru-cache'
|
||||||
|
|
||||||
|
import { roughSizeOfObject } from './util.js'
|
||||||
|
|
||||||
|
export type SimpleStoreMemoryOptions<K extends Key, V extends Value> = {
|
||||||
|
/**
|
||||||
|
* The maximum number of entries in the cache.
|
||||||
|
*/
|
||||||
|
max?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time-to-live of a cache entry, in milliseconds.
|
||||||
|
*/
|
||||||
|
ttl?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to automatically prune expired entries.
|
||||||
|
*/
|
||||||
|
ttlAutopurge?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum total size of the cache, in units defined by the sizeCalculation
|
||||||
|
* function.
|
||||||
|
*
|
||||||
|
* @default No limit
|
||||||
|
*/
|
||||||
|
maxSize?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum size of a single cache entry, in units defined by the
|
||||||
|
* sizeCalculation function.
|
||||||
|
*
|
||||||
|
* @default No limit
|
||||||
|
*/
|
||||||
|
maxEntrySize?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function that returns the size of a value. The size is used to determine
|
||||||
|
* when the cache should be pruned, based on `maxSize`.
|
||||||
|
*
|
||||||
|
* @default The (rough) size in bytes used in memory.
|
||||||
|
*/
|
||||||
|
sizeCalculation?: (value: V, key: K) => number
|
||||||
|
} & ( // Memory is not infinite, so at least one pruning option is required.
|
||||||
|
| { max: number }
|
||||||
|
| { maxSize: number }
|
||||||
|
| { ttl: number; ttlAutopurge: boolean }
|
||||||
|
)
|
||||||
|
|
||||||
|
// LRUCache does not allow storing "null", so we use a symbol to represent it.
|
||||||
|
const nullSymbol = Symbol('nullItem')
|
||||||
|
type AsLruValue<V extends Value> = V extends null
|
||||||
|
? typeof nullSymbol
|
||||||
|
: Exclude<V, null>
|
||||||
|
const toLruValue = <V extends Value>(value: V) =>
|
||||||
|
(value === null ? nullSymbol : value) as AsLruValue<V>
|
||||||
|
const fromLruValue = <V extends Value>(value: AsLruValue<V>) =>
|
||||||
|
(value === nullSymbol ? null : value) as V
|
||||||
|
|
||||||
|
export class SimpleStoreMemory<K extends Key, V extends Value>
|
||||||
|
implements SimpleStore<K, V>
|
||||||
|
{
|
||||||
|
#cache: LRUCache<K, AsLruValue<V>>
|
||||||
|
|
||||||
|
constructor({ sizeCalculation, ...options }: SimpleStoreMemoryOptions<K, V>) {
|
||||||
|
this.#cache = new LRUCache<K, AsLruValue<V>>({
|
||||||
|
...options,
|
||||||
|
allowStale: false,
|
||||||
|
updateAgeOnGet: false,
|
||||||
|
updateAgeOnHas: false,
|
||||||
|
sizeCalculation: sizeCalculation
|
||||||
|
? (value, key) => sizeCalculation(fromLruValue(value), key)
|
||||||
|
: options.maxEntrySize != null || options.maxSize != null
|
||||||
|
? // maxEntrySize and maxSize require a size calculation function.
|
||||||
|
roughSizeOfObject
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: K): V | undefined {
|
||||||
|
const value = this.#cache.get(key)
|
||||||
|
if (value === undefined) return undefined
|
||||||
|
|
||||||
|
return fromLruValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: K, value: V): void {
|
||||||
|
this.#cache.set(key, toLruValue(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
del(key: K): void {
|
||||||
|
this.#cache.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.#cache.clear()
|
||||||
|
}
|
||||||
|
}
|
77
packages/internal/simple-store-memory/src/util.ts
Normal file
77
packages/internal/simple-store-memory/src/util.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
const knownSizes = new WeakMap<object, number>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see {@link https://stackoverflow.com/a/11900218/356537}
|
||||||
|
*/
|
||||||
|
export function roughSizeOfObject(value: unknown): number {
|
||||||
|
const objectList = new Set()
|
||||||
|
const stack = [value] // This would be more efficient using a circular buffer
|
||||||
|
let bytes = 0
|
||||||
|
|
||||||
|
while (stack.length) {
|
||||||
|
const value = stack.pop()
|
||||||
|
|
||||||
|
// > All objects on the heap start with a shape descriptor, which takes one
|
||||||
|
// > pointer size (usually 4 bytes these days, thanks to "pointer
|
||||||
|
// > compression" on 64-bit platforms).
|
||||||
|
|
||||||
|
switch (typeof value) {
|
||||||
|
// Types are ordered by frequency
|
||||||
|
case 'string':
|
||||||
|
// https://stackoverflow.com/a/68791382/356537
|
||||||
|
bytes += 12 + 4 * Math.ceil(value.length / 4)
|
||||||
|
break
|
||||||
|
case 'number':
|
||||||
|
bytes += 12 // Shape descriptor + double
|
||||||
|
break
|
||||||
|
case 'boolean':
|
||||||
|
bytes += 4 // Shape descriptor
|
||||||
|
break
|
||||||
|
case 'object':
|
||||||
|
bytes += 4 // Shape descriptor
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (knownSizes.has(value)) {
|
||||||
|
bytes += knownSizes.get(value)!
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (objectList.has(value)) continue
|
||||||
|
objectList.add(value)
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
bytes += 4
|
||||||
|
stack.push(...value)
|
||||||
|
} else {
|
||||||
|
bytes += 8
|
||||||
|
const keys = Object.getOwnPropertyNames(value)
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
bytes += 4
|
||||||
|
const key = keys[i]
|
||||||
|
const val = value[key]
|
||||||
|
if (val !== undefined) stack.push(val)
|
||||||
|
stack.push(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'function':
|
||||||
|
bytes += 8 // Shape descriptor + pointer (assuming functions are shared)
|
||||||
|
break
|
||||||
|
case 'symbol':
|
||||||
|
bytes += 8 // Shape descriptor + pointer
|
||||||
|
break
|
||||||
|
case 'bigint':
|
||||||
|
bytes += 16 // Shape descriptor + BigInt
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
knownSizes.set(value, bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig/isomorphic.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist"
|
||||||
|
},
|
||||||
|
"include": ["./src"]
|
||||||
|
}
|
4
packages/internal/simple-store-memory/tsconfig.json
Normal file
4
packages/internal/simple-store-memory/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"include": [],
|
||||||
|
"references": [{ "path": "./tsconfig.build.json" }]
|
||||||
|
}
|
32
packages/internal/simple-store/package.json
Normal file
32
packages/internal/simple-store/package.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@atproto-labs/simple-store",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "Simple store interfaces & utilities",
|
||||||
|
"keywords": [
|
||||||
|
"cache",
|
||||||
|
"isomorphic"
|
||||||
|
],
|
||||||
|
"homepage": "https://atproto.com",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/bluesky-social/atproto",
|
||||||
|
"directory": "packages/internal/simple-store"
|
||||||
|
},
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --build tsconfig.build.json"
|
||||||
|
}
|
||||||
|
}
|
160
packages/internal/simple-store/src/cached-getter.ts
Normal file
160
packages/internal/simple-store/src/cached-getter.ts
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import { Awaitable, SimpleStore, Key, Value } from './simple-store.js'
|
||||||
|
|
||||||
|
export type GetCachedOptions = {
|
||||||
|
signal?: AbortSignal
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Do not use the cache to get the value. Always get a new value from the
|
||||||
|
* getter function.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
noCache?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When getting a value from the cache, allow the value to be returned even if
|
||||||
|
* it is stale.
|
||||||
|
*
|
||||||
|
* Has no effect if the `isStale` option was not provided to the CachedGetter.
|
||||||
|
*
|
||||||
|
* @default true // If the CachedGetter has an isStale option
|
||||||
|
* @default false // If no isStale option was provided to the CachedGetter
|
||||||
|
*/
|
||||||
|
allowStale?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Getter<K, V> = (
|
||||||
|
key: K,
|
||||||
|
options: undefined | GetCachedOptions,
|
||||||
|
storedValue: undefined | V,
|
||||||
|
) => Awaitable<V>
|
||||||
|
|
||||||
|
export type CachedGetterOptions<K, V> = {
|
||||||
|
isStale?: (key: K, value: V) => boolean | PromiseLike<boolean>
|
||||||
|
onStoreError?: (err: unknown, key: K, value: V) => void | PromiseLike<void>
|
||||||
|
deleteOnError?: (
|
||||||
|
err: unknown,
|
||||||
|
key: K,
|
||||||
|
value: V,
|
||||||
|
) => boolean | PromiseLike<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingItem<V> = Promise<{ value: V; isFresh: boolean }>
|
||||||
|
|
||||||
|
const returnTrue = () => true
|
||||||
|
const returnFalse = () => false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper utility that uses a store to speed up the retrieval of values from an
|
||||||
|
* (expensive) getter function.
|
||||||
|
*/
|
||||||
|
export class CachedGetter<K extends Key = string, V extends Value = Value> {
|
||||||
|
private pending = new Map<K, PendingItem<V>>()
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly getter: Getter<K, V>,
|
||||||
|
readonly store: SimpleStore<K, V>,
|
||||||
|
readonly options?: Readonly<CachedGetterOptions<K, V>>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async get(key: K, options?: GetCachedOptions): Promise<V> {
|
||||||
|
options?.signal?.throwIfAborted()
|
||||||
|
|
||||||
|
const isStale = this.options?.isStale
|
||||||
|
|
||||||
|
const allowStored: (value: V) => Awaitable<boolean> = options?.noCache
|
||||||
|
? returnFalse // Never allow stored values to be returned
|
||||||
|
: options?.allowStale || isStale == null
|
||||||
|
? returnTrue // Always allow stored values to be returned
|
||||||
|
: async (value: V) => !(await isStale(key, value))
|
||||||
|
|
||||||
|
// As long as concurrent requests are made for the same key, only one
|
||||||
|
// request will be made to the cache & getter function at a time. This works
|
||||||
|
// because there is no async operation between the while() loop and the
|
||||||
|
// pending.set() call. Because of the "single threaded" nature of
|
||||||
|
// JavaScript, the pending item will be set before the next iteration of the
|
||||||
|
// while loop.
|
||||||
|
let previousExecutionFlow: undefined | PendingItem<V>
|
||||||
|
while ((previousExecutionFlow = this.pending.get(key))) {
|
||||||
|
try {
|
||||||
|
const { isFresh, value } = await previousExecutionFlow
|
||||||
|
|
||||||
|
if (isFresh) return value
|
||||||
|
if (await allowStored(value)) return value
|
||||||
|
} catch {
|
||||||
|
// Ignore errors from previous execution flows (they will have been
|
||||||
|
// propagated by that flow).
|
||||||
|
}
|
||||||
|
|
||||||
|
options?.signal?.throwIfAborted()
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentExecutionFlow: PendingItem<V> = Promise.resolve()
|
||||||
|
.then(async () => {
|
||||||
|
const storedValue = await this.getStored(key, options)
|
||||||
|
if (storedValue !== undefined && (await allowStored(storedValue))) {
|
||||||
|
// Use the stored value as return value for the current execution
|
||||||
|
// flow. Notify other concurrent execution flows (that should be
|
||||||
|
// "stuck" in the loop before until this promise resolves) that we got
|
||||||
|
// a value, but that it came from the store (isFresh = false).
|
||||||
|
return { isFresh: false, value: storedValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve()
|
||||||
|
.then(async () => (0, this.getter)(key, options, storedValue))
|
||||||
|
.catch(async (err) => {
|
||||||
|
if (storedValue !== undefined) {
|
||||||
|
if (await this.options?.deleteOnError?.(err, key, storedValue)) {
|
||||||
|
await this.delStored(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
.then(async (value) => {
|
||||||
|
// The value should be stored even is the signal was aborted.
|
||||||
|
await this.setStored(key, value)
|
||||||
|
return { isFresh: true, value }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.pending.delete(key)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.pending.has(key)) {
|
||||||
|
// This should never happen. Indeed, there must not be any 'await'
|
||||||
|
// statement between this and the loop iteration check meaning that
|
||||||
|
// this.pending.get returned undefined. It is there to catch bugs that
|
||||||
|
// would occur in future changes to the code.
|
||||||
|
throw new Error('Concurrent request for the same key')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pending.set(key, currentExecutionFlow)
|
||||||
|
|
||||||
|
const { value } = await currentExecutionFlow
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
bind(key: K): (options?: GetCachedOptions) => Promise<V> {
|
||||||
|
return async (options) => this.get(key, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStored(key: K, options?: GetCachedOptions): Promise<V | undefined> {
|
||||||
|
try {
|
||||||
|
return await this.store.get(key, options)
|
||||||
|
} catch (err) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setStored(key: K, value: V): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.store.set(key, value)
|
||||||
|
} catch (err) {
|
||||||
|
await this.options?.onStoreError?.(err, key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delStored(key: K): Promise<void> {
|
||||||
|
await this.store.del(key)
|
||||||
|
}
|
||||||
|
}
|
2
packages/internal/simple-store/src/index.ts
Normal file
2
packages/internal/simple-store/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './cached-getter.js'
|
||||||
|
export * from './simple-store.js'
|
16
packages/internal/simple-store/src/simple-store.ts
Normal file
16
packages/internal/simple-store/src/simple-store.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export type Awaitable<V> = V | PromiseLike<V>
|
||||||
|
|
||||||
|
export type Key = string | number
|
||||||
|
export type Value = NonNullable<unknown> | null
|
||||||
|
|
||||||
|
export type GetOptions = { signal?: AbortSignal }
|
||||||
|
|
||||||
|
export interface SimpleStore<K extends Key = string, V extends Value = Value> {
|
||||||
|
/**
|
||||||
|
* @return undefined if the key is not in the store (which is why Value cannot contain "undefined").
|
||||||
|
*/
|
||||||
|
get: (key: K, options?: GetOptions) => Awaitable<undefined | V>
|
||||||
|
set: (key: K, value: V) => Awaitable<void>
|
||||||
|
del: (key: K) => Awaitable<void>
|
||||||
|
clear?: () => Awaitable<void>
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user