diff --git a/README.md b/README.md index 954028e..3364813 100644 --- a/README.md +++ b/README.md @@ -85,14 +85,14 @@ Returns a stream of shard messages identified by root id. Takes `opts` - standa Returns a stream of shards others have shared with you. Takes `opts` - standard stream options like `live`, `reverse` etc. - ### Shard forward methods -#### `darkCrystal.forward.async.publish(root, recp, callback)` +#### `darkCrystal.forward.async.publish({ root, recp, requestId } callback)` -Takes arguments +Takes an object with properties: - `root` the id of the root message with which the shard is associated - `recp` the feedId of the recipient of the forwarded shard +- `requestId` the id of an associated `dark-crystal/forward-request` message (optional) Publishes a forward message which allows a shard to be sent to someone other than the owner of the secret. @@ -104,6 +104,30 @@ Returns a stream of forwarded shard messages identified by root id. Takes `opts Returns a stream of all forwarded shards you have recieved. Takes `opts` - standard stream options like `live`, `reverse` etc. +### Forward request methods + +#### `darkCrystal.forwardRequest.async.publish-all({ secretOwner, recps })` + +Takes an object with properties: +- `secretOwner` the feedId of the owner of the secret of which the shards are being requested +- `recps` an array of feedIds of the recipients of the request (who should be the holders of the shards being requested) + +Publishes a request to the given recipients that they forward you shards. Note that the request does not specify a rootId, since we can assume the rootId is unknown to the person requesting a forward. Forward requests are currently optional. + +#### `darkCrystal.forwardRequest.pull.bySecretOwner(secretOwner, opts)` + +Returns a stream of forward requests for shards authored by a given feedId. +`opts` are standard stream options. + +#### `darkCrystal.forwardRequest.pull.forOwnShards(filter, opts)` + +Returns a stream of forward requests for shards which you hold. `filter` is an optional argument which, if given, is a function returning a boolean which is applied as a filter to the stream. +`opts` are standard stream options. + +#### `darkCrystal.forwardRequest.pull.fromSelf(opts)` + +Returns a stream of forward requests which you authored (to others). +`opts` are standard stream options. ### Validators diff --git a/forward-request/async/build.js b/forward-request/async/build.js new file mode 100644 index 0000000..c1b6ab2 --- /dev/null +++ b/forward-request/async/build.js @@ -0,0 +1,18 @@ +const { isForwardRequest } = require('ssb-dark-crystal-schema') + +module.exports = function (server) { + return function buildForwardRequest ({ secretOwner, recp }, callback) { + var content = { + type: 'dark-crystal/forward-request', + version: '1.0.0', + secretOwner, + recps: [recp, server.id] + } + + // TODO: check that secretOwner !== recp or sever.id + + if (!isForwardRequest(content)) return callback(isForwardRequest.errors) + + callback(null, content) + } +} diff --git a/forward-request/async/publish-all.js b/forward-request/async/publish-all.js new file mode 100644 index 0000000..371a829 --- /dev/null +++ b/forward-request/async/publish-all.js @@ -0,0 +1,40 @@ +const pull = require('pull-stream') +const { isFeed } = require('ssb-ref') + +const buildForwardRequest = require('../async/build') +const publish = require('../../lib/publish-msg') + +module.exports = function (server) { + return function publishAll ({ secretOwner, recps }, callback) { + let feedIds = recps + .map(recp => typeof recp === 'string' ? recp : recp.link) + .filter(Boolean) + .filter(isFeed) + + if (feedIds.length !== recps.length) return callback(new Error('forwardRequests publishAll: all recps must be valid feedIds'), recps) + if (!validRecps(feedIds)) return callback(new Error('forwardRequests publishAll: all recps must be valid feedIds', feedIds)) + if (!isFeed(secretOwner)) return callback(new Error('forwardRequests publishAll: invalid feedId', secretOwner)) + + pull( + pull.values(feedIds.map(recp => ({ secretOwner, recp }))), + pull.asyncMap(buildForwardRequest(server)), + pull.collect((err, forwardRequests) => { + if (err) return callback(err) + + publishAll(forwardRequests) + }) + ) + + function publishAll (forwardRequests) { + pull( + pull.values(forwardRequests), + pull.asyncMap(publish(server)), + pull.collect(callback) + ) + } + } +} + +function validRecps (recps) { + return recps.every(isFeed) +} diff --git a/forward-request/pull/by-secret-owner.js b/forward-request/pull/by-secret-owner.js new file mode 100644 index 0000000..a3f74dd --- /dev/null +++ b/forward-request/pull/by-secret-owner.js @@ -0,0 +1,35 @@ +const pull = require('pull-stream') +const next = require('pull-next-query') +const { isForwardRequest } = require('ssb-dark-crystal-schema') +const { isFeedId } = require('ssb-ref') + +module.exports = function (server) { + return function bySecretOwner (secretOwner, opts = {}) { + assert(isFeedId(secretOwner), 'secretOwner must be a valid feedId') + const query = [{ + $filter: { + value: { + timestamp: { $gt: 0 }, // needed for pull-next-query to stepOn on published timestamp + content: { type: 'dark-crystal/forward-request' } + } + } + }, { + $filter: { + value: { + content: { secretOwner } + } + } + }] + + const _opts = Object.assign({}, { query, limit: 100 }, opts) + + return pull( + next(server.query.read, _opts), + pull.filter(isForwardRequest) + ) + } +} + +function assert (test, message) { + if (!test) throw new Error(message || 'AssertionError') +} diff --git a/forward-request/pull/for-own-shards.js b/forward-request/pull/for-own-shards.js new file mode 100644 index 0000000..9e4f872 --- /dev/null +++ b/forward-request/pull/for-own-shards.js @@ -0,0 +1,25 @@ +const pull = require('pull-stream') +// const next = require('pull-next-query') +const ShardsFromOthers = require('../../shard/pull/from-others') +const forwardRequestBySecretOwner = require('./by-secret-owner') +const { get } = require('lodash') + +module.exports = function (server) { + const shardsFromOthers = ShardsFromOthers(server) + + return function forOwnShards (opts = {}) { + return pull( + shardsFromOthers(), + pull.unique(s => get(s, 'value.author')), + pull.asyncMap((shard, cb) => { + pull( + forwardRequestBySecretOwner(get(shard, 'value.author')), + pull.collect((err, forwards) => { + if (err) cb(err) + cb(null, forwards) + }) + ) + }) + ) + } +} diff --git a/forward-request/pull/from-others.js b/forward-request/pull/from-others.js new file mode 100644 index 0000000..90a2cdd --- /dev/null +++ b/forward-request/pull/from-others.js @@ -0,0 +1,29 @@ +const pull = require('pull-stream') +const next = require('pull-next-query') +const { isForwardRequest } = require('ssb-dark-crystal-schema') + +module.exports = function (server) { + return function fromOthers (opts = {}) { + const query = [{ + $filter: { + value: { + timestamp: { $gt: 0 }, // needed for pull-next-query to stepOn on published timestamp + content: { type: 'dark-crystal/forward-request' } + } + } + }, { + $filter: { + value: { + author: { $ne: server.id } + } + } + }] + + const _opts = Object.assign({}, { query, limit: 100 }, opts) + + return pull( + next(server.query.read, _opts), + pull.filter(isForwardRequest) + ) + } +} diff --git a/forward-request/pull/from-self.js b/forward-request/pull/from-self.js new file mode 100644 index 0000000..4d22b2c --- /dev/null +++ b/forward-request/pull/from-self.js @@ -0,0 +1,29 @@ +const pull = require('pull-stream') +const next = require('pull-next-query') +const { isForwardRequest } = require('ssb-dark-crystal-schema') + +module.exports = function (server) { + return function fromSelf (opts = {}) { + const query = [{ + $filter: { + value: { + timestamp: { $gt: 0 }, // needed for pull-next-query to stepOn on published timestamp + content: { type: 'dark-crystal/forward-request' } + } + } + }, { + $filter: { + value: { + author: server.id + } + } + }] + + const _opts = Object.assign({}, { query, limit: 100 }, opts) + + return pull( + next(server.query.read, _opts), + pull.filter(isForwardRequest) + ) + } +} diff --git a/forward/async/build.js b/forward/async/build.js index 9d49e79..32cf7b0 100644 --- a/forward/async/build.js +++ b/forward/async/build.js @@ -1,7 +1,7 @@ const isForward = require('../../isForward') module.exports = function buildShard (server) { - return function ({ root, shard, shareVersion, recp }, cb) { + return function ({ root, shard, shardId, requestId, shareVersion, recp }, cb) { // this undoes the privatebox packing we've used to encrypt shards server.private.unbox(shard, (err, theDecryptedShard) => { if (err) return cb(err) @@ -11,6 +11,8 @@ module.exports = function buildShard (server) { version: '1.0.0', root, shard: theDecryptedShard, + shardId, + requestId, shareVersion, recps: [recp, server.id] } diff --git a/forward/async/publish.js b/forward/async/publish.js index fb51a15..4fdfacf 100644 --- a/forward/async/publish.js +++ b/forward/async/publish.js @@ -7,7 +7,7 @@ const publish = require('../../lib/publish-msg') module.exports = function (server) { const pullShardsByRoot = PullShardsByRoot(server) - return function publishForward (root, recp, callback) { + return function publishForward ({ root, recp, requestId }, callback) { if (!ref.isMsgId(root)) return callback(new Error('Invalid rootId')) pull( pullShardsByRoot(root), @@ -27,12 +27,11 @@ module.exports = function (server) { } const shareVersion = get(shards[0], 'value.content.version') - + const shardId = get(shards[0], 'key') const shard = get(shards[0], 'value.content.shard') - buildForward(server)({ root, shard, shareVersion, recp }, (err, content) => { + buildForward(server)({ root, shard, shardId, requestId, shareVersion, recp }, (err, content) => { if (err) return callback(err) - publish(server)(content, callback) }) }) diff --git a/lib/assert.js b/lib/assert.js new file mode 100644 index 0000000..536cfdc --- /dev/null +++ b/lib/assert.js @@ -0,0 +1,3 @@ +module.exports = function assert (test, message) { + if (!test) throw new Error(message || 'AssertionError') +} diff --git a/lib/unpackLink.js b/lib/unpackLink.js new file mode 100644 index 0000000..ecdd1f3 --- /dev/null +++ b/lib/unpackLink.js @@ -0,0 +1,12 @@ +const assert = require('./assert') +const { isBlobId } = require('ssb-ref') + +module.exports = function unpackLink (link) { + const index = link.indexOf('?') + const blobId = link.substring(0, index) + const blobKey = link.substring(index + 1) + assert(((index > 0) && (blobKey.length)), 'Blob not encrypted') + // TODO: test for well formed blob key + assert((isBlobId(blobId)), 'attachment contains invalid blob reference') + return { blobId, blobKey } +} diff --git a/methods.js b/methods.js index 13c180e..0c37f08 100644 --- a/methods.js +++ b/methods.js @@ -1,4 +1,4 @@ -const { isRitual, isRoot, isShard, isForward } = require('ssb-dark-crystal-schema') +const { isRitual, isRoot, isShard, isForward, isForwardRequest } = require('ssb-dark-crystal-schema') const isRequest = require('./isRequest') const isReply = require('./isReply') @@ -56,12 +56,23 @@ module.exports = { fromOthersByRoot: require('./forward/pull/from-others-by-root') } }, + forwardRequest: { + async: { + publishAll: require('./forward-request/async/publish-all') + }, + pull: { + bySecretOwner: require('./forward-request/pull/by-secret-owner'), + fromSelf: require('./forward-request/pull/from-self'), + forOwnShards: require('./forward-request/pull/for-own-shards') + } + }, sync: { isRitual: () => isRitual, isRoot: () => isRoot, isShard: () => isShard, isForward: () => isForward, isRequest: () => isRequest, - isReply: () => isReply + isReply: () => isReply, + isForwardRequest: () => isForwardRequest } } diff --git a/shard/async/build.js b/shard/async/build.js index 5215711..faf4e69 100644 --- a/shard/async/build.js +++ b/shard/async/build.js @@ -1,17 +1,22 @@ const { box } = require('ssb-keys') const { isShard, errorParser } = require('ssb-dark-crystal-schema') +const { pickBy, identity } = require('lodash') // TODO - change to use secretbox from sodium? module.exports = function buildShard (server) { - return function ({ root, shard, recp }, cb) { - const content = { + return function ({ root, shard, recp, attachment }, cb) { + var content = { type: 'dark-crystal/shard', version: '2.0.0', root, shard: box(shard, [recp]), - recps: [recp, server.id] + recps: [recp, server.id], + attachment } + // remove falsey values + content = pickBy(content, identity) + if (!isShard(content)) return cb(errorParser(content)) cb(null, content) diff --git a/shard/async/publish-all.js b/shard/async/publish-all.js index 12fe2d0..f270ba3 100644 --- a/shard/async/publish-all.js +++ b/shard/async/publish-all.js @@ -5,13 +5,13 @@ const buildShard = require('../async/build') const publish = require('../../lib/publish-msg') module.exports = function (server) { - return function publishAll ({ shards, recps, rootId }, callback) { + return function publishAll ({ shards, recps, rootId, attachment }, callback) { if (!validRecps(recps)) return callback(new Error('shards publishAll: all recps must be valid feedIds', recps)) if (!isMsg(rootId)) return callback(new Error('shard publishAll: invalid rootId', rootId)) if (shards.length !== recps.length) return callback(new Error('shard publishAll: need as many shards as recps')) const opts = shards.map((shard, i) => { - return { root: rootId, shard, recp: recps[i] } + return { root: rootId, shard, recp: recps[i], attachment } }) pull( diff --git a/share/async/share.js b/share/async/share.js index fa31f85..71bc15f 100644 --- a/share/async/share.js +++ b/share/async/share.js @@ -1,27 +1,48 @@ -const { isFeed } = require('ssb-ref') +const { isFeed, isLink } = require('ssb-ref') const PublishRoot = require('../../root/async/publish') const PublishRitual = require('../../ritual/async/publish') const PublishAllShards = require('../../shard/async/publish-all') const secrets = require('dark-crystal-secrets') +const unpackLink = require('../../lib/unpackLink') const isNumber = require('../../lib/isNumber') const isString = require('../../lib/isString') const isFunction = require('../../lib/isFunction') +const assert = require('../../lib/assert') module.exports = function (server) { const publishRoot = PublishRoot(server) const publishRitual = PublishRitual(server) const publishAllShards = PublishAllShards(server) - return function ({ name, secret, quorum, label, recps }, callback) { - if (!name && !isString(name)) throw new Error('name must be a string') - if (!secret && !isString(secret)) throw new Error('secret must be a string') - if (!isNumber(quorum)) throw new Error('quorum must be a number') - if (!Array.isArray(recps)) throw new Error('recps must be an array') - if (!isFunction(callback)) throw new Error('callback is not a function') + return function (params, callback) { + var { + name, + secret, + quorum, + label, + recps, + attachment + } = params + assert((name && isString(name)), 'name must be a string') + assert((secret && isString(secret)), 'secret must be a string') + assert((isNumber(quorum)), 'quorum must be a number') + assert((Array.isArray(recps)), 'recps must be an array') + assert((isFunction(callback)), 'callback must be a function') + + if (attachment) { + if (!isString(attachment.name)) return callback(new Error('data.attachment.name: provide an attachment name')) + if (!isLink(attachment.link)) return callback(new Error('data.attachment.link: referenced schema does not match')) + try { + var { blobId, blobKey } = unpackLink(attachment.link) + } catch (err) { return callback(err) } + label = blobKey + attachment = { name: attachment.name, link: blobId } + } + if (!label) label = name - if (!isString(label)) throw new Error('label must be a string') + assert((isString(label)), 'label must be a string') let feedIds = recps .map(recp => typeof recp === 'string' ? recp : recp.link) @@ -51,9 +72,8 @@ module.exports = function (server) { // TEMP SOLUTION: Have a publishAllShards (plural) function which validates each with isShard before publishing all // RESOLUTION: Extracted reducer into a publishAll function // - publishAllShards({ shards, recps: recipients, rootId }, (err, shards) => { + publishAllShards({ shards, recps: recipients, rootId, attachment }, (err, shards) => { if (err) return callback(err) - callback(null, { root: root, ritual: ritual, diff --git a/test/forward-request/async/publishAll/v1.test.js b/test/forward-request/async/publishAll/v1.test.js new file mode 100644 index 0000000..e93723e --- /dev/null +++ b/test/forward-request/async/publishAll/v1.test.js @@ -0,0 +1,62 @@ +const { describe } = require('tape-plus') +const { isForwardRequest } = require('ssb-dark-crystal-schema') +const getContent = require('ssb-msg-content') + +const PublishAll = require('../../../../forward-request/async/publish-all') +const Server = require('../../../testbot') + +describe('forwardRequest.async.publish', context => { + let server + let publishAll + let secretOwner, recps + + context.beforeEach(c => { + server = Server() + publishAll = PublishAll(server) + + secretOwner = server.createFeed().id + recps = [ + server.createFeed().id, + server.createFeed().id, + server.createFeed().id + ] + }) + + context.afterEach(c => { + server.close() + }) + + context('publishes a message when valid', (assert, next) => { + publishAll({ secretOwner, recps }, (err, forwardRequests) => { + assert.notOk(err, 'null errors') + assert.ok(Array.isArray(forwardRequests), 'returns some data') + assert.equal(forwardRequests.length, recps.length, 'publishes one message for each recipient') + + forwardRequests.forEach(forwardRequest => { + assert.ok(isForwardRequest(forwardRequest), 'valid forward request') + assert.equal(getContent(forwardRequest).secretOwner, secretOwner, 'correct secretOwner') + }) + next() + }) + }) + + context('fails to publish all with invalid secretOwner', (assert, next) => { + secretOwner = 'this is not a feedId' + + publishAll({ secretOwner, recps }, (err, forwardRequests) => { + assert.ok(err, 'produces error') + assert.notOk(forwardRequests, 'forward object is null') + next() + }) + }) + + context('fails to publish all with single invalid recp', (assert, next) => { + recps[0] = 'this is not a feedId' + + publishAll({ secretOwner, recps }, (err, recipients) => { + assert.ok(err, 'produces error') + assert.equal(recps, recipients, 'returns invalid recps') + next() + }) + }) +}) diff --git a/test/forward/async/publish/v1.test.js b/test/forward/async/publish/v1.test.js index 16efdbd..e8aa95a 100644 --- a/test/forward/async/publish/v1.test.js +++ b/test/forward/async/publish/v1.test.js @@ -41,7 +41,7 @@ describe('forward.async.publish (v1 shard)', context => { bob.publish(bobShard, (err, bobReply) => { if (err) console.error(err) - publish(root, alice.id, (err, forward) => { + publish({ root, recp: alice.id }, (err, forward) => { const { version, shareVersion, shard } = getContent(forward) assert.notOk(err, 'null errors') assert.ok(forward, 'valid forward object') @@ -55,7 +55,7 @@ describe('forward.async.publish (v1 shard)', context => { context('fails to publish when invalid', (assert, next) => { root = 'this is not a root' - publish(root, alice.id, (errs, forward) => { + publish({ root, recp: alice.id }, (errs, forward) => { assert.ok(errs, 'has errors') assert.notOk(forward, 'forward is null') next() @@ -65,7 +65,7 @@ describe('forward.async.publish (v1 shard)', context => { context('fails to publish when shard is forwarded to its author', (assert, next) => { bob.publish(bobShard, (err, bobReply) => { if (err) console.error(err) - publish(root, bob.id, (err, forward) => { + publish({ root, recp: bob.id }, (err, forward) => { assert.ok(err, 'throws error') assert.notOk(forward, 'forward is null') next() diff --git a/test/forward/async/publish/v2.test.js b/test/forward/async/publish/v2.test.js index eb37a3d..dc25c2d 100644 --- a/test/forward/async/publish/v2.test.js +++ b/test/forward/async/publish/v2.test.js @@ -41,7 +41,7 @@ describe('forward.async.publish (v2 shard)', context => { bob.publish(bobShard, (err, bobReply) => { if (err) console.error(err) - publish(root, alice.id, (err, forward) => { + publish({ root, recp: alice.id }, (err, forward) => { const { version, shareVersion, shard } = getContent(forward) assert.notOk(err, 'null errors') assert.ok(forward, 'valid forward object') @@ -55,7 +55,7 @@ describe('forward.async.publish (v2 shard)', context => { context('fails to publish when invalid', (assert, next) => { root = 'this is not a root' - publish(root, alice.id, (errs, forward) => { + publish({ root, recp: alice.id }, (errs, forward) => { assert.ok(errs, 'has errors') assert.notOk(forward, 'forward is null') next() @@ -65,7 +65,7 @@ describe('forward.async.publish (v2 shard)', context => { context('fails to publish when shard is forwarded to its author', (assert, next) => { bob.publish(bobShard, (err, bobReply) => { if (err) console.error(err) - publish(root, bob.id, (err, forward) => { + publish({ root, recp: bob.id }, (err, forward) => { assert.ok(err, 'throws error') assert.notOk(forward, 'forward is null') next() diff --git a/test/shard/async/publishAll.test.js b/test/shard/async/publishAll.test.js index 9f39792..1d1c7a1 100644 --- a/test/shard/async/publishAll.test.js +++ b/test/shard/async/publishAll.test.js @@ -34,6 +34,8 @@ describe('shard.async.publishAll', context => { context('publishes all shards', (assert, next) => { publishAll({ shards, recps, rootId }, (err, publishedShards) => { + console.log(publishedShards[0]) + console.log(publishedShards[0].value.content) assert.notOk(err, 'error is null') assert.ok(Array.isArray(publishedShards), 'returns some data') assert.equal(publishedShards.length, shards.length, 'publishes one message for each recipient') diff --git a/test/share/async/share.test.js b/test/share/async/share.test.js index b6dbda9..9d8d979 100644 --- a/test/share/async/share.test.js +++ b/test/share/async/share.test.js @@ -1,13 +1,14 @@ const { describe } = require('tape-plus') const pull = require('pull-stream') const Server = require('../../testbot') +const unpackLink = require('../../../lib/unpackLink') const Share = require('../../../share/async/share') describe('share.async.share', context => { let server let share - let recps, name, secret, quorum + let recps, name, secret, quorum, attachment context.beforeEach(c => { server = Server() @@ -20,6 +21,12 @@ describe('share.async.share', context => { name = 'My SBB Dark Crystal' secret = Math.random().toString(36) quorum = 3 + attachment = { + name: 'gossip.json', + link: '&ERGA0oJCELz2s4sr47f75iXZComB/2akzZq+IpcuqDs=.sha256?unbox=qTWboArROGUrRUjniZGSxh9zcqpdjCSAsJSWYBRqhyQ=.boxs', + size: 66300, + type: 'application/json' + } }) context.afterEach(c => { @@ -96,6 +103,28 @@ describe('share.async.share', context => { }) }) + context('invalid attachment', (assert, next) => { + attachment.link = 'not a valid link' + + share({ name, secret, quorum, recps, attachment }, (err, data) => { + assert.ok(err, 'raises error') + assert.notOk(data, 'data is undefined') + assert.equal(err.message, 'data.attachment.link: referenced schema does not match') + + next() + }) + }) + + context('throws an error when given an unencrypted blob reference', (assert, next) => { + attachment.link = '&ERGA0oJCELz2s4sr47f75iXZComB/2akzZq+IpcuqDs=.sha256' + share({ name, secret, quorum, recps, attachment }, (err, data) => { + assert.ok(err, 'raises error') + assert.notOk(data, 'data is undefined') + assert.equal(err.message, 'Blob not encrypted') + next() + }) + }) + context('publishes a root, a ritual and the shards', (assert, next) => { share({ name, secret, quorum, recps }, (err, data) => { assert.notOk(err, 'error is null') @@ -133,6 +162,7 @@ describe('share.async.share', context => { ) }) }) + context('publishes a root, a ritual and the shards, when a label is given', (assert, next) => { const label = 'Give this key to your nearest and dearest' share({ name, secret, quorum, label, recps }, (err, data) => { @@ -171,6 +201,52 @@ describe('share.async.share', context => { ) }) }) + + context('Handles encrypted blob attachment', (assert, next) => { + share({ name, secret, quorum, recps, attachment }, (err, data) => { + assert.notOk(err, 'error is null') + assert.ok(data, 'returns the data') + + const pullType = (type) => server.query.read({ + query: [{ + $filter: { value: { content: { type } } } + }] + }) + + pull( + pullType('dark-crystal/root'), + pull.collect((err, roots) => { + if (err) console.error(err) + assert.deepEqual(trim(data.root), trim(roots[0]), 'publishes a root') + + pull( + pullType('dark-crystal/ritual'), + pull.collect((err, rituals) => { + if (err) console.error(err) + assert.deepEqual(trim(data.ritual), trim(rituals[0]), 'publishes a single ritual') + + pull( + pullType('dark-crystal/shard'), + pull.collect((err, shards) => { + assert.notOk(err, 'no error') + assert.deepEqual(data.shards.map(trim), shards.map(trim), 'publishes a set of shards') + + data.shards.map(s => s.value.content.attachment).forEach(attached => ( + assert.deepEqual( + attached, + { name: attachment.name, link: unpackLink(attachment.link).blobId }, + 'shard contains blob reference' + ) + )) + next() + }) + ) + }) + ) + }) + ) + }) + }) }) function trim (msg) {