From 1006f56d674ba16bb67bc891c94586b672bbc873 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:20:27 +0530 Subject: [PATCH 01/10] fix(gst): acquire filed GSTR-1 artifacts from the surfaces the portal offers The filed GSTR-1 detail route can carry the download control itself, labelled DOWNLOAD FILED (PDF), with no View Summary control on the page. Navigating away from a page that already offered the download left the flow waiting until its step limit. Observer signals now read their control patterns from the return descriptors instead of restating them, so a label the descriptor learns about cannot stay unrecognised in the other layer. A live NIL GSTR-1 summary envelope is smaller than the minimum byte floor the validator applied, so a valid artifact was rejected as too small. The floor is removed and rejections name their own shape-based reason. --- .../filed-returns-download-trigger.ts | 69 ++++++++++++++++++- .../filed-returns-single-period-flow.ts | 3 +- src/background/gstr2b-artifact-acquisition.ts | 5 +- src/connectors/gst/artifact-source.ts | 41 ++++++++--- src/connectors/gst/artifact-validation.ts | 49 ++++++++++++- .../filed-returns-acquisition-diagnostics.ts | 41 +++++++++++ .../gst/filed-returns-detail-navigation.ts | 4 ++ .../gst/filed-returns-durable-signals.ts | 41 +++++++++-- .../gst/filed-returns-observer-signals.ts | 14 +++- .../gst/filed-returns-return-descriptors.ts | 6 +- .../gst/portal-artifact-endpoints.ts | 26 ++++++- 11 files changed, 273 insertions(+), 26 deletions(-) create mode 100644 src/connectors/gst/filed-returns-acquisition-diagnostics.ts diff --git a/src/background/filed-returns-download-trigger.ts b/src/background/filed-returns-download-trigger.ts index a13862ba..890a58f4 100644 --- a/src/background/filed-returns-download-trigger.ts +++ b/src/background/filed-returns-download-trigger.ts @@ -43,6 +43,13 @@ import { type FlowStepResponse = Extract; +// The portal's own declined-artifact answers. Only these are adopted from a post-click inspection; +// any other page state leaves the original acquisition failure standing with its reason intact. +const DECLINED_ARTIFACT_SIGNALS = new Set([ + "filed-gstr1-excel-no-details-available", + "filed-gstr2b-not-generated", +]); + async function persistSingleArtifactRecoveryIntent( scope: FiledReturnsDownloadScope, artifactType: FiledReturnsConcreteArtifactType, @@ -686,7 +693,14 @@ async function triggerPageGeneratedSinglePeriodArtifact( : {}), }, } - : { + : ((await postClickBlockedStep({ + artifactType, + deps, + requestId, + returnType, + scope, + tabId, + })) ?? { ok: true, flowStep: { connectorId: "gst", @@ -699,7 +713,7 @@ async function triggerPageGeneratedSinglePeriodArtifact( ], safeMessage: acquired.safeMessage ?? artifactFailureMessageForDelivery(acquired.reason), }, - }; + }); } finally { if (tracksBrowserDownload && !retainCheckpointForRecovery) { await clearArtifactAcquisitionCheckpoint(checkpointTarget, requestId); @@ -707,6 +721,57 @@ async function triggerPageGeneratedSinglePeriodArtifact( } } +// The portal answers a filed GSTR-1 e-invoice Excel request with an information dialog when the +// taxpayer has no e-invoices to report. That is an answer, not a failure: there is nothing to +// download and retrying cannot change it. +// +// The content script has always been able to recognise that dialog, and the ledger has always +// known how to record the artifact as unavailable and carry the run on. Nothing sent the message +// between them, so the recognition never ran, and the run stopped on a generic failure whose only +// offered remedy was to retry something that cannot succeed. +// +// This asks, and only when an acquisition has already failed. It cannot turn a failure into a +// download: the step it returns is still `blocked`, and completion still requires correlated +// download evidence. +async function postClickBlockedStep({ + artifactType, + deps, + requestId, + returnType, + scope, + tabId, +}: { + artifactType: FiledReturnsConcreteArtifactType; + deps: FiledReturnsFlowMessagingDeps; + requestId: string; + returnType: "GSTR-1" | "GSTR-2B"; + scope: FiledReturnsDownloadScope; + tabId: number; +}): Promise { + const declinable = + (returnType === "GSTR-1" && artifactType === "EXCEL") || returnType === "GSTR-2B"; + if (!declinable) return null; + const response = normaliseContentScriptMessageResponse( + await deps.sendMessageToTabWithInjection(tabId, { + type: "PACK_CONTENT_INSPECT_FILED_RETURN_POST_CLICK_V3", + payload: { + actionId: requestId, + artifactType, + financialYear: scope.financialYear, + period: scope.period, + returnType, + }, + }), + "PACK_CONTENT_INSPECT_FILED_RETURN_POST_CLICK_V3", + ); + if (!response.ok || !("flowStep" in response)) return null; + // Only the recognised no-details answer is adopted. Any other post-click state leaves the + // original acquisition failure standing, reason intact. + return response.flowStep.safeSignals.some((signal) => DECLINED_ARTIFACT_SIGNALS.has(signal)) + ? { ok: true, flowStep: response.flowStep } + : null; +} + async function deliverValidatedArtifact({ artifactType, base64, diff --git a/src/background/filed-returns-single-period-flow.ts b/src/background/filed-returns-single-period-flow.ts index 4379aa66..3604df03 100644 --- a/src/background/filed-returns-single-period-flow.ts +++ b/src/background/filed-returns-single-period-flow.ts @@ -50,6 +50,7 @@ import { type Gstr1PeriodMismatchRecovery, type ReturnTypeMismatchRecovery, } from "./filed-returns-gstr1-period-mismatch-recovery"; +import type { ReturnsDashboardAnchorFailureReason } from "../connectors/gst/filed-returns-durable-signals"; // The deadline is the product bound. This only stops a broken zero-delay response loop. const ZERO_DELAY_RUNAWAY_STEP_LIMIT = 10_000; @@ -307,7 +308,7 @@ async function blockedWrongOriginResponse( scope: FiledReturnsDownloadScope, deps: FiledReturnsFlowRunnerDeps, shouldPersistSinglePeriodSummary: boolean, - reason: "ambiguous" | "not-found" | "timeout" | "unavailable", + reason: ReturnsDashboardAnchorFailureReason, ): Promise { return withPersistedSinglePeriodSummary( scope, diff --git a/src/background/gstr2b-artifact-acquisition.ts b/src/background/gstr2b-artifact-acquisition.ts index 09336eca..68697132 100644 --- a/src/background/gstr2b-artifact-acquisition.ts +++ b/src/background/gstr2b-artifact-acquisition.ts @@ -38,7 +38,10 @@ export async function acquirePageGeneratedArtifact(input: { controlSelector: `[data-pack-artifact-request="${input.requestId}"]`, ...(input.returnType === "GSTR-2B" ? { - expectedControlText: artifact.controlText, + // Read straight from the GSTR-2B descriptor: only that return type pins the + // control text here, and GSTR-1 has more than one label for the same artifact. + expectedControlText: + GSTR2B_PAGE_GENERATED_ARTIFACTS[input.artifactType].controlText, expectedPeriodTexts: acceptedFiledReturnsMonthTexts(input.period), } : {}), diff --git a/src/connectors/gst/artifact-source.ts b/src/connectors/gst/artifact-source.ts index f2ab2da6..bf65eb91 100644 --- a/src/connectors/gst/artifact-source.ts +++ b/src/connectors/gst/artifact-source.ts @@ -1,8 +1,7 @@ -import { validateArtifactBytes } from "./artifact-validation"; +import { describeJsonArtifactRejection, validateArtifactBytes } from "./artifact-validation"; import { GSTR1_DETAIL_PATH, GSTR1_PAGE_GENERATED_ARTIFACTS, - GSTR1_SUMMARY_PATH, GSTR1_SUMMARY_PREFLIGHT_PATH, GSTR2B_JSON_PATH, GSTR2B_ORIGIN, @@ -224,14 +223,38 @@ async function acquireGstr1Artifact( const preflightBytes = new Uint8Array(await response.arrayBuffer()); if (isHtmlResponse(preflightBytes)) return failed(request, "preflight-failed"); const preflight = validateArtifactBytes(preflightBytes, "JSON", request.returnPeriod, "GSTR-1"); - if (!preflight.ok) return failed(request, preflight.reason); - const expectedPath = request.artifactType === "PDF" ? GSTR1_SUMMARY_PATH : GSTR1_DETAIL_PATH; - if (view.location.pathname !== expectedPath) - return failed(request, "wrong-page", ["target-period-verified"]); + if (!preflight.ok) { + return failed(request, preflight.reason, [ + "gstr1-summary-preflight-rejected", + ...describeJsonArtifactRejection(preflightBytes, request.returnPeriod, "GSTR-1"), + ]); + } const descriptor = GSTR1_PAGE_GENERATED_ARTIFACTS[request.artifactType]; - const controls = resolvePageArtifactControls(documentRef, descriptor.controlText); - if (controls.length !== 1 || !controls[0]) - return failed(request, "control-not-found", ["target-period-verified"]); + // The page decides which control to look for, because the same artifact is labelled differently + // on each surface that offers it. A page that is not one of those surfaces is refused. + const surface = descriptor.surfaces.find( + (candidate) => candidate.path === view.location.pathname, + ); + if (!surface) { + // Named symbolically rather than by path, so the reason is diagnosable without a portal URL + // reaching a signal, a log, or an issue. + return failed(request, "wrong-page", [ + "target-period-verified", + request.artifactType === "PDF" + ? "gstr1-pdf-expects-summary-page" + : "gstr1-excel-expects-detail-page", + view.location.pathname === GSTR1_DETAIL_PATH ? "gstr1-on-detail-page" : "gstr1-on-other-page", + ]); + } + const controls = resolvePageArtifactControls(documentRef, surface.controlText); + if (controls.length !== 1 || !controls[0]) { + // How many matched matters: none means the label is wrong for this page shape, several means + // the label is ambiguous and binding to one of them would be a guess. + return failed(request, "control-not-found", [ + "target-period-verified", + controls.length === 0 ? "gstr1-control-label-unmatched" : "gstr1-control-label-ambiguous", + ]); + } const pageTargetMismatchSignals = gstr1PageTargetMismatchSignals(controls[0], request); if (pageTargetMismatchSignals.length > 0) { return failed(request, "page-period-mismatch", [ diff --git a/src/connectors/gst/artifact-validation.ts b/src/connectors/gst/artifact-validation.ts index beaf7ac9..6c106f0f 100644 --- a/src/connectors/gst/artifact-validation.ts +++ b/src/connectors/gst/artifact-validation.ts @@ -11,7 +11,6 @@ export type ArtifactValidationResult = | { ok: false; reason: "empty" | "too-large" | "unexpected-content" | "target-period-mismatch" }; const MIN_PDF_BYTES = 1024; -const MIN_JSON_BYTES = 100; export const MAX_ARTIFACT_BYTES = 25 * 1024 * 1024; const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46, 0x2d]; const XLSX_MAGIC = [0x50, 0x4b]; @@ -65,7 +64,12 @@ export function validateArtifactBytes( mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }; } - if (bytes.byteLength < MIN_JSON_BYTES) return { ok: false, reason: "unexpected-content" }; + // No size floor. A filed return with nothing in it has a legitimately compact summary envelope, + // and a byte count cannot tell that apart from a truncated response -- captured live on + // 2026-09-10, where a valid April envelope under 100 bytes was refused before anything read it. + // Every case the floor was standing in for is caught below and caught better: an empty body is + // rejected above, a non-JSON body fails to parse, and a body too small to hold the envelope + // fails the envelope and period checks that follow. try { const parsed = JSON.parse(new TextDecoder().decode(bytes)) as unknown; const contract = filedReturnsJsonDocumentContract(returnType); @@ -83,6 +87,47 @@ export function validateArtifactBytes( } } +// Why a JSON artifact was refused, as signal fragments safe to show and to persist. +// +// `validateArtifactBytes` returns a category, and two different failures share the +// `unexpected-content` category: a body that is not the expected envelope, and one whose period +// field is absent. Diagnosing a live refusal from outside the browser needs the difference, and a +// guard that cannot say which condition fired costs a round trip every time it fires. +// +// Everything here is a shape fact -- byte count band, field presence, parse success. No field +// value is included, so nothing taxpayer-identifying can reach a signal. +export function describeJsonArtifactRejection( + bytes: Uint8Array, + expectedReturnPeriod: string, + returnType: FiledReturnsReturnType, +): string[] { + if (bytes.byteLength === 0) return ["json-body-empty"]; + // Report the size band and keep going. Stopping here says only that the body is small, which + // cannot distinguish a legitimately compact envelope from a truncated or unrelated response -- + // and that distinction is the whole question when a return has nothing in it. + const signals: string[] = []; + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(bytes)) as unknown; + } catch { + return [...signals, "json-parse-failed"]; + } + const contract = filedReturnsJsonDocumentContract(returnType); + if (contract.requiredStatus !== undefined) { + const statusMatches = isJsonObject(parsed) && parsed.status === contract.requiredStatus; + if (!statusMatches) return [...signals, "json-status-unexpected"]; + } + const document = jsonObjectAtPath(parsed, contract.envelopePath); + if (!document) return [...signals, "json-envelope-missing"]; + const actualPeriod = document[contract.returnPeriodKey]; + if (typeof actualPeriod !== "string") return [...signals, "json-period-field-missing"]; + // A small body that still satisfies the contract is the case the size floor gets wrong, and + // this signal is what says so. + return actualPeriod === expectedReturnPeriod + ? [...signals, "json-contract-satisfied"] + : [...signals, "json-period-mismatch"]; +} + function jsonObjectAtPath(input: unknown, path: readonly string[]): Record | null { let current = input; for (const segment of path) { diff --git a/src/connectors/gst/filed-returns-acquisition-diagnostics.ts b/src/connectors/gst/filed-returns-acquisition-diagnostics.ts new file mode 100644 index 00000000..26323236 --- /dev/null +++ b/src/connectors/gst/filed-returns-acquisition-diagnostics.ts @@ -0,0 +1,41 @@ +// The signals that say why an artifact acquisition was refused. +// +// A leaf module with no imports, because three places need the same list: the code that emits +// each signal, and the durable allowlist that decides whether a refusal can be persisted. +// +// That coupling is the point. An unregistered signal does not degrade gracefully -- it rejects the +// whole durable array, and the run then halts on non-canonical recovery metadata instead of +// recording the refusal the signal was describing. Emitting and persisting therefore read from one +// list rather than two that can drift, which is how the first version of these diagnostics broke +// the full-year run it was built to explain. +// +// Every entry is a Pack-owned constant. None carries portal text, a field value, or a path, so +// nothing portal-derived reaches durable state through them. +export const JSON_ARTIFACT_REJECTION_SIGNALS = [ + // No longer emitted: the size floor it described was removed once a live capture showed a valid + // sub-100-byte envelope. It stays registered because durable state written by an earlier build + // can still contain it, and an unregistered token rejects the whole array it travels in. + "json-body-under-minimum", + "json-body-empty", + "json-parse-failed", + "json-status-unexpected", + "json-envelope-missing", + "json-period-field-missing", + "json-period-mismatch", + "json-contract-satisfied", +] as const; + +export const GSTR1_ACQUISITION_DIAGNOSTIC_SIGNALS = [ + "gstr1-summary-preflight-rejected", + "gstr1-pdf-expects-summary-page", + "gstr1-excel-expects-detail-page", + "gstr1-on-detail-page", + "gstr1-on-other-page", + "gstr1-control-label-unmatched", + "gstr1-control-label-ambiguous", +] as const; + +export const ARTIFACT_ACQUISITION_DIAGNOSTIC_SIGNALS = [ + ...JSON_ARTIFACT_REJECTION_SIGNALS, + ...GSTR1_ACQUISITION_DIAGNOSTIC_SIGNALS, +] as const; diff --git a/src/connectors/gst/filed-returns-detail-navigation.ts b/src/connectors/gst/filed-returns-detail-navigation.ts index 13f4cc52..b08a842b 100644 --- a/src/connectors/gst/filed-returns-detail-navigation.ts +++ b/src/connectors/gst/filed-returns-detail-navigation.ts @@ -150,6 +150,10 @@ export function clickFiledGstr1SummaryForPdf( if (scope.returnType !== "GSTR-1") return null; if (!scopeIncludesPdfArtifact(scope)) return null; if (safeSignals.includes("download-pdf-gstr-1")) return null; + // The detail route can carry the filed-PDF control itself, captured 2026-09-10 as + // `DOWNLOAD FILED (PDF)` with no View Summary control anywhere on the page. Navigating away from + // a page that already offers the download is what left this flow waiting until its step limit. + if (safeSignals.includes("download-filed-gstr-1")) return null; if (isGstr1SummaryRoute(documentRef)) return null; const summaryControl = findGstr1ViewSummaryControl(documentRef); diff --git a/src/connectors/gst/filed-returns-durable-signals.ts b/src/connectors/gst/filed-returns-durable-signals.ts index 08dca878..ea5f0bdc 100644 --- a/src/connectors/gst/filed-returns-durable-signals.ts +++ b/src/connectors/gst/filed-returns-durable-signals.ts @@ -25,6 +25,21 @@ import { FILED_RETURNS_TARGET_REVIEW_CLEAR_FAILURE_STAGES, filedReturnsTargetReviewClearFailureSignal, } from "./filed-returns-target-review-clear"; +import { ARTIFACT_ACQUISITION_DIAGNOSTIC_SIGNALS } from "./filed-returns-acquisition-diagnostics"; + +// The reasons and methods those templates can produce. Enumerated here beside the allowlist so a +// new one is a compile-time change in one place rather than a signal that silently fails to +// persist. +export const RETURNS_DASHBOARD_ANCHOR_FAILURE_REASONS = [ + "ambiguous", + "not-found", + "timeout", + "unavailable", +] as const; +export const PORTAL_BLOB_SHIM_SUPPRESSION_METHODS = ["dispatchEvent", "click"] as const; + +export type ReturnsDashboardAnchorFailureReason = + (typeof RETURNS_DASHBOARD_ANCHOR_FAILURE_REASONS)[number]; const MAX_DURABLE_SIGNAL_COUNT = 32; @@ -133,6 +148,7 @@ const EXACT_DURABLE_SIGNALS = new Set([ "filed-gstr1-download-trigger-ambiguous", "filed-gstr1-excel-control-pending", "filed-gstr1-excel-no-details-available", + "filed-gstr2b-not-generated", GSTR1_PERIOD_MISMATCH_RECOVERY_STOPPED_SIGNAL, "filed-gstr1-result-view-auto-attempt-failed", "filed-gstr1-result-view-auto-clicked", @@ -387,10 +403,7 @@ const EXACT_DURABLE_SIGNALS = new Set([ "return-dashboard-after-returns-menu", "return-dashboard-after-services-menu", "return-dashboard-initial-scan", - "returns-dashboard-anchor-ambiguous", - "returns-dashboard-anchor-not-found", - "returns-dashboard-anchor-timeout", - "returns-dashboard-anchor-unavailable", + ...RETURNS_DASHBOARD_ANCHOR_FAILURE_REASONS.map((reason) => `returns-dashboard-anchor-${reason}`), "return-filing-period-left-unselected", "return-type-selected", "safe-dialog-dismissed", @@ -635,6 +648,7 @@ const SCOPED_RETURN_SIGNAL_SUFFIXES = new Set([ const ARTIFACT_FAILURE_SIGNALS = new Set([ "artifact-acquisition-failed", "artifact-filed-gstr1-excel-no-details-available", + "artifact-filed-gstr2b-not-generated", // Artifact-acquisition recovery exists to survive service-worker death, so // its outcomes must be persistable. Without these the blocked summary that // routes an interrupted acquisition to review is rejected by @@ -662,6 +676,20 @@ const ARTIFACT_FAILURE_SIGNALS = new Set([ "artifact-acquisition-download-completed-unpersisted", "artifact-acquisition-download-reconciled", ...Object.keys(ARTIFACT_FAILURE_MESSAGES).map((reason) => `artifact-${reason}`), + // Why an acquisition was refused, not just that it was. Spread from the same list the emitters + // read, so a new diagnostic cannot be added without becoming persistable -- an unregistered one + // rejects the whole array and halts the run on non-canonical recovery metadata. + ...ARTIFACT_ACQUISITION_DIAGNOSTIC_SIGNALS, + // Built by template rather than written as literals, which is why a scan for unregistered + // signal strings never found them. Each accompanies a blocked step that has to persist, and an + // unregistered one rejects the whole array -- the run then halts on non-canonical recovery + // metadata rather than on the navigation problem it was describing. + ...PORTAL_BLOB_SHIM_SUPPRESSION_METHODS.map( + (method) => `portal-blob-shim-suppressed-via-${method}`, + ), + // Passed as the step-limit signal for the GSTR-1 Excel detail wait. Its `gstr1` prefix puts it + // in the navigation rejection category, so an unregistered one rejects the whole array. + "gstr1-excel-detail-step-limit-reached", ]); export function parseDurableFiledReturnsSignals(input: unknown): string[] | null { @@ -686,6 +714,11 @@ export function durableFiledReturnsSignalRejectionReason( // persisted or rendered. It distinguishes Pack-owned producer families during // live recovery without admitting portal-derived text into durable state. function durableUnknownSignalCategory(signal: string): DurableFiledReturnsSignalRejectionReason { + // The published reason stays a fixed projection -- naming the token in durable state or in the + // panel would admit arbitrary text into both. But a rejection nobody can attribute costs a build + // and a live run per attempt to locate, which is how three unregistered signals stayed hidden. + // The console is neither persisted nor rendered, so the token can be named there. + console.warn(`review-gate: unregistered durable signal rejected: ${signal}`); if (signal.startsWith("filed-return-detail-")) return "unknown-detail-identity"; if ( /^(?:artifact-|filed-gstr|page-|browser-download|full-fiscal-year-opfs|single-period-opfs)/.test( diff --git a/src/connectors/gst/filed-returns-observer-signals.ts b/src/connectors/gst/filed-returns-observer-signals.ts index 7b71ce26..f047d300 100644 --- a/src/connectors/gst/filed-returns-observer-signals.ts +++ b/src/connectors/gst/filed-returns-observer-signals.ts @@ -1,6 +1,7 @@ import { filedReturnScopedSignal } from "./filed-returns-return-descriptors"; import type { FiledReturnsReturnType } from "./filed-returns-return-types"; import type { FiledReturnsObservationHints } from "./filed-returns-observer-types"; +import { filedReturnDescriptor } from "./filed-returns-return-descriptors"; // Fixed classifier vocabulary only. These tokens carry no portal text or taxpayer // data and are shared by the observation and durable-recovery boundaries. @@ -86,11 +87,18 @@ function addReturnTextSignals(text: string, signals: string[]): void { } function addDownloadControlSignals(text: string, signals: string[]): void { - if (/download filed gstr[\s-]?3b/.test(text)) signals.push("download-filed-gstr-3b"); - if (/download filed gstr[\s-]?1\b/.test(text)) signals.push("download-filed-gstr-1"); + // Read the control patterns from the descriptors rather than restating them. This file used to + // carry its own copies, so a portal label the descriptor learned about stayed unrecognised here + // -- which is how `DOWNLOAD FILED (PDF)` was missed by both layers at once. + const gstr1 = filedReturnDescriptor("GSTR-1"); + if (filedReturnDescriptor("GSTR-3B").explicitDownloadPattern.test(text)) { + signals.push("download-filed-gstr-3b"); + } + if (gstr1.explicitDownloadPattern.test(text)) signals.push("download-filed-gstr-1"); if ( signals.includes("gstr-1-summary-route") && - (/\bdownload\s*\(?\s*pdf\s*\)?\b/.test(text) || /\bdownload\b.*\bsummary\b.*\bpdf\b/.test(text)) + ((gstr1.secondaryDownloadPattern?.test(text) ?? false) || + /\bdownload\b.*\bsummary\b.*\bpdf\b/.test(text)) ) { signals.push("download-pdf-gstr-1"); } diff --git a/src/connectors/gst/filed-returns-return-descriptors.ts b/src/connectors/gst/filed-returns-return-descriptors.ts index a34d7732..985f0443 100644 --- a/src/connectors/gst/filed-returns-return-descriptors.ts +++ b/src/connectors/gst/filed-returns-return-descriptors.ts @@ -31,7 +31,11 @@ const FILED_RETURN_MECHANICS: Record = { - PDF: { controlText: "DOWNLOAD SUMMARY (PDF)", expectedMime: "application/pdf" }, + PDF: { + surfaces: [ + { path: GSTR1_SUMMARY_PATH, controlText: "DOWNLOAD SUMMARY (PDF)" }, + { path: GSTR1_DETAIL_PATH, controlText: "DOWNLOAD FILED (PDF)" }, + ], + expectedMime: "application/pdf", + }, EXCEL: { - controlText: "DOWNLOAD DETAILS FROM E-INVOICES (EXCEL)", + surfaces: [ + { path: GSTR1_DETAIL_PATH, controlText: "DOWNLOAD DETAILS FROM E-INVOICES (EXCEL)" }, + ], expectedMime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }, }; From 83dd53f262d23d30253208058a7fb47ed220a8ce Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:20:46 +0530 Subject: [PATCH 02/10] fix(background): name the failing symbol when a background handler throws A handler failure rendered one generic sentence for every cause, so three live sessions were spent guessing which throw it was. The fingerprint appends the error name and the first non-extension stack symbol, which is enough to identify the throw from the panel without DevTools. Redaction is the point of the charset filter: only name-shaped characters survive, so no message, path, URL, or portal value can reach the panel through it. --- .../background-failure-fingerprint.ts | 30 +++++++++++++++++++ src/entrypoints/background.ts | 17 ++++++++--- 2 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 src/background/background-failure-fingerprint.ts diff --git a/src/background/background-failure-fingerprint.ts b/src/background/background-failure-fingerprint.ts new file mode 100644 index 00000000..a40e1a31 --- /dev/null +++ b/src/background/background-failure-fingerprint.ts @@ -0,0 +1,30 @@ +// Enough to name a thrown failure, and nothing that could carry portal text. +// +// An error's message is not safe to render: it can quote a page, a URL, or a field value. Its +// class name and the innermost stack symbol come from this bundle's own code, so they identify +// where a failure happened without repeating anything the portal said. Both are filtered to a +// strict symbol charset, so a thrown non-Error — or an error with a doctored name — cannot smuggle +// text through either. +// +// This exists because a discarded error cost three separate round trips to locate, each needing a +// build and a live authenticated run. The panel gets the fingerprint; the console gets the error. +const SYMBOL_CHARSET = /[^A-Za-z0-9_.:$-]/gu; + +function safeSymbol(value: string): string { + return value.replace(SYMBOL_CHARSET, "").slice(0, 60); +} + +export function backgroundFailureFingerprint(error: unknown): string { + if (!(error instanceof Error)) return "NonError"; + const name = safeSymbol(error.name || "Error"); + const frame = + typeof error.stack === "string" + ? (error.stack + .split("\n") + .slice(1) + .map((line) => /at\s+([^\s(]+)/u.exec(line)?.[1]) + .find((symbol) => symbol && !symbol.startsWith("chrome-extension")) ?? "") + : ""; + const symbol = safeSymbol(frame); + return symbol ? `${name} at ${symbol}` : name; +} diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts index 8d7c884d..4b9723f7 100644 --- a/src/entrypoints/background.ts +++ b/src/entrypoints/background.ts @@ -76,6 +76,7 @@ export { rememberGstTabIfSupported, sendMessageToTabWithInjection, } from "../background/gst-tab-context"; +import { backgroundFailureFingerprint } from "../background/background-failure-fingerprint"; const OFFICIAL_URL = "https://pack.complyeaze.com"; @@ -159,14 +160,22 @@ export default defineBackground(() => { void handleMessage(message, sender) .then((response) => sendResponse(response)) - .catch(() => + .catch((error: unknown) => { + // The reply stays a safe, fixed message -- it reaches the panel, and an arbitrary error + // string there could carry portal text. The console is neither rendered nor persisted, so + // the reason is named there instead of discarded. A handler that fails without saying why + // costs a build and a live run to locate, which is the whole reason this line exists. + console.error( + `Pack background handler failed for ${backgroundMessageSource(message)}:`, + error, + ); sendResponse({ ok: false, error: "BACKGROUND_MESSAGE_HANDLER_FAILED", - safeMessage: `Pack stopped while handling ${backgroundMessageSource(message)}. Try the action again.`, + safeMessage: `Pack stopped while handling ${backgroundMessageSource(message)}. Try the action again. (${backgroundFailureFingerprint(error)})`, safeSite: backgroundMessageHandlerSite(message), - } satisfies PackMessageResponse), - ); + } satisfies PackMessageResponse); + }); return true; }); }); From efc5ba96326d3f21d26499b4a8d1c70a22489a14 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:20:46 +0530 Subject: [PATCH 03/10] test(gst): pin the filed GSTR-1 acquisition surfaces and the handler fingerprint --- .../background-download-default.test.ts | 10 +- .../background-failure-fingerprint.test.ts | 32 ++++++ ...turns-download-trigger-acquisition.test.ts | 98 +++++++++++++++++ tests/background/gst-tab-selection.test.ts | 11 +- tests/connectors/artifact-source.test.ts | 6 +- tests/connectors/artifact-validation.test.ts | 100 ++++++++++++++++++ 6 files changed, 249 insertions(+), 8 deletions(-) create mode 100644 tests/background/background-failure-fingerprint.test.ts diff --git a/tests/background/background-download-default.test.ts b/tests/background/background-download-default.test.ts index 4a7e8d54..34c8bfc4 100644 --- a/tests/background/background-download-default.test.ts +++ b/tests/background/background-download-default.test.ts @@ -827,15 +827,21 @@ describe("background filed returns download defaults", () => { const response = await sendBackgroundMessage({ type: "PACK_GET_LAST_MANIFEST" }); - expect(response).toEqual({ + expect(response).toMatchObject({ ok: false, error: "BACKGROUND_MESSAGE_HANDLER_FAILED", - safeMessage: "Pack stopped while handling the local manifest request. Try the action again.", safeSite: "background-message-handler:last-manifest", }); if (response.ok) throw new Error("expected a safe background failure"); + // The reply names where it failed and appends a fingerprint of the throw. The fingerprint is + // the error class and the innermost symbol from Pack's own bundle -- never the error message, + // which is what could carry a portal URL or a local path. + expect(response.safeMessage).toContain( + "Pack stopped while handling the local manifest request. Try the action again.", + ); expect(response.safeMessage).not.toContain("portal URL"); expect(response.safeMessage).not.toContain("local path"); + expect(response.safeMessage).not.toContain("must not escape"); }); it("persists and returns a terminal GSTR-2B mismatch summary to the popup", async () => { diff --git a/tests/background/background-failure-fingerprint.test.ts b/tests/background/background-failure-fingerprint.test.ts new file mode 100644 index 00000000..8ec91495 --- /dev/null +++ b/tests/background/background-failure-fingerprint.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { backgroundFailureFingerprint } from "../../src/background/background-failure-fingerprint"; + +describe("background failure fingerprint", () => { + it("names the error class and the innermost symbol", () => { + const error = new TypeError("boom"); + error.stack = "TypeError: boom\n at parseDurableSummary (file.js:1:1)\n at next (f.js:2)"; + expect(backgroundFailureFingerprint(error)).toBe("TypeError at parseDurableSummary"); + }); + + it("never repeats the error message", () => { + const error = new Error("GSTIN 00AAAAA0000A1Z0 rejected at https://portal.example/x"); + error.stack = `${error.message}\n at readLedger (file.js:1:1)`; + const fingerprint = backgroundFailureFingerprint(error); + expect(fingerprint).not.toContain("GSTIN"); + expect(fingerprint).not.toContain("0AAAAA0000A1Z0"); + expect(fingerprint).not.toContain("https"); + }); + + it("cannot be smuggled through by a thrown non-Error", () => { + expect(backgroundFailureFingerprint("GSTIN 00AAAAA0000A1Z0")).toBe("NonError"); + expect(backgroundFailureFingerprint({ name: "x", stack: "at leak" })).toBe("NonError"); + }); + + it("strips anything outside the symbol charset", () => { + const error = new Error("x"); + error.name = "Bad Name/With Spaces"; + error.stack = "e\n at some symbol with spaces (f.js:1)"; + // Only the literal " at " separator may contain spaces; both symbols are charset-filtered. + expect(backgroundFailureFingerprint(error)).toBe("BadNameWithSpaces at some"); + }); +}); diff --git a/tests/background/filed-returns-download-trigger-acquisition.test.ts b/tests/background/filed-returns-download-trigger-acquisition.test.ts index 7e1d712e..7d55d20f 100644 --- a/tests/background/filed-returns-download-trigger-acquisition.test.ts +++ b/tests/background/filed-returns-download-trigger-acquisition.test.ts @@ -1030,3 +1030,101 @@ function acquiredJson(): PackMessageResponse { }, }; } + +describe("filed GSTR-1 e-invoice Excel with no details to download", () => { + // The portal answers this request with an information dialog when the taxpayer reports no + // e-invoices. The content script has always recognised it and the ledger has always known how to + // record the artifact as unavailable -- but nothing sent the message between them, so the + // recognition never ran and the run stalled offering a retry that cannot succeed. + // + // The message contract was tested; that it is ever sent was not. This pins the send, on the + // path that matters: the control was armed and clicked, and the click produced no download. + function messagingDeps(postClick: PackMessageResponse) { + return vi.fn(async (_tabId: number, message: { type: string }) => + message.type === "PACK_CONTENT_INSPECT_FILED_RETURN_POST_CLICK_V3" + ? postClick + : ({ + ok: true, + artifact: { + ok: true, + state: "ready", + requestId: "synthetic-request", + safeSignals: ["target-period-verified"], + }, + } as PackMessageResponse), + ); + } + + const noDetailsStep = { + ok: true, + flowStep: { + connectorId: "gst", + scopeId: "filed-returns:gstr-1", + state: "blocked", + safeSignals: ["filed-gstr1-excel-no-details-available"], + safeMessage: "The GST Portal reported that no e-invoice details are available.", + }, + } as PackMessageResponse; + + function armClickWithoutDownload() { + captureMocks.acquirePageGeneratedArtifact.mockResolvedValueOnce({ + ok: false as const, + reason: "generation-timeout", + safeSignals: [] as string[], + } as never); + } + + it("asks the page why, and adopts the portal's no-details answer", async () => { + armClickWithoutDownload(); + const sendMessageToTabWithInjection = messagingDeps(noDetailsStep); + + const response = await triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType: "EXCEL", + deps: { sendMessageToTabWithInjection, storageKeys: {} }, + scope: { financialYear: "2025-26", period: "April", returnType: "GSTR-1" }, + tabId: 17, + }); + + expect(sendMessageToTabWithInjection).toHaveBeenCalledWith( + 17, + expect.objectContaining({ type: "PACK_CONTENT_INSPECT_FILED_RETURN_POST_CLICK_V3" }), + ); + expect(response).toMatchObject({ + flowStep: { + safeSignals: expect.arrayContaining(["filed-gstr1-excel-no-details-available"]), + // Still blocked. This records an absence; it never turns a click into a download. + state: "blocked", + }, + }); + }); + + it("keeps the original failure when the page reports no recognised block", async () => { + armClickWithoutDownload(); + const sendMessageToTabWithInjection = messagingDeps({ + ok: true, + flowStep: { + connectorId: "gst", + scopeId: "filed-returns:gstr-1", + state: "candidate-not-found", + safeSignals: ["filed-return-post-click-blocked-state-not-found"], + safeMessage: "Pack did not find a recognized post-click portal block.", + }, + } as PackMessageResponse); + + const response = await triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType: "EXCEL", + deps: { sendMessageToTabWithInjection, storageKeys: {} }, + scope: { financialYear: "2025-26", period: "April", returnType: "GSTR-1" }, + tabId: 17, + }); + + expect(response).toMatchObject({ + flowStep: { + safeSignals: expect.arrayContaining(["artifact-acquisition-failed"]), + state: "blocked", + }, + }); + }); +}); diff --git a/tests/background/gst-tab-selection.test.ts b/tests/background/gst-tab-selection.test.ts index 21b9a2f4..6773a846 100644 --- a/tests/background/gst-tab-selection.test.ts +++ b/tests/background/gst-tab-selection.test.ts @@ -449,14 +449,19 @@ describe("Pack GST tab selection", () => { listener?.({ type: "PACK_GET_CONTEXT" }, { id: "pack-test-extension" }, resolve); }); - expect(response).toEqual({ + expect(response).toMatchObject({ ok: false, error: "BACKGROUND_MESSAGE_HANDLER_FAILED", - safeMessage: - "Pack stopped while handling the current GST Portal state. Try the action again.", safeSite: "background-message-handler:gst-context", }); + const { safeMessage } = response as { safeMessage: string }; + expect(safeMessage).toContain( + "Pack stopped while handling the current GST Portal state. Try the action again.", + ); + // The rejected detail is the error's message, and it never reaches the reply. The appended + // fingerprint carries only the error class and a symbol from Pack's own bundle. expect(JSON.stringify(response)).not.toContain("sensitive portal detail"); + expect(safeMessage).not.toContain("must not be exposed"); }); it("infers a GSTR-2B observation from the active summary route instead of returning stale wrong-page", async () => { diff --git a/tests/connectors/artifact-source.test.ts b/tests/connectors/artifact-source.test.ts index d3c648e2..65e7da16 100644 --- a/tests/connectors/artifact-source.test.ts +++ b/tests/connectors/artifact-source.test.ts @@ -432,7 +432,7 @@ describe("GSTR-1 artifact acquisition", () => { ok: false, reason: "control-not-found", requestId: request.requestId, - safeSignals: ["target-period-verified"], + safeSignals: ["target-period-verified", "gstr1-control-label-unmatched"], }); expect(documentRef.querySelectorAll("[data-pack-artifact-request]").length).toBe(0); }); @@ -468,7 +468,7 @@ describe("GSTR-1 artifact acquisition", () => { ok: false, reason: "control-not-found", requestId: request.requestId, - safeSignals: ["target-period-verified"], + safeSignals: ["target-period-verified", "gstr1-control-label-unmatched"], }); expect(documentRef.querySelectorAll("[data-pack-artifact-request]").length).toBe(0); }); @@ -483,7 +483,7 @@ describe("GSTR-1 artifact acquisition", () => { ok: false, reason: "control-not-found", requestId: request.requestId, - safeSignals: ["target-period-verified"], + safeSignals: ["target-period-verified", "gstr1-control-label-ambiguous"], }); expect(documentRef.querySelectorAll("[data-pack-artifact-request]").length).toBe(0); }); diff --git a/tests/connectors/artifact-validation.test.ts b/tests/connectors/artifact-validation.test.ts index 93332c18..f33d993c 100644 --- a/tests/connectors/artifact-validation.test.ts +++ b/tests/connectors/artifact-validation.test.ts @@ -1,8 +1,15 @@ import { describe, expect, it } from "vitest"; import { + describeJsonArtifactRejection, filedReturnsJsonDocumentContract, validateArtifactBytes, } from "../../src/connectors/gst/artifact-validation"; +import { ARTIFACT_ACQUISITION_DIAGNOSTIC_SIGNALS } from "../../src/connectors/gst/filed-returns-acquisition-diagnostics"; +import { + PORTAL_BLOB_SHIM_SUPPRESSION_METHODS, + RETURNS_DASHBOARD_ANCHOR_FAILURE_REASONS, + isDurableFiledReturnsSignal, +} from "../../src/connectors/gst/filed-returns-durable-signals"; const encoder = new TextEncoder(); @@ -94,3 +101,96 @@ describe("validateArtifactBytes", () => { }); }); }); + +describe("compact filed-return summary envelopes", () => { + // Captured live on 2026-09-10: a filed GSTR-1 period with nothing in it answers the summary + // preflight with a valid envelope well under 100 bytes. A size floor refused it before anything + // read it, and the run blocked on a return that was filed and downloadable. + const compactGstr1Envelope = encoder.encode(JSON.stringify({ data: { ret_period: "042025" } })); + + it("accepts a valid summary envelope that is smaller than a hundred bytes", () => { + expect(compactGstr1Envelope.byteLength).toBeLessThan(100); + expect(validateArtifactBytes(compactGstr1Envelope, "JSON", "042025", "GSTR-1")).toEqual({ + ok: true, + mimeType: "application/json", + }); + }); + + // What the floor was standing in for, each caught by the contract instead and caught better. + it("still refuses compact bodies the contract rejects", () => { + const cases: Array<[string, Uint8Array, string]> = [ + ["not JSON at all", encoder.encode("error"), "unexpected-content"], + ["no envelope", encoder.encode(JSON.stringify({ status: 1 })), "unexpected-content"], + [ + "no period field", + encoder.encode(JSON.stringify({ data: { other: "x" } })), + "unexpected-content", + ], + [ + "another period", + encoder.encode(JSON.stringify({ data: { ret_period: "052025" } })), + "target-period-mismatch", + ], + ]; + for (const [label, bytes, reason] of cases) { + expect(bytes.byteLength, label).toBeLessThan(100); + expect(validateArtifactBytes(bytes, "JSON", "042025", "GSTR-1"), label).toEqual({ + ok: false, + reason, + }); + } + }); + + it("still refuses an empty body", () => { + expect(validateArtifactBytes(new Uint8Array(), "JSON", "042025", "GSTR-1")).toEqual({ + ok: false, + reason: "empty", + }); + }); +}); + +describe("acquisition diagnostics are persistable", () => { + // The defect this pins: a diagnostic signal that is emitted but not registered rejects the + // entire durable signal array, so the run halts on non-canonical recovery metadata instead of + // recording the refusal the signal was there to explain. Emitting a new one without registering + // it is worse than not emitting it at all. + it("registers every acquisition diagnostic as a durable signal", () => { + const unregistered = ARTIFACT_ACQUISITION_DIAGNOSTIC_SIGNALS.filter( + (signal) => !isDurableFiledReturnsSignal(signal), + ); + expect(unregistered).toEqual([]); + }); + + it("emits only registered signals when a compact body fails the contract", () => { + const bodies = [ + encoder.encode(""), + encoder.encode(JSON.stringify({ status: 1 })), + encoder.encode(JSON.stringify({ data: { other: "x" } })), + encoder.encode(JSON.stringify({ data: { ret_period: "052025" } })), + new Uint8Array(), + ]; + for (const bytes of bodies) { + for (const signal of describeJsonArtifactRejection(bytes, "042025", "GSTR-1")) { + expect(isDurableFiledReturnsSignal(signal), signal).toBe(true); + } + } + }); +}); + +describe("template-built signals are persistable", () => { + // These are assembled from a prefix and a variable, so a scan for signal string literals never + // sees them. Three reached production unregistered, and each one rejected the entire durable + // array it travelled in -- halting the run on non-canonical recovery metadata instead of on the + // navigation problem the signal described. + it("registers every returns-dashboard anchor failure and blob-shim suppression", () => { + const built = [ + ...RETURNS_DASHBOARD_ANCHOR_FAILURE_REASONS.map( + (reason) => `returns-dashboard-anchor-${reason}`, + ), + ...PORTAL_BLOB_SHIM_SUPPRESSION_METHODS.map( + (method) => `portal-blob-shim-suppressed-via-${method}`, + ), + ]; + expect(built.filter((signal) => !isDurableFiledReturnsSignal(signal))).toEqual([]); + }); +}); From babcedc25b40f457cf79b3fa481858dab8fc3e1b Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 08:48:26 +0530 Subject: [PATCH 04/10] refactor(gst): let the compiler hold the acquisition-diagnostic lists The diagnostics module claimed emitters and the durable allowlist read one list. They did not: emitters wrote string literals and only the allowlist imported it, so the coupling the comment described existed nowhere. An unregistered signal rejects the whole durable array, which is how a diagnostic built to explain a failure halted the run instead. Emitters now declare they return one of the listed signals, so a literal nobody registered fails to compile rather than failing at persistence. That is the level this class needs. Separately, five structures across three modules held the two portal refusals -- a signal set, a reason set, a map between them, and two allowlist entries -- with the artifact- prefix relating them stated nowhere. One list and one derivation now, so registering a third refusal is a single line. --- .../filed-returns-download-trigger.ts | 12 +++--- src/connectors/gst/artifact-validation.ts | 5 ++- .../filed-returns-acquisition-diagnostics.ts | 37 ++++++++++++++++++- .../gst/filed-returns-durable-signals.ts | 26 ++++++++++--- 4 files changed, 64 insertions(+), 16 deletions(-) diff --git a/src/background/filed-returns-download-trigger.ts b/src/background/filed-returns-download-trigger.ts index 890a58f4..39b691c9 100644 --- a/src/background/filed-returns-download-trigger.ts +++ b/src/background/filed-returns-download-trigger.ts @@ -40,15 +40,13 @@ import { gstr3bFullFiscalYearAcquisitionNotWiredStep, isGstr3bFullFiscalYearAcquisitionScope, } from "./gstr3b-artifact-acquisition-block"; +import { DECLINED_ARTIFACT_SIGNALS } from "../connectors/gst/filed-returns-acquisition-diagnostics"; type FlowStepResponse = Extract; -// The portal's own declined-artifact answers. Only these are adopted from a post-click inspection; -// any other page state leaves the original acquisition failure standing with its reason intact. -const DECLINED_ARTIFACT_SIGNALS = new Set([ - "filed-gstr1-excel-no-details-available", - "filed-gstr2b-not-generated", -]); +// Only the portal's own declined-artifact answers are adopted from a post-click inspection; any +// other page state leaves the original acquisition failure standing with its reason intact. +const DECLINED_ARTIFACT_SIGNAL_SET = new Set(DECLINED_ARTIFACT_SIGNALS); async function persistSingleArtifactRecoveryIntent( scope: FiledReturnsDownloadScope, @@ -767,7 +765,7 @@ async function postClickBlockedStep({ if (!response.ok || !("flowStep" in response)) return null; // Only the recognised no-details answer is adopted. Any other post-click state leaves the // original acquisition failure standing, reason intact. - return response.flowStep.safeSignals.some((signal) => DECLINED_ARTIFACT_SIGNALS.has(signal)) + return response.flowStep.safeSignals.some((signal) => DECLINED_ARTIFACT_SIGNAL_SET.has(signal)) ? { ok: true, flowStep: response.flowStep } : null; } diff --git a/src/connectors/gst/artifact-validation.ts b/src/connectors/gst/artifact-validation.ts index 6c106f0f..d8f23263 100644 --- a/src/connectors/gst/artifact-validation.ts +++ b/src/connectors/gst/artifact-validation.ts @@ -1,4 +1,5 @@ import type { FiledReturnsReturnType } from "./filed-returns-return-types"; +import type { JsonArtifactRejectionSignal } from "./filed-returns-acquisition-diagnostics"; export type ArtifactValidationResult = | { @@ -100,12 +101,12 @@ export function describeJsonArtifactRejection( bytes: Uint8Array, expectedReturnPeriod: string, returnType: FiledReturnsReturnType, -): string[] { +): JsonArtifactRejectionSignal[] { if (bytes.byteLength === 0) return ["json-body-empty"]; // Report the size band and keep going. Stopping here says only that the body is small, which // cannot distinguish a legitimately compact envelope from a truncated or unrelated response -- // and that distinction is the whole question when a return has nothing in it. - const signals: string[] = []; + const signals: JsonArtifactRejectionSignal[] = []; let parsed: unknown; try { parsed = JSON.parse(new TextDecoder().decode(bytes)) as unknown; diff --git a/src/connectors/gst/filed-returns-acquisition-diagnostics.ts b/src/connectors/gst/filed-returns-acquisition-diagnostics.ts index 26323236..4469ab4b 100644 --- a/src/connectors/gst/filed-returns-acquisition-diagnostics.ts +++ b/src/connectors/gst/filed-returns-acquisition-diagnostics.ts @@ -1,7 +1,7 @@ // The signals that say why an artifact acquisition was refused. // -// A leaf module with no imports, because three places need the same list: the code that emits -// each signal, and the durable allowlist that decides whether a refusal can be persisted. +// A leaf module with no imports, because two places need the same list: the code that emits each +// signal, and the durable allowlist that decides whether a refusal can be persisted. // // That coupling is the point. An unregistered signal does not degrade gracefully -- it rejects the // whole durable array, and the run then halts on non-canonical recovery metadata instead of @@ -9,6 +9,11 @@ // list rather than two that can drift, which is how the first version of these diagnostics broke // the full-year run it was built to explain. // +// The coupling is enforced by the types below rather than by asking each emitter to import a +// constant: an emitter declares it returns one of these, so a literal that is not on the list +// fails to compile. That is the level the last failure of this kind needed -- the list was right +// and the emitter simply wrote a string nobody had registered. +// // Every entry is a Pack-owned constant. None carries portal text, a field value, or a path, so // nothing portal-derived reaches durable state through them. export const JSON_ARTIFACT_REJECTION_SIGNALS = [ @@ -39,3 +44,31 @@ export const ARTIFACT_ACQUISITION_DIAGNOSTIC_SIGNALS = [ ...JSON_ARTIFACT_REJECTION_SIGNALS, ...GSTR1_ACQUISITION_DIAGNOSTIC_SIGNALS, ] as const; + +export type JsonArtifactRejectionSignal = (typeof JSON_ARTIFACT_REJECTION_SIGNALS)[number]; +export type Gstr1AcquisitionDiagnosticSignal = + (typeof GSTR1_ACQUISITION_DIAGNOSTIC_SIGNALS)[number]; + +// The portal's own refusals to produce an artifact: a filed GSTR-1 with no e-invoices to report, +// and a GSTR-2B the portal did not draft. Both are answers rather than faults, and a run records +// the artifact as unavailable and carries on. +// +// Three structures held this pair before -- a signal set, a reason set, and a map between them -- +// in two modules, with the `artifact-` prefix that relates them stated nowhere. Adding the second +// refusal meant editing all three, and the relationship was only ever visible by reading them side +// by side. +export const DECLINED_ARTIFACT_SIGNALS = [ + "filed-gstr1-excel-no-details-available", + "filed-gstr2b-not-generated", +] as const; + +export type DeclinedArtifactSignal = (typeof DECLINED_ARTIFACT_SIGNALS)[number]; + +/** The reason recorded against the artifact, derived from the flow signal that carried it. */ +export function declinedArtifactReason( + signal: DeclinedArtifactSignal, +): `artifact-${DeclinedArtifactSignal}` { + return `artifact-${signal}`; +} + +export const DECLINED_ARTIFACT_REASONS = DECLINED_ARTIFACT_SIGNALS.map(declinedArtifactReason); diff --git a/src/connectors/gst/filed-returns-durable-signals.ts b/src/connectors/gst/filed-returns-durable-signals.ts index ea5f0bdc..479c8696 100644 --- a/src/connectors/gst/filed-returns-durable-signals.ts +++ b/src/connectors/gst/filed-returns-durable-signals.ts @@ -25,7 +25,11 @@ import { FILED_RETURNS_TARGET_REVIEW_CLEAR_FAILURE_STAGES, filedReturnsTargetReviewClearFailureSignal, } from "./filed-returns-target-review-clear"; -import { ARTIFACT_ACQUISITION_DIAGNOSTIC_SIGNALS } from "./filed-returns-acquisition-diagnostics"; +import { + ARTIFACT_ACQUISITION_DIAGNOSTIC_SIGNALS, + DECLINED_ARTIFACT_REASONS, + DECLINED_ARTIFACT_SIGNALS, +} from "./filed-returns-acquisition-diagnostics"; // The reasons and methods those templates can produce. Enumerated here beside the allowlist so a // new one is a compile-time change in one place rather than a signal that silently fails to @@ -147,8 +151,9 @@ const EXACT_DURABLE_SIGNALS = new Set([ "filed-gstr1-download-status-not-filed", "filed-gstr1-download-trigger-ambiguous", "filed-gstr1-excel-control-pending", - "filed-gstr1-excel-no-details-available", - "filed-gstr2b-not-generated", + // Both portal refusals, from the list that defines them, so registering a new one is not a + // separate step someone can forget -- which is how the last one halted a run. + ...DECLINED_ARTIFACT_SIGNALS, GSTR1_PERIOD_MISMATCH_RECOVERY_STOPPED_SIGNAL, "filed-gstr1-result-view-auto-attempt-failed", "filed-gstr1-result-view-auto-clicked", @@ -361,6 +366,18 @@ const EXACT_DURABLE_SIGNALS = new Set([ "gstr1-artifact-response-missing", "gstr1-artifact-state-invalid", "gstr2b-detail-heading", + // Page-identity evidence for the GSTR-2B summary route. These were transient while the only + // step that carried them was the "ready" hand-off to acquisition, whose signals never reach + // durable state. Recording the portal's refusal made them terminal, and one unregistered token + // rejects the whole array -- which blocked every period the portal declined to generate. + "gstr2b-visible-period-verified", + "gstr2b-visible-period-mismatch", + "gstr2b-labelled-period-evidence-missing", + "gstr2b-server-period-mismatch", + "gstr2b-server-visible-period-conflict", + "gstr2b-summary-period-mismatch", + "gstr2b-summary-dashboard-back-clicked", + "gstr2b-summary-back-clicked", "gstr2b-detail-route", "gstr2b-dashboard-period-select-found", "gstr2b-dashboard-period-select-missing", @@ -647,8 +664,7 @@ const SCOPED_RETURN_SIGNAL_SUFFIXES = new Set([ ]); const ARTIFACT_FAILURE_SIGNALS = new Set([ "artifact-acquisition-failed", - "artifact-filed-gstr1-excel-no-details-available", - "artifact-filed-gstr2b-not-generated", + ...DECLINED_ARTIFACT_REASONS, // Artifact-acquisition recovery exists to survive service-worker death, so // its outcomes must be persistable. Without these the blocked summary that // routes an interrupted acquisition to review is rejected by From 49ec1b476f9952234d7f761dc72fc82d1851c2d7 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 05:22:31 +0530 Subject: [PATCH 05/10] fix(privacy): stop three sinks repeating what they were handed Three places treated "the console is not persisted" as a reason to log whatever arrived, and one treated a character class as proof a value was safe. **The background handler logged the raw error.** A rejection from storage, scripting, or downloads can quote a URL, a path, or a response body. The fingerprint carries what a reader needs and is established to be this bundle's own, so it is what gets logged. **The durable-signal boundary named the token it rejected.** That boundary exists precisely because it cannot know what it has been handed -- a legacy or malformed entry read back from storage is the input it is there to refuse -- so repeating it in a console sink undoes the refusal. The diagnostic it replaced was written when an unregistered signal could only be found at runtime; it no longer can, because the signal lists are types and a producer emitting an unregistered token fails to compile. **The fingerprint laundered its input.** `safeSymbol` stripped punctuation, so a stack frame pointing at a portal URL came back looking like a symbol precisely because the characters that would have exposed it were deleted. A filter that edits its input until it passes is worse than no filter: it manufactures the appearance of safety. A symbol is this bundle's because the frame it came from points at this bundle's own origin -- provenance, not shape. A name is kept when it is shaped like an error class, which every class here and in the platform is, and which cannot spell a GSTIN, an ARN, or a URL. Anything else degrades to `Error`. --- .../background-failure-fingerprint.ts | 39 ++++++++++++------- .../gst/filed-returns-durable-signals.ts | 12 +++--- src/entrypoints/background.ts | 12 +++--- 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/src/background/background-failure-fingerprint.ts b/src/background/background-failure-fingerprint.ts index a40e1a31..2c54541e 100644 --- a/src/background/background-failure-fingerprint.ts +++ b/src/background/background-failure-fingerprint.ts @@ -1,30 +1,43 @@ // Enough to name a thrown failure, and nothing that could carry portal text. // // An error's message is not safe to render: it can quote a page, a URL, or a field value. Its -// class name and the innermost stack symbol come from this bundle's own code, so they identify -// where a failure happened without repeating anything the portal said. Both are filtered to a -// strict symbol charset, so a thrown non-Error — or an error with a doctored name — cannot smuggle -// text through either. +// class name and the innermost stack symbol can name where a failure happened without repeating +// anything the portal said -- but only if each is established to be this bundle's, rather than +// merely made to look harmless. +// +// Stripping punctuation was the earlier approach and it was worse than no filter: a frame pointing +// at a portal URL came back with its slashes deleted, so the value *looked* like a symbol +// precisely because the characters that would have exposed it were gone. A filter that launders +// its input is not a guard. // // This exists because a discarded error cost three separate round trips to locate, each needing a -// build and a live authenticated run. The panel gets the fingerprint; the console gets the error. -const SYMBOL_CHARSET = /[^A-Za-z0-9_.:$-]/gu; +// build and a live authenticated run. + +// A frame names a place in this bundle only when the file it points at is this bundle's. The +// symbol is what is kept; the URL is what proves the symbol is ours, and it is never kept. An +// anonymous frame has no symbol to take, and a frame from anywhere else does not match at all. +const BUNDLE_FRAME = /^\s*at\s+(?:async\s+)?([A-Za-z_$][\w$.]{0,59})\s+\(chrome-extension:\/\//u; + +// Every error class in this bundle ends in `Error`, as do the platform's own; `DOMException` is +// the one exception the platform makes. Letters and that suffix cannot spell a GSTIN, an ARN, or +// a URL, and a name that fails the shape degrades to `Error` rather than being laundered into one. +const BUNDLE_ERROR_NAME = /^[A-Za-z]{1,40}Error$/u; -function safeSymbol(value: string): string { - return value.replace(SYMBOL_CHARSET, "").slice(0, 60); +function safeErrorName(name: string): string { + if (name === "DOMException") return name; + return BUNDLE_ERROR_NAME.test(name) ? name : "Error"; } export function backgroundFailureFingerprint(error: unknown): string { if (!(error instanceof Error)) return "NonError"; - const name = safeSymbol(error.name || "Error"); - const frame = + const name = safeErrorName(error.name || "Error"); + const symbol = typeof error.stack === "string" ? (error.stack .split("\n") .slice(1) - .map((line) => /at\s+([^\s(]+)/u.exec(line)?.[1]) - .find((symbol) => symbol && !symbol.startsWith("chrome-extension")) ?? "") + .map((line) => BUNDLE_FRAME.exec(line)?.[1]) + .find(Boolean) ?? "") : ""; - const symbol = safeSymbol(frame); return symbol ? `${name} at ${symbol}` : name; } diff --git a/src/connectors/gst/filed-returns-durable-signals.ts b/src/connectors/gst/filed-returns-durable-signals.ts index 479c8696..eca34326 100644 --- a/src/connectors/gst/filed-returns-durable-signals.ts +++ b/src/connectors/gst/filed-returns-durable-signals.ts @@ -730,11 +730,13 @@ export function durableFiledReturnsSignalRejectionReason( // persisted or rendered. It distinguishes Pack-owned producer families during // live recovery without admitting portal-derived text into durable state. function durableUnknownSignalCategory(signal: string): DurableFiledReturnsSignalRejectionReason { - // The published reason stays a fixed projection -- naming the token in durable state or in the - // panel would admit arbitrary text into both. But a rejection nobody can attribute costs a build - // and a live run per attempt to locate, which is how three unregistered signals stayed hidden. - // The console is neither persisted nor rendered, so the token can be named there. - console.warn(`review-gate: unregistered durable signal rejected: ${signal}`); + // The token itself is never logged. This boundary exists precisely because it cannot know what + // it has been handed -- a legacy or malformed entry read back from storage is exactly the input + // it is here to refuse -- so repeating it in a console sink undoes the refusal it just made. + // + // The diagnostic this replaces was written when an unregistered signal could only be found at + // runtime. It no longer can: the signal lists are now types, and a producer emitting a token the + // allowlist does not carry fails to compile. A category is what remains useful at runtime. if (signal.startsWith("filed-return-detail-")) return "unknown-detail-identity"; if ( /^(?:artifact-|filed-gstr|page-|browser-download|full-fiscal-year-opfs|single-period-opfs)/.test( diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts index 4b9723f7..45a6c568 100644 --- a/src/entrypoints/background.ts +++ b/src/entrypoints/background.ts @@ -161,13 +161,13 @@ export default defineBackground(() => { void handleMessage(message, sender) .then((response) => sendResponse(response)) .catch((error: unknown) => { - // The reply stays a safe, fixed message -- it reaches the panel, and an arbitrary error - // string there could carry portal text. The console is neither rendered nor persisted, so - // the reason is named there instead of discarded. A handler that fails without saying why - // costs a build and a live run to locate, which is the whole reason this line exists. + // The fingerprint, not the error. A handler that fails without saying why costs a build + // and a live run to locate, so the reason is named rather than discarded -- but an error + // raised by storage, scripting, or downloads can quote a URL, a path, or a response body, + // and "the console is not persisted" is not a reason to log those. The fingerprint carries + // what a reader needs and is established to be this bundle's own. console.error( - `Pack background handler failed for ${backgroundMessageSource(message)}:`, - error, + `Pack background handler failed for ${backgroundMessageSource(message)}: ${backgroundFailureFingerprint(error)}`, ); sendResponse({ ok: false, From 9b12a266b7d7ecb303e7fb13bc19853eb95b0e93 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 05:22:31 +0530 Subject: [PATCH 06/10] fix(gst): ask the page, not the page's text, on two GSTR-1 decisions **A declined dialog was not bound to the target that raised it.** The detail route does not change per period, so a dialog left standing by an earlier target would mark this artifact unavailable -- and a composite or full-year run would carry on having silently omitted an artifact the portal never declined for it. The guard that binds a download click already asks exactly the right question, so it is asked here too: recording a refusal resolves a target outright and no artifact follows to corroborate it, which is the same bar. **A View Summary step was skipped on a text-derived signal.** `download-filed-gstr-1` says the filed-PDF label appears somewhere on the page, which decoy or non-actionable copy also does. Skipping the real control on that basis strands a target whose PDF was reachable: acquisition then finds nothing to click and blocks it. Whether a page offers an artifact is a question about controls, so the page's controls are what get asked. The resolver that answers it moves next to the descriptor owning the label, so the two cannot drift apart, and `artifact-source` loses its private copy. --- src/connectors/gst/artifact-source.ts | 15 ++-------- .../gst/filed-returns-detail-navigation.ts | 8 ++++- .../filed-returns-post-click-blocked-state.ts | 12 ++++++++ .../gst/portal-artifact-endpoints.ts | 30 +++++++++++++++++++ 4 files changed, 52 insertions(+), 13 deletions(-) diff --git a/src/connectors/gst/artifact-source.ts b/src/connectors/gst/artifact-source.ts index bf65eb91..be697c5b 100644 --- a/src/connectors/gst/artifact-source.ts +++ b/src/connectors/gst/artifact-source.ts @@ -7,8 +7,8 @@ import { GSTR2B_ORIGIN, GSTR2B_PAGE_GENERATED_ARTIFACTS, GSTR2B_SUMMARY_PATH, + findPageArtifactControls, } from "./portal-artifact-endpoints"; -import { getClickableElements, normaliseText } from "./filed-returns-dom"; import { extractScopedFiledReturnsDetailIdentity } from "./filed-returns-detail-identity"; import { filedReturnDetailIdentityMatchesScope } from "./filed-returns-detail-navigation"; import { resolveVisibleFiledReturnDownloadCandidates } from "./filed-returns-download-candidates"; @@ -246,7 +246,7 @@ async function acquireGstr1Artifact( view.location.pathname === GSTR1_DETAIL_PATH ? "gstr1-on-detail-page" : "gstr1-on-other-page", ]); } - const controls = resolvePageArtifactControls(documentRef, surface.controlText); + const controls = findPageArtifactControls(documentRef, surface.controlText); if (controls.length !== 1 || !controls[0]) { // How many matched matters: none means the label is wrong for this page shape, several means // the label is ambiguous and binding to one of them would be a guess. @@ -332,7 +332,7 @@ async function acquireGstr2bArtifact( if (view.location.pathname !== GSTR2B_SUMMARY_PATH) return failed(request, "wrong-page", ["target-period-verified"]); const descriptor = GSTR2B_PAGE_GENERATED_ARTIFACTS[request.artifactType]; - const controls = resolvePageArtifactControls(documentRef, descriptor.controlText); + const controls = findPageArtifactControls(documentRef, descriptor.controlText); if (controls.length !== 1 || !controls[0]) return failed(request, "control-not-found", ["target-period-verified"]); // The preflight above validated the fetched JSON, not the page. The summary @@ -362,15 +362,6 @@ async function acquireGstr2bArtifact( }; } -function resolvePageArtifactControls(documentRef: Document, canonicalLabel: string): HTMLElement[] { - const normalisedLabel = normaliseText(canonicalLabel); - return getClickableElements(documentRef).filter( - (element) => - getClickableElements(element).length === 0 && - normaliseText(element.textContent || "").includes(normalisedLabel), - ); -} - function failed( request: ArtifactRequest, reason: ArtifactFailureReason, diff --git a/src/connectors/gst/filed-returns-detail-navigation.ts b/src/connectors/gst/filed-returns-detail-navigation.ts index b08a842b..45572cb7 100644 --- a/src/connectors/gst/filed-returns-detail-navigation.ts +++ b/src/connectors/gst/filed-returns-detail-navigation.ts @@ -8,6 +8,7 @@ import { import type { extractFiledReturnsDetailIdentity } from "./filed-returns-detail-identity"; import { navigateToReturnDashboardPage } from "./filed-returns-navigator"; import { filedReturnDescriptor, filedReturnScopeId } from "./filed-returns-return-descriptors"; +import { offersFiledGstr1DetailPdf } from "./portal-artifact-endpoints"; export function shouldReturnFromMismatchedDetail( detailIdentity: ReturnType, @@ -153,7 +154,12 @@ export function clickFiledGstr1SummaryForPdf( // The detail route can carry the filed-PDF control itself, captured 2026-09-10 as // `DOWNLOAD FILED (PDF)` with no View Summary control anywhere on the page. Navigating away from // a page that already offers the download is what left this flow waiting until its step limit. - if (safeSignals.includes("download-filed-gstr-1")) return null; + // + // Asked of the page's controls, not of a signal derived from its text. `download-filed-gstr-1` + // only says the label appears somewhere, which decoy or non-actionable copy also does -- and + // skipping a real View Summary control on that basis strands a target whose PDF was reachable + // all along. The question is whether this page offers something to click, so the page is asked. + if (offersFiledGstr1DetailPdf(documentRef)) return null; if (isGstr1SummaryRoute(documentRef)) return null; const summaryControl = findGstr1ViewSummaryControl(documentRef); diff --git a/src/connectors/gst/filed-returns-post-click-blocked-state.ts b/src/connectors/gst/filed-returns-post-click-blocked-state.ts index c2bd3f02..95d4295d 100644 --- a/src/connectors/gst/filed-returns-post-click-blocked-state.ts +++ b/src/connectors/gst/filed-returns-post-click-blocked-state.ts @@ -2,6 +2,7 @@ import type { PortalDownloadTriggerResult } from "../../core/contracts"; import { delay } from "../../core/time"; import type { FiledReturnsDownloadTarget } from "./filed-returns-contracts"; import { filedReturnScopeId } from "./filed-returns-return-descriptors"; +import { verifyFiledReturnsDownloadTarget } from "./filed-returns-download-target"; const GSTR1_EXCEL_POST_CLICK_BLOCKED_WAIT_MS = 800; const GSTR1_EXCEL_POST_CLICK_BLOCKED_POLL_MS = 100; @@ -39,6 +40,17 @@ export function detectPostClickBlockedState( return null; } + // A dialog on screen is not bound to this target by being on screen. The detail route does not + // change per period, and a dialog left standing by an earlier target would otherwise mark this + // artifact unavailable -- letting a composite or full-year run carry on having silently omitted + // an artifact the portal never declined for it. + // + // This is the same guard that binds a download click, asked the same question: the visible page + // must be this return type, this period, this financial year. Recording a refusal resolves the + // target outright and no artifact follows to corroborate it, so it is held to the same bar. It + // fails closed -- an unreadable detail header is "could not determine", never "matches". + if (verifyFiledReturnsDownloadTarget(documentRef, target, [])) return null; + return { connectorId: "gst", scopeId: filedReturnScopeId(target.returnType), diff --git a/src/connectors/gst/portal-artifact-endpoints.ts b/src/connectors/gst/portal-artifact-endpoints.ts index 053271ff..78ea0b99 100644 --- a/src/connectors/gst/portal-artifact-endpoints.ts +++ b/src/connectors/gst/portal-artifact-endpoints.ts @@ -1,3 +1,5 @@ +import { getClickableElements, normaliseText } from "./filed-returns-dom"; + export const GSTR2B_ORIGIN = "https://gstr2b.gst.gov.in"; export const GSTR2B_JSON_PATH = "/gstr2b/auth/api/gstr2b/getjson"; export const GSTR2B_SUMMARY_PATH = "/gstr2b/auth/gstr2b/summary"; @@ -49,3 +51,31 @@ export const GSTR1_PAGE_GENERATED_ARTIFACTS: Record< expectedMime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }, }; + +/** + * The controls on this page that a descriptor's `controlText` names, and that can be clicked. + * + * A leaf clickable whose text carries the label -- not any element mentioning it. Whether a page + * offers an artifact is a question about controls, and answering it from page text lets decoy or + * non-actionable copy stand in for a button. It lives beside the descriptor so that the label and + * the search for it cannot drift apart. + */ +export function findPageArtifactControls( + documentRef: Document, + canonicalLabel: string, +): HTMLElement[] { + const normalisedLabel = normaliseText(canonicalLabel); + return getClickableElements(documentRef).filter( + (element) => + getClickableElements(element).length === 0 && + normaliseText(element.textContent || "").includes(normalisedLabel), + ); +} + +/** Whether the filed GSTR-1 detail route is itself offering the filed PDF, as captured live. */ +export function offersFiledGstr1DetailPdf(documentRef: Document): boolean { + const surface = GSTR1_PAGE_GENERATED_ARTIFACTS.PDF.surfaces.find( + (candidate) => candidate.path === GSTR1_DETAIL_PATH, + ); + return surface ? findPageArtifactControls(documentRef, surface.controlText).length > 0 : false; +} From 583654a740537ff6cca781a6b3bb34305ef781d6 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 05:22:31 +0530 Subject: [PATCH 07/10] fix(gst): keep a failure's reason when the page cannot be asked why **Inspection failure replaced the acquisition failure.** The follow-up message runs after an acquisition has already failed and can only refine it. A tab that has closed, navigated, or refuses injection means the failure cannot be refined, not that a new one occurred -- but the unguarded call threw, replacing a specific actionable reason with the generic background error and losing the terminal summary with it. **A definitive refusal left its intent checkpoint standing.** The retain decision is made about an acquisition failure. When the portal's no-details answer replaces that failure, it is no longer one: nothing exists to download, so nothing is left for a retry to reconcile. The checkpoint survived anyway and blocked the next attempt as `artifact-acquisition-start-unreconciled` -- refusing the retry that very result offers. The nested ternary became an early return; the second fix has nowhere to live inside an expression. --- .../filed-returns-download-trigger.ts | 98 ++++++++++++------- 1 file changed, 60 insertions(+), 38 deletions(-) diff --git a/src/background/filed-returns-download-trigger.ts b/src/background/filed-returns-download-trigger.ts index 39b691c9..8775832e 100644 --- a/src/background/filed-returns-download-trigger.ts +++ b/src/background/filed-returns-download-trigger.ts @@ -677,41 +677,53 @@ async function triggerPageGeneratedSinglePeriodArtifact( checkpointHasDownloadId, externallyVisibleActionMayHaveOccurred, })); - return acquired.ok - ? { - ok: true, - flowStep: { - connectorId: "gst", - scopeId: filedReturnScopeId(returnType), - state: "downloaded", - safeSignals: [...artifact.safeSignals, ...acquired.safeSignals], - safeMessage: acquired.safeMessage ?? artifactSuccessMessage(returnType, artifactType), - ...(hasDownloadDiagnostic(acquired) && acquired.downloadDiagnostic - ? { downloadDiagnostic: acquired.downloadDiagnostic } - : {}), - }, - } - : ((await postClickBlockedStep({ - artifactType, - deps, - requestId, - returnType, - scope, - tabId, - })) ?? { - ok: true, - flowStep: { - connectorId: "gst", - scopeId: filedReturnScopeId(returnType), - state: "blocked", - safeSignals: [ - "artifact-acquisition-failed", - `artifact-${acquired.reason}`, - ...acquired.safeSignals, - ], - safeMessage: acquired.safeMessage ?? artifactFailureMessageForDelivery(acquired.reason), - }, - }); + if (acquired.ok) { + return { + ok: true, + flowStep: { + connectorId: "gst", + scopeId: filedReturnScopeId(returnType), + state: "downloaded", + safeSignals: [...artifact.safeSignals, ...acquired.safeSignals], + safeMessage: acquired.safeMessage ?? artifactSuccessMessage(returnType, artifactType), + ...(hasDownloadDiagnostic(acquired) && acquired.downloadDiagnostic + ? { downloadDiagnostic: acquired.downloadDiagnostic } + : {}), + }, + }; + } + + const declined = await postClickBlockedStep({ + artifactType, + deps, + requestId, + returnType, + scope, + tabId, + }); + if (declined) { + // The retain decision above was made about an acquisition failure. This is not one: the + // portal has established that no download exists for this target, so there is nothing for a + // retry to reconcile. Leaving the intent checkpoint standing would block the next attempt as + // `artifact-acquisition-start-unreconciled` -- refusing the retry this very result offers. + retainCheckpointForRecovery = false; + return declined; + } + + return { + ok: true, + flowStep: { + connectorId: "gst", + scopeId: filedReturnScopeId(returnType), + state: "blocked", + safeSignals: [ + "artifact-acquisition-failed", + `artifact-${acquired.reason}`, + ...acquired.safeSignals, + ], + safeMessage: acquired.safeMessage ?? artifactFailureMessageForDelivery(acquired.reason), + }, + }; } finally { if (tracksBrowserDownload && !retainCheckpointForRecovery) { await clearArtifactAcquisitionCheckpoint(checkpointTarget, requestId); @@ -749,8 +761,13 @@ async function postClickBlockedStep({ const declinable = (returnType === "GSTR-1" && artifactType === "EXCEL") || returnType === "GSTR-2B"; if (!declinable) return null; - const response = normaliseContentScriptMessageResponse( - await deps.sendMessageToTabWithInjection(tabId, { + // This runs after an acquisition has already failed, and it can only refine that failure. If the + // tab has closed, navigated, or refuses injection, the answer is simply that the failure cannot + // be refined -- so the original reason stands. Throwing here would replace a specific, actionable + // failure with the generic background error and lose the terminal summary with it. + let raw: unknown; + try { + raw = await deps.sendMessageToTabWithInjection(tabId, { type: "PACK_CONTENT_INSPECT_FILED_RETURN_POST_CLICK_V3", payload: { actionId: requestId, @@ -759,7 +776,12 @@ async function postClickBlockedStep({ period: scope.period, returnType, }, - }), + }); + } catch { + return null; + } + const response = normaliseContentScriptMessageResponse( + raw, "PACK_CONTENT_INSPECT_FILED_RETURN_POST_CLICK_V3", ); if (!response.ok || !("flowStep" in response)) return null; From 758b35a43f6fd83083da2bfb931a57b5bfe2f6d1 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 05:22:31 +0530 Subject: [PATCH 08/10] fix(gst): stop decoding a body already known to be too large The size cap is a processing bound, not only a verdict. A rejected oversized preflight body went straight into the diagnostic helper, which decoded and parsed all of it -- spending exactly the work the cap exists to refuse, on the one path where the input is known to be unreasonable. The band is the whole diagnostic here: nothing inside an oversized body would change what a reader does about it. The signal registers by derivation, so no allowlist needed telling. --- src/connectors/gst/artifact-validation.ts | 5 +++++ src/connectors/gst/filed-returns-acquisition-diagnostics.ts | 1 + 2 files changed, 6 insertions(+) diff --git a/src/connectors/gst/artifact-validation.ts b/src/connectors/gst/artifact-validation.ts index d8f23263..225b03fb 100644 --- a/src/connectors/gst/artifact-validation.ts +++ b/src/connectors/gst/artifact-validation.ts @@ -103,6 +103,11 @@ export function describeJsonArtifactRejection( returnType: FiledReturnsReturnType, ): JsonArtifactRejectionSignal[] { if (bytes.byteLength === 0) return ["json-body-empty"]; + // The size cap is a processing bound, not only a verdict. Decoding and parsing a body this + // function has already been told is too large spends exactly the work the cap exists to refuse, + // on the one path where the input is known to be unreasonable. The band is the whole diagnostic + // here: nothing inside an oversized body would change what a reader does about it. + if (bytes.byteLength > MAX_ARTIFACT_BYTES) return ["json-body-oversized"]; // Report the size band and keep going. Stopping here says only that the body is small, which // cannot distinguish a legitimately compact envelope from a truncated or unrelated response -- // and that distinction is the whole question when a return has nothing in it. diff --git a/src/connectors/gst/filed-returns-acquisition-diagnostics.ts b/src/connectors/gst/filed-returns-acquisition-diagnostics.ts index 4469ab4b..61b28f3e 100644 --- a/src/connectors/gst/filed-returns-acquisition-diagnostics.ts +++ b/src/connectors/gst/filed-returns-acquisition-diagnostics.ts @@ -22,6 +22,7 @@ export const JSON_ARTIFACT_REJECTION_SIGNALS = [ // can still contain it, and an unregistered token rejects the whole array it travels in. "json-body-under-minimum", "json-body-empty", + "json-body-oversized", "json-parse-failed", "json-status-unexpected", "json-envelope-missing", From 50cf3cac0422a6c98d58dc180f93027ab79d4525 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 05:22:46 +0530 Subject: [PATCH 09/10] test(gst): pin the eight readings this change stopped trusting Each fails with its guard removed. The fingerprint fixtures used invented `file.js` frames. A background stack points at this bundle's own origin on every line, which is what makes a symbol ours -- so a fixture that omitted it could not have caught a frame from anywhere else. --- .../background-failure-fingerprint.test.ts | 50 +++++-- ...turns-download-trigger-acquisition.test.ts | 42 ++++++ tests/connectors/artifact-validation.test.ts | 22 ++- .../filed-returns-durable-signals.test.ts | 25 +++- ...led-returns-flow-gstr1-acquisition.test.ts | 128 ++++++++++++++++++ 5 files changed, 255 insertions(+), 12 deletions(-) diff --git a/tests/background/background-failure-fingerprint.test.ts b/tests/background/background-failure-fingerprint.test.ts index 8ec91495..18e52264 100644 --- a/tests/background/background-failure-fingerprint.test.ts +++ b/tests/background/background-failure-fingerprint.test.ts @@ -1,20 +1,23 @@ import { describe, expect, it } from "vitest"; import { backgroundFailureFingerprint } from "../../src/background/background-failure-fingerprint"; +// Frames as the service worker actually produces them: every line of a background stack points at +// a file served from the extension's own origin. A frame pointing anywhere else did not come from +// this bundle, and is the case the fingerprint must not repeat. +const BUNDLE = "chrome-extension://abcdefghijklmnopabcdefghijklmnop/background.js"; + describe("background failure fingerprint", () => { it("names the error class and the innermost symbol", () => { const error = new TypeError("boom"); - error.stack = "TypeError: boom\n at parseDurableSummary (file.js:1:1)\n at next (f.js:2)"; + error.stack = `TypeError: boom\n at parseDurableSummary (${BUNDLE}:1:1)\n at next (${BUNDLE}:2:1)`; expect(backgroundFailureFingerprint(error)).toBe("TypeError at parseDurableSummary"); }); it("never repeats the error message", () => { const error = new Error("GSTIN 00AAAAA0000A1Z0 rejected at https://portal.example/x"); - error.stack = `${error.message}\n at readLedger (file.js:1:1)`; + error.stack = `${error.message}\n at readLedger (${BUNDLE}:1:1)`; const fingerprint = backgroundFailureFingerprint(error); - expect(fingerprint).not.toContain("GSTIN"); - expect(fingerprint).not.toContain("0AAAAA0000A1Z0"); - expect(fingerprint).not.toContain("https"); + expect(fingerprint).toBe("Error at readLedger"); }); it("cannot be smuggled through by a thrown non-Error", () => { @@ -22,11 +25,38 @@ describe("background failure fingerprint", () => { expect(backgroundFailureFingerprint({ name: "x", stack: "at leak" })).toBe("NonError"); }); - it("strips anything outside the symbol charset", () => { + it("takes no symbol from a frame outside this bundle", () => { + // The earlier filter deleted punctuation, so this frame came back as a plausible-looking + // symbol -- laundered into safety by removing exactly the characters that exposed it. Origin + // is what makes a symbol ours; a character class never could. + const error = new Error("x"); + error.stack = `Error: x\n at https://www.gst.gov.in/returns/auth/gstr1 (${BUNDLE}:1:1)`; + const fingerprint = backgroundFailureFingerprint(error); + expect(fingerprint).toBe("Error"); + expect(fingerprint).not.toContain("gst"); + }); + + it("takes no symbol from an anonymous frame", () => { + const error = new Error("x"); + error.stack = `Error: x\n at ${BUNDLE}:1:1`; + expect(backgroundFailureFingerprint(error)).toBe("Error"); + }); + + it("refuses a name that is not shaped like an error class", () => { + // Every error class in this bundle ends in `Error`, and so do the platform's. A name that does + // not degrades to `Error` rather than being edited until it passes. + const error = new Error("x"); + error.name = "GSTIN00AAAAA0000A1Z0"; + error.stack = `x\n at readLedger (${BUNDLE}:1:1)`; + const fingerprint = backgroundFailureFingerprint(error); + expect(fingerprint).toBe("Error at readLedger"); + expect(fingerprint).not.toContain("AAAAA"); + }); + + it("keeps the bundle's own error classes", () => { const error = new Error("x"); - error.name = "Bad Name/With Spaces"; - error.stack = "e\n at some symbol with spaces (f.js:1)"; - // Only the literal " at " separator may contain spaces; both symbols are charset-filtered. - expect(backgroundFailureFingerprint(error)).toBe("BadNameWithSpaces at some"); + error.name = "XlsxSizeLimitError"; + error.stack = `x\n at writeWorkbook (${BUNDLE}:1:1)`; + expect(backgroundFailureFingerprint(error)).toBe("XlsxSizeLimitError at writeWorkbook"); }); }); diff --git a/tests/background/filed-returns-download-trigger-acquisition.test.ts b/tests/background/filed-returns-download-trigger-acquisition.test.ts index 7d55d20f..fa0abb33 100644 --- a/tests/background/filed-returns-download-trigger-acquisition.test.ts +++ b/tests/background/filed-returns-download-trigger-acquisition.test.ts @@ -1099,6 +1099,48 @@ describe("filed GSTR-1 e-invoice Excel with no details to download", () => { }); }); + it("keeps the original failure when the page cannot be asked at all", async () => { + // This runs after an acquisition has already failed and can only refine that failure. A tab + // that has closed, navigated, or refuses injection means the failure cannot be refined -- not + // that a new one happened. Throwing here would replace a specific, actionable reason with the + // generic background error and lose the terminal summary with it. + armClickWithoutDownload(); + const sendMessageToTabWithInjection = vi.fn( + async (_tabId: number, message: { type: string }) => { + if (message.type === "PACK_CONTENT_INSPECT_FILED_RETURN_POST_CLICK_V3") { + throw new Error("Could not establish connection. Receiving end does not exist."); + } + return { + ok: true, + artifact: { + ok: true, + state: "ready", + requestId: "synthetic-request", + safeSignals: ["target-period-verified"], + }, + } as PackMessageResponse; + }, + ); + + const response = await triggerAndObserveFiledReturnDownload({ + activePeriod: "April", + artifactType: "EXCEL", + deps: { sendMessageToTabWithInjection, storageKeys: {} }, + scope: { financialYear: "2025-26", period: "April", returnType: "GSTR-1" }, + tabId: 17, + }); + + expect(response).toMatchObject({ + flowStep: { + safeSignals: expect.arrayContaining([ + "artifact-acquisition-failed", + "artifact-generation-timeout", + ]), + state: "blocked", + }, + }); + }); + it("keeps the original failure when the page reports no recognised block", async () => { armClickWithoutDownload(); const sendMessageToTabWithInjection = messagingDeps({ diff --git a/tests/connectors/artifact-validation.test.ts b/tests/connectors/artifact-validation.test.ts index f33d993c..3ed6b862 100644 --- a/tests/connectors/artifact-validation.test.ts +++ b/tests/connectors/artifact-validation.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { + MAX_ARTIFACT_BYTES, describeJsonArtifactRejection, filedReturnsJsonDocumentContract, validateArtifactBytes, @@ -193,4 +194,23 @@ describe("template-built signals are persistable", () => { ]; expect(built.filter((signal) => !isDurableFiledReturnsSignal(signal))).toEqual([]); }); + + it("names an oversized body without decoding or parsing it", () => { + // The cap is a processing bound, not only a verdict. Decoding and parsing a body already known + // to be too large spends exactly the work the cap refuses, on the path where the input is known + // to be unreasonable. + const oversized = new Uint8Array(MAX_ARTIFACT_BYTES + 1); + const decode = vi.spyOn(TextDecoder.prototype, "decode"); + const parse = vi.spyOn(JSON, "parse"); + try { + expect(describeJsonArtifactRejection(oversized, "052025", "GSTR-1")).toEqual([ + "json-body-oversized", + ]); + expect(decode).not.toHaveBeenCalled(); + expect(parse).not.toHaveBeenCalled(); + } finally { + decode.mockRestore(); + parse.mockRestore(); + } + }); }); diff --git a/tests/connectors/filed-returns-durable-signals.test.ts b/tests/connectors/filed-returns-durable-signals.test.ts index f84741ae..f777277e 100644 --- a/tests/connectors/filed-returns-durable-signals.test.ts +++ b/tests/connectors/filed-returns-durable-signals.test.ts @@ -1,5 +1,5 @@ import { FILED_RETURNS_WORKBOOK_ABSENCE_OUTCOMES } from "../../src/connectors/gst/offscreen-blob-url"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { durableFiledReturnsSignalRejectionReason, isDurableFiledReturnsSignal, @@ -537,4 +537,27 @@ describe("filed-return durable signal contract", () => { expect(parseDurableFiledReturnsSignals([signals[0], signal])).toBeNull(); } }); + + it("never repeats a rejected token to a console sink", () => { + // This boundary exists because it cannot know what it has been handed -- a legacy or malformed + // entry read back from storage is exactly the input it refuses. Naming that entry in a log + // undoes the refusal it just made. + const sinks = [ + vi.spyOn(console, "warn").mockImplementation(() => undefined), + vi.spyOn(console, "error").mockImplementation(() => undefined), + vi.spyOn(console, "log").mockImplementation(() => undefined), + ]; + try { + const rejected = parseDurableFiledReturnsSignals([ + "gstr2b-for-00AAAAA0000A1Z0-at-https://portal.example/x", + ]); + + expect(rejected).toBeNull(); + const written = sinks.flatMap((sink) => sink.mock.calls.flat()).join(" "); + expect(written).not.toContain("00AAAAA0000A1Z0"); + expect(written).not.toContain("portal.example"); + } finally { + for (const sink of sinks) sink.mockRestore(); + } + }); }); diff --git a/tests/connectors/filed-returns-flow-gstr1-acquisition.test.ts b/tests/connectors/filed-returns-flow-gstr1-acquisition.test.ts index 0df300c4..7eeeee5d 100644 --- a/tests/connectors/filed-returns-flow-gstr1-acquisition.test.ts +++ b/tests/connectors/filed-returns-flow-gstr1-acquisition.test.ts @@ -468,6 +468,71 @@ describe("filed returns flow — GSTR-1 artifact acquisition", () => { expect(back).not.toHaveBeenCalled(); }); + it("still uses View Summary when the filed-PDF label is only text", async () => { + // The label appearing on the page is not the page offering the download. Skipping the real + // View Summary control because some non-actionable copy carries the words strands a target + // whose PDF was reachable: acquisition then finds no control to click and blocks it. + const documentRef = createGstDocument( + ` +
+

GSTR-1

+
Status - Filed
+
Financial Year - 2025-26
+
Return Period - April
+

Use DOWNLOAD FILED (PDF) once the summary has been generated.

+ +
+ `, + "https://return.gst.gov.in/returns/auth/gstr1", + ); + makeLayoutVisible(documentRef); + let summaryClicked = 0; + documentRef.querySelector("[data-summary]")?.addEventListener("click", () => { + summaryClicked += 1; + }); + + const result = await runFiledReturnsDownloadStep(documentRef, { + artifactType: "PDF", + financialYear: "2025-26", + period: "April", + returnType: "GSTR-1", + }); + + expect(summaryClicked).toBe(1); + expect(result.safeSignals).toContain("filed-gstr1-summary-view-clicked"); + }); + + it("skips View Summary when the detail route offers a filed-PDF control to click", async () => { + const documentRef = createGstDocument( + ` +
+

GSTR-1

+
Status - Filed
+
Financial Year - 2025-26
+
Return Period - April
+ + +
+ `, + "https://return.gst.gov.in/returns/auth/gstr1", + ); + makeLayoutVisible(documentRef); + let summaryClicked = 0; + documentRef.querySelector("[data-summary]")?.addEventListener("click", () => { + summaryClicked += 1; + }); + + const result = await runFiledReturnsDownloadStep(documentRef, { + artifactType: "PDF", + financialYear: "2025-26", + period: "April", + returnType: "GSTR-1", + }); + + expect(summaryClicked).toBe(0); + expect(result.safeSignals).not.toContain("filed-gstr1-summary-view-clicked"); + }); + it("does not leave a filed GSTR-1 summary when its visible scope is incomplete", async () => { const documentRef = createGstDocument( ` @@ -546,6 +611,69 @@ describe("filed returns flow — GSTR-1 artifact acquisition", () => { expect(excelClicked).toBe(1); }); + it("refuses the no-details dialog for a target the visible page is not about", async () => { + // The detail route does not change per period and a dialog outlives the target that raised it. + // Answering from body text alone lets one period's refusal mark another period's artifact + // unavailable, and a composite or full-year run then carries on having omitted an artifact the + // portal never declined for it. + const documentRef = createDocument(` +
+ +

GSTR-1

+
Status - Filed
+
Financial Year - 2025-26
+
Return Period - May
+
+

Information

+

No details available for download (This is relevant only if you have reported e-invoices).

+ +
+
+ `); + makeLayoutVisible(documentRef); + + const result = detectPostClickBlockedState( + documentRef, + { + actionId: "test-action", + artifactType: "EXCEL" as const, + financialYear: "2025-26", + period: "June", + returnType: "GSTR-1" as const, + }, + [], + ); + + expect(result).toBeNull(); + }); + + it("refuses the no-details dialog when the detail header cannot be read", async () => { + // Fail closed: a page that does not name its period is "could not determine", never "matches". + const documentRef = createDocument(` +
+
+

Information

+

No details available for download (This is relevant only if you have reported e-invoices).

+
+
+ `); + makeLayoutVisible(documentRef); + + const result = detectPostClickBlockedState( + documentRef, + { + actionId: "test-action", + artifactType: "EXCEL" as const, + financialYear: "2025-26", + period: "May", + returnType: "GSTR-1" as const, + }, + [], + ); + + expect(result).toBeNull(); + }); + it("returns from a mismatched detail page before running the requested exact period", async () => { const documentRef = createDocument(`
From 9beb1aba5931d3a845d8b6bfecef3387a32af528 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 12 Sep 2026 05:22:46 +0530 Subject: [PATCH 10/10] docs(gst): record the second shape of the GSTR-1 detail route The change relied on a live 2026-09-10 capture that this repository had not recorded, while `LIVE_FILED_RETURNS_SPIKE.md` still stated the opposite from an earlier one: that the detail page never exposes the PDF and must be navigated through View Summary. Both captures are real. The detail route has two shapes, and the spike's paragraph was read as a general rule. A contradiction left standing is how a fix gets reimplemented from the stale half, so the spike entry is marked superseded in place -- it stays, because it is a dated record of what was seen -- and the finding goes where captures go. --- docs/LIVE_FILED_RETURNS_SPIKE.md | 18 +++++++++++----- docs/PORTAL_INTEGRATION_FINDINGS.md | 33 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/docs/LIVE_FILED_RETURNS_SPIKE.md b/docs/LIVE_FILED_RETURNS_SPIKE.md index d0ec66db..9078dade 100644 --- a/docs/LIVE_FILED_RETURNS_SPIKE.md +++ b/docs/LIVE_FILED_RETURNS_SPIKE.md @@ -494,11 +494,19 @@ safe-result inspection found one GSTR-1/IFF result row for May and no `/returns/auth/gstr1`, with safe signals for the GSTR-1 detail route, GSTR-1 heading and filed status. -The live PDF path is different from GSTR-3B: the filed GSTR-1 detail page does -not expose the final summary PDF download directly. It exposes a portal -`View Summary` action whose live merged label appeared as -`VIEW SUMMARY PROCEED TO FILE/SUMMARY VIEW SUMMARY`; that action opens a -separate summary page with the PDF download control near the bottom. +> **Superseded in part on 2026-09-10.** The paragraph below records what this +> spike saw, and it is accurate for that capture. It is not the general rule it +> was read as: a later capture found the filed-PDF control on the detail route +> itself, with no `View Summary` control on the page at all. The detail route +> has **two** shapes, and a flow written from this paragraph alone waits out its +> step budget on the other one. See "Filed GSTR-1 detail route can be the +> download surface itself" in `PORTAL_INTEGRATION_FINDINGS.md`. + +The live PDF path is different from GSTR-3B: on the page captured here, the +filed GSTR-1 detail page does not expose the final summary PDF download +directly. It exposes a portal `View Summary` action whose live merged label +appeared as `VIEW SUMMARY PROCEED TO FILE/SUMMARY VIEW SUMMARY`; that action +opens a separate summary page with the PDF download control near the bottom. Implementation changes from this evidence: diff --git a/docs/PORTAL_INTEGRATION_FINDINGS.md b/docs/PORTAL_INTEGRATION_FINDINGS.md index 6a32c995..127cdbcd 100644 --- a/docs/PORTAL_INTEGRATION_FINDINGS.md +++ b/docs/PORTAL_INTEGRATION_FINDINGS.md @@ -412,3 +412,36 @@ read-only live probe confirms a stable, scope-bound source for the necessary fac cadences and registration states. Until then, the evidence supports neither replacing the calendar threshold nor suppressing a reader-selected period; record a bounded, user-visible not-filed or unresolved outcome when the acquisition flow establishes one. + +## Filed GSTR-1 detail route can be the download surface itself (2026-09-10) + +Captured live, authenticated, source-surfaces build v0.6.0, on the GSTR-1 detail route +(`/returns/auth/gstr1`). No value read from the page is reproduced here: this file records what the +portal's surfaces do, not what any return says. + +**The page carried the filed-PDF control and no navigation step.** Its control row offered a back +action, an e-invoice details Excel download, a disabled reset, and a filed-PDF download labelled +`DOWNLOAD FILED (PDF)`. There was no `VIEW SUMMARY` control anywhere on it — confirmed against the +full page, header band through footer. + +**What this falsifies.** `LIVE_FILED_RETURNS_SPIKE.md` recorded the opposite from an earlier +capture: that the detail page never exposes the PDF and must be navigated through `View Summary`. +That paragraph is accurate for the page it saw and wrong as a general rule. The detail route has two +shapes. `clickFiledGstr1SummaryForPdf` searched for a control that does not exist on this one and +re-emitted `filed-gstr1-summary-view-pending` every step until the flow step limit, so the run +reported `blocked` / `user-action-required` with no action a user could take. Observed signals: +`gstr-1-detail-route`, `filed-gstr1-summary-view-pending`, `flow-step-limit-reached`. + +**Why both layers missed the control.** The label is `DOWNLOAD FILED (PDF)` — no return type after +`FILED`. `explicitDownloadPattern` for GSTR-1 required `download filed gstr1`, and +`secondaryDownloadPattern` required `download` immediately before `pdf`, so the intervening `filed` +defeated it. The observer carried its own inline copy of that second pattern rather than reading the +descriptor, so the same fact had to be wrong in two places at once. + +**The fix keys off the control, not the shape.** Whether this variant belongs to a particular filing +cadence is unestablished — one page was captured — so the flow asks whether a filed-PDF control is +present and clickable rather than inferring it from the route or from page text. A label appearing +in page copy is not a control: decoy or non-actionable text carrying the same words would otherwise +suppress a real `View Summary` step and strand a target whose PDF was reachable. + +**Not yet established.** Which periods render which shape, and whether a page can offer both.