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
+1 -58
View File
@@ -18,7 +18,7 @@ on:
- .gitea/workflows/deploy.yml - .gitea/workflows/deploy.yml
env: env:
PORTAL_RELEASE: v0.6.23 PORTAL_RELEASE: v0.3.35
INSTANCE: uhhm-portal INSTANCE: uhhm-portal
jobs: jobs:
@@ -58,10 +58,6 @@ jobs:
KANIDM_URL=${{ vars.KANIDM_URL }} KANIDM_URL=${{ vars.KANIDM_URL }}
OAUTH2_CLIENT_ID=${{ vars.OAUTH2_CLIENT_ID }} OAUTH2_CLIENT_ID=${{ vars.OAUTH2_CLIENT_ID }}
OAUTH2_CLIENT_SECRET=${{ secrets.OAUTH2_CLIENT_SECRET }} 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 }} PUBLIC_URL=${{ vars.PUBLIC_URL }}
COOKIE_SECURE=true COOKIE_SECURE=true
# This repo is its own instance's content source. # This repo is its own instance's content source.
@@ -114,60 +110,7 @@ jobs:
} }
{$DOMAIN} { {$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 { log {
output file /var/log/caddy/www.log output file /var/log/caddy/www.log
} }
+6 -11
View File
@@ -15,18 +15,13 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# The lint is portal itself (`portal lint`, since portal 0.6.0; # Same bare-metal runner/host as this repo's own Deploy workflow,
# iris is no longer released), at the version this site runs # which ships question_lint alongside the portal binary in every
# (PORTAL_RELEASE in deploy.yml). # 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 - name: Lint questions
env: run: /srv/app/uhhm-portal/current/question_lint --path questions
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
reload: reload:
runs-on: bare 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 lint, so no publicly collected answer can land where no one will ever
see it. 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 ## Routing: the tree is the router
The `questions/` directory tree is the URL tree — `index.yaml` names 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 type: textarea
optional: true 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 - name: Projects
description: "" description: ""
action: /review action: /review
-18
View File
@@ -4,21 +4,3 @@
hero: hero:
kind: module kind: module
module: hero.js 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