ssb-profile

A helper module for reading and writing to "profile threads" in scuttlebutt

Usage no npm install needed!

<script type="module">
  import ssbProfile from 'https://cdn.skypack.dev/ssb-profile';
</script>

README

ssb-profile

An secret-stack plugin for creating, reading, updating profiles in scuttlebutt

Example Usage

 const Stack = require('secret-stack')
 const caps = require('ssb-caps')
 const Config = require('ssb-config/inject')
 
 const config = Config({})
 
 const ssb = Stack({ caps })
   .use(require('ssb-db'))
+  .use(require('ssb-backlinks')) // required
+  .use(require('ssb-query')) //     required
+  .use(require('ssb-profile'))
   .use(require('ssb-tribes')) //    (optonal) support for private messages
   .call(null, config)
 
 const details = {
   preferredName: 'Ben',
   avatarImage: {
     blob: '&CLbw5B9d5+H59oxDNOy4bOkwIaOhfLfqOLm1MGKyTLI=.sha256',
     mimeType: 'image/png'
   }
 }

 ssb.profile.person.public.create(details, (err, profileId) => {
   // ...
 })
// later:
ssb.profile.person.public.get(profileId, (err, profile) => {
  // ...
})

// or:
const update = {
  preferredName: 'Ben Tairea',
}

ssb.profile.person.public.update(profileId, update, (err, updateMsg) => {
  // ...
})

Requirements

An ssb-server with the following plugins:

  • ssb-backlinks
  • ssb-query

API

Profiles for people:

  • ssb.profile.person.source.*
    • has every field
    • recps must be [group]
  • ssb.profile.person.group.*
    • excludes: [phone, address, email]
    • recps must be [group]
  • ssb.profile.person.admin.*
    • same as source, but admins can post updates to it too.
    • recps must be [poBoxId, feedId] (for someone sending something to admins who wants to be part of updates) OR [groupId] (for something that is one-way to admins/ admin-only)
  • ssb.profile.person.public.*
    • only: [preferredName, avatarImage]
    • no recps
graph TB
  subgraph Personal group
    source
  end
  
public(public)
  
  subgraph Family group
    group(group)
    subgraph kaitiaki group
      admin(admin)
    end
    
  end
  
source-..->public
source-..->group
source-..->admin

This graph show how Āhau uses these profiles. Dotted lines show how updates to the source profile are propogate to the others.

Profiles for communities

  • ssb.profile.community.public.* - public community profile
  • ssb.profile.community.group.* - encrypted community profile

