mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-09 03:03:18 +08:00
Require browser login for scheduled-task HTTP APIs.
List/run/pause/resume/delete now return 401 without UDS cookies; hide sidebar chrome when logged out. uds-auth still validates the session store on the same routes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
67238d746e
commit
a38f3dcec3
4 changed files with 122 additions and 17 deletions
|
|
@ -160,6 +160,21 @@ window.__ModuleLoader__.load({
|
||||||
const NS = 'dsh-ops-cron'
|
const NS = 'dsh-ops-cron'
|
||||||
const inject = ['slots', 'locale', 'settingsScope']
|
const inject = ['slots', 'locale', 'settingsScope']
|
||||||
const API = '/dsh-ops-cron'
|
const API = '/dsh-ops-cron'
|
||||||
|
|
||||||
|
function readCookie(name) {
|
||||||
|
const parts = String(document.cookie || '').split(';')
|
||||||
|
for (const part of parts) {
|
||||||
|
const idx = part.indexOf('=')
|
||||||
|
if (idx < 0) continue
|
||||||
|
if (part.slice(0, idx).trim() !== name) continue
|
||||||
|
try { return decodeURIComponent(part.slice(idx + 1).trim()) } catch { return part.slice(idx + 1).trim() }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
function hasUdsLoginCookie() {
|
||||||
|
return !!(readCookie('PORTALSSOUser') || readCookie('ZTEDPGSSOUser') || readCookie('UDS_FALLBACK_USER'))
|
||||||
|
}
|
||||||
|
|
||||||
const LOCALE_NS = 'settings.dshCronTasks'
|
const LOCALE_NS = 'settings.dshCronTasks'
|
||||||
const TITLE_PREFIX = '定时任务 · '
|
const TITLE_PREFIX = '定时任务 · '
|
||||||
const listSnapshot = {
|
const listSnapshot = {
|
||||||
|
|
@ -518,6 +533,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
||||||
}
|
}
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
|
if (!hasUdsLoginCookie() && path !== '/health') {
|
||||||
|
throw new Error('登录后才能使用定时任务')
|
||||||
|
}
|
||||||
const response = await fetch(`${API}${path}`, {
|
const response = await fetch(`${API}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers },
|
headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers },
|
||||||
|
|
@ -1525,6 +1543,15 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
||||||
const place = () => {
|
const place = () => {
|
||||||
const found = findNewSessionButton()
|
const found = findNewSessionButton()
|
||||||
if (!found) return
|
if (!found) return
|
||||||
|
if (!hasUdsLoginCookie()) {
|
||||||
|
if (entry && entry.isConnected) entry.remove()
|
||||||
|
if (listRoot && listRoot.isConnected) listRoot.remove()
|
||||||
|
if (cronOn) {
|
||||||
|
cronOn = false
|
||||||
|
setCronMode(findSidebarRoot(found), false, entry, t)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
const sidebar = findSidebarRoot(found)
|
const sidebar = findSidebarRoot(found)
|
||||||
const region = findRegionArea(sidebar)
|
const region = findRegionArea(sidebar)
|
||||||
let anchor = found
|
let anchor = found
|
||||||
|
|
@ -1612,6 +1639,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
||||||
}
|
}
|
||||||
|
|
||||||
place()
|
place()
|
||||||
|
const onAuth = () => { place() }
|
||||||
|
window.addEventListener('focus', onAuth)
|
||||||
|
window.addEventListener('uds-auth-changed', onAuth)
|
||||||
let boots = 0
|
let boots = 0
|
||||||
const boot = () => {
|
const boot = () => {
|
||||||
boots += 1
|
boots += 1
|
||||||
|
|
@ -1621,6 +1651,8 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
||||||
requestAnimationFrame(boot)
|
requestAnimationFrame(boot)
|
||||||
const timer = setInterval(place, 4000)
|
const timer = setInterval(place, 4000)
|
||||||
return () => {
|
return () => {
|
||||||
|
window.removeEventListener('focus', onAuth)
|
||||||
|
window.removeEventListener('uds-auth-changed', onAuth)
|
||||||
observer.disconnect()
|
observer.disconnect()
|
||||||
clearInterval(timer)
|
clearInterval(timer)
|
||||||
if (placeRaf) cancelAnimationFrame(placeRaf)
|
if (placeRaf) cancelAnimationFrame(placeRaf)
|
||||||
|
|
|
||||||
71
lib/host.js
71
lib/host.js
|
|
@ -52,11 +52,50 @@ function parseUrl(req) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function parseCookieHeader(header, name) {
|
||||||
|
if (!header || typeof header !== 'string') return null
|
||||||
|
for (const part of header.split(';')) {
|
||||||
|
const idx = part.indexOf('=')
|
||||||
|
if (idx < 0) continue
|
||||||
|
if (part.slice(0, idx).trim() !== name) continue
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(part.slice(idx + 1).trim())
|
||||||
|
} catch {
|
||||||
|
return part.slice(idx + 1).trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Browser UDS / fallback login cookies (validated more strictly by uds-auth when present). */
|
||||||
|
function browserEmpNo(request) {
|
||||||
|
const cookie = request?.headers?.cookie || ''
|
||||||
|
return parseCookieHeader(cookie, 'PORTALSSOUser')
|
||||||
|
|| parseCookieHeader(cookie, 'ZTEDPGSSOUser')
|
||||||
|
|| parseCookieHeader(cookie, 'UDS_FALLBACK_USER')
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireBrowserLogin(request, write) {
|
||||||
|
if (!isTrustedApiRequest(request)) {
|
||||||
|
write(403, { ok: false, error: 'forbidden' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!browserEmpNo(request)) {
|
||||||
|
write(401, { ok: false, error: 'login_required', message: '登录后才能使用定时任务' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
function isTrustedApiRequest(request) {
|
function isTrustedApiRequest(request) {
|
||||||
const host = request.headers.host ?? ''
|
const host = request.headers.host ?? ''
|
||||||
if (!host) return false
|
if (!host) return false
|
||||||
const hostname = host.split(':')[0].replace(/^\[|\]$/g, '')
|
const hostname = host.split(':')[0].replace(/^\[|\]$/g, '')
|
||||||
if ((request.headers['sec-fetch-site'] ?? '') === 'cross-site') return false
|
if ((request.headers['sec-fetch-site'] ?? '') === 'cross-site') return false
|
||||||
|
// Allow same-origin LAN / non-loopback Host (login still required separately).
|
||||||
|
const site = request.headers['sec-fetch-site'] ?? ''
|
||||||
|
if (site === 'same-origin' || site === 'same-site') return true
|
||||||
const origin = request.headers.origin
|
const origin = request.headers.origin
|
||||||
if (origin !== undefined && origin !== 'null') {
|
if (origin !== undefined && origin !== 'null') {
|
||||||
try {
|
try {
|
||||||
|
|
@ -391,14 +430,14 @@ export function createHostService(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === `${API_PREFIX}/settings` && method === 'GET') {
|
if (path === `${API_PREFIX}/settings` && method === 'GET') {
|
||||||
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
if (!requireBrowserLogin(req, write)) return
|
||||||
const state = await snapshot()
|
const state = await snapshot()
|
||||||
write(200, { ok: true, settings: state.settings })
|
write(200, { ok: true, settings: state.settings })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === `${API_PREFIX}/settings` && method === 'PUT') {
|
if (path === `${API_PREFIX}/settings` && method === 'PUT') {
|
||||||
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
if (!requireBrowserLogin(req, write)) return
|
||||||
const body = await readJsonBody(req)
|
const body = await readJsonBody(req)
|
||||||
const settings = await updateSettings(body)
|
const settings = await updateSettings(body)
|
||||||
write(200, { ok: true, settings })
|
write(200, { ok: true, settings })
|
||||||
|
|
@ -406,7 +445,7 @@ export function createHostService(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === `${API_PREFIX}/models` && method === 'GET') {
|
if (path === `${API_PREFIX}/models` && method === 'GET') {
|
||||||
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
if (!requireBrowserLogin(req, write)) return
|
||||||
const catalog = typeof sessionPort?.listModels === 'function'
|
const catalog = typeof sessionPort?.listModels === 'function'
|
||||||
? await sessionPort.listModels()
|
? await sessionPort.listModels()
|
||||||
: { groups: [], current: null }
|
: { groups: [], current: null }
|
||||||
|
|
@ -415,7 +454,7 @@ export function createHostService(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === `${API_PREFIX}/presets` && method === 'GET') {
|
if (path === `${API_PREFIX}/presets` && method === 'GET') {
|
||||||
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
if (!requireBrowserLogin(req, write)) return
|
||||||
const catalog = typeof sessionPort?.listPresets === 'function'
|
const catalog = typeof sessionPort?.listPresets === 'function'
|
||||||
? await sessionPort.listPresets()
|
? await sessionPort.listPresets()
|
||||||
: { items: [], current: null }
|
: { items: [], current: null }
|
||||||
|
|
@ -424,7 +463,7 @@ export function createHostService(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === `${API_PREFIX}/workspaces` && method === 'GET') {
|
if (path === `${API_PREFIX}/workspaces` && method === 'GET') {
|
||||||
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
if (!requireBrowserLogin(req, write)) return
|
||||||
const workspaces = typeof sessionPort?.listWorkspaces === 'function'
|
const workspaces = typeof sessionPort?.listWorkspaces === 'function'
|
||||||
? await sessionPort.listWorkspaces()
|
? await sessionPort.listWorkspaces()
|
||||||
: []
|
: []
|
||||||
|
|
@ -433,7 +472,7 @@ export function createHostService(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === `${API_PREFIX}/im-catalog` && method === 'GET') {
|
if (path === `${API_PREFIX}/im-catalog` && method === 'GET') {
|
||||||
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
if (!requireBrowserLogin(req, write)) return
|
||||||
const dshIm = getDshIm()
|
const dshIm = getDshIm()
|
||||||
if (!dshIm || typeof dshIm.listDeliveryCatalog !== 'function') {
|
if (!dshIm || typeof dshIm.listDeliveryCatalog !== 'function') {
|
||||||
write(200, {
|
write(200, {
|
||||||
|
|
@ -463,14 +502,14 @@ export function createHostService(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === `${API_PREFIX}/jobs` && method === 'GET') {
|
if (path === `${API_PREFIX}/jobs` && method === 'GET') {
|
||||||
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
if (!requireBrowserLogin(req, write)) return
|
||||||
const state = await snapshot()
|
const state = await snapshot()
|
||||||
write(200, { ok: true, jobs: listJobs(state).map(jobView) })
|
write(200, { ok: true, jobs: listJobs(state).map(jobView) })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === `${API_PREFIX}/jobs` && method === 'POST') {
|
if (path === `${API_PREFIX}/jobs` && method === 'POST') {
|
||||||
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
if (!requireBrowserLogin(req, write)) return
|
||||||
const body = await readJsonBody(req)
|
const body = await readJsonBody(req)
|
||||||
const job = await createJob(body)
|
const job = await createJob(body)
|
||||||
write(200, { ok: true, job })
|
write(200, { ok: true, job })
|
||||||
|
|
@ -482,14 +521,14 @@ export function createHostService(options = {}) {
|
||||||
const jobId = decodeURIComponent(jobMatch[1])
|
const jobId = decodeURIComponent(jobMatch[1])
|
||||||
const rest = jobMatch[2] || ''
|
const rest = jobMatch[2] || ''
|
||||||
if (method === 'GET' && !rest) {
|
if (method === 'GET' && !rest) {
|
||||||
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
if (!requireBrowserLogin(req, write)) return
|
||||||
const state = await snapshot()
|
const state = await snapshot()
|
||||||
const job = getJob(state, jobId)
|
const job = getJob(state, jobId)
|
||||||
if (!job) return write(404, { ok: false, error: 'job not found' })
|
if (!job) return write(404, { ok: false, error: 'job not found' })
|
||||||
write(200, { ok: true, job: jobView(job) })
|
write(200, { ok: true, job: jobView(job) })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
if (!requireBrowserLogin(req, write)) return
|
||||||
if (method === 'PATCH' && !rest) {
|
if (method === 'PATCH' && !rest) {
|
||||||
const body = await readJsonBody(req)
|
const body = await readJsonBody(req)
|
||||||
const job = await updateJob(jobId, body)
|
const job = await updateJob(jobId, body)
|
||||||
|
|
@ -520,7 +559,7 @@ export function createHostService(options = {}) {
|
||||||
|
|
||||||
const openMatch = path.match(new RegExp(`^${API_PREFIX}/runs/([^/]+)/open$`))
|
const openMatch = path.match(new RegExp(`^${API_PREFIX}/runs/([^/]+)/open$`))
|
||||||
if (openMatch && method === 'POST') {
|
if (openMatch && method === 'POST') {
|
||||||
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
if (!requireBrowserLogin(req, write)) return
|
||||||
const runId = decodeURIComponent(openMatch[1])
|
const runId = decodeURIComponent(openMatch[1])
|
||||||
const state = await snapshot()
|
const state = await snapshot()
|
||||||
const run = (state.runs || []).find((row) => row.id === runId)
|
const run = (state.runs || []).find((row) => row.id === runId)
|
||||||
|
|
@ -535,7 +574,7 @@ export function createHostService(options = {}) {
|
||||||
|
|
||||||
const adoptMatch = path.match(new RegExp(`^${API_PREFIX}/sessions/([^/]+)/adopt$`))
|
const adoptMatch = path.match(new RegExp(`^${API_PREFIX}/sessions/([^/]+)/adopt$`))
|
||||||
if (adoptMatch && method === 'POST') {
|
if (adoptMatch && method === 'POST') {
|
||||||
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
if (!requireBrowserLogin(req, write)) return
|
||||||
const sessionId = decodeURIComponent(adoptMatch[1])
|
const sessionId = decodeURIComponent(adoptMatch[1])
|
||||||
if (!sessionId) return write(400, { ok: false, error: 'sessionId required' })
|
if (!sessionId) return write(400, { ok: false, error: 'sessionId required' })
|
||||||
if (typeof sessionPort?.adoptSession !== 'function') {
|
if (typeof sessionPort?.adoptSession !== 'function') {
|
||||||
|
|
@ -547,14 +586,14 @@ export function createHostService(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === `${API_PREFIX}/conceal` && method === 'POST') {
|
if (path === `${API_PREFIX}/conceal` && method === 'POST') {
|
||||||
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
if (!requireBrowserLogin(req, write)) return
|
||||||
const hidden = await concealKnownSessions()
|
const hidden = await concealKnownSessions()
|
||||||
write(200, { ok: true, hidden })
|
write(200, { ok: true, hidden })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === `${API_PREFIX}/history` && method === 'GET') {
|
if (path === `${API_PREFIX}/history` && method === 'GET') {
|
||||||
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
if (!requireBrowserLogin(req, write)) return
|
||||||
const state = await snapshot()
|
const state = await snapshot()
|
||||||
const jobId = url.searchParams.get('jobId') || undefined
|
const jobId = url.searchParams.get('jobId') || undefined
|
||||||
write(200, { ok: true, runs: listHistory(state, jobId).map(runView) })
|
write(200, { ok: true, runs: listHistory(state, jobId).map(runView) })
|
||||||
|
|
@ -562,7 +601,7 @@ export function createHostService(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === `${API_PREFIX}/preview` && method === 'POST') {
|
if (path === `${API_PREFIX}/preview` && method === 'POST') {
|
||||||
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
if (!requireBrowserLogin(req, write)) return
|
||||||
const body = await readJsonBody(req)
|
const body = await readJsonBody(req)
|
||||||
const settings = (await snapshot()).settings
|
const settings = (await snapshot()).settings
|
||||||
const schedule = validateSchedule(body.schedule || body, body.timezone || settings.timezone)
|
const schedule = validateSchedule(body.schedule || body, body.timezone || settings.timezone)
|
||||||
|
|
@ -572,7 +611,7 @@ export function createHostService(options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === `${API_PREFIX}/workspace-visible` && method === 'GET') {
|
if (path === `${API_PREFIX}/workspace-visible` && method === 'GET') {
|
||||||
if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' })
|
if (!requireBrowserLogin(req, write)) return
|
||||||
const state = await snapshot()
|
const state = await snapshot()
|
||||||
const listed = url.searchParams.getAll('id')
|
const listed = url.searchParams.getAll('id')
|
||||||
write(200, {
|
write(200, {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "dsh-ops-cron",
|
"name": "dsh-ops-cron",
|
||||||
"description": "Scheduled tasks for DeepSeek Harness: Agent cron_* tools + sidebar 定时任务, with DSH or WhatsApp/IM delivery per job.",
|
"description": "Scheduled tasks for DeepSeek Harness: Agent cron_* tools + sidebar 定时任务, with DSH or WhatsApp/IM delivery per job.",
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"private": false,
|
"private": false,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@ async function jsonRequest(base, path, options = {}) {
|
||||||
accept: 'application/json',
|
accept: 'application/json',
|
||||||
origin: base,
|
origin: base,
|
||||||
host: new URL(base).host,
|
host: new URL(base).host,
|
||||||
|
'sec-fetch-site': 'same-origin',
|
||||||
|
cookie: options.anonymous ? '' : 'PORTALSSOUser=tester',
|
||||||
...(options.body ? { 'content-type': 'application/json' } : {}),
|
...(options.body ? { 'content-type': 'application/json' } : {}),
|
||||||
...options.headers,
|
...options.headers,
|
||||||
},
|
},
|
||||||
|
|
@ -191,6 +193,38 @@ test('overlap skip writes a skipped history row instead of a second session', as
|
||||||
assert.equal(inflight, 1)
|
assert.equal(inflight, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
test('http api: anonymous list/run-now is rejected', async (t) => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
|
||||||
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||||
|
const service = createHostService({
|
||||||
|
filePath: join(dir, 'store.json'),
|
||||||
|
now: () => Date.parse('2026-08-24T01:00:00.000Z'),
|
||||||
|
sessionPort: {
|
||||||
|
async createAndPrompt() { return { sessionId: 'x', status: 'succeeded', summary: 'ok' } },
|
||||||
|
async archiveSession() {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await service.createJob({
|
||||||
|
name: 'secret job',
|
||||||
|
prompt: 'do not leak',
|
||||||
|
schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'UTC' },
|
||||||
|
})
|
||||||
|
const http = await listen(service)
|
||||||
|
t.after(() => http.close())
|
||||||
|
const listed = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { anonymous: true })
|
||||||
|
assert.equal(listed.status, 401)
|
||||||
|
assert.equal(listed.body.error, 'login_required')
|
||||||
|
const jobs = await jsonRequest(http.url, '/dsh-ops-cron/jobs')
|
||||||
|
assert.equal(jobs.status, 200)
|
||||||
|
const jobId = jobs.body.jobs[0].id
|
||||||
|
const ran = await jsonRequest(http.url, `/dsh-ops-cron/jobs/${jobId}/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
anonymous: true,
|
||||||
|
})
|
||||||
|
assert.equal(ran.status, 401)
|
||||||
|
})
|
||||||
|
|
||||||
test('shipped HTTP handler: create, list, run-now, history', async (t) => {
|
test('shipped HTTP handler: create, list, run-now, history', async (t) => {
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
|
||||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue