Skip to content

Database Rules

RTDB rules are written in Bolt which gets compiled to regular security rules json during deploy.

These rules check integrity of data being written and also the permissions of user writing there. Generally users should be only able to read and write their own data or data of a group they are part of. But there are nuances.

Group permissions

User can have the following permissions in a group:

  • None - cannot access group (with notable exception of preview, see below)
  • Read-only - only read group data
  • Read-Write - read, write
  • Owner - read, write, delete

Group Preview

Preview is enabled using the group's hash - if a user has a sharing link (and therefore its hash) they can set this hash into their user entity as inviteLinkHash and rules will enable them to read basic data about the group. If the user isn't signed into the app yet the app has to sign them in anonymously.

Once the user joins the group (adds their uid to /permissions/<groupId>/<uid>) their access will be driven by this permission.

Moreover, the name and ownerColor of a group are readable to anyone to be able to preview the group.

Database Rules

Database rules
// = Firebase Security Rules which gets compiled by Bolt =
// Guide: https://github.com/firebase/bolt/blob/master/docs/guide.md
// Language documentation: https://github.com/firebase/bolt/blob/master/docs/language.md
// Compile locally by running: `firebase-bolt < database.rules.bolt`


// 
// Archived groups
//

path /archivedGroups/{user_id} {
    read() { isAdmin() || isCurrentUser(user_id) }

    path /{group_id} is ArchivedGroup {
        write() { hasOwnerPermission(group_id) }
    }
}

type ArchivedGroup {
    name: String,
    ownerId: Id
}

hasArchivedGroup(group_id) {
    prior(root).archivedGroups[auth.uid][group_id] != null
}

//
// Deleted groups
//

path /deletedGroups {
    read() { isAdmin() }
    index() { "timestamp" }

    path /{group_id} is DeletedGroup {
        write() { hasOwnerPermission(group_id) }
    }
}

type DeletedGroup {
    name: String,
    ownerId: Id,
    timestamp: Number
}
//
// Campaigns
//

path /campaigns/{campaign_id}/{lang} is Campaign {
  read() { true }
  write() { isAdmin() }
}

type Campaign {
    title: String,
    description: String,
    imageUrl: PhotoUrl,
    actionLink: String,
    actionName: String
}

//
// Changes
//

path /changes/{group_id} {
  read() { isAdmin() || hasReadPermissionOrInviteLink(group_id) }
  index() { "serverTimestamp" }

  path /{change_id} is Change {
      write() { isAdmin() && isInsert(this) }
  }
}

type Change {
    action: Action,
    by: Id | Null,
    entity: Entity,
    entityId: Id | Null,
    entityName: EntityName | Null,
    serverTimestamp: Number
}

type Action extends String {
    validate() { this == "insert" || this == "update" || this == "delete" || this == "currencyChange" || this == "migrate" || this == "premiumPurchase" || this == "exchangeRatesChange" || this == "archive" || this == "restore" }
}

type Entity extends String {
    validate() { this == "expense" || this == "transfer" || this == "income" || this == "allTransactions" || this == "group" || this == "member" || this == "permission" || this == "recurringTransaction" || this == "futureTransaction" }
}

type EntityName extends String {
    validate() { hasMaxLength(this, 43)} // 2x member name + 3 chars for ->
}

//
// Exchange rates
//

path /exchangeRatesToUsd {
    read() { isSignedIn() }

    path /latest {
        read() { true } // latest exchange rates
    }

    path /{date}/{currency_code}/{exchange_rate} is Weight {}
}

//
// Groups
//

path /groups {
    index() { ["preview", "lastChanged"] }
}

path /groups/{group_id} is Group {
    read() { isAdmin() || hasReadPermissionOrInviteLink(group_id) }
    write() { (isAdmin() && !isDelete(this)) || hasOwnerPermission(group_id) }
    validate() { newData.child('premiumPurchasedBy').val() === data.child('premiumPurchasedBy').val() && newData.child('premiumPurchasedUntil').val() === data.child('premiumPurchasedUntil').val() }

    path /name {
        read() { true } // needed for basic group preview when link not active
    }

    path /ownerColor {
        read() { true } // needed for basic group preview when link not active
    }

    path /previewed {
        write() { isUpdate(this) && hasArchivedGroup(group_id.replace('--temp', '')) } // needed so all former group user can update last time group was previewed
    }
}

path /groupCategories/{group_id} {
    read() { isAdmin() || hasReadPermissionOrInviteLink(group_id) }
    write() { isAdmin() || hasWritePermission(group_id) }

    path /{category} is CategoryName {}
}

type Group {
    convertedToCurrency: CurrencyCode,
    inviteLink: InviteLink | Null,
    inviteLinkActive: Boolean,
    inviteLinkHash: InviteLinkHash | Null,
    lastChanged: Number | Null,
    migrated: Boolean | Null,
    minimizeDebts: Boolean,
    remindOldDebts: Boolean | Null,
    name: GroupName,
    ownerColor: Color,
    preview: Boolean | Null,
    previewed: Number | Null,
    defaultPermission: PermissionLevelLimited | Null
}

type InviteLink extends String {
    validate() { hasMaxLength(this, 1024) }
}

type GroupName extends String {
    validate() { hasMaxLength(this, 30)}
}

//
// Invite link hashes
//

path /inviteLinkHashes/{hash} is String {
    read() { isSignedIn() }
    write() { isAdmin() }
}

//
// Members
//

path /members/{group_id} {
    read() { isAdmin() || hasReadPermissionOrInviteLink(group_id) }
    write() { isAdmin() || hasWritePermission(group_id) }
    index() { "name" }

    path /{member_id} is Member { }   
}

type Member {
    active: Boolean,
    bankAccount: BankAccount | Null,
    lightningAddress: Email | Null,
    defaultWeight: Weight,
    migrated: Boolean | Null,
    name: MemberName,
    paymentHandles: PaymentHandle[] | Null,
    photoUrl: PhotoUrl | Null
}

type PaymentHandle extends String {
    validate() { hasMaxLength(this, 50) }
}

type BankAccount extends String {
    validate() { hasMaxLength(this, 50) }
}

type MemberName extends String {
    validate() { hasMaxLength(this, 20) }
}

//
// Offers
//

path /offers/{offer_id} {
    read() { isSignedIn() }
}

//
// Payment providers
//

path /paymentProviders {
    read() { isSignedIn() }

    path /{provider_id} is PaymentProvider {
        write() { isAdmin() }
    }
}

type PaymentProvider {
    platforms: Platform[] | Null,
    name: String,
    icon: PhotoUrl,
    link: String,
    handlePrefix: String | Null,
    amountFormat: AmountFormat,
    currencies: CurrencyCode[] | Null,
    regions: Region[] | Null,
    androidPackageName: String | Null,
    active: Boolean,
    order: Number
}

type AmountFormat extends String {
    validate() { this == "minor" || this == "decimal" }
}

type Region extends String {
    validate() { this.length >= 2 && this.length <= 3 }
}

//
// Partner premiums
//

path /partnerGroupPremiums/{partner_id}/{user_id}/{subscription_id} is Boolean {
    read() { isAdmin() }
    write() { isAdmin() }
}

//
// Permissions
//

path /permissions/{group_id} {
    read() { isAdmin() || hasReadPermission(group_id) }
    write() { isAdmin() || isOwnerDeletingPermissions(this, group_id) }

    path /{user_id} is Permission {
        write() {  
            isNewPermissionForNotYetCreatedGroup(this, group_id) || isNewWritePermissionWithInviteLink(this, group_id) || isRemovedPermissionForCurrentUser(this, user_id) || hasOwnerPermission(group_id)
        }
    }
}

isOwnerDeletingPermissions(value, group_id) {
    isSignedIn() && isDelete(value) && hasOwnerPermission(group_id)
}

