# Administering DALICC

Part of the [documentation index](../README.md#documentation).

This is the manual for everything an administrator does in the browser, on the pages
under `/admin`: people and invitations, the review queue, the curated license records and
the core dependency graphs, the blog, what the API is being used for, how much the
service is used, the assistant settings, what the site looks like and the
record of what changed.

**Who it is for.** Somebody with the `admin` role on a running DALICC installation. You
need no checkout, no terminal and no access to the server for any of it. Using the site
itself is [USERS.md](USERS.md); installing the service and looking after the machine it
runs on belong to an operator, in [DEPLOYMENT.md](DEPLOYMENT.md) and
[OPERATIONS.md](OPERATIONS.md).

Contents: 1. what an administrator can do, 2. accounts and invitations, 3. the review
queue and correction requests, 4. curated license records, 5. core dependency graphs,
6. the blog and the media library, 7. API usage and per-account limits, 8. the
assistant settings, 9. notification and contact mail, 10. the audit log, 11. where
the administration pages are, 12. publisher names, 13. usage statistics,
14. appearance, 15. registration.

---

## 1. What an administrator can do

There are two account roles, `admin` and `member`. An administrator manages people,
invitations and the settings page, reads the record of what changed, and may act on any
license or dependency graph whoever owns it. The whole matrix, from a member's point of
view, is in [USERS.md](USERS.md#12-who-may-do-what), and `/admin/settings` shows the
same table on the site.

**Where the pages are.** *Administration* is the third block of the column that runs
down the left of every page behind the login, and the **Account** entry in the main
menu carries a shortcut to it. Section 11 lists every page and its address.

**How the menu is grouped.** Under *Administration* the first entry is **Overview**
(`/admin`, the dashboard), and the other pages follow in five groups, each under a
small heading of its own:

| Group | Entries |
|---|---|
| Accounts | Users, Invitations, Reserved names |
| Review | Review queue, Correction requests to review, Review inbox |
| Content | Curated records, Licenses, Dependency graphs, Blog posts, Media |
| Reports | Statistics, API usage, Audit log |
| Settings | Registration, Assistant, Appearance, Deployment settings |

The dashboard at `/admin` shows the same five groups, in the same order, as sections of
cards, one card per page with a line on what it is for, and the menu has an entry for
every card. The two Review entries are named so that they cannot be mistaken for
*Correction requests* and *Inbox* in your own groups above them, which are your
requests and your conversations. The page of all accounts is called *Users* in the menu,
on its card, in its heading and in its breadcrumb.

**What this installation is configured to do.** `/admin/settings` shows the values that
shape the account area, each with the name of the variable it comes from. It is
read-only on purpose: those values come from the installation, so a restart of the
service can never silently undo a change somebody made in a browser. Changing one is an
operator's job ([DEPLOYMENT.md](DEPLOYMENT.md#4-environment-variable-reference)).

**The first administrator** is not created here. An installation starts with no account
and no default password, and the first administrator is made while the service is being
set up ([DEPLOYMENT.md](DEPLOYMENT.md#34-the-first-start-and-the-first-administrator)).
Every administrator after that is invited from the page below.

---

## 2. Accounts and invitations

**Invite somebody.** `/admin/invitations`, enter the address, pick the role, submit. If
the page shows a link instead of confirming a mail, copy it and send it yourself, through
a channel you trust: it is a credential for one account and it is shown exactly once.
You can **withdraw** a pending invitation, or **create a new link** for the same address,
which withdraws the old one so that only the newest mail works.

Inviting an address that already has an account is legitimate and useful: it is how a
deactivated account is invited back, and how somebody who has no account yet is given a
role on a license. An invitation never lowers an existing account's role.

**Which privacy notice somebody has read** is kept on the account. Accepting an
invitation records the wording that was on `/privacy` that day. When the notice is
replaced, every member whose account records an older wording sees a banner once at
their next sign-in: plain lines saying what changed, and a button that records that
they have read it. It blocks nothing and asks for no consent; where a change needs a
consent, the consent is asked for on the form where it is given. The button writes
`privacy.acknowledged` to the audit log with the version, which is how the association
can say what somebody agreed to. There is nothing for you to send and nothing to clear.

**Somebody lost their password.** `/admin/users`, open the account, then either *Send a
reset link* (mails it, or shows it when the installation cannot send mail) or *Set a
temporary password* (shown once, and the person must change it at their next login).
Prefer the reset link: a temporary password travels through you. Nobody, you included,
can read an existing password; only a one-way hash of it is kept. **Ask for a new
password at the next login**, a box on the same page, makes the person choose a new
password the next time they sign in, without sending anything.

**Somebody left.** Open the account and clear *Account is active*. Their sessions end at
once, their API tokens stop working and they cannot log in. Nothing they own is deleted.
Inviting the address again reactivates the same account.

**Change somebody's name.** The name on the account page is the publisher of every
license that account publishes, so it belongs to one account and to nobody else. Three
kinds of name are refused, each with the reason: one that another account already uses,
an e-mail address that is not this account's own, and one that a published license
already carries. Section 12 says what to do about the last of those. Accounts marked
*name shared* on `/admin/users` were already sharing a name before the rule existed, and
they keep it until one of the two is changed.

**Promote or demote.** Change the role on the account page. You cannot remove your own
administrator role or switch your own account off, so the last administrator cannot lock
the installation.

**What the forms refuse.** A value the form does not offer is refused with a sentence and
changes nothing, rather than being turned into something else: a role that is not in the
list (on an account and on an invitation), an action the account page does not have, an
empty name, and a name longer than 120 characters. The same holds on the other pages: a
token name longer than 80 characters, a model name longer than 200, a provider the
assistant page does not list, a deprecation reason longer than 2,000 characters and a
successor graph that does not exist. Before, a long value was cut short and saved.

If the name somebody picks when accepting an invitation is already taken, the invitation
form comes back with the reason under the name, and the invitation stays good for the
next try.

**Suspected compromise.** Open the account, *Log this person out everywhere*, then *Send
a reset link*. Then check `/admin/audit` for bursts of failed logins and for created
tokens you do not recognise.

**Write to one person.** *Message* on `/admin/users/{id}` starts a conversation with that
account, which they read in their own inbox. Nobody else can open it.

**Somebody asks to be deleted.** A member does this themselves at `/account/delete`,
where their password is asked for. *Anonymise account* on `/admin/users/{id}` is for the
case where they cannot: the address no longer works, the account is switched off, or the
request arrived by post. Tick the confirmation and submit; you cannot do it to your own
account, because that one belongs on your own page behind your password.

It replaces the name and the address with placeholders (`a former member` and
`deleted-<id>@invalid`), clears the password, revokes every token, invalidates every
session, removes the notification preference row and every notification written for the
account, deletes the license drafts and the dependency graph drafts with all their saved
versions, deletes the invitations carrying that address and the ones the account sent,
takes the account id off the counting rows of the two assistants, and detaches the
account from the publisher name it reserved.

**It does not touch a published document.** A license says who granted it, so the name
stays on it. What the person can do instead is mark a license withdrawn, which the
delete page offers for each of theirs before they confirm. If somebody asks for a name
to be taken off a withdrawn license as well, the board decides and an administrator
carries it out by hand; the version as published stays in `license_revisions` and is
not republished.

Until September 2026 anonymising rewrote `dct:publisher` and `dct:creator` to
`a former DALICC member`. Licenses from accounts anonymised before that still carry the
placeholder literal and are left as they are: putting a name back would be a change to
a published document nobody asked for.

The publisher name stays reserved when the account published something, with no row
saying whose it was, so that nobody else can publish under it; when nothing was
published the reservation goes and the name is free again.

The ownership rows, the submissions, the correction requests and the messages stay,
under the placeholder, so the record of a review that other people took part in is not
rewritten.

The audit entry is `user.anonymised`, with `by` (`self` or `admin`) and the counts of
tokens revoked, drafts deleted, graphs deleted, invitations removed, notifications
removed, assistant rows detached, licenses left published and licenses withdrawn. The
entry names the account id, which after this points at nothing that identifies a person.

If the triple store cannot be reached the database half still happens; a withdrawal that
could not be written is skipped and can be made again from the license page.

The placeholder address can neither sign in nor be invited: an invitation for an address
that already has an account reactivates it, and a deleted account must not come back
that way. `/admin/invitations` refuses it with that reason. There is no way back: if the
same person returns, create a new account for them.

---

## 3. The review queue and correction requests

Members offer their own licenses and dependency graphs for the curated library, and
report that a curated record is wrong. What those two look like from their side is
[USERS.md section 20](USERS.md#20-submissions-correction-requests-and-the-inbox). This
is the other side of the same conversation.

**The review queue** is `/admin/review`, also reachable as *Review queue* in the Review
group of the administration menu. Filter it by kind, by status and by age,
where age means *older than*, so that what has been waiting longest is easy to find. One
submission shows what was offered, with links to its public page and its manage page, the
rationale, the conversation and the decisions:

* **Start reviewing it**, so the submitter can see somebody is;
* **Ask for changes**, with a note saying what;
* **Approve it**, then **Publish it** with the curated identifier (2 to 64 characters;
  letters, digits, dots, plus signs and dashes for a license; letters, digits, dots,
  dashes and underscores for a graph). Publishing creates the record (section 4) and
  cannot be undone;
* **Reject it**, with a note.

**The correction queue** is `/admin/requests`, *Correction requests to review* in the
Review group. One request shows the structured reasons
next to a link to the record and a link to the editor. **Accept** it and it appears in
the *Correction request* select on that record's publish form. Publishing a version that
names the request resolves it and links the version in its conversation. Use **Reject**
or **Close** with a note when no version will answer it, and **Mark it resolved** by hand
when it was fixed some other way.

**The queue inbox** is `/admin/inbox`, *Review inbox* in the Review group of the
administration menu: every submission and correction thread in one list, whoever started it. Your own conversations, including the ones somebody started
with you, stay in your personal `/inbox`.

Nothing is moved, renamed or deleted when a submission is published. A **new** curated
record is created that says the same thing, names the submitted license as its source and
its owner as its creator, and starts at version 1 with the review state `dalicc:Created`.
The submitter's own license keeps its address, its owner and its history. The new record
has not been reviewed against the legal text; review it before anybody relies on it.

---

## 4. Curated license records

The 581 records of the DALICC license library are a different kind of object from the
licenses accounts compose: nobody owns them, they are listed and searched, and what they
say decides what the compatibility checker answers. They are kept in the project
repository, and they can also be changed here in the browser, which is the way to make a
correction that cannot wait for a release.

**Where.** *Administration* then *Curated records*, or `/admin/licenses/core`. The list
shows every record with its model version, when it last changed, its review state, and a
badge when it has an unpublished draft or a correction that is not in the repository yet.
When the license store does not answer, the list is read from the files shipped with
DALICC, without titles, and the page says so above the list.

**Two editors, one draft.** `/admin/licenses/core/{id}/edit` has two tabs:

* **Guided form** is the License Composer, prefilled from the record, with the fields a curated
  record needs and a composed one does not (alternative names, SPDX identifier, legal
  code URL, publisher, logo, the version of the license itself). It is a faithful editor
  for almost every record; when a record holds something the form cannot carry, the page
  says so and lists the statements, so the choice to use the Turtle tab is an informed
  one.
* **Turtle** is the whole record as text. It is checked before it is stored: it must
  parse, hold exactly one `odrl:Set` whose subject is the record's own address, use only
  actions of the DALICC vocabulary and only absolute addresses, and keep the review
  state. A document that does not parse is refused with "That is not valid Turtle
  (line n)", keeping the line number where the parser gave one; the other errors say
  which rule the document breaks.

Saving in either tab writes the same draft, which is private to the administration pages.
**Preview** renders it read only, as the public page would.

**Publishing.** "Publish as version n+1" asks for a summary (one sentence, which becomes
the headline of the change log entry), a reason (free text, which becomes the reason of
every change in it) and optionally the identifier of a correction request. It then:

1. stores the version, immutably, with your name and the date;
2. keeps the version it replaces retrievable;
3. replaces the record in the license library, so the site and the API serve the new
   model at once;
4. raises the record's version number and its modification date, and keeps its version
   history;
5. writes the change log entry, which appears on the license page and in
   `/license-library/changes` in the same sequence as the versions that came from the
   repository.

It cannot be undone. A mistake is corrected by publishing another version.

**Deprecating.** A curated license can be withdrawn with a reason and an optional
successor. It is a version like any other: the record keeps resolving and gains a
deprecation marker, the date and the pointer at the successor.

**Getting a correction into the repository.** The server does not write into the project
repository, so a version you publish here lives on this installation until somebody with
a checkout writes it back. `/admin/licenses/core/export` lists what is waiting, without
writing anything, so you can see at a glance whether anything is outstanding. Doing it is
an operator's task, and a weekly rhythm is reasonable
([OPERATIONS.md](OPERATIONS.md#62-bringing-server-side-corrections-back-into-git)).
Nothing is lost while it waits: the versions stay in place and the site keeps serving
them.

**A submission becoming a curated record.** On `/admin/licenses/{id}/promote` you give a
published, composed license a curated identifier (2 to 64 characters, letters, digits,
dots, plus signs and dashes, unique against the records that already exist). The rest is
in section 3.

**Every license with an owner** is at `/admin/licenses`, with filters and the deprecate
and transfer actions. You may open any of them in the composer, save drafts, publish new
versions and deprecate them, which is how the DALICC team corrects a license it is asked
about without taking it over. It is never silent: the revision you save is attributed to
you with a badge, the owner's manage page tells them their license was last edited by an
administrator and when, the act is recorded in the audit log, and the owner can restore
any earlier revision in one step. Ownership does not change.

---

## 5. Core dependency graphs

A dependency graph says which actions include, imply, equal or contradict which other
actions, and it is what the compatibility checker reasons with. A **core** graph is
curated data: an administrator edits it in place, publishing mints the next version
rather than creating a second object, and nobody but an administrator may touch one. The
graph that ships with the service, `dg_default`, cannot be deprecated, because
withdrawing it would silently change every answer the service gives. How members work
with graphs of their own is [USERS.md section 18](USERS.md#18-dependency-graphs), and
what the reasoning does with a graph is explained for visitors on the *How reasoning
works* page at `/reasoning`.

**Promoting somebody's graph.** `/admin/dependency-graphs`, *Change*, *Promote to core*.
You assign the identifier by hand (2 to 64 characters of letters, digits, dots, dashes or
underscores; it becomes the address and never changes). The statements are copied,
version 1 of the core graph is stored with a change log naming who promoted it, and the
user's graph is marked deprecated with a pointer at its successor. Nothing is deleted and
no address stops resolving.

**Editing a core graph.** Open the ordinary editor on it. *Publish version n+1* needs a
one-sentence summary and takes an optional reason. It archives the statements the graph
had as version *n* and writes the new ones as version *n+1*, and it works the change list
out from the statements themselves rather than from what you believed you changed. The
history in the Dependency Graph Viewer (`/dependency-graph`) then shows the versions that
came from the repository and the ones published here as one sequence.

Like a curated license record, a version published here waits on this installation until
an operator writes it back into the project repository
([OPERATIONS.md](OPERATIONS.md#62-bringing-server-side-corrections-back-into-git)). A
graph a **member** published is never written back, because it belongs to them.

**Editing a jurisdiction graph.** The seven jurisdiction graphs (`dg_eu`, `dg_us`, `dg_cn`,
`dg_gb`, `dg_jp`, `dg_in`, `dg_br`) are complete graphs, and the editor edits them as a whole
like the core graph: the 46 axioms, the core graph's adopted rule and the graph's own rules.
Every rule of a core graph needs an **explanation** beside its basis, two or three plain
sentences on what the rule does, why, and what a person combining licenses will notice; the
editor refuses to save a rule without one. What the editor does not show is kept from the
version before: the title and description, the core graph and version the graph was built
from, and the records of the axioms it removes. The page of such a graph says that its
**source of truth is the difference file** `licensedata/dependencygraph/differences/<id>.ttl`
in the repository. A version you publish here takes effect on this server at once; to keep
it, an operator exports it to git exactly like an edit of the core graph (`make
export-library` writes the complete graph to `licensedata/dependencygraph/<id>.ttl`), then
folds it back into the difference file and regenerates the graph:

```bash
python scripts/build_dependency_graphs.py --extract dg_eu     # complete graph -> difference file
git diff licensedata/dependencygraph/differences/dg_eu.ttl  # review what the edit changed
python scripts/build_dependency_graphs.py --bump --summary "..."   # the next version, logged
python scripts/validate_data.py
```

`--extract` refuses an edit a difference file cannot say (an axiom the core graph does not
hold, or a core rule dropped without a rule that replaces it); such a change belongs in the
core graph. An axiom you removed on the server comes back as a removal record whose basis and
explanation are empty, and the build refuses it until somebody writes both. An edit of the
**core** graph changes every jurisdiction graph as well: after it is exported, `--bump` gives
each of the seven its next version, "Follows core graph version n".

---

## 6. The blog and the media library

The blog is managed in the browser by administrators, at `/admin/posts` and
`/admin/media` (also *Blog posts* and *Media* in the Content group of the administration
menu). Members cannot open these pages. Everything that changes is recorded in the
audit log.

The blog is seeded from markdown files that ship with the service, in
`app/content/blog`. At every start the service imports the seed files it does not have
yet, by address. A new release that brings a new seed file adds that post at the next
restart, next to everything you have written.

A start also reads the files it has imported before, so a corrected seed file reaches an
instance that is already running. A post is rewritten from its file only if you have
never saved it in the editor, and only the fields that differ are touched: the date it
carries and its place in the blog do not move. The moment you open such a post and save
it, the editor owns it and no release changes it again. If you want the file's text back
after that, delete the post and restart. Posts recovered from the old site are never
rewritten this way, because those files are not maintained.

Deleting a seeded post is permanent too. The address goes onto a list of removed seeds,
and a restart does not bring the post back.

The same start also clears up the other way round. A post that came from a seed file and
whose file is no longer in `app/content/blog` is removed, and its address goes into the
removed-seed list, so the blog holds what the files say it holds. Three kinds of post are
left alone: one you wrote in the editor, one whose file is still there, and one that was
edited in the editor after it was imported. Each removal is written to the service log on
its own line.

Some installations still hold posts recovered from the old dalicc.net site that were
edited here, so the start leaves them. They carry an **archived** badge in the list and
can be removed in one go, see
[Remove the posts of the old site](#remove-the-posts-of-the-old-site).

### Write a post

1. **Blog posts** in the admin menu, then **New post**.
2. **Title** is required. The **address** under it is filled in from the title while you
   type and is what the post is published at (`/blog/<address>`). You can change it, and
   the form refuses an address another post already uses. Changing the address of a post
   that is already public breaks the links people have to it, so change it only if you
   have to.
3. **Teaser**: the sentence shown in the blog list, on the home page and in the feed.
   Leave it empty and the first paragraph of the body is used.
4. **Body**: markdown. `#` for a heading, `*text*` for emphasis, `[label](address)` for a
   link, `![description](address)` for an image. The preview pane on the right shows the
   result as you type; it shows exactly what the published page will show, because it is
   rendered and cleaned the same way. With JavaScript switched off the preview is simply
   absent and everything else works.
5. **Categories** and **tags**: separated by commas. Each one becomes a page
   (`/blog/category/...`, `/blog/tag/...`) and appears in the sidebar of the blog.
6. **Status**: `Draft` while you are working. A draft is invisible to everybody else: it
   is not on `/blog`, not on a category or tag page, not in the feed, not in the sitemap
   and not on the home page, and its address answers 404.
7. **Publication date**: in UTC. Left empty, publishing sets it to the moment you
   publish. Set it by hand to keep the original date of an older piece. **Author
   name** beside it is the name shown on the post, up to 120 characters; never put an
   e-mail address there.
8. **Save** keeps your work. **Save and publish** puts it on the blog. A published post
   can be taken down again with **Save as draft**; the date is kept.

### Add a photograph

You can upload from the editor (right-hand side, "Upload an image") or from **Media**.
Either way:

* **A description is required.** It is read out to people who cannot see the picture and
  it is shown when the image fails to load. The form does not accept an empty one.
* Accepted formats are **JPEG, PNG, GIF and WebP**, up to **8 MB**. What counts is the
  content of the file, not its name: renaming a document to `.png` does not get it in.
* **SVG files are refused.** An SVG is a document that can carry script, not a picture.
  Export the graphic as PNG or WebP.
* Every upload is **re-encoded**, which removes the camera model, the timestamp and the
  location a photograph usually carries. Animated GIFs keep their frames.
* The file is stored under a random name. Nothing you type is ever used as a path.

Uploading from the editor inserts `![your description](/media/...)` at the cursor
straight away. From the media library, use **Copy address**, or the markdown line shown
next to each image in the editor.

**Cover image**: pick one in the editor from the images already uploaded. It is shown
above the post, in the blog list and as the preview image when the post is shared.

### Delete something

* A **post**: the **Delete** button opens a confirmation page. Tick the box and confirm.
  It is removed from the blog, the feed and the sitemap immediately and cannot be
  recovered. If you only want it off the blog for now, save it as a draft instead.
* An **image**: **Delete** in the media library. This is **refused** while any post still
  uses the image, as a cover or in its body, and the message names the posts. Take it out
  of those posts first.

### Remove the posts of the old site

The posts recovered from the old dalicc.net site carry an **archived** badge. Every start
already removes the imported posts whose markdown file is gone, so what is usually left
is the ones somebody edited here. Where an installation still holds any, the Blog posts page
offers **Remove all archived posts**.

The button opens a confirmation page that lists every post it would take, with its
address and its publication date. Tick the box and confirm, and all of them go at once:
off the blog, the feed and the sitemap, with their addresses written into the removed
seed list so a restart leaves the blog as you left it. Posts you wrote in the editor are
not touched, and the removal is written to the audit log as `post.archive_removed` with
the number of posts.

Export the blog first if you want to keep a copy of them, see
[Export and import the whole blog](#export-and-import-the-whole-blog). The export can be
read back here or into another installation.

### The feed

`https://dalicc.net/blog/feed.xml` is an RSS 2.0 feed of the twenty newest published
posts. The blog pages link to it, and any feed reader can subscribe.

### Export and import the whole blog

Two buttons at the top of the Blog posts page move the blog as a whole.

**Export all posts and media** downloads `dalicc-blog-<date>.tar.gz`. Every post is in it
as a markdown file, drafts included, with its title, address, dates, author, status,
categories, tags and teaser at the top. Every uploaded image is in it as a file, and
`media/index.json` lists the description, the type, the size and the checksum of each one.
Unpack the archive with any archive program and you can read a post in a text editor and
open the pictures.

**Import an archive** takes such a file back in, on another installation or on this one.
Choose the file, choose what should happen to something that is already here, and
confirm:

* **Add only** keeps what is stored here. A post whose address is already taken and an
  image that is already in the media library are left untouched and listed as skipped.
* **Replace existing** takes the archive's version: a post with the same address is
  overwritten, and the description of an image that is already here is replaced.

Posts are matched by their address, images by the content of the file. Importing the
same archive twice therefore changes nothing the second time, and an image that is
already in the library is never stored a second time. An imported image gets a new id in
this installation, so every `/media/...` address inside a post body and every cover
image is rewritten to point at the image the import stored.

The result page lists what was created, what was replaced and what was skipped, and the
import is written to the audit log.

**What is refused.** The archive is read and checked in full before anything at all is
written, so a refused archive leaves the blog exactly as it was. An archive is refused
when a path inside it is absolute or reaches outside the archive with `..`, and when it
holds a link or a device entry. It is refused when a file is larger than its limit, which
is 200 MB for the archive and 8 MB for one image, and when an image is not a JPEG, PNG,
GIF or WebP by its content, does not match the checksum in the index, or has no
description. It is refused as well when `manifest.json` is missing, unreadable or written
in a format this installation does not read. The page then names the file inside the
archive and the reason.

### Backups

A post and the pictures it shows are useless apart, so the posts and the uploaded images
have to be backed up together. On a standard installation they sit side by side and one
copy covers both; the procedure is an operator's
([OPERATIONS.md](OPERATIONS.md#3-backup-and-restore)). An export is a copy of the content
in a form you can read and load again, and it is worth making one before a large edit. It
is not a substitute for that backup, because it holds no accounts, no licenses and no
audit log.

---

## 7. API usage and per-account limits

Requests made with a personal API token are counted per account, in four windows at once
(per second, per minute, per hour and per day), and an expensive call counts as more than
one. What a member sees of that is
[USERS.md section 21](USERS.md#21-api-limits-and-usage).

* **`/admin/api-usage`** lists the last 24 hours: the busiest accounts, the busiest
  endpoints, how many requests were refused, the defaults in force and what each
  expensive endpoint costs. `/admin/api-usage/data` is the same figures as JSON. The rows
  behind it are kept for seven days, so export them before that if an investigation will
  run longer.
* **`/admin/users/{id}/limits`**, reachable from every account page, shows that account's
  four limits, what it has used of each window right now, and its last fifty requests.
  Saving stores limits for that account alone; "Use the defaults again" removes them so
  the account follows the installation's defaults once more. Both are written to the
  audit log.

The four numbers have to be whole numbers of at least one and have to grow with the
window: a per-hour limit below the per-minute one would be a per-minute limit with a
confusing label, and the form says so instead of storing it.

A fifth field on the same form, **translations per day**, caps how often that account may
use Text-to-License. Leave it empty and the account follows the installation default;
zero means the account keeps its API allowance but cannot start a translation, which is
not the same as switching the account off.

**If somebody is abusing the API**, escalate in this order and stop as soon as it is
enough:

1. **Revoke the token.** `/admin/users/{id}` lists the tokens of an account, each with
   a **Revoke** button (audited as `api_token.revoked_by_admin`); the person revokes
   their own on their tokens page, and switching the account off stops all of them at
   once. Revocation is immediate: the next request with that token is refused.
2. **Lower that account's limits** on `/admin/users/{id}/limits`. It takes effect on the
   next request, and the counts already recorded stay where they are.
3. **Look at what they called**, in the last fifty rows on the limits page or in the
   endpoint table on `/admin/api-usage`.

Lowering the limits for everybody, or blocking traffic that carries no token at all, is a
change to the installation and belongs to an operator
([OPERATIONS.md](OPERATIONS.md#71-somebody-is-abusing-the-api)).

This page is the last 24 hours. For weeks and months, with charts and an export, see
[section 13](#13-usage-statistics).

---

## 8. The assistant settings

`/admin/assistant` is **Assistant** in the Settings group of the menu, and the page
itself is titled **Assistant settings**. It has two sections of
equal rank, one per direction of the assistant:

* **Text-to-License** holds the providers, their access keys, their models and the
  daily caps, together with the runs of the last two hours (up to fifty) and what each
  of them cost; a run row is deleted after two hours, and the day's number stays on
  the statistics page. Each
  provider has a **Test connection** button. How the feature looks to the person using
  it is [USERS.md section 19](USERS.md#19-text-to-license).
* **License-to-Text** holds the switch, the allowance, a **Try it** button and the
  figures of the other direction, described
  [below](#license-to-text-has-its-own-section).

Both directions use the same providers and keys, so they are set once, in the
Text-to-License section.

The providers are a table of three. Each one has its own key, its own model and its
own allowance, and a run uses one of them: the one that can answer and has the largest
tokens-per-minute allowance it has reported, with the order below deciding between two
that report the same. When a provider refuses a part, that part and the rest of the run
go to the next provider in the table, so a free allowance that runs out in the middle of
a long license no longer ends the reading.

* **The access key**, per provider. A key stored on this page is encrypted where it is
  kept and is shown only by its last four characters. It wins over the one the
  installation was started with, which is how it is replaced without anybody touching
  the server. **Test connection** lists that provider's models, asks it one small
  question and prints the allowance figures that came back. **Remove stored key** puts
  the provider back on the key the installation was started with. A provider with no key
  anywhere is simply not used. Storing a key is refused on an installation that has no
  signing key of its own, because the stored value could not then be read back after a
  restart. Fixing that is an operator's job
  ([OPERATIONS.md](OPERATIONS.md#41-the-provider-access-key)).
* **The model**, per provider. Chosen from that provider's own list. Changing it changes
  what the assistant proposes, so change it deliberately.
* **Use this provider** switches one provider off without removing its key, and **Up**
  and **Down** change the order the providers are tried in.
* **Requests a day** and **Tokens a day** are what that provider's tier allows. The page
  counts what has been spent today next to them, and when a cap is reached the next
  provider takes over until midnight UTC. Empty or 0 means no cap.
* **Who the provider is**, per provider: its legal name, its country and the address of
  its own privacy notice (an `https://` address). The privacy notice lists every
  provider a text may go to with these three, because a label such as the one on this
  page does not tell a reader which company receives their text. Nothing is filled in
  for you: take the three from the provider's terms and privacy notice, and check them
  again when the provider changes hands. Until all three are recorded, the notice
  prints the label and says what is still missing. **Save who the provider is** writes
  an `assistant.identity_changed` audit entry with what changed.
* **The caps** further down are about people rather than providers: how many
  translations one account may run per day and how many the whole installation may run
  per day. A key costs money when it is used, and the whole installation's cap is the
  backstop. A single account's cap is set on that account's limits page (section 7).
* **The state column** shows what each provider will still accept and, while one is
  away, when it comes back. The feature is only unavailable while every provider is
  away, and the countdown a reader then sees is the first one back.

To add a provider: get a key from it, paste it into that provider's box, press **Store
the key**, then **List the models** and choose one, and set the two daily caps its tier
allows, and record who the provider is. Nothing else is needed; the next run can
already use it.

`/admin/assistant/prompt` shows the whole instruction the assistant is given, exactly as
it is sent, together with its version. What you see on either page is what goes out.

Every change here is written to the audit log with the provider it was about, including
the last four characters of a key that was set or removed. The text people submit is
never recorded.

### License-to-Text has its own section

`/license-to-text` writes the license text of a license model, the other direction of
Text-to-License ([USERS.md section 19a](USERS.md#19a-license-to-text)). It uses the
providers, the keys and the models above, and the installation's daily cap, because it
spends the same provider budget. Everything else is its own, in the section
**License-to-Text** further down the same page.

* **License-to-Text is available on this deployment** switches the whole feature on and
  off. Off, `/license-to-text`, the *Write this as a license text* button in the
  composer and `POST /licenselibrary/narrate` all say that it is switched off and answer
  `503`. `GET /licenselibrary/license/{id}?format=text` keeps working, because that text
  is written from the DALICC vocabulary and asks no provider.
* **License texts per account per day** is the allowance. Leave it empty and an account
  may write as many license texts as it may run translations, which is what an
  installation that treats the two directions as one budget wants. `0` switches the
  feature off for everybody without switching the feature itself off.
* **Visitors without an account** is a choice of three: follow the Text-to-License rule,
  allow it, or ask people to log in first.

However you set the allowance, the two counts are kept apart: writing a license text
never takes a translation away, and running a translation never takes a license text
away.

**Try it** writes the license text of MIT, a license from the library, the way a
reader's request would at that moment. The providers are asked in their usual order,
and the page shows the text that came back, which provider wrote it and with which
model, and what it spent: one request, the tokens in and out, and the requests and
tokens the provider says are left. When no provider answers, the text comes from the
DALICC vocabulary and the page gives the reason, for instance that no provider has a
key or that the daily allowance is used up. The try counts as one License-to-Text run
in the figures below, and it spends none of your own account's allowance. It is written
to the audit log as `assistant.narrate_tried`.

So there are two ways to check the providers from this page. **Test connection** in the
Text-to-License section asks one provider a very small question and costs a handful of
tokens. **Try it** in the License-to-Text section does a full run on a real license and
costs a few thousand tokens of whichever provider answers.

**What License-to-Text did** under the form is the record: license texts today, over
the last 7 days and over the last 30 days, and a table of who wrote them, one line per
provider plus one line for the texts the DALICC vocabulary wrote with no provider asked.
The figures are whole days in UTC, today included, and are counted in the daily counts
as each text is written, so they cover the whole week and month although the run
itself is deleted after two hours.

Nothing of a license text is stored beyond that count. Where the key is missing, where a
cap is reached or where the provider does not answer, the feature writes the text from
the DALICC vocabulary instead of failing, and the page tells the reader that is what
happened. So switching the *assistant* off does not switch License-to-Text off: it makes
every text the plain one. Switching *License-to-Text* off is the setting above.

The three settings can also be set in the environment as `DALICC_NARRATE_ENABLED`,
`DALICC_NARRATE_PER_DAY` and `DALICC_ANONYMOUS_NARRATE`
([DEPLOYMENT.md](DEPLOYMENT.md)); what is stored on this page wins over them, and
emptying a field here gives the environment back. Every change is written to the audit
log as `assistant.narrate_changed`.

---

## 9. Notification and contact mail

Every notification always appears in the recipient's inbox, whether or not the
installation can send mail. When it can, the same notification is also mailed, with a
one-line summary and a link back, and each person can switch that off for themselves.

When it cannot, nothing is silently dropped and every page says so. An invitation link
and a password-reset link are shown to you instead of being mailed, to pass on by hand;
"forgot password" tells the visitor to contact an administrator; and the contact form
offers the visitor a prefilled mail link. Configuring a mail server removes all of that
at once, and it is an operator's task
([OPERATIONS.md](OPERATIONS.md#43-outgoing-mail-and-what-happens-without-it)).

A mail server that is down never fails what somebody was doing: the notification is
written, the send is attempted once, and the failure is logged. The inbox shows which
notifications went out by mail.

---

## 10. The audit log

`/admin/audit` is the record of everything that changed, newest first, 25 entries a
page, with the account that did it, the object it was done to and the time. The page
has no search of its own; the action names are stable dotted strings, so an operator
can search the `audit_log` table for one.

| Area | Actions |
|---|---|
| Sessions and passwords | `auth.login`, `auth.login_failed`, `auth.login_refused`, `auth.login_unverified`, `auth.logout`, `auth.logout_everywhere`, `auth.password_changed`, `auth.password_reset_requested`, `auth.password_reset` |
| Invitations | `invite.created`, `invite.accepted`, `invite.revoked`, `invite.resent` |
| Signing up and signing in with a provider | `register.created`, `register.verification_sent`, `register.verified`, `register.duplicate`, `sso.linked`, `sso.refused` |
| Accounts | `user.role_changed`, `user.activated`, `user.deactivated`, `user.updated`, `user.password_set`, `user.bootstrapped`, `user.approved`, `user.rejected`, `user.anonymised`, `privacy.acknowledged` |
| API tokens | `token.created`, `token.revoked`, `api_token.revoked_by_admin` |
| Publisher names | `name.released`, `name.refreshed` |
| Licenses | `license.draft_created`, `license.published`, `license.version_created`, `license.deprecated`, `license.transferred`, `license.member_added`, `license.member_updated`, `license.member_removed`, `license.member_invited` |
| Curated records | `core_license.created`, `core_license.published`, `core_license.draft_discarded` |
| Dependency graphs | `depgraph.draft_created`, `depgraph.published`, `depgraph.version_created`, `depgraph.deprecated`, `depgraph.promoted`, `depgraph.transferred`, `depgraph.member_added`, `depgraph.member_updated`, `depgraph.member_removed`, `depgraph.member_invited` |
| Review workflow | `submission.created`, `submission.status_changed`, `submission.published`, `request.created`, `request.status_changed`, `request.resolved`, `thread.created` |
| Blog and media | `post.create`, `post.update`, `post.publish`, `post.unpublish`, `post.delete`, `post.archive_removed`, `post.export`, `post.import`, `post.import_refused`, `media.upload`, `media.delete` |
| API limits | `ratelimit.updated` |
| Usage statistics | `stats.rollup` |
| The two assistants | `assistant.key_set`, `assistant.key_removed`, `assistant.model_changed`, `assistant.provider_changed`, `assistant.order_changed`, `assistant.quota_changed`, `assistant.identity_changed`, `assistant.narrate_changed`, `assistant.narrate_tried`, `assistant.tested` |
| Registration and appearance | `registration.policy_changed`, `registration.policy_reset`, `appearance.changed`, `appearance.reset`, `banner.changed` |

Three rules hold for every entry:

* **No secret is ever written.** Not a password, not a token, not an access key, not the
  body of a request. A failed login is counted without the address that was tried.
* **Nothing is rewritten.** A withdrawal, a deprecation and a revocation are entries of
  their own; the earlier ones stay as they were written.
* **It is not the only record.** A status change in the review workflow is also a message
  in the conversation, a notification for the people it concerns, and a decision recorded
  on the submission or the request itself.

**Two years, then the entry goes.** An hourly sweep drops every entry older than
`DALICC_AUDIT_RETENTION_DAYS`, which is 730 days
([DEPLOYMENT.md](DEPLOYMENT.md#48-retention-and-the-periodic-sweep)). The same sweep
enforces the other retentions the privacy notice states: seven days of API request
rows, `DALICC_STATS_RETENTION_DAYS` of daily usage counts, two hours of the assistants'
finished runs, and the account ids of the daily counts, folded into a number after two days, a sign-up whose address was never confirmed, a week after its last link ran out, a password reset link once it is used or has run out, and an invitation nobody accepted, a week after it ran out or was withdrawn. Long enough to investigate an intrusion that is noticed
late, short enough that the record of who did what does not follow a person for ever.
Export what an investigation needs before it ages out.

**What a publish records.** `license.published` carries the consent the publisher gave
and the version of the privacy notice it was given against, for the web form, for the
API and for an anonymous publish, which writes an entry with no account behind it. The
version comes from the same wording the account accepted, so an entry says what
somebody agreed to and not only that they agreed.

---

## 11. Where the administration pages are

On a page with several forms (the assistant settings, an account, registration, the
statistics), the sentence that says what a form did appears inside that form's section,
and the page scrolls there.

| Page | What it is |
|---|---|
| `/admin` | the administration home |
| `/admin/users`, `/admin/users/{id}` | accounts |
| `/admin/names` | the publisher names that are already in use |
| `/admin/users/{id}/limits` | one account's API limits and usage |
| `/admin/api-usage` | the last 24 hours across all accounts |
| `/admin/statistics` | how much the site and the API were used, over weeks and months |
| `/admin/invitations` | invitations |
| `/admin/licenses`, `/admin/licenses/{id}` | every owned license |
| `/admin/licenses/{id}/promote` | publish a submitted license as a curated record |
| `/admin/licenses/core`, `/admin/licenses/core/{id}/edit` | the curated records and their editor |
| `/admin/licenses/core/export` | what is waiting to be written back into the repository |
| `/admin/dependency-graphs` | core and promoted dependency graphs |
| `/admin/review`, `/admin/requests`, `/admin/inbox` | the review queue, the correction queue and their conversations |
| `/admin/posts`, `/admin/media` | the blog and the media library |
| `/admin/assistant`, `/admin/assistant/prompt` | the assistant settings: Text-to-License (providers, keys, models, caps, runs) and License-to-Text (switch, allowance, Try it, figures), and the instruction as it is sent |
| `/admin/appearance` | the palette, the font set, the type scale, the menu style and the logo the site is drawn in, and the site banner |
| `/admin/registration` | invitation only or self-registration, and the options that come with it |
| `/admin/audit` | the audit log |
| `/admin/settings` | what this installation is configured to do |

The pages a member uses are in [USERS.md](USERS.md#23-where-to-find-things), the same
objects over the API are in [API.md](API.md), and the server behind all of it is in
[DEPLOYMENT.md](DEPLOYMENT.md) and [OPERATIONS.md](OPERATIONS.md).

---

## 12. Publisher names

A published license names its publisher, and that name is the only thing about the author
it carries. DALICC therefore treats a name as belonging to one account. No two accounts
can publish under the same name; an account may use an e-mail address as its name only
when the address is its own; and a name that a published license already carries cannot
be taken by somebody else. What that looks like from a member's side is
[USERS.md section 12](USERS.md#the-name-you-publish-under).

`/admin/names` is the list of the names that are already in use. Each row shows the name
as the license writes it, the key it is compared by, where it was found and whether it has
been released to an account.

* **Held.** Nobody can take the name as the name of their account. This is the state of
  every name that DALICC found on a custom license with no owner, which is most of the
  legacy library: those licenses were published before there were accounts, so the
  service cannot know whose names they are.
* **Released.** You have said that the name belongs to one account. That account can use
  it as the name it publishes under; for everybody else it stays held.

**Release a name.** Copy the name into the form, give the address of the account it
belongs to, and submit. Ask for evidence first, the way you would before moving a license
to a new owner: releasing a name lets that account publish documents under it. The
release is written to the audit log as `name.released`, with the name and the account.

**Refresh from the library** reads the publisher and creator names out of the library
again, and out of the private drafts of every account. The same sweep runs whenever the
service starts, so the button is for the license that was published a minute ago and for
seeing the effect of a release. A name that no longer appears anywhere is dropped from the
list, unless it has been released. When the triple store cannot be reached the page says
so and nothing changes.

An account is never blocked by a name found on a license it owns, because the sweep
records which account that is. Somebody can always take the name their own published
license carries; only another account is refused.

---

## 13. Usage statistics

`/admin/statistics` answers the question "how much is DALICC being used", over a range of
days rather than over the last day. It is the long view; `/admin/api-usage`
([section 7](#7-api-usage-and-per-account-limits)) stays the operational one, and the two
pages link to each other.

### What is counted, and what is never counted

The page is built on counts and on nothing else. DALICC stores **no IP address, no
browser name, no screen size, no referrer, no cookie value and no visitor identifier of
any kind**, and there is nowhere in the table behind this page that such a value could
go: a row is a day, what was counted, which one of it, and a number. "Unique" on this
page therefore always means a DALICC account or a DALICC API token, never a person
behind a browser. Anonymous traffic is a count and can never be a uniqueness.

| Counted | How it is worked out |
|---|---|
| Page views | Every request that was answered with a page, under the family of its address: `/license-library/*` rather than one line per license, `/admin/users` rather than one line per account |
| API requests | From the API usage log, by operation family (`/licenselibrary/license/*`, `/compatibilitycheck`) |
| Answers | The same requests by class: `2xx`, `4xx`, `429` and `5xx`. A redirect counts with the answers that worked, and `429` is kept apart because a refused request is what an operator looks for |
| Active accounts and tokens | An account that called the API or signed in that day, and a token that was used that day |
| Logins, sign-ups | From the audit log (`auth.login`); a sign-up is an invitation that was accepted or an account created on the sign-up form or through the identity provider (`register.created`) |
| Translations | Runs started, counted as they start, so the figure survives the run rows, which are deleted after two hours; parts read, runs that failed and runs per provider, worked out from the run rows while they exist |
| Licenses, dependency graphs, posts | What was published that day |
| Submissions | What was offered for the curated library that day |

Not counted at all: assets under `/static` and `/media`, the health check, and this page
itself, so looking at the figures never changes them. A request that ended in an error
is not a page view either.

Every day runs from midnight to midnight **UTC**, wherever you and the server are.

### How to read the page

**The controls** sit in one row above everything they scope, and they are all in the
address bar, so a range can be bookmarked or pasted into a message and shows the same
page to the next person.

* **Range.** Six presets (7 days, 30 days, 90 days, 12 months, this year, all), or two
  dates. The dates win over a preset; a range given backwards is turned around and one
  that ends in the future is cut at today.
* **Grain.** Day, week or month. Left alone it follows the length of the range: days up
  to three months, weeks up to two years, months beyond that.
* **Calendar.** Which measure the calendar at the bottom draws, API requests or page
  views.
* **Compare with the previous period** draws the range before this one as a muted line
  behind the measure, and it is the same comparison the tiles use.

**The tiles** are the headline numbers, each with its change against the previous period
of the same length. "No earlier figure to compare with" means the earlier period was
empty; a rise from nothing is not a percentage and DALICC will not print one.

**The charts** are drawn on the server, so they are there with JavaScript switched off
and they print. Each one shows one measure on one axis, never two scales on one plot.
Moving the pointer across a chart over time brings up a hairline and a readout of every
series at that date; that is the only thing the page's JavaScript does. Under every chart
is **Table view**, which opens the same numbers as a table, so no value is only available
by hovering.

* **Page views over time** and **Translation runs over time** are single measures, with
  the previous period behind them when the compare box is ticked.
* **Translation runs per provider** ranks the providers by the runs they answered in
  the range.
* **API requests over time, by answer** stacks the four answer classes. A tall amber or
  red band is the thing to look at.
* **Active accounts and tokens over time** counts each account once per period. Account
  ids are dropped after two days and only the daily number is kept, so a long range is a
  lower bound rather than an exact figure. The note under the chart says so.
* **The ten busiest pages** and **the ten busiest API operations** are ranked bars, every
  bar the same colour: the length already says which is bigger.
* **Content published per period** stacks licenses, dependency graphs and posts.
* The **calendar** is the last twelve months, one cell per day, darker where there was
  more. A day with nothing stays in the page colour, because nothing happened is not a
  small amount of something. Its table view is the same grid as a table, one row per
  week.

### The exports

* **Download CSV**, at `/admin/statistics/export.csv`, gives the rows the page is drawn
  from for the selected range: `day,kind,key,count`, one line each. It is what to open
  in a spreadsheet or attach to a report.
* **Open the JSON** at `/admin/statistics/data.json` gives the tiles and every chart's
  table as JSON, for an operator with `curl`.

Both take the same range parameters as the page, so the address in the bar and the
address of the export are the same range.

### When the figures are worked out

Page views are counted as they happen, in memory, and written every half minute or every
two hundred pages, whichever comes first. Everything else is worked out once a day from
records DALICC already keeps, and the same work runs at startup, on the first page view
after midnight, and from **Work this range out again** at the bottom of the page. Running
it twice writes the same numbers, so pressing the button can never double anything. Press
it when you have just published something and want the figure now. The button is written
to the audit log as `stats.rollup`.

The rows are kept for three years (`DALICC_STATS_RETENTION_DAYS`, in
[DEPLOYMENT.md](DEPLOYMENT.md#48-retention-and-the-periodic-sweep)); the oldest days are
dropped by the hourly sweep. Because the API usage log itself only keeps seven days, a
day that is never rolled up within a week keeps whatever it already had and cannot gain
the parts that came from that log.

**A fresh installation is empty on purpose.** Counting started when this version was
deployed, so a range that reaches back further shows the empty state rather than zeroes
pretending to be measurements. The page names the first day it has anything for.

---

## 14. Appearance

`/admin/appearance` decides what the whole site looks like: the colours, the three
faces it is set in, how large everything is, how the menu is set and how the logo is
drawn. Nothing here changes what a page says
or what anybody may do; it changes how the site is drawn, for every visitor.

Five things are chosen on one page, each in a select of its own.

**A palette** says what the colours are. Nine are offered:

* **DALICC classic**, the colours the site has worn since the original theme was
  recovered, and the one it starts with.
* **Institutional Navy**, navy for the wordmark, the headings and the menu, coral for
  the second half of the wordmark and for the call to action, a pale blue hero.
* **Graphite and amber**, **Forest and sand** and **Slate and teal**, three quieter
  registers.
* **Ink and gold**, **Charcoal and mint** and **Midnight and sand**, three more
  professional registers: ink with a deep gold, charcoal with a mint hero, midnight
  blue with warm sand.
* **High contrast**, for readers who need the strongest separation: near black on
  white, a saturated blue for links and buttons, black on a pale yellow hero. Every
  pair of text and background is above 7:1.

**A font set** says which three families carry the site: one for the hero line and the
headings, one for running text, one for identifiers, license expressions, versions,
hashes and timestamps. Classic, Institutional (Barlow Condensed, Inter, Inconsolata),
Plex, Geist, Source, Merriweather and Inter (a serif for the headings, an editorial
feel) and Manrope and JetBrains Mono. Every file is served by DALICC itself, so choosing a set asks
nothing of any other company and sends nobody's address anywhere.

**A type scale** says how large everything is, from one body size: Compact (15 px),
Regular (16), Comfortable (17) and Large (18, which is what the site is set in today
and therefore the one it starts with).

**A menu style** decides whether the main menu is set in capitals in the display face
or in sentence case in the reading face. Each font set arrives with the answer that
suits its display face; you may change it.

**A logo** says how the DALICC wordmark is drawn: **Wordmark** (as it has always been,
"dali" in the brand colour, "cc" in the accent, the subtitle line under it),
**Wordmark, single colour** (all of it in the brand colour) or **Wordmark without
subtitle**. Every option takes the palette's own logo colours. There is no option with
a separate symbol, because DALICC has none. The small icon in the browser tab follows
the palette as well; the picture shown when the site is shared on social media stays
the original wordmark.

The palette, the font set and the logo also dress the animated picture on the home
page, so the circle of steps always matches the band it sits on.

### What the page shows

One **preview panel** sits beside the five selects, in the site's own words: the
logo, the menu, a hero line, a button, a card, a table with a license identifier and a
date, a muted note. Change any select and the panel is redrawn at once in the
combination the five make, without reloading the page, so the choice is made by looking
rather than by reading a colour value. Under each select is a sentence about the option
chosen in it.

Under the panel are the **contrast ratios** of the chosen palette: how far apart body text, second-rank
text, muted text, a link, a heading, a button label and the hero line are from the
surfaces they sit on. A ratio of 4.5 is what text at a normal size needs to reach
accessibility level AA, and 3.0 is what a heading and the hero line need. Each row
says *passes* or *under 4.5*. The seven palettes designed for this page reach every
one; DALICC classic and Institutional Navy each fall short on a few, and the page
names which, because they were given rather than designed. That is a decision to make
with open eyes, not a reason the option is hidden.

### Trying it before anybody else sees it

**Preview on the site** applies the choice to your own browser and to nothing else. Walk
through the home page, a license, the composer and this page in it; no visitor and no
other administrator sees any of it. **Stop the preview** puts your browser back. The
preview is a cookie: it does not outlive a browser that is closed, and copying it into
another browser does nothing, because it is read only for an administrator.

### Applying it

**Apply to the site** stores the choice and every visitor sees it, on every page that
is not the legacy `/web` pages. The change is written to the audit log as
`appearance.changed`, with the palette, the font set, the scale, the menu switch and
the logo.

**Reset to the deployment's choice** removes the stored choice. The site then wears whatever
the installation is configured to wear, which unless an operator set something else is
DALICC classic, the Classic font set, the Large scale and the Wordmark. The reset is audited as
`appearance.reset`.

A stored choice wins over the installation's own setting, the same way the
Text-to-License settings work, so a palette can be changed without a deployment. The
variables are `DALICC_APPEARANCE_PALETTE`, `DALICC_APPEARANCE_FONTS`,
`DALICC_APPEARANCE_SCALE`, `DALICC_APPEARANCE_NAV_CAPS` and
`DALICC_APPEARANCE_LOGO`
([DEPLOYMENT.md](DEPLOYMENT.md#4-environment-variable-reference)).

### Where the reasoning is

What the roles mean, why each palette is what it is, where each font file came from
and how to add a palette, a font set or a scale, is in the developer documentation of
the service, section "Typography and colour". That section also carries the typography guidance this system
was built to: one face for reading, one for display, one for exactness, never four;
monospace where exact characters matter and proportional where language matters; and
the size system the four scales follow.

### The site banner

The last section of `/admin/appearance`, **Site banner**, puts one line of text in a
band above the header of every page: the public pages, the account and
administration pages and the error pages, for every visitor, signed in or not. Use it
for something every reader should know about the installation itself, such as that it
is a development version or that it will be down for maintenance on Friday.

It has three controls, saved together with **Save the banner**:

* **Show the banner**, a switch. Off unless the deployment turned it on.
* **Visitors can close the banner**, a switch, off unless the deployment turned it on.
  When it is on, the band has a **Close** button. A visitor who closes the banner does
  not see it again in that browser until the text changes; any change to the text,
  even one character, shows it again to everybody. The browser keeps this itself, so
  closing it in one browser does not close it in another, and a browser that blocks
  site storage shows the banner without the button. When the switch is off there is
  no button and the banner is shown to everybody, including those who closed it
  before.
* **Banner text**, up to 500 characters of plain text. Line breaks become spaces, so
  the banner is always one paragraph; it wraps onto more lines on a narrow screen. A
  tag or a character reference such as `&amp;` is refused with a note beside the
  field, and nothing is saved. Leave the field empty to show the default text:

  > DEVELOPMENT VERSION: This is an experimental DALICC environment. Features, data
  > and APIs may change or be reset without notice. A stable version of this service,
  > with a potentially different feature set, will be made available soon at
  > dalicc.net.

The answer appears beside the form: *Saved. The banner is on and every page shows
it.*, *Saved. The banner is off and no page shows it.*, or *Nothing changed: the banner
was already like this.* Another server process of the same installation follows within
five seconds.

The band is drawn in the warning colours of the palette the site is wearing. Leave
the second switch off for a notice every reader must keep seeing, such as that the
installation is a development version. Programs that use the API see the same text as
`notice` in `GET /v2`, `null` while the banner is off, and `notice_closable`, true when
visitors may close it.

A change is written to the audit log as `banner.changed`, with both switches and the
text before and after. A save that changes nothing is not recorded.

What is saved here wins over the installation's own settings,
`DALICC_SITE_BANNER_ENABLED`, `DALICC_SITE_BANNER_TEXT` and
`DALICC_SITE_BANNER_CLOSABLE`
([DEPLOYMENT.md](DEPLOYMENT.md#4-environment-variable-reference)). The sentence under
the form says which of the two is in force.

---

## 15. Registration

`/admin/registration` decides how a person gets an account. It is *Registration* in the
Settings group of the administration menu. The page has one form: the mode first, then the options that
self-registration takes. Each option has a **?** next to it; the fields of the
identity provider (issuer, client id, secret, scopes and claim) have a line of help
under them instead. Rest the pointer on it,
focus it with the keyboard or press it, and a paragraph says what the option does and
why it is or is not recommended. A screen reader reads the same paragraph with the
button without anybody opening it.

**Invitation only (recommended)** is how the site has always worked and what it starts
with. Nobody creates an account on their own; `/register` answers that accounts are
given out by invitation. It is recommended because every account is then somebody a
member vouched for, and a publisher name on a license belongs to a person the
association can reach.

**Self-registration** opens `/register`. Invitations keep working exactly as before, so
you can still invite an administrator or a collaborator. The options decide what state a
new account starts in:

| Option | Default | What it does | Why |
|---|---|---|---|
| **E-mail verification (recommended)** | on | The account stays switched off until its owner opens a link sent to the address. The link works once, for 24 hours. Signing in with the right password before that sends a fresh link. | Without it anybody can sign up with somebody else's address, and every mail the site sends afterwards goes to a stranger. |
| **Administrator approval (recommended)** | on | The account can sign in, compose and save drafts, but it publishes nothing until you approve it. | A published license is permanent and public, so somebody should look at an account before its first one goes out. |
| **Form check** | off | The sign-up form carries the reCAPTCHA check the license composer uses. | It keeps automatic sign-ups out, but the visitor's browser loads a script from Google, which may process what it collects outside the European Union. Verification and approval stop most automatic sign-ups without that. |
| **Allowed e-mail domains** | empty | Only addresses at these domains may sign up. A subdomain counts: `ustp.at` admits `fh.ustp.at`. | Useful when the members come from a known set of institutions. Invitations are not affected. |
| **Sign in with an identity provider (SSO)** | off | People sign in with the account they already have at their organisation. See below. | Nobody has to remember another password, and the provider has already confirmed the address. |

With both verification and approval on, a new account first confirms its address and
then waits for you. With neither, anybody can create an account with any address and
publish at once; the page warns about that combination.

**The approval queue** is at the top of `/admin/users` whenever an account is waiting.
**Approve** lets it publish and mails the owner. **Reject** mails the owner and then
deletes the account the way `/account/delete` does: the name, the address and the
password are replaced by placeholders and the drafts go. The queue also says how many
sign-ups have not confirmed their address yet; those are deleted a week after their last
link runs out, by the same periodic sweep that trims the logs. The audit entries are
`register.created`, `register.verified`, `register.duplicate`, `user.approved` and
`user.rejected`; the last two record in `mailed` whether the mail went out. On an
installation that cannot send mail the queue says so, and after **Approve** or
**Reject** the page says that the person was not told, so tell them yourself.

**Sign in with an identity provider** uses OpenID Connect with PKCE. Register this site
at the provider as a confidential client with the redirect address the page prints
(`https://<your site>/login/sso/callback`), then enter:

* the **issuer**, the address the provider's discovery document hangs under, over
  `https`;
* the **client id** and the **client secret**. The secret is encrypted before it is
  stored, like the Text-to-License keys, and only its last four characters are shown
  again. It can also stay in `DALICC_SSO_CLIENT_SECRET`;
* the **scopes**, `openid email profile` unless the provider needs others; `openid` is
  required;
* the **claim that holds the e-mail address**, usually `email`. The provider also has
  to report the address as verified (`email_verified`), or the sign-in is refused.

**Test the provider**, under the issuer, asks the issuer in the form for its discovery
document (`<issuer>/.well-known/openid-configuration`) and saves nothing; the answer
appears inside the provider's section. Saving with the provider switched on asks the
same question first. When the address does not answer, answers with an error status,
or serves a document that names another issuer or lacks the sign-in, token or userinfo
address, nothing is saved and the reason stands under the issuer, for example
"Nothing was saved. No discovery document at
https://login.example.org/realms/members/.well-known/openid-configuration (the address
did not answer)." That keeps a wrong issuer from reaching the login page, where every
member would otherwise meet "The identity provider cannot be reached at the moment".

The login page and the sign-up page then show a button. An existing member is found by
the address the provider reports, the first time, and by the provider's own identifier
for them after that, so a changed address at the provider keeps the account. A new
person gets an account under the options above, after one more page that asks for the
name to publish under and the privacy notice confirmation; the verification mail is
skipped because the provider has confirmed the address, and approval still applies.
Under invitation only the provider signs existing members in and creates nobody.

**The identity provider is the only way in** refuses a member's password and hides the
password sign-up. Administrators keep their password, so a provider that is down cannot
lock the site. Leave it off unless every member has an account at the provider.

**What stands in the way** is printed above the form: verification switched on while the
installation cannot send mail (the sign-up page then stays closed unless the provider can
still sign people up), the form check without the reCAPTCHA keys, an identity provider
without its secret, and a secret that cannot be stored because `DALICC_SECRET_KEY` is
unset.

Every save is written to the audit log as `registration.policy_changed` with the fields
that changed; a new or removed client secret appears as `stored` or `removed`, never as
the value. **Reset to the deployment defaults** removes every stored choice, the secret
included (`registration.policy_reset`). The defaults are the `DALICC_REGISTRATION_*` and
`DALICC_SSO_*` settings in [DEPLOYMENT.md](DEPLOYMENT.md#412-registration). The
**Deployment defaults** paragraph under the form says what they are on this
installation: the mode, verification, approval, the form check, the allowed domains,
the identity provider and whether it is the only way in.

---
