diff --git a/.changeset/feedback-preference-read.md b/.changeset/feedback-preference-read.md new file mode 100644 index 00000000..15656546 --- /dev/null +++ b/.changeset/feedback-preference-read.md @@ -0,0 +1,5 @@ +--- +"nansen-cli": minor +--- + +Read account feedback preferences and stop automatic retries when feedback is not enabled for the account. diff --git a/README.md b/README.md index ce642d1e..9fd7dc97 100644 --- a/README.md +++ b/README.md @@ -80,11 +80,17 @@ Agents should check the saved account preference before sending feedback on thei own. If enabled, they may send without asking again, subject to their host's controls. If off, unset or unavailable, show the proposed report and ask first. Approval for one report does not change the preference. Never enable it -automatically. This CLI version has no preference-read command, so ask first -unless an authenticated preference read is available through another supported -tool. An explicit user request to send the report needs no additional preference +automatically. Use `nansen feedback preferences --json` for a fresh account read +before each new automatic report. Without `--json`, it prints a human summary. +Reads use no credits or report quota. Change the lasting choice in Superapp settings. +An explicit user request to send the report needs no additional preference check. The submit command sends the report without an interactive prompt. +HTTP 403 with `reason: feedback_not_enabled` means feedback is not enabled for +this account. Stop automatic retries. Approval or an enabled preference does +not override rollout. Keep the ID and unchanged report for recovery; an earlier +uncertain attempt remains uncertain. Never switch accounts or pay to bypass denial. + ## Auth Plain `nansen login` requests fresh browser approval and saves a session automatically. No API-key copying or PEM setup is needed. Selected sessions renew automatically; rejected or uncertain renewal requires fresh login. diff --git a/skills/nansen-agent-guide/SKILL.md b/skills/nansen-agent-guide/SKILL.md index 02643db9..a67c526a 100644 --- a/skills/nansen-agent-guide/SKILL.md +++ b/skills/nansen-agent-guide/SKILL.md @@ -73,15 +73,16 @@ Browser nansen:api grants have API-key-equivalent account permissions, subject t When a Nansen task exposes a useful product gap, draft a concise report without secrets or unnecessary conversation details. Before sending on your own, read -the current account's saved feedback preference through an available -authenticated preference reader. If enabled, you may send without asking again, +the current account's saved feedback preference with +`nansen feedback preferences --json`. Use a fresh read for each new report. If enabled, you may send without asking again, subject to the host's controls. If off, unset or unavailable, show the proposed report and ask the user. If they decline or you cannot ask, do not send. -This CLI version does not include a preference-read command. If no supported -preference reader is available, ask first. An explicit user request or approval +If the preference read fails, ask first. An explicit user request or approval permits that report; it does not change the saved preference. Never enable the -preference automatically or add approval fields to the report. +preference automatically or add approval fields to the report. Direct users to +Superapp settings for a lasting choice. Host or local-deny controls still apply +to an enabled preference and to one-report approval. After the user requests the report or the preference permits it, submit with `nansen feedback submit --file feedback.json --submission-id --json`. @@ -97,3 +98,9 @@ do not keep retrying or ask for approval again in the same session. Preserve the ID and unchanged report for a later retry. If this CLI version lacks the feedback command, report that it is unavailable and stop. Accepted-ID replays and receipt reads remain available during a pause. + +A 403 with `reason: feedback_not_enabled` means feedback is not enabled for +this account. Stop automatic retries and tell the user. Approval and an enabled +preference do not override rollout. Keep the saved ID and unchanged report. +An earlier uncertain attempt remains uncertain; recover it with the same ID +after availability is restored. Never switch accounts or pay to bypass denial. diff --git a/src/__tests__/feedback-recovery.test.js b/src/__tests__/feedback-recovery.test.js index ec34450d..d967acdf 100644 --- a/src/__tests__/feedback-recovery.test.js +++ b/src/__tests__/feedback-recovery.test.js @@ -356,3 +356,38 @@ it.skipIf(process.platform === 'win32').each(['json', 'human'])('reports quota r expect(new Set(posts(h).map(req => req.headers['idempotency-key']))).toEqual(new Set([saved.submission_id])); expect(h.read()).toMatchObject({ state: 'confirmed', submission_id: saved.submission_id }); }); + +it('preserves the saved ID on rollout denial and distinguishes prior uncertainty', async () => { + for (const prior of [false, true]) { + const h = await harness(); + if (prior) await killedPending(h); + h.post((_req, res) => reply(res, 403, { code: 'forbidden', reason: 'feedback_not_enabled', message: 'Not enabled for account.' })); + const result = await h.run(prior ? ['resume', '--json'] : h.submit); + const error = JSON.parse(result.stdout); + expect(error.details).toMatchObject({ reason: 'feedback_not_enabled', outcome: prior ? 'unknown' : 'rejected', submission_id: h.read().submission_id }); + expect(result.stdout).toContain('Stop automatic retries'); expect(error.details.retry_command).toBeUndefined(); + const saved = h.read(); expect(saved.state).toBe('pending'); expect(saved.body).toBe(h.body); + h.post((req, res) => reply(res, 200, receipt(req, { replayed: prior }))); + expect((await h.run(['resume', '--json'])).code).toBe(0); + expect(h.read().submission_id).toBe(saved.submission_id); + } +}); + +it.each([ + { status: 500, reason: 'internal_error', outcome: 'unknown' }, + { status: 503, reason: 'feedback_store_unavailable', outcome: 'unknown' }, + { status: 503, reason: 'feedback_admissions_paused', outcome: 'rejected' }, +])('retains recoverable journal after $reason then rollout denial', async ({ status, reason, outcome }) => { + const h = await harness(); let attempts = 0; + h.post((_req, res) => reply(res, ++attempts === 1 ? status : 403, attempts === 1 + ? { code: 'upstream_unavailable', reason } + : { code: 'forbidden', reason: 'feedback_not_enabled' })); + const result = await h.run(h.submit); const saved = h.read(); + expect(JSON.parse(result.stdout)).toMatchObject({ details: { outcome, reason: 'feedback_not_enabled', submission_id: saved.submission_id } }); + expect(saved).toMatchObject({ state: 'pending', body: h.body }); + expect(posts(h)).toHaveLength(2); expect(posts(h)[0].body).toBe(posts(h)[1].body); + expect(posts(h)[0].headers['idempotency-key']).toBe(posts(h)[1].headers['idempotency-key']); + h.post((req, res) => reply(res, 200, receipt(req, { replayed: outcome === 'unknown' }))); + expect((await h.run(['resume', '--json'])).code).toBe(0); + expect(h.read()).toMatchObject({ state: 'confirmed', submission_id: saved.submission_id }); +}); diff --git a/src/__tests__/feedback.test.js b/src/__tests__/feedback.test.js index 6a09075c..242c98f2 100644 --- a/src/__tests__/feedback.test.js +++ b/src/__tests__/feedback.test.js @@ -288,3 +288,117 @@ it('uses the saved browser account on retries and preserves structured quota err expect(fetch.mock.calls[0][1].body).toBe(fetch.mock.calls[1][1].body); for (const secret of [bundle.accessToken, bundle.refreshToken, bundle.privateJwk.d]) expect(JSON.stringify(error)).not.toContain(secret); }); + +const preference = { automatic_reporting: 'enabled', configured: true, version: 1, updated_at: '2026-10-15T12:00:00Z', can_manage: true }; +it('reads fresh account preferences in human and JSON output without caching, payments, or submission', async () => { + const disabled = { ...preference, automatic_reporting: 'disabled', version: 2 }; + const h = await harness([{ status: 200, body: preference }, { status: 200, body: disabled }]); + const first = await h.run(['preferences', '--json', '--cache', '--paginate']); + expect(first.code).toBe(0); expect(JSON.parse(first.stdout).data).toEqual(preference); + const next = await h.run(['preferences']); + expect(next.code).toBe(0); expect(next.stdout).toContain('disabled'); expect(next.stdout).toContain('ask before sending'); + expect(h.requests).toHaveLength(2); + for (const req of h.requests) { + expect(req).toMatchObject({ path: '/api/v1/feedback/preferences', method: 'GET', body: '' }); + expect(req.headers.apikey).toBe('synthetic-key'); expect(req.headers['idempotency-key']).toBeUndefined(); + expect(req.headers['payment-signature']).toBeUndefined(); + } + expect(fs.existsSync(path.join(h.home, '.nansen/cache'))).toBe(false); + expect(fs.existsSync(path.join(h.home, '.nansen/feedback'))).toBe(false); +}); +it.each([ + { automatic_reporting: 'disabled', configured: false, version: 0, updated_at: null, can_manage: false }, + { ...preference, automatic_reporting: 'disabled' }, +])('reports disabled or unconfigured preferences without submitting', async body => { + const h = await harness([{ status: 200, body }]); + const r = await h.run(['preferences']); expect(r.code).toBe(0); expect(r.stdout).toContain('ask before sending'); + expect(h.requests).toHaveLength(1); +}); +it.each([ + { status: 200, body: {} }, { status: 200, body: { ...preference, configured: false } }, + { status: 200, body: { ...preference, automatic_reporting: 'unknown' } }, + { status: 503, body: { code: 'upstream_unavailable', reason: 'preference_store_unavailable' } }, + { status: 404, body: { code: 'not_found' } }, +])('fails closed on unavailable or invalid preference $status', async response => { + const h = await harness([response]); const r = await h.run(['preferences', '--json', '--no-retry']); + expect(r.code).toBe(1); expect(JSON.parse(r.stdout).success).toBe(false); + expect(h.requests).toHaveLength(1); expect(h.requests[0].method).toBe('GET'); +}); +it.each([['preferences', 'enable'], ['preferences', '--file', 'report.json'], ['preferences', '--new']])('rejects preference mutations %j before sending', async args => { + const h = await harness([{ status: 200, body: preference }]); + expect((await h.run([...args, '--json'])).code).toBe(1); expect(h.requests).toHaveLength(0); +}); +it('stops on account rollout denial and preserves an earlier lost-response outcome', async () => { + const denial = { status: 403, body: { code: 'forbidden', reason: 'feedback_not_enabled', message: 'Feedback is not enabled.' } }; + for (const lost of [false, true]) { + const h = await harness(lost ? ['disconnect', denial] : [denial]); + const r = await h.run([...h.submit, '--json']); const error = JSON.parse(r.stdout); + expect(error.code).toBe(lost ? 'submission_outcome_unknown' : 'forbidden'); + expect(error.details).toMatchObject({ reason: 'feedback_not_enabled', outcome: lost ? 'unknown' : 'rejected' }); + expect(r.stdout).toContain('Stop automatic retries'); expect(h.requests).toHaveLength(lost ? 2 : 1); + } +}); +it('keeps existing telemetry accurate for preference reads without recording preference values', async () => { + const h = await harness([{ status: 200, body: preference }]); + await h.run(['preferences', '--json', '--cache'], { NANSEN_NO_TELEMETRY: '0', NODE_OPTIONS: `--require=${h.capture}` }); + const event = JSON.parse(fs.readFileSync(h.eventFile, 'utf8').trim()); + expect(event).toMatchObject({ event: 'cli_command_succeeded', path: '/feedback', properties: { from_cache: false } }); + expect(JSON.stringify(event)).not.toContain('automatic_reporting'); +}); + +it('rejects a preference response when reading a receipt with the reserved ID', async () => { + const h = await harness([{ status: 200, body: preference }]); + for (const mode of [[], ['--json']]) { + const r = await h.run(['get', 'preferences', '--no-retry', ...mode]); + expect(r.code).toBe(1); expect(r.stdout).toContain('Receipt lookup failed'); + expect(r.stdout).not.toContain('undefined'); expect(r.stdout).not.toContain('automatic_reporting'); + } + expect(h.requests).toHaveLength(2); + expect(h.requests.every(r => r.method === 'GET' && r.path === '/api/v1/feedback/preferences')).toBe(true); +}); +it('keeps the human uncertainty warning and original ID after a rollout denial on a retry', async () => { + const h = await harness(['disconnect', { status: 403, body: { code: 'forbidden', reason: 'feedback_not_enabled' } }]); + const r = await h.run(h.submit); + expect(r.code).toBe(1); expect(r.stdout).toContain('Stop automatic retries'); + expect(r.stdout).toContain('may have been saved'); expect(r.stdout).toContain('unchanged --file'); + expect(r.stdout).toContain(examples.accepted.body.submission_id); + expect(h.requests).toHaveLength(2); + expect(h.requests[0].headers['idempotency-key']).toBe(h.requests[1].headers['idempotency-key']); +}); + +it.each([ + { status: 500, reason: 'internal_error', outcome: 'unknown' }, + { status: 503, reason: 'feedback_store_unavailable', outcome: 'unknown' }, + { status: 503, reason: 'feedback_admissions_paused', outcome: 'rejected' }, +])('preserves $outcome after HTTP $status $reason then rollout denial', async ({ status, reason, outcome }) => { + for (const flags of [[], ['--json']]) { + const h = await harness([ + { status, headers: { 'Retry-After': '0' }, body: { code: 'upstream_unavailable', reason } }, + { status: 403, body: { code: 'forbidden', reason: 'feedback_not_enabled' } }, + ]); + const r = await h.run([...h.submit, ...flags]); + expect(r.code).toBe(1); expect(r.stdout).toContain('Stop automatic retries'); + if (flags.length) expect(JSON.parse(r.stdout)).toMatchObject({ + code: outcome === 'unknown' ? 'submission_outcome_unknown' : 'forbidden', + details: { outcome, reason: 'feedback_not_enabled', submission_id: examples.accepted.body.submission_id }, + }); + if (outcome === 'unknown') expect(r.stdout).toContain('may have been saved'); + else expect(r.stdout).not.toContain('may have been saved'); + expect(h.requests).toHaveLength(2); + expect(h.requests[0].body).toBe(h.requests[1].body); + expect(h.requests[0].headers['idempotency-key']).toBe(h.requests[1].headers['idempotency-key']); + } +}); + +it.each([{ flags: [] }, { flags: ['--no-retry'] }])('reports a terminal HTTP 5xx as unknown with retry flags $flags', async ({ flags }) => { + const h = await harness([{ status: 503, headers: { 'Retry-After': '0' }, + body: { code: 'upstream_unavailable', reason: 'feedback_store_unavailable', request_id: 'uncertain-commit' } }]); + const r = await h.run([...h.submit, '--json', '--retries', '1', ...flags]); + expect(r.code).toBe(1); + expect(JSON.parse(r.stdout)).toMatchObject({ code: 'submission_outcome_unknown', status: 503, + details: { outcome: 'unknown', reason: 'feedback_store_unavailable', request_id: 'uncertain-commit', submission_id: examples.accepted.body.submission_id } }); + expect(r.stdout).toContain('may have been saved'); + expect(h.requests).toHaveLength(flags.length ? 1 : 2); + expect(new Set(h.requests.map(r => r.headers['idempotency-key'])).size).toBe(1); + expect(new Set(h.requests.map(r => r.body)).size).toBe(1); +}); diff --git a/src/cli.js b/src/cli.js index 1bd65ac5..e14e476b 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1136,7 +1136,7 @@ COMMANDS: wallet ${WALLET_SUBCOMMANDS.join(', ')} agent Ask the Nansen AI research agent (fast/expert modes) alerts list, create, update, toggle, delete - feedback submit, resume, forget local data, or get a receipt (--json for structured output) + feedback preferences, submit, resume, forget local data, or get a receipt (--json for structured output) web search, fetch mcp install/uninstall/verify the Nansen MCP server account Check the effective credential, plan, and remaining credits (free) diff --git a/src/commands/feedback.js b/src/commands/feedback.js index 6c7fc0d9..bff50d7c 100644 --- a/src/commands/feedback.js +++ b/src/commands/feedback.js @@ -36,7 +36,8 @@ function retryDelay(response, data, attempt, retry) { // Feedback keeps the file bytes and uses selected account credentials. It never // enters the generic client's body cleaning, cache, pagination or payment paths. -async function requestFeedback(api, endpoint, body, submissionId) { +async function requestFeedback(api, endpoint, body, submissionId, responseType = 'receipt') { + const preference = responseType === 'preferences'; if (Object.keys(api.defaultHeaders).length) { throw new NansenError('Feedback uses account authentication only. Remove --x402-payment-signature.', ErrorCode.INVALID_PARAMS); } @@ -76,6 +77,13 @@ async function requestFeedback(api, endpoint, body, submissionId) { data = await response.json(); if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('Invalid response'); if (response.ok) { + if (preference) { + if (!['enabled', 'disabled'].includes(data.automatic_reporting) || typeof data.configured !== 'boolean' || + !Number.isSafeInteger(data.version) || data.version < 0 || typeof data.can_manage !== 'boolean' || + !(data.updated_at === null || (typeof data.updated_at === 'string' && Number.isFinite(Date.parse(data.updated_at)))) || + (!data.configured && data.automatic_reporting !== 'disabled')) throw new Error('Invalid preference'); + return Object.fromEntries(['automatic_reporting', 'configured', 'version', 'updated_at', 'can_manage'].map(key => [key, data[key]])); + } if (typeof data.feedback_id !== 'string' || typeof data.submission_id !== 'string' || !['received', 'reviewed', 'closed'].includes(data.status) || (submissionId && data.submission_id !== submissionId)) throw new Error('Invalid receipt'); @@ -88,9 +96,9 @@ async function requestFeedback(api, endpoint, body, submissionId) { } const unknown = body !== undefined; lastError = new NansenError( - unknown ? 'The submission may have been saved. Retry the same --submission-id with the unchanged --file.' : 'Receipt lookup failed. Retry nansen feedback get with the same feedback ID.', + unknown ? 'The submission may have been saved. Retry the same --submission-id with the unchanged --file.' : preference ? 'Feedback preference unavailable. Show the report and ask before sending.' : 'Receipt lookup failed. Retry nansen feedback get with the same feedback ID.', unknown ? 'submission_outcome_unknown' : ErrorCode.NETWORK_ERROR, response?.status ?? null, - { reason: unknown ? 'submission_outcome_unknown' : 'receipt_unavailable', submission_id: submissionId ?? null, request_id: redact(requestId(response)) }, + { reason: unknown ? 'submission_outcome_unknown' : preference ? 'preference_unavailable' : 'receipt_unavailable', submission_id: submissionId ?? null, request_id: redact(requestId(response)) }, ); if (unknown) uncertainSubmission = lastError; if (attempt < maxRetries && (!response || response.ok || TEMPORARY.has(response.status))) { @@ -102,15 +110,27 @@ async function requestFeedback(api, endpoint, body, submissionId) { const details = redact(Object.fromEntries(ERROR_FIELDS.filter(key => key in data).map(key => [key, data[key]]))); details.request_id ??= redact(requestId(response)); if (submissionId) details.submission_id = submissionId; + if (response.status === 403 && data.reason === 'feedback_not_enabled') { + const uncertainty = uncertainSubmission ? ' An earlier attempt may have been saved. Keep the same --submission-id and unchanged --file for recovery.' : ''; + throw new NansenError(`Feedback is not enabled for this account. Stop automatic retries. User approval or an enabled preference does not override rollout.${uncertainty}`, + uncertainSubmission ? uncertainSubmission.code : details.code || 'forbidden', response.status, + { ...details, outcome: uncertainSubmission ? 'unknown' : 'rejected' }); + } const unavailable = response.status === 402 || data.code === 'insufficient_credits' || response.status === 501 || (response.status === 404 && body !== undefined); if (unavailable) { throw new NansenError('Feedback is unavailable on this backend. Retry after feedback support is deployed; no payment or account switch was attempted.', 'feedback_unavailable', response.status, details); } if (response.status === 404) { + if (preference) throw new NansenError('Feedback preference unavailable on this backend. Show the report and ask before sending.', 'feedback_unavailable', response.status, details); throw new NansenError('Receipt not found, or this backend does not support feedback yet. Check the feedback ID, active account and NANSEN_BASE_URL. Do not resubmit as a new report.', details.code || 'not_found', response.status, details); } lastError = new NansenError(details.message || 'Feedback request failed.', details.code || statusToErrorCode(response.status, data), response.status, details); + if (body !== undefined && response.status >= 500 && data.reason !== 'feedback_admissions_paused') { + uncertainSubmission ??= new NansenError('The submission may have been saved. Retry the same --submission-id with the unchanged --file.', + 'submission_outcome_unknown', response.status, + { reason: 'submission_outcome_unknown', submission_id: submissionId, request_id: details.request_id }); + } if (uncertainSubmission && [401, 403].includes(response.status)) throw preserveUncertainSubmission(lastError, uncertainSubmission); const weekly = data.reason === 'feedback_weekly_quota_exhausted'; const delay = retryDelay(response, data, attempt, retry); @@ -118,6 +138,10 @@ async function requestFeedback(api, endpoint, body, submissionId) { await sleep(delay); continue; } + if (uncertainSubmission && response.status >= 500) { + throw new NansenError(`${lastError.message} ${uncertainSubmission.message}`, uncertainSubmission.code, + response.status, { ...details, outcome: 'unknown' }); + } throw lastError; } throw lastError; @@ -141,6 +165,7 @@ async function feedbackIdentity(api) { async function managedFeedback(api, { body, newSubmission = false, forget = false } = {}) { return withFeedbackJournal(async journal => { let entry = journal.entry; + const previouslyPending = entry?.state === 'pending' && !newSubmission; if (forget) { journal.clear(); return { forgotten: true }; } if (newSubmission && entry?.state === 'pending') throw new NansenError('A submission is still pending. Run: nansen feedback resume. To erase its local retry data, run: nansen feedback forget.', 'feedback_pending'); if ((!entry && body === undefined) || (entry?.state === 'cleared' && !newSubmission)) throw new NansenError('No replayable feedback remains. Expired or erased submissions are never replayed. Use --new only for a deliberate new report.', 'feedback_recovery_unavailable'); @@ -172,6 +197,12 @@ async function managedFeedback(api, { body, newSubmission = false, forget = fals } catch (error) { if (error.details?.reason === 'feedback_erased' || (entry.state === 'confirmed' && error.status === 404)) { journal.clear(); throw error; } if (entry.state !== 'pending' || error.code === 'feedback_recovery_unavailable') throw error; + if (error.status === 403 && error.details?.reason === 'feedback_not_enabled') { + const unknown = previouslyPending || error.details.outcome === 'unknown'; + const outcomeText = unknown ? error.details.outcome === 'unknown' ? '' : ' An earlier attempt may have been accepted.' : ' This report was not accepted.'; + throw new NansenError(`${error.message}${outcomeText} The saved ID and report are retained for recovery.`, + error.code, error.status, { ...error.details, submission_id: entry.submission_id, outcome: unknown ? 'unknown' : 'rejected' }); + } if ([400, 413, 422].includes(error.status) || (error.status === 409 && error.details?.reason === 'idempotency_key_reused')) { journal.clear(); throw new NansenError(`${error.message} This attempt was rejected. Local retry data was erased. Correct the report and use --new with the corrected --file.`, @@ -214,7 +245,13 @@ export function buildFeedbackCommands({ log = console.log } = {}) { feedback: async (args, api, flags, options) => { let receipt; if (flags['submission-id']) throw new NansenError('--submission-id requires a value.', ErrorCode.INVALID_PARAMS); - if (args[0] === 'submit') { + if (args[0] === 'preferences') { + if (args.length !== 1 || options.file || options['submission-id'] || flags.new) throw new NansenError('Use: nansen feedback preferences [--json]', ErrorCode.INVALID_PARAMS); + receipt = await requestFeedback(api, `${PATH}/preferences`, undefined, undefined, 'preferences'); + if (flags.json || flags.pretty || flags.p) return receipt; + log(`Automatic feedback: ${receipt.automatic_reporting}${receipt.configured ? '' : ' (not configured)'}.\n${receipt.automatic_reporting === 'enabled' ? 'Agents may send without asking, subject to host controls and account rollout.' : 'Show the report and ask before sending.'}\nChange the lasting choice in Superapp settings. Approval for one report does not change it.`); + return; + } else if (args[0] === 'submit') { if (args.length !== 1) throw new NansenError('Use: nansen feedback submit --file feedback.json [--submission-id | --new] [--json]', ErrorCode.INVALID_PARAMS); const id = options['submission-id']; if (id !== undefined && flags.new) throw new NansenError('Use either --new or --submission-id.', ErrorCode.INVALID_PARAMS); @@ -244,7 +281,7 @@ export function buildFeedbackCommands({ log = console.log } = {}) { }); } else receipt = await requestFeedback(api, `${PATH}/${encodeURIComponent(id)}`); } else { - throw new NansenError('Use: nansen feedback submit --file feedback.json, nansen feedback resume, nansen feedback forget, or nansen feedback get . Add --json for structured output.', ErrorCode.INVALID_PARAMS); + throw new NansenError('Use: nansen feedback preferences, nansen feedback submit --file feedback.json, nansen feedback resume, nansen feedback forget, or nansen feedback get . Add --json for structured output.', ErrorCode.INVALID_PARAMS); } if (flags.json || flags.pretty || flags.p) return receipt; log(receipt.forgotten ? 'Local feedback retry data erased. This does not delete a report already saved by Nansen. Your original input file is unchanged.' : receiptText(receipt)); diff --git a/src/schema.json b/src/schema.json index f053f376..751589c9 100644 --- a/src/schema.json +++ b/src/schema.json @@ -1,8 +1,8 @@ { "commands": { "feedback": { - "description": "Send product feedback to Nansen or read an account-scoped receipt. Free; seven reports per account per week, resetting Monday 00:00 UTC. New submissions may be paused; the CLI returns an unavailable error.", - "notes": "Use the configured API key or login session. Keep the same submission ID and unchanged report after an uncertain result. No caching or payment fallback. Remove credentials and unnecessary personal context from reports. Without --submission-id, save one private submission before sending. Resume with nansen feedback resume. Use --new for a deliberate new report after confirmation or local erasure. Pending data expires after one calendar year; feedback commands enforce cleanup. Automatic IDs require POSIX private storage and native locking; otherwise supply an explicit ID. Backend validates shared report fields. Agents: send on your own only after an authenticated read shows the account preference is enabled and host controls permit it. Otherwise show the report and ask first; a direct user request permits that report. This CLI version has no preference-read command. Never enable the preference automatically. Submit is noninteractive and does not verify approval. New submissions may be paused before release or during an outage. A 503 with reason feedback_admissions_paused means this attempt accepted no new report and used no weekly allowance. Tell the user feedback is unavailable and stop; do not keep retrying or ask for approval again in the same session. Preserve the ID and unchanged file for a later retry. Accepted-ID replays and receipt reads remain available during a pause.", + "description": "Read the account feedback preference, submit product feedback, or recover an account-scoped receipt. Free; seven new reports per account per UTC week.", + "notes": "Use the configured API key or login session. Keep the same submission ID and unchanged report after an uncertain result. No caching or payment fallback. Remove credentials and unnecessary personal context from reports. Without --submission-id, save one private submission before sending. Resume with nansen feedback resume. Use --new for a deliberate new report after confirmation or local erasure. Pending data expires after one calendar year; feedback commands enforce cleanup. Automatic IDs require POSIX private storage and native locking; otherwise supply an explicit ID. Backend validates shared report fields. Agents: send on your own only after an authenticated read shows the account preference is enabled and host controls permit it. Otherwise show the report and ask first; a direct user request permits that report. Use nansen feedback preferences --json for a fresh account preference before each new automatic report. If the read fails, show the report and ask. Host or local-deny controls still apply. Direct lasting choices to Superapp settings. Never enable the preference automatically. Submit is noninteractive and does not verify approval. New submissions may be paused before release or during an outage. A 503 with reason feedback_admissions_paused means this attempt accepted no new report and used no weekly allowance. Tell the user feedback is unavailable and stop; do not keep retrying or ask for approval again in the same session. Preserve the ID and unchanged file for a later retry. A 403 with reason feedback_not_enabled means this account is outside rollout. Stop automatic retries; approval and saved preferences do not override it. Preserve IDs and earlier uncertain outcomes. Accepted-ID replays and receipt reads remain available during a pause.", "subcommands": { "submit": { "description": "Submit a JSON report. Received means saved; human review may still be pending.", @@ -91,6 +91,28 @@ "examples": [ "nansen feedback forget --json" ] + }, + "preferences": { + "method": "GET", + "endpoint": "/api/v1/feedback/preferences", + "description": "Read the authenticated account preference fresh, with no credits or report quota. Does not change it.", + "options": { + "json": { + "type": "boolean", + "description": "Return the standard JSON preference or error envelope." + } + }, + "returns": [ + "automatic_reporting", + "configured", + "version", + "updated_at", + "can_manage" + ], + "examples": [ + "nansen feedback preferences", + "nansen feedback preferences --json" + ] } } },