isNewPermissionForNotYetCreatedGroup(value, group_id) {
    isSignedIn() && isInsert(value) && prior(root).groups[group_id] == null
}

isRemovedPermissionForCurrentUser(value, user_id) {
    isDelete(value) && isCurrentUser(user_id)
}

isNewWritePermissionWithInviteLink(value, group_id) {
    isInsert(value) && value.level <= 20 && hasValidInviteLink(group_id)
}

type Permission {
    level: PermissionLevel,
    migrated: Boolean | Null
}

type PermissionLevelLimited extends Number {
    validate() { this == 10 || this == 20 }
}

type PermissionLevel extends Number {
    validate() { this == 10 || this == 20 || this == 30 }
}

//
// Push registrations
// 

path /pushRegistrations/{user_id}/{device_token} is PushRegistration {
    write() { isCurrentUser(user_id) }
}

type PushRegistration {
    platform: Platform,
    url: PhotoUrl | Null,
    version: AppVersion | Null,
    timestamp: InitialTimestamp | Null
}

type Platform extends String {
    validate() { this == "android" || this == "ios" || this == "web" || this == "windows" }
}

//
// Rewards
//

path /userRewards/{user_id} {
    read() { isCurrentUser(user_id) }

    path /{reward_id} is UserReward { }
}

type UserReward {
    count: Number,
    target: Number,
    type: RewardType,
    subscriptionId: Id | Null
}

type RewardType extends String {
    validate() { this == "referral" }
}

//
// Server tasks
//

path /serverTasks/currencyChange/{task_id} is ServerTask<CurrencyTask> {
    read() { isSignedIn() }
    write() { isOwnerAndNewOrRemovedTask(this) }
}

path /serverTasks/deleteGroup/{task_id} is ServerTask<GroupTask> {
    read() { isSignedIn() }
    write() { isOwnerAndNewOrRemovedTask(this) }
}

path /serverTasks/deleteArchivedGroup/{task_id} is ServerTask<GroupTask> {
    read() { isSignedIn() }
    write() { isOwnerOfRestoringAndNewOrRemovedTask(this) }
}

path /serverTasks/archiveGroup/{task_id} is ServerTask<GroupTask> {
    read() { isSignedIn() }
    write() { isMemberAndNewOrRemovedTask(this) }
}

path /serverTasks/previewGroup/{task_id} is ServerTask<GroupTask> {
    read() { isSignedIn() }
    write() { isSignedInAndNewOrRemovedTask(this) }
}

path /serverTasks/restoreGroup/{task_id} is ServerTask<GroupTask> {
    read() { isSignedIn() }
    write() { isOwnerOfRestoringAndNewOrRemovedTask(this) }
}

path /serverTasks/deleteTransactions/{task_id} is ServerTask<GroupTask> {
    read() { isSignedIn() }
    write() { isOwnerAndNewOrRemovedTask(this) }
}

path /serverTasks/verifySubscription/{task_id} is ServerTask<SubscriptionTask> {
    read() { isSignedIn() }
    write() { isSignedInAndNewOrRemovedTask(this) }
}

path /serverTasks/verifyInitialSubscription/{task_id} is ServerTask<InitialSubscriptionTask> {
    read() { isSignedIn() }
    write() { isSignedInAndNewOrRemovedTask(this) }
}

path /serverTasks/activateEmptyGroupPremium/{task_id} is ServerTask<ActivateEmptyGroupPremiumTask> {
    read() { isSignedIn() }
    write() { isSignedInAndNewOrRemovedTask(this) }
}

path /serverTasks/verifyGroupPremium/{task_id} is ServerTask<VerifyGroupPremiumTask> {
    read() { isSignedIn() }
    write() { isSignedInAndNewOrRemovedTask(this) }
}

path /serverTasks/updateExchangeRates/{task_id} is ServerTask<UpdateExchangeRatesTask> {
    read() { isSignedIn() }
    write() { isSignedInAndNewOrRemovedTask(this) }
}

