feat(settings): surface push.ntfyUrl in /public/settings for the app
The Android app's embedded push distributor (M7 Part 2) needs the shard's
client-facing ntfy relay URL to build its device topic endpoint, but the M7
Part 1 backend only used the NTFY_* vars server-side and never surfaced them.
Add a `push: { ntfyUrl }` block to settings.getPublic(), sourced from
NTFY_PUBLIC_URL or the first NTFY_ALLOWED_ORIGINS entry (never the possibly
internal NTFY_BASE_URL); null when unconfigured, so the app shows push as
unavailable for that shard. Additive, non-sensitive, forward-compatible.
- Extend the PublicSettings swagger schema; regenerate swagger-output.json.
- publicBrand.test.js: cover null / NTFY_PUBLIC_URL / NTFY_ALLOWED_ORIGINS.
- Document NTFY_PUBLIC_URL in .env.example and (docs PR) BACKEND_DESIGN.md.
Full server suite green (250 pass).
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -101,12 +101,20 @@ TOWNCRIER_DURATION_SEC=3600
|
|||||||
# Push notifications (M7) — opt-in fan-out to the Android app via a self-hosted
|
# 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
|
# 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.
|
# 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
|
# NTFY_BASE_URL Internal relay URL the publisher POSTs to; also part of the
|
||||||
# device may only register an endpoint on this origin.
|
# backend's SSRF allow-set — a device may only register an
|
||||||
# NTFY_ALLOWED_ORIGINS Optional comma-separated extra allowed origins.
|
# 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).
|
# 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
|
# 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_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=
|
# NTFY_PUBLISH_TOKEN=
|
||||||
|
|||||||
@@ -105,9 +105,28 @@ async function getPublic() {
|
|||||||
hero: brand.hero,
|
hero: brand.hero,
|
||||||
favicon: brand.favicon,
|
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
|
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 = {
|
module.exports = {
|
||||||
get,
|
get,
|
||||||
set,
|
set,
|
||||||
|
|||||||
@@ -14952,6 +14952,41 @@
|
|||||||
},
|
},
|
||||||
"brand": {
|
"brand": {
|
||||||
"$ref": "#/components/schemas/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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -680,6 +680,14 @@ const doc = {
|
|||||||
},
|
},
|
||||||
gameAccountSignup: { type: 'boolean', example: false },
|
gameAccountSignup: { type: 'boolean', example: false },
|
||||||
brand: { $ref: '#/components/schemas/Brand' },
|
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,
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.contactEmail, 'hi@shard.tld') // contact_email overrides
|
||||||
assert.equal(pub.brand.accent, brand.accent) // colors still from config
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user