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.
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 c7891873549c9ca1004894b3
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.
policy.json
{
"auth": {
"anonymous": true
},
"constants": {
"AGENT_ADDRESS": "DiSsEKhEqLS9RUDSKZf2PVGhRYpWxSmTJGfrSZfRZZWJ",
"AGENT_ADDRESS_BOUND": true
},
"defs": {
"joined": "get(/exchanges/$id/joins/@user.id) != null",
"owner": "get(/exchanges/$id/owners/@user.id) != null",
"api": "@user.id == @const.BOUNDED_PUBLIC_PRINCIPAL_API",
"drawn": "get(/exchanges/$id).status == 'drawn'",
"revealed": "get(/exchanges/$id).revealAt > 0 && @time.now >= get(/exchanges/$id).revealAt",
"notFinal": "(@data.status != 'drawn' || @data.revealAt == 0 || @time.now < @data.revealAt)",
"exchangeValid": "@StringUtils.length(@newData.name) >= 1 && @StringUtils.length(@newData.name) <= 80 && @StringUtils.length(@newData.budget) <= 40 && (@StringUtils.length(@newData.date) == 0 || @StringUtils.length(@newData.date) == 10) && @StringUtils.length(@newData.notes) <= 1000 && (@newData.status == 'open' || @newData.status == 'drawn')"
},
"exchanges/$id": {
"description": "A Secret Santa exchange. Readable by anyone who has opened its join link (a join below); the random id is the link's secret. Only owners change it. status is open or drawn; round counts draws, so a redraw replaces every match. A reveal is always set in the future. organizerSees and the reveal date are fixed once names are drawn (the reveal can only move later or be turned off), so the organizer cannot quietly start seeing matches. Once the reveal has passed, the draw is final: no undo, no redraw, no reveal change. Only the public API draws names, and every draw adds one to round.",
"fields": {
"name": "String",
"budget": "String",
"date": "String",
"notes": "String",
"allowJoin": "Bool",
"organizerSees": "Bool",
"revealAt": "UInt",
"status": "String",
"round": "UInt",
"drawnAt": "UInt",
"createdBy": "String!",
"createdAt": "UInt!"
},
"rules": {
"read": "@user.id != null && @def.joined",
"create": "@user.id != null && @newData.createdBy == @user.id && @newData.createdAt <= @time.now && @newData.status == 'open' && @newData.round == 0 && @newData.drawnAt == 0 && (@newData.revealAt == 0 || @newData.revealAt > @time.now) && @def.exchangeValid && getAfter(/exchanges/$id/joins/@user.id) != null && getAfter(/exchanges/$id/owners/@user.id) != null && getAfter(/exchangekeys/$id) != null",
"update": "@user.id != null && @def.owner && @def.exchangeValid && (((@newData.status == @data.status || @newData.status == 'open') && @newData.round == @data.round && @newData.drawnAt == @data.drawnAt) || (@def.api && @newData.status == 'drawn' && @newData.round == @data.round + 1 && (@newData.revealAt == 0 || @newData.revealAt > @time.now))) && (@newData.organizerSees == @data.organizerSees || @data.status == 'open') && (@newData.revealAt == @data.revealAt || @newData.revealAt == 0 || (@newData.revealAt > @time.now && (@data.status == 'open' || (@data.revealAt > 0 && @newData.revealAt > @data.revealAt)))) && (@def.notFinal || (@newData.status == @data.status && @newData.round == @data.round && @newData.revealAt == @data.revealAt))",
"delete": "false"
}
},
"exchanges/$id/joins/$userId": {
"description": "Someone who opened the exchange's link on a device. Knowing the exchange id is what lets you join.",
"fields": {
"at": "UInt!"
},
"rules": {
"read": "@user.id != null && @user.id == $userId",
"create": "@user.id != null && @user.id == $userId && @newData.at <= @time.now && getAfter(/exchanges/$id) != null",
"update": "false",
"delete": "false"
}
},
"exchanges/$id/owners/$userId": {
"description": "Who can manage the exchange: 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(/exchanges/$id) == null && getAfter(/exchanges/$id).createdBy == @user.id) || (get(/exchangekeys/$id) != null && getAfter(/exchangeclaims/$id/c/@user.id).key == get(/exchangekeys/$id).key))",
"update": "false",
"delete": "false"
}
},
"exchangekeys/$id": {
"description": "The exchange's admin key. Only the public API can read it, to check a key someone presents.",
"fields": {
"key": "String!"
},
"rules": {
"read": "@def.api",
"create": "@user.id != null && get(/exchanges/$id) == null && getAfter(/exchanges/$id).createdBy == @user.id && @StringUtils.length(@newData.key) >= 20",
"update": "false",
"delete": "false"
}
},
"exchangeclaims/$id/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"
}
},
"keepapart/$id": {
"description": "Pairs of people who never draw each other (couples, last year's pairs), as a JSON list of [personId, personId]. Only owners see them, since last year's pairs would give away last year's secret.",
"fields": {
"pairs": "String"
},
"rules": {
"read": "@user.id != null && @def.owner",
"create": "@user.id != null && getAfter(/exchanges/$id/owners/@user.id) != null && @StringUtils.length(@newData.pairs) <= 20000",
"update": "@user.id != null && @def.owner && @StringUtils.length(@newData.pairs) <= 20000",
"delete": "false"
}
},
"exchanges/$id/people/$pid": {
"description": "A person in the exchange: a name, not an account. Owners add people while names are not drawn; when the organizer allows it, people add themselves from the join link, holding their own key from the start. Every person has a key (personkeys) and a private page that opens with it.",
"fields": {
"name": "String",
"addedBy": "String!",
"self": "Bool!",
"createdAt": "UInt!"
},
"rules": {
"read": "@user.id != null && @def.joined",
"create": "@user.id != null && @newData.addedBy == @user.id && @newData.createdAt <= @time.now && @StringUtils.length(@newData.name) >= 1 && @StringUtils.length(@newData.name) <= 60 && getAfter(/exchanges/$id).status == 'open' && getAfter(/personkeys/$pid).exchangeId == $id && ((@newData.self == false && getAfter(/exchanges/$id/owners/@user.id) != null) || (@newData.self == true && get(/exchanges/$id).allowJoin == true && getAfter(/exchanges/$id/joins/@user.id) != null && getAfter(/personclaims/$pid/c/@user.id).exchangeId == $id))",
"update": "@user.id != null && @def.owner && @StringUtils.length(@newData.name) >= 1 && @StringUtils.length(@newData.name) <= 60",
"delete": "@user.id != null && @def.owner && get(/exchanges/$id).status == 'open'"
}
},
"exchanges/$id/contacts/$pid": {
"description": "How the organizer can reach a person (an email or a phone number), so the app can open the organizer's own email or text app with the message. Santa sends nothing itself. Readable by owners and by whoever wrote it.",
"fields": {
"contact": "String",
"by": "String!"
},
"rules": {
"read": "@user.id != null && (@def.owner || @data.by == @user.id)",
"create": "@user.id != null && @newData.by == @user.id && @StringUtils.length(@newData.contact) <= 120 && getAfter(/exchanges/$id/people/$pid) != null && (getAfter(/exchanges/$id/owners/@user.id) != null || getAfter(/exchanges/$id/people/$pid).addedBy == @user.id)",
"update": "@user.id != null && (@def.owner || @data.by == @user.id) && @StringUtils.length(@newData.contact) <= 120",
"delete": "@user.id != null && @def.owner"
}
},
"personkeys/$pid": {
"description": "A person's key and their exchange. Made once, with the person, by whoever adds them. Only the public API can read it, to check a key someone presents.",
"fields": {
"key": "String!",
"exchangeId": "String!"
},
"rules": {
"read": "@def.api",
"create": "@user.id != null && @StringUtils.length(@newData.key) >= 20 && get(/exchanges/@newData.exchangeId/people/$pid) == null && getAfter(/exchanges/@newData.exchangeId/people/$pid).addedBy == @user.id",
"update": "false",
"delete": "false"
}
},
"personclaims/$pid/c/$userId": {
"description": "A person's key presented on a device. It counts only with an open record written in the same batch, so every device that can see a person's match is listed on that person's page. Never readable.",
"fields": {
"key": "String!",
"exchangeId": "String!",
"openId": "String!"
},
"rules": {
"read": "false",
"create": "@user.id != null && @user.id == $userId && getAfter(/exchanges/@newData.exchangeId/opens/@newData.openId).by == @user.id && getAfter(/exchanges/@newData.exchangeId/opens/@newData.openId).pid == $pid",
"update": "false",
"delete": "false"
}
},
"exchanges/$id/opens/$openId": {
"description": "A device (or the API, via 'api') that opened a person's private page with the right key. Everyone in the exchange can see these, so a page opened by someone else shows. Its id is <personId>_<userId>, so each device counts once. Only the API's own record says 'api', and since every API caller shares it, the API moves its at to the latest call.",
"fields": {
"pid": "String!",
"by": "String!",
"via": "String!",
"at": "UInt"
},
"rules": {
"read": "@user.id != null && @def.joined",
"create": "@user.id != null && @newData.by == @user.id && @newData.at <= @time.now && $openId == @StringUtils.concat(@newData.pid, '_', @user.id) && (@newData.via == 'web' || (@newData.via == 'api' && @def.api)) && getAfter(/personkeys/@newData.pid) != null && getAfter(/personkeys/@newData.pid).exchangeId == $id && getAfter(/personclaims/@newData.pid/c/@user.id).exchangeId == $id && getAfter(/personclaims/@newData.pid/c/@user.id).key == getAfter(/personkeys/@newData.pid).key",
"update": "@def.api && @data.by == @user.id && @newData.at >= @data.at && @newData.at <= @time.now",
"delete": "false"
}
},
"exchanges/$id/matches/$pid": {
"description": "Who a person gives a gift to (to is a person id), for the current round. Only the public API writes matches, and only in the batch that draws (round goes up by one), so a match never changes within a round. Readable by the person (a device holding their key), by owners only when the organizer chose to see the list before the draw, and by everyone in the exchange after the reveal date.",
"fields": {
"to": "String",
"round": "UInt",
"at": "UInt"
},
"rules": {
"read": "@user.id != null && @def.drawn && @data.round == get(/exchanges/$id).round && (get(/personclaims/$pid/c/@user.id).exchangeId == $id || (@def.owner && get(/exchanges/$id).organizerSees == true) || (@def.revealed && @def.joined))",
"create": "@def.api && getAfter(/exchanges/$id).status == 'drawn' && @newData.round == getAfter(/exchanges/$id).round && @newData.round == get(/exchanges/$id).round + 1 && @newData.at <= @time.now && @newData.to != $pid && getAfter(/exchanges/$id/people/$pid) != null && getAfter(/exchanges/$id/people/@newData.to) != null",
"update": "@def.api && getAfter(/exchanges/$id).status == 'drawn' && @newData.round == getAfter(/exchanges/$id).round && @newData.round == get(/exchanges/$id).round + 1 && @newData.at <= @time.now && @newData.to != $pid && getAfter(/exchanges/$id/people/$pid) != null && getAfter(/exchanges/$id/people/@newData.to) != null",
"delete": "false"
}
},
"exchanges/$id/santas/$to/s/$userId": {
"description": "A device proving it is the current Secret Santa of person $to: it holds the key of the person whose match is $to in this round. This is what lets a Santa read their giftee's wish list and ask them questions.",
"fields": {
"from": "String",
"round": "UInt"
},
"rules": {
"read": "@user.id != null && @user.id == $userId",
"create": "@user.id != null && @user.id == $userId && @def.drawn && @newData.round == get(/exchanges/$id).round && get(/personclaims/@newData.from/c/@user.id).exchangeId == $id && get(/exchanges/$id/matches/@newData.from).to == $to && get(/exchanges/$id/matches/@newData.from).round == @newData.round",
"update": "@user.id != null && @user.id == $userId && @def.drawn && @newData.round == get(/exchanges/$id).round && get(/personclaims/@newData.from/c/@user.id).exchangeId == $id && get(/exchanges/$id/matches/@newData.from).to == $to && get(/exchanges/$id/matches/@newData.from).round == @newData.round",
"delete": "false"
}
},
"exchanges/$id/wishes/$pid": {
"description": "A person's wish list, written by the person. Readable by the person and by their current Secret Santa.",
"fields": {
"text": "String",
"at": "UInt"
},
"rules": {
"read": "@user.id != null && (get(/personclaims/$pid/c/@user.id).exchangeId == $id || (@def.drawn && get(/exchanges/$id/santas/$pid/s/@user.id).round == get(/exchanges/$id).round))",
"create": "@user.id != null && get(/personclaims/$pid/c/@user.id).exchangeId == $id && get(/exchanges/$id/people/$pid) != null && @StringUtils.length(@newData.text) <= 2000 && @newData.at <= @time.now",
"update": "@user.id != null && get(/personclaims/$pid/c/@user.id).exchangeId == $id && @StringUtils.length(@newData.text) <= 2000 && @newData.at <= @time.now",
"delete": "false"
}
},
"exchanges/$id/asks/$to/q/$qid": {
"description": "An anonymous question from a Secret Santa to their giftee ($to), and the giftee's answer. Every reader sees who wrote a document, so only the public API writes questions: for an assistant holding the Santa's key, or for a Santa's browser that left the question in askrequests. Nothing records who asked. Only the giftee can answer. Readable by the giftee and by the current Santa.",
"fields": {
"text": "String!",
"answer": "String",
"round": "UInt!",
"askedAt": "UInt!",
"answeredAt": "UInt"
},
"rules": {
"read": "@user.id != null && (get(/personclaims/$to/c/@user.id).exchangeId == $id || (@def.drawn && @data.round == get(/exchanges/$id).round && get(/exchanges/$id/santas/$to/s/@user.id).round == get(/exchanges/$id).round))",
"create": "@def.api && @def.drawn && @newData.round == get(/exchanges/$id).round && @StringUtils.length(@newData.text) >= 1 && @StringUtils.length(@newData.text) <= 300 && @newData.answer == '' && @newData.answeredAt == 0 && @newData.askedAt <= @time.now && (getAfter(/exchanges/$id/santas/$to/s/@user.id).round == @newData.round || (get(/askrequests/$qid).exchangeId == $id && get(/askrequests/$qid).to == $to && get(/askrequests/$qid).round == @newData.round && get(/askrequests/$qid).text == @newData.text))",
"update": "@user.id != null && get(/personclaims/$to/c/@user.id).exchangeId == $id && @StringUtils.length(@newData.answer) <= 500 && @newData.answeredAt <= @time.now",
"delete": "false"
}
},
"askrequests/$qid": {
"description": "A question a Secret Santa's browser asks their giftee. Only the public API reads it: it writes the question under exchanges/<id>/asks/<to>/q/<qid> itself, so the giftee never sees who asked.",
"fields": {
"exchangeId": "String!",
"to": "String!",
"text": "String!",
"round": "UInt!"
},
"rules": {
"read": "@def.api",
"create": "@user.id != null && @StringUtils.length(@newData.text) >= 1 && @StringUtils.length(@newData.text) <= 300 && get(/exchanges/@newData.exchangeId).status == 'drawn' && @newData.round == get(/exchanges/@newData.exchangeId).round && get(/exchanges/@newData.exchangeId/santas/@newData.to/s/@user.id).round == @newData.round",
"update": "false",
"delete": "false"
}
},
"functions": {
"api": {
"auth": "true",
"entry": "functions/api.ts",
"public": true,
"methods": [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE"
],
"cors": "passthrough"
}
}
}
Change history
Point OpenApps links to openapps.free
Bind agent address in policy constants
Use functions.openapps.xyz alias, add OpenApp contribution strip
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync