Add the SheerID student verification module (#36704)

GitOrigin-RevId: a3673c09e185c8f857d5da3e706fa9a310fba6d4
This commit is contained in:
Tim Down
2026-09-17 12:07:32 +00:00
committed by Copybot
parent 37e27bb22e
commit c3d962c075
8 changed files with 393 additions and 1 deletions
@@ -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
)
@@ -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": "",
+11
View File
@@ -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
+22
View File
@@ -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 subscription</0>",
"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 subscription</0>",
"also": "Also",
"alternatively_create_new_institution_account": "Alternatively, you can create a <b>new account</b> with your institution email (<b>__email__</b>) by clicking <b>__clickText__</b>.",
"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 settings</0>.",
"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 file</0> 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 settings</0>.",
"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 features</0>.",
"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 <code>\\cite{}</code> in your .tex file. <a>Learn more</a>",
@@ -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",
@@ -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',
@@ -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()
})
})
+3
View File
@@ -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(