A client-side framework for building model-driven decentralized applications on top of Blockstack storage and authentication.
- Why?
- How?
- So, is it decentralized?
- Installation
- Configuration
- Authentication
- Models
- Collaboration
- Streaming real-time changes
- Saving centralized user-related data
- Development
There are already a number of great tools for building decentralized apps. Blockstack provides easy-to-use libraries for using decentralized authentication with a decentralized key-value storage system.
Some apps end up running into limitations when building complex applications, though. This library aims to solve a few of those problems, such as:
- Storing and querying complex data
- Indexing data to easily query user's publicly saved data
- Collaboration with other users
- Real-time updates (in progress)
The crux of radiks is that it's built to be used with Radiks-server, which is an indexing server for decentralized apps. Whenever a users saves or updates a model, radiks follows this process:
- Encrypt private data on the client-side
- Save a raw JSON version of this encrypted data in Gaia
- Store the encrypted data in Radiks-server, making it easy to query in the future.
Application developer's don't want users to have full control over writes to their database. Instead, users should only be able to write to data that they explicitly 'own'. To do this, Radiks creates 'signing keys', and signs all data using this signing key. The indexing server, Radiks-server, will only allow writes that are appropriately signed. Learn more about authorization
Although radiks applications rely on a centrally-hosted database, it is still fundamentally a decentralized application. It checks off these boxes:
- No data lock-in.
- Because all data is also stored in Gaia, the user still controls a 'copy' of their data for as long as they need to. If the application server shuts down, the user can still access their data. It also makes it easy to backup or migrate their data at any time. Learn more about Gaia
- Censorship resistance
- Again, because all data is also stored in Gaia, no party can revoke access to this data at any time.
- Maximum privacy
- Because all data is encrypted on the client side before being stored anywhere, the application host cannot inspect, sell, or use your data in any way that you don't explicitly authorize
- Built on decentralized authentication
- Radiks is deeply tied to Blockstack authentication, which uses a blockchain and Gaia to give you full control over your user data. Learn more about Blockstack auth
To get started, first setup MongoDB and a radiks server. You must use MongoDB >3.6, because they fixed an issue with naming patterns in keys. Check out the radiks-server
documentation for more information.
In your client-side code, install the radiks
package:
yarn add radiks
## or
npm install --save radiks
If you are using blockstack.js
<=18, you must use the radiks version 0.1.*, otherwise if you're using blockstack.js
>=19, use radiks 0.2.* .
To set up radiks.js, you only need to configure the URL that your Radiks-server instance is running on. If you're using the pre-built Radiks server, this will be http://localhost:1260
. If you're in production or are using a custom Radiks server, you'll need to specify exactly which URL it's available at.
Radiks also is compatible with version 19 of blockstack.js, which requires you to configure a UserSession
object to handle all user-data-related methods. You'll need to define this and pass it to your Radiks configuration, so that Radiks can know how to fetch information about the current logged in user.
To configure radiks, use code that looks like this when starting up your application:
import { UserSession, AppConfig } from 'blockstack';
import { configure } from 'radiks';
const userSession = new UserSession({
appConfig: new AppConfig(['store_write', 'publish_data'])
})
configure({
apiServer: 'http://my-radiks-server.com',
userSession
});
Most of your code will be informed by following Blockstack's authentication documentation.
After your user logs in with Blockstack, you'll have some code to save the user's data in localStorage. You'll want to use the same UserSession
you configured with Radiks, which can be fetched from the getConfig
method.
import { User, getConfig } from 'radiks';
const handleSignIn = () => {
const { userSession } = getConfig();
if (userSession.isSignInPending()) {
await userSession.handlePendingSignIn();
await User.createWithCurrentUser();
}
}
Calling User.createWithCurrentUser
will do a few things:
- Fetch user data that Blockstack.js stores in
localStorage
- Save the user's public data (including their public key) in Radiks-server
- Find or create a signing key that is used to authorize writes on behalf of this user
- Cache the user's signing key (and any group-related signing keys) to make signatures and decryption happen quickly later on
Creating models for your application's data is where radiks truly becomes helpful. We provide a Model
class that you can extend to easily create, save, and fetch models.
import { Model, User } from 'radiks';
class Todo extends Model {
static className = 'Todo';
static schema = { // all fields are encrypted by default
title: String,
completed: Boolean,
}
};
// after authentication:
const todo = new Todo({ title: 'Use Radiks in an app' });
await todo.save();
todo.update({
completed: true,
});
await todo.save();
const incompleteTodos = await Todo.fetchOwnList({ // fetch todos that this user created
completed: false
});
console.log(incompleteTodos.length); // 0
To create a model class, first import the Model
class from radiks. Then, create a class that extends this model, and provide a schema.
Important: Make sure you add a static className
property to your class. This is used when storing and querying information. If you don't add this, radiks will default to the actual model's class name. However, in production, your code will likely be minified, and the actual class name will be different. For this reason, it's highly recommended that you define the className
manually.
The first static property you'll need to define is a schema. Create a static schema
property on your class to define it. Each key
in this object is the name of the field. The value is whatever type you want the field to be, or you can pass some options.
If you don't want to include any options, just pass the class for that field, like String
, Boolean
, or Number
.
To include options, pass an object, with a mandatory type
field. The only supported option right now is decrypted
. This defaults to false
, but if you provide true
, then this field will not be encrypted before storing data publicly. This is useful if you want to be able to query this field when fetching data.
Important: do not add the decrypted
option to fields that contain sensitive user data. Remember, because this is decentralized storage, anyone can read the user's data. That's why encrypting it is so important. If you want to be able to filter sensitive data, then you should do it on the client-side, after decrypting it. A good use-case for storing decrypted fields is to store a foreignId
that references a different model, for a "belongs-to" type of relation.
Include an optional defaults
static property to define default values for a field.
import { Model } from 'radiks';
class Person extends Model {
static className = 'Person';
static schema = {
name: String,
age: Number,
isHuman: Boolean,
likesDogs: {
type: Boolean,
decrypted: true // all users will know if this record likes dogs!
}
}
static defaults = {
likesDogs: true
}
}
All model instances have an _id
attribute. If you don't pass an _id
to the model (when constructing it), then an _id
will be created automatically using uuid/v4
. This _id
is used as a primary key when storing data, and would be used for fetching this model in the future.
In addition to automatically creating an _id
attribute, radiks also creates a createdAt
and updatedAt
property when creating and saving models.
To create an instance of a model, pass some attributes to the constructor of that class:
const person = new Person({
name: 'Hank',
isHuman: false,
likesDogs: false // just an example, I love dogs!
})
To fetch an existing model, first construct it with a required id
property. Then, call the fetch()
function, which returns a promise.
const person = await Person.findById('404eab3a-6ddc-4ba6-afe8-1c3fff464d44');
After calling fetch
, radiks will automatically decrypt all encrypted fields.
All attributes (other than id
) are stored in an attrs
property on the model.
const { name, likesDogs } = person.attrs;
console.log(`Does ${name} like dogs?`, likesDogs);
To quickly update multiple attributes of a model, pass those attributes to the update
function.
const newAttributes = {
likesDogs: false,
age: 30
}
person.update(newAttributes)
Note that calling update
does not save the model.
To save a model to Gaia and MongoDB, call the save
function. First, it encrypts all attributes that do not have the decrypted
option in their schema. Then, it saves a JSON representation of the model in Gaia, as well as in MongoDB. save
returns a promise.
await person.save();
To delete a model, just call the destroy
method on it.
await person.destroy();
To fetch multiple records that match a certain query, use the class's fetchList
function. This method creates an HTTP query to Radiks-server, which then queries the underlying database. Radiks-server uses the query-to-mongo
package to turn an HTTP query into a MongoDB query. Read the documentation for that package to learn how to do complex querying, sorting, limiting, etc.
Here are some examples:
const dogHaters = await Person.fetchList({ likesDogs: false });
Or, imagine a Task
model with a name
, a boolean for completed
, and an order
attribute.
class Task extends Model {
static className = 'Task';
static schema = {
name: String,
completed: {
type: Boolean,
decrypted: true,
},
order: {
type: Number,
decrypted: true,
}
}
}
const tasks = await Task.fetchList({
completed: false,
sort: '-order'
})
You can also get the count record directly.
const dogHaters = await Person.count({ likesDogs: false });
// dogHaters is the count number
Use the fetchOwnList
method to find models that were created by the current user. By using this method, you can preserve privacy, because Radiks uses a signingKey
that only the current user knows.
const tasks = await Task.fetchOwnList({
completed: false
});
It is common for applications to have multiple different models, where some reference another. For example, imagine a task-tracking application where a user has multiple projects, and each project has multiple tasks. Here's what those models might look like:
class Project extends Model {
static className = 'Project';
static schema = { name: String }
}
class Task extends Model {
static className = 'Task';
static schema = {
name: String,
projectId: {
type: String,
decrypted: true,
}
completed: Boolean
}
}
Whenever you save a task, you'll want to save a reference to the project it's in:
const task = new Task({
name: 'Improve radiks documentation',
projectId: project._id
})
await task.save();
Then, later you'll want to fetch all tasks for a certain project:
const tasks = await Task.fetchList({
projectId: project._id,
})
Radiks lets you define an afterFetch
method, which you can use to automatically fetch child records when you fetch the parent instance.
class Project extends Model {
static className = 'Project';
static schema = { name: String }
async afterFetch() {
this.tasks = await Task.fetchList({
projectId: this.id,
})
}
}
const project = await Project.findById('some-id-here');
console.log(project.tasks); // will already have fetched and decrypted all related tasks
You can extend the default user model to add your own fields.
import { User } from 'radiks';
// For example I want to add a public name on my user model
class MyAppUserModel extends User {
static schema = {
...User.schema,
name: {
type: String,
decrypted: true,
},
};
}
A key feature of Radiks is support for private collaboration between multiple users. Supporting collaboration with client-side encryption and user-owned storage can be complicated, but the patterns to implement it are generally the same for different apps. Radiks provides out-of-the box for collaboration, making it easy to build private, collaborative apps.
Radiks is built in a way that provides maximum privacy and security for collaborative groups. Radiks-server and external users have no knowledge about who is in a group.
The key model behind a collaborative group is UserGroup
. By default, it only has one attribute, name
, which is encrypted. You can create multiple subclasses of UserGroup
later on with different attributes, if you need to.
The general workflow for creating a collaborative group that can share and edit encrypted models is as follows:
- The admin of the group creates a new
UserGroup
, which acts as the 'hub' and controls the logic around inviting and removing users. - The admin invites one or more other users to a group:
- The admin specifies the username of the user they want to invite
- Radiks looks up the user's public key
- Radiks creates an 'invitation' that is encrypted with the user's public key, and contains information about the
UserGroup
- When the invited user 'activates' an invitation, they create a
GroupMembership
, which they can later use to reference information (such as private keys and signing keys) related to the group.
- Later on, members of the group can create and update models that are related to the group. These models must contain a reference to the group, using the attribute
userGroupId
. This allows Radiks to know which keys to use for encryption and signing. - The admin of the group can later remove a user from a group. They do this by creating a new private key for signing and encryption, and updating the
GroupMembership
of all users except the user they just removed. - After a key is rotated, all new and updated models must use the new key for signing. Radiks-server validates all group-related models to ensure that they're signed with the most up-to-date key.
import { UserGroup } from 'radiks';
// ...
const group = new UserGroup({ name: 'My Group Name' });
await group.create();
Calling create
on a new UserGroup
will create the group and activate an invitation for the creator of the group.
Use the makeGroupMembership
method on a UserGroup
instance to invite a user. The only argument passed to this method is the username of the user you want to invite.
import { UserGroup } from 'radiks';
const group = await UserGroup.findById(myGroupId);
const usernameToInvite = 'hankstoever.id';
const invitation = await group.makeGroupMembership(usernameToInvite);
console.log(invitation._id); // the ID used to later activate an invitation
Use the activate
method on a GroupInvitation
instance to activate an invitation:
import { GroupInvitation } from 'radiks';
const invitation = await GroupInvitation.findById(myInvitationID);
await invitation.activate();
Call UserGroup.myGroups
to fetch all groups that the current user is a member of:
import { UserGroup } from 'radiks';
const groups = await UserGroup.myGroups();
Use the method UserGroup.find(id)
when fetching a specific UserGroup. This method has extra boilerplate to handle decrypting the model, because the private keys may need to be fetched from different models.
const group = await UserGroup.find('my-id-here');
Radiks-server
provides a websocket endpoint that will stream all new inserts and updates that it sees on the server. Radiks
provides a helpful interface to poll for these changes on a model-by-model basis. For example, if you had a Task
model, you could get real-time updates on all your tasks. This is especially useful in collaborative environments. As soon as a collaborator updates a model, you can get the change in real-time, and update your views accordingly.
Before you can implement the websocket function, you must configure your Radiks-Server
with express-ws
const app = express()
expressWS(app)
Here's an example for how to use the API:
import Task from './models/task';
const streamCallback = (task) => {
// this callback will be called whenever a task is created or updated.
// `task` is an instance of `Task`, and all methods are defined on it.
// If the user has the necessary keys to decrypt encrypted fields on the model,
// the model will be decrypted before the callback is invoked.
if (task.projectId === myAppsCurrentProjectPageId) {
// update your view here with this task
}
}
Task.addStreamListener(streamCallback)
// later on, you might want to remove the stream listener (if the
// user changes pages, for example). When calling `removeStreamListener`,
// you MUST provide the exact same callback that you used with `addStreamListener`.
Task.removeStreamListener(streamCallback)
Sometimes, you need to save some data on behalf of the user that only the server is able to see. A common use case for this is when you want to notify a user, and you need to store, for example, their email. This should be updatable only by the user, and only the server (or that user) should be able to see it. Radiks provides the Central
API to handle this:
import { Central } from 'radiks';
const key = 'UserSettings';
const value = { email: 'myemail@example.com' };
await Central.save(key, value);
const result = await Central.get(key);
console.log(result); // { email: 'myemail@example.com' }
To compile with babel, run:
yarn compile
# or, to auto-compile when files change
yarn compile-watch
To run tests:
yarn test
To run eslint
:
yarn eslint