book-free.openapps.xyz

Source

This app’s code and revision history are public. Download a copy or explore the files below.

Public
The app’s code and revision history are public.
Owned app · rev f8f1b3a80195
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 7385a6c1c47153b1728160ff
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
← All files
policy.json
{
  "constants": {
    "AGENT_ADDRESS": "3nDxyXwEZTptKxAjhd3ujJzUckXZXYXpiSxBSSnpCQW8",
    "AGENT_ADDRESS_BOUND": true
  },
  "auth": {
    "anonymous": true
  },
  "defs": {
    "pageValid": "@StringUtils.length(@newData.name) >= 1 && @StringUtils.length(@newData.name) <= 80 && @StringUtils.length(@newData.title) >= 1 && @StringUtils.length(@newData.title) <= 120 && @StringUtils.length(@newData.description) <= 1000 && (@newData.durationMin == 15 || @newData.durationMin == 30 || @newData.durationMin == 45 || @newData.durationMin == 60) && @StringUtils.length(@newData.timezone) >= 1 && @StringUtils.length(@newData.timezone) <= 64 && @StringUtils.length(@newData.hours) >= 2 && @StringUtils.length(@newData.hours) <= 2000 && @newData.noticeHours <= 720 && @newData.horizonDays >= 1 && @newData.horizonDays <= 365 && (@newData.bufferMin // 15) * 15 == @newData.bufferMin && @newData.bufferMin <= 60 && @StringUtils.length(@newData.questions) <= 2000 && @StringUtils.length(@newData.meeting) >= 1 && @StringUtils.length(@newData.meeting) <= 40",
    "linkValid": "@StringUtils.length(@newData.meetingUrl) >= 12 && @StringUtils.length(@newData.meetingUrl) <= 500",
    "pageOwner": "get(/pages/$handle/owners/@user.id) != null"
  },
  "pages/$handle": {
    "description": "A public booking page: who you meet, for how long, and when they are open. hours is JSON of weekday to ranges like \"09:00-17:00\" in the page's time zone; daysOff is a JSON list of [from, to] dates (inclusive, in that zone) when it takes no bookings; questions is a JSON list of {label, required}; meeting names the kind of call (Google Meet, Zoom). The meeting link is not here: it lives in pagelinks, readable only by owners and people who booked. The handle is the address and the document id.",
    "fields": {
      "name": "String",
      "title": "String",
      "description": "String",
      "durationMin": "UInt",
      "timezone": "String",
      "hours": "String",
      "daysOff": "String?",
      "noticeHours": "UInt",
      "horizonDays": "UInt",
      "bufferMin": "UInt",
      "questions": "String",
      "meeting": "String",
      "createdBy": "String!",
      "createdAt": "UInt!"
    },
    "rules": {
      "read": "true",
      "create": "@user.id != null && @newData.createdBy == @user.id && @newData.createdAt <= @time.now && @StringUtils.length($handle) >= 3 && @StringUtils.length($handle) <= 32 && @def.pageValid && getAfter(/pages/$handle/owners/@user.id) != null && getAfter(/pagekeys/$handle) != null && getAfter(/pagelinks/$handle) != null",
      "update": "@user.id != null && @def.pageOwner && @def.pageValid",
      "delete": "false"
    }
  },
  "pages/$handle/owners/$userId": {
    "description": "Who can manage the page and see its bookings: the creator, in the same batch that creates it, or anyone who presents the admin key.",
    "fields": {
      "at": "UInt!"
    },
    "rules": {
      "read": "@user.id != null && @user.id == $userId",
      "create": "@user.id != null && @user.id == $userId && @newData.at <= @time.now && ((get(/pages/$handle) == null && getAfter(/pages/$handle).createdBy == @user.id) || (get(/pagekeys/$handle) != null && getAfter(/pageclaims/$handle/c/@user.id).key == get(/pagekeys/$handle).key))",
      "update": "false",
      "delete": "false"
    }
  },
  "pages/$handle/slots/$cellSec": {
    "description": "A 15-minute cell of held time, keyed by its start in UTC seconds. A booking holds every cell from its start to the end of the break after it, and a cell can only be created once, so two bookings can never overlap. Each cell but the last names the next one, which the rule requires in the same batch. Public: it shows only that the time is taken.",
    "fields": {
      "bookingId": "String",
      "at": "UInt",
      "next": "UInt?"
    },
    "rules": {
      "read": "true",
      "create": "@user.id != null && $cellSec * 1 == @newData.at && get(/pages/$handle/bookings/@newData.bookingId) == null && getAfter(/pages/$handle/bookings/@newData.bookingId).by == @user.id && @newData.at >= getAfter(/pages/$handle/bookings/@newData.bookingId).start && @newData.at + 900 <= getAfter(/pages/$handle/bookings/@newData.bookingId).heldUntil && (@newData.at + 900 == getAfter(/pages/$handle/bookings/@newData.bookingId).heldUntil || (@newData.next == @newData.at + 900 && getAfter(/pages/$handle/slots/@newData.next).bookingId == @newData.bookingId))",
      "update": "false",
      "delete": "@user.id != null && getAfter(/pages/$handle/bookings/@data.bookingId).status == 'cancelled'"
    }
  },
  "pages/$handle/bookers/$userId": {
    "description": "Marks that this account booked on the page (or opened a booking's manage link), which lets it read the meeting link.",
    "fields": {
      "bookingId": "String"
    },
    "rules": {
      "read": "@user.id != null && @user.id == $userId",
      "create": "@user.id != null && @user.id == $userId && getAfter(/pages/$handle/bookings/@newData.bookingId) != null && ((get(/pages/$handle/bookings/@newData.bookingId) == null && getAfter(/pages/$handle/bookings/@newData.bookingId).by == @user.id) || (get(/bookingkeys/@newData.bookingId) != null && getAfter(/bookingclaims/@newData.bookingId/c/@user.id).key == get(/bookingkeys/@newData.bookingId).key))",
      "update": "@user.id != null && @user.id == $userId && getAfter(/pages/$handle/bookings/@newData.bookingId) != null && ((get(/pages/$handle/bookings/@newData.bookingId) == null && getAfter(/pages/$handle/bookings/@newData.bookingId).by == @user.id) || (get(/bookingkeys/@newData.bookingId) != null && getAfter(/bookingclaims/@newData.bookingId/c/@user.id).key == get(/bookingkeys/@newData.bookingId).key))",
      "delete": "false"
    }
  },
  "pages/$handle/bookings/$bookingId": {
    "description": "One booking on the page. Readable by the page's owners, by the account that made it, and by anyone who presented its cancel key (the manage link). Only a cancel can change it, by the guest or an owner; cancelling frees its cells in the same batch. A guest who moves a booking cancels it with movedTo naming the new booking, in the batch that creates the new one.",
    "fields": {
      "start": "UInt!",
      "end": "UInt!",
      "heldUntil": "UInt!",
      "name": "String!",
      "email": "String!",
      "note": "String!",
      "answers": "String!",
      "status": "String",
      "cancelledBy": "String?",
      "cancelledAt": "UInt?",
      "movedTo": "String?",
      "by": "String!",
      "createdAt": "UInt!"
    },
    "rules": {
      "read": "@user.id != null && (@def.pageOwner || @data.by == @user.id || (get(/bookingkeys/$bookingId) != null && get(/bookingclaims/$bookingId/c/@user.id).key == get(/bookingkeys/$bookingId).key))",
      "create": "@user.id != null && @newData.by == @user.id && @newData.createdAt <= @time.now && @newData.status == 'booked' && get(/pages/$handle) != null && (@newData.start // 900) * 900 == @newData.start && @newData.end == @newData.start + get(/pages/$handle).durationMin * 60 && @newData.heldUntil == @newData.end + get(/pages/$handle).bufferMin * 60 && @newData.start >= @time.now + get(/pages/$handle).noticeHours * 3600 && @newData.start <= @time.now + get(/pages/$handle).horizonDays * 86400 + 3600 && @StringUtils.length(@newData.name) >= 1 && @StringUtils.length(@newData.name) <= 80 && @StringUtils.length(@newData.email) >= 3 && @StringUtils.length(@newData.email) <= 254 && @StringUtils.length(@newData.note) <= 1000 && @StringUtils.length(@newData.answers) <= 4000 && getAfter(/pages/$handle/slots/@newData.start).bookingId == $bookingId && getAfter(/bookingkeys/$bookingId).handle == $handle",
      "update": "@user.id != null && @data.status == 'booked' && @newData.status == 'cancelled' && @newData.cancelledAt <= @time.now && ((@newData.cancelledBy == 'owner' && @def.pageOwner) || (@newData.cancelledBy == 'guest' && (@data.by == @user.id || (get(/bookingkeys/$bookingId) != null && getAfter(/bookingclaims/$bookingId/c/@user.id).key == get(/bookingkeys/$bookingId).key))))",
      "delete": "false"
    }
  },
  "pagelinks/$handle": {
    "description": "The page's meeting link (a Google Meet, Zoom or Teams room). Only owners and people who booked can read it.",
    "fields": {
      "meetingUrl": "String"
    },
    "rules": {
      "read": "@user.id != null && (@def.pageOwner || get(/pages/$handle/bookers/@user.id) != null)",
      "create": "@user.id != null && get(/pages/$handle) == null && getAfter(/pages/$handle).createdBy == @user.id && @def.linkValid",
      "update": "@user.id != null && @def.pageOwner && @def.linkValid",
      "delete": "false"
    }
  },
  "pagekeys/$handle": {
    "description": "The page's admin key. Only the public API can read it, to check a key someone presents.",
    "fields": {
      "key": "String!"
    },
    "rules": {
      "read": "@user.id == @const.BOUNDED_PUBLIC_PRINCIPAL_API",
      "create": "@user.id != null && get(/pages/$handle) == null && getAfter(/pages/$handle).createdBy == @user.id && @StringUtils.length(@newData.key) >= 20",
      "update": "false",
      "delete": "false"
    }
  },
  "pageclaims/$handle/c/$userId": {
    "description": "An admin key someone presented to become an owner. Never readable.",
    "fields": {
      "key": "String"
    },
    "rules": {
      "read": "false",
      "create": "@user.id != null && @user.id == $userId",
      "update": "@user.id != null && @user.id == $userId",
      "delete": "false"
    }
  },
  "bookingkeys/$bookingId": {
    "description": "The booking's cancel key and the page it is on. Only the public API can read it, to check a key someone presents.",
    "fields": {
      "key": "String!",
      "handle": "String!"
    },
    "rules": {
      "read": "@user.id == @const.BOUNDED_PUBLIC_PRINCIPAL_API",
      "create": "@user.id != null && get(/pages/@newData.handle/bookings/$bookingId) == null && getAfter(/pages/@newData.handle/bookings/$bookingId).by == @user.id && @StringUtils.length(@newData.key) >= 20",
      "update": "false",
      "delete": "false"
    }
  },
  "bookingclaims/$bookingId/c/$userId": {
    "description": "A cancel key someone presented to manage a booking. Never readable.",
    "fields": {
      "key": "String"
    },
    "rules": {
      "read": "false",
      "create": "@user.id != null && @user.id == $userId",
      "update": "@user.id != null && @user.id == $userId",
      "delete": "false"
    }
  },
  "functions": {
    "api": {
      "auth": "true",
      "entry": "functions/api.ts",
      "public": true,
      "methods": [
        "GET",
        "POST",
        "PATCH"
      ],
      "cors": "passthrough"
    }
  }
}

Change history

Add favicon.ico and link tag
f8f1b3a801 · 2 hours ago
Quality pass: guest reschedule, days off, calendar feed, API docs
c827e5d073 · 5 hours ago
Bounded source sync
68e67b4092 · 1 day ago
Bounded source sync
ab2e006f9b · 1 day ago
Bounded source sync
bb10824b54 · 1 day ago
Bounded source sync
d22f765fb7 · 1 day ago
Bounded source sync
30b4b06513 · 1 day ago
Bounded source sync
c3d297477a · 1 day ago
Bounded source sync
7b18317417 · 1 day ago