Paperback Server using the parse-server module on Express.
npm run devto run mongo and dashboardnpm run mongoto run the Mongo database servernpm run dashboardto run the Parse dashboard for database inspectionnpm run localorheroku localto run the web server locally in a Heroku environmentnpm testto run behavior tests
- Create a new Heroku instance either by clicking on the following button or by going to heroku.com/deploy.
- Set Config Variables in Settings to the following:
APP_ID= A short ID for the app, e.g.pbserverAPP_NAME= Name of the product, e.g.PaperbackMASTER_KEY= Administration pass key, must remain secret, e.g.ZBv4Rsk7PMONGODB_URI= MongoDB database url including username and password, can be shared across instances if they are properly prefixedREDIS_URL= Redis server url including username and password, can be shared across instances by using different database indices, e.g.redis://10.0.0.0:6379/?db=0orredis://10.0.0.0:6379/?db=1. To make sure instances are isolated, use a different index for each instance. Default is index 0, Heroku Redis Premium supports 512 database indices, from 0 to 511.PARSE_MOUNT= Url prefix for Parse access, most likely/parseSERVER_ROOT= External url used to access this instance, usuallyhttp://<instance-name>.herokuapp.comANDROID_SENDER_ID= Android FCM Sender ID used for push notifications on Android, e.g.12345678912ANDROID_API_KEY= Android GCM Server key used for push notifications on Android, e.g.AAAABcDaBCD:ABC12aBCDeF-AbC...VERIFICATION_EMAIL_SENDER= Email address to send from for account verification, e.g.support@example.comMAILGUN_API_KEY= Mailgun Account API key to use for sending emails, e.g.key-12345abcd123456abcdMAILGUN_DOMAIN= Mailgun Account domain to send from, e.g.sandboxabcd.mailgun.orgIOS_BUNDLE= Name of the iOS app bundle used for push notifications, e.g.com.example.PaperbackAppIOS_PRODUCTION=truefor production push notification certificates,falsefor development certificatesIOS_PASSPHRASE= Password for the iOS push notifications certificateIOS_CERTIFICATE= (new, optional) Path to the iOS certificate file used for push notifications, defaults topush/PushCertificate.p12MONGODB_PREFIX= (new, optional) Prefix for MongoDB database collection names, allows sharing the same database across multiple instances, defaults toAPP_ID+_(underscore)REDIS_PREFIX= (new, optional) Prefix for redis server keys used for the kue queue system, defaults toq, isolation between instances should be done via database indices (see above)
-
Push the server code to Heroku via Git using the steps described on the project dashboard. Take note that you might need to update/change/add the push certificate file for iOS first.
-
The server should now be operational and accessible via
http://<instance-name>.herokuapp.com.
You should have your MONGODB_URI set in the Heroku dashboard in the following
format already if you are using the mLab MongoDB addon:
mongodb://<username>:<password>@<host>:<port>/<database-name>
The individual values usually look something like this:
- username:
heroku_abcd1234 - password:
abcdef12345abcdef12345abcdef12345 - host:
xx123456.mlab.com - port:
12345 - database-name: usually the same as username
You can import the included Parse schema by running the following command from the repository working directory substituting the values with ones from your MongoDB connection url (see above).
mongorestore -h <host>:<port> -d <database-name> -u <username> -p <password> schema/dev
On an uninitialized database, this should be enough. For an existing database,
you'll have to drop the _SCHEMA collection first with the following:
mongo --eval "db.getCollection('_SCHEMA').drop()" <MONGODB_URI>
Backup an entire remote database with mongodump:
mongodump -h <host>:<port> -d <database-name> -u <username> -p <password> -o dump-dir
Restore a remote database from a local dump directory with mongorestore:
mongorestore -h <host>:<port> -d <database-name> -u <username> -p <password> -o dump-dir/<database-name>
Push status logs can usually accumulate over time, so you can add a TTL (Time To Live) index for the _PushStatus collection by connecting with the mongo cli tool to the database and
executing the following line:
db.getCollection('_PushStatus').createIndex({ "_created_at": 1 }, { expireAfterSeconds: 21600 })
The entries will then get autodeleted after the specified amount of seconds +- a few minutes.
If you want to change the value in seconds you have to either delete the index and create
a new one or use the appropriate MongoDB commands to modify the value of expireAfterSeconds.
All of the returned responses are wrapped in a result object if successful, e.g.:
{
"result": {
"available": true,
"code": 1
}
}
code is always < 1000 for successful results and it represents the response
code for the message to allow for easier application logic. See constants.js
for all the message code definitions.
For brevity purposes all cloud function responses are assumed to be wrapped in
this result structure. The return codes are specified as constant names,
found in constants.js, to avoid multiple possibly conflicting definitions,
with / separating successful and error codes.
If the request was unsuccessful, an error is returned, e.g.:
{
"code": 141,
"error": {
"message": "Contact not found.",
"code": 1404
}
}
For application-level logic, the top level code is always 141, meaning that
the Cloud Code script failed. The error field also contains details about the
failure, like a descriptive human readable message and a specific failure
error code.
The specific error code is between 1000 and 1998 for application errors,
1999 for "other" Parse errors and between 2000 and 2999 for specific Parse
errors that occur in the middle of application logic. For Parse errors you can
get the server-level error code by subtracting 2000 from the error code.
See constants.js for all application error definitions.
For Parse server-level errors, see Parse Server documentation.
{
// Username should be an email that is then also used for verification and
// password reset.
"username": "test@example.com",
// Email should not be used anymore, it is set to be the same as the username
// automatically.
// "email": --- deprecated ---,
"password": "password",
// Required to be unique, you can check for availability with `checkNameFree`.
"displayName": "Signey",
"avatar": integer
}
{
"objectId": "wL3sIcT2NA",
"createdAt": "2016-11-17T15:02:16.447Z",
"sessionToken": "r:31ba286ce8adbee3aa938f79d99d0cdc"
}
{
"code": 200,
"error": "bad or missing username"
}
{
"code": 201,
"error": "password is required"
}
// This applies for email as well, since they are equivalent.
{
"code": 202,
"error": "Account already exists for this username."
}
{
"code": 141,
"error": {
"id": 1005,
"m": "Display name already taken."
}
}
{
"email": "test@example.com"
}
// Empty on success (sends password reset email)
{}
{
"code": 204,
"error": "you must provide an email"
}
{
"code": 205,
"error": "No user found with email test."
}
All of the cloud functions below require you to be logged in as a user. Email verification is required before first login.
0 -> Init
1 -> Lobby
2 -> Running
3 -> Ended
0 -> Active
1 -> Inactive
0 -> None
1 -> Easy
2 -> Medium
3 -> Hard
"creator"
"open"
"invite"
"none"
"ai"
0 -> Player
1 -> Timeout
{
"displayName": "name"
}
{
"code": AVAILABILITY / INVALID_PARAMETER,
"available": true|false
"reason": {
"code": / INVALID_PARAMETER | DISPLAY_NAME_BLACKLISTED | DISPLAY_NAME_TAKEN,
"message": string
}
}
Change user preferences, currently only supports changing the avatar.
{
"avatar": integer
}
{
"code": USER_SAVED / INVALID_PARAMETER
}
{
// Optional game type identifier. Defaults to undefined.
// This can then be filtered on later.
"typeId": integer,
// You can provide any number of slots (within reason)
// for the game, but there has to be exactly
// one `creator` slot.
// The order of the slots defines the order
// of the game turns / rounds.
//
// This generates a `playerNum` field, which holds the
// number of non-`none`-type slots, and an `isRandom` field,
// which is `true` if there are any `open`-type slots.
"slots": [
// A publicly open slot, at least one
// slot has to be open to mark the game as `isRandom`
// and make it findable via `findGames`
{ "type": "open" },
// Exactly one of these has to be present. The creator
// gets automatically assigned to this slot.
{ "type": "creator" },
// Reserved slot by display name. Converted to `userId`
// on game creation to lock down the specified user, which
// might be useful if display names are ever changeable.
{ "type": "invite", "displayName": "name" },
// AI-type slot, not implemented correctly right now.
// `difficulty` is an integer enum defined above.
{ "type": "ai", "difficulty": integer },
// Accepted for now, but not really useful? Maybe for easier
// mapping of indices.
{ "type": "none" },
],
"fameCards": {
"The Chinatown Connection": 6,
"Dead Planet": 4,
"Vicious Triangle": 3,
"Lady of the West": 1
},
// After this many seconds, the turn ends automatically and the game
// transitions to the next player
"turnMaxSec": 60
}
// Game join response object
{
"code": GAME_CREATED / GAME_QUOTA_EXCEEDED | GAME_INVALID_CONFIG | GAME_PLAYERS_UNAVAILABLE,
// Game object
"game": {
"objectId": "id",
"config": {
// Number of players in the game, i.e. number of slots that do not have
// the "none" type.
"playerNum": 2,
...
}
...
},
// Number of players after the game was joined
"playerCount": 3,
// Player object of the user
"player": {
"objectId": "id",
// Slot index number of the player
// Assigned automatically based on the provided slots
"slot": integer
...
}
}
{
"gameId": "id"
}
{
"code": GAME_INVITE / PLAYER_NOT_FOUND,
"link": "url of the invite website",
"invite": {
"objectId": "id" // The invite ID
}
}
Declines the player invitation for the specified game and changes the relevant game slot to open-type.
{
"gameId": "id"
}
{
"code": GAME_INVITE_DECLINED / GAME_NOT_FOUND | GAME_INVITE_ERROR,
}
{
"gameId": "id"
}
// Game join response object (see createGame)
{
"code": GAME_JOINED / GAME_NOT_FOUND | PLAYER_ALREADY_IN_GAME | GAME_INVALID_STATE,
"game": {...},
"playerCount": 3,
"player": {...}
}
Used to leave the specified game and inactivate the player. If the game is still running, the user slot is replaced with an AI slot. All players must use this at the end of the game to indicate that they are done with it and so it can get destroyed.
{
"gameId": "id"
}
{
"code": GAME_LEFT / PLAYER_NOT_IN_GAME | GAME_INVALID_STATE | GAME_NOT_FOUND,
"player": {...}
}
Find games open to the public, i.e. with at least one open-type slot.
{
// Game type id to filter on (optional)
"typeId: integer
// How many games to return sorted by least recent first
"limit": integer (default 20, min 1, max 100)
// How many games to skip (for pagination)
"skip": integer (default 0)
}
{
"code": GAME_LIST,
// Game objects with `isRandom` being `true`
"games": [
{
...
// `true` when you have already joined this game, otherwise `false`.
"joined": true,
// Number of free open slots available. This excludes invite slots and
// open slots already taken up by other players.
"freeSlots": 1,
// See `listGames` for the rest of the properties.
...
}
]
}
List games with an invite slot for the calling user.
{
// Game type id to filter on (optional)
"typeId: integer
// How many games to return sorted by most recent first
"limit": integer (default 20, min 1, max 100)
// How many games to skip (for pagination)
"skip": integer (default 0)
}
{
"code": GAME_LIST,
// Game objects
"games": [
{
// See `listGames` for the properties.
...
}
]
}
List all the games the logged-in user is currently participating in.
{
// Game type id to filter on (optional)
"typeId: integer
// How many games to return sorted by most recent first
"limit": integer (default 20, min 1, max 100)
// How many games to skip (for pagination)
"skip": integer (default 0),
// Optionally filter to specific game IDs
"gameIds": ["idA", "idB", ...]
}
{
"code": GAME_LIST,
"games": [
// Game one
{
"objectId": "idA",
// State the game is in, see "Game State" above.
"state": integer,
// Turn number starting from 0, incremented every turn.
"turn": integer,
// `true` if the game is able to be manually started via
// `startGame` by the creator.
"startable": true|false,
// Number of free open slots available. This excludes invite slots,
// the creator slot and open slots already taken up by other players.
"freeSlots": 1,
// Should always be `true` for `listGames`
"joined": true,
"config": [
// Game type id, if it exists.
"typeId": integer
"slots": [{
// See "Slot Type" above.
"type": string,
// `true` if a player is occupying this slot.
// Always `true` for AI-type slots.
"filled": true|false,
// If filled, a constrained Player object. Not available for
// AI slot types, except if the AI slot is a result of the
// player dropping.
"player": {
// User object
"user": {
"displayName": string,
"avatar": integer,
"objectId": string
},
// Active or inactive, see Player State above.
"state": integer,
},
// AI difficulty as specified in the create game configuration,
// only present for AI-type slots.
"difficulty": integer
},
{
"type": "open",
"filled": true,
"player": {
"slot": 1,
"className": "Player"
}
}
]
"playerNum": integer,
"isRandom": true|false,
"turnMaxSec": integer,
]
...
},
// Game two
{
"objectId": "idB",
...
},
...
]
}
{
// How many contacts to return sorted by most recent first
"limit": integer (default 100, min 1, max 1000)
// How many contacts to skip (for pagination)
"skip": integer (default 0)
}
{
"code": CONTACT_LIST,
"contacts": [
{
"displayName": "Ally",
"objectId": "idA",
// You can set this number on user signup or with `userSet`
"avatar": integer
},
{
"displayName": "Bobzor",
"objectId": "idB",
"avatar": integer
}
]
}
{
"displayName": "name"
}
{
"code": CONTACT_ADDED / USER_NOT_FOUND | CONTACT_NOT_FOUND | CONTACT_EXISTS,
"contact": {
// The user that added the contact
"user": {
"displayName": "Carry",
"objectId": "ElX0nxSAy7",
...
},
// The contact that was added
"contact": {
"displayName": "Ally",
"objectId": "etSAhagpLp",
...
}
},
}
Block a user from inviting the calling user to games. Unblock by calling
addFriend again. Creates a contact with blocked: true if it doesn't
exist yet.
{
"displayName": "name"
}
{
"code": CONTACT_BLOCKED / USER_NOT_FOUND
}
{
"displayName": "name"
}
{
"code": CONTACT_DELETED / USER_NOT_FOUND | CONTACT_NOT_FOUND
}
Start a game manually if it's able to be started (startable game property
in listGames should equal true). Only available to the creator of the game.
{
"gameId": "id"
}
// Game join response object (see createGame)
{
"code": GAME_STARTED / GAME_THIRD_PARTY | GAME_NOT_STARTABLE | GAME_START_ERROR | GAME_INSUFFICIENT_PLAYERS,
"game": {...},
"playerCount": integer,
"player": {...}
}
Add a new game turn. The player order is based on the slot index. Lower slot indexes are first, then it wraps around.
{
"gameId": "id",
"save": "save contents",
"final": true|false
}
{
"code": TURN_SAVED / TURN_NOT_IT | GAME_INVALID_STATE,
"ended": true|false
}
{
"gameId": "id",
// How many turns to return sorted by most recent first
"limit": integer (default 3, min 1, max 100)
// How many turns to skip (for pagination)
"skip": integer (default 0)
}
{
"code": TURN_LIST / TURN_THIRD_PARTY | GAME_NOT_FOUND,
"turns": [
{
// Player object (with a User)
"player": {...},
// Type, e.g. player made turn or
// turn made by timeout.
// See "Turn Type" above.
"type": integer,
// Turn index
"turn": integer,
// Save contents provided in `gameTurn`.
// In case of a timeout, this should equal
// the last valid turn save made or `null`
// if there were no valid turns yet i.e.
// game starts with a turn timeout.
"save": string
},
{
"player": {...},
"turn": 3,
"save": "qwertz"
},
{
"player": {...},
"turn": 2,
"save": "asdfg"
},
...
]
}
Stores a token for push notifications in the database.
Make sure you've sent an X-Parse-Installation-Id header with login and signup
requests, so the server can have a unique installation ID for the device.
Here is an example on how to generate the installation ID: https://github.com/parse-community/Parse-SDK-JS/blob/master/src/InstallationController.js#L18..L32
If you don't send an installation ID along with your signup and login requests, storing the push token will fail.
{
"deviceToken": "Firebase registration token here",
"pushType": "gcm" // Optional, defaults to "gcm"
}
Note that gcm should be used as the pushType not only for GCM, but also for
FCM/Firebase push notifications, as they share the same push code.
{
"code": PUSH_TOKEN_SET / PUSH_TOKEN_ERROR | INVALID_PARAMETER
}
See schema/schema.json.
example save game https://gist.github.com/MarkFassett/4d256c6e526d92eaba3dccab6d0d384b
account flow
1. create account
2. click auth link sent to e-mail
3. log in with user/pass
pw recovery flow
4. request password recovery e-mail via ui
5. link to click to reset pw and related form
new device login
send notification e-mail
keep track of user's devices
Challenge flow
1. create game, set max slots
2. send challenge link(s) or request random players
if random flag set, then people can join by request random
3. start play when ready (min 2 players)
Other items
Need push notifications to drive play
Should keep log of all games
Ranking system
maybe just go by avg score due to absence of griefing/competitive scores?
or by deviation from group norm to normalize for the cards?
Or something even more or less clever...
/checkNameFree?displayName=
/createAccount?user=&login=&displayname=
- check no obscene name
- check unique display name
/authenticate?user=&login=&deviceId=
/recoverPassword?email=
/createGame?settings=
get back shortlink to send to let people play with you
properties
max players
# of fame cards (1-16)
AI player count
??? max turn time ??? - if expired next guy is notified and runs AI for the idle player
/requestGame
used to join random game
create a lobby if none present
lobbies will time out and start play to keep things going
potentially secret AI?
/gameTurn?gameId&state
Upload save game for next player and notify them it's ready
16kb currently
4 player will be couple kb more
/listGames
get back state of all games you are involved in
/listFriends
get list of all known buddies
/deleteFriend
remove friend from list
Read the full Parse Server guide here: https://github.com/ParsePlatform/parse-server/wiki/Parse-Server-Guide
- Make sure you have at least Node 4.3.
node --version - Clone this repo and change directory to it.
npm install- Install mongo locally using https://docs.mongodb.com/master/administration/install-community/
- Run
mongoto connect to your database, just to make sure it's working. Once you see a mongo prompt, exit with Control-D - Run the server with:
npm start - By default it will use a path of /parse for the API routes. To change this, or use older client SDKs, run
export PARSE_MOUNT=/1before launching the server. - You now have a database named "dev" that contains your Parse data
- Install ngrok and you can test with devices
- Clone the repo and change directory to it
- Log in with the Heroku Toolbelt and create an app:
heroku create - Use the mLab addon:
heroku addons:create mongolab:sandbox --app YourAppName - By default it will use a path of /parse for the API routes. To change this, or use older client SDKs, run
heroku config:set PARSE_MOUNT=/1 - Deploy it with:
git push heroku master
- Clone the repo and change directory to it
- Log in with the AWS Elastic Beanstalk CLI, select a region, and create an app:
eb init - Create an environment and pass in MongoDB URI, App ID, and Master Key:
eb create --envvars DATABASE_URI=<replace with URI>,APP_ID=<replace with Parse app ID>,MASTER_KEY=<replace with Parse master key>
A detailed tutorial is available here: Azure welcomes Parse developers
- Clone the repo and change directory to it
- Create a project in the Google Cloud Platform Console.
- Enable billing for your project.
- Install the Google Cloud SDK.
- Setup a MongoDB server. You have a few options:
- Create a Google Compute Engine virtual machine with MongoDB pre-installed.
- Use MongoLab to create a free MongoDB deployment on Google Cloud Platform.
- Modify
app.yamlto update your environment variables. - Delete
Dockerfile - Deploy it with
gcloud preview app deploy
A detailed tutorial is available here: Running Parse server on Google App Engine
- Clone the repo and change directory to it
- Log in with the Scalingo CLI and create an app:
scalingo create my-parse - Use the Scalingo MongoDB addon:
scalingo addons-add scalingo-mongodb free - Setup MongoDB connection string:
scalingo env-set DATABASE_URI='$SCALINGO_MONGO_URL' - By default it will use a path of /parse for the API routes. To change this, or use older client SDKs, run
scalingo env-set PARSE_MOUNT=/1 - Deploy it with:
git push scalingo master
- Register for a free OpenShift Online (Next Gen) account
- Create a project in the OpenShift Online Console.
- Install the OpenShift CLI.
- Add the Parse Server template to your project:
oc create -f https://raw.githubusercontent.com/ParsePlatform/parse-server-example/master/openshift.json - Deploy Parse Server from the web console
- Open your project in the OpenShift Online Console:
- Click Add to Project from the top navigation
- Scroll down and select NodeJS > Parse Server
- (Optionally) Update the Parse Server settings (parameters)
- Click Create
A detailed tutorial is available here: Running Parse Server on OpenShift Online (Next Gen)
Before using it, you can access a test page to verify if the basic setup is working fine http://localhost:1337/test. Then you can use the REST API, the JavaScript SDK, and any of our open-source SDKs:
Example request to a server running locally:
curl -X POST \
-H "X-Parse-Application-Id: myAppId" \
-H "Content-Type: application/json" \
-d '{"score":1337,"playerName":"Sean Plott","cheatMode":false}' \
http://localhost:1337/parse/classes/GameScore
curl -X POST \
-H "X-Parse-Application-Id: myAppId" \
-H "Content-Type: application/json" \
-d '{}' \
http://localhost:1337/parse/functions/hello
Example using it via JavaScript:
Parse.initialize('myAppId','unused');
Parse.serverURL = 'https://whatever.herokuapp.com';
var obj = new Parse.Object('GameScore');
obj.set('score',1337);
obj.save().then(function(obj) {
console.log(obj.toJSON());
var query = new Parse.Query('GameScore');
query.get(obj.id).then(function(objAgain) {
console.log(objAgain.toJSON());
}, function(err) {console.log(err); });
}, function(err) { console.log(err); });Example using it on Android:
//in your application class
Parse.initialize(new Parse.Configuration.Builder(getApplicationContext())
.applicationId("myAppId")
.server("http://myServerUrl/parse/") // '/' important after 'parse'
.build());
ParseObject testObject = new ParseObject("TestObject");
testObject.put("foo", "bar");
testObject.saveInBackground();Example using it on iOS (Swift):
//in your AppDelegate
Parse.initializeWithConfiguration(ParseClientConfiguration(block: { (configuration: ParseMutableClientConfiguration) -> Void in
configuration.server = "https://<# Your Server URL #>/parse/" // '/' important after 'parse'
configuration.applicationId = "<# Your APP_ID #>"
}))You can change the server URL in all of the open-source SDKs, but we're releasing new builds which provide initialization time configuration of this property.

