Ticket #461 — no-login public withdrawal/cancellation flow. Full-stack build. Start with STACK GATE.
First: check stack health + issue labels + worktree state in parallel.
{"command":"echo \"=== API health ===\"; curl -fsS -o /dev/null -w \"%{http_code}\" http://localhost:4012/health 2>&1 || curl -fsS -o /dev/null -w \"%{http_code}\" http://localhost:4012/ 2>&1; echo \"\"; echo \"=== Portal ===\"; curl -fsS -o /dev/null -w \"%{http_code}\" http://localhost:3012/ 2>&1; echo \"\"; echo \"=== Embed ===\"; curl -fsS -o /dev/null -w \"%{http_code}\" http://localhost:3112/ 2>&1; echo \"\"; echo \"=== Dashboard ===\"; curl -fsS -o /dev/null -w \"%{http_code}\" http://localhost:7777/ 2>&1; echo \"\"","description":"Check health of all four services"}{"command":"env -u GITHUB_TOKEN /opt/homebrew/bin/gh issue view 461 -R gangkharai/sherpa-api --json number,title,labels,state 2>&1","description":"View issue 461 labels and state"}{"command":"echo \"=== target worktree top-level ===\"; ls -la /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance 2>&1 | head -40","description":"List target worktree contents"}=== API health === curl: (52) Empty reply from server 000curl: (52) Empty reply from server 000 === Portal === 200 === Embed === 200 === Dashboard === 200
{"labels":[{"id":"LA_kwDOPQJPvs8AAAAClW6cHA","name":"auto","description":"Auto-implement this ticket with Claude Code","color":"0e8a16"}],"number":461,"state":"OPEN","title":"No-login public withdrawal/cancellation flow"}=== target worktree top-level === total 1952 drwxr-xr-x@ 45 bakker staff 1440 Jun 1 08:40 . drwxr-xr-x 82 bakker staff 2624 Jun 1 08:39 .. drwxr-xr-x@ 2 bakker staff 64 Jun 1 08:40 .auto-proof drwxr-xr-x@ 2 bakker staff 64 Jun 1 08:39 .cache drwxr-xr-x@ 6 bakker staff 192 Jun 1 08:39 .claude drwxr-xr-x@ 3 bakker staff 96 Jun 1 08:39 .cursor -rw-r--r--@ 1 bakker staff 82 Jun 1 08:39 .cursorignore -rw-r--r--@ 1 bakker staff 510 Jun 1 08:39 .dockerignore -rw-r--r--@ 1 bakker staff 9305 Jun 1 08:39 .env -rw-r--r--@ 1 bakker staff 113 Jun 1 08:39 .git drwxr-xr-x@ 5 bakker staff 160 Jun 1 08:39 .github -rw-r--r--@ 1 bakker staff 234 Jun 1 08:39 .gitignore -rw-r--r--@ 1 bakker staff 1789 Jun 1 08:39 .mcp.json drwxr-xr-x@ 2 bakker staff 64 Jun 1 08:39 .npm -rw-r--r--@ 1 bakker staff 122 Jun 1 08:39 .npmrc drwxr-xr-x@ 3 bakker staff 96 Jun 1 08:39 .playwright-mcp -rw-r--r--@ 1 bakker staff 149 Jun 1 08:39 .prettierrc -rw-r--r--@ 1 bakker staff 398 Jun 1 08:39 .sequelizerc drwxr-xr-x@ 4 bakker staff 128 Jun 1 08:39 .vscode -rw-r--r--@ 1 bakker staff 6928 Jun 1 08:39 AGENTS.md -rw-r--r--@ 1 bakker staff 2314 Jun 1 08:39 CLAUDE.md -rw-r--r--@ 1 bakker staff 1666 Jun 1 08:39 Makefile -rw-r--r--@ 1 bakker staff 20342 Jun 1 08:39 README.md -rw-r--r--@ 1 bakker staff 8602 Jun 1 08:39 RELEASE_PROCESS.md -rw-r--r--@ 1 bakker staff 4486 Jun 1 08:39 TESTING.md -rw-r--r--@ 1 bakker staff 51091 Jun 1 08:39 bundle-results.png drwxr-xr-x@ 5 bakker staff 160 Jun 1 08:39 docker -rw-r--r--@ 1 bakker staff 1407 Jun 1 08:39 docker-compose.yml -rw-r--r--@ 1 bakker staff 1471 Jun 1 08:39 docker-compose.yml.original drwxr-xr-x@ 33 bakker staff 1056 Jun 1 08:39 docs -rw-r--r--@ 1 bakker staff 8235 Jun 1 08:39 env.reference -rw-r--r--@ 1 bakker staff 2553 Jun 1 08:39 eslint.config.mjs drwxr-xr-x@ 12 bakker staff 384 Jun 1 08:39 helpers drwxr-xr-x@ 7 bakker staff 224 Jun 1 08:39 k8s -rw-r--r--@ 1 bakker staff 1089 Jun 1 08:39 knip.json -rw-r--r--@ 1 bakker staff 2052 Jun 1 08:39 lib.ts -rw-r--r--@ 1 bakker staff 286472 Jun 1 08:39 linting.txt drwxr-xr-x@ 2 bakker staff 64 Jun 1 08:39 node_modules -rw-r--r--@ 1 bakker staff 511194 Jun 1 08:39 package-lock.json
Stack: API (4012) DOWN, Portal ✓, Embed ✓, Dashboard ✓. Issue only has auto label.
Set in-progress label + kick off API install via dashboard.
{"command":"env -u GITHUB_TOKEN /opt/homebrew/bin/gh issue edit 461 -R gangkharai/sherpa-api --add-label \"auto:in-progress\" --remove-label \"auto\" 2>&1","description":"Set in-progress label on issue"}{"command":"curl -fsS -X POST http://localhost:7777/api/api-action -H 'Content-Type: application/json' -d '{\"path\":\"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance\",\"action\":\"install\"}' 2>&1; echo \"\"","description":"Start API npm install via dashboard"}https://github.com/gangkharai/sherpa-api/issues/461
{"path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance","action":"install","status":"running","startedAt":1780328469883,"endedAt":null,"exitCode":null,"error":null,"logPath":"/var/folders/pr/7k4xc3v1715_0zbbw8tgwl5r0000gn/T/worktree-dashboard-logs/sherpa-api-api-461-no-login-public-withdrawal-cance-install.log","tail":""}Install running. Label set. While install runs, explore codebase in parallel. Poll install + launch exploration.
{"command":"sleep 20; curl -fsS \"http://localhost:7777/api/api-action?path=/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance\" 2>&1; echo \"\"","description":"Poll API install status"}{"description":"Map policy cancellation flow in api","prompt":"Explore the sherpa-api worktree at /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance to map how policy cancellation and withdrawal currently work. I need a precise map for implementing a NEW public (no-login) cancellation/withdrawal flow.\n\nFind and report (with exact file paths + line numbers + code excerpts):\n1. The existing policy cancellation / withdrawal logic. Search for terms like \"cancel\", \"withdraw\", \"cancellation\", \"withdrawal\", \"refund\". Where is a policy cancelled today (which route/service/function)? What status transitions happen? Is there a grace-period / cooling-off / 14-day withdrawal concept already?\n2. The Policy/PolicyOrder data model — the Sequelize model file(s), key fields (status enum values, surname/customer name fields, policy number field, dates, refund-related fields). Where are models defined?\n3. How refunds are issued today (payment provider integration, refund service).\n4. The audit logging mechanism — how are auditable events recorded? Is there an audit table/model/service? Show how to write an audit entry.\n5. Customer self-cancel inside the authenticated portal — which API endpoint backs it? Trace the route → controller → service.\n\nReport findings as a structured map. Be concrete with file paths and code excerpts. Do NOT write or edit any files.","subagent_type":"Explore"}{"description":"Map public routes, rate limit, email, tokens","prompt":"Explore the sherpa-api worktree at /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance. I'm implementing a public (no-authentication) cancellation flow with email verification, rate limiting, and anti-enumeration. I need to know what infrastructure already exists so I reuse conventions.\n\nFind and report (exact file paths + line numbers + code excerpts):\n1. ROUTE STRUCTURE: How are routes registered (Fastify)? Where is the route directory? How is a route plugin defined and registered? Show a representative route file (schema, handler). Is there an existing \"public\" or unauthenticated route namespace (e.g. /v1/public, webhooks, anything with no auth preHandler)? How is authentication normally enforced (preHandler/decorator/hook) and how would I OPT OUT for a public route?\n2. RATE LIMITING: Is @fastify/rate-limit or any rate limiter installed/configured? Search package.json and code. How is it applied per-route?\n3. EMAIL / SMS SENDING: How does the app send email today (service, provider — e.g. nodemailer, SES, Azure, Postmark)? Where is the email service? Show how to send an email with a link. Any SMS/OTP capability?\n4. ONE-TIME TOKEN / VERIFICATION LINK patterns: Any existing token generation for email verification, password reset, magic links, signed URLs? Crypto helpers (hmac, random token)? Show them.\n5. VALIDATION & SCHEMA: zod or TypeBox? Show how request/response schemas are declared in a route. (Check package.json — fastify@5, zod@4 are present.)\n6. CONFIG / ENV: How is configuration loaded (env vars, config module)? Where is the base URL / public URL configured?\n7. MIGRATIONS: Where do Sequelize migrations live, and what is the naming convention / template? Show the most recent migration file as an example.\n\nReport as a structured reference map with concrete excerpts. Do NOT write or edit any files.","subagent_type":"Explore"}{"path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance","action":"install","status":"done","startedAt":1780328469883,"endedAt":1780328482925,"exitCode":0,"error":null,"logPath":"/var/folders/pr/7k4xc3v1715_0zbbw8tgwl5r0000gn/T/worktree-dashboard-logs/sherpa-api-api-461-no-login-public-withdrawal-cance-install.log","tail":"\n> Sherpa [email protected] prepare\n> husky\n\ngit command not found\nadded 84 packages, removed 2 packages, changed 5 packages, and audited 934 packages in 4s\n\n221 packages are looking for funding\n run `npm fund` for details\n\n5 moderate severity vulnerabilities\n\nTo address issues that do not require attention, run:\n npm audit fix\n\nSome issues need review, and may require choosing\na different dependency.\n\nRun `npm audit` for details.\n"}{"command":"find /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance -type f -name \"*.ts\" -o -name \"*.js\" -o -name \"*.json\" | head -30"}/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/.cursor/environment.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/knip.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/k8s/sherpa-api.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/k8s/package-lock.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/k8s/package.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/k8s/synth.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/.mcp.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/.claude/settings.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/.claude/settings.local.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/docs/test-policies-data.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/package-lock.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/package.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/scripts/extract-product-labels.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/scripts/check-migration-tenant-rls.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/.github/scripts/validate-deployment.js /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/tsconfig.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/lib.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/.vscode/settings.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/.vscode/extensions.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/helpers/generate-single-secret.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/translations/generated/locales.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/translations/common-labels.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/translations/locales/es_419.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/translations/locales/nl.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/translations/locales/de.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/translations/locales/fi.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/translations/locales/it.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/translations/locales/fr.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/translations/locales/id.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/translations/locales/pt_BR.json
{"command":"find /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance -type f -name \"*.json\" | grep -E \"(package|tsconfig)\" | head -5"}/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/k8s/package-lock.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/k8s/package.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/package-lock.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/package.json /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/tsconfig.json
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/package.json"}{"command":"ls -la /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/ | grep -E \"^d\""}drwxr-xr-x@ 45 bakker staff 1440 Jun 1 08:40 . drwxr-xr-x 82 bakker staff 2624 Jun 1 08:39 .. drwxr-xr-x@ 2 bakker staff 64 Jun 1 08:40 .auto-proof drwxr-xr-x@ 2 bakker staff 64 Jun 1 08:39 .cache drwxr-xr-x@ 6 bakker staff 192 Jun 1 08:39 .claude drwxr-xr-x@ 3 bakker staff 96 Jun 1 08:39 .cursor drwxr-xr-x@ 5 bakker staff 160 Jun 1 08:39 .github drwxr-xr-x@ 2 bakker staff 64 Jun 1 08:39 .npm drwxr-xr-x@ 3 bakker staff 96 Jun 1 08:39 .playwright-mcp drwxr-xr-x@ 4 bakker staff 128 Jun 1 08:39 .vscode drwxr-xr-x@ 5 bakker staff 160 Jun 1 08:39 docker drwxr-xr-x@ 33 bakker staff 1056 Jun 1 08:39 docs drwxr-xr-x@ 12 bakker staff 384 Jun 1 08:39 helpers drwxr-xr-x@ 7 bakker staff 224 Jun 1 08:39 k8s drwxr-xr-x@ 2 bakker staff 64 Jun 1 08:39 node_modules drwxr-xr-x@ 14 bakker staff 448 Jun 1 08:39 refactor_queue drwxr-xr-x@ 6 bakker staff 192 Jun 1 08:39 scripts drwxr-xr-x@ 30 bakker staff 960 Jun 1 08:39 src drwxr-xr-x@ 10 bakker staff 320 Jun 1 08:39 translations
1 {
2 "name": "Sherpa API",
3 "version": "0.1.0",
4 "main": "src/service.ts",
5 "type": "module",
6 "imports": {
7 "@/*": "./src/*"
8 },
9 "scripts": {
10 "start": "tsx src/service.ts",
11 "dev": "tsx watch --exclude ./translations/** src/service.ts",
12 "db:migrate": "tsx src/db/migrate.ts",
13 "db:migrate:up": "tsx src/db/migrate.ts up",
14 "db:migrate:down": "tsx src/db/migrate.ts down",
15 "db:migrate:create": "tsx src/db/migrate.ts create",
16 "db:migrate:status": "tsx src/db/migrate.ts status",
17 "db:setup": "npm run db:migrate:up && npm run db:seed:cron",
18 "db:seed:cron": "bash scripts/seed-cron-tables.sh",
19 "db:seed:all": "tsx scripts/seed.ts",
20 "db:seed:undo:all": "tsx scripts/seed.ts --undo",
21 "test": "sh helpers/test.sh",
22 "renewal:process": "tsx src/scripts/policy-renewal.ts 2>&1",
23 "exchange-rates:update": "tsx src/scripts/update-exchange-rates.ts 2>&1",
24 "billing:daily": "tsx src/scripts/daily-recurring-billing.ts 2>&1",
25 "lifecycle:transitions": "tsx src/scripts/policy-lifecycle-transitions.ts 2>&1",
26 "embed-checkout:reconcile": "tsx src/scripts/embed-checkout-reconcile.ts 2>&1",
27 "payment-invite:nudge": "tsx src/scripts/payment-invite-nudge.ts 2>&1",
28 "worker": "tsx src/workers/runner.ts",
29 "audit": "npm audit",
30 "audit:fix": "npm audit fix",
31 "extract-labels": "tsx scripts/extract-product-labels.ts",
32 "translate": "dotenv -e .env -- lmt-translate",
33 "translate:verbose": "dotenv -e .env -- lmt-translate -v",
34 "translate:rebuild": "dotenv -e .env -- lmt-translate -v --rebuild",
35 "validate-translations": "lmt-validate-translations",
36 "format": "prettier --write .",
37 "format:check": "prettier --check .",
38 "check:imports": "bash scripts/check-internal-imports.sh",
39 "check:migrations": "tsx scripts/check-migration-tenant-rls.ts",
40 "lint": "eslint . && npm run check:imports && npm run check:migrations",
41 "lint:fix": "eslint . --fix",
42 "type-check": "tsc --noEmit",
43 "prepare": "husky",
44 "pre-commit": "lint-staged",
45 "pre-push": "npm run security-audit",
46 "security-audit": "npm audit",
47 "knip": "knip",
48 "knip:fix": "knip --fix",
49 "check-all": "npm run type-check && npm run lint && npm run format:check && npm run security-audit && npm run knip"
50 },
51 "keywords": [],
52 "author": "",
53 "license": "ISC",
54 "description": "",
55 "dependencies": {
56 "@anthropic-ai/foundry-sdk": "^0.2.0",
57 "@aws-sdk/client-bedrock-agent-runtime": "^3.956.0",
58 "@aws-sdk/client-bedrock-agentcore": "^3.985.0",
59 "@aws-sdk/client-s3": "^3.992.0",
60 "@aws-sdk/s3-request-presigner": "^3.992.0",
61 "@azure/ai-projects": "^2.0.0-beta.3",
62 "@azure/identity": "^4.13.0",
63 "@azure/msal-node": "^5.0.4",
64 "@azure/storage-blob": "^12.29.1",
65 "@fastify/compress": "^8.1.0",
66 "@fastify/cookie": "^11.0.2",
67 "@fastify/cors": "^11.2.0",
68 "@fastify/helmet": "^13.0.2",
69 "@fastify/jwt": "^10.0.0",
70 "@fastify/multipart": "^9.3.0",
71 "@fastify/oauth2": "^8.1.2",
72 "@fastify/rate-limit": "^10.3.0",
73 "@fastify/secure-session": "^8.2.0",
74 "@fastify/session": "^11.1.1",
75 "@fastify/swagger": "^9.6.1",
76 "@fastify/swagger-ui": "^5.2.5",
77 "@fastify/type-provider-typebox": "^5.2.0",
78 "@gangkharai/lmt": "^0.1.5",
79 "@google/genai": "^1.34.0",
80 "@google/generative-ai": "^0.24.1",
81 "@modelcontextprotocol/sdk": "^1.26.0",
82 "@sinclair/typebox": "^0.34.49",
83 "@types/multer": "^2.0.0",
84 "apache-arrow": "^21.1.0",
85 "axios": "^1.13.5",
86 "bcryptjs": "^3.0.3",
87 "bullmq": "^5.67.3",
88 "csv-parse": "^6.1.0",
89 "decimal.js": "^10.6.0",
90 "fast-xml-parser": "^5.3.6",
91 "fastify": "^5.8.1",
92 "fastify-plugin": "^5.1.0",
93 "iovalkey": "0.3.3",
94 "isomorphic-dompurify": "^3.13.0",
95 "joi": "^18.0.2",
96 "jsonwebtoken": "^9.0.3",
97 "mathjs": "^15.1.0",
98 "mri": "^1.2.0",
99 "multer": "^2.0.2",
100 "nodemailer": "^8.0.1",
101 "parquetjs": "^0.11.2",
102 "pdf-lib": "^1.17.1",
103 "pdf-to-png-converter": "^3.11.0",
104 "pg": "^8.18.0",
105 "pino": "^10.3.0",
106 "postcss": "^8.5.14",
107 "postcss-prefix-selector": "^2.1.1",
108 "qrcode": "^1.5.4",
109 "reflect-metadata": "^0.2.2",
110 "sequelize": "^6.37.7",
111 "sequelize-typescript": "^2.1.6",
112 "speakeasy": "^2.0.0",
113 "stripe": "^20.3.1",
114 "tsc-alias": "^1.8.16",
115 "tsx": "^4.21.0",
116 "typeid-js": "^1.2.0",
117 "ulid": "^3.0.2",
118 "umzug": "^3.8.2",
119 "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
120 "zod": "^4.1.12"
121 },
122 "devDependencies": {
123 "@types/jsonwebtoken": "^9.0.10",
124 "@types/lodash": "^4.17.21",
125 "@types/node": "^24.9.2",
126 "@types/nodemailer": "^7.0.3",
127 "@types/parquetjs": "^0.10.6",
128 "@types/pg": "^8.16.0",
129 "@types/qrcode": "^1.5.6",
130 "@types/speakeasy": "^2.0.10",
131 "@typescript-eslint/eslint-plugin": "^8.56.0",
132 "@typescript-eslint/parser": "^8.56.0",
133 "dotenv-cli": "^11.0.0",
134 "eslint": "^9.38.0",
135 "eslint-config-prettier": "^10.1.8",
136 "globals": "^16.4.0",
137 "husky": "^9.1.7",
138 "knip": "^5.83.1",
139 "lint-staged": "^16.2.7",
140 "pino-pretty": "^13.1.2",
141 "prettier": "^3.6.2",
142 "typescript": "^5.9.3",
143 "typescript-eslint": "^8.46.2"
144 },
145 "overrides": {
146 "fast-jwt": "^6.1.0",
147 "fast-xml-parser": "^5.3.6",
148 "minimatch": "^10.2.1",
149 "ajv": "^8.18.0",
150 "express-rate-limit": "^8.2.2",
151 "picomatch": ">=2.3.2",
152 "yaml": ">=2.8.3",
153 "brace-expansion": ">=5.0.5",
154 "uuid": "^14.0.0",
155 "thrift": "^0.23.0"
156 },
157 "lint-staged": {
158 "*.{js,ts}": [
159 "eslint --fix",
160 "prettier --write"
161 ],
162 "*.{json,md}": [
163 "prettier --write"
164 ]
165 }
166 }
167 {"command":"ls -la /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/ | head -20"}total 1952 drwxr-xr-x@ 45 bakker staff 1440 Jun 1 08:40 . drwxr-xr-x 82 bakker staff 2624 Jun 1 08:39 .. drwxr-xr-x@ 2 bakker staff 64 Jun 1 08:40 .auto-proof drwxr-xr-x@ 2 bakker staff 64 Jun 1 08:39 .cache drwxr-xr-x@ 6 bakker staff 192 Jun 1 08:39 .claude drwxr-xr-x@ 3 bakker staff 96 Jun 1 08:39 .cursor -rw-r--r--@ 1 bakker staff 82 Jun 1 08:39 .cursorignore -rw-r--r--@ 1 bakker staff 510 Jun 1 08:39 .dockerignore -rw-r--r--@ 1 bakker staff 9305 Jun 1 08:39 .env -rw-r--r--@ 1 bakker staff 113 Jun 1 08:39 .git drwxr-xr-x@ 5 bakker staff 160 Jun 1 08:39 .github -rw-r--r--@ 1 bakker staff 234 Jun 1 08:39 .gitignore -rw-r--r--@ 1 bakker staff 1789 Jun 1 08:39 .mcp.json drwxr-xr-x@ 2 bakker staff 64 Jun 1 08:39 .npm -rw-r--r--@ 1 bakker staff 122 Jun 1 08:39 .npmrc drwxr-xr-x@ 3 bakker staff 96 Jun 1 08:39 .playwright-mcp -rw-r--r--@ 1 bakker staff 149 Jun 1 08:39 .prettierrc -rw-r--r--@ 1 bakker staff 398 Jun 1 08:39 .sequelizerc drwxr-xr-x@ 4 bakker staff 128 Jun 1 08:39 .vscode
{"command":"find /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance -type d -name \"routes\" -o -type d -name \"src\" | head -5"}/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/mcp/routes /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/lens/synthetic-client/routes /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/lens/journey/routes /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/lens/optimizations/commission/quote/routes
{"command":"ls -la /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/"}total 112 drwxr-xr-x@ 30 bakker staff 960 Jun 1 08:39 . drwxr-xr-x@ 45 bakker staff 1440 Jun 1 08:40 .. -rw-r--r--@ 1 bakker staff 7913 Jun 1 08:39 AGENTS.md drwxr-xr-x@ 5 bakker staff 160 Jun 1 08:39 app -rw-r--r--@ 1 bakker staff 5918 Jun 1 08:39 app.ts drwxr-xr-x@ 4 bakker staff 128 Jun 1 08:39 config drwxr-xr-x@ 3 bakker staff 96 Jun 1 08:39 constants drwxr-xr-x@ 75 bakker staff 2400 Jun 1 08:39 controllers drwxr-xr-x@ 7 bakker staff 224 Jun 1 08:39 db drwxr-xr-x@ 10 bakker staff 320 Jun 1 08:39 domains drwxr-xr-x@ 4 bakker staff 128 Jun 1 08:39 error drwxr-xr-x@ 6 bakker staff 192 Jun 1 08:39 financials drwxr-xr-x@ 8 bakker staff 256 Jun 1 08:39 integrations -rw-r--r--@ 1 bakker staff 132 Jun 1 08:39 lifecycle.ts -rw-r--r--@ 1 bakker staff 2625 Jun 1 08:39 logger.ts drwxr-xr-x@ 119 bakker staff 3808 Jun 1 08:39 models -rw-r--r--@ 1 bakker staff 3616 Jun 1 08:39 persistentValkeyClient.ts drwxr-xr-x@ 9 bakker staff 288 Jun 1 08:39 plugins drwxr-xr-x@ 36 bakker staff 1152 Jun 1 08:39 routes -rw-r--r--@ 1 bakker staff 20412 Jun 1 08:39 routes.ts drwxr-xr-x@ 21 bakker staff 672 Jun 1 08:39 schemas drwxr-xr-x@ 9 bakker staff 288 Jun 1 08:39 scripts -rw-r--r--@ 1 bakker staff 3672 Jun 1 08:39 service.ts drwxr-xr-x@ 86 bakker staff 2752 Jun 1 08:39 services drwxr-xr-x@ 7 bakker staff 224 Jun 1 08:39 test drwxr-xr-x@ 5 bakker staff 160 Jun 1 08:39 trail drwxr-xr-x@ 19 bakker staff 608 Jun 1 08:39 types drwxr-xr-x@ 29 bakker staff 928 Jun 1 08:39 utils -rw-r--r--@ 1 bakker staff 3286 Jun 1 08:39 valkeyClient.ts drwxr-xr-x@ 4 bakker staff 128 Jun 1 08:39 workers
{"command":"find /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src -type f -name \"*.ts\" | grep -E \"(route|plugin|register)\" | head -15"}/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/payments/routes.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/mcp/routes.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/mcp/routes/get-v1-mcp-outbound-offer.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/mcp/routes/get-well-known-openid-configuration.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/mcp/routes/get-well-known-oauth-authorization-server.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/mcp/routes/get-well-known-openai-apps-challenge.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/mcp/routes/v1-mcp-[serverId]-openai.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/mcp/routes/v1-mcp-[serverId].ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/mcp/routes/get-well-known-oauth-protected-resource.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/lens/synthetic-client/routes/post-clients.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/lens/synthetic-client/routes/get-propensity-coherence.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/lens/synthetic-client/routes/get-metrics.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/lens/synthetic-client/routes/get-task-health.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/lens/synthetic-client/routes/get-task-cluster-health.ts
{"command":"ls -la /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/"}total 112 drwxr-xr-x@ 30 bakker staff 960 Jun 1 08:39 . drwxr-xr-x@ 45 bakker staff 1440 Jun 1 08:40 .. -rw-r--r--@ 1 bakker staff 7913 Jun 1 08:39 AGENTS.md drwxr-xr-x@ 5 bakker staff 160 Jun 1 08:39 app -rw-r--r--@ 1 bakker staff 5918 Jun 1 08:39 app.ts drwxr-xr-x@ 4 bakker staff 128 Jun 1 08:39 config drwxr-xr-x@ 3 bakker staff 96 Jun 1 08:39 constants drwxr-xr-x@ 75 bakker staff 2400 Jun 1 08:39 controllers drwxr-xr-x@ 7 bakker staff 224 Jun 1 08:39 db drwxr-xr-x@ 10 bakker staff 320 Jun 1 08:39 domains drwxr-xr-x@ 4 bakker staff 128 Jun 1 08:39 error drwxr-xr-x@ 6 bakker staff 192 Jun 1 08:39 financials drwxr-xr-x@ 8 bakker staff 256 Jun 1 08:39 integrations -rw-r--r--@ 1 bakker staff 132 Jun 1 08:39 lifecycle.ts -rw-r--r--@ 1 bakker staff 2625 Jun 1 08:39 logger.ts drwxr-xr-x@ 119 bakker staff 3808 Jun 1 08:39 models -rw-r--r--@ 1 bakker staff 3616 Jun 1 08:39 persistentValkeyClient.ts drwxr-xr-x@ 9 bakker staff 288 Jun 1 08:39 plugins drwxr-xr-x@ 36 bakker staff 1152 Jun 1 08:39 routes -rw-r--r--@ 1 bakker staff 20412 Jun 1 08:39 routes.ts drwxr-xr-x@ 21 bakker staff 672 Jun 1 08:39 schemas drwxr-xr-x@ 9 bakker staff 288 Jun 1 08:39 scripts -rw-r--r--@ 1 bakker staff 3672 Jun 1 08:39 service.ts drwxr-xr-x@ 86 bakker staff 2752 Jun 1 08:39 services drwxr-xr-x@ 7 bakker staff 224 Jun 1 08:39 test drwxr-xr-x@ 5 bakker staff 160 Jun 1 08:39 trail drwxr-xr-x@ 19 bakker staff 608 Jun 1 08:39 types drwxr-xr-x@ 29 bakker staff 928 Jun 1 08:39 utils -rw-r--r--@ 1 bakker staff 3286 Jun 1 08:39 valkeyClient.ts drwxr-xr-x@ 4 bakker staff 128 Jun 1 08:39 workers
{"command":"find /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src -maxdepth 2 -type d | head -20"}/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/types /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/payments /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/llm /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/products /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/mcp /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/integrations /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/lens /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/data /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/financials /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/app /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/trail /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/test /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/config /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/plugins /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/plugins/auth /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/constants /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/llm
{"pattern":"cancel|withdraw|cancellation|withdrawal","path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src","output_mode":"files_with_matches"}Found 128 files sherpa-api-api-461-no-login-public-withdrawal-cance/src/types/templates.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/types/permissions.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/templates/financialSummaryTemplate.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/pricing/mathEvaluator.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/pricing/AGENTS.md sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/policyStatusService.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/policyRenewalService.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/invoiceService.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/invoiceBackfillService.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/embedCheckoutSessionService.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/embedCheckoutSessionService.test.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/embedCheckoutSessionService.io.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/embedCheckoutReconcileService.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/embedCheckoutReconcileService.test.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/embedCheckoutDedupService.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/embedCheckoutDedupService.test.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/claimPayoutService.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/claimMessageService.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/cancellationService.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleWelcomeEmailService.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/automatedPolicyService.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/typebox/product/ProductTemplate.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/typebox/policy/PolicyOrder.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/typebox/data/TemplateLibrary.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/typebox/claim/ClaimRecordError.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/typebox/claim/ClaimRecord.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/typebox/claim/AutomatedPolicyRun.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/typebox/catalog/CancellationTerms.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/typebox/bundle/BundleSubscription.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/productApiController.schemas.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/policy.schemas.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/plus/template.schemas.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/plus/simulation.schemas.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/plus/policyOrder.schemas.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/plus/policy.schemas.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/plus/financials.schemas.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/plus/coverage.schemas.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/plus/claimRecord.schemas.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/plus/claimPayout.schemas.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/plus/cancellationTerms.schemas.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/plus/bundle.schemas.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/customerTemplate.schemas.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/claim.schemas.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/policyStatusRoutes.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/policyRoutes.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/plus/policyRoutes.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/plus/policyOrderRoutes.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/plus/index.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/plus/financialsRoutes.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/plus/claimPayoutRoutes.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/plus/cancellationTermsRoutes.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/plus/automatedPolicyRoutes.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/embedCheckoutRoutes.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/customerSelfRoutes.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/claimRoutes.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/bundleRoutes.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_TemplateLibrary.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_ProductTemplate.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_PolicyOrder.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_OrganizationPolicyStatus.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_ClaimRecord.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_ClaimPayout.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_CancellationTerms.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_BundleSubscription.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/CommissionCalculation.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/integrations/router.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/financials/types.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/financials/queries.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/payments/service.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/payments/routes.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/payments/providers/stripe/webhook.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/payments/providers/stripe/index.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/payments/interface.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/payments/AGENTS.md sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/lens/journey/tester.html sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/lens/flows/tasks.html sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/lens/flows/quoting-tester.html sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/abandonedPolicyStatus.apitest.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.16T00.00.01.add-test-mode-and-test-status.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.16T00.00.01.add-abandoned-policy-status.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.04.30T00.00.01.standardize-money-decimal-15-2.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.04.29T00.00.03.seed-default-policy-statuses.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.04.29T00.00.01.create-organization-policy-statuses.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.04.27T00.00.01.standardize-money-decimal-15-4.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.04.22T00.00.03.create-claim-payouts.ts s
{"command":"find /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models -name \"*policy*\" -o -name \"*order*\" | head -20"}(Bash completed with no output)
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes.ts","limit":100}1 import type { FastifyInstance } from 'fastify';
2 import { getRequired } from '../lib.js';
3 import packageJson from '../package.json' with { type: 'json' };
4 import {
5 anotherSherpaSchema,
6 handleAnotherSherpa,
7 } from '@/domains/lens/controllers/another-sherpa.js';
8 import { handleLensHealth } from '@/domains/lens/controllers/health.js';
9 import { handleHelloSherpa, helloSherpaSchema } from '@/domains/lens/controllers/hello-sherpa.js';
10 import {
11 handleQuote,
12 handleQuoteDev,
13 handleQuoteSignal,
14 quoteSchema,
15 quoteDevSchema,
16 quoteSignalSchema,
17 } from '@/domains/lens/controllers/quote.js';
18 import {
19 handleSimulationComplete,
20 simulationCompleteSchema,
21 } from '@/domains/lens/controllers/simulation-complete.js';
22 import {
23 handleSimulationDynamic,
24 simulationDynamicSchema,
25 } from '@/domains/lens/controllers/simulation-dynamic.js';
26 import {
27 handleSimulationHistogramsLoans,
28 simulationHistogramsLoansSchema,
29 } from '@/domains/lens/controllers/simulation-histograms-loans.js';
30 import {
31 handleSimulationStages,
32 simulationStagesSchema,
33 } from '@/domains/lens/controllers/simulation-stages.js';
34 import {
35 getTaskRequestById,
36 getTaskRequestSchema,
37 } from '@/domains/lens/controllers/task-requests.js';
38 import {
39 getTaskOutputs,
40 getTaskOutputsSchema,
41 getTaskOutputFileRedirect,
42 getTaskOutputFileRedirectSchema,
43 getTaskOutputFileUrl,
44 getTaskOutputFileUrlSchema,
45 } from '@/domains/lens/controllers/task-results/outputs.js';
46 import {
47 createTaskResult,
48 createTaskResultSchema,
49 getTaskResultById,
50 getTaskResultSchema,
51 } from '@/domains/lens/controllers/task-results.js';
52 import { getTaskDataFiles, getTaskDataFilesSchema } from '@/domains/lens/controllers/tasks/data.js';
53 import {
54 handleModelReset,
55 modelResetSchema,
56 } from '@/domains/lens/controllers/tasks/models/model.js';
57 import {
58 dataStatsToolSchema,
59 handleDataStatsTool,
60 handleDataStatsToolExtended,
61 } from '@/domains/lens/controllers/tools/data-stats.js';
62
63 // Optimization types
64 import { routeGetV1LensOptimizationTypes } from '@/domains/lens/optimizations/routes/get-optimization-types.js';
65
66 // Commission routes
67 import { routePostV1LensOptimizationsCommission } from '@/domains/lens/optimizations/commission/routes/post-optimization.js';
68 import { routeGetV1LensOptimizationsCommission } from '@/domains/lens/optimizations/commission/routes/get-optimizations.js';
69 import { routeGetV1LensOptimizationsCommissionId } from '@/domains/lens/optimizations/commission/routes/get-optimization-[id].js';
70 import { routePatchV1LensOptimizationsCommissionId } from '@/domains/lens/optimizations/commission/routes/patch-optimization-[id].js';
71 import { routeDeleteV1LensOptimizationsCommissionId } from '@/domains/lens/optimizations/commission/routes/delete-optimization-[id].js';
72 import { routePostV1LensOptimizationsModelsCommission } from '@/domains/lens/optimizations/commission/lm-models/routes/post-model.js';
73 import { routeGetV1LensOptimizationsModelsCommission } from '@/domains/lens/optimizations/commission/lm-models/routes/get-models.js';
74 import { routeGetV1LensOptimizationsModelsCommissionId } from '@/domains/lens/optimizations/commission/lm-models/routes/get-model-[id].js';
75 import { routePostV1LensOptimizationsCommissionIdStages } from '@/domains/lens/optimizations/commission/stages/routes/post-stage.js';
76 import { routeGetV1LensOptimizationsCommissionIdStages } from '@/domains/lens/optimizations/commission/stages/routes/get-stages.js';
77 import { routeGetV1LensOptimizationsCommissionIdStagesId } from '@/domains/lens/optimizations/commission/stages/routes/get-stage-[id].js';
78 import { routeGetV1LensOptimizationsCommissionIdActivations } from '@/domains/lens/optimizations/commission/stages/routes/get-stage-activations.js';
79 import { routePostV1LensCommissionQuote } from '@/domains/lens/optimizations/commission/quote/routes/post-quote.js';
80 import { routeGetV1LensOptimizationsCommissionIdQuotes } from '@/domains/lens/optimizations/commission/quote/routes/get-quotes.js';
81 import { routePostV1LensCommissionGenerateBatch } from '@/domains/lens/optimizations/commission/batch/routes/post-generate-batch.js';
82 import { routeDeleteV1LensCommissionBatchEvents } from '@/domains/lens/optimizations/commission/batch/routes/delete-batch-events.js';
83
84 // Synthetic client (admin-driven simulated traffic for bandit convergence validation)
85 import { routeGetV1LensSyntheticClientTaskShape } from '@/domains/lens/synthetic-client/routes/get-task-shape.js';
86 import { routeGetV1LensSyntheticClientTaskPosterior } from '@/domains/lens/synthetic-client/routes/get-task-posterior.js';
87 import { routeGetV1LensSyntheticClientTaskHealth } from '@/domains/lens/synthetic-client/routes/get-task-health.js';
88 import { routeGetV1LensSyntheticClientTaskClusterHealth } from '@/domains/lens/synthetic-client/routes/get-task-cluster-health.js';
89 import { routeGetV1LensSyntheticClientPropensityCoherence } from '@/domains/lens/synthetic-client/routes/get-propensity-coherence.js';
90 import { routeGetV1LensSyntheticClientTaskSuggestedConfig } from '@/domains/lens/synthetic-client/routes/get-task-suggested-config.js';
91 import { routePostV1LensSyntheticClientBanditReset } from '@/domains/lens/synthetic-client/routes/post-bandit-reset.js';
92 import { routeGetV1LensSyntheticClientMetrics } from '@/domains/lens/synthetic-client/routes/get-metrics.js';
93
94 // Persistent-client routes
95 import { routePostV1LensSyntheticClientClients } from '@/domains/lens/synthetic-client/routes/post-clients.js';
96 import { routeGetV1LensSyntheticClientClient } from '@/domains/lens/synthetic-client/routes/get-client.js';
97 import { routeGetV1LensSyntheticClientByOptimization } from '@/domains/lens/synthetic-client/routes/get-by-optimization.js';
98 import { routePatchV1LensSyntheticClientClient } from '@/domains/lens/synthetic-client/routes/patch-client.js';
99 import { routePostV1LensSyntheticClientActions, routePostV1LensSyntheticClientRate } from '@/domains/lens/synthetic-client/routes/post-actions.js';
100 import { routeDeleteV1LensSyntheticClientClient } from '@/domains/lens/synthetic-client/routes/delete-client.js';{"command":"ls /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/ | head -20"}addressRoutes.ts authRoutes.ts azureAdRoutes.ts bundleRoutes.ts claimRetryPayment.apitest.ts claimRoutes.ts customerPortalRoutes.ts customerSelfRoutes.ts customerTemplateRoutes.ts dataModelRoutes.ts docsPdfRoutes.apitest.ts docsPdfRoutes.ts embedCheckoutRoutes.test.ts embedCheckoutRoutes.ts funnelTemplateSetRoutes.ts funnelTemplatesRoutes.ts health.ts lib organizationRoutes.apitest.ts organizationRoutes.ts
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/cancellationService.ts"}1 import { CancellationTerms, RefundType, CancellationPeriodType } from '@/models/CancellationTerms.js';
2 import { PolicyOrder } from '@/models/PolicyOrder.js';
3 import { Product } from '@/models/Product.js';
4 import { getBusinessDate } from '@/utils/dateUtils.js';
5 import { translateTemplateString } from '@/services/templateI18nService.js';
6
7 /**
8 * Localize a fixed description template, then substitute `{{placeholders}}`.
9 * Cancellation descriptions are API-authored dynamic copy, so they belong in
10 * the API LMT dictionary (the templates below are registered as source strings
11 * in `translations/runtime-strings.ts`). Unknown locales / untranslated keys
12 * fall back to the English source.
13 */
14 function tr(
15 locale: string,
16 template: string,
17 params: Record<string, string | number> = {},
18 ): string {
19 let out = translateTemplateString(template, locale, null);
20 for (const [key, value] of Object.entries(params)) {
21 out = out.split(`{{${key}}}`).join(String(value));
22 }
23 return out;
24 }
25
26 export interface CancellationCalculation {
27 effectiveCancellationDate: string;
28 newExpiryDate: string;
29 refundAmount: number;
30 cancellationFee: number;
31 netRefund: number;
32 isWithinGracePeriod: boolean;
33 daysFromInception: number;
34 description: string;
35 calculationDetails: Record<string, unknown>;
36 }
37
38 export interface CancellationOption {
39 cancellationType: 'immediate' | 'end_of_billing_cycle' | 'specific_date' | 'earliest_allowed';
40 effectiveDate: string;
41 newExpiryDate: string;
42 refundAmount: number;
43 cancellationFee: number;
44 netRefund: number;
45 description: string;
46 isRecommended: boolean;
47 isWithinGracePeriod: boolean;
48 calculationDetails: Record<string, unknown>;
49 }
50
51 export interface CancellationPreview {
52 availableOptions: CancellationOption[];
53 recommendedOption: string;
54 currentPolicy: {
55 policyId: string;
56 inceptionDate: string;
57 currentExpiryDate: string | null;
58 paymentFrequency: string | null;
59 nextBillingDate?: string;
60 gracePeriodEndsOn: string;
61 daysFromInception: number;
62 };
63 requestedDate: string;
64 }
65
66 function addDays(dateString: string, days: number): string {
67 const date = new Date(dateString);
68 date.setDate(date.getDate() + days);
69 return date.toISOString().split('T')[0];
70 }
71
72 function extractPremiumFromInsurance(policyOrder: PolicyOrder): number {
73 // Use the dedicated premiumWithTax column if available
74 if (policyOrder.premiumWithTax !== null && policyOrder.premiumWithTax !== undefined) {
75 return Number(policyOrder.premiumWithTax);
76 }
77
78 // Fallback to extracting from insurance JSON (for backward compatibility)
79 if (!policyOrder.insurance || typeof policyOrder.insurance !== 'object') {
80 return 0;
81 }
82
83 const insuranceData = policyOrder.insurance as Record<string, unknown>;
84 const productNames = Object.keys(insuranceData);
85
86 for (const productName of productNames) {
87 const productData = insuranceData[productName] as Record<string, unknown>;
88 if (productData && typeof productData === 'object') {
89 const planNames = Object.keys(productData);
90 for (const planName of planNames) {
91 const planData = productData[planName] as Record<string, unknown>;
92 if (planData && typeof planData === 'object' && planData.premiumsByFrequency) {
93 const premiumsByFrequency = planData.premiumsByFrequency as Record<string, unknown>;
94 if (premiumsByFrequency && typeof premiumsByFrequency === 'object') {
95 const frequencyKey = Object.keys(premiumsByFrequency)[0];
96 if (frequencyKey) {
97 const premiumData = premiumsByFrequency[frequencyKey] as Record<string, unknown>;
98 if (premiumData && typeof premiumData === 'object' && premiumData.premiumWithTax) {
99 return parseFloat(String(premiumData.premiumWithTax));
100 }
101 }
102 }
103 }
104 }
105 }
106 }
107
108 return 0;
109 }
110
111 function getFirstOfNextMonth(dateString: string): string {
112 const date = new Date(dateString);
113 const nextMonth = new Date(date.getFullYear(), date.getMonth() + 1, 1);
114 return nextMonth.toISOString().split('T')[0];
115 }
116
117 function calculateDaysBetween(startDate: string, endDate: string): number {
118 const start = new Date(startDate);
119 const end = new Date(endDate);
120 const diffTime = Math.abs(end.getTime() - start.getTime());
121 return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
122 }
123
124 export async function previewCancellationOptions(
125 policyOrder: PolicyOrder,
126 requestedCancellationDate: string,
127 timezone: string,
128 locale: string = 'en',
129 ): Promise<CancellationPreview> {
130 if (!policyOrder.paymentFrequency) {
131 throw new Error('Payment frequency is required for cancellation calculation');
132 }
133
134 const organizationId = policyOrder.organizationId;
135 const product = await Product.findOne({ organizationId }, {
136 where: { productName: policyOrder.productName }
137 });
138 if (!product) {
139 throw new Error('Product not found');
140 }
141
142 const cancellationTerms = await CancellationTerms.findOne({ organizationId }, {
143 where: {
144 productId: product.id,
145 paymentFrequency: policyOrder.paymentFrequency,
146 active: true,
147 },
148 });
149
150 if (!cancellationTerms) {
151 throw new Error('No cancellation terms found for this product and payment frequency');
152 }
153
154 const inceptionDate = policyOrder.inceptionDate;
155 const originalExpiryDate = policyOrder.expiryDate;
156 const today = getBusinessDate(timezone);
157 const daysFromInception = calculateDaysBetween(inceptionDate, today);
158 const gracePeriodEndsOn = addDays(inceptionDate, cancellationTerms.gracePeriodDays);
159 const isWithinGracePeriod = daysFromInception <= cancellationTerms.gracePeriodDays;
160
161 const options: CancellationOption[] = [];
162
163 // Option 1: Immediate cancellation
164 const immediateResult = await calculateCancellationForDate(
165 policyOrder,
166 cancellationTerms,
167 today,
168 'immediate',
169 locale
170 );
171 options.push({
172 cancellationType: 'immediate',
173 effectiveDate: immediateResult.effectiveCancellationDate,
174 newExpiryDate: immediateResult.newExpiryDate,
175 refundAmount: immediateResult.refundAmount,
176 cancellationFee: immediateResult.cancellationFee,
177 netRefund: immediateResult.netRefund,
178 description: tr(locale, 'Cancel immediately - {{detail}}', { detail: immediateResult.description }),
179 isRecommended: false,
180 isWithinGracePeriod: immediateResult.isWithinGracePeriod,
181 calculationDetails: immediateResult.calculationDetails,
182 });
183
184 // Option 2: End of billing cycle (for all payment frequencies)
185 let nextBillingDate: string;
186 if (policyOrder.paymentFrequency === 'day') {
187 nextBillingDate = today;
188 } else if (policyOrder.paymentFrequency === 'month') {
189 nextBillingDate = getFirstOfNextMonth(today);
190 } else if (policyOrder.paymentFrequency === 'quarter') {
191 nextBillingDate = getNextQuarterStart(today);
192 } else if (policyOrder.paymentFrequency === 'year' && originalExpiryDate) {
193 nextBillingDate = originalExpiryDate;
194 } else if (policyOrder.paymentFrequency === 'one-time') {
195 nextBillingDate = today;
196 } else {
197 nextBillingDate = originalExpiryDate || today;
198 }
199
200 // Only show end of billing cycle if it's different from immediate and in the future
201 if (nextBillingDate !== today && nextBillingDate > today) {
202 const endOfCycl{"command":"find /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models -name \"*Policy*\" -type f"}/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/AutomatedPolicyRun.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/PolicyAttachment.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/PolicyOrder.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/PolicyAcceptanceConfig.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/PolicyOrderError.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/PolicyRenewalRun.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_PolicyAcceptanceConfig.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_PolicyOrderError.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_PolicySetting.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_PolicyMessage.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_PolicyRenewalRun.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_PolicyOrder.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_OrganizationPolicyStatus.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_Policy.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_PolicyAttachment.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_PolicyParameterCategoryPage.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_PolicyParameterCategory.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_PolicyParameter.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_AutomatedPolicyRun.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/Policy.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/PolicyParameterCategoryPage.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/OrganizationPolicyStatus.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/PolicySetting.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/PolicyMessage.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/PolicyParameterCategory.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/PolicyParameter.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/PolicySettingField.ts
{"command":"grep -r \"status.*enum\\|withdrawal\\|14.*day\\|cooling.off\\|grace.period\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models --include=\"*.ts\" | head -20"}/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_CancellationTerms.ts: COOLING_OFF = 'cooling_off', /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_CancellationTerms.ts: comment: 'Grace period in days for full refund (e.g., 14 days cooling-off period)', /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_CancellationTerms.ts: comment: 'Type of refund within grace period', /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_CancellationTerms.ts: comment: 'Type of refund after grace period',
{"command":"ls /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/ | grep -E \"auth|health|public\""}authRoutes.ts health.ts
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/authRoutes.ts","limit":60}1 import type { FastifyInstance, FastifyPluginAsync, FastifyRequest } from 'fastify';
2 import jwt from 'jsonwebtoken';
3 import { QueryTypes } from 'sequelize';
4
5 import { change2FAMethod, verify2FAMethodChange } from '../controllers/twoFactorController.js';
6 import { login, customerLogin, register, refreshToken, customerRefreshToken, logout, requestPasswordReset, resetPassword, socialAuthCallback, resend2FAEmail, createAccountWithToken } from '@/controllers/authController.js';
7 import { StatusError } from '@/error/error.js';
8 import { SocialAuthProvider } from '@/models/SocialAuthProvider.js';
9 import { User } from '@/models/User.js';
10 import { RefreshTokenRequest } from '@/types/fastify.js';
11 import { compareClientSecret } from '@/utils/crypto.js';
12 import { generateAccessToken, generateRefreshToken, generateClientAccessToken, generateClientRefreshToken } from '@/utils/tokenUtils.js';
13 import { systemSequelize } from '@/models/db.js';
14 import { getUserEffectivePermissions } from '@/plugins/rbac.js';
15 import {
16 loginBodySchema,
17 registerBodySchema,
18 refreshTokenBodySchema,
19 requestPasswordResetBodySchema,
20 resetPasswordBodySchema,
21 createAccountWithTokenBodySchema,
22 resend2FAEmailBodySchema,
23 change2FAMethodBodySchema,
24 verify2FAMethodChangeBodySchema,
25 clientCredentialsBodySchema
26 } from '@/schemas/auth.schemas.js';
27
28 interface ClientCredentialsRequest {
29 clientId: string;
30 clientSecret: string;
31 }
32
33 interface DecodedRefreshToken {
34 id: string;
35 type: string;
36 clientNumericId?: number | null;
37 }
38
39 export const authRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
40 // Public authentication routes (no authentication required)
41 await fastify.register(async function (fastify: FastifyInstance) {
42
43 // Login route
44 fastify.post('/login', {
45 schema: {
46 description: 'Authenticates a CRM/portal user (a Sherpa Plus operator inside a partner organization) with email and password and issues a JWT access token plus refresh token. Use this when a back-office user signs in to the partner portal. Do NOT use this for end-customers signing in on a customer-facing portal — use POST /v1/auth/customer/login instead. If the user has 2FA enabled, the response contains a short-lived `tempToken` and `require2FA: true` instead of access/refresh tokens; complete the flow by calling the 2FA verify endpoint with that tempToken.',
47 tags: ['Authentication'],
48 body: loginBodySchema,
49 response: {
50 200: {
51 type: 'object',
52 additionalProperties: true,
53 description: 'Either a successful login envelope (with access/refresh tokens) or a 2FA challenge envelope (with tempToken and require2FA=true).',
54 properties: {
55 status: { type: 'string', description: 'Always "success" on a 2xx response.', example: 'success' },
56 message: { type: 'string', description: 'Human-readable status. "Login successful" when fully authenticated, "Two-factor authentication required" when a 2FA challenge is being returned.', example: 'Login successful' },
57 tempToken: { type: 'string', description: 'Short-lived (10 minute) JWT issued only when 2FA is required. Pass this back to the 2FA verify endpoint along with the user-supplied 2FA code. Absent on non-2FA logins.', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' },
58 require2FA: { type: 'boolean', description: 'True when the user has 2FA enabled and the login is not yet complete. When true, only `tempToken` is returned (no access/refresh tokens).', example: false },
59 data: {
60 type: 'object',{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/health.ts"}1 // Health endpoints for the API. Two endpoints, two purposes:
2 // /v1/health/self — liveness. 200 unless draining. Never touches DB/Valkey.
3 // /v1/health — readiness. DB failure = instant 503. Valkey failure
4 // tolerated for VALKEY_GRACE_MS before flipping to 503.
5 //
6 // The grace window lives here, not in the K8s probe, so dashboards and manual
7 // consumers see the same logic. K8s probe config stays at defaults.
8 // Full reasoning: docs/health-checks.md
9 import type { FastifyRequest, FastifyReply } from 'fastify';
10
11 import { getRequired } from '../../lib.js';
12 import { sequelize } from '@/models/db.js';
13 import valkeyClient from '@/valkeyClient.js';
14 import { getIsDraining } from '@/lifecycle.js';
15 import type { SelfHealthResponse } from '@/types/contracts.js';
16
17 const COMMIT_HASH = getRequired('COMMIT_HASH');
18
19 interface ServiceHealth {
20 status: 'healthy' | 'unhealthy';
21 error?: string;
22 }
23
24 interface HealthResponse {
25 status: 'healthy' | 'draining' | 'unhealthy';
26 timestamp: string;
27 commitHash: string;
28 services: {
29 database: ServiceHealth;
30 valkey: ServiceHealth;
31 fastify: ServiceHealth;
32 };
33 }
34
35 async function checkDatabase(): Promise<ServiceHealth> {
36 try {
37 await sequelize.authenticate();
38 return {
39 status: 'healthy',
40 };
41 } catch (error) {
42 return {
43 status: 'unhealthy',
44 error: error instanceof Error ? error.message : 'Unknown database error',
45 };
46 }
47 }
48
49 // Valkey is allowed to be unhealthy for up to this window before it fails
50 // readiness. Short Valkey blips (container restart, network hiccup) shouldn't
51 // pull the pod out of routing — ioredis reconnects on its own. Sustained
52 // outage past this window is a real problem worth pulling traffic for.
53 const VALKEY_GRACE_MS = 60_000;
54 let valkeyUnhealthySince: number | null = null;
55
56 async function checkValkey(): Promise<ServiceHealth & { withinGrace?: boolean }> {
57 try {
58 if (!valkeyClient.connected) {
59 throw new Error('Valkey client not connected');
60 }
61 await valkeyClient.get('health-check-key');
62 valkeyUnhealthySince = null;
63 return { status: 'healthy' };
64 } catch (error) {
65 const now = Date.now();
66 if (valkeyUnhealthySince === null) valkeyUnhealthySince = now;
67 const withinGrace = now - valkeyUnhealthySince < VALKEY_GRACE_MS;
68 return {
69 status: 'unhealthy',
70 error: error instanceof Error ? error.message : 'Unknown Valkey error',
71 withinGrace,
72 };
73 }
74 }
75
76 export async function comprehensiveHealthCheck(request: FastifyRequest, reply: FastifyReply) {
77 const draining = getIsDraining();
78 const [database, valkey] = await Promise.all([
79 checkDatabase(),
80 checkValkey(),
81 ]);
82
83 const dbOk = database.status === 'healthy';
84 const valkeyOkForReadiness = valkey.status === 'healthy' || valkey.withinGrace === true;
85
86 const status: HealthResponse['status'] = draining
87 ? 'draining'
88 : dbOk && valkeyOkForReadiness
89 ? 'healthy'
90 : 'unhealthy';
91
92 const response: HealthResponse = {
93 status,
94 timestamp: new Date().toISOString(),
95 commitHash: COMMIT_HASH,
96 services: {
97 database,
98 valkey,
99 fastify: {
100 status: 'healthy',
101 },
102 },
103 };
104
105 if (status !== 'healthy') {
106 reply.code(503);
107 }
108
109 return response;
110 }
111
112 export async function selfHealthCheck(request: FastifyRequest, reply: FastifyReply): Promise<SelfHealthResponse> {
113 const draining = getIsDraining();
114 if (draining) {
115 reply.code(503);
116 }
117 return {
118 status: draining ? 'draining' : 'healthy',
119 timestamp: new Date().toISOString(),
120 commitHash: COMMIT_HASH,
121 };
122 }
123 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/PolicyOrder.ts","limit":150}1 import { _PolicyOrder } from './internal/_PolicyOrder.js';
2 import { toSafeModel } from './lib/safe-model.js';
3
4 export const PolicyOrder = toSafeModel(_PolicyOrder);
5 export type PolicyOrder = _PolicyOrder;
6
7 // Re-export raw class for db.ts registration, association includes, and type usage
8 export { _PolicyOrder };
9 export { RenewalStatus, TransactionType, CancelReason, CommissionStatus, DeletionReason } from './internal/_PolicyOrder.js';
10 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_PolicyOrder.ts","limit":150}1 import { Table, Column, Model, DataType, BelongsTo, ForeignKey, HasMany } from 'sequelize-typescript';
2 import { _BundleSubscription } from './_BundleSubscription.js';
3 import { _Client } from './_Client.js';
4 import { _Customer } from './_Customer.js';
5 import { _Product } from './_Product.js';
6 import { Country } from '../Country.js';
7
8 /**
9 * Policy status is now per-organization. Each org defines its own status codes
10 * via OrganizationPolicyStatuses, each mapped to a fixed lifecycleCategory
11 * (pending/active/cancelled/expired/superseded/renewal/suspended). Platform code that
12 * needs to read or write a status by its lifecycle meaning should use
13 * `policyStatusService` — never hard-code a status string.
14 */
15 export enum RenewalStatus {
16 INITIATED = 'initiated',
17 COMPLETED = 'completed',
18 CANCELLED = 'cancelled',
19 FAILED = 'failed',
20 }
21
22 export enum TransactionType {
23 NEW_BUSINESS = 'NEW_BUSINESS',
24 ENDORSEMENT = 'ENDORSEMENT',
25 RENEWAL = 'RENEWAL',
26 CANCELLATION = 'CANCELLATION',
27 SUSPENSION = 'SUSPENSION',
28 REINSTATEMENT = 'REINSTATEMENT',
29 }
30
31 export enum CancelReason {
32 UNAUTHORIZED_CHARGES = 'unauthorized_charges',
33 UNCLEAR_BENEFITS = 'unclear_benefits',
34 CONSIDERING_OTHER_PROVIDERS = 'considering_other_providers',
35 TOO_EXPENSIVE = 'too_expensive',
36 CLAIM_EXPERIENCE = 'claim_experience',
37 SERVICE_ISSUES = 'service_issues',
38 NO_LONGER_NEEDED = 'no_longer_needed',
39 }
40
41 export enum CommissionStatus {
42 PENDING = 'pending',
43 APPROVED = 'approved',
44 PAID = 'paid',
45 CANCELLED = 'cancelled',
46 }
47
48 export enum DeletionReason {
49 DUPLICATE = 'duplicate',
50 TEST = 'test',
51 OTHER = 'other',
52 }
53
54 @Table({
55 modelName: 'PolicyOrder',
56 tableName: 'PolicyOrders',
57 timestamps: true,
58 paranoid: true,
59 createdAt: 'createdAt',
60 updatedAt: 'updatedAt',
61 indexes: [
62 {
63 fields: ['quoteId'],
64 name: 'policy_orders_quote_id_idx',
65 },
66 {
67 fields: ['clientId'],
68 name: 'policy_orders_client_id_idx',
69 },
70 {
71 fields: ['policyNumber'],
72 name: 'policy_orders_policy_number_idx',
73 },
74 {
75 fields: ['status'],
76 name: 'policy_orders_status_idx',
77 },
78 {
79 fields: ['productName'],
80 name: 'policy_orders_product_name_idx',
81 },
82 {
83 fields: ['customerId'],
84 name: 'policy_orders_customer_id_idx',
85 },
86 ],
87 })
88 export class _PolicyOrder extends Model {
89 @Column({
90 type: DataType.INTEGER,
91 autoIncrement: true,
92 primaryKey: true,
93 })
94 declare id: number;
95
96 @Column({
97 type: DataType.UUID,
98 allowNull: false,
99 })
100 declare organizationId: string;
101
102 @Column({
103 type: DataType.UUID,
104 allowNull: false,
105 unique: true,
106 defaultValue: DataType.UUIDV4,
107 })
108 declare policyId: string;
109
110 @Column({
111 type: DataType.UUID,
112 allowNull: true,
113 })
114 declare quoteId: string | null;
115
116 @ForeignKey(() => _Product)
117 @Column({
118 type: DataType.INTEGER,
119 allowNull: true,
120 })
121 declare productId?: number;
122
123 @BelongsTo(() => _Product)
124 declare productRelation?: _Product;
125
126 @Column({
127 type: DataType.STRING(255),
128 allowNull: false,
129 })
130 declare productName: string;
131
132 @Column({
133 type: DataType.STRING(255),
134 allowNull: true,
135 })
136 declare productPlanName?: string;
137
138 @Column({
139 type: DataType.INTEGER,
140 allowNull: true,
141 })
142 declare productPlanId?: number;
143
144 @Column({
145 type: DataType.JSON,
146 allowNull: false,
147 defaultValue: {},
148 })
149 declare parameters: Record<string, unknown>;
150 {"command":"ls -la /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/plugins/"}total 112 drwxr-xr-x@ 9 bakker staff 288 Jun 1 08:39 . drwxr-xr-x@ 30 bakker staff 960 Jun 1 08:39 .. -rw-r--r--@ 1 bakker staff 3824 Jun 1 08:39 apiCapture.ts -rw-r--r--@ 1 bakker staff 4947 Jun 1 08:39 auditLog.ts drwxr-xr-x@ 7 bakker staff 224 Jun 1 08:39 auth -rw-r--r--@ 1 bakker staff 8637 Jun 1 08:39 auth-v2.ts -rw-r--r--@ 1 bakker staff 7855 Jun 1 08:39 domainContext.ts -rw-r--r--@ 1 bakker staff 5380 Jun 1 08:39 rbac.ts -rw-r--r--@ 1 bakker staff 13311 Jun 1 08:39 socialAuth.ts
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/plugins"}EISDIR: illegal operation on a directory, read '/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/plugins'
{"command":"ls /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/plugins/auth/"}bearer.ts client.ts lib.ts permit.ts widget.ts
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_PolicyOrder.ts","limit":350,"offset":150}150
151 @Column({
152 type: DataType.JSON,
153 allowNull: false,
154 defaultValue: {},
155 })
156 declare insurance: Record<string, unknown>;
157
158 @Column({
159 type: DataType.JSON,
160 allowNull: true,
161 })
162 declare product?: Record<string, unknown>;
163
164 @Column({
165 type: DataType.STRING(255),
166 allowNull: false,
167 })
168 declare network: string;
169
170 @Column({
171 type: DataType.JSON,
172 allowNull: false,
173 defaultValue: {},
174 })
175 declare requestHeaders: Record<string, unknown>;
176
177 // Locale active at purchase (e.g. 'en', 'sv', 'pt_BR'). Promoted out of
178 // requestHeaders.locale so messaging + email services can resolve the
179 // customer's channel language without parsing JSON. Falls back to
180 // requestHeaders.locale → Customer.preferredLanguage → 'en' when null.
181 @Column({
182 type: DataType.STRING(10),
183 allowNull: true,
184 })
185 declare language: string | null;
186
187 @Column({
188 type: DataType.JSON,
189 allowNull: false,
190 defaultValue: {},
191 })
192 declare requestBody: Record<string, unknown>;
193
194 @Column({
195 type: DataType.STRING(64),
196 allowNull: false,
197 defaultValue: 'pending',
198 })
199 declare status: string;
200
201 @Column({
202 type: DataType.DATEONLY,
203 allowNull: false,
204 })
205 declare inceptionDate: string;
206
207 @Column({
208 type: DataType.DATEONLY,
209 allowNull: false,
210 })
211 declare issueDate: string;
212
213 @Column({
214 type: DataType.DATEONLY,
215 allowNull: true,
216 })
217 declare expiryDate: string | null;
218
219 @ForeignKey(() => _Client)
220 @Column({
221 type: DataType.INTEGER,
222 allowNull: true,
223 })
224 declare clientId?: number;
225
226 @BelongsTo(() => _Client)
227 declare client?: _Client;
228
229 @ForeignKey(() => _Customer)
230 @Column({
231 type: DataType.UUID,
232 allowNull: true,
233 })
234 declare customerId?: string;
235
236 @BelongsTo(() => _Customer)
237 declare customer?: _Customer;
238
239 @Column({
240 type: DataType.STRING(100),
241 allowNull: true,
242 })
243 declare policyNumber?: string;
244
245 @Column({
246 type: DataType.ENUM(...Object.values(CancelReason)),
247 allowNull: true,
248 })
249 declare cancelReason?: CancelReason;
250
251 @Column({
252 type: DataType.DATEONLY,
253 allowNull: true,
254 })
255 declare cancellationDate?: string;
256
257 @Column({
258 type: DataType.DATEONLY,
259 allowNull: true,
260 })
261 declare supersededDate?: string;
262
263 @Column({
264 type: DataType.ENUM('customer', 'admin'),
265 allowNull: true,
266 })
267 declare cancelledBy?: 'customer' | 'admin';
268
269 @Column({
270 type: DataType.DATEONLY,
271 allowNull: true,
272 })
273 declare suspensionDate?: string;
274
275 @Column({
276 type: DataType.DATEONLY,
277 allowNull: true,
278 })
279 declare reinstatementDate?: string;
280
281 @Column({
282 type: DataType.STRING(255),
283 allowNull: true,
284 })
285 declare suspensionReason?: string;
286
287 @Column({
288 type: DataType.ENUM('customer', 'admin'),
289 allowNull: true,
290 })
291 declare suspendedBy?: 'customer' | 'admin';
292
293 @Column({
294 type: DataType.ENUM('year', 'month', 'quarter', 'one-time', 'day'),
295 allowNull: true,
296 })
297 declare paymentFrequency?: 'year' | 'month' | 'quarter' | 'one-time' | 'day';
298
299 @Column({
300 type: DataType.DECIMAL(15, 2),
301 allowNull: true,
302 comment: 'Base premium amount before taxes and fees',
303 })
304 declare basePremium?: number;
305
306 @Column({
307 type: DataType.DECIMAL(15, 2),
308 allowNull: true,
309 comment: 'Premium amount excluding taxes',
310 })
311 declare premiumWithoutTax?: number;
312
313 @Column({
314 type: DataType.DECIMAL(15, 2),
315 allowNull: true,
316 comment: 'Total premium amount including taxes',
317 })
318 declare premiumWithTax?: number;
319
320 @Column({
321 type: DataType.DECIMAL(15, 2),
322 allowNull: true,
323 comment: 'Sales tax amount',
324 })
325 declare salesTax?: number;
326
327 @Column({
328 type: DataType.DECIMAL(15, 2),
329 allowNull: true,
330 })
331 declare commissionTax?: number;
332
333 @Column({
334 type: DataType.STRING(3),
335 allowNull: true,
336 defaultValue: 'USD',
337 comment: 'Currency code for premium amounts',
338 })
339 declare currency?: string;
340
341 @ForeignKey(() => Country)
342 @Column({
343 type: DataType.STRING(2),
344 allowNull: false,
345 validate: {
346 isUppercase: true,
347 len: [2, 2],
348 },
349 })
350 declare country: string;
351
352 @Column({
353 type: DataType.STRING(3),
354 allowNull: true,
355 })
356 declare displayCurrency?: string;
357
358 @Column({
359 type: DataType.STRING(3),
360 allowNull: true,
361 })
362 declare baseCurrency?: string;
363
364 @Column({
365 type: DataType.DECIMAL(16, 6),
366 allowNull: true,
367 })
368 declare fxRate?: number;
369
370 @Column({
371 type: DataType.DATE,
372 allowNull: true,
373 })
374 declare fxRateAt?: Date;
375
376 @Column({
377 type: DataType.ENUM('prefix', 'suffix'),
378 allowNull: true,
379 defaultValue: 'prefix',
380 })
381 declare currencyPosition?: 'prefix' | 'suffix';
382
383 @Column({
384 type: DataType.STRING(1),
385 allowNull: true,
386 defaultValue: ',',
387 validate: {
388 isIn: [['.', ',']],
389 },
390 })
391 declare decimalSeparator?: string;
392
393 @Column({
394 type: DataType.STRING(1),
395 allowNull: true,
396 defaultValue: '.',
397 validate: {
398 isIn: [['.', ',']],
399 },
400 })
401 declare groupSeparator?: string;
402
403 @Column({
404 type: DataType.INTEGER,
405 allowNull: true,
406 defaultValue: 2,
407 })
408 declare decimalsAmount?: number;
409
410 @Column({
411 type: DataType.ENUM(
412 'DD/MM/YYYY',
413 'DD-MM-YYYY',
414 'DD.MM.YYYY',
415 'DD MMMM YYYY',
416 'MM/DD/YYYY',
417 'MM-DD-YYYY',
418 'MMMM DD, YYYY',
419 'YYYY-MM-DD'
420 ),
421 allowNull: true,
422 defaultValue: 'DD/MM/YYYY',
423 })
424 declare dateFormat?: string;
425
426 @Column({
427 type: DataType.UUID,
428 allowNull: true,
429 comment: 'Reference to simulation task ID for dynamic pricing',
430 })
431 declare taskId?: string;
432
433 @Column({
434 type: DataType.INTEGER,
435 allowNull: true,
436 comment: 'ID of the commission structure used for calculation',
437 })
438 declare commissionStructureId?: number;
439
440 @Column({
441 type: DataType.DECIMAL(5, 2),
442 allowNull: true,
443 comment: 'Baseline commission percentage from structure',
444 })
445 declare baselineCommissionPercentage?: number;
446
447 @Column({
448 type: DataType.DECIMAL(5, 2),
449 allowNull: true,
450 comment: 'Dynamic commission percentage (may differ from baseline)',
451 })
452 declare dynamicCommissionPercentage?: number;
453
454 @Column({
455 type: DataType.DECIMAL(5, 2),
456 allowNull: true,
457 comment: 'Variance from baseline commission percentage',
458 })
459 declare variancePercentage?: number;
460
461 @Column({
462 type: DataType.INTEGER,
463 allowNull: true,
464 comment: 'ID of allocation rule used for variance distribution',
465 })
466 declare allocationRuleId?: number;
467
468 @Column({
469 type: DataType.DECIMAL(15, 2),
470 allowNull: true,
471 comment: 'Total commission amount calculated',
472 })
473 declare totalCommissionAmount?: number;
474
475 @Colum{"command":"grep -n \"cancelledBy\\|cancelledOn\\|cancelReason\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_PolicyOrder.ts | head -20"}249: declare cancelReason?: CancelReason; 267: declare cancelledBy?: 'customer' | 'admin';
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/plugins/auth/bearer.ts","limit":80}1 import type { FastifyRequest, FastifyInstance } from 'fastify';
2 import { QueryTypes } from 'sequelize';
3 import {
4 createClientIdentity,
5 createOrganizationIdentity,
6 createUserIdentity,
7 type JwtTokenPayload,
8 validateOrganizationAccess
9 } from './lib.js';
10 import { StatusError } from '@/error/error.js';
11 import { systemSequelize } from '@/models/db.js';
12 import type { Organization } from '@/models/Organization.js';
13 import type {
14 Authentication,
15 BearerAuth,
16 RequestIdentity
17 } from '@/types/fastify.js';
18
19 // ==========================================
20 // Bearer Token Extraction
21 // ==========================================
22
23 export function extractBearerToken(authHeader: string): string {
24 const bearerPrefix = 'Bearer ';
25 if (!authHeader.startsWith(bearerPrefix)) {
26 throw new StatusError(401, 'Invalid authorization header format');
27 }
28 return authHeader.slice(bearerPrefix.length);
29 }
30
31 // ==========================================
32 // Authentication Strategies
33 // ==========================================
34
35 async function authenticateWithClientBearer(decoded: JwtTokenPayload): Promise<{ ident: RequestIdentity; auth: Authentication }> {
36 if (decoded.clientNumericId == null) {
37 throw new StatusError(401, 'Client token missing clientNumericId');
38 }
39
40 const clientData = await systemSequelize.query( // SAFE-SYSTEM: auth bootstrap
41 `SELECT c.id, c."clientId", c.name, c.active, c.scopes, c."userId", c."organizationId",
42 u.id as "user.id", u.email as "user.email", u."firstName" as "user.firstName",
43 u."lastName" as "user.lastName", u.role as "user.role"
44 FROM "Clients" c
45 LEFT JOIN "Users" u ON u.id = c."userId"
46 WHERE c.id = :clientNumericId AND c.active = true
47 LIMIT 1`,
48 {
49 replacements: { clientNumericId: decoded.clientNumericId },
50 type: QueryTypes.SELECT,
51 }
52 );
53
54 if (clientData.length === 0) {
55 throw new StatusError(401, 'Client in token not found');
56 }
57
58 const clientRow = clientData[0] as any;
59 const client = {
60 id: clientRow.id,
61 clientId: clientRow.clientId,
62 name: clientRow.name,
63 active: clientRow.active,
64 scopes: clientRow.scopes,
65 userId: clientRow.userId,
66 organizationId: clientRow.organizationId,
67 user: clientRow['user.id'] ? {
68 id: clientRow['user.id'],
69 email: clientRow['user.email'],
70 firstName: clientRow['user.firstName'],
71 lastName: clientRow['user.lastName'],
72 role: clientRow['user.role'],
73 } : null,
74 };
75
76 const bearerAuth: BearerAuth = {
77 organizationId: client.organizationId,
78 userId: client.user ? client.user.id : null,
79 client: {
80 id: client.id,{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/plugins/auth-v2.ts","limit":100}1 import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2 import fp from 'fastify-plugin';
3
4 import { StatusError } from '@/error/error.js';
5 import { handleErrors } from '@/error/handler.js';
6
7 // Import new auth strategies
8 import { authenticateWithBearer, extractBearerToken } from '@/plugins/auth/bearer.js';
9 import { authenticateWithClientCredentials } from '@/plugins/auth/client.js';
10
11 // Export token generation helpers
12 export { createPermitTokenPermit, generatePermitToken } from '@/plugins/auth/permit.js';
13
14 if (!process.env.JWT_SECRET) {
15 throw new Error('JWT_SECRET is not defined');
16 }
17
18 // ==========================================
19 // Core Auth Logic
20 // ==========================================
21
22 import { validateUserPermissions, validateWidgetPermissions } from '@/plugins/auth/lib.js';
23 import { authenticateWithPermitToken } from '@/plugins/auth/permit.js';
24 import { authenticateWithWidgetToken } from '@/plugins/auth/widget.js';
25 import type {
26 Authentication,
27 RequestIdentity,
28 } from '@/types/fastify.js';
29
30 // ==========================================
31 // Main Authentication Dispatcher
32 // ==========================================
33
34 async function authenticateRequest(request: FastifyRequest, fastify: FastifyInstance): Promise<{ ident: RequestIdentity; auth: Authentication }> {
35 const authHeader = request.headers.authorization;
36 const widgetTokenHeader = request.headers['x-widget-token'] as string;
37
38 // Widget token from header (check first)
39 if (widgetTokenHeader) {
40 return authenticateWithWidgetToken(widgetTokenHeader);
41 }
42
43 // Permit token authentication
44 if (authHeader?.startsWith('Permit ')) {
45 return authenticateWithPermitToken(authHeader, fastify, request);
46 }
47
48 // Client credentials authentication
49 if (authHeader?.startsWith('Client ')) {
50 return authenticateWithClientCredentials(authHeader);
51 }
52
53 // Bearer token authentication (JWT only)
54 if (authHeader?.startsWith('Bearer ')) {
55 const token = extractBearerToken(authHeader);
56 return authenticateWithBearer(token, fastify, request);
57 }
58
59 throw new StatusError(401, 'No valid authorization provided');
60 }
61
62 // ==========================================
63 // New Scope Validation Logic
64 // ==========================================
65
66 async function validateScopePermissions(request: FastifyRequest, requiredScopes: string[]): Promise<void> {
67 if (requiredScopes.length === 0) {
68 return;
69 }
70
71 if (!request.ident || !request.auth) {
72 throw new StatusError(401, 'Authentication required');
73 }
74
75 // Widget token authentication
76 if (request.auth.method === 'widget' && request.auth.widget) {
77 // Widget permissions are always an array
78 const widgetPermissions = request.auth.widget.permissions;
79 await validateWidgetPermissions(widgetPermissions, requiredScopes);
80 return;
81 }
82
83 // User authentication
84 if (request.auth.method === 'bearer' && request.ident.user) {
85 await validateUserPermissions(request.ident.user.id, requiredScopes, request.ident.org?.id);
86 return;
87 }
88
89 // Client authentication - clients have scopes
90 if (request.auth.method === 'client' && request.ident.client) {
91 const clientScopes = request.ident.client.scopes || [];
92 const hasAllScopes = requiredScopes.every(scope => clientScopes.includes(scope));
93
94 if (!hasAllScopes) {
95 throw new StatusError(403, 'Insufficient client permissions');
96 }
97 return;
98 }
99
100 throw new StatusError(403, 'Insufficient permissions');{"command":"find /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes -name \"*cancellation*\" -o -name \"*withdraw*\""}/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/plus/cancellationTermsRoutes.ts
{"command":"grep -r \"POST\\|GET.*cancel\\|PUT.*cancel\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes --include=\"*.ts\" | head -20"}/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/policyRoutes.ts: description: 'Bind an existing quote into a policy. Pricing/plan/coverages are taken from the quote; this endpoint just adds policyholder data and issues the policy. Triggers invoice generation (internal or external payment provider, depending on product configuration). Use POST /v1/policy/premium when the partner supplies its own pricing instead of a quote.',
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/policyRoutes.ts: description: 'Create a policy when the partner has its OWN pricing engine and is supplying pre-calculated premiums. Skips the quote step; the policy is bound directly with caller-supplied premiumsByFrequency and policyParameters. Use POST /v1/policy when pricing should come from Sherpa (the standard flow).',
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/policyRoutes.ts: description: 'Preview cancellation outcomes WITHOUT cancelling. Returns up to four options (immediate, end_of_billing_cycle, specific_date, earliest_allowed) computed against the product\'s configured cancellation terms (grace period, refund type, fees, notice period). Use the returned cancellationType values when calling POST /v1/policy/cancelPolicy/{policyId}. Returns 400 if the policy is already cancelled.',
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/policyRoutes.ts: description: 'Cancel a policy. Creates a CANCELLATION version and supersedes the active version (the policy chain remains intact). The chosen `selectedOption` MUST come from a prior call to GET /v1/policy/cancelPolicy/{policyId}/preview — invalid options return 400. For bundle policies, sibling policies may also be re-priced as a side-effect.',
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/socialAuthRoutes.ts: method: 'POST',
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/bundleRoutes.ts: description: 'UUID of the bundle quote returned by POST /v1/quote/bundle.',
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/bundleRoutes.ts: description: 'UUID of an existing bundle quote (from POST /v1/quote/bundle) to convert into policies.',
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/bundleRoutes.ts: description: 'UUID of the bundle subscription created by POST /v1/policy/bundle.',
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/bundleRoutes.ts: 'Returns the merged quote form for all products in a bundle. Parameters shared across products are placed first; product-specific parameters appear after. Use this to render a single combined questionnaire that, when submitted, can drive POST /v1/quote/bundle.',
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/bundleRoutes.ts: // POST /v1/quote/bundle — create bundle quote
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/bundleRoutes.ts: 'Creates a bundle quote — runs each selected product through pricing in parallel, applies the bundle\'s discount tiers, and returns the combined result plus the per-product quote session ids. The returned `bundleQuoteId` can be passed to GET /v1/quote/bundle/:bundleQuoteId to refetch and to POST /v1/policy/bundle to convert the quote into policies.',
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/bundleRoutes.ts: // POST /v1/policy/bundle — create bundle policies
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/bundleRoutes.ts: // GET /v1/policy/bundle/:subscriptionId/cancel-preview — preview cancel impact
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/bundleRoutes.ts: // POST /v1/policy/bundle/:subscriptionId/cancel — cancel product from bundle
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/bundleRoutes.ts: 'Cancels a single product within a bundle subscription. Records the cancellation, recomputes the bundle-level discount on the remaining products, and emits endorsement records for any policies whose pricing changed as a result. Use GET …/cancel-preview first if you need to show the customer the financial impact.',
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/bundleRoutes.ts: // POST /v1/policy/bundle/:subscriptionId/add — add product to bundle
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/uploadRoutes.ts: /** @deprecated Use POST /v1/data/upload-urls instead */
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/uploadRoutes.ts: 'DEPRECATED — replaced by POST /v1/data/upload-urls. Generates signed Azure Blob Storage upload URLs for one or more files. Each returned `uploadUrl` is a time-limited PUT target for the partner to upload bytes directly to storage. Sunset: 2025-06-01.',
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/claimRetryPayment.apitest.ts:describe('POST /v1/claim/:claimRecordId/retry-payment', () => {
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/claimRetryPayment.apitest.ts: 'POST',{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/authRoutes.ts","limit":120,"offset":39}39 export const authRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
40 // Public authentication routes (no authentication required)
41 await fastify.register(async function (fastify: FastifyInstance) {
42
43 // Login route
44 fastify.post('/login', {
45 schema: {
46 description: 'Authenticates a CRM/portal user (a Sherpa Plus operator inside a partner organization) with email and password and issues a JWT access token plus refresh token. Use this when a back-office user signs in to the partner portal. Do NOT use this for end-customers signing in on a customer-facing portal — use POST /v1/auth/customer/login instead. If the user has 2FA enabled, the response contains a short-lived `tempToken` and `require2FA: true` instead of access/refresh tokens; complete the flow by calling the 2FA verify endpoint with that tempToken.',
47 tags: ['Authentication'],
48 body: loginBodySchema,
49 response: {
50 200: {
51 type: 'object',
52 additionalProperties: true,
53 description: 'Either a successful login envelope (with access/refresh tokens) or a 2FA challenge envelope (with tempToken and require2FA=true).',
54 properties: {
55 status: { type: 'string', description: 'Always "success" on a 2xx response.', example: 'success' },
56 message: { type: 'string', description: 'Human-readable status. "Login successful" when fully authenticated, "Two-factor authentication required" when a 2FA challenge is being returned.', example: 'Login successful' },
57 tempToken: { type: 'string', description: 'Short-lived (10 minute) JWT issued only when 2FA is required. Pass this back to the 2FA verify endpoint along with the user-supplied 2FA code. Absent on non-2FA logins.', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' },
58 require2FA: { type: 'boolean', description: 'True when the user has 2FA enabled and the login is not yet complete. When true, only `tempToken` is returned (no access/refresh tokens).', example: false },
59 data: {
60 type: 'object',
61 additionalProperties: true,
62 description: 'Authenticated user payload. Only present when 2FA is NOT required (or already satisfied).',
63 properties: {
64 accessToken: { type: 'string', description: 'Short-lived JWT bearer access token. Send as `Authorization: Bearer <token>` on subsequent requests.', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' },
65 refreshToken: { type: 'string', description: 'Long-lived JWT used to mint a new access token via POST /v1/auth/refresh-token when the access token expires.', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' },
66 user: {
67 type: 'object',
68 additionalProperties: true,
69 description: 'The authenticated user record (CRM/portal user, not customer).',
70 properties: {
71 id: { type: 'string', format: 'uuid', description: 'User UUID.', example: '550e8400-e29b-41d4-a716-446655440000' },
72 email: { type: 'string', format: 'email', description: 'User email address (lowercased).', example: '[email protected]' },
73 firstName: { type: 'string', description: 'Given name.', example: 'Alex' },
74 lastName: { type: 'string', description: 'Family name.', example: 'Stone' },
75 role: { type: 'string', description: 'User role within their organization (e.g. "admin", "manager", "agent"). Drives RBAC checks.', example: 'admin' },
76 userType: { type: 'string', description: 'Discriminator for which user surface this account belongs to. Typically "crm" for portal users.', example: 'crm' },
77 organizationId: { type: 'string', format: 'uuid', description: 'UUID of the organization context the user logged in to. Resolved from the request domain when possible, otherwise the user\'s primary organization.', example: '550e8400-e29b-41d4-a716-446655440000' },
78 },
79 },
80 },
81 },
82 },
83 },
84 },
85 },
86 }, login);
87
88 // Customer login route (authenticates against Customers table)
89 fastify.post('/customer/login', {
90 schema: {
91 description: 'Authenticates an end-customer (the policyholder) against the Customers table for the organization that owns the request domain, and issues a customer-scoped JWT access/refresh token pair. Use this from the customer-facing portal of a partner organization. Requires the request to come from the organization\'s configured `portalDomain` — the Origin/Referer header is validated against it. Do NOT use this for partner back-office users; call POST /v1/auth/login instead.',
92 tags: ['Authentication'],
93 body: loginBodySchema,
94 response: {
95 200: {
96 type: 'object',
97 additionalProperties: true,
98 description: 'Successful customer login envelope.',
99 properties: {
100 status: { type: 'string', description: 'Always "success" on a 2xx response.', example: 'success' },
101 message: { type: 'string', description: 'Human-readable status.', example: 'Login successful' },
102 data: {
103 type: 'object',
104 additionalProperties: true,
105 description: 'Authenticated customer payload.',
106 properties: {
107 accessToken: { type: 'string', description: 'Customer-scoped JWT bearer access token. Send as `Authorization: Bearer <token>` to call /v1/customers/me/* endpoints.', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' },
108 refreshToken: { type: 'string', description: 'Long-lived JWT used to mint a new customer access token via POST /v1/auth/customer/refresh-token.', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' },
109 user: {
110 type: 'object',
111 additionalProperties: true,
112 description: 'The authenticated customer record. Note: in this response the customer is exposed under the `user` key for symmetry with the partner login response.',
113 properties: {
114 id: { type: 'string', format: 'uuid', description: 'Customer UUID.', example: '550e8400-e29b-41d4-a716-446655440000' },
115 email: { type: 'string', format: 'email', description: 'Customer email address (lowercased).', example: '[email protected]' },
116 firstName: { type: 'string', description: 'Customer given name.', example: 'Jane' },
117 lastName: { type: 'string', description: 'Customer family name.', example: 'Doe' },
118 role: { type: 'string', description: 'Always the literal string "customer".', example: 'customer' },
119 organizationId: { type: 'string', format: 'uuid', description: 'UUID of the organization that owns this customer (resolved from the request domain).', example: '550e8400-e29b-41d4-a716-446655440000' },
120 },
121 },
122 },
123 },
124 },
125 },
126 },
127 },
128 }, customerLogin);
129
130 // Register route
131 fastify.post('/register', {
132 schema: {
133 description: 'Registers a new end-customer in the organization that owns the request domain, persists their contact details, and immediately issues a customer-scoped JWT access/refresh token pair (no separate login step needed). Use this from the custom{"command":"grep -r \"rate-limit\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src --include=\"*.ts\" | head -10"}/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/app.ts:import rateLimit from '@fastify/rate-limit';
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/rateLimit.ts:import type { RateLimitOptions, errorResponseBuilderContext } from '@fastify/rate-limit';
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/error/handler.ts: // Handle FastifyError with a known statusCode (e.g. rate-limit 429, auth 401)
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/docsPdfRoutes.apitest.ts: // parallel tests sharing the same client IP rate-limit bucket. Enable
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/docsPdfRoutes.apitest.ts: // locally to verify rate-limit behavior end-to-end.
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/docsPdfRoutes.apitest.ts: test.skip('rate-limit returns 429 on 6th call within a minute', async () => {
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/quoteRoutes.ts: accepted: { type: 'number', description: 'Number of events the server accepted for forwarding (may be less than the input length if rate-limited or filtered).', example: 3 },{"command":"grep -r \"preHandler\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes --include=\"*.ts\" | head -5"}/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/policyRoutes.ts: preHandler: [fastify.authenticateScope(['policies:read'])], /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/policyRoutes.ts: preHandler: [fastify.authenticateScope(['policies:read'])], /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/policyRoutes.ts: preHandler: [fastify.authenticateScope(['policies:read'])], /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/policyRoutes.ts: preHandler: [fastify.authenticateScope(['policies:read'])], /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/policyRoutes.ts: preHandler: [fastify.authenticateScope(['policies:update'])],
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/policyRoutes.ts","limit":200}1 import { FastifyInstance, FastifyPluginAsync, FastifyRequest } from 'fastify';
2 import { getPolicyForm } from '@/controllers/policyController.js';
3 import { createPolicyOrderFromQuote, createPolicyOrderFromPremium, cancelPolicy, getPolicies, getPolicyById, getPoliciesByExternalClientId, getPolicyVersions, endorsePolicy, customerEndorsementQuote, directUpdatePolicyOrder, batchUpdatePolicyOrders, previewPolicySuspension, suspendPolicy, previewPolicyReinstatement, reinstatePolicy } from '@/controllers/policyOrderController.js';
4 import { StatusError } from '@/error/error.js';
5 import { ClientAuth } from '@/types/fastify.js';
6 import {
7 createPolicyFromQuoteBodySchema,
8 createPolicyFromPremiumBodySchema,
9 cancelPolicyBodySchema,
10 policyParamsSchema,
11 policyFormQuerySchema,
12 cancellationPreviewQuerySchema,
13 suspensionPreviewQuerySchema,
14 suspendPolicyBodySchema,
15 reinstatementPreviewQuerySchema,
16 reinstatePolicyBodySchema,
17 getPolicyByIdQuerystringSchema,
18 endorsePolicyBodySchema,
19 customerEndorsementQuoteBodySchema,
20 getPoliciesQuerystringSchema
21 } from '@/schemas/policy.schemas.js';
22 import {
23 directUpdatePolicyOrderBodySchema,
24 batchUpdatePolicyOrdersBodySchema
25 } from '@/schemas/plus/policyOrder.schemas.js';
26
27 interface CreatePolicyRequest {
28 quoteId: string;
29 status?: string;
30 productName?: string;
31 productPlanName?: string;
32 parameters: Record<string, unknown>;
33 inceptionDate?: string;
34 paymentFrequency?: 'year' | 'month' | 'quarter' | 'one-time' | 'day';
35 externalClientId?: string;
36 externalTransactionId?: string;
37 testMode?: boolean;
38 }
39
40 interface CreatePolicyFromPremiumRequest {
41 status?: string;
42 productName?: string;
43 productPlanName?: string;
44 quoteParameters: Record<string, unknown>;
45 policyParameters: Record<string, unknown>;
46 inceptionDate?: string;
47 premiumsByFrequency: Record<string, { premiumWithTax: number }>;
48 paymentFrequency?: 'year' | 'month' | 'quarter' | 'one-time' | 'day';
49 externalClientId?: string;
50 externalTransactionId?: string;
51 testMode?: boolean;
52 }
53
54 interface PolicyParams {
55 policyId: string;
56 }
57
58
59 interface PolicyFormQuery {
60 productName?: string;
61 policyId?: string;
62 quoteId?: string;
63 }
64
65 interface GetAllQuery {
66 userId?: string;
67 productName?: string;
68 policyId?: string;
69 productPlanName?: string;
70 status?: string;
71 issueDateFrom?: string;
72 issueDateTo?: string;
73 inceptionDateFrom?: string;
74 inceptionDateTo?: string;
75 expiryDateFrom?: string;
76 expiryDateTo?: string;
77 page?: number;
78 limit?: number;
79 }
80
81 // Reusable response sub-schemas. Both 200 and 201 responses on this file use
82 // `additionalProperties: true` because the policy resource is large and
83 // product-dependent (parameters, insurance, commission fields are dynamic).
84 const policyOrderObjectSchema = {
85 type: 'object',
86 additionalProperties: true,
87 description: 'Policy order resource. Top-level fields below are guaranteed; the `parameters`, `insurance`, and pricing/commission fields are product-specific. Customer-facing responses have commission and (when hidePrices is enabled) pricing fields stripped.',
88 properties: {
89 policyId: { type: 'string', description: 'Version-specific policy identifier (UUID).', example: '5d3ab05d-fab2-4f41-9d96-a21439e84799' },
90 policyNumber: { type: ['string', 'null'], description: 'Human-readable policy number, or null if not yet assigned.', example: 'POL-2026-001234' },
91 rootPolicyId: { type: ['string', 'null'], description: 'policyId of the original policy version. All endorsements/cancellations share the same rootPolicyId.', example: 'cd3ca828-cb2c-44af-b71f-daac5241dff4' },
92 previousPolicyId: { type: ['string', 'null'], description: 'policyId of the version this one replaced (null for the original).', example: null },
93 version: { type: 'integer', description: 'Monotonic version number within the policy chain (1 = original).', example: 2 },
94 transactionType: { type: 'string', description: 'Why this version exists: NEW_BUSINESS, ENDORSEMENT, CANCELLATION, or RENEWAL.', example: 'NEW_BUSINESS' },
95 status: { type: 'string', description: 'pending | active | renewal | cancelled | expired | superseded.', example: 'active' },
96 productId: { type: ['integer', 'null'], example: 12 },
97 productName: { type: 'string', example: 'travel-insurance' },
98 productPlanName: { type: ['string', 'null'], example: 'standard' },
99 quoteId: { type: ['string', 'null'], description: 'UUID of the quote this policy was bound from (null for /v1/policy/premium).', example: '7ccf8ee2-56b1-449e-b54a-34e03356e352' },
100 customerId: { type: ['string', 'null'], format: 'uuid', example: '550e8400-e29b-41d4-a716-446655440000' },
101 clientId: { type: ['integer', 'null'], description: 'Numeric client ID (sales channel). Null for direct-to-customer.', example: 5 },
102 organizationId: { type: 'string', format: 'uuid', example: '22222222-2222-2222-2222-222222222222' },
103 paymentFrequency: { type: ['string', 'null'], description: 'year | month | quarter | one-time | day.', example: 'month' },
104 issueDate: { type: ['string', 'null'], format: 'date', example: '2026-04-25' },
105 inceptionDate: { type: ['string', 'null'], format: 'date', example: '2026-04-25' },
106 expiryDate: { type: ['string', 'null'], format: 'date', example: '2027-04-25' },
107 effectiveDate: { type: ['string', 'null'], format: 'date', description: 'When THIS version takes effect.', example: '2026-04-25' },
108 cancellationDate: { type: ['string', 'null'], format: 'date', example: null },
109 cancelReason: { type: ['string', 'null'], example: null },
110 cancelledBy: { type: ['string', 'null'], description: 'customer | admin', example: null },
111 currency: { type: ['string', 'null'], example: 'EUR' },
112 basePremium: { type: ['number', 'string', 'null'], description: 'Base premium before tax/discounts.', example: 100 },
113 premiumWithoutTax: { type: ['number', 'string', 'null'], example: 100 },
114 premiumWithTax: { type: ['number', 'string', 'null'], example: 121 },
115 salesTax: { type: ['number', 'string', 'null'], example: 21 },
116 originalPremiumWithoutTax: { type: ['number', 'string', 'null'], description: 'Pre-bundle-discount premium without tax.', example: 100 },
117 originalPremiumWithTax: { type: ['number', 'string', 'null'], description: 'Pre-bundle-discount premium with tax.', example: 121 },
118 parameters: { type: 'object', additionalProperties: true, description: 'Policyholder/policy form data (product-specific keys).' },
119 insurance: { type: 'object', additionalProperties: true, description: 'Pricing engine output: per-product/per-plan premium structure with optional coverages array.' },
120 product: { type: ['object', 'null'], additionalProperties: true, description: 'Snapshot of product metadata at issue time (currency, formatting, documents).' },
121 customerSuspensions: { type: 'boolean', description: 'Whether the product has the customerSuspensions setting enabled. Customer-portal uses this to decide whether to render the Suspend action.', example: false },
122 hasActiveSuspensionTerms: { type: 'boolean', description: 'Whether at least one active SuspensionTerms row exists for the policy\'s productId + paymentFrequency. Customer-portal hides the Suspend action when false.', example: true },
123 createdAt: { type: 'string', format: 'date-time' },
124 updatedAt: { type: 'string', format: 'date-time' },
125 },
126 } as const;
127
128 const paginatedListEnvelope = (innerArrayKey: string) => ({
129 type: 'object',
130 properties: {
131 status: { type: 'string', example: 'success' },
132 data: {
133 ty{"command":"grep -n \"cancelPolicy\\|GET.*preview\\|POST.*cancel\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/policyRoutes.ts"}3:import { createPolicyOrderFromQuote, createPolicyOrderFromPremium, cancelPolicy, getPolicies, getPolicyById, getPoliciesByExternalClientId, getPolicyVersions, endorsePolicy, customerEndorsementQuote, directUpdatePolicyOrder, batchUpdatePolicyOrders, previewPolicySuspension, suspendPolicy, previewPolicyReinstatement, reinstatePolicy } from '@/controllers/policyOrderController.js';
9: cancelPolicyBodySchema,
516: }>('/policy/cancelPolicy/:policyId/preview', {
519: description: 'Preview cancellation outcomes WITHOUT cancelling. Returns up to four options (immediate, end_of_billing_cycle, specific_date, earliest_allowed) computed against the product\'s configured cancellation terms (grace period, refund type, fees, notice period). Use the returned cancellationType values when calling POST /v1/policy/cancelPolicy/{policyId}. Returns 400 if the policy is already cancelled.',
537: cancellationType: { type: 'string', description: 'Pass this string as `selectedOption` when posting to /v1/policy/cancelPolicy/{policyId}.', example: 'end_of_billing_cycle' },
586: }>('/policy/cancelPolicy/:policyId', {
589: description: 'Cancel a policy. Creates a CANCELLATION version and supersedes the active version (the policy chain remains intact). The chosen `selectedOption` MUST come from a prior call to GET /v1/policy/cancelPolicy/{policyId}/preview — invalid options return 400. For bundle policies, sibling policies may also be re-priced as a side-effect.',
593: body: cancelPolicyBodySchema,
629: const result = await cancelPolicy(req as unknown as import('fastify').FastifyRequest<{{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/rateLimit.ts"}1 import type { FastifyRequest } from 'fastify';
2 import type { RateLimitOptions, errorResponseBuilderContext } from '@fastify/rate-limit';
3 import { extractClientIp } from './ip.js';
4
5 /**
6 * Creates rate limit configuration for route-level rate limiting.
7 *
8 * @param max - Maximum number of requests allowed in the time window
9 * @param timeWindowMs - Time window in milliseconds
10 * @returns Rate limit configuration object
11 */
12 export function createRateLimitConfig(max: number, timeWindowMs: number): RateLimitOptions {
13 return {
14 max,
15 timeWindow: timeWindowMs,
16 keyGenerator: (request: FastifyRequest): string => {
17 // Use client IP as the key for rate limiting
18 // This ensures rate limiting is applied per client, not globally
19 return extractClientIp(request);
20 },
21 errorResponseBuilder: (request: FastifyRequest, context: errorResponseBuilderContext) => {
22 return {
23 error: 'Too Many Requests',
24 message: `Rate limit exceeded. Maximum ${max} requests per ${timeWindowMs / 1000} seconds allowed.`,
25 limit: context.max,
26 timeWindow: timeWindowMs / 1000,
27 reset: new Date(Date.now() + context.ttl),
28 };
29 },
30 enableDraftSpec: true, // Enable draft spec for better error messages
31 };
32 }
33
34 /**
35 * Default rate limit configuration for address autocomplete endpoint.
36 * Limits to 10 requests per minute per IP address.
37 */
38 export const autocompleteRateLimit = createRateLimitConfig(10, 60 * 1000);
39 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/app.ts","limit":60}1 import apiCapturePlugin from '@/plugins/apiCapture.js';
2 import { extendWithCors } from '@/app/cors.js';
3 import { extendWithSwagger, extendWithSwaggerUi } from '@/app/swagger.js';
4 import { StatusError } from '@/error/error.js';
5 import { errorHandler } from '@/error/handler.js';
6 import { partnerApiPlugin } from '@/integrations/index.js';
7 import { extendWithLogger } from '@/logger.js';
8 import auditLogPlugin from '@/plugins/auditLog.js';
9 import authV2Plugin from '@/plugins/auth-v2.js';
10 import domainContext from '@/plugins/domainContext.js';
11 import rbacPlugin from '@/plugins/rbac.js';
12 import socialAuthPlugin from '@/plugins/socialAuth.js';
13 import { extendWithBaseRoutes, extendWithRoutes } from '@/routes.js';
14 import { registerAllSchemas } from '@/schemas/typebox/index.js';
15 import '@/types/fastify.js';
16 import cookie from '@fastify/cookie';
17 import helmet from '@fastify/helmet';
18 import jwt from '@fastify/jwt';
19 import multipart from '@fastify/multipart';
20 import rateLimit from '@fastify/rate-limit';
21 import secureSession from '@fastify/secure-session';
22 import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
23 import type { FastifyInstance } from 'fastify';
24 import fastify from 'fastify';
25 import { getRequired } from '../lib.js';
26
27 const JWT_SECRET = getRequired('JWT_SECRET');
28 const COOKIE_SECRET = getRequired('COOKIE_SECRET');
29 const SESSION_SECRET = getRequired('SESSION_SECRET');
30 const SESSION_SALT = getRequired('SESSION_SALT');
31 const NODE_ENV = getRequired('NODE_ENV');
32 const SHERPA_ENV = getRequired('SHERPA_ENV');
33
34 // Flip to true to capture all inbound + outbound HTTP to capture/api-capture.jsonl
35 // Capturing = the developer does a manual flow and all events are logged.
36 // This is input for knowing perfect flows for building on later (e2e tests, response contracts, etc).
37 const CAPTURE_HTTP = false;
38
39 // Production safety check: fail startup if wildcard is used in production
40 const allowedOriginsEnv = getRequired('ALLOWED_ORIGINS');
41 if (SHERPA_ENV === 'production' && allowedOriginsEnv.includes('*')) {
42 throw new Error('SECURITY: Wildcard (*) in ALLOWED_ORIGINS is forbidden in production environment');
43 }
44
45 async function extendWithSecureSession(app: FastifyInstance): Promise<void> {
46 await app.register(secureSession, {
47 secret: SESSION_SECRET,
48 salt: SESSION_SALT,
49 expiry: 24 * 60 * 60,
50 cookie: {
51 path: '/',
52 httpOnly: true,
53 secure: false,
54 sameSite: 'lax',
55 },
56 });
57 }
58
59 async function extendWithErrorHandlers(app: FastifyInstance): Promise<void> {
60 app.setErrorHandler(errorHandler);{"command":"find /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src -type f -name \"*.ts\" | xargs grep -l \"nodemailer\\|email\\|mail\" | head -10"}/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/types/templates.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/types/fastify.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/types/passport.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/payments/providers/stripe/webhook.test.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/payments/providers/stripe/webhook.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/payments/providers/stripe/index.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/payments/interface.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/payments/service.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/integrations/test/audit.test.ts xargs: grep: terminated with signal 13; aborting
{"command":"find /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/integrations -type f -name \"*.ts\" | head -20"}/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/integrations/nocode/driver.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/integrations/router.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/integrations/index.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/integrations/driver-factory.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/integrations/interfaces.ts
{"command":"ls /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/integrations/"}{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/customerSelfRoutes.ts","limit":100}README.md driver-factory.ts index.ts interfaces.ts nocode router.ts
1 import type { FastifyInstance, FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
2 import { Op } from 'sequelize';
3 import { Customer } from '@/models/Customer.js';
4 import { Invoice } from '@/models/Invoice.js';
5 import { InvoiceLineItem } from '@/models/InvoiceLineItem.js';
6 import { Organization } from '@/models/Organization.js';
7 import { PolicyOrder } from '@/models/PolicyOrder.js';
8 import { PolicyStatusLifecycleCategory } from '@/models/OrganizationPolicyStatus.js';
9 import { getCodesForCategory } from '@/services/policyStatusService.js';
10 import { BundleSubscription } from '@/models/BundleSubscription.js';
11 import { StatusError } from '@/error/error.js';
12 import {
13 getCustomerInvoices,
14 getCustomerInvoiceById,
15 generateInvoicePdfHtmlPublic,
16 } from '@/services/invoiceService.js';
17 import { generateDocumentPdf } from '@/services/pdfService.js';
18 import { getLocaleFromHeader } from '@/services/localeService.js';
19 import { getPaymentStatus } from '@/domains/payments/service.js';
20 import { shouldHidePrices, getHiddenPriceProductIds } from '@/utils/hidePrices.js';
21 import logger from '@/logger.js';
22
23 interface FlatInvoice {
24 id: number;
25 organizationId: string;
26 policyId: string;
27 policyOrderId: number;
28 invoiceNumber: string;
29 type: string;
30 status: string;
31 billingPeriodStartDate: string;
32 billingPeriodEndDate: string;
33 invoiceDate: string;
34 premiumWithoutTax: string;
35 tax: string;
36 premiumWithTax: string;
37 currency: string;
38 metadata: Record<string, unknown> | null;
39 policyNumber: string | undefined;
40 paymentFrequency: string | undefined;
41 lineItems?: InvoiceLineItem[];
42 }
43
44 function flattenInvoices(invoices: Invoice[]): FlatInvoice[] {
45 return invoices.map((invoice) => {
46 const policyOrder = invoice.policyOrder as { policyNumber?: string; paymentFrequency?: string } | undefined;
47 return {
48 id: invoice.id,
49 organizationId: invoice.organizationId,
50 policyId: invoice.policyId,
51 policyOrderId: invoice.policyOrderId,
52 invoiceNumber: invoice.invoiceNumber,
53 type: invoice.type,
54 status: invoice.status,
55 billingPeriodStartDate: invoice.billingPeriodStartDate,
56 billingPeriodEndDate: invoice.billingPeriodEndDate,
57 invoiceDate: invoice.invoiceDate,
58 premiumWithoutTax: String(invoice.premiumWithoutTax),
59 tax: String(invoice.tax),
60 premiumWithTax: String(invoice.premiumWithTax),
61 currency: invoice.currency,
62 metadata: invoice.metadata as Record<string, unknown> | null,
63 policyNumber: policyOrder?.policyNumber,
64 paymentFrequency: policyOrder?.paymentFrequency,
65 lineItems: (invoice as Invoice & { lineItems?: InvoiceLineItem[] }).lineItems,
66 };
67 });
68 }
69
70 function requireCustomer(request: FastifyRequest): { customerId: string; organizationId: string } {
71 const customerId = request.auth?.bearer?.customerId;
72 const organizationId = request.ident?.org?.id;
73
74 if (!customerId) {
75 throw new StatusError(401, 'Customer authentication required');
76 }
77
78 if (!organizationId) {
79 throw new StatusError(400, 'Organization context required');
80 }
81
82 return { customerId, organizationId };
83 }
84
85 export const customerSelfRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
86 fastify.addHook('preHandler', fastify.authenticate);
87
88 // ==========================================
89 // Customer Profile
90 // ==========================================
91
92 fastify.get('/me', {
93 schema: {
94 description: 'Returns the profile record of the customer identified by the supplied customer-scoped Bearer token. Use this from the customer-facing portal to populate "My account" pages. Requires a Bearer token issued by POST /v1/auth/customer/login (or /v1/auth/register); the token determines which customer is "me" — the URL has no customer id.',
95 tags: ['Customer Self-Service'],
96 security: [{ Bearer: [] }],
97 response: {
98 200: {
99 type: 'object',
100 additionalProperties: true,{"command":"find /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src -name \"*audit*\" -o -name \"*trail*\" -o -name \"*log*\" | grep -v node_modules"}/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/types/trail.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/products/setup/setupCatalog.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/integrations/test/audit.test.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/integrations/runtime/audit.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/trail /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/trail/trail.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/plugins/auditLog.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/constants/auditLogActions.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/typebox/catalog /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/trail.schemas.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/plus/auditLog.schemas.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/logger.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.14T00.00.04.create-external-integration-call-logs.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2025.07.20T00.00.16.audit-logs.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.04.24T00.00.01.claim-payouts-1to1-and-audit.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/auditLogController.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/trailRoutes.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/plus/auditLogRoutes.ts
{"command":"find /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services -type f -name \"*mail*\" -o -name \"*email*\" | head -10"}/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/paymentRetryEmailService.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/welcomeEmailService.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/acceptanceEmailService.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/paymentInviteEmailService.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleWelcomeEmailService.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/quoteEmailService.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/emailService.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/jobs/bundleWelcomeEmailJob.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/jobs/paymentRetryEmailJob.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/jobs/welcomeEmailJob.ts
{"command":"ls /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/ | grep -E \"mail|email|notif\""}acceptanceEmailService.ts bundleWelcomeEmailService.ts claimEmailService.ts emailService.ts paymentInviteEmailService.ts paymentRetryEmailService.ts quoteEmailService.ts welcomeEmailService.ts
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/plugins/auditLog.ts"}1 import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2 import fp from 'fastify-plugin';
3
4 import logger from '@/logger.js';
5 import { systemSequelize } from '@/models/db.js';
6
7 interface RequestWithStartTime extends FastifyRequest {
8 startTime?: number;
9 }
10
11 // Allow-list of URL prefixes whose mutations should be audited. Method filtering
12 // (POST/PUT/PATCH/DELETE only) is applied separately, so GETs naturally never land here.
13 const AUDIT_PATTERNS: RegExp[] = [
14 /^\/v1\/auth\//,
15 /^\/v1\/admin\/users\//,
16 /^\/v1\/admin\/products\//,
17 /^\/v1\/admin\/policies\//,
18 /^\/v1\/admin\/quotes\//,
19 /^\/v1\/products\//,
20 /^\/v1\/policies\//,
21 /^\/v1\/quotes\//,
22 /^\/v1\/customer-portal\//,
23 /^\/v1\/plus\//,
24 ];
25
26 // Endpoints inside the allow-list above that we explicitly do NOT want in AuditLogs:
27 // pure compute / preview / render endpoints called at high frequency that would otherwise
28 // drown out the meaningful signal. Add new entries here when a noisy POST is identified.
29 const AUDIT_SKIP_PATTERNS: RegExp[] = [
30 // Token refresh — pre-existing exclusion, fires on every navigation.
31 /^\/v1\/auth\/refresh-token$/,
32 // Pricing computation — called per-keystroke from quote forms.
33 /^\/v1\/plus\/pricing\/calculate(-coverage)?$/,
34 // Template render preview / validation — high-volume editor previews.
35 /^\/v1\/plus\/templates\/render\//,
36 /^\/v1\/plus\/templates\/validate$/,
37 // LLM completion — chatty, no persistent state mutation.
38 /^\/v1\/plus\/llm\/generate$/,
39 // Purchase-probability simulation runs — pure compute.
40 /^\/v1\/plus\/simulations\/purchase-probability$/,
41 ];
42
43 async function auditLogPlugin(fastify: FastifyInstance) {
44 fastify.addHook('onRequest', async (request: RequestWithStartTime) => {
45 request.startTime = Date.now();
46 });
47
48 fastify.addHook('onResponse', async (request: RequestWithStartTime, reply: FastifyReply) => {
49 const action = `${request.method} ${request.url}`;
50 let organizationId: string | undefined;
51 try {
52 const endTime = Date.now();
53 const startTime = request.startTime || endTime;
54 const duration = endTime - startTime;
55
56 if (request.url.startsWith('/v1/health')) {
57 return;
58 }
59
60 if (!shouldAudit(request.method, request.url)) {
61 return;
62 }
63
64 organizationId = request.ident?.org?.id || request.userAuth?.organizationId;
65 if (!organizationId) {
66 return;
67 }
68
69 await systemSequelize.query( // SAFE-SYSTEM: audit log write
70 `INSERT INTO "AuditLogs" ("organizationId", "userId", "action", "details", "ipAddress", "userAgent", "createdAt", "updatedAt")
71 VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())`,
72 {
73 bind: [
74 organizationId,
75 request.userAuth?.id || null,
76 action,
77 JSON.stringify({
78 statusCode: reply.statusCode,
79 duration,
80 // Surface route params (e.g. policyId, productId) as searchable JSON keys —
81 // previously they only existed in the URL string, forcing LIKE scans on `action`.
82 params: serializeParams(request.params),
83 body: obfuscateSensitiveData(request.body),
84 }),
85 request.ip,
86 request.headers['user-agent'] || '',
87 ],
88 }
89 );
90 } catch (error) {
91 // Use a structured event name so this is greppable / alertable in log aggregation.
92 logger.error(
93 {
94 err: error,
95 auditAction: action,
96 organizationId,
97 userId: request.userAuth?.id,
98 },
99 'audit_log_insert_failed',
100 );
101 }
102 });
103 }
104
105 function obfuscateSensitiveData(body: unknown): unknown {
106 if (!body || typeof body !== 'object' || body === null) {
107 return body;
108 }
109
110 const sensitiveFields = ['password', 'clientSecret', 'token', 'refreshToken', 'accessToken'];
111 const obfuscated = { ...(body as Record<string, unknown>) };
112
113 for (const field of sensitiveFields) {
114 if (field in obfuscated && obfuscated[field]) {
115 obfuscated[field] = '***';
116 }
117 }
118
119 return obfuscated;
120 }
121
122 function serializeParams(params: unknown): Record<string, unknown> | undefined {
123 if (!params || typeof params !== 'object') return undefined;
124 const entries = Object.entries(params as Record<string, unknown>);
125 if (entries.length === 0) return undefined;
126 return Object.fromEntries(entries);
127 }
128
129 function shouldAudit(method: string, url: string): boolean {
130 const auditMethods = ['POST', 'PUT', 'DELETE', 'PATCH'];
131 if (!auditMethods.includes(method)) {
132 return false;
133 }
134
135 // Pattern matches must run against the path only — querystring is irrelevant.
136 const path = url.split('?')[0];
137
138 if (AUDIT_SKIP_PATTERNS.some((pattern) => pattern.test(path))) {
139 return false;
140 }
141
142 return AUDIT_PATTERNS.some((pattern) => pattern.test(path));
143 }
144
145 export default fp(auditLogPlugin, {
146 name: 'auditLog',
147 }); {"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/constants/auditLogActions.ts"}1 // Audit log action constants
2 export const AUDIT_ACTIONS = {
3 CREATE: 'CREATE',
4 READ: 'READ',
5 UPDATE: 'UPDATE',
6 DELETE: 'DELETE',
7 LOGIN: 'LOGIN',
8 LOGOUT: 'LOGOUT',
9 REGISTER: 'REGISTER',
10 PASSWORD_RESET: 'PASSWORD_RESET',
11 EXPORT: 'EXPORT',
12 IMPORT: 'IMPORT',
13 } as const;
14
15 export const ACTION_MAP = {
16 'POST': AUDIT_ACTIONS.CREATE,
17 'GET': AUDIT_ACTIONS.READ,
18 'PUT': AUDIT_ACTIONS.UPDATE,
19 'PATCH': AUDIT_ACTIONS.UPDATE,
20 'DELETE': AUDIT_ACTIONS.DELETE,
21 } as const;
22
23 export type AuditAction = typeof AUDIT_ACTIONS[keyof typeof AUDIT_ACTIONS];
24 export type HttpMethod = keyof typeof ACTION_MAP;
25
26 export default AUDIT_ACTIONS; {"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/trail/trail.ts","limit":100}1 import Valkey from 'iovalkey';
2 import { getRequired } from 'lib.js';
3
4 const VALKEY_HOST = getRequired('VALKEY_HOST');
5 const VALKEY_PORT = parseInt(getRequired('VALKEY_PORT'));
6 const VALKEY_DB = parseInt(getRequired('VALKEY_DB'));
7
8 export interface TrailMessage {
9 body: any;
10 ts: number;
11 // 1 when system message.
12 sys?: number;
13 }
14
15 function validateMessage(message: TrailMessage) {
16 if (!message) {
17 throw new Error('Message cannot be null or undefined');
18 }
19 if (typeof message.ts !== 'number' || message.ts <= 0) {
20 throw new Error('Message must have a valid timestamp');
21 }
22 }
23
24 export class Trail {
25 public readonly id: string;
26 public readonly channelKey: string;
27 public readonly bufferKey: string;
28
29 private client: any = null;
30 private subClient: any = null;
31 private disconnecting: Promise<void> | null = null;
32
33 constructor(id: string) {
34 if (!id || typeof id !== 'string') {
35 throw new Error('Trail ID must be a non-empty string');
36 }
37 this.id = id;
38 this.channelKey = `trail:channel:${id}`;
39 this.bufferKey = `trail:buffer:${id}`;
40 }
41
42 private ensureClient() {
43 if (this.disconnecting) {
44 throw new Error('Cannot create client while disconnecting');
45 }
46 if (!this.client) {
47 // Create a dedicated client for regular operations to match original implementation
48 this.client = new Valkey({
49 host: VALKEY_HOST,
50 port: VALKEY_PORT,
51 db: VALKEY_DB,
52 connectTimeout: 5000,
53 maxRetriesPerRequest: 3,
54 });
55 }
56 }
57
58 private ensureSubClient() {
59 if (this.disconnecting) {
60 throw new Error('Cannot create sub client while disconnecting');
61 }
62 if (!this.subClient) {
63 // Create a separate client for subscription to avoid "subscriber mode" conflicts
64 this.subClient = new Valkey({
65 host: VALKEY_HOST,
66 port: VALKEY_PORT,
67 db: VALKEY_DB,
68 connectTimeout: 5000,
69 maxRetriesPerRequest: 3,
70 });
71 }
72 }
73
74 public async disconnectClient() {
75 if (this.client) {
76 try {
77 await this.client.quit();
78 this.client = null;
79 } catch (error) {
80 console.warn('Error disconnecting client:', error);
81 }
82 }
83 }
84
85 public async disconnectSubClient() {
86 if (this.subClient) {
87 try {
88 await this.subClient.unsubscribe(this.channelKey);
89 await this.subClient.quit();
90 this.subClient = null;
91 } catch (error) {
92 console.warn('Error disconnecting sub client:', error);
93 }
94 }
95 }
96
97 public async createTrail(ttlSeconds: number) {
98 if (!ttlSeconds || ttlSeconds <= 0) {
99 throw new Error('TTL must be greater than 0');
100 }{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/emailService.ts","limit":100}1 import nodemailer from 'nodemailer';
2 import { getRequired } from '../../lib.js';
3 import logger from '@/logger.js';
4
5 const SHERPA_ENV = getRequired('SHERPA_ENV');
6 const MAILCATCHER_HOST = getRequired('MAILCATCHER_HOST');
7
8 let transporter: nodemailer.Transporter | null = null;
9
10 const createTransporter = async (): Promise<nodemailer.Transporter | null> => {
11 if (SHERPA_ENV === 'production') {
12 return nodemailer.createTransport({
13 service: 'SendGrid',
14 auth: {
15 user: process.env.SENDGRID_USER,
16 pass: process.env.SENDGRID_PASS,
17 },
18 });
19 } else {
20 // Use mailcatcher for development
21 return nodemailer.createTransport({
22 host: MAILCATCHER_HOST,
23 port: 1025,
24 secure: false,
25 ignoreTLS: true,
26 connectionTimeout: 5000,
27 greetingTimeout: 5000,
28 });
29 }
30 };
31
32 export interface EmailAttachment {
33 filename: string;
34 content: Buffer;
35 contentType: string;
36 }
37
38 const DEFAULT_FROM = '"Sherpa Insurance" <[email protected]>';
39
40 export const sendEmail = async (
41 to: string,
42 subject: string,
43 text: string,
44 html: string,
45 attachments?: EmailAttachment[],
46 from?: string
47 ): Promise<boolean> => {
48 try {
49 if (!transporter) {
50 transporter = await createTransporter();
51 }
52
53 if (!transporter) {
54 logger.error('Email transporter not available');
55 return false;
56 }
57
58 const mailOptions: nodemailer.SendMailOptions = {
59 from: from || DEFAULT_FROM,
60 to,
61 subject,
62 text,
63 html,
64 };
65
66 if (attachments && attachments.length > 0) {
67 mailOptions.attachments = attachments.map(attachment => ({
68 filename: attachment.filename,
69 content: attachment.content,
70 contentType: attachment.contentType,
71 }));
72 }
73
74 const info = await transporter.sendMail(mailOptions);
75 logger.info({ messageId: info.messageId, to, subject }, 'Email sent successfully:');
76
77 return true;
78 } catch (error) {
79 const errorInfo: Record<string, unknown> = {
80 error: error instanceof Error ? error.message : String(error),
81 stack: error instanceof Error ? error.stack : undefined,
82 };
83
84 if (error instanceof Error && 'errno' in error) {
85 errorInfo.errno = (error as Error & { errno: unknown }).errno;
86 }
87 if (error instanceof Error && 'code' in error) {
88 errorInfo.code = (error as Error & { code: unknown }).code;
89 }
90 if (error instanceof Error && 'syscall' in error) {
91 errorInfo.syscall = (error as Error & { syscall: unknown }).syscall;
92 }
93
94 logger.error(errorInfo, 'Detailed email sending error:');
95
96 return false;
97 }
98 };
99
100 export const resetTransporter = (): void => {{"command":"grep -r \"crypto\\|token\\|generateToken\\|randomBytes\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils --include=\"*.ts\" | head -15"}/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/llm/mesh-handler.ts: id: crypto.randomUUID(),
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/llm/mesh-handler.ts: id: crypto.randomUUID(),
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/llm/mesh-handler.ts: 'X-Thread-Id': crypto.randomUUID(),
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/llm/mesh-handler.ts: id: crypto.randomUUID(),
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/llm/openai-handler.ts: (payload as { max_completion_tokens: number }).max_completion_tokens = params.maxTokens;
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/llm/openai-handler.ts: (payload as { max_tokens: number }).max_tokens = params.maxTokens;
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/llm/claude-handler.ts: max_tokens: params.maxTokens,
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/llm/azure-gpt-handler.ts: (payload as { max_completion_tokens: number }).max_completion_tokens = params.maxTokens;
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/llm/azure-gpt-handler.ts: (payload as { max_tokens: number }).max_tokens = params.maxTokens;
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/llm/aws-bedrock-handler.ts:import { randomUUID } from 'crypto';
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/tokenUtils.ts:import crypto from 'crypto';
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/tokenUtils.ts:import jwt from 'jsonwebtoken';
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/tokenUtils.ts: return crypto.randomBytes(3).toString('hex').toUpperCase();
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/tokenUtils.ts: codes.push(crypto.randomBytes(4).toString('hex'));
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/tokenUtils.ts: { tokenType: 'client', clientNumericId: client.id, organizationId: client.organizationId },{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/tokenUtils.ts"}1 import crypto from 'crypto';
2 import jwt from 'jsonwebtoken';
3 import { Client } from '@/models/Client.js';
4 import { Customer } from '@/models/Customer.js';
5 import { User } from '@/models/User.js';
6
7 export const generateAccessToken = (user: User, clientNumericId: number | null, targetOrganizationId?: string) => {
8 const payload: any = { id: user.id, email: user.email, role: user.role, clientNumericId };
9
10 if (targetOrganizationId) {
11 payload.organizationId = targetOrganizationId;
12 }
13
14 return jwt.sign(
15 payload,
16 process.env.JWT_SECRET as string,
17 { expiresIn: '15m' }
18 );
19 };
20
21 export const generateRefreshToken = (user: User, clientNumericId: number | null, organizationId?: string) => {
22 const payload: any = { id: user.id, clientNumericId };
23
24 if (organizationId) {
25 payload.organizationId = organizationId;
26 }
27
28 return jwt.sign(payload, process.env.JWT_REFRESH_SECRET as string, { expiresIn: '7d' });
29 };
30
31 export const generateSetupToken = (user: User) => {
32 return jwt.sign(
33 { id: user.id, email: user.email, require2FA: true },
34 process.env.JWT_SECRET as string,
35 {
36 expiresIn: '15m',
37 }
38 );
39 };
40
41 export const generateTempToken = (user: User) => {
42 return jwt.sign(
43 { id: user.id, email: user.email, require2FA: true },
44 process.env.JWT_SECRET as string,
45 {
46 expiresIn: '5m',
47 }
48 );
49 };
50
51 export const generateEmailToken = () => {
52 return crypto.randomBytes(3).toString('hex').toUpperCase();
53 };
54
55 export const generateBackupCodes = () => {
56 const codes = [];
57 for (let i = 0; i < 10; i++) {
58 codes.push(crypto.randomBytes(4).toString('hex'));
59 }
60 return codes;
61 };
62
63 export const generateClientAccessToken = (client: Client) => {
64 return jwt.sign(
65 { tokenType: 'client', clientNumericId: client.id, organizationId: client.organizationId },
66 process.env.JWT_SECRET as string,
67 { expiresIn: '15m' }
68 );
69 };
70
71 export const generateClientRefreshToken = (client: Client) => {
72 return jwt.sign(
73 { tokenType: 'client', clientNumericId: client.id },
74 process.env.JWT_REFRESH_SECRET as string,
75 { expiresIn: '7d' }
76 );
77 };
78
79 export const generatePortalAccessToken = (
80 externalClientId: string,
81 organizationId: string,
82 clientNumericId: number
83 ) => {
84 return jwt.sign(
85 {
86 tokenType: 'portal',
87 externalClientId,
88 organizationId,
89 clientNumericId,
90 },
91 process.env.JWT_SECRET as string,
92 { expiresIn: '1h' }
93 );
94 };
95
96 export const generatePortalRefreshToken = (
97 externalClientId: string,
98 organizationId: string,
99 clientNumericId: number
100 ) => {
101 return jwt.sign(
102 {
103 tokenType: 'portal',
104 externalClientId,
105 organizationId,
106 clientNumericId,
107 },
108 process.env.JWT_REFRESH_SECRET as string,
109 { expiresIn: '7d' }
110 );
111 };
112
113 export const generateCustomerAccessToken = (customer: Customer, organizationId: string) => {
114 return jwt.sign(
115 {
116 tokenType: 'customer',
117 id: customer.id,
118 email: customer.email,
119 role: 'customer',
120 organizationId,
121 },
122 process.env.JWT_SECRET as string,
123 { expiresIn: '1h' }
124 );
125 };
126
127 export const generateCustomerRefreshToken = (customer: Customer, organizationId: string) => {
128 return jwt.sign(
129 {
130 tokenType: 'customer',
131 id: customer.id,
132 organizationId,
133 },
134 process.env.JWT_REFRESH_SECRET as string,
135 { expiresIn: '7d' }
136 );
137 };
138
139 export const PDF_DOWNLOAD_TOKEN_TTL_SECONDS = 60 * 60;
140
141 export const generatePdfDownloadToken = (policyId: string, organizationId: string) => {
142 return jwt.sign(
143 {
144 tokenType: 'pdf-download',
145 policyId,
146 organizationId,
147 },
148 process.env.JWT_SECRET as string,
149 { expiresIn: PDF_DOWNLOAD_TOKEN_TTL_SECONDS }
150 );
151 };
152
153 interface InternalTokenIdent {
154 user: { id: string; email: string | null; role: string };
155 org: { id: string };
156 }
157
158 /**
159 * Generate an internal Bearer token for service-to-service calls.
160 * Used when widget auth needs to make internal API calls that require Bearer auth.
161 * Short-lived (5 min) token with user and org context.
162 */
163 export function generateInternalServiceToken(
164 ident: InternalTokenIdent,
165 clientNumericId: number | null
166 ): string {
167 const token = jwt.sign(
168 {
169 id: ident.user.id,
170 email: ident.user.email,
171 role: ident.user.role,
172 clientNumericId,
173 organizationId: ident.org.id,
174 },
175 process.env.JWT_SECRET as string,
176 { expiresIn: '5m' }
177 );
178 return `Bearer ${token}`;
179 }
180 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/CancellationTerms.ts"}{"command":"find /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils -type f -name \"crypto*\""}1 import { _CancellationTerms } from './internal/_CancellationTerms.js';
2 import { toSafeModel } from './lib/safe-model.js';
3
4 export const CancellationTerms = toSafeModel(_CancellationTerms);
5 export type CancellationTerms = _CancellationTerms;
6
7 // Re-export raw class for db.ts registration, association includes, and type usage
8 export { _CancellationTerms };
9 export { RefundType, CancellationPeriodType } from './internal/_CancellationTerms.js';
10 /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/crypto.ts
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/crypto.ts"}1 import crypto from 'crypto';
2 import nodeCrypto from 'node:crypto';
3 import bcrypt from 'bcryptjs';
4 import jwt from 'jsonwebtoken';
5 import type { StringValue } from 'ms';
6
7 /**
8 * Hash a client secret using bcrypt
9 * @param {string} clientSecret The plain text client secret
10 * @returns {Promise<string>} Hashed client secret
11 */
12 export const hashClientSecret = async (clientSecret: string) => {
13 // Use a cost factor of 10 for production use
14 // For higher security, increase to 12 or more, but be aware of performance implications
15 const saltRounds = 10;
16 return await bcrypt.hash(clientSecret, saltRounds);
17 };
18
19 /**
20 * Generate a secure random client secret
21 * @param {number} length Length of the client secret
22 * @param {string} prefix Prefix to add to the client secret
23 * @returns {string} A secure random client secret
24 */
25 export const generateClientSecret = (length: number, prefix: string) => {
26 const bytes = nodeCrypto.randomBytes(length);
27 return `${prefix}${bytes.toString('hex')}`;
28 };
29
30 /**
31 * Generate a secure random client ID
32 * @param {number} length Length of the client ID
33 * @param {string} prefix Prefix to add to the client ID
34 * @returns {string} A secure random client ID
35 */
36 export const generateClientId = (length: number, prefix: string) => {
37 const bytes = nodeCrypto.randomBytes(length);
38 return `${prefix}${bytes.toString('hex')}`;
39 };
40
41 // Generate a secure random reset token
42 export function generateResetToken(): string {
43 return crypto.randomBytes(32).toString('hex');
44 }
45
46 // Generate a secure random string
47 export function generateRandomString(length: number): string {
48 return crypto.randomBytes(length).toString('hex');
49 }
50
51 // Hash a password with bcrypt
52 export async function hashPassword(password: string): Promise<string> {
53 return bcrypt.hash(password, 10);
54 }
55
56 // Compare password with hash
57 export async function comparePassword(password: string, hash: string): Promise<boolean> {
58 return bcrypt.compare(password, hash);
59 }
60
61 // Compare client secret with hash
62 export async function compareClientSecret(clientSecret: string, hash: string): Promise<boolean> {
63 return bcrypt.compare(clientSecret, hash);
64 }
65
66 // Generate a secure JWT token
67 export function generateJWT(payload: object | string, secret: string, expiresIn: StringValue | number): string {
68 return jwt.sign(payload, secret, { expiresIn });
69 }
70
71 // Verify JWT token
72 export function verifyJWT(token: string, secret: string): unknown {
73 return jwt.verify(token, secret);
74 }
75
76 // Encrypt text using AES-256-GCM with JWT_SECRET as key
77 export function encrypt(text: string, secretKey: string): string {
78 const algorithm = 'aes-256-gcm';
79 const key = crypto.scryptSync(secretKey, 'salt', 32);
80 const iv = crypto.randomBytes(16);
81 const cipher = crypto.createCipheriv(algorithm, key, iv);
82
83 let encrypted = cipher.update(text, 'utf8', 'hex');
84 encrypted += cipher.final('hex');
85
86 const authTag = cipher.getAuthTag();
87
88 return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
89 }
90
91 // Decrypt text using AES-256-GCM with JWT_SECRET as key
92 export function decrypt(encryptedText: string, secretKey: string): string {
93 const algorithm = 'aes-256-gcm';
94 const key = crypto.scryptSync(secretKey, 'salt', 32);
95
96 const [ivHex, authTagHex, encrypted] = encryptedText.split(':');
97 const iv = Buffer.from(ivHex, 'hex');
98 const authTag = Buffer.from(authTagHex, 'hex');
99
100 const decipher = crypto.createDecipheriv(algorithm, key, iv);
101 decipher.setAuthTag(authTag);
102
103 let decrypted = decipher.update(encrypted, 'hex', 'utf8');
104 decrypted += decipher.final('utf8');
105
106 return decrypted;
107 }
108 {"command":"grep -n \"export const cancelPolicy\\|async function cancelPolicy\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/policyOrderController.ts"}1657:export async function cancelPolicy(
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_CancellationTerms.ts"}1 import { Table, Column, Model, DataType, BelongsTo, ForeignKey } from 'sequelize-typescript';
2 import { _Product } from './_Product.js';
3
4 export enum RefundType {
5 FULL_REFUND = 'full_refund',
6 PARTIAL_REFUND = 'partial_refund',
7 NO_REFUND = 'no_refund',
8 PRORATED_REFUND = 'prorated_refund',
9 }
10
11 export enum CancellationPeriodType {
12 COOLING_OFF = 'cooling_off',
13 NOTICE_PERIOD = 'notice_period',
14 END_OF_TERM = 'end_of_term',
15 IMMEDIATE = 'immediate',
16 }
17
18 @Table({
19 modelName: 'CancellationTerms',
20 tableName: 'CancellationTerms',
21 timestamps: true,
22 indexes: [
23 {
24 fields: ['productId'],
25 name: 'idx_cancellation_terms_product',
26 },
27 {
28 fields: ['paymentFrequency'],
29 name: 'idx_cancellation_terms_frequency',
30 },
31 ],
32 })
33 export class _CancellationTerms extends Model {
34 @Column({
35 type: DataType.INTEGER,
36 autoIncrement: true,
37 primaryKey: true,
38 })
39 declare id: number;
40
41 @Column({
42 type: DataType.UUID,
43 allowNull: false,
44 })
45 declare organizationId: string;
46
47 @ForeignKey(() => _Product)
48 @Column({
49 type: DataType.INTEGER,
50 allowNull: false,
51 })
52 declare productId: number;
53
54 @BelongsTo(() => _Product)
55 declare product: _Product;
56
57 @Column({
58 type: DataType.ENUM('year', 'month', 'quarter', 'one-time', 'day'),
59 allowNull: false,
60 })
61 declare paymentFrequency: 'year' | 'month' | 'quarter' | 'one-time' | 'day';
62
63 @Column({
64 type: DataType.INTEGER,
65 allowNull: false,
66 comment: 'Grace period in days for full refund (e.g., 14 days cooling-off period)',
67 })
68 declare gracePeriodDays: number;
69
70 @Column({
71 type: DataType.ENUM(...Object.values(RefundType)),
72 allowNull: false,
73 comment: 'Type of refund within grace period',
74 })
75 declare gracePeriodRefundType: RefundType;
76
77 @Column({
78 type: DataType.INTEGER,
79 allowNull: true,
80 comment: 'Notice period in days required for cancellation (e.g., 30 days notice)',
81 })
82 declare noticePeriodDays?: number;
83
84 @Column({
85 type: DataType.ENUM(...Object.values(RefundType)),
86 allowNull: false,
87 comment: 'Type of refund after grace period',
88 })
89 declare standardRefundType: RefundType;
90
91 @Column({
92 type: DataType.ENUM(...Object.values(CancellationPeriodType)),
93 allowNull: false,
94 comment: 'How cancellation timing is handled',
95 })
96 declare cancellationPeriodType: CancellationPeriodType;
97
98 @Column({
99 type: DataType.DECIMAL(5, 4),
100 allowNull: true,
101 comment: 'Fixed refund percentage (0.0 to 1.0) for partial refunds',
102 })
103 declare refundPercentage?: number;
104
105 @Column({
106 type: DataType.DECIMAL(15, 2),
107 allowNull: true,
108 comment: 'Fixed cancellation fee amount',
109 })
110 declare cancellationFee?: number;
111
112 @Column({
113 type: DataType.TEXT,
114 allowNull: true,
115 comment: 'Additional terms and conditions text',
116 })
117 declare termsText?: string;
118
119 @Column({
120 type: DataType.BOOLEAN,
121 allowNull: false,
122 defaultValue: true,
123 })
124 declare active: boolean;
125
126 declare createdAt: Date;
127 declare updatedAt: Date;
128 }
129 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/policyOrderController.ts","limit":200,"offset":1657}1657 export async function cancelPolicy(
1658 request: FastifyRequest<{
1659 Params: { policyId: string };
1660 Body: {
1661 cancelReason: string;
1662 selectedOption: string;
1663 requestedCancellationDate?: string;
1664 cancelledBy?: 'customer' | 'admin';
1665 };
1666 }>
1667 ) {
1668 const { policyId } = request.params;
1669 const { cancelReason, selectedOption, requestedCancellationDate, cancelledBy } = request.body;
1670 const organizationId = request.ident?.org?.id;
1671
1672 if (!organizationId) {
1673 throw new StatusError(400, 'Organization context required');
1674 }
1675
1676 if (!cancelReason) {
1677 throw new StatusError(400, 'Cancel reason is required');
1678 }
1679
1680 if (!selectedOption) {
1681 throw new StatusError(400, 'Selected cancellation option is required');
1682 }
1683
1684 const cancellableStatusCodes = await getCodesForCategories(organizationId, [
1685 PolicyStatusLifecycleCategory.ACTIVE,
1686 PolicyStatusLifecycleCategory.SUSPENDED,
1687 ]);
1688 const cancelledStatusCode = await getDefaultCodeForCategory(organizationId, PolicyStatusLifecycleCategory.CANCELLED);
1689 const supersededStatusCode = await getDefaultCodeForCategory(organizationId, PolicyStatusLifecycleCategory.SUPERSEDED);
1690
1691 const validCancelReasons = Object.values(CancelReason);
1692 if (!validCancelReasons.includes(cancelReason as CancelReason)) {
1693 throw new StatusError(400, 'Invalid cancel reason');
1694 }
1695
1696 const validOptions = ['immediate', 'end_of_billing_cycle', 'specific_date', 'earliest_allowed'];
1697 if (!validOptions.includes(selectedOption)) {
1698 throw new StatusError(400, 'Invalid cancellation option');
1699 }
1700
1701 const requestedPolicy = await PolicyOrderModel.findOne(request, {
1702 where: { policyId },
1703 include: [{ model: Client.raw, as: 'client', where: { organizationId }, required: false }],
1704 });
1705
1706 if (!requestedPolicy) {
1707 throw new StatusError(404, 'Policy order not found');
1708 }
1709
1710 const rootPolicyId = requestedPolicy.rootPolicyId || requestedPolicy.policyId;
1711
1712 // Suspended policies can be cancelled directly without reinstating first.
1713 // This is intentional: a customer choosing to terminate while paused shouldn't be
1714 // forced through reinstate-then-cancel. Endorsements/renewals remain blocked while suspended.
1715 const policyOrder = await PolicyOrderModel.findOne(request, {
1716 where: {
1717 [Op.or]: [{ rootPolicyId }, { policyId: rootPolicyId }],
1718 status: { [Op.in]: cancellableStatusCodes },
1719 },
1720 include: [{ model: Client.raw, as: 'client', where: { organizationId }, required: false }],
1721 order: [['version', 'DESC']],
1722 });
1723
1724 if (!policyOrder) {
1725 throw new StatusError(400, 'No active or suspended policy version found to cancel');
1726 }
1727
1728 const timezone = request.ident!.org.timezone;
1729 const today = getBusinessDate(timezone);
1730 const cancellationDate = requestedCancellationDate || today;
1731
1732 let cancellationResult: Record<string, unknown>;
1733
1734 try {
1735 const { previewCancellationOptions } = await import('@/services/cancellationService.js');
1736 const locale = getLocaleFromHeader(request) ?? 'en';
1737 const preview = await previewCancellationOptions(policyOrder, cancellationDate, timezone, locale);
1738
1739 const selectedCancellation = preview.availableOptions.find(
1740 (option) => option.cancellationType === selectedOption
1741 );
1742
1743 if (!selectedCancellation) {
1744 throw new StatusError(
1745 400,
1746 `Selected option '${selectedOption}' is not available for this policy`
1747 );
1748 }
1749
1750 const originalPremiumWithTax = Number(policyOrder.premiumWithTax) || 0;
1751 const originalPremiumWithoutTax = Number(policyOrder.premiumWithoutTax) || 0;
1752 const originalSalesTax = Number(policyOrder.salesTax) || 0;
1753 const originalCommissionTax = Number(policyOrder.commissionTax) || 0;
1754 const commissionRate = (Number(policyOrder.dynamicCommissionPercentage) || 0) / 100;
1755
1756 let refundPremium = 0;
1757 let refundTax = 0;
1758 let refundCommission = 0;
1759 let refundCommissionTax = 0;
1760
1761 if (originalPremiumWithTax > 0 && selectedCancellation.refundAmount > 0) {
1762 const refundRatio = selectedCancellation.refundAmount / originalPremiumWithTax;
1763 refundPremium = Math.round(originalPremiumWithoutTax * refundRatio * 100) / 100;
1764 refundTax = Math.round(originalSalesTax * refundRatio * 100) / 100;
1765 refundCommission = Math.round(refundPremium * commissionRate * 100) / 100;
1766 refundCommissionTax = Math.round(originalCommissionTax * refundRatio * 100) / 100;
1767 }
1768
1769 const cancellationMetadata = {
1770 ...selectedCancellation.calculationDetails,
1771 cancellationType: selectedOption,
1772 refundAmount: selectedCancellation.refundAmount,
1773 cancellationFee: selectedCancellation.cancellationFee,
1774 netRefund: selectedCancellation.netRefund,
1775 };
1776
1777 const cancelledPolicyOrder = await sequelize.transaction(async (transaction) => {
1778 const cancelled = await PolicyOrderModel.create({ organizationId: policyOrder.organizationId }, {
1779 policyId: crypto.randomUUID(),
1780 rootPolicyId: policyOrder.rootPolicyId,
1781 quoteId: policyOrder.quoteId,
1782 previousPolicyId: policyOrder.policyId,
1783 transactionType: TransactionType.CANCELLATION,
1784 version: (policyOrder.version || 1) + 1,
1785 effectiveDate: selectedCancellation.effectiveDate,
1786 status: cancelledStatusCode,
1787 country: policyOrder.country,
1788 productName: policyOrder.productName,
1789 productPlanName: policyOrder.productPlanName,
1790 productId: policyOrder.productId,
1791 productPlanId: policyOrder.productPlanId,
1792 parameters: { ...policyOrder.parameters, cancellation: cancellationMetadata },
1793 insurance: policyOrder.insurance,
1794 product: policyOrder.product,
1795 network: policyOrder.network,
1796 requestHeaders: policyOrder.requestHeaders,
1797 requestBody: policyOrder.requestBody,
1798 paymentFrequency: policyOrder.paymentFrequency,
1799 policyNumber: policyOrder.policyNumber,
1800 inceptionDate: policyOrder.inceptionDate,
1801 issueDate: policyOrder.issueDate,
1802 expiryDate: selectedCancellation.newExpiryDate,
1803 cancellationDate: selectedCancellation.effectiveDate,
1804 cancelReason: cancelReason as string,
1805 cancelledBy: cancelledBy || 'customer',
1806 basePremium: policyOrder.basePremium,
1807 premiumWithoutTax: policyOrder.premiumWithoutTax,
1808 premiumWithTax: policyOrder.premiumWithTax,
1809 salesTax: policyOrder.salesTax,
1810 commissionTax: policyOrder.commissionTax ?? 0,
1811 currency: policyOrder.currency,
1812 commissionStructureId: policyOrder.commissionStructureId,
1813 baselineCommissionPercentage: policyOrder.baselineCommissionPercentage,
1814 dynamicCommissionPercentage: policyOrder.dynamicCommissionPercentage,
1815 variancePercentage: policyOrder.variancePercentage,
1816 allocationRuleId: policyOrder.allocationRuleId,
1817 totalCommissionAmount: policyOrder.totalCommissionAmount,
1818 partyAllocations: policyOrder.partyAllocations,
1819 originalPremiumWithoutTax: policyOrder.originalPremiumWithoutTax,
1820 originalPremiumWithTax: policyOrder.originalPremiumWithTax,
1821 billingEffectiveDate: selectedCancellation.effectiveDat{"command":"ls /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/ | head -15"}auth.schemas.ts azureAd.schemas.ts claim.schemas.ts customerPortal.schemas.ts customerTemplate.schemas.ts dataModel.schemas.ts organizationSwitching.schemas.ts plus policy.schemas.ts productApi.schemas.ts productApiController.schemas.ts productLibrary.schemas.ts quote.schemas.ts socialAuth.schemas.ts trail.schemas.ts
{"command":"find /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db -type f -name \"*migrat*\" | head -10"}/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.04.03T00.00.01.migrate-customer-id-integer-to-uuid.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.01.30T00.00.02.migrate-customers-from-users.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2025.10.11T00.00.01.migrate-to-product-modules.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.04.02T00.00.01.migrate-user-id-integer-to-uuid.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2025.11.19T00.00.01.migrate-policy-term-to-quote-settings.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.03.07T00.00.03.migrate-bundle-templates-to-bundle-level.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2025.07.20T00.01.18.seed-default-organization-and-migrate-data.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrate.ts
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/auth.schemas.ts","limit":80}{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/policyOrderController.ts","limit":150,"offset":1850}1 /**
2 * Shared schemas for authentication-related endpoints
3 */
4
5 import { Type } from '@sinclair/typebox';
6
7 export const loginBodySchema = Type.Object({
8 email: Type.String({ format: 'email', description: 'Account email address. Case-insensitive — server lower-cases before lookup.', example: '[email protected]' }),
9 password: Type.String({ description: 'Account password in plain text (transport secured by HTTPS). Server compares against the stored bcrypt hash.', example: 'S3cur3-P@ssw0rd' }),
10 });
11
12 export const registerBodySchema = Type.Object({
13 email: Type.String({ format: 'email', description: 'Customer email address. Must be unique within the organization. Lower-cased server-side.', example: '[email protected]' }),
14 password: Type.String({ description: 'Customer password. Must satisfy the policy: at least 8 characters, with at least one lowercase letter, one uppercase letter, one digit, and one special character.', example: 'S3cur3-P@ssw0rd' }),
15 firstName: Type.String({ description: 'Customer given name (2–50 characters, trimmed).', example: 'Jane' }),
16 lastName: Type.String({ description: 'Customer family name (2–50 characters, trimmed).', example: 'Doe' }),
17 phoneNumber: Type.Optional(Type.String({ description: 'Customer phone number in E.164 format (leading "+", country code, then 2–15 digits).', example: '+34600123456' })),
18 country: Type.Optional(Type.String({ description: 'ISO-3166-1 alpha-2 country code, or a country name (2–50 characters).', example: 'ES' })),
19 region: Type.Optional(Type.String({ description: 'Region, state, province, or autonomous community (2–50 characters).', example: 'Catalonia' })),
20 address: Type.Optional(Type.String({ description: 'Street address line, including building/unit if applicable (5–100 characters).', example: 'Carrer de Mallorca 401' })),
21 city: Type.Optional(Type.String({ description: 'City or town name (2–50 characters).', example: 'Barcelona' })),
22 postalCode: Type.Optional(Type.String({ description: 'Postal/ZIP code in the local national format (3–20 characters).', example: '08013' })),
23 companyName: Type.Optional(Type.String({ description: 'Optional company/organization name when the customer is registering on behalf of a business (max 100 characters). Empty string and null are accepted.', example: 'Acme Insurance Partners' })),
24 });
25
26 export const refreshTokenBodySchema = Type.Object({
27 refreshToken: Type.String({ description: 'A previously issued JWT refresh token. Pass the most recently rotated value — older refresh tokens for the same session are invalidated when a new pair is minted.', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' }),
28 });
29
30 export const requestPasswordResetBodySchema = Type.Object({
31 email: Type.String({ format: 'email', description: 'Account email address to send a password-reset link to. Lookup is case-insensitive. The endpoint always returns 200 even if no account exists, to avoid leaking account existence.', example: '[email protected]' }),
32 });
33
34 export const resetPasswordBodySchema = Type.Object({
35 token: Type.String({ description: 'The single-use reset token delivered to the user\'s email by POST /v1/auth/password-reset-request. Valid for 1 hour after issue. Consumed on success — replays return 400.', example: 'kx9ay4nfqd2omq6yc7tdjp' }),
36 newPassword: Type.String({ description: 'The new password to set. Must satisfy the standard password policy (at least 8 characters, mixed case, digit, and special character).', example: 'N3w-S3cur3-P@ssw0rd' }),
37 });
38
39 export const createAccountWithTokenBodySchema = Type.Object({
40 token: Type.String({ description: 'Single-use account-activation token sent to the user\'s email. Valid only while the underlying record (Customer or User) still has a NULL password and the token has not expired.', example: 'kx9ay4nfqd2omq6yc7tdjp' }),
41 password: Type.String({ description: 'Initial password for the account. Must satisfy the standard password policy (at least 8 characters, mixed case, digit, special character).', example: 'S3cur3-P@ssw0rd' }),
42 socialProvider: Type.Optional(Type.String({ description: 'Reserved for future social-provider activation flows. Currently rejected with 400 if supplied — leave unset.', example: 'google' })),
43 });
44
45 export const resend2FAEmailBodySchema = Type.Object({
46 tempToken: Type.String({ description: 'The 10-minute temporary JWT returned by POST /v1/auth/login when the user has 2FA enabled. Identifies the in-flight 2FA challenge to resend the email code for.', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' }),
47 });
48
49 export const change2FAMethodBodySchema = Type.Object({
50 method: Type.Union([Type.Literal('app'), Type.Literal('email')], { description: 'Desired 2FA delivery method. "app" enrolls a TOTP authenticator (returns a QR code and base32 secret in the response). "email" enrolls email-based 6-digit codes (sends an enrollment code immediately). Must differ from the user\'s current method.', example: 'app' }),
51 });
52
53 export const verify2FAMethodChangeBodySchema = Type.Object({
54 token: Type.String({ description: 'The verification code from the new 2FA method: a 6-digit TOTP code from the authenticator app, or the 6-digit code from the enrollment email. Email codes expire 10 minutes after issue.', example: '482915' }),
55 });
56
57 export const clientCredentialsBodySchema = Type.Object({
58 clientId: Type.String({ description: 'Public client identifier issued when the API client was provisioned. Format: "cl_" prefix followed by a UUID.', example: 'cl_faf23d26-93db-4a72-af6f-462dff015c96' }),
59 clientSecret: Type.String({ description: 'Confidential client secret issued together with the clientId. Format: "cs_" prefix followed by a UUID. Treat as a password — never expose in browsers, mobile apps, or version control.', example: 'cs_0a644bc5-8b9f-4ffd-b680-9ff0087f3f63' }),
60 });
61 1850 reason: cancelReason as string,
1851 });
1852 } else if (!policyOrder.testMode) {
1853 if (isExternalProvider(cancellationProvider) && !policyOrder.externalPaymentRef) {
1854 logger.warn({
1855 policyOrderId: policyOrder.id,
1856 provider: cancellationProvider,
1857 }, 'External provider configured but no externalPaymentRef on policy — falling back to internal invoice');
1858 }
1859 await generateInvoice(
1860 cancelled,
1861 undefined,
1862 cancellationMetadata,
1863 selectedCancellation.cancellationFee,
1864 transaction
1865 );
1866 }
1867
1868 return cancelled;
1869 });
1870
1871 cancellationResult = {
1872 policyId: cancelledPolicyOrder.policyId,
1873 policyNumber: cancelledPolicyOrder.policyNumber,
1874 status: cancelledPolicyOrder.status,
1875 cancellationDate: cancelledPolicyOrder.cancellationDate,
1876 cancelReason: cancelledPolicyOrder.cancelReason,
1877 previousPolicyId: policyOrder.policyId,
1878 cancellation: {
1879 effectiveCancellationDate: selectedCancellation.effectiveDate,
1880 newExpiryDate: selectedCancellation.newExpiryDate,
1881 refundAmount: selectedCancellation.refundAmount,
1882 cancellationFee: selectedCancellation.cancellationFee,
1883 netRefund: selectedCancellation.netRefund,
1884 description: selectedCancellation.description,
1885 selectedOption: selectedOption,
1886 },
1887 };
1888 } catch (error) {
1889 if (error instanceof Error && error.message.includes('No cancellation terms found')) {
1890 const cancelledPolicyOrder = await PolicyOrderModel.create({ organizationId: policyOrder.organizationId }, {
1891 policyId: crypto.randomUUID(),
1892 rootPolicyId: policyOrder.rootPolicyId,
1893 quoteId: policyOrder.quoteId,
1894 previousPolicyId: policyOrder.policyId,
1895 transactionType: TransactionType.CANCELLATION,
1896 version: (policyOrder.version || 1) + 1,
1897 effectiveDate: cancellationDate,
1898 status: cancelledStatusCode,
1899 country: policyOrder.country,
1900 productName: policyOrder.productName,
1901 productPlanName: policyOrder.productPlanName,
1902 productId: policyOrder.productId,
1903 productPlanId: policyOrder.productPlanId,
1904 parameters: policyOrder.parameters,
1905 insurance: policyOrder.insurance,
1906 product: policyOrder.product,
1907 network: policyOrder.network,
1908 requestHeaders: policyOrder.requestHeaders,
1909 requestBody: policyOrder.requestBody,
1910 paymentFrequency: policyOrder.paymentFrequency,
1911 policyNumber: policyOrder.policyNumber,
1912 inceptionDate: policyOrder.inceptionDate,
1913 issueDate: policyOrder.issueDate,
1914 expiryDate: cancellationDate,
1915 cancellationDate: cancellationDate,
1916 cancelReason: cancelReason as string,
1917 cancelledBy: cancelledBy || 'customer',
1918 basePremium: policyOrder.basePremium,
1919 premiumWithoutTax: policyOrder.premiumWithoutTax,
1920 premiumWithTax: policyOrder.premiumWithTax,
1921 salesTax: policyOrder.salesTax,
1922 commissionTax: policyOrder.commissionTax ?? 0,
1923 currency: policyOrder.currency,
1924 adjustmentPremium: 0,
1925 adjustmentTax: 0,
1926 adjustmentCommission: 0,
1927 adjustmentCommissionTax: 0,
1928 clientId: policyOrder.clientId,
1929 customerId: policyOrder.customerId,
1930 testMode: policyOrder.testMode,
1931 });
1932
1933 await policyOrder.update({ status: supersededStatusCode });
1934
1935 cancellationResult = {
1936 policyId: cancelledPolicyOrder.policyId,
1937 policyNumber: cancelledPolicyOrder.policyNumber,
1938 status: cancelledPolicyOrder.status,
1939 cancellationDate: cancelledPolicyOrder.cancellationDate,
1940 cancelReason: cancelledPolicyOrder.cancelReason,
1941 previousPolicyId: policyOrder.policyId,
1942 cancellation: {
1943 effectiveCancellationDate: cancellationDate,
1944 newExpiryDate: cancellationDate,
1945 refundAmount: 0,
1946 cancellationFee: 0,
1947 netRefund: 0,
1948 description: 'Policy cancelled - no specific terms configured',
1949 selectedOption: selectedOption,
1950 },
1951 };
1952 } else {
1953 throw error;
1954 }
1955 }
1956
1957 if (policyOrder.bundleSubscriptionId) {
1958 try {
1959 const endorsementCtx = buildEndorsementContextFromRequest(request);
1960 await handleBundleCancellationSideEffect(policyOrder.bundleSubscriptionId, policyOrder.productName, timezone, endorsementCtx);
1961 } catch (err) {
1962 logger.error({ err, bundleSubscriptionId: policyOrder.bundleSubscriptionId }, 'Failed to recalculate bundle after cancellation');
1963 }
1964 }
1965
1966 return cancellationResult;
1967 }
1968
1969 export async function getPolicyById(
1970 request: FastifyRequest<{ Params: { policyId: string }; Querystring: { version?: number } }>,
1971 _reply: FastifyReply
1972 ): Promise<Record<string, unknown>> {
1973 void _reply;
1974 const { policyId } = request.params;
1975 const { version } = request.query;
1976 const organizationId = request.ident?.org?.id;
1977
1978 if (!organizationId) {
1979 throw new StatusError(400, 'Organization context required');
1980 }
1981
1982 const currentUser = request.ident?.user;
1983 const isCustomer = currentUser?.userType === 'customer';
1984
1985 const requestedPolicy = await PolicyOrderModel.findOne(request, {
1986 where: { policyId },
1987 include: [{ model: Product.raw, as: 'productRelation', required: true }],
1988 });
1989
1990 if (!requestedPolicy) {
1991 throw new StatusError(404, 'Policy order not found');
1992 }
1993
1994 const rootPolicyId = requestedPolicy.rootPolicyId || requestedPolicy.policyId;
1995
1996 let policyOrder: PolicyOrderModel | null;
1997
1998 if (version !== undefined) {
1999 policyOrder = await PolicyOrderModel.findOne(request, {{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations"}{"command":"grep -n \"handleCreditNote\\|refund\\|RefundType\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/policyOrderController.ts | head -30"}76:import { handleInvoice as handleExternalInvoice, handleCreditNote, handlePaymentIntent, handleSetupIntent, resolvePaymentProvider } from '@/domains/payments/service.js';
1611: ? options.map((opt: any) => ({ ...opt, refundAmount: null, cancellationFee: null, netRefund: null }))
1632: refundAmount: 0,
1756: let refundPremium = 0;
1757: let refundTax = 0;
1758: let refundCommission = 0;
1759: let refundCommissionTax = 0;
1761: if (originalPremiumWithTax > 0 && selectedCancellation.refundAmount > 0) {
1762: const refundRatio = selectedCancellation.refundAmount / originalPremiumWithTax;
1763: refundPremium = Math.round(originalPremiumWithoutTax * refundRatio * 100) / 100;
1764: refundTax = Math.round(originalSalesTax * refundRatio * 100) / 100;
1765: refundCommission = Math.round(refundPremium * commissionRate * 100) / 100;
1766: refundCommissionTax = Math.round(originalCommissionTax * refundRatio * 100) / 100;
1772: refundAmount: selectedCancellation.refundAmount,
1822: adjustmentPremium: -refundPremium,
1823: adjustmentTax: -refundTax,
1824: adjustmentCommission: -refundCommission,
1847: await handleCreditNote(policyOrder, {
1881: refundAmount: selectedCancellation.refundAmount,
1945: refundAmount: 0,
5678: await handleCreditNote(policyOrder, {EISDIR: illegal operation on a directory, read '/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations'
{"command":"ls /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/ | tail -5"}2026.05.29T00.00.02.add-message-translation-fields.ts 2026.05.30T00.00.00.create-payout-category-and-fields.ts 2026.05.30T00.00.01.localize-default-checkout-premium-prefix.ts abandonedPolicyStatus.apitest.ts seed
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.30T00.00.01.localize-default-checkout-premium-prefix.ts"}1 import type { QueryInterface } from 'sequelize';
2 import { QueryTypes } from 'sequelize';
3 import type { MigrationFn } from 'umzug';
4
5 // The seeded default funnel-checkout template (see
6 // 2026.05.14T00.00.03.seed-default-funnel-checkout-templates.ts) rendered the
7 // "Premium for" prefix as plain template copy, so the embed's data-sherpa-i18n
8 // translator skipped it — the headline stayed English even when the rest of the
9 // checkout localized. Wrap the prefix in <span data-sherpa-i18n> so the embed
10 // translates it via its dictionary ("Premium for" registered in
11 // sherpa-embed src so LMT extracts it). The product name stays a slot.
12 //
13 // Only the common default row (no org, no product) is touched, and only when it
14 // still carries the exact unmarked markup — orgs that saved their own checkout
15 // template via the portal are left untouched, and re-running is a no-op.
16 const UNMARKED = '<p class="sherpa-checkout__product">Premium for <span data-sherpa-slot="product-name">';
17 const MARKED = '<p class="sherpa-checkout__product"><span data-sherpa-i18n>Premium for</span> <span data-sherpa-slot="product-name">';
18
19 export const up: MigrationFn<QueryInterface> = async ({ context }) => {
20 // Mirror the embed-checkout route's common-default lookup: it resolves the
21 // null-org/null-product row by `categoryTypes @> {checkout}` only and never
22 // filters on templateType (which is empty on the live default row).
23 const [, meta] = await context.sequelize.query(
24 `UPDATE "FunnelTemplatesLibrary"
25 SET "html" = REPLACE("html", :unmarked, :marked), "updatedAt" = NOW()
26 WHERE "productId" IS NULL
27 AND "organizationId" IS NULL
28 AND 'checkout' = ANY("categoryTypes")
29 AND "html" LIKE :likeUnmarked`,
30 {
31 replacements: { unmarked: UNMARKED, marked: MARKED, likeUnmarked: `%${UNMARKED}%` },
32 type: QueryTypes.UPDATE,
33 },
34 );
35 console.log(`Localized default checkout premium prefix (rows updated: ${meta ?? 0})`);
36 };
37
38 export const down: MigrationFn<QueryInterface> = async ({ context }) => {
39 await context.sequelize.query(
40 `UPDATE "FunnelTemplatesLibrary"
41 SET "html" = REPLACE("html", :marked, :unmarked), "updatedAt" = NOW()
42 WHERE "productId" IS NULL
43 AND "organizationId" IS NULL
44 AND 'checkout' = ANY("categoryTypes")
45 AND "html" LIKE :likeMarked`,
46 {
47 replacements: { unmarked: UNMARKED, marked: MARKED, likeMarked: `%${MARKED}%` },
48 type: QueryTypes.UPDATE,
49 },
50 );
51 };
52 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/config"}EISDIR: illegal operation on a directory, read '/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/config'
{"command":"ls /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/config/"}address-schemas.ts azureAd.ts
{"command":"find /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models -name \"*audit*\" -o -name \"*Audit*\" -o -name \"*Refund*\" -o -name \"*Credit*\" -type f"}/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_AuditLog.ts /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/AuditLog.ts
{"command":"ls /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models | grep -i audit"}AuditLog.ts
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/config/azureAd.ts"}1 import { LogLevel } from '@azure/msal-node';
2 import type { Configuration } from '@azure/msal-node';
3 import { getRequired } from '../../lib.js';
4
5 const AZURE_REDIRECT_URI = getRequired('AZURE_REDIRECT_URI');
6 const AZURE_POST_LOGOUT_REDIRECT_URI = getRequired('AZURE_POST_LOGOUT_REDIRECT_URI');
7 const AZURE_CLIENT_ID = getRequired('AZURE_CLIENT_ID');
8 const AZURE_CLIENT_SECRET = getRequired('AZURE_CLIENT_SECRET');
9
10 // AZURE_TENANT_ID is optional - when not provided, uses 'common' for multi-tenant support
11 const AZURE_TENANT_ID = process.env.AZURE_TENANT_ID || 'common';
12
13 /**
14 * Azure AD configuration for MSAL Node
15 * Uses 'common' tenant for multi-tenant support when AZURE_TENANT_ID is not specified
16 */
17 export const msalConfig: Configuration = {
18 auth: {
19 clientId: AZURE_CLIENT_ID,
20 authority: `https://login.microsoftonline.com/${AZURE_TENANT_ID}`,
21 clientSecret: AZURE_CLIENT_SECRET,
22 },
23 system: {
24 loggerOptions: {
25 // eslint-disable-next-line @typescript-eslint/no-unused-vars
26 loggerCallback(_loglevel: LogLevel, _message: string) {
27 // MSAL logging disabled - using application logger instead
28 },
29 piiLoggingEnabled: false,
30 logLevel: process.env.NODE_ENV === 'development' ? LogLevel.Verbose : LogLevel.Error,
31 },
32 },
33 };
34
35 /**
36 * Azure AD scopes for user authentication
37 */
38 export const AUTH_SCOPES = ['openid', 'profile', 'email', 'User.Read'];
39
40 /**
41 * Azure AD redirect URIs
42 */
43 export const REDIRECT_URI = AZURE_REDIRECT_URI;
44 export const POST_LOGOUT_REDIRECT_URI = AZURE_POST_LOGOUT_REDIRECT_URI;
45
46 /**
47 * Azure AD endpoints
48 */
49 export const GRAPH_ME_ENDPOINT = 'https://graph.microsoft.com/v1.0/me';
50 export const GRAPH_MEMBEROF_ENDPOINT = 'https://graph.microsoft.com/v1.0/me/memberOf';
51
52 /**
53 * Configuration validation
54 */
55 export function validateAzureAdConfig(): boolean {
56 const requiredEnvVars = [
57 'AZURE_CLIENT_ID', // Reuse existing
58 'AZURE_TENANT_ID', // New - your organization's tenant
59 'AZURE_CLIENT_SECRET', // Reuse existing
60 ];
61
62 const missing = requiredEnvVars.filter((envVar) => !process.env[envVar]);
63
64 if (missing.length > 0) {
65 console.error('Missing required Azure AD environment variables:', missing);
66 return false;
67 }
68
69 return true;
70 }
71
72 /**
73 * Check if Azure AD authentication is enabled
74 */
75 export function isAzureAdEnabled(): boolean {
76 return process.env.AZURE_AD_ENABLED === 'true' && validateAzureAdConfig();
77 }
78 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_AuditLog.ts"}1 import { Table, Column, Model, BelongsTo, ForeignKey, DataType } from 'sequelize-typescript';
2 import { _Customer } from './_Customer.js';
3 import { User } from '../User';
4
5 @Table({
6 modelName: 'AuditLog',
7 timestamps: true,
8 })
9 export class _AuditLog extends Model {
10 @Column({
11 type: DataType.INTEGER,
12 autoIncrement: true,
13 primaryKey: true,
14 })
15 declare id: number;
16
17 @Column({
18 type: DataType.UUID,
19 allowNull: false,
20 })
21 declare organizationId: string;
22
23 @ForeignKey(() => User)
24 @Column({
25 type: DataType.UUID,
26 allowNull: true,
27 })
28 declare userId: string | null;
29
30 @ForeignKey(() => User)
31 @Column({
32 type: DataType.UUID,
33 allowNull: true,
34 })
35 declare targetUserId: string | null;
36
37 @ForeignKey(() => _Customer)
38 @Column({
39 type: DataType.UUID,
40 allowNull: true,
41 })
42 declare customerId: string | null;
43
44 @Column({
45 type: DataType.STRING,
46 allowNull: false,
47 })
48 declare action: string;
49
50 @Column({
51 type: DataType.JSON,
52 allowNull: true,
53 })
54 declare details: any;
55
56 @Column({
57 type: DataType.STRING,
58 allowNull: true,
59 })
60 declare ipAddress: string | null;
61
62 @Column({
63 type: DataType.STRING,
64 allowNull: true,
65 })
66 declare userAgent: string | null;
67
68 @Column({
69 type: DataType.DATE,
70 allowNull: false,
71 })
72 declare createdAt: Date;
73
74 @Column({
75 type: DataType.DATE,
76 allowNull: false,
77 })
78 declare updatedAt: Date;
79
80 @BelongsTo(() => User, 'userId')
81 declare user: User;
82
83 @BelongsTo(() => User, 'targetUserId')
84 declare targetUser: User;
85
86 @BelongsTo(() => _Customer, 'customerId')
87 declare customer: _Customer;
88 }
89 {"command":"grep -r \"PUBLIC_URL\\|BASE_URL\\|PORTAL_DOMAIN\\|SITE_URL\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/.env | head -10"}{"command":"grep -n \"previewPolicyCancellation\\|cancelPolicy.*preview\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/policyOrderController.ts"}1530:export async function previewPolicyCancellation( 1565: // lookup used by cancelPolicy/reinstatePolicy so the cancel preview keeps
(Bash completed with no output)
{"command":"grep -r \"getRequired\\|process.env\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/app.ts | head -15"}/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/app.ts:import { getRequired } from '../lib.js';
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/app.ts:const JWT_SECRET = getRequired('JWT_SECRET');
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/app.ts:const COOKIE_SECRET = getRequired('COOKIE_SECRET');
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/app.ts:const SESSION_SECRET = getRequired('SESSION_SECRET');
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/app.ts:const SESSION_SALT = getRequired('SESSION_SALT');
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/app.ts:const NODE_ENV = getRequired('NODE_ENV');
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/app.ts:const SHERPA_ENV = getRequired('SHERPA_ENV');
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/app.ts:const allowedOriginsEnv = getRequired('ALLOWED_ORIGINS');{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/policyOrderController.ts","limit":130,"offset":1530}1530 export async function previewPolicyCancellation(
1531 request: FastifyRequest<{
1532 Params: { policyId: string };
1533 Querystring: { requestedDate?: string };
1534 }>
1535 ) {
1536 const { policyId } = request.params;
1537 const { requestedDate } = request.query;
1538 const organizationId = request.ident?.org?.id;
1539
1540 if (!organizationId) {
1541 throw new StatusError(400, 'Organization context required');
1542 }
1543
1544 const requestedPolicy = await PolicyOrderModel.findOne(request, {
1545 where: {
1546 policyId,
1547 },
1548 include: [
1549 {
1550 model: Client.raw,
1551 as: 'client',
1552 where: { organizationId },
1553 required: false,
1554 },
1555 ],
1556 });
1557
1558 if (!requestedPolicy) {
1559 throw new StatusError(404, 'Policy order not found');
1560 }
1561
1562 // Resolve to the latest active version within the policy chain. The caller may
1563 // pass the policyId of an older row (e.g. a NEW_BUSINESS row that is now
1564 // superseded by an endorsement or other version). Mirror the rootPolicyId
1565 // lookup used by cancelPolicy/reinstatePolicy so the cancel preview keeps
1566 // working when the URL points to a historical version.
1567 const rootPolicyId = requestedPolicy.rootPolicyId || requestedPolicy.policyId;
1568 const activeStatusCodes = await getCodesForCategory(organizationId, PolicyStatusLifecycleCategory.ACTIVE);
1569
1570 const policyOrder = await PolicyOrderModel.findOne(request, {
1571 where: {
1572 [Op.or]: [{ rootPolicyId }, { policyId: rootPolicyId }],
1573 status: { [Op.in]: activeStatusCodes },
1574 },
1575 include: [
1576 {
1577 model: Client.raw,
1578 as: 'client',
1579 where: { organizationId },
1580 required: false,
1581 },
1582 ],
1583 order: [['version', 'DESC']],
1584 });
1585
1586 if (!policyOrder) {
1587 // No active version in this policy chain. Surface the most useful error:
1588 // if the latest non-active version is cancelled, say so; otherwise treat
1589 // it as not-cancellable.
1590 const latestInChain = await PolicyOrderModel.findOne(request, {
1591 where: { [Op.or]: [{ rootPolicyId }, { policyId: rootPolicyId }] },
1592 order: [['version', 'DESC']],
1593 });
1594 if (latestInChain) {
1595 const latestCategory = await getCategoryForCode(organizationId, latestInChain.status);
1596 if (latestCategory === PolicyStatusLifecycleCategory.CANCELLED) {
1597 throw new StatusError(400, 'Policy is already cancelled');
1598 }
1599 }
1600 throw new StatusError(400, 'Only active policies can be cancelled');
1601 }
1602
1603 const timezone = request.ident!.org.timezone;
1604 const requestedCancellationDate = requestedDate || getBusinessDate(timezone);
1605
1606 // Check if prices should be hidden for this product
1607 const hidePricesCancellation = policyOrder.productId ? await shouldHidePrices(request, policyOrder.productId) : false;
1608
1609 const stripRefundOptions = (options: any[]) =>
1610 hidePricesCancellation
1611 ? options.map((opt: any) => ({ ...opt, refundAmount: null, cancellationFee: null, netRefund: null }))
1612 : options;
1613
1614 try {
1615 const { previewCancellationOptions } = await import('@/services/cancellationService.js');
1616 const locale = getLocaleFromHeader(request) ?? 'en';
1617 const preview = await previewCancellationOptions(policyOrder, requestedCancellationDate, timezone, locale);
1618
1619 if (hidePricesCancellation) {
1620 preview.availableOptions = stripRefundOptions(preview.availableOptions);
1621 }
1622
1623 return preview;
1624 } catch (error) {
1625 if (error instanceof Error && error.message.includes('No cancellation terms found')) {
1626 return {
1627 availableOptions: stripRefundOptions([
1628 {
1629 cancellationType: 'immediate',
1630 effectiveDate: requestedCancellationDate,
1631 newExpiryDate: requestedCancellationDate,
1632 refundAmount: 0,
1633 cancellationFee: 0,
1634 netRefund: 0,
1635 description: 'Cancel immediately - no specific terms configured',
1636 isRecommended: true,
1637 isWithinGracePeriod: false,
1638 calculationDetails: {},
1639 },
1640 ]),
1641 recommendedOption: 'immediate',
1642 currentPolicy: {
1643 policyId: policyOrder.policyId,
1644 inceptionDate: policyOrder.inceptionDate,
1645 currentExpiryDate: policyOrder.expiryDate,
1646 paymentFrequency: policyOrder.paymentFrequency as string,
1647 gracePeriodEndsOn: policyOrder.inceptionDate,
1648 daysFromInception: 0,
1649 },
1650 requestedDate: requestedCancellationDate,
1651 };
1652 }
1653 throw error;
1654 }
1655 }
1656
1657 export async function cancelPolicy(
1658 request: FastifyRequest<{
1659 Params: { policyId: string };{"command":"grep -rn \"POST.*public.*cancel\\|no.login.*cancel\\|public.*withdrawal\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes --include=\"*.ts\" | head -20"}(Bash completed with no output)
{"command":"grep -E \"PUBLIC_URL|PORTAL_DOMAIN|BASE_URL|SITE_URL|API_URL\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/.env | head -10"}(Bash completed with no output)
{"command":"find /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains -type d -name \"payments\" -o -type d -name \"withdrawals\" | head -5"}/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/payments
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/payments/routes.ts","limit":80}1 import type { FastifyInstance, FastifyReply } from 'fastify';
2 import { StatusError } from '@/error/error.js';
3 import { PolicyOrder } from '@/models/PolicyOrder.js';
4 import { PolicyStatusLifecycleCategory } from '@/models/OrganizationPolicyStatus.js';
5 import { getCategoryForCode } from '@/services/policyStatusService.js';
6 import { handleInvoice, handleCreditNote, getPaymentStatus, activatePoliciesByPaymentRef } from './service.js';
7 import { PaymentProviderError } from './interface.js';
8
9 const handleProviderError = (err: unknown, reply: FastifyReply): FastifyReply => {
10 if (err instanceof PaymentProviderError) {
11 return reply.code(502).send({
12 error: 'payment_provider_error',
13 ...err.error,
14 });
15 }
16 throw err;
17 };
18
19 export const registerPaymentRoutes = (fastify: FastifyInstance): void => {
20 routePostV1PaymentsInvoice(fastify);
21 routeGetV1PaymentsStatus(fastify);
22 routePostV1PaymentsCreditNote(fastify);
23 };
24
25 const routePostV1PaymentsInvoice = (fastify: FastifyInstance): void => {
26 const schema = {
27 hide: true,
28 description: 'Create a hosted-checkout invoice with the configured payment provider for a PolicyOrder. Returns the provider URL the customer should be redirected to, plus the external reference and current status. Wrapped errors from the payment provider are surfaced as 502 with a machine-readable code.',
29 tags: ['Internal — Payments'],
30 response: {
31 200: {
32 type: 'object',
33 additionalProperties: true,
34 properties: {
35 url: { type: 'string' },
36 externalReference: { type: 'string' },
37 status: { type: 'string', enum: ['pending', 'completed'] },
38 },
39 },
40 },
41 };
42
43 fastify.post<{
44 Body: { policyOrderId: number; returnUrl?: string };
45 }>(
46 '/v1/payments/invoice',
47 { schema },
48 async (request, reply) => {
49 const { policyOrderId, returnUrl } = request.body;
50
51 if (!policyOrderId) {
52 throw new StatusError(400, 'policyOrderId is required');
53 }
54
55 const policyOrder = await PolicyOrder.findByPk(request, policyOrderId);
56 if (!policyOrder) {
57 throw new StatusError(404, `PolicyOrder ${policyOrderId} not found`);
58 }
59
60 if (policyOrder.testMode) {
61 throw new StatusError(400, 'Payment processing is disabled for test policies');
62 }
63
64 try {
65 const result = await handleInvoice(policyOrder, 'new_business', returnUrl);
66 return {
67 url: result.url,
68 externalReference: result.externalReference,
69 status: result.status,
70 };
71 } catch (err) {
72 return handleProviderError(err, reply);
73 }
74 },
75 );
76 };
77
78 const routeGetV1PaymentsStatus = (fastify: FastifyInstance): void => {
79 const schema = {
80 hide: true,{"command":"grep -n \"public\\|no.*login\\|withdrawal\\|cancelPolicy\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes.ts | head -30"}128:import { publicBundleRoutes } from '@/routes/bundleRoutes.js';
148:import { routeGetV1DataPublicExternalUrl } from '@/domains/data/routes/get-public-[externalUrl].js';
276: // Data public routes (no auth)
287: await app.register(publicBundleRoutes, { prefix: '/v1' });{"command":"grep -n \"publicBundleRoutes\\|public.*route\\|export.*public\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/bundleRoutes.ts | head -30"}{"command":"ls /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/mcp/routes/ | head -10"}270:export const publicBundleRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {get-v1-mcp-outbound-offer.ts get-well-known-oauth-authorization-server.ts get-well-known-oauth-protected-resource.ts get-well-known-openai-apps-challenge.ts get-well-known-openid-configuration.ts v1-mcp-[serverId]-openai.ts v1-mcp-[serverId].ts
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/mcp/routes/get-v1-mcp-outbound-offer.ts","limit":80}1 // MOCK — this entire route is a placeholder for the real onboarding flow.
2 // Must be replaced before reaching staging or production.
3 import type { FastifyInstance } from 'fastify';
4 import { getRequired } from 'lib.js';
5 import { StatusError } from '@/error/error.js';
6 import { getOffer } from '@/domains/mcp/flows/offers.js';
7
8 const SHERPA_ENV = getRequired('SHERPA_ENV');
9
10 export function routeGetV1McpOutboundOffer(fastify: FastifyInstance): void {
11 if (SHERPA_ENV === 'staging' || SHERPA_ENV === 'production') {
12 return;
13 }
14
15 fastify.get<{ Params: { offerId: string } }>(
16 '/v1/mcp/offer/:offerId',
17 {
18 schema: {
19 hide: true,
20 description: 'Mock outbound landing page for an MCP offer link. Renders a static HTML page with the offer\'s premium, deductible, and coverage limit. Disabled in staging and production (returns nothing — guarded by SHERPA_ENV); will be replaced by the real onboarding flow before reaching staging.',
21 tags: ['Internal — MCP'],
22 },
23 },
24 async (request, reply) => {
25 const offer = getOffer(request.params.offerId);
26 if (!offer) throw new StatusError(404, 'Offer not found or expired');
27
28 const premium = `$${offer.monthlyPremium}/mo`;
29 const deductible = `$${offer.deductible}`;
30 const limit = `$${offer.coverageLimit.toLocaleString('en-US')}`;
31
32 reply.type('text/html; charset=utf-8');
33 return `<!DOCTYPE html>
34 <html lang="en">
35 <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${offer.title}</title>
36 <style>
37 *{box-sizing:border-box}
38 body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f8f9fa}
39 .mock{background:#fff3cd;border:2px solid #ffc107;color:#856404;padding:12px 24px;border-radius:8px;font-weight:600;font-size:14px;text-align:center;margin-bottom:24px}
40 .card{background:#fff;border-radius:12px;padding:48px;max-width:520px;width:100%;box-shadow:0 2px 8px rgba(0,0,0,.08)}
41 h1{margin:0 0 8px;font-size:28px}
42 .desc{color:#666;margin:0 0 32px;font-size:15px;line-height:1.5}
43 .grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-bottom:32px}
44 .stat{text-align:center}
45 .stat-value{font-size:22px;font-weight:600}
46 .stat-label{font-size:13px;color:#888;margin-top:4px}
47 .cta{display:block;width:100%;padding:14px;background:#111;color:#fff;border:none;border-radius:8px;font-size:16px;cursor:pointer;text-align:center}
48 </style>
49 </head>
50 <body>
51 <div class="card">
52 <div class="mock">MOCK — Development placeholder</div>
53 <h1>${offer.title}</h1>
54 <p class="desc">${offer.description}</p>
55 <div class="grid">
56 <div class="stat"><div class="stat-value">${premium}</div><div class="stat-label">Monthly</div></div>
57 <div class="stat"><div class="stat-value">${deductible}</div><div class="stat-label">Deductible</div></div>
58 <div class="stat"><div class="stat-value">${limit}</div><div class="stat-label">Coverage</div></div>
59 </div>
60 <button class="cta" disabled>Get This Policy</button>
61 </div>
62 </body>
63 </html>`;
64 }
65 );
66 }
67 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/mcp/routes.ts","limit":60}1 import type { FastifyInstance } from 'fastify';
2 import { routeGetWellKnownOpenaiAppsChallenge } from '@/domains/mcp/routes/get-well-known-openai-apps-challenge.js';
3 import { routeGetWellKnownOauthProtectedResource } from '@/domains/mcp/routes/get-well-known-oauth-protected-resource.js';
4 import { routeGetWellKnownOauthAuthorizationServer } from '@/domains/mcp/routes/get-well-known-oauth-authorization-server.js';
5 import { routeGetWellKnownOpenidConfiguration } from '@/domains/mcp/routes/get-well-known-openid-configuration.js';
6 import { routeV1McpServerId } from '@/domains/mcp/routes/v1-mcp-[serverId].js';
7 import { routeV1McpServerIdOpenai } from '@/domains/mcp/routes/v1-mcp-[serverId]-openai.js';
8 import { routeGetV1McpOutboundOffer } from '@/domains/mcp/routes/get-v1-mcp-outbound-offer.js';
9
10 export function registerMcpRoutes(fastify: FastifyInstance): void {
11 // .well-known (vhost-gated — only respond on known MCP hosts)
12 routeGetWellKnownOpenaiAppsChallenge(fastify);
13 routeGetWellKnownOauthProtectedResource(fastify);
14 routeGetWellKnownOauthAuthorizationServer(fastify);
15 routeGetWellKnownOpenidConfiguration(fastify);
16
17 // MCP transport (standard)
18 routeV1McpServerId(fastify);
19
20 // MCP transport (OpenAI compat — normalizes broken headers)
21 routeV1McpServerIdOpenai(fastify);
22
23 // Outbound links (rendered in browser when users click offer links)
24 routeGetV1McpOutboundOffer(fastify);
25 }
26 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/bundleRoutes.ts","limit":100,"offset":270}270 export const publicBundleRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
271 // GET /v1/bundles — list available bundles (for embed discovery)
272 fastify.get('/bundles', {
273 preHandler: [fastify.authenticateScope(['quotes:create'])],
274 schema: {
275 description:
276 'Returns the active product bundles configured for the authenticated organization. Use this to populate a bundle picker in the embed front-end. Each bundle exposes the products it contains, the minimum number of products the customer must select, and any discount tiers configured for the bundle.',
277 tags: ['Bundle Subscriptions'],
278 security: [{ clientCredentials: [] }],
279 querystring: listBundlesQuerystringSchema,
280 response: {
281 200: {
282 type: 'object',
283 additionalProperties: true,
284 properties: {
285 status: { type: 'string', description: 'Always "success" on 200 responses.', example: 'success' },
286 data: {
287 type: 'array',
288 description: 'Active bundles for the organization (filtered by `bundleName` when provided).',
289 items: { type: 'object', additionalProperties: true },
290 },
291 },
292 },
293 },
294 },
295 }, async (request, reply) => {
296 return listBundles(request as any, reply);
297 });
298
299 // GET /v1/quote/bundle/form — merged quote form for all products in a bundle
300 // (must be registered before :bundleQuoteId to avoid param collision)
301 fastify.get('/quote/bundle/form', {
302 preHandler: [fastify.authenticateScope(['quotes:create'])],
303 schema: {
304 description:
305 'Returns the merged quote form for all products in a bundle. Parameters shared across products are placed first; product-specific parameters appear after. Use this to render a single combined questionnaire that, when submitted, can drive POST /v1/quote/bundle.',
306 tags: ['Bundle Subscriptions'],
307 security: [{ clientCredentials: [] }],
308 querystring: bundleQuoteFormQuerystringSchema,
309 response: {
310 200: {
311 type: 'object',
312 additionalProperties: true,
313 properties: {
314 bundleName: { type: 'string', description: 'Stable machine name of the bundle.', example: 'travel-health-bundle' },
315 bundleTitle: { type: 'string', description: 'Human-readable bundle title for headings.', example: 'Travel + Health bundle' },
316 minimumProducts: { type: 'integer', description: 'Minimum number of products the customer must select to qualify for the bundle.', example: 2 },
317 products: {
318 type: 'array',
319 description: 'Names of every product included in the bundle definition.',
320 items: { type: 'string' },
321 },
322 pages: {
323 type: 'array',
324 description: 'Ordered page list. Each page contains parameter categories with their fields ready to render.',
325 items: { type: 'object', additionalProperties: true },
326 },
327 },
328 },
329 },
330 },
331 }, async (request, reply) => {
332 return getBundleQuoteForm(request as any, reply);
333 });
334
335 // POST /v1/quote/bundle — create bundle quote
336 fastify.post('/quote/bundle', {
337 preHandler: [fastify.authenticateScope(['quotes:create'])],
338 schema: {
339 description:
340 'Creates a bundle quote — runs each selected product through pricing in parallel, applies the bundle\'s discount tiers, and returns the combined result plus the per-product quote session ids. The returned `bundleQuoteId` can be passed to GET /v1/quote/bundle/:bundleQuoteId to refetch and to POST /v1/policy/bundle to convert the quote into policies.',
341 tags: ['Bundle Subscriptions'],
342 security: [{ clientCredentials: [] }],
343 body: createBundleQuoteBodySchema,
344 response: {
345 201: {
346 type: 'object',
347 additionalProperties: true,
348 properties: {
349 bundleQuoteId: { type: 'string', format: 'uuid', description: 'UUID of the new bundle quote — pass to subsequent calls.', example: '7ccf8ee2-56b1-449e-b54a-34e03356e352' },
350 selectedProductNames: {
351 type: 'array',
352 description: 'Echoes the list of products the caller selected.',
353 items: { type: 'string' },
354 },
355 insurance: {
356 type: 'object',
357 additionalProperties: true,
358 description: 'Per-product pricing breakdown keyed by product name (premium, taxes, fees, plus optional coverage details when `showCoverages=true`).',
359 },
360 discount: {
361 type: ['object', 'null'],
362 additionalProperties: true,
363 description: 'Resolved bundle-level discount applied to the quote. Null when no tier qualified.',
364 },
365 availableTiers: {
366 type: 'array',
367 description: 'All discount tiers configured on the bundle, with each tier annotated to indicate whether the current selection qualifies. Useful for upsell hints ("add 1 more product to save 10%").',
368 items: { type: 'object', additionalProperties: true },
369 },{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.29T00.00.02.add-message-translation-fields.ts"}1 import type { MigrationFn } from 'umzug';
2 import { QueryInterface, DataTypes } from 'sequelize';
3
4 // Adds outbound-translation provenance to PolicyMessages + ClaimMessages.
5 // When a CRM operator translates before sending: `body` holds the translated
6 // text that the customer reads, `bodyOriginal` retains the agent's original
7 // (so the agent can re-display what they wrote), and the language pair records
8 // what was translated to what. For untranslated messages only `originalLanguage`
9 // is set. Inbound (customer-authored) messages set only `originalLanguage`.
10 // On-demand inbound translations are NOT persisted — they're computed and
11 // cached at read time so re-translation stays cheap without locking the row
12 // to a translation that may become stale.
13
14 export const up: MigrationFn<QueryInterface> = async ({ context }) => {
15 for (const table of ['PolicyMessages', 'ClaimMessages'] as const) {
16 await context.addColumn(table, 'bodyOriginal', {
17 type: DataTypes.TEXT,
18 allowNull: true,
19 });
20 await context.addColumn(table, 'originalLanguage', {
21 type: DataTypes.STRING(10),
22 allowNull: true,
23 });
24 await context.addColumn(table, 'translatedLanguage', {
25 type: DataTypes.STRING(10),
26 allowNull: true,
27 });
28 await context.addColumn(table, 'translationModel', {
29 type: DataTypes.STRING(64),
30 allowNull: true,
31 });
32 await context.addColumn(table, 'translatedAt', {
33 type: DataTypes.DATE,
34 allowNull: true,
35 });
36 }
37 };
38
39 export const down: MigrationFn<QueryInterface> = async () => {
40 // Forward-only deployment
41 };
42 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes.ts","limit":60,"offset":1}1 import type { FastifyInstance } from 'fastify';
2 import { getRequired } from '../lib.js';
3 import packageJson from '../package.json' with { type: 'json' };
4 import {
5 anotherSherpaSchema,
6 handleAnotherSherpa,
7 } from '@/domains/lens/controllers/another-sherpa.js';
8 import { handleLensHealth } from '@/domains/lens/controllers/health.js';
9 import { handleHelloSherpa, helloSherpaSchema } from '@/domains/lens/controllers/hello-sherpa.js';
10 import {
11 handleQuote,
12 handleQuoteDev,
13 handleQuoteSignal,
14 quoteSchema,
15 quoteDevSchema,
16 quoteSignalSchema,
17 } from '@/domains/lens/controllers/quote.js';
18 import {
19 handleSimulationComplete,
20 simulationCompleteSchema,
21 } from '@/domains/lens/controllers/simulation-complete.js';
22 import {
23 handleSimulationDynamic,
24 simulationDynamicSchema,
25 } from '@/domains/lens/controllers/simulation-dynamic.js';
26 import {
27 handleSimulationHistogramsLoans,
28 simulationHistogramsLoansSchema,
29 } from '@/domains/lens/controllers/simulation-histograms-loans.js';
30 import {
31 handleSimulationStages,
32 simulationStagesSchema,
33 } from '@/domains/lens/controllers/simulation-stages.js';
34 import {
35 getTaskRequestById,
36 getTaskRequestSchema,
37 } from '@/domains/lens/controllers/task-requests.js';
38 import {
39 getTaskOutputs,
40 getTaskOutputsSchema,
41 getTaskOutputFileRedirect,
42 getTaskOutputFileRedirectSchema,
43 getTaskOutputFileUrl,
44 getTaskOutputFileUrlSchema,
45 } from '@/domains/lens/controllers/task-results/outputs.js';
46 import {
47 createTaskResult,
48 createTaskResultSchema,
49 getTaskResultById,
50 getTaskResultSchema,
51 } from '@/domains/lens/controllers/task-results.js';
52 import { getTaskDataFiles, getTaskDataFilesSchema } from '@/domains/lens/controllers/tasks/data.js';
53 import {
54 handleModelReset,
55 modelResetSchema,
56 } from '@/domains/lens/controllers/tasks/models/model.js';
57 import {
58 dataStatsToolSchema,
59 handleDataStatsTool,
60 handleDataStatsToolExtended,{"command":"grep -n \"cancel\\|withdraw\\|public\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/customerSelfRoutes.ts | head -40"}445: description: 'Lists active bundle subscriptions held by the authenticated customer. A bundle subscription groups several individual policies sold together (e.g. a home + contents + liability bundle). Use this to power a "My subscriptions" card in the customer portal. Only rows with status="active" are returned; cancelled/expired subscriptions are excluded.',
481: subscriptionId: { type: 'string', description: 'Stable bundle subscription identifier (matches BundleSubscription.subscriptionId, the public id), not the numeric primary key.', example: 'sub_01HF8K3R5N2A7Z9XQYV1MEDC4P' },{"command":"git -C /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance branch -vv 2>/dev/null || echo \"Not a git repo\""}{"command":"tail -100 /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes.ts"}+ auto/api-456-outlet-catalog-per-product-outle adb3086a (/Users/bakker/dev/gangkhar/sherpa-api-api-456-outlet-catalog-per-product-outle) [origin/auto/api-456-outlet-catalog-per-product-outle] chore: eval proof screenshots (#456) + auto/api-457-custom-additional-fields-on-endo a93cf56c (/Users/bakker/dev/gangkhar/sherpa-api-api-457-custom-additional-fields-on-endo) [origin/auto/api-457-custom-additional-fields-on-endo] test(#457): independent QE proof — portal + embed endorsement flows verified + auto/api-458-credit-check-compliance-integrat f09370ff (/Users/bakker/dev/gangkhar/sherpa-api-api-458-credit-check-compliance-integrat) [origin/auto/api-458-credit-check-compliance-integrat] test(#458): independent QE eval screenshots (live portal UI) + auto/api-459-scheduled-pre-payout-sanction-sc a8b52403 (/Users/bakker/dev/gangkhar/sherpa-api-api-459-scheduled-pre-payout-sanction-sc) [origin/development] Merge pull request #473 from gangkharai/docs/sync-model-mapping-agents + auto/api-460-timed-delayed-emails-for-cooling a8b52403 (/Users/bakker/dev/gangkhar/sherpa-api-api-460-timed-delayed-emails-for-cooling) [origin/development] Merge pull request #473 from gangkharai/docs/sync-model-mapping-agents * auto/api-461-no-login-public-withdrawal-cance a8b52403 [origin/development] Merge pull request #473 from gangkharai/docs/sync-model-mapping-agents + auto/api-462-nordic-bank-direct-debit-autogir c7ed8bf4 (/Users/bakker/dev/gangkhar/sherpa-api-api-462-nordic-bank-direct-debit-autogir) [origin/auto/api-462-nordic-bank-direct-debit-autogir] test(qe): round-2 eval screenshots for #462 (reconcile-to-policy verified in UI) + auto/api-463-recurring-card-payments-subscrip 9cb7706b (/Users/bakker/dev/gangkhar/sherpa-api-api-463-recurring-card-payments-subscrip) [origin/auto/api-463-recurring-card-payments-subscrip] test(#463): independent QE round-2 eval screenshots (cancellation verified live) + auto/api-464-multiple-payment-providers-per-p 0a881c9a (/Users/bakker/dev/gangkhar/sherpa-api-api-464-multiple-payment-providers-per-p) [origin/auto/api-464-multiple-payment-providers-per-p] chore: QA eval proof (#464 round 2) + auto/api-465-per-outlet-claim-rules 87149333 (/Users/bakker/dev/gangkhar/sherpa-api-api-465-per-outlet-claim-rules) [origin/auto/api-465-per-outlet-claim-rules] chore(qe): eval proof screenshots for per-outlet claim rules (#465) + auto/api-466-passkey-webauthn-login 078f81bd (/Users/bakker/dev/gangkhar/sherpa-api-api-466-passkey-webauthn-login) [origin/auto/api-466-passkey-webauthn-login] test(qe): independent QE proof screenshots for #466 passkey UI + auto/portal-263-per-outlet-documents-template adb3086a (/Users/bakker/dev/gangkhar/sherpa-api-portal-263-per-outlet-documents-template) [origin/auto/api-456-outlet-catalog-per-product-outle] chore: eval proof screenshots (#456) chore/remove-lmt-dep bf870932 [origin/development: behind 188] Merge pull request #407 from gangkharai/revert/workflow-docker-touches chore/scrub-claude-worktrees 271facd3 [origin/chore/scrub-claude-worktrees] chore: scrub .claude/worktrees from repo + ignore future entries ci/dockerfile-deploy-npmci-buildkit-secret 3d3ff3fc [origin/ci/dockerfile-deploy-npmci-buildkit-secret] ci(deploy-dev): npm ci inside Docker via BuildKit secret (drop rsync) ci/dockerignore-strip-from-artifacts 3106b60a [origin/ci/dockerignore-strip-from-artifacts] ci(deploy-dev): strip .dockerignore from artifacts to ship node_modules ci/github-token-packages-auth cab169d5 [origin/ci/github-token-packages-auth] ci(deploy-dev): use built-in GITHUB_TOKEN for npm.pkg.github.com auth ci/restore-translations-mirror-portal c0899ef8 [origin/ci/restore-translations-mirror-portal] ci: restore LMT translations + GitHub Packages auth, mirror portal pattern ci/translate-cron-azure-env 976bdeb7 [origin/ci/translate-cron-azure-env] ci: add development environment to translate-cron workflow ci/translate-rebase-before-push 95b67a8a [origin/ci/translate-rebase-before-push] ci(deploy-dev): rebase before pushing translation commit ci/translations-azure-ai-env aeb7d377 [origin/ci/translations-azure-ai-env] ci(deploy-dev): set AZURE_AI_URL + fetch AZURE_AI_API_KEY for lmt-translate claude/assign-insurer-to-coverage-fyV4w da7397c0 [origin/claude/assign-insurer-to-coverage-fyV4w: gone] fix: handle missing organizationId in product lookup claude/coverage-level-taxes-fees-Y2Dkg 52ed957c [origin/claude/coverage-level-taxes-fees-Y2Dkg: gone] refactor: replace finalPrice with premiumWithoutTax in coverage pricing + claude/customer-support-module-RgEBm eeb3117f (/Users/bakker/dev/gangkhar/sherpa-api-customer-support-module-rgebm) [origin/claude/customer-support-module-RgEBm] refactor(support): route emails via [email protected] claude/delete-policy-claim-2oRWp f61c99a8 [origin/claude/delete-policy-claim-2oRWp: gone] Merge branch 'development' of github.com:gangkharai/sherpa-api into claude/delete-policy-claim-2oRWp claude/exclude-test-labels-production-i55PU 4b59f42d [origin/claude/exclude-test-labels-production-i55PU: gone] fix: allow 'test' in plus policy-orders and claim-records querystring schemas claude/hide-prices-telemedicine-YjSsP 934aa96c [origin/claude/hide-prices-telemedicine-YjSsP: gone] feat(security): strip commission data from customer/widget responses claude/implement-hide-prices-VL79w 9787d17c [origin/claude/implement-hide-prices-VL79w: gone] fix: strip additional pricing fields and fix FlatInvoice types claude/multi-currency-migration-backfill-fix 8ab85a76 [origin/claude/multi-currency-migration-backfill-fix] fix(migrations): backfill orphan country rows before NOT NULL claude/multi-currency-support-giJXu 2322694e [origin/claude/multi-currency-support-giJXu] fix(migrations): backfill orphan rows before NOT NULL country claude/per-coverage-taxes-review-jx25t 6e9aaa15 [origin/claude/per-coverage-taxes-review-jx25t: gone] fix: wire up commission tax fields across all PolicyOrder creation paths claude/plan-sanction-screening-PTxLR 42bc6e7d [origin/claude/plan-sanction-screening-PTxLR] fix(tenant-safety): import internal models via safe wrappers claude/pricing-rule-value-types-PZm9E 87033c88 [origin/claude/pricing-rule-value-types-PZm9E: gone] Merge remote-tracking branch 'origin/development' into claude/pricing-rule-value-types-PZm9E + development 8636f82d (/Users/bakker/dev/gangkhar/sherpa-api) [origin/development: behind 10] Merge pull request #469 from gangkharai/feature/checkout-premium-prefix-i18n feat/add-german-locale f4c2772e [origin/feat/add-german-locale] feat(i18n): add German (de) as 12th supported locale + feat/auto-detect-style b8123231 (/Users/bakker/dev/gangkhar/sherpa-api-auto-detect-style) [origin/feat/auto-detect-style] api(funnel-templates): drop templateType concept + add default fallback feat/compliance-pending-review-count eec8c9ce [origin/feat/compliance-pending-review-count] feat(plus): add pending-review count endpoint for compliance checks feat/cors-per-tenant-portal-domain 1ac067a4 [origin/feat/cors-per-tenant-portal-domain] feat(cors): per-tenant allowlist via Organization.portalDomain feat/dynamic-commission-config c347bdf1 [origin/feat/dynamic-commission-config: gone] feat: add dynamicCommissionConfig to commission structures feat/embed-checkout-allowed-parent-origins 1faffc01 [origin/feat/embed-checkout-allowed-parent-origins] feat(embed-checkout): add allowedParentOrigins to session response feat/embed-customer-portal-land-on-dashboard 84c821d6 [origin/feat/embed-customer-portal-land-on-dashboard: behind
registerPaymentRoutes(fastify);
});
// Lens domain routes - all grouped together
await app.register(async function (fastify) {
fastify.addHook('preHandler', fastify.authenticate);
// Journey routes
routePostV1LensJourneyEvents(fastify);
routeGetV1LensJourneyEvents(fastify);
routeGetV1LensJourneyEventsArrow(fastify);
routeGetV1LensJourneyEventsBatch(fastify);
routeGetV1LensJourneySSE(fastify);
// Dev-only routes
const sherpaEnv = getRequired('SHERPA_ENV');
if (sherpaEnv !== 'production') {
routeGetV1LensJourneyDevDump(fastify);
}
// Optimization types (shared across all optimization types)
routeGetV1LensOptimizationTypes(fastify);
// Commission optimization routes
routePostV1LensOptimizationsCommission(fastify);
routeGetV1LensOptimizationsCommission(fastify);
routeGetV1LensOptimizationsCommissionId(fastify);
routePatchV1LensOptimizationsCommissionId(fastify);
routeDeleteV1LensOptimizationsCommissionId(fastify);
routePostV1LensOptimizationsModelsCommission(fastify);
routeGetV1LensOptimizationsModelsCommission(fastify);
routeGetV1LensOptimizationsModelsCommissionId(fastify);
routePostV1LensOptimizationsCommissionIdStages(fastify);
routeGetV1LensOptimizationsCommissionIdStages(fastify);
routeGetV1LensOptimizationsCommissionIdStagesId(fastify);
routeGetV1LensOptimizationsCommissionIdActivations(fastify);
routePostV1LensCommissionQuote(fastify);
routeGetV1LensOptimizationsCommissionIdQuotes(fastify);
routePostV1LensCommissionGenerateBatch(fastify);
routeDeleteV1LensCommissionBatchEvents(fastify);
routeGetV1LensSyntheticClientTaskShape(fastify);
routeGetV1LensSyntheticClientTaskPosterior(fastify);
routeGetV1LensSyntheticClientTaskHealth(fastify);
routeGetV1LensSyntheticClientTaskClusterHealth(fastify);
routeGetV1LensSyntheticClientPropensityCoherence(fastify);
routeGetV1LensSyntheticClientTaskSuggestedConfig(fastify);
routePostV1LensSyntheticClientBanditReset(fastify);
routeGetV1LensSyntheticClientMetrics(fastify);
// Persistent-client (long-lived hosted synthetic traffic)
routePostV1LensSyntheticClientClients(fastify);
routeGetV1LensSyntheticClientClient(fastify);
routeGetV1LensSyntheticClientByOptimization(fastify);
routePatchV1LensSyntheticClientClient(fastify);
routePostV1LensSyntheticClientActions(fastify);
routePostV1LensSyntheticClientRate(fastify);
routeDeleteV1LensSyntheticClientClient(fastify);
routeGetV1LensSyntheticClientEventsStream(fastify);
routeGetV1LensSyntheticClientErrors(fastify);
// Lens tool routes (these proxy to lens for analyzing csv files their fields and cardinality)
fastify.post('/v1/lens/tools/data-stats', { schema: dataStatsToolSchema }, handleDataStatsTool);
fastify.post('/v1/lens/tools/data-stats-extended', { schema: dataStatsToolSchema }, handleDataStatsToolExtended);
// Lens task model routes
fastify.patch('/v1/lens/tasks/:id/models/:model', { schema: modelResetSchema }, handleModelReset);
// Lens task data routes
fastify.get('/v1/lens/tasks/:taskId/data', { schema: getTaskDataFilesSchema }, getTaskDataFiles);
// Task request routes
fastify.get('/v1/task-requests/:taskId', { schema: getTaskRequestSchema }, getTaskRequestById);
// Task result routes
fastify.get('/v1/task-results/:taskId', { schema: getTaskResultSchema }, getTaskResultById);
fastify.post('/v1/task-results/:taskId', { schema: createTaskResultSchema }, createTaskResult);
// Task outputs route
fastify.get('/v1/task-results/:id/outputs', { schema: getTaskOutputsSchema }, getTaskOutputs);
// Task output file redirect route
fastify.get('/v1/task-results/:taskId/output-redirect', { schema: getTaskOutputFileRedirectSchema }, getTaskOutputFileRedirect);
// Task output file URL route (JSON response)
fastify.get('/v1/task-results/:taskId/output', { schema: getTaskOutputFileUrlSchema }, getTaskOutputFileUrl);
// Lens service routes
fastify.post('/v1/lens/hello-sherpa', { schema: helloSherpaSchema }, handleHelloSherpa);
fastify.post('/v1/lens/another-sherpa', { schema: anotherSherpaSchema }, handleAnotherSherpa);
fastify.post('/v1/lens/simulation-histograms-loans', { schema: simulationHistogramsLoansSchema }, handleSimulationHistogramsLoans);
fastify.post('/v1/lens/simulation-dynamic', { schema: simulationDynamicSchema }, handleSimulationDynamic);
fastify.post('/v1/lens/simulation-complete', { schema: simulationCompleteSchema }, handleSimulationComplete);
fastify.post('/v1/lens/simulation-stages', { schema: simulationStagesSchema }, handleSimulationStages);
// @deprecated - Use journey events instead
fastify.post('/v1/lens/quote', { schema: quoteSchema }, handleQuote);
fastify.post('/v1/lens/quote:dev', { schema: quoteDevSchema }, handleQuoteDev);
fastify.post('/v1/lens/quote-signal', { schema: quoteSignalSchema }, handleQuoteSignal);
});
}{"command":"grep -A 20 \"extendWithRoutes\\|extendWithBaseRoutes\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes.ts | head -40"}export async function extendWithBaseRoutes(app: FastifyInstance): Promise<void> {
// Health routes
app.get(
'/v1/health',
{
schema: {
hide: true,
description: 'Comprehensive health check covering database, cache, and Fastify event loop. Used by Kubernetes readiness probes and the deploy pipeline. Not part of the partner integration surface — partners do not need to call this endpoint.',
tags: ['Internal — Health'],
},
},
comprehensiveHealthCheck
);
// Lightweight service health check for the load balancers
app.get(
'/v1/health/self',
{
schema: {
hide: true,
description: 'Lightweight self-only health check used by load balancers and Kubernetes liveness probes. Returns 200 as soon as the Fastify process can serve a request, without checking downstream dependencies.',
--
export async function extendWithRoutes(app: FastifyInstance): Promise<void> {
await app.register(organizationRoutes, { prefix: '/v1' });
await app.register(plusRoutes, { prefix: '/v1/plus' });
await app.register(authRoutes, { prefix: '/v1/auth' });
await app.register(azureAdRoutes, { prefix: '/v1/auth/azure' });
await app.register(socialAuthRoutes, { prefix: '/v1/auth' });
await app.register(organizationSwitchingRoutes, { prefix: '/v1/auth' });
await app.register(trailRoutes, { prefix: '/v1/trail' });
// Data routes (authenticated)
await app.register(async function (fastify) {
fastify.addHook('preHandler', fastify.authenticate);
fastify.addHook('preHandler', async (request) => {
if (request.headers['x-widget-token']) {
throw new StatusError(403, 'Widget tokens cannot access data resources');
}
});{"command":"grep -n \"handleCreditNote\\|handleInvoice\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/payments/service.ts | head -10"}{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes.ts","offset":320,"limit":80}320 routeGetV1LensJourneySSE(fastify);
321
322 // Dev-only routes
323 const sherpaEnv = getRequired('SHERPA_ENV');
324 if (sherpaEnv !== 'production') {
325 routeGetV1LensJourneyDevDump(fastify);
326 }
327
328 // Optimization types (shared across all optimization types)
329 routeGetV1LensOptimizationTypes(fastify);
330
331 // Commission optimization routes
332 routePostV1LensOptimizationsCommission(fastify);
333 routeGetV1LensOptimizationsCommission(fastify);
334 routeGetV1LensOptimizationsCommissionId(fastify);
335 routePatchV1LensOptimizationsCommissionId(fastify);
336 routeDeleteV1LensOptimizationsCommissionId(fastify);
337 routePostV1LensOptimizationsModelsCommission(fastify);
338 routeGetV1LensOptimizationsModelsCommission(fastify);
339 routeGetV1LensOptimizationsModelsCommissionId(fastify);
340 routePostV1LensOptimizationsCommissionIdStages(fastify);
341 routeGetV1LensOptimizationsCommissionIdStages(fastify);
342 routeGetV1LensOptimizationsCommissionIdStagesId(fastify);
343 routeGetV1LensOptimizationsCommissionIdActivations(fastify);
344 routePostV1LensCommissionQuote(fastify);
345 routeGetV1LensOptimizationsCommissionIdQuotes(fastify);
346 routePostV1LensCommissionGenerateBatch(fastify);
347 routeDeleteV1LensCommissionBatchEvents(fastify);
348 routeGetV1LensSyntheticClientTaskShape(fastify);
349 routeGetV1LensSyntheticClientTaskPosterior(fastify);
350 routeGetV1LensSyntheticClientTaskHealth(fastify);
351 routeGetV1LensSyntheticClientTaskClusterHealth(fastify);
352 routeGetV1LensSyntheticClientPropensityCoherence(fastify);
353 routeGetV1LensSyntheticClientTaskSuggestedConfig(fastify);
354 routePostV1LensSyntheticClientBanditReset(fastify);
355 routeGetV1LensSyntheticClientMetrics(fastify);
356
357 // Persistent-client (long-lived hosted synthetic traffic)
358 routePostV1LensSyntheticClientClients(fastify);
359 routeGetV1LensSyntheticClientClient(fastify);
360 routeGetV1LensSyntheticClientByOptimization(fastify);
361 routePatchV1LensSyntheticClientClient(fastify);
362 routePostV1LensSyntheticClientActions(fastify);
363 routePostV1LensSyntheticClientRate(fastify);
364 routeDeleteV1LensSyntheticClientClient(fastify);
365 routeGetV1LensSyntheticClientEventsStream(fastify);
366 routeGetV1LensSyntheticClientErrors(fastify);
367
368 // Lens tool routes (these proxy to lens for analyzing csv files their fields and cardinality)
369 fastify.post('/v1/lens/tools/data-stats', { schema: dataStatsToolSchema }, handleDataStatsTool);
370 fastify.post('/v1/lens/tools/data-stats-extended', { schema: dataStatsToolSchema }, handleDataStatsToolExtended);
371
372 // Lens task model routes
373 fastify.patch('/v1/lens/tasks/:id/models/:model', { schema: modelResetSchema }, handleModelReset);
374
375 // Lens task data routes
376 fastify.get('/v1/lens/tasks/:taskId/data', { schema: getTaskDataFilesSchema }, getTaskDataFiles);
377
378 // Task request routes
379 fastify.get('/v1/task-requests/:taskId', { schema: getTaskRequestSchema }, getTaskRequestById);
380
381 // Task result routes
382 fastify.get('/v1/task-results/:taskId', { schema: getTaskResultSchema }, getTaskResultById);
383 fastify.post('/v1/task-results/:taskId', { schema: createTaskResultSchema }, createTaskResult);
384
385 // Task outputs route
386 fastify.get('/v1/task-results/:id/outputs', { schema: getTaskOutputsSchema }, getTaskOutputs);
387
388 // Task output file redirect route
389 fastify.get('/v1/task-results/:taskId/output-redirect', { schema: getTaskOutputFileRedirectSchema }, getTaskOutputFileRedirect);
390
391 // Task output file URL route (JSON response)
392 fastify.get('/v1/task-results/:taskId/output', { schema: getTaskOutputFileUrlSchema }, getTaskOutputFileUrl);
393
394 // Lens service routes
395 fastify.post('/v1/lens/hello-sherpa', { schema: helloSherpaSchema }, handleHelloSherpa);
396 fastify.post('/v1/lens/another-sherpa', { schema: anotherSherpaSchema }, handleAnotherSherpa);
397 fastify.post('/v1/lens/simulation-histograms-loans', { schema: simulationHistogramsLoansSchema }, handleSimulationHistogramsLoans);
398 fastify.post('/v1/lens/simulation-dynamic', { schema: simulationDynamicSchema }, handleSimulationDynamic);
399 fastify.post('/v1/lens/simulation-complete', { schema: simulationCompleteSchema }, handleSimulationComplete);134:export const handleInvoice = async ( 367:export const handleCreditNote = async ( 570: ? await handleInvoice(pendingOrders[0], 'new_business', returnUrl)
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes.ts","offset":220,"limit":100}220 return securityTxt;
221 });
222
223 // MCP domain (no auth — handles its own transport + .well-known)
224 registerMcpRoutes(app);
225
226 app.post(
227 '/csp-violation-report-endpoint',
228 {
229 schema: {
230 hide: true,
231 },
232 },
233 async (request, reply) => {
234 if (request.body) {
235 logger.warn(request.body, 'CSP Violation: ');
236 } else {
237 logger.warn('CSP Violation: No data received!');
238 }
239 reply.code(204);
240 return;
241 }
242 );
243 }
244
245 export async function extendWithRoutes(app: FastifyInstance): Promise<void> {
246 await app.register(organizationRoutes, { prefix: '/v1' });
247 await app.register(plusRoutes, { prefix: '/v1/plus' });
248 await app.register(authRoutes, { prefix: '/v1/auth' });
249 await app.register(azureAdRoutes, { prefix: '/v1/auth/azure' });
250 await app.register(socialAuthRoutes, { prefix: '/v1/auth' });
251
252 await app.register(organizationSwitchingRoutes, { prefix: '/v1/auth' });
253 await app.register(trailRoutes, { prefix: '/v1/trail' });
254
255 // Data routes (authenticated)
256 await app.register(async function (fastify) {
257 fastify.addHook('preHandler', fastify.authenticate);
258 fastify.addHook('preHandler', async (request) => {
259 if (request.headers['x-widget-token']) {
260 throw new StatusError(403, 'Widget tokens cannot access data resources');
261 }
262 });
263 routePostV1DataUploadUrls(fastify);
264 routePostV1DataUploadValidate(fastify);
265 routeGetV1DataFiles(fastify);
266 routeGetV1DataFilesId(fastify);
267 routeGetV1DataFilesIdUrl(fastify);
268 routePatchV1DataFilesId(fastify);
269 routeDeleteV1DataFilesId(fastify);
270 routePostV1DataFilesIdExternalUuid(fastify);
271 });
272
273 // Data read-url — uses authenticateScope (client credentials), not session auth
274 routePostV1DataReadUrl(app);
275
276 // Data public routes (no auth)
277 routeGetV1DataPublicExternalUrl(app);
278
279 // @deprecated - Use /data routes instead. Will be removed in future version.
280 await app.register(uploadRoutes, { prefix: '/v1/uploads' });
281 await app.register(policyRoutes, { prefix: '/v1' });
282 await app.register(policyAcceptanceRoutes, { prefix: '/v1' });
283 await app.register(embedCheckoutRoutes, { prefix: '/v1' });
284 await app.register(policyPdfDownloadRoutes, { prefix: '/v1' });
285 await app.register(claimRoutes, { prefix: '/v1' });
286 await app.register(quoteRoutes, { prefix: '/v1/quote' });
287 await app.register(publicBundleRoutes, { prefix: '/v1' });
288 await app.register(customerPortalRoutes, { prefix: '/v1/customer-portal' });
289 await app.register(customerSelfRoutes, { prefix: '/v1/customers' });
290 await app.register(customerTemplateRoutes, { prefix: '/v1/templates' });
291
292 await app.register(userRoutes, { prefix: '/v1/users' });
293 await app.register(productApiRoutes, { prefix: '/v1' });
294 await app.register(dataModelRoutes, { prefix: '/v1/data-model' });
295 await app.register(funnelTemplatesRoutes, { prefix: '/v1' });
296 await app.register(funnelTemplateSetRoutes, { prefix: '/v1' });
297 await app.register(addressRoutes, { prefix: '/v1/address' });
298 await app.register(webhookRoutes);
299 // Stripe webhook in its own encapsulated context (needs raw body parser for signature verification)
300 await app.register(async function (fastify) {
301 registerStripeWebhook(fastify);
302 });
303 await app.register(testRoutes);
304
305 // Payment domain routes (authenticated)
306 await app.register(async function (fastify) {
307 fastify.addHook('preHandler', fastify.authenticate);
308 registerPaymentRoutes(fastify);
309 });
310
311 // Lens domain routes - all grouped together
312 await app.register(async function (fastify) {
313 fastify.addHook('preHandler', fastify.authenticate);
314
315 // Journey routes
316 routePostV1LensJourneyEvents(fastify);
317 routeGetV1LensJourneyEvents(fastify);
318 routeGetV1LensJourneyEventsArrow(fastify);
319 routeGetV1LensJourneyEventsBatch(fastify);{"command":"grep -r \"password-reset\\|requestPasswordReset\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers --include=\"*.ts\" | head -3"}/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/authController.ts:export async function requestPasswordReset(request: FastifyRequest<{ Body: PasswordResetRequest }>) {
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/authController.ts: requestPasswordReset,{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/authController.ts","limit":150}1 import * as bcryptjs from 'bcryptjs';
2 import { FastifyReply, FastifyRequest } from 'fastify';
3 import Joi from 'joi';
4 import jwt from 'jsonwebtoken';
5 import { Op, QueryTypes } from 'sequelize';
6
7 import { getRequired, getPortalUrl } from '../../lib.js';
8 import { StatusError } from '@/error/error.js';
9 import { Client } from '@/models/Client.js';
10 import { Customer } from '@/models/Customer.js';
11 import { systemSequelize } from '@/models/db.js';
12 import { InvalidatedToken } from '@/models/InvalidatedToken.js';
13 import { User } from '@/models/User.js';
14 import { sendEmail } from '@/services/emailService.js';
15 import type {
16 LoginRequest,
17 RegisterRequest,
18 RefreshTokenRequest,
19 PasswordResetRequest,
20 ResetPasswordRequest,
21 ResendTwoFactorRequest,
22 DecodedToken,
23 } from '@/types/fastify.js';
24 import {
25 generateAccessToken,
26 generateClientAccessToken,
27 generateClientRefreshToken,
28 generateCustomerAccessToken,
29 generateCustomerRefreshToken,
30 generateEmailToken,
31 generatePortalAccessToken,
32 generatePortalRefreshToken,
33 generateRefreshToken,
34 } from '@/utils/tokenUtils.js';
35 import { toProductionDomain } from '@/utils/localDevDomain.js';
36
37 // Environment variables at module scope
38 const JWT_SECRET = getRequired('JWT_SECRET');
39 const JWT_REFRESH_SECRET = getRequired('JWT_REFRESH_SECRET');
40 const NODE_ENV = getRequired('NODE_ENV');
41
42 const passwordRegex =
43 /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,}$/;
44
45 const registerSchema = Joi.object({
46 firstName: Joi.string().trim().min(2).max(50).required(),
47 lastName: Joi.string().trim().min(2).max(50).required(),
48 email: Joi.string().email().trim().lowercase().max(100).required(),
49 password: Joi.string().pattern(passwordRegex).required(),
50 phoneNumber: Joi
51 .string()
52 .pattern(/^\+?[1-9]\d{1,14}$/)
53 .required(), // E.164 format
54 country: Joi.string().min(2).max(50).required(),
55 region: Joi.string().min(2).max(50).required(),
56 address: Joi.string().min(5).max(100).required(),
57 city: Joi.string().min(2).max(50).required(),
58 postalCode: Joi.string().min(3).max(20).required(),
59 companyName: Joi.string().trim().max(100).allow('', null).optional(),
60 // Optional social registration fields
61 socialRegistration: Joi.boolean().optional(),
62 socialProvider: Joi.string().allow(null, '').optional(),
63 socialId: Joi.string().allow(null, '').optional(),
64 });
65
66 const loginSchema = Joi.object({
67 email: Joi.string().email().trim().lowercase().required(),
68 password: Joi.string().required(),
69 });
70
71 /**
72 * Validates that the request Origin matches the domainOrganization's portalDomain.
73 * Prevents attackers from forging Origin headers to create data in other organizations.
74 */
75 const validateOrigin = (
76 request: FastifyRequest,
77 domainPortalDomain: string,
78 ): void => {
79 const origin = request.headers.origin || request.headers.referer;
80
81 if (!origin) {
82 throw new StatusError(400, 'Origin header required for customer registration');
83 }
84
85 let originHostname: string;
86 try {
87 originHostname = new URL(origin).hostname;
88 } catch {
89 throw new StatusError(400, 'Invalid Origin header');
90 }
91
92 const normalizedOrigin = toProductionDomain(originHostname);
93 const normalizedPortal = toProductionDomain(domainPortalDomain);
94
95 if (normalizedOrigin !== normalizedPortal) {
96 throw new StatusError(403, 'Origin does not match organization portal domain');
97 }
98 };
99
100 // Login function with proper validation
101 export async function login(request: FastifyRequest<{ Body: LoginRequest }>) {
102 // Add Joi validation that was in the original
103 const validationResult = loginSchema.validate(request.body);
104 const { error } = validationResult;
105 if (error) {
106 throw new StatusError(400, 'Invalid credentials');
107 }
108
109 const validatedValue = validationResult.value as LoginRequest;
110 const { email, password } = validatedValue;
111
112 if (!email || !password) {
113 throw new StatusError(400, 'Email and password are required');
114 }
115
116 const user = await User.findOne({
117 where: {
118 email: email.toLowerCase(),
119 deleted: false
120 }
121 });
122
123 if (!user) {
124 throw new StatusError(401, 'Invalid credentials');
125 }
126
127 if (!user.password) {
128 throw new StatusError(401, 'Invalid credentials');
129 }
130
131 const isValidPassword = await bcryptjs.compare(password, user.password);
132 if (!isValidPassword) {
133 throw new StatusError(401, 'Invalid credentials');
134 }
135
136 // Resolve organization context: use domain org if user is a member, otherwise fall back to primary org
137 const domainOrganizationId = request.domainOrganization?.id;
138 let loginOrganizationId: string | undefined;
139 if (domainOrganizationId) {
140 // SAFE-SYSTEM: pre-auth membership probe; org context is being established here
141 const membershipRows = await systemSequelize.query<{ organizationId: string }>(
142 `SELECT "organizationId" FROM "UserOrganizations"
143 WHERE "userId" = :userId AND "organizationId" = :organizationId AND "isActive" = true
144 LIMIT 1`,
145 {
146 replacements: { userId: user.id, organizationId: domainOrganizationId },
147 type: QueryTypes.SELECT,
148 },
149 );
150 if (membershipRows.length > 0) {{"command":"grep -A 50 \"requestPasswordReset\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/authController.ts | head -60"}export async function requestPasswordReset(request: FastifyRequest<{ Body: PasswordResetRequest }>) {
const { email } = request.body;
if (!email) {
throw new StatusError(400, 'Email is required');
}
const user = await User.findOne({
where: {
email: email.toLowerCase(),
deleted: false
}
});
if (!user) {
// Don't reveal whether user exists or not
return {
status: 'success',
message: 'If an account with this email exists, a password reset link has been sent',
};
}
// Generate reset token (simple approach)
const resetToken = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
const resetTokenExpiry = new Date(Date.now() + 3600000); // 1 hour from now
await user.update({
resetToken,
resetTokenExpiry,
});
if (!user.email) {
throw new StatusError(400, 'User has no email address');
}
await sendEmail(
user.email,
'Password Reset Request',
`Your password reset token is: ${resetToken}`,
`<p>Your password reset token is: <strong>${resetToken}</strong></p>`
);
return {
status: 'success',
message: 'If an account with this email exists, a password reset link has been sent',
};
}
export async function resetPassword(request: FastifyRequest<{ Body: ResetPasswordRequest }>) {
const { token, newPassword } = request.body;
--
requestPasswordReset,
resetPassword,
socialAuthCallback,
resend2FAEmail,
};{"command":"grep -B 5 -A 50 \"password-reset-request\" /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/authRoutes.ts | head -80"} },
},
}, customerRefreshToken);
// Password reset request route
fastify.post('/password-reset-request', {
schema: {
description: 'Initiates a password-reset flow for a CRM/portal user by emailing them a one-time reset token. Use this from the portal "Forgot password?" form. For privacy, the response is identical (status 200, generic message) whether or not an account with the given email exists — never use this response to probe for account existence.',
tags: ['Authentication'],
body: requestPasswordResetBodySchema,
response: {
200: {
type: 'object',
additionalProperties: true,
description: 'Generic acknowledgement envelope. Returned for both existing and non-existing emails.',
properties: {
status: { type: 'string', description: 'Always "success" on a 2xx response.', example: 'success' },
message: { type: 'string', description: 'Generic, non-revealing message confirming that the request was accepted.', example: 'If an account with this email exists, a password reset link has been sent' },
},
},
},
},
}, requestPasswordReset);
// Password reset route
fastify.post('/password-reset', {
schema: {
description: 'Completes a password-reset flow by consuming the one-time reset token from the email and setting a new password on the user record. Use this as the second step after POST /v1/auth/password-reset-request. The token is single-use and expires 1 hour after issue; expired or already-used tokens return 400.',
tags: ['Authentication'],
body: resetPasswordBodySchema,
response: {
200: {
type: 'object',
additionalProperties: true,
description: 'Password reset success envelope.',
properties: {
status: { type: 'string', description: 'Always "success" on a 2xx response.', example: 'success' },
message: { type: 'string', description: 'Confirmation message.', example: 'Password reset successful' },
},
},
},
},
}, resetPassword);
// Create account with token route
fastify.post('/create-account', {
schema: {
description: 'Activates an account that was provisioned without a password (typically via an invite or a self-service customer record created by an agent) by consuming a one-time activation token and setting an initial password. Use this when the user clicks the "Set up your account" link emailed to them. The endpoint resolves the token first against pending Customer records, then against pending User records.',
tags: ['Authentication'],
body: createAccountWithTokenBodySchema,
response: {
200: {
type: 'object',
additionalProperties: true,
description: 'Account activation success envelope.',
properties: {
status: { type: 'string', description: 'Always "success" on a 2xx response.', example: 'success' },
message: { type: 'string', description: 'Confirmation message.', example: 'Account created successfully' },
},
},
},
},
}, createAccountWithToken);
// Social auth callback
fastify.get('/callback/:provider', {
schema: {
description: 'OAuth2 callback used by social-login providers to deliver an authorization payload back to the API. The handler issues access/refresh tokens and 302-redirects the browser back into the partner portal — there is no JSON response. Browsers hit this endpoint, not API clients; do not call it directly from server-side code.',
tags: ['Internal — SSO Callbacks'],
hide: true,
}
}, socialAuthCallback);
// Get active social auth providers
fastify.get('/providers', {
schema: {
description: 'Returns the list of social-login providers the platform currently has enabled (e.g. google, microsoft, facebook). Use this on the login screen to decide which "Sign in with ..." buttons to render. The list reflects the global SocialAuthProvider table — providers can be enabled or disabled at runtime.',
tags: ['Authentication'],Perfect! Now I have a comprehensive understanding. Let me create the final map document:
---
## POLICY CANCELLATION & WITHDRAWAL FLOW MAP
### **1. EXISTING CANCELLATION LOGIC**
**Grace Period / Cooling-Off / 14-Day Withdrawal:**
- **Model**: `/src/models/internal/_CancellationTerms.ts` (lines 1-128)
- `gracePeriodDays` field (line 68): Configured grace period (e.g., 14 days for EU cooling-off)
- `gracePeriodRefundType` (lines 73-75): Full/partial/prorated refund WITHIN grace period
- `standardRefundType` (lines 86-89): Refund type AFTER grace period
- Enums: `RefundType` (full_refund, partial_refund, prorated_refund, no_refund)
- Enums: `CancellationPeriodType` (COOLING_OFF, NOTICE_PERIOD, END_OF_TERM, IMMEDIATE)
**Cancellation Calculation Service:**
- **Main Logic**: `/src/services/cancellationService.ts` (lines 1-659)
- `previewCancellationOptions()` (lines 124-337): Returns up to 4 cancellation options without finalizing
- `calculateCancellationForDate()` (lines 339-495): Core calculation engine
- Grace period check (line 349): `const isWithinGracePeriod = daysFromInception <= cancellationTerms.gracePeriodDays`
- Generates: effective cancellation date, new expiry date, refund amount, cancellation fee, net refund
- Handles different cancellation period types (COOLING_OFF, NOTICE_PERIOD, END_OF_TERM)
**Policy Cancellation Controller:**
- **Cancel Endpoint**: `/src/controllers/policyOrderController.ts` (lines 1657-1967)
- Route: `POST /v1/policy/cancelPolicy/{policyId}` (policyRoutes.ts, line 586)
- Creates new `transactionType: CANCELLATION` PolicyOrder record
- Status transition: active → superseded, new record → cancelled
- Tracks: `cancelReason`, `cancelledBy` ('customer' | 'admin'), `cancellationDate`
- Calculates refund adjustments (lines 1756-1767): refundPremium, refundTax, refundCommission
**Preview Endpoint** (no-cancel):
- Route: `GET /v1/policy/cancelPolicy/{policyId}/preview` (policyRoutes.ts, line 516)
- Controller: `previewPolicyCancellation()` (policyOrderController.ts, lines 1530-1655)
- Returns available cancellation options without creating records
---
### **2. POLICY & POLICYORDER DATA MODEL**
**Core PolicyOrder Model:**
- **File**: `/src/models/internal/_PolicyOrder.ts` (lines 1-600+)
- **Key Fields**:
- `policyId` (UUID, line 108): Version-specific identifier
- `rootPolicyId`: Original policy in chain (endorsements/cancellations share same root)
- `previousPolicyId`: Policy this version supersedes
- `policyNumber` (line 243): Human-readable policy ID
- `status` (string, line 199): Per-organization status code (maps to lifecycle category via `OrganizationPolicyStatus`)
- `productName` (line 130), `productPlanName` (line 136): Product metadata
- `version` (integer): Monotonic version within policy chain (1 = original)
- `transactionType` (enum, line 26-29): NEW_BUSINESS, ENDORSEMENT, RENEWAL, CANCELLATION, SUSPENSION, REINSTATEMENT
**Cancellation-Related Fields:**
- `cancelReason` (enum CancelReason, line 246-249): unauthorized_charges, unclear_benefits, too_expensive, claim_experience, service_issues, no_longer_needed, considering_other_providers
- `cancellationDate` (DATEONLY, line 255): When cancellation becomes effective
- `cancelledBy` (enum, lines 264-267): 'customer' or 'admin'
- `supersededDate` (optional, line 261): When superseded
**Premium & Refund Fields:**
- `premiumWithTax`, `premiumWithoutTax`, `basePremium` (DECIMAL 15,2, lines 313-318)
- `salesTax` (line 325), `commissionTax` (line 331)
- `adjustmentPremium`, `adjustmentTax`, `adjustmentCommission` (for cancellation refunds)
**Dates:**
- `inceptionDate`, `issueDate`, `expiryDate` (DATEONLY, lines 205-217)
- `effectiveDate`: When this version takes effect (endorsement/cancellation time)
**Safe Model Wrapper:**
- `/src/models/PolicyOrder.ts` (lines 1-10): Wraps _PolicyOrder with RLS protections
---
### **3. REFUND HANDLING & PAYMENT INTEGRATION**
**Refund Type Logic:**
- `/src/services/cancellationService.ts` (lines 405-437, 570-598)
- **FULL_REFUND**: 100% premium back, zero fee
- **PARTIAL_REFUND**: `refundPercentage` × premium (e.g., 50%)
- **PRORATED_REFUND**: `(remainingDays / totalDays) × premium`
- **NO_REFUND**: Zero amount
**External Payment Provider Integration:**
- **File**: `/src/domains/payments/service.ts`
- `handleCreditNote()` (line 367): Issues refund credit for external providers
- Called from `cancelPolicy()` (policyOrderController.ts, line 1847)
- Only for non-test policies with `externalPaymentRef` set
- Routes to Stripe, PayPal, or other configured payment provider
**Internal Refund (when no external provider):**
- `generateInvoice()` (policyOrderController.ts, line 1859)
- Creates internal credit note as AdjustmentTransaction in Invoice table
- Uses `adjustmentPremium`, `adjustmentTax`, `adjustmentCommission` fields
---
### **4. AUDIT LOGGING**
**Audit Log Table & Plugin:**
- **Model**: `/src/models/internal/_AuditLog.ts` (lines 1-89)
- Fields: `organizationId`, `userId`, `customerId`, `action` (string), `details` (JSON), `ipAddress`, `userAgent`, `createdAt`
- Foreign keys to User and Customer
**Audit Plugin:**
- **File**: `/src/plugins/auditLog.ts` (lines 1-147)
- Automatically captures POST/PUT/PATCH/DELETE mutations on allow-listed paths
- Allow-list patterns (line 13-24): `/v1/auth/`, `/v1/admin/`, `/v1/policies/`, `/v1/plus/`, etc.
- Skip patterns (line 29-41): Excludes high-frequency endpoints (pricing, template render, token refresh)
- **NOT skipped**: POST /v1/policy/cancelPolicy/{policyId} — automatically audited
- Writes to AuditLogs table via `systemSequelize.query()` (line 69-89)
- Obfuscates sensitive fields: password, token, clientSecret, refreshToken, accessToken
- Captures: statusCode, duration, params (route params), body
**Trail System (Real-time Event Streaming):**
- **File**: `/src/trail/trail.ts` (lines 1-100+)
- Uses Valkey (Redis) pub/sub for event streaming
- Channels: `trail:channel:{id}`, `trail:buffer:{id}`
- Used for real-time policy updates and notifications
**Audit Actions Enum:**
- `/src/constants/auditLogActions.ts`
- CREATE, READ, UPDATE, DELETE, LOGIN, LOGOUT, REGISTER, PASSWORD_RESET, EXPORT, IMPORT
**Writing Audit Entries (Pattern from cancelPolicy):**
```typescript
// Already automatic via auditLogPlugin for POST/PUT/PATCH/DELETE on /v1/policy/cancelPolicy endpoints
// Manual entry example (if needed):
await systemSequelize.query(
`INSERT INTO "AuditLogs" ("organizationId", "userId", "action", "details", "ipAddress", "userAgent", "createdAt", "updatedAt")
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())`,
{
bind: [
organizationId,
userId,
'POST /v1/policy/cancelPolicy/' + policyId,
JSON.stringify({
statusCode: 200,
duration: 145,
params: { policyId },
body: { cancelReason, selectedOption }
}),
ipAddress,
userAgent
]
}
);
```
---
### **5. AUTHENTICATED CUSTOMER SELF-CANCEL (CURRENT)**
**Customer Self-Service Routes:**
- **File**: `/src/routes/customerSelfRoutes.ts`
- Base handler: `requireCustomer()` function (lines 70-83) extracts customerId from Bearer token
**Customer Policies Endpoint:**
- Still in exploration—customer-portal routes show customer can list their policies
- Cancellation would use same `POST /v1/policy/cancelPolicy/{policyId}` endpoint
- Auth: Customer-scoped Bearer token (from `POST /v1/auth/customer/login` or `/v1/auth/register`)
**Authenticated Cancellation Flow (Current):**
1. Customer calls `GET /v1/policy/cancelPolicy/{policyId}/preview` → preview options
2. Customer calls `POST /v1/policy/cancelPolicy/{policyId}` with:
- `cancelReason`, `selectedOption` (from preview)
- `cancelledBy: 'customer'` (implicit)
3. Creates new CANCELLATION PolicyOrder, supersedes active version
4. Triggers refund (external or internal) based on payment provider
5. Audited via auditLogPlugin
--Perfect! Now I have all the information I need. Let me create a comprehensive reference document:
## Sherpa API Infrastructure Reference for Public Cancellation Flow
I've mapped your infrastructure thoroughly. Here's the structured reference for implementing your public (no-auth) cancellation flow:
---
### 1. ROUTE STRUCTURE & REGISTRATION
**Route Files Location:** `/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes.ts` (master registration) + domain-specific route files in `/src/routes/` and `/src/domains/*/routes/`
**Pattern: Fastify Plugin Pattern**
```typescript
// File: /src/routes/authRoutes.ts (lines 39-41)
export const authRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
await fastify.register(async function (fastify: FastifyInstance) {
// Public routes (NO preHandler auth) inside this scope
fastify.post('/login', { schema: {...} }, login);
fastify.post('/register', { schema: {...} }, register);
fastify.post('/password-reset-request', { schema: {...} }, requestPasswordReset);
fastify.post('/password-reset', { schema: {...} }, resetPassword);
});
};
```
**Route Registration (Master):** `/src/routes.ts` lines 245-253
```typescript
export async function extendWithRoutes(app: FastifyInstance): Promise<void> {
await app.register(authRoutes, { prefix: '/v1/auth' });
// ... other routes
// AUTHENTICATED group (has preHandler hook)
await app.register(async function (fastify) {
fastify.addHook('preHandler', fastify.authenticate);
// routes here REQUIRE auth
});
}
```
**Public Routes Already in Codebase:**
- `/v1/auth/login` — no auth (lines 44-86)
- `/v1/auth/register` — no auth (lines 131-210)
- `/v1/auth/password-reset-request` — no auth (around line 380+)
- `/v1/auth/password-reset` — no auth (around line 410+)
- Data public routes: `/v1/data/public/*` (line 277)
- Health routes: `/v1/health`, `/v1/health/self` (no auth)
- MCP domain routes: `/v1/mcp/*` (line 224, no auth, handles own transport)
**How to Opt OUT of Auth:**
Define your route inside a plain `fastify.register()` block (NOT within the authenticated scope). No `preHandler: [fastify.authenticate]` hook. Example at `/src/routes/authRoutes.ts` lines 39-41.
---
### 2. RATE LIMITING
**Package:** `@fastify/[email protected]` (package.json line 72)
**Configuration:** `/src/utils/rateLimit.ts`
```typescript
// Lines 12-32
export function createRateLimitConfig(max: number, timeWindowMs: number): RateLimitOptions {
return {
max,
timeWindow: timeWindowMs,
keyGenerator: (request: FastifyRequest): string => {
return extractClientIp(request); // Per-IP rate limiting
},
errorResponseBuilder: (request: FastifyRequest, context: errorResponseBuilderContext) => {
return {
error: 'Too Many Requests',
message: `Rate limit exceeded. Maximum ${max} requests per ${timeWindowMs / 1000} seconds allowed.`,
limit: context.max,
timeWindow: timeWindowMs / 1000,
reset: new Date(Date.now() + context.ttl),
};
},
};
}
// Line 38 - Example
export const autocompleteRateLimit = createRateLimitConfig(10, 60 * 1000); // 10/min
```
**IP Extraction Helper:** `/src/utils/ip.ts` (used by `extractClientIp()`)
**Per-Route Application:** Add to route handler options:
```typescript
fastify.post('/v1/customer/cancellation/request', {
schema: {...},
rateLimit: createRateLimitConfig(3, 60 * 1000), // 3 per minute
}, handler);
```
---
### 3. EMAIL / SMS SENDING
**Email Service:** `/src/services/emailService.ts` (lines 1-98)
**Provider:**
- **Production:** SendGrid (`SENDGRID_USER`, `SENDGRID_PASS` env vars) — lines 12-18
- **Development:** MailCatcher (via `MAILCATCHER_HOST` env var, port 1025) — lines 20-29
**Send Email Function:**
```typescript
// Lines 40-98
export const sendEmail = async (
to: string,
subject: string,
text: string,
html: string,
attachments?: EmailAttachment[],
from?: string
): Promise<boolean>
```
**Example Usage from Password Reset:** `/src/controllers/authController.ts`
```typescript
await sendEmail(
user.email,
'Password Reset Request',
`Your password reset token is: ${resetToken}`,
`<p>Your password reset token is: <strong>${resetToken}</strong></p>`
);
```
**Default From:** `"Sherpa Insurance" <[email protected]>` (line 38)
**Environment Variables:**
- `SHERPA_ENV` — determines SendGrid vs MailCatcher
- `SENDGRID_USER`, `SENDGRID_PASS` — SendGrid credentials (production)
- `MAILCATCHER_HOST` — MailCatcher hostname (dev)
**No SMS:** No SMS/OTP service configured; only email is available.
---
### 4. ONE-TIME TOKEN & VERIFICATION LINK PATTERNS
**Token Generation Functions:** `/src/utils/crypto.ts`
**Crypto Helpers Available:**
```typescript
// Line 42-44: Secure random token (hex, 64 chars)
export function generateResetToken(): string {
return crypto.randomBytes(32).toString('hex');
}
// Lines 46-49: Secure random string (customizable length)
export function generateRandomString(length: number): string {
return crypto.randomBytes(length).toString('hex');
}
// Line 77-89: AES-256-GCM encryption (key + IV + auth tag)
export function encrypt(text: string, secretKey: string): string {
// Returns: "iv:authTag:encrypted"
}
// Line 91-107: Matching decrypt
export function decrypt(encryptedText: string, secretKey: string): string
```
**JWT Token Generation:** `/src/utils/tokenUtils.ts`
```typescript
// Lines 51-53: Email verification code (6 chars hex uppercase)
export const generateEmailToken = () => {
return crypto.randomBytes(3).toString('hex').toUpperCase();
};
// Lines 31-39: Temp JWT (5 min expiry, for 2FA challenges)
export const generateTempToken = (user: User) => {
return jwt.sign(
{ id: user.id, email: user.email, require2FA: true },
process.env.JWT_SECRET as string,
{ expiresIn: '5m' }
);
};
// Lines 55-61: 10 backup codes (8 chars hex each)
export const generateBackupCodes = () => {
const codes = [];
for (let i = 0; i < 10; i++) {
codes.push(crypto.randomBytes(4).toString('hex'));
}
return codes;
};
```
**Password Reset Token Pattern (Current):** `/src/controllers/authController.ts`
```typescript
// WEAK approach (don't copy)
const resetToken = Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
// Use generateResetToken() instead for crypto-secure tokens
```
**Verification Link Pattern:**
- Generate token → store with expiry timestamp
- Email link: `https://{portalDomain}/reset-password?token={token}`
- Validate token exists, not expired, not already used → set new password + mark consumed
---
### 5. VALIDATION & SCHEMA
**Schema Library:** TypeBox (`@sinclair/[email protected]`) — lines 1-4 of `/src/schemas/auth.schemas.ts`
**NOT Zod** — uses **TypeBox** for schema definition.
**Example Schema (Auth):** `/src/schemas/auth.schemas.ts` lines 1-60
```typescript
import { Type } from '@sinclair/typebox';
export const resetPasswordBodySchema = Type.Object({
token: Type.String({
description: 'The single-use reset token delivered to the user\'s email by POST /v1/auth/password-reset-request. Valid for 1 hour after issue. Consumed on success — replays return 400.',
example: 'kx9ay4nfqd2omq6yc7tdjp'
}),
newPassword: Type.String({
description: 'The new password to set. Must satisfy the standard password policy (at least 8 characters, mixed case, digit, and special character).',
example: 'N3w-S3cur3-P@ssw0rd'
}),
});
```
**Route Schema Usage:**
```typescript
fastify.post('/password-reset', {
schema: {
description: '...',
tags: ['Authentication'],
body: resetPasswordBodySchema,
response: {
200: { type: 'object', properties: {...} }
}
}
}, resetPassword);
```
**Password Validation Regex:** `/src/controllers/authController.ts` line 42-43
```typescript
const passwordRegex = /^(?=.*\d)(?=.*[a-z]Install done (exit 0). Note: routes use TypeBox, not zod. Now migrate + explore portal/embed in parallel.
{"command":"curl -fsS -X POST http://localhost:7777/api/api-action -H 'Content-Type: application/json' -d '{\"path\":\"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance\",\"action\":\"migrate\"}' 2>&1; echo \"\"","description":"Start API migrate via dashboard"}{"path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance","action":"migrate","status":"running","startedAt":1780328628612,"endedAt":null,"exitCode":null,"error":null,"logPath":"/var/folders/pr/7k4xc3v1715_0zbbw8tgwl5r0000gn/T/worktree-dashboard-logs/sherpa-api-api-461-no-login-public-withdrawal-cance-migrate.log","tail":""}{"description":"Map portal customer-facing structure","prompt":"Explore the sherpa-portal worktree at /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance (Next.js 16, React 19, Tailwind 4, App Router). I'm building a NEW public (no-login) policy cancellation/withdrawal UI. I need to understand the structure to add a public page + (if needed) a management/audit view.\n\nFind and report (exact file paths + line numbers + code excerpts):\n1. APP ROUTER STRUCTURE: Where are pages/routes (app/ directory)? How are public (unauthenticated) pages distinguished from authenticated ones? Is there a route group like (auth) vs (public) or a middleware/proxy that gates auth? Show the proxy.ts/middleware file if present (note: Next 16 uses proxy.ts not middleware.ts). Is there ANY existing public page (e.g. a login page, a password-reset page, a public funnel) I can mirror?\n2. API CLIENT: How does the portal call sherpa-api? Where is the base URL configured (env var name)? Show the fetch/axios wrapper. How are unauthenticated vs authenticated calls made?\n3. UI COMPONENTS / DESIGN SYSTEM: What component library / primitives exist (buttons, inputs, forms, cards, alerts, toasts)? Where do they live? Show 2-3 representative components I should reuse (e.g. Button, Input, Card). What's the styling approach (Tailwind classes, shadcn, custom)?\n4. FORMS & VALIDATION: How are forms handled (react-hook-form? plain state? zod?)? Show a representative form.\n5. EXISTING POLICY/CANCELLATION UI: Is there any existing policy management or cancellation screen in the authenticated portal? Where? (search \"cancel\", \"policy\", \"withdraw\").\n6. i18n: Is there a translation system (t() function)? Where are strings defined? (My memory says portal uses t() for enums.)\n7. How is the dev server run and what port/config? Show package.json scripts and any env files (.env, .env.local) — specifically the var that points at the API base URL.\n\nReport as a structured reference map with concrete excerpts. Do NOT write or edit any files.","subagent_type":"Explore"}{"command":"find /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance -type f -name \"*.ts\" -o -name \"*.tsx\" -o -name \"*.json\" | head -50"}/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/tmp_coh.json /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/.cursor/environment.json /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/.cursor/mcp.json /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/.tsconfig.app.json /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/customers/[id]/page.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/customers/page.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/settings/page.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/policyStatus.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/claimRule.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/dateConstraints.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/cancellationTerm.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/navigation.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/insurer.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/suspensionTerm.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/product.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/contracts.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/template.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/permissions.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/parameters.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/pricing.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/widgetToken.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/claim.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/admin.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/displayConditions.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/auditLog.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/paymentProvider.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/dataModel.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/policy.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/acceptance.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/quote.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/coveragePricing.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/financials.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/organization.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/user.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/commission.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/bundle.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/claimPayout.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/types/registration.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/products/new/page.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/products/[id]/claim-rules/page.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/products/[id]/commissions/[commissionId]/page.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/products/[id]/commissions/page.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/products/[id]/settings/page.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/products/[id]/embed/page.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/products/[id]/quote/[quoteId]/edit/page.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/products/[id]/quote/[quoteId]/page.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/products/[id]/quote/new/page.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/products/[id]/quote/page.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/products/[id]/terms/new/page.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/products/[id]/terms/[termId]/page.tsx
{"command":"ls -la /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/ | head -30"}total 1656 drwxr-xr-x@ 54 bakker staff 1728 Jun 1 08:39 . drwxr-xr-x 82 bakker staff 2624 Jun 1 08:39 .. drwxr-xr-x@ 3 bakker staff 96 Jun 1 08:39 .claude drwxr-xr-x@ 4 bakker staff 128 Jun 1 08:39 .cursor -rw-r--r--@ 1 bakker staff 82 Jun 1 08:39 .cursorignore -rw-r--r--@ 1 bakker staff 672 Jun 1 08:39 .dockerignore -rw-r--r--@ 1 bakker staff 1661 Jun 1 08:39 .env -rw-r--r--@ 1 bakker staff 119 Jun 1 08:39 .git drwxr-xr-x@ 5 bakker staff 160 Jun 1 08:39 .github -rw-r--r--@ 1 bakker staff 664 Jun 1 08:39 .gitignore drwxr-xr-x@ 3 bakker staff 96 Jun 1 08:39 .next -rw-r--r--@ 1 bakker staff 73 Jun 1 08:39 .npmrc -rw-r--r--@ 1 bakker staff 196 Jun 1 08:39 .prettierrc -rw-r--r--@ 1 bakker staff 96 Jun 1 08:39 .tailwindignore -rw-r--r--@ 1 bakker staff 77 Jun 1 08:39 .tsconfig.app.json drwxr-xr-x@ 4 bakker staff 128 Jun 1 08:39 .vscode -rw-r--r--@ 1 bakker staff 9668 Jun 1 08:39 AGENTS.md -rw-r--r--@ 1 bakker staff 2378 Jun 1 08:39 CLAUDE.md -rw-r--r--@ 1 bakker staff 1 Jun 1 08:39 CUSTOMER_INFORMATION_SOLUTION.md -rw-r--r--@ 1 bakker staff 155 Jun 1 08:39 Dockerfile -rw-r--r--@ 1 bakker staff 485 Jun 1 08:39 Makefile -rw-r--r--@ 1 bakker staff 8827 Jun 1 08:39 RBAC_FRONTEND_IMPLEMENTATION.md -rw-r--r--@ 1 bakker staff 9498 Jun 1 08:39 README.md -rw-r--r--@ 1 bakker staff 8602 Jun 1 08:39 RELEASE_PROCESS.md drwxr-xr-x@ 4 bakker staff 128 Jun 1 08:39 _deprecated drwxr-xr-x@ 53 bakker staff 1696 Jun 1 08:39 app -rw-r--r--@ 1 bakker staff 1 Jun 1 08:39 clear-auth.js -rw-r--r--@ 1 bakker staff 415 Jun 1 08:39 components.json drwxr-xr-x@ 3 bakker staff 96 Jun 1 08:39 docs
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/package.json"}1 {
2 "name": "sherpa-portal",
3 "version": "0.1.0",
4 "type": "module",
5 "private": true,
6 "scripts": {
7 "dev": "next dev --turbopack",
8 "dev:https": "next dev --turbopack --experimental-https --experimental-https-key ./ssl/localhost-key.pem --experimental-https-cert ./ssl/localhost-cert.pem",
9 "build": "next build",
10 "extract-labels": "tsx scripts/sync-common-labels.ts",
11 "translate": "dotenv -e .env -- lmt-translate",
12 "translate:verbose": "dotenv -e .env -- lmt-translate -v",
13 "translate:rebuild": "dotenv -e .env -- lmt-translate -v --rebuild",
14 "validate-translations": "lmt-validate-translations",
15 "start": "next start",
16 "lint": "next lint",
17 "format": "prettier --write .",
18 "format:check": "prettier --check .",
19 "type-check": "tsc --noEmit",
20 "pre-commit": "lint-staged",
21 "pre-push": "npm run security-audit",
22 "security-audit": "npm audit",
23 "check-all": "npm run type-check && npm run lint && npm run format:check && npm run security-audit",
24 "knip": "knip",
25 "test": "tsx tests/runner.ts",
26 "test:suite": "tsx tests/runner.ts"
27 },
28 "lint-staged": {
29 "*.{js,jsx,ts,tsx}": [
30 "eslint --fix",
31 "prettier --write"
32 ],
33 "*.{json,md,css}": [
34 "prettier --write"
35 ]
36 },
37 "dependencies": {
38 "@ai-sdk/react": "^3.0.44",
39 "@assistant-ui/react": "^0.12.10",
40 "@uwdata/flechette": "^2.5.0",
41 "@assistant-ui/react-ai-sdk": "^1.3.14",
42 "@assistant-ui/react-markdown": "^0.12.3",
43 "@codemirror/lang-css": "^6.3.1",
44 "@codemirror/lang-html": "^6.4.11",
45 "@codemirror/state": "^6.6.0",
46 "@codemirror/theme-one-dark": "^6.1.3",
47 "@codemirror/view": "^6.40.0",
48 "@dnd-kit/core": "^6.3.1",
49 "@dnd-kit/sortable": "^10.0.0",
50 "@dnd-kit/utilities": "^3.2.2",
51 "@gangkharai/chat-ui": "^0.1.34",
52 "@gangkharai/lmt": "^0.1.4",
53 "@heroicons/react": "^2.2.0",
54 "@radix-ui/react-accordion": "^1.2.10",
55 "@radix-ui/react-alert-dialog": "^1.1.13",
56 "@radix-ui/react-avatar": "^1.1.11",
57 "@radix-ui/react-checkbox": "^1.3.1",
58 "@radix-ui/react-collapsible": "^1.1.12",
59 "@radix-ui/react-dialog": "^1.1.13",
60 "@radix-ui/react-label": "^2.1.6",
61 "@radix-ui/react-radio-group": "^1.3.6",
62 "@radix-ui/react-select": "^2.2.4",
63 "@radix-ui/react-slot": "^1.2.4",
64 "@radix-ui/react-switch": "^1.2.4",
65 "@radix-ui/react-tabs": "^1.1.11",
66 "@radix-ui/react-tooltip": "^1.2.8",
67 "@sentry/nextjs": "^10.27.0",
68 "@tailwindcss/postcss": "^4.1.5",
69 "@types/dompurify": "^3.0.5",
70 "@types/pako": "^2.0.3",
71 "@types/papaparse": "^5.5.2",
72 "ai": "^6.0.42",
73 "apache-arrow": "^21.0.0",
74 "autoprefixer": "^10.4.21",
75 "axios": "^1.9.0",
76 "class-variance-authority": "^0.7.1",
77 "clsx": "^2.1.1",
78 "codemirror": "^6.0.2",
79 "date-fns": "^4.1.0",
80 "dompurify": "^3.3.3",
81 "exifr": "^7.1.3",
82 "framer-motion": "^12.23.26",
83 "joi": "^18.0.1",
84 "jwt-decode": "^4.0.0",
85 "libphonenumber-js": "^1.12.7",
86 "lucide-react": "^0.561.0",
87 "next": "^16.2.1",
88 "pako": "^2.1.0",
89 "papaparse": "^5.5.2",
90 "postcss-import": "^16.1.0",
91 "prismjs": "^1.30.0",
92 "react": "^19.2.0",
93 "react-country-region-selector": "^4.0.5",
94 "react-dnd": "^16.0.1",
95 "react-dnd-html5-backend": "^16.0.1",
96 "react-dom": "^19.2.0",
97 "react-hook-form": "^7.56.2",
98 "react-icons": "^5.5.0",
99 "react-phone-number-input": "^3.4.12",
100 "react-toastify": "^11.0.5",
101 "recharts": "^3.3.0",
102 "remark-gfm": "^4.0.1",
103 "tailwind-merge": "^3.2.0",
104 "tailwind-scrollbar": "^4.0.2",
105 "tailwindcss": "^4.1.5",
106 "tailwindcss-animate": "^1.0.7",
107 "tw-animate-css": "^1.4.0",
108 "unicode-emoji-json": "^0.8.0",
109 "web-vitals": "^5.1.0",
110 "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
111 "zustand": "^5.0.10"
112 },
113 "devDependencies": {
114 "@types/node": "^24.10.2",
115 "@types/react": "^19",
116 "@types/react-dom": "^19",
117 "@types/xlsx": "^0.0.36",
118 "@typescript-eslint/eslint-plugin": "^8.32.0",
119 "@typescript-eslint/parser": "^8.32.0",
120 "baseline-browser-mapping": "^2.8.32",
121 "dotenv-cli": "^11.0.0",
122 "eslint": "^9.26.0",
123 "eslint-config-next": "16.0.8",
124 "eslint-config-prettier": "^10.1.2",
125 "eslint-plugin-import": "^2.31.0",
126 "knip": "^5.80.2",
127 "lint-staged": "^16.2.6",
128 "prettier": "^3.7.4",
129 "prettier-plugin-tailwindcss": "^0.7.1",
130 "tsx": "^4.20.6",
131 "typescript": "^5.8.3"
132 },
133 "engines": {
134 "node": ">=22.13.0"
135 },
136 "optionalDependencies": {
137 "lightningcss-linux-x64-gnu": "^1.30.2"
138 },
139 "overrides": {
140 "diff": "^8.0.3",
141 "jsondiffpatch": "^0.7.2",
142 "tmp": "^0.2.4",
143 "js-yaml": "^4.1.1",
144 "fast-uri": "3.1.2",
145 "@openrouter/ai-sdk-provider": {
146 "ai": ">=5.0.52"
147 },
148 "@assistant-ui/react-ai-sdk": {
149 "ai": "^6.0.42",
150 "@ai-sdk/react": "$@ai-sdk/react"
151 },
152 "lodash": "^4.17.21",
153 "@modelcontextprotocol/sdk": "^1.24.0",
154 "body-parser": "^2.2.1",
155 "fast-xml-parser": ">=5.3.4",
156 "minimatch": ">=10.2.1",
157 "serialize-javascript": ">=7.0.3",
158 "ajv": "^8.18.0",
159 "picomatch": ">=2.3.2",
160 "yaml": ">=2.8.3",
161 "brace-expansion": ">=5.0.5",
162 "postcss": ">=8.5.10"
163 }
164 }
165 {"command":"find /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app -type d -maxdepth 2 | sort"}/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/admin /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/admin/integrations /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/api /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/api/examples /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/api/health /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/api/llm /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/api/repairs /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/api/sentry-example-api /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/api/sherpa-mesh /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/auth /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/auth/callback /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/bundles /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/bundles/[id] /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/bundles/new /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/chat /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/admin /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/assistant /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/claim-payouts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/claim-rules /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/claims /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/commissions /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/compliance /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/country-picker /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/data /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/filters /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/financials /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/invoices /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/llm-template-builder /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/mcp /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/navigation /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/payments /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/policies /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/policy-management /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/portal-assistant /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/products /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/profile /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/screening /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/settings /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/ui /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/users /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/constants /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/create-account /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/customer-portal /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/customer-portal/claims /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/customer-portal/components /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/customer-portal/dashboard /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/customer-portal/invoices /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/customer-portal/lib /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/customer-portal/notifications /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/customer-portal/policies /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/customer-portal/profile /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/customer-portal/templates /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/customers /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/customers/[id] /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/dashboard /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/dashboard-legacy /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/data /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/data/components /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/data/services /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/dev /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/dev/flags /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/docs /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/domain-blocked /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/embed-customer-portal /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/embed-customer-portal/claims /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/embed-customer-portal/dashboard /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/embed-customer-portal/invoices /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/embed-customer-portal/lib /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/embed-customer-portal/notifications /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/embed-customer-portal/pages /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/embed-customer-portal/policies /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/embed-customer-portal/profile /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/embed-customer-portal/templates /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/financials /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-p
{"description":"Map embed funnel structure","prompt":"Explore the sherpa-embed worktree at /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance (Vite 7 + TypeScript, likely React). This is the embedded customer-facing insurance funnel that renders on outlet/dealer pages. I'm adding an embeddable, no-login policy withdrawal/cancellation widget that renders on the outlet page where the policy was sold.\n\nFind and report (exact file paths + line numbers + code excerpts):\n1. PROJECT STRUCTURE: How is the app structured? Entry point(s)? Is it a single funnel app or multiple embeddable widgets? How is it embedded on a host page (iframe? web component? script tag mount)? Show the mount/bootstrap code.\n2. ROUTING: How does it decide what to render (routes, query params, a \"step\" state machine)? Show how a flow/step is selected.\n3. API CLIENT: How does embed call sherpa-api? Base URL env var name? Show the fetch wrapper. Are calls unauthenticated (public)?\n4. UI / COMPONENTS / STYLING: What components and styling approach (Tailwind? CSS modules?)? Show a representative screen/step component and reusable inputs/buttons.\n5. EXISTING FLOWS: What funnel steps exist today (quote, checkout, payment, confirmation)? Where are the step components? Is there anything resembling a post-sale / manage-policy / cancel flow?\n6. CONFIG: How is the embed configured per outlet (outlet id, product, theme)? Where do query params / config get read? \n7. DEV SERVER: package.json scripts, the dev port, and the env var that points at the API base URL. Any .env files.\n\nReport as a structured reference map with concrete excerpts. Do NOT write or edit any files.","subagent_type":"Explore"}{"command":"find /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance -name \"proxy.ts\" -o -name \"middleware.ts\" -o -name \".env*\" 2>/dev/null"}/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/node_modules/reselect/src/autotrackMemoize/proxy.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/node_modules/immer/src/core/proxy.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/node_modules/recharts/node_modules/@reduxjs/toolkit/node_modules/immer/src/core/proxy.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/node_modules/recharts/node_modules/redux/src/types/middleware.ts /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/.env /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/proxy.ts
{"command":"find /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance -type f -name \"package.json\" -o -name \"vite.config.*\" -o -name \"tsconfig.json\" | head -20"}/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/k8s/package.json /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/node_modules/tinyglobby/node_modules/picomatch/package.json /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/node_modules/tinyglobby/node_modules/fdir/package.json /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/node_modules/tinyglobby/package.json /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/node_modules/callsites/package.json /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/node_modules/zod/v4-mini/package.json /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/node_modules/zod/locales/package.json /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/node_modules/zod/mini/package.json /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/node_modules/zod/v4/locales/package.json /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/node_modules/zod/v4/core/package.json /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/node_modules/zod/v4/mini/package.json /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/node_modules/zod/v4/classic/package.json /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/node_modules/zod/v4/package.json /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/node_modules/zod/package.json /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/node_modules/zod/v3/package.json /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/node_modules/styled-jsx/package.json /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/node_modules/obug/package.json /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/node_modules/jsesc/package.json /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/node_modules/tldts/package.json /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/node_modules/@types/chai/package.json
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/proxy.ts"}1 import { NextRequest, NextResponse } from "next/server";
2
3 import { getSherpaApiUrl } from "./app/src/utils/env";
4
5 // Allowlist of static-asset file extensions the middleware skips. Keep in sync
6 // with whatever public/ + bundler output actually produces; broaden only when
7 // a real new asset type appears. Intentionally narrow — the previous
8 // `pathname.includes(".")` check would skip any URL containing a dot (e.g.
9 // `/users/[email protected]`), letting org resolution be bypassed.
10 const STATIC_EXT =
11 /\.(js|css|map|ico|png|jpe?g|gif|svg|webp|avif|woff2?|ttf|otf|eot|json|txt|xml|webmanifest)$/i;
12
13 const suspiciousPatterns = [
14 /<script/i,
15 /javascript:/i,
16 /onerror=/i,
17 /onload=/i,
18 /onclick=/i,
19 /<img/i,
20 /<iframe/i,
21 /<object/i,
22 /<embed/i,
23 /<svg/i,
24 /ptt93e3cf1e/,
25 ];
26
27 const isSuspicious = (value: string): boolean => {
28 return suspiciousPatterns.some((pattern) => pattern.test(value));
29 };
30
31 export async function proxy(request: NextRequest) {
32 const url = request.nextUrl.clone();
33 const searchParams = url.searchParams;
34 let hasSuspiciousParams = false;
35
36 searchParams.forEach((value, key) => {
37 if (isSuspicious(value) || isSuspicious(key)) {
38 searchParams.delete(key);
39 hasSuspiciousParams = true;
40 }
41 });
42
43 if (hasSuspiciousParams) {
44 return NextResponse.redirect(url);
45 }
46
47 try {
48 // Bail out for static asset requests so we never make an org-resolution
49 // round-trip for them. Restrict to a known extension allowlist — the
50 // previous `pathname.includes(".")` check let any URL with a dot anywhere
51 // in the path (including encoded values) skip middleware entirely.
52 if (STATIC_EXT.test(request.nextUrl.pathname)) {
53 return NextResponse.next();
54 }
55
56 const skipPages = [
57 "/register",
58 "/create-account",
59 "/forgot-password",
60 "/reset-password",
61 "/two-factor-setup",
62 "/two-factor-verify",
63 "/domain-blocked",
64 "/api",
65 "/login",
66 "/customer-portal",
67 "/embed-customer-portal",
68 ];
69
70 if (skipPages.some((page) => request.nextUrl.pathname.startsWith(page))) {
71 return NextResponse.next();
72 }
73
74 const host = request.headers.get("host") || request.nextUrl.hostname;
75 const hostname = host.split(":")[0];
76
77 const apiBaseUrl = getSherpaApiUrl(request.nextUrl.protocol);
78
79 const res = await fetch(`${apiBaseUrl}/v1/organization?by=origin`, {
80 method: "GET",
81 headers: {
82 Origin: `${request.nextUrl.protocol}//${host}`,
83 "Content-Type": "application/json",
84 },
85 cache: "no-store",
86 });
87 if (res.ok) {
88 // A 200 with an unparseable body is a contract violation, not a routing
89 // signal. Fall through to NextResponse.next() rather than crashing the
90 // middleware (which runs on every portal request).
91 let orgData: any;
92 try {
93 orgData = await res.json();
94 } catch {
95 return NextResponse.next();
96 }
97
98 // Only rewrite to customer portal for orgs with portalType 'customer'.
99 // The default Gangkhar org has portalType 'crm' and serves the admin app.
100 if (orgData?.portalType !== "customer") {
101 return NextResponse.next();
102 }
103
104 const templateId = orgData?.settings?.customerPortal?.templateId;
105 const portalMode = orgData?.portalMode;
106
107 if (portalMode === "embedded" && templateId) {
108 const page =
109 request.nextUrl.pathname === "/" ? "index" : request.nextUrl.pathname.slice(1);
110
111 const rewriteUrl = new URL(`/embed-customer-portal/pages/${page}`, request.url);
112 return NextResponse.rewrite(rewriteUrl);
113 }
114
115 if (portalMode === "standalone") {
116 const pathname = request.nextUrl.pathname;
117 if (pathname === "/") {
118 const rewriteUrl = new URL("/customer-portal/dashboard", request.url);
119 return NextResponse.rewrite(rewriteUrl);
120 }
121 const rewriteUrl = new URL(`/customer-portal${pathname}`, request.url);
122 return NextResponse.rewrite(rewriteUrl);
123 }
124 }
125 } catch (error) {
126 if (process.env.NODE_ENV !== "development") {
127 console.error("Proxy error:", error);
128 }
129 }
130
131 return NextResponse.next();
132 }
133
134 export const config = {
135 matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
136 };
137 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance"}EISDIR: illegal operation on a directory, read '/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance'
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/.env"}1 # Frontend Configuration 2 FRONTEND_PORT=3012 3 NODE_ENV=development 4 5 # API Configuration 6 NEXT_PUBLIC_SHERPA_API_HTTP=http://localhost:4012 7 NEXT_PUBLIC_SHERPA_API_HTTPS=https://api.chris.dev.gangkhar.dev 8 NEXT_PUBLIC_APP_HTTP=http://localhost:3012 9 NEXT_PUBLIC_APP_HTTPS=http://portal.wayni.chris.dev.gangkhar.dev/ 10 NEXT_PUBLIC_EMBED_HTTP=http://localhost:3112 11 NEXT_PUBLIC_EMBED_HTTPS=https://embed.chris.dev.gangkhar.dev 12 NEXT_PUBLIC_SHERPA_BLOB_URL=l3:// 13 14 # Development Settings 15 NEXT_PUBLIC_USE_HTTPS=true 16 NEXT_TELEMETRY_DISABLED=1 17 18 # AI Configuration 19 NEXT_PUBLIC_LLM_PROVIDER=openai 20 SHERPA_LLM_CLIENT_ID=cl_a65507846ab385d5a93d891eb4a87fc9 21 SHERPA_LLM_CLIENT_SECRET=cs_4a59886b3147284e7ac581e3558ee7cb03fc7df3c8ade66515003bfdfffa9b15 22 AZURE_AI_API_KEY=RM0Hh0gDoFVtTFukzQNEm39JKkAvs3wpCrB4DxdTHNrhDEgruA1QJQQJ99BLACHYHv6XJ3w3AAAAACOGunQd 23 AZURE_AI_URL=https://sherpa-foundry.openai.azure.com 24 25 # Other Settings 26 NEXT_PUBLIC_RESEND_COUNTER_INITIAL=5 27 COMMIT_HASH=dev-local 28 29 SHERPA_LOG_CURL=1 30 31 # Sherpa Mesh (AI Agents) — browser calls mesh directly, no portal proxy 32 # Local dev: http://localhost:4500 (make dev) or http://localhost:4510 (make proxy) 33 NEXT_PUBLIC_SHERPA_MESH_HTTP=http://localhost:4500 34 NEXT_PUBLIC_SHERPA_MESH_HTTPS=https://agents.dev.gangkhar.dev 35 # Server-side only — used by existing chat-ui routes (/api/sherpa-mesh/*) 36 SHERPA_MESH_API=http://localhost:4500 37 SHERPA_MESH_AUTH=DevCredentials sherpa:sherpa 38 39 SENTRY_AUTH_TOKEN=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 40 41 NEXT_PUBLIC_REPAIRS_PARTNER=ueno-bank
{"command":"ls -la /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/ | head -30"}total 600 drwxr-xr-x@ 30 bakker staff 960 Jun 1 08:39 . drwxr-xr-x 82 bakker staff 2624 Jun 1 08:39 .. drwxr-xr-x@ 3 bakker staff 96 Jun 1 08:39 .claude drwxr-xr-x@ 3 bakker staff 96 Jun 1 08:39 .cursor -rw-r--r--@ 1 bakker staff 363 Jun 1 08:39 .env -rw-r--r--@ 1 bakker staff 364 Jun 1 08:39 .eslintrc.json -rw-r--r--@ 1 bakker staff 117 Jun 1 08:39 .git drwxr-xr-x@ 5 bakker staff 160 Jun 1 08:39 .github -rw-r--r--@ 1 bakker staff 862 Jun 1 08:39 .gitignore -rw-r--r--@ 1 bakker staff 48 Jun 1 08:39 .npmrc -rw-r--r--@ 1 bakker staff 10603 Jun 1 08:39 AGENTS.md -rw-r--r--@ 1 bakker staff 2032 Jun 1 08:39 CLAUDE.md -rw-r--r--@ 1 bakker staff 182 Jun 1 08:39 LICENSE -rw-r--r--@ 1 bakker staff 6585 Jun 1 08:39 README.md -rw-r--r--@ 1 bakker staff 8602 Jun 1 08:39 RELEASE_PROCESS.md drwxr-xr-x@ 4 bakker staff 128 Jun 1 08:39 docker drwxr-xr-x@ 4 bakker staff 128 Jun 1 08:39 docs -rw-r--r--@ 1 bakker staff 463 Jun 1 08:39 env.reference -rw-r--r--@ 1 bakker staff 1447 Jun 1 08:39 index.html drwxr-xr-x@ 7 bakker staff 224 Jun 1 08:39 k8s drwxr-xr-x@ 234 bakker staff 7488 Jun 1 08:39 node_modules -rw-r--r--@ 1 bakker staff 218261 Jun 1 08:39 package-lock.json -rw-r--r--@ 1 bakker staff 2128 Jun 1 08:39 package.json drwxr-xr-x@ 4 bakker staff 128 Jun 1 08:39 public drwxr-xr-x@ 4 bakker staff 128 Jun 1 08:39 scripts drwxr-xr-x@ 11 bakker staff 352 Jun 1 08:39 src drwxr-xr-x@ 7 bakker staff 224 Jun 1 08:39 translations -rw-r--r--@ 1 bakker staff 727 Jun 1 08:39 tsconfig.json -rw-r--r--@ 1 bakker staff 238 Jun 1 08:39 tsconfig.node.json
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/login/page.tsx"}{"file_path":"/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/package.json"}1 "use client";
2
3 import React, { Suspense } from "react";
4 import { useState, useEffect } from "react";
5
6
7 import Link from "next/link";
8 import { useRouter, useSearchParams } from "next/navigation";
9
10 import { FaMicrosoft } from "react-icons/fa";
11
12 import { useTranslation } from "@gangkharai/lmt/client";
13 import { useAuth } from "../components/AuthProvider";
14 import { EnhancedLocaleSwitcher } from "../components/EnhancedLocaleSwitcher";
15 import { LOCALE_LABELS } from "@/app/lib/supported-locales";
16 import Loader from "../components/Loader";
17 import { useOrganization } from "../components/OrganizationProvider";
18 import SocialLoginButtons from "../components/SocialLoginButtons";
19 import Toast, { showErrorToast } from "../components/Toast";
20 import { Button } from "../components/ui/button";
21 import { Input } from "../components/ui/input";
22 import api from "../src/utils/api";
23 import { getSherpaApiUrl } from "../src/utils/env";
24 import { redirectAfterAuth } from "../src/utils/session";
25
26 function LoginContent() {
27 const t = useTranslation();
28 const [email, setEmail] = useState("");
29 const [password, setPassword] = useState("");
30 const [isLoading, setIsLoading] = useState(false);
31 const [error, setError] = useState("");
32 const [successMessage, setSuccessMessage] = useState<React.ReactNode>(null);
33 const router = useRouter();
34 const searchParams = useSearchParams();
35 const { login, user, loading: authLoading } = useAuth();
36 const { organization, isLoading: orgLoading, error: orgError } = useOrganization();
37
38 useEffect(() => {
39 document.title = t('Login - Sherpa Portal');
40
41 const errorParam = searchParams.get("error");
42 if (errorParam) {
43 setError(decodeURIComponent(errorParam));
44 }
45 if (searchParams.get("registered") === "true") {
46 setSuccessMessage(t('Registration successful! You can now log in with your new account.'));
47 }
48 }, [searchParams, t]);
49
50 useEffect(() => {
51 // Already-authenticated visitor lands on /login: route them where they
52 // were headed via redirectAfterAuth (single reader of captured intent per
53 // §6.3), falling back to the role-appropriate landing page.
54 if (!authLoading && user) {
55 const defaultPath = user.userType === "customer" ? "/" : "/products";
56 redirectAfterAuth(router, defaultPath);
57 }
58 }, [authLoading, user, router]);
59
60 // Show Azure AD login based on feature flag
61 const showAzureLogin = organization?.settings?.features?.azureAdIntegration === true;
62 const showSocialLogin = organization?.settings?.features?.socialLogin === true;
63
64 // Show loading while auth is loading or organization is still loading (unless there's an org error)
65 if (authLoading || (orgLoading && !orgError)) {
66 return <Loader />;
67 }
68
69 // If user is already authenticated, show loader while redirecting
70 if (user) {
71 return <Loader />;
72 }
73
74 const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
75 e.preventDefault();
76 setIsLoading(true);
77 setError("");
78
79 try {
80 // Use portalType from API, fall back to customerPortal setting presence
81 const isCustomerLogin = organization?.portalType
82 ? organization.portalType === "customer"
83 : !!organization?.settings?.customerPortal;
84 const loginEndpoint = isCustomerLogin ? "/auth/customer/login" : "/auth/login";
85 const response = await api.post(loginEndpoint, { email, password });
86 const data = response.data;
87
88 if (response.status === 200) {
89 if (!isCustomerLogin && data.message === "2FA required") {
90 router.push(
91 `/two-factor-verify?tempToken=${encodeURIComponent(data.tempToken)}&method=${data.twoFactorMethod}`
92 );
93 } else if (!isCustomerLogin && data.message === "2FA setup required") {
94 localStorage.setItem("setupToken", data.setupToken);
95 localStorage.setItem("refreshToken", data.refreshToken);
96 router.push("/two-factor-setup");
97 } else {
98 await login(data.data.accessToken, data.data.refreshToken);
99 // Redirection will be handled by the login function / useEffect hook
100 }
101 } else {
102 setError(data.error || t('Login failed. Please try again.'));
103 }
104 } catch (error) {
105 console.error("Login error:", error);
106 const errorMsg =
107 error instanceof Error ? error.message : t('Login failed. Please check your credentials.');
108 setError(errorMsg);
109 showErrorToast(errorMsg);
110 } finally {
111 setIsLoading(false);
112 }
113 };
114
115 const handleAzureLogin = () => {
116 // Redirect to Azure AD login. Pass current origin so API callback returns
117 // here instead of the API's configured default portal URL.
118 const redirect = window.location.origin;
119 window.location.href = `${getSherpaApiUrl()}/v1/auth/azure/login?redirect=${encodeURIComponent(redirect)}`;
120 };
121
122 return (
123 <div className="flex min-h-screen flex-col justify-center bg-white py-12 sm:px-6 lg:px-8">
124 <Toast />
125 <div className="absolute top-4 right-4">
126 <EnhancedLocaleSwitcher locales={LOCALE_LABELS} />
127 </div>
128 <div className="sm:mx-auto sm:w-full sm:max-w-md">
129 <div className="flex justify-center bg-white">
130 <img
131 src={organization?.settings?.branding?.logoUrl || "/assets/GangkharLogo.svg"}
132 alt={`${organization?.settings?.branding?.applicationName || organization?.displayName || organization?.name || "Portal"} Logo`}
133 width={240}
134 height={80}
135 className="object-contain"
136 />
137 </div>
138 <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
139 {t('Log in to {name}', { name: organization?.settings?.branding?.applicationName ||
140 organization?.displayName ||
141 organization?.name ||
142 t('your account') })}
143 </h2>
144 </div>
145
146 <div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
147 <div className="bg-white px-4 py-8 shadow sm:rounded-md sm:px-10">
148 {error && (
149 <div className="mb-4 rounded border border-red-400 bg-red-100 p-4 text-red-700">
150 {error}
151 </div>
152 )}
153 {successMessage && (
154 <div className="mb-4 rounded border border-green-400 bg-green-100 p-4 text-green-700">
155 {successMessage}
156 </div>
157 )}
158
159 {showAzureLogin ? (
160 // Azure AD Login when feature is enabled
161 <div className="space-y-6">
162 <Button
163 onClick={handleAzureLogin}
164 className="flex w-full cursor-pointer items-center justify-center gap-2"
165 disabled={isLoading}
166 >
167 <FaMicrosoft className="h-5 w-5 text-white" />
168 {isLoading ? t('Redirecting...') : t('Log in with Microsoft Entra ID')}
169 </Button>
170 </div>
171 ) : (
172 // Email/password + social login when Azure AD is off
173 <>
174 <form className="space-y-6" onSubmit={handleSubmit}>
175 <div>
176 <label htmlFor="email" className="block text-sm font-medium text-gray-700">
177 {t('Email address')}
178 </label>
179 <div className="mt-1">
180 <Input
181 id="email"
182 name="email"
183 1 {
2 "name": "sherpa-embed",
3 "version": "0.1.0",
4 "type": "module",
5 "engines": {
6 "node": ">=22.0.0"
7 },
8 "scripts": {
9 "dev": "vite",
10 "build": "tsc && vite build",
11 "preview": "vite preview",
12 "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
13 "lint:fix": "eslint . --ext js,jsx --fix",
14 "type-check": "tsc --noEmit",
15 "extract-labels": "tsx scripts/sync-common-labels.ts",
16 "extract-api-error-templates": "tsx scripts/extract-api-error-templates.ts",
17 "translate": "dotenv -e .env -- lmt-translate",
18 "translate:verbose": "dotenv -e .env -- lmt-translate -v",
19 "translate:rebuild": "dotenv -e .env -- lmt-translate -v --rebuild",
20 "validate-translations": "lmt-validate-translations",
21 "test": "vitest run"
22 },
23 "dependencies": {
24 "@gangkharai/lmt": "^0.1.4",
25 "@microsoft/clarity": "^1.0.2",
26 "@picocss/pico": "^2.1.1",
27 "@preact/preset-vite": "^2.10.2",
28 "@stripe/react-stripe-js": "^6.3.0",
29 "@stripe/stripe-js": "^9.5.0",
30 "dompurify": "^3.3.2",
31 "html-react-parser": "^5.2.10",
32 "isomorphic-dompurify": "^3.13.0",
33 "lucide-preact": "^1.7.0",
34 "postcss": "^8.5.14",
35 "postcss-prefix-selector": "^2.1.1",
36 "preact": "^10.28.2",
37 "react-hook-form": "^7.68.0",
38 "react-phone-number-input": "^3.4.14",
39 "typescript": "^5.9.3",
40 "vite": "^7.3.0"
41 },
42 "overrides": {
43 "minimatch": "^10.2.1",
44 "ajv": "^8.18.0",
45 "rollup": ">=4.59.0",
46 "picomatch": ">=2.3.2",
47 "yaml": ">=2.8.3",
48 "brace-expansion": ">=5.0.5"
49 },
50 "devDependencies": {
51 "@types/dompurify": "^3.2.0",
52 "@types/node": "^25.0.3",
53 "@types/react": "^19.2.2",
54 "@types/react-dom": "^19.2.3",
55 "@typescript-eslint/eslint-plugin": "^8.50.1",
56 "@typescript-eslint/parser": "^8.46.2",
57 "@vitest/coverage-v8": "^4.1.6",
58 "dotenv-cli": "^8.0.0",
59 "eslint": "^9.39.2",
60 "eslint-plugin-react-hooks": "^7.0.1",
61 "eslint-plugin-react-refresh": "^0.4.26",
62 "tsx": "^4.22.3",
63 "vitest": "^4.1.6"
64 },
65 "optionalDependencies": {
66 "@rollup/rollup-linux-x64-gnu": "^4.60.0"
67 }
68 }
69 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/src/utils/api.ts"}1 import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosRequestConfig } from "axios";
2 import { jwtDecode } from "jwt-decode";
3
4 import { getSherpaApiUrl } from "./env";
5 import { endSession } from "./session";
6 import useAuthStore from "../store/authStore";
7 import useConnectionStore from "../store/connectionStore";
8
9 // Paths that never carry a user's Authorization header. The request
10 // interceptor skips both the attach and the pre-refresh check on these; the
11 // response interceptor skips the refresh-and-retry chain on 401 from these.
12 // A 401 from a credential-less request is not a verdict on the user's session
13 // — see rfc/ideas/organization-resolution-and-auth-policy.md §6.1.
14 //
15 // INVARIANT: every entry MUST start with `/v1/`. The request interceptor
16 // rewrites legacy `/auth/...` and bare paths into `/v1/...` BEFORE this list
17 // is consulted, so any non-`/v1/` entry here is unreachable and indicates a
18 // refactor drift. The module-load assertion below fails fast if violated.
19 const NO_AUTH_PATHS = [
20 "/v1/auth/login",
21 "/v1/auth/customer/login",
22 "/v1/auth/register",
23 "/v1/auth/forgot-password",
24 "/v1/auth/refresh-token",
25 "/v1/auth/customer/refresh-token",
26 "/v1/organization",
27 "/v1/health/self",
28 ];
29
30 // Fail fast on refactor breakage: surfaces a missing rewrite or a stale entry
31 // at startup rather than as a silent auth-attach regression at runtime.
32 for (const path of NO_AUTH_PATHS) {
33 if (!path.startsWith("/v1/")) {
34 throw new Error(
35 `NO_AUTH_PATHS invariant violated: "${path}" must start with "/v1/". ` +
36 `The request interceptor only sees rewritten /v1/* URLs.`,
37 );
38 }
39 }
40
41 function stripQuery(url: string | undefined): string {
42 return (url || "").split("?")[0];
43 }
44
45 function isNoAuthPath(url: string | undefined): boolean {
46 return NO_AUTH_PATHS.includes(stripQuery(url));
47 }
48
49 class ApiService {
50 private static instance: ApiService;
51 private api: AxiosInstance;
52 private isRefreshing: boolean = false;
53
54 private constructor() {
55 this.api = axios.create({
56 baseURL: getSherpaApiUrl(),
57 validateStatus: (status) => (status >= 200 && status < 300) || status === 304,
58 });
59
60 this.setupInterceptors();
61 }
62
63 private failedQueue: Array<{
64 resolve: (value?: unknown) => void;
65 reject: (reason?: unknown) => void;
66 }> = [];
67
68 private processQueue(error: Error | null = null): void {
69 this.failedQueue.forEach((promise) => {
70 if (error) {
71 promise.reject(error);
72 } else {
73 promise.resolve();
74 }
75 });
76 this.failedQueue = [];
77 }
78
79 public static getInstance(): ApiService {
80 if (!ApiService.instance) {
81 ApiService.instance = new ApiService();
82 }
83 return ApiService.instance;
84 }
85
86 public getAxiosInstance(): AxiosInstance {
87 return this.api;
88 }
89
90 public isTokenExpired(token: string): boolean {
91 try {
92 const decoded = jwtDecode(token);
93 const currentTime = Date.now() / 1000;
94 return decoded.exp ? decoded.exp < currentTime : false;
95 } catch {
96 return true;
97 }
98 }
99
100 public isCustomerToken(token: string): boolean {
101 try {
102 const decoded = jwtDecode<{ tokenType?: string }>(token);
103 return decoded.tokenType === "customer";
104 } catch {
105 return false;
106 }
107 }
108
109 public async refreshAuthToken(): Promise<string | null> {
110 try {
111 const refreshToken = useAuthStore.getState().refreshToken;
112 if (!refreshToken) {
113 // No refresh token to use — the session was already over.
114 endSession({ captureIntent: true });
115 return null;
116 }
117
118 const isCustomer = this.isCustomerToken(refreshToken);
119 const refreshUrl = isCustomer
120 ? `${getSherpaApiUrl()}/v1/auth/customer/refresh-token`
121 : `${getSherpaApiUrl()}/v1/auth/refresh-token`;
122
123 const localeValue = this.getLocaleCookie();
124 const response = await axios.post(
125 refreshUrl,
126 { refreshToken },
127 {
128 headers: localeValue
129 ? {
130 "Content-Type": "application/json",
131 "locale": localeValue,
132 }
133 : {
134 "Content-Type": "application/json",
135 },
136 validateStatus: (status) => (status >= 200 && status < 300) || status === 304,
137 }
138 );
139
140 if (response.data.status !== "success") {
141 throw new Error("Refresh token response status is not success");
142 }
143
144 const { accessToken, refreshToken: newRefreshToken } = response.data.data;
145 if (!accessToken || !newRefreshToken) {
146 throw new Error("Missing tokens in refresh response");
147 }
148
149 useAuthStore.getState().setTokens(accessToken, newRefreshToken);
150 return accessToken;
151 } catch (err) {
152 // Distinguish "the refresh endpoint rejected the token" (4xx → real
153 // session-end signal per §6.1) from "the refresh request itself failed
154 // because the network/server is having a moment" (transient → keep the
155 // session, let the health poller recover). Only the former ends session.
156 const e = err as { response?: { status?: number }; code?: string };
157 const status = e?.response?.status;
158 const isCredentialRejection = status === 401 || status === 403;
159 if (isCredentialRejection) {
160 endSession({ captureIntent: true });
161 } else if (
162 !e?.response &&
163 (e?.code === "ERR_NETWORK" || e?.code === "ERR_CONNECTION_REFUSED")
164 ) {
165 useConnectionStore.getState().markFailure();
166 }
167 return null;
168 }
169 }
170
171 private getLocaleCookie(): string | null {
172 if (typeof document === "undefined") return null;
173
174 const cookies = document.cookie.split(";");
175 const localeCookie = cookies.find((cookie) => cookie.trim().startsWith("locale="));
176 if (localeCookie) {
177 return localeCookie.split("=")[1].trim();
178 }
179 return null;
180 }
181
182 private setupInterceptors(): void {
183 this.api.interceptors.request.use(
184 async (config: InternalAxiosRequestConfig & { sherpaAdmin?: boolean }) => {
185 const rawUrl = config.url || "/";
186 const ensured = rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`;
187 if (!ensured.startsWith("/v1/")) {
188 const isAdmin = config.sherpaAdmin !== false;
189 if (ensured.startsWith("/auth")) {
190 config.url = `/v1${ensured}`;
191 } else {
192 config.url = isAdmin ? `/v1/plus${ensured}` : `/v1${ensured}`;
193 }
194 } else {
195 config.url = ensured;
196 }
197 try {
198 void useAuthStore.getState().accessToken;
199 } catch {}
200 const localeValue = this.getLocaleCookie();
201 if (localeValue) {
202 config.headers["locale"] = localeValue;
203 }
204
205 // Credential-less routes: don't attach Authorization and don't trigger
206 // a pre-refresh on expired tokens. A response from these routes is
207 // never a verdict on the user's session.
208 if (isNoAuthPath(config.url)) {
209 return config;
210 }
211
212 let accessToken = useAuthStore.getState().accessToken;
213
214 // If a refresh is already in progress, wait for it to complete
215 if (this.isRefreshing) {
216 return new Promise((resolve, reject) => {
217 this.failedQueue{"file_path":"/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/.env"}1 # API Configuration 2 VITE_SHERPA_API=http://localhost:4012 3 VITE_SHERPA_STORAGE=http://localhost:9000 4 5 # Development Configuration 6 NODE_ENV=development 7 8 # LMT translations — same values as sherpa-portal/.env 9 AZURE_AI_URL=https://sherpa-foundry.openai.azure.com 10 AZURE_AI_API_KEY=RM0Hh0gDoFVtTFukzQNEm39JKkAvs3wpCrB4DxdTHNrhDEgruA1QJQQJ99BLACHYHv6XJ3w3AAAAACOGunQd 11
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/env.reference"}1 # API Configuration 2 VITE_SHERPA_API=http://localhost:4000 3 VITE_SHERPA_STORAGE=http://localhost:9000 4 5 # Development Configuration 6 NODE_ENV=development 7 8 # LMT translations — `npm run translate` reads these. Copy your local 9 # values (same as sherpa-portal/.env uses) from the Azure Foundry / 10 # Azure AI resource. Keep this file as a reference only; real values 11 # live in your gitignored .env. 12 AZURE_AI_URL=https://sherpa-foundry.openai.azure.com 13 AZURE_AI_API_KEY= 14
{"command":"ls -la /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/ui/ | head -30"}total 296 drwxr-xr-x@ 28 bakker staff 896 Jun 1 08:39 . drwxr-xr-x@ 60 bakker staff 1920 Jun 1 08:39 .. -rw-r--r--@ 1 bakker staff 2469 Jun 1 08:39 DynamicIcon.tsx -rw-r--r--@ 1 bakker staff 4907 Jun 1 08:39 ExportDropdown.tsx -rw-r--r--@ 1 bakker staff 15260 Jun 1 08:39 IconPicker.tsx -rw-r--r--@ 1 bakker staff 1975 Jun 1 08:39 accordion.tsx -rw-r--r--@ 1 bakker staff 1183 Jun 1 08:39 badge.tsx -rw-r--r--@ 1 bakker staff 2218 Jun 1 08:39 button.tsx -rw-r--r--@ 1 bakker staff 1935 Jun 1 08:39 card.tsx -rw-r--r--@ 1 bakker staff 1067 Jun 1 08:39 checkbox.tsx -rw-r--r--@ 1 bakker staff 4751 Jun 1 08:39 colorPicker.tsx -rw-r--r--@ 1 bakker staff 5893 Jun 1 08:39 currencyDisplay.tsx -rw-r--r--@ 1 bakker staff 3906 Jun 1 08:39 dialog.tsx -rw-r--r--@ 1 bakker staff 524 Jun 1 08:39 divider.tsx -rw-r--r--@ 1 bakker staff 3878 Jun 1 08:39 input.tsx -rw-r--r--@ 1 bakker staff 627 Jun 1 08:39 label.tsx -rw-r--r--@ 1 bakker staff 8159 Jun 1 08:39 multi-select.tsx -rw-r--r--@ 1 bakker staff 7064 Jun 1 08:39 percentage.tsx -rw-r--r--@ 1 bakker staff 6528 Jun 1 08:39 richTextarea.tsx -rw-r--r--@ 1 bakker staff 5664 Jun 1 08:39 select.tsx -rw-r--r--@ 1 bakker staff 8032 Jun 1 08:39 shadowPicker.tsx -rw-r--r--@ 1 bakker staff 1897 Jun 1 08:39 splitButton.tsx -rw-r--r--@ 1 bakker staff 1223 Jun 1 08:39 switch.tsx -rw-r--r--@ 1 bakker staff 2724 Jun 1 08:39 table.tsx -rw-r--r--@ 1 bakker staff 1639 Jun 1 08:39 tabs.tsx -rw-r--r--@ 1 bakker staff 1441 Jun 1 08:39 textarea.tsx -rw-r--r--@ 1 bakker staff 1996 Jun 1 08:39 tooltip.tsx -rw-r--r--@ 1 bakker staff 2741 Jun 1 08:39 typed-text.tsx
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/index.html"}1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="UTF-8" /> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 <meta name="description" content="Sherpa Insurance Embed Widget" /> 7 <!-- 8 script-src: 'unsafe-inline' removed. Vite 7 ships only external 9 hashed modules from /assets; there are no inline <script> tags in 10 production output. Stripe.js is loaded from https://js.stripe.com. 11 style-src: 'unsafe-inline' retained — Stripe Elements injects inline 12 styles into its same-origin iframe, and the embed itself uses inline 13 <style> tags for partner CSS (now scoped + sanitized via 14 sanitizeTemplateCss). Replacing with hashes would require pre-build 15 hash injection for every Stripe Elements release; not in scope. 16 --> 17 <meta 18 http-equiv="Content-Security-Policy" 19 content=" 20 default-src 'self'; 21 script-src 'self' https://js.stripe.com; 22 style-src 'self' 'unsafe-inline'; 23 img-src 'self' data: https://purecatamphetamine.github.io; 24 connect-src 'self' %VITE_SHERPA_API% %VITE_SHERPA_STORAGE% https://api.stripe.com https://r.stripe.com; 25 frame-src 'self' https://js.stripe.com https://hooks.stripe.com https://m.stripe.network; 26 " 27 /> 28 <title>Sherpa Embed Widget</title> 29 </head> 30 <body> 31 <div id="app"></div> 32 <script type="module" src="/src/main.tsx"></script> 33 </body> 34 </html>
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/ui/button.tsx"}1 "use client";
2
3 // Processed for translation
4 import * as React from "react";
5
6 import { cva, type VariantProps } from "class-variance-authority";
7
8 import { cn } from "@/app/lib/utils";
9
10 // Button UI component - no translatable strings
11 const buttonVariants = cva(
12 "inline-flex items-center justify-center rounded-md text-sm font-medium shadow-sm transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none",
13 {
14 variants: {
15 variant: {
16 default:
17 "bg-primary-dynamic hover:bg-primary-dynamic-700 text-white ring-primary-dynamic-700",
18 destructive:
19 "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus:ring-destructive",
20 outline:
21 "border border-input bg-white hover:bg-accent hover:text-accent-foreground ring-primary-dynamic-700",
22 secondary:
23 "bg-secondary text-secondary-foreground hover:bg-secondary/80 focus:ring-secondary",
24 ghost: "hover:bg-accent hover:text-accent-foreground focus:ring-accent",
25 link: "underline-offset-4 hover:underline text-primary focus:ring-primary",
26 green: "bg-green-600 hover:bg-green-700 focus:ring-green-500 text-white",
27 red: "bg-red-100 text-red-700 hover:bg-red-200 focus:ring-red-500",
28 none: "bg-none text-primary-dynamic border-none shadow-none mt-0 cursor-pointer hover:text-blue-500",
29 },
30 size: {
31 default: "h-10 py-2 px-4",
32 sm: "h-9 px-3 rounded-md",
33 lg: "h-11 px-8 rounded-md",
34 },
35 },
36 compoundVariants: [
37 {
38 variant: "none",
39 class: "h-auto p-0 rounded-none",
40 },
41 ],
42 defaultVariants: {
43 variant: "default",
44 size: "default",
45 },
46 }
47 );
48
49 export interface ButtonProps
50 extends React.ButtonHTMLAttributes<HTMLButtonElement>,
51 VariantProps<typeof buttonVariants> {}
52
53 const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
54 ({ className, variant, size, ...props }, ref) => {
55 return (
56 <button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
57 );
58 }
59 );
60 Button.displayName = "Button";
61
62 export { Button, buttonVariants };
63 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/ui/input.tsx"}1 "use client";
2
3 // Processed for translation
4 import React, { useState, forwardRef, useId } from "react";
5
6 import { Eye, EyeOff } from "lucide-react";
7 import { useTranslation } from "@gangkharai/lmt/client";
8
9 interface CustomInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
10 isInvalid?: boolean;
11 label?: string;
12 helperText?: string;
13 errorMessage?: string;
14 showPasswordToggle?: boolean;
15 wrapperClassName?: string;
16 }
17
18 export const Input = forwardRef<HTMLInputElement, CustomInputProps>(
19 (
20 {
21 className = "",
22 isInvalid = false,
23 label,
24 helperText,
25 errorMessage,
26 id,
27 type = "text",
28 showPasswordToggle = true,
29 wrapperClassName = "",
30 ...rest
31 },
32 ref
33 ) => {
34 const t = useTranslation();
35 const [showPassword, setShowPassword] = useState(false);
36 const generatedId = useId();
37 // Use provided id or fall back to a stable generated ID
38 const inputId = id || `input-${generatedId}`;
39
40 // Handle special input types
41 const isCheckbox = type === "checkbox";
42 const isRadio = type === "radio";
43 const isPassword = type === "password";
44 const inputType = isPassword && showPassword ? "text" : type;
45
46 // Base className for standard inputs (not checkbox/radio)
47 const standardInputClassName = `block w-full appearance-none rounded-md border px-3 py-2 placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm ${
48 isInvalid ? "border-red-500" : "border-gray-300"
49 }`;
50
51 // Determine the final className
52 let inputClassName = "";
53 if (isCheckbox || isRadio) {
54 // Keep original className for checkboxes and radios
55 inputClassName = className;
56 } else {
57 // For other inputs, merge our standard styling with any provided class
58 inputClassName = `${standardInputClassName} ${className}`;
59 }
60
61 const togglePasswordVisibility = () => {
62 setShowPassword(!showPassword);
63 };
64
65 // Different rendering for checkboxes and radios vs other inputs
66 if (isCheckbox || isRadio) {
67 return (
68 <div className={wrapperClassName}>
69 <input ref={ref} id={inputId} type={type} className={inputClassName} {...rest} />
70 {label && (
71 <label htmlFor={inputId} className="ml-2 text-sm text-gray-700">
72 {label}
73 </label>
74 )}
75 {helperText && !isInvalid && <p className="mt-1 text-sm text-gray-500">{helperText}</p>}
76 {errorMessage && isInvalid && <p className="mt-1 text-sm text-red-600">{errorMessage}</p>}
77 </div>
78 );
79 }
80
81 return (
82 <div className={wrapperClassName}>
83 {label && (
84 <label htmlFor={inputId} className="block text-sm font-medium text-gray-700">
85 {label}
86 </label>
87 )}
88 <div className="relative mt-1">
89 <input ref={ref} id={inputId} className={inputClassName} type={inputType} {...rest} />
90 {isPassword && showPasswordToggle && (
91 <button
92 type="button"
93 onClick={togglePasswordVisibility}
94 className="absolute inset-y-0 right-0 flex items-center px-3 focus:outline-none"
95 tabIndex={-1}
96 aria-label={showPassword ? t("Hide password") : t("Show password")}
97 >
98 {showPassword ? (
99 <EyeOff className="h-4 w-4 text-gray-500" />
100 ) : (
101 <Eye className="h-4 w-4 text-gray-500" />
102 )}
103 </button>
104 )}
105 </div>
106 {helperText && !isInvalid && <p className="mt-1 text-sm text-gray-500">{helperText}</p>}
107 {errorMessage && isInvalid && <p className="mt-1 text-sm text-red-600">{errorMessage}</p>}
108 </div>
109 );
110 }
111 );
112
113 Input.displayName = "Input";
114
115 // Input UI component - no translatable strings
116 {"command":"find /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src -type f -name \"*.tsx\" -o -name \"*.ts\" | grep -v node_modules | head -40"}/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/app.tsx /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/main.tsx /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/types/tracking.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/types/dateConstraints.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/types/contracts.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/types/pricing.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/types/api.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/types/events.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/types/config.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/validation.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/uploadMetadataStore.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/paymentNavigation.test.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/currency.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/dateConstraints.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/currencies.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/storageNamespace.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/eventTracker.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/paymentNavigation.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/urlBuilder.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/objectPath.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/coverage.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/html.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/security.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/apiClient.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/formatters.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/parentOrigin.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/options.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/frequency.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/components/customizable-form/useTemplateParser.tsx /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/components/customizable-form/ControlledCustomInput.tsx /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/components/customizable-form/CustomInput.tsx /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/components/customizable-form/CustomizableMultipageForm.tsx /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/components/customizable-form/useFormNavigation.tsx /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/components/customizable-form/LocationAutocomplete.tsx /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/components/customizable-form/generateTemplateHtml.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/components/customizable-form/AdaptiveForm.tsx /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/components/customizable-form/AddressAutocomplete.tsx /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/components/LocaleSwitcher.tsx /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/components/ResultsDisplay.tsx /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/components/DynamicIcon.tsx
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/ui/card.tsx"}1 "use client";
2
3 import * as React from "react";
4
5 import { cn } from "@/app/src/utils/cn";
6
7 // Card UI component - no translatable strings
8
9 const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
10 ({ className, ...props }, ref) => (
11 <div
12 ref={ref}
13 className={cn(
14 "rounded-lg border border-gray-200 bg-white text-gray-950 shadow-sm",
15 className
16 )}
17 {...props}
18 />
19 )
20 );
21 Card.displayName = "Card";
22
23 const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
24 ({ className, ...props }, ref) => (
25 <div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
26 )
27 );
28 CardHeader.displayName = "CardHeader";
29
30 const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
31 ({ className, ...props }, ref) => (
32 <h3
33 ref={ref}
34 className={cn("text-xl leading-none font-semibold tracking-tight", className)}
35 {...props}
36 />
37 )
38 );
39 CardTitle.displayName = "CardTitle";
40
41 const CardDescription = React.forwardRef<
42 HTMLParagraphElement,
43 React.HTMLAttributes<HTMLParagraphElement>
44 >(({ className, ...props }, ref) => (
45 <p ref={ref} className={cn("text-sm text-gray-500", className)} {...props} />
46 ));
47 CardDescription.displayName = "CardDescription";
48
49 const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
50 ({ className, ...props }, ref) => (
51 <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
52 )
53 );
54 CardContent.displayName = "CardContent";
55
56 const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
57 ({ className, ...props }, ref) => (
58 <div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
59 )
60 );
61 CardFooter.displayName = "CardFooter";
62
63 export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
64 {"command":"find /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src -type d | head -20"}/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/types /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/components /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/components/customizable-form /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/components/thankyou /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/components/customizable-results /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/hooks /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/lib /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout
{"command":"find /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app -name \"*.tsx\" -type f | xargs grep -l \"useForm\\|FormField\\|react-hook-form\" | head -5"}/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/profile/PasswordChangeSection.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/profile/PersonalInfoSection.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/profile/AddressSection.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/profile/ContactInfoSection.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/policy-management/PolicyQuoteForm.tsx
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/profile/PasswordChangeSection.tsx","limit":80}1 "use client";
2
3 import React, { useState } from "react";
4
5 import { CheckCircle, XCircle } from "lucide-react";
6 import { useForm } from "react-hook-form";
7 import { useTranslation } from "@gangkharai/lmt/client";
8
9 import api from "../../src/utils/api";
10 import { PasswordChangeSectionProps, passwordRequirements } from "../../types/user";
11 import { showSuccessToast, showErrorToast } from "../Toast";
12 import { Button } from "../ui/button";
13 import { Input } from "../ui/input";
14
15 const PasswordChangeSection: React.FC<PasswordChangeSectionProps> = ({
16 userId,
17 onSuccess,
18 onError,
19 }) => {
20 const t = useTranslation();
21 const [isEditing, setIsEditing] = useState(false);
22 const [isLoading, setIsLoading] = useState(false);
23 const {
24 register,
25 handleSubmit,
26 watch,
27 formState: { errors },
28 reset,
29 } = useForm<{ newPassword: string; confirmPassword: string }>({
30 mode: "onChange",
31 });
32 const [passwordFocused, setPasswordFocused] = useState(false);
33
34 const newPassword = watch("newPassword", "");
35 const confirmPassword = watch("confirmPassword", "");
36
37 const onSubmit = async (data: { newPassword: string; confirmPassword: string }) => {
38 if (data.newPassword !== data.confirmPassword) {
39 onError(t('Passwords do not match'));
40 showErrorToast(t('Passwords do not match'));
41 return;
42 }
43
44 setIsLoading(true);
45 try {
46 const response = await api.post("/auth/change-password", {
47 password: data.newPassword,
48 userId, // Include the userId in the request
49 });
50
51 if (response.status >= 200 && response.status < 300) {
52 showSuccessToast(t('Password updated successfully'));
53 onSuccess();
54 setIsEditing(false);
55 reset();
56 } else {
57 const errorMsg = response.data.error || t('Failed to change password');
58 onError(errorMsg);
59 showErrorToast(errorMsg);
60 }
61 } catch {
62 const errorMsg = t('An unexpected error occurred');
63 onError(errorMsg);
64 showErrorToast(errorMsg);
65 } finally {
66 setIsLoading(false);
67 }
68 };
69
70 const handleCancel = () => {
71 setIsEditing(false);
72 reset();
73 };
74
75 const passwordsMatch = newPassword === confirmPassword && newPassword !== "";
76
77 return (
78 <div className="mb-6 overflow-hidden bg-white shadow sm:rounded-md">
79 <div className="flex items-center justify-between px-4 py-5 sm:px-6">
80 <h3 className="text-lg leading-6 font-medium text-gray-900">{t('Password')}</h3>{"command":"find /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src -type f \\( -name \"*.tsx\" -o -name \"*.ts\" \\) | grep -E \"(main|app|index|page)\" | head -30"}/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/app.tsx /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/main.tsx /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/components/customizable-form/CustomizableMultipageForm.tsx /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/sanitizeTemplateCss.test.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/sanitizeTemplateHtml.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/verifyStripeRedirect.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/providers.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/sanitizeTemplateHtml.test.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/types.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/StripeCheckout.tsx /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/MinimalSummary.tsx /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/TemplateChrome.tsx /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/sanitizeTemplateCss.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/EmbedCheckout.tsx
{"command":"find /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app -name \"*.tsx\" -type f | xargs grep -l \"cancel.*policy\\|withdraw.*policy\\|policy.*cancel\" | head -5"}/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/embed-customer-portal/policies/[policyId]/cancel/page.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/embed-customer-portal/templates/oyika/Coverage.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/embed-customer-portal/templates/wayni/CancelarMiSeguro.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/settings/PolicyStatusesTab.tsx /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/settings/TranslationsTab.tsx
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/main.tsx"}1 import React from 'react';
2 import { render } from 'preact';
3 import { useEffect, useState } from 'preact/hooks';
4 import { TranslationProvider } from './lib/translations';
5 import { getDictionary, supportedLocales, type SupportedLocale } from './lib/lmt';
6 import { useLocale } from './hooks/useLocale';
7 import App from './app';
8
9 function Root() {
10 const { locale, setLocale } = useLocale();
11 const [overrides, setOverrides] = useState<Record<string, Record<string, string>> | null>(null);
12 const dictionary = getDictionary(locale, overrides);
13
14 // Allow the host page to drive the embed's language live.
15 // Host contract: window.postMessage({ type: 'sherpa:setLocale', locale: 'pt_BR' }, '*')
16 useEffect(() => {
17 const onMessage = (event: MessageEvent) => {
18 const data = event.data;
19 if (!data || data.type !== 'sherpa:setLocale') return;
20 const next = data.locale;
21 if (supportedLocales.includes(next as SupportedLocale)) {
22 setLocale(next as SupportedLocale);
23 }
24 };
25 window.addEventListener('message', onMessage);
26 return () => window.removeEventListener('message', onMessage);
27 }, [setLocale]);
28
29 return (
30 <TranslationProvider dictionary={dictionary} setOverrides={setOverrides}>
31 <App locale={locale} onLocaleChange={setLocale} />
32 </TranslationProvider>
33 );
34 }
35
36 const container = document.getElementById('app');
37 if (container) {
38 render(<Root />, container);
39 } else {
40 console.error('Failed to find app container element');
41 }
42 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/embed-customer-portal/policies/[policyId]/cancel/page.tsx"}1 "use client";
2
3 import TemplateDispatcher from "@/app/customer-portal/lib/TemplateDispatcher";
4 import DefaultCancelPolicy from "@/app/customer-portal/policies/[policyId]/cancel/DefaultCancelPolicy";
5
6 export default function CancelPolicyPage({
7 params,
8 }: {
9 params: { policyId: string } | Promise<{ policyId: string }>;
10 }) {
11 return (
12 <TemplateDispatcher
13 page="cancelPolicy"
14 DefaultComponent={DefaultCancelPolicy}
15 props={{ params }}
16 />
17 );
18 }
19 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/app.tsx"}1 import React from 'react';
2 import { useState, useEffect, useRef } from 'preact/hooks';
3 import { lazy, Suspense } from 'preact/compat';
4 import { useTranslation, useSetTranslationOverrides } from './lib/translations';
5 import '@picocss/pico/css/pico.min.css';
6
7 import QuoteForm from './components/QuoteForm';
8 import PolicyForm from './components/PolicyForm';
9 import ClaimForm from './components/ClaimForm';
10 import ThankYou from './components/ThankYou';
11 import BundleProductSelect from './components/BundleProductSelect';
12 import BundleResults from './components/BundleResults';
13 import BundlePolicyForm from './components/BundlePolicyForm';
14 import BundleThankYou from './components/BundleThankYou';
15 import AdaptiveResultsDisplay from './components/customizable-results/AdaptiveResultsDisplay';
16 import ErrorBoundary from './components/ErrorBoundary';
17 import { LocaleSwitcher } from './components/LocaleSwitcher';
18 import { buildThankYouUrl, buildQuoteResultsUrl } from './utils/urlBuilder';
19 import { apiClient } from './utils/apiClient';
20 import { eventTracker } from './utils/eventTracker';
21 import { SHERPA_INTERNAL_NAVIGATE_EVENT } from './utils/paymentNavigation';
22 import { useConfig } from './hooks/useConfig';
23 import { useWidgetStatus, usePostMessage } from './hooks/usePostMessage';
24 import type { SupportedLocale } from './lib/lmt';
25
26 import type {
27 QuoteData,
28 PolicyData,
29 ClaimData,
30 FormApiResponse,
31 WidgetParams,
32 AcceptanceView,
33 } from './types/config';
34 import AcceptancePage from './components/AcceptancePage';
35
36 const EmbedCheckout = lazy(() => import('./pages/EmbedCheckout'));
37
38 interface BundleDiscountTier {
39 tierType: 'count' | 'combo';
40 productCount: number | null;
41 productNames: string[] | null;
42 discountType: 'percentage' | 'fixed';
43 discountValue: number;
44 priority: number;
45 }
46
47 interface BundleQuoteData {
48 bundleQuoteId: string;
49 bundleName: string;
50 products: Array<{
51 productName: string;
52 productTitle: string;
53 displayName: string;
54 planType: string;
55 premium: number;
56 currency: string;
57 frequency: string;
58 }>;
59 discount: {
60 discountPercent: number;
61 totalBeforeDiscount: number;
62 totalAfterDiscount: number;
63 savings: number;
64 };
65 availableTiers: BundleDiscountTier[];
66 currency: string;
67 currencyPosition?: 'prefix' | 'suffix';
68 decimalSeparator?: string;
69 groupSeparator?: string;
70 decimalsAmount?: number;
71 }
72
73 interface ClaimFormChoice {
74 id: number;
75 name: string;
76 title: string | null;
77 description: string | null;
78 coverages: Array<{ id: number; name: string; title: string | null; code: string | null }>;
79 }
80
81 interface AppState {
82 loading: boolean;
83 error: string | null;
84 unavailable?: boolean;
85 quoteData: QuoteData | null;
86 policyData: PolicyData | null;
87 claimData: ClaimData | null;
88 formData: FormApiResponse | null;
89 // Set when multiple claim forms apply to the policy's held coverages and
90 // the customer needs to pick one. Cleared once a selection is made.
91 claimFormChoices?: ClaimFormChoice[] | null;
92 bundleQuoteData: BundleQuoteData | null;
93 bundleQuoteSessionIds: string[];
94 bundleInsurance: Record<string, Record<string, any>> | null;
95 acceptanceData?: AcceptanceView | null;
96 routeType: string | null;
97 thankYouProductTitle?: string | null;
98 }
99
100 interface AppProps {
101 locale: SupportedLocale;
102 onLocaleChange: (locale: SupportedLocale) => void;
103 }
104
105 const extractCheckoutSessionIdFromState = (pathname: string): { isCheckoutRoute: boolean; sessionId: string | null } => {
106 const parts = pathname.split('/').filter(Boolean);
107 if (parts[0] !== 'checkout') {
108 return { isCheckoutRoute: false, sessionId: null };
109 }
110 return { isCheckoutRoute: true, sessionId: parts[1] || null };
111 };
112
113 const InvalidCheckoutLink = () => {
114 const { sendMessage } = usePostMessage();
115 const notifiedRef = useRef(false);
116 useEffect(() => {
117 if (notifiedRef.current) return;
118 notifiedRef.current = true;
119 sendMessage('SHERPA_PAYMENT_FAILED', { reason: 'invalid_url' });
120 }, [sendMessage]);
121 return (
122 <main className='container'>
123 <article>
124 <header>
125 <h3>Invalid checkout link</h3>
126 </header>
127 <div className='error-banner'>
128 <p>Missing checkout session id</p>
129 </div>
130 </article>
131 </main>
132 );
133 };
134
135 // Outer routing shell. Holds only the pathname listener so its hook count
136 // stays constant across re-renders triggered by `history.pushState` (via
137 // `navigatePaymentResult`). All form-flow hooks live in NonCheckoutApp,
138 // which is unmounted/remounted when we cross the /checkout boundary —
139 // React's hooks-order invariant is preserved per-component.
140 export default function App({ locale, onLocaleChange }: AppProps) {
141 const [pathname, setPathname] = useState<string>(window.location.pathname);
142 useEffect(() => {
143 const sync = () => setPathname(window.location.pathname);
144 window.addEventListener('popstate', sync);
145 window.addEventListener(SHERPA_INTERNAL_NAVIGATE_EVENT, sync);
146 return () => {
147 window.removeEventListener('popstate', sync);
148 window.removeEventListener(SHERPA_INTERNAL_NAVIGATE_EVENT, sync);
149 };
150 }, []);
151
152 // Host-driven country / displayCurrency / pricingCurrency updates. Parent dispatches:
153 // iframe.contentWindow.postMessage(
154 // { source: 'sherpa-host', type: 'SET_CONTEXT',
155 // payload: { country: 'NO', displayCurrency: 'NOK', pricingCurrency: 'NOK' } },
156 // 'https://embed.example'
157 // );
158 // On receive: validate ISO codes, update URL params + apiClient, then
159 // reload so every in-flight fetch picks up the new context cleanly.
160 useEffect(() => {
161 if (typeof window === 'undefined') return;
162 const handler = (event: MessageEvent) => {
163 const data = event.data as { source?: unknown; type?: unknown; payload?: unknown } | null;
164 if (!data || typeof data !== 'object') return;
165 if (data.source !== 'sherpa-host') return;
166 if (data.type !== 'SET_CONTEXT') return;
167 const payload = data.payload as { country?: unknown; displayCurrency?: unknown; pricingCurrency?: unknown } | undefined;
168 if (!payload || typeof payload !== 'object') return;
169
170 const url = new URL(window.location.href);
171 let changed = false;
172
173 if (typeof payload.country === 'string') {
174 const c = payload.country.toUpperCase();
175 if (/^[A-Z]{2}$/.test(c)) {
176 url.searchParams.set('country', c);
177 apiClient.setCountry(c);
178 changed = true;
179 }
180 }
181 if (typeof payload.displayCurrency === 'string') {
182 const cur = payload.displayCurrency.toUpperCase();
183 if (/^[A-Z]{3}$/.test(cur)) {
184 url.searchParams.set('displayCurrency', cur);
185 apiClient.setDisplayCurrency(cur);
186 changed = true;
187 }
188 }
189 if (typeof payload.pricingCurrency === 'string') {
190 const cur = payload.pricingCurrency.toUpperCase();
191 if (/^[A-Z]{3}$/.test(cur)) {
192 url.searchParams.set('pricingCurrency', cur);
193 apiClient.setPricingCurrency(cur);
194 changed = true;
195 }
196 }
197 if (!changed) return;
198 window.history.replaceState({}, '', url.toString());
199 window.location.reload();
200 };
201 window.addEventListener('message', handler);
202 return () => window.removeEventListener('me{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/customer-portal/policies/[policyId]/cancel/DefaultCancelPolicy.tsx"}1 "use client";
2
3 import React, { useState } from "react";
4 import { useRouter } from "next/navigation";
5 import { usePortalPrefix } from "@/app/customer-portal/lib/portalLink";
6
7 import { ArrowLeft, AlertTriangle } from "lucide-react";
8
9 import { useTranslation } from "@gangkharai/lmt/client";
10 import { useProductTranslate } from "@/app/lib/useProductTranslate";
11 import { localeDateTag } from "@/app/lib/locale";
12 import { useAuth } from "@/app/components/AuthProvider";
13 import PortalLayout from "@/app/customer-portal/PortalLayout";
14 import api from "@/app/src/utils/api";
15 import { CurrencyDisplay } from "@/app/components/ui/currencyDisplay";
16 import useOrganizationStore from "@/app/src/store/organizationStore";
17 import { getBusinessDate, getBrowserTimezone } from "@/app/src/utils/businessDate";
18 import { usePolicyStatuses } from "@/app/src/hooks/usePolicyStatuses";
19
20 interface PolicyOrder {
21 id: number;
22 policyId: string;
23 productPlanName: string;
24 policyNumber: string;
25 status?: string;
26 displayCurrency?: string | null;
27 product: {
28 productTitle: string;
29 productPlanTitle: string;
30 translationOverrides?: Record<string, Record<string, string>> | null;
31 currency?: string;
32 currencyPosition?: "prefix" | "suffix";
33 decimalSeparator?: string;
34 groupSeparator?: string;
35 decimalsAmount?: number;
36 };
37 }
38
39 interface CancellationOption {
40 cancellationType: string;
41 effectiveDate: string;
42 newExpiryDate: string;
43 refundAmount: number;
44 cancellationFee: number;
45 netRefund: number;
46 description: string;
47 isRecommended: boolean;
48 isWithinGracePeriod: boolean;
49 }
50
51 interface CancellationPreview {
52 availableOptions: CancellationOption[];
53 recommendedOption: string;
54 currentPolicy: {
55 policyId: string;
56 inceptionDate: string;
57 currentExpiryDate: string;
58 paymentFrequency: string;
59 nextBillingDate: string;
60 gracePeriodEndsOn: string;
61 daysFromInception: number;
62 };
63 requestedDate: string;
64 }
65
66 export default function DefaultCancelPolicy({
67 params,
68 }: {
69 params: { policyId: string } | Promise<{ policyId: string }>;
70 }) {
71 const t = useTranslation();
72 const {} = useAuth();
73 const router = useRouter();
74 const portal = usePortalPrefix();
75 const organization = useOrganizationStore((s) => s.organization);
76 const orgTimezone = organization?.timezone || getBrowserTimezone();
77 const { isCategory, loading: statusesLoading } = usePolicyStatuses();
78 const [policy, setPolicy] = useState<PolicyOrder | null>(null);
79 const tp = useProductTranslate(policy?.product?.translationOverrides);
80
81 // cancellationType is a fixed API enum → static LMT dictionary. The option
82 // `description` is dynamic copy localized on the API, so it renders verbatim.
83 const cancellationTypeLabel = (type: string): string =>
84 ({
85 immediate: t("Immediate"),
86 end_of_billing_cycle: t("End of billing cycle"),
87 specific_date: t("Specific date"),
88 earliest_allowed: t("Earliest allowed"),
89 })[type] ??
90 type.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
91 const [cancelReason, setCancelReason] = useState("");
92 const [cancellationDate, setCancellationDate] = useState(getBusinessDate(orgTimezone));
93 const [selectedOption, setSelectedOption] = useState("");
94 const [cancellationPreview, setCancellationPreview] = useState<CancellationPreview | null>(null);
95 const [loadingPreview, setLoadingPreview] = useState(false);
96 const [isLoading, setIsLoading] = useState(false);
97 const [showResult, setShowResult] = useState(false);
98 const [result, setResult] = useState<{ success: boolean; message: string } | null>(null);
99 const suppressPreviewErrorsRef = React.useRef(false);
100
101 // Unwrap params using React.use() for Next.js 15+ compatibility
102 const unwrappedParams = params instanceof Promise ? React.use(params) : params;
103 const policyId = unwrappedParams?.policyId;
104 const cancellationSuccessKey = policyId ? `policy_cancel_success_${policyId}` : null;
105
106 React.useEffect(() => {
107 if (!policyId) return;
108
109 if (cancellationSuccessKey && sessionStorage.getItem(cancellationSuccessKey) === "true") {
110 suppressPreviewErrorsRef.current = true;
111 sessionStorage.setItem("refreshPolicyData", "true");
112 sessionStorage.removeItem(cancellationSuccessKey);
113 const returnPath = sessionStorage.getItem("policyReturnPath") || `${portal}/policies`;
114 sessionStorage.removeItem("policyReturnPath");
115 router.push(returnPath);
116 return;
117 }
118
119 if (statusesLoading) return;
120
121 let isMounted = true;
122
123 const applyPolicy = (policyData: PolicyOrder) => {
124 if (!isMounted) return;
125 setPolicy(policyData);
126
127 if (isCategory(policyData.status, "cancelled")) {
128 setResult({
129 success: false,
130 message: t('Policy is already cancelled'),
131 });
132 setShowResult(true);
133 }
134 };
135
136 // Try to get policy data from sessionStorage
137 const storedPolicy = sessionStorage.getItem(`policy_${policyId}`);
138 if (storedPolicy) {
139 applyPolicy(JSON.parse(storedPolicy));
140 return;
141 }
142
143 // Fallback: fetch from API when arriving via direct URL
144 (async () => {
145 try {
146 const response = await api.get(`/policies`, { sherpaAdmin: false });
147 const policiesResponseData = response.data?.data || response.data;
148 const policyOrders: PolicyOrder[] = policiesResponseData?.policyOrders || [];
149 const found = policyOrders.find((p) => p.policyId === policyId);
150 if (found) applyPolicy(found);
151 } catch (err) {
152 console.error("Error loading policy for cancel page:", err);
153 }
154 })();
155
156 return () => {
157 isMounted = false;
158 };
159 }, [policyId, statusesLoading, isCategory]);
160
161 // Fetch cancellation preview when date changes
162 React.useEffect(() => {
163 if (cancellationSuccessKey && sessionStorage.getItem(cancellationSuccessKey) === "true") return;
164 if (!policyId || !cancellationDate || showResult || isLoading || suppressPreviewErrorsRef.current) return;
165
166 // Wait until policy load + status guard resolves
167 if (!policy || statusesLoading) return;
168
169 // Don't fetch preview if policy is already cancelled or suspended
170 if (policy.status && (isCategory(policy.status, "cancelled") || isCategory(policy.status, "suspended"))) return;
171
172 const fetchCancellationPreview = async () => {
173 setLoadingPreview(true);
174 try {
175 const response = await api.get(
176 `/policy/cancelPolicy/${policyId}/preview?requestedDate=${cancellationDate}`,
177 { sherpaAdmin: false }
178 );
179
180 if (response.data) {
181 setCancellationPreview(response.data);
182 // Set recommended option as default
183 if (response.data.recommendedOption && !selectedOption) {
184 setSelectedOption(response.data.recommendedOption);
185 }
186 }
187 } catch (error: any) {
188 if (suppressPreviewErrorsRef.current) return;
189 console.error("Error fetching cancellation preview:", error);
190 const rawMessage = error.response?.data?.message || error.response?.data?.error;
191 const message =
192 typeof rawMessage === "string"
193 ? rawMessage
194 : t('Unable to load cancellation options for this policy.');
195 suppressPreviewErrorsRef.current = true;
196 sessionStorage.setItem("pend{"file_path":"/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/apiClient.ts","limit":100}1 import type {
2 WidgetParams,
3 QuoteApiResponse,
4 FormApiResponse,
5 FormParameter,
6 FormDataType,
7 AcceptanceView,
8 AcceptPolicyResult,
9 } from '../types/config';
10 import type {
11 ApiRequestOptions,
12 UploadSignFileDescriptor,
13 UploadSignResponse,
14 ClaimMessagePayload,
15 } from '../types/api';
16 import { initStorageNamespace, namespaceKey } from './storageNamespace';
17 import { detectLocale } from '../lib/lmt';
18
19 export interface TermsConfig {
20 displayStyle?: 'inline' | 'links';
21 linkBehavior?: 'popup' | 'new_tab';
22 sourceType?: 'template' | 'file' | 'url';
23 externalUrl?: string;
24 }
25
26 export interface BundleTemplate {
27 id: number;
28 name: string;
29 title: string;
30 templateType: string;
31 content: string;
32 termsConfig?: TermsConfig | null;
33 }
34
35 export interface TemplateWithConfig {
36 content: string;
37 termsConfig: TermsConfig | null;
38 }
39
40 class ApiClient {
41 private baseUrl: string;
42 private widgetToken: string;
43 private parentDomain: string | null;
44 private displayCurrency: string | null;
45 private pricingCurrency: string | null;
46 private country: string | null;
47
48 constructor(baseUrl: string) {
49 this.baseUrl = baseUrl;
50 this.widgetToken = '';
51 this.parentDomain = null;
52 this.displayCurrency = null;
53 this.pricingCurrency = null;
54 this.country = null;
55 }
56
57 initialize(params: WidgetParams, parentDomain: string | null) {
58 this.widgetToken = params.token;
59 this.parentDomain = parentDomain;
60 this.displayCurrency = params.displayCurrency ?? null;
61 this.pricingCurrency = params.pricingCurrency ?? null;
62 this.country = params.country ?? null;
63 initStorageNamespace(params.token);
64 }
65
66 setCountry(country: string | null) {
67 this.country = country;
68 }
69
70 setDisplayCurrency(displayCurrency: string | null) {
71 this.displayCurrency = displayCurrency;
72 }
73
74 setPricingCurrency(pricingCurrency: string | null) {
75 this.pricingCurrency = pricingCurrency;
76 }
77
78 private withDisplayCurrency<T extends Record<string, unknown>>(body: T): T {
79 if (!this.displayCurrency) return body;
80 if (body && typeof body === 'object' && 'displayCurrency' in body) return body;
81 return { ...body, displayCurrency: this.displayCurrency };
82 }
83
84 private withPricingCurrency<T extends Record<string, unknown>>(body: T): T {
85 if (!this.pricingCurrency) return body;
86 if (body && typeof body === 'object' && 'pricingCurrency' in body) return body;
87 return { ...body, pricingCurrency: this.pricingCurrency };
88 }
89
90 private withCountry<T extends Record<string, unknown>>(body: T): T {
91 if (!this.country) return body;
92 if (body && typeof body === 'object' && 'country' in body) return body;
93 return { ...body, country: this.country };
94 }
95
96 private async request(endpoint: string, options: ApiRequestOptions) {
97 const url = `${this.baseUrl}/v1${endpoint}`;
98
99 const headers: Record<string, string> = {
100 'Content-Type': 'application/json',{"command":"grep -r \"useTranslation\\|t(\" /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app --include=\"*.ts\" --include=\"*.tsx\" | head -3"}/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/customers/page.tsx:import { useTranslation } from "@gangkharai/lmt/client";
/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/customers/page.tsx: const t = useTranslation();
/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/customers/page.tsx: title={t("Customers")}{"file_path":"/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/apiClient.ts","offset":100,"limit":150}100 'Content-Type': 'application/json',
101 ...options.headers,
102 };
103
104 // Add required authentication headers
105 if (this.widgetToken) {
106 headers['X-Widget-Token'] = this.widgetToken;
107 }
108
109 // Add origin and parent domain headers
110 headers['Origin'] = window.location.origin;
111 if (this.parentDomain && this.parentDomain !== 'unknown') {
112 headers['X-Parent-Domain'] = this.parentDomain;
113 }
114
115 const response = await fetch(url, {
116 method: options.method,
117 headers,
118 body: options.body ? JSON.stringify(options.body) : undefined,
119 signal: options.signal,
120 });
121
122 if (!response.ok) {
123 try {
124 const errorData = await response.json();
125 // Handle the specific API error structure: {"status": "error", "error": {"message": "..."}}
126 if (errorData && errorData.error && errorData.error.message) {
127 throw new Error(errorData.error.message);
128 }
129 // Fallback to other common error formats
130 if (errorData && errorData.message) {
131 throw new Error(errorData.message);
132 }
133 throw new Error(
134 `API request failed: ${response.status} ${response.statusText}`
135 );
136 } catch (parseError) {
137 if (
138 parseError instanceof Error &&
139 parseError.message !==
140 `API request failed: ${response.status} ${response.statusText}`
141 ) {
142 throw parseError; // Re-throw if it's our custom error message
143 }
144 throw new Error(
145 `API request failed: ${response.status} ${response.statusText}`
146 );
147 }
148 }
149
150 return response.json();
151 }
152
153 async getQuote(quoteId: string): Promise<QuoteApiResponse> {
154 return this.request(`/quote/${quoteId}`, {
155 method: 'GET',
156 });
157 }
158
159 async getPolicyAcceptance(policyId: string): Promise<AcceptanceView> {
160 const response = await this.request(`/policy-acceptance/${policyId}`, {
161 method: 'GET',
162 });
163 return (response?.data ?? response) as AcceptanceView;
164 }
165
166 async acceptPolicy(
167 policyId: string,
168 body: { acceptedTermsItemIds: number[]; successUrl?: string }
169 ): Promise<AcceptPolicyResult> {
170 const response = await this.request(`/policy-acceptance/${policyId}/accept`, {
171 method: 'POST',
172 body,
173 });
174 return (response?.data ?? response) as AcceptPolicyResult;
175 }
176
177 async getQuoteForm(productName: string): Promise<FormApiResponse> {
178 const response = await this.request(`/quote/form?productName=${productName}`, {
179 method: 'GET',
180 });
181 return normalizeFormResponse(response);
182 }
183
184 async getPolicyForm(quoteId: string): Promise<FormApiResponse> {
185 const response = await this.request(`/policy/form?quoteId=${quoteId}`, {
186 method: 'GET',
187 });
188 return normalizeFormResponse(response);
189 }
190
191 async resolvePolicyForDate(
192 referencePolicyId: string,
193 productName: string,
194 date: string
195 ): Promise<{
196 policyId: string;
197 policyNumber: string | null;
198 inceptionDate: string;
199 expiryDate: string | null;
200 status: string;
201 }> {
202 const params = new URLSearchParams({
203 referencePolicyId,
204 productName,
205 date,
206 });
207 const response = await this.request(
208 `/claim/resolve-policy?${params.toString()}`,
209 { method: 'GET' }
210 );
211 return response.data;
212 }
213
214 async getClaimForm(
215 productName: string,
216 policyId: string,
217 claimId?: number
218 ): Promise<FormApiResponse> {
219 const suffix = claimId !== undefined ? `&claimId=${claimId}` : '';
220 const response = await this.request(
221 `/claim/form?productName=${productName}&policyId=${policyId}${suffix}`,
222 {
223 method: 'GET',
224 }
225 );
226 return normalizeFormResponse(response);
227 }
228
229 // List the claim forms the customer is eligible to file against this
230 // policy. Filtered server-side by Coverage M:N intersection — only forms
231 // whose linked coverages overlap with the policy's held coverages are
232 // returned. Legacy forms with no coverage links are always included.
233 async listClaimForms(
234 productName: string,
235 policyId: string
236 ): Promise<Array<{
237 id: number;
238 name: string;
239 title: string | null;
240 description: string | null;
241 coverages: Array<{ id: number; name: string; title: string | null; code: string | null }>;
242 }>> {
243 return this.request(
244 `/claim/forms?productName=${productName}&policyId=${policyId}`,
245 { method: 'GET' }
246 );
247 }
248
249 // Poll the outcome of a recently submitted claim record. Used right{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/src/utils/env.ts"}1 /// Use this to require all env variables and fail fast when they are missing!
2 /// We don't want to allow the service to go live with bad configuration.
3 /// Use this at the import-scope wherever we use environment variables and NEVER USE || 'default' fallbacks.
4 /// Eg: `const JWT_SECRET = getRequired('JWT_SECRET');` on top of your file.
5 export function getRequired(key: string): string {
6 if (key.startsWith("NEXT_PUBLIC_")) {
7 throw new Error(
8 `Do not use getRequired() with NEXT_PUBLIC_ variables. Use process.env.${key} directly since Next.js inlines these as static values during build.`
9 );
10 }
11
12 const value = process.env[key];
13 if (!value) {
14 throw new Error(`Environment variable ${key} is not set`);
15 }
16 return value;
17 }
18
19 function getUrl(httpUrl: string, httpsUrl: string, protocolOverride?: string): string {
20 const isDevelopment = process.env.NODE_ENV === "development";
21 if (!isDevelopment) return httpsUrl;
22 if (protocolOverride) {
23 const isHttps = protocolOverride === "https:";
24 return isHttps ? httpsUrl : httpUrl;
25 }
26 if (typeof window !== "undefined") {
27 const isHttps = window.location.protocol === "https:";
28 return isHttps ? httpsUrl : httpUrl;
29 }
30 return httpUrl;
31 }
32
33 /**
34 * Get the appropriate API URL based on environment and current protocol
35 */
36 export function getSherpaApiUrl(protocol?: string): string {
37 return getUrl(
38 process.env.NEXT_PUBLIC_SHERPA_API_HTTP!,
39 process.env.NEXT_PUBLIC_SHERPA_API_HTTPS!,
40 protocol
41 );
42 }
43
44 /**
45 * Get the appropriate app URL based on environment and current protocol
46 */
47 export function getAppUrl(): string {
48 return getUrl(process.env.NEXT_PUBLIC_APP_HTTP!, process.env.NEXT_PUBLIC_APP_HTTPS!);
49 }
50
51 /**
52 * Get the appropriate embed URL based on environment and current protocol
53 */
54 export function getEmbedUrl(): string {
55 return getUrl(process.env.NEXT_PUBLIC_EMBED_HTTP!, process.env.NEXT_PUBLIC_EMBED_HTTPS!);
56 }
57
58 /**
59 * Get the sherpa-mesh API URL for direct browser→mesh requests.
60 * Uses HTTP only when the current page is served over HTTP — HTTPS in all other cases.
61 */
62 export function getMeshUrl(): string {
63 const http = process.env.NEXT_PUBLIC_SHERPA_MESH_HTTP;
64 const https = process.env.NEXT_PUBLIC_SHERPA_MESH_HTTPS;
65 if (!http || !https) {
66 throw new Error(
67 "NEXT_PUBLIC_SHERPA_MESH_HTTP and NEXT_PUBLIC_SHERPA_MESH_HTTPS must be set. " +
68 "Add them to your .env file — see env.reference for values."
69 );
70 }
71 const isHttp = typeof window !== "undefined" && window.location.protocol === "http:";
72 return isHttp ? http : https;
73 }
74
75 // Hardcoded production mesh origins. Kept here (not in env) so a misconfigured
76 // NEXT_PUBLIC_SHERPA_MESH_HTTPS at build time cannot widen the allowlist past
77 // what the portal will accept as a bearer-token destination.
78 const HARDCODED_MESH_ORIGINS = [
79 "https://mesh.gangkhar.com",
80 "https://mesh.sherpa.gangkhar.com",
81 ];
82
83 function safeOrigin(value: string | undefined): string | null {
84 if (!value) return null;
85 try {
86 return new URL(value).origin;
87 } catch {
88 return null;
89 }
90 }
91
92 // Parse env-derived mesh origins once at module load. Anything that fails to
93 // parse is silently dropped — the hardcoded list still applies, and a
94 // misconfigured env will surface via getMeshUrl()'s missing-env throw.
95 const ENV_MESH_ORIGINS: readonly string[] = [
96 safeOrigin(process.env.NEXT_PUBLIC_SHERPA_MESH_HTTP),
97 safeOrigin(process.env.NEXT_PUBLIC_SHERPA_MESH_HTTPS),
98 ].filter((o): o is string => o !== null);
99
100 /**
101 * Returns true if `url` resolves to an origin we are allowed to send a portal
102 * bearer token to. The allowlist is:
103 * - origins derived from NEXT_PUBLIC_SHERPA_MESH_HTTP/HTTPS (parsed once at
104 * module load),
105 * - hardcoded production mesh origins,
106 * - any http://localhost[:port] (development only).
107 *
108 * Used by browser → mesh callers (e.g. usePortalChat) to refuse to attach a
109 * bearer when the resolved mesh URL has drifted to an unexpected origin.
110 */
111 export function isAllowedMeshOrigin(url: string): boolean {
112 const origin = safeOrigin(url);
113 if (!origin) return false;
114
115 if (ENV_MESH_ORIGINS.includes(origin)) return true;
116 if (HARDCODED_MESH_ORIGINS.includes(origin)) return true;
117
118 // Dev allowance: any http://localhost[:port]. We only permit this when
119 // NODE_ENV is "development" so a production build cannot accept localhost.
120 if (process.env.NODE_ENV === "development") {
121 try {
122 const parsed = new URL(url);
123 if (parsed.protocol === "http:" && parsed.hostname === "localhost") {
124 return true;
125 }
126 } catch {
127 return false;
128 }
129 }
130
131 return false;
132 }
133 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/hooks/useConfig.tsx"}File does not exist. Note: your current working directory is /Users/bakker/dev/gangkhar. Did you mean useConfig.ts?
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/Toast.tsx","limit":100}1 import React from "react";
2
3 import { X } from "lucide-react";
4 import { ToastContainer, toast, cssTransition } from "react-toastify";
5 import "react-toastify/dist/ReactToastify.css";
6
7 // Toast component - no translatable strings (utility component)
8 // Custom animations for our toasts
9 const slideDown = cssTransition({
10 enter: "animate__animated animate__slideInDown",
11 exit: "animate__animated animate__slideOutUp",
12 });
13
14 // Create a custom toast notification system
15 export const showSuccessToast = (message: React.ReactNode) => {
16 toast.success(<SuccessToast message={message} />, {
17 position: "top-center",
18 autoClose: 4000,
19 hideProgressBar: false,
20 closeOnClick: true,
21 pauseOnHover: true,
22 draggable: false,
23 className: "toast-success-container",
24 progressClassName: "toast-success-progress",
25 });
26 };
27
28 export const showErrorToast = (message: React.ReactNode) => {
29 toast.error(<ErrorToast message={message} />, {
30 position: "top-center",
31 autoClose: 5000,
32 hideProgressBar: false,
33 closeOnClick: true,
34 pauseOnHover: true,
35 draggable: false,
36 className: "toast-error-container",
37 progressClassName: "toast-error-progress",
38 });
39 };
40
41 // Custom toast components for success and error messages
42 export const SuccessToast = ({ message }: { message: React.ReactNode }) => (
43 <div className="flex items-center">
44 <span className="font-medium text-white">{message}</span>
45 </div>
46 );
47
48 export const ErrorToast = ({ message }: { message: React.ReactNode }) => (
49 <div className="flex items-center">
50 <span className="font-medium text-white">{message}</span>
51 </div>
52 );
53
54 // Custom close button
55 const CloseButton = ({ closeToast }: { closeToast: () => void }) => (
56 <button
57 onClick={closeToast}
58 className="hover:bg-opacity-20 ml-4 flex-shrink-0 rounded-full p-1 transition-colors duration-200 hover:bg-white"
59 >
60 <X className="h-4 w-4 text-white" />
61 </button>
62 );
63
64 // Toast styles to be included in the component
65 const toastStyles = `
66 .toast-success-container {
67 background: linear-gradient(to right, #10B981, #059669) !important;
68 box-shadow: 0 8px 16px rgba(16, 185, 129, 0.2) !important;
69 border-radius: 8px !important;
70 padding: 12px 16px !important;
71 margin-top: 16px !important;
72 transform: translateY(-5px);
73 }
74
75 .toast-error-container {
76 background: linear-gradient(to right, #EF4444, #DC2626) !important;
77 box-shadow: 0 8px 16px rgba(239, 68, 68, 0.2) !important;
78 border-radius: 8px !important;
79 padding: 12px 16px !important;
80 margin-top: 16px !important;
81 transform: translateY(-5px);
82 }
83
84 .toast-success-progress {
85 background: rgba(255, 255, 255, 0.4) !important;
86 }
87
88 .toast-error-progress {
89 background: rgba(255, 255, 255, 0.4) !important;
90 }
91
92 .Toastify__toast {
93 min-height: 56px !important;
94 border-radius: 8px !important;
95 transition: transform 0.3s ease !important;
96 }
97
98 .Toastify__toast:hover {
99 transform: translateY(-3px) !important;
100 }{"command":"find /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/hooks -type f -name \"*.ts*\""}/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/hooks/useConfig.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/hooks/useDisplayConditions.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/hooks/useRequote.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/hooks/useCoverageSelections.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/hooks/useLocale.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/hooks/useFormPersistence.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/hooks/useAddressAutocomplete.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/hooks/useApi.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/hooks/usePostMessage.ts
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/hooks/useConfig.ts"}1 import { useState, useEffect } from 'preact/hooks';
2 import { getParentDomain } from '../utils/security';
3 import type { WidgetParams } from '../types/config';
4
5 interface ConfigState {
6 params: WidgetParams | null;
7 parentDomain: string | null;
8 urlPrefillData: Record<string, unknown>;
9 loading: boolean;
10 error: string | null;
11 }
12
13 /**
14 * The in-widget locale dropdown should be hidden when a customer host page
15 * embeds us via iframe — the host owns its own language UX and we don't
16 * want two dropdowns competing. But when a developer or product owner loads
17 * the embed standalone (direct browser navigation, portal preview), they
18 * need the dropdown to QA each locale without hand-editing the URL.
19 *
20 * Resolution order:
21 * 1. ?showLocaleSwitcher=true → always show (explicit opt-in)
22 * 2. ?showLocaleSwitcher=false → always hide (the production snippet)
23 * 3. Standalone (window === window.parent, i.e. NOT iframed) → show
24 * 4. Iframed without an explicit flag → hide (customer-host default)
25 */
26 function resolveShowLocaleSwitcher(urlParams: URLSearchParams): boolean {
27 const raw = urlParams.get('showLocaleSwitcher');
28 if (raw === 'true') return true;
29 if (raw === 'false') return false;
30 try {
31 return window.parent === window;
32 } catch {
33 // Cross-origin throws on .parent in some browsers; fall back to hidden
34 // since the throw itself proves we're iframed.
35 return false;
36 }
37 }
38
39 export const useConfig = () => {
40 const [state, setState] = useState<ConfigState>({
41 params: null,
42 parentDomain: null,
43 urlPrefillData: {},
44 loading: true,
45 error: null,
46 });
47
48 useEffect(() => {
49 try {
50 // Parse URL parameters
51 const urlParams = new URLSearchParams(window.location.search);
52 const pathParts = window.location.pathname.split('/');
53
54 // Determine page type and extract ID from URL path
55 let pageType:
56 | 'quote'
57 | 'policy'
58 | 'claim'
59 | 'thank-you'
60 | 'test'
61 | 'accept'
62 | undefined;
63 let quoteId: string | undefined;
64 let policyId: string | undefined;
65 let claimId: string | undefined;
66
67 if (pathParts[1] === 'test') {
68 pageType = 'test';
69 } else if (
70 pathParts[1] === 'accept' &&
71 pathParts.length > 2 &&
72 pathParts[2] !== ''
73 ) {
74 // Customer acceptance link for a dealer-prepared draft policy:
75 // /accept/<policyId>?token=...
76 pageType = 'accept';
77 policyId = pathParts[2];
78 } else if (
79 pathParts[1] === 'quote' &&
80 pathParts.length > 2 &&
81 pathParts[2] !== ''
82 ) {
83 pageType = 'quote';
84 quoteId = pathParts[2];
85 } else if (
86 pathParts[1] === 'policy' &&
87 pathParts.length > 2 &&
88 pathParts[2] !== ''
89 ) {
90 pageType = 'policy';
91 policyId = pathParts[2];
92 } else if (
93 pathParts[1] === 'claim' &&
94 pathParts.length > 2 &&
95 pathParts[2] !== ''
96 ) {
97 pageType = 'claim';
98 claimId = pathParts[2];
99 } else if (pathParts[1] === 'claim') {
100 pageType = 'claim';
101 } else if (pathParts[1] === 'thank-you') {
102 pageType = 'thank-you';
103 policyId = urlParams.get('policyId') || undefined;
104 claimId = urlParams.get('claimId') || undefined;
105 }
106
107 // Extract prefill data from URL
108 const urlPrefillData: Record<string, unknown> = {};
109 for (const [key, value] of urlParams.entries()) {
110 // Handle prefill[path] format
111 const prefillMatch = key.match(/^prefill\[(.+)\]$/);
112 if (prefillMatch) {
113 const path = prefillMatch[1];
114 urlPrefillData[path] = value;
115 }
116 }
117
118 // Extract parameters from URL
119 const activeLocalesRaw = urlParams.get('activeLocales');
120
121 const rawDisplayCurrency = urlParams.get('displayCurrency') || undefined;
122 let displayCurrency: string | undefined;
123 if (rawDisplayCurrency) {
124 if (/^[A-Z]{3}$/.test(rawDisplayCurrency)) {
125 displayCurrency = rawDisplayCurrency;
126 } else {
127 console.warn(
128 `Invalid displayCurrency URL param: '${rawDisplayCurrency}' (expected ISO 4217 code, e.g. EUR). Ignoring.`
129 );
130 }
131 }
132
133 const rawPricingCurrency = urlParams.get('pricingCurrency') || undefined;
134 let pricingCurrency: string | undefined;
135 if (rawPricingCurrency) {
136 if (/^[A-Z]{3}$/.test(rawPricingCurrency)) {
137 pricingCurrency = rawPricingCurrency;
138 } else {
139 console.warn(
140 `Invalid pricingCurrency URL param: '${rawPricingCurrency}' (expected ISO 4217 code, e.g. NOK). Ignoring.`
141 );
142 }
143 }
144
145 const rawCountry = urlParams.get('country') || undefined;
146 let country: string | undefined;
147 if (rawCountry) {
148 const normalized = rawCountry.toUpperCase();
149 if (/^[A-Z]{2}$/.test(normalized)) {
150 country = normalized;
151 } else {
152 console.warn(
153 `Invalid country URL param: '${rawCountry}' (expected ISO 3166-1 alpha-2 code, e.g. NO). Ignoring.`
154 );
155 }
156 }
157
158 const params: WidgetParams = {
159 token: urlParams.get('token') || '',
160 product: urlParams.get('product') || undefined,
161 quoteId: quoteId || urlParams.get('quoteId') || undefined,
162 policyId: policyId || urlParams.get('policyId') || undefined,
163 claimId,
164 pageType,
165 from: urlParams.get('from') || undefined,
166 plan: urlParams.get('plan') || undefined,
167 frequency: urlParams.get('frequency') || undefined,
168 bundle: urlParams.get('bundle') || undefined,
169 preview: urlParams.get('preview') === '1',
170 activeLocales: activeLocalesRaw
171 ? activeLocalesRaw.split(',').map(l => l.trim()).filter(Boolean)
172 : undefined,
173 showLocaleSwitcher: resolveShowLocaleSwitcher(urlParams),
174 displayCurrency,
175 pricingCurrency,
176 country,
177 testMode: urlParams.get('testMode') === 'true' || urlParams.get('testMode') === '1',
178 offerMode: urlParams.get('mode') === 'offer',
179 };
180
181 // Validate required parameters. /thank-you reached from embed-checkout
182 // has no widget token — the embed-checkout session id was the credential
183 // and is no longer needed once the policy is active.
184 if (!params.token && params.pageType !== 'test' && params.pageType !== 'thank-you') {
185 throw new Error('Widget token is required');
186 }
187
188 if (
189 !params.quoteId &&
190 !params.policyId &&
191 !params.claimId &&
192 !params.product &&
193 !params.bundle &&
194 params.pageType !== 'thank-you' &&
195 params.pageType !== 'test'
196 ) {
197 throw new Error(
198 'Either quoteId, policyId, claimId, product, or bundle parameter is required'
199 );
200 }
201
202 // For claim forms, policyId is required
203 if (params.pageType === 'claim' && !params.policyId) {
204 throw new Error('policyId parameter is required for claim forms');
205 }
206
207 // Get parent domain for API headers
208 const parentDomain = getParentDomain();
209
210 setState({
211 params,
212 parentDomain,
213 urlPr{"command":"ls -la /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/customer-portal/ | grep -E \"\\.tsx$|^d\""}drwxr-xr-x@ 12 bakker staff 384 Jun 1 08:39 . drwxr-xr-x@ 53 bakker staff 1696 Jun 1 08:39 .. -rw-r--r--@ 1 bakker staff 20605 Jun 1 08:39 PortalLayout.tsx drwxr-xr-x@ 5 bakker staff 160 Jun 1 08:39 claims drwxr-xr-x@ 5 bakker staff 160 Jun 1 08:39 components drwxr-xr-x@ 4 bakker staff 128 Jun 1 08:39 dashboard drwxr-xr-x@ 4 bakker staff 128 Jun 1 08:39 invoices drwxr-xr-x@ 6 bakker staff 192 Jun 1 08:39 lib drwxr-xr-x@ 4 bakker staff 128 Jun 1 08:39 notifications drwxr-xr-x@ 5 bakker staff 160 Jun 1 08:39 policies drwxr-xr-x@ 4 bakker staff 128 Jun 1 08:39 profile drwxr-xr-x@ 6 bakker staff 192 Jun 1 08:39 templates
{"command":"ls -la /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/components/ | grep -E \"\\.tsx$\" | head -20"}-rw-r--r--@ 1 bakker staff 8473 Jun 1 08:39 AcceptancePage.tsx -rw-r--r--@ 1 bakker staff 15473 Jun 1 08:39 BundleOrderSummary.tsx -rw-r--r--@ 1 bakker staff 12848 Jun 1 08:39 BundlePolicyForm.tsx -rw-r--r--@ 1 bakker staff 3519 Jun 1 08:39 BundleProductSelect.tsx -rw-r--r--@ 1 bakker staff 56140 Jun 1 08:39 BundleResults.tsx -rw-r--r--@ 1 bakker staff 2716 Jun 1 08:39 BundleThankYou.tsx -rw-r--r--@ 1 bakker staff 14889 Jun 1 08:39 ClaimForm.tsx -rw-r--r--@ 1 bakker staff 1777 Jun 1 08:39 DynamicIcon.tsx -rw-r--r--@ 1 bakker staff 1577 Jun 1 08:39 ErrorBoundary.tsx -rw-r--r--@ 1 bakker staff 2364 Jun 1 08:39 FormProvider.tsx -rw-r--r--@ 1 bakker staff 879 Jun 1 08:39 LocaleSwitcher.tsx -rw-r--r--@ 1 bakker staff 73176 Jun 1 08:39 MultiPageForm.tsx -rw-r--r--@ 1 bakker staff 12613 Jun 1 08:39 OrderSummary.tsx -rw-r--r--@ 1 bakker staff 1333 Jun 1 08:39 PhoneInput.tsx -rw-r--r--@ 1 bakker staff 4758 Jun 1 08:39 PolicyDatesDisplay.tsx -rw-r--r--@ 1 bakker staff 17541 Jun 1 08:39 PolicyForm.tsx -rw-r--r--@ 1 bakker staff 2879 Jun 1 08:39 QuoteForm.tsx -rw-r--r--@ 1 bakker staff 76633 Jun 1 08:39 ResultsDisplay.tsx -rw-r--r--@ 1 bakker staff 16048 Jun 1 08:39 TermsAndConditionsDisplay.tsx -rw-r--r--@ 1 bakker staff 10636 Jun 1 08:39 ThankYou.tsx
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/customer-portal/PortalLayout.tsx","limit":100}1 "use client";
2
3 import React, { useEffect, useState } from "react";
4
5 import Image from "next/image";
6 import Link from "next/link";
7 import { usePathname, useRouter } from "next/navigation";
8 import {
9 Menu,
10 X,
11 User,
12 LogOut,
13 LucideIcon,
14 ShoppingBag,
15 LayoutDashboard,
16 UserCircle,
17 FileText,
18 Receipt,
19 Bell,
20 ChevronLeft,
21 ChevronRight,
22 ChevronDown,
23 } from "lucide-react";
24
25 import { useTranslation } from "@gangkharai/lmt/client";
26 import { EnhancedLocaleSwitcher } from "../components/EnhancedLocaleSwitcher";
27 import { LOCALE_LABELS } from "@/app/lib/supported-locales";
28 import Loader from "../components/Loader";
29 import Toast, { showErrorToast } from "../components/Toast";
30 import { useAuth } from "../components/AuthProvider";
31 import { useOrganization } from "../components/OrganizationProvider";
32 import api, { streamFetch } from "@/app/src/utils/api";
33 import { getSherpaApiUrl } from "@/app/src/utils/env";
34 import useNotificationStore from "@/app/src/store/notificationStore";
35
36 interface PortalLayoutProps {
37 children: React.ReactNode;
38 chatMode?: boolean;
39 }
40
41 interface MenuItem {
42 name: React.ReactNode;
43 href: string;
44 icon?: LucideIcon;
45 key: string;
46 }
47
48 const PortalLayout: React.FC<PortalLayoutProps> = ({ children, chatMode = false }) => {
49 const t = useTranslation();
50 const { user, loading: authLoading, logout } = useAuth();
51 const { organization } = useOrganization();
52 const router = useRouter();
53 const pathname = usePathname();
54 const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
55 const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
56 const [isProfileDropdownOpen, setIsProfileDropdownOpen] = useState(false);
57 const { unreadCount, setUnreadCount, prependNotification } = useNotificationStore();
58
59 useEffect(() => {
60 try {
61 const pending = sessionStorage.getItem("pendingErrorToast");
62 if (pending) {
63 showErrorToast(pending);
64 sessionStorage.removeItem("pendingErrorToast");
65 }
66 } catch {}
67 }, []);
68
69 useEffect(() => {
70 const sidebarWidth = isSidebarCollapsed ? 80 : 256;
71 document.documentElement.style.setProperty("--sidebar-width", `${sidebarWidth}px`);
72 }, [isSidebarCollapsed]);
73
74 // Load unread count on mount
75 useEffect(() => {
76 if (!user) return;
77 api.get("/claim-records/notifications/unread-count")
78 .then((res) => {
79 if (res.data.status === "success") setUnreadCount(res.data.data.count);
80 })
81 .catch(() => {});
82 }, [user]);
83
84 // SSE for real-time notifications (using streamFetch with proper auth headers)
85 useEffect(() => {
86 if (!user) return;
87
88 let mounted = true;
89 const abortController = new AbortController();
90 let reconnectTimeout: NodeJS.Timeout | null = null;
91 const RECONNECT_DELAYS = [1000, 2000, 4000, 8000];
92 let retryCount = 0;
93
94 async function connectSSE() {
95 if (!mounted) return;
96
97 try {
98 const url = `${getSherpaApiUrl()}/v1/plus/claim-records/notifications/sse`;
99 const res = await streamFetch(url, { signal: abortController.signal });
100 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/components/QuoteForm.tsx"}1 import React from 'react';
2 import { useQuoteApi } from '../hooks/useApi';
3 import { useWidgetStatus } from '../hooks/usePostMessage';
4 import { useConfig } from '../hooks/useConfig';
5 import { buildQuoteResultsUrl } from '../utils/urlBuilder';
6 import { useTranslation } from '../lib/translations';
7 import AdaptiveForm from './customizable-form/AdaptiveForm';
8 import type { QuoteData, FormApiResponse } from '../types/config';
9
10 interface QuoteFormProps {
11 quoteData: QuoteData;
12 formData: FormApiResponse;
13 onComplete: (results: QuoteData) => void;
14 onError: (error: string) => void;
15 urlPrefillData?: Record<string, unknown>;
16 error?: string | null;
17 }
18
19 export default function QuoteForm({
20 quoteData,
21 formData,
22 onComplete: _onComplete,
23 onError,
24 urlPrefillData = {},
25 error = null,
26 }: QuoteFormProps) {
27 const { createQuote, loading } = useQuoteApi();
28 const { notifyQuoteCompleted } = useWidgetStatus();
29 const config = useConfig();
30 const t = useTranslation();
31 const [navigating, setNavigating] = React.useState(false);
32
33 const handleSubmit = async (formValues: Record<string, unknown>) => {
34 try {
35 // Use the new createQuote method with the specific API format
36 const results = await createQuote(quoteData.productName, formValues);
37
38 // Convert API response to internal QuoteData format
39 const completedQuote: QuoteData = {
40 id: results.quoteId,
41 productName: quoteData.productName,
42 parameters: formValues,
43 results: results,
44 status: 'completed',
45 };
46
47 // Notify parent window - convert QuoteData to Record<string, unknown>
48 notifyQuoteCompleted({
49 id: completedQuote.id,
50 productName: completedQuote.productName,
51 parameters: completedQuote.parameters,
52 results: completedQuote.results,
53 status: completedQuote.status,
54 });
55
56 // Always navigate to pricing results page with the quoteId
57 const token = config.params?.token || '';
58 if (!token) {
59 throw new Error('Token is required for navigation');
60 }
61 const pricingResultsUrl = buildQuoteResultsUrl(
62 results.quoteId,
63 token,
64 quoteData.productName,
65 undefined,
66 'quote-form'
67 );
68 setNavigating(true);
69 window.location.href = pricingResultsUrl;
70 } catch (error) {
71 const errorMessage =
72 error instanceof Error ? error.message : t('Failed to generate quote');
73 onError(errorMessage);
74 throw error; // Re-throw to let MultiPageForm handle UI state
75 }
76 };
77
78 return (
79 <AdaptiveForm
80 formType='quote'
81 formData={formData}
82 productName={quoteData.productName}
83 onSubmit={handleSubmit}
84 onError={onError}
85 loading={loading || navigating}
86 initialData={quoteData.parameters}
87 urlPrefillData={urlPrefillData}
88 error={error}
89 />
90 );
91 }
92 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/components/PolicyForm.tsx","limit":100}1 import React from 'react';
2 import { useApi } from '../hooks/useApi';
3 import { useConfig } from '../hooks/useConfig';
4 import { apiClient } from '../utils/apiClient';
5 import { namespaceKey } from '../utils/storageNamespace';
6 import { navigatePaymentResult } from '../utils/paymentNavigation';
7 import type { PaymentFlowMode } from '../utils/paymentNavigation';
8 import { buildQuoteResultsUrl, buildThankYouUrl } from '../utils/urlBuilder';
9 import { formatPlanName } from '../utils/formatters';
10 import { useTranslation } from '../lib/translations';
11 import { formatCurrency } from '../utils/currency';
12 import type { CurrencyFormatOptions } from '../utils/currency';
13 import AdaptiveForm from './customizable-form/AdaptiveForm';
14 import OrderSummary from './OrderSummary';
15 import type { PolicyData, FormApiResponse } from '../types/config';
16
17 interface PolicyFormProps {
18 policyData: PolicyData;
19 formData: FormApiResponse;
20 onComplete: (results: PolicyData) => void;
21 onError: (error: string) => void;
22 urlPrefillData?: Record<string, unknown>;
23 error?: string | null;
24 }
25
26 export default function PolicyForm({
27 policyData,
28 formData,
29 onComplete: _onComplete,
30 onError,
31 urlPrefillData = {},
32 error = null,
33 }: PolicyFormProps) {
34 const { execute, loading } = useApi();
35 const config = useConfig();
36 const t = useTranslation();
37 const [navigating, setNavigating] = React.useState(false);
38 const [pricingData, setPricingData] = React.useState<any>(null);
39 const [paymentProvider, setPaymentProvider] = React.useState<string | null>(null);
40
41 // Operator "create offer" mode: build an awaiting_acceptance draft instead of
42 // buying. Payment + terms acceptance are deferred to the customer's /accept
43 // page, so we skip both here and the API emails the customer the link.
44 // Operator "create offer" mode: terms/consent fields are hidden in the form
45 // (the customer accepts them on /accept) via the `offerMode` prop passed to
46 // AdaptiveForm, and the API strips any submitted terms for awaiting_acceptance
47 // drafts.
48 const offerMode = config.params?.offerMode === true;
49
50 const showBackButton = config.params?.from === 'quote';
51
52 // Get selected plan and pricing data from URL params
53 const selectedPlan = config.params?.plan;
54 const quoteId = config.params?.quoteId || policyData.id;
55 const selectedFrequency = config.params?.frequency || 'month';
56
57 React.useEffect(() => {
58 const fetchPricingData = async () => {
59 if (quoteId && selectedPlan) {
60 try {
61 const response = await apiClient.getQuote(quoteId);
62
63 if (response?.paymentProvider) {
64 setPaymentProvider(response.paymentProvider);
65 }
66
67 let planData = null;
68
69 if (response?.insurance) {
70 const productKeys = Object.keys(response.insurance);
71 if (productKeys.length > 0) {
72 const productKey = productKeys[0];
73 const productData = response.insurance[productKey];
74
75 if (productData && productData[selectedPlan]) {
76 planData = productData[selectedPlan];
77 }
78 }
79 }
80
81 if (planData) {
82 const allFrequencies = Object.keys(
83 planData.premiumsByFrequency || {}
84 );
85 const frequency = allFrequencies.includes(selectedFrequency)
86 ? selectedFrequency
87 : allFrequencies[0] || 'month';
88 const premiumData = planData.premiumsByFrequency?.[frequency];
89
90 if (premiumData) {
91 const coverages = Array.isArray(planData.coverages)
92 ? planData.coverages
93 : [];
94 const selectedCoverages = coverages
95 .filter(
96 (c: any) =>
97 c.amountType !== 'Excluded' &&
98 (c.selected === true || c.baseCoverage === true)
99 )
100 .map((coverage: any) => {{"command":"find /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app -maxdepth 2 -type d -name \"*\\(auth\\)*\" -o -name \"*\\(public\\)*\" 2>/dev/null"}(Bash completed with no output)
{"command":"ls -la /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/ | grep \"^\\(\" "}Exit code 2 grep: parentheses not balanced
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/components/ThankYou.tsx","limit":50}1 import React, { useEffect, useState } from 'react';
2 import { useTranslation, useTranslateDynamic } from '../lib/translations';
3 import { useApiErrorTranslator } from '../lib/apiErrorTranslator';
4 import { useConfig } from '../hooks/useConfig';
5 import { useWidgetStatus } from '../hooks/usePostMessage';
6 import { buildClaimUrl } from '../utils/urlBuilder';
7 import type { FunnelTemplate } from '../types/config';
8 import { useThankYouTemplateParser } from './thankyou/useThankYouTemplateParser';
9
10 interface ThankYouProps {
11 policyId?: string;
12 claimId?: string;
13 productName?: string;
14 productTitle?: string;
15 error?: string | null;
16 thankyouTemplate?: FunnelTemplate | null;
17 }
18
19 function formatProductName(name: string): string {
20 return name.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
21 }
22
23 function useThankYouCss(thankyouTemplate?: FunnelTemplate | null): string {
24 const [css, setCss] = useState('');
25
26 useEffect(() => {
27 // If we have a template with CSS from the API, use it directly
28 if (thankyouTemplate?.css) {
29 setCss(thankyouTemplate.css);
30 return;
31 }
32
33 // Otherwise, load the default CSS from public/templates
34 fetch('/templates/thankyou/default/template.css')
35 .then(res => {
36 if (res.ok) return res.text();
37 return '';
38 })
39 .then(text => setCss(text))
40 .catch(() => setCss(''));
41 }, [thankyouTemplate]);
42
43 return css;
44 }
45
46 export default function ThankYou({ policyId, claimId, productName, productTitle, error = null, thankyouTemplate }: ThankYouProps) {
47 const config = useConfig();
48 const { requestBack } = useWidgetStatus();
49 const isClaim = !!claimId;
50 const css = useThankYouCss(thankyouTemplate);{"command":"ls -d /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/\\(* 2>/dev/null"}Exit code 1 (eval):1: no matches found: /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/(*
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/types/config.ts"}1 import type { DateConstraints } from './dateConstraints';
2
3 // Widget URL Parameter Types
4 export interface WidgetParams {
5 token: string; // Widget token for authentication
6 quoteId?: string; // Quote ID for existing quotes (from URL path)
7 policyId?: string; // Policy ID for existing policies (from URL path)
8 claimId?: string; // Claim ID for existing claims (from URL path)
9 product?: string; // Product name for new quote forms
10 pageType?: 'quote' | 'policy' | 'claim' | 'thank-you' | 'test' | 'accept'; // Type of page being accessed
11 from?: string; // Track navigation origin (e.g., 'quote' when coming from quote page)
12 plan?: string; // Selected plan name from pricing page
13 frequency?: string; // Selected payment frequency
14 bundle?: string; // Bundle name for bundle funnels
15 preview?: boolean; // Template designer preview mode
16 activeLocales?: string[]; // Restrict the locale switcher to this subset; defaults to all supported locales when absent
17 showLocaleSwitcher?: boolean; // Show the embed's in-widget locale dropdown. Defaults to TRUE when the embed is loaded standalone (top-level frame — direct browser nav, portal preview, dev QA) and FALSE when iframed (customer host page owns language UX). Customers paste `?showLocaleSwitcher=false` to force hide; portal preview can pass `=true` to force show. See useConfig.ts:resolveShowLocaleSwitcher.
18 displayCurrency?: string; // ISO 4217 currency to display and charge in (must be in product's allowlist)
19 pricingCurrency?: string; // ISO 4217 currency the pricing engine should run in (must be in product.pricingCurrencies)
20 country?: string; // ISO 3166-1 alpha-2 country code for this sale (must be in product.availableCountries)
21 testMode?: boolean; // Marks policy/claim creation as a test record; in production this forces "test" status so the record is hidden from public list/financial endpoints
22 offerMode?: boolean; // Operator "create offer" mode (?mode=offer): the funnel creates an awaiting_acceptance draft (skips payment + terms) instead of buying; the customer is emailed the /accept link
23 }
24
25 // Quote/policy acceptance page (GET /v1/policy-acceptance/:policyId).
26 export interface AcceptanceItem {
27 id: number;
28 itemType: 'parameter' | 'price' | 'coverage' | 'terms' | 'plan' | 'static_text';
29 label: string | null;
30 value?: string | null;
31 html?: string | null;
32 required: boolean;
33 displayOptions?: { mask?: 'last4' | 'email' | 'none'; emphasize?: boolean; hideIfEmpty?: boolean } | null;
34 }
35
36 export interface AcceptanceCategory {
37 id: number;
38 label: string | null;
39 description: string | null;
40 items: AcceptanceItem[];
41 }
42
43 export interface AcceptancePage {
44 id: number;
45 label: string | null;
46 description: string | null;
47 categories: AcceptanceCategory[];
48 }
49
50 export interface AcceptanceTerm {
51 itemId: number;
52 label: string | null;
53 templateId: string | null;
54 termsConfig: import('../utils/apiClient').TermsConfig | null;
55 required: boolean;
56 }
57
58 export interface AcceptanceView {
59 policyId: string;
60 status: string;
61 productName: string;
62 productPlanName: string | null;
63 paymentFrequency: string | null;
64 quoteId: string | null;
65 pricingData: import('./pricing').PricingData | null;
66 acceptance: {
67 title: string | null;
68 subtitle: string | null;
69 showEditButton: boolean;
70 acceptanceMode: 'checkbox' | 'button';
71 pages: AcceptancePage[];
72 };
73 payment: { required: boolean; provider: string; paymentUiMode: 'redirect' | 'iframe' };
74 terms: AcceptanceTerm[];
75 translationOverrides?: Record<string, Record<string, string>> | null;
76 activeLocales?: string[] | null;
77 }
78
79 export interface AcceptPolicyResultPayment {
80 url?: string;
81 clientSecret?: string;
82 embedCheckoutSessionId?: string;
83 externalReference?: string;
84 provider: string;
85 status?: string;
86 }
87
88 export interface AcceptPolicyResult {
89 policyId: string;
90 status: string;
91 paymentFlowMode?: 'redirect' | 'iframe';
92 payment?: {
93 url?: string;
94 clientSecret?: string;
95 embedCheckoutSessionId?: string;
96 externalReference?: string;
97 provider: string;
98 status?: string;
99 };
100 }
101
102 // API Response Types (matching actual Sherpa API)
103 export interface PremiumFrequency {
104 basePremium: number;
105 premiumWithoutTax: number;
106 salesTax: number;
107 premiumWithTax: number;
108 periodDays?: number;
109 adjustment?: number;
110 oldPriceWithoutTax?: number;
111 oldPriceWithTax?: number;
112 }
113
114 export interface QuoteApiResponse {
115 quoteId: string;
116 parameters?: Record<string, unknown>;
117 paymentProvider?: string;
118 funnelTemplate?: FunnelTemplate | null;
119 thankyouTemplate?: FunnelTemplate | null;
120 insurance: {
121 [productName: string]: {
122 [planType: string]: {
123 premiumsByFrequency: {
124 [frequency: string]: PremiumFrequency;
125 };
126 currency: string;
127 currencyPosition?: 'prefix' | 'suffix';
128 decimalSeparator?: string;
129 groupSeparator?: string;
130 decimalsAmount?: number;
131 coverages: Coverage[];
132 color?: string;
133 title?: string;
134 // The product's display name (set by the API per-plan, e.g.
135 // "Personal Inconvenience Insurance"). Distinct from `title`
136 // (plan name like "Basic"). Used by the page header so the
137 // translated label flows through the LMT dict instead of the
138 // slug-formatted productName.
139 productTitle?: string;
140 };
141 };
142 };
143 }
144
145 export interface CoveragePricing {
146 finalPrice: number;
147 basePrice: number;
148 premiumWithoutTax: number;
149 premiumWithTax: number;
150 salesTax: number;
151 periodDays?: number;
152 premiumsByFrequency?: {
153 [frequency: string]: {
154 premiumWithoutTax: number;
155 salesTax: number;
156 premiumWithTax: number;
157 periodDays?: number;
158 };
159 };
160 }
161
162 export interface CoverageOption {
163 id: number;
164 name: string;
165 description: string;
166 coverageLimit: string | number | null;
167 deductibles: { value: number; type: 'fixed' | 'percentage' }[] | null;
168 displayOrder: number;
169 metadata: Record<string, unknown> | null;
170 pricing?: Record<string, CoveragePricing>;
171 }
172
173 export interface Coverage {
174 id: number;
175 code: string;
176 name: string;
177 title: string;
178 description: string;
179 active: boolean;
180 productId: number;
181 productPlanId: number;
182 terms: string | null;
183 minIssueAge: number;
184 maxIssueAge: number;
185 maturityAge: number;
186 baseCoverage: boolean;
187 amountType: string;
188 coverageAmount: string | number | null;
189 coverageAmountCurrency?: string;
190 coverageAmountDisplayCurrency?: string;
191 minCoverageAmount: string | number | null;
192 maxCoverageAmount: string | number | null;
193 exclusionType: string;
194 exclusionPeriod: number | null;
195 waitingPeriod: number | null;
196 coverageFormula: string | null;
197 createdAt: string;
198 updatedAt: string;
199 coverageOptions?: CoverageOption[];
200 pricing?: CoveragePricing;
201 selected?: boolean;
202 selectedOptionId?: number;
203 selectedDeductible?: string;
204 }
205
206 // New hierarchical form structure types
207 export interface FormParameterOption {
208 value: string;
209 label: string;
210 description?: string;
211 icon?: string;
212 }
213
214 export interface EnumValueObject {
215 value: string;
216 label?: string;
217 description?: string;
218 icon?: string;
219 }
220
221 export interface ConditionRul{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/forgot-password/page.tsx","limit":100}1 "use client";
2
3 import React, { useState } from "react";
4
5 import { AlertCircle, CheckCircle } from "lucide-react";
6
7 import { useTranslation } from "@gangkharai/lmt/client";
8 import Toast, { showSuccessToast, showErrorToast } from "../components/Toast";
9 import { Button } from "../components/ui/button";
10 import { Input } from "../components/ui/input";
11 import api from "../src/utils/api";
12
13 export default function ForgotPassword() {
14 const t = useTranslation();
15 const [email, setEmail] = useState("");
16 const [isLoading, setIsLoading] = useState(false);
17 const [error, setError] = useState("");
18 const [success, setSuccess] = useState(false);
19
20 const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
21 e.preventDefault();
22 setIsLoading(true);
23 setError("");
24
25 try {
26 const response = await api.post("/auth/forgot-password", { email });
27
28 const data = response.data;
29
30 if (response.status >= 200 && response.status < 300) {
31 setSuccess(true);
32 showSuccessToast(t('Password reset instructions have been sent to your email.'));
33 } else {
34 const errorMsg = data.error || t('An error occurred. Please try again.');
35 setError(errorMsg);
36 showErrorToast(errorMsg);
37 }
38 } catch {
39 const errorMsg = t('An unexpected error occurred. Please try again.');
40 setError(errorMsg);
41 showErrorToast(errorMsg);
42 } finally {
43 setIsLoading(false);
44 }
45 };
46
47 return (
48 <div className="flex min-h-screen flex-col justify-center bg-white py-12 sm:px-6 lg:px-8">
49 <Toast />
50 <div className="sm:mx-auto sm:w-full sm:max-w-md">
51 <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
52 {t('Forgot your password?')}
53 </h2>
54 <p className="mt-2 text-center text-sm text-gray-600">
55 {t("Enter your email address and we'll send you a link to reset your password.")}
56 </p>
57 </div>
58
59 <div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
60 <div className="bg-white px-4 py-8 shadow sm:rounded-md sm:px-10">
61 {success ? (
62 <div className="rounded-md bg-green-50 p-4">
63 <div className="flex">
64 <div className="flex-shrink-0">
65 <CheckCircle className="h-5 w-5 text-green-400" aria-hidden="true" />
66 </div>
67 <div className="ml-3">
68 <h3 className="text-sm font-medium text-green-800">{t('Password reset email sent')}</h3>
69 <div className="mt-2 text-sm text-green-700">
70 <p>
71 {t('If an account exists for the email you entered, you will receive a password reset link shortly.')}
72 </p>
73 </div>
74 </div>
75 </div>
76 </div>
77 ) : (
78 <form className="space-y-6" onSubmit={handleSubmit}>
79 <div>
80 <label htmlFor="email" className="block text-sm font-medium text-gray-700">
81 {t('Email address')}
82 </label>
83 <div className="mt-1">
84 <Input
85 id="email"
86 name="email"
87 type="email"
88 autoComplete="email"
89 required
90 className="border-gray-300"
91 value={email}
92 onChange={(e) => setEmail(e.target.value)}
93 />
94 </div>
95 </div>
96
97 {error && (
98 <div className="rounded-md bg-red-50 p-4">
99 <div className="flex">
100 <div className="flex-shrink-0">{"file_path":"/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/utils/urlBuilder.ts"}1 const LOCALE_STORAGE_KEY = 'sherpa-embed-locale';
2
3 function getCurrentLocale(): string | null {
4 try {
5 const fromUrl = new URLSearchParams(window.location.search).get('locale');
6 if (fromUrl) return fromUrl;
7 } catch {
8 // Window unavailable; skip.
9 }
10 try {
11 return localStorage.getItem(LOCALE_STORAGE_KEY);
12 } catch {
13 return null;
14 }
15 }
16
17 interface UrlParams {
18 token: string;
19 productName?: string;
20 quoteId?: string;
21 policyId?: string;
22 claimId?: string;
23 from?: string;
24 plan?: string;
25 frequency?: string;
26 bundle?: string;
27 subscriptionId?: string;
28 }
29
30 interface PageUrlOptions {
31 pageType: 'quote' | 'policy' | 'claim' | 'thank-you';
32 id?: string;
33 params: UrlParams;
34 }
35
36 export function buildPageUrl({ pageType, id, params }: PageUrlOptions): string {
37 const { token, productName, policyId, claimId, from, plan, frequency, bundle, subscriptionId } = params;
38
39 if (!token) {
40 throw new Error('Token is required for all URLs');
41 }
42
43 const baseUrl = window.location.origin;
44 const urlParams = new URLSearchParams();
45
46 // Always include token
47 urlParams.append('token', token);
48
49 // Include product name for consistency across all URLs
50 if (productName) {
51 urlParams.append('product', productName);
52 }
53
54 // Include bundle name for bundle funnels
55 if (bundle) {
56 urlParams.append('bundle', bundle);
57 }
58
59 // Include subscription ID for thank-you pages
60 if (subscriptionId) {
61 urlParams.append('subscriptionId', subscriptionId);
62 }
63
64 // Carry the user's chosen locale across navigations.
65 const currentLocale = getCurrentLocale();
66 if (currentLocale) {
67 urlParams.append('locale', currentLocale);
68 }
69
70 // Forward testMode across page transitions. The iframe URL is the source
71 // of truth for widget config; once the host site mounts the embed with
72 // ?testMode=true, every subsequent page must keep the flag so the policy
73 // and claim creation calls still send testMode in their request body.
74 const currentSearch = new URLSearchParams(window.location.search);
75 const currentTestMode = currentSearch.get('testMode');
76 if (currentTestMode === 'true' || currentTestMode === '1') {
77 urlParams.append('testMode', 'true');
78 }
79
80 // Forward operator "offer" mode across page transitions so the whole funnel
81 // (quote → results → policy) stays in offer mode through to the draft submit.
82 if (currentSearch.get('mode') === 'offer') {
83 urlParams.append('mode', 'offer');
84 }
85
86 // Forward the in-widget switcher opt-in. Default is hidden; only when the
87 // host explicitly passes ?showLocaleSwitcher=true (portal preview, demo
88 // page) do we propagate it so the next page also shows the dropdown.
89 const currentShowSwitcher = currentSearch.get('showLocaleSwitcher');
90 if (currentShowSwitcher === 'true') {
91 urlParams.append('showLocaleSwitcher', 'true');
92 }
93
94 // Forward country + displayCurrency so per-sale jurisdiction & charge
95 // currency persist across page transitions. apiClient re-initializes
96 // from URL on every page, so dropping them would silently revert to
97 // the product defaults.
98 const currentCountry = currentSearch.get('country');
99 if (currentCountry && /^[A-Z]{2}$/.test(currentCountry.toUpperCase())) {
100 urlParams.append('country', currentCountry.toUpperCase());
101 }
102 const currentDisplayCurrency = currentSearch.get('displayCurrency');
103 if (currentDisplayCurrency && /^[A-Z]{3}$/.test(currentDisplayCurrency.toUpperCase())) {
104 urlParams.append('displayCurrency', currentDisplayCurrency.toUpperCase());
105 }
106 const currentPricingCurrency = currentSearch.get('pricingCurrency');
107 if (currentPricingCurrency && /^[A-Z]{3}$/.test(currentPricingCurrency.toUpperCase())) {
108 urlParams.append('pricingCurrency', currentPricingCurrency.toUpperCase());
109 }
110
111 // Build URL based on page type
112 let path: string;
113
114 switch (pageType) {
115 case 'quote':
116 if (id) {
117 path = `/quote/${id}`;
118 } else {
119 path = '/quote';
120 }
121 if (from) urlParams.append('from', from);
122 if (frequency) urlParams.append('frequency', frequency);
123 break;
124
125 case 'policy':
126 if (!id) {
127 throw new Error('Policy ID is required for policy URLs');
128 }
129 path = `/policy/${id}`;
130 if (from) urlParams.append('from', from);
131 if (plan) urlParams.append('plan', plan);
132 if (frequency) urlParams.append('frequency', frequency);
133 break;
134
135 case 'claim':
136 if (id) {
137 path = `/claim/${id}`;
138 } else {
139 path = '/claim';
140 }
141 if (policyId) urlParams.append('policyId', policyId);
142 break;
143
144 case 'thank-you':
145 path = '/thank-you';
146 if (policyId) urlParams.append('policyId', policyId);
147 if (claimId) urlParams.append('claimId', claimId);
148 break;
149
150 default:
151 throw new Error(`Unsupported page type: ${pageType}`);
152 }
153
154 return `${baseUrl}${path}?${urlParams.toString()}`;
155 }
156
157 // Convenience functions for common URL patterns
158 export function buildQuoteUrl(token: string, productName: string): string {
159 return buildPageUrl({
160 pageType: 'quote',
161 params: { token, productName }
162 });
163 }
164
165 export function buildQuoteResultsUrl(quoteId: string, token: string, productName: string, frequency?: string, from?: string): string {
166 return buildPageUrl({
167 pageType: 'quote',
168 id: quoteId,
169 params: { token, productName, ...(from && { from }), ...(frequency && { frequency }) }
170 });
171 }
172
173 // Edit button on the acceptance page: bounce the customer back into the
174 // original quote funnel to change plan/fields. We pass only the opaque quoteId
175 // + token — the funnel re-hydrates the customer's values server-side from the
176 // QuoteSession, so NO PII ever appears in the URL. Falls back to a fresh quote
177 // form when the draft has no originating quote.
178 export function buildAcceptanceEditUrl(
179 token: string,
180 productName: string,
181 quoteId?: string | null,
182 frequency?: string,
183 ): string {
184 if (quoteId) {
185 return buildQuoteResultsUrl(quoteId, token, productName, frequency, 'accept');
186 }
187 return buildQuoteUrl(token, productName);
188 }
189
190 export function buildPolicyUrl(quoteId: string, token: string, productName: string, plan: string, frequency?: string): string {
191 return buildPageUrl({
192 pageType: 'policy',
193 id: quoteId,
194 params: { token, productName, from: 'quote', plan, ...(frequency && { frequency }) }
195 });
196 }
197
198 export function buildClaimUrl(token: string, productName: string, policyId: string): string {
199 return buildPageUrl({
200 pageType: 'claim',
201 params: { token, productName, policyId }
202 });
203 }
204
205 export function buildThankYouUrl(token: string, productName: string, policyId?: string, claimId?: string): string {
206 return buildPageUrl({
207 pageType: 'thank-you',
208 params: { token, productName, policyId, claimId }
209 });
210 }
211
212 export function buildBundleResultsUrl(bundleQuoteId: string, token: string, bundleName: string, frequency?: string): string {
213 return buildPageUrl({
214 pageType: 'quote',
215 id: bundleQuoteId,
216 params: { token, bundle: bundleName, from: 'bundle-select', ...(frequency && { frequency }) }
217 });
218 }
219
220 export function buildBundlePolicyUrl(bundleQuoteId: string, token: string, bundleName: string, plans: {"command":"ls -la /Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/src/store/"}total 64 drwxr-xr-x@ 10 bakker staff 320 Jun 1 08:39 . drwxr-xr-x@ 9 bakker staff 288 Jun 1 08:39 .. -rw-r--r--@ 1 bakker staff 404 Jun 1 08:39 assistantSidebarStore.ts -rw-r--r--@ 1 bakker staff 3652 Jun 1 08:39 authStore.ts -rw-r--r--@ 1 bakker staff 901 Jun 1 08:39 connectionStore.ts -rw-r--r--@ 1 bakker staff 1372 Jun 1 08:39 draftResourceStore.ts -rw-r--r--@ 1 bakker staff 2147 Jun 1 08:39 draftStore.ts -rw-r--r--@ 1 bakker staff 1203 Jun 1 08:39 notificationStore.ts -rw-r--r--@ 1 bakker staff 947 Jun 1 08:39 orgNotificationStore.ts -rw-r--r--@ 1 bakker staff 1432 Jun 1 08:39 organizationStore.ts
{"command":"find /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages -type f"}/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/sanitizeTemplateCss.test.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/sanitizeTemplateHtml.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/checkout.css /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/verifyStripeRedirect.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/providers.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/sanitizeTemplateHtml.test.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/types.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/StripeCheckout.tsx /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/MinimalSummary.tsx /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/TemplateChrome.tsx /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/MinimalSummary.css /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/checkout/sanitizeTemplateCss.ts /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/EmbedCheckout.tsx
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/src/store/authStore.ts"}1 // src/store/authStore.ts
2
3 import { create } from "zustand";
4 import { persist } from "zustand/middleware";
5
6 import { UserData } from "../../types/user";
7 import { UserOrganization, OrganizationScope } from "../../types/organization";
8
9 interface AuthState {
10 accessToken: string | null;
11 refreshToken: string | null;
12 user: UserData | null;
13 currentOrganization: UserOrganization | null;
14 availableOrganizations: UserOrganization[];
15 organizationScope: OrganizationScope | null;
16 drillDownOrgId: string | null;
17 hydrated: boolean;
18 setTokens: (accessToken: string, refreshToken: string) => void;
19 setUser: (user: UserData) => void;
20 setCurrentOrganization: (organization: UserOrganization) => void;
21 setAvailableOrganizations: (organizations: UserOrganization[]) => void;
22 setOrganizationScope: (scope: OrganizationScope | null) => void;
23 setDrillDownOrgId: (orgId: string | null) => void;
24 logout: () => void;
25 setHydrated: (hydrated: boolean) => void;
26 }
27
28 // Check if we're in a browser environment
29 const isBrowser = typeof window !== "undefined";
30
31 const useAuthStore = create<AuthState>()(
32 persist(
33 (set) => ({
34 accessToken: null,
35 refreshToken: null,
36 user: null,
37 currentOrganization: null,
38 availableOrganizations: [],
39 organizationScope: null,
40 drillDownOrgId: null,
41 hydrated: false,
42 setTokens: (accessToken, refreshToken) => set({ accessToken, refreshToken }),
43 setUser: (user) => set({ user }),
44 setCurrentOrganization: (organization) => set({ currentOrganization: organization }),
45 setAvailableOrganizations: (organizations) => set({ availableOrganizations: organizations }),
46 setOrganizationScope: (scope) => set({ organizationScope: scope }),
47 setDrillDownOrgId: (orgId) => set({ drillDownOrgId: orgId }),
48 setHydrated: (hydrated) => set({ hydrated }),
49 logout: () => {
50 if (isBrowser) {
51 // Clear any authentication-related data from localStorage
52 localStorage.removeItem("accessToken");
53 localStorage.removeItem("refreshToken");
54 localStorage.removeItem("setupToken");
55 // Clear the main auth storage
56 localStorage.removeItem("auth-storage");
57 }
58 set({
59 accessToken: null,
60 refreshToken: null,
61 user: null,
62 currentOrganization: null,
63 availableOrganizations: [],
64 organizationScope: null,
65 drillDownOrgId: null,
66 });
67 },
68 }),
69 {
70 name: "auth-storage",
71 storage: {
72 getItem: (name) => {
73 if (!isBrowser) return null;
74 const str = localStorage.getItem(name);
75 return str ? JSON.parse(str) : null;
76 },
77 setItem: (name, value) => {
78 if (isBrowser) {
79 localStorage.setItem(name, JSON.stringify(value));
80 }
81 },
82 removeItem: (name) => {
83 if (isBrowser) {
84 localStorage.removeItem(name);
85 }
86 },
87 },
88 // The "onRehydrateStorage" callback ran during store creation can cause
89 // a temporal-dead-zone error (store referenced before initialization).
90 // We instead mark the store as hydrated after creation via the fallback
91 // timer below, or explicitly in client-side effects.
92 onRehydrateStorage: () => undefined,
93 }
94 )
95 );
96
97 // Ensure hydration happens on the client side
98 if (isBrowser) {
99 // Set a fallback timer in case onRehydrateStorage doesn't fire
100 setTimeout(() => {
101 if (!useAuthStore.getState().hydrated) {
102 useAuthStore.getState().setHydrated(true);
103 }
104 }, 100);
105 }
106
107 export default useAuthStore;
108 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/src/pages/EmbedCheckout.tsx","limit":80}1 /**
2 * Provider-agnostic checkout dispatcher.
3 *
4 * Fetches the session, polls for activation, and renders the right
5 * provider-specific module based on `session.paymentProvider`. Each provider
6 * module is React.lazy-imported so the SDK (and its bundle weight) only loads
7 * for the products that selected that provider.
8 *
9 * To add a new provider:
10 * 1. Create `./checkout/<Provider>Checkout.tsx` implementing
11 * `ProviderRendererProps` from `./checkout/types`.
12 * 2. Add a branch below in `renderProvider()` that lazy-imports it.
13 * 3. Surface the matching `paymentProvider` key + `providerConfig` shape on
14 * the sherpa-api side (PaymentProvider.getEmbedConfig).
15 */
16 import React, { Suspense } from 'react';
17 import { useState, useEffect, useCallback } from 'preact/hooks';
18 import { usePostMessage } from '../hooks/usePostMessage';
19 import { extendAllowlist } from '../utils/parentOrigin';
20 import type { CheckoutSession, Phase, ProviderRendererProps } from './checkout/types';
21 import { getProviderRenderer } from './checkout/providers';
22 import TemplateChrome from './checkout/TemplateChrome';
23 import OrderSummary from '../components/OrderSummary';
24 import MinimalSummary from './checkout/MinimalSummary';
25 import { verifyStripeRedirectMatchesSession } from './checkout/verifyStripeRedirect';
26 import { SHERPA_CHECKOUT_RESUBMIT_EVENT } from '../utils/paymentNavigation';
27 import { useTranslation, useSetTranslationOverrides } from '../lib/translations';
28 import type { SupportedLocale } from '../lib/lmt';
29 import './checkout/checkout.css';
30
31 // Back link rendered above the checkout scaffold. Matches the
32 // `.top-back-link` / `.back-arrow` styling used by MultiPageForm's
33 // first-page back link (see MultiPageForm.tsx ~line 1395). Kept in this
34 // outer shell (not inside TemplateChrome) so the partner template HTML is
35 // not contaminated. Hidden when there is no prior history — a deep link
36 // straight to /checkout/:id leaves nothing to navigate back to.
37 function CheckoutBackLink() {
38 const t = useTranslation();
39 if (typeof window === 'undefined') return null;
40 if (window.history.length <= 1) return null;
41 const handleClick = (e: Event) => {
42 e.preventDefault();
43 window.history.back();
44 };
45 return (
46 <div className='sherpa-checkout__back'>
47 <a
48 href='#'
49 className='sherpa-checkout__back-link'
50 onClick={handleClick}
51 >
52 <svg
53 className='sherpa-checkout__back-arrow'
54 width='16'
55 height='16'
56 viewBox='0 0 16 16'
57 fill='none'
58 xmlns='http://www.w3.org/2000/svg'
59 aria-hidden='true'
60 >
61 <path
62 d='M10 12L6 8L10 4'
63 stroke='currentColor'
64 strokeWidth='2'
65 strokeLinecap='round'
66 strokeLinejoin='round'
67 />
68 </svg>
69 {t('Back')}
70 </a>
71 </div>
72 );
73 }
74
75 const API_URL = import.meta.env.VITE_SHERPA_API;
76 const POLL_INTERVAL_MS = 2000;
77 const POLL_MAX_ATTEMPTS = 60; // ~2 minutes
78
79 const fetchSession = async (
80 sessionId: string,{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/next.config.js"}File does not exist. Note: your current working directory is /Users/bakker/dev/gangkhar. Did you mean next.config.mjs?
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/next.config.mjs"}1 /** @type {import('next').NextConfig} */
2 import { withSentryConfig } from "@sentry/nextjs";
3
4 const nextConfig = {
5 output: "standalone",
6 outputFileTracingRoot: import.meta.dirname,
7 assetPrefix: "",
8 poweredByHeader: false,
9 transpilePackages: ["@gangkharai/chat-ui", "@gangkharai/lmt"],
10 compress: true,
11 productionBrowserSourceMaps: false,
12 images: {
13 deviceSizes: [640, 750, 828, 1080, 1200],
14 imageSizes: [16, 32, 48, 64, 96, 128, 256],
15 formats: ["image/webp"],
16 remotePatterns: [
17 {
18 protocol: "https",
19 hostname: "http2.mlstatic.com",
20 pathname: "/frontend-assets/**",
21 },
22 ],
23 },
24 compiler: {
25 removeConsole: process.env.NODE_ENV === "production",
26 reactRemoveProperties: process.env.NODE_ENV === "production",
27 },
28 staticPageGenerationTimeout: 180,
29 async headers() {
30 const baseHeaders = [
31 {
32 key: "Referrer-Policy",
33 value: "strict-origin-when-cross-origin",
34 },
35 {
36 key: "X-Content-Type-Options",
37 value: "nosniff",
38 },
39 {
40 key: "Content-Security-Policy",
41 value: [
42 "default-src 'self'",
43 "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
44 "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
45 "img-src 'self' data: https: blob: http://localhost:*",
46 "font-src 'self' data: https: https://fonts.gstatic.com",
47 "connect-src 'self' https: http://localhost:* ws://localhost:* wss://localhost:*",
48 `frame-src 'self' blob: https://maps.google.com https://www.google.com ${process.env.NEXT_PUBLIC_EMBED_HTTP || ""} ${process.env.NEXT_PUBLIC_EMBED_HTTPS || ""}`.trim(),
49 "worker-src 'self' blob:",
50 "object-src 'none'",
51 "base-uri 'self'",
52 "form-action 'self'",
53 "script-src-elem 'self' 'unsafe-inline'",
54 ].join("; "),
55 },
56 ];
57
58 return [
59 {
60 source: "/(.*)",
61 headers: baseHeaders,
62 },
63 ];
64 },
65 };
66
67 const sentryConfig = {
68 org: "gangkhar",
69 project: "javascript-nextjs",
70 silent: !process.env.CI,
71 widenClientFileUpload: true,
72 tunnelRoute: "/monitoring",
73 disableLogger: true,
74 automaticVercelMonitors: true,
75 sourcemaps: {
76 disable: process.env.ENVIRONMENT === "development",
77 },
78 };
79
80 const finalConfig = withSentryConfig(nextConfig, sentryConfig);
81
82 export default finalConfig;
83 {"command":"find /Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance -maxdepth 1 -name \"vite.config*\""}/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/vite.config.ts
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/tailwind.config.ts"}File does not exist. Note: your current working directory is /Users/bakker/dev/gangkhar. Did you mean tailwind.config.js?
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/vite.config.ts"}1 import { defineConfig } from 'vite';
2 import preact from '@preact/preset-vite';
3
4 export default defineConfig(({ mode }) => ({
5 plugins: [
6 preact({
7 babel: {
8 plugins: mode === 'production' ? [] : undefined,
9 },
10 }),
11 ],
12 resolve: {
13 alias: {
14 'react': 'preact/compat',
15 'react-dom': 'preact/compat',
16 'react/jsx-runtime': 'preact/jsx-runtime',
17 },
18 },
19 optimizeDeps: {
20 include: ['preact/compat', 'preact/hooks', 'preact/jsx-runtime'],
21 },
22 build: {
23 outDir: 'dist',
24 sourcemap: mode === 'development',
25 rollupOptions: {
26 output: {
27 manualChunks: {
28 vendor: ['preact'],
29 utils: ['./src/utils/apiClient.ts'],
30 },
31 },
32 },
33 },
34 server: {
35 port: 3112,
36 host: true,
37 proxy: {
38 '/v1/data/public': {
39 target: process.env.VITE_SHERPA_API || 'http://localhost:4000',
40 changeOrigin: true,
41 followRedirects: true,
42 },
43 },
44 },
45 }));{"file_path":"/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/tailwind.config.js"}1 import tailwindScrollbar from "tailwind-scrollbar";
2 import tailwindAnimate from "tailwindcss-animate";
3
4 /** @type {import('tailwindcss').Config} */
5 const config = {
6 darkMode: "class",
7 content: [
8 "./app/**/*.{js,ts,jsx,tsx,mdx}",
9 "./components/**/*.{js,ts,jsx,tsx,mdx}",
10 "./pages/**/*.{js,ts,jsx,tsx,mdx}",
11 ],
12 // safelist is not supported in Tailwind v4 — use @source inline() in globals.css instead
13 theme: {
14 extend: {
15 colors: {
16 background: "hsl(var(--background))",
17 foreground: "hsl(var(--foreground))",
18 card: {
19 DEFAULT: "hsl(var(--card))",
20 foreground: "hsl(var(--card-foreground))",
21 },
22 popover: {
23 DEFAULT: "hsl(var(--popover))",
24 foreground: "hsl(var(--popover-foreground))",
25 },
26 primary: {
27 DEFAULT: "hsl(var(--primary))",
28 foreground: "hsl(var(--primary-foreground))",
29 },
30 secondary: {
31 DEFAULT: "hsl(var(--secondary))",
32 foreground: "hsl(var(--secondary-foreground))",
33 },
34 muted: {
35 DEFAULT: "hsl(var(--muted))",
36 foreground: "hsl(var(--muted-foreground))",
37 },
38 accent: {
39 DEFAULT: "hsl(var(--accent))",
40 foreground: "hsl(var(--accent-foreground))",
41 },
42 destructive: {
43 DEFAULT: "hsl(var(--destructive))",
44 foreground: "hsl(var(--destructive-foreground))",
45 },
46 border: {
47 DEFAULT: "hsl(var(--border))",
48 },
49 input: "hsl(var(--input))",
50 ring: "hsl(var(--ring))",
51 chart: {
52 1: "hsl(var(--chart-1))",
53 2: "hsl(var(--chart-2))",
54 3: "hsl(var(--chart-3))",
55 4: "hsl(var(--chart-4))",
56 5: "hsl(var(--chart-5))",
57 },
58 // Customer portal brand colors (legacy)
59 "brand-primary": "var(--primary-color)",
60 "brand-secondary": "var(--secondary-color)",
61 // Portal theming colors are defined via @theme inline in globals.css
62 blue: {
63 50: "#EAF5FC",
64 100: "#D5E9F5",
65 200: "#B5D5EA",
66 300: "#8ABBDD",
67 400: "#5D99C6",
68 500: "#3A7AAB",
69 600: "#1F6AB4",
70 700: "#24496D",
71 800: "#1A2C42",
72 900: "#13202E",
73 950: "#0B1319",
74 },
75 },
76 animation: {
77 "spin-slow": "spin 1.5s linear infinite",
78 },
79 borderRadius: {
80 lg: "var(--radius)",
81 md: "calc(var(--radius) - 2px)",
82 sm: "calc(var(--radius) - 4px)",
83 },
84 },
85 },
86 plugins: [
87 tailwindAnimate,
88 tailwindScrollbar({ nocompatible: true }),
89 function ({ addUtilities }) {
90 addUtilities({
91 ".scrollbar-hide": {
92 /* Firefox */
93 "scrollbar-width": "none",
94 /* Safari and Chrome */
95 "&::-webkit-scrollbar": {
96 display: "none",
97 },
98 },
99 });
100 },
101 ],
102 };
103
104 export default config;
105 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/README.md"}1 # Sherpa Embed Widget
2
3 A secure, lightweight insurance widget system that allows customers to embed insurance quote and policy functionality directly into their websites using a hybrid iframe+script approach.
4
5 ## Key Features
6
7 - **Hybrid iframe+script embedding** for security and responsiveness
8 - **Automatic widget resizing** based on content height
9 - **Secure iframe sandbox** with sandbox restrictions
10 - **Cross-origin communication** via PostMessage API
11 - **Widget token authentication** for secure API access
12 - **Real-time quote/policy generation**
13 - **Mobile-optimized responsive design**
14 - **Event-driven architecture** for status updates
15 - **Multi-language support** (English, Spanish, Portuguese) with auto-detection
16
17 ## Technology Stack
18
19 - **Frontend**: Preact with TypeScript
20 - **CSS**: Pico CSS (minimal, semantic)
21 - **Build Tool**: Vite
22 - **Communication**: PostMessage API
23 - **Authentication**: Widget Tokens
24
25 ## Quick Start
26
27 ```bash
28 npm install
29 cp env.reference .env
30 npm run dev
31 ```
32
33 Edit `.env` and set `VITE_SHERPA_API` to your backend URL.
34
35 ### LMT Translations
36
37 The translation pipeline (`npm run translate`) uses Azure OpenAI. Set up:
38
39 1. Go to [Azure AI Foundry](https://ai.azure.com/nextgen/r/,-,,-/allresources)
40 2. Create a project and deploy **gpt-4o**
41 3. Add `AZURE_AI_API_KEY` and `AZURE_AI_URL` (pattern: `https://<your-resource>.cognitiveservices.azure.com`) to your `.env`
42
43 Translation commands:
44 - `npm run translate` - Extract and translate missing keys
45 - `npm run translate:verbose` - Verbose mode with per-file logging and cost tracking
46 - `npm run translate:rebuild` - Retranslate everything from scratch
47 - `npm run validate-translations` - Validate all translation keys are present
48
49 Supported locales: `en` (English), `es_419` (Spanish), `pt_BR` (Portuguese)
50
51 ### Testing Translations
52
53 The locale can be set via URL parameter `&locale=<code>`:
54
55 - **English:** `http://localhost:3100/quote/<quoteId>?token=<token>&product=<product>&locale=en`
56 - **Spanish:** `http://localhost:3100/quote/<quoteId>?token=<token>&product=<product>&locale=es_419`
57 - **Portuguese:** `http://localhost:3100/quote/<quoteId>?token=<token>&product=<product>&locale=pt_BR`
58
59 Without a `locale` parameter, the widget detects in this order: URL `?locale=` → `localStorage` (last user choice) → `navigator.languages` → `en`. The chosen locale is persisted to `localStorage` (`sherpa-embed-locale`) and re-appended to navigation URLs so it survives both refreshes and page transitions.
60
61 A language switcher dropdown is displayed in the widget UI by default, allowing end-users to change language at any time.
62
63 ### Embedding on a host site
64
65 To restrict the available languages on a per-product basis, configure them in Sherpa-Portal (Products → Embed tab → Language settings) or pass the URL param:
66
67 - `activeLocales=en,pt_BR` — restrict the available languages (comma-separated). Defaults to all supported locales.
68
69 The host can also drive the embed's language live via `postMessage`:
70
71 ```js
72 document.querySelector('iframe').contentWindow.postMessage(
73 { type: 'sherpa:setLocale', locale: 'pt_BR' },
74 '*'
75 );
76 ```
77
78 Accepted locale values are the same as the `?locale=` param (`en`, `es_419`, `pt_BR`). Unrecognized values are ignored.
79
80 ## Development
81
82 See `.cursor/rules/development-workflow.mdc` for complete development workflow.
83
84 Key commands:
85 - `npm run dev` - Development server (http://localhost:3100)
86 - `npm run type-check` - TypeScript validation
87 - `npm run lint` - ESLint
88 - `npm run build` - Production build
89
90 ## Widget Integration
91
92 ### Option 1: Show Pricing (with Quote ID)
93 ```html
94 <iframe
95 id="sherpa-embed"
96 src="https://embed.gangkhar.com/quote/221d6541-5c9f-4ca8-9507-2b565e5b8f25?token=your-widget-token"
97 width="100%"
98 height="600"
99 frameborder="0"
100 sandbox="allow-scripts allow-forms"
101 ></iframe>
102 <script src="https://embed.gangkhar.com/widget.js"></script>
103 ```
104
105 ### Option 2: Customer Form (no Quote ID)
106 ```html
107 <iframe
108 id="sherpa-embed"
109 src="https://embed.gangkhar.com/quote?token=your-widget-token&product=accidental_damage_test"
110 width="100%"
111 height="600"
112 frameborder="0"
113 sandbox="allow-scripts allow-forms"
114 ></iframe>
115 <script src="https://embed.gangkhar.com/widget.js"></script>
116 ```
117
118 For advanced integration examples, framework integrations, API details, and customization options, see [docs/INTEGRATION_EXAMPLES.md](docs/INTEGRATION_EXAMPLES.md).
119
120 ## Development Guidelines
121
122 All code must follow:
123 - `.cursor/rules/coding-standards.mdc` - Core coding principles
124 - `.cursor/rules/security-patterns.mdc` - Security requirements
125 - `.cursor/rules/development-workflow.mdc` - Development process
126
127 ## Testing
128
129 Create test HTML file:
130 ```html
131 <!DOCTYPE html>
132 <html>
133 <body>
134 <iframe
135 id="sherpa-embed"
136 src="http://localhost:3100/quote?token=test&product=test"
137 width="100%"
138 height="600"
139 sandbox="allow-scripts allow-forms">
140 </iframe>
141 <script src="http://localhost:3100/widget.js"></script>
142 </body>
143 </html>
144 ```
145
146 ## Deployment
147
148 Push to `development` branch. GitHub Actions handles build and deployment automatically.
149
150 ## Configuration
151
152 Environment configuration is documented in `env.reference`. Copy to `.env` and configure for your setup.
153
154 ## API Integration
155
156 The widget integrates with Sherpa API endpoints. See `src/utils/apiClient.ts` for implementation details.
157
158 ### Required Headers
159 - `X-Widget-Token`: Widget authentication token
160 - `Origin`: Widget origin
161 - `X-Parent-Domain`: Parent site domain
162
163 ## Event Handling
164
165 The widget dispatches events that parent pages can listen to:
166 - `sherpa:loaded` - Widget loaded
167 - `sherpa:quote` - Quote completed
168 - `sherpa:policy` - Policy purchased
169 - `sherpa:error` - Error occurred
170
171 ```javascript
172 document.addEventListener('sherpa:quote', (event) => {
173 console.log('Quote completed:', event.detail);
174 });
175 ```
176
177 ## Architecture
178
179 The system uses a hybrid approach that combines:
180 1. **Secure Iframe**: Contains widget content with sandbox restrictions
181 2. **Auto-resize Script**: Handles dynamic height adjustment
182 3. **PostMessage Bridge**: Secure communication between iframe and parent
183
184 ### Benefits
185
186 - **Security**: Iframe sandbox prevents XSS and other security vulnerabilities
187 - **Responsiveness**: Script enables dynamic height adjustment and mobile optimization
188 - **Cross-domain Communication**: Secure messaging between widget and parent page
189 - **Auto-resize**: Widget automatically adapts to content changes
190 - **Mobile Optimization**: Responsive design works perfectly on all devices
191
192 Built with modern web technologies for security, performance, and maintainability.
193 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/AGENTS.md","limit":150}1 # Sherpa Embed
2
3 Preact-based embeddable insurance widget running in iframes with PostMessage communication.
4
5 ## Development Workflow
6
7 ### Setup
8
9 ```bash
10 npm install
11 cp env.reference .env
12 npm run dev
13 ```
14
15 ### Commands
16
17 ```bash
18 npm run dev # Start development server (http://localhost:3100)
19 npm run type-check # TypeScript validation - MUST pass
20 npm run lint # ESLint - MUST pass
21 npm run build # Production build
22 npm run preview # Test production build
23 ```
24
25 ### Environment
26
27 - Copy `env.reference` to `.env`
28 - Set `VITE_SHERPA_API` to your backend URL
29 - Never commit `.env` files
30
31 ### Testing
32
33 Create test HTML file:
34 ```html
35 <!DOCTYPE html>
36 <html>
37 <body>
38 <iframe
39 id="sherpa-embed"
40 src="http://localhost:3100/quote?token=test&product=test"
41 width="100%"
42 height="600"
43 sandbox="allow-scripts allow-forms">
44 </iframe>
45 <script src="http://localhost:3100/widget.js"></script>
46 </body>
47 </html>
48 ```
49
50 - Check console for PostMessage events
51 - Use Network tab to verify API calls
52
53 ### Deployment
54
55 - Push to `development` branch
56 - GitHub Actions handles build and deployment
57 - Production deploys automatically via GitHub
58
59 ### Quality Gates
60
61 - Zero TypeScript errors
62 - Zero ESLint errors
63 - All security patterns followed
64
65 ## Coding Standards
66
67 ### Core Principles
68
69 - Write functional, minimal, vanilla code
70 - Prefer short, readable code over cleverness
71 - Never use default values - always require explicit values
72 - Never use `undefined` as a value
73 - Avoid `type` as a field name
74 - Throw every error - never swallow them
75 - Keep code modular and composable
76 - Professional code - no emojis or casual language
77 - Less is more - limit scope to what's asked
78
79 ### TypeScript Requirements
80
81 - Zero TypeScript errors - code must pass `npm run type-check`
82 - Use strict types from `src/types/`
83 - Type imports: `import type { TypeName } from './types'`
84 - No `any` types
85 - Define interfaces for all props and API responses
86
87 ### Error Handling
88
89 ```typescript
90 // NEVER do this
91 try {
92 const result = await apiCall();
93 return result || 'default value';
94 } catch (err) {
95 console.log('Error occurred');
96 return null;
97 }
98
99 // ALWAYS do this
100 const result = await apiCall();
101 if (!result) {
102 throw new Error('API call returned no data');
103 }
104 return result;
105 ```
106
107 ### Component Structure
108
109 ```typescript
110 import { useState } from 'preact/hooks';
111 import type { ComponentProps } from '../types/config';
112
113 interface MyComponentProps {
114 title: string;
115 onAction: (data: unknown) => void;
116 }
117
118 export default function MyComponent({ title, onAction }: MyComponentProps) {
119 const [state, setState] = useState<string>('');
120
121 return (
122 <article>
123 <h2>{title}</h2>
124 </article>
125 );
126 }
127 ```
128
129 ### File Organization
130
131 - **Components**: `src/components/ComponentName.tsx`
132 - **Hooks**: `src/hooks/useHookName.ts`
133 - **Types**: `src/types/domain.ts`
134 - **Utils**: `src/utils/utilityName.ts`
135
136 ### API Integration
137
138 - Always use `src/utils/apiClient.ts`
139 - Handle errors with hooks from `src/hooks/useApi.ts`
140 - Include TypeScript types from `src/types/api.ts`
141 - Never provide default API responses
142
143 ### Import Order
144
145 1. External libraries (preact, etc.)
146 2. Internal utilities and hooks
147 3. Internal components
148 4. Type imports (with `type` keyword)
149
150 ### PerformanceExcellent! Now I have comprehensive information. Let me create a detailed reference map for the user.
---
## COMPREHENSIVE REFERENCE MAP: Sherpa Portal (Public Cancellation/Withdrawal UI)
### 1. APP ROUTER STRUCTURE & AUTHENTICATION GATING
**Routes Location:** `/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/`
**Public Pages (No Auth Required):**
- `/login` → `/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/login/page.tsx`
- `/register` → `/app/register/page.tsx`
- `/forgot-password` → `/app/forgot-password/page.tsx`
- `/reset-password/[token]` → `/app/reset-password/[token]/page.tsx`
- `/create-account` → `/app/create-account/page.tsx`
**Authentication Gating Mechanism:**
The app uses **proxy.ts (NOT middleware.ts)** at the root level for organization-aware routing:
📄 **File:** `/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/proxy.ts` (Lines 31-136)
**Key Logic:**
```typescript
const skipPages = [
"/register",
"/create-account",
"/forgot-password",
"/reset-password",
"/two-factor-setup",
"/two-factor-verify",
"/domain-blocked",
"/api",
"/login",
"/customer-portal",
"/embed-customer-portal",
];
if (skipPages.some((page) => request.nextUrl.pathname.startsWith(page))) {
return NextResponse.next();
}
```
**Org-Based Rewriting** (Lines 98-123):
- Calls `/v1/organization?by=origin` to resolve org type
- If `portalType === "customer"` + `portalMode === "standalone"`: rewrites `/` → `/customer-portal/dashboard`
- If `portalMode === "embedded"`: uses custom template dispatcher
- Otherwise: serves authenticated admin portal
**For Your Public Cancellation Page:** You should add a new skip page (e.g., `/public/policy-withdrawal` or `/cancel-policy`) to bypass org-resolution and serve it unauthenticated.
---
### 2. API CLIENT & BASE URL CONFIGURATION
**Base URL Environment Variables:**
📄 File: `/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/.env`
```env
# Line 6-7
NEXT_PUBLIC_SHERPA_API_HTTP=http://localhost:4012
NEXT_PUBLIC_SHERPA_API_HTTPS=https://api.chris.dev.gangkhar.dev
```
**API Client Wrapper:**
📄 File: `/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/src/utils/api.ts`
**How It Works:**
- Singleton `ApiService` class with axios instance (Lines 49-381)
- Base URL resolved via `getSherpaApiUrl()` (line 56)
- **Request Interceptor** (Lines 182-256):
- Rewrites bare paths to `/v1/plus/...` (admin) or `/v1/...` (customer)
- Attaches `Authorization: Bearer <token>` header (line 245)
- Pre-checks token expiry and auto-refreshes if needed (lines 224-242)
- **Response Interceptor** (Lines 258-379):
- 401 handling: auto-refresh token once, then reject
- Broadcasts `sherpa:product-mutated` events on POST/PUT/DELETE for UI sync
**Credential-less Paths** (Lines 19-28):
```typescript
const NO_AUTH_PATHS = [
"/v1/auth/login",
"/v1/auth/customer/login",
"/v1/auth/register",
"/v1/auth/forgot-password",
"/v1/auth/refresh-token",
"/v1/auth/customer/refresh-token",
"/v1/organization",
"/v1/health/self",
];
```
**Unauthenticated Requests:**
For your public cancellation flow, add a new endpoint here if needed. Use:
```typescript
api.post("/policy/cancelPolicy/:id/preview", data, { sherpaAdmin: false });
```
---
### 3. UI COMPONENTS & DESIGN SYSTEM
**Component Library:** Radix UI + shadcn-style components, Tailwind CSS 4 + Lucide icons
**Primitive Components Location:** `/Users/bakker/dev/gangkhar/sherpa-portal-api-461-no-login-public-withdrawal-cance/app/components/ui/`
**Key Components to Reuse:**
#### Button
📄 File: `/app/components/ui/button.tsx` (Lines 11-62)
```typescript
// Example usage:
<Button variant="default" size="default">
Primary Action
</Button>
<Button variant="destructive">Cancel Policy</Button>
<Button variant="outline">Cancel</Button>
<Button variant="ghost">Link-style button</Button>
// Variants: default | destructive | outline | secondary | ghost | link | green | red | none
// Sizes: default (h-10) | sm (h-9) | lg (h-11)
```
#### Input
📄 File: `/app/components/ui/input.tsx` (Lines 18-115)
```typescript
// Example usage:
<Input
id="email"
type="email"
label="Email"
placeholder="[email protected]"
errorMessage="Invalid email"
isInvalid={hasError}
showPasswordToggle={true} // for password fields
/>
// Includes built-in label, error message, helper text, password toggle
```
#### Card
📄 File: `/app/components/ui/card.tsx` (Lines 9-63)
```typescript
// Example usage:
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
<CardDescription>Subtitle</CardDescription>
</CardHeader>
<CardContent>
Content here
</CardContent>
<CardFooter>
Actions
</CardFooter>
</Card>
```
**Toast Notifications:**
📄 File: `/app/components/Toast.tsx` (Lines 1-100)
```typescript
import { showSuccessToast, showErrorToast } from "@/app/components/Toast";
showSuccessToast("Policy cancelled successfully");
showErrorToast("Unable to cancel policy");
```
**Styling Approach:**
- Tailwind 4 with dynamic brand colors (`portal-primary`, `portal-primary-light`, etc.)
- Colors defined via CSS variables in globals.css (not present in repo yet, define in your styles)
- Lucide React icons: `import { CheckCircle, AlertTriangle } from "lucide-react";`
---
### 4. FORMS & VALIDATION
**Form Library:** `react-hook-form` (no Zod, plain validators)
**Example Form Component:**
📄 File: `/app/components/profile/PasswordChangeSection.tsx` (Lines 1-100)
```typescript
import { useForm } from "react-hook-form";
import { useTranslation } from "@gangkharai/lmt/client";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
const MyForm = () => {
const t = useTranslation();
const {
register,
handleSubmit,
watch,
formState: { errors },
reset,
} = useForm({
mode: "onChange", // validate on change
});
const watchField = watch("fieldName", "");
const onSubmit = async (data) => {
try {
const response = await api.post("/endpoint", data);
if (response.status >= 200 && response.status < 300) {
showSuccessToast("Success");
}
} catch (error) {
showErrorToast("Error");
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Input
{...register("email", {
required: "Email is required",
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: "Invalid email",
},
})}
type="email"
placeholder="Email"
isInvalid={!!errors.email}
errorMessage={errors.email?.message}
/>
<Button type="submit">Submit</Button>
</form>
);
};
```
---
### 5. EXISTING POLICY CANCELLATION UI
**Authenticated Policy Cancellation Page** (reference for your public version):
📄 File: `/app/customer-portal/policies/[policyId]/cancel/DefaultCancelPolicy.tsx` (Lines 1-567)
**Key Features to Mirror:**
- Date picker for cancellation date (Line 456-462)
- Dropdown for cancellation reason (Lines 428-446)
- Radio options for cancellation types with refund/fee display (Lines 479-540)
- Success/failure result screens (Lines 336-385)
- Back navigation and session state handling
**API Endpoints Used:**
```typescript
// Get policy by ID
await api.get(`/policies`, { sherpaAdmin: false });
// Preview cancellation options
await api.get(
`/policy/cancelPolicy/${policyId}/preview?requestedDate=${date}`,
{ sherpaAdmin: false }
);
// Execute cancellation
await api.post(`/policy/cancelPolicy/${policyId}`, {
cancelReason: string,
selectedOption: string, // e.g., "immediate", "end_of_billing_cycle"
requestedCancellationDate?: string, // only for "specific_date"
}, { sherpaAdmin: false });
```
**Cancellation Type Labels** (Lines 83-90):
```typescript
const cancellationTypeLabel = (type: string): string =>
Perfect! Now I have comprehensive understanding. Let me create a structured reference document:
## SHERPA-EMBED CODEBASE REFERENCE MAP
---
### 1. PROJECT STRUCTURE & ENTRY POINTS
**Framework:** Vite 7 + TypeScript + Preact (not React, using `preact/compat` for compatibility)
**Entry Points:**
- **HTML Bootstrap:** `/index.html` (lines 1-34)
- Mounts React/Preact to `<div id="app"></div>`
- CSP policy configured (script-src allows only `https://js.stripe.com`)
- No inline scripts in production, only hashed Vite modules
- **JavaScript Entry:** `/src/main.tsx` (lines 1-41)
- Renders `<Root />` component to `#app`
- Sets up `TranslationProvider` with locale detection
- Initializes locale switcher and sets up `postMessage` listener for live locale changes
- **App Shell:** `/src/app.tsx` (lines 1-1181)
- Core router/dispatcher component
- Handles pathname-based routing (`/checkout/sessionId`, `/quote/id`, `/policy/id`, `/claim/id`, `/thank-you`)
- Holds most state logic: quote, policy, claim, form data
- Routes mapped via `determineRouteType()` (lines 274-287)
**Dev Server:**
- Port: `3112` (configured in `vite.config.ts` line 35)
- API proxy: `POST /v1/data/public` → `VITE_SHERPA_API`
---
### 2. ROUTING & FLOW SELECTION
**Routing Mechanism:** Hybrid URL path + query params + route type state machine
**Route Types (all defined in `app.tsx:determineRouteType()`, lines 274-287):**
| Route Type | Path | Trigger | State |
|---|---|---|---|
| `quote-form` | `/quote` | `product` param | Renders `QuoteForm` → call `/quote/form` |
| `quote-results` | `/quote/{quoteId}` | `quoteId` param | Renders `AdaptiveResultsDisplay` |
| `policy-form` | `/policy/{policyId}` | `policyId` + no `pageType` | Renders `PolicyForm` → call `/policy/form` |
| `thank-you` | `/thank-you` | `pageType=thank-you` | Renders `ThankYou` component |
| `claim-form` | `/claim/{policyId}` | `pageType=claim` | Renders `ClaimForm` → call `/claim/form` |
| `claim-form-picker` | (dynamic) | Multiple claim forms available | Renders form picker; user selects one |
| `acceptance` | `/accept/{policyId}` | `pageType=accept` | Renders `AcceptancePage` |
| `bundle-select` | (none, implicit) | `bundle` param + no quote/policy | Renders `BundleProductSelect` |
| `bundle-results` | `/quote/{bundleQuoteId}` | `bundle` + `quoteId` | Renders `BundleResults` |
| `bundle-policy` | `/policy/{bundleQuoteId}` | `bundle` + `policyId` | Renders `BundlePolicyForm` |
| `bundle-thank-you` | `/thank-you` | `bundle` + `pageType=thank-you` | Renders `BundleThankYou` |
**URL Pattern Examples:**
```
http://localhost:3112/quote/my-product?token=xyz&product=my_product
http://localhost:3112/quote/abc123?token=xyz&product=my_product
http://localhost:3112/policy/abc123?token=xyz&product=my_product&plan=basic&frequency=month
http://localhost:3112/thank-you?token=xyz&policyId=xyz
http://localhost:3112/claim?token=xyz&product=my_product&policyId=xyz
```
**Config Parsing:** `useConfig()` hook (file: `/src/hooks/useConfig.ts`, lines 39-232)
- Reads `window.location.pathname` to extract page type + IDs
- Reads `URLSearchParams` for: `token`, `product`, `quoteId`, `policyId`, `claimId`, `plan`, `frequency`, `bundle`, `locale`, `testMode`, `mode`, `country`, `displayCurrency`, `pricingCurrency`, `prefill[path]`
- Required: `token` (except `pageType=thank-you` and `pageType=test`)
- Returns: `WidgetParams` object with all parsed values
**Navigation Flow (internal):**
```
Quote Form (submit)
→ createQuote() call
→ window.location.href = buildQuoteResultsUrl()
→ /quote/{quoteId} loads
Quote Results (purchase click)
→ Select plan + frequency
→ window.location.href = buildPolicyUrl()
→ /policy/{quoteId} loads (note: quoteId in URL, not policyId)
Policy Form (submit)
→ Create policy via API
→ window.location.href = buildThankYouUrl()
→ /thank-you loads
```
---
### 3. API CLIENT & AUTHENTICATION
**API Client Location:** `/src/utils/apiClient.ts` (class `ApiClient`, instantiated as `export const apiClient`)
**Base URL:**
- Environment variable: `VITE_SHERPA_API` (default in `.env`: `http://localhost:4012`; reference: `http://localhost:4000`)
- Env file: `/Users/bakker/dev/gangkhar/sherpa-embed-api-461-no-login-public-withdrawal-cance/.env`
**Authentication (unauthenticated/public):**
- Widget is **NO-LOGIN** mode per branch name
- Header: `X-Widget-Token: {token}` (lines 106)
- No user password/session required
- All calls include `Origin` + optional `X-Parent-Domain` headers (lines 110-113)
**Fetch Wrapper:** `request()` method (lines 96-151)
```typescript
private async request(endpoint: string, options: ApiRequestOptions) {
const url = `${this.baseUrl}/v1${endpoint}`;
const headers = {
'Content-Type': 'application/json',
'X-Widget-Token': this.widgetToken,
'Origin': window.location.origin,
'X-Parent-Domain': this.parentDomain || '',
};
const response = await fetch(url, {
method: options.method,
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
});
// Error handling: parses `{status: "error", error: {message: "..."}}` format
return response.json();
}
```
**Key API Endpoints (from lines 153-400+):**
- `GET /v1/quote/{quoteId}` → `getQuote()`
- `POST /v1/quote/form?productName=X` → `getQuoteForm()` (returns `FormApiResponse`)
- `POST /v1/quote` → `createQuote(productName, params)`
- `GET /v1/policy/form?quoteId=X` → `getPolicyForm()`
- `POST /v1/policy` → `createPolicy()`
- `GET /v1/claim/form?productName=X&policyId=Y` → `getClaimForm()`
- `GET /v1/claim/forms?productName=X&policyId=Y` → `listClaimForms()` (returns array of eligible claim forms)
- `POST /v1/claim` → `submitClaim()`
- `GET /v1/policy-acceptance/{policyId}` → `getPolicyAcceptance()` (returns `AcceptanceView`)
- `POST /v1/policy-acceptance/{policyId}/accept` → `acceptPolicy()`
**Currency/Country Context:**
- Methods `setCountry()`, `setDisplayCurrency()`, `setPricingCurrency()` (lines 66-76) auto-inject into request bodies
- Survives page navigations via URL params
---
### 4. UI / COMPONENTS / STYLING
**Styling Approach:**
- Base CSS framework: **Pico CSS** (`@picocss/pico/css/pico.min.css`, imported in `app.tsx:5`)
- Custom inline styles in `app.tsx:1011-1177` (defines CSS variables, Pico overrides, locale switcher, error banner, phone input)
- **No Tailwind** — uses semantic HTML + Pico defaults
**Component Architecture:**
**Step/Flow Components:**
- `QuoteForm.tsx` (lines 1-91): Renders form from `FormApiResponse`, calls `createQuote()`, navigates to results
- `ResultsDisplay.tsx` (76KB): Renders pricing results, plan/frequency selection, purchase button
- `PolicyForm.tsx` (partial read): Form for policy details (deductible, coverage selections), calls `createPolicy()`, payment flow
- `ClaimForm.tsx`: Form for claim submission
- `ThankYou.tsx` (partial read): Success page with template support, back button, claim filing link
- `AcceptancePage.tsx`: Policy acceptance checklist (terms, params, coverage, pricing)
**Form Infrastructure:**
- `AdaptiveForm.tsx`: Generic form renderer for quote/policy/claim
- Consumes `FormApiResponse` (pages, categories, parameters)
- Handles multi-page navigation, prefill, validation
- `MultiPageForm.tsx` (73KB): Core multi-page form logic
- `CustomizableMultipageForm.tsx`: Wraps MultiPageForm with custom templates
- `CustomInput.tsx`, `ControlledCustomInput.tsx`: Individual form fields
- Location autocomplete, phone input, address fields
**Reusable Components:**
- `OrderSummary.tsx` (lines 1-12KB): Displays plan name, premium, frequency, coverage selections
- `LocaleSwitcher.tsx`: Language dropdown (hidden by default in iframes)
- `ErrorBoundary.tsx`: Catches React errors, renders fallback UI
- `TermsAndConditionsDisplay.tsx`: Renders inline/popup terms
**Representative Screen/Step Component:**
**QuoteForm.tsx** (lines 1-91):
```typescript
export default function QuoteForm({
quoteData,
formData,
onCompletComprehensive maps acquired. Poll migrate + read key implementation files.
{"command":"curl -fsS \"http://localhost:7777/api/api-action?path=/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance\" 2>&1; echo \"\"","description":"Poll API migrate status"}{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/emailService.ts"}{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/crypto.ts"}{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/rateLimit.ts"}{"path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance","status":"idle","tail":""}1 import type { FastifyRequest } from 'fastify';
2 import type { RateLimitOptions, errorResponseBuilderContext } from '@fastify/rate-limit';
3 import { extractClientIp } from './ip.js';
4
5 /**
6 * Creates rate limit configuration for route-level rate limiting.
7 *
8 * @param max - Maximum number of requests allowed in the time window
9 * @param timeWindowMs - Time window in milliseconds
10 * @returns Rate limit configuration object
11 */
12 export function createRateLimitConfig(max: number, timeWindowMs: number): RateLimitOptions {
13 return {
14 max,
15 timeWindow: timeWindowMs,
16 keyGenerator: (request: FastifyRequest): string => {
17 // Use client IP as the key for rate limiting
18 // This ensures rate limiting is applied per client, not globally
19 return extractClientIp(request);
20 },
21 errorResponseBuilder: (request: FastifyRequest, context: errorResponseBuilderContext) => {
22 return {
23 error: 'Too Many Requests',
24 message: `Rate limit exceeded. Maximum ${max} requests per ${timeWindowMs / 1000} seconds allowed.`,
25 limit: context.max,
26 timeWindow: timeWindowMs / 1000,
27 reset: new Date(Date.now() + context.ttl),
28 };
29 },
30 enableDraftSpec: true, // Enable draft spec for better error messages
31 };
32 }
33
34 /**
35 * Default rate limit configuration for address autocomplete endpoint.
36 * Limits to 10 requests per minute per IP address.
37 */
38 export const autocompleteRateLimit = createRateLimitConfig(10, 60 * 1000);
39 1 import nodemailer from 'nodemailer';
2 import { getRequired } from '../../lib.js';
3 import logger from '@/logger.js';
4
5 const SHERPA_ENV = getRequired('SHERPA_ENV');
6 const MAILCATCHER_HOST = getRequired('MAILCATCHER_HOST');
7
8 let transporter: nodemailer.Transporter | null = null;
9
10 const createTransporter = async (): Promise<nodemailer.Transporter | null> => {
11 if (SHERPA_ENV === 'production') {
12 return nodemailer.createTransport({
13 service: 'SendGrid',
14 auth: {
15 user: process.env.SENDGRID_USER,
16 pass: process.env.SENDGRID_PASS,
17 },
18 });
19 } else {
20 // Use mailcatcher for development
21 return nodemailer.createTransport({
22 host: MAILCATCHER_HOST,
23 port: 1025,
24 secure: false,
25 ignoreTLS: true,
26 connectionTimeout: 5000,
27 greetingTimeout: 5000,
28 });
29 }
30 };
31
32 export interface EmailAttachment {
33 filename: string;
34 content: Buffer;
35 contentType: string;
36 }
37
38 const DEFAULT_FROM = '"Sherpa Insurance" <[email protected]>';
39
40 export const sendEmail = async (
41 to: string,
42 subject: string,
43 text: string,
44 html: string,
45 attachments?: EmailAttachment[],
46 from?: string
47 ): Promise<boolean> => {
48 try {
49 if (!transporter) {
50 transporter = await createTransporter();
51 }
52
53 if (!transporter) {
54 logger.error('Email transporter not available');
55 return false;
56 }
57
58 const mailOptions: nodemailer.SendMailOptions = {
59 from: from || DEFAULT_FROM,
60 to,
61 subject,
62 text,
63 html,
64 };
65
66 if (attachments && attachments.length > 0) {
67 mailOptions.attachments = attachments.map(attachment => ({
68 filename: attachment.filename,
69 content: attachment.content,
70 contentType: attachment.contentType,
71 }));
72 }
73
74 const info = await transporter.sendMail(mailOptions);
75 logger.info({ messageId: info.messageId, to, subject }, 'Email sent successfully:');
76
77 return true;
78 } catch (error) {
79 const errorInfo: Record<string, unknown> = {
80 error: error instanceof Error ? error.message : String(error),
81 stack: error instanceof Error ? error.stack : undefined,
82 };
83
84 if (error instanceof Error && 'errno' in error) {
85 errorInfo.errno = (error as Error & { errno: unknown }).errno;
86 }
87 if (error instanceof Error && 'code' in error) {
88 errorInfo.code = (error as Error & { code: unknown }).code;
89 }
90 if (error instanceof Error && 'syscall' in error) {
91 errorInfo.syscall = (error as Error & { syscall: unknown }).syscall;
92 }
93
94 logger.error(errorInfo, 'Detailed email sending error:');
95
96 return false;
97 }
98 };
99
100 export const resetTransporter = (): void => {
101 transporter = null;
102 };
103 1 import crypto from 'crypto';
2 import nodeCrypto from 'node:crypto';
3 import bcrypt from 'bcryptjs';
4 import jwt from 'jsonwebtoken';
5 import type { StringValue } from 'ms';
6
7 /**
8 * Hash a client secret using bcrypt
9 * @param {string} clientSecret The plain text client secret
10 * @returns {Promise<string>} Hashed client secret
11 */
12 export const hashClientSecret = async (clientSecret: string) => {
13 // Use a cost factor of 10 for production use
14 // For higher security, increase to 12 or more, but be aware of performance implications
15 const saltRounds = 10;
16 return await bcrypt.hash(clientSecret, saltRounds);
17 };
18
19 /**
20 * Generate a secure random client secret
21 * @param {number} length Length of the client secret
22 * @param {string} prefix Prefix to add to the client secret
23 * @returns {string} A secure random client secret
24 */
25 export const generateClientSecret = (length: number, prefix: string) => {
26 const bytes = nodeCrypto.randomBytes(length);
27 return `${prefix}${bytes.toString('hex')}`;
28 };
29
30 /**
31 * Generate a secure random client ID
32 * @param {number} length Length of the client ID
33 * @param {string} prefix Prefix to add to the client ID
34 * @returns {string} A secure random client ID
35 */
36 export const generateClientId = (length: number, prefix: string) => {
37 const bytes = nodeCrypto.randomBytes(length);
38 return `${prefix}${bytes.toString('hex')}`;
39 };
40
41 // Generate a secure random reset token
42 export function generateResetToken(): string {
43 return crypto.randomBytes(32).toString('hex');
44 }
45
46 // Generate a secure random string
47 export function generateRandomString(length: number): string {
48 return crypto.randomBytes(length).toString('hex');
49 }
50
51 // Hash a password with bcrypt
52 export async function hashPassword(password: string): Promise<string> {
53 return bcrypt.hash(password, 10);
54 }
55
56 // Compare password with hash
57 export async function comparePassword(password: string, hash: string): Promise<boolean> {
58 return bcrypt.compare(password, hash);
59 }
60
61 // Compare client secret with hash
62 export async function compareClientSecret(clientSecret: string, hash: string): Promise<boolean> {
63 return bcrypt.compare(clientSecret, hash);
64 }
65
66 // Generate a secure JWT token
67 export function generateJWT(payload: object | string, secret: string, expiresIn: StringValue | number): string {
68 return jwt.sign(payload, secret, { expiresIn });
69 }
70
71 // Verify JWT token
72 export function verifyJWT(token: string, secret: string): unknown {
73 return jwt.verify(token, secret);
74 }
75
76 // Encrypt text using AES-256-GCM with JWT_SECRET as key
77 export function encrypt(text: string, secretKey: string): string {
78 const algorithm = 'aes-256-gcm';
79 const key = crypto.scryptSync(secretKey, 'salt', 32);
80 const iv = crypto.randomBytes(16);
81 const cipher = crypto.createCipheriv(algorithm, key, iv);
82
83 let encrypted = cipher.update(text, 'utf8', 'hex');
84 encrypted += cipher.final('hex');
85
86 const authTag = cipher.getAuthTag();
87
88 return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
89 }
90
91 // Decrypt text using AES-256-GCM with JWT_SECRET as key
92 export function decrypt(encryptedText: string, secretKey: string): string {
93 const algorithm = 'aes-256-gcm';
94 const key = crypto.scryptSync(secretKey, 'salt', 32);
95
96 const [ivHex, authTagHex, encrypted] = encryptedText.split(':');
97 const iv = Buffer.from(ivHex, 'hex');
98 const authTag = Buffer.from(authTagHex, 'hex');
99
100 const decipher = crypto.createDecipheriv(algorithm, key, iv);
101 decipher.setAuthTag(authTag);
102
103 let decrypted = decipher.update(encrypted, 'hex', 'utf8');
104 decrypted += decipher.final('utf8');
105
106 return decrypted;
107 }
108 Migrate shows "idle" — ambiguous. Re-poll migrate + read cancellation/route internals in parallel.
{"command":"sleep 5; echo \"=== migrate status ===\"; curl -fsS \"http://localhost:7777/api/api-action?path=/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance\" 2>&1; echo \"\"; echo \"=== migrate log tail ===\"; tail -25 /var/folders/pr/7k4xc3v1715_0zbbw8tgwl5r0000gn/T/worktree-dashboard-logs/sherpa-api-api-461-no-login-public-withdrawal-cance-migrate.log 2>&1","description":"Re-poll migrate and read its log"}{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/cancellationService.ts","limit":160}{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes.ts"}=== migrate status ===
{"path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance","status":"idle","tail":""}
=== migrate log tail ===
[start 2026-06-01T15:43:48.611Z] docker compose exec -T api npm run db:migrate:up
> Sherpa [email protected] db:migrate:up
> tsx src/db/migrate.ts up1 import type { FastifyInstance } from 'fastify';
2 import { getRequired } from '../lib.js';
3 import packageJson from '../package.json' with { type: 'json' };
4 import {
5 anotherSherpaSchema,
6 handleAnotherSherpa,
7 } from '@/domains/lens/controllers/another-sherpa.js';
8 import { handleLensHealth } from '@/domains/lens/controllers/health.js';
9 import { handleHelloSherpa, helloSherpaSchema } from '@/domains/lens/controllers/hello-sherpa.js';
10 import {
11 handleQuote,
12 handleQuoteDev,
13 handleQuoteSignal,
14 quoteSchema,
15 quoteDevSchema,
16 quoteSignalSchema,
17 } from '@/domains/lens/controllers/quote.js';
18 import {
19 handleSimulationComplete,
20 simulationCompleteSchema,
21 } from '@/domains/lens/controllers/simulation-complete.js';
22 import {
23 handleSimulationDynamic,
24 simulationDynamicSchema,
25 } from '@/domains/lens/controllers/simulation-dynamic.js';
26 import {
27 handleSimulationHistogramsLoans,
28 simulationHistogramsLoansSchema,
29 } from '@/domains/lens/controllers/simulation-histograms-loans.js';
30 import {
31 handleSimulationStages,
32 simulationStagesSchema,
33 } from '@/domains/lens/controllers/simulation-stages.js';
34 import {
35 getTaskRequestById,
36 getTaskRequestSchema,
37 } from '@/domains/lens/controllers/task-requests.js';
38 import {
39 getTaskOutputs,
40 getTaskOutputsSchema,
41 getTaskOutputFileRedirect,
42 getTaskOutputFileRedirectSchema,
43 getTaskOutputFileUrl,
44 getTaskOutputFileUrlSchema,
45 } from '@/domains/lens/controllers/task-results/outputs.js';
46 import {
47 createTaskResult,
48 createTaskResultSchema,
49 getTaskResultById,
50 getTaskResultSchema,
51 } from '@/domains/lens/controllers/task-results.js';
52 import { getTaskDataFiles, getTaskDataFilesSchema } from '@/domains/lens/controllers/tasks/data.js';
53 import {
54 handleModelReset,
55 modelResetSchema,
56 } from '@/domains/lens/controllers/tasks/models/model.js';
57 import {
58 dataStatsToolSchema,
59 handleDataStatsTool,
60 handleDataStatsToolExtended,
61 } from '@/domains/lens/controllers/tools/data-stats.js';
62
63 // Optimization types
64 import { routeGetV1LensOptimizationTypes } from '@/domains/lens/optimizations/routes/get-optimization-types.js';
65
66 // Commission routes
67 import { routePostV1LensOptimizationsCommission } from '@/domains/lens/optimizations/commission/routes/post-optimization.js';
68 import { routeGetV1LensOptimizationsCommission } from '@/domains/lens/optimizations/commission/routes/get-optimizations.js';
69 import { routeGetV1LensOptimizationsCommissionId } from '@/domains/lens/optimizations/commission/routes/get-optimization-[id].js';
70 import { routePatchV1LensOptimizationsCommissionId } from '@/domains/lens/optimizations/commission/routes/patch-optimization-[id].js';
71 import { routeDeleteV1LensOptimizationsCommissionId } from '@/domains/lens/optimizations/commission/routes/delete-optimization-[id].js';
72 import { routePostV1LensOptimizationsModelsCommission } from '@/domains/lens/optimizations/commission/lm-models/routes/post-model.js';
73 import { routeGetV1LensOptimizationsModelsCommission } from '@/domains/lens/optimizations/commission/lm-models/routes/get-models.js';
74 import { routeGetV1LensOptimizationsModelsCommissionId } from '@/domains/lens/optimizations/commission/lm-models/routes/get-model-[id].js';
75 import { routePostV1LensOptimizationsCommissionIdStages } from '@/domains/lens/optimizations/commission/stages/routes/post-stage.js';
76 import { routeGetV1LensOptimizationsCommissionIdStages } from '@/domains/lens/optimizations/commission/stages/routes/get-stages.js';
77 import { routeGetV1LensOptimizationsCommissionIdStagesId } from '@/domains/lens/optimizations/commission/stages/routes/get-stage-[id].js';
78 import { routeGetV1LensOptimizationsCommissionIdActivations } from '@/domains/lens/optimizations/commission/stages/routes/get-stage-activations.js';
79 import { routePostV1LensCommissionQuote } from '@/domains/lens/optimizations/commission/quote/routes/post-quote.js';
80 import { routeGetV1LensOptimizationsCommissionIdQuotes } from '@/domains/lens/optimizations/commission/quote/routes/get-quotes.js';
81 import { routePostV1LensCommissionGenerateBatch } from '@/domains/lens/optimizations/commission/batch/routes/post-generate-batch.js';
82 import { routeDeleteV1LensCommissionBatchEvents } from '@/domains/lens/optimizations/commission/batch/routes/delete-batch-events.js';
83
84 // Synthetic client (admin-driven simulated traffic for bandit convergence validation)
85 import { routeGetV1LensSyntheticClientTaskShape } from '@/domains/lens/synthetic-client/routes/get-task-shape.js';
86 import { routeGetV1LensSyntheticClientTaskPosterior } from '@/domains/lens/synthetic-client/routes/get-task-posterior.js';
87 import { routeGetV1LensSyntheticClientTaskHealth } from '@/domains/lens/synthetic-client/routes/get-task-health.js';
88 import { routeGetV1LensSyntheticClientTaskClusterHealth } from '@/domains/lens/synthetic-client/routes/get-task-cluster-health.js';
89 import { routeGetV1LensSyntheticClientPropensityCoherence } from '@/domains/lens/synthetic-client/routes/get-propensity-coherence.js';
90 import { routeGetV1LensSyntheticClientTaskSuggestedConfig } from '@/domains/lens/synthetic-client/routes/get-task-suggested-config.js';
91 import { routePostV1LensSyntheticClientBanditReset } from '@/domains/lens/synthetic-client/routes/post-bandit-reset.js';
92 import { routeGetV1LensSyntheticClientMetrics } from '@/domains/lens/synthetic-client/routes/get-metrics.js';
93
94 // Persistent-client routes
95 import { routePostV1LensSyntheticClientClients } from '@/domains/lens/synthetic-client/routes/post-clients.js';
96 import { routeGetV1LensSyntheticClientClient } from '@/domains/lens/synthetic-client/routes/get-client.js';
97 import { routeGetV1LensSyntheticClientByOptimization } from '@/domains/lens/synthetic-client/routes/get-by-optimization.js';
98 import { routePatchV1LensSyntheticClientClient } from '@/domains/lens/synthetic-client/routes/patch-client.js';
99 import { routePostV1LensSyntheticClientActions, routePostV1LensSyntheticClientRate } from '@/domains/lens/synthetic-client/routes/post-actions.js';
100 import { routeDeleteV1LensSyntheticClientClient } from '@/domains/lens/synthetic-client/routes/delete-client.js';
101 import { routeGetV1LensSyntheticClientEventsStream } from '@/domains/lens/synthetic-client/routes/get-events-stream.js';
102 import { routeGetV1LensSyntheticClientErrors } from '@/domains/lens/synthetic-client/routes/get-errors.js';
103
104 // Journey routes
105 import { routePostV1LensJourneyEvents } from '@/domains/lens/journey/routes/post-journey-events.js';
106 import { routeGetV1LensJourneyEvents } from '@/domains/lens/journey/routes/get-journey-events.js';
107 import { routeGetV1LensJourneyEventsArrow } from '@/domains/lens/journey/routes/get-journey-events-arrow.js';
108 import { routeGetV1LensJourneyEventsBatch } from '@/domains/lens/journey/routes/get-journey-events-batch.js';
109 import { routeGetV1LensJourneySSE } from '@/domains/lens/journey/routes/get-journey-sse.js';
110 import { routeGetV1LensJourneyDevDump } from '@/domains/lens/journey/routes/get-journey-dev-dump.js';
111
112 import { StatusError } from '@/error/error.js';
113 import logger from '@/logger.js';
114 import { plusRoutes } from '@/routes/plus/index.js';
115 import { authRoutes } from '@/routes/authRoutes.js';
116 import { azureAdRoutes } from '@/routes/azureAdRoutes.js';
117 import { claimRoutes } from '@/routes/claimRoutes.js';
118 import { customerPortalRoutes } from '@/routes/customerPortalRoutes.js';
119 import { customerSelfRoutes } from '@/routes/customerSelfRoutes.js';
120 import { customerTemplateRoutes } from '@/routes/customerTemplateRoutes.js';
121
122 import { dataModelRoutes } from '@/routes/dataModelRoutes.js';
123 import { funnelTemplatesRoutes } from '@/routes/funnelTemplatesRoutes.js';
124 import { funnel1 import { CancellationTerms, RefundType, CancellationPeriodType } from '@/models/CancellationTerms.js';
2 import { PolicyOrder } from '@/models/PolicyOrder.js';
3 import { Product } from '@/models/Product.js';
4 import { getBusinessDate } from '@/utils/dateUtils.js';
5 import { translateTemplateString } from '@/services/templateI18nService.js';
6
7 /**
8 * Localize a fixed description template, then substitute `{{placeholders}}`.
9 * Cancellation descriptions are API-authored dynamic copy, so they belong in
10 * the API LMT dictionary (the templates below are registered as source strings
11 * in `translations/runtime-strings.ts`). Unknown locales / untranslated keys
12 * fall back to the English source.
13 */
14 function tr(
15 locale: string,
16 template: string,
17 params: Record<string, string | number> = {},
18 ): string {
19 let out = translateTemplateString(template, locale, null);
20 for (const [key, value] of Object.entries(params)) {
21 out = out.split(`{{${key}}}`).join(String(value));
22 }
23 return out;
24 }
25
26 export interface CancellationCalculation {
27 effectiveCancellationDate: string;
28 newExpiryDate: string;
29 refundAmount: number;
30 cancellationFee: number;
31 netRefund: number;
32 isWithinGracePeriod: boolean;
33 daysFromInception: number;
34 description: string;
35 calculationDetails: Record<string, unknown>;
36 }
37
38 export interface CancellationOption {
39 cancellationType: 'immediate' | 'end_of_billing_cycle' | 'specific_date' | 'earliest_allowed';
40 effectiveDate: string;
41 newExpiryDate: string;
42 refundAmount: number;
43 cancellationFee: number;
44 netRefund: number;
45 description: string;
46 isRecommended: boolean;
47 isWithinGracePeriod: boolean;
48 calculationDetails: Record<string, unknown>;
49 }
50
51 export interface CancellationPreview {
52 availableOptions: CancellationOption[];
53 recommendedOption: string;
54 currentPolicy: {
55 policyId: string;
56 inceptionDate: string;
57 currentExpiryDate: string | null;
58 paymentFrequency: string | null;
59 nextBillingDate?: string;
60 gracePeriodEndsOn: string;
61 daysFromInception: number;
62 };
63 requestedDate: string;
64 }
65
66 function addDays(dateString: string, days: number): string {
67 const date = new Date(dateString);
68 date.setDate(date.getDate() + days);
69 return date.toISOString().split('T')[0];
70 }
71
72 function extractPremiumFromInsurance(policyOrder: PolicyOrder): number {
73 // Use the dedicated premiumWithTax column if available
74 if (policyOrder.premiumWithTax !== null && policyOrder.premiumWithTax !== undefined) {
75 return Number(policyOrder.premiumWithTax);
76 }
77
78 // Fallback to extracting from insurance JSON (for backward compatibility)
79 if (!policyOrder.insurance || typeof policyOrder.insurance !== 'object') {
80 return 0;
81 }
82
83 const insuranceData = policyOrder.insurance as Record<string, unknown>;
84 const productNames = Object.keys(insuranceData);
85
86 for (const productName of productNames) {
87 const productData = insuranceData[productName] as Record<string, unknown>;
88 if (productData && typeof productData === 'object') {
89 const planNames = Object.keys(productData);
90 for (const planName of planNames) {
91 const planData = productData[planName] as Record<string, unknown>;
92 if (planData && typeof planData === 'object' && planData.premiumsByFrequency) {
93 const premiumsByFrequency = planData.premiumsByFrequency as Record<string, unknown>;
94 if (premiumsByFrequency && typeof premiumsByFrequency === 'object') {
95 const frequencyKey = Object.keys(premiumsByFrequency)[0];
96 if (frequencyKey) {
97 const premiumData = premiumsByFrequency[frequencyKey] as Record<string, unknown>;
98 if (premiumData && typeof premiumData === 'object' && premiumData.premiumWithTax) {
99 return parseFloat(String(premiumData.premiumWithTax));
100 }
101 }
102 }
103 }
104 }
105 }
106 }
107
108 return 0;
109 }
110
111 function getFirstOfNextMonth(dateString: string): string {
112 const date = new Date(dateString);
113 const nextMonth = new Date(date.getFullYear(), date.getMonth() + 1, 1);
114 return nextMonth.toISOString().split('T')[0];
115 }
116
117 function calculateDaysBetween(startDate: string, endDate: string): number {
118 const start = new Date(startDate);
119 const end = new Date(endDate);
120 const diffTime = Math.abs(end.getTime() - start.getTime());
121 return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
122 }
123
124 export async function previewCancellationOptions(
125 policyOrder: PolicyOrder,
126 requestedCancellationDate: string,
127 timezone: string,
128 locale: string = 'en',
129 ): Promise<CancellationPreview> {
130 if (!policyOrder.paymentFrequency) {
131 throw new Error('Payment frequency is required for cancellation calculation');
132 }
133
134 const organizationId = policyOrder.organizationId;
135 const product = await Product.findOne({ organizationId }, {
136 where: { productName: policyOrder.productName }
137 });
138 if (!product) {
139 throw new Error('Product not found');
140 }
141
142 const cancellationTerms = await CancellationTerms.findOne({ organizationId }, {
143 where: {
144 productId: product.id,
145 paymentFrequency: policyOrder.paymentFrequency,
146 active: true,
147 },
148 });
149
150 if (!cancellationTerms) {
151 throw new Error('No cancellation terms found for this product and payment frequency');
152 }
153
154 const inceptionDate = policyOrder.inceptionDate;
155 const originalExpiryDate = policyOrder.expiryDate;
156 const today = getBusinessDate(timezone);
157 const daysFromInception = calculateDaysBetween(inceptionDate, today);
158 const gracePeriodEndsOn = addDays(inceptionDate, cancellationTerms.gracePeriodDays);
159 const isWithinGracePeriod = daysFromInception <= cancellationTerms.gracePeriodDays;
160 Migrate started (log shows banner). Real gate = health 200. Restart API + read cancel execution logic, policy/customer name fields, recent migration.
{"command":"curl -fsS -X POST http://localhost:7777/api/restart -H 'Content-Type: application/json' -d '{\"paths\":[\"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance\"]}' --max-time 180 2>&1; echo \"\"; echo \"=== health after restart ===\"; curl -fsS -o /dev/null -w \"%{http_code}\" http://localhost:4012/health 2>&1; echo \"\"","description":"Restart API and check health"}{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/policyOrderController.ts","offset":1640,"limit":340}{"pattern":"surname|lastName|firstName|policyNumber|customerEmail|\\bemail\\b","path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_PolicyOrder.ts","output_mode":"content","-n":true}{"results":[{"path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance","ok":true,"freedPort":4012,"readyMs":26067}]}
=== health after restart ===
curl: (22) The requested URL returned error: 404
4041640 ]),
1641 recommendedOption: 'immediate',
1642 currentPolicy: {
1643 policyId: policyOrder.policyId,
1644 inceptionDate: policyOrder.inceptionDate,
1645 currentExpiryDate: policyOrder.expiryDate,
1646 paymentFrequency: policyOrder.paymentFrequency as string,
1647 gracePeriodEndsOn: policyOrder.inceptionDate,
1648 daysFromInception: 0,
1649 },
1650 requestedDate: requestedCancellationDate,
1651 };
1652 }
1653 throw error;
1654 }
1655 }
1656
1657 export async function cancelPolicy(
1658 request: FastifyRequest<{
1659 Params: { policyId: string };
1660 Body: {
1661 cancelReason: string;
1662 selectedOption: string;
1663 requestedCancellationDate?: string;
1664 cancelledBy?: 'customer' | 'admin';
1665 };
1666 }>
1667 ) {
1668 const { policyId } = request.params;
1669 const { cancelReason, selectedOption, requestedCancellationDate, cancelledBy } = request.body;
1670 const organizationId = request.ident?.org?.id;
1671
1672 if (!organizationId) {
1673 throw new StatusError(400, 'Organization context required');
1674 }
1675
1676 if (!cancelReason) {
1677 throw new StatusError(400, 'Cancel reason is required');
1678 }
1679
1680 if (!selectedOption) {
1681 throw new StatusError(400, 'Selected cancellation option is required');
1682 }
1683
1684 const cancellableStatusCodes = await getCodesForCategories(organizationId, [
1685 PolicyStatusLifecycleCategory.ACTIVE,
1686 PolicyStatusLifecycleCategory.SUSPENDED,
1687 ]);
1688 const cancelledStatusCode = await getDefaultCodeForCategory(organizationId, PolicyStatusLifecycleCategory.CANCELLED);
1689 const supersededStatusCode = await getDefaultCodeForCategory(organizationId, PolicyStatusLifecycleCategory.SUPERSEDED);
1690
1691 const validCancelReasons = Object.values(CancelReason);
1692 if (!validCancelReasons.includes(cancelReason as CancelReason)) {
1693 throw new StatusError(400, 'Invalid cancel reason');
1694 }
1695
1696 const validOptions = ['immediate', 'end_of_billing_cycle', 'specific_date', 'earliest_allowed'];
1697 if (!validOptions.includes(selectedOption)) {
1698 throw new StatusError(400, 'Invalid cancellation option');
1699 }
1700
1701 const requestedPolicy = await PolicyOrderModel.findOne(request, {
1702 where: { policyId },
1703 include: [{ model: Client.raw, as: 'client', where: { organizationId }, required: false }],
1704 });
1705
1706 if (!requestedPolicy) {
1707 throw new StatusError(404, 'Policy order not found');
1708 }
1709
1710 const rootPolicyId = requestedPolicy.rootPolicyId || requestedPolicy.policyId;
1711
1712 // Suspended policies can be cancelled directly without reinstating first.
1713 // This is intentional: a customer choosing to terminate while paused shouldn't be
1714 // forced through reinstate-then-cancel. Endorsements/renewals remain blocked while suspended.
1715 const policyOrder = await PolicyOrderModel.findOne(request, {
1716 where: {
1717 [Op.or]: [{ rootPolicyId }, { policyId: rootPolicyId }],
1718 status: { [Op.in]: cancellableStatusCodes },
1719 },
1720 include: [{ model: Client.raw, as: 'client', where: { organizationId }, required: false }],
1721 order: [['version', 'DESC']],
1722 });
1723
1724 if (!policyOrder) {
1725 throw new StatusError(400, 'No active or suspended policy version found to cancel');
1726 }
1727
1728 const timezone = request.ident!.org.timezone;
1729 const today = getBusinessDate(timezone);
1730 const cancellationDate = requestedCancellationDate || today;
1731
1732 let cancellationResult: Record<string, unknown>;
1733
1734 try {
1735 const { previewCancellationOptions } = await import('@/services/cancellationService.js');
1736 const locale = getLocaleFromHeader(request) ?? 'en';
1737 const preview = await previewCancellationOptions(policyOrder, cancellationDate, timezone, locale);
1738
1739 const selectedCancellation = preview.availableOptions.find(
1740 (option) => option.cancellationType === selectedOption
1741 );
1742
1743 if (!selectedCancellation) {
1744 throw new StatusError(
1745 400,
1746 `Selected option '${selectedOption}' is not available for this policy`
1747 );
1748 }
1749
1750 const originalPremiumWithTax = Number(policyOrder.premiumWithTax) || 0;
1751 const originalPremiumWithoutTax = Number(policyOrder.premiumWithoutTax) || 0;
1752 const originalSalesTax = Number(policyOrder.salesTax) || 0;
1753 const originalCommissionTax = Number(policyOrder.commissionTax) || 0;
1754 const commissionRate = (Number(policyOrder.dynamicCommissionPercentage) || 0) / 100;
1755
1756 let refundPremium = 0;
1757 let refundTax = 0;
1758 let refundCommission = 0;
1759 let refundCommissionTax = 0;
1760
1761 if (originalPremiumWithTax > 0 && selectedCancellation.refundAmount > 0) {
1762 const refundRatio = selectedCancellation.refundAmount / originalPremiumWithTax;
1763 refundPremium = Math.round(originalPremiumWithoutTax * refundRatio * 100) / 100;
1764 refundTax = Math.round(originalSalesTax * refundRatio * 100) / 100;
1765 refundCommission = Math.round(refundPremium * commissionRate * 100) / 100;
1766 refundCommissionTax = Math.round(originalCommissionTax * refundRatio * 100) / 100;
1767 }
1768
1769 const cancellationMetadata = {
1770 ...selectedCancellation.calculationDetails,
1771 cancellationType: selectedOption,
1772 refundAmount: selectedCancellation.refundAmount,
1773 cancellationFee: selectedCancellation.cancellationFee,
1774 netRefund: selectedCancellation.netRefund,
1775 };
1776
1777 const cancelledPolicyOrder = await sequelize.transaction(async (transaction) => {
1778 const cancelled = await PolicyOrderModel.create({ organizationId: policyOrder.organizationId }, {
1779 policyId: crypto.randomUUID(),
1780 rootPolicyId: policyOrder.rootPolicyId,
1781 quoteId: policyOrder.quoteId,
1782 previousPolicyId: policyOrder.policyId,
1783 transactionType: TransactionType.CANCELLATION,
1784 version: (policyOrder.version || 1) + 1,
1785 effectiveDate: selectedCancellation.effectiveDate,
1786 status: cancelledStatusCode,
1787 country: policyOrder.country,
1788 productName: policyOrder.productName,
1789 productPlanName: policyOrder.productPlanName,
1790 productId: policyOrder.productId,
1791 productPlanId: policyOrder.productPlanId,
1792 parameters: { ...policyOrder.parameters, cancellation: cancellationMetadata },
1793 insurance: policyOrder.insurance,
1794 product: policyOrder.product,
1795 network: policyOrder.network,
1796 requestHeaders: policyOrder.requestHeaders,
1797 requestBody: policyOrder.requestBody,
1798 paymentFrequency: policyOrder.paymentFrequency,
1799 policyNumber: policyOrder.policyNumber,
1800 inceptionDate: policyOrder.inceptionDate,
1801 issueDate: policyOrder.issueDate,
1802 expiryDate: selectedCancellation.newExpiryDate,
1803 cancellationDate: selectedCancellation.effectiveDate,
1804 cancelReason: cancelReason as string,
1805 cancelledBy: cancelledBy || 'customer',
1806 basePremium: policyOrder.basePremium,
1807 premiumWithoutTax: policyOrder.premiumWithoutTax,
1808 premiumWithTax: policyOrder.premiumWithTax,
1809 salesTax: policyOrder.salesTax,
1810 commissionTax: policyOrder.commissionTax ?? 0,
1811 currency: policyOrder.currency,
1812 commissionStructureId: policyOrder.commissionStructureId,
1813 baselineCommissionPercentage: policyOrder.baselineCommissionPercenta71: fields: ['policyNumber'], 178: // requestHeaders.locale so messaging + email services can resolve the 243: declare policyNumber?: string;
API restart OK (readyMs 26s). /health 404 is expected — real routes are / and /v1/health/self. Verify those + dig into model wrapper, surname location, audit, migration template.
{"command":"echo \"=== / ===\"; curl -fsS -o /dev/null -w \"%{http_code}\" http://localhost:4012/ 2>&1; echo \"\"; echo \"=== /v1/health/self ===\"; curl -fsS http://localhost:4012/v1/health/self 2>&1; echo \"\"; echo \"=== migrate log full ===\"; cat /var/folders/pr/7k4xc3v1715_0zbbw8tgwl5r0000gn/T/worktree-dashboard-logs/sherpa-api-api-461-no-login-public-withdrawal-cance-migrate.log 2>&1 | tail -30","description":"Confirm API up via / and health/self; read migrate log"}=== / ===
200
=== /v1/health/self ===
{"status":"healthy","timestamp":"2026-06-01T15:48:31.322Z","commitHash":"dev"}
=== migrate log full ===
[start 2026-06-01T15:43:48.611Z] docker compose exec -T api npm run db:migrate:up
> Sherpa [email protected] db:migrate:up
> tsx src/db/migrate.ts up{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/PolicyOrder.ts"}1 import { _PolicyOrder } from './internal/_PolicyOrder.js';
2 import { toSafeModel } from './lib/safe-model.js';
3
4 export const PolicyOrder = toSafeModel(_PolicyOrder);
5 export type PolicyOrder = _PolicyOrder;
6
7 // Re-export raw class for db.ts registration, association includes, and type usage
8 export { _PolicyOrder };
9 export { RenewalStatus, TransactionType, CancelReason, CommissionStatus, DeletionReason } from './internal/_PolicyOrder.js';
10 {"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_AuditLog.ts"}1 import { Table, Column, Model, BelongsTo, ForeignKey, DataType } from 'sequelize-typescript';
2 import { _Customer } from './_Customer.js';
3 import { User } from '../User';
4
5 @Table({
6 modelName: 'AuditLog',
7 timestamps: true,
8 })
9 export class _AuditLog extends Model {
10 @Column({
11 type: DataType.INTEGER,
12 autoIncrement: true,
13 primaryKey: true,
14 })
15 declare id: number;
16
17 @Column({
18 type: DataType.UUID,
19 allowNull: false,
20 })
21 declare organizationId: string;
22
23 @ForeignKey(() => User)
24 @Column({
25 type: DataType.UUID,
26 allowNull: true,
27 })
28 declare userId: string | null;
29
30 @ForeignKey(() => User)
31 @Column({
32 type: DataType.UUID,
33 allowNull: true,
34 })
35 declare targetUserId: string | null;
36
37 @ForeignKey(() => _Customer)
38 @Column({
39 type: DataType.UUID,
40 allowNull: true,
41 })
42 declare customerId: string | null;
43
44 @Column({
45 type: DataType.STRING,
46 allowNull: false,
47 })
48 declare action: string;
49
50 @Column({
51 type: DataType.JSON,
52 allowNull: true,
53 })
54 declare details: any;
55
56 @Column({
57 type: DataType.STRING,
58 allowNull: true,
59 })
60 declare ipAddress: string | null;
61
62 @Column({
63 type: DataType.STRING,
64 allowNull: true,
65 })
66 declare userAgent: string | null;
67
68 @Column({
69 type: DataType.DATE,
70 allowNull: false,
71 })
72 declare createdAt: Date;
73
74 @Column({
75 type: DataType.DATE,
76 allowNull: false,
77 })
78 declare updatedAt: Date;
79
80 @BelongsTo(() => User, 'userId')
81 declare user: User;
82
83 @BelongsTo(() => User, 'targetUserId')
84 declare targetUser: User;
85
86 @BelongsTo(() => _Customer, 'customerId')
87 declare customer: _Customer;
88 }
89 API confirmed UP (200 on /, healthy self-check). Existing schema present (shared DB). Now the critical design question: how to query PolicyOrder without an auth request. Read safe-model wrapper + locate surname + org-by-origin resolution + migration template.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/lib/safe-model.ts"}1 import type { FastifyRequest } from 'fastify';
2 import type { FindOptions, CreateOptions, ModelStatic, Model, BulkCreateOptions, CreationAttributes, UpdateOptions, DestroyOptions, CountOptions, FindOrCreateOptions, UpsertOptions, Identifier, Transaction, Sequelize, Attributes } from 'sequelize';
3 import { Op, QueryTypes } from 'sequelize';
4 import { StatusError } from '@/error/error.js';
5
6 let _sequelize: Sequelize | null = null;
7 const getSequelize = (): Sequelize => {
8 if (!_sequelize) throw new Error('safe-model: sequelize not initialized. Call initSafeModel() first.');
9 return _sequelize;
10 };
11 export const initSafeModel = (seq: Sequelize) => { _sequelize = seq; };
12
13 export type OrgCtx = { organizationId: string };
14
15 type CtxOrReq = OrgCtx | FastifyRequest;
16
17 export type ScopeMode = 'strict' | 'hierarchical';
18
19 declare module 'fastify' {
20 interface FastifyContextConfig {
21 scopeMode?: ScopeMode;
22 }
23 }
24
25 // Routes opt into cross-organization read scope by tagging
26 // `routeOptions.config.scopeMode = 'hierarchical'`. Everything else stays
27 // strict (own org only). The DB-side `current_organization_in_scope` RLS
28 // function only honors the closure-expanded scope set when scope_mode is
29 // hierarchical — so accidentally leaking a scope set on a strict route is
30 // still filtered to the singleton at the database boundary.
31 const resolveScopeMode = (req: FastifyRequest): ScopeMode => {
32 const config = req.routeOptions?.config as { scopeMode?: ScopeMode } | undefined;
33 return config?.scopeMode === 'hierarchical' ? 'hierarchical' : 'strict';
34 };
35
36 const resolveOrgId = (ctxOrReq: CtxOrReq): string => {
37 // 'raw' is always present on Fastify requests (the underlying http.IncomingMessage),
38 // while 'ident' may not be set on unauthenticated routes.
39 if ('raw' in ctxOrReq) {
40 const req = ctxOrReq as FastifyRequest;
41 const orgId = req.ident?.org?.id ?? req.clientAuth?.organizationId;
42 if (!orgId) throw new StatusError(401, 'Missing org context on request');
43 return orgId;
44 }
45 const orgId = (ctxOrReq as OrgCtx).organizationId;
46 if (!orgId) throw new StatusError(400, 'Missing organizationId in context');
47 return orgId;
48 };
49
50 const getOrgScope = async (orgId: string): Promise<string[]> => {
51 const rows = await getSequelize().query<{ id: string }>(
52 `SELECT "descendantId" AS id FROM "OrganizationClosure" WHERE "ancestorId" = :orgId`,
53 { replacements: { orgId }, type: QueryTypes.SELECT },
54 );
55 if (rows.length === 0) {
56 return [orgId];
57 }
58 return rows.map((r) => r.id);
59 };
60
61 type ResolvedScope = { scope: string[]; mode: ScopeMode };
62
63 type ScopedRequest = FastifyRequest & {
64 __orgScope?: string[];
65 __orgScopeMode?: ScopeMode;
66 };
67
68 export const resolveScope = async (ctxOrReq: CtxOrReq): Promise<ResolvedScope> => {
69 if (!('raw' in ctxOrReq)) {
70 return { scope: [(ctxOrReq as OrgCtx).organizationId], mode: 'strict' };
71 }
72 const req = ctxOrReq as ScopedRequest;
73 if (req.__orgScope && req.__orgScopeMode) {
74 return { scope: req.__orgScope, mode: req.__orgScopeMode };
75 }
76
77 const mode = resolveScopeMode(req);
78 const userOrgId = req.ident?.org?.id;
79 if (userOrgId) {
80 const scope = mode === 'hierarchical' ? await getOrgScope(userOrgId) : [userOrgId];
81 req.__orgScope = scope;
82 req.__orgScopeMode = mode;
83 return { scope, mode };
84 }
85 const clientOrgId = req.clientAuth?.organizationId;
86 if (clientOrgId) {
87 // Client-credential (machine-to-machine) callers always run strict —
88 // there's no logged-in human picking a hierarchical view.
89 const scope = [clientOrgId];
90 req.__orgScope = scope;
91 req.__orgScopeMode = 'strict';
92 return { scope, mode: 'strict' };
93 }
94 throw new StatusError(401, 'Missing org context on request');
95 };
96
97 const setRlsContext = async (
98 targetOrgId: string,
99 scope: string[],
100 mode: ScopeMode,
101 t: Transaction,
102 ): Promise<void> => {
103 await getSequelize().query('SET LOCAL app.current_organization = :orgId', {
104 replacements: { orgId: targetOrgId },
105 transaction: t,
106 });
107 await getSequelize().query('SET LOCAL app.current_organization_set = :scope', {
108 replacements: { scope: scope.join(',') },
109 transaction: t,
110 });
111 await getSequelize().query('SET LOCAL app.scope_mode = :mode', {
112 replacements: { mode },
113 transaction: t,
114 });
115 };
116
117 export const withRls = <T>(
118 targetOrgId: string,
119 scope: string[],
120 mode: ScopeMode,
121 opts: { transaction?: Transaction | null } | undefined,
122 fn: (t: Transaction) => Promise<T>,
123 ): Promise<T> => {
124 if (opts?.transaction) {
125 return setRlsContext(targetOrgId, scope, mode, opts.transaction).then(() => fn(opts.transaction!));
126 }
127 return getSequelize().transaction((t) => setRlsContext(targetOrgId, scope, mode, t).then(() => fn(t)));
128 };
129
130 export const resolveOrgFilter = (userWhere: any, scope: string[]): unknown => {
131 const userOrgFilter = userWhere?.organizationId;
132 if (typeof userOrgFilter === 'string') {
133 if (!scope.includes(userOrgFilter)) {
134 throw new StatusError(403, 'organizationId is outside the caller\'s scope');
135 }
136 return userOrgFilter;
137 }
138 return { [Op.in]: scope };
139 };
140
141 // Marker for instances already wrapped — avoids double-wrapping when an
142 // instance flows through multiple safe-model calls (e.g. reload).
143 const TENANT_CTX = Symbol.for('sherpa.tenantCtx');
144
145 /**
146 * Attach the caller's tenant context to a model instance (or array of them)
147 * and override the mutating methods (.update, .save, .destroy, .reload,
148 * .increment, .decrement, .restore) so they run inside withRls — pinning
149 * app.current_organization on the txn so strict tenant_isolation RLS lets
150 * the row through.
151 *
152 * Without this, an instance loaded through safe-model still calls UPDATE /
153 * DELETE on a fresh connection with no GUC set, the RLS policy filters the
154 * row out, and Sequelize reports zero affected rows without throwing.
155 */
156 function attachTenantCtx<T extends Model | null>(target: T, ctx: CtxOrReq): T {
157 if (!target) return target;
158 if ((target as any)[TENANT_CTX]) return target;
159 (target as any)[TENANT_CTX] = ctx;
160
161 // Writes are always strict (own-org only). Matches the static safe-model
162 // API's behavior — Model.update(ctx, ...) forces strict regardless of the
163 // route's scopeMode. Same rule must apply when the caller mutates via an
164 // instance loaded under hierarchical reads.
165 const runStrict = async <R>(opts: any, fn: (t: Transaction) => Promise<R>): Promise<R> => {
166 const orgId = resolveOrgId(ctx);
167 return withRls(orgId, [orgId], 'strict', opts, fn);
168 };
169
170 // Reads respect the caller's resolved scope (hierarchical when opted in).
171 const runScoped = async <R>(opts: any, fn: (t: Transaction) => Promise<R>): Promise<R> => {
172 const { scope, mode } = await resolveScope(ctx);
173 return withRls(scope[0], scope, mode, opts, fn);
174 };
175
176 const wrap = <Args extends any[], R>(
177 method: (...args: Args) => Promise<R>,
178 runner: <X>(opts: any, fn: (t: Transaction) => Promise<X>) => Promise<X>,
179 ): (...args: Args) => Promise<R> => {
180 return (...args: Args) => {
181 const trailing = args[args.length - 1];
182 const opts = (trailing && typeof trailing === 'object' ? trailing : {}) as any;
183 return runner(opts, (t) => {
184 const merged = { ...opts, transaction: t };
185 const newArgs: any[] = opts === trailing ? args.slice(0, -1) : args.slice();
186 newArgs.push(merged);
187{"pattern":"domainOrganization|by=origin|byOrigin|resolveOrganizationByOrigin|portalDomain","path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src","output_mode":"files_with_matches"}Found 30 files sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/organizationUtils.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/types/fastify.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/test/hierarchicalVisibility.apitest.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/schemas/typebox/org/Organization.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/socialAuthRoutes.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/plus/organizationRoutes.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/organizationRoutes.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/organizationRoutes.apitest.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/authRoutes.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/plugins/socialAuth.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/plugins/domainContext.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/plugins/auth/widget.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/plugins/auth/lib.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/plugins/auth/client.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/plugins/auth/bearer.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/Organization.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.03.04T00.00.01.seed-additional-test-organizations.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.02.23T00.00.01.create-anvnordic-organization.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.02.09T00.00.00.fix-test-org-portal-domain-restore-unique.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.02.08T00.00.02.fix-test-organization-portal-domain-env-slug.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.01.08T00.00.01.seed-test-organization-for-sherpa-test.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2025.07.30T21.00.06.seed-wayni-policies-partner-module.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2025.07.30T20.00.00.add-portal-domain-to-organizations.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/uploadController.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/twoFactorController.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/organizationController.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/azureAdController.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/authController.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/app/swagger.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/app/cors.ts
{"pattern":"lastName|surname|firstName","path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_Client.ts","output_mode":"content","-n":true}No matches found
Critical discovery: safe-model accepts { organizationId } as context (not just a request) — exactly what the public flow needs. There's a domainContext.ts plugin for org-by-origin. Read it + find where surname is stored + migration template.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/plugins/domainContext.ts"}1 import { isIP } from 'net';
2 import type { FastifyInstance, FastifyRequest } from 'fastify';
3 import fp from 'fastify-plugin';
4 import { QueryTypes } from 'sequelize';
5 import { getRequired } from 'lib.js';
6 import { StatusError } from '@/error/error.js';
7 import { handleErrors } from '@/error/handler.js';
8 import { systemSequelize } from '@/models/db.js';
9 import type { Organization } from '@/models/Organization.js';
10 import { toProductionDomain } from '@/utils/localDevDomain.js';
11
12 export async function findOrganizationByPortalDomain(portalDomain: string): Promise<Organization | null> {
13 const results = await systemSequelize.query( // SAFE-SYSTEM: pre-auth domain lookup
14 `SELECT id, name, "displayName", "portalDomain", "portalUrl", "apiUrl", "crmDomain", "crmUrl", "portalType", "portalMode", settings, active, "azureTenantId"
15 FROM "Organizations"
16 WHERE "portalDomain" = :portalDomain AND active = true
17 LIMIT 1`,
18 { replacements: { portalDomain }, type: QueryTypes.SELECT }
19 );
20 return results.length > 0 ? results[0] as Organization : null;
21 }
22
23 export function safeParseOriginUrl(url: string): URL | null {
24 try {
25 return new URL(url);
26 } catch {
27 return null;
28 }
29 }
30
31 // FastifyRequest extensions are now centralized in src/types/fastify.ts
32
33 const API_PORT = getRequired('API_PORT');
34 const SHERPA_PORTAL_HTTP = getRequired('SHERPA_PORTAL_HTTP');
35 const SHERPA_PORTAL_HTTPS = getRequired('SHERPA_PORTAL_HTTPS');
36 const SHERPA_DOCKER_HOSTNAME_REMAP = getRequired('SHERPA_DOCKER_HOSTNAME_REMAP');
37
38 const routesWithoutDomainContext = [
39 '/v1/health',
40 '/v1/health/self',
41 '/v1/lens/health',
42 // Exact match only — if a sibling like /v1/organization/:id is added, list it here too.
43 '/v1/organization',
44 '/favicon.ico',
45 ];
46
47 const domainOriginSkiplist = [
48 'login.microsoftonline.com',
49 // Gangkhar embed domains are always allowed — they load inside portal iframes
50 // and resolve org context via widget token, not origin.
51 'embed.dev.gangkhar.dev',
52 'embed.staging.gangkhar.dev',
53 'embed.gangkhar.com',
54 ]
55
56 // Figure out the portal domain, with support for development mode.
57 let portalUrl = SHERPA_PORTAL_HTTPS;
58 if(process.env.NODE_ENV === 'development') {
59 // In development mode we store localhost in the db
60 portalUrl = SHERPA_PORTAL_HTTP;
61 }
62 const sherpaPortalDomain = new URL(portalUrl).hostname;
63
64 function safeParseUrl(url: string) {
65 return safeParseOriginUrl(url);
66 }
67
68 async function domainContextPlugin(fastify: FastifyInstance) {
69 fastify.addHook('preHandler', handleErrors(async (request: FastifyRequest) => {
70 if(!request.headers.host) {
71 throw new StatusError(412, 'Could not determine organization from request');
72 }
73
74 // Skip system routes
75 const path = request.routeOptions.url; // THIS IS THE PATH OF THE ROUTE, NOT THE URL OF THE REQUEST
76
77 // path is undefined for unmatched routes (404s); nothing to resolve for those
78 if (!path) {
79 return;
80 }
81
82 for(const route of routesWithoutDomainContext) {
83 if(path === route) {
84 return;
85 }
86 }
87
88 // MCP domain: entirely separate auth + tenant resolution (via McpHost, not portalDomain).
89 // MCP resolves its own organization context from the Host header → McpHost → organizationId.
90 // TODO: later, MCP requests should set their own request context (like domainOrganization/user)
91 // so downstream code can use a unified interface. For now, MCP handlers are self-contained.
92 if (path.startsWith('/v1/mcp/')) {
93 return;
94 }
95
96 // Public OpenAPI/Swagger routes: served cross-origin (Swagger UI, Redoc, custom doc sites).
97 // No tenant context — the spec is the same for all callers regardless of portal origin.
98 if (path.startsWith('/docs')) {
99 return;
100 }
101
102 // .well-known routes owned by the MCP domain. These are not MCP-specific — they serve OAuth
103 // discovery, OpenID config, and domain verification. They may grow into their own domain
104 // (e.g. src/domains/well-known/) as more consumers beyond MCP rely on them.
105 // Explicit list so we don't accidentally skip future .well-known routes that need domain context.
106 if (path === '/.well-known/openai-apps-challenge'
107 || path === '/.well-known/oauth-protected-resource'
108 || path === '/.well-known/oauth-authorization-server'
109 || path === '/.well-known/openid-configuration') {
110 return;
111 }
112
113 const [originalHostname, originalPort] = request.headers.host.split(':');
114
115 try {
116 // When it is a browser request, origin and referer might be set to urls of the portal domain, eg: portal.dev.gangkhar.dev
117 const origin = request.headers.origin || request.headers.referer;
118 const originUrl = origin ? safeParseUrl(origin) : null;
119 if (originUrl) {
120 // If this is a valid URL
121 const hostname = originUrl.hostname;
122
123 if(!domainOriginSkiplist.includes(hostname)) {
124 const lookupDomain = toProductionDomain(hostname);
125 let organization = await findOrganizationByPortalDomain(lookupDomain);
126
127 if(!organization && request.headers['x-widget-token'] && request.headers['x-parent-domain']) {
128 const parentDomain = request.headers['x-parent-domain'] as string;
129 const parentUrl = safeParseUrl(parentDomain.startsWith('http') ? parentDomain : `https://${parentDomain}`);
130 if (parentUrl) {
131 organization = await findOrganizationByPortalDomain(toProductionDomain(parentUrl.hostname));
132 }
133 }
134
135 if(!organization) {
136 fastify.log.info(`[DOMAIN] Organization not found for portal origin: ${hostname}`);
137 throw new StatusError(404, `Organization not found for portal origin: ${hostname}`);
138 }
139
140 request.domainOrganization = organization;
141 return;
142 }
143 }
144
145 // Here it is a direct request to the API, not a browser request (or a browser which blocks origin/referer).
146 let hostname = originalHostname;
147
148 // When the host is a raw IP with port, this means it is an internal docker networking request.
149 // This should go through without loading the tenant context.
150 if(isIP(originalHostname) > 0 && originalPort == API_PORT) {
151 return;
152 }
153
154 // Exception: we use localhost for the docker vnet hostname (otherwise lens services get rejected).
155 if(process.env.NODE_ENV === 'development') {
156 const [dockerHostname, hostHostname] = SHERPA_DOCKER_HOSTNAME_REMAP.split(':');
157 if(dockerHostname === hostname) {
158 fastify.log.info(`[DOMAIN] Development hostname remapped: ${originalHostname} -> ${hostHostname}`);
159 hostname = hostHostname;
160 }
161 }
162
163 // Here we fallback to the default organization
164
165 // Load default organization.
166 const organization = await findOrganizationByPortalDomain(sherpaPortalDomain);
167 if(!organization) {
168 fastify.log.error(`[DOMAIN] Default organization not found: ${sherpaPortalDomain}`);
169 throw new StatusError(404, 'Default organization not found');
170 }
171 request.domainOrganization = organization;
172 } catch (error) {
173 // Log details of unhandled errors, all variables we use
174 if(!(error instanceof StatusError)) {
175 const requestVars = {
176 host: request.headers.host,
177 origin: request.headers.origin,
178 refe{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.29T00.00.02.add-message-translation-fields.ts"}1 import type { MigrationFn } from 'umzug';
2 import { QueryInterface, DataTypes } from 'sequelize';
3
4 // Adds outbound-translation provenance to PolicyMessages + ClaimMessages.
5 // When a CRM operator translates before sending: `body` holds the translated
6 // text that the customer reads, `bodyOriginal` retains the agent's original
7 // (so the agent can re-display what they wrote), and the language pair records
8 // what was translated to what. For untranslated messages only `originalLanguage`
9 // is set. Inbound (customer-authored) messages set only `originalLanguage`.
10 // On-demand inbound translations are NOT persisted — they're computed and
11 // cached at read time so re-translation stays cheap without locking the row
12 // to a translation that may become stale.
13
14 export const up: MigrationFn<QueryInterface> = async ({ context }) => {
15 for (const table of ['PolicyMessages', 'ClaimMessages'] as const) {
16 await context.addColumn(table, 'bodyOriginal', {
17 type: DataTypes.TEXT,
18 allowNull: true,
19 });
20 await context.addColumn(table, 'originalLanguage', {
21 type: DataTypes.STRING(10),
22 allowNull: true,
23 });
24 await context.addColumn(table, 'translatedLanguage', {
25 type: DataTypes.STRING(10),
26 allowNull: true,
27 });
28 await context.addColumn(table, 'translationModel', {
29 type: DataTypes.STRING(64),
30 allowNull: true,
31 });
32 await context.addColumn(table, 'translatedAt', {
33 type: DataTypes.DATE,
34 allowNull: true,
35 });
36 }
37 };
38
39 export const down: MigrationFn<QueryInterface> = async () => {
40 // Forward-only deployment
41 };
42 {"pattern":"lastName|surname|firstName|holderName|fullName","path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_Customer.ts","output_mode":"content","-n":true}63: declare firstName: string | null; 69: declare lastName: string | null; 199: const parts = [this.firstName, this.lastName].filter(Boolean);
Now I understand org-by-origin (request.domainOrganization) and that surname = Customer.lastName. Read Customer model fully + confirm domainContext is global + how policies link a customer/email.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_Customer.ts"}1 import bcrypt from 'bcryptjs';
2 import {
3 Table,
4 Column,
5 Model,
6 HasMany,
7 BelongsTo,
8 ForeignKey,
9 DataType,
10 BeforeCreate,
11 BeforeUpdate,
12 } from 'sequelize-typescript';
13 import { Organization } from '../Organization.js';
14 import { _PolicyOrder } from './_PolicyOrder.js';
15
16 @Table({
17 modelName: 'Customer',
18 timestamps: true,
19 indexes: [
20 {
21 fields: ['email', 'organizationId'],
22 unique: true,
23 name: 'customers_email_organization_unique',
24 },
25 {
26 fields: ['organizationId'],
27 name: 'idx_customers_organization_id',
28 },
29 {
30 fields: ['externalClientId'],
31 name: 'idx_customers_external_client_id',
32 },
33 ],
34 })
35 export class _Customer extends Model {
36 @Column({
37 type: DataType.UUID,
38 defaultValue: DataType.UUIDV4,
39 primaryKey: true,
40 })
41 declare id: string;
42
43 @ForeignKey(() => Organization)
44 @Column({
45 type: DataType.UUID,
46 allowNull: false,
47 })
48 declare organizationId: string;
49
50 @Column({
51 type: DataType.STRING,
52 allowNull: false,
53 validate: {
54 isEmail: true,
55 },
56 })
57 declare email: string;
58
59 @Column({
60 type: DataType.STRING,
61 allowNull: true,
62 })
63 declare firstName: string | null;
64
65 @Column({
66 type: DataType.STRING,
67 allowNull: true,
68 })
69 declare lastName: string | null;
70
71 @Column({
72 type: DataType.STRING,
73 allowNull: true,
74 })
75 declare phoneNumber: string | null;
76
77 @Column({
78 type: DataType.STRING,
79 allowNull: true,
80 })
81 declare country: string | null;
82
83 @Column({
84 type: DataType.STRING,
85 allowNull: true,
86 })
87 declare region: string | null;
88
89 @Column({
90 type: DataType.STRING,
91 allowNull: true,
92 })
93 declare address: string | null;
94
95 @Column({
96 type: DataType.STRING,
97 allowNull: true,
98 })
99 declare city: string | null;
100
101 @Column({
102 type: DataType.STRING,
103 allowNull: true,
104 })
105 declare postalCode: string | null;
106
107 @Column({
108 type: DataType.STRING,
109 allowNull: true,
110 })
111 declare companyName: string | null;
112
113 @Column({
114 type: DataType.STRING,
115 allowNull: true,
116 })
117 declare externalClientId: string | null;
118
119 // Customer-channel locale used to localize messaging + transactional emails
120 // when the resolver can't find a per-policy language. Updated last-wins on
121 // each purchase from the request's locale header.
122 @Column({
123 type: DataType.STRING(10),
124 allowNull: true,
125 })
126 declare preferredLanguage: string | null;
127
128 @Column({
129 type: DataType.STRING,
130 allowNull: true,
131 })
132 declare password: string | null;
133
134 @Column({
135 type: DataType.STRING,
136 allowNull: true,
137 })
138 declare resetToken: string | null;
139
140 @Column({
141 type: DataType.DATE,
142 allowNull: true,
143 })
144 declare resetTokenExpiry: Date | null;
145
146 @Column({
147 type: DataType.ENUM('local', 'social'),
148 allowNull: false,
149 defaultValue: 'local',
150 })
151 declare authProvider: 'local' | 'social';
152
153 @Column({
154 type: DataType.BOOLEAN,
155 defaultValue: false,
156 })
157 declare deleted: boolean;
158
159 @Column({
160 type: DataType.DATE,
161 allowNull: false,
162 })
163 declare createdAt: Date;
164
165 @Column({
166 type: DataType.DATE,
167 allowNull: false,
168 })
169 declare updatedAt: Date;
170
171 @BelongsTo(() => Organization)
172 declare organization: Organization;
173
174 @HasMany(() => _PolicyOrder, {
175 foreignKey: 'customerId',
176 as: 'policyOrders',
177 })
178 declare policyOrders: _PolicyOrder[];
179
180 @BeforeCreate
181 static async hashPasswordBeforeCreate(instance: _Customer) {
182 if (instance.password) {
183 instance.password = await bcrypt.hash(instance.password, 10);
184 }
185 }
186
187 @BeforeUpdate
188 static async hashPasswordBeforeUpdate(instance: _Customer) {
189 if (instance.changed('password') && instance.password) {
190 instance.password = await bcrypt.hash(instance.password, 10);
191 }
192 }
193
194 public isDeleted(): boolean {
195 return this.deleted;
196 }
197
198 public getFullName(): string {
199 const parts = [this.firstName, this.lastName].filter(Boolean);
200 return parts.join(' ') || this.email;
201 }
202 }
203 {"pattern":"domainContext|registerDomainContext|domainContextPlugin","path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src","output_mode":"content","-n":true,"glob":"!**/domainContext.ts"}sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/organizationController.ts:18:import { findOrganizationByPortalDomain, safeParseOriginUrl } from '@/plugins/domainContext.js';
sherpa-api-api-461-no-login-public-withdrawal-cance/src/app/swagger.ts:330: // the domainContext plugin (so request.domainOrganization is undefined
sherpa-api-api-461-no-login-public-withdrawal-cance/src/app/cors.ts:5:import { findOrganizationByPortalDomain } from '@/plugins/domainContext.js';
sherpa-api-api-461-no-login-public-withdrawal-cance/src/app/cors.ts:155: // Block origin: omit CORS headers and let domainContext handle rejection
sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/embedCheckoutRoutes.ts:60:import { safeParseOriginUrl } from '@/plugins/domainContext.js';
sherpa-api-api-461-no-login-public-withdrawal-cance/src/app.ts:10:import domainContext from '@/plugins/domainContext.js';
sherpa-api-api-461-no-login-public-withdrawal-cance/src/app.ts:153: await app.register(domainContext);
sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/mcp/openai/AGENTS.md:140:Outbound landing pages live at `/v1/mcp/offer/...` (whitelisted in domainContext via `/v1/mcp/` prefix).
sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/mcp/README.md:122:MCP routes bypass the main API's `domainContext` plugin entirely:
sherpa-api-api-461-no-login-public-withdrawal-cance/src/domains/mcp/README.md:124:- `/v1/mcp/*` — early return in `domainContext`, MCP resolves its own org from Host → McpHost.Customer has email+lastName; domainContext is global (app.ts:153); safe-model takes {organizationId}. Last checks: lib.ts helpers, rate-limit registration, and the password-reset route pattern (email-link + anti-enumeration to mirror).
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/lib.ts"}1 /// Use this to require all env variables and fail fast when they are missing!
2 /// We don't want to allow the service to go live with bad configuration.
3 /// Use this at the import-scope wherever we use environment variables and NEVER USE || 'default' fallbacks.
4 /// Eg: `const JWT_SECRET = getRequired('JWT_SECRET');` on top of your file.
5 export function getRequired(key: string): string {
6 const value = process.env[key]
7 if (!value) {
8 throw new Error(`Environment variable ${key} is not set`)
9 }
10 return value
11 }
12
13 /**
14 * Get the appropriate Portal (Frontend) URL based on environment and request protocol
15 * In development + HTTP request: uses HTTP localhost
16 * In development + HTTPS request: uses HTTPS localhost
17 * In production: always uses HTTPS domain
18 */
19 export function getPortalUrl(protocol?: string): string {
20 const NODE_ENV = getRequired('NODE_ENV')
21
22 if (NODE_ENV === 'development') {
23 if (protocol === 'http') {
24 return getRequired('SHERPA_PORTAL_HTTP')
25 }
26 }
27
28 return getRequired('SHERPA_PORTAL_HTTPS')
29 }
30
31 /**
32 * Get the appropriate API URL based on environment and request protocol
33 * In development + HTTP request: uses HTTP localhost
34 * In development + HTTPS request: uses HTTPS localhost
35 * In production: always uses HTTPS domain
36 */
37 export function getApiUrl(protocol?: string): string {
38 const NODE_ENV = getRequired('NODE_ENV')
39
40 if (NODE_ENV === 'development') {
41 if (protocol === 'http') {
42 return getRequired('SHERPA_API_HTTP')
43 }
44 }
45
46 return getRequired('SHERPA_API_HTTPS')
47 }
48
49 /**
50 * Get the appropriate sherpa-embed URL based on environment and request protocol.
51 * Used to build customer-facing quote/checkout links (e.g. the web quote link
52 * emailed to a customer). Mirrors getPortalUrl/getApiUrl.
53 */
54 export function getEmbedUrl(protocol?: string): string {
55 const NODE_ENV = getRequired('NODE_ENV')
56
57 if (NODE_ENV === 'development') {
58 if (protocol === 'http') {
59 return getRequired('SHERPA_EMBED_HTTP')
60 }
61 }
62
63 return getRequired('SHERPA_EMBED_HTTPS')
64 }{"pattern":"rate-limit|rateLimit|@fastify/rate-limit|registerRateLimit","path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/app.ts","output_mode":"content","-n":true}20:import rateLimit from '@fastify/rate-limit';
101: await app.register(rateLimit, {
106: // Register rate limiting plugin with global disabled, so it only applies to routes with config.rateLimit
108: await app.register(rateLimit, {{"pattern":"password-reset-request|resetToken|getPortalUrl|reset-password\\?|If an account","path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/authController.ts","output_mode":"content","-n":true,"head_limit":40}7:import { getRequired, getPortalUrl } from '../../lib.js';
578: message: 'If an account with this email exists, a password reset link has been sent',
583: const resetToken = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
584: const resetTokenExpiry = new Date(Date.now() + 3600000); // 1 hour from now
587: resetToken,
588: resetTokenExpiry,
598: `Your password reset token is: ${resetToken}`,
599: `<p>Your password reset token is: <strong>${resetToken}</strong></p>`
604: message: 'If an account with this email exists, a password reset link has been sent',
617: resetToken: token,
618: resetTokenExpiry: {
632: resetToken: null,
633: resetTokenExpiry: null,
648: const frontendUrl = getPortalUrl(request.protocol);
794: WHERE "resetToken" = :token AND "resetTokenExpiry" > NOW() AND password IS NULL AND deleted = false
809: `UPDATE "Customers" SET password = :hashedPassword, "resetToken" = NULL, "resetTokenExpiry" = NULL, "updatedAt" = NOW()
822: resetToken: token,
823: resetTokenExpiry: { [Op.gt]: new Date() },
842: resetToken: null,
843: resetTokenExpiry: null,Read rate-limit registration + an existing per-route usage to nail the pattern.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/app.ts","offset":95,"limit":70}95 await extendWithErrorHandlers(app);
96 await extendWithLogger(app);
97 if (CAPTURE_HTTP) await app.register(apiCapturePlugin);
98 await app.register(auditLogPlugin);
99 await extendWithCors(app);
100 if (SHERPA_ENV === 'production') {
101 await app.register(rateLimit, {
102 max: 500,
103 timeWindow: 15 * 60 * 1000,
104 });
105 }
106 // Register rate limiting plugin with global disabled, so it only applies to routes with config.rateLimit
107 // This allows individual routes to opt-in to rate limiting with custom limits and rate limit headers
108 await app.register(rateLimit, {
109 global: false,
110 addHeaders: {
111 'x-ratelimit-limit': true,
112 'x-ratelimit-remaining': true,
113 'x-ratelimit-reset': true,
114 },
115 });
116 await app.register(jwt, {
117 secret: JWT_SECRET,
118 decoratorName: 'jwtUser',
119 });
120 await extendWithSwagger(app);
121 await extendWithSwaggerUi(app);
122 await app.register(cookie, { secret: COOKIE_SECRET });
123 await extendWithSecureSession(app);
124 await app.register(multipart, {
125 limits: {
126 fileSize: 314572800,
127 },
128 });
129 await app.register(helmet, {
130 global: true,
131 contentSecurityPolicy: {
132 directives: {
133 defaultSrc: ["'self'"],
134 baseUri: ["'self'"],
135 fontSrc: ["'self'", "https:", "data:"],
136 formAction: ["'self'"],
137 frameAncestors: ["'self'"],
138 imgSrc: ["'self'", "data:", "https:", "http:"],
139 connectSrc: ["'self'", "https:", "http:"],
140 objectSrc: ["'none'"],
141 scriptSrc: ["'self'"],
142 scriptSrcAttr: ["'none'"],
143 styleSrc: ["'self'", "https:", "'unsafe-inline'"],
144 upgradeInsecureRequests: [],
145 },
146 },
147 });
148
149 // We mount the base hello world routes before the tenant context plugin
150 await extendWithBaseRoutes(app);
151
152 // From here on out, we need to know the tenant context
153 await app.register(domainContext);
154 await app.register(authV2Plugin);
155 await app.register(rbacPlugin);
156 await app.register(socialAuthPlugin);
157 await app.register(partnerApiPlugin);
158 await extendWithRoutes(app);
159
160 return app;
161 }
162
163 // Safety: capturing must never be enabled outside local environment
164 if (SHERPA_ENV !== 'local' && CAPTURE_HTTP) {{"pattern":"autocompleteRateLimit|config:\\s*\\{[^}]*rateLimit|rateLimit:","path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes","output_mode":"content","-n":true,"-C":2}sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/embedCheckoutRoutes.ts-442- fastify.get<{ Params: { embedCheckoutSessionId: string } }>('/embed-checkout-sessions/:embedCheckoutSessionId', {
sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/embedCheckoutRoutes.ts-443- config: {
sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/embedCheckoutRoutes.ts:444: rateLimit: embedSessionReadRateLimit,
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/embedCheckoutRoutes.ts-445- },
sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/embedCheckoutRoutes.ts-446- schema: {
--
sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/embedCheckoutRoutes.ts-620- fastify.get<{ Params: { embedCheckoutSessionId: string } }>('/embed-checkout-sessions/:embedCheckoutSessionId/status', {
sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/embedCheckoutRoutes.ts-621- config: {
sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/embedCheckoutRoutes.ts:622: rateLimit: embedSessionReadRateLimit,
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/embedCheckoutRoutes.ts-623- },
sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/embedCheckoutRoutes.ts-624- schema: {
--
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/addressRoutes.ts-1-import { FastifyInstance, FastifyPluginAsync } from 'fastify';
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/addressRoutes.ts-2-import { getAutocomplete, getDetails } from '@/controllers/addressController.js';
sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/addressRoutes.ts:3:import { autocompleteRateLimit } from '@/utils/rateLimit.js';
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/addressRoutes.ts-4-
sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/addressRoutes.ts-5-export const addressRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
--
sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/addressRoutes.ts-10- preHandler: [fastify.authenticate],
sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/addressRoutes.ts-11- config: {
sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/addressRoutes.ts:12: rateLimit: autocompleteRateLimit,
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/addressRoutes.ts-13- },
sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/addressRoutes.ts-14- schema: {
--
sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/docsPdfRoutes.ts-23- preHandler: [fastify.authenticate],
sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/docsPdfRoutes.ts-24- config: {
sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/docsPdfRoutes.ts:25: rateLimit: createRateLimitConfig(5, 60_000),
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/docsPdfRoutes.ts-26- },
sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/docsPdfRoutes.ts-27- schema: {Have the full pattern. One more read: controller imports + preview function + category helpers, to extract a shared executeCancellation (regulatory parity on refunds requires the public flow reuse the authenticated execution path).
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/policyOrderController.ts","offset":1,"limit":70}1 import crypto from 'crypto';
2 import jwt from 'jsonwebtoken';
3 import { FastifyRequest, FastifyReply } from 'fastify';
4 import { Op } from 'sequelize';
5 import { generatePdfDownloadToken, PDF_DOWNLOAD_TOKEN_TTL_SECONDS } from '@/utils/tokenUtils.js';
6 import { getOrganizationBackendUrl } from '@/utils/organizationUtils.js';
7 import { calculatePriceforQuote } from '@/controllers/pricingController.js';
8 import { sequelize } from '@/models/db.js';
9 import { tenantQuery } from '@/models/lib/db-query.js';
10 import { getPolicyVisibilityConfig } from '@/utils/policyMessagesVisibility.js';
11 import { getBusinessDate, resolveOrgTimezone } from '@/utils/dateUtils.js';
12 import { StatusError } from '@/error/error.js';
13 import logger from '@/logger.js';
14 import { Client } from '@/models/Client.js';
15 import { Country } from '@/models/Country.js';
16 import { DriverFactory } from '@/integrations/driver-factory.js';
17 import { Insurer } from '@/models/Insurer.js';
18 import { InsuranceType } from '@/models/InsuranceType.js';
19 import { Module } from '@/models/Module.js';
20 import { ModuleEndpoint } from '@/models/ModuleEndpoint.js';
21 import { Partner } from '@/models/Partner.js';
22 import { Policy } from '@/models/Policy.js';
23 import {
24 PolicyOrder as PolicyOrderModel,
25 CancelReason,
26 TransactionType,
27 DeletionReason,
28 } from '@/models/PolicyOrder.js';
29 import { ClaimRecord, ClaimStatus } from '@/models/ClaimRecord.js';
30 import { OrganizationPolicyStatus, PolicyStatusLifecycleCategory } from '@/models/OrganizationPolicyStatus.js';
31 import {
32 evaluateComplianceGate,
33 collectComplianceCheckIds,
34 isComplianceGateParked,
35 pickComplianceParkCategory,
36 attachPolicyOrderToScreening,
37 } from '@/services/policyIssuanceService.js';
38 import {
39 assertStatusAllowedForCreate,
40 assertValidStatusForOrg,
41 getCategoryForCode,
42 getCodesForCategory,
43 getCodesForCategories,
44 getDefaultCodeForCategory,
45 resolveStatusForCreate,
46 resolveStatusForUpdate,
47 } from '@/services/policyStatusService.js';
48 import { PolicyOrderError } from '@/models/PolicyOrderError.js';
49 import { PolicyParameter } from '@/models/PolicyParameter.js';
50 import { Pricing } from '@/models/Pricing.js';
51 import { Organization } from '@/models/Organization.js';
52 import { Product, type ProductDocumentRef } from '@/models/Product.js';
53 import { DataFile } from '@/models/DataFile.js';
54 import { ProductModulePartner } from '@/models/ProductModulePartner.js';
55 import { ProductOptimization } from '@/models/ProductOptimization.js';
56 import { ProductPlan } from '@/models/ProductPlan.js';
57 import { ProductSetting } from '@/models/ProductSetting.js';
58 import { SuspensionTerms } from '@/models/SuspensionTerms.js';
59 import { findActiveProductTemplate } from '@/models/productTemplateHelpers.js';
60 import { Quote } from '@/models/Quote.js';
61 import { QuoteParameter } from '@/models/QuoteParameter.js';
62 import { QuoteSession } from '@/models/QuoteSession.js';
63 import { Customer } from '@/models/Customer.js';
64 import { generateDocumentPdf } from '@/services/pdfService.js';
65 import { renderTemplate } from '@/services/templateService.js';
66 import { localizeTemplateContent } from '@/services/templateI18nService.js';
67 import { getLocaleFromHeader, getLocaleFromRequestHeadersMap } from '@/services/localeService.js';
68 import { mergeTranslationDefaults, collectCoverageStrings } from '@/services/productTranslationDefaults.js';
69 import { createCustomerFromPolicyParameters } from '@/services/userService.js';
70 import { validateParameters, pickValidationMessage } from '@/services/validationService.js';{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/policyOrderController.ts","offset":1530,"limit":128}1530 export async function previewPolicyCancellation(
1531 request: FastifyRequest<{
1532 Params: { policyId: string };
1533 Querystring: { requestedDate?: string };
1534 }>
1535 ) {
1536 const { policyId } = request.params;
1537 const { requestedDate } = request.query;
1538 const organizationId = request.ident?.org?.id;
1539
1540 if (!organizationId) {
1541 throw new StatusError(400, 'Organization context required');
1542 }
1543
1544 const requestedPolicy = await PolicyOrderModel.findOne(request, {
1545 where: {
1546 policyId,
1547 },
1548 include: [
1549 {
1550 model: Client.raw,
1551 as: 'client',
1552 where: { organizationId },
1553 required: false,
1554 },
1555 ],
1556 });
1557
1558 if (!requestedPolicy) {
1559 throw new StatusError(404, 'Policy order not found');
1560 }
1561
1562 // Resolve to the latest active version within the policy chain. The caller may
1563 // pass the policyId of an older row (e.g. a NEW_BUSINESS row that is now
1564 // superseded by an endorsement or other version). Mirror the rootPolicyId
1565 // lookup used by cancelPolicy/reinstatePolicy so the cancel preview keeps
1566 // working when the URL points to a historical version.
1567 const rootPolicyId = requestedPolicy.rootPolicyId || requestedPolicy.policyId;
1568 const activeStatusCodes = await getCodesForCategory(organizationId, PolicyStatusLifecycleCategory.ACTIVE);
1569
1570 const policyOrder = await PolicyOrderModel.findOne(request, {
1571 where: {
1572 [Op.or]: [{ rootPolicyId }, { policyId: rootPolicyId }],
1573 status: { [Op.in]: activeStatusCodes },
1574 },
1575 include: [
1576 {
1577 model: Client.raw,
1578 as: 'client',
1579 where: { organizationId },
1580 required: false,
1581 },
1582 ],
1583 order: [['version', 'DESC']],
1584 });
1585
1586 if (!policyOrder) {
1587 // No active version in this policy chain. Surface the most useful error:
1588 // if the latest non-active version is cancelled, say so; otherwise treat
1589 // it as not-cancellable.
1590 const latestInChain = await PolicyOrderModel.findOne(request, {
1591 where: { [Op.or]: [{ rootPolicyId }, { policyId: rootPolicyId }] },
1592 order: [['version', 'DESC']],
1593 });
1594 if (latestInChain) {
1595 const latestCategory = await getCategoryForCode(organizationId, latestInChain.status);
1596 if (latestCategory === PolicyStatusLifecycleCategory.CANCELLED) {
1597 throw new StatusError(400, 'Policy is already cancelled');
1598 }
1599 }
1600 throw new StatusError(400, 'Only active policies can be cancelled');
1601 }
1602
1603 const timezone = request.ident!.org.timezone;
1604 const requestedCancellationDate = requestedDate || getBusinessDate(timezone);
1605
1606 // Check if prices should be hidden for this product
1607 const hidePricesCancellation = policyOrder.productId ? await shouldHidePrices(request, policyOrder.productId) : false;
1608
1609 const stripRefundOptions = (options: any[]) =>
1610 hidePricesCancellation
1611 ? options.map((opt: any) => ({ ...opt, refundAmount: null, cancellationFee: null, netRefund: null }))
1612 : options;
1613
1614 try {
1615 const { previewCancellationOptions } = await import('@/services/cancellationService.js');
1616 const locale = getLocaleFromHeader(request) ?? 'en';
1617 const preview = await previewCancellationOptions(policyOrder, requestedCancellationDate, timezone, locale);
1618
1619 if (hidePricesCancellation) {
1620 preview.availableOptions = stripRefundOptions(preview.availableOptions);
1621 }
1622
1623 return preview;
1624 } catch (error) {
1625 if (error instanceof Error && error.message.includes('No cancellation terms found')) {
1626 return {
1627 availableOptions: stripRefundOptions([
1628 {
1629 cancellationType: 'immediate',
1630 effectiveDate: requestedCancellationDate,
1631 newExpiryDate: requestedCancellationDate,
1632 refundAmount: 0,
1633 cancellationFee: 0,
1634 netRefund: 0,
1635 description: 'Cancel immediately - no specific terms configured',
1636 isRecommended: true,
1637 isWithinGracePeriod: false,
1638 calculationDetails: {},
1639 },
1640 ]),
1641 recommendedOption: 'immediate',
1642 currentPolicy: {
1643 policyId: policyOrder.policyId,
1644 inceptionDate: policyOrder.inceptionDate,
1645 currentExpiryDate: policyOrder.expiryDate,
1646 paymentFrequency: policyOrder.paymentFrequency as string,
1647 gracePeriodEndsOn: policyOrder.inceptionDate,
1648 daysFromInception: 0,
1649 },
1650 requestedDate: requestedCancellationDate,
1651 };
1652 }
1653 throw error;
1654 }
1655 }
1656
1657 export async function cancelPolicy(Final pre-implementation read: controller imports for the cancellation execution dependencies (refund/invoice/bundle helpers) so the extracted shared function imports correctly.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/policyOrderController.ts","offset":70,"limit":75}70 import { validateParameters, pickValidationMessage } from '@/services/validationService.js';
71 import {
72 CommissionCalculationData,
73 PartyAllocation,
74 } from '@/services/commissionCalculationService.js';
75 import { generateInvoice, getInvoicesByPolicyOrderId } from '@/services/invoiceService.js';
76 import { handleInvoice as handleExternalInvoice, handleCreditNote, handlePaymentIntent, handleSetupIntent, resolvePaymentProvider } from '@/domains/payments/service.js';
77 import { isExternalProvider } from '@/domains/payments/registry.js';
78 import { tryCreateEmbedCheckoutSession } from '@/services/embedCheckoutSessionService.io.js';
79 import { findReusablePolicyOrder, reuseIframeCheckout } from '@/services/embedCheckoutDedupService.io.js';
80 import { withQuoteLock } from '@/services/quoteDedupLock.js';
81 import { isSafeHttpsUrl } from '@/utils/urlValidation.js';
82 import { BundleSubscription } from '@/models/BundleSubscription.js';
83 import { ProductBundle } from '@/models/ProductBundle.js';
84 import type { InvoiceResult, PaymentIntentResult } from '@/domains/payments/interface.js';
85 import { sendSaleOutcomeEvent } from '@/services/lensOptimizationService.js';
86 import { PremiumResult } from '@/types/pricing.js';
87 import {
88 handleBundleCancellationSideEffect,
89 handleBundleReinstatementSideEffect,
90 handleBundleSuspensionSideEffect,
91 buildEndorsementContextFromRequest,
92 } from '@/services/bundleEndorsementService.js';
93 import {
94 previewSuspensionOptions as previewSuspensionOptionsService,
95 previewReinstatementOptions as previewReinstatementOptionsService,
96 } from '@/services/suspensionService.js';
97 import {
98 enqueueSuspensionEmail,
99 enqueueReinstatementEmail,
100 } from '@/services/jobs/welcomeEmailJob.js';
101 import {
102 applyBundleDiscount,
103 calculateEndorsementAdjustments,
104 extractPaymentFrequencyFromInsurance,
105 extractPremiumDataFromInsurance,
106 calculateAndExtractCommissionData,
107 enrichInsuranceWithCoverageOptions,
108 calculateExpiryDate,
109 getProductMetadata,
110 } from '@/services/policyOrderService.js';
111 import type { AdjustmentBreakdownItem, EndorsementAdjustments } from '@/services/policyOrderService.js';
112 import { shouldHidePrices, stripInsurancePrices, stripInsuranceCommission, stripCommissionFields, isCustomerFacing } from '@/utils/hidePrices.js';
113 import { getCommissionTaxRate } from '@/services/taxHelpers.js';
114 import { assertDisplayCurrencyAllowed, snapshotFx } from '@/services/currencyService.js';
115 import { getDecimalsForCurrency } from '@/utils/currencyHelper.js';
116 import { isProductionEnvironment } from '@/utils/env.js';
117 import { TEST_POLICY_STATUS_CODE } from '@/models/OrganizationPolicyStatus.js';
118
119 // Re-export for backwards compatibility with existing consumers
120 export {
121 applyBundleDiscount,
122 calculateEndorsementAdjustments,
123 extractPremiumDataFromInsurance,
124 calculateAndExtractCommissionData,
125 enrichInsuranceWithCoverageOptions,
126 calculateExpiryDate,
127 getProductMetadata,
128 } from '@/services/policyOrderService.js';
129 export type { AdjustmentBreakdownItem, EndorsementAdjustments } from '@/services/policyOrderService.js';
130
131 // Last-wins sync of the customer's channel locale based on the locale captured
132 // at this purchase. No-op when no locale was captured or the customer already
133 // holds the same value, so reads stay free of writes for English-only orgs.
134 async function syncCustomerPreferredLanguage(
135 customer: Customer | null | undefined,
136 language: string | null,
137 ): Promise<void> {
138 if (!customer || !language) return;
139 if (customer.preferredLanguage === language) return;
140 await customer.update({ preferredLanguage: language });
141 }
142
143 async function generateNextPolicyNumber(productId: number, organizationId: string): Promise<string> {
144 const result = await tenantQuery<Array<{{"pattern":"function buildEndorsementContextFromRequest|export.*buildEndorsementContextFromRequest|function handleBundleCancellationSideEffect","path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src","output_mode":"content","-n":true,"-A":12}sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts:45:export function buildEndorsementContextFromRequest(request: FastifyRequest): EndorsementContext {
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-46- const ident = request.ident!;
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-47- const clientId = ident.client?.id || (request as any).clientAuth?.clientNumericId || null;
sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-48- const headers: Record<string, string> = {};
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-49- Object.entries(request.headers).forEach(([key, value]) => {
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-50- if (!['cookie'].includes(key.toLowerCase()) && value !== undefined) {
sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-51- headers[key] = Array.isArray(value) ? value.join(', ') : String(value);
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-52- }
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-53- });
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-54- if (!headers.authorization && ident.user && ident.org) {
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-55- headers.authorization = generateInternalServiceToken(
sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-56- { user: ident.user, org: ident.org },
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-57- clientId,
--
sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts:327:export async function handleBundleCancellationSideEffect(
sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-328- bundleSubscriptionId: number,
sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-329- cancelledProductName: string,
sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-330- timezone: string,
sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-331- context: EndorsementContext,
sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-332-): Promise<void> {
sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-333- const subscription = await BundleSubscription.findByPk({ organizationId: context.organizationId }, bundleSubscriptionId);
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-334- if (!subscription || subscription.status === 'cancelled') return;
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-335-
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-336- const newProducts = subscription.activeProductNames.filter((p) => p !== cancelledProductName);
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-337- const newTier = newProducts.length >= 2
/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-338- ? await resolveDiscountTier(subscription.bundleId, newProducts, context.organizationId)
sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts-339- : null;{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/bundleEndorsementService.ts","offset":1,"limit":45}1 import crypto from 'crypto';
2 import { Op } from 'sequelize';
3 import type { FastifyRequest } from 'fastify';
4 import logger from '@/logger.js';
5 import { BundleSubscription } from '@/models/BundleSubscription.js';
6 import {
7 PolicyOrder,
8 TransactionType,
9 } from '@/models/PolicyOrder.js';
10 import { PolicyStatusLifecycleCategory } from '@/models/OrganizationPolicyStatus.js';
11 import {
12 getCodesForCategories,
13 getDefaultCodeForCategory,
14 } from '@/services/policyStatusService.js';
15 import { Product } from '@/models/Product.js';
16 import { ProductSetting } from '@/models/ProductSetting.js';
17 import { QuoteSession } from '@/models/QuoteSession.js';
18 import {
19 calculateEndorsementAdjustments,
20 enrichInsuranceWithCoverageOptions,
21 extractPremiumDataFromInsurance,
22 applyBundleDiscount,
23 calculateAndExtractCommissionData,
24 getProductMetadata,
25 } from '@/services/policyOrderService.js';
26 import { createQuoteWithPricing } from '@/controllers/quoteController.js';
27 import { resolveDiscountTier } from '@/services/bundleDiscountService.js';
28 import { generateInvoice } from '@/services/invoiceService.js';
29 import { handleInvoice as handleExternalInvoice, resolvePaymentProvider } from '@/domains/payments/service.js';
30 import { getCommissionTaxRate } from '@/services/taxHelpers.js';
31 import { isExternalProvider } from '@/domains/payments/registry.js';
32 import type { PartyAllocation } from '@/services/commissionCalculationService.js';
33 import { getBusinessDate } from '@/utils/dateUtils.js';
34 import { generateInternalServiceToken } from '@/utils/tokenUtils.js';
35
36 export interface EndorsementContext {
37 organizationId: string;
38 clientNumericId: number | null;
39 userId: string;
40 requestHeaders: Record<string, string>;
41 network: string;
42 timezone: string;
43 }
44
45 export function buildEndorsementContextFromRequest(request: FastifyRequest): EndorsementContext {Have complete context. Two quick environment checks (MailCatcher for email-token extraction in proofs, and the public-route org resolution), then I write the plan.
{"command":"echo \"=== api .env mail + urls ===\"; rg -n 'MAILCATCHER_HOST|^SHERPA_PORTAL_HTTP|^SHERPA_EMBED_HTTP|^SHERPA_API_HTTP|^NODE_ENV|^SHERPA_ENV|^API_PORT' /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/.env 2>&1 | head -20; echo \"=== mailcatcher web UIs ===\"; for p in 1080 1025; do echo -n \"port $p: \"; curl -fsS -o /dev/null -w \"%{http_code}\" \"http://localhost:$p/\" 2>&1; echo \"\"; done; echo \"=== mailcatcher messages api ===\"; curl -fsS \"http://localhost:1080/messages\" 2>&1 | head -c 300; echo \"\"","description":"Check MailCatcher host/port and api env URLs"}=== api .env mail + urls ===
2:NODE_ENV=development
3:SHERPA_ENV=local
4:API_PORT=4012
18:SHERPA_PORTAL_HTTP=http://localhost:3012
19:SHERPA_PORTAL_HTTPS=https://portal.chris.dev.gangkhar.dev
20:SHERPA_EMBED_HTTP=http://localhost:3100
21:SHERPA_EMBED_HTTPS=https://embed.chris.dev.gangkhar.dev
22:SHERPA_API_HTTP=http://localhost:4012
23:SHERPA_API_HTTPS=https://api.chris.dev.gangkhar.dev
181:MAILCATCHER_HOST=mailcatcher
=== mailcatcher web UIs ===
port 1080: 200
port 1025: curl: (1) Received HTTP/0.9 when not allowed
000
=== mailcatcher messages api ===
[{"id":1,"sender":"<[email protected]>","recipients":["<[email protected]>"],"subject":"New message on your policy","size":"3664","created_at":"2026-05-30T22:44:34+00:00"},{"id":2,"sender":"<[email protected]>","recipients":["<[email protected]>"],"subject":"Welcome to life_insurance_54{"pattern":"customerId|as: 'customer'|belongsTo.*Customer|@BelongsTo.*Customer","path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_PolicyOrder.ts","output_mode":"content","-n":true}83: fields: ['customerId'], 234: declare customerId?: string; 236: @BelongsTo(() => _Customer)
All context gathered. Confirmed: MailCatcher web API at localhost:1080 (can extract email tokens for proof), getPortalUrl('http')→localhost:3012, PolicyOrder @BelongsTo(_Customer). Now invoke the prescribed implementer skill.
{"skill":"github-ticket-implementer","args":"gangkharai/sherpa-api#461 — No-login public withdrawal/cancellation flow. Full-stack across api/portal/embed/test worktrees on branch auto/api-461-no-login-public-withdrawal-cance. Context already gathered."}Launching skill: github-ticket-implementer
Plan recorded (proceeding autonomously per runbook — no waiting). Note: runbook mandates the iterative-review skill for Step 4, which I'll use instead of /review-changes.
Plan:
PublicCancellationRequests table + inline RLS) · model + safe wrapper + db.ts registration · extract shared executeCancellation (refactor cancelPolicy to reuse) · publicCancellationService (initiate/view/confirm, token hashing, anti-enumeration, email) · 3 rate-limited public routes (no auth)./withdrawal + /withdrawal/confirm pages · add to proxy.ts skipPages.pageType=withdrawal widget.First, find the exact inline-RLS migration template (CI requires it per project memory).
{"pattern":"ENABLE ROW LEVEL SECURITY|CREATE POLICY|tenant_isolation","path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations","output_mode":"files_with_matches","head_limit":15}Found 15 files limit: 15 sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.27T00.00.05.create-policy-acceptance-configs.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.21T00.00.02.upgrade-singleton-rls-to-scope-function.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.20T00.00.02.create-policy-attachments.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.20T00.00.01.create-policy-messages.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.19T00.00.02.rename-sanction-screenings-to-compliance-checks.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.15T00.00.02.add-hierarchical-rls-scope.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.14T00.00.11.create-external-integration-field-mappings.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.14T00.00.06.create-sanction-screenings.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.14T00.00.05.create-external-integration-request-templates.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.14T00.00.04.create-external-integration-call-logs.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.14T00.00.03.create-external-integration-auth-configs.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.14T00.00.02.create-external-integration-environments.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.14T00.00.01.enable-rls-on-funnel-template-sets.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.14T00.00.01.create-external-integrations.ts sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.13T00.00.01.create-embed-checkout-sessions.ts
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.05.27T00.00.05.create-policy-acceptance-configs.ts"}1 import type { MigrationFn } from 'umzug';
2 import { QueryInterface, DataTypes } from 'sequelize';
3
4 // Config for the customer quote/policy acceptance page, modelled as the same
5 // pages → categories → items hierarchy used by policy/quote/claim parameters
6 // (so the admin editor, API, and embed render are consistent). `PolicyAcceptanceConfigs`
7 // is one row per product holding page-level settings; `AcceptancePages` →
8 // `AcceptanceCategories` → `AcceptanceItems` is the orderable tree. Items carry
9 // an `itemType` (parameter/price/plan/coverage/terms/static_text) — the only
10 // difference from a parameter hierarchy. Tenant tables with the standard
11 // permissive RLS template (check:migrations enforces it for new tenant tables).
12
13 const rlsTables = [
14 'PolicyAcceptanceConfigs',
15 'AcceptancePages',
16 'AcceptanceCategories',
17 'AcceptanceItems',
18 ];
19
20 export const up: MigrationFn<QueryInterface> = async ({ context }) => {
21 await context.createTable('PolicyAcceptanceConfigs', {
22 id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
23 organizationId: { type: DataTypes.UUID, allowNull: false },
24 productId: {
25 type: DataTypes.INTEGER,
26 allowNull: false,
27 references: { model: 'Products', key: 'id' },
28 onDelete: 'CASCADE',
29 },
30 name: { type: DataTypes.STRING(255), allowNull: false },
31 title: { type: DataTypes.STRING(255), allowNull: true },
32 subtitle: { type: DataTypes.TEXT, allowNull: true },
33 showEditButton: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
34 acceptanceMode: {
35 type: DataTypes.ENUM('checkbox', 'button'),
36 allowNull: false,
37 defaultValue: 'checkbox',
38 },
39 active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
40 createdAt: { type: DataTypes.DATE, allowNull: false },
41 updatedAt: { type: DataTypes.DATE, allowNull: false },
42 });
43 await context.addIndex('PolicyAcceptanceConfigs', ['organizationId', 'productId', 'active'], {
44 name: 'idx_acceptance_config_org_product_active',
45 });
46
47 await context.createTable('AcceptancePages', {
48 id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
49 organizationId: { type: DataTypes.UUID, allowNull: false },
50 acceptanceConfigId: {
51 type: DataTypes.UUID,
52 allowNull: false,
53 references: { model: 'PolicyAcceptanceConfigs', key: 'id' },
54 onDelete: 'CASCADE',
55 },
56 name: { type: DataTypes.STRING(255), allowNull: false },
57 label: { type: DataTypes.STRING(255), allowNull: true },
58 description: { type: DataTypes.TEXT, allowNull: true },
59 order: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
60 createdAt: { type: DataTypes.DATE, allowNull: false },
61 updatedAt: { type: DataTypes.DATE, allowNull: false },
62 });
63 await context.addIndex('AcceptancePages', ['acceptanceConfigId', 'order'], {
64 name: 'idx_acceptance_page_config_order',
65 });
66
67 await context.createTable('AcceptanceCategories', {
68 id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
69 organizationId: { type: DataTypes.UUID, allowNull: false },
70 acceptanceConfigId: {
71 type: DataTypes.UUID,
72 allowNull: false,
73 references: { model: 'PolicyAcceptanceConfigs', key: 'id' },
74 onDelete: 'CASCADE',
75 },
76 acceptancePageId: {
77 type: DataTypes.INTEGER,
78 allowNull: true,
79 references: { model: 'AcceptancePages', key: 'id' },
80 onDelete: 'CASCADE',
81 },
82 name: { type: DataTypes.STRING(255), allowNull: false },
83 label: { type: DataTypes.STRING(255), allowNull: true },
84 description: { type: DataTypes.TEXT, allowNull: true },
85 order: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
86 createdAt: { type: DataTypes.DATE, allowNull: false },
87 updatedAt: { type: DataTypes.DATE, allowNull: false },
88 });
89 await context.addIndex('AcceptanceCategories', ['acceptanceConfigId', 'order'], {
90 name: 'idx_acceptance_category_config_order',
91 });
92 await context.addIndex('AcceptanceCategories', ['acceptancePageId'], {
93 name: 'idx_acceptance_category_page',
94 });
95
96 await context.createTable('AcceptanceItems', {
97 id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
98 organizationId: { type: DataTypes.UUID, allowNull: false },
99 acceptanceConfigId: {
100 type: DataTypes.UUID,
101 allowNull: false,
102 references: { model: 'PolicyAcceptanceConfigs', key: 'id' },
103 onDelete: 'CASCADE',
104 },
105 acceptanceCategoryId: {
106 type: DataTypes.INTEGER,
107 allowNull: true,
108 references: { model: 'AcceptanceCategories', key: 'id' },
109 onDelete: 'CASCADE',
110 },
111 order: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
112 itemType: {
113 type: DataTypes.ENUM('parameter', 'price', 'coverage', 'terms', 'plan', 'static_text'),
114 allowNull: false,
115 },
116 parameterPath: { type: DataTypes.STRING(255), allowNull: true },
117 label: { type: DataTypes.STRING(255), allowNull: true },
118 termsTemplateType: { type: DataTypes.STRING(100), allowNull: true },
119 termsTemplateId: { type: DataTypes.INTEGER, allowNull: true },
120 staticHtml: { type: DataTypes.TEXT, allowNull: true },
121 displayOptions: { type: DataTypes.JSONB, allowNull: true },
122 required: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
123 createdAt: { type: DataTypes.DATE, allowNull: false },
124 updatedAt: { type: DataTypes.DATE, allowNull: false },
125 });
126 await context.addIndex('AcceptanceItems', ['acceptanceCategoryId', 'order'], {
127 name: 'idx_acceptance_item_category_order',
128 });
129 await context.addIndex('AcceptanceItems', ['acceptanceConfigId'], {
130 name: 'idx_acceptance_item_config',
131 });
132
133 // RLS — permissive template (unset GUC OR org match); safe-model WHERE is the
134 // primary filter. See src/db/AGENTS.md.
135 for (const table of rlsTables) {
136 await context.sequelize.query(`ALTER TABLE "${table}" ENABLE ROW LEVEL SECURITY`);
137 await context.sequelize.query(`ALTER TABLE "${table}" FORCE ROW LEVEL SECURITY`);
138 await context.sequelize.query(`DROP POLICY IF EXISTS tenant_isolation ON "${table}"`);
139 await context.sequelize.query(`
140 CREATE POLICY tenant_isolation ON "${table}"
141 FOR ALL
142 USING (
143 NULLIF(current_setting('app.current_organization', true), '') IS NULL
144 OR "organizationId" = NULLIF(current_setting('app.current_organization', true), '')::uuid
145 )
146 `);
147 await context.sequelize.query(`GRANT SELECT ON "${table}" TO dashboard_user`);
148 }
149 };
150
151 export const down: MigrationFn<QueryInterface> = async () => {
152 // Forward-only deployment
153 };
154 Have the exact RLS template. Now build the backend. Start with migration + model + safe wrapper.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.06.01T00.00.00.create-public-cancellation-requests.ts","content":"import type { MigrationFn } from 'umzug';\nimport { QueryInterface, DataTypes } from 'sequelize';\n\n// Backing store for the login-less public withdrawal/cancellation flow (#461).\n// EU withdrawal rules require a cancel option on the page the policy was sold,\n// with no account. A request is keyed on policy number + surname but ONLY\n// actionable via a single-use token delivered to the email on file — knowing\n// the identifiers is not enough to cancel a third party's policy. We persist\n// the sha256 of the token (never the raw value), a masked email hint for the\n// confirmation screen, and the captured reason/date/refund snapshot for audit.\n// Tenant table with the standard permissive RLS template (check:migrations\n// enforces it for new tenant tables).\n\nconst TABLE = 'PublicCancellationRequests';\n\nexport const up: MigrationFn<QueryInterface> = async ({ context }) => {\n await context.createTable(TABLE, {\n id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },\n organizationId: { type: DataTypes.UUID, allowNull: false },\n rootPolicyId: { type: DataTypes.UUID, allowNull: false },\n policyNumber: { type: DataTypes.STRING(255), allowNull: false },\n customerId: {\n type: DataTypes.UUID,\n allowNull: true,\n references: { model: 'Customers', key: 'id' },\n onDelete: 'SET NULL',\n },\n // sha256 hex of the single-use token. The raw token only ever lives in the\n // verification email — we store the hash so a DB read can't be replayed.\n tokenHash: { type: DataTypes.STRING(64), allowNull: false },\n // Masked address (e.g. j••@example.com) shown on the confirm screen so the\n // customer can recognise where the link was sent without leaking the email.\n emailHint: { type: DataTypes.STRING(255), allowNull: true },\n status: {\n type: DataTypes.ENUM('pending', 'verified', 'confirmed', 'expired'),\n allowNull: false,\n defaultValue: 'pending',\n },\n reason: { type: DataTypes.STRING(100), allowNull: true },\n selectedOption: { type: DataTypes.STRING(50), allowNull: true },\n requestedCancellationDate: { type: DataTypes.DATEONLY, allowNull: true },\n withinGracePeriod: { type: DataTypes.BOOLEAN, allowNull: true },\n refundAmount: { type: DataTypes.DECIMAL(15, 2), allowNull: true },\n currency: { type: DataTypes.STRING(10), allowNull: true },\n resultPolicyId: { type: DataTypes.UUID, allowNull: true },\n expiresAt: { type: DataTypes.DATE, allowNull: false },\n consumedAt: { type: DataTypes.DATE, allowNull: true },\n ipAddress: { type: DataTypes.STRING(255), allowNull: true },\n userAgent: { type: DataTypes.STRING(512), allowNull: true },\n createdAt: { type: DataTypes.DATE, allowNull: false },\n updatedAt: { type: DataTypes.DATE, allowNull: false },\n });\n\n await context.addIndex(TABLE, ['tokenHash'], {\n name: 'idx_public_cancellation_token_hash',\n unique: true,\n });\n await context.addIndex(TABLE, ['organizationId', 'policyNumber'], {\n name: 'idx_public_cancellation_org_policy_number',\n });\n await context.addIndex(TABLE, ['organizationId', 'status'], {\n name: 'idx_public_cancellation_org_status',\n });\n\n // RLS — permissive template (unset GUC OR org match); safe-model WHERE is the\n // primary filter. See src/db/AGENTS.md.\n await context.sequelize.query(`ALTER TABLE \"${TABLE}\" ENABLE ROW LEVEL SECURITY`);\n await context.sequelize.query(`ALTER TABLE \"${TABLE}\" FORCE ROW LEVEL SECURITY`);\n await context.sequelize.query(`DROP POLICY IF EXISTS tenant_isolation ON \"${TABLE}\"`);\n await context.sequelize.query(`\n CREATE POLICY tenant_isolation ON \"${TABLE}\"\n FOR ALL\n USING (\n NULLIF(current_setting('app.current_organization', true), '') IS NULL\n OR \"organizationId\" = NULLIF(current_setting('app.current_organization', true), '')::uuid\n )\n `);\n await context.sequelize.query(`GRANT SELECT ON \"${TABLE}\" TO dashboard_user`);\n};\n\nexport const down: MigrationFn<QueryInterface> = async () => {\n // Forward-only deployment\n};\n"}File created successfully at: /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/db/migrations/2026.06.01T00.00.00.create-public-cancellation-requests.ts (file state is current in your context — no need to Read it back)
Now the model + safe wrapper.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_PublicCancellationRequest.ts","content":"import { Table, Column, Model, BelongsTo, ForeignKey, DataType } from 'sequelize-typescript';\nimport { Organization } from '../Organization.js';\nimport { _Customer } from './_Customer.js';\n\nexport enum PublicCancellationStatus {\n PENDING = 'pending',\n VERIFIED = 'verified',\n CONFIRMED = 'confirmed',\n EXPIRED = 'expired',\n}\n\n/**\n * One row per login-less withdrawal/cancellation request (#461). Created when a\n * customer enters their policy number + surname on the public widget; only\n * actionable via the single-use token mailed to the address on file. We store\n * the sha256 of the token (never the raw value) plus the captured reason/date\n * and refund snapshot so the whole flow stays auditable without an account.\n */\n@Table({\n modelName: 'PublicCancellationRequest',\n timestamps: true,\n})\nexport class _PublicCancellationRequest extends Model {\n @Column({\n type: DataType.UUID,\n defaultValue: DataType.UUIDV4,\n primaryKey: true,\n })\n declare id: string;\n\n @ForeignKey(() => Organization)\n @Column({\n type: DataType.UUID,\n allowNull: false,\n })\n declare organizationId: string;\n\n @Column({\n type: DataType.UUID,\n allowNull: false,\n })\n declare rootPolicyId: string;\n\n @Column({\n type: DataType.STRING,\n allowNull: false,\n })\n declare policyNumber: string;\n\n @ForeignKey(() => _Customer)\n @Column({\n type: DataType.UUID,\n allowNull: true,\n })\n declare customerId: string | null;\n\n @Column({\n type: DataType.STRING(64),\n allowNull: false,\n })\n declare tokenHash: string;\n\n @Column({\n type: DataType.STRING,\n allowNull: true,\n })\n declare emailHint: string | null;\n\n @Column({\n type: DataType.ENUM('pending', 'verified', 'confirmed', 'expired'),\n allowNull: false,\n defaultValue: PublicCancellationStatus.PENDING,\n })\n declare status: PublicCancellationStatus;\n\n @Column({\n type: DataType.STRING(100),\n allowNull: true,\n })\n declare reason: string | null;\n\n @Column({\n type: DataType.STRING(50),\n allowNull: true,\n })\n declare selectedOption: string | null;\n\n @Column({\n type: DataType.DATEONLY,\n allowNull: true,\n })\n declare requestedCancellationDate: string | null;\n\n @Column({\n type: DataType.BOOLEAN,\n allowNull: true,\n })\n declare withinGracePeriod: boolean | null;\n\n @Column({\n type: DataType.DECIMAL(15, 2),\n allowNull: true,\n })\n declare refundAmount: number | null;\n\n @Column({\n type: DataType.STRING(10),\n allowNull: true,\n })\n declare currency: string | null;\n\n @Column({\n type: DataType.UUID,\n allowNull: true,\n })\n declare resultPolicyId: string | null;\n\n @Column({\n type: DataType.DATE,\n allowNull: false,\n })\n declare expiresAt: Date;\n\n @Column({\n type: DataType.DATE,\n allowNull: true,\n })\n declare consumedAt: Date | null;\n\n @Column({\n type: DataType.STRING,\n allowNull: true,\n })\n declare ipAddress: string | null;\n\n @Column({\n type: DataType.STRING(512),\n allowNull: true,\n })\n declare userAgent: string | null;\n\n @Column({\n type: DataType.DATE,\n allowNull: false,\n })\n declare createdAt: Date;\n\n @Column({\n type: DataType.DATE,\n allowNull: false,\n })\n declare updatedAt: Date;\n\n @BelongsTo(() => Organization)\n declare organization: Organization;\n\n @BelongsTo(() => _Customer, 'customerId')\n declare customer: _Customer;\n}\n"}File created successfully at: /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/internal/_PublicCancellationRequest.ts (file state is current in your context — no need to Read it back)
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/PublicCancellationRequest.ts","content":"import { _PublicCancellationRequest } from './internal/_PublicCancellationRequest.js';\nimport { toSafeModel } from './lib/safe-model.js';\n\nexport const PublicCancellationRequest = toSafeModel(_PublicCancellationRequest);\nexport type PublicCancellationRequest = _PublicCancellationRequest;\n\n// Re-export raw class for db.ts registration + association includes, and the\n// status enum for use across services/routes.\nexport { _PublicCancellationRequest, PublicCancellationStatus } from './internal/_PublicCancellationRequest.js';\n"}File created successfully at: /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/PublicCancellationRequest.ts (file state is current in your context — no need to Read it back)
Now register the model in db.ts. Let me find the registration list.
{"pattern":"_AuditLog|_Customer\\b|addModels|_EmbedCheckoutSession|_PolicyAcceptanceConfig","path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/db.ts","output_mode":"content","-n":true}4:import { _AuditLog } from './internal/_AuditLog';
29:import { _Customer } from './internal/_Customer';
71:import { _EmbedCheckoutSession } from './internal/_EmbedCheckoutSession.js';
76:import { _PolicyAcceptanceConfig } from './internal/_PolicyAcceptanceConfig';
199:sequelize.addModels([
219: _Customer,
235: _AuditLog,
268: _PolicyAcceptanceConfig,
278: _EmbedCheckoutSession,{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/db.ts","offset":71,"limit":12}71 import { _EmbedCheckoutSession } from './internal/_EmbedCheckoutSession.js';
72 import { _PolicyRenewalRun } from './internal/_PolicyRenewalRun';
73 import { _PolicyParameter } from './internal/_PolicyParameter';
74 import { _PolicyParameterCategory } from './internal/_PolicyParameterCategory';
75 import { _PolicyParameterCategoryPage } from './internal/_PolicyParameterCategoryPage';
76 import { _PolicyAcceptanceConfig } from './internal/_PolicyAcceptanceConfig';
77 import { _AcceptancePage } from './internal/_AcceptancePage';
78 import { _AcceptanceCategory } from './internal/_AcceptanceCategory';
79 import { _AcceptanceItem } from './internal/_AcceptanceItem';
80 import { _PolicySetting } from './internal/_PolicySetting';
81 import { PolicySettingField } from './PolicySettingField';
82 import { _Pricing } from './internal/_Pricing';{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/db.ts","offset":274,"limit":14}274 _Policy, 275 Scope, 276 _PolicyOrder, 277 _PolicyOrderError, 278 _EmbedCheckoutSession, 279 _PolicyRenewalRun, 280 _Claim, 281 _ClaimCoverage, 282 _ClaimParameter, 283 _ClaimParameterCategory, 284 _ClaimParameterCategoryPage, 285 _ClaimRecord, 286 _ClaimRecordError, 287 _ClaimRule,
{"replace_all":false,"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/db.ts","old_string":"import { _EmbedCheckoutSession } from './internal/_EmbedCheckoutSession.js';\nimport { _PolicyRenewalRun } from './internal/_PolicyRenewalRun';","new_string":"import { _EmbedCheckoutSession } from './internal/_EmbedCheckoutSession.js';\nimport { _PublicCancellationRequest } from './internal/_PublicCancellationRequest.js';\nimport { _PolicyRenewalRun } from './internal/_PolicyRenewalRun';"}The file /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/db.ts has been updated successfully. (file state is current in your context — no need to Read it back)
{"replace_all":false,"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/db.ts","old_string":" _EmbedCheckoutSession,\n _PolicyRenewalRun,","new_string":" _EmbedCheckoutSession,\n _PublicCancellationRequest,\n _PolicyRenewalRun,"}The file /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/models/db.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Now extract the shared cancellation execution so the public flow produces identical refunds/audit as the authenticated path. Create the execution service.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/policyCancellationExecution.ts","content":"import crypto from 'crypto';\nimport type { FastifyRequest } from 'fastify';\nimport logger from '@/logger.js';\nimport { sequelize } from '@/models/db.js';\nimport {\n PolicyOrder as PolicyOrderModel,\n CancelReason,\n TransactionType,\n} from '@/models/PolicyOrder.js';\nimport type { PolicyOrder } from '@/models/PolicyOrder.js';\nimport { Product } from '@/models/Product.js';\nimport { PolicyStatusLifecycleCategory } from '@/models/OrganizationPolicyStatus.js';\nimport { getDefaultCodeForCategory } from '@/services/policyStatusService.js';\nimport { previewCancellationOptions } from '@/services/cancellationService.js';\nimport { generateInvoice } from '@/services/invoiceService.js';\nimport { handleCreditNote, resolvePaymentProvider } from '@/domains/payments/service.js';\nimport { isExternalProvider } from '@/domains/payments/registry.js';\nimport {\n handleBundleCancellationSideEffect,\n type EndorsementContext,\n} from '@/services/bundleEndorsementService.js';\nimport type { OrgCtx } from '@/models/lib/safe-model.js';\nimport { StatusError } from '@/error/error.js';\n\nconst VALID_CANCELLATION_OPTIONS = ['immediate', 'end_of_billing_cycle', 'specific_date', 'earliest_allowed'];\n\nexport interface ExecuteCancellationOptions {\n cancelReason: string;\n selectedOption: string;\n requestedCancellationDate?: string;\n cancelledBy?: 'customer' | 'admin';\n timezone: string;\n locale?: string;\n // Provided by the authenticated controller so a bundled policy recalculates\n // its remaining-products discount on cancel. Omitted by the login-less public\n // flow (outlet-sold policies are standalone); if a bundled policy ever flows\n // through there we skip the recalc and log rather than fabricate a user/token.\n endorsementContext?: EndorsementContext;\n}\n\n/**\n * Core cancellation execution shared by the authenticated cancel endpoint and\n * the login-less public withdrawal flow (#461). The caller is responsible for\n * loading and authorising `policyOrder` (the active/suspended version to\n * cancel); this function owns the money + lifecycle transaction so both\n * entry points produce identical CANCELLATION orders, refunds, and audit.\n *\n * `ctx` is either the authenticated FastifyRequest or an `{ organizationId }`\n * context — both satisfy safe-model's tenant scoping.\n */\nexport async function executeCancellation(\n ctx: OrgCtx | FastifyRequest,\n policyOrder: PolicyOrder,\n options: ExecuteCancellationOptions,\n): Promise<Record<string, unknown>> {\n const { cancelReason, selectedOption, requestedCancellationDate, cancelledBy, timezone } = options;\n\n if (!cancelReason) {\n throw new StatusError(400, 'Cancel reason is required');\n }\n if (!selectedOption) {\n throw new StatusError(400, 'Selected cancellation option is required');\n }\n if (!Object.values(CancelReason).includes(cancelReason as CancelReason)) {\n throw new StatusError(400, 'Invalid cancel reason');\n }\n if (!VALID_CANCELLATION_OPTIONS.includes(selectedOption)) {\n throw new StatusError(400, 'Invalid cancellation option');\n }\n\n const organizationId = policyOrder.organizationId;\n const cancelledStatusCode = await getDefaultCodeForCategory(organizationId, PolicyStatusLifecycleCategory.CANCELLED);\n const supersededStatusCode = await getDefaultCodeForCategory(organizationId, PolicyStatusLifecycleCategory.SUPERSEDED);\n\n const cancellationDate = requestedCancellationDate || policyOrder.inceptionDate;\n const locale = options.locale ?? 'en';\n\n let cancellationResult: Record<string, unknown>;\n\n try {\n const businessCancellationDate = requestedCancellationDate || (await import('@/utils/dateUtils.js')).getBusinessDate(timezone);\n const preview = await previewCancellationOptions(policyOrder, businessCancellationDate, timezone, locale);\n\n const selectedCancellation = preview.availableOptions.find(\n (option) => option.cancellationType === selectedOption\n );\n\n if (!selectedCancellation) {\n throw new StatusError(\n 400,\n `Selected option '${selectedOption}' is not available for this policy`\n );\n }\n\n const originalPremiumWithTax = Number(policyOrder.premiumWithTax) || 0;\n const originalPremiumWithoutTax = Number(policyOrder.premiumWithoutTax) || 0;\n const originalSalesTax = Number(policyOrder.salesTax) || 0;\n const originalCommissionTax = Number(policyOrder.commissionTax) || 0;\n const commissionRate = (Number(policyOrder.dynamicCommissionPercentage) || 0) / 100;\n\n let refundPremium = 0;\n let refundTax = 0;\n let refundCommission = 0;\n\n if (originalPremiumWithTax > 0 && selectedCancellation.refundAmount > 0) {\n const refundRatio = selectedCancellation.refundAmount / originalPremiumWithTax;\n refundPremium = Math.round(originalPremiumWithoutTax * refundRatio * 100) / 100;\n refundTax = Math.round(originalSalesTax * refundRatio * 100) / 100;\n refundCommission = Math.round(refundPremium * commissionRate * 100) / 100;\n }\n\n const cancellationMetadata = {\n ...selectedCancellation.calculationDetails,\n cancellationType: selectedOption,\n refundAmount: selectedCancellation.refundAmount,\n cancellationFee: selectedCancellation.cancellationFee,\n netRefund: selectedCancellation.netRefund,\n };\n\n const cancelledPolicyOrder = await sequelize.transaction(async (transaction) => {\n const cancelled = await PolicyOrderModel.create({ organizationId: policyOrder.organizationId }, {\n policyId: crypto.randomUUID(),\n rootPolicyId: policyOrder.rootPolicyId,\n quoteId: policyOrder.quoteId,\n previousPolicyId: policyOrder.policyId,\n transactionType: TransactionType.CANCELLATION,\n version: (policyOrder.version || 1) + 1,\n effectiveDFile created successfully at: /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/policyCancellationExecution.ts (file state is current in your context — no need to Read it back)
Fix: align the no-terms fallback date with the original (requestedCancellationDate || today) and import getBusinessDate statically.
{"replace_all":false,"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/policyCancellationExecution.ts","old_string":"import { previewCancellationOptions } from '@/services/cancellationService.js';","new_string":"import { previewCancellationOptions } from '@/services/cancellationService.js';\nimport { getBusinessDate } from '@/utils/dateUtils.js';"}The file /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/policyCancellationExecution.ts has been updated successfully. (file state is current in your context — no need to Read it back)
{"replace_all":false,"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/policyCancellationExecution.ts","old_string":" const cancellationDate = requestedCancellationDate || policyOrder.inceptionDate;\n const locale = options.locale ?? 'en';\n\n let cancellationResult: Record<string, unknown>;\n\n try {\n const businessCancellationDate = requestedCancellationDate || (await import('@/utils/dateUtils.js')).getBusinessDate(timezone);\n const preview = await previewCancellationOptions(policyOrder, businessCancellationDate, timezone, locale);","new_string":" const today = getBusinessDate(timezone);\n const cancellationDate = requestedCancellationDate || today;\n const locale = options.locale ?? 'en';\n\n let cancellationResult: Record<string, unknown>;\n\n try {\n const preview = await previewCancellationOptions(policyOrder, cancellationDate, timezone, locale);"}The file /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/policyCancellationExecution.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Now refactor the controller cancelPolicy to delegate to the shared function. Re-read the precise pre-execution block to edit it surgically.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/policyOrderController.ts","offset":1684,"limit":16}1684 const cancellableStatusCodes = await getCodesForCategories(organizationId, [
1685 PolicyStatusLifecycleCategory.ACTIVE,
1686 PolicyStatusLifecycleCategory.SUSPENDED,
1687 ]);
1688 const cancelledStatusCode = await getDefaultCodeForCategory(organizationId, PolicyStatusLifecycleCategory.CANCELLED);
1689 const supersededStatusCode = await getDefaultCodeForCategory(organizationId, PolicyStatusLifecycleCategory.SUPERSEDED);
1690
1691 const validCancelReasons = Object.values(CancelReason);
1692 if (!validCancelReasons.includes(cancelReason as CancelReason)) {
1693 throw new StatusError(400, 'Invalid cancel reason');
1694 }
1695
1696 const validOptions = ['immediate', 'end_of_billing_cycle', 'specific_date', 'earliest_allowed'];
1697 if (!validOptions.includes(selectedOption)) {
1698 throw new StatusError(400, 'Invalid cancellation option');
1699 }Make the three controller edits: import the shared function, drop the now-unused status-code lines, and replace the execution block with delegation.
{"replace_all":false,"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/policyOrderController.ts","old_string":" handleBundleSuspensionSideEffect,\n buildEndorsementContextFromRequest,\n} from '@/services/bundleEndorsementService.js';","new_string":" handleBundleSuspensionSideEffect,\n buildEndorsementContextFromRequest,\n} from '@/services/bundleEndorsementService.js';\nimport { executeCancellation } from '@/services/policyCancellationExecution.js';"}The file /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/policyOrderController.ts has been updated successfully. (file state is current in your context — no need to Read it back)
{"replace_all":false,"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/policyOrderController.ts","old_string":" const cancellableStatusCodes = await getCodesForCategories(organizationId, [\n PolicyStatusLifecycleCategory.ACTIVE,\n PolicyStatusLifecycleCategory.SUSPENDED,\n ]);\n const cancelledStatusCode = await getDefaultCodeForCategory(organizationId, PolicyStatusLifecycleCategory.CANCELLED);\n const supersededStatusCode = await getDefaultCodeForCategory(organizationId, PolicyStatusLifecycleCategory.SUPERSEDED);\n\n const validCancelReasons = Object.values(CancelReason);","new_string":" const cancellableStatusCodes = await getCodesForCategories(organizationId, [\n PolicyStatusLifecycleCategory.ACTIVE,\n PolicyStatusLifecycleCategory.SUSPENDED,\n ]);\n\n const validCancelReasons = Object.values(CancelReason);"}The file /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/policyOrderController.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Now replace the large execution block with the delegation call. Re-read the exact current block.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/policyOrderController.ts","offset":1720,"limit":14}1720 order: [['version', 'DESC']],
1721 });
1722
1723 if (!policyOrder) {
1724 throw new StatusError(400, 'No active or suspended policy version found to cancel');
1725 }
1726
1727 const timezone = request.ident!.org.timezone;
1728 const today = getBusinessDate(timezone);
1729 const cancellationDate = requestedCancellationDate || today;
1730
1731 let cancellationResult: Record<string, unknown>;
1732
1733 try {Read the full current execution block exactly so I can replace it in one edit.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/policyOrderController.ts","offset":1727,"limit":240}1727 const timezone = request.ident!.org.timezone;
1728 const today = getBusinessDate(timezone);
1729 const cancellationDate = requestedCancellationDate || today;
1730
1731 let cancellationResult: Record<string, unknown>;
1732
1733 try {
1734 const { previewCancellationOptions } = await import('@/services/cancellationService.js');
1735 const locale = getLocaleFromHeader(request) ?? 'en';
1736 const preview = await previewCancellationOptions(policyOrder, cancellationDate, timezone, locale);
1737
1738 const selectedCancellation = preview.availableOptions.find(
1739 (option) => option.cancellationType === selectedOption
1740 );
1741
1742 if (!selectedCancellation) {
1743 throw new StatusError(
1744 400,
1745 `Selected option '${selectedOption}' is not available for this policy`
1746 );
1747 }
1748
1749 const originalPremiumWithTax = Number(policyOrder.premiumWithTax) || 0;
1750 const originalPremiumWithoutTax = Number(policyOrder.premiumWithoutTax) || 0;
1751 const originalSalesTax = Number(policyOrder.salesTax) || 0;
1752 const originalCommissionTax = Number(policyOrder.commissionTax) || 0;
1753 const commissionRate = (Number(policyOrder.dynamicCommissionPercentage) || 0) / 100;
1754
1755 let refundPremium = 0;
1756 let refundTax = 0;
1757 let refundCommission = 0;
1758 let refundCommissionTax = 0;
1759
1760 if (originalPremiumWithTax > 0 && selectedCancellation.refundAmount > 0) {
1761 const refundRatio = selectedCancellation.refundAmount / originalPremiumWithTax;
1762 refundPremium = Math.round(originalPremiumWithoutTax * refundRatio * 100) / 100;
1763 refundTax = Math.round(originalSalesTax * refundRatio * 100) / 100;
1764 refundCommission = Math.round(refundPremium * commissionRate * 100) / 100;
1765 refundCommissionTax = Math.round(originalCommissionTax * refundRatio * 100) / 100;
1766 }
1767
1768 const cancellationMetadata = {
1769 ...selectedCancellation.calculationDetails,
1770 cancellationType: selectedOption,
1771 refundAmount: selectedCancellation.refundAmount,
1772 cancellationFee: selectedCancellation.cancellationFee,
1773 netRefund: selectedCancellation.netRefund,
1774 };
1775
1776 const cancelledPolicyOrder = await sequelize.transaction(async (transaction) => {
1777 const cancelled = await PolicyOrderModel.create({ organizationId: policyOrder.organizationId }, {
1778 policyId: crypto.randomUUID(),
1779 rootPolicyId: policyOrder.rootPolicyId,
1780 quoteId: policyOrder.quoteId,
1781 previousPolicyId: policyOrder.policyId,
1782 transactionType: TransactionType.CANCELLATION,
1783 version: (policyOrder.version || 1) + 1,
1784 effectiveDate: selectedCancellation.effectiveDate,
1785 status: cancelledStatusCode,
1786 country: policyOrder.country,
1787 productName: policyOrder.productName,
1788 productPlanName: policyOrder.productPlanName,
1789 productId: policyOrder.productId,
1790 productPlanId: policyOrder.productPlanId,
1791 parameters: { ...policyOrder.parameters, cancellation: cancellationMetadata },
1792 insurance: policyOrder.insurance,
1793 product: policyOrder.product,
1794 network: policyOrder.network,
1795 requestHeaders: policyOrder.requestHeaders,
1796 requestBody: policyOrder.requestBody,
1797 paymentFrequency: policyOrder.paymentFrequency,
1798 policyNumber: policyOrder.policyNumber,
1799 inceptionDate: policyOrder.inceptionDate,
1800 issueDate: policyOrder.issueDate,
1801 expiryDate: selectedCancellation.newExpiryDate,
1802 cancellationDate: selectedCancellation.effectiveDate,
1803 cancelReason: cancelReason as string,
1804 cancelledBy: cancelledBy || 'customer',
1805 basePremium: policyOrder.basePremium,
1806 premiumWithoutTax: policyOrder.premiumWithoutTax,
1807 premiumWithTax: policyOrder.premiumWithTax,
1808 salesTax: policyOrder.salesTax,
1809 commissionTax: policyOrder.commissionTax ?? 0,
1810 currency: policyOrder.currency,
1811 commissionStructureId: policyOrder.commissionStructureId,
1812 baselineCommissionPercentage: policyOrder.baselineCommissionPercentage,
1813 dynamicCommissionPercentage: policyOrder.dynamicCommissionPercentage,
1814 variancePercentage: policyOrder.variancePercentage,
1815 allocationRuleId: policyOrder.allocationRuleId,
1816 totalCommissionAmount: policyOrder.totalCommissionAmount,
1817 partyAllocations: policyOrder.partyAllocations,
1818 originalPremiumWithoutTax: policyOrder.originalPremiumWithoutTax,
1819 originalPremiumWithTax: policyOrder.originalPremiumWithTax,
1820 billingEffectiveDate: selectedCancellation.effectiveDate,
1821 adjustmentPremium: -refundPremium,
1822 adjustmentTax: -refundTax,
1823 adjustmentCommission: -refundCommission,
1824 adjustmentCommissionTax: 0,
1825 clientId: policyOrder.clientId,
1826 customerId: policyOrder.customerId,
1827 renewalStatus: null,
1828 renewalStatusError: null,
1829 testMode: policyOrder.testMode,
1830 }, { transaction });
1831
1832 await policyOrder.update({ status: supersededStatusCode }, { transaction });
1833
1834 const cancellationProduct = await Product.findByPk(request, cancelled.productId!, { transaction });
1835 const cancellationProvider = cancellationProduct ? await resolvePaymentProvider(cancellationProduct, policyOrder.bundleSubscriptionId) : 'internal';
1836 // Only issue an external credit note when an external provider was
1837 // actually used to collect for this policy (externalPaymentRef set).
1838 // Policies created without an external charge (test/free/internal-only)
1839 // fall through to the internal invoice path so cancellation still
1840 // works — the lifecycle status change is the source of truth.
1841 const useExternalCredit =
1842 !policyOrder.testMode
1843 && isExternalProvider(cancellationProvider)
1844 && !!policyOrder.externalPaymentRef;
1845 if (useExternalCredit) {
1846 await handleCreditNote(policyOrder, {
1847 amount: selectedCancellation.netRefund,
1848 description: `Cancellation credit for ${cancelled.productName}`,
1849 reason: cancelReason as string,
1850 });
1851 } else if (!policyOrder.testMode) {
1852 if (isExternalProvider(cancellationProvider) && !policyOrder.externalPaymentRef) {
1853 logger.warn({
1854 policyOrderId: policyOrder.id,
1855 provider: cancellationProvider,
1856 }, 'External provider configured but no externalPaymentRef on policy — falling back to internal invoice');
1857 }
1858 await generateInvoice(
1859 cancelled,
1860 undefined,
1861 cancellationMetadata,
1862 selectedCancellation.cancellationFee,
1863 transaction
1864 );
1865 }
1866
1867 return cancelled;
1868 });
1869
1870 cancellationResult = {
1871 policyId: cancelledPolicyOrder.policyId,
1872 policyNumber: cancelledPolicyOrder.policyNumber,
1873 status: cancelledPolicyOrder.status,
1874 cancellationDate: cancelledPolicyOrder.cancellationDate,
1875 cancelReason: cancelledPolicyOrder.cancelReason,
1876 previousPolicyId: policyOrder.policyId,
1877 cancellation: {
1878 effectiveCancellationDate: selectedCancellation.effectiveDate,
1879 newExpiryDate: selectedCancellation.newExpiryDate,
1880 refundAmount: selectedCancellation.refundAmount,
1881 cancellationFee: selected{"replace_all":false,"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/policyOrderController.ts","old_string":" const timezone = request.ident!.org.timezone;\n const today = getBusinessDate(timezone);\n const cancellationDate = requestedCancellationDate || today;\n\n let cancellationResult: Record<string, unknown>;\n\n try {\n const { previewCancellationOptions } = await import('@/services/cancellationService.js');\n const locale = getLocaleFromHeader(request) ?? 'en';\n const preview = await previewCancellationOptions(policyOrder, cancellationDate, timezone, locale);\n\n const selectedCancellation = preview.availableOptions.find(\n (option) => option.cancellationType === selectedOption\n );\n\n if (!selectedCancellation) {\n throw new StatusError(\n 400,\n `Selected option '${selectedOption}' is not available for this policy`\n );\n }\n\n const originalPremiumWithTax = Number(policyOrder.premiumWithTax) || 0;\n const originalPremiumWithoutTax = Number(policyOrder.premiumWithoutTax) || 0;\n const originalSalesTax = Number(policyOrder.salesTax) || 0;\n const originalCommissionTax = Number(policyOrder.commissionTax) || 0;\n const commissionRate = (Number(policyOrder.dynamicCommissionPercentage) || 0) / 100;\n\n let refundPremium = 0;\n let refundTax = 0;\n let refundCommission = 0;\n let refundCommissionTax = 0;\n\n if (originalPremiumWithTax > 0 && selectedCancellation.refundAmount > 0) {\n const refundRatio = selectedCancellation.refundAmount / originalPremiumWithTax;\n refundPremium = Math.round(originalPremiumWithoutTax * refundRatio * 100) / 100;\n refundTax = Math.round(originalSalesTax * refundRatio * 100) / 100;\n refundCommission = Math.round(refundPremium * commissionRate * 100) / 100;\n refundCommissionTax = Math.round(originalCommissionTax * refundRatio * 100) / 100;\n }\n\n const cancellationMetadata = {\n ...selectedCancellation.calculationDetails,\n cancellationType: selectedOption,\n refundAmount: selectedCancellation.refundAmount,\n cancellationFee: selectedCancellation.cancellationFee,\n netRefund: selectedCancellation.netRefund,\n };\n\n const cancelledPolicyOrder = await sequelize.transaction(async (transaction) => {\n const cancelled = await PolicyOrderModel.create({ organizationId: policyOrder.organizationId }, {\n policyId: crypto.randomUUID(),\n rootPolicyId: policyOrder.rootPolicyId,\n quoteId: policyOrder.quoteId,\n previousPolicyId: policyOrder.policyId,\n transactionType: TransactionType.CANCELLATION,\n version: (policyOrder.version || 1) + 1,\n effectiveDate: selectedCancellation.effectiveDate,\n status: cancelledStatusCode,\n country: policyOrder.country,\n productName: policyOrder.productName,\n productPlanName: policyOrder.productPlanName,\n productId: policyOrder.productId,\n productPlanId: policyOrder.productPlanId,\n parameters: { ...policyOrder.parameters, cancellation: cancellationMetadata },\n insurance: policyOrder.insurance,\n product: policyOrder.product,\n network: policyOrder.network,\n requestHeaders: policyOrder.requestHeaders,\n requestBody: policyOrder.requestBody,\n paymentFrequency: policyOrder.paymentFrequency,\n policyNumber: policyOrder.policyNumber,\n inceptionDate: policyOrder.inceptionDate,\n issueDate: policyOrder.issueDate,\n expiryDate: selectedCancellation.newExpiryDate,\n cancellationDate: selectedCancellation.effectiveDate,\n cancelReason: cancelReason as string,\n cancelledBy: cancelledBy || 'customer',\n basePremium: policyOrder.basePremium,\n premiumWithoutTax: policyOrder.premiumWithoutTax,\n premiumWithTax: policyOrder.premiumWithTax,\n salesTax: policyOrder.salesTax,\n commissionTax: policyOrder.commissionTax ?? 0,\n currency: policyOrder.currency,\n commissionStructureId: policyOrder.commissionStructureId,\n baselineCommissionPercentage: policyOrder.baselineCommissionPercentage,\n dynamicCommissionPercentage: policyOrder.dynamicCommissionPercentage,\n variancePercentage: policyOrder.variancePercentage,\n allocationRuleId: policyOrder.allocationRuleId,\n totalCommissionAmount: policyOrder.totalCommissionAmount,\n partyAllocations: policyOrder.partyAllocations,\n originalPremiumWithoutTax: policyOrder.originalPremiumWithoutTax,\n originalPremiumWithTax: policyOrder.originalPremiumWithTax,\n billingEffectiveDate: selectedCancellation.effectiveDate,\n adjustmentPremium: -refundPremium,\n adjustmentTax: -refundTax,\n adjustmentCommission: -refundCommission,\n adjustmentCommissionTax: 0,\n clientId: policyOrder.clientId,\n customerId: policyOrder.customerId,\n renewalStatus: null,\n renewalStatusError: null,\n testMode: policyOrder.testMode,\n }, { transaction });\n\n await policyOrder.update({ status: supersededStatusCode }, { transaction });\n\n const cancellationProduct = await Product.findByPk(request, cancelled.productId!, { transaction });\n const cancellationProvider = cancellationProduct ? await resolvePaymentProvider(cancellationProduct, policyOrder.bundleSubscriptionId) : 'internal';\n // Only issue an external credit note when an external provider was\n // actually used to collect for this policy (externalPaymentRef set).\n // Policies created without an external charge (test/free/internal-only)\n // fall through to the internal invoice path so cancellation still\n // works — the lifecycle status change is the source of truth.\n const useExternalCredit =\n !policyOrder.testMode\n && isExternaThe file /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/controllers/policyOrderController.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Controller delegates to the shared function now. Before writing the public service, check the audit mechanism (the ticket requires "full audit") — read the audit plugin and look for any audit-write helper.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/plugins/auditLog.ts"}1 import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2 import fp from 'fastify-plugin';
3
4 import logger from '@/logger.js';
5 import { systemSequelize } from '@/models/db.js';
6
7 interface RequestWithStartTime extends FastifyRequest {
8 startTime?: number;
9 }
10
11 // Allow-list of URL prefixes whose mutations should be audited. Method filtering
12 // (POST/PUT/PATCH/DELETE only) is applied separately, so GETs naturally never land here.
13 const AUDIT_PATTERNS: RegExp[] = [
14 /^\/v1\/auth\//,
15 /^\/v1\/admin\/users\//,
16 /^\/v1\/admin\/products\//,
17 /^\/v1\/admin\/policies\//,
18 /^\/v1\/admin\/quotes\//,
19 /^\/v1\/products\//,
20 /^\/v1\/policies\//,
21 /^\/v1\/quotes\//,
22 /^\/v1\/customer-portal\//,
23 /^\/v1\/plus\//,
24 ];
25
26 // Endpoints inside the allow-list above that we explicitly do NOT want in AuditLogs:
27 // pure compute / preview / render endpoints called at high frequency that would otherwise
28 // drown out the meaningful signal. Add new entries here when a noisy POST is identified.
29 const AUDIT_SKIP_PATTERNS: RegExp[] = [
30 // Token refresh — pre-existing exclusion, fires on every navigation.
31 /^\/v1\/auth\/refresh-token$/,
32 // Pricing computation — called per-keystroke from quote forms.
33 /^\/v1\/plus\/pricing\/calculate(-coverage)?$/,
34 // Template render preview / validation — high-volume editor previews.
35 /^\/v1\/plus\/templates\/render\//,
36 /^\/v1\/plus\/templates\/validate$/,
37 // LLM completion — chatty, no persistent state mutation.
38 /^\/v1\/plus\/llm\/generate$/,
39 // Purchase-probability simulation runs — pure compute.
40 /^\/v1\/plus\/simulations\/purchase-probability$/,
41 ];
42
43 async function auditLogPlugin(fastify: FastifyInstance) {
44 fastify.addHook('onRequest', async (request: RequestWithStartTime) => {
45 request.startTime = Date.now();
46 });
47
48 fastify.addHook('onResponse', async (request: RequestWithStartTime, reply: FastifyReply) => {
49 const action = `${request.method} ${request.url}`;
50 let organizationId: string | undefined;
51 try {
52 const endTime = Date.now();
53 const startTime = request.startTime || endTime;
54 const duration = endTime - startTime;
55
56 if (request.url.startsWith('/v1/health')) {
57 return;
58 }
59
60 if (!shouldAudit(request.method, request.url)) {
61 return;
62 }
63
64 organizationId = request.ident?.org?.id || request.userAuth?.organizationId;
65 if (!organizationId) {
66 return;
67 }
68
69 await systemSequelize.query( // SAFE-SYSTEM: audit log write
70 `INSERT INTO "AuditLogs" ("organizationId", "userId", "action", "details", "ipAddress", "userAgent", "createdAt", "updatedAt")
71 VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())`,
72 {
73 bind: [
74 organizationId,
75 request.userAuth?.id || null,
76 action,
77 JSON.stringify({
78 statusCode: reply.statusCode,
79 duration,
80 // Surface route params (e.g. policyId, productId) as searchable JSON keys —
81 // previously they only existed in the URL string, forcing LIKE scans on `action`.
82 params: serializeParams(request.params),
83 body: obfuscateSensitiveData(request.body),
84 }),
85 request.ip,
86 request.headers['user-agent'] || '',
87 ],
88 }
89 );
90 } catch (error) {
91 // Use a structured event name so this is greppable / alertable in log aggregation.
92 logger.error(
93 {
94 err: error,
95 auditAction: action,
96 organizationId,
97 userId: request.userAuth?.id,
98 },
99 'audit_log_insert_failed',
100 );
101 }
102 });
103 }
104
105 function obfuscateSensitiveData(body: unknown): unknown {
106 if (!body || typeof body !== 'object' || body === null) {
107 return body;
108 }
109
110 const sensitiveFields = ['password', 'clientSecret', 'token', 'refreshToken', 'accessToken'];
111 const obfuscated = { ...(body as Record<string, unknown>) };
112
113 for (const field of sensitiveFields) {
114 if (field in obfuscated && obfuscated[field]) {
115 obfuscated[field] = '***';
116 }
117 }
118
119 return obfuscated;
120 }
121
122 function serializeParams(params: unknown): Record<string, unknown> | undefined {
123 if (!params || typeof params !== 'object') return undefined;
124 const entries = Object.entries(params as Record<string, unknown>);
125 if (entries.length === 0) return undefined;
126 return Object.fromEntries(entries);
127 }
128
129 function shouldAudit(method: string, url: string): boolean {
130 const auditMethods = ['POST', 'PUT', 'DELETE', 'PATCH'];
131 if (!auditMethods.includes(method)) {
132 return false;
133 }
134
135 // Pattern matches must run against the path only — querystring is irrelevant.
136 const path = url.split('?')[0];
137
138 if (AUDIT_SKIP_PATTERNS.some((pattern) => pattern.test(path))) {
139 return false;
140 }
141
142 return AUDIT_PATTERNS.some((pattern) => pattern.test(path));
143 }
144
145 export default fp(auditLogPlugin, {
146 name: 'auditLog',
147 }); Auto-audit early-returns for unauthenticated requests, so I'll write explicit audit entries. Confirm resolveOrgTimezone signature before writing the service.
{"pattern":"export (async )?function resolveOrgTimezone|export const resolveOrgTimezone","path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/utils/dateUtils.ts","output_mode":"content","-n":true,"-A":8}31:export async function resolveOrgTimezone(
32- request: { ident?: { org?: { timezone?: string } } },
33- organizationId: string,
34-): Promise<string> {
35- const fromIdent = request.ident?.org?.timezone;
36- if (fromIdent) return fromIdent;
37-
38- const { Organization } = await import('@/models/Organization.js');
39- const org = await Organization.findByPk(organizationId, { attributes: ['timezone'] });resolveOrgTimezone({}, orgId) works for public. Now write the public cancellation service.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/publicCancellationService.ts","content":"import crypto from 'crypto';\nimport { Op } from 'sequelize';\nimport logger from '@/logger.js';\nimport { StatusError } from '@/error/error.js';\nimport { systemSequelize } from '@/models/db.js';\nimport { PolicyOrder } from '@/models/PolicyOrder.js';\nimport type { PolicyOrder as PolicyOrderInstance } from '@/models/PolicyOrder.js';\nimport { Customer } from '@/models/Customer.js';\nimport {\n PublicCancellationRequest,\n PublicCancellationStatus,\n} from '@/models/PublicCancellationRequest.js';\nimport { PolicyStatusLifecycleCategory } from '@/models/OrganizationPolicyStatus.js';\nimport { getCodesForCategories } from '@/services/policyStatusService.js';\nimport { previewCancellationOptions, type CancellationPreview } from '@/services/cancellationService.js';\nimport { executeCancellation } from '@/services/policyCancellationExecution.js';\nimport { sendEmail } from '@/services/emailService.js';\nimport { resolveOrgTimezone, getBusinessDate } from '@/utils/dateUtils.js';\nimport { getPortalUrl } from 'lib.js';\n\n// Single-use verification token lifetime. Long enough to read an email and\n// click through, short enough that a leaked link goes stale quickly.\nconst TOKEN_TTL_MS = 30 * 60 * 1000;\n\n// Identical response for every initiate call regardless of whether a policy\n// matched — the anti-enumeration guarantee. The verification link is the real\n// gate: it is only ever delivered to the email on file for a genuine match.\nconst GENERIC_INITIATE_RESULT = {\n status: 'sent' as const,\n message: 'If a matching policy was found, a confirmation link has been sent to the email on file.',\n};\n\nexport type InitiateResult = typeof GENERIC_INITIATE_RESULT;\n\nfunction hashToken(rawToken: string): string {\n return crypto.createHash('sha256').update(rawToken).digest('hex');\n}\n\n/** Mask an email for display on the confirm screen: [email protected] -> ja••@x.com */\nfunction maskEmail(email: string): string {\n const [local, domain] = email.split('@');\n if (!domain) return '••';\n const visible = local.slice(0, 2);\n return `${visible}${'•'.repeat(Math.max(2, local.length - 2))}@${domain}`;\n}\n\nfunction normaliseSurname(value: string): string {\n return value.trim().toLowerCase();\n}\n\nasync function writeAudit(\n organizationId: string,\n customerId: string | null,\n action: string,\n details: Record<string, unknown>,\n ipAddress: string | null,\n userAgent: string | null,\n): Promise<void> {\n try {\n await systemSequelize.query( // SAFE-SYSTEM: audit log write for login-less flow (no request identity)\n `INSERT INTO \"AuditLogs\" (\"organizationId\", \"userId\", \"customerId\", \"action\", \"details\", \"ipAddress\", \"userAgent\", \"createdAt\", \"updatedAt\")\n VALUES ($1, NULL, $2, $3, $4, $5, $6, NOW(), NOW())`,\n { bind: [organizationId, customerId, action, JSON.stringify(details), ipAddress, userAgent] },\n );\n } catch (err) {\n logger.error({ err, auditAction: action, organizationId }, 'audit_log_insert_failed');\n }\n}\n\nasync function findCancellablePolicyByNumber(\n organizationId: string,\n policyNumber: string,\n): Promise<PolicyOrderInstance | null> {\n const cancellableStatusCodes = await getCodesForCategories(organizationId, [\n PolicyStatusLifecycleCategory.ACTIVE,\n PolicyStatusLifecycleCategory.SUSPENDED,\n ]);\n return PolicyOrder.findOne({ organizationId }, {\n where: { policyNumber, status: { [Op.in]: cancellableStatusCodes } },\n order: [['version', 'DESC']],\n });\n}\n\nasync function findCancellablePolicyByRoot(\n organizationId: string,\n rootPolicyId: string,\n): Promise<PolicyOrderInstance | null> {\n const cancellableStatusCodes = await getCodesForCategories(organizationId, [\n PolicyStatusLifecycleCategory.ACTIVE,\n PolicyStatusLifecycleCategory.SUSPENDED,\n ]);\n return PolicyOrder.findOne({ organizationId }, {\n where: {\n [Op.or]: [{ rootPolicyId }, { policyId: rootPolicyId }],\n status: { [Op.in]: cancellableStatusCodes },\n },\n order: [['version', 'DESC']],\n });\n}\n\nexport interface InitiateInput {\n organizationId: string;\n policyNumber: string;\n surname: string;\n ipAddress: string | null;\n userAgent: string | null;\n protocol: string;\n locale: string;\n}\n\n/**\n * Step 1 — look up the policy by number + surname and, ONLY on a genuine match,\n * mail a single-use confirmation link to the address on file. Always returns the\n * same generic result so an invalid lookup leaks nothing (anti-enumeration).\n */\nexport async function initiatePublicCancellation(input: InitiateInput): Promise<InitiateResult> {\n const { organizationId, ipAddress, userAgent, protocol, locale } = input;\n const policyNumber = input.policyNumber.trim();\n const surname = input.surname.trim();\n\n if (!policyNumber || !surname) {\n return GENERIC_INITIATE_RESULT;\n }\n\n const policyOrder = await findCancellablePolicyByNumber(organizationId, policyNumber);\n if (!policyOrder) {\n logger.info({ organizationId }, 'public_cancellation: no cancellable policy for lookup');\n return GENERIC_INITIATE_RESULT;\n }\n\n if (!policyOrder.customerId) {\n return GENERIC_INITIATE_RESULT;\n }\n\n const customer = await Customer.findByPk({ organizationId }, policyOrder.customerId);\n if (!customer || !customer.email || !customer.lastName) {\n return GENERIC_INITIATE_RESULT;\n }\n\n if (normaliseSurname(customer.lastName) !== normaliseSurname(surname)) {\n return GENERIC_INITIATE_RESULT;\n }\n\n const rootPolicyId = policyOrder.rootPolicyId || policyOrder.policyId;\n\n // Retire any earlier still-pending request for this policy so only the latest\n // emailed link is live.\n await PublicCancellationRequest.update(\n { organizationId },\n { status: PublicCancellationStatus.EXPIRED },\n { where: File created successfully at: /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/publicCancellationService.ts (file state is current in your context — no need to Read it back)
Fix dead code in initiate (void locale after return; locale unused there).
{"replace_all":false,"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/publicCancellationService.ts","old_string":" const { organizationId, ipAddress, userAgent, protocol, locale } = input;\n const policyNumber = input.policyNumber.trim();","new_string":" const { organizationId, ipAddress, userAgent, protocol } = input;\n const policyNumber = input.policyNumber.trim();"}The file /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/publicCancellationService.ts has been updated successfully. (file state is current in your context — no need to Read it back)
{"replace_all":false,"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/publicCancellationService.ts","old_string":" logger.info({ organizationId, rootPolicyId }, 'public_cancellation: verification link sent');\n return GENERIC_INITIATE_RESULT;\n void locale;\n}","new_string":" logger.info({ organizationId, rootPolicyId }, 'public_cancellation: verification link sent');\n return GENERIC_INITIATE_RESULT;\n}"}The file /Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/services/publicCancellationService.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Now the public routes (TypeBox, rate-limited, no auth). Mirror an existing public route's style first.
{"file_path":"/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance/src/routes/addressRoutes.ts"}1 import { FastifyInstance, FastifyPluginAsync } from 'fastify';
2 import { getAutocomplete, getDetails } from '@/controllers/addressController.js';
3 import { autocompleteRateLimit } from '@/utils/rateLimit.js';
4
5 export const addressRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => {
6 // GET /v1/address/autocomplete - Get address suggestions
7 fastify.get<{ Querystring: { input: string; countries?: string } }>(
8 '/autocomplete',
9 {
10 preHandler: [fastify.authenticate],
11 config: {
12 rateLimit: autocompleteRateLimit,
13 },
14 schema: {
15 description:
16 'Returns address autocomplete suggestions sourced from Google Places API. Backed by per-widget session tokens to keep Google billing optimal — call this endpoint as the user types, then call GET /v1/address/details with the chosen placeId to complete the session. Inputs shorter than 3 characters return an empty list without calling Google. Authentication: widget token only (issued from the embed funnel).',
17 tags: ['Address Lookup'],
18 security: [{ widgetToken: [] }],
19 querystring: {
20 type: 'object',
21 properties: {
22 input: {
23 type: 'string',
24 description:
25 'User-typed search string (street, city, address fragment). Minimum 3 characters — shorter inputs return an empty suggestions list without contacting Google.',
26 example: '1600 Amphitheatre',
27 },
28 countries: {
29 type: 'string',
30 description:
31 'Optional comma-separated list of ISO 3166-1 alpha-2 country codes (case-insensitive) used to restrict results geographically. Example: "us,ca,mx".',
32 example: 'us,ca',
33 },
34 },
35 required: ['input'],
36 },
37 response: {
38 200: {
39 type: 'object',
40 additionalProperties: true,
41 description: 'Suggestions returned by Google Places autocomplete.',
42 properties: {
43 suggestions: {
44 type: 'array',
45 description:
46 'Ordered list of place suggestions. Empty when input is shorter than 3 characters or no matches were found.',
47 items: {
48 type: 'object',
49 additionalProperties: true,
50 properties: {
51 placeId: {
52 type: 'string',
53 description:
54 'Google Place ID. Pass to GET /v1/address/details to retrieve the structured address.',
55 example: 'ChIJ2eUgeAK6j4ARbn5u_wAGqWA',
56 },
57 mainText: {
58 type: 'string',
59 description:
60 'Primary line of the suggestion (typically the street/place name).',
61 example: '1600 Amphitheatre Parkway',
62 },
63 secondaryText: {
64 type: 'string',
65 description:
66 'Secondary line of the suggestion (typically city, region, country). May be an empty string when Google does not provide one.',
67 example: 'Mountain View, CA, USA',
68 },
69 fullText: {
70 type: 'string',
71 description: 'Full suggestion text combining main and secondary lines.',
72 example: '1600 Amphitheatre Parkway, Mountain View, CA, USA',
73 },
74 },
75 },
76 },
77 },
78 },
79 },
80 },
81 },
82 getAutocomplete
83 );
84
85 // GET /v1/address/details - Get place details by placeId
86 fastify.get<{ Querystring: { placeId: string } }>(
87 '/details',
88 {
89 preHandler: [fastify.authenticate],
90 schema: {
91 description:
92 'Resolves a Google Place ID (returned by GET /v1/address/autocomplete) into a structured address plus a country-aware field layout for rendering form inputs. Calling this endpoint also closes the current Places autocomplete billing session for the widget token. Authentication: widget token only.',
93 tags: ['Address Lookup'],
94 security: [{ widgetToken: [] }],
95 querystring: {
96 type: 'object',
97 properties: {
98 placeId: {
99 type: 'string',
100 description:
101 'Google Place ID copied from the suggestions list returned by GET /v1/address/autocomplete.',
102 example: 'ChIJ2eUgeAK6j4ARbn5u_wAGqWA',
103 },
104 },
105 required: ['placeId'],
106 },
107 response: {
108 200: {
109 type: 'object',
110 additionalProperties: true,
111 description:
112 'Structured address components, country-aware field layout, and the formatted address string returned by Google.',
113 properties: {
114 address: {
115 type: 'object',
116 additionalProperties: true,
117 description:
118 'Parsed address components. Any field Google does not return is provided as an empty string (never null/undefined).',
119 properties: {
120 streetNumber: {
121 type: 'string',
122 description: 'House/building number, e.g. "1600". Empty string when not available.',
123 example: '1600',
124 },
125 route: {
126 type: 'string',
127 description: 'Street/route name, e.g. "Amphitheatre Parkway".',
128 example: 'Amphitheatre Parkway',
129 },
130 streetAddress: {
131 type: 'string',
132 description:
133 'Convenience field combining streetNumber and route ("1600 Amphitheatre Parkway").',
134 example: '1600 Amphitheatre Parkway',
135 },
136 city: {
137 type: 'string',
138 description:
139 'City or locality. Falls back to administrative_area_level_2 when Google does not provide a locality.',
140 example: 'Mountain View',
141 },
142 state: {
143 type: 'string',
144 description: 'Long state/region name (administrative_area_level_1).',
145 example: 'California',
146 },
147 stateCode: {
148 type: 'string',
149 description:
150 'Short state/region code (e.g. "CA" in the US, "BY" in Germany). Empty string when not available.',
151 example: 'CA',
152 },
153 country: {
154 type: 'string',
155 description: 'Long country name as returned by Google.',
156 example: 'United States',
157 },
158 countryCode: {
159 type: 'string',
160 description: 'ISO 3166-1 alpha-2 country code.',
161 example: 'US',
162 },
163 postalCode: {
164 type: 'string',
165 description: 'Postal/ZIP code. Empty string when not provided by Google.',
166 example: '94043',
167 Routes use raw JSON Schema (not Type.). Now write the public routes file.
API Error: Overloaded
API Error: Overloaded
2026-06-01T08:38:27 board status → In progress; assigned @me 2026-06-01T08:38:27 start sherpa-api#461 → branch auto/api-461-no-login-public-withdrawal-cance 2026-06-01T08:38:32 DELETE any existing worktree set for auto/api-461-no-login-public-withdrawal-cance (clean slate) 2026-06-01T08:39:12 POST /api/create auto/api-461-no-login-public-withdrawal-cance (synchronous; builds api image + starts servers) 2026-06-01T08:40:01 stack ready api=4012 portal=3012 embed=3112 target=/Users/bakker/dev/gangkhar/sherpa-api-api-461-no-login-public-withdrawal-cance 2026-06-01T08:40:01 invoking claude (model=claude-opus-4-8[1m], timeout=7200s) ./config.sh: line 96: 53085 Terminated: 15 ( sleep "$TIMEOUT"; kill -TERM "$cpid" 2> /dev/null ) 2026-06-01T09:01:58 FAIL sherpa-api#461: claude exited rc=1 (timed out or errored)
[start 2026-06-01T15:41:09.882Z] docker compose exec -T api npm install > Sherpa [email protected] prepare > husky git command not found added 84 packages, removed 2 packages, changed 5 packages, and audited 934 packages in 4s 221 packages are looking for funding run `npm fund` for details 5 moderate severity vulnerabilities To address issues that do not require attention, run: npm audit fix Some issues need review, and may require choosing a different dependency. Run `npm audit` for details.
[start 2026-06-01T15:43:48.611Z] docker compose exec -T api npm run db:migrate:up > Sherpa [email protected] db:migrate:up > tsx src/db/migrate.ts up
[start Mon Jun 1 06:36:12 PDT 2026] > [email protected] dev > vite [32m[1mVITE[22m v7.3.2[39m [2mready in [0m[1m1591[22m[2m[0m ms[22m [32m➜[39m [1mLocal[22m: [36mhttp://localhost:[1m3112[22m/[39m [32m➜[39m [1mNetwork[22m: [36mhttp://192.168.139.3:[1m3112[22m/[39m [32m➜[39m [1mNetwork[22m: [36mhttp://192.168.107.0:[1m3112[22m/[39m [32m➜[39m [1mNetwork[22m: [36mhttp://192.168.97.0:[1m3112[22m/[39m [32m➜[39m [1mNetwork[22m: [36mhttp://192.168.0.107:[1m3112[22m/[39m [start Mon Jun 1 08:39:32 PDT 2026] > [email protected] dev > vite [32m[1mVITE[22m v7.3.2[39m [2mready in [0m[1m709[22m[2m[0m ms[22m [32m➜[39m [1mLocal[22m: [36mhttp://localhost:[1m3112[22m/[39m [32m➜[39m [1mNetwork[22m: [36mhttp://192.168.139.3:[1m3112[22m/[39m [32m➜[39m [1mNetwork[22m: [36mhttp://192.168.107.0:[1m3112[22m/[39m [32m➜[39m [1mNetwork[22m: [36mhttp://192.168.97.0:[1m3112[22m/[39m [32m➜[39m [1mNetwork[22m: [36mhttp://192.168.0.10:[1m3112[22m/[39m [32m➜[39m [1mNetwork[22m: [36mhttp://192.168.0.107:[1m3112[22m/[39m
[start Mon Jun 1 06:36:12 PDT 2026] > [email protected] dev > next dev --turbopack [1m[38;2;173;127;168m▲ Next.js 16.2.6[39m[22m (Turbopack) - Local: http://localhost:3012 - Network: http://192.168.139.3:3012 - Environments: .env [32m[1m✓[22m[39m Ready in 912ms [@sentry/nextjs] DEPRECATION WARNING: disableLogger is deprecated and will be removed in a future version. Use webpack.treeshake.removeDebugLogging instead. (Not supported with Turbopack.) [@sentry/nextjs] DEPRECATION WARNING: automaticVercelMonitors is deprecated and will be removed in a future version. Use webpack.automaticVercelMonitors instead. (Not supported with Turbopack.) - Experiments (use with caution): · clientTraceMetadata [?25h [start Mon Jun 1 08:39:32 PDT 2026] > [email protected] dev > next dev --turbopack [1m[38;2;173;127;168m▲ Next.js 16.2.6[39m[22m (Turbopack) - Local: http://localhost:3012 - Network: http://192.168.139.3:3012 - Environments: .env [32m[1m✓[22m[39m Ready in 233ms [@sentry/nextjs] DEPRECATION WARNING: disableLogger is deprecated and will be removed in a future version. Use webpack.treeshake.removeDebugLogging instead. (Not supported with Turbopack.) [@sentry/nextjs] DEPRECATION WARNING: automaticVercelMonitors is deprecated and will be removed in a future version. Use webpack.automaticVercelMonitors instead. (Not supported with Turbopack.) - Experiments (use with caution): · clientTraceMetadata Browserslist: browsers data (caniuse-lite) is 6 months old. Please run: npx update-browserslist-db@latest Why you should do it regularly: https://github.com/browserslist/update-db#readme [37m[1m○[22m[39m Compiling / ... [draftStore] module loaded, id: tvhy GET / [32m200[39m in 5.2s[2m (next.js: 4.9s, proxy.ts: 81ms, application-code: 227ms)[22m