Add prosekit rich-text field, Gitea repo embeds, automation KV read endpoint; fix apex/www session-cookie mismatch on /auth/callback
Deploy / deploy (push) Successful in 29s

- type: prosekit requirement kind, backed by public/prosekit-editor.js
  (ProseMirror via prosekit, loaded from esm.sh, no bundler) - mirrors
  its HTML into a paired hidden input so it reuses the existing
  RwSignal/on:input wiring.
- Pasting a project.uhhm.no/<owner>/<repo> URL in the editor embeds a
  repo card, resolved server-side via a new /gitea-repo handler
  (content::gitea_repo_handler) so the browser never needs Gitea API
  CORS.
- New /automation/kv/{bucket} handler, bearer-token gated
  (AUTOMATION_READ_TOKEN), for backing automations (n8n) to read a
  NATS KV bucket without a browser session.
- Fix: a login started on one of apex/www set its session cookie
  there, but Kanidm's redirect_uri is fixed to PUBLIC_URL - landing
  the callback on a different, empty session ("no login in
  progress"). Caddy now redirects www -> apex so every visit stays on
  one canonical host.
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-05 13:45:56 +02:00
parent 18025bf870
commit 44c8c9d410
6 changed files with 405 additions and 9 deletions
+169
View File
@@ -0,0 +1,169 @@
// Glue for `type: prosekit` requirement fields (see AlternativeCard in
// src/app.rs) - mounts a headless ProseKit (ProseMirror) rich-text
// editor into a container element, then mirrors its rendered HTML into
// a paired hidden <input> on every change, firing a real `input` event
// on it. That lets the Rust side reuse the exact same RwSignal/on:input
// wiring every other requirement kind already goes through, instead of
// needing its own submit-time collection path.
//
// Loaded straight from a CDN as an ES module (no bundler in this
// project, same pattern as yes.js) - prosekit ships pre-bundled ESM,
// and re-exports the ProseMirror packages it's built on under
// `prosekit/pm/*`, so importing primitives (InputRule, etc.) from there
// instead of a separate prosemirror-* package guarantees they're the
// exact same module instance the editor itself uses.
//
// Least-verified part of this file (worth an actual hwatu pass after
// deploy, not just trusting the docs): the exact shape `createEditor`
// wants for `defaultContent` when seeding from an HTML string, and the
// precise `definePasteHandler` callback signature. Everything else here
// mirrors prosekit's own documented examples closely.
import 'https://esm.sh/prosekit/basic/style.css'
import 'https://esm.sh/prosekit/basic/typography.css'
import { defineBasicExtension } from 'https://esm.sh/prosekit/basic'
import {
createEditor,
defineNodeSpec,
definePasteHandler,
defineUpdateHandler,
union,
} from 'https://esm.sh/prosekit/core'
// project.uhhm.no/<owner>/<repo>, no trailing path - a bare repo link,
// not a file/issue/PR link within one (those aren't "the repo", and
// trying to card-ify them too isn't what was asked for).
const GITEA_REPO_URL_RE = /^https:\/\/project\.uhhm\.no\/([\w.-]+)\/([\w.-]+)\/?$/
// Loads each stylesheet once regardless of how many editors this page
// mounts - <link> tags, not a JS import, since plain `import 'x.css'`
// isn't valid outside a bundler and esm.sh's CSS-as-JS support isn't
// worth depending on here.
function ensureStylesheet(href) {
if (document.querySelector(`link[href="${href}"]`)) return
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = href
document.head.appendChild(link)
}
function defineGiteaRepoEmbed() {
return defineNodeSpec({
name: 'giteaRepoEmbed',
group: 'block',
atom: true,
selectable: true,
attrs: {
owner: { default: '' },
repo: { default: '' },
description: { default: '' },
url: { default: '' },
},
parseDOM: [
{
tag: 'div[data-gitea-repo]',
getAttrs: (dom) => ({
owner: dom.getAttribute('data-owner') || '',
repo: dom.getAttribute('data-repo') || '',
description: dom.getAttribute('data-description') || '',
url: dom.getAttribute('data-url') || '',
}),
},
],
// Plain inline styles, not a class - this same markup is what ends
// up as the newsletter's HTML body, read by an email client with no
// access to this app's stylesheet.
toDOM: (node) => {
const { owner, repo, description, url } = node.attrs
return [
'div',
{
'data-gitea-repo': 'true',
'data-owner': owner,
'data-repo': repo,
'data-description': description,
'data-url': url,
style:
'display:block;border:1px solid #ddd;border-radius:8px;' +
'padding:12px 16px;margin:8px 0;font-family:inherit;',
},
[
'a',
{ href: url, style: 'font-weight:600;text-decoration:none;color:inherit;' },
`${owner}/${repo}`,
],
['div', { style: 'opacity:0.7;font-size:0.9em;margin-top:4px;' }, description],
]
},
})
}
// Resolves owner/repo via portal's own /gitea-repo handler
// (content::gitea_repo_handler in src/content.rs), not Gitea's API
// directly - keeps this editor from needing a CORS allowance on
// Gitea's side, and matches every other backing store in this app
// being server-resolved only.
async function resolveGiteaRepo(owner, repo) {
const res = await fetch(
`/gitea-repo?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`,
)
if (!res.ok) {
throw new Error(`resolve_gitea_repo failed with status ${res.status}`)
}
return await res.json()
}
function defineGiteaRepoEmbedPasteRule() {
return definePasteHandler((view, event) => {
const text = event.clipboardData?.getData('text/plain')?.trim()
const match = text && text.match(GITEA_REPO_URL_RE)
if (!match) return false
event.preventDefault()
const [, owner, repo] = match
const pos = view.state.selection.from
resolveGiteaRepo(owner, repo)
.then((info) => {
const nodeType = view.state.schema.nodes.giteaRepoEmbed
if (!nodeType) return
const node = nodeType.create(info)
const tr = view.state.tr.replaceRangeWith(pos, pos, node)
view.dispatch(tr)
})
.catch((err) => {
console.error('gitea repo embed failed', err)
})
return true
})
}
export function mountEditor(container, hiddenInput, initial) {
ensureStylesheet('https://esm.sh/prosekit/basic/style.css')
ensureStylesheet('https://esm.sh/prosekit/basic/typography.css')
const extension = union(
defineBasicExtension(),
defineGiteaRepoEmbed(),
defineGiteaRepoEmbedPasteRule(),
)
const editor = createEditor({
extension,
// Seeds from the hidden input's current value (only ever non-empty
// when re-mounting content that already went through this same
// editor, since that's the only place this HTML shape comes from).
defaultContent: initial || undefined,
})
const sync = () => {
const html = editor.view.dom.innerHTML
if (hiddenInput.value === html) return
hiddenInput.value = html
hiddenInput.dispatchEvent(new Event('input', { bubbles: true }))
}
editor.use(defineUpdateHandler(sync))
editor.mount(container)
sync()
}