1 Commits
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Fable 5.1 9266699003 needs.yaml: what UHHM needs this site to do, as a checkable spec
Lint and reload / lint (push) Skipped
Five needs (commission, follow, collaborate, improve-site as planned,
track-prospect) and nine personas in their own words, for portal's
question_lint needs pass (uhhm/portal 188f9bf). The runtime never
reads this file; older lint binaries ignore it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-21 16:29:14 +02:00
8 changed files with 135 additions and 209 deletions
+2 -59
View File
@@ -18,7 +18,7 @@ on:
- .gitea/workflows/deploy.yml
env:
PORTAL_RELEASE: v0.6.23
PORTAL_RELEASE: v0.3.35
INSTANCE: uhhm-portal
jobs:
@@ -58,10 +58,6 @@ jobs:
KANIDM_URL=${{ vars.KANIDM_URL }}
OAUTH2_CLIENT_ID=${{ vars.OAUTH2_CLIENT_ID }}
OAUTH2_CLIENT_SECRET=${{ secrets.OAUTH2_CLIENT_SECRET }}
# Onboarding from a desk (portal 0.3.40+, `invite:` alternatives): a
# Kanidm service-account token in idm_people_on_boarding. Empty
# means invites are not configured on this instance.
KANIDM_API_TOKEN=${{ secrets.KANIDM_API_TOKEN }}
PUBLIC_URL=${{ vars.PUBLIC_URL }}
COOKIE_SECURE=true
# This repo is its own instance's content source.
@@ -114,60 +110,7 @@ jobs:
}
{$DOMAIN} {
# Mail clients look for their configuration on the bare domain -
# Thunderbird at /.well-known/autoconfig, Outlook by POSTing to
# /Autodiscover. Stalwart already generates both, templated on the
# address asked about, so these two paths go to it instead of being
# copied into this repo: one source of truth, and no second
# hostname to certify. Host is rewritten to the name Stalwart
# answers for; it never sees a request for the bare domain
# otherwise. (MTA-STS cannot be served here - RFC 8461 requires
# mta-sts.<domain> - so it keeps its own record and route.)
# PACC, the standard that replaces guessing at hostnames: one JSON
# document on the mail domain itself advertising every protocol,
# which is why it belongs here and not on a subdomain. Stalwart
# generates it, and /.well-known/mail-v1.xml beside it.
@pacc path /.well-known/user-agent-configuration.json /.well-known/mail-v1.xml
handle @pacc {
reverse_proxy stalwart:8080 {
header_up Host mail.{$DOMAIN}
}
}
handle /.well-known/autoconfig/* {
reverse_proxy stalwart:8080 {
header_up Host mail.{$DOMAIN}
}
}
# CalDAV and CardDAV discovery (RFC 6764): a client looks for
# these on the domain its address is in, and Stalwart answers
# each with a 307 to the collection. iOS uses these even though
# Apple Mail ignores autoconfig and Autodiscover entirely.
# The collections the two redirects above point at. Stalwart builds
# those Location headers from the forwarded host, not from the Host
# this route rewrites, so they land on the bare domain - which means
# the collections have to be reachable here too, or a client follows
# a 307 into portal's catch-all. Keeping them on one host is the
# better answer anyway: the client never changes origin.
@dav path /.well-known/caldav /.well-known/carddav /dav/*
handle @dav {
reverse_proxy stalwart:8080 {
header_up Host mail.{$DOMAIN}
}
}
@autodiscover path /Autodiscover/* /autodiscover/* /AutoDiscover/*
handle @autodiscover {
reverse_proxy stalwart:8080 {
header_up Host mail.{$DOMAIN}
}
}
handle {
reverse_proxy host.docker.internal:3010
}
reverse_proxy host.docker.internal:3010
log {
output file /var/log/caddy/www.log
}
+6 -11
View File
@@ -15,18 +15,13 @@ jobs:
steps:
- uses: actions/checkout@v4
# The lint is portal itself (`portal lint`, since portal 0.6.0;
# iris is no longer released), at the version this site runs
# (PORTAL_RELEASE in deploy.yml).
# Same bare-metal runner/host as this repo's own Deploy workflow,
# which ships question_lint alongside the portal binary in every
# pinned release - so content is validated by the exact portal
# version this instance runs, not a hand-maintained yq/jq subset
# of the same rules.
- name: Lint questions
env:
PORTAL_RELEASE: v0.6.23
run: |
# The checkout is on a noexec mount for this runner; the cargo
# target dir is where it may run what it fetched.
bin="/var/local/cargo-target/portal-lint-$PORTAL_RELEASE"
[ -x "$bin" ] || { curl -sfL "${{ github.server_url }}/uhhm/portal/releases/download/$PORTAL_RELEASE/portal-x86_64-linux" -o "$bin" && chmod +x "$bin"; }
"$bin" lint --path questions
run: /srv/app/uhhm-portal/current/question_lint --path questions
reload:
runs-on: bare
-6
View File
@@ -1,6 +0,0 @@
# How to report a security problem with uhhm.no, served from this repo's
# .well-known/ by portal's static root overlay.
Contact: mailto:bl@uhhm.no
Expires: 2027-10-07T00:00:00.000Z
Preferred-Languages: no, en
Canonical: https://uhhm.no/.well-known/security.txt
-31
View File
@@ -289,37 +289,6 @@ it>` naming the automation that does. A bucket nothing reads fails
lint, so no publicly collected answer can land where no one will ever
see it.
**Mail is content** (portal 0.3.43, delivered by
[gdo](https://project.uhhm.no/uhhm/gdo)). A state in `aggregates.yaml`
may carry the mail its arrival sends:
```yaml
states:
open: { event: received, mail: { to: submitter, subject: "Got it, {name}", body: "We read every one. Withdraw it here: {site}/decide/brief?chain={chain}&email={email}" } }
fixed: { event: fixed, mail: { to: board@example.no, subject: "Fixed: {where}", body: "..." } }
```
`to` is `submitter` (the record's `email` field; lint then requires
every form recording into that bucket to collect a required `email`)
or one fixed address. Subject and body are templates over the
record's fields, plus `{site}` (the instance's public URL), `{chain}`
(the record id), `{email}`, `{bucket}`, `{state}`; the body is
markdown, sent as plain text with an HTML alternative. An invite
(`invite:` on an alternative) mails its one-time link built in, in the
site's `lang`. None of it is sent unless `site.yaml` says who it comes
from:
```yaml
mail:
from: UHHM <portal@uhhm.no>
reply_to: bl@uhhm.no # optional
invite: { subject: "...", body: "..." } # optional rewording; {name} {username} {email} {group} {link} {site} {site_name} {expires}
```
Portal renders each mail the moment the record enters the state and
publishes it on `portal.mail.send` in the `mail` JetStream stream;
gdo, one per host, hands it to the host's SMTP. A transition that
fires twice sends once.
## Routing: the tree is the router
The `questions/` directory tree is the URL tree — `index.yaml` names
-13
View File
@@ -1,13 +0,0 @@
/* uhhm - the skin over portal's own stylesheet
(site.yaml `stylesheet`, loaded after main.css, so these rules win). */
/* Portal puts the way in beside the wordmark on every page of a site
that has sign-in at all, and there is no setting to turn it off:
`sign_in` follows whether OIDC is enabled on the instance. uhhm.no is
read and answered without an account, so the whole nav goes - signed
out, where it reads "Sign in", and signed in, where it reads a name
and "Sign out". Whoever edits the site goes to /auth/login and
/auth/logout directly. */
.session {
display: none;
}
+127
View File
@@ -0,0 +1,127 @@
# What UHHM, as a business, needs this site to do - stated apart from
# how the pages word it. `question_lint` checks every need against the
# pages and aggregates.yaml (portal `src/needs.rs`): a path of
# submissions gets the visitor's answer into `lands_in` within the
# effort budget, `handled_by` has a desk over that bucket, and every
# `done_when` state is reachable through the moves the desk offers.
#
# The personas are visitors in their own words - never the site's.
# `question_lint --needs-tasks` turns each page on their path into a
# typed choice for an answering engine; `--needs-score` grades its
# picks. A misroute means the page said one thing and they heard
# another. The runtime never reads this file.
needs:
- id: commission
who: someone with a problem software could solve, and a budget
wants: UHHM to take the work on
via: Good software begins with a clear argument
lands_in: projects
handled_by: owners
done_when: [accepted, declined]
max_steps: 1
# name, email, the build, the organization - the page argues for
# each of the four; a fifth would need the same argument.
max_fields: 4
- id: follow
who: someone curious who isn't ready to get involved
wants: to hear when something ships
via: Attention is earned
lands_in: subscribers
handled_by: owners
# Being on the list is the finished state; leaving is theirs to do.
done_when: [open, unsubscribed]
max_steps: 1
max_fields: 2
- id: collaborate
who: a developer, designer or researcher
wants: to work with UHHM
via: Work happens through dialogue, not tickets
lands_in: applicants
handled_by: owners
done_when: [active, declined]
max_steps: 1
max_fields: 3
- id: improve-site
who: anyone using this site
wants: the site itself to change
enters: /develop/proposal
# Announced, not open: the automation that turns a proposal into a
# pull request is still being built, so the alternative is disabled.
planned: true
lands_in: development_proposals
handled_by: owners
done_when: [approved, rejected]
max_steps: 1
max_fields: 3
- id: track-prospect
who: an owner who met an organization worth following up
wants: the relationship written down and walked to an outcome
enters: /review
enters_as: owners
via: Record a prospect
lands_in: organizations
handled_by: owners
done_when: [client, past_client]
max_steps: 1
max_fields: 2
personas:
- id: architecture-firm
need: commission
text: >
We run a small architecture firm and our project files are
scattered across three cloud drives. We want to pay someone to
set up proper storage and backups we control ourselves.
- id: climbing-gym-founder
need: commission
text: >
I have an idea for a booking app for climbing gyms and I'm
looking for a team that can build the first version with me.
- id: municipality-buyer
need: commission
text: >
Our municipality needs an internal tool for tracking building
permits. We are looking for a vendor who can scope it and
deliver it.
- id: exhibition-visitor
need: follow
text: >
I saw your drawing network at an exhibition and liked it. I don't
want to get involved, I'd just like to know when there is news.
- id: tech-journalist
need: follow
text: >
I write about Nordic technology. Keep me posted on what you
release so I can cover it.
- id: rust-developer
need: collaborate
text: >
I'm a Rust developer between jobs and I like that you build in
the open. I would love to contribute, and maybe join you.
- id: interaction-designer
need: collaborate
text: >
I'm an interaction designer. I want to work with you on the
drawing interface.
- id: student-intern
need: collaborate
text: >
I'm a computer science student looking for a summer internship
doing infrastructure work.
- id: site-critic
need: improve-site
text: >
Your contact form never says how long a reply takes. You should
add that.
-71
View File
@@ -76,77 +76,6 @@ alternatives:
type: textarea
optional: true
# A guest for a conversation on chat.uhhm.no: an account at id.uhhm.no
# with a passkey of their own, a place in cnats_users (which is what
# lets someone into the lobby, where the video calls are), and a mail
# saying when to turn up. The link lasts 24 hours.
- name: Invite to a conversation
description: >
Someone you want to talk to on chat.uhhm.no. They get a mail from
bl@uhhm.no with a link to make a passkey with us (valid for 24
hours) and the time to turn up in the lobby, where the video is.
action: /review
consequence: [Send the invite]
invite:
group: cnats_users
mail:
subject: "A conversation with {site_name}, {tidspunkt}"
body: |-
Hello {name},
You are invited to a conversation on chat.uhhm.no, {tidspunkt}.
First, make a passkey with us. Open the link below, add a
passkey, and press "Save" at the end: without Save it is not
kept. The link works until {expires}:
{link}
Your username is {username}.
When the time comes, go to https://chat.uhhm.no, sign in with
the passkey, and you land in the lobby. Press the video button
there, and we are in.
{notat}
features:
- name: ""
description: Who, and when to meet in the lobby.
requirements:
- name: name
label: Name
- name: email
label: Email
type: email
- name: tidspunkt
label: When
type: datetime-local
- name: notat
label: A line for them (optional)
type: textarea
optional: true
# Every invite sent from here, from portal's own invites bucket. The
# row carries the one-time link too, should the mail not reach them.
- name: Invited
description: >
Everyone invited from this desk, with their one-time link and when
it runs out. The mail went out by itself; the link is here should
it not reach them. A link that ran out can be sent again; mark an
invite done once they have signed in.
action: /review
consequence: [Confirm]
features:
- name: ""
description: One row per invite.
resource:
source: { kind: kv, bucket: portal_invites }
requires_group: owners
transitions:
- { from: open, to: done, label: Done }
- { from: expired, to: open, label: Send again }
- { from: expired, to: done, label: Done }
- name: Projects
description: ""
action: /review
-18
View File
@@ -4,21 +4,3 @@
hero:
kind: module
module: hero.js
# The skin over portal's own stylesheet.
stylesheet: custom.css
# The iOS configuration profile at /profile.mobileconfig. Everything in it -
# hosts, ports, the DAV collection paths - comes from Stalwart's own PACC
# document, so there is nothing here to keep in step with the mail server.
# The profile carries no address or password; iOS asks at install.
profile:
pacc: https://mail.uhhm.no/.well-known/user-agent-configuration.json
name: uhhm
organization: uhhm
# Mail goes out through gdo on this host, which sends over JMAP as
# bl@uhhm.no; replies come back to the same address.
mail:
from: "Bendik Lynghaug <bl@uhhm.no>"
reply_to: bl@uhhm.no