Did network & updates to non-followers (#74)
* add did-network * send interactions to non-followers * send target as did for follows * update readme * quick comment * cleanup
This commit is contained in:
parent
96a96f43c1
commit
cd9d567a86
@ -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()
|
||||
})
|
||||
|
||||
|
59
server/src/routes/did-network/index.ts
Normal file
59
server/src/routes/did-network/index.ts
Normal file
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user