{
  "openapi": "3.1.0",
  "info": {
    "title": "meibo API",
    "description": "Reads and changes an owner's meibo school and club directories, through an\nagent the owner hands a key to.\n\n**A key is the owner's, for every directory they have, and is shown\nonce.** The owner makes it on their account page and chooses its scopes\nthere. Only a hash of it is stored, so a lost key is revoked and replaced,\nnever recovered. A key acts as the owner and follows the owner's rules.\n\n**Name the directory with `?directory=`.** Every operation takes the\ndirectory's slug, the last part of its address, as `?directory=<slug>`.\nLeft out, a key reaches the owner's one directory; when the owner has more\nthan one, it gets `400` with `reason: directory_required` and their slugs\nin `directories`. A directory the key does not reach, another owner's or\none that is not there, gets `404` with `reason: directory_not_reached`.\nA key reaches a directory only while its owner owns it: handed to someone\nelse, it is out of the previous owner's keys' reach. A key made before keys\nreached every directory reaches only the one it was made for, needs no\n`?directory=`, and gets `401` once that directory changes hands.\n\n**`entries:read` is off unless the owner grants it.** The dashboard shows\nthis beside it:\n\n> Reads every entry in full: parents' names, emails, phones and home addresses, and children's names and teachers. What it reads goes to the assistant's AI provider, under your control. Photos are never shared.\n\nA key without it can still add, edit, publish and delete entries. What\nthose writes answer is an entry's `id` and `isPublished`, and nothing of\nwhat is in it.\n\n**Photos are never served, and a key cannot send one.** An entry carries\n`hasPhoto` wherever the dashboard shows a photo: on the entry, and on each\nparent and child. A body that carries `photoKey` anywhere, even as `null`,\nis refused `400` with `reason: photos`. Editing an entry keeps the photos it\nalready has.\n\n**Every call is logged.** Each call a key is authenticated for is recorded\nagainst the directory it reached, with its method, route and status, and\nthe owner can read that log for 90 days. That includes refusals. A refusal\nbefore any directory is reached is not logged: a `401`, and the `400` and\n`404` about `?directory=`.\n\n**A suspended owner's key keeps reading and withdrawing.** Reads,\nunpublishing an entry and deleting one are served. Every other write gets\n`403` with `reason: suspended`.\n\n**So does the key of a frozen directory.** An account may own as many\ndirectories as its plan allows. When it owns more, after a downgrade or a\nlapsed subscription, its oldest directories are served in full and the\nrest are frozen until the owner upgrades or deletes one. A frozen\ndirectory's key is served reads, unpublishes and deletes, and every other\nwrite gets `403` with `reason: frozen`, the owner's `plan` and its\ndirectory `limit`.\n\n**Rate limits.** The edge allows 300 requests per 5 minutes from one IP\naddress, and 600 requests per 5 minutes with one key, counted across\neverything on this host. Past either limit, a request gets `429` with a\n`WafBlock` body and never reaches meibo. Invites have their own cap: at\nmost 20 addresses per request, and 200 invites per directory in any 24\nhours, shared with the invites the owner sends from the dashboard.\n\n**Answers.** A success is `{ \"success\": true, \"data\": … }`. A refusal is a\nbare `Refusal`. Its `error` is a sentence to show a person, and its\n`reason`, when it has one, is a word to branch on.\n\n**If something here did not work, tell us.** A call refused that this\nreference says should succeed, a response that does not match its schema,\nor a step that is missing or wrong: email support@fennec.studio with the\nmethod and path, the status and the `error` and `reason` you got, and what\nyou expected. Leave out the key and anything an entry holds: names,\nemails, phones and addresses stay in the directory. An agent that cannot\nsend email should give the owner the address.\n",
    "version": "1.0.0",
    "contact": {
      "name": "meibo support",
      "email": "support@fennec.studio"
    }
  },
  "servers": [
    {
      "url": "https://api.meibo.io",
      "description": "Production"
    }
  ],
  "tags": [
    {
      "name": "settings",
      "description": "The directory's name, preset, groups and logo."
    },
    {
      "name": "entries",
      "description": "The families and staff listed in the directory."
    },
    {
      "name": "moderation",
      "description": "Entries waiting for approval, entries someone reported, and entries published after pre-screening in the last 7 days."
    },
    {
      "name": "invites",
      "description": "Inviting families by email."
    }
  ],
  "components": {
    "securitySchemes": {
      "directoryKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "The key, sent as `Authorization: Bearer <key>`. The `Bearer ` prefix may\nbe left off. A key that is missing, revoked or expired gets `401`, and\nso does a key made for one directory, before keys reached every\ndirectory, once that directory has changed hands.\n\nA key holds some of these scopes, chosen by the owner when it is made.\nEach operation's `security` names the scopes it needs, and an operation\nthat names none is open to any key. A key without a scope the operation\nneeds is refused `403` with `reason: scope`, and `scope` names it.\n\n- `directory`: change the directory's name, preset, groups and logo.\n- `entries:write`: add entries and edit them.\n- `moderation`: read the moderation queue, publish and unpublish\n  entries, dismiss reports, and delete entries.\n- `invites`: email families a directory's address, within its invite\n  cap. It lets nobody in.\n- `entries:read`: read entries in full, contacts and children's names\n  included. Off unless the owner grants it.\n"
      }
    },
    "schemas": {
      "SuccessEnvelope": {
        "type": "object",
        "required": [
          "success",
          "data"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "const": true
          },
          "data": {}
        },
        "description": "Every success, `201` included. `data` is the operation's answer."
      },
      "Refusal": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "A sentence to show a person. Its wording can change; branch on `reason`."
          },
          "reason": {
            "type": "string",
            "description": "A word to branch on, where the refusal has one. This is not an enum.\nLayers this API shares with the dashboard pass their own reasons\nthrough, so expect values that are not listed here. Known values:\n\n- `scope`: the key lacks the scope named in `scope`.\n- `dashboard-only`: a settings body names a field only the dashboard\n  may change. `field` names it.\n- `photos`: the body carries `photoKey`.\n- `suspended`: the owner's account is suspended, and this is a write\n  other than an unpublish or a delete.\n- `frozen`: the directory is past the number its owner's plan\n  allows, and this is a write other than an unpublish or a delete.\n  `plan` and `limit` say which plan and how many.\n- `entry_limit`: publishing would take the directory past its plan's\n  entry limit. `limit` is that limit.\n- `directory_required`: the key reaches more than one directory\n  and the call named none. `directories` lists their slugs.\n- `directory_not_reached`: the key does not reach the directory\n  `?directory=` names.\n"
          },
          "scope": {
            "$ref": "#/components/schemas/Scope",
            "description": "With `reason` `scope`, the scope the key lacks."
          },
          "field": {
            "type": "string",
            "description": "A settings body's field. With `reason` `dashboard-only`, the first field a key may not change. Otherwise, the field that failed its check: `name`, `preset`, `groups` or `logo`.\n"
          },
          "limit": {
            "type": "integer",
            "description": "With `reason` `entry_limit`, how many published entries the plan allows. With `reason` `frozen`, how many directories it allows.\n"
          },
          "plan": {
            "type": "string",
            "description": "With `reason` `frozen`, the owner's plan: `free`, `small` or `large`. Custom allows any number, so it never freezes one.\n"
          },
          "directories": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "With `reason` `directory_required`, the slugs of the directories the key reaches, oldest first. Send one as `?directory=`.\n"
          },
          "invalid": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Invites only. The addresses that are not email addresses."
          },
          "removed": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Invites only. The addresses of people the owner removed or declined."
          },
          "remaining": {
            "type": "integer",
            "minimum": 0,
            "description": "Invites only. How many more invites the directory may send now."
          },
          "sent": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Invites only. The addresses that were emailed before others failed."
          },
          "failed": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Invites only. The addresses that could not be emailed."
          }
        },
        "description": "Every refusal meibo itself answers. `error` is always there. Everything else depends on the refusal, and each operation's responses say which fields its refusals carry.\n"
      },
      "WafBlock": {
        "type": "object",
        "required": [
          "error",
          "message"
        ],
        "properties": {
          "error": {
            "type": "string",
            "const": "Too Many Requests"
          },
          "message": {
            "type": "string",
            "description": "A retry hint to show a person."
          }
        },
        "description": "What the edge answers when it rate-limits a request. The request never reached meibo, so this is not a `Refusal`: `error` is a fixed status label and the sentence is in `message`. Back off and retry.\n"
      },
      "Scope": {
        "type": "string",
        "enum": [
          "directory",
          "entries:write",
          "moderation",
          "invites",
          "entries:read"
        ],
        "description": "A scope a key can hold. See the `directoryKey` scheme for what each allows."
      },
      "Settings": {
        "type": "object",
        "required": [
          "name",
          "slug",
          "preset",
          "groups",
          "logoUrl",
          "scopes"
        ],
        "properties": {
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string",
            "description": "The directory's slug. Only its manage page, `/manage/<slug>/`, changes it."
          },
          "preset": {
            "$ref": "#/components/schemas/Preset"
          },
          "groups": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Group"
            },
            "description": "The directory's groups: its classes, teams or productions, as its preset names them. A child joins one by its `id`.\n"
          },
          "logoUrl": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "The logo's public URL, or null when there is none."
          },
          "scopes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Scope"
            },
            "description": "The scopes of the key that asked."
          }
        },
        "description": "The directory's settings, as a key sees them. The two access rules, the email list and billing are the dashboard's, and are not here.\n"
      },
      "Preset": {
        "type": "string",
        "enum": [
          "school",
          "team",
          "adultTeam",
          "theater",
          "study",
          "club",
          "other"
        ],
        "description": "What the directory is for. It sets the words its pages use, whether an entry is a family or one person, and how many groups each person can be in: one under `school`, `team`, `adultTeam` and `other`, any number under `theater`, `study` and `club`.\n"
      },
      "Group": {
        "type": "object",
        "required": [
          "id",
          "name",
          "aliases",
          "leaders"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Send it back in an update to keep this group, and the children in it."
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "The group's name. Null for a group known by its leader."
          },
          "aliases": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Other names for the group, such as a room number."
          },
          "leaders": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "description": "Entry ids of the staff entries that lead the group. Being a leader grants nothing.\n"
          }
        }
      },
      "SettingsUpdate": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100,
            "description": "The directory's name, trimmed."
          },
          "preset": {
            "$ref": "#/components/schemas/Preset"
          },
          "groups": {
            "type": "array",
            "maxItems": 100,
            "items": {
              "$ref": "#/components/schemas/GroupInput"
            },
            "description": "Replaces the whole list. A group sent with an `id` keeps that group and the children in it, and the `id` must be one of this directory's; a group sent without one is new. Each group needs a name or a leader, no two may share a name (ignoring case), and each leader must be a staff entry of this directory. Text is trimmed, blank aliases are dropped, and a row with nothing in it is dropped.\n"
          },
          "logo": {
            "type": "string",
            "description": "A new logo: the image's bytes in base64, optionally as a `data:` URL. PNG, JPEG or WebP, whatever the declared type says, 512 KB or smaller and at most 2048 pixels on each side. SVG is refused. There is no way to remove a logo here, and `null` is refused.\n"
          }
        },
        "description": "Only `name`, `preset`, `groups` and `logo`. Any other field is refused `400` with `reason: dashboard-only` before anything is saved. A field left out keeps its value, so `{}` changes nothing and answers the settings.\n"
      },
      "GroupInput": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "minLength": 1
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 80
          },
          "aliases": {
            "type": "array",
            "maxItems": 10,
            "items": {
              "type": "string",
              "maxLength": 80
            }
          },
          "leaders": {
            "type": "array",
            "items": {
              "type": "integer",
              "minimum": 1
            }
          }
        }
      },
      "Entry": {
        "description": "A family, a staff member or an individual, as the owner sees it. Photos are never sent: `hasPhoto` says whether there is one.\n",
        "oneOf": [
          {
            "$ref": "#/components/schemas/FamilyEntry"
          },
          {
            "$ref": "#/components/schemas/StaffEntry"
          },
          {
            "$ref": "#/components/schemas/IndividualEntry"
          }
        ],
        "discriminator": {
          "propertyName": "type",
          "mapping": {
            "family": "#/components/schemas/FamilyEntry",
            "staff": "#/components/schemas/StaffEntry",
            "individual": "#/components/schemas/IndividualEntry"
          }
        }
      },
      "FamilyEntry": {
        "type": "object",
        "required": [
          "id",
          "type",
          "name",
          "isPublished",
          "hasPhoto",
          "parents",
          "children"
        ],
        "properties": {
          "id": {
            "type": "integer"
          },
          "type": {
            "type": "string",
            "const": "family"
          },
          "name": {
            "type": "string"
          },
          "isPublished": {
            "type": "boolean",
            "description": "False while the entry waits for approval, or after it is unpublished."
          },
          "hasPhoto": {
            "type": "boolean"
          },
          "parents": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Parent"
            }
          },
          "children": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Child"
            }
          }
        }
      },
      "Parent": {
        "type": "object",
        "required": [
          "id",
          "name",
          "email",
          "phoneNumber",
          "address",
          "hasPhoto"
        ],
        "properties": {
          "id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Send it back in an edit to keep this row, and its photo."
          },
          "name": {
            "type": "string"
          },
          "email": {
            "type": [
              "string",
              "null"
            ]
          },
          "phoneNumber": {
            "type": [
              "string",
              "null"
            ]
          },
          "address": {
            "type": [
              "string",
              "null"
            ]
          },
          "hasPhoto": {
            "type": "boolean"
          }
        }
      },
      "Child": {
        "type": "object",
        "required": [
          "id",
          "name",
          "teacher",
          "groups",
          "hasPhoto"
        ],
        "properties": {
          "id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Send it back in an edit to keep this row, and its photo."
          },
          "name": {
            "type": "string"
          },
          "teacher": {
            "type": [
              "string",
              "null"
            ],
            "description": "A teacher none of the settings' `groups` names, as it was sent; else the name of the child's first group, or its first leader's name when it has none.\n"
          },
          "groups": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Ids of the settings' `groups` this child is in."
          },
          "hasPhoto": {
            "type": "boolean"
          }
        }
      },
      "StaffEntry": {
        "type": "object",
        "required": [
          "id",
          "type",
          "name",
          "isPublished",
          "hasPhoto",
          "title",
          "email",
          "phoneNumber",
          "description",
          "links"
        ],
        "properties": {
          "id": {
            "type": "integer"
          },
          "type": {
            "type": "string",
            "const": "staff"
          },
          "name": {
            "type": "string"
          },
          "isPublished": {
            "type": "boolean",
            "description": "False while the entry waits for approval, or after it is unpublished."
          },
          "hasPhoto": {
            "type": "boolean"
          },
          "title": {
            "type": [
              "string",
              "null"
            ]
          },
          "email": {
            "type": [
              "string",
              "null"
            ]
          },
          "phoneNumber": {
            "type": [
              "string",
              "null"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "links": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Link"
            }
          }
        }
      },
      "IndividualEntry": {
        "type": "object",
        "description": "One person, as the `adultTeam` preset lists its players and the `theater`, `study`, `club` and `other` presets their members: a staff member's fields, and the groups they are in.\n",
        "required": [
          "id",
          "type",
          "name",
          "isPublished",
          "hasPhoto",
          "title",
          "email",
          "phoneNumber",
          "description",
          "links",
          "groups"
        ],
        "properties": {
          "id": {
            "type": "integer"
          },
          "type": {
            "type": "string",
            "const": "individual"
          },
          "name": {
            "type": "string"
          },
          "isPublished": {
            "type": "boolean",
            "description": "False while the entry waits for approval, or after it is unpublished."
          },
          "hasPhoto": {
            "type": "boolean"
          },
          "title": {
            "type": [
              "string",
              "null"
            ]
          },
          "email": {
            "type": [
              "string",
              "null"
            ]
          },
          "phoneNumber": {
            "type": [
              "string",
              "null"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "links": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Link"
            }
          },
          "groups": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Ids of the settings' `groups` this person is in."
          }
        }
      },
      "Link": {
        "type": "object",
        "required": [
          "name",
          "url"
        ],
        "properties": {
          "name": {
            "type": [
              "string",
              "null"
            ]
          },
          "url": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "EntryList": {
        "type": "object",
        "required": [
          "entries",
          "page",
          "totalPages",
          "totalEntries"
        ],
        "properties": {
          "entries": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Entry"
            },
            "description": "By name, 48 to a page, unpublished entries included."
          },
          "page": {
            "type": "integer",
            "minimum": 1
          },
          "totalPages": {
            "type": "integer",
            "minimum": 0
          },
          "totalEntries": {
            "type": "integer",
            "minimum": 0
          }
        }
      },
      "EntryDetail": {
        "type": "object",
        "required": [
          "entry",
          "canEdit",
          "canDelete",
          "canModerate"
        ],
        "properties": {
          "entry": {
            "$ref": "#/components/schemas/Entry"
          },
          "canEdit": {
            "type": "boolean"
          },
          "canDelete": {
            "type": "boolean"
          },
          "canModerate": {
            "type": "boolean",
            "description": "A key acts as the owner, so all three are true. The scopes decide what the key may do."
          }
        }
      },
      "EntryWritten": {
        "type": "object",
        "required": [
          "id",
          "isPublished"
        ],
        "properties": {
          "id": {
            "type": "integer"
          },
          "isPublished": {
            "type": "boolean"
          }
        },
        "description": "What a write answers, and all it answers, so a key that may write but not read learns nothing of an entry's contents.\n"
      },
      "EntryDeleted": {
        "type": "object",
        "required": [
          "id"
        ],
        "properties": {
          "id": {
            "type": "integer"
          }
        }
      },
      "EntryInput": {
        "description": "A new entry, or the whole of an edit. `type` and `name` are always required, even to change one field. Fields that belong to the other type are ignored. On an edit, a field left out keeps its value. A list that is sent is the whole list: send each row back with its `id` to keep it (and its photo), and a row without one is new. `photoKey` is refused anywhere in the body, with `reason: photos`. Every type is taken under every preset.\n",
        "oneOf": [
          {
            "$ref": "#/components/schemas/FamilyEntryInput"
          },
          {
            "$ref": "#/components/schemas/StaffEntryInput"
          },
          {
            "$ref": "#/components/schemas/IndividualEntryInput"
          }
        ],
        "discriminator": {
          "propertyName": "type",
          "mapping": {
            "family": "#/components/schemas/FamilyEntryInput",
            "staff": "#/components/schemas/StaffEntryInput",
            "individual": "#/components/schemas/IndividualEntryInput"
          }
        }
      },
      "FamilyEntryInput": {
        "type": "object",
        "required": [
          "type",
          "name"
        ],
        "properties": {
          "type": {
            "type": "string",
            "const": "family"
          },
          "name": {
            "type": "string",
            "minLength": 1
          },
          "parents": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "$ref": "#/components/schemas/ParentInput"
            }
          },
          "children": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "$ref": "#/components/schemas/ChildInput"
            }
          }
        }
      },
      "ParentInput": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "id": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 1
          },
          "name": {
            "type": "string",
            "minLength": 1
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "description": "Blank is stored as null."
          },
          "phoneNumber": {
            "type": [
              "string",
              "null"
            ],
            "description": "Blank is stored as null."
          },
          "address": {
            "type": [
              "string",
              "null"
            ],
            "description": "Blank is stored as null."
          }
        }
      },
      "ChildInput": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "id": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 1
          },
          "name": {
            "type": "string",
            "minLength": 1
          },
          "teacher": {
            "type": [
              "string",
              "null"
            ],
            "description": "Blank is stored as null. A teacher naming one of the settings' `groups`, by its name or an alias and ignoring case, puts the child in that group instead of being stored. Sent without `groups`, the child is in that group alone, or in none.\n"
          },
          "groups": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            },
            "description": "Ids of the settings' `groups` this child is in. At most one under a preset of `school`, `team` or `other`. Left out, with `teacher` left out too, an edit keeps the child's groups.\n"
          }
        }
      },
      "StaffEntryInput": {
        "type": "object",
        "required": [
          "type",
          "name"
        ],
        "properties": {
          "type": {
            "type": "string",
            "const": "staff"
          },
          "name": {
            "type": "string",
            "minLength": 1
          },
          "title": {
            "type": [
              "string",
              "null"
            ]
          },
          "email": {
            "type": [
              "string",
              "null"
            ]
          },
          "phoneNumber": {
            "type": [
              "string",
              "null"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "links": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "$ref": "#/components/schemas/LinkInput"
            }
          }
        }
      },
      "IndividualEntryInput": {
        "type": "object",
        "required": [
          "type",
          "name"
        ],
        "properties": {
          "type": {
            "type": "string",
            "const": "individual"
          },
          "name": {
            "type": "string",
            "minLength": 1
          },
          "title": {
            "type": [
              "string",
              "null"
            ]
          },
          "email": {
            "type": [
              "string",
              "null"
            ]
          },
          "phoneNumber": {
            "type": [
              "string",
              "null"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "links": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "$ref": "#/components/schemas/LinkInput"
            }
          },
          "groups": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            },
            "description": "Ids of the settings' `groups` this person is in. At most one under a preset of `school`, `team` or `other`. An id the settings do not list is refused. Left out, an edit keeps the person's groups.\n"
          }
        }
      },
      "LinkInput": {
        "type": "object",
        "required": [
          "url"
        ],
        "properties": {
          "name": {
            "type": [
              "string",
              "null"
            ]
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "An `http://` or `https://` address."
          }
        }
      },
      "EntryModeration": {
        "type": "object",
        "minProperties": 1,
        "additionalProperties": false,
        "properties": {
          "isPublished": {
            "type": "boolean",
            "description": "Publish or unpublish. A suspended owner's key may still unpublish."
          },
          "dismissReports": {
            "type": "boolean",
            "description": "`true` clears every report on the entry. `false` does nothing."
          }
        }
      },
      "EntryUpdate": {
        "description": "One of three bodies. `isPublished` and `dismissReports` alone (`EntryModeration`) change nothing else and need `moderation`. An edit (`EntryInput`) needs `entries:write`. An edit with either field beside it needs both, and runs in order: the edit, then the publish, then the reports.\n",
        "anyOf": [
          {
            "$ref": "#/components/schemas/EntryModeration"
          },
          {
            "allOf": [
              {
                "$ref": "#/components/schemas/EntryInput"
              },
              {
                "type": "object",
                "properties": {
                  "isPublished": {
                    "type": "boolean"
                  },
                  "dismissReports": {
                    "type": "boolean"
                  }
                }
              }
            ]
          }
        ]
      },
      "ModerationList": {
        "type": "object",
        "required": [
          "entries",
          "page",
          "totalPages",
          "totalEntries"
        ],
        "properties": {
          "entries": {
            "description": "By name, 48 to a page. Entries waiting for approval, entries with reports, and entries published after pre-screening in the last 7 days. With `entries:read` each is a `WithheldModerationEntry`. Without it, each is a `ModerationSummary`.\n",
            "anyOf": [
              {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/WithheldModerationEntry"
                }
              },
              {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/ModerationSummary"
                }
              }
            ]
          },
          "page": {
            "type": "integer",
            "minimum": 1
          },
          "totalPages": {
            "type": "integer",
            "minimum": 0
          },
          "totalEntries": {
            "type": "integer",
            "minimum": 0
          }
        }
      },
      "ModerationSummary": {
        "type": "object",
        "required": [
          "id",
          "name",
          "isPublished",
          "flags",
          "aiReview"
        ],
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "isPublished": {
            "type": "boolean"
          },
          "flags": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ModerationFlag"
            }
          },
          "aiReview": {
            "$ref": "#/components/schemas/NullableAiReview"
          }
        },
        "description": "A queued entry for a key without `entries:read`. Contacts and children's names stay out."
      },
      "WithheldModerationEntry": {
        "description": "A queued entry for a key with `entries:read`. The whole entry, with its reports and its pre-screening result.",
        "allOf": [
          {
            "$ref": "#/components/schemas/Entry"
          },
          {
            "$ref": "#/components/schemas/ModerationFields"
          }
        ]
      },
      "ModerationFields": {
        "type": "object",
        "required": [
          "flags",
          "aiReview"
        ],
        "properties": {
          "flags": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ModerationFlag"
            }
          },
          "aiReview": {
            "$ref": "#/components/schemas/NullableAiReview"
          }
        }
      },
      "ModerationFlag": {
        "type": "object",
        "required": [
          "reason",
          "reportedAt"
        ],
        "properties": {
          "reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "What the reporter wrote."
          },
          "reportedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        },
        "description": "One report. Who made it is not sent."
      },
      "NullableAiReview": {
        "description": "The pre-screening result, or null when there is none.",
        "anyOf": [
          {
            "$ref": "#/components/schemas/AiReview"
          },
          {
            "type": "null"
          }
        ]
      },
      "AiReview": {
        "type": "object",
        "required": [
          "action",
          "category",
          "confidence",
          "model",
          "reviewedAt"
        ],
        "properties": {
          "action": {
            "type": "string",
            "enum": [
              "approve",
              "hold"
            ]
          },
          "category": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "ok",
              "spam",
              "duplicate",
              "impersonation",
              "off-topic",
              "unsuitable-photo",
              "failed",
              null
            ],
            "description": "Why. `failed` means pre-screening errored or timed out, which holds the entry."
          },
          "confidence": {
            "type": [
              "number",
              "null"
            ],
            "minimum": 0,
            "maximum": 1
          },
          "model": {
            "type": [
              "string",
              "null"
            ]
          },
          "reviewedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "InviteRequest": {
        "type": "object",
        "required": [
          "emails"
        ],
        "properties": {
          "emails": {
            "type": "string",
            "description": "One string of addresses, separated by commas, semicolons or new lines. A list is refused. Blanks are dropped, and so are repeats, ignoring case. At most 20 addresses after that.\n"
          }
        }
      },
      "InvitesSent": {
        "type": "object",
        "required": [
          "sent"
        ],
        "properties": {
          "sent": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Every address emailed, as parsed from `emails`."
          }
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "No key, or a key that is revoked, expired or no longer its directory owner's. Not logged.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Refusal"
            },
            "examples": {
              "unauthorized": {
                "$ref": "#/components/examples/Unauthorized"
              }
            }
          }
        }
      },
      "WafRateLimited": {
        "description": "The edge's rate limit, per IP address or per key. The request never reached meibo. Back off and retry.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/WafBlock"
            },
            "examples": {
              "waf": {
                "$ref": "#/components/examples/WafBlock"
              }
            }
          }
        }
      },
      "EntryNotFound": {
        "description": "No entry with that id in this directory. An id that is not a positive whole number is answered the same way. Or the key does not reach the directory `?directory=` names (`reason: directory_not_reached`).\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Refusal"
            },
            "examples": {
              "notFound": {
                "$ref": "#/components/examples/EntryNotFound"
              },
              "notReached": {
                "$ref": "#/components/examples/DirectoryNotReached"
              }
            }
          }
        }
      },
      "DirectoryRequired": {
        "description": "The key reaches more than one directory and the call named none. `directories` lists their slugs: send one as `?directory=`.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Refusal"
            },
            "examples": {
              "directoryRequired": {
                "$ref": "#/components/examples/DirectoryRequired"
              }
            }
          }
        }
      },
      "DirectoryNotReached": {
        "description": "The key does not reach the directory `?directory=` names: another owner's, or one that is not there.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Refusal"
            },
            "examples": {
              "notReached": {
                "$ref": "#/components/examples/DirectoryNotReached"
              }
            }
          }
        }
      }
    },
    "parameters": {
      "Directory": {
        "name": "directory",
        "in": "query",
        "required": false,
        "schema": {
          "type": "string"
        },
        "description": "The slug of the directory the call is about: the last part of its address. Needed only when the key reaches more than one directory.\n"
      },
      "EntryId": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "pattern": "^[1-9][0-9]*$"
        },
        "description": "The entry's `id`."
      },
      "Page": {
        "name": "page",
        "in": "query",
        "required": false,
        "schema": {
          "type": "integer",
          "minimum": 1,
          "default": 1
        },
        "description": "Which page, 48 to a page. Anything that is not a whole number from 1 up reads as 1. A page past the end has no entries.\n"
      }
    },
    "examples": {
      "Unauthorized": {
        "summary": "No key, or a key that no longer works",
        "value": {
          "error": "Invalid or expired API key"
        }
      },
      "WafBlock": {
        "summary": "Rate-limited at the edge",
        "value": {
          "error": "Too Many Requests",
          "message": "Rate limit exceeded. Please try again later."
        }
      },
      "Suspended": {
        "summary": "The owner's account is suspended",
        "value": {
          "error": "This directory is suspended. Contact support.",
          "reason": "suspended"
        }
      },
      "Frozen": {
        "summary": "The directory is past what the owner's plan allows",
        "value": {
          "error": "This directory is read-only. Your Free plan includes 1 directory. Upgrade your plan or delete another directory to make changes here.",
          "reason": "frozen",
          "plan": "free",
          "limit": 1
        }
      },
      "Photos": {
        "summary": "The body carries photoKey",
        "value": {
          "error": "An API key cannot send photos. Send the entry without photoKey.",
          "reason": "photos"
        }
      },
      "EntryNotFound": {
        "summary": "No such entry here",
        "value": {
          "error": "Entry not found"
        }
      },
      "DirectoryRequired": {
        "summary": "The key reaches several directories, and the call named none",
        "value": {
          "error": "This API key reaches more than one directory. Say which with ?directory=<slug>.",
          "reason": "directory_required",
          "directories": [
            "maple",
            "oak"
          ]
        }
      },
      "DirectoryNotReached": {
        "summary": "The key does not reach the directory named",
        "value": {
          "error": "This API key does not reach that directory.",
          "reason": "directory_not_reached"
        }
      }
    }
  },
  "paths": {
    "/api/v1/directory": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Directory"
        }
      ],
      "get": {
        "operationId": "readSettings",
        "tags": [
          "settings"
        ],
        "summary": "Read the directory's settings",
        "description": "Open to any key, whatever its scopes, and served while the owner is suspended.",
        "security": [
          {
            "directoryKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "The settings.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Settings"
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "settings": {
                    "value": {
                      "success": true,
                      "data": {
                        "name": "Maple Elementary",
                        "slug": "maple",
                        "preset": "school",
                        "groups": [
                          {
                            "id": "66f0c1a2b3c4d5e6f7a8b9c0",
                            "name": "Ms. Rivera",
                            "aliases": [
                              "Room 4"
                            ],
                            "leaders": [
                              12
                            ]
                          },
                          {
                            "id": "66f0c1a2b3c4d5e6f7a8b9c1",
                            "name": "Mr. Chen",
                            "aliases": [],
                            "leaders": []
                          }
                        ],
                        "logoUrl": "https://public.fennec.studio/storefronts/media/directory-logo-1.png",
                        "scopes": [
                          "directory",
                          "entries:write",
                          "moderation"
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/DirectoryRequired"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "The key does not reach the directory `?directory=` names (`reason: directory_not_reached`), or its owner no longer owns it.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                },
                "examples": {
                  "notReached": {
                    "$ref": "#/components/examples/DirectoryNotReached"
                  },
                  "noDirectory": {
                    "value": {
                      "error": "This account does not own a directory."
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/WafRateLimited"
          }
        }
      },
      "put": {
        "operationId": "updateSettings",
        "tags": [
          "settings"
        ],
        "summary": "Change the directory's name, preset, groups or logo",
        "description": "Changes the fields the body sends, and answers the settings as they are now. The slug, both access rules, pre-screening and the email list stay dashboard-only.\n",
        "security": [
          {
            "directoryKey": [
              "directory"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SettingsUpdate"
              },
              "examples": {
                "rename": {
                  "value": {
                    "name": "Maple Elementary School",
                    "groups": [
                      {
                        "id": "66f0c1a2b3c4d5e6f7a8b9c0",
                        "name": "Ms. Rivera",
                        "aliases": [
                          "Room 4"
                        ],
                        "leaders": [
                          12
                        ]
                      },
                      {
                        "name": "Mr. Chen"
                      }
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The settings, as saved.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Settings"
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "settings": {
                    "value": {
                      "success": true,
                      "data": {
                        "name": "Maple Elementary School",
                        "slug": "maple",
                        "preset": "school",
                        "groups": [
                          {
                            "id": "66f0c1a2b3c4d5e6f7a8b9c0",
                            "name": "Ms. Rivera",
                            "aliases": [
                              "Room 4"
                            ],
                            "leaders": [
                              12
                            ]
                          },
                          {
                            "id": "66f0c1a2b3c4d5e6f7a8b9c1",
                            "name": "Mr. Chen",
                            "aliases": [],
                            "leaders": []
                          }
                        ],
                        "logoUrl": null,
                        "scopes": [
                          "directory"
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "A field a key may not change (`reason: dashboard-only`, with `field`), a body that is not a JSON object, or a field that fails its check (with `field`). Or the key reaches more than one directory and the call named none (`reason: directory_required`).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                },
                "examples": {
                  "directoryRequired": {
                    "$ref": "#/components/examples/DirectoryRequired"
                  },
                  "dashboardOnly": {
                    "value": {
                      "error": "An API key can change only name, preset, groups and logo.",
                      "reason": "dashboard-only",
                      "field": "slug"
                    }
                  },
                  "badField": {
                    "value": {
                      "error": "Enter a name.",
                      "field": "name"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The key lacks `directory` (`reason: scope`), the owner is suspended (`reason: suspended`), or the directory is frozen (`reason: frozen`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                },
                "examples": {
                  "scope": {
                    "value": {
                      "error": "This API key does not have the directory scope.",
                      "reason": "scope",
                      "scope": "directory"
                    }
                  },
                  "suspended": {
                    "$ref": "#/components/examples/Suspended"
                  },
                  "frozen": {
                    "$ref": "#/components/examples/Frozen"
                  }
                }
              }
            }
          },
          "404": {
            "description": "The key does not reach the directory `?directory=` names (`reason: directory_not_reached`), or its owner no longer owns it.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                },
                "examples": {
                  "notReached": {
                    "$ref": "#/components/examples/DirectoryNotReached"
                  },
                  "noDirectory": {
                    "value": {
                      "error": "This account does not own a directory."
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "A `preset` whose people are in one group each (`school`, `team`, `other`), sent while some people are in more than one group. `field` is `preset`, and `error` says how many. Nothing is saved.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                },
                "examples": {
                  "severalGroups": {
                    "value": {
                      "error": "2 people are in more than one production. Leave each of them in one before switching.",
                      "field": "preset"
                    }
                  }
                }
              }
            }
          },
          "413": {
            "description": "The logo, or the whole body, is too large. `field` is `logo` when it is the logo.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                },
                "examples": {
                  "logo": {
                    "value": {
                      "error": "Logos must be 512 KB or smaller.",
                      "field": "logo"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/WafRateLimited"
          }
        }
      }
    },
    "/api/v1/directory/entries": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Directory"
        }
      ],
      "get": {
        "operationId": "listEntries",
        "tags": [
          "entries"
        ],
        "summary": "List or search the entries",
        "description": "One page of entries by name, 48 to a page, unpublished entries included. Served while the owner is suspended.\n",
        "security": [
          {
            "directoryKey": [
              "entries:read"
            ]
          }
        ],
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Words to search for, ignoring case. An entry matches when every word appears in its name, a parent's name or email, a child's name, teacher, or group's name or other names, a staff member's title, or an individual's title, or group's name or other names. A group renamed since an entry was last saved is found by its old name until the entry is saved again. Blank lists every entry. Only the first 8 words and first 100 characters are searched.\n"
          },
          {
            "$ref": "#/components/parameters/Page"
          }
        ],
        "responses": {
          "200": {
            "description": "One page of entries.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/EntryList"
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "page": {
                    "value": {
                      "success": true,
                      "data": {
                        "entries": [
                          {
                            "id": 11,
                            "type": "family",
                            "name": "The Okafors",
                            "isPublished": true,
                            "hasPhoto": true,
                            "parents": [
                              {
                                "id": "p1",
                                "name": "Ada",
                                "email": "ada@example.com",
                                "phoneNumber": "555-0100",
                                "address": null,
                                "hasPhoto": true
                              }
                            ],
                            "children": [
                              {
                                "id": "c1",
                                "name": "Tobi",
                                "teacher": "Ms. Rivera",
                                "groups": [
                                  "66f0c1a2b3c4d5e6f7a8b9c0"
                                ],
                                "hasPhoto": false
                              }
                            ]
                          },
                          {
                            "id": 12,
                            "type": "staff",
                            "name": "Ms. Rivera",
                            "isPublished": false,
                            "hasPhoto": false,
                            "title": "Year 2 teacher",
                            "email": null,
                            "phoneNumber": null,
                            "description": null,
                            "links": [
                              {
                                "name": "Class page",
                                "url": "https://school.example/year-2"
                              }
                            ]
                          }
                        ],
                        "page": 1,
                        "totalPages": 1,
                        "totalEntries": 2
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/DirectoryRequired"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The key lacks `entries:read` (`reason: scope`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                },
                "examples": {
                  "scope": {
                    "value": {
                      "error": "This API key does not have the entries:read scope.",
                      "reason": "scope",
                      "scope": "entries:read"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/DirectoryNotReached"
          },
          "429": {
            "$ref": "#/components/responses/WafRateLimited"
          }
        }
      },
      "post": {
        "operationId": "createEntry",
        "tags": [
          "entries"
        ],
        "summary": "Add an entry",
        "description": "Adds a family, a staff member or an individual. It is published at once, unless the directory approves entries first, or already publishes as many as its plan allows. Either way it is saved, and `isPublished` says which.\n",
        "security": [
          {
            "directoryKey": [
              "entries:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EntryInput"
              },
              "examples": {
                "family": {
                  "value": {
                    "type": "family",
                    "name": "The Parks",
                    "parents": [
                      {
                        "name": "Min-jun Park",
                        "email": "minjun@example.com",
                        "phoneNumber": "555-0142"
                      }
                    ],
                    "children": [
                      {
                        "name": "Seo-yeon",
                        "teacher": "Mr. Chen"
                      }
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Saved.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/EntryWritten"
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "created": {
                    "value": {
                      "success": true,
                      "data": {
                        "id": 91,
                        "isPublished": false
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The body carries `photoKey` (`reason: photos`), or fails a check. Or the key reaches more than one directory and the call named none (`reason: directory_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                },
                "examples": {
                  "directoryRequired": {
                    "$ref": "#/components/examples/DirectoryRequired"
                  },
                  "photos": {
                    "$ref": "#/components/examples/Photos"
                  },
                  "badBody": {
                    "value": {
                      "error": "Choose Family or Staff."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The key lacks `entries:write` (`reason: scope`), the owner is suspended (`reason: suspended`), or the directory is frozen (`reason: frozen`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                },
                "examples": {
                  "scope": {
                    "value": {
                      "error": "This API key does not have the entries:write scope.",
                      "reason": "scope",
                      "scope": "entries:write"
                    }
                  },
                  "suspended": {
                    "$ref": "#/components/examples/Suspended"
                  },
                  "frozen": {
                    "$ref": "#/components/examples/Frozen"
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/DirectoryNotReached"
          },
          "429": {
            "$ref": "#/components/responses/WafRateLimited"
          }
        }
      }
    },
    "/api/v1/directory/entries/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/EntryId"
        },
        {
          "$ref": "#/components/parameters/Directory"
        }
      ],
      "get": {
        "operationId": "getEntry",
        "tags": [
          "entries"
        ],
        "summary": "Read one entry",
        "description": "Served while the owner is suspended.",
        "security": [
          {
            "directoryKey": [
              "entries:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The entry.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/EntryDetail"
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "entry": {
                    "value": {
                      "success": true,
                      "data": {
                        "entry": {
                          "id": 11,
                          "type": "family",
                          "name": "The Okafors",
                          "isPublished": true,
                          "hasPhoto": true,
                          "parents": [
                            {
                              "id": "p1",
                              "name": "Ada",
                              "email": "ada@example.com",
                              "phoneNumber": null,
                              "address": null,
                              "hasPhoto": true
                            }
                          ],
                          "children": [
                            {
                              "id": "c1",
                              "name": "Tobi",
                              "teacher": "Ms. Rivera",
                              "groups": [
                                "66f0c1a2b3c4d5e6f7a8b9c0"
                              ],
                              "hasPhoto": false
                            }
                          ]
                        },
                        "canEdit": true,
                        "canDelete": true,
                        "canModerate": true
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/DirectoryRequired"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The key lacks `entries:read` (`reason: scope`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                },
                "examples": {
                  "scope": {
                    "value": {
                      "error": "This API key does not have the entries:read scope.",
                      "reason": "scope",
                      "scope": "entries:read"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/EntryNotFound"
          },
          "429": {
            "$ref": "#/components/responses/WafRateLimited"
          }
        }
      },
      "put": {
        "operationId": "updateEntry",
        "tags": [
          "entries",
          "moderation"
        ],
        "summary": "Edit, publish, unpublish, or dismiss reports",
        "description": "The scopes needed depend on the body (see `EntryUpdate`): `entries:write` for an edit, `moderation` for `isPublished` or `dismissReports`, and both for both. A body with no fields, or one that is not an object, is treated as an edit. While the owner is suspended, only an unpublish is served: `isPublished: false`, with no edit and no `dismissReports: true`.\n",
        "security": [
          {
            "directoryKey": [
              "entries:write"
            ]
          },
          {
            "directoryKey": [
              "moderation"
            ]
          },
          {
            "directoryKey": [
              "entries:write",
              "moderation"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EntryUpdate"
              },
              "examples": {
                "publish": {
                  "value": {
                    "isPublished": true,
                    "dismissReports": true
                  }
                },
                "edit": {
                  "value": {
                    "type": "family",
                    "name": "The Okafors",
                    "parents": [
                      {
                        "id": "p1",
                        "name": "Ada",
                        "email": "ada@example.com",
                        "phoneNumber": "555-0100"
                      }
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Saved.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/EntryWritten"
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "saved": {
                    "value": {
                      "success": true,
                      "data": {
                        "id": 11,
                        "isPublished": true
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The body carries `photoKey` (`reason: photos`), `isPublished` or `dismissReports` is not a boolean, or an edit fails a check. Or the key reaches more than one directory and the call named none (`reason: directory_required`).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                },
                "examples": {
                  "directoryRequired": {
                    "$ref": "#/components/examples/DirectoryRequired"
                  },
                  "photos": {
                    "$ref": "#/components/examples/Photos"
                  },
                  "notBoolean": {
                    "value": {
                      "error": "isPublished must be true or false."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The key lacks a scope the body needs (`reason: scope`), the owner is suspended (`reason: suspended`), or the directory is frozen (`reason: frozen`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                },
                "examples": {
                  "scope": {
                    "value": {
                      "error": "This API key does not have the entries:write scope.",
                      "reason": "scope",
                      "scope": "entries:write"
                    }
                  },
                  "suspended": {
                    "$ref": "#/components/examples/Suspended"
                  },
                  "frozen": {
                    "$ref": "#/components/examples/Frozen"
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/EntryNotFound"
          },
          "409": {
            "description": "Publishing would take the directory past its plan's entry limit (`reason: entry_limit`, with `limit`). An edit sent with it has been saved, and the message says so. The reports are left as they were.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                },
                "examples": {
                  "entryLimit": {
                    "value": {
                      "error": "This directory already publishes 25 entries, the most its plan allows. Unpublish another entry or upgrade the plan to publish this one.",
                      "reason": "entry_limit",
                      "limit": 25
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/WafRateLimited"
          }
        }
      },
      "delete": {
        "operationId": "deleteEntry",
        "tags": [
          "entries",
          "moderation"
        ],
        "summary": "Delete an entry",
        "description": "Served while the owner is suspended.",
        "security": [
          {
            "directoryKey": [
              "moderation"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/EntryDeleted"
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "deleted": {
                    "value": {
                      "success": true,
                      "data": {
                        "id": 11
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/DirectoryRequired"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The key lacks `moderation` (`reason: scope`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                },
                "examples": {
                  "scope": {
                    "value": {
                      "error": "This API key does not have the moderation scope.",
                      "reason": "scope",
                      "scope": "moderation"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/EntryNotFound"
          },
          "429": {
            "$ref": "#/components/responses/WafRateLimited"
          }
        }
      }
    },
    "/api/v1/directory/moderation": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Directory"
        }
      ],
      "get": {
        "operationId": "listModeration",
        "tags": [
          "moderation"
        ],
        "summary": "List the moderation queue",
        "description": "One page of the entries waiting for approval, the entries with reports, and the entries published after pre-screening in the last 7 days, by name, 48 to a page. An entry published after pre-screening is published already, and is listed so the owner can see it and unpublish it. What each carries depends on the key: the whole entry with `entries:read`, and a `ModerationSummary` without it. Served while the owner is suspended.\n",
        "security": [
          {
            "directoryKey": [
              "moderation"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Page"
          }
        ],
        "responses": {
          "200": {
            "description": "One page of the queue.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/ModerationList"
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "withEntriesRead": {
                    "summary": "A key that also holds entries:read",
                    "value": {
                      "success": true,
                      "data": {
                        "entries": [
                          {
                            "id": 12,
                            "type": "staff",
                            "name": "Ms. Rivera",
                            "isPublished": false,
                            "hasPhoto": true,
                            "title": "Year 2 teacher",
                            "email": null,
                            "phoneNumber": null,
                            "description": null,
                            "links": [],
                            "flags": [],
                            "aiReview": {
                              "action": "hold",
                              "category": "off-topic",
                              "confidence": 0.4,
                              "model": "gemini-2.5-flash",
                              "reviewedAt": "2026-09-25T02:00:00.000Z"
                            }
                          }
                        ],
                        "page": 1,
                        "totalPages": 1,
                        "totalEntries": 1
                      }
                    }
                  },
                  "summary": {
                    "summary": "A key without entries:read",
                    "value": {
                      "success": true,
                      "data": {
                        "entries": [
                          {
                            "id": 11,
                            "name": "The Okafors",
                            "isPublished": true,
                            "flags": [
                              {
                                "reason": "Not a family at this school",
                                "reportedAt": "2026-09-25T01:00:00.000Z"
                              }
                            ],
                            "aiReview": null
                          }
                        ],
                        "page": 1,
                        "totalPages": 1,
                        "totalEntries": 1
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/DirectoryRequired"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The key lacks `moderation` (`reason: scope`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                },
                "examples": {
                  "scope": {
                    "value": {
                      "error": "This API key does not have the moderation scope.",
                      "reason": "scope",
                      "scope": "moderation"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/DirectoryNotReached"
          },
          "429": {
            "$ref": "#/components/responses/WafRateLimited"
          }
        }
      }
    },
    "/api/v1/directory/invites": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Directory"
        }
      ],
      "post": {
        "operationId": "sendInvites",
        "tags": [
          "invites"
        ],
        "summary": "Invite families by email",
        "description": "Emails each address an invite to the directory, from the owner's storefront. It lets nobody in: whoever signs in with that address still has to get in another way, by a link, the email list or the directory being open, and an access request stays waiting for the owner. At most 20 addresses per request. The directory may send 200 invites in any 24 hours, and the invites the owner sends from the dashboard count too. Each invite stops counting 24 hours after it was sent. Every refusal but a `500` naming `sent` means nothing was emailed.\n",
        "security": [
          {
            "directoryKey": [
              "invites"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/InviteRequest"
              },
              "examples": {
                "two": {
                  "value": {
                    "emails": "ana@example.com, ben@example.com"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Every address was emailed.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/SuccessEnvelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/InvitesSent"
                        }
                      }
                    }
                  ]
                },
                "examples": {
                  "sent": {
                    "value": {
                      "success": true,
                      "data": {
                        "sent": [
                          "ana@example.com",
                          "ben@example.com"
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "`emails` is missing or not a string, names no address or too many, or names something that is not an email address (listed in `invalid`). Or the key reaches more than one directory and the call named none (`reason: directory_required`).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                },
                "examples": {
                  "directoryRequired": {
                    "$ref": "#/components/examples/DirectoryRequired"
                  },
                  "invalid": {
                    "value": {
                      "error": "Some of these are not email addresses.",
                      "invalid": [
                        "ana"
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The key lacks `invites` (`reason: scope`), the owner is suspended (`reason: suspended`), or the directory is frozen (`reason: frozen`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                },
                "examples": {
                  "scope": {
                    "value": {
                      "error": "This API key does not have the invites scope.",
                      "reason": "scope",
                      "scope": "invites"
                    }
                  },
                  "suspended": {
                    "$ref": "#/components/examples/Suspended"
                  },
                  "frozen": {
                    "$ref": "#/components/examples/Frozen"
                  }
                }
              }
            }
          },
          "404": {
            "description": "The key does not reach the directory `?directory=` names (`reason: directory_not_reached`), or the directory, or the storefront its invites are sent from, is gone.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                },
                "examples": {
                  "notReached": {
                    "$ref": "#/components/examples/DirectoryNotReached"
                  },
                  "gone": {
                    "value": {
                      "error": "Directory not found"
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "Some addresses belong to people the owner removed or declined, listed in `removed`. Take them out to invite the others.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                },
                "examples": {
                  "removed": {
                    "value": {
                      "error": "You removed or declined these people, so they cannot be invited: ben@example.com. Take them out of the list to invite the others. Nothing was emailed.",
                      "removed": [
                        "ben@example.com"
                      ]
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Two conditions share this status. The directory's invite cap, a `Refusal` whose `remaining` says how many more it may send now. Or the edge's rate limit, a `WafBlock`, which never reached meibo.\n",
            "content": {
              "application/json": {
                "schema": {
                  "anyOf": [
                    {
                      "$ref": "#/components/schemas/Refusal"
                    },
                    {
                      "$ref": "#/components/schemas/WafBlock"
                    }
                  ]
                },
                "examples": {
                  "cap": {
                    "value": {
                      "error": "This directory can send 3 more invites now, of at most 200 in any 24 hours. Nothing was emailed. Send 3 or fewer addresses.",
                      "remaining": 3
                    }
                  },
                  "waf": {
                    "$ref": "#/components/examples/WafBlock"
                  }
                }
              }
            }
          },
          "500": {
            "description": "Nothing could be sent, or some addresses were emailed and others were not. In the second case `sent` and `failed` say which.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                },
                "examples": {
                  "partial": {
                    "value": {
                      "error": "Some invites could not be sent.",
                      "sent": [
                        "ana@example.com"
                      ],
                      "failed": [
                        "ben@example.com"
                      ]
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}
