Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 16x 16x 16x 16x 1x 1x 1x 1x 1x 1x 1x 15x 15x 15x 15x 15x 15x 15x 13x 16x 2x 2x 2x 1x 1x 2x 13x 16x 1x 1x 1x 1x 1x 1x 1x 1x 12x 16x 1x 1x 1x 1x 1x 1x 1x 1x 11x 16x 2x 2x 9x 16x 5x 5x 1x 1x 1x 1x 1x 1x 1x 4x 4x 4x 4x 4x 1x 1x 1x 1x 1x 1x 1x 2x 2x 2x 2x 2x 2x 2x 2x 2x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 16x 2x 2x 2x 2x 2x 2x 16x 16x 16x 16x 1x 4x 4x 1x 1x 1x 1x 1x 1x 1x 4x 1x 1x 1x 1x 1x 1x 1x 4x 2x 2x 2x 2x 2x 2x 2x 1x 12x 12x 12x 12x 12x 12x 12x 12x 12x 1x 5x 5x 5x 5x 5x 4x 4x 4x 4x 5x 5x | import type {
JobAnalysis,
JobErrorCode,
JobPanelError,
PageExtract,
UsageInfo,
} from "../types/job";
import {
getIdToken,
markNotAuthorized,
signInSilently,
signOut,
} from "./auth/authService";
declare const WXT_AZURE_FUNCTION_URL: string;
declare const WXT_AZURE_FUNCTION_KEY: string;
const TIMEOUT_MS = 30_000;
export interface JobAnalysisRequest {
extract: PageExtract;
profile?: string;
assumeJobPosting?: boolean;
}
export async function postJobAnalysis(
request: JobAnalysisRequest
): Promise<JobAnalysis> {
if (!WXT_AZURE_FUNCTION_URL) {
throw makeJobError(
"not-configured",
"The analysis service is not configured.",
"Please reinstall the extension.",
false
);
}
const endpoint = new URL(WXT_AZURE_FUNCTION_URL);
if (WXT_AZURE_FUNCTION_KEY) {
endpoint.searchParams.set("code", WXT_AZURE_FUNCTION_KEY);
}
let response = await attemptFetch(endpoint, request, await getIdToken());
if (response.status === 401) {
// One silent renewal, then the sign-in gate (auth contract).
const renewed = await signInSilently();
if (renewed) {
response = await attemptFetch(endpoint, request, renewed.idToken);
}
}
if (response.status === 401) {
await signOut();
throw makeJobError(
"no-access",
"Your session ended.",
"Sign in to continue.",
false
);
}
if (response.status === 403) {
await markNotAuthorized();
throw makeJobError(
"no-access",
"Your account can't sign in right now.",
"See the sign-in screen for details.",
false
);
}
if (response.status === 429) {
throw await mapTooManyRequests(response);
}
if (response.ok) {
const payload = (await response.json()) as unknown;
if (!isJobAnalysis(payload)) {
throw makeJobError(
"service-error",
"The analysis service returned an unexpected result.",
"Try again.",
true
);
}
return payload;
}
throw mapHttpError(response.status);
}
/**
* 429 covers two distinct causes (contracts/metering.md): the monthly usage
* allowance (USAGE_LIMIT_REACHED, carries `usage` for the exhausted-state
* card, FR-009) and the per-IP rate limiter (RATE_LIMITED, generic retry
* friction). Callers must branch on error.code, never on status alone.
*/
async function mapTooManyRequests(response: Response): Promise<JobPanelError> {
let body: unknown;
try {
body = await response.json();
} catch {
body = null;
}
const errorCode = (body as { error?: { code?: string } } | null)?.error?.code;
const usage = (body as { usage?: UsageInfo } | null)?.usage;
if (errorCode === "USAGE_LIMIT_REACHED" && usage) {
const tierLabel = usage.tier === "premium" ? "premium" : "free";
return makeJobError(
"usage-limit-reached",
`You've used all ${usage.limit} ${tierLabel} analyses this month.`,
"Upgrade for more analyses, or wait for your allowance to reset.",
false,
usage
);
}
return makeJobError(
"service-error",
"The analysis service encountered an error.",
"Try again.",
true
);
}
async function attemptFetch(
endpoint: URL,
request: JobAnalysisRequest,
idToken: string | null
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
return await fetch(endpoint.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
...(idToken ? { Authorization: `Bearer ${idToken}` } : {}),
},
body: JSON.stringify({
extract: request.extract,
...(request.profile ? { profile: request.profile } : {}),
...(request.assumeJobPosting ? { assumeJobPosting: true } : {}),
}),
signal: controller.signal,
});
} catch {
throw makeJobError(
"network-error",
"Could not reach the analysis service.",
"Check your internet connection and try again.",
true
);
} finally {
clearTimeout(timeoutId);
}
}
function mapHttpError(status: number): JobPanelError {
if (status === 413) {
return makeJobError(
"extract-too-large",
"This page is too large to analyze.",
"Try a page for a single job posting.",
false
);
}
if (status === 400) {
return makeJobError(
"unknown",
"The page could not be analyzed.",
"Try re-analyzing from the posting page itself.",
false
);
}
if (status === 502 || status === 500 || status === 503 || status === 504) {
return makeJobError(
"service-error",
"The analysis service encountered an error.",
"Try again.",
true
);
}
return makeJobError("unknown", "An unexpected error occurred.", "Try again.", true);
}
function makeJobError(
code: JobErrorCode,
message: string,
action: string,
retryable: boolean,
usage?: UsageInfo
): JobPanelError {
return { code, message, action, retryable, ...(usage ? { usage } : {}) };
}
function isJobAnalysis(value: unknown): value is JobAnalysis {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
return (
typeof v.isJobPosting === "boolean" &&
typeof v.arrangement === "string" &&
typeof v.arrangementConfidence === "string" &&
Array.isArray(v.techStack) &&
typeof v.analyzedAt === "string"
);
}
|