Skip to content

Commit

Permalink
Merge pull request #65 from ssbc/exclude-member
Browse files Browse the repository at this point in the history
Basic member exclusion flow
  • Loading branch information
Powersource authored Mar 22, 2023
2 parents 495519f + c21c8e2 commit db66745
Show file tree
Hide file tree
Showing 11 changed files with 392 additions and 57 deletions.
39 changes: 30 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,36 +107,57 @@ NOTE: If `create` finds an empty (i.e. seemingly unused) group feed, it will sta

- `opts` _Object_ - currently empty, but will be used in the future to specify details like whether the group has an admin subgroup, etc.
- `cb` _Function_ - callback function of signature `(err, group)` where `group` is an object containing:
- `id` _CloakedId_ - a cipherlink that's safe to use publicly to name the group, and is used in `recps` to trigger encrypting messages to that group, encoded as an ssb-uri

- `id` _GroupUri_ - an SSB URI that's safe to use publicly to name the group, and is used in `recps` to trigger encrypting messages to that group
- `subfeed` _Keys_ - the keys of the subfeed you should publish group data to
- `secret` _Buffer_ - the symmetric key used by the group for encryption
- `writeKey` _GroupKey_ - the current key used for publishing new messages to the group. It is one of the `readKeys`.
- `readKeys` _[GroupKey]_ - an array of all keys used to read messages for this group.
- `root` _MessagedId_ - the MessageId of the `group/init` message of the group, encoded as an ssb-uri.

where _GroupKey_ is an object of the format

- `key` _Buffer_ - the symmetric key used by the group for encryption
- `scheme` _String_ - the scheme for this key

### `ssb.tribes2.get(groupId, cb)`

Gets information about a specific group.

- `groupId` _CloakedId_ - the public-safe cipherlink which identifies the group
- `groupId` _GroupUri_ - the public-safe SSB URI which identifies the group
- `cb` _Function_ - callback function of signature `(err, group)` where `group` is an object on the same format as the `group` object returned by #create

### `ssb.tribes2.list({ live }) => source`

