diff --git a/schemas/desk/README.md b/schemas/desk/README.md index 3dae3e89..d8aacfd5 100644 --- a/schemas/desk/README.md +++ b/schemas/desk/README.md @@ -186,3 +186,43 @@ incomplete reservations. BE-2 tests. They use synthetic data, a fixed clock and a real SQLite-backed Miniflare DO, with manually driven alarms. This verifies local persistence, concurrency, failures, refresh and expiry; no deployed Worker or broker is used. + +## Delivery read mapping + +GET `/api/desk/alerts/delivery` uses the existing console session and the same +`notificationLog` adapter as `/api/notifications/send-log`. It performs no send, +collector, dispatch or business-state write. The v1 extension `source` is optional +for existing fixtures and consumers; new responses always include it. Existing +`isAlertDelivery` accepts the unchanged unknown channel and null summaries and +ignores additive fields. F1 must validate `source` before rendering its details; +its endpoint remains explicitly mock until that separate frontend change. + +The mapping below is frozen before implementing the adapter. + +| Existing log field | Desk projection | Meaning and absent-data rule | +|---|---|---| +| `read_status` | `source.read_status` | Preserve complete/partial/no_data/unknown/read_failed/not_connected; invalid becomes unknown. Complete is bounded read coverage, never delivery. | +| `read_reason_codes` | `source.read_reason_codes` | Fixed allowlist only, including coverage gaps, missing/expired receipts and sender skips. | +| `as_of` | `source.as_of` | Read time, nullable; never send/composition/ACK time. | +| `items[].source_run_id`, `source_run_attempt` | `source.items[]` same fields | Positive safe integers or null. Each row stays independent. | +| `items[].workflow_updated_at` | Same field | Nullable workflow metadata, never send time or freshness proof. | +| `items[].business_day`, `report_date` | Same fields | Nullable date labels; no inferred window boundaries or correlation. | +| `items[].status` | `source.items[].sender_status` | Fixed sender/receipt enum; includes receipt_missing, receipt_expired, sender_skipped, read_failed and unknown. Expiry is stale artifact evidence, not fresh channel health. | +| `items[].delivered`, `dry_run`, `test_message` | `sender_accepted`, `dry_run`, `test_message` | Preserve booleans or null; sender acceptance never proves receiver ACK. Partial reads keep individual results. | +| `items[].read_error_code` | Same field | Existing shared fixed error-code allowlist, otherwise null. Exceptions never appear. | +| No receiver receipt | `source.items[].receiver_ack = "unknown"` | Always unknown for this source, even for delivered or partially accepted senders. | +| No channel-health observation | `channel.status` unknown | read_failed for a failed read, source_unavailable for disconnected/no-data, otherwise not_supported. Never infer current health from historical sends. | +| No verified send time/outcome/recipient count | `last_delivery = null` | All required facts must be supplied by a future reviewed source before populating this object. | +| No schedule/composition time/target or run counts | `digest = null` | Do not use item count, workflow time or date labels as substitutes. | +| `status`, `unavailable_reason`, `coverage`, `next_cursor`, `run_limit`, `truncated`, `read_failure_count`, item `id`, `source_sha`, `source_url`, `channel`, `receipt_status`, `reason_code`, `sent_at` | Not copied | Coverage comes from read_status/reasons; source locators and free text are omitted; the current public receipt has no sent_at. | + +`generated_at` is projection time and `channel.name` is the adapter's fixed +`telegram` label. Unknown source shapes are never empty-success: malformed +collections become unknown with an invalid_notification_log reason. Reader +exceptions produce read_failed without provider details. There is no merge or +join across rows, runs, attempts, report dates or business days. There is no +new TTL or relaxed receipt window: receipt_expired is retained verbatim; +workflow age alone cannot establish staleness or current channel health. +A fresh HTTP 200 and ok envelope mean the read projection succeeded, not that +a notification arrived. Source integration and offline tests do not establish +active serving routes, deployed adoption or runtime delivery. diff --git a/schemas/desk/alert-delivery.json b/schemas/desk/alert-delivery.json index 9df9d909..76b08aec 100644 --- a/schemas/desk/alert-delivery.json +++ b/schemas/desk/alert-delivery.json @@ -52,6 +52,283 @@ }, {"type": "null"} ] + }, + "source": { + "type": "object", + "description": "Optional v1 sender-log evidence. Read coverage and sender acceptance never establish receiver acknowledgement.", + "properties": { + "read_status": { + "type": "string", + "enum": [ + "complete", + "partial", + "no_data", + "unknown", + "read_failed", + "not_connected" + ] + }, + "read_reason_codes": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "run_listing_coverage_unknown", + "run_listing_truncated", + "run_listing_incomplete", + "notification_receipts_read_failed", + "receipt_missing", + "receipt_expired", + "sender_skipped", + "no_completed_workflow_runs", + "notification_log_not_connected", + "invalid_notification_log" + ] + } + }, + "as_of": { + "anyOf": [ + { + "$ref": "https://schemas.example.invalid/qsl/desk/common.json#/$defs/Time" + }, + { + "type": "null" + } + ] + }, + "items": { + "type": "array", + "maxItems": 5, + "items": { + "type": "object", + "properties": { + "source_run_id": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "source_run_attempt": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "workflow_updated_at": { + "anyOf": [ + { + "$ref": "https://schemas.example.invalid/qsl/desk/common.json#/$defs/Time" + }, + { + "type": "null" + } + ] + }, + "business_day": { + "anyOf": [ + { + "$ref": "https://schemas.example.invalid/qsl/desk/common.json#/$defs/Date" + }, + { + "type": "null" + } + ] + }, + "report_date": { + "anyOf": [ + { + "$ref": "https://schemas.example.invalid/qsl/desk/common.json#/$defs/Date" + }, + { + "type": "null" + } + ] + }, + "sender_status": { + "type": "string", + "enum": [ + "route_check", + "dry_run", + "blocked_missing_secrets", + "delivered", + "send_failed", + "skipped_duplicate", + "blocked_resend_guard", + "business_digest_failed", + "skipped_empty", + "receipt_missing", + "receipt_expired", + "sender_skipped", + "read_failed", + "unknown" + ] + }, + "sender_accepted": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "dry_run": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "test_message": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "receiver_ack": { + "const": "unknown" + }, + "read_error_code": { + "anyOf": [ + { + "type": "string", + "enum": [ + "receipt_read_timeout", + "receipt_too_large", + "receipt_body_missing", + "invalid_receipt_archive", + "public_receipt_missing", + "invalid_public_receipt", + "run_listing_failed", + "run_listing_transport_failed", + "run_listing_http_other", + "run_listing_http_401", + "run_listing_http_403", + "run_listing_http_404", + "run_listing_http_429", + "run_listing_http_500", + "run_listing_http_502", + "run_listing_http_503", + "run_listing_http_504", + "artifact_listing_failed", + "artifact_listing_transport_failed", + "artifact_listing_http_other", + "artifact_listing_http_401", + "artifact_listing_http_403", + "artifact_listing_http_404", + "artifact_listing_http_429", + "artifact_listing_http_500", + "artifact_listing_http_502", + "artifact_listing_http_503", + "artifact_listing_http_504", + "artifact_archive_failed", + "artifact_archive_transport_failed", + "artifact_archive_http_other", + "artifact_archive_http_401", + "artifact_archive_http_403", + "artifact_archive_http_404", + "artifact_archive_http_429", + "artifact_archive_http_500", + "artifact_archive_http_502", + "artifact_archive_http_503", + "artifact_archive_http_504", + "artifact_download_failed", + "artifact_download_transport_failed", + "artifact_download_http_other", + "artifact_download_http_401", + "artifact_download_http_403", + "artifact_download_http_404", + "artifact_download_http_429", + "artifact_download_http_500", + "artifact_download_http_502", + "artifact_download_http_503", + "artifact_download_http_504", + "receipt_decode_failed", + "receipt_decode_transport_failed", + "receipt_decode_http_other", + "receipt_decode_http_401", + "receipt_decode_http_403", + "receipt_decode_http_404", + "receipt_decode_http_429", + "receipt_decode_http_500", + "receipt_decode_http_502", + "receipt_decode_http_503", + "receipt_decode_http_504", + "receipt_projection_failed", + "receipt_projection_transport_failed", + "receipt_projection_http_other", + "receipt_projection_http_401", + "receipt_projection_http_403", + "receipt_projection_http_404", + "receipt_projection_http_429", + "receipt_projection_http_500", + "receipt_projection_http_502", + "receipt_projection_http_503", + "receipt_projection_http_504", + "sender_jobs_failed", + "sender_jobs_transport_failed", + "sender_jobs_http_other", + "sender_jobs_http_401", + "sender_jobs_http_403", + "sender_jobs_http_404", + "sender_jobs_http_429", + "sender_jobs_http_500", + "sender_jobs_http_502", + "sender_jobs_http_503", + "sender_jobs_http_504" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "source_run_id", + "source_run_attempt", + "workflow_updated_at", + "business_day", + "report_date", + "sender_status", + "sender_accepted", + "dry_run", + "test_message", + "receiver_ack", + "read_error_code" + ], + "additionalProperties": false + } + } + }, + "required": [ + "read_status", + "read_reason_codes", + "as_of", + "items" + ], + "additionalProperties": false } }, "required": ["ok", "schema_version", "generated_at", "channel", "last_delivery", "digest"], diff --git a/tests/console_api_worker_validation.mjs b/tests/console_api_worker_validation.mjs index 598501df..470d40b2 100644 --- a/tests/console_api_worker_validation.mjs +++ b/tests/console_api_worker_validation.mjs @@ -280,6 +280,14 @@ try { assert.equal((await call(path, { cookie: null })).status, 401); assert.equal((await call(path, { method: "POST", body: {} })).status, 405); } + const deskDelivery = await call("/api/desk/alerts/delivery", { cookie: reader }); + assert.equal(deskDelivery.status, 200); + assert.equal(deskDelivery.body.schema_version, "qsl.desk.alert_delivery.v1"); + assert.equal(deskDelivery.body.source.read_status, "not_connected"); + assert.equal(deskDelivery.body.channel.status.status, "unknown"); + assert.equal(deskDelivery.body.last_delivery, null); + assert.equal((await call("/api/desk/alerts/delivery", { cookie: null })).status, 401); + assert.equal((await call("/api/desk/alerts/delivery", { method: "POST", body: {} })).status, 405); const controls = await call("/api/accounts/run-controls?account=synthetic-alpha"); validate("run_controls", controls.body); assert.equal(controls.body.actions.enable.enabled, false); diff --git a/tests/desk_contract_validation.mjs b/tests/desk_contract_validation.mjs index a74944f5..a2951129 100644 --- a/tests/desk_contract_validation.mjs +++ b/tests/desk_contract_validation.mjs @@ -175,3 +175,6 @@ for (const scenario of ["ok", "degraded"]) { } } console.log(`Desk contract validation passed: ${expected.length} responses, ${expectedFiles.length} fixtures, ${objectChecks} closed objects and negative contract checks`); + +// Exercise the real delivery route and validate its additive v1 evidence. +await import("./desk_delivery_read_validation.mjs"); diff --git a/tests/desk_delivery_read_validation.mjs b/tests/desk_delivery_read_validation.mjs new file mode 100644 index 00000000..d47c6d5c --- /dev/null +++ b/tests/desk_delivery_read_validation.mjs @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { consoleApiResponse } from "../web/strategy-switch-console/application/console_api/routes.js"; +const url = "https://console.invalid/api/desk/alerts/delivery"; +const deps = { json: (body, status = 200) => Response.json(body, { status }), + readSession: async () => ({ allowed: true }), + accountOptions: () => { throw Error("must not read account state"); }, + command: () => { throw Error("must not write business state"); }, + notificationReceipts: async () => ({ read_status: "partial", as_of: "2030-01-07T12:00:00Z", + read_reason_codes: ["receipt_missing"], items: [ + { source_run_id: 11, source_run_attempt: 1, status: "delivered", delivered: true, dry_run: false, test_message: false, business_day: "2030-01-06" }, + { source_run_id: 12, source_run_attempt: 1, status: "receipt_missing", business_day: "2030-01-07" }, + ] }) }; +const response = await consoleApiResponse(new Request(url), {}, deps); +assert.equal(response.status, 200, "authenticated Desk delivery read must use the existing receipt adapter"); +const body = await response.json(); +assert.equal(body.source.read_status, "partial"); +assert.equal(body.channel.status.status, "unknown"); +assert.equal(body.last_delivery, null); +assert.equal(body.digest, null); +assert.deepEqual(body.source.items.map(row => row.receiver_ack), ["unknown", "unknown"]); +assert.deepEqual(body.source.items.map(row => row.source_run_id), [11, 12]); +assert.deepEqual(body.source.items.map(row => row.business_day), ["2030-01-06", "2030-01-07"]); +assert.equal(body.source.items[0].sender_accepted, true); +assert.equal(body.source.items[1].sender_status, "receipt_missing"); +console.log("Desk authenticated read regression passed"); + +// The new route must deny unauthenticated reads before touching a source. +let reads = 0; +assert.equal((await consoleApiResponse(new Request(url), {}, { ...deps, + readSession: async () => null, notificationReceipts: () => { reads++; } })).status, 401); +assert.equal(reads, 0); +assert.equal((await consoleApiResponse(new Request(url, { method: "POST" }), {}, deps)).status, 405); + +const { deskDelivery } = await import("../web/strategy-switch-console/application/console_api/desk_delivery.js"); +const { createRequire } = await import("node:module"); +const { readFileSync } = await import("node:fs"); +const require = createRequire(new URL("../web/strategy-switch-console/package.json", import.meta.url)); +const Ajv = require("ajv/dist/2020").default; +const ajv = new Ajv({ strict: true, strictTypes: false }); +require("ajv-formats")(ajv); +const common = JSON.parse(readFileSync(new URL("../schemas/desk/common.json", import.meta.url))); +const schema = JSON.parse(readFileSync(new URL("../schemas/desk/alert-delivery.json", import.meta.url))); +ajv.addSchema(common); +const check = ajv.compile(schema); +const now = () => Date.parse("2030-01-07T12:00:00Z"); +const project = source => deskDelivery({}, { notificationReceipts: async () => source }, now); +const validate = value => assert.ok(check(value), ajv.errorsText(check.errors)); +validate(body); +for (const [read_status, reason, sender_status] of [ + ["partial", "receipt_missing", "receipt_missing"], + ["partial", "receipt_expired", "receipt_expired"], + ["partial", "sender_skipped", "sender_skipped"], + ["read_failed", "notification_receipts_read_failed", "read_failed"], + ["unknown", "run_listing_coverage_unknown", "unknown"], + ["complete", null, "delivered"], +]) { + const result = await project({ read_status, read_reason_codes: reason ? [reason] : [], as_of: null, + items: [{ status: sender_status, delivered: true, workflow_updated_at: "2000-01-01T00:00:00Z" }] }); + validate(result); + assert.equal(result.source.read_status, read_status); + assert.equal(result.source.items[0].sender_status, sender_status); + assert.equal(result.source.items[0].receiver_ack, "unknown"); + assert.equal(result.source.items[0].workflow_updated_at, "2000-01-01T00:00:00Z"); + assert.equal(result.last_delivery, null, "old workflow metadata never becomes a current send"); + assert.equal(result.channel.status.status, "unknown"); +} +for (const read_status of ["no_data", "not_connected"]) { + const result = await project({ read_status, read_reason_codes: [], items: [] }); + validate(result); + assert.equal(result.source.read_status, read_status); + assert.equal(result.channel.status.reason, "source_unavailable"); +} +const thrown = await deskDelivery({}, { notificationReceipts: () => { throw Error("FAKE_PRIVATE_PROVIDER_DETAIL"); } }, now); +validate(thrown); +assert.equal(thrown.source.read_status, "read_failed"); +assert.equal(thrown.channel.status.reason, "read_failed"); +assert.doesNotMatch(JSON.stringify(thrown), /FAKE_PRIVATE/); +const fallback = await deskDelivery({}, {}, now); +validate(fallback); +assert.equal(fallback.source.read_status, "not_connected"); +for (const malformed of [null, {}, { read_status: "complete", read_reason_codes: [], items: null }, + { read_status: "complete", read_reason_codes: [], items: [null] }, + { read_status: "complete", read_reason_codes: [], items: Array(6).fill({}) }]) { + const result = await project(malformed); + validate(result); + assert.equal(result.source.read_status, "unknown"); + assert.deepEqual(result.source.read_reason_codes, ["invalid_notification_log"]); +} +const unsafe = await project({ read_status: "partial", read_reason_codes: ["FAKE_PRIVATE_PROVIDER_DETAIL", "receipt_missing"], + as_of: "2030-02-30T00:00:00Z", secret: "FAKE_PRIVATE_PROVIDER_DETAIL", items: [{ + source_run_id: -1, source_run_attempt: 0, status: "FAKE_PRIVATE_PROVIDER_DETAIL", delivered: "true", + business_day: "2030-02-30", report_date: "FAKE_PRIVATE_PROVIDER_DETAIL", workflow_updated_at: "yesterday", + read_error_code: "FAKE_PRIVATE_PROVIDER_DETAIL", source_url: "https://fake.invalid/private", receiver_ack: "accepted", + }] }); +validate(unsafe); +assert.doesNotMatch(JSON.stringify(unsafe), /FAKE_PRIVATE|fake.invalid|yesterday/); +assert.deepEqual(unsafe.source.read_reason_codes, ["receipt_missing"]); +for (const key of ["source_run_id", "source_run_attempt", "sender_accepted", "business_day", "report_date", "workflow_updated_at", "read_error_code"]) + assert.equal(unsafe.source.items[0][key], null, key); +assert.equal(unsafe.source.items[0].sender_status, "unknown"); + +// Never join counts, ACKs or outcomes from another attempt, run or window. +const rows = [ + { source_run_id: 21, source_run_attempt: 1, business_day: "2030-01-06", report_date: "2030-01-07", status: "delivered", delivered: true }, + { source_run_id: 21, source_run_attempt: 2, business_day: "2030-01-07", report_date: "2030-01-08", status: "send_failed", delivered: false }, + { source_run_id: 22, source_run_attempt: 1, business_day: "2030-01-06", status: "receipt_expired" }, +]; +const source = { read_status: "partial", read_reason_codes: ["receipt_expired"], items: rows }; +const snapshot = structuredClone(source); +const concurrent = await Promise.all(Array.from({ length: 8 }, () => project(source))); +assert.deepEqual(source, snapshot, "projection never mutates shared source or persisted cache"); +for (const result of concurrent) { + validate(result); + assert.deepEqual(result.source.items.map(row => [row.source_run_id, row.source_run_attempt, row.business_day]), + [[21, 1, "2030-01-06"], [21, 2, "2030-01-07"], [22, 1, "2030-01-06"]]); + assert.deepEqual(result.source.items.map(row => row.sender_accepted), [true, false, null]); + assert.ok(result.source.items.every(row => row.receiver_ack === "unknown")); + assert.equal(result.digest, null); + assert.equal(result.last_delivery, null); +} +let current = source; +const freshDeps = { notificationReceipts: async () => current }; +await deskDelivery({}, freshDeps, now); +current = { read_status: "read_failed", read_reason_codes: ["notification_receipts_read_failed"], items: [] }; +assert.equal((await deskDelivery({}, freshDeps, now)).source.read_status, "read_failed", "no retained healthy snapshot"); +assert.equal((await import("../web/strategy-switch-console/application/console_api/desk_delivery.js?restart")).deskDelivery instanceof Function, true); +console.log("Desk delivery schema, auth, unknowns, redaction, correlation and concurrent read checks passed"); diff --git a/web/strategy-switch-console/application/console_api/desk_delivery.js b/web/strategy-switch-console/application/console_api/desk_delivery.js new file mode 100644 index 00000000..33cb6169 --- /dev/null +++ b/web/strategy-switch-console/application/console_api/desk_delivery.js @@ -0,0 +1,50 @@ +/** Bounded sender-log projection. No send, writes, joins or receiver claims. */ +import { notificationLog } from "./operations.js"; +import { RECEIPT_READ_ERROR_CODES } from "./notification_receipts.js"; + +const READ_STATUSES = ["complete", "partial", "no_data", "unknown", "read_failed", "not_connected"]; +const REASONS = ["run_listing_coverage_unknown", "run_listing_truncated", "run_listing_incomplete", + "notification_receipts_read_failed", "receipt_missing", "receipt_expired", "sender_skipped", + "no_completed_workflow_runs", "notification_log_not_connected", "invalid_notification_log"]; +const SENDER_STATUSES = ["route_check", "dry_run", "blocked_missing_secrets", "delivered", "send_failed", + "skipped_duplicate", "blocked_resend_guard", "business_digest_failed", "skipped_empty", + "receipt_missing", "receipt_expired", "sender_skipped", "read_failed", "unknown"]; +const timestamp = value => { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value)) return null; + const parsed = new Date(value); + return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 19) === value.slice(0, 19) ? value : null; +}; +const date = value => typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) + && timestamp(`${value}T00:00:00Z`) ? value : null; +const bool = value => typeof value === "boolean" ? value : null; +const id = value => Number.isSafeInteger(value) && value > 0 ? value : null; +const object = value => value !== null && typeof value === "object" && !Array.isArray(value); + +export async function deskDelivery(env, deps, now = Date.now) { + let log; + try { log = await notificationLog(env, deps); } + catch { log = { read_status: "read_failed", read_reason_codes: ["notification_receipts_read_failed"], items: [] }; } + const valid = object(log) && READ_STATUSES.includes(log.read_status) + && Array.isArray(log.items) && log.items.length <= 5 && log.items.every(object) + && Array.isArray(log.read_reason_codes); + const readStatus = valid ? log.read_status : "unknown"; + const reason = readStatus === "read_failed" ? "read_failed" + : ["not_connected", "no_data"].includes(readStatus) ? "source_unavailable" : "not_supported"; + return { + ok: true, schema_version: "qsl.desk.alert_delivery.v1", generated_at: new Date(now()).toISOString(), + channel: { name: "telegram", status: { status: "unknown", reason } }, + last_delivery: null, digest: null, + source: { + read_status: readStatus, + read_reason_codes: valid ? [...new Set(log.read_reason_codes.filter(code => REASONS.includes(code)))] : ["invalid_notification_log"], + as_of: valid ? timestamp(log.as_of) : null, + items: valid ? log.items.map(item => ({ + source_run_id: id(item.source_run_id), source_run_attempt: id(item.source_run_attempt), + workflow_updated_at: timestamp(item.workflow_updated_at), business_day: date(item.business_day), report_date: date(item.report_date), + sender_status: SENDER_STATUSES.includes(item.status) ? item.status : "unknown", + sender_accepted: bool(item.delivered), dry_run: bool(item.dry_run), test_message: bool(item.test_message), + receiver_ack: "unknown", read_error_code: RECEIPT_READ_ERROR_CODES.includes(item.read_error_code) ? item.read_error_code : null, + })) : [], + }, + }; +} diff --git a/web/strategy-switch-console/application/console_api/routes.js b/web/strategy-switch-console/application/console_api/routes.js index f6c7cb17..c52f4bab 100644 --- a/web/strategy-switch-console/application/console_api/routes.js +++ b/web/strategy-switch-console/application/console_api/routes.js @@ -7,6 +7,7 @@ import { } from "./presentation.js"; import { todaySummary, strategySummary, notificationLog } from "./operations.js"; import { inbox } from "./inbox.js"; +import { deskDelivery } from "./desk_delivery.js"; export { inbox } from "./inbox.js"; export async function consoleApiResponse(request, env, deps) { @@ -63,6 +64,7 @@ export async function consoleApiResponse(request, env, deps) { coverage: "durable_human_decision_records", }); } + if (path === "/api/desk/alerts/delivery") return json(await deskDelivery(env, deps)); if (path === "/api/notifications/send-log") return json(await notificationLog(env, deps)); const config = await deps.accountOptions(env); const accounts = Object.entries(config.options || {}).flatMap( diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index 73704afc..3804368c 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -847,7 +847,7 @@ export default { wallet: (req, e) => binanceWalletHistoryResponse(req, e, new URL(req.url)), benchmarks: marketBenchmarkResponse, }); } - if (["/api/decisions-inbox", "/api/owner-decisions/history", "/api/accounts", "/api/account-settings/diff", "/api/account-settings/history", "/api/account-settings/draft", "/api/runtime/today", "/api/strategies/summary", "/api/notifications/send-log", "/api/accounts/run-controls"].includes(url.pathname)) { + if (["/api/decisions-inbox", "/api/owner-decisions/history", "/api/accounts", "/api/account-settings/diff", "/api/account-settings/history", "/api/account-settings/draft", "/api/runtime/today", "/api/strategies/summary", "/api/notifications/send-log", "/api/desk/alerts/delivery", "/api/accounts/run-controls"].includes(url.pathname)) { // The today projection invokes the same daily reader once per account. // Share only this request's authenticated session and binding inputs. let todaySession, todayOptions, todayRegistered;