path /serverTasks/cloneGroup/{task_id} is ServerTaskBase<CloneGroupRequest, GroupResponse> {
    read() { isSignedIn() }
    write() { isSignedInAndNewOrRemovedTask(this) }
}

path /serverTasks/categorizeTransactions/{task_id} is ServerTaskBase<GroupTask, CategorizeTransactionsResponse> {
    read() { isSignedIn() }
    write() { isMemberAndNewOrRemovedTask(this) }
}

path /serverTasks/updateTransactionCategories/{task_id} is ServerTask<UpdateTransactionCategoriesRequest> {
    read() { isSignedIn() }
    write() { isMemberAndNewOrRemovedTask(this) }
}

path /serverTasks/deleteUser/{task_id} is ServerTaskBase<DeleteUserRequest, DeleteUserResponse> {
    read() { isSignedIn() }
    write() { isSignedInAndNewOrRemovedTask(this) }
}

path /serverTasks/activateUserReward/{task_id} is ServerTaskBase<ActivateUserRewardRequest, ActivateUserRewardResponse> {
    read() { isSignedIn() }
    write() { isSignedInAndNewOrRemovedTask(this) }
}

path /serverTasks/remindAllDebts/{task_id} is ServerTask<GroupTask> {
    read() { isSignedIn() }
    write() { isSignedInAndNewOrRemovedTask(this) }
}

path /serverTasks/remindDebt/{task_id} is ServerTask<RemindDebtTask> {
    read() { isSignedIn() }
    write() { isSignedInAndNewOrRemovedTask(this) }
}

path /serverTasks/cancelSubscription/{task_id} is ServerTask<SubscriptionIdTask> {
    read() { isSignedIn() }
    write() { isSignedInAndNewOrRemovedTask(this) }
}

path /serverTasks/calculateDebts/{task_id} is ServerTask<GroupTask> {
    read() { isSignedIn() }
    write() { isSignedInAndNewOrRemovedTask(this) }
}

path /serverTasks/importMemberBalances/{task_id} is ServerTask<ImportMemberBalancesTask> {
    read() { isSignedIn() }
    write() { isSignedInAndNewOrRemovedTask(this) }
}

isOwnerOfRestoringAndNewOrRemovedTask(value) {
    isDelete(value) || (prior(root).archivedGroups[auth.uid][value.request.groupId].ownerId == auth.uid && isInsert(value))
}

isOwnerAndNewOrRemovedTask(value) {
    isDelete(value) || (hasOwnerPermission(value.request.groupId) && isInsert(value))
}

isMemberAndNewOrRemovedTask(value) {
    isDelete(value) || (hasWritePermission(value.request.groupId) && isInsert(value))
}

isSignedInAndNewOrRemovedTask(value) {
    isSignedIn() && (isInsert(value) || isDelete(value))
}

type ServerTaskBase<TRequest, TResponse> {
    request: TRequest,
    response: TResponse | Null,
    serverTimestamp: InitialTimestamp | Null
}

type ServerTask<TRequest> extends ServerTaskBase<TRequest, Response> {
}

type Response {
    code : ResponseCode
}

type ResponseCode extends String {
    validate() { this == "ok" || this == "error" || this == "syncFailed" }
}

type SubscriptionIdTask {
    subscriptionId: Id;
}

type CurrencyTask {
    groupId: Id,
    targetCurrency: CurrencyCode
}

type UpdateExchangeRatesTask {
    groupId: Id,
    exchangeRatesToGroupCurrency: WeightOrAutomatic[]
}

type WeightOrAutomatic extends Weight | Automatic {
}

type Automatic extends String {
    validate() { this == "automatic" }
}

type GroupTask {
    groupId: Id
}

type ImportSource extends String {
    validate() { this == "splitwise" || this == "settleup" }
}

type ImportMemberBalancesTask {
    data: String,
    source: ImportSource
}

