diff --git a/services/web/app/src/models/StudentVerification.mjs b/services/web/app/src/models/StudentVerification.mjs
new file mode 100644
index 0000000000..e8211a7535
--- /dev/null
+++ b/services/web/app/src/models/StudentVerification.mjs
@@ -0,0 +1,123 @@
+import mongoose from '../infrastructure/Mongoose.mjs'
+
+const { Schema } = mongoose
+const { ObjectId } = Schema
+
+/**
+ * @typedef {'pending' | 'success' | 'failed'} StudentVerificationStatus
+ */
+
+// Kept separate so retention, validity and the retry block can change
+// independently, though all three are 30 days at the moment.
+
+// How long a success counts as verified. TODO: 30 days is a placeholder;
+// SheerID's own model is annual.
+export const VALIDITY_IN_SECONDS = 60 * 60 * 24 * 30
+
+// How long a failure blocks starting again. TODO: decide whether to allow one
+// automatic retry first.
+export const BLOCK_DURATION_IN_SECONDS = 60 * 60 * 24 * 30
+
+// How long the record is kept, via a Mongo TTL index.
+export const EXPIRY_IN_SECONDS = 60 * 60 * 24 * 30
+
+// Runs at boot, since every path that touches a StudentVerification loads this
+// module. Fails loudly rather than letting a misconfiguration quietly delete
+// records that are still in use.
+if (
+ EXPIRY_IN_SECONDS < Math.max(VALIDITY_IN_SECONDS, BLOCK_DURATION_IN_SECONDS)
+) {
+ throw new Error(
+ 'StudentVerification: EXPIRY_IN_SECONDS must be >= VALIDITY_IN_SECONDS and BLOCK_DURATION_IN_SECONDS (a record must not be deleted by the TTL while it is still authoritative for the purchase gate or the retry block)'
+ )
+}
+
+/**
+ * @param {number} seconds
+ */
+function dateSecondsFromNow(seconds) {
+ const timestamp = new Date()
+ timestamp.setSeconds(timestamp.getSeconds() + seconds)
+ return timestamp
+}
+
+export const StudentVerificationSchema = new Schema(
+ {
+ // Unique, so it can be the sole lookup key everywhere.
+ verificationId: { type: String, required: true, unique: true },
+ userId: { type: ObjectId, required: true },
+ status: {
+ type: String,
+ enum: ['pending', 'success', 'failed'],
+ default: 'pending',
+ },
+ // Only ever set alongside a FAILED status. Distinguishes a permanent
+ // SheerID block (verificationLimitExceeded) from an ordinary declined
+ // result (docReviewLimitExceeded), so the frontend can show 'blocked'
+ // rather than 'declined'. Not a status of its own.
+ blocked: { type: Boolean, default: false },
+ // Upper-case.
+ country: { type: String, required: true },
+ createdAt: { type: Date, default: Date.now },
+ updatedAt: { type: Date, default: Date.now },
+ // Throttles the upstream fetch independently of how often the page polls
+ // us. Absent means never refreshed.
+ lastRefreshedAt: { type: Date },
+ // When the status stopped being pending. Set once, alongside the move to
+ // success or failed. Subtracting createdAt gives how long the verification
+ // took, which updatedAt would not, since any later write moves it.
+ resolvedAt: { type: Date },
+ // Captured at the start so it survives the user leaving and coming back
+ // later via email link, then reattached to the purchase once they are
+ // verified.
+ itm: {
+ itm_campaign: { type: String, default: null },
+ itm_content: { type: String, default: null },
+ itm_referrer: { type: String, default: null },
+ },
+ // How long a SUCCESS counts as verified for the purchase gate. See
+ // VALIDITY_IN_SECONDS above. The default is only a starting point:
+ // StudentVerificationManager sets this from the moment SheerID resolved
+ // the verification, so the window does not start ticking while a document
+ // review is still going on.
+ validUntil: {
+ type: Date,
+ default: () => dateSecondsFromNow(VALIDITY_IN_SECONDS),
+ },
+ // How long a FAILED record blocks starting a new verification. See
+ // BLOCK_DURATION_IN_SECONDS above. Checked explicitly by the caller
+ // (StudentVerificationController.startVerification) rather than
+ // relying on the record ageing out via the TTL, so the block duration
+ // can move independently of retention. Set from resolvedAt when the
+ // verification fails, so the block runs from the failure rather than from
+ // when the user started.
+ blockedUntil: {
+ type: Date,
+ default: () => dateSecondsFromNow(BLOCK_DURATION_IN_SECONDS),
+ },
+ // How long the record is kept. Mongo deletes it when this passes. Set from
+ // resolvedAt alongside the other two windows, so a record is never deleted
+ // while it is still authoritative for the gate or the retry block.
+ expires: {
+ type: Date,
+ default: () => dateSecondsFromNow(EXPIRY_IN_SECONDS),
+ index: { expireAfterSeconds: 0 },
+ },
+ },
+ {
+ collection: 'studentVerifications',
+ minimize: false,
+ }
+)
+
+// Not created automatically: infrastructure/Mongoose.mjs sets autoIndex: false
+// outside dev. Real environments get them from
+// tools/migrations/20260808093000_create_studentVerifications_indexes.mjs,
+// which says what each one serves. Keep the two in sync.
+StudentVerificationSchema.index({ userId: 1, status: 1, createdAt: -1 })
+StudentVerificationSchema.index({ status: 1, lastRefreshedAt: 1 })
+
+export const StudentVerification = mongoose.model(
+ 'StudentVerification',
+ StudentVerificationSchema
+)
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index d786618613..1a0d0cb9cf 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -32,6 +32,7 @@
"about_to_leave_project": "",
"about_to_leave_projects": "",
"about_to_remove_user_preamble": "",
+ "about_to_switch_to_plan_x_for_price_y": "",
"about_to_trash_projects": "",
"abstract": "",
"accept_all_cookies": "",
@@ -157,7 +158,9 @@
"all_these_experiments_are_available_exclusively": "",
"allocate_license": "",
"allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "",
+ "already_changed_your_plan": "",
"already_in_your_library": "",
+ "already_purchased_this_plan": "",
"an_email_has_already_been_sent_to": "",
"an_error_occured_while_restoring_project": "",
"and_much_more": "",
@@ -303,6 +306,7 @@
"chat_error": "",
"checking_dropbox_status": "",
"checking_project_github_status": "",
+ "checking_your_student_status": "",
"choose_a_custom_color": "",
"choose_from_group_members": "",
"choose_how_you_search_your_references": "",
@@ -401,6 +405,7 @@
"continue": "",
"continue_github_merge": "",
"continue_to": "",
+ "continue_to_checkout": "",
"continue_using_free_features": "",
"continue_with_free_plan": "",
"conversion_error_details": "",
@@ -812,6 +817,7 @@
"get_ready_for_overleaf_at_its_best_pro": "",
"get_ready_for_overleaf_standard": "",
"get_real_time_track_changes": "",
+ "get_standard": "",
"get_started_with_latex": "",
"get_your_hands_on_the_ultimate_research_writing_ai_assistant": "",
"git": "",
@@ -1524,6 +1530,7 @@
"please_confirm_secondary_email_or_edit": "",
"please_confirm_your_email_before_making_it_default": "",
"please_contact_support_to_makes_change_to_your_plan": "",
+ "please_contact_us_if_you_think_this_is_in_error": "",
"please_enter_confirmation_code": "",
"please_get_in_touch": "",
"please_link_before_making_primary": "",
@@ -1659,6 +1666,8 @@
"readcube_sync_description": "",
"readcube_upgrade_prompt_content": "",
"readcube_upgrade_prompt_title": "",
+ "ready_to_complete_your_plan_change": "",
+ "ready_to_complete_your_purchase": "",
"ready_to_join_x": "",
"ready_to_join_x_in_group_y": "",
"ready_to_set_up": "",
@@ -2066,6 +2075,18 @@
"strongly_agree": "",
"strongly_disagree": "",
"student": "",
+ "student_status_verified": "",
+ "student_verification_blocked": "",
+ "student_verification_blocked_info": "",
+ "student_verification_confirm_email_to_continue": "",
+ "student_verification_declined": "",
+ "student_verification_declined_info": "",
+ "student_verification_error": "",
+ "student_verification_error_info": "",
+ "student_verification_intro": "",
+ "student_verification_rate_limited": "",
+ "student_verification_rate_limited_info": "",
+ "student_verification_required": "",
"subject": "",
"subject_area": "",
"submit_title": "",
@@ -2343,6 +2364,7 @@
"turn_on": "",
"turn_on_link_sharing": "",
"type_a_command": "",
+ "unable_to_load_current_plan_details": "",
"unable_to_save_check_connection_or_download": "",
"unarchive": "",
"uncategorized": "",
@@ -2584,6 +2606,7 @@
"you_can_also_right_click_a_file_to_set_it_as_main": "",
"you_can_buy_this_plan_but_not_as_a_trial": "",
"you_can_link_x_in_your_account_settings": "",
+ "you_can_now_buy_the_student_plan": "",
"you_can_now_enable_sso": "",
"you_can_now_log_in_sso": "",
"you_can_now_search_and_add_references_from_your_rm_library_without_needing_to_import_files": "",
@@ -2591,6 +2614,7 @@
"you_can_restore_or_permanently_delete_them_at_any_time": "",
"you_can_select_or_invite_collaborator": "",
"you_can_select_or_invite_collaborator_plural": "",
+ "you_can_still_get_the_features_you_need_on_our_standard_plan": "",
"you_can_still_use_your_premium_features": "",
"you_cant_add_or_change_password_due_to_sso": "",
"you_cant_join_this_group_subscription": "",
diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts
index 431bedd87d..da0b42f1c7 100644
--- a/services/web/frontend/js/utils/meta.ts
+++ b/services/web/frontend/js/utils/meta.ts
@@ -334,6 +334,17 @@ export interface Meta {
}>
'ol-stripePublicKeyUK': string
'ol-stripePublicKeyUS': string
+ 'ol-studentVerificationPage': {
+ planCode: string
+ origin: 'change-plan' | undefined
+ alreadyVerified: boolean
+ alreadyOnPlan: boolean
+ // Already translated by the server, because the keys come from the plans
+ // config and so are not picked up by the frontend translation build.
+ studentPlanFeatures: string[]
+ standardPlanFeatures: string[]
+ ip: string | undefined
+ }
'ol-subscription': any // TODO: mixed types, split into two fields
'ol-subscriptionChangePreview': SubscriptionChangePreview
'ol-subscriptionCreationPreview': SubscriptionCreationPreview
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index bbf230a147..d529e21b6f 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -38,6 +38,7 @@
"about_to_leave_project": "You are about to leave this project.",
"about_to_leave_projects": "You are about to leave the following projects:",
"about_to_remove_user_preamble": "You’re about to remove __userName__ (__userEmail__). Doing this will mean:",
+ "about_to_switch_to_plan_x_for_price_y": "You’re about to switch to <0>__planName__0> for __price__.",
"about_to_trash_projects": "You are about to trash the following projects:",
"abstract": "Abstract",
"accept": "Accept",
@@ -196,9 +197,11 @@
"all_these_experiments_are_available_exclusively": "All these experiments are available exclusively to members of the Labs program. If you sign up, you can choose which experiments you want to try.",
"allocate_license": "Allocate license",
"allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "Allows to search by author, title, etc. Possible to pull results directly from your reference manager (if connected).",
+ "already_changed_your_plan": "You’ve already changed to this plan. <0>Go to your subscription0>",
"already_have_an_account": "Already have an account?",
"already_have_sl_account": "Already have an __appName__ account?",
"already_in_your_library": "Already in your library",
+ "already_purchased_this_plan": "You’ve already purchased this plan. <0>Go to your subscription0>",
"also": "Also",
"alternatively_create_new_institution_account": "Alternatively, you can create a new account with your institution email (__email__) by clicking __clickText__.",
"an_email_has_already_been_sent_to": "An email has already been sent to <0>__email__0>. Please wait and try again later.",
@@ -387,6 +390,7 @@
"checking": "Checking",
"checking_dropbox_status": "Checking Dropbox status",
"checking_project_github_status": "Checking project status in GitHub",
+ "checking_your_student_status": "We’re checking your student status. We’ll email you if your document upload needs review.",
"choose_a_custom_color": "Choose a custom color",
"choose_a_new_password": "Choose a new password",
"choose_from_group_members": "Choose from group members",
@@ -523,6 +527,7 @@
"continue": "Continue",
"continue_github_merge": "I have manually merged. Continue",
"continue_to": "Continue to __appName__",
+ "continue_to_checkout": "Continue to checkout",
"continue_using_free_features": "Continue using our free features",
"continue_with_free_plan": "Continue with free plan",
"continue_with_service": "Continue with __service__",
@@ -2216,6 +2221,8 @@
"readcube_sync_description": "With the ReadCube integration you can import your references from ReadCube into your __appName__ projects.",
"readcube_upgrade_prompt_content": "Link your ReadCube account to search and add your references from ReadCube directly in your project—they’ll automatically be added to your .bib file. Or import them as a file into your __appName__ projects.",
"readcube_upgrade_prompt_title": "Cite from ReadCube",
+ "ready_to_complete_your_plan_change": "You’re ready to complete your plan change.",
+ "ready_to_complete_your_purchase": "You’re ready to complete your purchase.",
"ready_to_join_x": "You’re ready to join __inviterName__",
"ready_to_join_x_in_group_y": "You’re ready to join __inviterName__ in __groupName__",
"ready_to_set_up": "Ready to set up",
@@ -2713,6 +2720,18 @@
"strongly_disagree": "Strongly disagree",
"student": "Student",
"student_discount_tooltip": "Available to students currently enrolled at a degree-granting institution.",
+ "student_status_verified": "Your student status has been verified",
+ "student_verification_blocked": "You’ve already used your verification attempt",
+ "student_verification_blocked_info": "We weren’t able to verify your student status last time, so we can’t try again automatically.",
+ "student_verification_confirm_email_to_continue": "Please confirm your primary email address to continue. <0>Go to account settings0>.",
+ "student_verification_declined": "Your student status couldn’t be verified",
+ "student_verification_declined_info": "We weren’t able to confirm that you’re a current student.",
+ "student_verification_error": "Something went wrong",
+ "student_verification_error_info": "We weren’t able to start student verification just now. This is a problem on our side, not with your details. Please try again in a few minutes.",
+ "student_verification_intro": "We need to verify that you’re a current student before you can continue.",
+ "student_verification_rate_limited": "Too many attempts",
+ "student_verification_rate_limited_info": "We’ve had too many verification requests recently. Please wait a few minutes and try again.",
+ "student_verification_required": "Student verification required",
"students": "Students",
"subject": "Subject",
"subject_area": "Subject area",
@@ -3038,6 +3057,7 @@
"tutorials": "Tutorials",
"type_a_command": "Type a command…",
"unable_to_extract_the_supplied_zip_file": "Opening this content on Overleaf failed because the zip file could not be extracted. Please ensure that it is a valid zip file. If this keeps happening for links on a particular site, please report this to them.",
+ "unable_to_load_current_plan_details": "We couldn’t load your current plan details. Please try again.",
"unable_to_save_check_connection_or_download": "We couldn’t add this file to your project. Check your connection and try again, or <0>download the file0> instead.",
"unarchive": "Restore",
"uncategorized": "Uncategorized",
@@ -3314,6 +3334,7 @@
"you_can_also_right_click_a_file_to_set_it_as_main": "You can also right-click a file to set it as main.",
"you_can_buy_this_plan_but_not_as_a_trial": "You can buy this plan but not as a trial, as you’ve completed a trial before.",
"you_can_link_x_in_your_account_settings": "You can link __managers__ in your <0>account settings0>.",
+ "you_can_now_buy_the_student_plan": "You can now buy the student plan",
"you_can_now_enable_sso": "You can now enable SSO on your group settings page.",
"you_can_now_log_in_sso": "You can now log in through your institution and if eligible you will receive <0>__appName__ premium features0>.",
"you_can_now_search_and_add_references_from_your_rm_library_without_needing_to_import_files": "You can now search and add references from your __referenceManager__ library without needing to import files—just type \\cite{} in your .tex file. Learn more",
@@ -3323,6 +3344,7 @@
"you_can_restore_or_permanently_delete_them_at_any_time": "You can restore or permanently delete them at any time.",
"you_can_select_or_invite_collaborator": "You can select or invite __count__ collaborator on your current plan. Upgrade to add more editors or reviewers.",
"you_can_select_or_invite_collaborator_plural": "You can select or invite __count__ collaborators on your current plan. Upgrade to add more editors or reviewers.",
+ "you_can_still_get_the_features_you_need_on_our_standard_plan": "You can still get the features you need on our Standard plan",
"you_can_still_use_your_premium_features": "You can still use your premium features until the pause becomes active.",
"you_cant_add_or_change_password_due_to_sso": "You can’t add or change your password because your group or organization uses <0>single sign-on (SSO)0>.",
"you_cant_join_this_group_subscription": "You can’t join this group subscription",
diff --git a/services/web/test/acceptance/config/settings.test.defaults.js b/services/web/test/acceptance/config/settings.test.defaults.js
index 33ed4f88fc..bf98d10cf4 100644
--- a/services/web/test/acceptance/config/settings.test.defaults.js
+++ b/services/web/test/acceptance/config/settings.test.defaults.js
@@ -200,6 +200,26 @@ module.exports = {
price_in_cents: 1500,
features: features.collaborator,
},
+ // The real student plans live in settings.overrides.saas.js, but
+ // @overleaf/settings' merge replaces arrays wholesale rather than
+ // merging them, so this list (not that one) is what acceptance tests
+ // see. Without these, PlansLocator.findLocalPlanInSettings cannot
+ // resolve 'student' and any test of the student-verification gate is
+ // blocked before the gate is reached. Feature set is deliberately
+ // borrowed from collaborator: the gate only needs the plan to resolve.
+ {
+ planCode: 'student',
+ name: 'Student monthly',
+ price_in_cents: 1000,
+ features: features.collaborator,
+ },
+ {
+ planCode: 'student-annual',
+ name: 'Student annual',
+ price_in_cents: 10000,
+ annual: true,
+ features: features.collaborator,
+ },
{
planCode: 'professional',
name: 'Pro monthly',
diff --git a/services/web/test/unit/src/Settings/Settings.test.mjs b/services/web/test/unit/src/Settings/Settings.test.mjs
index e22b20a415..61bf40ee67 100644
--- a/services/web/test/unit/src/Settings/Settings.test.mjs
+++ b/services/web/test/unit/src/Settings/Settings.test.mjs
@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
@@ -88,3 +88,133 @@ describe('settings.defaults', function () {
expect(haveSameStructure(pricings)).to.be.true
})
})
+
+describe('settings.overrides.saas', function () {
+ const originalEnv = {
+ SHEERID_ENABLED: process.env.SHEERID_ENABLED,
+ SHEERID_MOCK: process.env.SHEERID_MOCK,
+ SHEERID_CLIENT_ID: process.env.SHEERID_CLIENT_ID,
+ SHEERID_CLIENT_SECRET: process.env.SHEERID_CLIENT_SECRET,
+ SHEERID_PROGRAM_ID: process.env.SHEERID_PROGRAM_ID,
+ SHEERID_API_BASE_URL: process.env.SHEERID_API_BASE_URL,
+ SHEERID_ENABLED_COUNTRIES: process.env.SHEERID_ENABLED_COUNTRIES,
+ SHEERID_FALLBACK_COUNTRY: process.env.SHEERID_FALLBACK_COUNTRY,
+ NODE_ENV: process.env.NODE_ENV,
+ }
+
+ function setCredentials() {
+ process.env.SHEERID_CLIENT_ID = 'client-id'
+ process.env.SHEERID_CLIENT_SECRET = 'client-secret'
+ process.env.SHEERID_PROGRAM_ID = 'program-id'
+ }
+
+ afterEach(function () {
+ for (const [key, value] of Object.entries(originalEnv)) {
+ if (value === undefined) {
+ delete process.env[key]
+ } else {
+ process.env[key] = value
+ }
+ }
+ clearSettingsCache()
+ })
+
+ it('sets the verification page CSP frame-src from the configured SheerID host', function () {
+ process.env.SHEERID_API_BASE_URL = 'https://sandbox.sheerid.com'
+ clearSettingsCache()
+
+ const settings = require('../../../../config/settings.overrides.saas.js')
+
+ expect(
+ settings.csp.viewDirectives[
+ 'modules/student-verification/app/views/student-verification'
+ ]
+ ).to.deep.equal(["frame-src 'self' https://sandbox.sheerid.com"])
+ })
+
+ it('upper-cases the SheerID country settings', function () {
+ process.env.SHEERID_ENABLED_COUNTRIES = 'gb, us ,De'
+ process.env.SHEERID_FALLBACK_COUNTRY = 'gb'
+ clearSettingsCache()
+
+ const settings = require('../../../../config/settings.overrides.saas.js')
+
+ expect(settings.sheerId.enabledCountries).to.deep.equal(['GB', 'US', 'DE'])
+ expect(settings.sheerId.fallbackCountry).to.equal('GB')
+ })
+
+ it('refuses to boot with SHEERID_ENABLED and the default mock client in production', function () {
+ process.env.SHEERID_ENABLED = 'true'
+ delete process.env.SHEERID_MOCK // defaults to mock: true
+ process.env.NODE_ENV = 'production'
+ clearSettingsCache()
+
+ expect(() =>
+ require('../../../../config/settings.overrides.saas.js')
+ ).to.throw(/SHEERID_ENABLED/)
+ })
+
+ it('boots with SHEERID_ENABLED, SHEERID_MOCK=false and credentials in production', function () {
+ process.env.SHEERID_ENABLED = 'true'
+ process.env.SHEERID_MOCK = 'false'
+ process.env.NODE_ENV = 'production'
+ setCredentials()
+ clearSettingsCache()
+
+ expect(() =>
+ require('../../../../config/settings.overrides.saas.js')
+ ).not.to.throw()
+ })
+
+ it('refuses to boot with the real client and no credentials in production', function () {
+ process.env.SHEERID_ENABLED = 'true'
+ process.env.SHEERID_MOCK = 'false'
+ process.env.NODE_ENV = 'production'
+ delete process.env.SHEERID_CLIENT_ID
+ delete process.env.SHEERID_CLIENT_SECRET
+ delete process.env.SHEERID_PROGRAM_ID
+ clearSettingsCache()
+
+ expect(() =>
+ require('../../../../config/settings.overrides.saas.js')
+ ).to.throw(/SHEERID_CLIENT_ID, SHEERID_CLIENT_SECRET, SHEERID_PROGRAM_ID/)
+ })
+
+ it('names only the credentials that are missing', function () {
+ process.env.SHEERID_ENABLED = 'true'
+ process.env.SHEERID_MOCK = 'false'
+ process.env.NODE_ENV = 'production'
+ setCredentials()
+ delete process.env.SHEERID_PROGRAM_ID
+ clearSettingsCache()
+
+ expect(() =>
+ require('../../../../config/settings.overrides.saas.js')
+ ).to.throw(/requires SHEERID_PROGRAM_ID/)
+ })
+
+ it('boots with the real client and no credentials outside production', function () {
+ process.env.SHEERID_ENABLED = 'true'
+ process.env.SHEERID_MOCK = 'false'
+ process.env.NODE_ENV = 'test'
+ delete process.env.SHEERID_CLIENT_ID
+ delete process.env.SHEERID_CLIENT_SECRET
+ delete process.env.SHEERID_PROGRAM_ID
+ clearSettingsCache()
+
+ expect(() =>
+ require('../../../../config/settings.overrides.saas.js')
+ ).not.to.throw()
+ })
+
+ it('boots with SHEERID_ENABLED and the default mock client outside production', function () {
+ process.env.SHEERID_ENABLED = 'true'
+ delete process.env.SHEERID_MOCK
+ process.env.NODE_ENV = 'test'
+ clearSettingsCache()
+
+ expect(() =>
+ require('../../../../config/settings.overrides.saas.js')
+ ).not.to.throw()
+ })
+})
diff --git a/services/web/types/backend/express/request.d.ts b/services/web/types/backend/express/request.d.ts
index bfac639aea..1d2d163573 100644
--- a/services/web/types/backend/express/request.d.ts
+++ b/services/web/types/backend/express/request.d.ts
@@ -20,6 +20,9 @@ declare module 'express' {
userRestrictions?: Set
oauth_user?: OAuth2Server.User
logger: RequestLogger
+ // Set by a body-parser `verify` callback on routes that check a webhook
+ // signature, which needs the bytes exactly as received.
+ rawBody?: Buffer | string
i18n: {
language: string
translate(
diff --git a/tools/migrations/20260808093000_create_studentVerifications_indexes.mjs b/tools/migrations/20260808093000_create_studentVerifications_indexes.mjs
new file mode 100644
index 0000000000..884a66807a
--- /dev/null
+++ b/tools/migrations/20260808093000_create_studentVerifications_indexes.mjs
@@ -0,0 +1,59 @@
+import Helpers from './lib/helpers.mjs'
+import { getCollectionInternal } from './lib/mongodb.mjs'
+
+const tags = ['saas']
+
+// Keep in sync with the indexes declared on the model
+// (services/web/app/src/models/StudentVerification.mjs): Mongoose runs with
+// autoIndex: false, so nothing creates these automatically.
+const indexes = [
+ {
+ // Every lookup of a single verification is by this id (status polling, the
+ // webhook, and the mock outcome controls).
+ key: { verificationId: 1 },
+ unique: true,
+ name: 'verificationId_1',
+ },
+ {
+ // The purchase gate: the user's latest unexpired SUCCESS verification.
+ key: { userId: 1, status: 1, createdAt: -1 },
+ name: 'userId_1_status_1_createdAt_-1',
+ },
+ {
+ // Retention: records carry their own `expires` date, so the TTL monitor
+ // deletes each one at the time stored on it rather than after a fixed
+ // interval.
+ key: { expires: 1 },
+ expireAfterSeconds: 0,
+ name: 'expires_1',
+ },
+ {
+ // The reconciliation poller
+ // (modules/student-verification/scripts/reconcile_student_verifications.mjs)
+ // sweeps pending records by refresh staleness, with no userId to narrow
+ // on, so the {userId, status, createdAt} index cannot serve it and the DB
+ // runs with notablescan.
+ key: { status: 1, lastRefreshedAt: 1 },
+ name: 'status_1_lastRefreshedAt_1',
+ },
+]
+
+const migrate = async () => {
+ const studentVerifications = await getCollectionInternal(
+ 'studentVerifications'
+ )
+ await Helpers.addIndexesToCollection(studentVerifications, indexes)
+}
+
+const rollback = async () => {
+ const studentVerifications = await getCollectionInternal(
+ 'studentVerifications'
+ )
+ await Helpers.dropIndexesFromCollection(studentVerifications, indexes)
+}
+
+export default {
+ tags,
+ migrate,
+ rollback,
+}