Did network & updates to non-followers ()

* add did-network

* send interactions to non-followers

* send target as did for follows

* update readme

* quick comment

* cleanup
This commit is contained in:
Daniel Holmgren 2022-04-13 23:05:08 -05:00 committed by GitHub
parent 96a96f43c1
commit cd9d567a86
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 262 additions and 41 deletions

@ -162,6 +162,8 @@ Therefore we try to talk about the general concept as "interactions" and the par
In this prototype a user's root DID is a simple `did:key`. In the future, these will be more permanent identifiers such as `did:bsky` (read our proposal in the architecture docs) or `did:ion`.
The DID network is outside of the scope of this prototype. However, a DID is the canoncial, unchanging identifier for a user. and is needed in ordcer to enable data/server interop. Therefore we run a very simple DID network that only allows POSTs and GETs (with signature checks). The DID network is run _on_ the data server (`http://localhost:2583/did-network`), however every server that is running communicates with the _same_ data server when it comes to DID network requests. As DIDs are self-describing for resolution, we emulate this by hard coding how to discover a DID (ie "always go to _this particular address_ not your personal data server").
You'll notice that we delegate a UCAN from the root key to the root key (which is a no-op), this is to mirror the process of receiving a fully delegated UCAN _from your actual root key_ to a _fully permissioned device key_.
You'll also notice that the DID for the microblogging namespace is just `did:bsky:microblog` (which is not an actual valid DID). This is a stand in until we have an addressed network for schemas.
@ -172,5 +174,4 @@ UCAN permissions are also simplified at the current moment, allowing for scoped
In the architecture overview, we specify three client types: full, light, and delegator. This library only contains implementaions of full and delegator. Thus we use delegator for light weight operations and a full client when we want the entire repository.
The main ramification of this is that data server subscribers must receive the _full repo_ of the users that they subscribe to. Once we add in light clients, they can receive only the _sections_ the repo that they are interested in (for instance a single post or a like) while having the same trust model as a full repo.
The main ramification of this is that data server subscribers must receive the _full repo_ of the users that they subscribe to. Once we add in light clients, they can receive only the _sections_ of the repo that they are interested in (for instance a single post or a like) while having the same trust model as a full repo.