type RemindDebtTask extends GroupTask {
    fromMemberId: Id,
    toMemberId: Id,
    amount: Weight
}

type CloneGroupRequest {
    cloneMembers: Boolean,
    cloneDebts: Boolean,
    clonePermissions: Boolean,
    cloneRecurringTransactions: Boolean,
    archiveOldGroup: Boolean,
    debtTransactionPurpose: Purpose,
    oldGroupId: Id,
    oldGroupName: GroupName,
    newGroupName: GroupName
}

type CategorizeTransactionsResponse extends Response {
    results: Object | Null
}

type UpdateTransactionCategoriesRequest extends GroupTask {
    transactionCategories: Object
}

type GroupResponse extends Response {
    newGroupId: String
}

type DeleteUserRequest {
    uid: String,
    force: Boolean | Null
}

type DeleteUserResponse extends Response {
    ownedGroupIds: String[] | Null,
    subscriptionId: Id | Null
}

type ActivateUserRewardRequest {
    rewardId: Id,
    type: PremiumRewardType,
    feature: PremiumFeatureType
}

type ActivateUserRewardResponse extends Response {
    subscriptionId: Id
}

type PremiumRewardType extends String {
    validate() { this == "featureReward" }
}

type PremiumFeatureType extends String {
    validate() { this == "colors" || this == "categories" || this == "receipts" || this == "recurringTransactions" || this == "futureTransactions" || this == "remindFriendsToPay" || this == "charts" || this == "exchangeRates" || this == "advancedExport" }
}

type SubscriptionTask {
    receipt: String,
    store: Store,
    type: SubscriptionType,
    sku: String | Null
}

type InitialSubscriptionTask {
    platform: Platform,
    update: Boolean,
    everythingReceipt: String | Null
}

type Store extends String {
    validate() { this == "googlePlay" || this == "appStore" || this == "windowsStore" || this == "stripe" }
}

type SubscriptionType extends String {
    validate() { this == "monthly" || this == "yearly" || this == "gift" || this == "group" }
}

type ActivateEmptyGroupPremiumTask {
    subscriptionId: Id,
    groupId: String
}

type VerifyGroupPremiumTask {
    receipt: String,
    store: Store,
    groupId: String,
    sku: String | Null,
    extendSubscriptionId: String | Null
}

//
// Statistics
//

path /statistics/public {
    read() { isSignedIn() }
}

//
// Subscriptions
//

path /subscriptions/{user_id} {
    read() { isCurrentUser(user_id) }
}

path /subscriptionsInternal/{user_id} is SubscriptionInternal {
    read() { false }
    write() { false }
}

path /subscriptionReceipts/{receipt_hash} {
    read() { false }
    write() { false }
}

//
// Unauthorized premium use tracking
//

path /unauthorizedPremiumUse/{user_id} {
    read() { false }
    write() { false }
}

//
// Per-user feature rate limits (server-enforced; clients never write)
//

path /rateLimits/{user_id} {
    read() { isAdmin() }
    write() { false }
}

type SubscriptionInternal {
    active: Boolean
}

// 
// Transactions
//

path /transactions/{group_id} {
    read() { isAdmin() || hasReadPermissionOrInviteLink(group_id) }
    write() { isAdmin() || hasWritePermission(group_id) }
    index() { ["type", "dateTime"] }

    path /{transaction_id} is Transaction { }
}

type TransactionBase {
    validate() { newData.hasChildren(['items', 'whoPaid']) }
    category: Category | Null,
    currencyCode: CurrencyCode,
    exchangeRates: Weight[]
    fixedExchangeRate: Boolean,
    items: Item[],
    purpose: Purpose | Null,
    receiptUrl: PhotoUrl | Null,
    type: TransactionType,
    whoPaid: MemberWeight[]
}

type TransactionTemplate extends TransactionBase {
    dateTime : ZeroNumber,
}

type Transaction extends TransactionBase {
    dateTime : Number,
    migrated: Boolean | Number | Null,
    templateId: Id | Null,
    timezone: Timezone | Null
}

