Skip to content

Commit

Permalink
Did network & updates to non-followers (bluesky-social#74)
Browse files Browse the repository at this point in the history
* add did-network

* send interactions to non-followers

* send target as did for follows

* update readme

* quick comment

* cleanup
  • Loading branch information
dholms authored Apr 14, 2022
1 parent 96a96f4 commit cd9d567
Show file tree
Hide file tree
Showing 19 changed files with 262 additions and 41 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
3 changes: 2 additions & 1 deletion common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 16 additions & 5 deletions common/src/microblog/delegator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 } {
Expand Down Expand Up @@ -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))
Expand Down
39 changes: 38 additions & 1 deletion common/src/network/service.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
17 changes: 17 additions & 0 deletions common/src/network/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
22 changes: 22 additions & 0 deletions server/src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// -----------

Expand Down Expand Up @@ -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
// -----------

Expand Down
15 changes: 14 additions & 1 deletion server/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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'])
},
}

Expand Down Expand Up @@ -85,6 +97,7 @@ const follows = {
}

const SCHEMAS: Schema[] = [
didNetwork,
userRoots,
userDids,
subscriptions,
Expand Down
2 changes: 1 addition & 1 deletion server/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 20 additions & 2 deletions server/src/routes/data/interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
Expand All @@ -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()
})
Expand Down
27 changes: 11 additions & 16 deletions server/src/routes/data/relationship.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
Expand All @@ -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()
})
Expand Down
13 changes: 12 additions & 1 deletion server/src/routes/data/repo.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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()
})

Expand Down
Loading

0 comments on commit cd9d567

Please sign in to comment.