@ -17,7 +17,8 @@
"ipld-hashmap": "^2.1.10",
"level": "^7.0.1",
"multiformats": "^9.6.4",
"ucans": "0.9.0-alpha3"
"ucans": "0.9.0-alpha3",
"uint8arrays": "^3.0.0"
},
"devDependencies": {
"@types/level": "^6.0.0",

@ -13,7 +13,7 @@ import {
} from './types.js'
import { schema as repoSchema } from '../repo/types.js'
import * as check from '../common/check.js'
import { assureAxiosError, authCfg } from '../network/util.js'
import { assureAxiosError, authCfg, cleanHostUrl } from '../network/util.js'
import * as ucan from 'ucans'
import { Collection, Follow } from '../repo/types.js'
import { Keypair } from '../common/types.js'
@ -90,9 +90,19 @@ export class MicroblogDelegator {
)
}
async register(username: string): Promise<void> {
async register(name: string): Promise<void> {
if (!this.keypair) {
throw new Error('No keypair or ucan store provided. Client is read-only.')
}
// register on data server
const token = await this.maintenanceToken()
await service.register(this.url, username, this.did, true, token)
await service.register(this.url, name, this.did, true, token)
const host = cleanHostUrl(this.url)
const username = `${name}@${host}`
// register on did network
await service.registerToDidNetwork(username, this.keypair)
}
normalizeUsername(username: string): { name: string; hostUrl: string } {
@ -273,8 +283,9 @@ export class MicroblogDelegator {
}
}
async followUser(username: string): Promise<void> {
const data = { creator: this.did, username }
async followUser(nameOrDid: string): Promise<void> {
const target = await this.resolveDid(nameOrDid)
const data = { creator: this.did, target }
const token = await this.relationshipToken()
try {
await axios.post(`${this.url}/data/relationship`, data, authCfg(token))

@ -1,9 +1,46 @@
import axios from 'axios'
import { CID } from 'multiformats'
import { assureAxiosError, authCfg } from './util.js'
import { assureAxiosError, authCfg, didNetworkUrl } from './util.js'
import * as check from '../common/check.js'
import { schema as repoSchema } from '../repo/types.js'
import * as ucan from 'ucans'
import * as uint8arrays from 'uint8arrays'
import { Keypair } from '../common/types.js'
export const registerToDidNetwork = async (
username: string,
keypair: Keypair,
): Promise<void> => {
const url = didNetworkUrl()
const dataBytes = uint8arrays.fromString(username, 'utf8')
const sigBytes = await keypair.sign(dataBytes)
const signature = uint8arrays.toString(sigBytes, 'base64url')
const did = keypair.did()
const data = { did, username, signature }
try {
await axios.post(url, data)
} catch (e) {
const err = assureAxiosError(e)
throw new Error(err.message)
}
}
export const getUsernameFromDidNetwork = async (
did: string,
): Promise<string | null> => {
const url = didNetworkUrl()
const params = { did }
try {
const res = await axios.get(url, { params })
return res.data.username
} catch (e) {
const err = assureAxiosError(e)
if (err.response?.status === 404) {
return null
}
throw new Error(err.message)
}
}
export const register = async (
url: string,

@ -21,3 +21,20 @@ export const authCfg = (token: ucan.Chained): AxiosRequestConfig => {
headers: authHeader(token),
}
}
// this will be self describing from the DID, so we hardwire this for now & make it an env variable
export const didNetworkUrl = (): string => {
const envVar = process.env.DID_NETWORK_URL
if (typeof envVar === 'string') {
return envVar
}
return 'http://localhost:2583/did-network'
}
export const cleanHostUrl = (url: string): string => {
let cleaned = url.replace('http://', '').replace('https://', '')
if (cleaned.endsWith('/')) {
cleaned = cleaned.slice(0, -1)
}
return cleaned
}

@ -41,6 +41,23 @@ export class Database {
await schema.dropAll(this.db)
}
// DID NETWORK
// -----------
async registerOnDidNetwork(
username: string,
did: string,
host: string,
): Promise<void> {
await this.db.insert({ username, did, host }).into('did_network')
}
async getUsernameFromDidNetwork(did: string): Promise<string | null> {
const row = await this.db.select('*').from('did_network').where({ did })
if (row.length < 1) return null
return `${row[0].username}@${row[0].host}`
}
// USER DIDS
// -----------
@ -71,6 +88,11 @@ export class Database {
return `${row[0].username}@${row[0].host}`
}
async isDidRegistered(did: string): Promise<boolean> {
const un = await this.getUsername(did)
return un !== null
}
// REPO ROOTS
// -----------

@ -8,6 +8,16 @@ type Schema = {
create: (db: Knex.CreateTableBuilder) => void
}
const didNetwork = {
name: 'did_network',
create: (table: Table) => {
table.string('did').primary()
table.string('username')
table.string('host')
table.unique(['username', 'host'])
},
}
const userRoots = {
name: 'repo_roots',
create: (table: Table) => {
@ -20,8 +30,10 @@ const userDids = {
name: 'user_dids',
create: (table: Table) => {
table.string('did').primary()
table.string('username').unique()
table.string('username')
table.string('host')
table.unique(['username', 'host'])
},
}
@ -85,6 +97,7 @@ const follows = {
}
const SCHEMAS: Schema[] = [
didNetwork,
userRoots,
userDids,
subscriptions,

@ -7,7 +7,7 @@ export const handler = (
res: Response,
next: NextFunction,
) => {
console.log('Error: ', err.toString())
console.log(err.toString())
const status = ServerError.is(err) ? err.status : 500
res.status(status).send(err.message)
next(err)

@ -112,6 +112,13 @@ router.post('/', async (req, res) => {
const db = util.getDB(res)
await db.createLike(like, likeCid)
await db.updateRepoRoot(like.author, repo.cid)
await subscriptions.notifyOneOff(
db,
util.getOwnHost(req),
like.post_author,
repo,
)
await subscriptions.notifySubscribers(db, repo)
res.status(200).send()
})
@ -137,12 +144,23 @@ router.delete('/', async (req, res) => {
ucanCheck.hasPostingPermission(did, namespace, 'interactions', tid),
)
const repo = await util.loadRepo(res, did, ucanStore)
await repo.runOnNamespace(namespace, async (store) => {
return store.interactions.deleteEntry(tid)
// delete the like, but first find the user it was for so we can notify their server
const postAuthor = await repo.runOnNamespace(namespace, async (store) => {
const cid = await store.interactions.getEntry(tid)
if (cid === null) {
throw new ServerError(404, `Could not find like: ${tid.formatted()}`)
}
const like = await repo.get(cid, schema.microblog.like)
await store.interactions.deleteEntry(tid)
return like.post_author
})
const db = util.getDB(res)
await db.deleteLike(tid.toString(), did, namespace)
await db.updateRepoRoot(did, repo.cid)
await subscriptions.notifyOneOff(db, util.getOwnHost(req), postAuthor, repo)
await subscriptions.notifySubscribers(db, repo)
res.status(200).send()
})

@ -14,43 +14,37 @@ const router = express.Router()
export const createRelReq = z.object({
creator: z.string(),
username: z.string(),
target: z.string(),
})
export type CreateRelReq = z.infer<typeof createRelReq>
router.post('/', async (req, res) => {
const { creator, username } = util.checkReqBody(req.body, createRelReq)
const { creator, target } = util.checkReqBody(req.body, createRelReq)
const ucanStore = await auth.checkReq(
req,
ucanCheck.hasAudience(SERVER_DID),
ucanCheck.hasRelationshipsPermission(creator),
)
const db = util.getDB(res)
const username = await service.getUsernameFromDidNetwork(target)
if (!username) {
throw new ServerError(404, `Could not find user on DID netork: ${target}`)
}
const [name, host] = username.split('@')
if (!host) {
throw new ServerError(400, 'Expected a username with a host')
}
const ownHost = req.get('host')
let target: string
const ownHost = util.getOwnHost(req)
if (host !== ownHost) {
const did = await service.lookupDid(`http://${host}`, name)
if (did === null) {
throw new ServerError(404, `Could not find user: ${username}`)
}
target = did
await db.registerDid(name, did, host)
await db.registerDid(name, target, host)
await service.subscribe(`http://${host}`, target, `http://${ownHost}`)
} else {
const did = await db.getDidForUser(name, ownHost)
if (did === null) {
throw new ServerError(404, `Could not find user: ${username}`)
}
target = did
}
const repo = await util.loadRepo(res, creator, ucanStore)
await repo.relationships.follow(target, username)
await db.createFollow(creator, target)
await db.updateRepoRoot(creator, repo.cid)
await subscriptions.notifyOneOff(db, util.getOwnHost(req), target, repo)
await subscriptions.notifySubscribers(db, repo)
res.status(200).send()
})
@ -75,6 +69,7 @@ router.delete('/', async (req, res) => {
await repo.relationships.unfollow(target)
await db.deleteFollow(creator, target)
await db.updateRepoRoot(creator, repo.cid)
await subscriptions.notifyOneOff(db, util.getOwnHost(req), target, repo)
await subscriptions.notifySubscribers(db, repo)
res.status(200).send()
})

@ -1,7 +1,7 @@
import express from 'express'
import { z } from 'zod'
import * as util from '../../util.js'
import { delta, IpldStore, Repo, schema } from '@bluesky/common'
import { delta, IpldStore, Repo, schema, service } from '@bluesky/common'
import Database from '../../db/index.js'
import { ServerError } from '../../error.js'
import * as subscriptions from '../../subscriptions.js'
@ -50,6 +50,17 @@ router.post('/:did', async (req, res) => {
await db.createRepoRoot(did, loaded.cid)
await subscriptions.notifySubscribers(db, loaded)
}
// check to see if we have their username in DB, for indexed queries
const haveUsername = await db.isDidRegistered(did)
if (!haveUsername) {
const username = await service.getUsernameFromDidNetwork(did)
if (username) {
const [name, host] = username.split('@')
await db.registerDid(name, did, host)
}
}
res.status(200).send()
})

@ -0,0 +1,59 @@
import express from 'express'
import { z } from 'zod'
import * as ucan from 'ucans'
import * as util from '../../util.js'
import { ServerError } from '../../error.js'
const router = express.Router()
export const postDidNetworkReq = z.object({
did: z.string(),
username: z.string(),
signature: z.string(),
})
export type PostDidNetworkReq = z.infer<typeof postDidNetworkReq>
router.post('/', async (req, res) => {
const { username, did, signature } = util.checkReqBody(
req.body,
postDidNetworkReq,
)
if (username.startsWith('did:')) {
throw new ServerError(
400,
'Cannot register a username that starts with `did:`',
)
}
const { db } = util.getLocals(res)
const validSig = await ucan.verifySignatureUtf8(username, signature, did)
if (!validSig) {
throw new ServerError(403, 'Not a valid signature on username')
}
const [name, host] = username.split('@')
if (!host) {
throw new ServerError(400, 'Poorly formatted username, expected `@`')
}
await db.registerOnDidNetwork(name, did, host)
return res.sendStatus(200)
})
export const getDidNetworkReq = z.object({
did: z.string(),
})
export type GetDidNetworkReq = z.infer<typeof getDidNetworkReq>
router.get('/', async (req, res) => {
const { did } = util.checkReqBody(req.query, getDidNetworkReq)
const { db } = util.getLocals(res)
const username = await db.getUsernameFromDidNetwork(did)
if (username === null) {
throw new ServerError(404, 'Could not find user')
}
res.send({ username })
})
export default router

@ -15,7 +15,7 @@ export const registerReq = z.object({
username: z.string(),
createRepo: z.boolean(),
})
export type registerReq = z.infer<typeof registerReq>
export type RegisterReq = z.infer<typeof registerReq>
router.post('/register', async (req, res) => {
const { username, did, createRepo } = util.checkReqBody(req.body, registerReq)
@ -31,10 +31,7 @@ router.post('/register', async (req, res) => {
ucanCheck.hasAudience(SERVER_DID),
ucanCheck.hasMaintenancePermission(did),
)
const host = req.get('host')
if (!host) {
throw new ServerError(500, 'Could not get own host')
}
const host = util.getOwnHost(req)
// create empty repo
if (createRepo) {

@ -3,6 +3,7 @@ import WellKnown from './well-known.js'
import ID from './id.js'
import Data from './data/index.js'
import Indexer from './indexer/index.js'
import DidNetwork from './did-network/index.js'
const router = express.Router()
@ -10,5 +11,6 @@ router.use('/.well-known', WellKnown)
router.use('/id', ID)
router.use('/data', Data)
router.use('/indexer', Indexer)
router.use('/did-network', DidNetwork)
export default router

@ -20,10 +20,7 @@ export type WebfingerReq = z.infer<typeof webfingerReq>
router.get('/webfinger', async (req, res) => {
const { resource } = util.checkReqBody(req.query, webfingerReq)
const db = util.getDB(res)
const host = req.get('host')
if (!host) {
throw new ServerError(500, 'Could not get own host')
}
const host = util.getOwnHost(req)
const did = await db.getDidForUser(resource, host)
if (!did) {
return res.status(404).send('User DID not found')

@ -1,4 +1,4 @@
import { Repo } from '@bluesky/common'
import { Repo, service } from '@bluesky/common'
import Database from './db'
export const attemptNotify = async (
@ -27,3 +27,33 @@ export const notifySubscribers = async (
const hosts = await db.getSubscriptionsForUser(repo.did)
await notifyHosts(hosts, repo)
}
export const isSubscriber = async (
db: Database,
host: string,
user: string,
): Promise<boolean> => {
const hosts = await db.getSubscriptionsForUser(user)
return hosts.indexOf(host) > -1
}
export const notifyOneOff = async (
db: Database,
ownHost: string,
didToNotify: string,
repo: Repo,
): Promise<void> => {
const username = await service.getUsernameFromDidNetwork(didToNotify)
if (!username) {
console.error(`Could not find user on DID network: ${didToNotify}`)
return
}
const [_, host] = username.split('@')
if (host === ownHost) return
const baseUrl = `http://${host}`
// if it's a subscriber, we'll be notifying anyway
if (!(await isSubscriber(db, host, repo.did))) {
await attemptNotify(baseUrl, repo)
}
}

@ -80,3 +80,11 @@ export const loadRepo = async (
}
return maybeRepo
}
export const getOwnHost = (req: Request): string => {
const host = req.get('host')
if (!host) {
throw new ServerError(500, 'Could not get own host')
}
return host
}

@ -90,7 +90,7 @@ test.serial('list follows', async (t) => {
const follows = await alice.listFollows()
t.is(follows.length, 1, 'registered follow')
t.is(follows[0].did, bob.did, 'matching did')
t.is(follows[0].username, 'bob', 'matching username')
t.is(follows[0].username, `bob@${HOST}`, 'matching username')
t.pass('successfully followed user')
})

@ -12,6 +12,8 @@ const PORT_TWO = USE_TEST_SERVER ? 2586 : 2584
const HOST_TWO = `localhost:${PORT_TWO}`
const SERVER_TWO = `http://${HOST_TWO}`
process.env['DID_NETWORK_URL'] = `${SERVER_ONE}/did-network`
let alice: MicroblogDelegator
let bob: MicroblogDelegator
let carol: MicroblogDelegator