type Item {
    validate() { newData.hasChildren(['forWhom']) }
    amount: Weight,
    forWhom: MemberWeight[]
}

type MemberWeight {
    memberId: Id,
    weight: Weight
}

type ZeroNumber extends Number {
    validate() { this == 0 }
}

type Category extends String {
    validate() { hasMaxLength(this, 12) }
}

type CategoryName extends String {
    validate() { hasMaxLength(this, 20) }
}

type Purpose extends String {
    validate() { hasMaxLength(this, 128) }
}

// +01:00
type Timezone extends String {
    validate() { this.matches(/^[+-](?:2[0-3]|[01][0-9]):[0-5][0-9]$/) }
}

type TransactionType extends String {
    validate() { this == "expense" || this == "transfer" }
}

//
// Recurring templates
//

path /recurringTransactions/{group_id} {
    read() { isAdmin() || hasReadPermissionOrInviteLink(group_id) }
    write() { isAdmin() || hasWritePermission(group_id) }

    path /{template_id} is RecurringTransaction { }
}

type RecurringTransaction {
    lastGenerated: Number | Null,
    migrated: Boolean | Null,
    recurrence: WeeklyRecurrence | MonthlyRecurrence | OtherRecurrence,
    runCount: Number | Null,
    template: TransactionTemplate
}

type RecurrenceBase {
    startDate: Number,
    endDate: Number | Null,
    endCount: Number | Null,
    timezoneOffsetMillis: Number,
    frequency: Number
}

type OtherRecurrence extends RecurrenceBase {
    period: RecurrencePeriodOther,
}

type WeeklyRecurrence extends RecurrenceBase {
    period: RecurrencePeriodWeekly,
    weeklySetting: DayOfWeek[] | Null
}

type MonthlyRecurrence extends RecurrenceBase {
    period: RecurrencePeriodMonthly,
    monthlySetting: RecurrenceDaySettingMonthly
}

type RecurrencePeriodWeekly extends String {
    validate() { this == "weekly" }
}

type RecurrencePeriodMonthly extends String {
    validate() { this == "monthly" }
}

type RecurrencePeriodOther extends String {
    validate() { this == "daily" || this == "yearly" }
}

type RecurrenceDaySettingMonthly extends String {
    validate() { this == "sameDayOfMonth" || this == "sameDayOfWeek" || this == "lastDayOfMonth" }
}

//
// User groups
//

path /userGroups/{user_id} {
    read() { isSignedIn() }
    index() { "order" }

    path /{group_id} is UserGroup {
        write() { isAdmin() || isCurrentUser(user_id) || hasWritePermission(group_id) }
    }
}

type UserGroup {
    color: Color,
    member: Id | Null,
    migrated: Boolean | Null,
    order: Number,
}

//
// User groups lightning withdrawals data. This isn't used anymore but cannot be removed until client apps are updated to stop reading from here
//

path /userLightningWithdrawals/{user_id} {
    read() { isCurrentUser(user_id) }

    path /{group_id}/{withdrawal_id} is LightningWithdrawal {
        read() { isSignedIn() && this.active == true }
        write() { isAdmin() }
    }
}

type LightningWithdrawal {
    active: Boolean,
    refund: Boolean,
    refundAddress: String,
    from: Id,
    lnurl: LnUrl,
    amount: Weight,
    originalCurrency: CurrencyCode,
    originalAmount: Weight,
    fee: Number,
    created: Number,
    expires: Number | Null,
    uuid: String
}

//
// Users
//

path /users {
    read() { isAdmin() }
    index() { "email" }
    path /{user_id} is User {
        read() { isSignedIn() }
        write() { (isAdmin() && isInsert(this)) || isCurrentUser(user_id) }
    }
}

path /usersInternal {
    read() { false }
    write() { false }
    index() { "anonymous" }
}

