25 Commits
Author SHA1 Message Date
Bendik LynghaugandClaude Opus 5 8d1b473c33 Portal v0.6.23 - the profile's PayloadVersion typing fix
Deploy instance / deploy (push) Successful in 1s
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 11:31:20 +02:00
Bendik LynghaugandClaude Opus 5 9465bc6a2f Offer an iOS configuration profile
Deploy instance / deploy (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
Lint and reload / lint (push) Successful in 3s
Portal v0.6.22 renders /profile.mobileconfig from Stalwart's PACC
document: mail, calendars and contacts in one install, with the hosts
and ports stated rather than probed for. Nothing about the mail server
is written down here - pacc points at the description that already
exists, so this file has nothing to keep in step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 11:23:28 +02:00
Bendik LynghaugandClaude Opus 5 82744011ed Serve PACC on the mail domain
Deploy instance / deploy (push) Successful in 1s
Lint and reload / reload (push) Successful in 0s
Lint and reload / lint (push) Successful in 1s
Stalwart implements PACC - /.well-known/user-agent-configuration.json,
one JSON document advertising JMAP, IMAP, SMTP, CalDAV, CardDAV, WebDAV,
ManageSieve and how to authenticate. Unlike autoconfig and Autodiscover
it is defined to be fetched from the mail domain itself rather than from
a guessed subdomain, so the apex is exactly where it belongs, and it was
only reachable at mail.<domain>. /.well-known/mail-v1.xml goes with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 10:43:16 +02:00
Bendik LynghaugandClaude Opus 5 aeef98389a Serve the DAV collections the discovery redirects point at
Deploy instance / deploy (push) Successful in 2s
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
/.well-known/caldav answered 307 to https://<domain>/dav/cal, and that
path was portal's catch-all - so a client that did the discovery
correctly followed it into an HTML page. Stalwart builds the Location
from the forwarded host rather than the Host this route rewrites, so
rather than fight that, /dav/* is served here too and the client stays
on one origin for the whole exchange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 10:38:41 +02:00
Bendik LynghaugandClaude Opus 5 07d3dc36d3 Publish files at the site root, and find CalDAV from the bare domain
Deploy instance / deploy (push) Successful in 2s
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
Portal v0.6.21 serves this repo's .well-known/ at the root of the site,
so security.txt is a file in this repo rather than something only a
reverse proxy could place. A path that is not here answers 404 now,
instead of the app shell answering 200 with HTML.

Caddy also hands /.well-known/caldav and /.well-known/carddav to
Stalwart, which answers each with a 307 to the collection. RFC 6764 has
a client look for these on the domain its address is in - and iOS uses
them, which matters because Apple Mail ignores both autoconfig and
Autodiscover for IMAP accounts and discovers nothing else by itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 10:35:48 +02:00
Bendik LynghaugandClaude Opus 5 30f9025d40 Serve mail autoconfiguration on the bare domain
Lint and reload / reload (push) Successful in 0s
Deploy instance / deploy (push) Successful in 1s
Lint and reload / lint (push) Successful in 1s
Thunderbird asks <domain>/.well-known/autoconfig, Outlook POSTs to
<domain>/Autodiscover, and both fell into portal's catch-all, which
answers 200 with an HTML page - so a client got markup where it expected
XML and went back to guessing.

Both prefixes now go to Stalwart, which already generates the answers,
templated on the address asked about. No static copy to drift out of
date and no hostname of their own to certify. Host is rewritten to the
name Stalwart has always answered for, and all three spellings of
/Autodiscover are matched because Outlook is not consistent about it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 09:29:06 +02:00
Bendik LynghaugandClaude Opus 5 15704959ad Take /einer off uhhm.no
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
Not where this belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 08:03:47 +02:00
Bendik LynghaugandClaude Opus 5 7de9bfe767 Say where the knowledge about juniper wood stops
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
/einer is four frontiers and nothing else: the gap between berry and
heartwood chemistry, the gap between inhibition on agar and durability
in wood, the delivery problem that a bio-based impregnation chemistry
might already solve, and whether extractive-driven durability can be
moved between species at all.

Context cards only - no form, no bucket, nothing asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-06 19:32:10 +02:00
Bendik LynghaugandClaude Opus 5 2efb868f16 Hide the session nav signed in too, not only signed out
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
:has(a[href^="/auth/login"]) only ever matched the signed-out nav, so
an owner still had a name and a sign-out beside the wordmark. The
header carries the wordmark and nothing else now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-06 19:27:04 +02:00
Bendik LynghaugandClaude Opus 5 d350732913 Take /einer down
Lint and reload / lint (push) Successful in 1s
Lint and reload / reload (push) Successful in 0s
The page led with what I lack rather than with what the question is.
Coming back when it is about the knowledge, not the asker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-06 17:37:37 +02:00
Bendik LynghaugandClaude Opus 5 caba12df09 Ask the juniper question in the open
Lint and reload / lint (push) Successful in 1s
Lint and reload / reload (push) Successful in 0s
/einer states what is known about juniper heartwood extractives — the
sesquiterpenes that carry the scent are the ones that inhibit decay
fungi — and what is not, then takes the two things the project needs:
people following along, and people with wood, equipment or lab time.

Both land in one bucket, worked from /review, because at this stage
they are the same conversation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-06 17:28:34 +02:00
Bendik LynghaugandClaude Opus 5 aa9987fb2e Hide the sign-in link beside the wordmark
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
uhhm.no is read and answered without an account, so the invitation to
sign in has no audience. Portal's header nav keeps it next to the
wordmark on every page; a skin hides it, as on redoal.com.

The rule only takes the link in: signed in, the nav still carries the
name and the way out, and the gate on /review still offers its own way
in. First custom.css here, wired through site.yaml's stylesheet key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-06 13:17:49 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 510b4b4ff5 The invite mail in English, like the rest of the site
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 22:02:12 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 9c5e66c7c0 The desk says the invite mail goes out by itself
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 22:00:31 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 3b8822cd78 The invite form has its button: action back to the desk
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 21:38:23 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 db6ead16c3 The review desk lists the invites sent, with their links
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 21:29:24 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 9df0368175 Lint with portal lint: iris is no longer released
Deploy instance / deploy (push) Successful in 2s
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 21:26:19 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 8788069ae2 Portal v0.6.15: invites with their own mail, for invites to a conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 21:25:59 +02:00
Bendik Aagaard Lynghaug 5682588a50 Merge branch 'invite-to-conversation' 2026-10-01 21:25:59 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 a6930233a5 Invite to a conversation: a passkey with us and a time in the lobby on chat.uhhm.no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-10-01 18:09:13 +02:00
Bendik Aagaard Lynghaug e00b9cfa0b Take portal v0.5.2 (the rate limiter needs connect info)
Deploy instance / deploy (push) Successful in 2s
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
2026-09-28 18:13:49 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 4381078b86 Take portal v0.5.1
Deploy instance / deploy (push) Failing after 1s
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Attachments both ways, rate limiting, sessions that survive a deploy,
and an upload route that asks the policy like every other way in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 17:53:46 +02:00
Bendik Aagaard LynghaugandClaude Opus 5 ecca063b5c Take portal v0.4.0
Deploy instance / deploy (push) Successful in 1s
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Four releases on from v0.3.36: maps and `place:`, a reply to a case
mail becoming a note on its record, and the access policy's newer
rules. iris moves with it, since it is the lint for exactly this
portal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 16:27:47 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 b9774c90f6 README: the mail stream is called mail
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 1s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 21:19:09 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 9917b07312 README: mail is content - mail: on a state, mail: in site.yaml, gdo
Lint and reload / lint (push) Successful in 3s
Lint and reload / reload (push) Successful in 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
2026-09-23 19:50:22 +02:00
7 changed files with 201 additions and 9 deletions
+55 -2
View File
@@ -18,7 +18,7 @@ on:
- .gitea/workflows/deploy.yml - .gitea/workflows/deploy.yml
env: env:
PORTAL_RELEASE: v0.3.36 PORTAL_RELEASE: v0.6.23
INSTANCE: uhhm-portal INSTANCE: uhhm-portal
jobs: jobs:
@@ -114,7 +114,60 @@ jobs:
} }
{$DOMAIN} { {$DOMAIN} {
reverse_proxy host.docker.internal:3010 # 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
}
log { log {
output file /var/log/caddy/www.log output file /var/log/caddy/www.log
} }
+7 -7
View File
@@ -15,18 +15,18 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# iris (uhhm/iris) is the lint: it reads content with the portal # The lint is portal itself (`portal lint`, since portal 0.6.0;
# release it is pinned to, so IRIS_RELEASE should match the portal # iris is no longer released), at the version this site runs
# this site runs (PORTAL_RELEASE in deploy.yml). # (PORTAL_RELEASE in deploy.yml).
- name: Lint questions - name: Lint questions
env: env:
IRIS_RELEASE: v0.3.39 PORTAL_RELEASE: v0.6.23
run: | run: |
# The checkout is on a noexec mount for this runner; the cargo # The checkout is on a noexec mount for this runner; the cargo
# target dir is where it may run what it fetched. # target dir is where it may run what it fetched.
bin="/var/local/cargo-target/iris-$IRIS_RELEASE" bin="/var/local/cargo-target/portal-lint-$PORTAL_RELEASE"
[ -x "$bin" ] || { curl -sfL "${{ github.server_url }}/uhhm/iris/releases/download/$IRIS_RELEASE/iris" -o "$bin" && chmod +x "$bin"; } [ -x "$bin" ] || { curl -sfL "${{ github.server_url }}/uhhm/portal/releases/download/$PORTAL_RELEASE/portal-x86_64-linux" -o "$bin" && chmod +x "$bin"; }
"$bin" check --path questions "$bin" lint --path questions
reload: reload:
runs-on: bare runs-on: bare
+6
View File
@@ -0,0 +1,6 @@
# 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,6 +289,37 @@ 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
@@ -0,0 +1,13 @@
/* 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;
}
+71
View File
@@ -76,6 +76,77 @@ 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,3 +4,21 @@
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