Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1625,6 +1625,33 @@
],
"type": "object"
},
"List-mail-presetsResponse": {
"additionalProperties": false,
"properties": {
"$schema": {
"description": "A URL to the JSON Schema for this object.",
"examples": [
"https://example.com/schemas/List-mail-presetsResponse.json"
],
"format": "uri",
"readOnly": true,
"type": "string"
},
"presets": {
"items": {
"$ref": "#/components/schemas/MailPresetDTO"
},
"type": [
"array",
"null"
]
}
},
"required": [
"presets"
],
"type": "object"
},
"List-mail-provider-optionsResponse": {
"additionalProperties": false,
"properties": {
Expand Down Expand Up @@ -1822,6 +1849,118 @@
],
"type": "object"
},
"MailPresetDTO": {
"additionalProperties": false,
"properties": {
"credential_label": {
"type": "string"
},
"docs_url": {
"type": "string"
},
"encryption": {
"enum": [
"none",
"starttls",
"tls"
],
"type": "string"
},
"help": {
"type": "string"
},
"host": {
"type": "string"
},
"id": {
"type": "string"
},
"label": {
"type": "string"
},
"port": {
"format": "int64",
"type": "integer"
},
"region": {
"$ref": "#/components/schemas/MailPresetRegionDTO"
},
"username_fixed": {
"type": "string"
},
"username_mode": {
"enum": [
"user",
"fixed",
"same_as_password"
],
"type": "string"
},
"username_prefill": {
"type": "string"
}
},
"required": [
"id",
"label",
"host",
"port",
"encryption",
"username_mode",
"username_fixed",
"username_prefill",
"credential_label",
"help",
"docs_url"
],
"type": "object"
},
"MailPresetRegionDTO": {
"additionalProperties": false,
"properties": {
"default": {
"type": "string"
},
"label": {
"type": "string"
},
"options": {
"items": {
"$ref": "#/components/schemas/MailPresetRegionOptionDTO"
},
"type": [
"array",
"null"
]
}
},
"required": [
"label",
"default",
"options"
],
"type": "object"
},
"MailPresetRegionOptionDTO": {
"additionalProperties": false,
"properties": {
"host": {
"type": "string"
},
"label": {
"type": "string"
},
"value": {
"type": "string"
}
},
"required": [
"value",
"label",
"host"
],
"type": "object"
},
"MailProviderBody": {
"additionalProperties": false,
"properties": {
Expand Down Expand Up @@ -1858,6 +1997,9 @@
"format": "int64",
"type": "integer"
},
"provider_type": {
"type": "string"
},
"username": {
"type": "string"
}
Expand Down Expand Up @@ -1911,6 +2053,12 @@
"format": "int64",
"type": "integer"
},
"provider_label": {
"type": "string"
},
"provider_type": {
"type": "string"
},
"username": {
"type": "string"
}
Expand All @@ -1923,6 +2071,8 @@
"username",
"from_address",
"encryption",
"provider_type",
"provider_label",
"created_at"
],
"type": "object"
Expand Down Expand Up @@ -3723,6 +3873,34 @@
"summary": "Revoke the current session"
}
},
"/api/v1/mail-presets": {
"get": {
"operationId": "list-mail-presets",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/List-mail-presetsResponse"
}
}
},
"description": "OK"
},
"default": {
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Error"
}
},
"summary": "List built-in outgoing-mail provider presets (admin only)"
}
},
"/api/v1/mail-providers": {
"get": {
"operationId": "list-mail-providers",
Expand Down
125 changes: 125 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1147,6 +1147,25 @@ components:
required:
- apps
type: object
List-mail-presetsResponse:
additionalProperties: false
properties:
$schema:
description: A URL to the JSON Schema for this object.
examples:
- https://example.com/schemas/List-mail-presetsResponse.json
format: uri
readOnly: true
type: string
presets:
items:
$ref: "#/components/schemas/MailPresetDTO"
type:
- array
- "null"
required:
- presets
type: object
List-mail-provider-optionsResponse:
additionalProperties: false
properties:
Expand Down Expand Up @@ -1286,6 +1305,87 @@ components:
required:
- user
type: object
MailPresetDTO:
additionalProperties: false
properties:
credential_label:
type: string
docs_url:
type: string
encryption:
enum:
- none
- starttls
- tls
type: string
help:
type: string
host:
type: string
id:
type: string
label:
type: string
port:
format: int64
type: integer
region:
$ref: "#/components/schemas/MailPresetRegionDTO"
username_fixed:
type: string
username_mode:
enum:
- user
- fixed
- same_as_password
type: string
username_prefill:
type: string
required:
- id
- label
- host
- port
- encryption
- username_mode
- username_fixed
- username_prefill
- credential_label
- help
- docs_url
type: object
MailPresetRegionDTO:
additionalProperties: false
properties:
default:
type: string
label:
type: string
options:
items:
$ref: "#/components/schemas/MailPresetRegionOptionDTO"
type:
- array
- "null"
required:
- label
- default
- options
type: object
MailPresetRegionOptionDTO:
additionalProperties: false
properties:
host:
type: string
label:
type: string
value:
type: string
required:
- value
- label
- host
type: object
MailProviderBody:
additionalProperties: false
properties:
Expand Down Expand Up @@ -1313,6 +1413,8 @@ components:
port:
format: int64
type: integer
provider_type:
type: string
username:
type: string
required:
Expand Down Expand Up @@ -1352,6 +1454,10 @@ components:
port:
format: int64
type: integer
provider_label:
type: string
provider_type:
type: string
username:
type: string
required:
Expand All @@ -1362,6 +1468,8 @@ components:
- username
- from_address
- encryption
- provider_type
- provider_label
- created_at
type: object
MailProviderOption:
Expand Down Expand Up @@ -2530,6 +2638,23 @@ paths:
$ref: "#/components/schemas/ErrorModel"
description: Error
summary: Revoke the current session
/api/v1/mail-presets:
get:
operationId: list-mail-presets
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/List-mail-presetsResponse"
description: OK
default:
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ErrorModel"
description: Error
summary: List built-in outgoing-mail provider presets (admin only)
/api/v1/mail-providers:
get:
operationId: list-mail-providers
Expand Down
1 change: 1 addition & 0 deletions docs/progress/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,3 +245,4 @@ Oldest first; append new entries to the bottom.
| [path-scoped-app-exposure.md](path-scoped-app-exposure.md) — **Closes #415.** Hosted exposure was whole-app and binary, which has no answer for an app that pairs a **token-authed API** with a **session-authed UI**: letting an external SDK reach the API meant making the app public, which dropped the box login in front of the UI too. Two catalog apps already hit that wall, and one of them (Laminar) signs in **any email with no password** on its self-hosted UI, so "go public to ingest" read as "anyone can sign in as anyone". A manifest may now declare `access.public_paths`, and a `restricted` app's route becomes **one** Caddy `subroute` — declared paths straight to the app, everything else gated exactly as before — so `upsertRoute`'s insert-at-0 and the catch-all order are untouched, and the proxy handler is built once and used on both branches so the #335 per-cookie strip cannot land on one and not the other. **The scrub grew past the issue's shape and that is the load-bearing part:** the identity headers are now deleted at the head of **every** hosted app route, not just where the gate runs, because the gate does not run on a public path — the app would otherwise get a brain-vouched `X-Malmo-User` on one path and a client-forged one on another with no way to tell them apart — and the same hole already existed for a fully public app, where an app that learned to trust the header while restricted kept trusting it the moment the owner flipped the toggle. Validation refuses `/`, `/*`, `/**` (a manifest must not void the owner's toggle), a bare-suffix wildcard (`/v1*` also matches `/v1admin`), and `%`/`?`/`#`/`\`/`//`/`..` (the proxy matches a cleaned decoded path while the app sees the original URI, so those mean two different things on the two sides) — but the spec now says plainly that **catalog review is the trust boundary**, not this validator. The dashboard names the open paths instead of claiming a bare "Only me", reading them from the **instance's** manifest copy so the label cannot drift from the route, and carrying them on **both** responses that report an app's exposure (the detail `GET` and the toggle's echo) rather than only the first — the pair is what the label is built from. The hosted lane's `access` boot carries the proof through real Caddy, including a bypass table (prefix footgun, traversal, encoded traversal, double slash, case variant) that must stay gated. **The first CI run paid for itself twice:** `//v1/` never reaches the gate — Caddy collapses the duplicate slash and 301's to the normalized path before matching, so the probe was safe and the expectation was wrong (the table now asserts the claim that matters: an undeclared path never reaches the app upstream anonymously) — and that failure came back **green**, because the lane matched its verdict with a `*PASS*` glob and the reason read "PATH GATE **BYPASS**". The harness now anchors on the exact verdict string; any failure text containing *bypass*/*password* would have silently greened any boot in this lane, so that one was latent well beyond this PR. **Gaps:** no catalog manifest declares the field yet, so it ships inert; the `UPDATES.md` clause making a widened `public_paths` prompt the owner is specified, not built; anonymous public-path traffic is invisible to Activity and has no rate limit in front of it | done |
| [catalog-no-box-side-copy.md](catalog-no-box-side-copy.md) — The box no longer keeps the catalog on disk. `loadCache` / the write-through in `syncOnce` are gone: the brain holds one snapshot in memory, re-fetches it, and shows an **empty store** when it cannot reach the endpoint, rather than browsing a last-good copy it could not install from anyway (a pinned old snapshot can also offer a manifest the store no longer publishes — `DECISIONS.md` 2026-08-17). `RemoteOptions.CacheDir` became `AssetCacheDir` and now means what it says. The three dev/test lanes that booted from a pre-seeded cache (`make dev-app`, both QEMU bootstraps) pass the snapshot explicitly as the new `MALMO_CATALOG_FILE` — read once, never written, unset on a real box. Icons and screenshots keep their disk cache but gained a **24-hour TTL**: filenames are stable per app, so without one the first icon a box fetched was the icon it served forever; an expired asset whose refetch fails is served stale rather than broken. The pinned wire fixture is now **synthetic** — three hand-written fake apps covering the full key surface, replacing a copy of the published catalog — with a `-update` flag that re-stamps its digest. `TestNoUnmodeledFields` is honest about what that costs it: it can no longer detect that the published shape moved, which becomes a publish-side check. `make test-nopam`, `fmt-check`, `vet`, `openapi-check` green | done |
| [store-detail-costs-and-screenshots.md](store-detail-costs-and-screenshots.md) — The store detail page now shows **what a third party charges** and **what the app looks like**. `external_costs` reached the box API in [catalog-wire-external-costs.md](catalog-wire-external-costs.md) and nothing rendered it, so a person learned about listmonk's mail bill after installing; a pricing panel above Information now shows a `Price` row plus each cost as a disclosure, **required ones open** (the one you must read to decide) and optional ones collapsed, with the rate estimate and the manifest prose. The "you pay the provider, not malmo" note is a **CSS-only tooltip** (`group-hover` / `group-focus-within`), so it works for pointer, keyboard and tap with no script. Price stays a **constant in the view**, not a wire field — every app is free today and what malmo charges is authored in the curation source — so there is one line to change when it does reach the wire. The screenshot strip, which was inert, now opens each shot **fit to the viewport** with arrow keys, prev/next and a live counter, using a **native `<dialog>`** rather than the fixed-overlay idiom `AppMenuDialog` hand-writes: the browser supplies the top layer, backdrop, Esc, focus trap and focus restore, leaving an index and a `src` swap. Both halves are ported from the marketing store's app page in `../cloud`, so the two surfaces show one catalog the same way — minus its progressive-enhancement half, which a Vue view cannot use. **Gaps:** typechecked and built but not yet clicked in a browser; `web-ui` has no test runner; the install dialog still does not repeat a required cost | done |
| [mail-provider-presets.md](mail-provider-presets.md) — **Closes #426.** Adding an email account meant typing **seven fields** the admin had to look up in their provider's docs, four of which are constants for any provider worth presetting. Settings → Outgoing email is now a two-step add: pick the provider, then supply only the credential, the from address, a username where the provider does not fix one, and a region for the two providers whose region changes the host — with host / port / encryption prefilled behind an **Advanced settings** disclosure, so a non-standard endpoint is never trapped, and `custom` rendering as that disclosure open with nothing prefilled (the old form). Nine presets in a new leaf package `internal/mailpreset`, served admin-only at `GET /api/v1/mail-presets`, **every one STARTTLS on a port a hosted box can reach** (587, except SMTP2GO's 2525) — hosted blocks 25 and 465, so no preset may default to implicit TLS. **Two of the issue's constants were wrong and one changed the design:** Mailgun's EU host is `smtp.eu.mailgun.org`, a *prefix* rather than a region code, so the planned single `{region}` host template could not serve both it and SES — each region option now names the host it resolves to, one mechanism with no substitution; and Brevo's username is the SMTP login `xxx@smtp-brevo.com`, not the account email, which is the kind of detail that turns into a failed test-send. `mail_providers.provider_type` persists **which** preset was picked (`DEFAULT 'custom'` via the idempotent ALTER path, no `CHECK` — one cannot ride an ALTER, so it is validated in Go like `scope` and `exposure`); pre-preset rows load as `custom`, which is right, they were typed by hand. It is kept **for the credential broker**, not for the form: without it every provider registered before the broker ships is an untyped row someone must classify by pattern-matching hostnames. The server **stores what the client sends and never re-derives** host / port / encryption from the preset, because re-deriving would silently undo the advanced override — so `provider_type` records what the admin picked, not a guarantee the values still match. The test-send now **names the blocked port** on hosted (a live bug for anyone who already registered a 465 provider there) and only on a *connect* failure, since an auth rejection means the port was reachable. `internal/lifecycle/mail.go` is untouched on purpose — `mailEnvLines` is the seam the broker replaces, and keeping it out is what makes the two features independent. **Gaps:** **no live test-send was performed against any provider** — that is the issue's real acceptance gate and needs a provisioned hosted box plus a real account at each of eight providers; the constants are verified against current vendor docs and nothing more. The SES region list is a common subset (8 of ~19); edit does not restore the region select (only the resolved host is stored); `web-ui` has no test runner; `make check` could not run end to end on this machine (`msteinert/pam` fails to build identically on unmodified `dev`) | done |
Loading