diff --git a/src/db/couchConnection.ts b/src/db/couchConnection.ts index 0ca83b1..1439178 100644 --- a/src/db/couchConnection.ts +++ b/src/db/couchConnection.ts @@ -28,7 +28,7 @@ export function makeLegacyConnection(connection: ServerScope): DbConnection { }, async getDeviceById(deviceId: string): Promise { - return await getDeviceById(connection, deviceId) + return await getDeviceById(connection, deviceId, '', new Date()) }, async getEventsByDeviceId(deviceId: string): Promise { diff --git a/src/db/couchDevices.ts b/src/db/couchDevices.ts index 2baedde..f64a58d 100644 --- a/src/db/couchDevices.ts +++ b/src/db/couchDevices.ts @@ -16,8 +16,8 @@ import { import { ServerScope } from 'nano' import { DeviceRow } from '../types/dbTypes' -import { asBase64 } from '../types/pushApiTypes' -import { Device, DeviceState } from '../types/pushTypes' +import { asBase64 } from '../types/pushCleaners' +import { Device, DeviceStatus } from '../types/pushTypes' /** * An API key, as stored in Couch. @@ -26,11 +26,10 @@ export const asCouchDevice = asCouchDoc>( asObject({ appId: asString, created: asDate, - deviceId: asString, - deviceToken: asOptional(asString), // Current token for notifications. - - enableLegacyPrices: asBoolean, // For legacy v1 API. + // Status: + deviceToken: asOptional(asString), + enableLegacyPrices: asBoolean, rootLoginIds: asArray(asBase64), visited: asDate }) @@ -42,17 +41,32 @@ export const devicesSetup: DatabaseSetup = { name: 'push-devices' } +/** + * Looks up a device by its id. + * If the device does not exist in the database, creates a fresh row. + */ export async function getDeviceById( connection: ServerScope, - deviceId: string -): Promise { + deviceId: string, + appId: string, + date: Date +): Promise { const db = connection.use(devicesSetup.name) const raw = db.get(deviceId).catch(error => { if (asMaybeNotFoundError(error) != null) return throw error }) - if (raw == null) return + if (raw == null) + return makeDeviceRow(connection, { + doc: { + appId, + created: date, + enableLegacyPrices: false, + rootLoginIds: [], + visited: date + } + }) return makeDeviceRow(connection, raw) } @@ -65,8 +79,8 @@ function makeDeviceRow(connection: ServerScope, raw: unknown): DeviceRow { return { device, - async updateState(state: DeviceState): Promise { - // TODO: Merge our state with the remote state + async updateStatus(status: Partial): Promise { + // TODO: Merge our status with the remote status // date = Math.max(date1, date2), etc... // Assume that the last document we fetched is still current: @@ -74,7 +88,7 @@ function makeDeviceRow(connection: ServerScope, raw: unknown): DeviceRow { while (true) { // Write to the database: const doc: CouchDevice = { - doc: { ...device, ...state }, + doc: { ...device, ...status }, id: remote.id, rev: remote.rev } @@ -85,7 +99,7 @@ function makeDeviceRow(connection: ServerScope, raw: unknown): DeviceRow { // If that worked, the merged document is now the latest: if (response?.ok === true) { base = doc - Object.assign(device, state) + Object.assign(device, status) return } diff --git a/src/db/couchPushEvents.ts b/src/db/couchPushEvents.ts index 8001ea3..b0b1c4d 100644 --- a/src/db/couchPushEvents.ts +++ b/src/db/couchPushEvents.ts @@ -2,6 +2,8 @@ import { asArray, asBoolean, asDate, + asEither, + asNull, asObject, asOptional, asString, @@ -19,42 +21,33 @@ import { ServerScope } from 'nano' import { base64 } from 'rfc4648' import { PushEventRow } from '../types/dbTypes' -import { asBase64, asPushTrigger } from '../types/pushApiTypes' -import { PushEvent, PushEventState } from '../types/pushTypes' +import { + asBase64, + asPushEffect, + asPushEventState, + asPushTrigger +} from '../types/pushCleaners' +import { PushEvent, PushEventStatus } from '../types/pushTypes' /** - * An API key, as stored in Couch. + * A push event, as stored in Couch. */ -export const asCouchPushEvent = asCouchDoc>( +export const asCouchPushEvent = asCouchDoc( asObject({ - // Identity: + created: asDate, eventId: asString, deviceId: asOptional(asString), loginId: asOptional(asBase64), // Event: - broadcast: asOptional( - asArray( - asObject({ - pluginId: asString, - rawTx: asBase64 - }) - ) - ), - push: asOptional( - asObject({ - title: asOptional(asString), - body: asOptional(asString), - data: asObject(asString) - }) - ), + effects: asArray(asPushEffect), + recurring: asBoolean, trigger: asPushTrigger, - // State flags: - active: asBoolean, // Watch the trigger when true - triggered: asOptional(asDate), - broadcasted: asOptional(asDate), - pushed: asOptional(asDate) + // Status: + state: asPushEventState, + effectErrors: asOptional(asArray(asEither(asString, asNull))), + triggered: asOptional(asDate) }) ) const wasCouchPushEvent = uncleaner(asCouchPushEvent) @@ -217,7 +210,7 @@ function makePushEventRow(connection: ServerScope, raw: unknown): PushEventRow { return { event, - async updateState(state: PushEventState): Promise { + async updateStatus(status: Partial): Promise { // TODO: Merge our state with the remote state // date = Math.max(date1, date2), etc... @@ -226,7 +219,7 @@ function makePushEventRow(connection: ServerScope, raw: unknown): PushEventRow { while (true) { // Write to the database: const doc: CouchPushEvent = { - doc: { ...event, ...state }, + doc: { ...event, ...status }, id: remote.id, rev: remote.rev } @@ -239,7 +232,7 @@ function makePushEventRow(connection: ServerScope, raw: unknown): PushEventRow { // If that worked, the merged document is now the latest: if (response?.ok === true) { base = doc - Object.assign(event, state) + Object.assign(event, status) return } diff --git a/src/middleware/withDevice.ts b/src/middleware/withDevice.ts new file mode 100644 index 0000000..cce9418 --- /dev/null +++ b/src/middleware/withDevice.ts @@ -0,0 +1,48 @@ +import { asMaybe } from 'cleaners' +import { Serverlet } from 'serverlet' + +import { getApiKeyByKey } from '../db/couchApiKeys' +import { getDeviceById } from '../db/couchDevices' +import { asPushRequestBody } from '../types/pushApiTypes' +import { DbRequest, DeviceRequest } from '../types/requestTypes' +import { errorResponse } from '../types/responseTypes' + +/** + * Parses the request payload and looks up the device. + * Legacy routes do not use this one. + */ +export const withDevice = + (server: Serverlet): Serverlet => + async request => { + const { connection, date, log, req } = request + + // Parse the common request body: + const body = asMaybe(asPushRequestBody)(req.body) + if (body == null) { + return errorResponse('Bad request body', { status: 400 }) + } + + // Look up the key in the database: + const apiKey = await log.debugTime( + 'getApiKeyByKey', + getApiKeyByKey(connection, body.apiKey) + ) + if (apiKey == null) { + return errorResponse('Incorrect API key', { status: 401 }) + } + + // Look up the device in the database: + const deviceRow = await log.debugTime( + 'getDeviceById', + getDeviceById(connection, body.deviceId, body.appId, date) + ) + + // Pass that along: + return await server({ + ...request, + apiKey, + appId: body.appId, + deviceRow, + payload: body.data + }) + } diff --git a/src/routes/deviceRoutes.ts b/src/routes/deviceRoutes.ts index 299d01a..61318ed 100644 --- a/src/routes/deviceRoutes.ts +++ b/src/routes/deviceRoutes.ts @@ -1,38 +1,87 @@ +import { asMaybe, uncleaner } from 'cleaners' +import { ServerScope } from 'nano' import { Serverlet } from 'serverlet' -import { PushEvent } from '../types/pushTypes' -import { ApiRequest } from '../types/requestTypes' -import { jsonResponse } from '../types/responseTypes' +import { getEventsByDeviceId } from '../db/couchPushEvents' +import { + asDeviceCheckinPayload, + asDevicePayload, + asDeviceUpdatePayload +} from '../types/pushApiTypes' +import { Device } from '../types/pushTypes' +import { DeviceRequest } from '../types/requestTypes' +import { errorResponse, jsonResponse } from '../types/responseTypes' + +const wasDevicePayload = uncleaner(asDevicePayload) +type DevicePayload = ReturnType /** * POST /v2/device */ -export const deviceFetchRoute: Serverlet = async request => { - return jsonResponse({}) -} -export interface DevicePayload { - deviceId: string - deviceToken: string - events: PushEvent[] - rootLoginIds: Uint8Array[] // asArray(asBase64) - created: Date - visited: Date +export const deviceFetchRoute: Serverlet = async request => { + const { connection, deviceRow } = request + + return jsonResponse( + wasDevicePayload(await makeDevicePayload(connection, deviceRow.device)) + ) } + /** * POST /v2/device/checkin */ -export const deviceCheckinRoute: Serverlet = async request => { - return jsonResponse({}) +export const deviceCheckinRoute: Serverlet = async request => { + const { connection, date, deviceRow, payload } = request + + const clean = asMaybe(asDeviceCheckinPayload)(payload) + if (clean == null) { + return errorResponse('Incorrect device checkin payload', { status: 400 }) + } + + await deviceRow.updateStatus({ + deviceToken: clean.deviceToken, + visited: date + }) + + return jsonResponse( + wasDevicePayload(await makeDevicePayload(connection, deviceRow.device)) + ) } /** * POST /v2/device/update */ -export const deviceUpdateRoute: Serverlet = async request => { - return jsonResponse({}) +export const deviceUpdateRoute: Serverlet = async request => { + const { connection, date, deviceRow, payload } = request + + const clean = asMaybe(asDeviceUpdatePayload)(payload) + if (clean == null) { + return errorResponse('Incorrect device update payload', { status: 400 }) + } + + await deviceRow.updateStatus({ + deviceToken: clean.deviceToken, + rootLoginIds: clean.rootLoginIds, + visited: date + }) + + // TODO: + // clean.createEvents + // clean.removeEvents + + return jsonResponse( + wasDevicePayload(await makeDevicePayload(connection, deviceRow.device)) + ) } -export interface DeviceUpdatePayload { - rootLoginIds: Uint8Array[] // asArray(asBase64) - events: PushEvent[] - deviceToken: string + +async function makeDevicePayload( + connection: ServerScope, + device: Device +): Promise { + const eventRows = await getEventsByDeviceId(connection, device.deviceId) + + return { + deviceToken: device.deviceToken, + rootLoginIds: device.rootLoginIds, + events: eventRows.map(row => row.event) + } } diff --git a/src/routes/loginRoutes.ts b/src/routes/loginRoutes.ts index 7fa845c..008ae11 100644 --- a/src/routes/loginRoutes.ts +++ b/src/routes/loginRoutes.ts @@ -1,24 +1,53 @@ -import { PushEvent } from '../types/pushTypes' +import { asMaybe, uncleaner } from 'cleaners' +import { Serverlet } from 'serverlet' + +import { getEventsByLoginId } from '../db/couchPushEvents' +import { asLoginPayload, asLoginUpdatePayload } from '../types/pushApiTypes' +import { DeviceRequest } from '../types/requestTypes' +import { errorResponse, jsonResponse } from '../types/responseTypes' + +const wasLoginPayload = uncleaner(asLoginPayload) /** - * Reads a login from the database. * POST /v2/login */ -export interface LoginFetchPayload { - deviceIds: string - events: PushEvent[] - created: Date - visited: Date +export const loginFetchRoute: Serverlet = async request => { + const { connection, rootLoginId } = request + + if (rootLoginId == null) { + return errorResponse('No login provided', { status: 400 }) + } + + const eventRows = await getEventsByLoginId(connection, rootLoginId) + return jsonResponse( + wasLoginPayload({ + events: eventRows.map(row => row.event) + }) + ) } /** - * Registers / updates a login. * POST /v2/login/update */ -export interface LoginUpdatePayload { - events: PushEvent[] +export const loginUpdateRoute: Serverlet = async request => { + const { connection, payload, rootLoginId } = request + + if (rootLoginId == null) { + return errorResponse('No login provided', { status: 400 }) + } + const clean = asMaybe(asLoginUpdatePayload)(payload) + if (clean == null) { + return errorResponse('Incorrect login update payload', { status: 400 }) + } + + // TODO: + // clean.createEvents + // clean.removeEvents - // removeEvents?: string[] - // replaceEvents?: PushEvent[] - // newEvents?: PushEvent[] + const eventRows = await getEventsByLoginId(connection, rootLoginId) + return jsonResponse( + wasLoginPayload({ + events: eventRows.map(row => row.event) + }) + ) } diff --git a/src/types/dbTypes.ts b/src/types/dbTypes.ts index 27531bf..3534545 100644 --- a/src/types/dbTypes.ts +++ b/src/types/dbTypes.ts @@ -1,13 +1,13 @@ -import { Device, DeviceState, PushEvent, PushEventState } from './pushTypes' +import { Device, DeviceStatus, PushEvent, PushEventStatus } from './pushTypes' export interface DeviceRow { device: Device - updateState: (state: DeviceState) => Promise + updateStatus: (state: Partial) => Promise } export interface PushEventRow { event: PushEvent - updateState: (state: PushEventState) => Promise + updateStatus: (state: Partial) => Promise } export interface DbConnection { diff --git a/src/types/pushApiTypes.ts b/src/types/pushApiTypes.ts index 41cb020..dae93c8 100644 --- a/src/types/pushApiTypes.ts +++ b/src/types/pushApiTypes.ts @@ -1,85 +1,23 @@ import { asArray, - asCodec, + asBoolean, asDate, asEither, - asNumber, + asNull, asObject, asOptional, asString, asUnknown, - asValue, Cleaner } from 'cleaners' -import { base64 } from 'rfc4648' import { - AddressBalanceTrigger, - PriceChangeTrigger, - PriceLevelTrigger, - PushTrigger, - TxConfirmTrigger -} from './pushTypes' - -// --------------------------------------------------------------------------- -// Common types -// --------------------------------------------------------------------------- - -export const asBase64 = asCodec( - raw => base64.parse(asString(raw)), - clean => base64.stringify(clean) -) - -export const asAddressBalanceTrigger: Cleaner = asObject( - { - type: asValue('address-balance'), - pluginId: asString, - tokenId: asOptional(asString), - address: asString, - aboveAmount: asOptional(asString), // Satoshis or Wei or such - belowAmount: asOptional(asString) // Satoshis or Wei or such - } -) - -export const asPriceChangeTrigger: Cleaner = asObject({ - type: asValue('price-change'), - pluginId: asString, - tokenId: asOptional(asString), - dailyChange: asOptional(asNumber), // Percentage - hourlyChange: asOptional(asNumber) // Percentage -}) - -export const asPriceLevelTrigger: Cleaner = asObject({ - type: asValue('price-level'), - currencyPair: asString, // From our rates server - aboveRate: asOptional(asNumber), - belowRate: asOptional(asNumber) -}) - -export const asTxConfirmTrigger: Cleaner = asObject({ - type: asValue('tx-confirm'), - pluginId: asString, - confirmations: asNumber, - txid: asString -}) - -export const asPushTrigger: Cleaner = asEither( - asAddressBalanceTrigger, - asPriceChangeTrigger, - asPriceLevelTrigger, - asTxConfirmTrigger -) - -export const asPushBroadcast = asObject({ - pluginId: asString, - rawTx: asBase64 -}) - -export const asPushMessage = asObject({ - title: asOptional(asString), - body: asOptional(asString), - data: asObject(asString) -}) + asBase64, + asPushEffect, + asPushEventState, + asPushTrigger +} from './pushCleaners' +import { PushEvent } from './pushTypes' // --------------------------------------------------------------------------- // Request types @@ -102,9 +40,10 @@ export const asPushRequestBody = asObject({ /** * A freshly push event to create from the client. */ -export const asPushEventCreate = asObject({ - broadcast: asOptional(asArray(asPushBroadcast)), - push: asOptional(asPushMessage), +export const asNewPushEvent = asObject({ + eventId: asString, + effects: asArray(asPushEffect), + recurring: asBoolean, trigger: asPushTrigger }) @@ -120,41 +59,51 @@ export const asDeviceCheckinPayload = asObject({ */ export const asDeviceUpdatePayload = asObject({ deviceToken: asOptional(asString), - events: asArray(asPushEventCreate), - rootLoginIds: asArray(asBase64) + rootLoginIds: asArray(asBase64), + + createEvents: asOptional(asArray(asNewPushEvent)), + removeEvents: asOptional(asArray(asString)) }) /** * PUSH /v2/login/update payload. */ export const asLoginUpdatePayload = asObject({ - events: asArray(asPushEventCreate) + createEvents: asOptional(asArray(asNewPushEvent)), + removeEvents: asOptional(asArray(asString)) }) // --------------------------------------------------------------------------- // Response types // --------------------------------------------------------------------------- -export const asPushEvent = asObject({}) +/** + * A push event returned from a query. + */ +export const asPushEvent: Cleaner< + Omit +> = asObject({ + ...asNewPushEvent.shape, + + // Status: + state: asPushEventState, + effectErrors: asOptional(asArray(asEither(asString, asNull))), + triggered: asOptional(asDate) +}) /** * POST /v2/device response payload. */ export const asDevicePayload = asObject({ - deviceId: asString, - deviceToken: asString, - events: asArray(asPushEvent), + deviceToken: asOptional(asString), rootLoginIds: asArray(asBase64), - created: asDate, - visited: asDate + + events: asArray(asPushEvent) }) /** * POST /v2/login response payload. */ export const asLoginPayload = asObject({ - deviceIds: asString, - events: asArray(asPushEvent), - created: asDate, - visited: asDate + events: asArray(asPushEvent) }) diff --git a/src/types/pushCleaners.ts b/src/types/pushCleaners.ts new file mode 100644 index 0000000..778f68e --- /dev/null +++ b/src/types/pushCleaners.ts @@ -0,0 +1,94 @@ +import { + asCodec, + asEither, + asNumber, + asObject, + asOptional, + asString, + asValue, + Cleaner +} from 'cleaners' +import { base64 } from 'rfc4648' + +import { + AddressBalanceTrigger, + BroadcastEffect, + MessageEffect, + PriceChangeTrigger, + PriceLevelTrigger, + PushEffect, + PushEventState, + PushTrigger, + TxConfirmTrigger +} from './pushTypes' + +export const asBase64 = asCodec( + raw => base64.parse(asString(raw)), + clean => base64.stringify(clean) +) + +export const asAddressBalanceTrigger: Cleaner = asObject( + { + type: asValue('address-balance'), + pluginId: asString, + tokenId: asOptional(asString), + address: asString, + aboveAmount: asOptional(asString), // Satoshis or Wei or such + belowAmount: asOptional(asString) // Satoshis or Wei or such + } +) + +export const asPriceChangeTrigger: Cleaner = asObject({ + type: asValue('price-change'), + pluginId: asString, + tokenId: asOptional(asString), + dailyChange: asOptional(asNumber), // Percentage + hourlyChange: asOptional(asNumber) // Percentage +}) + +export const asPriceLevelTrigger: Cleaner = asObject({ + type: asValue('price-level'), + currencyPair: asString, // From our rates server + aboveRate: asOptional(asNumber), + belowRate: asOptional(asNumber) +}) + +export const asTxConfirmTrigger: Cleaner = asObject({ + type: asValue('tx-confirm'), + pluginId: asString, + confirmations: asNumber, + txid: asString +}) + +export const asPushTrigger: Cleaner = asEither( + asAddressBalanceTrigger, + asPriceChangeTrigger, + asPriceLevelTrigger, + asTxConfirmTrigger +) + +export const asBroadcastEffect: Cleaner = asObject({ + type: asValue('broadcast'), + pluginId: asString, + rawTx: asBase64 +}) + +export const asMessageEffect: Cleaner = asObject({ + type: asValue('message'), + title: asOptional(asString), + body: asOptional(asString), + data: asObject(asString) +}) + +export const asPushEffect: Cleaner = asEither( + asBroadcastEffect, + asMessageEffect +) + +export const asPushEventState: Cleaner = asValue( + 'waiting', + 'cancelled', + 'triggered', + 'complete', + 'hidden' +) diff --git a/src/types/pushTypes.ts b/src/types/pushTypes.ts index 1c19530..4274c59 100644 --- a/src/types/pushTypes.ts +++ b/src/types/pushTypes.ts @@ -32,9 +32,8 @@ export interface ApiKey { /** * Mutable state on a device object. */ -export interface DeviceState { +export interface DeviceStatus { deviceToken?: string // Current token for notifications. - enableLegacyPrices: boolean // For legacy v1 API. rootLoginIds: Uint8Array[] visited: Date @@ -42,8 +41,10 @@ export interface DeviceState { /** * An app installed on a single phone. + * + * This the in-memory format, independent of the database. */ -export interface Device extends DeviceState { +export interface Device extends DeviceStatus { readonly appId: string readonly created: Date readonly deviceId: string @@ -91,41 +92,53 @@ export type PushTrigger = | TxConfirmTrigger /** - * Mutable flags that we can toggle on a push event. + * Broadcasts a transaction to a blockchain. */ -export interface PushEventState { - active: boolean // Watch the trigger when true - triggered?: Date - broadcasted?: Date - pushed?: Date +export interface BroadcastEffect { + readonly type: 'broadcast' + readonly pluginId: string + readonly rawTx: Uint8Array // asBase64 } /** - * Information for broadcasting transactions in response to a trigger. + * Sends a push notification. */ -export interface PushBroadcast { - pluginId: string - rawTx: Uint8Array // asBase64 +export interface MessageEffect { + readonly type: 'message' + readonly title?: string + readonly body?: string + readonly data?: { [key: string]: string } // JSON to push to device } +export type PushEffect = BroadcastEffect | MessageEffect + +export type PushEventState = + | 'waiting' // Waiting for the trigger + | 'cancelled' // Removed before the trigger happened + | 'triggered' // The trigger happened, but not the effects + | 'complete' // The trigger and effects are done + | 'hidden' // Removed after being triggered + /** - * Information for sending a push notification in response to a trigger. + * Mutable properties on a push event. */ -export interface PushMessage { - title?: string - body?: string - data: { [key: string]: string } // JSON to push to device +export interface PushEventStatus { + state: PushEventState + effectErrors?: Array // For effects that fail + triggered?: Date // When did we see the trigger? } /** - * An action to perform once a trigger takes place. + * Combines a trigger with an action. + * This the in-memory format, independent of the database. */ -export interface PushEvent extends PushEventState { - readonly eventId: string +export interface PushEvent extends PushEventStatus { + readonly created: Date + readonly eventId: string // From the client, not globally unique readonly deviceId?: string readonly loginId?: Uint8Array - readonly broadcast?: PushBroadcast[] - readonly push?: PushMessage + readonly effects: PushEffect[] + readonly recurring: boolean // Go back to waiting once complete readonly trigger: PushTrigger } diff --git a/src/types/requestTypes.ts b/src/types/requestTypes.ts index f4acbcb..79956dc 100644 --- a/src/types/requestTypes.ts +++ b/src/types/requestTypes.ts @@ -1,6 +1,7 @@ import { ServerScope } from 'nano' import { ExpressRequest } from 'serverlet/express' +import { DeviceRow } from './dbTypes' import { ApiKey } from './pushTypes' export interface Logger { @@ -12,9 +13,9 @@ export interface Logger { export interface LoggedRequest extends ExpressRequest { // Logging stuff: - date: Date - ip: string - log: Logger + readonly date: Date + readonly ip: string + readonly log: Logger } export interface DbRequest extends LoggedRequest { @@ -25,6 +26,15 @@ export interface ApiRequest extends DbRequest { readonly apiKey: ApiKey // Taken from the Express request: - json: unknown - query: unknown + readonly json: unknown + readonly query: unknown +} + +export interface DeviceRequest extends DbRequest { + readonly payload: unknown + + readonly apiKey: ApiKey + readonly appId: string + readonly deviceRow: DeviceRow + readonly rootLoginId?: Uint8Array }