iOS is the one client none of the mail autoconfiguration reaches: Apple Mail reads neither Mozilla autoconfig nor Autodiscover for IMAP, so adding an account means the device probing for ports - and a probe that meets a filtered port waits out a TCP timeout and reports the account as unverifiable, which the person reads as a wrong password. A profile states the hosts and the ports instead, and carries mail, calendars and contacts in one install. It invents none of them: site.yaml names a PACC document - the standard defined to be fetched from the mail domain itself - and every host, port and collection path comes from there, so the server is described in one place and the profile follows it. The document holds no address, username or password. iOS asks for those at install, which means one profile serves everyone with an account and it can be fetched by anyone: it says no more than PACC already says in public. Identifiers are derived from the site and the payload rather than drawn at random, because iOS replaces a profile whose ids it knows and stacks a duplicate when they differ. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
403 lines
16 KiB
Markdown
403 lines
16 KiB
Markdown
# Content reference
|
|
|
|
A content repo is the site. This is everything it may say.
|
|
|
|
```
|
|
site.yaml branding, language, mail
|
|
aggregates.yaml the state machine of every bucket
|
|
needs.yaml what the site is for (read by iris, never by portal)
|
|
questions/
|
|
index.yaml the front page, served at /
|
|
about.yaml served at /about
|
|
review/
|
|
_section.yaml applies to everything under /review
|
|
index.yaml served at /review
|
|
[record].yaml serves /review/<anything>
|
|
```
|
|
|
|
Nothing here is required except a `questions/` tree with something in
|
|
it. Portal's lint refuses content it cannot serve honestly, at boot,
|
|
on every hot reload, and in CI through `iris check`.
|
|
|
|
## site.yaml
|
|
|
|
```yaml
|
|
title: Vestmark kommune # the browser title and the header
|
|
wordmark: /site/logo.svg # an image instead of the title
|
|
wordmark_ink: true # a one-colour SVG mark drawn in the page's own ink
|
|
wordmark_invert: true # flip it in dark mode
|
|
wordmark_height: 2.4 # rem
|
|
lang: no # the interface language: en or no
|
|
stylesheet: /site/theme.css # served same-origin from the repo
|
|
favicon: /site/icon.svg
|
|
hero: { kind: module, module: hero.js } # or { kind: plain }
|
|
mail:
|
|
from: Vestmark kommune <post@vestmark.example>
|
|
reply_to: post@vestmark.example
|
|
invite: { subject: "...", body: "..." } # rewords the built-in invite mail
|
|
```
|
|
|
|
`mail.from` is required as soon as any state declares a mail.
|
|
|
|
Any text a person reads (`name`, `description`, `label`,
|
|
`placeholder`, a mail's `subject` and `body`, `consequence`,
|
|
`encouragements`) may be a list instead of one string: one entry per
|
|
[storyline](storylines.md), all lists the same length.
|
|
|
|
## A question
|
|
|
|
One YAML file is one page.
|
|
|
|
```yaml
|
|
id: /apply # defaults to the file's path
|
|
name: Apply to build
|
|
description: What we need from you.
|
|
qualifies: case_officers # a Kanidm group may open this page
|
|
requires_chain: /start # or: an answer they must already have given
|
|
followup: true # keep it out of the nav until earned
|
|
responsible: { name: Solveig, contact: post@vestmark.example }
|
|
# or a group's: no account is made for the inbox, and any signed-in
|
|
# member of the group may suggest a change to the page
|
|
responsible: { name: The board, contact: board@vestmark.example, group: board }
|
|
event: # this page announces an occasion
|
|
starts: 2026-10-14T18:00
|
|
duration: 2h
|
|
place: The town hall
|
|
place: # this page is about somewhere
|
|
lat: 59.8938
|
|
lon: 11.0332
|
|
zoom: 15
|
|
name: The town hall
|
|
alternatives: [ ... ]
|
|
```
|
|
|
|
`qualifies`, `requires_chain` and `responsible` are inherited from the
|
|
nearest `_section.yaml`. `event` makes a page an occasion with a time;
|
|
`place` makes it a place, drawing a still map under the question and
|
|
opening any `type: location` field on the page there rather than on
|
|
the whole world.
|
|
|
|
### Pictures across the page
|
|
|
|
`slideshow` lays a strip of pictures under the question, the full
|
|
width of the screen: every picture as tall as the strip and as wide as
|
|
its own shape, swiped or arrowed through, its caption over its foot.
|
|
|
|
```yaml
|
|
slideshow:
|
|
max_height: 60 # svh; 60 unless set, between 20 and 100
|
|
slides:
|
|
- image: images/built/01.webp # a path in the content repo, or https://
|
|
caption: The canteen at the theatre, renewed
|
|
width: 3207 # the picture's own size, optional:
|
|
height: 1400 # holds its place before it has loaded
|
|
```
|
|
|
|
Pictures in the content repo are served from this origin under
|
|
`/site/`; `webp`, `avif` and `jpeg` may be kept by the browser for an
|
|
hour. Keep them web-sized: around 1400 pixels tall is plenty.
|
|
|
|
## Files at the site root
|
|
|
|
A repo's `.well-known/` directory is served at the root of the site, at
|
|
the same paths it has in the repo:
|
|
|
|
```
|
|
.well-known/security.txt -> https://<site>/.well-known/security.txt
|
|
.well-known/assetlinks.json -> https://<site>/.well-known/assetlinks.json
|
|
.well-known/matrix/server -> https://<site>/.well-known/matrix/server
|
|
```
|
|
|
|
This is the one part of the URL space a repo may write into directly.
|
|
RFC 8615 reserves `/.well-known/`, and a question's id comes from a file
|
|
name under `questions/`, which cannot begin with a dot - so nothing
|
|
shipped here can shadow a page. A path that is not in the repo answers
|
|
**404**, not the app shell, so a program looking for one of these can
|
|
tell a missing file from a present one.
|
|
|
|
A file with no extension is served as JSON, which is what the
|
|
extensionless well-known files are. `.txt`, `.xml`, `.webmanifest` and
|
|
`.mobileconfig` are typed as themselves; the last matters because iOS
|
|
only offers to *install* a configuration profile that arrives as
|
|
`application/x-apple-aspen-config`.
|
|
|
|
Paths a reverse proxy claims in front of portal never reach the repo -
|
|
mail autoconfiguration (`/.well-known/autoconfig/`) and the CalDAV and
|
|
CardDAV redirects are usually handed to a mail server that generates
|
|
them.
|
|
|
|
## A configuration profile
|
|
|
|
iOS has no autoconfiguration for IMAP accounts - Apple Mail reads neither
|
|
Mozilla autoconfig nor Microsoft Autodiscover, which are for ActiveSync - so
|
|
adding an account by hand leaves the device probing for ports. A probe that
|
|
meets a *filtered* port, a firewall that drops rather than refuses, waits out
|
|
a TCP timeout, and the account comes back "could not be verified", which reads
|
|
to the person as a wrong password. An Apple configuration profile ends the
|
|
guessing by stating the hosts and the ports, and it carries mail, calendars
|
|
and contacts in a single install.
|
|
|
|
Portal renders one at `/profile.mobileconfig` when `site.yaml` says where to
|
|
get the values:
|
|
|
|
```yaml
|
|
profile:
|
|
pacc: https://mail.example.no/.well-known/user-agent-configuration.json
|
|
name: Example # what the accounts are called on the device
|
|
organization: Example AS # the profile's publisher, defaults to name
|
|
```
|
|
|
|
`pacc` is the mail server's **PACC** document - the standard that replaces
|
|
guessing at hostnames, defined to be fetched from the mail domain itself, and
|
|
served by Stalwart among others. Every host, port and collection path in the
|
|
profile comes from it, so nothing about the server is written down a second
|
|
place to drift from the first: move a collection on the server and the profile
|
|
moves with it. Where PACC states no port, the registered implicit-TLS default
|
|
is used (993 for IMAP, 465 for submission, 443 for DAV).
|
|
|
|
What the profile contains is only what PACC already says in public. It carries
|
|
**no address and no username** - iOS asks for those and the password at
|
|
install - so one document serves everyone with an account and may be fetched
|
|
by anyone. Payload identifiers are derived from the site and the payload
|
|
rather than drawn at random, so reinstalling replaces the accounts instead of
|
|
stacking duplicates beside them.
|
|
|
|
Two things it cannot do. A profile Portal renders is unsigned, so iOS shows it
|
|
as *Unverified* while still offering to install it; signing needs a
|
|
certificate the device already trusts. And a PACC document describing no
|
|
protocol iOS understands - JMAP alone, or an incoming server with no outgoing
|
|
one - is an error rather than a half-empty profile that looks like it worked.
|
|
|
|
No `profile:` means `/profile.mobileconfig` is a 404.
|
|
|
|
## An alternative
|
|
|
|
One choice. Never two actions behind one heading: if two things can be
|
|
done, they are two alternatives.
|
|
|
|
```yaml
|
|
alternatives:
|
|
- name: Send the drawings
|
|
description: We answer within twelve weeks.
|
|
action: /sent # the page to go to; /shape/{field} templates
|
|
record_as: applications # the bucket the answer lands in
|
|
disabled: false
|
|
consequence: [What happens next]
|
|
encouragements: [It takes two minutes]
|
|
images: [/site/drawing.png]
|
|
features: [ ... ]
|
|
```
|
|
|
|
Three things an alternative may do instead of, or beside, recording an
|
|
answer:
|
|
|
|
```yaml
|
|
self_transition: # the person moves their own record
|
|
bucket: applications
|
|
to: complete
|
|
from: [open, incomplete] # defaults to the initial state alone
|
|
label: Send them
|
|
invite: { group: case_officers } # onboard someone into Kanidm
|
|
# or with words of its own, using any field of the form:
|
|
# invite:
|
|
# group: guests
|
|
# mail: { subject: "...", body: "... {tidspunkt} ... {link} ..." }
|
|
```
|
|
|
|
`self_transition.from` is what keeps a person in dialogue with their
|
|
case: an applicant written to for a missing drawing answers from
|
|
`incomplete`, and may withdraw from wherever the case has got to.
|
|
Omitted, it is the initial state alone, which is what a one-shot
|
|
unsubscribe link means. Each state listed becomes its own access rule,
|
|
and the lint refuses a `from -> to` the state machine does not declare.
|
|
|
|
An `invite` needs a required `name` and `email` on the same
|
|
alternative, and the inviting group must be allowed to invite that
|
|
group by the policy.
|
|
|
|
## A feature
|
|
|
|
What an alternative offers, explained. A feature that is only layout
|
|
is a feature wasted: each one says something true about the choice.
|
|
|
|
```yaml
|
|
features:
|
|
- name: What you need
|
|
description: The case number from the letter.
|
|
icon: document
|
|
color: "#8ec2c0"
|
|
requirements: [ ... ] # form fields
|
|
resource: { ... } # live data
|
|
```
|
|
|
|
## A requirement (a form field)
|
|
|
|
```yaml
|
|
requirements:
|
|
- name: email # snake_case; this is the stored key
|
|
label: Your email
|
|
placeholder: you@example.no
|
|
type: email
|
|
optional: false
|
|
value: "{key}" # a preset; a dynamic page's segment fills it
|
|
```
|
|
|
|
`value: "{user.email}"` or `"{user.name}"` is the signed-in person's
|
|
own, and a field it fills is carried rather than asked: someone the
|
|
site knows is not asked who they are. A visitor gets the field, empty.
|
|
|
|
`type` is one of:
|
|
|
|
| type | what it is |
|
|
|---|---|
|
|
| `text` | the default |
|
|
| `email`, `tel`, `date`, `number`, … | anything else is passed straight through as the HTML input type |
|
|
| `textarea` | several lines |
|
|
| `prosekit` | a rich-text editor |
|
|
| `select` | one choice, or `multiple: true` for any number |
|
|
| `file` | an upload, with `accept:` and `multiple:` |
|
|
| `location` | a pin on a map, stored as `{lat, lon, zoom}` |
|
|
| `record` | the record this one belongs to |
|
|
| `gesture`, `voice` | a drawn stroke or a recording, through a redoal relay |
|
|
| `hidden` | a carrier value, no label row |
|
|
| `module` | a widget the content repo ships |
|
|
|
|
A `select` takes either inline `options: [...]` or a `resource:` to
|
|
draw them from, with `id_field:` naming each item's stable id.
|
|
|
|
A field may load its value from a resource when a sibling changes:
|
|
|
|
```yaml
|
|
- name: contents
|
|
type: textarea
|
|
bind:
|
|
field: path # the sibling to watch
|
|
resource: { source: { kind: url, url: "https://git.example/raw/{path}" } }
|
|
```
|
|
|
|
### type: record
|
|
|
|
How one case belongs to another — an objection to the application it
|
|
objects to, an appeal to the decision it appeals.
|
|
|
|
```yaml
|
|
- name: case
|
|
type: record
|
|
of: applications # the bucket the other record is in
|
|
value: "{key}" # arrives on the link, on a dynamic page
|
|
```
|
|
|
|
Nobody types one of these: the stored value is the parent's record id,
|
|
which is a chain hash. It arrives the way it was given out, so the
|
|
field carries rather than asks, and renders hidden. A submission
|
|
naming a record that is not there is refused, so a reference in a
|
|
stored record is one that resolved at least once.
|
|
|
|
The other half is a listing that asks for the children:
|
|
|
|
```yaml
|
|
resource:
|
|
source: { kind: kv, bucket: objections }
|
|
requires_group: case_officers
|
|
where: { field: case, equals: "{key}" }
|
|
```
|
|
|
|
And the way there from a list: `link` puts a link on every listed
|
|
record to a page about it, with `{id}` for the record's id and
|
|
`{field}` for any of its fields. A notice on a public board links to
|
|
`/notices/{id}`, the dynamic page `/notices/[key]` whose form carries
|
|
the notice's id back in with `value: "{key}"`:
|
|
|
|
```yaml
|
|
resource:
|
|
source: { kind: kv, bucket: notices }
|
|
public: true
|
|
link: { to: "/notices/{id}", label: I can lend or borrow this }
|
|
```
|
|
|
|
## A resource
|
|
|
|
Live data, declared by content and read by the server. What a resource
|
|
reads is never a client-supplied parameter.
|
|
|
|
```yaml
|
|
resource:
|
|
source: { kind: kv, bucket: applications }
|
|
key: abc123 # one record instead of the list
|
|
public: true # or requires_group: case_officers
|
|
states: [approved, refused] # only these; empty means all
|
|
where: { field: case, equals: "{key}" }
|
|
jq: ".[] | {name, url: .html_url}"
|
|
limit: 1 # at most this many, after jq
|
|
empty: Nothing decided yet.
|
|
link: { to: "/notices/{id}", label: Answer this } # a page per record
|
|
transitions: # the desk's buttons
|
|
- { from: complete, to: on_hearing, label: Send it out }
|
|
```
|
|
|
|
`source.kind` is one of `kv`, `gitea_starred`,
|
|
`gitea_releases`, `url`.
|
|
|
|
`limit: 1` shows only the first item — for a feed where only the newest
|
|
matters. A releases card wants the cut someone should run, not every
|
|
cut there has ever been, and a page that grows by one card per release
|
|
gets longer forever. It applies *after* `jq`, so a filter can reorder
|
|
and the limit takes the top of that ordering. A source that yields a
|
|
single record has no length and ignores it; `limit: 0` on a list shows
|
|
nothing, and the `empty` line speaks instead.
|
|
|
|
A spec with neither `public: true` nor `requires_group` is
|
|
unreachable: reads fail closed, and mutations always need a group
|
|
whatever `public` says.
|
|
|
|
`where: { field: email, equals: "{user.email}" }` is "my cases": the
|
|
records carrying the signed-in person's own address, read from the
|
|
session. A file on such a record is theirs alone to open.
|
|
|
|
A record that carries a picture (a `type: file` field holding a JPEG,
|
|
PNG, WebP, AVIF or GIF) is shown rather than read when the list has no
|
|
`transitions`: a card with the picture on top, the first field as its
|
|
heading, and the other fields under it, with no field names, state or
|
|
time. The cards appear in the order the records were put up. That is
|
|
how a site shows the people who work there, or a gallery of what it
|
|
has built. On a desk, the same picture is a thumbnail that opens.
|
|
`accept: image/jpeg,image/png,image/webp` on the field makes a phone
|
|
send a JPEG whatever it keeps its photos as. What the person wrote is shown as
|
|
markdown on stricter rules than the site's own: paragraphs, line
|
|
breaks as typed, lists, emphasis and links to https, mailto or tel
|
|
(marked `nofollow ugc`); no images, no raw HTML.
|
|
|
|
A desk (a list with `transitions`) shows every record still moving,
|
|
and of those in a final state - one the state machine has no move out
|
|
of - only the three most recently decided, newest first. "Show older"
|
|
under the list brings three more each time it is pressed. The desk
|
|
stays about the work in hand however long the site has run; nothing
|
|
is hidden from a read-only list, and `leaves_after` still takes a
|
|
record out of every list altogether.
|
|
|
|
A **releases manifest** - a `kind: url` resource whose JSON is
|
|
`{ name, latest, versions: [{ version, date, notes, assets: [{ platform,
|
|
url, sha256, size }] }], channels: { arch: { repo, package, note },
|
|
ios: { testflight, note } } }`, newest version first, as a release
|
|
pipeline writes it - renders as one card: the newest version, the file
|
|
for the visitor's own platform first (read from the browser: `ios`,
|
|
`macos`, `linux-x86_64`, `linux-aarch64`, `windows`, `android`), the
|
|
other files and the channels folded under "All downloads". Linux gets
|
|
the package repo lines beside its file; iPhones and Macs get
|
|
TestFlight. Platforms a manifest lacks fall through to the folded
|
|
list.
|
|
|
|
`states` and `where` narrow a listing. They matter most for a register
|
|
that is public by law — public about what was decided, not about what
|
|
was withdrawn — while the desk working the same bucket is a different
|
|
spec and still sees all of it. A record that has no such field never
|
|
matches: "everything that did not say" is not something a page can ask
|
|
for by accident.
|
|
|
|
## needs.yaml
|
|
|
|
What the site is for, as a checkable spec: who wants what, which
|
|
bucket the answer must land in, which group carries it to a finished
|
|
state. The runtime never reads it. `iris check` proves the site meets
|
|
it, and `iris replay` runs cases through the real engine.
|