Profiles for pataka

  • `ssb.profile.pataka.public*- public pataka profile

Person profile (PUBLIC)

Handles public facing (unencrypted) profiles of type profile/person.

ssb.profile.person.public

  • .create(details, cb)
  • .get(profileId, cb)
  • .update(profileId, details, cb)
  • .tombstone(profileId, details, cb)

Here details is an Object which allows:

{
  authors: {
    add: [Author]    // required on .create
    remove: [Author]
  },

  preferredName: String,
  gender: Gender,
  avatarImage: Image,

  tombstone: Tombstone

  // allowPublic: Booelan // if using ssb-recps-guard
}

NOTES:

  • authors is a special field which defines permissions for updates
    • you must set authors.add when creating a record
  • This type is deliberatly quite limited, to avoid accidental sharing of private data.
  • All fields (apart from authors) can also be set to null
  • See below for types.

Person group profile

Handles encrypted profiles of type profile/person.

ssb.profile.person.group

  • .create(details, cb)
  • .get(profileId, cb)
  • .update(profileId, details, cb)
  • .tombstone(profileId, details, cb)
  • .findAdminProfileLinks(groupProfileId, opts, cb) (see below)

Here details is an Object:

{
  recps: [Recp],     // required
  authors: {
    add: [Author]    // required on .create
    remove: [Author]
  },

  preferredName: String,
  legalName: String,
  altNames: {
    add: [String],
    remove: [String]
  },

  avatarImage: Image,
  headerImage: Image,

  description: String, 
  gender: Gender,

  aliveInterval: EdtfIntervalString,
  deceased: Boolean,
  placeOfBirth: String,
  placeOfDeath: String,
  buriedLocation: String,
  birthOrder: Int,

  profession: String,
  education: [String], // overwrites last Array of Strings
  school: [String],    // overwrites last Array of Strings

  address: String, 
  city: String,
  country: String,
  postCode: String,
  phone: String,
  email: String,

  tombstone: Tombstone
}

NOTES:

  • authors is a special field which defines permissions for updates
    • you must set authors.add when creating a record
  • recps is required when creating, but updates copy the initial recps
  • All fields (apart from authors, altNames) can also be set to null
  • See below for Types

Community profile (PUBLIC)

Handles public facing (unencrypted) profiles of type profile/community.

ssb.profile.community.public

  • .create(details, cb)
  • .get(profileId, cb)
  • .update(profileId, details, cb)
  • .tombstone(profileId, details, cb)

Here details is an Object which allows:

{
  authors: {
    add: [Author]    // required on .create
    remove: [Author],
  },

  preferredName: String,
  description: String,

  avatarImage: Image,
  headerImage: Image,

  address: String,
  city: String,
  country: String,
  postCode: String,
  phone: String,
  email: String,

  joiningQuestions: CustomForm, // only difference from private

  tombstone: Tombstone,

  poBoxId: POBoxId // public part of the poBoxId for a subgroup

  // allowPublic: Boolean       // if using ssb-recps-guard
}

NOTES:

  • authors is a special field which defines permissions for updates
    • you must set authors.add when creating a record
  • All fields (apart from authors) can also be set to null
  • POBoxId is a String cipherlink that can be used in recps by anyone, to send messages only those with the secret key can open
  • See below for Types

Community profile (GROUP)

Handles encrypted profiles of type profile/community and is for use within a group.

ssb.profile.community.group

  • .create(details, cb)
  • .get(profileId, cb)
  • .update(profileId, details, cb)
  • .tombstone(profileId, details, cb)

Here details is an Object of form:

{
  recps: [Recp],     // required
  authors: {
    add: [Author]    // required on .create
    remove: [Author],
  },

  preferredName: String,
  description: String,

  avatarImage: Image,
  headerImage: Image,

  address: String,
  city: String,
  country: String,
  postCode: String,
  phone: String,
  email: String,

  tombstone: Tombstone,

  poBoxId: POBoxId
}

NOTES:

  • recps is required when creating, but updates copy the initial recps
  • authors is a special field which defines permissions for updates
    • you must set authors.add when creating a record
  • All fields (apart from authors) can also be set to null
  • POBoxId is a String cipherlink that can be used in recps by anyone, to send messages only those with the secret key can open
  • See below for Types

How get methods work

Because there might be multiple offline edits to a profile which didn't know bout one-another, it's possible for divergence to happen:

   A   (the root message)
   |
   B   (an edit after A)
  / \
 C   D (two concurrent edits after B)

profile is an Object which maps the key of a each latest edit to the state it perceives the profile to be in! So for that prior example:

// profile
{
  key: MessageId,         // the root message of the profile tangle, aka profileId
  type: ProfileType,
  recps: [Recp],          // recipients (will be null on public records)
  originalAuthor: FeedId
  states: [
    { key: C, ...state },
    { key: D, ...state },
  ]
}

where

  • recps is the private recipients who can access the profile
  • states [State] - the one / multiple states in which the profile is in:
    • these are sorted from most to least recent edit (by asserted publishedDate on the last update message)
    • key MessageId is the key of the message which is the most recent edit
    • state is an object which shows what the state of the profile is (from the perspective of a person standing at that particular "head")
    • e.g. for some Public Person profile, it might look like:
      // State
      {
        type: 'person'                      // added to help you out
      
        authors: {
          '@xIP5FV16FwPUiIZ0TmINhoCo4Hdx6c4KQcznEDeWtWg=.ed25519': [
            { start: 203, end: Integer }
          ]
        },
        preferredName: 'Ben Tairea',
        gender: 'male',                           
        tombstone: null                     // all profile fields are present, are "null" if unset
      }
      

Fields which get reduced:

  • authors returns a collection of authors, and "intervals" for which that author was active
    • these are sequence numbers from the authors feed (unless the author is "*" in which case it's a time-stamp)
  • altNames returns an Array of names (ordered is not guarenteed)

ssb.profile.link.create(profileId, opts, cb)

where

  • profileId MessageId is the profile you're creating a link to
  • opts Object (optional) allows you to tune the link:
    • opts.feedId FeedId if provided creates a link/feed-profile with provided feedId instead of current ssb instance's feedId
    • opts.groupId GroupId creates a link/group-profile
    • opts.profileId MsgId creates a link/profile-profile/admin (set profileId to be the group profile, opts.profileId to be the admin profile)
    • opts.allowPublic Boolean (optional) - if you have ssb-recps-guard installed and want to bypass it for a public (unencrypted) link
  • cb Function - callback with signature (err, link) where link is the link message

Note:

  • if you link to a private profile, the link will be encrypted to the same recps as that profile
  • if you provide opts.feedId and opts.groupId you will get an error

Find methods

ssb.profile.find(opts, cb)

Arguments:

  • opts Object - an options object with properties:
    • opts.name String - a name (or fragment of) that could be part of a preferredName or legalName or altNames
    • opts.type String (optional)
      • if set, method will only return profiles of given type
      • Valid types:
        • 'person' 'person/admin' 'person/source' 'community' 'pataka'
        • null - if set to null, will return all types
      • default: 'person'
    • opts.groupId String (optional)
      • only returns results encrypted to a particular group
      • if it's a GroupId, and that group has a poBoxId, profiles encrypted to both are included
      • id it's a POBoxId, then just profiles encrypted to that P.O. Box will be included
    • opts.includeTombstoned Boolean (optional) - whether to include profiles which habe been tombstoned (default: false)
  • cb Function - a callback with signature (err, suggestions) where suggestions is an array of Profiles

ssb.profile.findByFeedId(feedId, cb)

Takes a feedId and calls back with all profiles which that feedId has linked to it. Signature of cb is cb(err, profiles) where profiles is of form:

{
  public: [Profile],
  private: [Profile]
}

NOTE:

  • profiles which have been tombstoned are not included in results
  • profiles are ordered from oldest to newest in terms of when they were linked to the feedId
  • advanced : ssb.profile.findByFeedId(feedId, opts, cb)
    • opts.getProfile - provide your own getter. signature getProfile(profileId, cb)
      • callback with cb(null, null) if you want to exclude a result
      • useful if you want to add a cache to your getter, or only allow certain types of profile
    • opts.groupId GroupId - only return profiles that exist in a particular private group
    • opts.sortPublicPrivate Boolean - whether to sort into { public, private }
      • default: true
      • if false returns an Array of profiles
    • opts.selfLinkOnly Boolean - only include profiles where the link message was authored by the feedId
      • default: true
      • if false, public and private groupings are further split into self and other:
        {
          self: { public: [Profile], private: [Profile] },
          other: { public: [Profile], private: [Profile] }
        }
        
      • if false you get profiles that anyone has linked to that feedId,
        • WARNING links asserted by others could be malicious
        • if you trust your context this can be a useful fallback

ssb.profile.findByGroupId(groupId, cb)

Takes a groupId and calls back with all profiles which that feedId has linked to it. Signature of cb is cb(err, profiles) where profiles is of form:

{
  public: [Profile],
  private: [Profile]
}

NOTE:

  • profiles which have been tombstoned are not included in results
  • profiles are ordered from oldest to newest in terms of when they were linked to the feedId
  • advanced you can call this with ssb.profile.findByGroupId(feedId, opts, cb)
    • opts.getProfile - provide your own getter. signature getProfile(profileId, cb)
      • callback with cb(null, null) if you want to exclude a result
      • useful if you want to add a cache to your getter, or only allow certain types of profile

ssb.profile.findFeedsByProfileId(profileId, cb)

Takes a profileId and calls back with all the feedIds which that profileId has linked to it. Signature of cb is cb(err, feeds) where feeds is of form:

[FeedId, FeedId, ...]

NOTE:

  • advanced : ssb.profile.findFeedsByProfile(profileId, opts, cb)
    • opts.selfLinkOnly Boolean - only include profiles where the link message was authored by the feedId
      • default: true
      • if false returns results in format:
        {
          self: [FeedId, ...],  // feeds that have link themselves to the profile
          other: [FeedId, ...]  // feeds that another person has linked to the profile
        }
        
  • alias ssb.profile.findFeedsByProfile

ssb.profile.person.group.findAdminProfileLinks(profileId, opts, cb)

Takes a profileId (person group profileId) and calls back with the parentLinks and childLinks which that profileId has linked to it. Signature of cb is cb(err, links) where links is of form:

{
  parentLinks: [Link],
  childLinks: [Link]
}

and Link is:

{
  key: MsgId,
  type: 'link/profile-profile/admin',
  parent: MsgId,
  child: MsgId,
  states: [{ key: MsgId, tombstone: Tombstone }]
  originalAuthor: FeedId,
  recps: [GroupId]
}

Types

  • Author String a FeedId or "*" (i.e. any user)
    • any updates that arent from a valid author are classed as invalid and will be ignored when using the get method
  • Recp String a "recipient", usually a FeedId or GroupId
    • the record will be encrypted so only that recipient(s) can access the record
    • requires an encryption plugin to be installed e.g. ssb-tribes, ssb-private1
  • Image Object:
    {
      blob: Blob,        // the content address for the blob (with prefex &)
      mimeType: String,  // mimetype of the image
      unbox: UnboxKey,   // (optional) a String for unboxing the blob if encrypted
      size: Number,      // (optional) size of the image in bytes
      width: Number,     // (optional) width of image in pixels
      height: Number     // (optional) height of image in pixels
    }
    
  • Gender String (male|female|other|unknown)
  • EdtfIntervalString - see edtf module and library of congress spec
  • Tombstone Object
    {
      date: UnixTime,  // an Integer indicating microseconds from 1st Jan 1970, can be negative!
      reason: String   // (optional)
    }
    
  • UnixTime Integer microseconds since 00:00 1st Jan 1970 (can be negative, read more)
  • CustomForm [FormField] - used generate custom form for people applying to join a community. e.g
    [
      { type: 'input', label: 'Who introduced you?' },
      { type: 'textarea', label: 'Please tell use about yourself' },
    ]
    
    • FormField Object of shape:
      {
        type: FieldType, // String: input|textarea
        label: String
      }
      

Record types

graph TB

%% ssb.profile

%% cipherlinks
feedId(feedId)
groupId(groupId)

%% public profiles
personPublic[profile/person]
communityPublic[profile/community]

%% public links
linkPersonPublic([link/feed-profile])
linkCommunityPublic([link/group-profile])

%% pataka[profile/pataka]

subgraph group
  communityGroup[profile/community]
  personGroup[profile/person<br/>]
 
  %% links encrypted to the group
  linkPersonGroup([link/feed-profile])
  linkCommunityGroup([link/group-profile])
  linkPersonPersonAdmin([link/profile-profile/admin])
 
  subgraph admin
    personAdmin[profile/person/admin]
 
    %% links encrypted to the admins
    linkPersonAdmin([link/feed-profile])
  end
end

%% connecting links
feedId -..-> linkPersonPublic -..-> personPublic
feedId -.-> linkPersonGroup -.-> personGroup
feedId -.-> linkPersonAdmin -.-> personAdmin

personAdmin -.-> linkPersonPersonAdmin -.-> personGroup

groupId -..-> linkCommunityPublic -..-> communityPublic
groupId -..-> linkCommunityGroup -..-> communityGroup

%% styling
classDef default fill:#990098, stroke:purple, stroke-width:1, color:white, font-family:sans, font-size:14px;

classDef cluster fill:#1fdbde55, stroke:#1fdbde;
classDef path stroke: blue;

classDef encrypted fill:#ffffffaa, stroke:purple, stroke-width:1, color:black, font-family:sans, font-size:14px;
classDef cipherlink fill:#0000ff33, stroke:purple, stroke-width:0, color:#00f, font-family:sans, font-size: 14px;

class personGroup,personAdmin,communityGroup,linkPersonGroup,linkPersonAdmin,linkCommunityGroup,linkPersonPersonAdmin encrypted;
class feedId,groupId cipherlink

Note - you only have link/profile-profile/admin for "unowned" profile (i.e. no link/feed-profile is present)

FAQ

I want to delete my legalName, how do?

  • first, know that if you previously published a legalName it will always be part of your record (even if it's not currently displayed)
  • if you want to clear a text field, just publish an update with null value: { legalName: null }

How do I clear an image?

  • same as with legalName - set it to null

Multiple editors for a profile?

  • work in progress!
  • currently supports multiple writers, but does not support merging of branched state
    • by default, .update extends the most recent branch

Development

Project layout (made with tree):

.
├── index.js           // ssb-server plugin (collects all methods)
├── method             // user facing methods
├── spec               // describes message + how to reduce them
│   ├── person
│   │   ├── source
│   │   ├── group
│   │   ├── admin
│   │   └── private
│   ├── community
│   │   ├── group
│   │   └── public
│   ├── pataka
│   │   
│   ├── link
│   │   ├── feed-profile
│   │   ├── group-profile
│   │   └── profile-profile-admin
│   └── lib
│
└── test               // tests!

run npm test to run tests