type User {
    authProvider: AuthProvider | Null,
    bankAccount: BankAccount | Null,
    currentTabId: Id | Null,
    defaultPaymentHandles: PaymentHandle[] | Null,
    email: Email | Null,
    lightningAddress: Email | Null,
    inviteLinkHash: InviteLinkHash | Null,
    name: UserName | Null,
    photoUrl: PhotoUrl | Null,
    locale: Locale | Null
}

type AuthProvider extends String {
    validate() { this == "google" || this == "facebook" || this == "email" || this == "apple" }
}

type Locale extends String {
    validate() { hasMaxLength(this, 10) }
}

type Email extends String {
    validate() { hasMaxLength(this, 255) && this.contains("@") && this.contains(".")}
}

type UserName extends String {
    validate() { hasMaxLength(this, 70) }
}

//
// Debts
//

path /debts/{group_id} is Debt[] {
    read() { isAdmin() || hasReadPermissionOrInviteLink(group_id) }
    write() { isAdmin() }
}

type Debt {
    from: Id,
    to: Id,
    amount: Weight
}

//
// Config
//

path /config {
    read() { true }
    write() { false }
}

//
// Helper functions
//

hasReadPermission(group_id) {
    hasPermission(group_id, 10)
}

hasWritePermission(group_id) {
    hasPermission(group_id, 20)
}

hasOwnerPermission(group_id) {
    hasPermission(group_id, 30)
}

hasPermission(group_id, level) {
    prior(root).permissions[group_id][auth.uid].level >= level
}

isSignedIn() {
    auth != null
}

isAdmin() {
    prior(root).users[auth.uid].admin
}

hasReadPermissionOrInviteLink(group_id) {
    hasValidInviteLink(group_id) || hasReadPermission(group_id)
}

hasValidInviteLink(group_id) {
    prior(root).groups[group_id].inviteLinkActive == true && (hasValidInviteLinkWithOldHash(group_id) || hasValidInviteLinkWithNewHash(group_id))
}

hasValidInviteLinkWithOldHash(group_id) {
    prior(root).groups[group_id].inviteLinkHash == prior(root).users[auth.uid].inviteLinkHash
}

hasValidInviteLinkWithNewHash(group_id) {
    prior(root).users[auth.uid].inviteLinkHash != null && prior(root).inviteLinkHashes[prior(root).users[auth.uid].inviteLinkHash] == group_id
}

initial(value, init) { 
    // Returns true if the value is intialized to init, or if it retains it's prior value, otherwise.
    value == (prior(value) == null ? init : prior(value))
}

hasMaxLength(text, length) {
    text.length > 0 && text.length <= length
}

isCurrentUser(user_id) {
    isSignedIn() && auth.uid == user_id
}

isInsert(value) {
    prior(value) == null && value != null
}

isUpdate(value) {
    prior(value) != null && value != null
}

isDelete(value) {
    prior(value) != null && value == null
}

isStringNumber(value) {
    value.matches(/^-?\d*(\.?\d+)*$/)
}

//
// Common types
//

type InitialTimestamp extends Number {
  validate() { initial(this, now) }
}

type CurrencyCode extends String {
    validate() { this.length == 3 }
}

type InviteLinkHash extends String {
    validate() { hasMaxLength(this, 20)}
}

type Color extends String {
    validate() { this.length == 7 && this.startsWith("#")}
}

type Weight extends String {
    validate() { hasMaxLength(this, 63) && isStringNumber(this) }
}

type PhotoUrl extends String {
    validate() { hasMaxLength(this, 2048) }
}

type AppVersion extends String {
    validate() { hasMaxLength(this, 20) }
}

type Id extends String {
    validate() { hasMaxLength(this, 50) }
}

type LnUrl extends String {
    validate() { hasMaxLength(this, 1024) && this.startsWith("LNURL") }
}

type DayOfWeek extends String {
    validate() { this == "mon" || this == "tue" || this == "wed" || this == "thu" || this == "fri" || this == "sat" || this == "sun" }
}