Creates a pull-stream source which emits `group` data of each private group you're a part of. If `live` is true then it also outputs all new groups you join.
(Same format as `group` object returned by #create)

### `ssb.tribes2.addMembers(groupId, feedIds, cb)`
### `ssb.tribes2.addMembers(groupId, feedIds, opts, cb)`

Publish `group/add-member` messages to a group of peers, which gives them all the details they need
to join the group.
Publish `group/add-member` messages to a group of peers, which gives them all the details they need to join the group. Newly added members will need to accept the invite using `acceptInvite()` before they start replicating the group.

- `groupId` _CloakedId_ - the public-safe cipherlink which identifies the group (same as in #create)
- `feedIds` _[FeedId]_ - an Array of 1-16 different ids for peers (accepts ssb-uri or sigil feed ids)
- `groupId` _GroupUri_ - the public-safe SSB URI which identifies the group (same as in #create)
- `feedIds` _[FeedId]_ - an Array of 1-15 different ids for peers (accepts ssb-uri or sigil feed ids)
- `opts` _Object_ - with the options:
- `text` _String_ - A piece of text attached to the addition. Visible to the whole group and the newly added people.
- `cb` _Function_ - a callback of signature `(err, msg)`

### `ssb.tribes2.publish(content, cb)`
### `ssb.tribes2.excludeMembers(groupId, feedIds, opts, cb)

Excludes some current members of the group, by creating a new key and group feed and reinviting everyone to that key except for the excluded members.

- `groupId` _GroupUri_ - the public-safe SSB URI which identifies the group (same as in #create)
- `feedIds` _[FeedId]_ - an Array of 1-15 different ids for peers (accepts ssb-uri or sigil feed ids)
- `opts` _Object_ - placeholder for future options.
- `cb` _Function_ - a callback of signature `(err)`

### `ssb.tribes2.publish(content, opts, cb)`

Publishes any kind of message encrypted to the group. The function wraps `ssb.db.create()` but handles adding tangles and using the correct encryption for the `content.recps` that you've provided. Mutates `content`.

- `opts` _Object_ - with the options:
- `isValid` _Function_ - a validator (typically `is-my-ssb-valid`/`is-my-json-valid`-based) that you want to check this message against before publishing. Have the function return false if the message is invalid and the message won't be published. By default uses the `content` validator from `private-group-spec`.
- `tangles` _[String]_ - by default `publish` always adds the `group` tangle to messages, but using this option you can ask it to add additional tangles. Currently only supports a few tangles that are core to groups.
- `feedKeys` _Keys_ - By default the message is published to the currently used group feed (current epoch) but using this option you can provide keys for another feed to publish on. Note that this doesn't affect the encryption used.
- `cb` _Function_ - a callback of signature `(err, msg)`

### `ssb.tribes2.listMembers(groupId, { live }) => source`
Expand Down
142 changes: 130 additions & 12 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const {
},
keySchemes,
} = require('private-group-spec')
const { SecretKey } = require('ssb-private-group-keys')
const { fromMessageSigil, isBendyButtV1FeedSSBURI } = require('ssb-uri2')
const buildGroupId = require('./lib/build-group-id')
const AddTangles = require('./lib/add-tangles')
Expand Down Expand Up @@ -141,50 +142,166 @@ module.exports = {

if (opts.text) content.text = opts.text

findOrCreateAdditionsFeed((err, additionsFeed) => {
const getFeed = opts?._feedKeys
? (cb) => cb(null, { keys: opts._feedKeys })
: findOrCreateAdditionsFeed

getFeed((err, additionsFeed) => {
// prettier-ignore
if (err) return cb(clarify(err, 'Failed to find or create additions feed when adding members'))

addTangles(content, ['group', 'members'], (err, content) => {
const options = {
isValid: isAddMember,
tangles: ['members'],
feedKeys: additionsFeed.keys,
}
publish(content, options, (err, msg) => {
// prettier-ignore
if (err) return cb(clarify(err, 'Failed to publish add-member message'))
return cb(null, msg)
})
})
})
})
}

function excludeMembers(groupId, feedIds, opts = {}, cb) {
if (cb === undefined)
return promisify(excludeMembers)(groupId, feedIds, opts)

ssb.metafeeds.findOrCreate(function gotRoot(err, myRoot) {
// prettier-ignore
if (err) return cb(clarify(err, "Couldn't get own root when excluding members"))

get(groupId, (err, { writeKey: oldWriteKey } = {}) => {
// prettier-ignore
if (err) return cb(clarify(err, "Couldn't get old key when excluding members"))

findOrCreateGroupFeed(oldWriteKey.key, (err, oldGroupFeed) => {
// prettier-ignore
if (err) return cb(clarify(err, "Couldn't get the old group feed when excluding members"))

const excludeContent = {
type: 'group/exclude',
excludes: feedIds,
recps: [groupId],
}
const excludeOpts = {
tangles: ['members'],
isValid: () => true,
}
publish(excludeContent, excludeOpts, (err) => {
// prettier-ignore
if (err) return cb(clarify(err, 'Failed to add group tangles when adding members'))
if (err) return cb(clarify(err, 'Failed to publish exclude msg'))

pull(
listMembers(groupId),
pull.collect((err, beforeMembers) => {
// prettier-ignore
if (err) return cb(clarify(err, "Couldn't get old member list when excluding members"))

const remainingMembers = beforeMembers.filter(
(member) => !feedIds.includes(member)
)
const newGroupKey = new SecretKey()
const addInfo = { key: newGroupKey.toBuffer() }

if (!isAddMember(content))
return cb(new Error(isAddMember.errorsString))
ssb.box2.addGroupInfo(groupId, addInfo, (err) => {
// prettier-ignore
if (err) return cb(clarify(err, "Couldn't store new key when excluding members"))

publishAndPrune(ssb, content, additionsFeed.keys, cb)
const newKey = {
key: newGroupKey.toBuffer(),
scheme: keySchemes.private_group,
}
ssb.box2.pickGroupWriteKey(groupId, newKey, (err) => {
// prettier-ignore
if (err) return cb(clarify(err, "Couldn't switch to new key for writing when excluding members"))

const newEpochContent = {
type: 'group/init',
version: 'v2',
groupKey: newGroupKey.toString('base64'),
tangles: {
members: { root: null, previous: null },
},
recps: [groupId, myRoot.id],
}
const newTangleOpts = {
tangles: ['epoch'],
isValid: () => true,
}
publish(newEpochContent, newTangleOpts, (err) => {
// prettier-ignore
if (err) return cb(clarify(err, "Couldn't post init msg on new epoch when excluding members"))

const reAddOpts = {
// the re-adding needs to be published on the old
// feed so that the additions feed is not spammed,
// while people need to still be able to find it
_feedKeys: oldGroupFeed.keys,
}
addMembers(
groupId,
remainingMembers,
reAddOpts,
(err) => {
// prettier-ignore
if (err) return cb(clarify(err, "Couldn't re-add remaining members when excluding members"))
return cb()
}
)
})
})
})
})
)
})
})
})
})
}

function publish(content, cb) {
if (cb === undefined) return promisify(publish)(content)
function publish(content, opts, cb) {
if (cb === undefined) return promisify(publish)(content, opts)

if (!content) return cb(new Error('Missing content'))

const isValid = opts?.isValid ?? isContent
const tangles = ['group', ...(opts?.tangles ?? [])]

const recps = content.recps
if (!recps || !Array.isArray(recps) || recps.length < 1) {
return cb(new Error('Missing recps'))
}
const groupId = recps[0]

addTangles(content, ['group'], (err, content) => {
addTangles(content, tangles, (err, content) => {
// prettier-ignore
if (err) return cb(clarify(err, 'Failed to add group tangle when publishing to a group'))

if (!isContent(content)) return cb(new Error(isContent.errorsString))
if (!isValid(content))
return cb(
new Error(isValid.errorsString ?? 'content failed validation')
)

get(groupId, (err, { writeKey }) => {
// prettier-ignore
if (err) return cb(clarify(err, 'Failed to get group details when publishing to a group'))

findOrCreateGroupFeed(writeKey.key, (err, groupFeed) => {
const getFeed = opts?.feedKeys
? (_, cb) => cb(null, { keys: opts.feedKeys })
: findOrCreateGroupFeed

getFeed(writeKey.key, (err, groupFeed) => {
// prettier-ignore
if (err) return cb(clarify(err, 'Failed to find or create group feed when publishing to a group'))

publishAndPrune(ssb, content, groupFeed.keys, cb)
publishAndPrune(ssb, content, groupFeed.keys, (err, msg) => {
// prettier-ignore
if (err) return cb(clarify(err, 'Failed to publishAndPrune when publishing a group message'))
return cb(null, msg)
})
})
})
})
Expand Down Expand Up @@ -314,6 +431,7 @@ module.exports = {
get,
list,
addMembers,
excludeMembers,
publish,
listMembers,
listInvites,
Expand Down
1 change: 1 addition & 0 deletions lib/add-tangles.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ module.exports = function AddTangles(server) {
const getTangle = {
group: GetTangle(server, 'group'),
members: GetTangle(server, 'members'),
epoch: GetTangle(server, 'epoch'),
}

function addSomeTangles(content, tangles, cb) {
Expand Down
9 changes: 7 additions & 2 deletions lib/get-tangle.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@ const pull = require('pull-stream')
const Reduce = require('@tangle/reduce')
const Strategy = require('@tangle/strategy')
const clarify = require('clarify-error')
const { isIdentityGroupSSBURI } = require('ssb-uri2')
const { isIdentityGroupSSBURI, fromMessageSigil } = require('ssb-uri2')

// for figuring out what "previous" should be for the group

const strategy = new Strategy({})

function toUri(link) {
if (typeof link !== 'string') return link
return link.startsWith('%') ? fromMessageSigil(link) : link
}

/** `server` is the ssb server you're using. `tangle` is the name of the tangle in the group you're looking for, e.g. "group" or "members" */
module.exports = function GetTangle(server, tangle) {
const getUpdates = GetUpdates(server, tangle)
Expand All @@ -35,7 +40,7 @@ module.exports = function GetTangle(server, tangle) {
if (err) return cb(clarify(err, 'Failed to read updates when getting tangle'))

const nodes = msgs.map((msg) => ({
key: msg.key,
key: toUri(msg.key),
previous: msg.value.content.tangles[tangle].previous,
}))
// NOTE: getUpdates query does not get root node
Expand Down
16 changes: 11 additions & 5 deletions lib/meta-feed-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,14 +176,19 @@ module.exports = (ssb) => {
// prettier-ignore
if (err) return cb(clarify(err, 'Failed to find or create root feed when creating a group'))

// find groups without any group/add-member messages
// crash resistence: look for groups without any group/add-member messages
let foundMemberlessGroup = false
pull(
ssb.db.query(
where(and(isDecrypted('box2'), type('group/init'))),
toPullStream()
),
// find only init for epoch zero
pull.filter(rootMsg => rootMsg.value.content?.tangles?.group?.root === null),

// see if there are and members added
pull.asyncMap((rootMsg, cb) => {
// return rootMsg if empty group, otherwise return false
let foundMember = false
pull(
ssb.db.query(
Expand All @@ -208,6 +213,7 @@ module.exports = (ssb) => {
)
}),
pull.filter(Boolean),

pull.take(1),
pull.drain(
(rootMsg) => {
Expand All @@ -230,9 +236,9 @@ module.exports = (ssb) => {
(err) => {
// prettier-ignore
if (err) return cb(clarify(err, "errored trying to find potential memberless feed"))
else if (!foundMemberlessGroup) {
return createGroupWithoutMembers(myRoot, cb)
}
else if (!foundMemberlessGroup) {
return createGroupWithoutMembers(myRoot, cb)
}
}
)
)
Expand All @@ -259,8 +265,8 @@ module.exports = (ssb) => {
findEmptyGroupFeed,
findOrCreateFromSecret,
findOrCreateGroupFeed,
createGroupWithoutMembers,
findOrCreateGroupWithoutMembers,
createGroupWithoutMembers,
getRootFeedIdFromMsgId,
}
}
15 changes: 1 addition & 14 deletions test/create.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ const { keySchemes } = require('private-group-spec')
const Ref = require('ssb-ref')
const { promisify: p } = require('util')
const { where, type, toPromise } = require('ssb-db2/operators')
const pull = require('pull-stream')
const Testbot = require('./helpers/testbot')
const countGroupFeeds = require('./helpers/count-group-feeds')

test('create', async (t) => {
const ssb = Testbot()
Expand Down Expand Up @@ -104,19 +104,6 @@ test('root message is encrypted', async (t) => {
await p(alice.close)(true)
})

function countGroupFeeds(server, cb) {
pull(
server.metafeeds.branchStream({ old: true, live: false }),
pull.filter((branch) => branch.length === 4),
pull.map((branch) => branch[3]),
pull.filter((feed) => feed.recps),
pull.collect((err, feeds) => {
if (err) return cb(err)
return cb(null, feeds.length)
})
)
}

function createEmptyGroupFeed({ server, root }, cb) {
const secret = new SecretKey()
server.metafeeds.findOrCreate(
Expand Down
Loading

0 comments on commit db66745

Please sign in to comment.