Jinn deploy - #6540
Conversation
Add POST /api/user/login/email that signs in by email alone. First-time emails auto-create the user with random username/password + the configured QuotaForNewUser trial credits; existing emails log in. No verification step — gated by new EmailOnlyLoginEnabled admin flag (default true). Beta-only posture: anyone who knows an email can sign in as that user. Disable the flag before any external distribution. - common/constants.go: EmailOnlyLoginEnabled flag - model/option.go: wire flag into InitOptionMap + updateOptionMap - controller/user.go: LoginByEmail handler + emailLocalPart helper - router/api-router.go: route registration - i18n: new MsgUserEmailOnlyLoginDisabled (en/zh-CN/zh-TW) - CLAUDE.md: drop unused project-protection rule Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add POST /api/user/login/email/{request,verify}-code for Claude-desktop
parity: client posts email, server emails a 6-digit code, client posts
{email,code} to verify and receive a session. First-time emails
auto-create the user on verify-code. request-code returns success:true
regardless of email existence to prevent enumeration. Per-email resend
cooldown (default 30s) layered on the per-IP rate-limit middleware;
wrong-code attempts capped (default 3) before the code is invalidated.
V0 default flipped off: EmailOnlyLoginAutoCreateNoVerify=false. V0
remains controllable via the admin flag once all desktops ship V1.
- common/constants.go: V1 TTL/attempts/cooldown + V0-off default;
EmailOnlyLoginEnabled promoted to master switch (gates V0 + V1)
- common/verification.go: EmailLoginPurpose = "el"
- controller/user.go: RequestEmailLoginCode + VerifyEmailLoginCode
- model/option.go: wire new flags; fix latent suffix-check bug that
prevented EmailOnlyLoginAutoCreateNoVerify from ever loading from DB
- router/api-router.go: register the two new routes
- i18n: MsgEmailLoginCode{Sent,RateLimited,Invalid,TooManyAttempts}
- Dockerfile: GOPROXY=goproxy.cn for HK/CN builds
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The V1 sign-in code was the first 6 chars of a stripped UUID, i.e. hex chars (0-9, a-f). The client OTP input filters via /\D/g, so any letter chars in a generated code got silently dropped — users would type all 6 received chars and see only the digits land in the boxes. Switch to a numeric-only generator (crypto/rand) so the code matches the "6-digit code" promise in the UI and the digit-only input filter. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Document the new deploy path: git push → ssh deploy.sh → ~30s. Includes server layout, frontend-dist handover, rollback, and why the upload-based upgrade.sh path is kept only as a fallback. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… group
- VerifyEmailLoginCode now sets Group="free" on auto-create so JINN
email sign-ins land on the free-tier channels (1-6) immediately
instead of the unrouted "default" group.
- User.Edit now skips empty-string username/display_name/remark in the
partial-update map. Previous behavior wiped those fields on any PUT
that didn't include them (e.g. `{"id":N,"group":"plus"}` to promote
a user would clear their username and display_name).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wrap the email-login code template in a full HTML document and add more body text — bare <div> bodies trip SpamAssassin HTML_MIME_NO_HTML_TAG (+0.6) and short image-bearing bodies trip HTML_IMAGE_ONLY_08 (+1.8) once Mailtrap injects its tracking pixel. Also add the missing MIME-Version header in SendEmail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…or rest Browsers reject Access-Control-Allow-Origin: * when a request carries credentials, which silently breaks the JINN Excel taskpane's cookie-authed login (/api/user/login/*, /api/token/*, /api/user/self). Echo the specific Origin + Allow-Credentials only for an allowlist of trusted JINN taskpane origins; all other browser origins keep the prior open, non-credentialed relay access unchanged. Same-origin admin UI bypasses CORS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The /api group set up gzip/rate-limit but never applied middleware.CORS(), so actual /api/* responses lacked Access-Control-Allow-Origin (only the OPTIONS preflight carried it via the global handler). Browsers require the header on the real response too, so the JINN taskpane's cookie-authed login (/api/user/login/*, /api/token/*, /api/user/self) was blocked despite a passing preflight. The /v1 relay group already does this; mirror it for /api. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The session cookie was SameSite=Strict, so the browser never sent it on
cross-origin requests. The JINN Excel taskpane (localhost:3000 / hosted →
apijinn) signs in with a session cookie, then mints its sk- key via cookie-
authed /api/token/* + /api/user/self — those came through unauthenticated under
Strict ("not logged in, no access token"). Switch to SameSite=None with
Secure=true (required for None; satisfied by prod HTTPS via Caddy).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-origin" This reverts commit 89eb328.
Office webviews (WKWebView/WebView2) refuse cross-registrable-domain third-party cookies, so the JINN Excel add-in's cross-origin session cookie can't authenticate the follow-up token-mint calls. Return the user's system access token in the login response so non-browser surfaces (desktop, Excel add-in) can authenticate via the Authorization header instead (UserAuth already accepts it). Add ensureAccessToken() to lazily generate+persist a token without clobbering an existing one. The browser dashboard ignores the field and keeps using the cookie. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- setting/payment_airwallex.go + option wiring (Enabled/ClientId/ApiKey/WebhookSecret/ApiBase) - service/airwallex: REST client with token cache; customers, consents, managed subscriptions, payment intents, HPP checkout URLs; unit tests - controller/subscription_payment_airwallex.go: /api/subscription/airwallex/pay (method-aware: card/applepay/googlepay live, alipay/wechat dormant behind active method types), /cancel (proration NONE, access to period end), /api/airwallex/webhook (HMAC-verified; consent.verified -> create subscription, subscription.active -> complete order / monthly renewal order, payment_intent.succeeded -> one-off completion) - SubscriptionPlan.AirwallexPriceId column; PaymentProviderAirwallex + method constants; model.GetLatestPendingSubscriptionOrder - RecurringCharger seam (agreement-based rails for CN edition) wired into the subscription lifecycle ticker with a daily gate; no-op for Airwallex Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Upstream unconditionally overwrote Plan.Currency to USD after the empty-string default; JINN plan rows are CNY and the Airwallex processor passes plan.Currency to hosted checkout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ecurringOptions The recurring Hosted Payment Page lives at /#/standalone/recurring and only reads consent settings from a JSON recurringOptions query param; flat next_triggered_by/merchant_trigger_reason params are dropped by the page's param whitelist, so consents came back customer-triggered and the card form rejected input (verified against elements.bundle.min.js 1.160.0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ethod Live API (x-api-version 2025-02-14) rejects creates without them; the published schema.json predates both fields. billing_customer_id = the PA customer, collection_method = AUTO_CHARGE (consent-backed merchant renewals). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Portal (account.jinn.ccwu.cc:8444) supplies its own post-checkout landing page; origin validated against the JINN trusted-origins list (also added to credentialed CORS), untrusted/absent falls back to the console path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Third undocumented required field on the live subscriptions/create API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t flow The unpinned live API routes subscriptions/create to the new Billing product, which demands bcus_ billing customers, psrc_ payment sources (which in turn reject scheduled consents), and collection_method. Pinning x-api-version on /api/v1/subscriptions/* validates the published-schema shape end-to-end; reverts the two speculative field additions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion for trade_no metadata Airwallex delivers subscription.active with a reduced object that omits metadata, so the handler never saw trade_no and dropped the order. When metadata is missing, fetch the subscription by id (pinned api version) and read trade_no from the full record. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion) Real demo checkout/invoice objects wrapped in the webhook envelope. Findings: billing_checkout.completed carries metadata.trade_no directly; invoice.paid has NO metadata -> resolve trade_no via subscription_id. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add AirwallexBillingCustomer table + GetAirwallexBillingCustomerId / SaveAirwallexBillingCustomerId (upsert). Required because Billing customers API has no merchant_customer_id filter; id must be stored to reuse across checkout, renewal, and cancellation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace legacy GenerateCustomerClientSecret/RecurringCheckoutURL tail of genAirwallexSubscriptionLink with a hosted Billing Checkout (SUBSCRIPTION mode). Persists/reuses the bcus_ customer id. Both Metadata and SubscriptionData.metadata carry trade_no + new_api_user_id so billing_checkout.completed and invoice.paid webhooks can both resolve the order. WeChat one-off branch unchanged; legacy helpers left intact for Task 10 cleanup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…newal handlers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The admin PUT /subscription/admin/plans/:id updateMap omitted airwallex_price_id, so plan rows could never be repointed to a Billing (2026-02-27) price via the API — blocking the Billing migration cutover. Add it alongside the other processor price ids. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The admin plan-edit form has no airwallex_price_id input, so a normal UI save submits it empty. Writing it unconditionally in updateMap (prev commit) would blank the Billing price on the next edit -> charged-but-not-upgraded. Only write it when non-empty; the cutover script always sends the pri_ id. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Airwallex rejects return_url when ui_mode=HOSTED (validation_error), so the subscribe call 400'd and returned no checkout url -> frontend showed "no pay_link in response". HOSTED checkouts use success_url for the post-payment redirect; this matches billing_test.go's known-good shape. Removes the stray ReturnUrl field. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ebhook SUBSCRIPTION-mode billing checkouts persist our metadata on the managed subscription (subscription_data.metadata), not the checkout object. The real billing_checkout.completed webhook is slim and the re-fetched checkout carries no trade_no, so the handler logged "无 trade_no metadata,忽略" and never upgraded the user despite a successful charge (2026-07-08 charged-but-not-upgraded, user blinknycshop). Mirror the invoice.paid handler: fall back to the subscription's metadata (subscription_id taken from the event, else the re-fetched checkout). Add getBillingCheckout seam + a truly-slim fixture/test that exercises event -> checkout re-fetch -> subscription resolution. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…data Close the last gap in the billing_checkout.completed metadata fallback: SUBSCRIPTION-mode checkouts may surface trade_no under the checkout's subscription_data.metadata rather than top-level metadata. Parse it off the re-fetched checkout so the handler no longer depends on where Airwallex chooses to echo our metadata. Chain is now: event-inline metadata -> checkout.metadata -> checkout.subscription_data.metadata -> subscription.metadata. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Airwallex Payment Acceptance events nest the resource under data.object, but Billing events (billing_checkout.*, invoice.*, subscription.*) place the resource fields directly under data with no object wrapper. The event struct read only data.object, so every Billing event yielded an empty object -> no trade_no/id/subscription_id -> handler logged "无 trade_no metadata,忽略" and returned 200. This made the prior re-fetch fallbacks unreachable (checkoutId empty -> re-fetch skipped) and caused the charged-but-not-upgraded incidents on the Billing path. - Add airwallexEventData.UnmarshalJSON to normalize both envelope shapes (use data.object when present, else data itself). One parser fix repairs billing_checkout.completed, invoice.payment.paid, and the intent handler uniformly. - Route invoice.payment.paid (Airwallex's actual event name) in addition to invoice.paid, so renewals are no longer silently dropped. - Rewrite the 3 webhook fixtures to the real data-direct envelope (they were hand-written with the wrong data.object shape, which is why the existing tests passed against a payload Airwallex never sends). - Add TestAirwallexWebhookBillingCheckoutCompletedEndToEnd: a signed end-to-end regression through parse -> route -> handler. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 2026-02-27 Billing flow (billing.go) fully replaced the legacy consent-based /api/v1/subscriptions/* flow, leaving the 2024 layer unreachable. Remove it: - 5 legacy Subscriptions API funcs: Create/Get/Cancel/List/UpdateSubscription - 3 orphaned consent helpers: GetCustomer, GenerateCustomerClientSecret, ListVerifiedConsents - orphaned types: Subscription, SubscriptionItem, SubscriptionRecurring, CreateSubscriptionRequest, PaymentConsent - unreachable subscriptionApiVersion (2024-09-27) pin + its do() switch branch Tests: drop TestCreateSubscriptionSendsVerifiedShape; retarget the generic error-mapping test to CreateCustomer. WeChat one-off path and the 2026 Billing layer are untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nvoice Two webhook renewal bugs, both event-ordering hazards: 1. First-cycle invoice.payment.paid arriving AFTER billing_checkout.completed was misclassified as a renewal (the old check inferred cycle from local order status), minting a second UserSubscription for one payment. Now the invoice itself is classified: per-subscription sequence -0001 in `number`, or created_at within 7 days of the original order. 2. Renewal idempotency was keyed on the processing month (-rYYYYMM): a retry straddling a month boundary double-charged, and two invoices processed in one month collapsed. Renewal trade_no now embeds the invoice id (-r-inv_*); events without an invoice id are logged and ignored. Replaces TestInvoicePaidRenewsAfterFirstCycle (which encoded the buggy spec) with five ordering/idempotency tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolve the plan from the subscription's live price id so a monthly->annual switch grants the annual plan, and let a plan change override the first-cycle invoice skip (a switch within 7 days of signup was being dropped).
…ion tests Reject the switch when the resolved current plan is already on an annual duration (nothing in the schema stops two enabled annual plans sharing an upgrade_group, so this was reachable despite the endpoint being monthly->annual only). Also adds test coverage for plan-not-found, plan-disabled, and empty-AirwallexPriceId target validation, which had no dedicated tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y-safe expire+repoint The invoice.paid handler silently fell back to the original plan whenever the subscription-items lookup or the per-item price->plan resolution errored. For an ordinary renewal that's the right call, but for a plan-switch proration invoice it granted another month of the OLD plan on an invoice that's already been charged at the annual rate and can never be reprocessed (keyed by invoice id). Both lookup failures now return an error so the webhook 500s and Airwallex retries delivery; the genuinely-unmapped-price case still falls back safely. Also reordered the plan-change branch so ExpireSupersededUserSubscriptions runs before the anchor order's PlanId repoint. These are two separate unlocked writes; running expire first means a failure between them leaves orig.PlanId unchanged, so a webhook retry recomputes planChanged=true and re-runs both steps (expire is idempotent) instead of permanently stranding a duplicate active subscription. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
WalkthroughThis PR adds email-based authentication with verification codes and access tokens, introduces Airwallex payment and subscription billing with signed webhook reconciliation, adds recurring-charge scheduling, updates CORS and persistence behavior, and documents deployment procedures. ChangesEmail authentication
Airwallex billing
Platform and deployment
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✨ Finishing Touches 💡 1⚔️ Resolve merge conflicts 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
CLAUDE.md (1)
109-122: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winRestore or explicitly replace the protected-project guardrail.
The change removes the
Protected Project Information — DO NOT Modify or Deleterule. Unless an equivalent policy exists elsewhere, future AI-assisted edits can modify or delete protected files and configuration without this repository-level safeguard.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@CLAUDE.md` around lines 109 - 122, Restore the “Protected Project Information — DO NOT Modify or Delete” rule in CLAUDE.md, or replace it with an equivalent repository-level safeguard that clearly identifies protected files and configuration and prohibits modifying or deleting them. Ensure the guardrail remains prominent and enforceable for AI-assisted edits.controller/user.go (1)
330-348: 🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy liftEvery login response now leaks the system management access token, including browser password logins.
setupLoginis shared byLogin,Verify2FALogin, passkey and OAuth flows, so the long-lived, non-expiringaccess_token— the credential that authorizes admin/user management endpoints — is now returned in the JSON body of every successful browser login and becomes reachable to any script on the page. The session cookie is HttpOnly precisely to avoid this; a single XSS now yields a persistent credential that survives logout. It also silently mints tokens for users who never requested one.Scope this to the clients that actually need it (e.g. only when the request carries the desktop/Office client marker, or a dedicated
/api/user/login/email/verify-coderesponse field) rather than the shared helper.🔒 Sketch
- accessToken := ensureAccessToken(user) + // Only non-browser surfaces that cannot carry the session cookie need this. + var accessToken string + if wantsAccessToken(c) { + accessToken = ensureAccessToken(user) + }with
wantsAccessTokengating on an explicit client header/flag set by the desktop and Office webview clients.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/user.go` around lines 330 - 348, Update the shared login response flow around setupLogin so accessToken is minted and included in data.access_token only when an explicit desktop/Office client marker or equivalent wantsAccessToken flag is present. Keep browser password, 2FA, passkey, and OAuth responses cookie-only, and ensure clients that do not request the token do not trigger ensureAccessToken(user).
🟠 Major comments (22)
service/subscription_recurring_test.go-17-22 (1)
17-22: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick winUse
testify/requirefor fatal assertions.Replace the
t.Fatalf/t.Fatalassertions in this file withrequirechecks, and wrap the empty-registry no-panic expectation in the new test withrequire.NotPanics.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@service/subscription_recurring_test.go` around lines 17 - 22, Replace all t.Fatal/t.Fatalf assertions in service/subscription_recurring_test.go with equivalent testify/require checks, adding the required import. In TestChargeDueAgreementSubscriptionsNoopWhenEmpty, wrap ChargeDueAgreementSubscriptions with require.NotPanics to explicitly enforce the no-panic expectation.Source: Coding guidelines
controller/subscription_payment_airwallex.go-159-182 (1)
159-182: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winInsert the pending order before creating the Airwallex checkout.
If
order.Insert()fails aftergenAirwallexSubscriptionLinksucceeded, the hosted checkout already exists and the user can pay — but no local order row exists, sobilling_checkout.completedwill hit the "订单未找到,忽略" path and silently drop a paid charge (the same failure class the fixtures in this PR were written to regress). Inserting the pending order first (and cleaning up / leaving it pending on link failure) keeps the webhook resolvable.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/subscription_payment_airwallex.go` around lines 159 - 182, Move the pending SubscriptionOrder creation and order.Insert call before genAirwallexSubscriptionLink in the subscription payment flow. If link generation fails, retain or clean up the inserted pending order according to existing conventions, while ensuring successful checkouts always have a resolvable local order for billing_checkout.completed.middleware/cors.go-16-20 (1)
16-20: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winCredentialed CORS allowlist is compiled in and includes
https://localhost:3000/3001.These entries ship to production, so any page an attacker can get running on the victim's
localhost:3000/3001(dev server, malicious local app, a compromised local tool) can issue cookie-authenticated requests to/api/user/self,/api/token/*, etc. The same list also gates payment return URLs viaTrustedBrowserOrigin. Recommend sourcing this from configuration/env and gating the localhost entries behind non-production mode.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@middleware/cors.go` around lines 16 - 20, Update trustedCredentialedOrigins so credentialed CORS origins are sourced from configuration or environment rather than compiled into the binary, and ensure localhost:3000 and localhost:3001 are added only in non-production mode. Preserve the production account portal origin and ensure the same configured allowlist remains available to TrustedBrowserOrigin for payment return-URL validation.middleware/cors.go-36-43 (1)
36-43: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Vary: Originmust be set on the wildcard branch too.The response body/headers differ per origin (echoed origin + credentials vs
*), butVary: Originis only emitted for trusted origins. A shared cache can therefore serve a trusted origin's credentialedAccess-Control-Allow-Originto another origin, or cache*for a trusted origin and break its credentialed flow.🛡️ Proposed fix
origin := c.Request.Header.Get("Origin") if origin != "" { + c.Header("Vary", "Origin") if trustedCredentialedOrigins[origin] { c.Header("Access-Control-Allow-Origin", origin) c.Header("Access-Control-Allow-Credentials", "true") - c.Header("Vary", "Origin") } else { c.Header("Access-Control-Allow-Origin", "*") }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@middleware/cors.go` around lines 36 - 43, Update the origin-handling branch in the CORS middleware so the wildcard response also sets the `Vary` header to `Origin`. Ensure every non-empty-origin response path, including the trusted credentialed and untrusted wildcard branches, emits `Vary: Origin`.controller/subscription_payment_airwallex.go-442-451 (1)
442-451: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winEnforce Airwallex
x-timestampfreshness before accepting signatures.
verifyAirwallexSignaturehashes the timestamp, but it never checks whether the timestamp is recent; becausex-timestampis in Unix milliseconds, a captured request can be replayed indefinitely. Reject signed headers whose timestamp is outside a short tolerance window, such as ±5 minutes.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/subscription_payment_airwallex.go` around lines 442 - 451, Update verifyAirwallexSignature to parse x-timestamp as a Unix-millisecond value and reject it when it falls outside a short freshness window, such as ±5 minutes from the current time. Return false for empty, malformed, or stale/future timestamps before validating the HMAC, while preserving the existing signature computation for fresh requests.DEPLOY.md-28-28 (1)
28-28: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winPin the runtime base image.
calciumion/new-api:latestmakes redeployments and rollbacks non-reproducible: rebuilding an old binary can silently pull a different upstream image. Use an immutable version or digest and update it deliberately.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@DEPLOY.md` at line 28, Update the Dockerfile.newapi runtime base image reference from calciumion/new-api:latest to an immutable version tag or digest, and ensure future upgrades change this reference deliberately while preserving the existing bin/new-api copy behavior.DEPLOY.md-72-72 (1)
72-72: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winDo not use
docker compose restartfor secret rotation.Restarting an existing container does not reapply changed environment variables. Require
docker compose up -d --force-recreate(or equivalent) after editing.env.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@DEPLOY.md` at line 72, Update the “Secrets rotation” guidance in DEPLOY.md to remove docker compose restart as an acceptable action and require docker compose up -d --force-recreate for new-api after editing /opt/newapi/.env, or an equivalent container recreation command.Dockerfile-22-22 (1)
22-22: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winDo not disable Go module checksum verification in the build settings.
Both the Docker builder and the documented deploy-time env globally set
GOSUMDB=off, which bypasses Go’s checksum-database integrity checks. Remove the global flag fromDockerfile#L22and document a trusted checksum-verification path inDEPLOY.md#L49-L50instead; use scopedGONOSUMDBonly if some modules cannot reach the checksum database.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Dockerfile` at line 22, Remove the global GOSUMDB=off setting from Dockerfile line 22 while preserving the other Go build environment variables. Update DEPLOY.md lines 49-50 to document a trusted checksum-verification configuration, using scoped GONOSUMDB only for modules that cannot access the checksum database; no direct Dockerfile change is needed beyond removing GOSUMDB.model/subscription_billing_customer_test.go-5-25 (1)
5-25: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick winUse
require/assertin the new backend tests.
model/subscription_billing_customer_test.go#L5-L25: replace setup and fatalt.Fatal/t.Fatalfassertions withrequire.model/subscription_plan_by_price_test.go#L5-L44: replace setup and fatal assertions withrequire.model/subscription_supersede_test.go#L9-L93: replace setup and fatal assertions withrequire; useassertfor independent postconditions where appropriate.As per coding guidelines, new Go backend tests must use
testify/requirefor setup and fatal assertions andtestify/assertfor non-fatal checks.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model/subscription_billing_customer_test.go` around lines 5 - 25, Replace fatal test assertions and setup checks in model/subscription_billing_customer_test.go lines 5-25 and model/subscription_plan_by_price_test.go lines 5-44 with testify/require calls. In model/subscription_supersede_test.go lines 9-93, use require for setup and assertions that must stop execution, and assert for independent postconditions; add or reuse the appropriate testify imports.Source: Coding guidelines
model/subscription.go-394-410 (1)
394-410: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winFail closed on duplicate Airwallex price mappings.
This lookup silently selects the lowest-ID plan when two rows share a price ID. A duplicated configuration can therefore grant the entitlement for a different plan than the charged Airwallex price. Enforce uniqueness for non-empty IDs in plan writes and return an error if this lookup finds multiple rows.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model/subscription.go` around lines 394 - 410, Update GetSubscriptionPlanByAirwallexPriceId to detect multiple matching SubscriptionPlan rows and return an error instead of selecting the lowest-ID result; preserve nil results for no matches or blank IDs. Enforce uniqueness of non-empty airwallex_price_id values in the SubscriptionPlan write/create/update paths, while allowing empty IDs to remain non-unique.controller/subscription.go-280-286 (1)
280-286: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy liftPreserve “omitted” versus “explicitly clear” field semantics.
Both sites treat
""as absent, so successful API calls cannot clear existing values.
controller/subscription.go#L280-L286: use a presence-aware request field so an explicit emptyairwallex_price_idcan disable that billing mapping while omission preserves it.model/user.go#L522-L538: use presence-aware update fields so admins can explicitly clearusername,display_name, orremarkwithout omitted fields erasing stored values.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/subscription.go` around lines 280 - 286, Preserve omitted-versus-explicit-empty semantics at both update sites: in controller/subscription.go lines 280-286, make the AirwallexPriceId request field presence-aware so omission leaves the existing mapping unchanged while an explicit empty value clears it; in model/user.go lines 522-538, make username, display_name, and remark update fields presence-aware so omitted fields are preserved and explicitly empty values clear the stored values.model/subscription.go-166-166 (1)
166-166: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winAdd
airwallex_price_idto the SQLitesubscription_plansmigration.
ensureSubscriptionPlanTableSQLitecreates/updatessubscription_plansseparately, and the current required column list omits this new field. Add the column to both the initialCREATE TABLEDDL and the required upgrade list, usingALTER TABLE ... ADD COLUMN.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model/subscription.go` at line 166, Add the Airwallex price ID column to the SQLite subscription plan schema: update ensureSubscriptionPlanTableSQLite’s initial CREATE TABLE DDL and its required upgrade-column list, and add missing columns through ALTER TABLE ... ADD COLUMN using the existing migration pattern.Source: Coding guidelines
model/subscription.go-1361-1376 (1)
1361-1376: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winMake the billing-customer save atomic.
AirwallexBillingCustomer.user_idhas auniqueIndex, but the read/create sequence atmodel/subscription.go:1366-1371lets concurrent checkouts create customers and then hit a duplicate-key error onCreateafter Airwallex has already allocated the billing customer. Replace it with a GORM-backed upsert onuser_id, e.g. usingclause.OnConflict { Columns: []clause.Column{{Name: "user_id"}}, DoUpdates: clause.Assignments(map[string]any{"customer_id": true}) }.Create(...).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model/subscription.go` around lines 1361 - 1376, Replace the read-then-create/update sequence in SaveAirwallexBillingCustomerId with a single GORM upsert keyed by user_id, using the existing unique index and OnConflict configuration. Ensure conflicts update customer_id and preserve CreatedAt for inserts, while retaining the current input validation and error propagation.Source: Coding guidelines
service/airwallex/client_test.go-1-131 (1)
1-131: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick winNew Airwallex test files don't use testify as required.
Both files are entirely new and use raw
t.Fatalf/manualif err != nilchecks instead oftestify/requireandtestify/assert.
service/airwallex/client_test.go#L1-L131: replace fatal setup/assertion checks (e.g. lines 55-61, 69-72, 82-85) withrequire.NoError/require.Equal, and non-fatal checks withassert.*.service/airwallex/billing_test.go#L1-L168: same replacement across all assertions (e.g. lines 17-25, 64-72, 90-100).As per coding guidelines: "New or substantially rewritten Go backend tests must use
testify/requirefor setup and fatal assertions andtestify/assertfor non-fatal checks."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@service/airwallex/client_test.go` around lines 1 - 131, Replace manual checks throughout service/airwallex/client_test.go lines 1-131 and service/airwallex/billing_test.go lines 1-168 with testify assertions: use require.NoError and other require methods for setup or fatal conditions, and assert methods for non-fatal validations. Add the testify/require and testify/assert imports while preserving each test’s existing expectations and coverage.Source: Coding guidelines
service/airwallex/client.go-42-78 (1)
42-78: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winLock held across the entire Airwallex login HTTP call serializes all Airwallex traffic.
tokenMuis acquired at function entry and released only viadefer, so it stays held for the full network round-trip to/authentication/login. Since every caller ofdo()goes throughgetToken()first, a slow or hung Airwallex login endpoint blocks all concurrent Airwallex operations (webhooks, checkouts, cancellations, price switches) one at a time for up to the 30s client timeout — a cascading stall under load or during an Airwallex outage.🔒 Proposed fix: release the lock before the network call (double-checked locking)
func getToken() (string, error) { tokenMu.Lock() - defer tokenMu.Unlock() if cachedToken != "" && time.Now().Before(tokenExpiry) { - return cachedToken, nil + token := cachedToken + tokenMu.Unlock() + return token, nil } + tokenMu.Unlock() + req, err := http.NewRequest(http.MethodPost, setting.AirwallexApiBase+"/api/v1/authentication/login", nil) ... if loginResp.Token == "" { return "", errors.New("airwallex login returned empty token") } + tokenMu.Lock() cachedToken = loginResp.Token tokenExpiry = time.Now().Add(tokenLifetime) + tokenMu.Unlock() return cachedToken, nil }This allows brief duplicate concurrent logins near expiry (harmless — Airwallex tokens aren't single-use) while preventing a slow login from stalling unrelated in-flight requests.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@service/airwallex/client.go` around lines 42 - 78, Update getToken so tokenMu is held only while checking cachedToken/tokenExpiry and while storing a newly fetched token, not during HTTP request creation, execution, body reading, or response parsing. Use double-checked locking before publishing the refreshed token, preserving cached-token reuse and allowing concurrent login attempts without serializing Airwallex operations.controller/user.go-207-215 (1)
207-215: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
EmailLoginCodeTTLis never enforced — it only formats the email text.The code's real lifetime comes from
common.VerifyCodeWithKey, which compares againstVerificationValidMinutes*60(10 min), notEmailLoginCodeTTL. An operator loweringEmailLoginCodeTTLto 300 gets an email saying "expires in 5 minutes" while the code stays valid for 10. Either drop the setting or thread it into verification.🐛 Suggested direction
Add a TTL-aware verify in
common/verification.goand call it here:// common/verification.go func VerifyCodeWithKeyTTL(key, code, purpose string, ttlSeconds int) bool { verificationMutex.Lock() defer verificationMutex.Unlock() value, ok := verificationMap[purpose+key] if !ok || int(time.Since(value.time).Seconds()) >= ttlSeconds { return false } return subtle.ConstantTimeCompare([]byte(code), []byte(value.code)) == 1 }then in
VerifyEmailLoginCode:common.VerifyCodeWithKeyTTL(req.Email, req.Code, common.EmailLoginPurpose, common.EmailLoginCodeTTL).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/user.go` around lines 207 - 215, Enforce common.EmailLoginCodeTTL during email-code verification instead of only using it in the expiration message. Add a TTL-aware verification helper in common/verification.go alongside VerifyCodeWithKey, then update VerifyEmailLoginCode to call it with req.Email, req.Code, common.EmailLoginPurpose, and common.EmailLoginCodeTTL while preserving the existing constant-time comparison and invalid-code behavior.router/api-router.go-16-21 (1)
16-21: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winApplying
CORS()to the whole/apigroup putsAccess-Control-Allow-Origin: *on the entire authenticated surface.
middleware.CORS()falls back toAccess-Control-Allow-Origin: *for any origin outsidetrustedCredentialedOrigins, so admin,/api/option(RootAuth),/api/channel, and/api/tokenresponses now all become cross-origin readable. Cookie auth is still protected (browsers reject credentials with*), but theaccess_tokenthis PR starts returning is anAuthorization-header credential, which*does not restrain — any page can script authenticated calls once it obtains one.The stated need is only
/api/user/login/*,/api/token/*,/api/user/self. Either scope the middleware to those groups, or drop the*fallback so only allowlisted origins get a CORS header. Note the OPTIONS short-circuit also now precedesGlobalAPIRateLimit()for every/apipath.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@router/api-router.go` around lines 16 - 21, Restrict CORS application in the apiRouter setup to the required authenticated endpoints—/api/user/login/*, /api/token/*, and /api/user/self—instead of applying middleware.CORS() to the entire /api group. Preserve preflight handling for those routes while ensuring admin, /api/option, and /api/channel remain without the wildcard CORS fallback and continue through GlobalAPIRateLimit().common/verification.go-87-108 (1)
87-108: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winUnbounded in-memory email-login state. Both maps are keyed by an attacker-supplied email and have no expiry sweep, so abandoned sign-in attempts accumulate for the process lifetime.
verificationMapalready prunes viaremoveExpiredPairs; these two do not.
common/verification.go#L87-L108: store a timestamp with each attempt count and prune entries older than the code lifetime when the map exceeds a size threshold, mirroringremoveExpiredPairs.controller/user.go#L171-L174:emailLoginLastSentis only deleted on successful verification — prune entries older thanEmailLoginCodeResendCooldownon write, or replace it with a TTL cache.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@common/verification.go` around lines 87 - 108, The verification attempt and resend-tracking maps retain attacker-supplied email entries indefinitely. In common/verification.go lines 87-108, update IncrementAttemptsAndMaybeInvalidate and ResetAttempts to store attempt timestamps and, once the map exceeds a size threshold, prune entries older than the verification-code lifetime, mirroring removeExpiredPairs. In controller/user.go lines 171-174, prune emailLoginLastSent entries older than EmailLoginCodeResendCooldown during writes, or replace the map with an equivalent TTL cache.controller/user.go-278-302 (1)
278-302: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftConcurrent verifications with the same code can create duplicate users.
VerifyCodeWithKey(Line 264) andDeleteKey(Line 274) are separate critical sections, so two in-flight requests carrying the same valid code both pass, both miss the lookup, and bothInsert.User.Emailis declaredgorm:"index"— not unique — so nothing stops two rows sharing the address, after which everyFirst(&user)by email becomes nondeterministic. Use a single find-or-create guarded by a transaction (or add a unique constraint on🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/user.go` around lines 278 - 302, Make the verified-email user creation path around VerifyCodeWithKey, DeleteKey, and the post-insert lookup concurrency-safe: use one transaction for the existence check and creation, or enforce a unique email constraint and handle duplicate-key errors by re-selecting the existing user. Ensure concurrent requests with the same code cannot leave duplicate User rows and preserve the existing response/error behavior.controller/user.go-118-125 (1)
118-125: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winEmail case handling is inconsistent between V0 and V1, and case-sensitivity differs per database.
LoginByEmailonly trims, whileRequestEmailLoginCode/VerifyEmailLoginCodelowercase (Lines 190, 257). On PostgreSQL and SQLite (binary collation)WHERE email = ?is case-sensitive, soFoo@x.comvia V0 andfoo@x.comvia V1 resolve to two different accounts; on MySQL's default_cicollation they resolve to one. Normalize identically in both handlers.🐛 Proposed fix
- req.Email = strings.TrimSpace(req.Email) + req.Email = strings.TrimSpace(strings.ToLower(req.Email))As per coding guidelines, "All database code must remain compatible with SQLite, MySQL >= 5.7.8, and PostgreSQL >= 9.6."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/user.go` around lines 118 - 125, Normalize the email consistently in LoginByEmail by trimming whitespace and converting it to lowercase before validation and the model.DB lookup, matching the normalization already used by RequestEmailLoginCode and VerifyEmailLoginCode. Preserve the existing invalid-parameter handling and query behavior after normalization.Source: Coding guidelines
controller/user.go-234-238 (1)
234-238: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy liftBlocking SMTP call on the request thread, and the email address is written to logs.
Two problems here:
common.SendEmailis synchronous andnet/smtp/tls.Dialare used without any deadline, so a hung SMTP endpoint pins this handler (and the client) until the OS TCP timeout. The comment says "fire-and-forget" but the call is not. The PR summary also describes this as asynchronous delivery.- The failure log embeds the full address; the same pattern appears on Lines 140 and 299. Logging user email identifiers is a privacy/compliance exposure — mask or log the user id instead.
🔒 Proposed fix
- if err := common.SendEmail(subject, req.Email, content); err != nil { - common.SysLog(fmt.Sprintf("RequestEmailLoginCode SendEmail failed for %s: %v", req.Email, err)) - } + go func(to, subject, content string) { + if err := common.SendEmail(subject, to, content); err != nil { + common.SysLog(fmt.Sprintf("RequestEmailLoginCode SendEmail failed for %s: %v", maskEmail(to), err)) + } + }(req.Email, subject, content)where
maskEmailkeeps only the first character and the domain. A dial/read deadline incommon.SendEmailis still worth adding regardless.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/user.go` around lines 234 - 238, Make the email delivery path in the request handler genuinely fire-and-forget by moving common.SendEmail out of the request thread while preserving failure logging. Replace full email addresses in the failure logs at the shown location and the corresponding logging sites near lines 140 and 299 with maskEmail output or a user identifier. Add appropriate dial/read deadlines inside common.SendEmail so SMTP operations cannot hang indefinitely.common/constants.go-94-97 (1)
94-97: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winDefaulting the email-login master switch to
trueopens passwordless account creation on every existing deployment.
EmailOnlyLoginEnabled = truemeans any instance that upgrades immediately exposes/api/user/login/email/request-codeand/verify-code, which auto-create accounts (withQuotaForNewUsercredits) for any address that can receive mail. Every other auth provider in this codebase defaults off (GitHubOAuthEnabled,TelegramOAuthEnabled, …). Recommend defaulting tofalseand enabling it explicitly for the JINN deployment.🔒 Proposed change
-var EmailOnlyLoginEnabled = true +var EmailOnlyLoginEnabled = false🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@common/constants.go` around lines 94 - 97, Change the default value of EmailOnlyLoginEnabled to false so existing deployments do not enable email-based account creation on upgrade. Preserve explicit configuration support for enabling it in the JINN deployment, consistent with the other authentication provider switches.
🟡 Minor comments (6)
service/subscription_recurring_test.go-17-20 (1)
17-20: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winRestore the global registry with
t.Cleanup.These tests permanently replace
recurringChargers; a fatal assertion or later test can observe an empty registry and lose pre-registered chargers. Snapshot it before setup and restore it viat.Cleanup.Proposed fix
+func isolateRecurringChargers(t *testing.T) { + t.Helper() + recurringChargersMu.Lock() + previous := recurringChargers + recurringChargers = map[string]RecurringCharger{} + recurringChargersMu.Unlock() + + t.Cleanup(func() { + recurringChargersMu.Lock() + recurringChargers = previous + recurringChargersMu.Unlock() + }) +} + func TestChargeDueAgreementSubscriptionsNoopWhenEmpty(t *testing.T) { - recurringChargersMu.Lock() - recurringChargers = map[string]RecurringCharger{} - recurringChargersMu.Unlock() + isolateRecurringChargers(t) ChargeDueAgreementSubscriptions() // must not panic — the HK/Airwallex steady state }Also applies to: 24-27, 46-48
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@service/subscription_recurring_test.go` around lines 17 - 20, Update TestChargeDueAgreementSubscriptionsNoopWhenEmpty and the other affected test setup blocks to snapshot the existing recurringChargers registry before replacing it, then register a t.Cleanup callback that restores the snapshot under recurringChargersMu; keep the test’s empty-registry setup unchanged during execution.DEPLOY.md-23-23 (1)
23-23: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winSpecify a language for the directory-tree fence.
Use
texthere to satisfy Markdown linting and improve renderer behavior.Proposed fix
-``` +```text🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@DEPLOY.md` at line 23, Specify the text language on the directory-tree fenced code block in DEPLOY.md by changing its opening fence to use text, while preserving the existing block contents.Source: Linters/SAST tools
model/subscription_billing_customer_test.go-6-10 (1)
6-10: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winReset the test’s persisted state.
AutoMigratedoes not clear an existing mapping for user4242, so test ordering or repeated execution can fail the initial-empty assertion. Initialize the table state before querying it.As per coding guidelines, backend tests must initialize database state explicitly.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model/subscription_billing_customer_test.go` around lines 6 - 10, Reset the persisted AirwallexBillingCustomer state before the initial lookup in this test: after AutoMigrate and before GetAirwallexBillingCustomerId(4242), explicitly clear or initialize the table using the test database setup mechanism. Preserve the assertion that user 4242 returns an empty ID.Source: Coding guidelines
service/airwallex/client_test.go-64-74 (1)
64-74: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winManual state restore is skipped on assertion failure, leaking global config into later tests.
t.Fatalfat line 71 exits before line 73 restoressetting.AirwallexApiKey, so a failure here corruptsAirwallexApiKeyfor every test run afterward, obscuring the real failure.🧹 Proposed fix: restore via t.Cleanup
func TestLoginFailureSurfaced(t *testing.T) { var logins int32 mockServer(t, &logins, func(w http.ResponseWriter, r *http.Request) {}) setting.AirwallexApiKey = "wrong" + t.Cleanup(func() { setting.AirwallexApiKey = "key" }) ResetTokenCache() _, err := CreateCustomer("req-1", "7", "") if err == nil || !strings.Contains(err.Error(), "login failed") { t.Fatalf("expected login failure, got %v", err) } - setting.AirwallexApiKey = "key" }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@service/airwallex/client_test.go` around lines 64 - 74, Update TestLoginFailureSurfaced to register restoration of setting.AirwallexApiKey through t.Cleanup immediately after changing it to "wrong", and remove the manual reset at the end so cleanup runs even when the assertion calls t.Fatalf.common/constants.go-99-103 (1)
99-103: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winDoc comment contradicts the value.
The comment says "Default true preserves V0 behaviour during the V1 rollout", but the variable is
false. Either fix the comment or the default.📝 Proposed fix
-// Default true preserves V0 behaviour during the V1 rollout. Flip to false -// once all desktop clients ship with V1 code-entry support. +// Default false: V0 is opt-in only. Operators enable it temporarily for +// clients that have not yet shipped V1 code-entry support. var EmailOnlyLoginAutoCreateNoVerify = false🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@common/constants.go` around lines 99 - 103, Resolve the contradiction between the doc comment and EmailOnlyLoginAutoCreateNoVerify by making the documented default match the intended rollout behavior: either set the variable to true to preserve the stated V0 default, or revise the comment to accurately describe a false default and its implications.common/verification.go-43-56 (1)
43-56: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick winModulo bias, and the fallback emits non-numeric codes.
digits[int(b[i])%10]is biased (256 % 10 ≠ 0), so digits 0–5 are ~4% more likely than 6–9 — a small but avoidable entropy loss on a 6-digit sign-in OTP. Also, therand.Readfallback returns a UUID-derived hex string, which violates the "digit-only" contract the client's OTP input relies on; sincecrypto/rand.Readnever returns a partial read without error, failing closed is cleaner.🔒 Proposed fix using rejection-free `rand.Int`
func GenerateNumericVerificationCode(length int) string { - const digits = "0123456789" - b := make([]byte, length) - if _, err := rand.Read(b); err != nil { - return GenerateVerificationCode(length) - } - for i := range b { - b[i] = digits[int(b[i])%10] - } - return string(b) + const digits = "0123456789" + b := make([]byte, length) + for i := range b { + n, err := rand.Int(rand.Reader, big.NewInt(10)) + if err != nil { + return "" + } + b[i] = digits[n.Int64()] + } + return string(b) }(requires
math/big; callers must treat""as a generation failure)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@common/verification.go` around lines 43 - 56, Update GenerateNumericVerificationCode to eliminate modulo bias by generating each digit with crypto/rand.Int over a bound of 10, adding the required math/big dependency. On any randomness failure, return an empty string instead of calling GenerateVerificationCode, while preserving the digit-only output contract for successful generation.
🧹 Nitpick comments (6)
controller/subscription_payment_airwallex_billing_test.go (1)
371-377: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRedundant conversion:
nis alreadyint64.♻️
- return int64(n) + return n🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/subscription_payment_airwallex_billing_test.go` around lines 371 - 377, Remove the redundant int64 conversion in countRenewalOrders and return the already-typed n value directly.controller/subscription_payment_airwallex.go (2)
760-765: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUnused
rawparameter.
handleAirwallexIntentSucceedednever readsraw; the payload is rebuilt fromevent.Data.Object. Drop the parameter and the argument at Line 487.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/subscription_payment_airwallex.go` around lines 760 - 765, Remove the unused raw parameter from handleAirwallexIntentSucceeded and update its call site at the referenced handler to stop passing the raw payload argument, preserving the existing event-based processing.
281-295: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valuePartial cancellation is reported as a total failure.
If subscription N fails after N-1 were cancelled, the user sees "取消订阅失败,请稍后重试" and a retry will re-cancel already-cancelled subscriptions. Consider continuing the loop, tracking failures, and reporting the counts.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/subscription_payment_airwallex.go` around lines 281 - 295, Update the subscription cancellation loop to continue processing remaining subscriptions when Airwallex.CancelBillingSubscription fails instead of returning immediately. Track cancelled and failed counts, preserve error logging for each failure, and return a response that reports both counts so retries do not appear to be an all-or-nothing operation.controller/subscription_payment_airwallex_planswitch_test.go (1)
41-227: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueNew Go tests should use
testify/requirefor fatal assertions instead ofif err != nil { t.Fatal(...) }. Both new test files mix the two styles; the guideline requiresrequirefor setup/fatal assertions andassertfor non-fatal checks.
controller/subscription_payment_airwallex_planswitch_test.go#L41-L227: replace theif err := ...; err != nil { t.Fatal(err) }blocks and theif got != want { t.Fatalf(...) }assertions withrequire.NoError/require.Equal.controller/subscription_payment_airwallex_billing_test.go#L141-L152: convertloadEvent's twot.Fatal(err)blocks torequire.NoError(t, err).As per coding guidelines, "New or substantially rewritten Go backend tests must use
testify/requirefor setup and fatal assertions andtestify/assertfor non-fatal checks".🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/subscription_payment_airwallex_planswitch_test.go` around lines 41 - 227, Update controller/subscription_payment_airwallex_planswitch_test.go lines 41-227 to use testify/require: replace fatal setup error checks with require.NoError and fatal equality assertions with require.Equal, preserving existing messages where needed; use assert only for non-fatal checks. Also update loadEvent in controller/subscription_payment_airwallex_billing_test.go lines 141-152 by replacing both t.Fatal(err) error checks with require.NoError(t, err).Source: Coding guidelines
service/airwallex/client.go (1)
42-131: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winNo
context.Contextpropagation for outbound Airwallex calls.
getToken()anddo()usehttp.NewRequestrather thanhttp.NewRequestWithContext, so callers (e.g. the webhook handler, which has its own request context) can't bound or cancel in-flight Airwallex calls beyond the shared package-levelHTTPClient30s timeout.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@service/airwallex/client.go` around lines 42 - 131, The Airwallex request helpers do not propagate caller cancellation or deadlines. Update getToken and do to accept a context.Context and create their outbound requests with http.NewRequestWithContext; pass the caller’s context through do to getToken and update all call sites to provide the appropriate request context, preserving existing authentication and response handling.i18n/keys.go (1)
82-82: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUse or remove the new email login expiry key.
MsgEmailLoginCodeExpiredis not referenced anywhere, whileVerifyEmailLoginCodestill maps expired codes back to the same “invalid” response as wrong codes. Either return this key for expired codes or remove the key and its translations.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@i18n/keys.go` at line 82, Update VerifyEmailLoginCode to return MsgEmailLoginCodeExpired when the login code is expired, preserving the existing invalid response for incorrect codes; alternatively, remove the unused MsgEmailLoginCodeExpired constant and all corresponding translations if expiry should remain mapped to invalid.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 4640fb22-dfe0-427f-9b09-38707d502069
⛔ Files ignored due to path filters (1)
web/default/bun.lockis excluded by!**/*.lock
📒 Files selected for processing (38)
CLAUDE.mdDEPLOY.mdDockerfilecommon/constants.gocommon/email.gocommon/verification.gocontroller/subscription.gocontroller/subscription_payment_airwallex.gocontroller/subscription_payment_airwallex_billing_test.gocontroller/subscription_payment_airwallex_changeplan_test.gocontroller/subscription_payment_airwallex_planswitch_test.gocontroller/subscription_payment_airwallex_test.gocontroller/testdata/awx_billing_checkout_completed.jsoncontroller/testdata/awx_billing_checkout_completed_slim.jsoncontroller/testdata/awx_invoice_paid.jsoncontroller/user.goi18n/keys.goi18n/locales/en.yamli18n/locales/zh-CN.yamli18n/locales/zh-TW.yamlmiddleware/cors.gomodel/main.gomodel/option.gomodel/subscription.gomodel/subscription_billing_customer_test.gomodel/subscription_plan_by_price_test.gomodel/subscription_supersede_test.gomodel/topup.gomodel/user.gorouter/api-router.goservice/airwallex/billing.goservice/airwallex/billing_test.goservice/airwallex/client.goservice/airwallex/client_test.goservice/subscription_recurring.goservice/subscription_recurring_test.goservice/subscription_reset_task.gosetting/payment_airwallex.go
The portal moved from account.jinn.ccwu.cc to account.jinnhq.com but the allowlist was not updated. TrustedBrowserOrigin therefore rejected the portal's return_url, airwallexReturnUrl silently fell back to the admin console, and paying users landed on /sign-in?redirect=/wallet instead of the confirmation page. Activation was unaffected (it runs on the webhook), which is why this went unnoticed. Adds a test pinning both the live and legacy portal origins.
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
middleware/cors.go (2)
34-59: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winSet
Vary: Originfor every origin-bearing response.The response differs by
Origin: trusted origins receive a specific origin and credentials, while others receive*. SettingVaryonly in the trusted branch can let a shared cache reuse a wildcard response for a credentialed request, breaking browser API calls.Proposed fix
if origin != "" { + c.Header("Vary", "Origin") if trustedCredentialedOrigins[origin] { c.Header("Access-Control-Allow-Origin", origin) c.Header("Access-Control-Allow-Credentials", "true") - c.Header("Vary", "Origin") } else {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@middleware/cors.go` around lines 34 - 59, Update CORS so every response with a non-empty Origin request header sets Vary: Origin, not only responses matching trustedCredentialedOrigins. Keep the existing trusted-origin and wildcard Access-Control-Allow-Origin behavior unchanged.
55-57: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winAbort only actual CORS preflight requests.
This currently intercepts every
OPTIONSrequest, even withoutOriginorAccess-Control-Request-Method, preventing any legitimate APIOPTIONShandler from running.Proposed fix
-if c.Request.Method == http.MethodOptions { +if c.Request.Method == http.MethodOptions && + origin != "" && + c.Request.Header.Get("Access-Control-Request-Method") != "" { c.AbortWithStatus(http.StatusNoContent) return }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@middleware/cors.go` around lines 55 - 57, Update the OPTIONS handling in the CORS middleware to abort only when the request includes both CORS preflight indicators: an Origin header and an Access-Control-Request-Method header. Allow other OPTIONS requests to continue to the downstream handler.
🧹 Nitpick comments (1)
middleware/cors_test.go (1)
11-34: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd tests for the actual
CORS()middleware behavior.These tests only exercise the allowlist helper. Add
httptest/Gin cases covering trusted credentialed responses, wildcard responses,Vary: Origin, allowed headers, andOPTIONSpreflight handling; otherwise the main changed behavior inmiddleware/cors.goremains unprotected.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@middleware/cors_test.go` around lines 11 - 34, Extend the CORS tests beyond TrustedBrowserOrigin by adding Gin/httptest coverage for the CORS() middleware. Verify trusted origins receive credentialed responses, untrusted origins receive wildcard responses, responses include Vary: Origin and the configured allowed headers, and OPTIONS requests are handled correctly as preflight requests.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@middleware/cors_test.go`:
- Line 3: Update the assertions in the tests in middleware/cors_test.go to use
testify/assert instead of t.Errorf, including the assertions at the referenced
import and test lines; add the required testify/assert import and preserve the
existing non-fatal assertion behavior.
---
Outside diff comments:
In `@middleware/cors.go`:
- Around line 34-59: Update CORS so every response with a non-empty Origin
request header sets Vary: Origin, not only responses matching
trustedCredentialedOrigins. Keep the existing trusted-origin and wildcard
Access-Control-Allow-Origin behavior unchanged.
- Around line 55-57: Update the OPTIONS handling in the CORS middleware to abort
only when the request includes both CORS preflight indicators: an Origin header
and an Access-Control-Request-Method header. Allow other OPTIONS requests to
continue to the downstream handler.
---
Nitpick comments:
In `@middleware/cors_test.go`:
- Around line 11-34: Extend the CORS tests beyond TrustedBrowserOrigin by adding
Gin/httptest coverage for the CORS() middleware. Verify trusted origins receive
credentialed responses, untrusted origins receive wildcard responses, responses
include Vary: Origin and the configured allowed headers, and OPTIONS requests
are handled correctly as preflight requests.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 18036ee6-0ff7-44d4-820e-e3a6a8e33656
📒 Files selected for processing (2)
middleware/cors.gomiddleware/cors_test.go
| @@ -0,0 +1,34 @@ | |||
| package middleware | |||
|
|
|||
| import "testing" | |||
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Use testify/assert for these non-fatal assertions.
The repository guideline requires new Go backend tests to use testify/assert or testify/require, rather than t.Errorf.
Proposed fix
import "testing"
+import "github.com/stretchr/testify/assert"
- if !TrustedBrowserOrigin(origin) {
- t.Errorf("origin %s must be trusted; payment return URLs from it are silently discarded otherwise", origin)
- }
+ assert.True(t, TrustedBrowserOrigin(origin), "origin %s must be trusted", origin)
- if TrustedBrowserOrigin(origin) {
- t.Errorf("origin %q must NOT be trusted", origin)
- }
+ assert.False(t, TrustedBrowserOrigin(origin), "origin %q must NOT be trusted", origin)Also applies to: 16-17, 30-31
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@middleware/cors_test.go` at line 3, Update the assertions in the tests in
middleware/cors_test.go to use testify/assert instead of t.Errorf, including the
assertions at the referenced import and test lines; add the required
testify/assert import and preserve the existing non-fatal assertion behavior.
Source: Coding guidelines
Charging immediately cannot be made to work on Airwallex Billing, verified
live on 2026-07-30:
- IMMEDIATE_CHARGE_AND_RESET_CYCLE + PRORATED charges the annual price in
full and returns the unused time as a credit note that REFUNDS to the
card, rather than discounting the new invoice. That refund failed with
amount_above_limit because refunds draw on the merchant CNY balance,
which was empty. The customer was left out of pocket with no signal.
- Charging the prorated difference as a one-off and suppressing the next
cycle with trial_end_at does not work: Billing accepts trial_end_at with
a 200 and silently ignores it, so that route would double-charge.
DEFER_CHARGE_AND_KEEP_CYCLE + NONE needs neither a refund nor a discount:
the customer keeps the period they paid for and is charged the annual price
at the billing date they already expected. No money moves at switch time, so
the whole failure class disappears.
The endpoint now returns effective_at so the portal can state when annual
starts, and a second click reports the switch as scheduled rather than
claiming the user is already on the plan.
Important
📝 变更描述 / Description
(简述:做了什么?为什么这样改能生效?请基于你对代码逻辑的理解来写,避免粘贴未经整理的内容)
🚀 变更类型 / Type of change
🔗 关联任务 / Related Issue
✅ 提交前检查项 / Checklist
Bug fix,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。📸 运行证明 / Proof of Work
(请在此粘贴截图、关键日志或测试报告,以证明变更生效)
Summary by CodeRabbit
deferred/effective_at.