feat(settings): surface push.ntfyUrl in /public/settings for the app #79

Merged
whitlocktech merged 1 commits from feat/settings-push-ntfy-url into main 2026-07-20 20:55:58 +00:00
5 changed files with 123 additions and 5 deletions

View File

@@ -101,12 +101,20 @@ TOWNCRIER_DURATION_SEC=3600
# Push notifications (M7) — opt-in fan-out to the Android app via a self-hosted
# ntfy UnifiedPush relay (docs/android/PLAN.md §11). The publisher POSTs
# content-free tickles to each device's endpoint, so no publish token is required.
# NTFY_BASE_URL Public relay URL; also the backend's SSRF allow-set — a
# device may only register an endpoint on this origin.
# NTFY_ALLOWED_ORIGINS Optional comma-separated extra allowed origins.
# NTFY_BASE_URL Internal relay URL the publisher POSTs to; also part of the
# backend's SSRF allow-set — a device may only register an
# endpoint on an allowed origin.
# NTFY_PUBLIC_URL Client-facing relay URL surfaced to the app via
# /public/settings.push.ntfyUrl (the app registers its topic
# endpoint here). Defaults to the first NTFY_ALLOWED_ORIGINS
# entry; set when the public URL differs from NTFY_BASE_URL.
# NTFY_ALLOWED_ORIGINS Optional comma-separated allowed origins (the app's endpoint
# must sit on one). Also the default source for NTFY_PUBLIC_URL.
# NTFY_PUBLISH_TOKEN Optional bearer token for backend->ntfy publishes (off by default).
# Leave NTFY_BASE_URL unset in local dev to allow any public HTTPS endpoint
# (private/loopback hosts are always rejected).
# (private/loopback hosts are always rejected). Without NTFY_PUBLIC_URL /
# NTFY_ALLOWED_ORIGINS the app shows push as unavailable for the shard.
# NTFY_BASE_URL=https://ntfy.example.com
# NTFY_ALLOWED_ORIGINS=
# NTFY_PUBLIC_URL=https://ntfy.example.com
# NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
# NTFY_PUBLISH_TOKEN=

View File

@@ -105,9 +105,28 @@ async function getPublic() {
hero: brand.hero,
favicon: brand.favicon,
}
// Push-notification relay (M7). The client-facing ntfy base URL the app's
// embedded distributor registers its device topic against; null when push is
// not configured for this shard, in which case the app simply shows push as
// unavailable. The publisher's own NTFY_BASE_URL may be an internal
// compose-network address, so a distinct NTFY_PUBLIC_URL is preferred; failing
// that we use the first NTFY_ALLOWED_ORIGINS entry (a device endpoint must sit
// on an allowed origin anyway). Never NTFY_BASE_URL — it may be internal-only.
out.push = { ntfyUrl: publicNtfyUrl() }
return out
}
// The client-facing ntfy base URL (no trailing slash), or null when unset.
function publicNtfyUrl() {
const explicit = (process.env.NTFY_PUBLIC_URL || '').trim()
if (explicit) return explicit.replace(/\/+$/, '')
const firstOrigin = (process.env.NTFY_ALLOWED_ORIGINS || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)[0]
return firstOrigin ? firstOrigin.replace(/\/+$/, '') : null
}
module.exports = {
get,
set,

View File

@@ -14952,6 +14952,41 @@
},
"brand": {
"$ref": "#/components/schemas/Brand"
},
"push": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Push-notification relay config (M7). `ntfyUrl` is the client-facing ntfy base URL the app registers its device topic against (from NTFY_PUBLIC_URL / NTFY_ALLOWED_ORIGINS); null when push is not configured for this shard."
},
"properties": {
"type": "object",
"properties": {
"ntfyUrl": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "https://ntfy.example.com"
}
}
}
}
}
}
}
}
},

View File

@@ -680,6 +680,14 @@ const doc = {
},
gameAccountSignup: { type: 'boolean', example: false },
brand: { $ref: '#/components/schemas/Brand' },
push: {
type: 'object',
description:
'Push-notification relay config (M7). `ntfyUrl` is the client-facing ntfy base URL the app registers its device topic against (from NTFY_PUBLIC_URL / NTFY_ALLOWED_ORIGINS); null when push is not configured for this shard.',
properties: {
ntfyUrl: { type: 'string', nullable: true, example: 'https://ntfy.example.com' },
},
},
},
additionalProperties: true,
},

View File

@@ -55,3 +55,51 @@ test('admin site_title / contact_email override the brand defaults', async () =>
assert.equal(pub.brand.contactEmail, 'hi@shard.tld') // contact_email overrides
assert.equal(pub.brand.accent, brand.accent) // colors still from config
})
// The push relay block the app's embedded distributor discovers its ntfy base
// URL from (M7 Part 2). Null when nothing is configured; NTFY_PUBLIC_URL wins,
// else the first NTFY_ALLOWED_ORIGINS entry; NTFY_BASE_URL is never surfaced.
test('push.ntfyUrl is null when no ntfy env is configured', async () => {
const saved = { pub: process.env.NTFY_PUBLIC_URL, allow: process.env.NTFY_ALLOWED_ORIGINS, base: process.env.NTFY_BASE_URL }
delete process.env.NTFY_PUBLIC_URL
delete process.env.NTFY_ALLOWED_ORIGINS
process.env.NTFY_BASE_URL = 'http://ntfy:80' // internal-only, must NOT leak
try {
const pub = await settings.getPublic()
assert.ok(pub.push, 'push block present')
assert.equal(pub.push.ntfyUrl, null)
} finally {
restoreNtfyEnv(saved)
}
})
test('push.ntfyUrl prefers NTFY_PUBLIC_URL and trims a trailing slash', async () => {
const saved = { pub: process.env.NTFY_PUBLIC_URL, allow: process.env.NTFY_ALLOWED_ORIGINS, base: process.env.NTFY_BASE_URL }
process.env.NTFY_PUBLIC_URL = 'https://ntfy.shard.tld/'
process.env.NTFY_ALLOWED_ORIGINS = 'https://other.tld'
try {
const pub = await settings.getPublic()
assert.equal(pub.push.ntfyUrl, 'https://ntfy.shard.tld')
} finally {
restoreNtfyEnv(saved)
}
})
test('push.ntfyUrl falls back to the first NTFY_ALLOWED_ORIGINS entry', async () => {
const saved = { pub: process.env.NTFY_PUBLIC_URL, allow: process.env.NTFY_ALLOWED_ORIGINS, base: process.env.NTFY_BASE_URL }
delete process.env.NTFY_PUBLIC_URL
process.env.NTFY_ALLOWED_ORIGINS = 'https://ntfy.shard.tld, https://second.tld'
try {
const pub = await settings.getPublic()
assert.equal(pub.push.ntfyUrl, 'https://ntfy.shard.tld')
} finally {
restoreNtfyEnv(saved)
}
})
function restoreNtfyEnv(saved) {
for (const [name, val] of [['NTFY_PUBLIC_URL', saved.pub], ['NTFY_ALLOWED_ORIGINS', saved.allow], ['NTFY_BASE_URL', saved.base]]) {
if (val === undefined) delete process.env[name]
else process.env[name] = val
}
}