diff --git a/client/components/Deposit/Deposit.tsx b/client/components/Deposit/Deposit.tsx
index 37107b991e..dad27dde71 100644
--- a/client/components/Deposit/Deposit.tsx
+++ b/client/components/Deposit/Deposit.tsx
@@ -15,6 +15,7 @@ import { assert, exists } from 'utils/assert';
import { PUBPUB_DOI_PREFIX } from 'utils/crossref/communities';
import DataciteDeposit from './DataciteDeposit';
+import DepositStatusCallout from './DepositStatusCallout';
import './deposit.scss';
import { UpdateDoi } from './UpdateDoi';
@@ -145,10 +146,18 @@ export default function Deposit(props: Props) {
const firstIntraWorkRelationship = resource && getFirstIntraWorkRelationship(resource);
const disabledDueToNoReleases = 'pub' in props && props.pub.releases?.length === 0;
+ // Deliberately still keyed on "a deposit exists" rather than "a deposit
+ // succeeded": a failed deposit does NOT unlock the DOI suffix again. Deposit
+ // state is per attempt, so a rejected *update* to a work whose DOI Crossref
+ // already registered also reads as failed, and letting the suffix be edited
+ // there would strand a live DOI pointing at nothing. The affordance for a
+ // failure is fixing the metadata and re-submitting, not renaming the DOI.
const crossrefDepositRecordId =
'pub' in props
? props.pub.crossrefDepositRecordId
: props.collection.crossrefDepositRecordId;
+ const depositRecord =
+ 'pub' in props ? props.pub.crossrefDepositRecord : props.collection.crossrefDepositRecord;
let children: React.ReactNode;
@@ -171,6 +180,14 @@ export default function Deposit(props: Props) {
children = (
<>
{unapprovedWarning}
+ {!justSetDoi && (
+
+ )}
{'pub' in props && resource && firstIntraWorkRelationship && (
This Pub will be cited as a member of the{' '}
diff --git a/client/components/Deposit/DepositStatusCallout.tsx b/client/components/Deposit/DepositStatusCallout.tsx
new file mode 100644
index 0000000000..f95654d7c8
--- /dev/null
+++ b/client/components/Deposit/DepositStatusCallout.tsx
@@ -0,0 +1,95 @@
+import React from 'react';
+
+import { Callout } from '@blueprintjs/core';
+
+import { getDoiDisplay } from 'utils/crossref/depositStatus';
+
+type Props = {
+ status?: string | null;
+ /** Registrar (or Doily) failure text for the last attempt, shown verbatim. */
+ error?: string | null;
+ lastCheckedAt?: string | Date | null;
+ registrarName?: string;
+};
+
+const formatCheckedAt = (lastCheckedAt?: string | Date | null) => {
+ if (!lastCheckedAt) {
+ return null;
+ }
+ const date = new Date(lastCheckedAt);
+ if (Number.isNaN(date.getTime())) {
+ return null;
+ }
+ return date.toLocaleString();
+};
+
+/**
+ * What the registrar actually said about a deposit, for the manage-level DOI UI.
+ *
+ * Before Doily pushed outcomes, PubPub could only say "deposited", which meant
+ * "we POSTed it and were not told no". So the two states worth an affordance
+ * (still in flight, and rejected) had no way to appear at all. Renders nothing
+ * for a legacy row: a deposit with no recorded state is the pre-Doily normal and
+ * must look exactly as it always did rather than sprouting a scary callout.
+ */
+export default function DepositStatusCallout(props: Props) {
+ const { status, error, lastCheckedAt, registrarName = 'Crossref' } = props;
+ const display = getDoiDisplay(status);
+ const checkedAt = formatCheckedAt(lastCheckedAt);
+ const checkedAtLine = checkedAt ? (
+
As of {checkedAt}.
+ ) : null;
+
+ if (display === 'legacy') {
+ return null;
+ }
+
+ if (display === 'registered') {
+ return (
+
+ The DOI is registered and resolves to this Pub.
+ {checkedAtLine}
+
+ );
+ }
+
+ if (display === 'unverified') {
+ return (
+
+
+ This deposit was submitted and {registrarName} did not report a failure, but the
+ registration could not be confirmed. The DOI is displayed as normal. If it does
+ not resolve, submit the deposit again.
+
+ {error && {error}
}
+ {checkedAtLine}
+
+ );
+ }
+
+ if (display === 'pending') {
+ return (
+
+
+ The deposit is with {registrarName}, which usually rules on it within a few
+ hours. Until it confirms, this DOI is left out of the Pub page, the citations
+ and the metadata search engines read, so that nobody is sent to a link that does
+ not resolve yet. Nothing more to do here: reload this page to see the outcome.
+
+ {checkedAtLine}
+
+ );
+ }
+
+ return (
+
+
+ The DOI is not registered, so it is left out of the Pub page, the citations and the
+ metadata search engines read. Correct what the message below points at, then submit
+ the deposit again.
+
+ {error && {error}
}
+ {checkedAtLine}
+
+ );
+}
diff --git a/client/components/Deposit/SubmitDepositButton.tsx b/client/components/Deposit/SubmitDepositButton.tsx
index 2149a9394e..8826799611 100644
--- a/client/components/Deposit/SubmitDepositButton.tsx
+++ b/client/components/Deposit/SubmitDepositButton.tsx
@@ -20,7 +20,10 @@ const buttonTextByStatus = {
[SubmitDepositStatus.Previewing]: 'Generating Preview',
[SubmitDepositStatus.Previewed]: 'Submit Deposit',
[SubmitDepositStatus.Depositing]: 'Depositing',
- [SubmitDepositStatus.Deposited]: 'DOI Deposited',
+ // Not "DOI Deposited": the registrar has only accepted the batch at this
+ // point and can still reject the record. The outcome is reported by the
+ // status callout next to this button, which is the only thing that knows it.
+ [SubmitDepositStatus.Deposited]: 'Deposit Submitted',
};
const getButtonText = (status: SubmitDepositStatus, depositRecord?: DepositRecord) => {
diff --git a/client/components/index.ts b/client/components/index.ts
index fbc1dc06a4..319bc56321 100644
--- a/client/components/index.ts
+++ b/client/components/index.ts
@@ -23,6 +23,7 @@ export { default as DashboardRowListing } from './DashboardRow/DashboardRowListi
export { default as DatePicker } from './DatePicker/DatePicker';
export { default as DataciteDeposit } from './Deposit/DataciteDeposit';
export { default as Deposit } from './Deposit/Deposit';
+export { default as DepositStatusCallout } from './Deposit/DepositStatusCallout';
export {
DevCommunitySwitcherMenu,
DevCommunitySwitcherMenuItems,
diff --git a/client/containers/DashboardSettings/PubSettings/Doi.tsx b/client/containers/DashboardSettings/PubSettings/Doi.tsx
index 6a228e2ab5..5ea4b98176 100644
--- a/client/containers/DashboardSettings/PubSettings/Doi.tsx
+++ b/client/containers/DashboardSettings/PubSettings/Doi.tsx
@@ -6,10 +6,11 @@ import React, { Component } from 'react';
import { Button, Callout, Collapse, FormGroup, InputGroup } from '@blueprintjs/core';
import { apiFetch } from 'client/utils/apiFetch';
-import { AssignDoi } from 'components';
+import { AssignDoi, DepositStatusCallout } from 'components';
import { getPrimaryCollection } from 'utils/collections/primary';
import { getSchemaForKind } from 'utils/collections/schemas';
import { PUBPUB_DOI_PREFIX } from 'utils/crossref/communities';
+import { getDoiDisplay } from 'utils/crossref/depositStatus';
import { isDoi } from 'utils/crossref/parseDoi';
import {
findParentEdgeByRelationTypes,
@@ -341,18 +342,30 @@ class Doi extends Component {
return null;
}
+ getDepositState() {
+ // Null for a viewer without manage permission (pubSanitize withholds the
+ // record), which lands on 'legacy' and renders as it always has.
+ return this.props.pubData.crossrefDepositRecord ?? null;
+ }
+
renderStatusMessage() {
const { pubData } = this.props;
const { justSetDoi } = this.state;
+ const depositState = this.getDepositState();
+ // A deposit with no recorded state is the pre-Doily normal: keep the exact
+ // sentence this page has always shown rather than implying the DOI is
+ // unconfirmed.
+ const hasDepositState = getDoiDisplay(depositState?.status) !== 'legacy';
if (justSetDoi) {
return (
-
- Successfully submitted a DOI registration for this Pub.
+
+ The DOI registration for this Pub has been submitted to Crossref.
- Registration may take a few hours to complete in Crossref's system. If
- DOI URLs do not work immediately, the registration is likely still
- processing.
+ Crossref usually rules on a deposit within a few hours. Until it confirms
+ the record, this DOI is left out of the Pub page, the citations and the
+ metadata search engines read. Reload this page to see the outcome, which
+ will include Crossref's error message if the deposit was rejected.
);
@@ -376,7 +389,16 @@ class Doi extends Component {
to Crossref.
)}
- {pubData.crossrefDepositRecordId && This Pub has been deposited to Crossref.
}
+ {pubData.crossrefDepositRecordId && !hasDepositState && (
+ This Pub has been deposited to Crossref.
+ )}
+ {pubData.crossrefDepositRecordId && hasDepositState && (
+
+ )}
{this.isDoiEditableWithoutRelations() && this.findSupplementTo() && (
The DOI for this Pub is not editable because it is a{' '}
diff --git a/client/containers/Pub/PubHeader/details/PubDetails.tsx b/client/containers/Pub/PubHeader/details/PubDetails.tsx
index 76c7a75145..131e0e88e0 100644
--- a/client/containers/Pub/PubHeader/details/PubDetails.tsx
+++ b/client/containers/Pub/PubHeader/details/PubDetails.tsx
@@ -7,6 +7,7 @@ import dateFormat from 'dateformat';
import { ClickToCopyButton, ContributorsList } from 'components';
import { collectionUrl } from 'utils/canonicalUrls';
import { getAllPubContributors } from 'utils/contributors';
+import { getDoiDisplay, isDoiPublic } from 'utils/crossref/depositStatus';
import { usePageContext } from 'utils/hooks';
import {
getPubCreatedDate,
@@ -21,7 +22,10 @@ import SmallHeaderButton from '../SmallHeaderButton';
import './pubDetails.scss';
type Props = {
- pubData: Pub;
+ pubData: Pub & {
+ crossrefDepositStatus?: string | null;
+ crossrefDepositEverRegistered?: boolean;
+ };
onCloseHeaderDetails: (...args: any[]) => any;
communityData: {};
};
@@ -31,7 +35,21 @@ const PubDetails = (props: Props) => {
const { collectionPubs } = pubData;
const contributors = getAllPubContributors(pubData, 'contributors');
const { scopeData } = usePageContext();
- const { canView } = scopeData.activePermissions;
+ const { canView, canManage } = scopeData.activePermissions;
+
+ // A DOI whose deposit has not been confirmed is not a link we may ask a
+ // reader to click: doi.org answers 404 until the registrar has the record.
+ // Pending still shows the DOI (it is the pub's assigned identifier, and it
+ // will start resolving) but as text, not a promise. A rejected deposit is
+ // shown only to someone who can do something about it, with the way there.
+ const doiDisplay = getDoiDisplay(pubData.crossrefDepositStatus);
+ // A DOI that ever registered still resolves even when the latest attempt was
+ // rejected, so it stays printable. doiDisplay above still reports 'failed',
+ // which is what drives the manager-only caveat.
+ const doiIsPublic = isDoiPublic(
+ pubData.crossrefDepositStatus,
+ pubData.crossrefDepositEverRegistered,
+ );
const createdAt = getPubCreatedDate(pubData);
const publishedDateString = getPubPublishedDateString(pubData);
@@ -76,7 +94,7 @@ const PubDetails = (props: Props) => {
)}
- {pubData.doi && (
+ {pubData.doi && doiIsPublic && (
DOI {' '}
{
)}
+ {pubData.doi && doiDisplay === 'pending' && (
+
+ DOI {' '}
+
+ {pubData.doi} (registration pending)
+
+
+ )}
+ {pubData.doi && doiDisplay === 'failed' && canManage && (
+
+ DOI {' '}
+
+ {pubData.doi}
(registration failed) {' '}
+
Resubmit
+
+
+ )}
diff --git a/client/containers/SuperAdminDashboard/FeatureFlags/FeatureFlagCard.tsx b/client/containers/SuperAdminDashboard/FeatureFlags/FeatureFlagCard.tsx
new file mode 100644
index 0000000000..b72c9d22a2
--- /dev/null
+++ b/client/containers/SuperAdminDashboard/FeatureFlags/FeatureFlagCard.tsx
@@ -0,0 +1,615 @@
+import type { CommunityOverride, FlagSummary, OverridesPayload, UserOverride } from './types';
+
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+
+import {
+ Button,
+ ButtonGroup,
+ Callout,
+ Collapse,
+ Icon,
+ Intent,
+ MenuItem,
+ Slider,
+ Spinner,
+ Tag,
+} from '@blueprintjs/core';
+import { Suggest } from '@blueprintjs/select';
+
+import { apiFetch } from 'client/utils/apiFetch';
+import { Avatar, UserAutocomplete } from 'components';
+
+import { countOverrides } from './types';
+
+type CommunityOption = {
+ id: string;
+ title: string;
+ subdomain: string;
+};
+
+type OverrideState = 'on' | 'off' | 'inert';
+
+type Props = {
+ flag: FlagSummary;
+ totalCommunities: number;
+ totalUsers: number;
+ onChange: (flag: FlagSummary) => void;
+ onRequestDelete: (flag: FlagSummary) => void;
+};
+
+const formatPercent = (fraction: number) => `${Math.round(fraction * 100)}%`;
+
+const useCommunitySearch = () => {
+ const [query, setQuery] = useState('');
+ const [results, setResults] = useState
([]);
+ const debounceRef = useRef>();
+
+ useEffect(() => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ if (!query.trim()) {
+ setResults([]);
+ return;
+ }
+ debounceRef.current = setTimeout(async () => {
+ try {
+ const params = new URLSearchParams({ q: query });
+ const data = await apiFetch.get(
+ `/api/superadmin/communities/search?${params}`,
+ );
+ setResults(data);
+ } catch {
+ setResults([]);
+ }
+ }, 250);
+ return () => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ };
+ }, [query]);
+
+ return { query, setQuery, results, setResults };
+};
+
+const AddStateToggle = (props: {
+ value: 'on' | 'off';
+ onChange: (value: 'on' | 'off') => void;
+}) => (
+
+ props.onChange('on')}
+ />
+ props.onChange('off')}
+ />
+
+);
+
+const OverrideStateControl = (props: {
+ enabled: boolean;
+ disabled: boolean;
+ onSetState: (state: OverrideState) => void;
+}) => (
+
+
+ !props.enabled && props.onSetState('on')}
+ />
+ props.enabled && props.onSetState('off')}
+ />
+
+ props.onSetState('inert')}
+ />
+
+);
+
+const FeatureFlagCard = (props: Props) => {
+ const { flag, totalCommunities, totalUsers, onChange, onRequestDelete } = props;
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [overrides, setOverrides] = useState(null);
+ const [isLoadingOverrides, setIsLoadingOverrides] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Local slider values (percent) so dragging doesn't fire requests
+ const [communitiesPercent, setCommunitiesPercent] = useState(
+ Math.round(flag.enabledCommunitiesFraction * 100),
+ );
+ const [usersPercent, setUsersPercent] = useState(Math.round(flag.enabledUsersFraction * 100));
+
+ const [communityAddState, setCommunityAddState] = useState<'on' | 'off'>('on');
+ const [userAddState, setUserAddState] = useState<'on' | 'off'>('on');
+ const communitySearch = useCommunitySearch();
+
+ useEffect(() => {
+ setCommunitiesPercent(Math.round(flag.enabledCommunitiesFraction * 100));
+ setUsersPercent(Math.round(flag.enabledUsersFraction * 100));
+ }, [flag.enabledCommunitiesFraction, flag.enabledUsersFraction]);
+
+ const loadOverrides = useCallback(async () => {
+ setIsLoadingOverrides(true);
+ setError(null);
+ try {
+ const data = await apiFetch.get(
+ `/api/superadmin/feature-flags/${flag.id}/overrides`,
+ );
+ setOverrides(data);
+ } catch (err: any) {
+ setError(err?.message || 'Failed to load overrides.');
+ } finally {
+ setIsLoadingOverrides(false);
+ }
+ }, [flag.id]);
+
+ const handleToggleExpand = useCallback(() => {
+ setIsExpanded((prev) => {
+ if (!prev && !overrides) {
+ loadOverrides();
+ }
+ return !prev;
+ });
+ }, [overrides, loadOverrides]);
+
+ const updateOverrides = useCallback(
+ (next: OverridesPayload) => {
+ setOverrides(next);
+ onChange({ ...flag, overrides: countOverrides(next) });
+ },
+ [flag, onChange],
+ );
+
+ const saveFractions = useCallback(
+ async (fields: { enabledCommunitiesFraction?: number; enabledUsersFraction?: number }) => {
+ setIsSaving(true);
+ setError(null);
+ try {
+ const result = await apiFetch.put(
+ `/api/superadmin/feature-flags/${flag.id}`,
+ fields,
+ );
+ onChange({ ...flag, ...result });
+ } catch (err: any) {
+ setError(err?.message || 'Failed to update rollout fraction.');
+ setCommunitiesPercent(Math.round(flag.enabledCommunitiesFraction * 100));
+ setUsersPercent(Math.round(flag.enabledUsersFraction * 100));
+ } finally {
+ setIsSaving(false);
+ }
+ },
+ [flag, onChange],
+ );
+
+ const setCommunityOverride = useCallback(
+ async (
+ community: { id: string; title: string; subdomain: string },
+ state: OverrideState,
+ ) => {
+ if (!overrides) return;
+ setIsSaving(true);
+ setError(null);
+ try {
+ await apiFetch.put(`/api/superadmin/feature-flags/${flag.id}/community-override`, {
+ communityId: community.id,
+ state,
+ });
+ const withoutEntry = overrides.communities.filter(
+ (o) => o.communityId !== community.id,
+ );
+ const nextCommunities: CommunityOverride[] =
+ state === 'inert'
+ ? withoutEntry
+ : [
+ ...withoutEntry,
+ {
+ communityId: community.id,
+ enabled: state === 'on',
+ title: community.title,
+ subdomain: community.subdomain,
+ },
+ ].sort((a, b) => a.title.localeCompare(b.title));
+ updateOverrides({ ...overrides, communities: nextCommunities });
+ } catch (err: any) {
+ setError(err?.message || 'Failed to update community override.');
+ } finally {
+ setIsSaving(false);
+ }
+ },
+ [flag.id, overrides, updateOverrides],
+ );
+
+ const setUserOverride = useCallback(
+ async (
+ user: {
+ id: string;
+ fullName: string;
+ slug: string;
+ avatar: string | null;
+ initials: string;
+ },
+ state: OverrideState,
+ ) => {
+ if (!overrides) return;
+ setIsSaving(true);
+ setError(null);
+ try {
+ await apiFetch.put(`/api/superadmin/feature-flags/${flag.id}/user-override`, {
+ userId: user.id,
+ state,
+ });
+ const withoutEntry = overrides.users.filter((o) => o.userId !== user.id);
+ const nextUsers: UserOverride[] =
+ state === 'inert'
+ ? withoutEntry
+ : [
+ ...withoutEntry,
+ {
+ userId: user.id,
+ enabled: state === 'on',
+ fullName: user.fullName,
+ slug: user.slug,
+ avatar: user.avatar,
+ initials: user.initials,
+ },
+ ].sort((a, b) => a.fullName.localeCompare(b.fullName));
+ updateOverrides({ ...overrides, users: nextUsers });
+ } catch (err: any) {
+ setError(err?.message || 'Failed to update user override.');
+ } finally {
+ setIsSaving(false);
+ }
+ },
+ [flag.id, overrides, updateOverrides],
+ );
+
+ const { communitiesOn, communitiesOff, usersOn, usersOff } = flag.overrides;
+
+ const renderSummaryTags = () => (
+
+ {flag.enabledCommunitiesFraction > 0 && (
+
+ {flag.enabledCommunitiesFraction >= 1
+ ? 'All communities'
+ : `${formatPercent(flag.enabledCommunitiesFraction)} of communities`}
+
+ )}
+ {flag.enabledUsersFraction > 0 && (
+
+ {flag.enabledUsersFraction >= 1
+ ? 'All users'
+ : `${formatPercent(flag.enabledUsersFraction)} of users`}
+
+ )}
+ {communitiesOn > 0 && (
+
+ {communitiesOn} {communitiesOn === 1 ? 'community' : 'communities'} on
+
+ )}
+ {communitiesOff > 0 && (
+
+ {communitiesOff} {communitiesOff === 1 ? 'community' : 'communities'} off
+
+ )}
+ {usersOn > 0 && (
+
+ {usersOn} {usersOn === 1 ? 'user' : 'users'} on
+
+ )}
+ {usersOff > 0 && (
+
+ {usersOff} {usersOff === 1 ? 'user' : 'users'} off
+
+ )}
+ {flag.enabledCommunitiesFraction === 0 &&
+ flag.enabledUsersFraction === 0 &&
+ communitiesOn === 0 &&
+ usersOn === 0 && (
+
+ Off for everyone
+
+ )}
+
+ );
+
+ const renderRolloutSection = () => (
+
+
Gradual rollout
+
+ Enable this flag for a fraction of all communities or users (chosen
+ deterministically by UUID). Overrides below always win over these fractions.
+
+
+
+
+ Communities
+
+ {
+ setCommunitiesPercent(0);
+ saveFractions({ enabledCommunitiesFraction: 0 });
+ }}
+ />
+ {
+ setCommunitiesPercent(100);
+ saveFractions({ enabledCommunitiesFraction: 1 });
+ }}
+ />
+
+
+
`${value}%`}
+ value={communitiesPercent}
+ disabled={isSaving}
+ onChange={setCommunitiesPercent}
+ onRelease={(value) =>
+ saveFractions({ enabledCommunitiesFraction: value / 100 })
+ }
+ />
+
+ ≈ {Math.round((communitiesPercent / 100) * totalCommunities)} of{' '}
+ {totalCommunities} communities
+
+
+
+
+ Users
+
+ {
+ setUsersPercent(0);
+ saveFractions({ enabledUsersFraction: 0 });
+ }}
+ />
+ {
+ setUsersPercent(100);
+ saveFractions({ enabledUsersFraction: 1 });
+ }}
+ />
+
+
+
`${value}%`}
+ value={usersPercent}
+ disabled={isSaving}
+ onChange={setUsersPercent}
+ onRelease={(value) => saveFractions({ enabledUsersFraction: value / 100 })}
+ />
+
+ ≈ {Math.round((usersPercent / 100) * totalUsers)} of {totalUsers} users
+
+
+
+
+ );
+
+ const renderCommunityOverrides = () => (
+
+
Community overrides ({overrides!.communities.length})
+
+
+
+ items={communitySearch.results}
+ query={communitySearch.query}
+ onQueryChange={communitySearch.setQuery}
+ inputValueRenderer={() => ''}
+ inputProps={{ placeholder: 'Search communities to add…' }}
+ popoverProps={{ minimal: true }}
+ itemRenderer={(item, { handleClick, modifiers }) => (
+
+ )}
+ noResults={ }
+ onItemSelect={(item) => {
+ communitySearch.setQuery('');
+ communitySearch.setResults([]);
+ setCommunityOverride(item, communityAddState);
+ }}
+ />
+
+ {overrides!.communities.length === 0 ? (
+
No community overrides.
+ ) : (
+
+
+ {overrides!.communities.map((override) => (
+
+
+
+ {override.title}
+ {' '}
+ {override.subdomain}
+
+
+
+ setCommunityOverride(
+ {
+ id: override.communityId,
+ title: override.title,
+ subdomain: override.subdomain,
+ },
+ state,
+ )
+ }
+ />
+
+
+ ))}
+
+
+ )}
+
+ );
+
+ const renderUserOverrides = () => (
+
+
User overrides ({overrides!.users.length})
+
+
+
o.userId) as any}
+ onSelect={
+ ((user: any) => {
+ if (user?.id) {
+ setUserOverride(
+ {
+ id: user.id,
+ fullName: user.fullName,
+ slug: user.slug,
+ avatar: user.avatar ?? null,
+ initials: user.initials ?? '?',
+ },
+ userAddState,
+ );
+ }
+ }) as any
+ }
+ />
+
+ {overrides!.users.length === 0 ? (
+
No user overrides.
+ ) : (
+
+
+ {overrides!.users.map((override) => (
+
+
+
+
+
+
+ setUserOverride(
+ {
+ id: override.userId,
+ fullName: override.fullName,
+ slug: override.slug,
+ avatar: override.avatar,
+ initials: override.initials,
+ },
+ state,
+ )
+ }
+ />
+
+
+ ))}
+
+
+ )}
+
+ );
+
+ return (
+
+
+
+ {flag.name}
+ {renderSummaryTags()}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {renderRolloutSection()}
+ {isLoadingOverrides &&
}
+ {overrides && (
+
+ {renderCommunityOverrides()}
+ {renderUserOverrides()}
+
+ )}
+
+ onRequestDelete(flag)}
+ />
+
+
+
+
+ );
+};
+
+export default FeatureFlagCard;
diff --git a/client/containers/SuperAdminDashboard/FeatureFlags/FeatureFlags.tsx b/client/containers/SuperAdminDashboard/FeatureFlags/FeatureFlags.tsx
new file mode 100644
index 0000000000..6a57f3bc60
--- /dev/null
+++ b/client/containers/SuperAdminDashboard/FeatureFlags/FeatureFlags.tsx
@@ -0,0 +1,211 @@
+import type { FlagSummary } from './types';
+
+import React, { useCallback, useMemo, useState } from 'react';
+
+import {
+ Button,
+ Callout,
+ Classes,
+ Dialog,
+ InputGroup,
+ Intent,
+ NonIdealState,
+} from '@blueprintjs/core';
+
+import { apiFetch } from 'client/utils/apiFetch';
+
+import FeatureFlagCard from './FeatureFlagCard';
+
+import './featureFlags.scss';
+
+type Props = {
+ featureFlags: FlagSummary[];
+ totalCommunities: number;
+ totalUsers: number;
+};
+
+const FeatureFlags = (props: Props) => {
+ const [flags, setFlags] = useState(props.featureFlags);
+ const [newFlagName, setNewFlagName] = useState('');
+ const [filterText, setFilterText] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+ const [pendingDelete, setPendingDelete] = useState(null);
+
+ const filteredFlags = useMemo(() => {
+ const q = filterText.toLowerCase().trim();
+ if (!q) return flags;
+ return flags.filter((flag) => flag.name.toLowerCase().includes(q));
+ }, [flags, filterText]);
+
+ const handleCreate = useCallback(async () => {
+ const name = newFlagName.trim();
+ if (!name) {
+ setError('Flag name is required.');
+ return;
+ }
+ setIsLoading(true);
+ setError(null);
+ setSuccess(null);
+ try {
+ const result = await apiFetch.post('/api/superadmin/feature-flags', {
+ name,
+ });
+ setFlags((prev) =>
+ [...prev, result].sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')),
+ );
+ setNewFlagName('');
+ setSuccess(
+ `Feature flag "${result.name}" created. It is off for everyone until you roll it out or add overrides.`,
+ );
+ } catch (err: any) {
+ setError(err?.message || 'Failed to create feature flag.');
+ } finally {
+ setIsLoading(false);
+ }
+ }, [newFlagName]);
+
+ const handleDelete = useCallback(async (flag: FlagSummary) => {
+ setPendingDelete(null);
+ setIsLoading(true);
+ setError(null);
+ setSuccess(null);
+ try {
+ await apiFetch.delete(`/api/superadmin/feature-flags/${flag.id}`);
+ setFlags((prev) => prev.filter((f) => f.id !== flag.id));
+ setSuccess(`Feature flag "${flag.name}" and all of its overrides were deleted.`);
+ } catch (err: any) {
+ setError(err?.message || 'Failed to delete feature flag.');
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ const handleFlagChange = useCallback((updated: FlagSummary) => {
+ setFlags((prev) => prev.map((f) => (f.id === updated.id ? updated : f)));
+ }, []);
+
+ const totalOverrideCount = (flag: FlagSummary) =>
+ flag.overrides.communitiesOn +
+ flag.overrides.communitiesOff +
+ flag.overrides.usersOn +
+ flag.overrides.usersOff;
+
+ return (
+
+
Feature Flags
+
+ Feature flags selectively enable features for certain communities or users. A flag
+ can be forced on or off per community or user, or rolled out gradually to a fraction
+ of all of them. Forced overrides always win over the gradual rollout;
+ “off” overrides win over “on” overrides. Flags are read in
+ code via initialData.featureFlags — see{' '}
+ server/featureFlag/README.md.
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {success && (
+
+ {success}
+
+ )}
+
+
Create a flag
+
+ setNewFlagName(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
+ disabled={isLoading}
+ />
+
+
+
+
Manage flags
+ {flags.length === 0 ? (
+
+ ) : (
+ <>
+
+ setFilterText(e.target.value)}
+ />
+
+ {filterText && filteredFlags.length !== flags.length
+ ? `Showing ${filteredFlags.length} of ${flags.length}`
+ : `Total: ${flags.length}`}{' '}
+ flag{flags.length !== 1 ? 's' : ''}
+
+
+
+ {filteredFlags.map((flag) => (
+
+ ))}
+
+ >
+ )}
+
+
setPendingDelete(null)}
+ title="Delete Feature Flag"
+ icon="warning-sign"
+ >
+
+
+ Delete feature flag {pendingDelete?.name} ?
+
+ {pendingDelete && totalOverrideCount(pendingDelete) > 0 && (
+
+ This will also remove its {totalOverrideCount(pendingDelete)} community
+ and user override
+ {totalOverrideCount(pendingDelete) !== 1 ? 's' : ''}.
+
+ )}
+
+ Any code checking this flag will see it as false afterwards.
+
+
+
+
+ setPendingDelete(null)}>Cancel
+ pendingDelete && handleDelete(pendingDelete)}
+ loading={isLoading}
+ >
+ Delete
+
+
+
+
+
+ );
+};
+
+export default FeatureFlags;
diff --git a/client/containers/SuperAdminDashboard/FeatureFlags/featureFlags.scss b/client/containers/SuperAdminDashboard/FeatureFlags/featureFlags.scss
new file mode 100644
index 0000000000..b9e422e4f5
--- /dev/null
+++ b/client/containers/SuperAdminDashboard/FeatureFlags/featureFlags.scss
@@ -0,0 +1,189 @@
+.feature-flags-component {
+ .intro {
+ max-width: 720px;
+ color: #5c7080;
+ }
+
+ .create-flag-form {
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+ margin-bottom: 24px;
+
+ .bp3-input-group {
+ width: 280px;
+ }
+ }
+
+ .filter-bar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 12px;
+
+ .bp3-input-group {
+ width: 280px;
+ }
+
+ .flag-count {
+ color: #5c7080;
+ font-size: 13px;
+ }
+ }
+
+ .flag-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .feature-flag-card {
+ border: 1px solid rgba(16, 22, 26, 0.15);
+ border-radius: 4px;
+ background: #fff;
+
+ .flag-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 10px 12px;
+ border: none;
+ background: none;
+ cursor: pointer;
+ text-align: left;
+
+ &:hover {
+ background: rgba(16, 22, 26, 0.03);
+ }
+
+ .flag-name {
+ font-size: 14px;
+ font-weight: 600;
+ }
+
+ .summary-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-left: auto;
+ }
+ }
+
+ .flag-detail {
+ padding: 4px 16px 16px 34px;
+ border-top: 1px solid rgba(16, 22, 26, 0.1);
+ }
+
+ .section-hint {
+ color: #5c7080;
+ font-size: 13px;
+ max-width: 640px;
+ }
+
+ h5 {
+ margin: 16px 0 8px;
+ font-size: 14px;
+ }
+
+ .rollout-sliders {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 32px;
+ }
+
+ .rollout-slider {
+ flex: 1;
+ min-width: 260px;
+ max-width: 380px;
+
+ .rollout-slider-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 8px;
+ }
+
+ .rollout-estimate {
+ display: block;
+ margin-top: 4px;
+ color: #5c7080;
+ font-size: 12px;
+ }
+ }
+
+ .override-sections {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 32px;
+ margin-top: 8px;
+
+ .override-section {
+ flex: 1;
+ min-width: 320px;
+ }
+ }
+
+ .add-override {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+
+ .user-autocomplete-component,
+ .bp3-popover-wrapper {
+ flex: 1;
+ }
+
+ .user-autocomplete-component .bp3-input {
+ height: 30px;
+ font-size: 14px;
+ }
+ }
+
+ .empty-overrides {
+ color: #5c7080;
+ font-size: 13px;
+ font-style: italic;
+ }
+
+ .overrides-table {
+ width: 100%;
+ border-collapse: collapse;
+
+ td {
+ padding: 4px 8px 4px 0;
+ border-bottom: 1px solid rgba(16, 22, 26, 0.08);
+ vertical-align: middle;
+
+ code {
+ margin-left: 6px;
+ color: #5c7080;
+ }
+ }
+
+ .controls-cell {
+ width: 1%;
+ white-space: nowrap;
+ }
+ }
+
+ .user-cell {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .override-controls {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ .danger-zone {
+ margin-top: 20px;
+ padding-top: 8px;
+ border-top: 1px dashed rgba(16, 22, 26, 0.15);
+ }
+ }
+}
diff --git a/client/containers/SuperAdminDashboard/FeatureFlags/index.ts b/client/containers/SuperAdminDashboard/FeatureFlags/index.ts
new file mode 100644
index 0000000000..09482e1dee
--- /dev/null
+++ b/client/containers/SuperAdminDashboard/FeatureFlags/index.ts
@@ -0,0 +1 @@
+export { default } from './FeatureFlags';
diff --git a/client/containers/SuperAdminDashboard/FeatureFlags/types.ts b/client/containers/SuperAdminDashboard/FeatureFlags/types.ts
new file mode 100644
index 0000000000..689c07c8b7
--- /dev/null
+++ b/client/containers/SuperAdminDashboard/FeatureFlags/types.ts
@@ -0,0 +1,40 @@
+export type FlagSummary = {
+ id: string;
+ name: string;
+ enabledUsersFraction: number;
+ enabledCommunitiesFraction: number;
+ overrides: {
+ communitiesOn: number;
+ communitiesOff: number;
+ usersOn: number;
+ usersOff: number;
+ };
+};
+
+export type CommunityOverride = {
+ communityId: string;
+ enabled: boolean;
+ title: string;
+ subdomain: string;
+};
+
+export type UserOverride = {
+ userId: string;
+ enabled: boolean;
+ fullName: string;
+ slug: string;
+ avatar: string | null;
+ initials: string;
+};
+
+export type OverridesPayload = {
+ communities: CommunityOverride[];
+ users: UserOverride[];
+};
+
+export const countOverrides = (overrides: OverridesPayload): FlagSummary['overrides'] => ({
+ communitiesOn: overrides.communities.filter((o) => o.enabled).length,
+ communitiesOff: overrides.communities.filter((o) => !o.enabled).length,
+ usersOn: overrides.users.filter((o) => o.enabled).length,
+ usersOff: overrides.users.filter((o) => !o.enabled).length,
+});
diff --git a/client/containers/SuperAdminDashboard/tabs.tsx b/client/containers/SuperAdminDashboard/tabs.tsx
index 72b2d64bef..d669c7555c 100644
--- a/client/containers/SuperAdminDashboard/tabs.tsx
+++ b/client/containers/SuperAdminDashboard/tabs.tsx
@@ -7,6 +7,7 @@ import CommunityTemplates from './CommunityTemplates';
import CustomDomains from './CustomDomains';
import DepositTargets from './DepositTargets';
import ExploreCommunities from './ExploreCommunities';
+import FeatureFlags from './FeatureFlags';
import FtpTargets from './FtpTargets';
import Hubs from './Hubs';
import LandingPageFeatures from './LandingPageFeatures';
@@ -28,6 +29,10 @@ export const superAdminTabs: Record = {
title: 'Custom Domains',
component: CustomDomains,
},
+ featureFlags: {
+ title: 'Feature Flags',
+ component: FeatureFlags,
+ },
depositTargets: {
title: 'Deposit Targets',
component: DepositTargets,
diff --git a/infra/.env.dev.enc b/infra/.env.dev.enc
index 467e574bbd..b2921ec154 100644
--- a/infra/.env.dev.enc
+++ b/infra/.env.dev.enc
@@ -1,62 +1,65 @@
-AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:CeuQFetXWreDHaRLHKWvFZD+mPPQr4iybwbzkzCBfOPoPQ4SuUxqtcXgQsv7OIpmKe60eXgiWjDGFL8l+FfnEg==,iv:B2jo0AQ4KIPSDDckwjdB0ZTGFkZRwr6FSGhpIdMe2R4=,tag:VUILXlihww1/B2UQNkRl+A==,type:str]
-ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:1h+PXYdhBrxpSyyWNi1/dv8u2H/LSU2va4H+Y7sDzv6+5+S1JjMwL5JJgNEs5S/EUM2dsps8A6u4ATMBun7VDQ==,iv:hF0vBkIACkGsoCyeGHiRX2/c5QeRLej2GMtiPJYQ71U=,tag:41pbtUEKLs6K68QbcTyzDw==,type:str]
-AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:dLNJcFzdZxjSr/E3+oaDMxrtO5g=,iv:U9lsFBmMGnXhkPniconFWK7JzLIdiwWwU+YJUi0k364=,tag:dUSoEm6DPKYAe+arZaBPZw==,type:str]
-AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:kI0YxgUs0HyXZzFvgErkH9zXl6c=,iv:f7HMrH4iukayDaOpGMrOGtNa4Yz9uThudJwAqrDc6Qc=,tag:vSZkec4/mxiFCSM9PY5Ksw==,type:str]
-AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:Sxewei8i2tQ4aKD2xsPhXzpHDRxr4saM65wGjAmiAXYGbbXIJy6c0Q==,iv:UwWYBfLifi3La/YPU82i92XIm/Ab/0d+pBtW2M+VO18=,tag:sj/4AVmtW4JEaq1L40MCsQ==,type:str]
-AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:kgn953l8vl07Z0COs8wby6uYHtGDdTOdJFUuomqdJTZkyxq3ej0Lgw==,iv:8Ug9+0nRaugVwVCdz3sKDgv1YBbdQOIdUkWjhNuEc4g=,tag:tFHUSUp1hmAtBRBfBN3s2w==,type:str]
-BACKUPS_SECRET=ENC[AES256_GCM,data:gzR5yT4pu+PVxEgHBbVEECbhiQgaguiS9MPhA02H+0KTzv7eg5+CTdkIBMc=,iv:2ELNw9fK3zfV6n9bdAMhPzMCAC+5QjogjVRGD7UW/LM=,tag:j4EIYJhs2TLHso/jGA06jQ==,type:str]
-CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:K5nqV6Zkb/4PZhyVFIR8qKIO50MTwwmPdFjX5aH95iyQPBtxP1Eg/QWe3E1r636wx/pdvYo=,iv:vcnbLg3+BgWFHAeCaHznxu5aLObt8VdxKimDJg5fPMQ=,tag:SnKkVEU/z+3bT+bLlrwt1g==,type:str]
-CLOUDFLARE_CUSTOM_HOSTNAME_API_TOKEN=ENC[AES256_GCM,data:m93YWyZBeYjZj4lDia/QoIvUpVTpt8afOMiKAoKHDJizLgBazAhaCvfsR3lF/XsVjTDaFq4=,iv:hYNlIL/HN0l+kTAtAbL8ne31xCMxLSVZwKyW4PGpkJ0=,tag:9b0RsTcKupmKkdbeb/hUCw==,type:str]
-CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:mXMTquUmxUH4d/x6ABhRM9/43i2Gkbqf3q/+RvLIB7A=,iv:HgQMKTlHY3ahv+sPxg22LS2o2dfx7YXN6xriO9xQOt0=,tag:chP6OxfPPewlrSzkF2NvcQ==,type:str]
-CONTENT_SEARCH_TERMS=ENC[AES256_GCM,data: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,iv:ZZOVRWpG6tVdjbRaj7Rp2H5tSgvBNhcYaSBJygNtNnI=,tag:9swxdeK6vrmv2kudRo2NvQ==,type:str]
-DATABASE_URL=ENC[AES256_GCM,data:aGKagyzYlt5NBWBdoDE5lCZvg7z+0AxYa6DvifcZac7f5/zV4TMNNqmxDFA=,iv:bHJvh/9bP8Rf/HjvJxgco9sCPu5qAqaWb3S9a56C1iI=,tag:Ttzdxxf2HwnxMKY9LjeEkg==,type:str]
-DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:AZ/F6pFS6VT75StvQkIyooIrUMUEOUpBFHfZtkuNA6I7bA==,iv:1Rm3YSG/qM1YFA+DhdhIeOJxmGzDDLxZdLlfFnOgu40=,tag:9yrAQ+2lHnfXjTh5cYMvpA==,type:str]
-DOI_LOGIN_ID=ENC[AES256_GCM,data:Tfggq4Oi,iv:EFqRmv61f3IlNmZA8idySBHEjDEyCY2UHFdHbkJ/cBs=,tag:+pyIigXw2UspFV562F2igA==,type:str]
-DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:Ii44BPGkKLGBEOi0Vy2QKy0BbWM=,iv:W7z2g/GA9sL27OtdZ48qBIAIUuSzV1t5u7kev/cwABc=,tag:4kESV3m7v7ufQZKZl9XGSw==,type:str]
-DOI_SUBMISSION_URL=ENC[AES256_GCM,data:SkOFdPUuyU7wbUR4O1OJscAs0ztrPkmkFV3iNzxPcSuII+2uOJkzV7Y=,iv:t3Hz4Bc70XAd96IOvlVufC/Yyr/i5sjBm7SfYnvDjhA=,tag:GeX8HRPA/46d2772SGvp+g==,type:str]
-FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:f64kkNCpSzOynu0Y4moNszbhI0v2CpV8OhIdRCDJEhY=,iv:KKxlJ99LofVQMxVfKZN8DqtPhuiZGgVlw08guwCJ2lQ=,tag:0nJ38fVKVTS3ACREmsLJ4g==,type:str]
-FASTLY_SERVICE_ID=ENC[AES256_GCM,data:V0zNqKm0E+JxyE14BrYlWWrqpFN4Ug==,iv:e5bG2Wk+WygRxZ7dgtvjiVQ7CTAE569+QFlc2d73eFw=,tag:t8ShaiZyFk8WBqkjc3q5WA==,type:str]
-FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data:qjmmU6OYW5LK6fkk98WoASJtGioXDKIQlLDkKGc8YSOkk6xVV2Z5e+4js8vg9upHGIWayGb9TcuPTn0dweNOMzpRD952Q3dPfbC/nfb8jHQFX/XRXKNLh52HH1j5fIzu0nbscFt5K52ogG2sIkzNIxhtglu0IgL1mGWlX7LHHOwX1AHxcJwMez8BlgbdoZjuHJD0ufvelBBohdtrTuQoFEoaMN4nZEjR6HXowBY/9+NmiyTT7kYq1Lv+WgAkzIlKa0DwiBWzq3U6xhP3wyOZ1OEpK+OdGxKhltUB8vJgLTxLQrONnS/eje5wVHmU2VAb6PZwFZu12DfD2uJG6wW3ggTYHwYo/mnLxTiyjaupA6OPn+AahXQu5EajYEXYJZ3iUe2sfb3mrOD+QOMUMhSK4TWPRlqOx65Lj+1rRpDT7JIY0Og5XHx/J3PglqhP6oIz9/fjViXBvNDiNKXZNKNWClZgMPxvkzclnMaMkkhC6JvW/sM2dq9XfJZorPUE2okFmFMv1kIu0dfCz8ULVwaS0a9MYUKkQxn4lKMpHar/U9dtv+mVlNrT/UY3ERn+kxozaPoUVH+DtI1POzHGer0IwDqVqlgqusSHgn/0+4c//6eaqh7ElZKo0fvGHvU0iw+kHFxpp3nfmlZjreoAEf7Qtzx0rCqJwtAXU6QffjZPjryLqUpdQdoN1kjEAUEbE00rYxt/JSkOBfnaXCt+MpAZauWyoc1+xpzpg0tKFJSsqFyOsDd0iddxJAmVPIVdvui3LFaO3BxSv0SwZsIAI8LSH3dQ5zFVTL9u7QRIJgq+q4LMwzZ1/UuAxgimPcUb3u0As91h4dhtdgxQRZqd/F5QOfLLndlNLO52Zvrug8cAEAr7U1vznFABa3uuV8bxug+QtBnsQJX+9I+adDoPWsSyBlpk6sH6yDn8y8ViMeKB+YfZ+jbDg4mb3s7KmUelyk62H20ktVGsdqMzpv10Io6QadDhwFc2Br+eMF42lAG7paoAqaWtqV6lF9CKvAszJdoWIx3CsHVdh4Q0xiEA0kTHG7596krVnMFaRkMgZKDpuX8l+nUbcOSWfnV7KqrUZXJt1yP6vUvtQ7cGQ1IwlgQbuzeHxCkhs4T5h2qDfrJjHoXGQU+2KY22ih2U1xVrLYo2HZwrAOa6p/q4bUTTN9J9iNeS4WvaeBqVVJwmq7P33OjVwzJ2nuAa9bBoyXGmDhZe/lU7xwyOQI3oO4glzI071mzCUfgXyxQpNe65SWJzJucy21efGLXkOSE//JWZDrugc/ZciTF+7PQzXL2fChoZk3zcGqUow8U/A8DTGzqY8VKK3GWfof3v2pdndqKH8J+vDbEYuXGGq5bLpfEIaolr0clfbXmevne0frIUco9Ytp2qte48KUEDjgYTG6NahtIYfXlz7dpmSiFHMs05amPL8eO6jnBqJQmeB1i3mcDnyyumtkRXukqIlkILN7SAzEOT9mkyoY1IP65BH3oyY973vGHOM1FmQTzKRlYapcgXYYK11QgvKVVGVdcwONd9NkKbCUcSSeO9oD25RmMaF2VRhlDU7EQRhoc5J6VFNdRjcm/AZlnp2wKBS3y7LZh31eS8HP1A4Z92WUXY14moFQw/k8r3qTBRa6eio7SX3Eax/AzUTG7j/hWa1Laz9GlL2dcuDJA3tiBa04wz8inEt5Mf5ZCgOJjub6OFVmF0kYl3DIgeuisOtXXwYQNjklkpKZfu3HKtM3psG7auG0GNjzTwfydedjDRvhnHiLylZOzFXnLNJuWKddENZYqpJktRzdr4s5AjpTHtq5VEH/4Ucm8V/Bdq0XeXNOAe2+aM/RV4CyFv7caWu0prODLnDBXiwSEBSzGEgEtxmy9SwR+UfDoadCjmTIizIDv1K63oRuk2xBywU1BSijswXw8fPpSzJnnZiSEAwAuzmAuN7X4caeyUpAJ+NCRX6zB0rTSMG/RwN5R1R7MPVFu9oaXuRk/AYSHhpANzAN61XZafIFJKgFiMWGdLCiCxkrPX0q2HQ5Zt5kMZ0Lah1Vpf6tjnEmhnypy2i+mbxqWugvBCYKLsCUfeXQR8V7z5khrQFZD7Cz1o1+iB9V3xd3uelaJ0SvD2yPbSnxD3X/2oUrCYjnd1ifNbFfZjtK8ABJeFS6vACbgxBA+gVJ57YckA0umPD5M65fDb35X1TmTKzfW9SqzMU0qhmwVVCB5r+RXUPVtkFCstZYNx6ZfX8PVmFi0cNMsWRKOzrR1eDAXzg9eR2IKWXKfHtYukFZ3iuyy4/YfHMA8gM3ikOGN6jhr5IclxIRdwXBHcHf4It1XH+rpaHhXb+wOwWh69pdMQUKRkh4Zb294KYhBMMib0G8+w60YkjdTGZWuVR5nF4FClituUkrDmX/8Eo/Um2hC8SNcm4mUFJtBZEMsKVjrNC1OCWQUM1EhQzY9aXSkXYiC5K7HI672TzDZTi0UJ0n/X9iwcWiQqxuBlsYWJK89QYmFc4Mqem39pyoAX7GM59KeqJxbslD3GUi4gXsiyldVy4ZRCL4YRzANjYO+Bvx3GXfk2Raoyf6YFji3FkUNRUO82Mwi1MJHmkzDWCGl1iPlbNfMgcJCXDmaPv7oFJuj9Aozzwd2cGmVviG8gzucEPEmR2vWVn/5T9d6ZqsdJj+Mw3wkSq4KAyLVfi5bs+TvrFRzdHgEZuwzsug6uP8f0l7hWscAyF1S3MOzsgkDSyC5f2wB+rISVdeaJwUdn5yNwnLaPcFI4CFBcKFIlp3h/hzCouVEPY3VY8Q+Z/VI10Bxuov4nFBxwRSSXQg/vHlwnxYPM1yDITCanzHrRATELyorJqd1oSYhgoE68TQKbR2vDu6jXwiyvVT0SX6pW8OxW4ShPWAhZKxnQfn0ZEilp9E1l4BkjE8h7+tPCqlPKw1NgIYSGJLNHVfO1/qKt8ozZerUfTu3M26oMggLyxbWtUtcuaiiwu5OrP/jBBov8P/fJhAVzCBlVxvyxuYehOp93hiLAgxrCOr6YimR2q3sOWBj5IAfD9kOJfJM5ZD+y5Wzksr/VfpRp3Ka++txcXTLI8jECWSfTYD4cZ6FVXa7hgFKDhFxnV32icBZsoLNPlCOgWfe3hZ5x6zoh0AFW8YH6pAdX7vCuwkLZk4cbvswP0PMrzK64GZjwOEGZEt5ryZyadGAQwIM9thlrztpzSPKrvtYEhYKXpmbN8IvHlqyaAOKDSOpuuStl3tOIS+glSyZbaNxLXQrnHQzhoolgCsp2r0A0aCai0BDBMbKUXkXy5Y70IejKun3loFMc72a8zYUGjYqbSLNLGj1v237w3zyBcrDr5VFEyEsr0LUwD5LbvUBHkaLth9U1HS2dAUoOwg1/AcNpG3/hGWwk1Z7XnoALRauFcUrTnzgRJdwNOVUvfLXrlwQcpOq+KByDuv8PA0PqtE2fQN/uUBm65aZKLkBvSVhvM4F65z4ejsKEujYqtAFs2QrXra8oKXSfrpgtYOku2jsJb+wm3m5V3aEW6balFaHcct3XkZbcJtGakU8i8MPPgqQBGCWaX2XmD9sng8WPTy3o8v3K7LDKJpV6uMvCyYN6MMoZv2bqeE9ESc21+/ZxAyCtQG4SPDBzCINyESyVm7xfx84fwDMVEyCEu3HSSE2cyMxVQ5hDCZGgMh8EKfwlSizSY3+nJ3Kvp/PNgHbijmc9bbuiIDx+Mr4c5fzWyla15W7EWR1CBh1xNpPb5HRJ25urGcyFwzOPC3oEjTVBShH9/l8MD3fkRyICsaB81JXTIbs4yXIzEWOOYbLCQwFX0SslxPA2pO/HuoK36nhmvmA2NVH4XuGGIQAAYu+qO8L9NfWZf9spngod7ax5og+14+4e4BYvobkas+lW7B5ZSh1VPW+fhxM3Z1UbtM/Yyb9LF1b+Mob5yzNFRNgH78wz4KpiriJRvH9NoqPln2oGFTZn21IGK/xoGOf2RjKH0gs8TznDfM1MPcNAT9vf6BZQVKYEtoXp/2B7sMuzSsnEUrkUyvXo4lZQCMiKFiEIcYYTj6jy5z0cGM+dVTDnMXt4B2piV3yRgqBAGn7lemDZKmvLy/45WeIwbbJ3IsUbaqPSscqIDbk9VgjCcTmECIWhSTE/rm0MoWzFGbg1C+0NuKhkIZub192HeOY=,iv:KyRW3oWpRe5EA6MNNt2pN6g70zD0n704vUVYIvxTJKg=,tag:ym+D1XHD1U001aNnimgHag==,type:str]
-IS_DUQDUQ=ENC[AES256_GCM,data:5tKfpA==,iv:w92nAFZtcgRx14WDDBH8y+Zn/oXi0RFPTCAdumr2tpw=,tag:yt5f/qj3Est9P6bKFaJHJw==,type:str]
-JWT_SIGNING_SECRET=ENC[AES256_GCM,data:4ofGjBTAbD6SQQGJfgidNoPIGSopGW65ZS6sgasgYpbZk/D5gIuAJplabhmOb96YoZowTmFoN5pRL3DsrzkZFoSB2IwJK8qsrLah9tvroABBE8QInwRvlqGeV7i1A3paoGL+uJbECtEsD8w2H6V1NBmPHblexXAhYt+UCzwkZWGyuVRgYmM3e3tqRXK+Fc8DGxvhANAKdvpDgb3ZqPh7Lsc5YEJsWxYLYAn69N2OMt6eZT1GvloaBfqbHzzXz0MVYDWeg3vVAmVcaamLk6GwwhvsVdk3TpyJD+yft0g5AXFWt+Z8QQ9DeObW+7LL/WOVbcYKyYrPmFC/zX0qCYIeM69+jv72Xlp8EYuw25sEwfzMixtlHkiQKj3vxZvsO5mZb7ORb9GzDMzntHncNnHDOxxKjFLi+fJyKXi6La4IuBStLUZiti+3RTZKOlnOmE76PfmYKaH9XlMnSWSA/eTKze48BHXHuWBPDMbUfaBiIwCABbUnb0SJIJ2YPN3vKrV4Q3irBdcUNuVKYQZR4e+jfMU8ssCvmAOjPYxERiVI9mAcMUuPEfpsh7ZysU24Q4X9kvtbUfodmNGMBLa00uRqWI26acyhdi3OVcGIXtTrRAPZfPU4u8btyza2rt4Gk6BRM8FQnqp+DO+4HjinhSvVNoboQkb/MdzhvyjHhkLWL8FhD/IxXLCDPTxBPysBmpv15QtzYSoRv38fMUNpzkdC4qIcXBseGa/rqd3bW0qSbwvtbddwYtQq8YqGA1XnokU7yp0lNG5tbSvIn7opX7ghFHn5QTYDzTDOTKWPs5HH8xnvtmU2HU4OaemiOrQ6n+lawowRHNyPmVXiFb6eHTwknLsvIiMzpQHgRod+kKHpzU/44MNIEF7O34Y7XFp8ul0ZdMsWfdipIeQov5A2pvBxPmG93rtwf6Hdnr46V5BBHYIkM3WTZ5G2bXI3tltVuuriDSC/3epdbYHeOgggxYwRqgHvAQT1JIJerG2bJsZdhjLkDbQJe9n+EDgMayb0bHOs+JRo5fiv5kRv3cIkUOyDf2e6X/0u1uoS6nROG8z/bt6cZQtcThb6JDS6KcfE3ANyAmUNOsoNKEp1/EhvjsHTzg+LpHa2OM1KNuj4oIhuwM0K8B95OLNLRqhIlV7M2fd2bK4DUIbn+5UUkm2n1j+jmQgHsrb5mHfE6m0j3QqXnjztqCvYLcFki/5Rqla8uM3cav5efofge/8gN6d5219eDq/6EpiXktCMX7LE/riArBqD8sAF8A8CM1tbtgJMw1ymYbh9FkXXKFMSJidi3/tCKfU+HY7OMEuEwq9wEI0PJIQ3UsXCmlm4AUUIoa8QicTmrunLn22HEj6QVCgO9FrM2g==,iv:qVUroGfV5lz2CnZSISv7inngrV3X408aLZ+BTrxu8XY=,tag:cJ3f45odmCGOxUz0EkFHVA==,type:str]
-MAILCHIMP_API_KEY=ENC[AES256_GCM,data:1W9nlGu8SinjKaZVUf5/TQl8kVQU4dOonHvfM1KNMxnl/Ms+,iv:AJkhlRxW40GRYMQqxMf55p6sutDplOWXKGk63U1x41Y=,tag:H8i9JZVN3JuY/+uame5r6Q==,type:str]
-MAILGUN_API_KEY=ENC[AES256_GCM,data:HWSvgB4b8t9z7VmzIeth3ZbhGr/2usYurS3l268hbNest8BG,iv:/T7NpIY6fwrbYI7C1bvJtgtrndOWAVJ7eem5NF6WB3A=,tag:KnU8fkvo1vMnODD6fIeB8A==,type:str]
-NODE_ENV=ENC[AES256_GCM,data:lGrIiDaxSoZuOA==,iv:zs1odXEO2R+11txvzFmUpzipeVJxBoZWITDM/SKqGwg=,tag:bh0yLlnTip+QAe3DQAwHRw==,type:str]
-S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:epxBBjJpK/FqddoNSO1oqJXjEnI=,iv:knA9FE48BV2bAygyZessBsZ+yAW2s6hS+MyaNyznPek=,tag:n7IhNsE/JCBGf1SXguQCiw==,type:str]
-S3_BACKUP_BUCKET=ENC[AES256_GCM,data:Ko63x88mBZm7MPk=,iv:Ax7si7nk3rnFVpVqLCcLfo4QOpzNK2yKvlLg3tpV7ik=,tag:zxxFHW1kHTLgXvwZYtDkfg==,type:str]
-S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:ymZokMnfUyNQTqx7gsPCQw6uOcMvmuLlK0nQtdV64Ooh9tw=,iv:qvfM2/g2eCz2BG3n8AzsLWt1GOvkVGXHCv1FLoK/Wto=,tag:rEG8ewaQDAc20rEq3WoR9g==,type:str]
-S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:yZnCYF4fUpDpRcQ7BoSWcy/4uut7rbZTTPGJlDVcwk7cBbfGFWzMiw==,iv:jnHBMYCKSAHJ0JNuo4yez4Wi4r645Lcyz2+5cKfwHjk=,tag:4wGfCKedfa62SZWOUuowwA==,type:str]
-SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:00TkzvpOS/96jCys0YIC+IIArgKUbQdk4tWsTxBuD8/vaTjH9Ep3W4GI1cvn5WetsPSguj6c7SjBTb+NjrTbQDDJhaKdW6UED9O4SZ9LJuD2y5qXyniG4RFoIbt6sVg0h22/ILtkblzOs7BINyk7wGcTJtzJJIi/8gRKx3cYgto5uZbcsvnpS+NsgIE/FHM2rfiKE+yn7tD31W26nDhcq1tqBO+2n+WwEHWwa6EyJMRoMQymOSZCVvuwKg==,iv:sypQorboZew9hY9vOYfom6QGFYHwxke3y2OXZOmwvHw=,tag:MsuIR71UgAD/l4b2AkJ+cw==,type:str]
-SENTRY_ORG=ENC[AES256_GCM,data:Go1W,iv:V1nCEzlxHxLi4HVy7k+wzpCRd21Hkm0+aUwoH6CVckk=,tag:329HWJlUYKP/NZ07nZnzyQ==,type:str]
-SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:r/E=,iv:4lssJ+awFk4S9RE1D9aUoenHHbb6a4abN7D6DMq7zIU=,tag:SC5b+zld9viFNudFBARUJQ==,type:str]
-SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:3rfYNKzEarl6bhxGuYsYyJy5XbYmVtY/6q9bJNnvxaJNpqIc0pFOlhZ0gq2Kf42IfwmqI6BEPWRnZAV9kuq+bF8U5rEwpMDfu5r34ylh7A==,iv:9RUOoYs6TvTeo/imma9H9qHGnVTC8FEfikjl0mt6Avc=,tag:JHeVhTdB+GNaHe6lN7jZrQ==,type:str]
-SMTP_HOST=ENC[AES256_GCM,data:ZQNSJDi/Kqx0C9GZcJlWh3h8gCTXj/F04m1POitrpFKx5g==,iv:6uvq79KuQlM3zsRZtEJdM2HvpaSSQq3mPErZN9OOqi0=,tag:CssW0Rbd/iBjph3r29EItw==,type:str]
-SMTP_PASS=ENC[AES256_GCM,data:4zjj/1f+8GkA/2qGitwvvYvneKSKZ9JvtE2kOCQmHEusTyVBsnG7PnSmi48=,iv:TBALj9SRcZIA+G25r8Icg02wXb9BJcd9JW2ESv5y3uY=,tag:a7MN+qrS+A35owv+smFJew==,type:str]
-SMTP_USER=ENC[AES256_GCM,data:UaBAzcxAVXegIvXSJmuTZpPYfwg=,iv:Kl4CiKsYu5zqf0V/X6Kt9KpZsQ0qmO4sLI6tgHJr5Yk=,tag:JN3U+onpfi+NwBCR1M4psg==,type:str]
-ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:WVkN527UM9ZKdGE4/NY7GKOUlEQ=,iv:jFy9ODaBGSuiQjtrChZGZe39FSDBeePmEu87XDle51w=,tag:SMkHDtl2SjdsqhAMNhjKLg==,type:str]
-ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:RITDDeJYJKVCSDyuBDrkvGyzOtQ=,iv:oLDKldrfwL3WzdE+NbbAeq82rDhdxXimvXZc7H2ntOU=,tag:f4binmJeaeVEvlPK0uInxg==,type:str]
-#ENC[AES256_GCM,data:9W0oDNA9KCnSb4PMcLbr6f16YIM=,iv:/qn5mF8T/Qnbi9eitqzodksxPlmZkz57xGKHk1rhVAQ=,tag:R3Mbn9fcBdw/nuGloCiEdA==,type:comment]
-OIDC_ISSUER_URL=ENC[AES256_GCM,data:L0xrf/XeAdTCvUOojAZOo9RTf80hORT8W5cPkMYtWIGVJNduQQ==,iv:OOs64tkJTdPHFGXab8+h2SKP4Wywz1jUHQlhPGFZs6w=,tag:5Wsa3ILKjVKTOvLjFM6i0Q==,type:str]
-OIDC_CLIENT_ID=ENC[AES256_GCM,data:gnGoKbelS9bB,iv:1omoSBLLrJWjEXFHOqCCmvBUzretuoVKQm+GhV/helo=,tag:P9FJFvaNJcHizJB94bonTg==,type:str]
-OIDC_CLIENT_SECRET=ENC[AES256_GCM,data:Xx1DJaoS/Stq5cVUlob+7/filrX/FhqEUJA62M8rpYYBUh8CcDvG8iNlxuxQjAYEXvGTVp03DnFGEt2G+GkN/Q==,iv:LCipZcEHbR+QC6/ZYW1oRwIv4qejz8Bs+xRSerfDTXc=,tag:ohHfEwdCps5/IaoSXbxftg==,type:str]
-AUTH_INTERNAL_API_KEY=ENC[AES256_GCM,data:UUM8pKRuCpIXXzZsNMu2soOkvdWvIWrTEhP01ie4TzRwo1+XYG/pCn1s+PxJ2oYsZTGtqA2AjFkYrUgynWBNXg==,iv:vWKzaD5xbqsL7ioNRjamw2CEUNa4+CahBZTdcFqf98s=,tag:o6Ids1tEN1LRYC9/afohJg==,type:str]
-OIDC_ACCOUNT_URL=ENC[AES256_GCM,data:D+g0Ui+UJGtbAyC3mrXgY9dThucJJpLvjZXWyB1sdKh80DydU8U1Jg==,iv:SEVIB8guiRSAQnnJNqwhnIT1I65Yn6Cp7QL9W/KZcqg=,tag:MYNHpMKMQeq3+OMJ2H9gzQ==,type:str]
-APP_URL=ENC[AES256_GCM,data:3RqsfXZuSr327VvOIHWky5cgRMZmtw==,iv:P1HwZLaw+wSRtqxcH8LVQ99DIuiArkVx1lwFaG5WIlw=,tag:5jV0V48c6AXmS15pCGdiRg==,type:str]
-SESSION_SECRET=ENC[AES256_GCM,data:QZx8ZJ7EWFLc6ZK3HIYPtpbKM4hPzw5Hwb4nIz6BTt8IDSsaJPkdr3lftwq9Q79Rc1k8ZF19OVN9s38kKpPmEzayGT+LZrXf29DCrtk8/gH7fURgzDK48JqqfMdE5+hhy8xps/BDBoRT+4JGXlFz59uuYx7Ofxt3gt30hCpAC1I=,iv:vaFlOqVfl3uKoDuf/rBh8uLwld3FCcUwEJn6ulSVQOs=,tag:UM1brsKxVd9qrgQNvC27Eg==,type:str]
-UNDERLAY_API_BASE_URL=ENC[AES256_GCM,data:jljciyIpMSNv3stt6vfCscl3Bnsvup8BDiI8BQ==,iv:wo6JSVyiY5cGnJunWBXr+BETOBzt/LEqaJpFBUiw2eI=,tag:w025/ipuBA7AED2S0G20tA==,type:str]
-sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBvQmZtSnpleTNHZHd0bU96\nVzNzSXYzSTdiVHRkRFhrLzlmc0xyWGFjTUdFCnE4UjVnQ2hnbythWTVSUjRSOEpT\nTnJVS0xUeVNVTk5IcnN1Z1pQeFpRb1UKLS0tIEl3ejVrdVpzRytlRUYyeUpKQzhl\naTVEM2kzUHNiSFdVTWtQNDlOYnFMRnMKs4olYugiLkoA5kD+7LYO0SicTHn1Fprf\n6QQTxsBdXyQF3biM9AP2MAutKg0UJ2pZRcxfKuOXTtpuoTKNB2WGcg==\n-----END AGE ENCRYPTED FILE-----\n
+AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:OXNfedjtxYVFRATp2+/S/mKWkdImXF+BSRH1b3v4o3V1Ae7ckGEf5dW7YGcoQUbDud/UyXy8xWQ6B1A++AVSMQ==,iv:GB/D1XQgXMyHkvA7XXPYIWYYB0/WxhZmBAHWHyy5wns=,tag:IGYhyH02Z47HytkRNlTFVg==,type:str]
+ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:mYo3ICiwR93e/5HAtwDqAw+1pUSkYJXmRyvCYesYdVb3mWF+dk/fmZigdQLD6Zer9Vxrs4UvLyngWJXtSUsamA==,iv:5PpRTWprorpL3g5g4+qgRrdknMlIJJ6q+XPuBx2ZR+Q=,tag:ITmfM/1VWOFR8ulfv63Ggw==,type:str]
+AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:EOx+06uA+M9Z6yqH441CiqoEiY8=,iv:lkny7hGltCogNfjVzNRB/VrinuWz8j0WekHj0O+it2c=,tag:BPgEXGA31u/7cVkENcgOWQ==,type:str]
+AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:A2Y93ihesdpjroLImEgh8uu4Dgs=,iv:nBaKnvnwvhxzXZi/0omNL3yhyRdRLspPQjItsgA1R4I=,tag:t2k2/RUCB9tT/MWLtXLK/A==,type:str]
+AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:ifdIthB8TG9g8U7pK1IpTNIdmce7QnTLEY+9aCjbiUykNLY9IWD0Pg==,iv:0x+JDxf/BB3tEtX9fFwAbUxEstSu3hyg7vMFjlzqjBI=,tag:ZdmSO6HB+hbZ9otQ20JcLg==,type:str]
+AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:ECLehz25cNLOoI8aEl14ESSSSeO4Xn6YiW+crNf3uMq4etnINbE0RQ==,iv:ywNMbgkjmcnESe+s4YkK/z9OBWufPUI2X0YC3hXWY7Y=,tag:D3SqF6e/jJgedlT6KULqdQ==,type:str]
+BACKUPS_SECRET=ENC[AES256_GCM,data:mEYYWB5g0OgxeK/RFxaJvZ3O6P/KF/RYV6rDEH+Z+6kY5sLX4/LWi4x43IY=,iv:le8ZIQmQ0Q1UFfFO8jBTkGgNh8OCwQIo90oXGfCdeBQ=,tag:9ReLC2A4fnxA+FiRYPXzyg==,type:str]
+CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:IpVWTWRlxNjmuJpHFGgWOyYQ6hVhFS7t0jS+qt9tfcbR+K7duOgQEpj/M/GFl6ME7Ht9dgo=,iv:opoQWOe2p3b4yCpZUdYTof0xywoSYiVeT9ebTHDORzI=,tag:3IVm4BVHgonLd0U5qFY11w==,type:str]
+CLOUDFLARE_CUSTOM_HOSTNAME_API_TOKEN=ENC[AES256_GCM,data:J5D4j0yYp69Zh3hgUVWSteGMAJ4WcBSPDCdqClqIzgeljVzT8nky/ndo+tsDxpGVQ6qAttQ=,iv:mD3fIvGqOjMO/2yatkyOJFcF+5ZXmRP/traYRgspgJU=,tag:LNkufcJi8Z89DXwcRjcxEw==,type:str]
+CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:FSLVbm20TKDOsRJJkF4Pntj9SQB8Ji4EuRl5V85v9F0=,iv:q5cRuzb5frKiV7OFlu3l0e/edlcbmwjM3tUtMxZyhgQ=,tag:HaHQ+13sLo8DNLJXJpA05A==,type:str]
+CONTENT_SEARCH_TERMS=ENC[AES256_GCM,data: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,iv:daOTzEJac3B09k/+RpIz282P01/87PmAD+f6opnUIJA=,tag:Aec2t2/l0VVTOq0rsd7jNg==,type:str]
+DATABASE_URL=ENC[AES256_GCM,data:ekgzlSBjHBZzo70sl8SsGwqrm7+KQcYUMfO3/bYrCU829QBR2+C7xoolFZs=,iv:sG0jtMMKzZF22cp3zfUzBSuYAXfK+43kOCHZlN/r4+k=,tag:X9GncVfDZU4P6/Zk+1TmvA==,type:str]
+DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:gDOIJ9N0CTI0mrlbfTjPoebEDMCQD4/xviiZ7mZzYSDpGg==,iv:JSB74FVaiySTN/qgrE4eEz7l1K332TLbC5COJm4OG4c=,tag:vGRCumPW0SQlp1f1rMQyEA==,type:str]
+DOI_LOGIN_ID=ENC[AES256_GCM,data:d85HibiC,iv:2cHnS8S4cw/1m2h8asZWAudP44LONC5EV61RYYhJO5c=,tag:BuYj31uahqYfATbdfGFVOA==,type:str]
+DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:2/U39e2ej1ufpg3vfhJBZEW4dnM=,iv:YqInmIblahKDxh2KctLZONlXvnB2tgJ3fiZpuqoNSB8=,tag:nH+SqAPpBZon4IixrgPqXg==,type:str]
+DOI_SUBMISSION_URL=ENC[AES256_GCM,data:gSn9IQYL0UlNS/rnjJ923FqSy7bWoIv5iZjIivEmm/yYTxjgq226YMY=,iv:fFUUIxNp89McjmAJi7Yw7R0y02SyeW0DNFlpJkMtJtg=,tag:Ikx3HeZ4wx5qRz9NWV9TjA==,type:str]
+FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:x7a1IdDwXFTzI7cwYE5b8B4ZWmzeZHHv2g/NdlCpvfw=,iv:Uuh8LR98rsEel3z2jMTo5bwm6mY1qkShxxcQ8vWeHKg=,tag:PfxT+SelE3IMmCqnQT56kg==,type:str]
+FASTLY_SERVICE_ID=ENC[AES256_GCM,data:Jx5fvZ1AeVO+Q86nmOC54NlxU2wsEw==,iv:xypl2yRH/yAggq7OrXll2byN3ytxpGlJD8KBUTfoMkk=,tag:LFAzFCbBlLjdSKFCUx/Bjw==,type:str]
+FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:hoVJDNRtd//qwHmI5ljUN/nGXVN7wOI+L1DQk6s9EZ8=,tag:Lyak3Ar7wy3sMJuyaatMiw==,type:str]
+IS_DUQDUQ=ENC[AES256_GCM,data:eUKP1g==,iv:CMslwkcBd5kBnSYuZWOwjoPmUYDA+vRgiEFTHQFYewU=,tag:Rky8fie67LCh7twhH79ZJg==,type:str]
+JWT_SIGNING_SECRET=ENC[AES256_GCM,data:IobYeRQ+pDanVO+hDuIXVKBqPKH0S4qtq0ipLiDnfEMDIQh4qfr/IapOnjT/qvxcoWh6PR08wJY7h7h+MEv1cMcX5Ti0V1V5ej7Xa7Z3R+lRWbXtgYsxHfMXqUkk7t9mromts1Z18v5TiWvXj79RZHIM/efqNz14QMsKwYAAsvezyasTjqrZ8OIUgtYeAy5WvGlRn76t/9XAmf1n+8a2pGho4Y/9hZpOxw3KYyaXJs+M//NkCZyYsMCbdWaVqCPypGMc0wwPNTRFRkHcPPN6wKkdy3mNvUQCzX4PuBG4MlTzx2lTYsmX6TI7RsuwCtiYiXAtRrSeL9odPw4DFKTdGwCPpI0jAYeF0tXsrBnHG0/tfWtd7P5DZ/9qdT0yRVNIe6uAt1CRn4IgS6bbqEqIbN6kT/7pC1DbMORoD+yobrgKYMBIOWXLaE16zI7Bynjr0Riy5pQaSLUbjam7jU+AMTKg6YD4Jqda1l9B7xie9E7sGPqrLYVz7LER8V1S5KjcU3rlbLlqZjjRwlYjixBhMn7qyzp+J08O2azFOiyljD2d5X7TrtSrDb5fvt3RtuwZkPIpKxBpeBStFao3IYvY8m65O6IU3T9KoxrPaK++uW2xML1WZ86gB0tOZKYYxPhpVCpgR8+Cq5rGTz2RhdjhlYOXVJs8UGKRgfIKE4oJ/TvXpRyM7jxscde5sABLW7xVhdWzq3IMbUDT5umHD3kZ1dJRhw0o8s7PNzUQpgACwymb4q/E/+6SyX5bnDnCf9hHQTF5TZzWpjfcwbzoKvXdOqB7XEsH2EtZT3sQJ1c+Y7hrU1zLyE1ErtWjl2D6+PyyuiLn0+NXXE4x9qcnzUwYT+KClCcvoFNXglpUmBuC3MumcljJI5J9mq5y43BMx/GxLNUczrrjjNKRwRkqvlQ19YJXE0ajgohkR5e3mGZSarwr5i+gQmu/MNu4PNSr/hgfr30UJ18LvgN2CUuXRD195AVSzwQo/qWtuCWz+0Jb1v3v2ofxGHat7G8Bbl8UmOvwLizEbooHocIP5Td9PmpcBHoXhT2pq4QFW1/m3zOrEs7usWCW6s9DXLVny8THSrpOFE3OoI/BgRnfqTJW/4AsdQoyJ3SpdaJHEtBrycz1+buWc2gbGRNbvDJzdtvb8ofv1yeoV/GXrtNJwFIaIuLzxmve41XEL4qsOL+i855VmDtijHKyN2c9ptFpgyqJKGv0K2C6sosGv/el5DiSd1XA13tiXzQZgrv51+3ChQ3FbXi2OWuVCLD32k437uZwmd/D4m4fHbyjTj/3C1W6ddo9EGbglGsMlscepSBjl7GKC73OiVIJ0BevdMTT8h6kOI5U9o/4Mtw9qRPIm/NsSzssZw==,iv:YUSB9zfsHRWRybYG5GoIqt1+V/aJRbqC/0niCkRCZ+E=,tag:DDvvpFfuKfvCTURaM076WQ==,type:str]
+MAILCHIMP_API_KEY=ENC[AES256_GCM,data:trGiInGtvYk79UyZeE7ljYnosjHuq1FGHQT3c0+ubHjbeY03,iv:KTtju358cuCkU7TbQmGScBgZ8lpuuzudJ0O2dtCIUHw=,tag:YxPbGdB4I0ZBZ/8JNiIoAw==,type:str]
+MAILGUN_API_KEY=ENC[AES256_GCM,data:3GHg6dWvTAUn9JCjKqn22a3JqE+x696HK0IUew+K8T/QW9Rw,iv:g17UynpWB3RO0tq3skZAoy/+Mg9eYHXGBbwZy4fgODI=,tag:pKOQ9JmMiWqWMOdOippb/g==,type:str]
+NODE_ENV=ENC[AES256_GCM,data:lacic8wA0JeyxA==,iv:3+/oRgi18nPRS4BKf1+c+IVsXp1deHhigrhUezJQy8k=,tag:/WXCUfRmRWTz2QgBuYjyjA==,type:str]
+S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:hMXuu6WDZ8A3vPCDDfybbFqOS+4=,iv:rvQHqZyrQQ/Q1HlUhujUFQWauR0t7jrXN/ksk9DqoFM=,tag:8pwLa65Tl9vNOmpmaA1UBg==,type:str]
+S3_BACKUP_BUCKET=ENC[AES256_GCM,data:J3qeoNh05AM3c2c=,iv:a9p4aNk5buT8F8/jBFm3USmPhcRZn6JkJOjV/Uek2OU=,tag:m01kuUBSN3HZoXXMErsRxw==,type:str]
+S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:nwTczjwLX4oo3x4irl2RCi1+3tQcph12kSxQgx5V5Pn/AtU=,iv:UkTODNUCg/8eTgppdcLiHgsshzQADbKw8GM6YQS64U0=,tag:g8VFUBjzjc89jPk2RSi+zQ==,type:str]
+S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:PGB0vTNx4OYFtAJwj3QBmlCpNFVkBgjWzVeiOor3R9Hq9F00Oma9LA==,iv:+6RPG/3IOL8Za+Q00ZND3K8W/nCpWOZ+vuu4gUDEuy8=,tag:/7dF2pLYtoKJ+DMIfnAaGQ==,type:str]
+SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:OBXk1nx69C/uPtGhn+RoS2L1VqnKZwoJUq+SUUhpQfwKUO3Po6fivOSmT61CgE+lJdGiB0lgQnWx2Jp1gcvBVmjDN+VK50idTzsEUcOjpGZ69D1W5dYfr9RpkP08+SkRb0ovwzgbZkwZtAmOZDU5V1jtMDjSfYecWKHrijgZuwWBPY1l0MHQ9VCriF9nsIuxnguKIN5XSAzIpkq4GGEJ3rfYyKaNDji2BxtUqAfcK3+/pwL4lLbcJYQsfg==,iv:kr67DIak6AUWpfbye2yDmk7wqvfrKSTZNs3OUMGGthI=,tag:NZSWjATAYEcRbpgGZRQYEg==,type:str]
+SENTRY_ORG=ENC[AES256_GCM,data:BNIE,iv:SCbpbb0Jz3OAVqhqFVbA0d5Hb99NzL21gdZF5VJRnL4=,tag:fhWs4+XqRx6u64f8jENhiQ==,type:str]
+SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:XbQ=,iv:+Ctwzx1mJEzJZpwwHlrL2vTRILOyvyz1j3yeNFtGwWU=,tag:NmNdAPD6f+78sij4dz6yaw==,type:str]
+SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:ZzInGkwxhYWZVmwdEMDL9M+Qkn81kkhI3sjm5RlQj/j2c6Ca7wd7ZmWviqdtz34bTzdakIupFHa7SDtludRu9MspRx32lavXbdmbipfq2w==,iv:+xwECO+xFM6u8V4oDo0i4ZZN6bMD91qWIufOFLFbHIw=,tag:UPLIEbwznGqW/bBSg1wIsw==,type:str]
+SMTP_HOST=ENC[AES256_GCM,data:1hbRt7Y07goMDI26wqQG7P7jxcPdzwHjBIMhmyKfirDi0g==,iv:3qTFPu5GG62mA926xvWWVymydGcKuh6ytJ58TNU4dFM=,tag:BtgbMHyY/U4NTQ9DskGQ2g==,type:str]
+SMTP_PASS=ENC[AES256_GCM,data:ot1DHj3oCm3SITlL/0EaXNEGAf17IozB+lxJAvKGOM0etlL59MZbTbb+IpM=,iv:wrBcNLWMkpFcbBGQqnL0fZAI1XbZ+QJfk10htqX+1X4=,tag:MJEck/+vnr7LQC86EmjCLA==,type:str]
+SMTP_USER=ENC[AES256_GCM,data:2wiZtg/JuKxMXp79BXN/abZYmVM=,iv:lfT+pWoryzJUisigxoleQMAtzIqz5M9IqeP81e2uan0=,tag:ggmfWS2ncLx5lilH0kKefw==,type:str]
+ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:djp/jdIWvyropnr1+MPZ4GhySGs=,iv:RsNXzkwshze0+sG4Izu+Jnib9vbKDA7T4Iz0KjMFZqI=,tag:1vO9cIsgE83j+eU/0wpreA==,type:str]
+ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:0haCyKnRRjmNUpCAWaHPu5v+JUU=,iv:TMjuLILak7L0WP/qipV0QkdfLX9h8n/wpj+gCel7y7M=,tag:ZIr4byk6evLTdZ9AoIMYHg==,type:str]
+#ENC[AES256_GCM,data:WSk+GuUZOGT0N0EmO+w0CtVg4kU=,iv:h5PnTN3Vmu8Y/tX4jIdTno5DZxBXBETAWONf5oypvWc=,tag:du+PSUrvM6018bQqKlQpnQ==,type:comment]
+OIDC_ISSUER_URL=ENC[AES256_GCM,data:OdM/N8mJ3ii9FN/7FBd27QkfGEygLSGya8toahiKXWKog1+12Q==,iv:2pcZreMiMmxgCadbFM6vHSP8ft/B1QdXMQkNyk8YKd0=,tag:w5X7IxvSfG46v/W32Mla5g==,type:str]
+OIDC_CLIENT_ID=ENC[AES256_GCM,data:YuRjDJHlIYun,iv:CYNrHWQ+qt3JVknZsBCX1CDKxy/lwPYMC/Z+86FN28c=,tag:6cDS8kQMYFAytm7lWJlJ0A==,type:str]
+OIDC_CLIENT_SECRET=ENC[AES256_GCM,data:dPt6qHPkVylYJD2tlyRg+0FlnFrk8O9rps95+EsHr2af7KVk1rn6zkC9+dfJsykw5IXmMu15L0RY22kJ73aFcw==,iv:yqWsHfH8MxjKjQAshZ5Ua29jJ+IRkY4OXvyy0/b+31I=,tag:z9hPlKfwrK7SbWcqW8F9XQ==,type:str]
+AUTH_INTERNAL_API_KEY=ENC[AES256_GCM,data:9cjfBpiOd11U+GXqPoQ/yNtTKJl7/ipkCSjsF8P4l5uZajuNp7/hoGec5BbsPwKX3EZAQt6ZZ3hBWQ4OJ9kR6Q==,iv:xIo44AoCyjtn4rRr1U+D5HZ1QAWvia/ERyXNnWrf9qU=,tag:eI2NgPJJK+JnGvhLJrzhIg==,type:str]
+OIDC_ACCOUNT_URL=ENC[AES256_GCM,data:gHImUhgGAiPieqocH6RL/YrRri5U+aIyEjzhLf4GmFJmuAAjABJ8xA==,iv:wRZ/MPG4PG2DGs9wsF6Rm2ykVYyDaibD59PjXcL83mo=,tag:jywTBSbqa0zQKD/+XZU1Vw==,type:str]
+APP_URL=ENC[AES256_GCM,data:fsWnO6TY7SBFDsfeeEG78gfJEqaQPQ==,iv:Y8y40tIuI0/Hs+x+XiYMRTapMOM9BkO+pwGXMnRnQUk=,tag:HEIif3fhw5mpiDxhEcLDXA==,type:str]
+SESSION_SECRET=ENC[AES256_GCM,data:87lkUo8O91mEDmSdeZG09T41GL0RRsU4yi1zlFEWW2aJqDKqs/ep5BHmYebKHhznKy9VTfBgelru+PmGpndZZlt1+Pjo6UUZGajsQ79J3ITzZuMuM52Oq1MCUujeoKUpAMUd48kFBoMxpRqwVCvV7hH3eEzPKTAcpNN7xvBDnrw=,iv:t0Bf7cpji0kqbD/RZtSegkUAGN0D1aaMyGcV312FCPc=,tag:ws6OKaln4CmxzgQlBOTLOw==,type:str]
+UNDERLAY_API_BASE_URL=ENC[AES256_GCM,data:E0l9igamZFF5+kLLbE1/VgQQb/BWR+knXDmMRg==,iv:yhVzW8ZGikvSYjW6SVsxDCQGv7pOkccn7zYA/66CC94=,tag:j7H31sIHcI8dn08TDp2Bfg==,type:str]
+DOILY_URL=ENC[AES256_GCM,data:R73Y+xR4r5JobiGQJleYl2LkEg8x,iv:/piKCyv3ztgVDnUMay9+PauAMBgF8xWeKpsbz64zDuU=,tag:/Bag8xX85cm8Zv5tjNBjjA==,type:str]
+DOILY_API_TOKEN=ENC[AES256_GCM,data:u3kiI3Z2gS2/SzKzQU4dEDemf1vQDIXmLW0mOZTiZp5ZaTC+HpSm4PM=,iv:UP7BABybIJmGhhVssSJM83NikonQTlHaDG1zYxm46DY=,tag:Puor+Nm2avNX5oJJ7cPcQw==,type:str]
+DOILY_WEBHOOK_SECRET=ENC[AES256_GCM,data:hnVp+fC4/d67Vs4haJvqcQ2zM7TwiwX/i7fJFXyEWcE1vKA1C7U=,iv:DGgjaT6e8YiRsOpdGjgK2iDuE9cQK8rya6YkGSz6E4w=,tag:pSGh1syh00yIKkGIPaisCw==,type:str]
+sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBpeVM4bEJtYXFKd2g0WXcr\nNXRvc1dWelBVQXNNVWFidTFkK1NlcXpCd1N3CnZVaFRLelY2ci9uZ3c1eTFRZVlQ\naFJRbENUREJ1c1pDU1NXQ1pyNzVyZmMKLS0tICtOQ25iRWtZY0h4cW54bTh2ZVM4\nckNGMjJpMmorQ0x3VGQwT3dSZWRkYm8KK8bWdrVZNo/0zOBE5Je1P3d4FfOFxUGn\nCW6QiQLLjWCf6YYosQUhmRKe6lBchWEYt5OuWN+5jy7Zj29X6MmMkQ==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr
-sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAvWUJrajFpQ1V1N3ZWdzJp\ndkpsMXg1RkRyckVqOHR4VGNLaUxwTHljNnpvClBPR0YyQkRBcDRhVlI1dUVwT3Yx\ndmpuTzY4V0sxQytjYVA0OWJoZ2dBZEEKLS0tIENLNmV1Y1c1WDdpbnFONVRZUklw\nSUR4TVl2c2hycXZ4Y2dPSFBXRWpKM00KK0BnHwnntHSSGrPnq47PJb1zgPX1UmUl\nGyTcxbFyn9/NE6995r9my54XFIaEgXgHcNds6fZmeFUOCYjrf7m3GA==\n-----END AGE ENCRYPTED FILE-----\n
+sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrOW1ENkNranhCOWRBTHlP\ndkJOWVJlUmNXbVdia2dJZTkwWE4rVVE2aEdZCncyUW9neXJ1ZFVJMlBFVVNoV3Ar\nWXFCWW00aDR2cGIrd0luTFFQVGY2L1kKLS0tIHBrOE1HVWQ3WThTRDY1S2RkcU1k\nS2tiMXZWQTRLTXVvbTJwZVRvK29iUDgK4uro98SlBJf5fMfOVRJq0eiCBM8I4V1T\nm0lvKXmd17YRj8dEITweiM4Yz1GNiO3jcPBwu/BDC+K0H5P/bWPJ6A==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj
-sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBBajRCMm9TKzF2Q1RWWndz\nZFVFYWd1MngxSUZHdzJNNVlwbEVMWjl6M0FnCittSTRjM1Y2b25WL29pSVV3dkdn\nZ0h2QURDaWJHT0ZVbVlTVTgzZkRUdHMKLS0tIFBqKzVkV2Zjb3JvREFQK0g0STI4\nUWc3Sit1QWs2ZGJzR0dBKzBvNHFYaFEK/qQTep8S+YJLB93LpdHX4hVmAlfY+BSJ\nvwE/cohvxWtVGkiKUIWwJFxNyKbVCJQaQsr02qrjByAPqmUzxQ3mQQ==\n-----END AGE ENCRYPTED FILE-----\n
+sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBiSFNIUElGdUlJK1VTNkpZ\nazExQU1CTDh1SFo0ZG1pSkdwUlRzWlR3Y0hJCmtKa0tOT00zWVlTRG4vckZ3M3Vj\nVkJNS3ZGQzYwWlh3SmVjekJMWmJUQzgKLS0tIHh5eHNPZFcreTZIbEwvZldNcXBw\nbkdRbTVZRktvamJYa3BvbTcvRnViZUEK2LJ+0+SBAMn+DlU/+IwfX5wpHS9I2R/5\nPSZkElId+Zsth1v5LiraJ9EBVwazsUGc5mmt3FKm/Vv0ACH5dtKYjA==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk
-sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtejU3UTNrWDVWNWZpNDFU\nMVVzRFZ1cWMvOGp2MjVUTE5aeXFkS1lyQUJrClNrYWtrVlRkU2pZREJzalJRNWl1\na25EUzQ0a0M3VGNrZGtNdGxtYWtRMk0KLS0tIGdoWUJVcUJnMEQxaHhub0FkUVBR\nNjZ2ektiS3NEN0hETU93QTdjNmt6NmsK3A3+++5pIK18Y7DgJ7uSqTUpWjTHTLRA\nC+I/NiTdAwC4cDQALjJDhp7gv1KgHcgiUlcQ7y0xSGiewR2kl5nHWQ==\n-----END AGE ENCRYPTED FILE-----\n
+sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAzU21vQU9XdXVlNkM5V3Rh\nbE5mODdCdmdsMjY5ZExpelVvaE9GVGZLRmx3ClRZNWErcTQzeUo3Z1RiK2hveUhH\nZ2I2TmlGS0w3M05JQ2t2ZThoVHJjYzgKLS0tIHdZdmlvWVhsOG0vUzhDSndPZmFC\nWC9tK2Nkbld3bk14WHoxbExxQmNmVkEK0Rgo8Q0MjF34xuQpl9IxpllRNS1eCJAe\naJwk8Y4frKvS9INvCT5+nmLztBma5Ormp+fO2YoHl43A2Z15qf3kHw==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h
-sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6ZEtuYW55dEtpZWo5SXUv\nakpDZjZkTjBPYnEydzB2cWVoa1pDTTJCOEFZCkdoUml0MEI2Zk83REZ3aWFiWnJ0\nVlBSVzhGNHhyaFhYSEVZeGZqMTRRYkkKLS0tIG1jOHU5RU91cnJ1T3lzNDZFcWlV\nbitzb1FMRDhQcVNyS0xpL1JVOEE3YVkKoe5cBd3FUHVAbvErKptqAKgXzwisCT6u\nk93K/N6ykrf48PtJlnndzMVT0bssT9RT6IC5z2UTJDEXrYHAW+r4DA==\n-----END AGE ENCRYPTED FILE-----\n
+sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlTkRpZllPRkdvbmJhR3M1\nOW12M1FjRldRTHRrclFJWFQ3cmVldmVEMm1VCjRYMnZzVVdFWGlaNW1VU2tvUnU1\nWGZvL1B4VnUvT1RpVHYyclhuMUZDVVkKLS0tIDdDMjVYQ0pING9VSi9TSzhlWWxy\nbGNlcVpiOEhIcnVhVkVRa0Y1cVVFNlEKNaDcFrCi6OvPIzACPjMB8gU1tdEVcUsf\n1jEPydWuVzwdUQa2OPo3DgfC5nsaQh+k5SpTi+842+GgoLOvKLaqQA==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx
-sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBMNGxQcEJKQy9JWEsvZk9W\neFYrZzJ6bE1QWC9mVGxnZnE3bXFYTk45MG4wCndaeFFUZ1BQYzVnYzVyUG05RFhp\nTU5xdk81b0hqRkY0aXlNeUVQTUNxWncKLS0tIDAycjN6aHlZYmtlWHg2Z2V2cCs0\nN0g3ZlI2UllEVEhidC84N1hHdVVublkK9jvd3b7/KHcp5XbOaa7i47zyNzKf66eT\nQTVuXP8Ls1w8Eg4HMj2TKT5XbpLYg5QyZ6/DFjevlCd9ZJZiQpm5qw==\n-----END AGE ENCRYPTED FILE-----\n
+sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBvaHJ0VVdqOU9aQ2pSbWNx\nUXo5a3VzdjRNRkJZR29ZZGR0TERVVVU0YVY0CmMrV1RtSmlpbVpwMUZWb04zdDRo\nQ3hhRFhTSlVrMDE2RytlZ3EwdlF1OG8KLS0tIENpaVRTd09WNlJFZWJhK3orc2Ey\nc1h2UmNtWnhTTXEyZXg0OXhxY3RhbFkKj5cqoljkyYMhSMAlcRj6l7FKDdLwho/T\nAISHQVsUcGx/Y6HnJ96Z+tatHV+zmQXyMiCVlydRBV0pMzJTcrs1ZA==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy
-sops_lastmodified=2026-07-21T20:32:44Z
-sops_mac=ENC[AES256_GCM,data:jRrbpQMeXfJP9Nug/YOwvDCuOtvLrEbjWCwPG+AmzGtFYsu/HfLsh3HsP9Wkc1+jz48QPtpRUvpDBp3N+5GmOfLb89FFEi1YGt0Q27E+/9R3+o+NYMTI1A7Mt9lR/mIRHcB1NXGLE1OLAgBtBhhbjwwz1ATkOuFTGCXD8LapDFQ=,iv:nD/hYjyEVr9tF5Fog60/GkMImqDwaOBpvXKybISDbc8=,tag:djA7g2bvZ0rHr8qdzDFBOA==,type:str]
+sops_lastmodified=2026-08-13T20:02:21Z
+sops_mac=ENC[AES256_GCM,data:5RfOy1c5NMfgZCDNdcV4c6I48ris644/R9WYzG33dVKzXoMvM/QoAcM8kiKjo3yZjQVZucSKwc6Gw6fRAFWE/SP0O2TB3kDHqi7vyT6OdhjcE8jInOCIFnWMzc4gAtInlrSbgO1JbMpT1CwnfJ8KOemwuHcLru9cUHQUqadAvDo=,iv:jubHKqaaGJrqq8SDl6or1cHw0zUwt/EX0vhzRzA2c4U=,tag:TNpu0rCOGPhv7FQTv9D7ew==,type:str]
sops_unencrypted_suffix=_unencrypted
-sops_version=3.11.0
+sops_version=3.12.1
diff --git a/infra/.env.enc b/infra/.env.enc
index 58736b4e6f..c60fe04339 100644
--- a/infra/.env.enc
+++ b/infra/.env.enc
@@ -1,63 +1,65 @@
-AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:nKAJCGNbcbW1pevyzjV72sQOj1uLvdCU19HSitiSQMHjeSRZDkktYVcJYJ3byadnBl4C86k/7Ux7WfFBYK/THQ==,iv:wWvpRhnZ3otAoa1NYnZxYnlzHsa5XejTiZpxRWto4bk=,tag:BEf7bjVHdTrFPVoXTeAOew==,type:str]
-ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:xyUqI13H7pbfnwWicZX8tajq4cv4IY2tTa4yqRi/NEp1GqufE2qn1jtE3UmfFu6yoYO71eNAsN0gnLRGNKL+Zr/hNZ6puN7cUiLULsZkewj62faJkKHbO24WhXEmtPwmRBdrVkfDvcXkyiL5SyoLtdIZRRx8GY4av3UZtd6Lyw8=,iv:ePcG/p8P1H2j1WnO2I2xuZKwO/OWZPGmFuqvL/x1Z5Q=,tag:wUuntEuY9OR9R73G/B8FFg==,type:str]
-AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:hQ81y1b4yXNL87t2zIN/pq4MuwU=,iv:zb0p5i8BG1OkKBtd+BK/y+azZ41/HEbKRIg2JSDdCJU=,tag:GqhpKkBGcgVrmcS6g7zOKQ==,type:str]
-AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:Ast26Btaiibal72T29BvsiDJTC4=,iv:Bk2Y6AbQ6x/SsaXs184JOew7VKQb2cJvJCX5NYZQGyw=,tag:rC+TmW5E/HZCL40+P9GIuw==,type:str]
-AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:6ciQRHHpkzCJ3pEZYZADpSpNcr86gK6Yf4zbIikFdwjcmgd5WKfozw==,iv:ikHCYdM9HC5JI83mZZcEitW2q/MfGiZcNAasXS8Nzsw=,tag:k4ovqT+ZoLSeg9U3rPOoqA==,type:str]
-AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:ca6z1Pt8dk3Lcf+RCvpNg4K0zkEuXIgOKbnwKXVU1TzgarcpS8x0zA==,iv:11GI6mXuoPZYtVe+tYmUHcNoT0KBYrvIGnPmDTIClwA=,tag:Bf3K9ywsF2ZgCOX3XO4K2w==,type:str]
-BACKUPS_SECRET=ENC[AES256_GCM,data:E+jr+YH/iyP4MrQ8HlNH875PdvqJKy11Yr9kd7WXkAhY3WGIZU1D+zXTqPo=,iv:CVqf7zyj48L1CcfQpgL2ejYH+XXIiM4iab569Tbo9OY=,tag:OBkd/TgGveTArUYi2VudYA==,type:str]
-BLOCKLIST_IP_ADDRESSES=ENC[AES256_GCM,data:6cLu9wIwRALumuYFQ/eMfVhz4P4F,iv:VJRJ4T63CK5JtUvL0bR1/K2FpvDa0aNiRI0F6IMFz40=,tag:72ccqb0O3qgM+AfVglJ3Hw==,type:str]
-CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:MyAnaAc4cemoxEwui54M1pQsg1PDNUNpaRioqWhaG3oc3bVGmcRYGOksX0M9EJPoQIPB2P8=,iv:6jNFMTaB9NT8YKp6NXeWsiwde7CteC+nhFVCWr+3kpY=,tag:ApwXxCLuMm+RUbid+GmgCA==,type:str]
-CLOUDFLARE_CUSTOM_HOSTNAME_API_TOKEN=ENC[AES256_GCM,data:o0/lmGP5ZlGvuRzoaSl00H1x/wdIhENS2ul2yjTexuFllpscQLAvXYd7ShO9AYerfb0o1bM=,iv:oP5tzmxKzJB2L0dN4YdqEVh7+HXW6tJtN1Ek21r5rms=,tag:0NkNpuTbaG/C84ALSAvYgQ==,type:str]
-CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:qKnZgQIX2nnwII5gITDvpa8EYC9Yv7AjcPjA3edAFM0=,iv:MwDBuySSaCAhz9ZBXABdQSxkDIL2rI7cNgsxsSu4DbU=,tag:o4YAw/fTssJOlNwzSr0f9g==,type:str]
-CONTENT_SEARCH_TERMS=ENC[AES256_GCM,data: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,iv:Q+UBxBYyQM3uG09FdVHh8+ugh7C7tvXl40wvRjXa6Vg=,tag:nB+TOGcWKeU9ZVZQdmQCug==,type:str]
-DATABASE_URL=ENC[AES256_GCM,data:CS+w7W4OP/tHqMTIn9pGlGdwQ4hclOl1Us94HlYr2r1czs6z0Gp0d3dXaIA=,iv:AJ8puqa09CpDxkP4g0bhEWICWP3a4cfytJ314YN9GPg=,tag:LoiBkEXMIRWJ+3+RTlGh5w==,type:str]
-DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:cHsPq4ZWCryMUf+DrFUAKTRQpyhPRs6WD7yMR7c=,iv:HOLZDMTc9JpRzSLg8RGxHkEOxN6a+W+rwjlUk+F4L4k=,tag:mB4fSWC3FfBGk4VpSxMeFQ==,type:str]
-DOI_LOGIN_ID=ENC[AES256_GCM,data:78W9kfhM,iv:OdMRie2AdC2xAOtpX4hlHIwhvtuZRdTahnbwUvj55h4=,tag:jH6us4dPfUoxzuenZvMY4g==,type:str]
-DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:NAvPg+3JHZDZ4C/piBWVLNA3Rr8=,iv:NhBuLgzd/hm6fswOdlN0lTLpLA0vUWn7H1Kt/xbu4FU=,tag:1xkwY6Vg95tl8b1J99Sq6A==,type:str]
-DOI_SUBMISSION_URL=ENC[AES256_GCM,data:DsaMex+bwj6tpPpTfkgcpHvH49XQVoqBPSeYYamaCITsiRikkIbTEA==,iv:7hPaKO2OpA1IRSqWaIwqwUXDgNkC2zAnN04if7k3BEg=,tag:vsxfBvm7JAxYt7grqFdtrg==,type:str]
-FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:mCdfeCH/6MDYBEnNuzKdtyjqtO+sRXad8d5wOdnOsIM=,iv:PBBysWu795lrCtA8UxV7e8Rbxeq0CEiRwt6NDw7OEqQ=,tag:jABMu5+xS5h3T09jn5895Q==,type:str]
-FASTLY_SERVICE_ID=ENC[AES256_GCM,data:kXYD4jkYkKDqvUGCfJuBK3bp4+TMiQ==,iv:bXHy4/OZAtrXl4PQYkN+FZiEGMp5Fy5w17/mvwljStA=,tag:Wevbpy21CXSn3bOx//GFLA==,type:str]
-FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:D6IyehLbXDEQFRnLFH68PdK7SKHqB/c2c4x6UsAd4yY=,tag:5a99bSWXAhYycxEHYHSodg==,type:str]
-JWT_SIGNING_SECRET=ENC[AES256_GCM,data:KkMvHzSsBQk1AFBvbctXq+Xzf50oKS9IoFop4IrnSAI1RyGGfarulTz/6k+eZZ3oyQXw8/DHmHVaZ+gPWzWWPJgXQ9g3aWQ+VM9JAFtWv3b4pGupelmLwbhOPvojOdEzGc0//G73UPoZV8HiIY5A+k4HhwI2R5xi7YVDS0wLvYCUzJnPRegjewJYGOy3ckBT+pWvawvXk1/K/UlmERUTe7KW0vOuWZs8QKL2TGjiUPSM39Uymd5zIj19wYN2/XX9oct1Z+gcYJWiQBTUBnAPzOnD6Ml+yHlNKhaLvwWI5vjVEPbi+0b8Snh3YRhyOub3Hs5Lx2FzLKqAmRq2Wtv7gZkxCig0fqxArK7S+QWRKDwHHDN8SA/KvRsusZnExYr4EZXLaMocyjes6VXZX9cIMkDF/R3w5INoLt5yEJhMnpqOiTSKZnoMwPrx9C+LrHz2dATsDzUHwEZSBhGA7s+SV0Ye8o1EUHIfS4YYHFOAHChRWZUvVp+RWuillvvXcz8XHSML0qlo8nlrjgds3lkzj7DMJJlb1Jn7tF+r3CKS7FX/ug0pQSz5QCQ5dWgzbYC5FI9a/5iBmmxmC9lXLnItuWFx6W32VgAS3sea5gtXQThMChbSLxGjMw/wgWiso5XhAKsJh70MZqIs4p4TNBwiJMKQNWAm6vBbhw3yJR9pbHw=,iv:9NVOS9V774HHPB7hDnRNC0MFVgPdVu+wy8lSzDRzffM=,tag:qPysc7WhbELSvxPEtB0erQ==,type:str]
-MAILCHIMP_API_KEY=ENC[AES256_GCM,data:CZ8qvWOtz9Ybh5PuwjmKzAmigBLfpKic2E/+aWngrr9z/rBJ,iv:kKy+To/7DaEsgbh3HrCXWxKcoCwQM/ohJX7pozYFNWE=,tag:LytFTk4Nu2a3b6mYVRJV/Q==,type:str]
-NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES=ENC[AES256_GCM,data:7xc=,iv:rRuw3YOWgmKYTw7OpaAepe98oo03ns2K6jNNlQXGLCk=,tag:2mVwxG97kNNsmYJGTfECyw==,type:str]
-NODE_ENV=ENC[AES256_GCM,data:hYGn9Qovbl9hkg==,iv:zjpqEuFgOxL1pjPHJm2/3C+OOjz6hrH0/KzfaZ/DHE4=,tag:0zK3FULG8HlJDonf+35PJQ==,type:str]
-PUBPUB_PRODUCTION=ENC[AES256_GCM,data:1CJ27w==,iv:hulzxqxdRZ/E0HL7k2/4jMRt+mizta5WHVRrlEtjrCM=,tag:ikWQ9ZJ6W17ABlMCAM21oQ==,type:str]
-S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:/GP8lpJPqr/Cl461jQFSmd48EH4=,iv:O+S8XYqg8FxVK9js6utElYbdWxQbSvQLrYMUoMUlZ18=,tag:ASNv4pj1pa5ffs8RrITgCQ==,type:str]
-S3_BACKUP_BUCKET=ENC[AES256_GCM,data:IpyFvw1DbPDAsVc=,iv:uvA6DuJvoJUijcPzu5DysR7aHYnHNgEKF7ZyopyvrUM=,tag:wvhMRrPVqHpok4/cvT7l5g==,type:str]
-S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:i6bXZtPZ/RgUvp6chHJwE/dtn/WcAHRJX9MGBfOrXlwqXho=,iv:ON1FnO3z+ptbg8mnYs+rlso97Af8F7wNg5/7xd1jb1U=,tag:11/8G/SZYHu/xu5oeg+z6w==,type:str]
-S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:nn9G5aw4bciyVRb20vBIGgrBhOK3N9ol7HLMJ5VszyKMo/Ixt10L6A==,iv:2CFSvw1mjGJ6g2iBfRqsqRoNlK/9rkpV0TRE2ILwbK4=,tag:66aeSbSNgSA2h9F+A6330w==,type:str]
-SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:e+rbMrEue1B5R2zzi1HBaHHOZTgx8Bh4JWSlScuHJFg/OcWSMG0LA6pyWDTdW7yzTEsnj/6quh+dwkrcrGKDxCgauiCuCdzDO4rZq9EeoRwSty+yM4VN7ZbzVU50TkESw0lrcBK0ZIhOy4NQbCNVDoAHMRKEtpnuX/UHwWWwoXFg5sagEcaFaN1ySA4185jnyVC0JYsrXBLUjn5ouR4wylEjwMoenXBbD6LWt7cxIVQsv63l4rAA3/WUPw==,iv:8Iv8ZRNEmj/ZseL0uq/YMhjNipW0F5gRgkx0egb6/ZY=,tag:qB99ietgjlAHBdIXIzzvfA==,type:str]
-SENTRY_ORG=ENC[AES256_GCM,data:e0RF,iv:Yj3mmiPY0Wdr1qem3swedaJGxA2jRKNsxwoJK1gBzhA=,tag:iqa8iWcvGponAiYmaVEGdw==,type:str]
-SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:apg=,iv:BAC38E6vZmWIdn1ZzWCm8r9tesARnh1eCwYHpRnepW0=,tag:clkCFa8DYEfhjZeTl/+Iig==,type:str]
-SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:v3iNLDKdsKDofd/VmC4oZUZWeaOhJAtAEH25oI0kCS9H4JwnbUZQgOB5yXUWhx/hOhBDOS1hGZp0NVOe8fjUe1HQweIlg77S+WlZUF/Q2g==,iv:QZxCGIg7s4M0ERDKxPiaGZkxWEIh7skD9u1hJtUlHEw=,tag:UGUUwJcTsWElxagign3Phw==,type:str]
-SMTP_HOST=ENC[AES256_GCM,data:J0mUnN3vUG/j2IJEdodFqIX5/Rh0bSet6NalAeZzvhJl3w==,iv:g7k2j+PVKt3CNSz2Pl/0bxIz8pw1SD+TtRVaSh7IFDE=,tag:24Ea50gIHrVSryWprqut1Q==,type:str]
-SMTP_PASS=ENC[AES256_GCM,data:oZ1ZuWx0gRw+aX4i13Qxzov7P/W++dEh06yXxOZNMDSyX+tQtbIlV1ukdgk=,iv:qPuEgTOO1+/3oS09g+W8lWfSJNN2iuCl3WbKob/7dtU=,tag:eGLHwOmhEAw19N2tSstxqg==,type:str]
-SMTP_USER=ENC[AES256_GCM,data:XPqfkw7sEda6ipnJFGHWzjFECiI=,iv:dsQxxgck43wGJWB6plp4Wwz6VrFO+RqhKpWJuYs1gt4=,tag:iGSal9/LhISBYJorSvVUJA==,type:str]
-ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:SzT0u84nctI/FcUEefhEOXTwlZA=,iv:qY3LK3uTRY00KrJfpVCuz+sW2+aJaviO6kDquvt2k9k=,tag:TilTnnE1OCeWrva/vw+y1w==,type:str]
-ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:wCzsGwrfQpiZSVqWWfReoTUtpsE=,iv:568nOOZd/L8eyf7egLKsvI6nlZiLuefioSMzOnQ5Jb8=,tag:DMIHrT0pSpn7ysuWwIigUA==,type:str]
-#ENC[AES256_GCM,data:JJK26DpDVy4JWFESYykJDni3q8s=,iv:gg9sKq3iV18xBFTH4LcxWZ5I/jVoXmXhAg1LRXpi1h8=,tag:EsmQYHtYXu59YAgiNKzvIg==,type:comment]
-OIDC_ISSUER_URL=ENC[AES256_GCM,data:sPQTqu2r/wcBM3Fq9D93F6MY203b+n/fTRObyxHSJZ1R,iv:jemvLfNCYqMqy7qmO2rE13oTPs262zPzUiJ3Jncullk=,tag:vjxVRvHCx4rhZzoZBwNc+w==,type:str]
-OIDC_CLIENT_ID=ENC[AES256_GCM,data:LP8Vfg+WsVNe,iv:16iQ75/KguqXioGZppA+ODoGOwfop1aNNOeBurkUiNY=,tag:9jnMdc+46Y74unhpEp0oug==,type:str]
-OIDC_CLIENT_SECRET=ENC[AES256_GCM,data:24ehyGhw3QKaCg85+MzxV6tcoH4I8xZcATHHTwYTquJbB9ArUrIs1ejcpZLfqUtdp481XvBiB8OJlp81j14/3A==,iv:KXMtoXWLap2JotTdnJ8az8AzumcAJQ4KTAEb3ZMoqVg=,tag:S61eRvwOwV5l5bqfhG4lkg==,type:str]
-AUTH_INTERNAL_API_KEY=ENC[AES256_GCM,data:YogyeowEw1RjvLjBEN1whMK/kQdnbjYcuTCRU6y+gS/45ZqQ/wQPFkboSm6SUMqhwVw+8BIfbENr4YMcmreHKg==,iv:yk1OtmeqkC3iBlYn5O0hmYCcRk9vN5GNLhdelbloOKs=,tag:XPHmZYq2SRuqS7ENtzT3BQ==,type:str]
-OIDC_ACCOUNT_URL=ENC[AES256_GCM,data:/HP/Hfw7PzODkJ4muhBzkUizWufRFIGJjr8h3X3hpcni4E9a,iv:IE1Nfh62OpiIW7zwxCDTHB9VFAdh1dZIDpoxAb44xoE=,tag:taxu2+fwS0ffDxGsC8aFvw==,type:str]
-APP_URL=ENC[AES256_GCM,data:d0oEw+lPXkPsm4ahv56embD7A6qSRw==,iv:mRmWjk6TtN77TyFqrDbO+9m+yeFnDFN1PWKfj+3Rv7E=,tag:hwPK2vgFL1VV8E8mH3cYBw==,type:str]
-SESSION_SECRET=ENC[AES256_GCM,data:trdzqh4EE6qV2wBW4/wCi/3lSB8pYRZuiBp1vv3O1dy95Mex/KYMbGOsL4IW8bhJDTLeRKrkTqbsTuihq9ER03t9EVaio2OAVrYTXbgJ2tbl3V7xwB1b5WQYYEI0SMfummIHL2qDcvqRH7H4U9tAutqNNRIZ+zAHcJwcZF+BSTE=,iv:up3oHceimL5RbF0RgYvCtdNjA+2kAfuhUSPXwtnK4m8=,tag:WU4gEyIFpu2H4XoCSAQ2xQ==,type:str]
-UNDERLAY_API_BASE_URL=ENC[AES256_GCM,data:hw7L35HfUV5qtv3fx2EP+94TIuX/gCk7PJlEwQ==,iv:0oRj3cP02a0YLdF5saT64lqYWYKAwxvtWFqjT5YsxlY=,tag:3fV21v5aGMdNKSOHUNmteQ==,type:str]
-sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBOakRHbTkzVmlXQU1PQzND\namNBTUdUZHJreHJyT1djYU95bCtLNnFYTEd3Ci9wSDZvNHhrSWdvT0I4TmVQU1Z3\nUUZHQTlNbEpUVlJaSmVPblVPOWpBZ1kKLS0tIDcrWXJRR2YyY2Q1T1FTSlNaMit1\ncEFpeEx5aE1iZ0JqTndLb1FzaGdGWnMKmzkEbE6BA80dA2JTwjRcJEZ+CFjnAoGr\nonXjpDhzd6tW5F3ZKhwRzlxfOCfKjF8s1PC+9H2HgpzYN+g1EIBwJw==\n-----END AGE ENCRYPTED FILE-----\n
+AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:J9/wGRgYaff67h3IMKBbGnljFKhKbrngf80W/wj8sCCtB5MFtftWPABTHgEDjOu6gBAKIdlYNFqH/XzyMlHvxA==,iv:hiTfWmGHyE5AGY92roZT97n23L3q7w3GaN7L3igbFyg=,tag:ApUx3rJWXn2SQQ0qJXD8gg==,type:str]
+ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:hDu76wINYeJ79yuYz8Nz8DWxUGr5BBghO/Mk4h59waXOUEFJ9FkPHWY6I8nLJdpWjsK/V72UxOQ29M1Tuhbh68MlFIeZVY+HwVPVVE5W7kx0UdRaoWy1OVTxdoH+BsRjm0m2AN5Gti1U5h+x1KOiyaOizcHWP8u41IDCU4h+YCg=,iv:q7DZByNn2n5p7lM3naDz2WaD0epa3RYUZImdUto0ngo=,tag:Ky4HZBYWisR1QDJTeMcrlQ==,type:str]
+AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:11nczYkZW+SJrmwZTOcxAdBlZOs=,iv:VdjVgs7G4xPv0lUuHbFBfDZsXzax83Jwvr/bkQStq4M=,tag:UcEw/1K4olt9KOAHQp68cA==,type:str]
+AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:blAWO8BwYOJ2naCWf8BeHeA/BG0=,iv:DM0ZHktLO2viIlHkOu4AFFwp1+QJZC+P2a0XhykUvlk=,tag:ZSXxYMhKIdN0LnAZDM46Zg==,type:str]
+AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:Yg6lw+/mZ7VJfx/Xr1VrRiYTuoyEmW1rZ91D+cnuVFIg1FiI5aQWLg==,iv:ET+VG4P7cnd+kPdByWr4aqTlQqpF4hDHSuB7kaLAY7A=,tag:Iq3BHqBINbEkGLCU+9V4TA==,type:str]
+AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:p9/Ws7Gtnft2yo0/LTnoQSy5wDPJDwDC7LpJlgjrTtpAONSo9id8DA==,iv:/4FsvlqEtgUalqHrERkTwxzELeaLCdLdOloNF386Sj8=,tag:i/jmi+f6F/nZ67p42jowjw==,type:str]
+BACKUPS_SECRET=ENC[AES256_GCM,data:2BVeq+aa9yRidXperJ9bahDyPQThLEcn0nzGBiz0PXb0lB931P4OQIIlNk8=,iv:DCufHLFrzLmJSMI3hSpgSGcZPrFJ+837vUNxE4pcW+U=,tag:0PHvlsro13/3tGHZw5FyzA==,type:str]
+BLOCKLIST_IP_ADDRESSES=ENC[AES256_GCM,data:oH0mZFy8zaPy9RSxkgy7wGkTYkhN,iv:9yoMzkF71b8ePDO3K5uaePbLHURQzpSGvlB2oIET/1o=,tag:y0FHWNEOEjb+EiDuT82IkA==,type:str]
+CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:a+W4sS4K84BD0sps3nkM3WsCoHqhHCtj0yQC1Z5rP4gBmcnkyVALKxJxmYj9TLEoBuLgpP0=,iv:RfZ3xrLvrMnO4+yMLI6/QtRPIVDKnRB5rZj/Qu7JJ4Y=,tag:+2yHLE4/3cwZE7NCYO/mwQ==,type:str]
+CLOUDFLARE_CUSTOM_HOSTNAME_API_TOKEN=ENC[AES256_GCM,data:yeHQlYeUPQf+nLktDLpLenlXOG3sYzeO6x/vep0mMGtdMz0TPNhJOWiY+hQwVbwDy+88sh0=,iv:hGNNgAHMBnZ4OTegXOegxDOiT4ckebuJ5YUMhwLdxGI=,tag:n1boYI7nJbq+dl91AOTD2w==,type:str]
+CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:MiUWP2xcxfhDOaIXjUw38ACylNZPM6gGW1R3nghkTIc=,iv:0iu7FGBAD04lFJowAqKxqLirSVig6Np08pTL6WKNfq4=,tag:gmAlvI/HyfVDGpNahK0v4g==,type:str]
+CONTENT_SEARCH_TERMS=ENC[AES256_GCM,data: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,iv:ip8jFXHcuSeVtwZc9coUBykHYgR6FWdbe7EdpEAr6BA=,tag:k+cMnBX2C8jaXDb3ACrUYQ==,type:str]
+DATABASE_URL=ENC[AES256_GCM,data:++kORRhTm0t/o0i+DE/zocT7d8MOFhkL2PIGv8UF9cnfaKVttoNspDvv+o4=,iv:ipFTUyziuINHncYiwOKZkHSWKabigwYrjplwvjoXCNk=,tag:P7WwD8lBErKQ+o27za8meg==,type:str]
+DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:0iXilK58g8/Y8ITs1jrFUHIKWh0eVS57Ceuo5Jk=,iv:BcyYL/nykUMH6ziS4Iy/Mx79vlzLYKw3knCFqmgv/Y8=,tag:ElDM4uGY6yiwgTge+cBd2Q==,type:str]
+DOI_LOGIN_ID=ENC[AES256_GCM,data:EPy7Lnpg,iv:chvY/Nevd0OTC0xcuPXgqwjIKOA+cGGBfHxA6VM7wCg=,tag:HLoEy+IFNl6Fj2Gnq3NQRw==,type:str]
+DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:c8sF51BwZN1NmV72WTzPbimNTts=,iv:ykNS1ctbvp1HdCEDQPP+D/fjXcLfYFkmBOViSNuOgRc=,tag:qOA2b4ntdbQxSf5JHn+xcQ==,type:str]
+DOI_SUBMISSION_URL=ENC[AES256_GCM,data:k6qIh69LikZsuD7E8972nq7kxvUrf39x+iGZgCcpRZ+lgnuQqmL+gA==,iv:qIHKpDlpstGKXUx/uFSpro9CRmKBecI/zyrm6X3dPP0=,tag:/hiFaZm96n42SICGzqCutw==,type:str]
+FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:8/pFYcov9AFBF+hkzEIhIV2fC4r1wHlptgZSNeGnT5k=,iv:7d+/JR4uUNFF7rVattWL3zsWXg8kdOTGh6e9cq0S6ko=,tag:hQ4grlyeJb260gmt5+4g4Q==,type:str]
+FASTLY_SERVICE_ID=ENC[AES256_GCM,data:n9WTqh74ujP0nHlRyIHXw+GABQDUSw==,iv:TNUC1XY5nqiae4ejSITXypuhWkenCSjQ2YIKKqA69qk=,tag:Le7UEGEXwVEqiGtKF5UG+A==,type:str]
+FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:aomSYPAOS0QDx9QCQn3v8mShybE6rpK6lkruhG32Cww=,tag:RDEjdGo9Z2acc0XJawQO4A==,type:str]
+JWT_SIGNING_SECRET=ENC[AES256_GCM,data:YH1sj7wfyjdFmTt8HxpzpVSO4CuOhiHtsfDp5VRvzxTPuSdx/PrjmD7Msc53JaId2iaPZv4RnKXIbwTCT6KoramZboILSQo2BxiWgtWNEJctKayrIeOCTtUcd/6GpItnQms3bBMpxmogee3NE+OU2thPMvbqk75T9AOdtJUDZ5sksdt5Y9NRDu7waBuX5lGVpgGFMMH8I2yryaW7b/eawFkJp8KZ6LFYJbq97nYnVowk1qYzN35SbgBPfuPEQh9MANGnzU4ZmqJgs1zlH/8xG6nVwZ1SQZvIWzH0sdey6H2cXu1stLe6RL7ZHAW8T9Hc/7FcYmi8Muy3QErovGwVA5IAT5Uido22Xjn9cD9OP+hmBO2FJAROkJ3PZt1RXviCC92XJNeSM6Dm7oqdQbbOsr0EoUCOWgYL5fHUcB5QoEiooFDhAHa6u2d40LBK8QtL7hpCkElMg8JlRUah7QDK56txx2EL5OimaIdwCFYO2IM7zu9Kow/0wxE8b7XF3Oq4yur4PyyeGS+r/FC80WC8bHs7bfDVNG0DRTs52aWGQHt8YvGxosyGlyKRUUhVAksX7gVG9R5//tn3WobdDgSmTSNhJduKKROZG56DjuFZriom124CTyZIC2RuKmjcnh9rkdnFbM+FC/xyFTK55KzS4W+fm3suF0XDG8wEfV6AyfU=,iv:TUf6lnQUosel4FFyAHKl9cuhuQ3Q+hoBoib4uz/RuWE=,tag:XwmYg9Amfhz/jWYHgp0q1w==,type:str]
+MAILCHIMP_API_KEY=ENC[AES256_GCM,data:UXhpRvvzE4GMk5FIUZt5NfXFWfMgTghofmZ0+c0j5KgK5GKh,iv:AVi/E/Ncphvj5GJxqy6NOccHuecrptLAZaEWIjkSfgs=,tag:gq0YO0pdBEy6Rb0XGoXNQA==,type:str]
+NEW_ACCOUNT_LINK_COMMENT_WINDOW_MINUTES=ENC[AES256_GCM,data:KXg=,iv:l47E7Sydsq8721fX79sxvFU2/S4TQguD5GQhtio1DuY=,tag:LkAEF30fJZiYn8/DRwfOSw==,type:str]
+NODE_ENV=ENC[AES256_GCM,data:v1t1XhbIPkgrkg==,iv:7cx+nVF3c0fk1QnB3uDVK8UsW4NqLYCBXbUT+mjYYCo=,tag:Ayj8Smfm3fMIiSXB8bhJsw==,type:str]
+PUBPUB_PRODUCTION=ENC[AES256_GCM,data:RCuFWg==,iv:iJWZEP3IotIeYJ2HZFQWZD6ncPdc8eFNxuKcli0aDic=,tag:QmgHiccC0fTW48M/l/9TvA==,type:str]
+S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:xk8CgReLDiOGeMzeOfNvcBmnqr8=,iv:ojduqiCE05QCLOL4YCjnZez8xDTzVE4sBGfCJGS4Ja4=,tag:EAZHkNHmvdTY/NcTdD3suw==,type:str]
+S3_BACKUP_BUCKET=ENC[AES256_GCM,data:mxO2zZ7zSHVicrU=,iv:0Wd66ifNDgUxZcXVZJZNSbRsmBjoXGktCpRa40svaPc=,tag:h3HwIiWQ59DMPkUuY+HxpQ==,type:str]
+S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:d3Y2OXPLOTMnCC8W7Cb8AbHT4Rk4Bh+fxC8ZXFoXF2y1x4U=,iv:ooQx1k8Bhj1tBWoRrO0MoDd4Z+sFxFTbwEHcO7U00eI=,tag:ONYIITFAuWdldB0HuEg4EA==,type:str]
+S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:deKJbct7ZXlLPK6SUUCNUgi0rl5aRsOIgvWnxFdjXxXQrL6VaAbMcA==,iv:U6+6Ed56dATMIhjgzmxuVkXA+zXfvd9qWaA5QIhFbRA=,tag:getYYGsbAKhZ7DAAzTmMMg==,type:str]
+SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:6NIAVZED/6a8F0rSXRxAulPfD9aGxbx/WSHkbemuMwIcX8NbZyCFbk2OhNdaaoSpJUxyF5ALxMXeQCGpzFLwprnuniEbuXDU79PvsOErE2TdbELmM7tiRhpYR6yhoJxk09FjHakciL4cecPxXP+NmU4dShmDXUy6WsmuVbyCdSuG8KliQd0ftRxkOKKBwXlC/la3Q+x9hvleDqLk0oGRkBGyi3jABYvIUKmRktSNtaqdqiFzc5ed5XzZPg==,iv:TCMpw7cy6Ol33wmq5SnD8fvdJhsTHWRdWGoRqCBclJ8=,tag:BcN6V/zJBYjZCmMwTBSx7Q==,type:str]
+SENTRY_ORG=ENC[AES256_GCM,data:sLrp,iv:BpEcStJSogc2u4b93CfKlOsSM+ahz27k29W9R5dwbrI=,tag:3bCPsLlxtwZZt9gCqDMBpA==,type:str]
+SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:m5w=,iv:vSeIU0aoqYuQY90JWe3fn7FKAK9vA1Rzc0k1j4/emZ0=,tag:btWqZ1A1SZ/fjiYzBCNpIQ==,type:str]
+SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:nT3EY01RYMXsbJldYjyEi+BQEs8Zqxl8Y5zO2F9WvlQqx+9D6ZcPk4H4fnVsJUjru0lEhh2u3OwwhcbbKtc9Ene8lsNlrh7pZHl88m8MUA==,iv:/jwYFexlldsenDZtUbxxXoYyd2tUaSJHkEOz1/caHOE=,tag:dilvnm4TXxncT+DQxmXEzw==,type:str]
+SMTP_HOST=ENC[AES256_GCM,data:rZF/kk9Lm7o2jJ45XXJOefXw95pm3SCjXQBNfAJ1c/BW3g==,iv:9p/MH+vAMDHnhv1EY1/M+IWeJnDZSUVduYh/NzxjnLo=,tag:5B/KOoyD2m7JGc8fcbKrFw==,type:str]
+SMTP_PASS=ENC[AES256_GCM,data:97bxFd0Y0Rk9/OV3qpZKRXBhwTPtqdJtepnjkIgQT0r9GKVwGaOlcf/UC8o=,iv:Ly0tDK7Qr+d87vCJufUbbfGjYpIgwF0P8WfO+Om99uk=,tag:mVvUIdLuPhLlfXuSdHnOlQ==,type:str]
+SMTP_USER=ENC[AES256_GCM,data:oAhYQa4oN1aihSjJeWKCorbNyOc=,iv:SC4FPFKjMxNOkt//5SONd8fBJCpnBLxhIivYOH5XPoU=,tag:xbd1UrWeV7x7aQ00NFGTaw==,type:str]
+ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:e+0JwwO0KMUsiM1UsCts6FEGJsg=,iv:hSA0AqSySD9aah34HEqnx4gJq6EH5AJmXhtXny/uBsI=,tag:au45YZUcyJZO065KgFRefw==,type:str]
+ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:iMvAqzuBu2ifRunqRGKbYqvJ/GM=,iv:yUZ39iGNLJA9f1ZFZvcS7w/IQK4OgE00+5/SpA0BSiM=,tag:DOh2xsscd9Gnoh7pHRjKfQ==,type:str]
+#ENC[AES256_GCM,data:5dU5o+J1HIwCtf+6rAxhoDW8VHY=,iv:JjRyD4psDzfVRIXfCs38te5vz1NYN+M9+wegu2uSir4=,tag:4wBn6yzuWeAAh16dTeoVMQ==,type:comment]
+OIDC_ISSUER_URL=ENC[AES256_GCM,data:lB5ntyBHFwTPZ68vsCAAHNr8E6n9dyCu0J95uFDm2cCM,iv:C/uqgbnUvB2YLk3KumDbmuvojU1WNsM/grheqwISa0c=,tag:0d87a0MagNV3bz/c/Mffew==,type:str]
+OIDC_CLIENT_ID=ENC[AES256_GCM,data:O8RtjtItzhKN,iv:zJfs88tcGLA6J6Zl6rTfMYDTpuRYlbsJE9w9dTvF3Mc=,tag:Lzb4Ezg6zc0uj/3UEIG2ZQ==,type:str]
+OIDC_CLIENT_SECRET=ENC[AES256_GCM,data:fKibHyguqBbVyRrlt6Nu7p1Rm9Zwky4jz3y9gBzwRtTjRFWS/Y/EsvmTDA+RyXLSA9IQKZ2xj2z8t7m0QUxKqw==,iv:dIklVFAp4nU8hFQrLp68OQdczOOFSllISEjKaooVaSI=,tag:LTH2i7/uQp0bRj/gg/ybLw==,type:str]
+AUTH_INTERNAL_API_KEY=ENC[AES256_GCM,data:7VtmkUzlfmPpzMKcsODuxMZMLw8iM4ELThdZfRMQfy/NINOA1lEoytQitVEoo1rEAORtVbAwNwj1wWIOGuCMHQ==,iv:d6l2BJx9I3pkwm7MRfFKeQq85XHyk9aQsBis3kH8sqY=,tag:VxyAlahfEiDcIdiK2jT+0g==,type:str]
+OIDC_ACCOUNT_URL=ENC[AES256_GCM,data:ZnPPcs12oXyE/6W6EadQZQ/HgQ6NeNGwKCDiFB8fSwcjhyfO,iv:vt3qN++bzZXQ6Pz0ntitbu9wPBHPolTVgPA5vkqfeXg=,tag:JfjkWbjrX+TeMMUHfJ836w==,type:str]
+APP_URL=ENC[AES256_GCM,data:/5DkJ4UMEcQgl6eDmF94y3TCym0AAQ==,iv:w3yExNHKQ+ASSTNfWZ+Ks8ktwogDAa/j2SCTw3gCwd8=,tag:nXWOk+Hx5FKM+sOcZzTEVw==,type:str]
+SESSION_SECRET=ENC[AES256_GCM,data:T6Ky9KG7InMQbqW1eyT0yIUXOcVydx7tHVpSlHg33bL9XeLdWT820Agh1pfRDpqSQkhAEl5BAFNKBiGeP+WdNKyigvmE6kpO3EM2yW3N0+H2Af0KEiZ7ciVI0LAaDgnmn7npEvvLA5+jDdVf52FNbEnDwP5kqJKlGPpQGZ/L/AA=,iv:GDoHgEkEWBG/nkTTO5+jUq4G0U5VYhRb1EYB03bu1gU=,tag:O8PZvB50NeVkRYE3BR4j/w==,type:str]
+DOILY_URL=ENC[AES256_GCM,data:OmJufF2N4kaR7vY9suWYB7M=,iv:eHy/3hoBl7SlUquTBO9i9QkNAiyUVCH26HuTotby68o=,tag:CdUSqUE/3sfO2LNGSRJXwA==,type:str]
+DOILY_API_TOKEN=ENC[AES256_GCM,data:rw8TgJJsMQhzbTxf1ixwwypXs6driHf0CdcT6ZTCP7BjRuwBAV+P5LE=,iv:5zZqP3gR1XocHl7geC3EyozpyDS+u0ebf86z07fmrCI=,tag:FSEvpOqOZTZJoTPsiBH0Mw==,type:str]
+DOILY_SEED_WEBHOOK=ENC[AES256_GCM,data:Z0VnMJHa/K4RjfFuDlpzDipMGNsXH0+8QY2qEFS6b1K+ChV6U3o=,iv:esHAiZsgXeHLbhi3rnOVJj9sdLZ76C2i2iRMjfqVxPc=,tag:w7IJ1VgQ1BWaKGJuaZPVIw==,type:str]
+sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwZmRHallleTg2VEoyOG1C\nNHNvaWZpRlVPemtVeHpPK0lIY2pIcmJBdUNRCjhhb3FaMnB5bFdFQkVPOVROdGlr\nMmJwWnNoRVp3bytic3BkTHE4SThTVEEKLS0tIC9EOFBVSTRTQ0lsZjB5ay8yU0w0\nc0ZoTE00ditOKzJjRUMzUTB4UHhDRFUKZzMzatBYQttNC6NgC2LxsRgRU1x613oA\nNxRnUD+mwLIKQzzV31g2CGScQx3xt462KaacGnvXF9DmWtdLCxeWYg==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr
-sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAxdVhNV2owRFprRHpvaWps\nYTNCMENNOFFmOHA3T0wrL2NHSkZkdjlXajJVClVSSXYxRW4xajU1Y29tcFNwdHZC\nQVBpUm9FZUdPL1Byc0Z1N2xpMFBxUkEKLS0tIEY5QlZyanZUYVNVamN4NWVCUkFM\nSGs0Rkt3dHZPZmRBdUJzd2djNUFocVUKKbQoJcRHAFLr5tFIf6zL/fuMpR98r/1S\nE/DwmgL5bMrmQqp22SIRLbHBMYXi9YjI5py/F2a+qFr96Bm5WI2JIw==\n-----END AGE ENCRYPTED FILE-----\n
+sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFZHpxdjdHYkxZc1Y3TUxG\nN0dOcmg3U3Q3M29WNVFZTDFjb2paQW5RS0V3Ck1XZENPM1BrMVl4OXhvbFA4dk1p\neGtkbW91dDdPU1lYbHdjd3JuUStTUXMKLS0tIE5MSEZ6VGF5VVAxUitCVWlxeEVG\naGcrRnNaSHJPb0J0Y1BqRDdBVEVLOGMKy8bBIcCPV4KZrAmvNdrNG6XynXMRn8gL\nHcP+TQ/SWYrSFyuVZ80sxUN18zIszNgG8bAmuZSqmOoosLH140ppRA==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj
-sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBiTkMvb2lIc0F1eE9wWXBa\na0w0ck1kMGlSTjdkV3JKY3VMN0hSN1NlTTNBCmJFcjEyTE44R3Q3eU5seUdtNC9Y\nL3F1K1NzVW1oSWhybmJ5MHdZdlVDdjAKLS0tIDkxUzNKYmR6ajB6RjVMbXBNeDVE\nZkovWHE5aCtxQXlLbWF3SkJOT01RbkEKh8RmEzkWKfS/wAMF1THY+3OxaJyasien\nYYqgvPbBe0BUNTLq5G5Te5BWfgrIcgBMkN/lQtYsejYDPEGyBiEfnw==\n-----END AGE ENCRYPTED FILE-----\n
+sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBnRkJTSW1CRmdyV3lsclYx\nQmlBejNFR2ZyRnBzaEtlVmxEVTI1T0ZiWGpnCkgvM0NoKy84U1VGVTByU0ZEaFVJ\nbVllSnBuY1dNTitRNm14MmRyL1YySWcKLS0tIDloUUdXZUkrVzdOUUR5L2dMM1ZK\nTkxWUDBvSzlUb3pNc0xPdDZwVHp0RncKkFK5PLXHEZ9mtv0VrEXQFGY5x2K1ysY3\nG9YQffhrWDLKXeu3RBPQMdyICGC3lR8SfIDNOA2PWYgzWVam2axg9A==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk
-sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0Sk1leXFSRldZYjhuSC82\nZVlmcXV3Qk92cnpSYmFFanRqQit5UUNGd3lRCmFkYkYxeXhxck5RQ28wMnBpbStI\nS0c1QTNNUHZCd2N2TzFpREowWmEwOXMKLS0tIDN4cGEraUwyS3cwUGFrbmhzR0or\nK3AwNXhobXFyRzFvUXVVTEF6RUpROFUKmrB0DeA0xA6t21KiJ0cjzyKU95WlMgzp\nNCq4bOyPZ9Cn9oDN/3xZ5B9aZLW2IZiQr6c9drMMUutdZzKNipq/Tg==\n-----END AGE ENCRYPTED FILE-----\n
+sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6VkRTdVZyRUZBblI3TWVD\nd1N3aStuVlJZOE9YNG00Uk1QTVA4d0ppUTM0CkszQUpmK3FsRWduUkRpRjFvaFdF\nSXpBTGhXUGNjcVM5WjFRcUFiZC9SNEkKLS0tIC9Bc0NiSnBXUlA1cEpyZllsWTJD\nV3crVzFoR0J0clc2a3BBMXhoQ3N1cjAKunRHEDisw6mqddiU6OoLnysQl/GMwADn\neEj0J9vzy6vvFZ5ycCN1Zd0d+gPtz9x/5SZYYkS6LqdAFVt7xpjkDQ==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h
-sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRaE9aYXpOcU1hZnhqVk41\nV0N5NkpRcXJzTC9RSWlqN3NvMWVJazBwTUNrCmxhMXk5ZkhSaUdkRUNSVGswelcv\nM3JkVkUyb0E1S1VGb1lDdkUvb0M2V2sKLS0tIGNETFBtNjBuMkwzK0Z5akZ5N01p\nK2lLbFQ0SW9FQzBwUjg1QlpubU43Vm8KPayhLJIm6e/ziRbmZxVT6TFbIGGIqhxs\nVpl3sGNBmMy+XeZe4SlnuXc5ROdsZBvUypxGmGsxZAJyBctSmoyijg==\n-----END AGE ENCRYPTED FILE-----\n
+sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsMWlOUzJadWxtcWQ5dDZH\nZ2JDWDgzaVp1amlTWkFKdVZPUloxeVNLZlg4ClVHOUJPa2ExbGhXRWJISXBkZXU2\nUWV4U2xacGNaNjdzbXRyVkE4d215RFEKLS0tIHJxaGlpQVc2MDZ1Q3lFQjVkQ2l5\naTFXQnhXZTREanZxemxNNmJDeExKUkEK8RZtUdDVeyKvkgl1zo4Ba32imx88PVda\n4h5OztSkIDI9tydvo222bSiQYET7jjrlD23lnIFZpiI6ARyR1yX1DA==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx
-sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwdVpaczlmMm9VU3gxaDIv\nQmFOMTBJcjl5dlJiN0hPb0VFWFFoZTNkYlRnCjJHbDFJV3NlRjlpUHRQaEpRSEZJ\nbUhuRlg0NUMyd05BQS9VN0wvNDZmaDQKLS0tIHFjcS8vUjlzYUd6NmxCaURIMENt\nQ2Q4ZWJjR2hRS2J2NnE2YnhQVHdIOTAKhynY3WlqDE97x42c2OpqX84Rf7wO3rSd\nwDIcoQ3wM4dd6SS78aafRwmJXpJQMqT1hnf2gcl6c3Bc/bQbHN/ENg==\n-----END AGE ENCRYPTED FILE-----\n
+sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWbzNJNlA3cWFMWUt2Rnh4\ndTZSKzViZjYyVEY0b3gvckFsTXVWL1hUdG0wCk9WVmlBc05IZjNEdHpPRjZuZkNR\nalM5Z0h0ekNTWUNhRDhXOUNCMmRNQW8KLS0tIG1nM3Y2Sk9mK3JxbDV3U3czdXVU\nSW1FVFA0Zm9PU3pWSHBib0Q2SzJYd0UK03yoD4Iuy0nmg1KdwPygTIliaTe0AU4H\noEYPcKzEJfjNw/JvOatxNFgkEzvAt3dNniDw7JL9xQx5SeOMakeHUQ==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy
-sops_lastmodified=2026-07-21T20:32:39Z
-sops_mac=ENC[AES256_GCM,data:CxUbVVy01Ncg9HhrmTa22OO/QdfN996Fr8jKORo2sEFZ/VQmgn6K3ZCsDAa1m0IAjVxwfcs4ZujtSD63zH/h+S9DVsFhaXmR/QAQeyq0Ue1CEJXSsfv4KLXcDCTn9Wo0pTAYLCQXtTCxcnZkiMm5bJZeix17VglLJYs3riEscxs=,iv:sZ6p91PlakKyJvHTxdTzrvgzYvTFgCuUtmsPmBMz5kk=,tag:1qdcCCskiOGtmZEswMDetw==,type:str]
+sops_lastmodified=2026-08-13T20:02:24Z
+sops_mac=ENC[AES256_GCM,data:75aWhEXKGg23QmdsT5exzxsnYUb+I37Mh0X9H335F+b8jyuUpDUhOOOmk+Ia4uknu80JJ8osxthlrNMaYeVUiFno9I9TZpemHZ6MZIHOk1GDatPTENrwGMq28KumAytWMN2n6IlUNfswdCciz4tUbUOlNk4sAxZE1jeRcktEJoc=,iv:3kzn7TX6bcYCrVUF2UoKJUe1pUNLrVMZfBKAkSw7iW8=,tag:2rRn1s9a0N2J+bwmdqj83g==,type:str]
sops_unencrypted_suffix=_unencrypted
-sops_version=3.11.0
+sops_version=3.12.1
diff --git a/infra/.env.local.enc b/infra/.env.local.enc
index f7e32a8296..94d48dea11 100644
--- a/infra/.env.local.enc
+++ b/infra/.env.local.enc
@@ -1,63 +1,66 @@
-AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:ZD1AeUYED1B8RnL5gtxgqZCZQGMFpfh3A7AAhTLoVDaqSwm99z2v33H7ADEf/1VHIo+JjG0idcCL0yToYz3ycg==,iv:z9TKI75XpP6854/Z3hTTHBDJCoRjDvnbUad2h96hkfU=,tag:xnkqmfWhEFY+eF++gB4Iuw==,type:str]
-ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:Q5Qml1FrF7ZE3bRptICP1q2toTG3mSaQikLBpoH5hL3EUXIAvN4hKIC/K+Ldv78FyU+G/OYM3phThjCoKMdnhA==,iv:UhsPOf97yb1M/BhKqvVvMCahdV4MnVXB+Szi83u4enA=,tag:s+ZETzyRndys11xwmnKFqQ==,type:str]
-AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:af2OYs+OW5t8hX/ovfbpKQwjvd0=,iv:z3pGXeYTRr4bVjhWlWerLyY+By2HpgpLhH1GejDiymg=,tag:RsL8OHO80v8FHqIdLGpBfQ==,type:str]
-AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:fmU5y2oc+r3mJyIt2zmZA5rCdiY=,iv:iK9HMuvSXDWF6pnJOuakf4bzQY14gq8iSX6RpiPOK7A=,tag:84pMzTM/3X/ByOHP+blShQ==,type:str]
-AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:Pm+wJBqTIEE5L1nl9XLSNgY0l/6on/TI3LYQ394jsUc8Bhb+3vmA5w==,iv:6rFTPtoGKaw5Iyut/hfHHdH/TizhTpK8vShgt6EHQhk=,tag:tGeLVNV/+oVdgIa0MthuRA==,type:str]
-AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:CLimkLWV3BN23ORRt8ej1ryzB7wo0bbuTIP08fRu0I35Nd1CYB44lQ==,iv:tWPJP3E/QTuwQd05yfoIOooue0VtdXKVGjynZPV/NJc=,tag:3nE5nW8DALpGQhUpdKeHbQ==,type:str]
-BACKUPS_SECRET=ENC[AES256_GCM,data:rHi0xiQLn7fnUxdsGuOWuUNudvd1v3llUk14BmsFBn9VI4AYmANkEDcFl5M=,iv:Y81gRSLu9c3lCsBCKAzV3aNhlSsYBsazsjAPkHdm5CM=,tag:Rb86SN5g2q0FIar4r8OHJQ==,type:str]
-CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:GZOgx2T/+1fOGkZj0yEjfH8QFtuQAXQBKID6mDX/2tCJF/yw43SbwFts+gKwH/lfiuC/vb4=,iv:OlX5ax1Pmv+n/ZIcBR45xOGj3A3wVU0wc40qpbfl79M=,tag:iT5ESOBjYy/ZOldIjGJC7g==,type:str]
-CLOUDFLARE_CUSTOM_HOSTNAME_API_TOKEN=ENC[AES256_GCM,data:a7szrh92prSoC/S1ACh9hG8U+VCr6576eLY7mOIQJMs7HXu48rgU1+C5Smgdj98JobXTLko=,iv:LJsjLymhA0+KLX8dYZPpM0rm5t0gKDDMeEYmOrkPxw8=,tag:d1gLbgf9yaO0xPDSyXuvmw==,type:str]
-CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:H90mwKgc5r4vo63T0OoE5Q8N0fEdOSt1c3qGJ8Yh9So=,iv:7Qog4qXqe7l1jCFpML6Pj/5rhxl0wuLA9gEL8Ceie58=,tag:APnP3k/2hm5LE1BzZ5e/FA==,type:str]
-CONTENT_SEARCH_TERMS=ENC[AES256_GCM,data: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,iv:vTHAYp6tYRZle3NYR3WP3WTvDwE22VaDbq6fv86kb4w=,tag:luBgeFFAf8gSqV3XoSgoNA==,type:str]
-DATABASE_URL=ENC[AES256_GCM,data:SsAfyjLMt17//V+uvnxDPwpVc1cseAoVRNGQmdpP4dWv5DLIfWgyEQb8XdQ=,iv:FC11OYpPj/RLKBmRQfoIZ3DfVo4+utM7vlU2aecAqZE=,tag:WWEJz6ePUUhiSbBr4vE8OA==,type:str]
-DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:3wsLme7+naLkpR5kUx7y/IQtRfE6grYLwzbbe+P6t+BYUg==,iv:ofOLOhaQfdK01RsPxkKaZN7DyCMYyQxrXnjnijSHTjw=,tag:q1FJz02ng0ASPDQPO5Xk9w==,type:str]
-DOI_LOGIN_ID=ENC[AES256_GCM,data:o/MsRojc,iv:3qCBo9tnRe72pEfGIITioqSrAbH+Y6l2Wlsa05iBKWU=,tag:f4FCCGn0lJyh3fA0L3vheg==,type:str]
-DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:FuP4fjB5yNVtAqLrFqO0+eA1inY=,iv:d+qqlwwRttLL53hh5QEP8gSuIYsrIP4LuGDIYEljx1g=,tag:bFPWxJN+P4yJRVAvyCIqFg==,type:str]
-DOI_SUBMISSION_URL=ENC[AES256_GCM,data:iGGFDZa8b4zBnYFZovejbu/JsyH+uuKMoC/vZuxUJK7z1KOr01UItUM=,iv:XJW5CDrqEfd3z7AOH6bswiM/8ftnzZR/w1qcIZzGqQg=,tag:LGktoFOKQwpTpdlQ3h+kzg==,type:str]
-FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:U/kWY29Rsc24N0IS1HdVRRoUBP9PeMP1EUaEdbK5ew4=,iv:y+GzPj23YHMOO+muevFK5m+gqHzrYi58P6IQgNqeXJA=,tag:WGaYdnsYvHJAVtTcz3Hi3A==,type:str]
-FASTLY_SERVICE_ID=ENC[AES256_GCM,data:32g2QrW1quy6UoX8z2UZlD7QfXc9dA==,iv:ixLmagGSxek5uTJFUv70M266LIv1JI4rHkGmLwKNKOk=,tag:4x5SpTIZk6KlTglphvZtEA==,type:str]
-FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:0qe7uifJYDTBem/px+eqR2GIbLX7ecutD7Xo4CspgKo=,tag:pqExBcZXqjAuwhz1Ysm7Xw==,type:str]
-IS_DUQDUQ=ENC[AES256_GCM,data:a4c+Qg==,iv:a4io6cDFRkSQqmZRn94Oqs/zxvN9lwoZ+NOszFL7jsM=,tag:OhBQtR2Cineluvmkg9v2iw==,type:str]
-JWT_SIGNING_SECRET=ENC[AES256_GCM,data:RepwbitmN1rQgzlNcRRaN2D9LuWe4SGq/WWQ5CDBVyFklShhlkKqPAQiytfbpfrhfpn+jjS2cXyhshNHStM9dmLFLlB2w2yNHoXe/73CNxESQmt7KeHoAHA7eQcDdrJ3A6ZIZxaIqv3mb4xJMM7ARK48vaDzvRv824FKWL/fMLaFEjAFfezmhJi73E8/vWvTwoGNx1+zbYdprT03cDEPQkaeTseJ4YWP0AV1hXqz4XTwYgqOuQ9bb0WmxsEyriouBeuZ0wCn4PX+/tRjRWMzUqDlfwYxyh6vQ+foPOxfnRh/+vT4JUVhZY/hEnWIripJXgKWDdETeknx7zU8Vh583pVxVtRGp66U1M3vuIBfuYVw0LnQpCJ+Um67EdyvZIWRXOSo2Bd0J9Ig4ZYINTbtqMNgLq1Rp7s0E4v6TZeMRG/ARufl3EsZIl/ejNvNpRuvAvyjOOi7hP0TXWV9Ha15fuU9HKQ6IC9+l/rVFVbhNVlLsONngY7tzCNdUSrSkRyJ2nx/Smfm2jHGON9SrLtIgKIqhGQvum5f9867pCmj/C5AzCKKIVIpOQqam42KIG2Vd7BhbRhhieDqs9gUsYJ3OPclLRLcqf9RaqSa38Pzsnlx9w8iuQEsdu3rLargeFgQ0MSZaLXjF9/KrMvuwVRR6nJ7gSs83UY2n1QJ2at7EdzeMyS7NMbf2JF79DWBmgsNRDvmYjTd/JU16VjhKYyoLirQz3EzEWJxXJOjbClX4exiccW4ie7V2StbjoWbK0S0FlQmt+0sq2VxM6/gpR2bFcVyoNyuPP7kqlbapsl7VNORH7NVm6sCrilnPBpv+ZkfbpZ6pfwJvHuYaU0LqD/WuXeCdUfmxmqonfDXGDvjJON0Uu7M1n2e0nO0gM2ExD5bZuGwMRHIkr9NuuXxxFK/4NwfKG4nKodiw16g5C9uPvmWH00PjdW6AkpwN7XEUM4XQiUyGiprt/JwJ6/yyIHQ/RL0DjvqS1zyLwuQjsiIm8M9fShF+ItSSnpEyEhCkhfwzevNmGT9sUXBfI8xhVYwrDv6bSIAIOmn/XFAnxfWCEazUmtDM8FN6MsBWsN82Od4x2Yv6e3lREG1EY8umgYnuHCaACGMCmQH8LS7C+HvegxvepK477ioiP8BBCFRH1Hi06FluB4T5chne4U0frqLD0hvY4qX6WjRTWmKjpCUspsJ7Jatvf24EIjQmp2peKPeM2X8xxQHFGygpH/nwCEzpBqlENrWwqrwJWNl8Asuuu4ZJuuyQLlA3Bh52ACY+gSoirfC8t7u8sadu8rLaSS5NAXIZxsnICIgWSTOsjfsGOiJ3BulmYop17rrxe+oCN5Uuf42d8CD6p678JX7DjtqLQ==,iv:D2drTK5IogMJ14RYbompuD1QF2/JaJwgw57j1k0T3bU=,tag:EhQ0pDCt5MTvJPx5Jn4KAQ==,type:str]
-MAILCHIMP_API_KEY=ENC[AES256_GCM,data:xyO/4wqOSHpDP62q/dEjT/ZCYyJ4OtRWxRjE5FaESc2kwqOm,iv:SQi/wDN9UAIHqwW+LO6k0t1xfgVnYYLUqWJkr0vW2Vc=,tag:oPtUx/8aivSo7rupCXZEPA==,type:str]
-MAILGUN_API_KEY=ENC[AES256_GCM,data:d8yVFBA2TtYlMfOMAmDoxY9n6ubyKzU9o3yzyGVd8Q3rQNEA,iv:N7yx+GcyN+kaeJsUTZ+pz1Zhp4RhFQe5nvy0r/bW8W4=,tag:8EhfKf25tOGCCtc9C4dtmw==,type:str]
-NODE_ENV=ENC[AES256_GCM,data:JlxEV/S0su/SAA==,iv:kurdIuv1SAVu31qHU0zDTkr/qkqByWCwoeub079A2eA=,tag:t+DmUaGeKL9v2lX8bduxJA==,type:str]
-S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:Gd+6/N/sbM9eyrRzxgbzxjQcy70=,iv:+tCDqW++du6AHNJEktHYD/BNjwJNzu4qHndP80LKx5c=,tag:jkZ0IUVXEDVSc79QMcZFQQ==,type:str]
-S3_BACKUP_BUCKET=ENC[AES256_GCM,data:dm7eGE01fcdfDNM=,iv:Px4GRHxcHwPisz8caHKPb2+7k/1G0DBrum4cbW78838=,tag:LuYi6iSTHoJqgwfBaWlUMQ==,type:str]
-S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:JDYMPcHMtCcR3SUibRB2pnnZjv+ibXWBSyoIA+p44eOy/1Y=,iv:kTBMeD5g3aXjW6nIQGE+ReH63QF3gsiFQy1rm4RoAFY=,tag:UtsY0iGgxYg52jDGtyUGhg==,type:str]
-S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:GYk6lIw0eMUCdJdwIECBjDUXfDAq/UQHmwJT2cwlnX3NWznzKp96CA==,iv:lK+vrXHtFoxZrPqHhlLuopgCP9oz05NU6nM9Bs8Vo2c=,tag:RuohU69HKQ6lFx2zPnJW1A==,type:str]
-SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:efs/O+MhLY/VAsS4wq/rtbw+hq1f1KgIC7Q8aYe803/i2G96McMXXdnPr9RoS1gAWzGnstjOXqDqNKtoet2CgAUe2etWa4Oguc4ZtmTPjhZC7MFseSrZIy2Qg2CYTVlElwbg7zbOw21GWNzEjmXW1L1rYZ3re9cNcvd+R/5TLtzx0BcLTTI/njdvcyn0XnxHK7QkvQ/YjnUCTU9pclVOOgitnb9wth0kMc+OF/QTcRexzBwCx+MyQEd7GQ==,iv:u8NqzxAL/MjQVmiZDWxvl+E0ZEO+Br8c8zcDVGkyxeY=,tag:v9Yzkpg202X9k1F+FRjIQg==,type:str]
-SENTRY_ORG=ENC[AES256_GCM,data:GOVX,iv:ETyJipKzVcI1RCXPkcLX+VMSk44rbnDemroVQeFwoi4=,tag:GALes4rSowdIGdb4cz+kuA==,type:str]
-SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:u98=,iv:mwskzEbRoKK9sROsi638m/D6iNb7Fz3qWCyYWA9cInw=,tag:h0B4rcQyusee3pcYRPdJYQ==,type:str]
-SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:Vdl95AIe1L8y/anuNO/8BCeKcmXJ+xH6dQc2tuCvOs0Tu0qBfDpCtx7lPmYH+pmUKidE024o77F9wMMtRIbwicsQ52rUDMkFjQMq4sEnDw==,iv:b6pHCWNrfBZ0m5ioNQ/U4lRnKnECcWRUqZkPzob+33M=,tag:NUy4g8Wq5CpfM8rPhEC4SA==,type:str]
-SMTP_HOST=ENC[AES256_GCM,data:/NXd/v6oSiGHgkB6HFx0VsX+AlUUp/fKLeAxytVXgGAb4A==,iv:3gzgB8g6jf40r+9NdmdnAmho6odvOvT8bqk9jO8t43g=,tag:IsQEKU6aem04QzKH3dyDKw==,type:str]
-SMTP_PASS=ENC[AES256_GCM,data:z3dqAZxZ2WvtT2R15m2GR2HHGi2jW2YXGLSsGbr2fsURI6cf7l3X2Tmggto=,iv:UJhEazx5yWemu9/o0bhKCJZap517WCdQjzhRXCTRHDA=,tag:nimyGtZ7I3lurJIVH8EkUA==,type:str]
-SMTP_USER=ENC[AES256_GCM,data:JitixUQgXizjFuEYj/sbm4QL+ew=,iv:d88fSWoG030mPOeVyr9xDYNySMn0CJy1jV+baCsi1ak=,tag:vklqVlCpXEFGq2XwdpWKLQ==,type:str]
-ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:wvT3xIlGtQlCCZru5tlgS5WuaWg=,iv:0Posj3nnbTrXlKAO3Ea+Akng0VhHqvqVAtz1cBqk+Yw=,tag:obZl8tKvvdiG8euwugRhJg==,type:str]
-ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:Vh8MlPx/YHMWGbiaQTFGI2iVnuI=,iv:94zdqNTmG7opAeDauPLRB5/UYjBHXKJNracatprMPOI=,tag:HeFYluKTqI3xGlqgAE50oA==,type:str]
-#ENC[AES256_GCM,data:TjN2UsYIDfyu7whuId9jSnNG/6o=,iv:JE6664f97Ef1pCJQ+fPXrW7UVWAM3fgmTPWjW60h8tU=,tag:oTraOJ54CmDmmAsK+/KLHw==,type:comment]
-OIDC_ISSUER_URL=ENC[AES256_GCM,data:f3j1aKxWDiVv/tkpHqIE9rrUXqr7,iv:JnL/p9pCEJ1EAI3MN1SuzjnrBNgTzo/GqDNKUZywS6E=,tag:x+LPS5iQ19xVE9QshmFh2g==,type:str]
-OIDC_ISSUER_INTERNAL_URL=ENC[AES256_GCM,data:+LXtT+t4IqXuEjYlfdlS1U5/GQ44mweGi9thZ92itNM=,iv:4LajXP2gUIPPlz1ez3wcPQFOwDGrz0WH7p225sZTWMM=,tag:Y9jk/2qNyhxkBVPmNj1iGw==,type:str]
-OIDC_CLIENT_ID=ENC[AES256_GCM,data:hvJivzcXVpUp,iv:5zkAHfRc5PQufS2R+fejy5aX+zmSg0diBabw+ky7waM=,tag:1It1C6ziHZYyaYax0kLJJA==,type:str]
-OIDC_CLIENT_SECRET=ENC[AES256_GCM,data:Qs87Fxk6kc9/VvoGYuT6rZPQiSN0+VXOjLlztRYJuL8P5pOkCm5ofTMv5+vv3V6vO+pnn+xAvGgmlyocs4pHlw==,iv:5dZsPTK5M6rysIhRJSgzcWu5a2iukjHD/rWs62y9IZQ=,tag:sITEelDDQazMQzxEwiBzjw==,type:str]
-AUTH_INTERNAL_API_KEY=ENC[AES256_GCM,data:ur8wfy27HOUlJzXkob6H4y+FvMFhgIqt9yw3nN4+NaA=,iv:FwgsS3/y1olLxFEybwo0E49Jr+Sjd1j0A3cwW6yoSS4=,tag:YNNrULrEZ0tIYBAfWu+hWw==,type:str]
-OIDC_ACCOUNT_URL=ENC[AES256_GCM,data:CrvN8lMjUz8Z+WKYwCI1j1g2YhHJ,iv:91s50BYiH21W60AGKlhBJ/mPtgOcJA8dod+V8s0IHDw=,tag:/Mhi+niCS8/Lk5sKpTH++w==,type:str]
-APP_URL=ENC[AES256_GCM,data:48gVT1NH0tW86AVChqLvdgL8U2ue,iv:UjwxflH6UEGfkZvEY/GBKTLhoIoprhzjist6fUoGB8E=,tag:qWHI6X92Mhtf+MTFqqPSKg==,type:str]
-SESSION_SECRET=ENC[AES256_GCM,data:W6kBWCuZmMbQHSNjYgI/sBMb2Csru0n+l8RwE+/TmkmXkOB3mg3B75TyPK0YCVhuASypmjU5sdNF+VaTqUeLrg==,iv:2LqSKwHV8LMRI4itM05mpCZCPcvgA03ychjIj9HpqiU=,tag:HWrUMGuVgS1o4+IxH8ip7Q==,type:str]
-UNDERLAY_API_BASE_URL=ENC[AES256_GCM,data:FDZHrLtiM8ohv9gI0hoSRPm9LLkN6Y3JVezKRhGCz8rTqqbI,iv:u7KNh9asZqBJGtY/wwz0qO+nIdU0tNU9fmcQtk5koHw=,tag:dZoLJ7EXZpgVd4RC+p42Gg==,type:str]
-sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFdm9CZGRLbThBZ1lEVCt0\nTGxpK2ZzMUpwd2t6Y05FRS92TVdMaUJ5eTBvCm52RmRzQVJydElBWlFiSWN6cERY\nYy9sQnlvejEwczUwZ3JPdE8rNjN4ZVUKLS0tIHVZWXFrWkVqRFhtOFE0ZFcvZHBw\nNzAzdUkxT28wZWZsYmVTUmNxbVhVck0KKbbyIj/z0hw6ArYuuy1irPHKNsL6MojR\nMnCjX44ytYBR3C5ghVKB2IqYuyRPzBLdZ0HKOZsr+b2aI6cUyFUNNA==\n-----END AGE ENCRYPTED FILE-----\n
+AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:T0M9O3RCWgMe1K0E1squyfobZxGY538vJqh7CNq3xxX+0CNhjfZVXWodOMgpd7kCqNmuoX5JxAtFIuM0o4CjSw==,iv:NTnAhRqmt2FjM6kPLN2Z3IZyAdN4IJw9bcF3YKoZdKU=,tag:5vpzyFoa8Bi5S8StXlhdOQ==,type:str]
+ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:/ZDDOyNqAeuUF4s12BBiDg1sxOhu0Q7DBfcu8X6x0GKLxwKTHpFz31Za3Bc7bMY48VzS/REOhPMUcxjjEu0kPw==,iv:963sLWUMofelMpTK5K4h+R2h5CgiPVnZ0lIiJS+t4DQ=,tag:bapKjIEKf/I1pXmHVVz5IA==,type:str]
+AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:RS13MbSTvtq8VR6jOXrEfKgHRSk=,iv:VY6+38gT0U6VZmP8HYcJfyY+OtdZkOLp2LvnArSWV7M=,tag:8T0cPXqV6K8s18XFjzf/jA==,type:str]
+AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:7BNuXZ9Fb8OlCoznPDqmFM6p0gQ=,iv:CAEm11wrlgejksuu0X1rRfpAWMCKTeWqr4407K5hqN4=,tag:z0T9W25x2bUD2w4ETKOiEg==,type:str]
+AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:928PlPSubOOGnaOO7E/8bnziX9pV1HO5dwDZzZi9O7yWHTeRVPW7/A==,iv:pcYh4ljZGYouvA4gHBbvb0Lmb5Xb4tHwIkHuKsQnZKc=,tag:vo1cAdjjr4YvzYp3LWrHqg==,type:str]
+AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:s0qOxva+ToH4j+Lgg3Fevr9m8EJt76yd3cJ5MZwz/8vbfzYoHXiFJA==,iv:F31XFA3hSoNWarGFD8tWNOxP0y0ycKzojuG0Mi+D6Iw=,tag:dBMmuTCpen7JUKaC36ssSA==,type:str]
+BACKUPS_SECRET=ENC[AES256_GCM,data:e2B0JZPWbMO7wO6SUcLPqJsnruFsPRcV+5vlQueyIFhHkbOLQxl6xqLxaS4=,iv:WXGD3Lt3WMonrT44NZuKxcxBArCtMuUobL1+j5Rwo9k=,tag:M5v0t+UXQQXkqwhzYVHMzQ==,type:str]
+CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:la2y2SzH988xgrBC6MlfdJG83pPQWcdRTnjqNdSEe5hoqncDzY3ib51pHeCSDYDc0MWhfWY=,iv:um8dxY0obdCABIW58/0Z3ewE3qVFylBedU3oU85bm/c=,tag:FgfJ6TR9SdI8nvKxZoIZFQ==,type:str]
+CLOUDFLARE_CUSTOM_HOSTNAME_API_TOKEN=ENC[AES256_GCM,data:37mQDbgoSAt0H1C+eNTpD4TP1DaZd3MRsYntLUnlMPzgPGbYfpO9dNGc/LjA69b2PNNCMJ0=,iv:GfIzPLRe7MmAFNb1ln81KXUqhY9vDFKpS/IE+yByG4g=,tag:dxdUN2ZuiSfB2gZASP6HbA==,type:str]
+CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:pty8obNFJhkR0mxxusImkU/I6SxjIMK69idYhcfEYkU=,iv:d5Ml4iBbj9Q/sLlGuAHXSpvwJjxgGeksqZGlpJCx6j4=,tag:yaz2yJCH25dKiCdOe440lQ==,type:str]
+CONTENT_SEARCH_TERMS=ENC[AES256_GCM,data:49hxhS8aNKcParuoSkTpnfl+odKrhrTGts60mYL90iUKb4CUPsd2m/+kv5rMW60cNCMWeydfmDjmMfxgvEut2Msn7UTARTPAxwQ/RktBh6TjZPyf4Ic1VO6fbBGNWT6lGll2ZkM4JG1x0kZq01UnrfuusCKFxKhuWb5DnvpkuTMxYcdfYzFoijzi5kTvSFFW9on0XMs/nqgH/5Q7GTPnzIWy4tcD1BhtNKVJk2w5yV3xFfIgF1/AX8sVs7lTbXkbU5Ab6J2hIILEAaq6WtTE2K7WVAK2ZW0a1yYArTZ4uSlTYjty55+OwkeR9pnYih76KKlAXdN3C7Y/BqGrtv/A4OpLmbFlkJsz9j/Wmsk+4Ob1qwG1WkF03h6Zh0jAjB4iDqOYM90h/pumudV567lLB1w1g/4aDzfUQ9O45cENKVKICJnkGn5BpwBUHouN1lT3iDmQ/Rj6+hYP95JIUm+tJDXtjLPTtZezJD/pDuCOq1DgA1m71VA6r4TcvAE50rf+A3HBz6uAJpH3YxnyNP2O/U9ybjrAHXfALvSNN3wspr7nKKB9yYmqhm9AbUPEZjfkXR/OCLtEVMwQVytwnndMzddN0GXUFOaSOfsHTFs7CwgPkN3MPb/SzvqRLrx3RVQ+he5xaqkRvb7vP2WPkVX7b5mjMm4Eag5z3n9c+SGbaBP/V9/uq7bYokrfrDKB94pngq7EH9cLBjDsieU2/iZ6julLHktD8p5we1MUtjBUmmPLL4cvbEvUwENQLwTUaxybnWQ2TUEvIC2G3ZxjYG90saOQ+6vhsZh+StcZxXLnOu8rG87OpUPrzXJnAxVfHAigQWMCqH/MWYxC5O6/DtmNK8tFGkQs8yiE+BxZRMmdXHVnxjAweAd5HThb9kTKjQNs1wEz5ESzIq9Qt4alQn2h7QGU+GYs74BKmUzqPVCgjO08lzewWcUNY32PSLwLmOu1QMO9gE1qQ7SuMX/9bZ6HMB1xnaDUbElKqB0x2Pf6V4GMzTvfSkE2/rtqkLUdN3QNPoNs41EYq2nDEdJq+SlZ+FwdJ6J1jlcL9whZy2ZUyb22pkuWpLOlsQ3GUbZdZGu1BvzCQntussH0AquqZB6ixFgv+RSR79fZsvLsqheQRVypT5pFthiMvtdNDxto8BhtdACT/dqZwTa2ztMdijOi42MnKxQZUSs4k7UsCpVp/KCjAUFgYbQXbKQnsc87TK7An0y75QiBWArO7EBcgkUWVx0vT1/0ZkmKTAFQyh5BTJk7up+8+EdXAi62y+aIgAS//AJpn4b8ry/BIjn1cVLN8YPaaLuJByUmCBHL8onQuQ6ab0nEKBHGCiWXy2RJqWjoBKvjr45/lwS3wK+gYbRhM19ijLM48u5cmkTAiE0i4TFm3gQ5XoMYVAeOToZCxhUTefi6aN5BhyENgvcTVAbqfzP+T7P9rDtQ6uKRMsILDAkoT92fqwrp3F3typcUkM63Q/Iv0jaqFsv0D2TPEmsZk1qRbDOPduy3xshKUvq3C3B2RItpRApH3fNlWaD1KNaXVwoYcAk+GdDClscDFXSs5BIc9rKkxZqcT3bj212fy90FEQ/FtOAZYMS20IpMn98Na932BrkDdVnnGfr3iTlen4gS+PKw7tVmv7ubaVmFFbdYtqSbeSehwJgK4BlC8FdPKBKXcvbaLaCDVR9dosmYGZNN3ohH81/JQVZZtpz6cKdQUMtl4y1zm4+widGOFpsLJUni99/SKnWD2l/k8kdCB6vbtw28pjNUkPA8UAXsB7n5hFrnKiqE5HOIDO70zmn0sNvJaE82PBZGgN+KJd9gkxzb3QhRwYuSLTU9Mdx7U0i6VkE23qzLk9eol2uBRsVkj5rPJjk92FhJbag+xZoMz/6bQ33DcA7uFh4zL5mUTPYWUicokkv4oOqdZ/PqqzEHsRHcvNqUeA4PUA240lzEkI2WDotbmyrFpkXi/KLxUbnV4CMyovOZ1FNd3hW9FSKZm/O8/Kw4NoZyQL1RrFrzCK21zljzqVBnNk09LPu9zuhwT7ZLb3u0TvcM1ZJbHf0pRSZB71MPgfrX6bFyD3OJsTA/mUkP3lARLDmIXNOcYJOxyW9zS9Lq9/7KZ4N3gqLKfmcRs7K92MWjFcFvrxuLjqek1wrguDf+yBLKJ5GJUcbrrB4123yzx0HUBWa5spOqwAuHN3L0SPq1cd11SPxsperHVg/pOw7+VJxx49OXoNwi9ofFiWj6EgAhBW12SDx2hyR6PHV5p7ArLQz4pWELnglo0bfi7xzOMREr0GiaIsG45IY6lfUNZueZm+YFKz4SWHXBaeIHICICwaGzpEtHOc1+xgtMhgD77eu+IhN1L3Num2lLOLb+REKc0od4MQTU+krmKu69u41BndOtalsMIonz+qSrTdRbhf3u1yxDTIa3mpYz3ZIwa4zQ7dmZ7gH8x9AwiDB+oN86FzX/eq0oaw7KhGdLW8dxe7Wp42eNnq80Pfkcef0KImI51U5BXU8JtRBwhvLdxijfW8B3Uhs9sFr1L05MB1eeYcEE7nwUZ0rMATfptGgmB4959XAS+wDW19Q+umwPb9HAUb+pnWEEUanxhCSHJfo+p/raqrCBzHZzabTmKLPiQnDNgtK25YIi/l0TH/zEamlCsuGhVr+wsewl1rYJBoWNkKOEOG+MEc1IGJYDsFDQPmSEauULnKK58nfD3s/JzU19XvfkS4AMQKOf9Mjf5VpfTRojy8D0cfyM7gsCvzo9aXwg93dCWdeWTCShFBsaavlICVoNkbatzLMFnGwpcyJwfBmbNE/95fb8sn//uGwrOYRoNkbSYVVDKxvmVmb9mKUAwNVIqxPRcZnFIcnqJJ14nFMqqOj6a/sYf4Dp+7IteBTryECDOf+Rn7577x2IcvQWuBzu77uUCvkGirc8Jdua5Ccgmmdi6bleNB/5xljWcH/d0ZFpa5LUo9wWdnryLoMFUtBqbbY6TaoJddVAVvlp1OpFDf9en7+1NF2slal8KoCHR5eBeZcnWQybi2/wfCKhZMQufDyBJGJCaZk3lQ9XelTYA/TqmBSmQnyM60IlNOGdGZA2tMhRW0N2S8hil8Knu/orf5cNvZ5OLhuDgrSKQFMqitCdoHA5utd/g+KqOBri7ncYitS/MFQUDOXOSAnFB2FM/HKJDCwAGehvj6Wjg8+9abTBZ7DbThU88+GGoVqnO7st7j08BK9xNRCq2Oph1psWs31IwcfSDYPjxPeDk98D2vCZGMZyPl8PbAjlN7nc7zy0XAhafGELDAgZ8t3oM8XDtBd75sNAaWwn7p+vhaVMuwjtOmjyeXJJ9sXnvUb87zAuRY1Ivs7ay+WGCwlPoKtGs3LIUtH0lB5kcjcnJydv1mVQwGOt6XijwINmdrLzj/9Mddgs+HVdbjFxsPo54l7ix/KlBstfRbxEILtOanZ3j3ozlV68mCQshVaL/aILrH6AP7xgNPajUb7er+jwCNrH1DM2fagR79MRSJ7NWbwhHD/PavN7P1mNUGx/el+9sX0GX8A6x9UIm2lvL3/lD5sAJ4r6g36ZbtEoUPMxfJcUc6HjWS4p7xZY7FN8MfXRDJBS+GloJimq+vcO9D7pJsnmLVvKQrEAh+6isOrSyl7+1FehxxQyPlOSzCFaKRncuVzeKLG4xoaHHe8K5SJsTZ4PIv7WmcKqNU96zOImj9UOs+em26aSeA9t/3VMR26XA5FKg9jY/7HACO7RLtRV4zAC5YCWAc6Z+//5gFpk9tBkj01oLzlBB7FFqKzuZ3GHCCX6rthuZtnQ42+2HxNq3lz5wKC/Co8mv/0WnHc356wK6Ro7jP7BYWgKVNMaeMYve5bE5SOWXKkdg6w25uvpwi4+dvKGTh1ZLBjQdkXGDlNs7H0+3iDpuJH9ekbBjPykF31MWk55ajYcDDXsLKT3lDVNQPe/4Pq3jHszgCNh8SB+Sk+BuQcnTNym6PbDb/XobubSCuPBEintUosLopTiZ1uFdNlkyLV1AkC5GybWE4AzUKc/j7whLw5MG8b5OTzflrZoAsSjdBqEykr1URfQrnyM6XfvIaVqsFfh7hM=,iv:euMgGQ/Nq9v610HuFr+pNbXpwgwsIy7tMAFfzDZ0GdQ=,tag:1pT9nSEC6TlbfQZ38q8C5g==,type:str]
+DATABASE_URL=ENC[AES256_GCM,data:2Afk5vaQsYItR/0xnJi3sPqwcmI0WZPcRaByo5RLjMfD3LKq/2ti3+eUzBQ=,iv:TOSM1AvWhSqk+r+Sl2iHsLku8FFDMEyLZCTTQ6UZzJE=,tag:3SBCLirlAW6+Ur4Gvz/wfQ==,type:str]
+DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:mciG/5nMc3V1zNYojPXquAlUox6JVOgtK4txB5F4ujmFlA==,iv:z83mi1MYUqcxpbKZk9AuwmAuy+n2yOZ6H49AYeDf5YY=,tag:l6Ri0Ot+3wHKMpM5uGQBCQ==,type:str]
+DOI_LOGIN_ID=ENC[AES256_GCM,data:wIGwgV3+,iv:gC23KKGoaL1ZWwJMpZehJrPIqfkkkV4PdWTxVTmwkqE=,tag:Y+0eO6s51M4q/D/TPYNenQ==,type:str]
+DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:LB/4HwWbKIZoZ31f5+Vnpe33IyM=,iv:9ETvRp570HPznL1M2QuXQRki6FY9R1lNjpjYSaMkUng=,tag:OKp07v5GuEBnEYEfnckc/A==,type:str]
+DOI_SUBMISSION_URL=ENC[AES256_GCM,data:POCSk7qi2E7OqCuATtavsoWITrpmGlUW6K1U/L53nCXGQp7ObKIYhu0=,iv:UC+8gBJiuCq39Z56LLB/EHPFsoFx5iZ4WUpFx0VJumA=,tag:6YZSlAYUEhRYIceTZb+eXA==,type:str]
+FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:frf+q2ZS6rNfqw0aM7FIeI2rn5x+Et6szUpBHf4mh9o=,iv:fZNJaTeAhlCTaE2rwOuG8aazpVmIkIPRp2Qn428WqmY=,tag:MZCuHsKUPW9V9RBrhmckZg==,type:str]
+FASTLY_SERVICE_ID=ENC[AES256_GCM,data:RFBsi9IbYpYCa+XGCvEN75InLc9pLQ==,iv:zGeztJ+/aQ30k7HA0B5ppXeLhNhqgetMCIfhbAzzcqk=,tag:w64Eyi7eQpdZUDObDYtnSw==,type:str]
+FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:iVfjh0surklD35wTEhhy2qBTQF0Q2KU0QSHWcaPE6bM=,tag:u4OiAhitUH7o2UgTDxzA0Q==,type:str]
+IS_DUQDUQ=ENC[AES256_GCM,data:wGYPeQ==,iv:HkM6b23lAOy0srSANcFfSshDEKrZBIsf3fUxLqQ8njg=,tag:tIbrfpR2EHTVQfeBZiMCEg==,type:str]
+JWT_SIGNING_SECRET=ENC[AES256_GCM,data:an9jIcZBAFvL0Y5EyTKeVbDbEvAyIoruAETykQefaKeqfIJe6khIR/LeyqvXcxQZcLmX8XPcnpuReXAXYho1nMFJ0S8uA1fx+lSCRjXhaJP0lfnHzIXHATtRWBV8QSe1iONG0CsUTjPqgrTBBY7mEEEGJLLE5aUgViumtQH7CJ5sqkE3usFZ898Ws8gmAJmimk/Eev5ngYUvO20NYKQ17Tn25XfW4JgIVz39GCBGYH9EJ7Z28N8cRlwWZx0qjNSwt4C7NCgWaxKSkd96jzUlm7eFfpig/bLLdAR+uPf3eDzTka570/xCbIJKTUo8Fp+dGdn3DMGMHabr6ZYIAkN/NaCG0ZGUeOS+3Ly+Cw70Vm4GkeHo/VxhhzX83tbKcY2xiPcZWl9CDkBpcCu7A931bdPzU/MMhzqPLEY4qV9CHng/OHfUXrYv8lsBO2Ju2miZ+lVC3ji3sCy6TcM33m8AU3gP9XvJ0pq0gAkGqwR7HDqaJ4zwsa+ipm9PIaSJSs+DaHgXj8fi0ta+oXCSiLtwVPZD7lNYC6S0D3jBES8zuDghQWmNrQ7tnohtTKR3LYSRYEO0nSIHg3yd/eU2AfcGBTD1TOMzTW3P7D84m+AEZUQMT0nksyZiSIr4RHVhoW5jt9wBxbHrug30ZUqMqLWwzlgrnyk7iiu6kqmrKHtZArIyq+KNH34zAiUvlrLTKKrl22k2x2fwN87iZ6lrjwA/koiX+SBGSSEwyd1lJikHiFt8dciVjVHYU1XpLf2aMemhyW+3EOW0mVJp4VZq4sVPQcF+uTEJHQjCuNs9jLCw7YEJHVtQIh5LMnsNHuenfxpuDnlnE1W6uloSpiJgvT/psuZtkz+xPceqQ+PdoTgog/i1Lq4OitkDiOX3oUsSOo0dZEUofdT2T4LeW0yiV+d9PzFgbJTu/XxuH7SWb02M2ZL8BguRF/bulE9JS64KE9sDqdefEGErOPwJf7pzHH1boNoGEJE4Wp5OYSb7SF2BUVYMcTWKUphqivcOd1nrFZtf/gDLRcIs2m+cqBPAeSNw928ib+T9IHc1m60xG0mMmPvOR61iiXNBu/1brJmB+gwK43CF3Eb4sZXI7bQmMttGcZRHhg3UUdCge0tGkMNQrXf9UVwKtEygaiHu9uR+olTX3oc+nJsC1jfiKmirX6y/rl5W8vMvzza8yNOkLprETz4cG5pGOvGf2Dti7h+iNUl5BoURj2ckI6IVbwVWJW8PJCFh5bxBhUTcIfwXGvrFBfJQnNn6FNbAaB9iIRLr3a39fV0J0s5K7VbQoI4wL4GhifBf3qTbw1caHqXuD2k02Imnh84hbY1DHjKaToMZAmuyPlgp9YeDjAyWj3YEL7uWvA==,iv:9tg0I8hjK9HNvl+XaSlSZvCPPOFabbhtK2GX+20x4Ck=,tag:vJzimJbz5lt8O8i/yhCr2Q==,type:str]
+MAILCHIMP_API_KEY=ENC[AES256_GCM,data:JpcqyLkVstlI7Y1s+4NMqH1trBUdRmQXcH9yc+YasRNBDamr,iv:qjjhWWVdLVk7LCOzLOHajERP0s74NMBWlfgXCix0mhM=,tag:I300C9g6STXjF6G8oK43ng==,type:str]
+MAILGUN_API_KEY=ENC[AES256_GCM,data:svcdXikgLFH4YKty0F1uwcuABGX0VWqdEB07NXWf9JgQQjTy,iv:CSOqmfluNVuVCBkOJ1PTg6o5rHvCAudZSabhAylqmtA=,tag:oxNOMwLeaq2vlyP8DFKcjg==,type:str]
+NODE_ENV=ENC[AES256_GCM,data:O7MQWt0i1Gvgeg==,iv:7DIvNaFKjKjIg3gcofXSL1uHd201M4UGUBuKXJOjis0=,tag:BFvCJbPeS/j8DkTZsL84BQ==,type:str]
+S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:lss4sxyzyWcNu8zKibPBbcadKWw=,iv:Z5/rFJZPA+3bjAZYa0CTFAtRfXc6sV6r7O+qUibEoUQ=,tag:d/niE5fRQXclv83IEIImlg==,type:str]
+S3_BACKUP_BUCKET=ENC[AES256_GCM,data:RT+bryqon/jLSwU=,iv:g3YJU5BCZIzQOgc3HIoi6sAHIfk506CoRAWSwHMC3KQ=,tag:x3qA5u9exsU7rx1CkCX1Eg==,type:str]
+S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:mhxlSCvLj88OfWuRXATpTE7RG+f83wbkUbSYjBOYhj9Nzng=,iv:+iJORd8D09Ajz0VJUhu/5S6J2fePGZ/uI1uUAGtj3MY=,tag:uhpDh66DgqnEIzKEyFib9w==,type:str]
+S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:c98ovIWu6RKANoVuGV9lh5FxaCstXJfAhLWsZynJSojdlmKRMLFXWg==,iv:cOld634ckkY5EY3+AzVOou4Jio4HeYAs302PcbmHSYg=,tag:UR5vIyZNgSAH0k564hF/xA==,type:str]
+SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:81oLTgse7gECs9Eenx1ZvRt4TOHJbPTd8svVGHcb1103IbWbEbuh7BNDXS8o4zTKg+Zi3//TUn7tOfQACYSGxxokClvgQpTfxIzaS85ST46d4FYQhKjGfzgd6q/koNZLeODsfxjp9xCr2EL2Zv5Iqz6ImqAgoVCiwk76Dx0xbLKfACvmhcWHZv0D8eRklBYuRnsosUr778RpvGVqWatkBAtEdGa4fl1fXL/7wU5pEgBlUztI1bpcIjtEng==,iv:xPvGtrT/tzV0cyM1KamLdWNK1cZ4r/Vus3dLZzRfqvA=,tag:QQTPaxbt68gXdKNllNhf0A==,type:str]
+SENTRY_ORG=ENC[AES256_GCM,data:tlZC,iv:oUFSl7uscOY1UvnaPzCLQcLf5fzdMiMR8zKvom5f73g=,tag:s1WVDuyUgqOnKRsEpRakpg==,type:str]
+SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:yAU=,iv:Ju0SWjvZ44m2CKnWH+dwDiB0EbWv+DwlPToX5gbZ+gI=,tag:lOrUtFi4hVDBUtKsKjtYtA==,type:str]
+SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:4JhCF5fzc+K3AskB8OYKiuJwxLJImAnQgfVCHyORyUBvCab+0OI8QXTaRFrOWfUkbGt34KpMjdHiP6iUo/o834bXuGySsh5OanZBjx1khQ==,iv:ssSQuFy/Hm6Eg6j+btDl/gQWA1v0SOh3PbHNJNlf4no=,tag:ZVpefES2Qcf+DauUGTkT3g==,type:str]
+SMTP_HOST=ENC[AES256_GCM,data:H+eKHluJGSUTuI9/2movHLfhiifZSIf/WlocIEBodqk3Zw==,iv:jUN4SlBOIj0lOjn2jqsa2MQKq4aL4/rJBiP8jz9wO7I=,tag:UglhdP3A9BVki5c1bT008Q==,type:str]
+SMTP_PASS=ENC[AES256_GCM,data:dGYLhwyzPUksLIkKmnOxkaNhzP7ngX4dQJCDs8LNSAqvxbregmLgpgIaPRA=,iv:MWWAbarLktsWmmP0UiIgumwt3R0j4YkRXjpA+D/pSh0=,tag:Y7dyrJXQNb1yKP/K3LgzGw==,type:str]
+SMTP_USER=ENC[AES256_GCM,data:dgiheyAgOnsKN7WvCjvJf1JsmuQ=,iv:zakvskdy9AzNCTrHvSph/WS+lQVsbYUdok+vuqqztKM=,tag:UUsXEutbDnfXyDS84CntCg==,type:str]
+ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:k1sVts1vMOOhweKwsLOMAHIOH34=,iv:jyBCJ1h/alGI/1dIUb6j5qYDQX7N5QrJ09wAFtk972g=,tag:6IHqbXnsBlT+A3oQVd3bVg==,type:str]
+ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:qXOwCGXP5OHUA9POEXRRxxxxuck=,iv:5tF8dva1e0SuiWYVTqvvdX+vvdF7dzKoGjUHQJmCJHg=,tag:6oIZEggMmuGZ0PJ0Rdf2bw==,type:str]
+#ENC[AES256_GCM,data:ZQim9L0OZQv/5948nQ0QJgRmKkY=,iv:Zdoyhf4ExDRcPmZRDYSQ4EwHdERUfeTKdzMSAl5Uy9A=,tag:32HhRJN6wYtD7sOgHac59w==,type:comment]
+OIDC_ISSUER_URL=ENC[AES256_GCM,data:7h6qcMa0XEnoo5Ax80w2Te2fXLdT,iv:dSi0sIamxO2lYAxslZPziJgPQzPDPq02mCZ0/Y41Xbw=,tag:S3c7moLSvDeKczrW2Xg4LA==,type:str]
+OIDC_ISSUER_INTERNAL_URL=ENC[AES256_GCM,data:+mHT1eva9tjxlYudFoLxVmQUH5nMqEalfehnbBHcPCI=,iv:BAZ/dw/mt4bwDFmLW2/CtJqXkdLKZuzfiV5pqTMgDeE=,tag:VN26Lv8w+1OPw33O3rf4oQ==,type:str]
+OIDC_CLIENT_ID=ENC[AES256_GCM,data:RnAWbpcNk8Xb,iv:smmuvWIGFLO2hqfLp5UTbHsr+uu3Y3TxACvNBO83Luw=,tag:hTQkwVNiEWpNEwtqZBdeRA==,type:str]
+OIDC_CLIENT_SECRET=ENC[AES256_GCM,data:zaVzssGOzhObbMwOdP3zi4tfZLenPVEWXH7tkimApLvkGpJxLezPzg1o4xDXMy8MwBBZRZHkT9tDV0NgYOM/Mw==,iv:Zm+dGCRV5yB+v1RnTiQGq3HG9pKUpxswF5VNvrUwW94=,tag:/px5nJ9TWvY4/Bpy+zTkcg==,type:str]
+AUTH_INTERNAL_API_KEY=ENC[AES256_GCM,data:rxWXr2917Z1wSplirELWcCsv8KgMsna54DfWmjKjs0M=,iv:YyE2dSmw6aeZsm4e1K7CqRZ2A4J6jMDAVdjDh6Rb4Ak=,tag:rydD8ACsS7DyHds6R+xwRg==,type:str]
+OIDC_ACCOUNT_URL=ENC[AES256_GCM,data:2A+AAfaM99M0LOPrfHR/RZdJ+D+s,iv:KLRGqlzk6eTLoskw4j1SnK50QvILNzk6c3lXpweeqB8=,tag:18sG07VQCyb8YMzvAOSisA==,type:str]
+APP_URL=ENC[AES256_GCM,data:oCw+qtiSZT8fcnSjgzm9HtoN6Svn,iv:8fIu8Nt3ZcpkFzstcD72Qu1P1aHfuKMSSK0YcSKh8gA=,tag:ii8J1fdq8tAC+0FbzlzG2g==,type:str]
+SESSION_SECRET=ENC[AES256_GCM,data:blSS86satTJJyVrmGCx7jeXQ6gKACa8zaUBNtQZNOothhoG1dSsDNRB1RAvm5vSHvGNYuyQi1fCHW+aWQYsYpA==,iv:MGMaRfvQ5zsIlKTBdIDVFB7Gxi+2eqNOgKYNXYqpsxI=,tag:iNIiEWho7WHkpZiILEPwKw==,type:str]
+UNDERLAY_API_BASE_URL=ENC[AES256_GCM,data:ovcygWOdPNECJ5ht0XunWb0xHTnoXUCEHnEOCHU+50soLVHT,iv:YFmtLenxNh35fyeXvsIepsyOapRShTmd6MQ9+NKpX3U=,tag:EegFuqjGp3QCBQv+rtsTVQ==,type:str]
+DOILY_URL=ENC[AES256_GCM,data:p5GzJtqIHODA9alPqeeI994biK7kW9CBQ0NR4A0Vz7A=,iv:+gKapMpr3uiN3H5sItsHcfrKvY7sC+Z7yJ6Gu1FjqmA=,tag:tT60kYrSx2DhMi2eJSJoXg==,type:str]
+DOILY_API_TOKEN=ENC[AES256_GCM,data:eS0s2pk5+VBcLJdmpnxQp+XRxs3hgoEjjkmgK56ZdZ0w0TmxU1kgK6g=,iv:z74bGKjjOttin3k8/A1vwZo81OsXSjErzz+ieaYVoWg=,tag:Ze9p4jD9YMdB2qUHdEK8Kg==,type:str]
+DOILY_WEBHOOK_SECRET=ENC[AES256_GCM,data:1tVqkWvY5pqzFbjwd7AVJZY84C4fTdArTF4Bnqa3FkLdQdvW/6A=,iv:6fLEdiOWYv/krD+BDqGRCmUyI8IKYlgd10IGCO5+z+o=,tag:H6nSaJ/mEnff4aA5ZEyk6w==,type:str]
+sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBVeUpkQ3JVQTBSVktyMDJu\nbldISkgxVHQ4elJVWjQxMjk5V0cxSkhjczNBCkp2UG8xcTVaeUpha2dPVFpZaS9k\neHJKSXNueTIza1doTGlUbVVGc3d4TlkKLS0tIEJLMW5pUElCR3Rpb05aTDdPTlI4\nbndQRVE4Qk5IbGREYzFUVFlnVnJsUTAKKbM61U2p/xJofAA182Uqj32qxGeklr64\nhXnfrCcZFtTqjOyuGCy2gzmT8hPbS/qVxZEhWYzztHmJNXLVjt/f+A==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr
-sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPaFpGRlRHdmVFeEpuOEE2\nVlgvdXlxRTBLOTF6Z1JlRXpRVDYyK2hNN0U0ClE5TEVxa1NxT0hhRUI4dDk1NWZI\nTGg0RFI2eE1waG9XT0lYL1NEMGFGVzAKLS0tIHVFWnBSaU1ET2d6RzA5M24renJX\nRHZoWncyNVpxUFRwckVqVCtUTTBWaWcKsq6Uq2kUa+12tN550PEc3wryVQpYGKA4\n/pr5o7n2XOYMyyA6X7jKMu4AT55eUCgcguw8ziMKxecWuj0SmQqrJA==\n-----END AGE ENCRYPTED FILE-----\n
+sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZdWZtZTVsdVJKRkYzVWZ2\nbXBQNUs0ZzdvanUwQy9LUkdyRmpjSWZDN1cwCm5Pa2h6VzhFTWcxR0hJV2xST1J1\nRElybW1vOFJKNkJmaG1rcXAwb1czc1EKLS0tIEpTdktUWDNoRkpFbWdNMStZWFNQ\nMnJrWklXVGlJQkZ3RHpnd0ZEZHE3N0EKOuW9bpmMkas3kdOJt0PG4UdC0Vf1Ob3f\nuO/UbRefAOppuxlpNeUmSEF9mIv3XVvYB/TAdGRMHb+hI3S4krYzcQ==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj
-sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJdGJZeXFmTlRSQW9kbFpv\nd0RzUkdURkdDejdzMHdPRE5MUXhFSnFwL1RzCjYzVG42OERtNjZyaG54ZUNISHNR\nOE5OZ2JvR1grODg4ZWMzTHJ3WEtPRTgKLS0tIERPMVE3UGNDUVd3dDZNNSs0MDA4\nd0NkV3cvcEphdXpEMnpRWmszZUY1ZmcKfWu4OKWivdyPMCiTwJlt0dFgY47Xw8oW\n+iIV8l6Q0YrOPt/2D58yu+w+wfWu2VBSvd5K1vFkBokD6jwMS11yzQ==\n-----END AGE ENCRYPTED FILE-----\n
+sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAxVmNqZXBpd3NwdmFpUVg4\nTXc5STFaclVGUnZuU25XYWg4WGJPa1dqNHhnClFCRS9ub2VCSXlCZEs1R2R0VU1n\najFINmJTalRYN3NBd0h0Zlpha1VuUk0KLS0tIExBRXR5ZFc3dUtsS0V1NGpOaWF5\na0pQd3VBd1ZHMWI1VFMrVmVjMDN3VjAKt1zC5bZO5REgVWxoYl1zuQJF5Ti2Z4iD\nSfcUrRc98qeoSn+QNzmzFnEjNm7k5IjPkyVCrQKjnly6Az2ddUemQQ==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk
-sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBmb1dYalJHT2VUWHA4R2hR\naHJ0eEJrMnc3eVpVMkp3Y3Nva3Exc3l5T0hNCkZFZXBxVFBxK21ld3dtR05zSnhC\nS2tvdzVhQ1l5UlFwSzdSM2pJbStmS00KLS0tICtGMWlzR1RSc055SkFyNmczVXpm\nMnllc2RnWUlwcEVQWnE0bzhYY3ZmbFkKg/xasFuiwa1gX3IRwBiEWf1h47Lt1jvo\njSM2A1u/kA20M8ZwxxFZtlAHoNFVKwRjlQxXqHufJ9C5ShfMdwQY7Q==\n-----END AGE ENCRYPTED FILE-----\n
+sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwZmxDV09kN3UrbTg5RC9R\nUUhSUStEclV0bHYyQXlpdXVpcEQ1NXBIUVVRCjduaVo3TzBlMVZicWlqZUZVRDFt\nZ0pjUG9VMXM2bkZXMG1pNmhGdVlTZGMKLS0tIGU0NTFnZUtjbGc4V3RxdGFEQTN2\nN0U1V2Z6MFh2VzBQdlQrUmxlb1RKTmsKT76WXWpjcwd9iJsdI6rY11q4rjvYVAJF\nywN6cOZK0PmYUOSaJ+a9lWhgNBQun81m102ovSOzfUyuqQKDui7+4Q==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h
-sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0SEMybFVIbkxXdmVXL2Ur\nYnd0MXM5SXY4enFhemhYSy9tTlpOb0hTa0c0CjZIc3c2NjdOWHpWODlHZHFBMnB5\neURXRVRpR2JJSFJsc3RoV0kyZkMvZFkKLS0tIDVRNktPbmpzWnptbWJxa1YrUVZa\nNjdWQjFsbit0bi9CT3ZPMmMvTGFQeWMKst8Xbr4ilmDZ9w+M8mSnVIEjDlS00R1D\ntRLZkjywPaKn9rZZfkkRJr0TP7rlg7kf3s/s/SqihubHk2eitnqoYw==\n-----END AGE ENCRYPTED FILE-----\n
+sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBubTVjSFptdlRQRTNBNE9t\nZWR3M08yYnZXTHZHMlAvZG16MEVCUWZSOGhrClhjdlRFcWR2NktqRDByQlBhcWVv\nZ1VNc3FqYzUycmZFNm9wMGJzZVVkUzAKLS0tIHNIV0tHSzJMYlZqdHBHMUljWjhS\nQlBlMlpuRVAxRFVic0xKRk80RE44UjAKI2xflI3nYec9c0Cbkq8GEhHqtfZWsbdA\nVlnlPZVv8Ck0sUkMmPfIdagNLDSOcO1nIzrtLKVeJfBpwaEbnGpJdg==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx
-sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJdnR6dURhdGsxaEtERmpD\ndDZKYkV4dTRhNWhHL2RmUVBCNWVXLzJDYnhnClg4UmlKR0VUR0JxVE5GOU1Cckk4\nNGkzVHFEZFF0eXNnTlQrU1hLUTdlRU0KLS0tIHFWNENlNkdpUEJoVzFqcTI5WkQ4\nbkluY2R2eGxPSGo5UWF3cHhrSmQ4RG8Ka+ApzwnOAkAFbOmjWBXWCkM/V5HVrwnt\nGBPlXxapGLqHcKRbiohMLqrIEjvUuAe1NExRuNLjh/NPHMkok6Kfqw==\n-----END AGE ENCRYPTED FILE-----\n
+sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBmN1VLaStGMmxoN2RIRHJs\nZ1NwQVF1TW5HUGFaQWpmNmhKZUhjNTNXQ0JZCk0zalJlc1htRjNkS2ErdC9HUUNj\ndGt0UnlZTEhVRTZhNHA5QU1LaW1LZ00KLS0tIGVmdVprYmxKKzRSc3VpbUF0Nzc5\naHgyRGZmSEpad2lmOUM3NEwrM2RVVHMKGBSqRl+BVL81JCkjru7EKXgrMfxTwOF2\neK9JaPtqkqklGHar4IrW/wGArs6NZjn2yGduZPw1SAstR1e65MRlkg==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy
-sops_lastmodified=2026-07-21T14:08:53Z
-sops_mac=ENC[AES256_GCM,data:vMjJyYagbzrEwBHs6dR9ZE+Fdu7WncpLmeQ2Coj6d+fUMmv8KLz7xs3a4m8pYc9AG04A2ryv2d0AXrz28V9X1o1LZPkNytP6HzcHA2CnY5NJ6mLntglHgiekXwxMJY+I+UTCDHOSM1b4znE9ngYyW7nyRsneXsTswf0BgJKE9Dc=,iv:Z0nZoD52XkAScDLjEQwFyts6pMy6HFEJtVd23aySHF4=,tag:MNbklBfJZSPYugyLPXDHOQ==,type:str]
+sops_lastmodified=2026-08-13T20:02:17Z
+sops_mac=ENC[AES256_GCM,data:qc4erEcGnUXCrXiBL2bP7XHYq+fFQvHbrFO/IpPyI+7RF8peo2u/HleI07YTCqNi+ijeLBCxx+bsnAwRkEl4YnHZ0sqRXrEi6+6eXpQzDy6Q2dMkVNqYeUc7m6snVUQINwd2tHFglQNx5L6xSB7xha93f+zYeXBx0U7Nu1ZvAH0=,iv:wFoniq57e/K5QzjiV3KbTFGcXNPYJasy21ylwIsF4HY=,tag:GIK7J8IvVYtehMcCkLL+qg==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.12.1
diff --git a/server/crossrefDepositRecord/model.ts b/server/crossrefDepositRecord/model.ts
index 752458173c..ee639f3fa9 100644
--- a/server/crossrefDepositRecord/model.ts
+++ b/server/crossrefDepositRecord/model.ts
@@ -1,8 +1,9 @@
import type { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize';
import type { SerializedModel } from 'types';
+import type { DepositStatus } from 'utils/crossref/depositStatus';
-import { Column, DataType, Default, Model, PrimaryKey, Table } from 'sequelize-typescript';
+import { Column, DataType, Default, Index, Model, PrimaryKey, Table } from 'sequelize-typescript';
@Table
export class CrossrefDepositRecord extends Model<
@@ -18,4 +19,32 @@ export class CrossrefDepositRecord extends Model<
@Column(DataType.JSONB)
declare depositJson: object | null;
+
+ /**
+ * status of the deposit for doily
+ */
+ @Column(DataType.TEXT)
+ declare status: CreationOptional;
+
+ /** Doily's deposit id, so a webhook can find this row without matching on DOI. Necessary in case of failures/edited doily doi */
+ @Index
+ @Column(DataType.TEXT)
+ declare doilyDepositId: CreationOptional;
+
+ /** allows us to retain the DOI even if the pub's DOI is edited underneath us */
+ @Index
+ @Column(DataType.TEXT)
+ declare doi: CreationOptional;
+
+ @Column(DataType.TEXT)
+ declare error: CreationOptional;
+
+ @Column(DataType.DATE)
+ declare lastCheckedAt: CreationOptional;
+
+ /**
+ * When this DOI first registered, from Doily's firstRegisteredAt.
+ */
+ @Column(DataType.DATE)
+ declare firstRegisteredAt: CreationOptional;
}
diff --git a/server/crossrefDepositRecord/queries.ts b/server/crossrefDepositRecord/queries.ts
index 1ccb3715d0..6070cce04e 100644
--- a/server/crossrefDepositRecord/queries.ts
+++ b/server/crossrefDepositRecord/queries.ts
@@ -1,8 +1,27 @@
+import type { DepositStatus } from 'utils/crossref/depositStatus';
+
import { CrossrefDepositRecord } from 'server/models';
-export const createCrossrefDepositRecord = ({ depositJson }) => {
+/**
+ * The parts of a deposit's outcome that live in columns rather than inside the
+ * depositJson blob, so the dashboard and the DOI display rule can read one field
+ * instead of digging through a registrar payload.
+ */
+export type DepositState = {
+ status?: DepositStatus | null;
+ doilyDepositId?: string | null;
+ doi?: string | null;
+ error?: string | null;
+ lastCheckedAt?: Date | null;
+};
+
+export const createCrossrefDepositRecord = ({
+ depositJson,
+ ...depositState
+}: { depositJson: any } & DepositState) => {
return CrossrefDepositRecord.create({
depositJson,
+ ...depositState,
});
};
diff --git a/server/doi/queries.ts b/server/doi/queries.ts
index ce276df3d0..982c8b97bf 100644
--- a/server/doi/queries.ts
+++ b/server/doi/queries.ts
@@ -1,8 +1,16 @@
+import type { DepositState } from 'server/crossrefDepositRecord/queries';
+
import {
createCrossrefDepositRecord,
updateCrossrefDepositRecord,
} from 'server/crossrefDepositRecord/queries';
import { getCommunityDepositTarget } from 'server/depositTarget/queries';
+import {
+ DoilyUnsupportedRecordError,
+ findPrimaryDoilyRecord,
+ isDoilyEnabledForCommunity,
+ submitDepositViaDoily,
+} from 'server/doily/client';
import {
Collection,
CollectionAttribution,
@@ -15,6 +23,7 @@ import buildPubOptions from 'server/utils/queryHelpers/pubOptions';
import { expect } from 'utils/assert';
import { getPrimaryCollectionPub } from 'utils/collections/primary';
import createDeposit, { getDois } from 'utils/crossref/createDeposit';
+import { isDepositStatus } from 'utils/crossref/depositStatus';
import { submitDoiData } from './submit';
@@ -73,7 +82,11 @@ const findCommunity = (communityId) =>
],
});
-export const persistCrossrefDepositRecord = async (ids, depositJson) => {
+export const persistCrossrefDepositRecord = async (
+ ids,
+ depositJson,
+ depositState: DepositState = {},
+) => {
const { collectionId, pubId } = ids;
const targetModel = expect(
pubId
@@ -94,10 +107,14 @@ export const persistCrossrefDepositRecord = async (ids, depositJson) => {
return updateCrossrefDepositRecord({
crossrefDepositRecordId,
depositJson,
+ ...depositState,
});
}
- const crossrefDepositRecord = await createCrossrefDepositRecord({ depositJson });
+ const crossrefDepositRecord = await createCrossrefDepositRecord({
+ depositJson,
+ ...depositState,
+ });
// this is just to make typescript happy, update cannot be called on the union
await (targetModel as Pub).update({
@@ -167,6 +184,59 @@ export const setDoiData = async (
};
const ids = { collectionId, pubId };
+ const isDoilyEnabled = await isDoilyEnabledForCommunity(communityId);
+
+ // Doily path (community feature flag `doilyDeposits`): build the deposit
+ // JSON as usual, but hand registration to Doily, which maps it to Crossref
+ // 5.4.0, versions it, and owns result polling. One shot — Doily's
+ // prechecks replace the two-phase relationship dance below. Record types
+ // Doily rejects (conference, supplement) fall through to the legacy path;
+ // any other Doily failure is a real failure (see server/doily/client.ts
+ // for why falling back would silently strand the DOI's metadata).
+ if (isDoilyEnabled) {
+ const timestamp = new Date().getTime();
+ const depositJson = await getDoiData(depositParams, doiTarget, timestamp);
+ const { deposit, dois } = depositJson;
+ try {
+ const primaryDoi = expect(dois.pub ?? dois.collection ?? dois.community);
+ const doilyResult = await submitDepositViaDoily({
+ communityId,
+ depositJson,
+ primaryDoi,
+ });
+ // Doily's answer is the deposit's opening state, not its outcome:
+ // Crossref accepts a batch and rules on it minutes to hours later.
+ // Lift the parts we act on into columns (the whole response stays in
+ // the blob for audit) so the webhook in server/doily/webhook.ts has
+ // a row to find and the UI has a status to read.
+ const primaryRecord = findPrimaryDoilyRecord(doilyResult.records, primaryDoi);
+ await Promise.all([
+ persistDoiData(ids, dois),
+ persistCrossrefDepositRecord(
+ ids,
+ { ...depositJson, doily: doilyResult },
+ {
+ status: isDepositStatus(primaryRecord?.status)
+ ? primaryRecord.status
+ : null,
+ doilyDepositId: primaryRecord?.depositId ?? null,
+ doi: primaryRecord?.doi ?? primaryDoi,
+ // Cleared, not left: an error from the attempt before this
+ // one would otherwise sit next to a deposit that has just
+ // been accepted.
+ error: null,
+ lastCheckedAt: new Date(),
+ },
+ ),
+ ]);
+ return { deposit, dois };
+ } catch (error) {
+ if (!(error instanceof DoilyUnsupportedRecordError)) {
+ throw error;
+ }
+ // fall through to the legacy pipeline below
+ }
+ }
// peer reviews in crossref require a rel:program with isReviewOf relation,
// so we cannot do the two-phase deposit (disconnect then connect) that we
diff --git a/server/doily/__tests__/webhookContract.test.ts b/server/doily/__tests__/webhookContract.test.ts
new file mode 100644
index 0000000000..911f8fa816
--- /dev/null
+++ b/server/doily/__tests__/webhookContract.test.ts
@@ -0,0 +1,212 @@
+import { createHmac } from 'crypto';
+import { describe, expect, it } from 'vitest';
+
+import {
+ DOILY_TEST_DOI,
+ isTestPayload,
+ shouldApplyDepositState,
+ signDoilyBody,
+ verifyDoilySignature,
+} from '../webhookContract';
+
+const SECRET = 'whsec_9SsQ2n3f0hVJq7LmZ4pXbWc1AeRtYu8I';
+
+/**
+ * A body with keys in the order Doily emits them (not alphabetical), irregular
+ * whitespace and a non-ASCII character. Re-serializing the parsed form of this
+ * produces different bytes, which is the trap these cases exist to pin.
+ */
+const RAW_BODY =
+ '{"event":"deposit.registered", "timestamp":"2026-08-12T09:15:00.000Z",' +
+ '"deliveryId":"d0f1e2d3-4455-6677-8899-aabbccddeeff",' +
+ '"deposit":{"id":"dep_abc123","doi":"10.21428/testing.abc","status":"registered"},' +
+ '"note":"kürbis"}';
+
+describe('signDoilyBody', () => {
+ it('produces sha256= plus lowercase hex over the raw bytes', () => {
+ const signature = signDoilyBody(SECRET, RAW_BODY);
+ expect(signature).toMatch(/^sha256=[0-9a-f]{64}$/);
+ // Independent of our own helper: the digest Doily computes.
+ expect(signature).toBe(
+ `sha256=${createHmac('sha256', SECRET).update(RAW_BODY, 'utf8').digest('hex')}`,
+ );
+ });
+
+ it('signs a Buffer and the equivalent string identically', () => {
+ expect(signDoilyBody(SECRET, Buffer.from(RAW_BODY, 'utf8'))).toBe(
+ signDoilyBody(SECRET, RAW_BODY),
+ );
+ });
+
+ it('uses the secret as the literal whsec_ string it was issued as', () => {
+ expect(signDoilyBody(SECRET, RAW_BODY)).not.toBe(
+ signDoilyBody(SECRET.replace('whsec_', ''), RAW_BODY),
+ );
+ });
+});
+
+describe('verifyDoilySignature', () => {
+ it('accepts the signature over the exact bytes received', () => {
+ expect(
+ verifyDoilySignature({
+ rawBody: Buffer.from(RAW_BODY, 'utf8'),
+ header: signDoilyBody(SECRET, RAW_BODY),
+ secret: SECRET,
+ }),
+ ).toBe(true);
+ });
+
+ it('rejects a re-serialized body, which is why the route needs raw bytes', () => {
+ // JSON.stringify(JSON.parse(body)) drops the extra whitespace, so the HMAC
+ // covers bytes nobody ever sent. A handler reading req.body from
+ // express.json() would fail every single delivery this way.
+ const reserialized = JSON.stringify(JSON.parse(RAW_BODY));
+ expect(reserialized).not.toBe(RAW_BODY);
+ expect(
+ verifyDoilySignature({
+ rawBody: reserialized,
+ header: signDoilyBody(SECRET, RAW_BODY),
+ secret: SECRET,
+ }),
+ ).toBe(false);
+ });
+
+ it('rejects a tampered body, another secret, and a missing header', () => {
+ const header = signDoilyBody(SECRET, RAW_BODY);
+ expect(verifyDoilySignature({ rawBody: `${RAW_BODY} `, header, secret: SECRET })).toBe(
+ false,
+ );
+ expect(verifyDoilySignature({ rawBody: RAW_BODY, header, secret: 'whsec_other' })).toBe(
+ false,
+ );
+ expect(verifyDoilySignature({ rawBody: RAW_BODY, header: undefined, secret: SECRET })).toBe(
+ false,
+ );
+ });
+
+ it('rejects a malformed header without throwing on the length mismatch', () => {
+ // timingSafeEqual throws when the buffers differ in length, so a truncated
+ // header must be caught before the compare rather than becoming a 500.
+ expect(() =>
+ verifyDoilySignature({ rawBody: RAW_BODY, header: 'sha256=deadbeef', secret: SECRET }),
+ ).not.toThrow();
+ expect(
+ verifyDoilySignature({ rawBody: RAW_BODY, header: 'sha256=deadbeef', secret: SECRET }),
+ ).toBe(false);
+ expect(verifyDoilySignature({ rawBody: RAW_BODY, header: '', secret: SECRET })).toBe(false);
+ });
+});
+
+describe('isTestPayload', () => {
+ it('recognizes the console test event by either marker', () => {
+ expect(isTestPayload({ test: true, deposit: { doi: '10.21428/real.doi' } })).toBe(true);
+ expect(isTestPayload({ deposit: { doi: DOILY_TEST_DOI } })).toBe(true);
+ });
+
+ it('lets a real transition through', () => {
+ expect(isTestPayload({ deposit: { doi: '10.21428/real.doi', status: 'registered' } })).toBe(
+ false,
+ );
+ });
+});
+
+describe('shouldApplyDepositState', () => {
+ const at = (iso: string) => new Date(iso);
+
+ it('drops a stale pending assertion that would un-register a live DOI', () => {
+ // The retry of a deposit.submitted landing after the deposit.registered it
+ // preceded. Applied in arrival order it hides a registered DOI.
+ expect(
+ shouldApplyDepositState({
+ storedStatus: 'registered',
+ storedAt: at('2026-08-12T10:00:00Z'),
+ incomingStatus: 'queued',
+ incomingAt: at('2026-08-12T09:00:00Z'),
+ }),
+ ).toBe(false);
+ });
+
+ it('applies a pending assertion that is genuinely newer (a re-deposit)', () => {
+ expect(
+ shouldApplyDepositState({
+ storedStatus: 'registered',
+ storedAt: at('2026-08-12T10:00:00Z'),
+ incomingStatus: 'queued',
+ incomingAt: at('2026-08-12T11:00:00Z'),
+ }),
+ ).toBe(true);
+ });
+
+ it('never refuses a settled verdict, even one that looks older', () => {
+ // lastCheckedAt can be stamped off PubPub's clock and `timestamp` comes
+ // from Doily's, so a few seconds of skew must not be able to discard the
+ // registrar's answer.
+ expect(
+ shouldApplyDepositState({
+ storedStatus: 'queued',
+ storedAt: at('2026-08-12T10:00:00Z'),
+ incomingStatus: 'registered',
+ incomingAt: at('2026-08-12T09:59:00Z'),
+ }),
+ ).toBe(true);
+ expect(
+ shouldApplyDepositState({
+ storedStatus: 'registered',
+ storedAt: at('2026-08-12T10:00:00Z'),
+ incomingStatus: 'failed',
+ incomingAt: at('2026-08-12T09:59:00Z'),
+ }),
+ ).toBe(true);
+ });
+
+ it('accepts anything onto a legacy row or over another pending state', () => {
+ expect(
+ shouldApplyDepositState({
+ storedStatus: null,
+ storedAt: null,
+ incomingStatus: 'queued',
+ incomingAt: at('2026-08-12T09:00:00Z'),
+ }),
+ ).toBe(true);
+ expect(
+ shouldApplyDepositState({
+ storedStatus: 'submitted',
+ storedAt: at('2026-08-12T10:00:00Z'),
+ incomingStatus: 'queued',
+ incomingAt: at('2026-08-12T09:00:00Z'),
+ }),
+ ).toBe(true);
+ });
+
+ it('is idempotent: the same assertion twice still applies', () => {
+ // At-least-once delivery means the same deliveryId can arrive twice, and
+ // re-applying identical state has to be a harmless no-op write.
+ const options = {
+ storedStatus: 'registered',
+ storedAt: at('2026-08-12T10:00:00Z'),
+ incomingStatus: 'registered',
+ incomingAt: at('2026-08-12T10:00:00Z'),
+ };
+ expect(shouldApplyDepositState(options)).toBe(true);
+ expect(shouldApplyDepositState(options)).toBe(true);
+ });
+
+ it('applies when either side has no timestamp to compare', () => {
+ expect(
+ shouldApplyDepositState({
+ storedStatus: 'registered',
+ storedAt: null,
+ incomingStatus: 'queued',
+ incomingAt: at('2026-08-12T09:00:00Z'),
+ }),
+ ).toBe(true);
+ expect(
+ shouldApplyDepositState({
+ storedStatus: 'registered',
+ storedAt: at('2026-08-12T10:00:00Z'),
+ incomingStatus: 'queued',
+ incomingAt: null,
+ }),
+ ).toBe(true);
+ });
+});
diff --git a/server/doily/client.ts b/server/doily/client.ts
new file mode 100644
index 0000000000..ab8c4efba1
--- /dev/null
+++ b/server/doily/client.ts
@@ -0,0 +1,214 @@
+import { env } from 'server/env';
+import { getFeatureFlagForUserAndCommunity } from 'server/featureFlag/queries';
+import { Community } from 'server/models';
+
+export const DOILY_FLAG = 'doilyDeposits';
+
+export const isDoilyConfigured = () => Boolean(env.DOILY_URL && env.DOILY_API_TOKEN);
+
+export const isDoilyEnabledForCommunity = async (communityId: string) => {
+ if (!isDoilyConfigured()) {
+ return false;
+ }
+ try {
+ return await getFeatureFlagForUserAndCommunity(null, communityId, DOILY_FLAG);
+ } catch {
+ // The flag row hasn't been created in this environment yet.
+ return false;
+ }
+};
+
+const doilyFetch = async (path: string, init?: RequestInit) => {
+ const res = await fetch(`${env.DOILY_URL}${path}`, {
+ ...init,
+ headers: {
+ Authorization: `Bearer ${env.DOILY_API_TOKEN}`,
+ 'Content-Type': 'application/json',
+ ...init?.headers,
+ },
+ });
+ return res;
+};
+
+/** Thrown when Doily rejects the record type — the legacy path handles it. */
+export class DoilyUnsupportedRecordError extends Error {
+ constructor(public reason: string) {
+ super(`Doily does not handle this record type (${reason}); using the legacy path`);
+ }
+}
+
+const orgIdByCommunityId = new Map();
+
+/**
+ * The Doily organization for this community, or null if there is not one yet.
+ * Never provisions: a read-only caller (the status backfill) has to be able to
+ * ask the question without creating an organization as a side effect.
+ */
+export const findDoilyOrgId = async (communityId: string): Promise => {
+ const cached = orgIdByCommunityId.get(communityId);
+ if (cached) {
+ return cached;
+ }
+
+ const community = await Community.findOne({
+ where: { id: communityId },
+ attributes: ['id', 'subdomain'],
+ });
+ if (!community) {
+ throw new Error(`Community ${communityId} not found`);
+ }
+
+ const listRes = await doilyFetch('/v1/organizations');
+ if (!listRes.ok) {
+ throw new Error(`Doily organization lookup failed (${listRes.status})`);
+ }
+ const orgs = (await listRes.json()) as { id: string; slug: string }[];
+ const existing = orgs.find((org) => org.slug === community.subdomain);
+ if (!existing) {
+ return null;
+ }
+ orgIdByCommunityId.set(communityId, existing.id);
+ return existing.id;
+};
+
+/**
+ * One Doily organization per community (slug = subdomain). Provisioning
+ * requires the community to be linked to a KF Auth org — Doily enforces
+ * kfOrgId on every organization.
+ */
+export const resolveDoilyOrgId = async (communityId: string): Promise => {
+ const existing = await findDoilyOrgId(communityId);
+ if (existing) {
+ return existing;
+ }
+
+ const community = await Community.findOne({
+ where: { id: communityId },
+ attributes: ['id', 'subdomain', 'title', 'kfOrgId'],
+ });
+ if (!community) {
+ throw new Error(`Community ${communityId} not found`);
+ }
+
+ if (!community.kfOrgId) {
+ throw new Error(
+ `Community "${community.subdomain}" has no KF Auth org — link it before enabling ${DOILY_FLAG}`,
+ );
+ }
+ const createRes = await doilyFetch('/v1/organizations', {
+ method: 'POST',
+ body: JSON.stringify({
+ name: community.title,
+ slug: community.subdomain,
+ kfOrgId: community.kfOrgId,
+ }),
+ });
+ if (!createRes.ok) {
+ throw new Error(`Doily organization provisioning failed (${createRes.status})`);
+ }
+ const created = (await createRes.json()) as { id: string };
+ orgIdByCommunityId.set(communityId, created.id);
+ return created.id;
+};
+
+export type DoilyRecordResult = {
+ doi: string;
+ kind: string;
+ depositId: string;
+ status: string;
+ batchId: string | null;
+ action: 'created' | 'updated' | 'unchanged';
+ submitted: boolean;
+ blockers?: { code: string; message: string }[];
+};
+
+/**
+ * The record a deposit's state should be read from. A PubPub deposit can create
+ * several Doily records at once (a pub plus the collection it appears in), and
+ * only the one carrying the DOI we asked for describes this work. The trailing
+ * fallback covers Doily returning a single record whose DOI it normalized.
+ */
+export const findPrimaryDoilyRecord = (
+ records: DoilyRecordResult[],
+ primaryDoi: string,
+): DoilyRecordResult | undefined =>
+ records.find((record) => record.doi === primaryDoi) ?? records[records.length - 1];
+
+export type DoilyDepositSummary = {
+ id: string;
+ doi: string | null;
+ status: string;
+ error: string | null;
+ batchId: string | null;
+ registeredAt: string | null;
+ updatedAt: string;
+};
+
+/**
+ * One page of an organization's deposits. Used by the status backfill, which
+ * pages through every deposit Doily holds for a community and matches on DOI,
+ * the only join available for rows deposited before PubPub recorded Doily's
+ * deposit id.
+ */
+export const listDoilyDeposits = async (options: {
+ organizationId: string;
+ limit: number;
+ offset: number;
+}): Promise<{ items: DoilyDepositSummary[]; total: number }> => {
+ const { organizationId, limit, offset } = options;
+ const query = new URLSearchParams({
+ organizationId,
+ limit: String(limit),
+ offset: String(offset),
+ });
+ const res = await doilyFetch(`/v1/deposits?${query.toString()}`);
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`Doily deposit listing failed (${res.status}): ${body.slice(0, 500)}`);
+ }
+ return (await res.json()) as { items: DoilyDepositSummary[]; total: number };
+};
+
+/**
+ * Send a PubPub deposit JSON to Doily for mapping + submission. Throws on
+ * hard blockers or transport failures (never silently falls back — see the
+ * timestamp note above); throws DoilyUnsupportedRecordError for 422s.
+ */
+export const submitDepositViaDoily = async (options: {
+ communityId: string;
+ depositJson: unknown;
+ primaryDoi: string;
+}): Promise<{ organizationId: string; records: DoilyRecordResult[] }> => {
+ const { communityId, depositJson, primaryDoi } = options;
+ const organizationId = await resolveDoilyOrgId(communityId);
+
+ const res = await doilyFetch('/v1/pubpub/deposits', {
+ method: 'POST',
+ body: JSON.stringify({ organizationId, depositJson, primaryDoi }),
+ });
+
+ if (res.status === 400) {
+ const body = (await res.json()) as { reason?: string };
+ throw new Error(`Doily deposit failed (${res.status}): ${body ?? 'unknown reason'}`);
+ }
+
+ if (res.status === 422) {
+ const body = (await res.json()) as { reason?: string };
+ throw new DoilyUnsupportedRecordError(body.reason ?? 'unsupported');
+ }
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`Doily deposit failed (${res.status}): ${body.slice(0, 500)}`);
+ }
+
+ const { records } = (await res.json()) as { records: DoilyRecordResult[] };
+ const primary = findPrimaryDoilyRecord(records, primaryDoi);
+ if (primary && !primary.submitted && primary.action !== 'unchanged') {
+ const messages = (primary.blockers ?? []).map((blocker) => blocker.message).join('; ');
+ throw new Error(
+ `Doily blocked the deposit of ${primary.doi}: ${messages || 'unknown reason'}`,
+ );
+ }
+
+ return { organizationId, records };
+};
diff --git a/server/doily/webhook.ts b/server/doily/webhook.ts
new file mode 100644
index 0000000000..239e444cd6
--- /dev/null
+++ b/server/doily/webhook.ts
@@ -0,0 +1,210 @@
+import type { Request, Response } from 'express';
+
+import express, { Router } from 'express';
+import { Op } from 'sequelize';
+
+import { env } from 'server/env';
+import { Collection, CrossrefDepositRecord, Pub } from 'server/models';
+import { wrap } from 'server/wrap';
+import { type DepositStatus, isDepositStatus } from 'utils/crossref/depositStatus';
+
+import {
+ asDate,
+ asString,
+ DELIVERY_HEADER,
+ type DoilyWebhookPayload,
+ EVENT_HEADER,
+ isTestPayload,
+ SIGNATURE_HEADER,
+ shouldApplyDepositState,
+ verifyDoilySignature,
+} from './webhookContract';
+
+/**
+ * Inbound half of the Doily webhook contract (see ./webhookContract.ts for the
+ * headers, the signature and the ordering rule). Doily POSTs one of
+ * deposit.registered / deposit.failed / deposit.submitted whenever a deposit
+ * changes state at the registrar, which is how PubPub finally learns that a DOI
+ * Crossref accepted on the wire was rejected hours later.
+ *
+ * Two properties of the sender shape everything here:
+ *
+ * 1. The HMAC covers the exact bytes Doily serialized. Re-serializing a parsed
+ * body cannot reproduce them (key order and whitespace differ), so this route
+ * takes its body as a Buffer and is mounted ahead of the global
+ * express.json() in server/server.ts.
+ * 2. Delivery is at least once, and can arrive out of order. So a payload is
+ * treated as a state assertion, never a command: applying it twice writes the
+ * same columns twice, and an assertion older than what we already know is
+ * dropped.
+ */
+
+export const DOILY_WEBHOOK_PATH = '/api/doily/webhook';
+
+const log = (message: string) => console.log(`[doily-webhook] ${message}`);
+const warn = (message: string) => console.warn(`[doily-webhook] ${message}`);
+
+/**
+ * Find the deposit record this payload is about. By Doily's deposit id first,
+ * since that is exact. The DOI fallbacks matter for every deposit made before
+ * this feature existed: those rows have no doilyDepositId and no doi of their
+ * own, and the only thing tying them to a DOI is the Pub or Collection that
+ * points at them. Matching is exact rather than case-insensitive on purpose: the
+ * DOI in the payload is the one PubPub itself sent to Doily.
+ */
+const findDepositRecord = async (options: { depositId: string | null; doi: string | null }) => {
+ const { depositId, doi } = options;
+ if (depositId) {
+ const byDepositId = await CrossrefDepositRecord.findOne({
+ where: { doilyDepositId: depositId },
+ });
+ if (byDepositId) {
+ return byDepositId;
+ }
+ }
+ if (!doi) {
+ return null;
+ }
+ const byDoi = await CrossrefDepositRecord.findOne({
+ where: { doi },
+ order: [['updatedAt', 'DESC']],
+ });
+ if (byDoi) {
+ return byDoi;
+ }
+ const [pub, collection] = await Promise.all([
+ Pub.findOne({
+ where: { doi, crossrefDepositRecordId: { [Op.not]: null } },
+ attributes: ['id', 'crossrefDepositRecordId'],
+ }),
+ Collection.findOne({
+ where: { doi, crossrefDepositRecordId: { [Op.not]: null } },
+ attributes: ['id', 'crossrefDepositRecordId'],
+ }),
+ ]);
+ const recordId = pub?.crossrefDepositRecordId ?? collection?.crossrefDepositRecordId;
+ return recordId ? CrossrefDepositRecord.findOne({ where: { id: recordId } }) : null;
+};
+
+const handleDoilyWebhook = async (req: Request, res: Response) => {
+ const secret = env.DOILY_WEBHOOK_SECRET;
+ if (!secret) {
+ // 500 rather than a quiet 200: the secret comes from config, so Doily's
+ // retries over the next day are exactly the window an operator has to fix
+ // it without the event being lost.
+ warn('DOILY_WEBHOOK_SECRET is not configured, refusing the delivery');
+ return res.status(500).json({ error: 'Webhook secret is not configured' });
+ }
+
+ const rawBody = req.body;
+ if (!Buffer.isBuffer(rawBody)) {
+ // Something parsed the body before this handler ran, so the bytes Doily
+ // signed are gone and every signature would fail. Say so instead of
+ // answering 401 to legitimate deliveries forever.
+ warn('body reached the handler already parsed, raw bytes are required to verify the HMAC');
+ return res.status(500).json({ error: 'Webhook body was parsed before verification' });
+ }
+
+ if (!verifyDoilySignature({ rawBody, header: req.header(SIGNATURE_HEADER), secret })) {
+ warn(`rejected a delivery with an invalid signature (${req.header(DELIVERY_HEADER)})`);
+ return res.status(401).json({ error: 'Invalid signature' });
+ }
+
+ let payload: DoilyWebhookPayload;
+ try {
+ payload = JSON.parse(rawBody.toString('utf8'));
+ } catch {
+ // Cannot happen for a body Doily serialized and signed, so if it does an
+ // operator wants it visible in Doily's delivery log rather than swallowed
+ // as a success.
+ warn('signature verified but the body is not JSON');
+ return res.status(400).json({ error: 'Malformed JSON body' });
+ }
+
+ const deposit = payload.deposit ?? {};
+ const depositId = asString(deposit.id);
+ const doi = asString(deposit.doi);
+ const deliveryId = asString(payload.deliveryId) ?? req.header(DELIVERY_HEADER) ?? 'unknown';
+ const event = asString(payload.event) ?? req.header(EVENT_HEADER) ?? 'unknown';
+
+ if (isTestPayload(payload)) {
+ // The console's test button. 2xx so it goes green, and not a single write:
+ // its deposit is fabricated, and acting on it would let a button in
+ // Doily's UI declare a queued DOI registered.
+ log(`ignored test delivery ${deliveryId} (${event})`);
+ return res.status(200).json({ ok: true, ignored: 'test' });
+ }
+
+ if (!isDepositStatus(deposit.status)) {
+ // A status this build does not know about. Keeping the state we have and
+ // logging beats writing a word the display rule cannot reason about.
+ warn(`ignored delivery ${deliveryId} (${event}) with unknown status ${deposit.status}`);
+ return res.status(200).json({ ok: true, ignored: 'unknown-status' });
+ }
+ const status: DepositStatus = deposit.status;
+
+ const record = await findDepositRecord({ depositId, doi });
+ if (!record) {
+ // 2xx on purpose. No deposit record is going to appear because Doily asked
+ // eight more times over the next day, and an unmatched delivery is the
+ // normal shape of a deposit made straight through Doily, or of one whose
+ // pub has since been deleted.
+ warn(
+ `no deposit record for ${event} delivery ${deliveryId} (deposit=${depositId} doi=${doi})`,
+ );
+ return res.status(200).json({ ok: true, matched: false });
+ }
+
+ const incomingAt = asDate(payload.timestamp);
+ if (
+ !shouldApplyDepositState({
+ storedStatus: record.status,
+ storedAt: record.lastCheckedAt,
+ incomingStatus: status,
+ incomingAt,
+ })
+ ) {
+ log(
+ `dropped stale ${event} delivery ${deliveryId}: ${status} at ${incomingAt?.toISOString()} is older than ${record.status} at ${record.lastCheckedAt?.toISOString()}`,
+ );
+ return res.status(200).json({ ok: true, applied: false });
+ }
+
+ await record.update({
+ status,
+ // Stamped on every applied delivery, so a row matched by DOI has the exact
+ // handle next time.
+ doilyDepositId: depositId ?? record.doilyDepositId,
+ doi: doi ?? record.doi,
+ // Written unconditionally, including as null: a deposit that has just
+ // registered must not keep displaying the error text of the attempt before
+ // it.
+ error: asString(deposit.error),
+ // The moment the state was true, not the moment we heard about it, so a
+ // retry that arrives an hour late cannot look like fresher news than the
+ // verdict that overtook it.
+ lastCheckedAt: incomingAt ?? new Date(),
+ // Never cleared once set. Doily only sets firstRegisteredAt on the first
+ // successful registration and re-registrations leave it alone, but a
+ // coalesce here is what guarantees that a delivery which happens to omit
+ // it cannot demote a live DOI to unpublishable.
+ firstRegisteredAt: asDate(deposit.firstRegisteredAt) ?? record.firstRegisteredAt,
+ });
+
+ log(`applied ${event} delivery ${deliveryId}: ${record.id} is now ${status}`);
+ return res.status(200).json({ ok: true, applied: true });
+};
+
+/**
+ * Mounted in server/server.ts BEFORE the global express.json(), which is the
+ * whole reason this is its own router: express.raw() has to be the first parser
+ * to see this request or the signed bytes are lost. The raw parser accepts any
+ * content type because the route serves exactly one producer, and refusing to
+ * read the body over a charset-annotated or proxy-rewritten Content-Type would
+ * turn every delivery into a 500.
+ */
+export const doilyWebhookRouter = Router().post(
+ DOILY_WEBHOOK_PATH,
+ express.raw({ type: '*/*', limit: '1mb' }),
+ wrap(handleDoilyWebhook),
+);
diff --git a/server/doily/webhookContract.ts b/server/doily/webhookContract.ts
new file mode 100644
index 0000000000..b894a85c89
--- /dev/null
+++ b/server/doily/webhookContract.ts
@@ -0,0 +1,130 @@
+import { createHmac, timingSafeEqual } from 'crypto';
+
+import { getDoiDisplay } from 'utils/crossref/depositStatus';
+
+/**
+ * The Doily webhook contract, with nothing in it that touches the database or
+ * express. Kept separate from webhook.ts so the two rules that are easy to get
+ * wrong (HMAC over the raw bytes, and what to do with an out-of-order delivery)
+ * can be exercised on their own, without a Postgres connection.
+ *
+ * POST /api/doily/webhook
+ * X-Doily-Event: deposit.registered | deposit.failed | deposit.submitted
+ * X-Doily-Delivery:
+ * X-Doily-Signature: sha256=
+ */
+
+export const SIGNATURE_HEADER = 'x-doily-signature';
+export const EVENT_HEADER = 'x-doily-event';
+export const DELIVERY_HEADER = 'x-doily-delivery';
+
+/**
+ * The DOI Doily's console "send test event" button fabricates. Both this and the
+ * payload's `test: true` are checked: a button in another app's UI must never be
+ * able to tell PubPub that a real DOI is registered, and 10.0000/ is a reserved
+ * prefix that cannot belong to a real deposit.
+ */
+export const DOILY_TEST_DOI = '10.0000/doily.test';
+
+/**
+ * `sha256=`, keyed with the subscription secret as
+ * the UTF-8 string it was issued as (`whsec_…`, not decoded, not trimmed), over
+ * the exact bytes of the request body.
+ */
+export const signDoilyBody = (secret: string, rawBody: Buffer | string): string =>
+ `sha256=${createHmac('sha256', secret).update(rawBody).digest('hex')}`;
+
+export const verifyDoilySignature = (options: {
+ rawBody: Buffer | string;
+ header: string | undefined;
+ secret: string;
+}): boolean => {
+ const { rawBody, header, secret } = options;
+ if (!header) {
+ return false;
+ }
+ const expected = Buffer.from(signDoilyBody(secret, rawBody), 'utf8');
+ const received = Buffer.from(header, 'utf8');
+ // timingSafeEqual throws when the lengths differ, and they differ for
+ // anything that is not a well-formed `sha256=` + 64 hex chars header. The
+ // length check keeps that throw out of the request path. It is not a
+ // shortcut around the constant-time compare.
+ if (expected.length !== received.length) {
+ return false;
+ }
+ return timingSafeEqual(received, expected);
+};
+
+/** The DepositSummary fields this consumer reads. Doily sends more; we ignore it. */
+export type DoilyWebhookDeposit = {
+ id?: unknown;
+ doi?: unknown;
+ status?: unknown;
+ error?: unknown;
+ /**
+ * Set once, on the DOI's first successful registration, and never cleared.
+ * This is what separates "the record does not exist" from "the record exists
+ * and an update to it was rejected", which `status` alone cannot express.
+ */
+ firstRegisteredAt?: unknown;
+};
+
+export type DoilyWebhookPayload = {
+ event?: unknown;
+ timestamp?: unknown;
+ deliveryId?: unknown;
+ test?: unknown;
+ deposit?: DoilyWebhookDeposit;
+};
+
+export const asString = (value: unknown): string | null =>
+ typeof value === 'string' && value.length > 0 ? value : null;
+
+export const asDate = (value: unknown): Date | null => {
+ const raw = asString(value);
+ if (!raw) {
+ return null;
+ }
+ const date = new Date(raw);
+ return Number.isNaN(date.getTime()) ? null : date;
+};
+
+/** Is this payload the console's test event rather than a real transition? */
+export const isTestPayload = (payload: DoilyWebhookPayload): boolean =>
+ payload.test === true || asString(payload.deposit?.doi) === DOILY_TEST_DOI;
+
+/**
+ * Whether an incoming assertion should overwrite what the row already says.
+ *
+ * The failure mode this exists for: a `deposit.submitted` whose first attempt hit
+ * a 502 is retried on Doily's backoff, so it can land *after* the
+ * `deposit.registered` that followed it. Applied in arrival order that would move
+ * a registered DOI back to "pending", hiding it from the pub page, the citations
+ * and the Scholar meta tags until the next backfill run.
+ *
+ * The guard is deliberately narrow: only a *pending* assertion older than an
+ * already-*settled* state is refused. A settled verdict (registered, unverified,
+ * failed) is always applied, whatever the timestamps say, because `lastCheckedAt`
+ * may have been written off PubPub's clock while `timestamp` comes from Doily's,
+ * and losing a registration to clock skew is the exact bug this whole feature
+ * exists to prevent.
+ */
+export const shouldApplyDepositState = (options: {
+ storedStatus: string | null | undefined;
+ storedAt: Date | null | undefined;
+ incomingStatus: string;
+ incomingAt: Date | null;
+}): boolean => {
+ const { storedStatus, storedAt, incomingStatus, incomingAt } = options;
+ if (getDoiDisplay(incomingStatus) !== 'pending') {
+ return true;
+ }
+ const storedDisplay = getDoiDisplay(storedStatus);
+ if (storedDisplay === 'legacy' || storedDisplay === 'pending') {
+ return true;
+ }
+ if (!storedAt || !incomingAt) {
+ return true;
+ }
+ return incomingAt.getTime() >= storedAt.getTime();
+};
diff --git a/server/envSchema.ts b/server/envSchema.ts
index 60f0779723..17c5ab0035 100644
--- a/server/envSchema.ts
+++ b/server/envSchema.ts
@@ -131,6 +131,23 @@ export const envSchema = z.object({
// ── DataCite ─────────────────────────────────────────────────────────
DATACITE_DEPOSIT_URL: z.string().url().describe('DataCite DOI deposit endpoint URL'),
+ // ── Doily (Crossref deposit broker) ─────────────────────────────────
+ DOILY_URL: z
+ .string()
+ .url()
+ .optional()
+ .describe('Doily API base URL — with DOILY_API_TOKEN, enables the doilyDeposits flag path'),
+ DOILY_API_TOKEN: z
+ .string()
+ .optional()
+ .describe('Doily app token (doily_sk_…) for the pubpub app'),
+ DOILY_WEBHOOK_SECRET: z
+ .string()
+ .optional()
+ .describe(
+ 'Doily webhook subscription secret (whsec_…), the HMAC key for POST /api/doily/webhook',
+ ),
+
// ── Message Queues ──────────────────────────────────────────────────
CLOUDAMQP_URL: z.string().describe('CloudAMQP (RabbitMQ) connection URL'),
diff --git a/server/routes/pubDocument.tsx b/server/routes/pubDocument.tsx
index e0a6ffcd8e..f45287b811 100644
--- a/server/routes/pubDocument.tsx
+++ b/server/routes/pubDocument.tsx
@@ -70,6 +70,8 @@ const renderPubDocument = (
contextTitle: getPubPageContextTitle(pubData, initialData.communityData),
description: pubData.description,
doi: pubData.doi,
+ depositStatus: pubData.crossrefDepositStatus,
+ depositEverRegistered: pubData.crossrefDepositEverRegistered,
pdfDownloadUrl: getPdfDownloadUrl(initialData.communityData, pubData) || '',
image: pubData.avatar,
initialData,
diff --git a/server/routes/superAdminDashboard.tsx b/server/routes/superAdminDashboard.tsx
index 73fc62b496..35200944ef 100644
--- a/server/routes/superAdminDashboard.tsx
+++ b/server/routes/superAdminDashboard.tsx
@@ -22,11 +22,22 @@ import {
import { getAllTemplates } from 'server/communityTemplate/queries';
import { env } from 'server/env';
import { getExploreCommunities } from 'server/exploreFeatured/queries';
+import { createFeatureFlag } from 'server/featureFlag/queries';
+import { setFeatureFlagOverrideForCommunity } from 'server/featureFlagCommunity/queries';
+import { setFeatureFlagOverrideForUser } from 'server/featureFlagUser/queries';
import Html from 'server/Html';
// NOTE: Suggested Hubs SSR returns an empty shell; summaries are fetched client-side on mount.
import { getAllHubsWithCommunityCounts } from 'server/hub/queries';
import { getLandingPageFeatures } from 'server/landingPageFeature/queries';
-import { Community, DepositTarget, FtpTarget } from 'server/models';
+import {
+ Community,
+ DepositTarget,
+ FeatureFlag,
+ FeatureFlagCommunity,
+ FeatureFlagUser,
+ FtpTarget,
+ User,
+} from 'server/models';
import { queryCommunitiesForSpamManagement } from 'server/spamTag/communityDashboard';
import { queryUsersForSpamManagement } from 'server/spamTag/userDashboard';
import {
@@ -73,7 +84,48 @@ const parseSpamFieldFilterParam = (
return values;
};
+const sanitizeFeatureFlag = (flag: FeatureFlag) => ({
+ id: flag.id,
+ name: flag.name,
+ enabledUsersFraction: flag.enabledUsersFraction ?? 0,
+ enabledCommunitiesFraction: flag.enabledCommunitiesFraction ?? 0,
+ overrides: {
+ communitiesOn: (flag.communities ?? []).filter((c) => c.enabled).length,
+ communitiesOff: (flag.communities ?? []).filter((c) => c.enabled === false).length,
+ usersOn: (flag.users ?? []).filter((u) => u.enabled).length,
+ usersOff: (flag.users ?? []).filter((u) => u.enabled === false).length,
+ },
+});
+
+const featureFlagIncludes = [
+ { model: FeatureFlagUser, as: 'users', attributes: ['id', 'enabled'] },
+ { model: FeatureFlagCommunity, as: 'communities', attributes: ['id', 'enabled'] },
+];
+
+const getFeatureFlagWithCounts = async (id: string) => {
+ const flag = await FeatureFlag.findByPk(id, { include: featureFlagIncludes });
+ if (!flag) {
+ throw new NotFoundError(new Error('Feature flag not found'));
+ }
+ return flag;
+};
+
const getTabProps = async (tabKind: SuperAdminTabKind, locationData: types.LocationData) => {
+ if (tabKind === 'featureFlags') {
+ const [flags, totalCommunities, totalUsers] = await Promise.all([
+ FeatureFlag.findAll({
+ include: featureFlagIncludes,
+ order: [['name', 'ASC']],
+ }),
+ Community.count(),
+ User.count(),
+ ]);
+ return {
+ featureFlags: flags.map(sanitizeFeatureFlag),
+ totalCommunities,
+ totalUsers,
+ };
+ }
if (tabKind === 'customDomains') {
const communities = await Community.findAll({
where: { domain: { [Op.ne]: null } },
@@ -830,6 +882,198 @@ router.post('/api/superadmin/ftp-targets/:id/copy', async (req, res, next) => {
}
});
+// ── Feature Flags API ──────────────────────────────────────────────────────
+
+const assertSuperAdmin = async (req: any) => {
+ const initialData = await getInitialData(req);
+ if (!initialData.loginData.isSuperAdmin) {
+ throw new ForbiddenError();
+ }
+};
+
+const featureFlagNamePattern = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
+
+const parseOverrideState = (state: any): types.FeatureFlagOverrideState => {
+ if (state !== 'on' && state !== 'off' && state !== 'inert') {
+ throw new BadRequestError(new Error('state must be "on", "off", or "inert"'));
+ }
+ return state;
+};
+
+const parseFraction = (value: any, label: string) => {
+ const fraction = Number(value);
+ if (Number.isNaN(fraction) || fraction < 0 || fraction > 1) {
+ throw new BadRequestError(new Error(`${label} must be a number between 0 and 1`));
+ }
+ return fraction;
+};
+
+router.post('/api/superadmin/feature-flags', async (req, res, next) => {
+ try {
+ await assertSuperAdmin(req);
+ const name = String(req.body.name ?? '').trim();
+ if (!featureFlagNamePattern.test(name)) {
+ throw new BadRequestError(
+ new Error(
+ 'Flag names must start with a letter and contain only letters, numbers, hyphens, and underscores (e.g. newActivityDash)',
+ ),
+ );
+ }
+ const existing = await FeatureFlag.findOne({ where: { name } });
+ if (existing) {
+ throw new BadRequestError(new Error(`A feature flag named "${name}" already exists`));
+ }
+ const flag = await createFeatureFlag(name);
+ return res.status(201).json(sanitizeFeatureFlag(await getFeatureFlagWithCounts(flag.id)));
+ } catch (err) {
+ return handleErrors(req, res, next)(err);
+ }
+});
+
+router.put('/api/superadmin/feature-flags/:id', async (req, res, next) => {
+ try {
+ await assertSuperAdmin(req);
+ const flag = await getFeatureFlagWithCounts(req.params.id);
+ const { enabledUsersFraction, enabledCommunitiesFraction } = req.body;
+ if (enabledUsersFraction !== undefined) {
+ flag.enabledUsersFraction = parseFraction(enabledUsersFraction, 'enabledUsersFraction');
+ }
+ if (enabledCommunitiesFraction !== undefined) {
+ flag.enabledCommunitiesFraction = parseFraction(
+ enabledCommunitiesFraction,
+ 'enabledCommunitiesFraction',
+ );
+ }
+ await flag.save();
+ return res.json(sanitizeFeatureFlag(flag));
+ } catch (err) {
+ return handleErrors(req, res, next)(err);
+ }
+});
+
+router.delete('/api/superadmin/feature-flags/:id', async (req, res, next) => {
+ try {
+ await assertSuperAdmin(req);
+ const flag = await FeatureFlag.findByPk(req.params.id);
+ if (!flag) {
+ throw new NotFoundError(new Error('Feature flag not found'));
+ }
+ await Promise.all([
+ FeatureFlagCommunity.destroy({ where: { featureFlagId: flag.id } }),
+ FeatureFlagUser.destroy({ where: { featureFlagId: flag.id } }),
+ ]);
+ await flag.destroy();
+ return res.json({ id: req.params.id });
+ } catch (err) {
+ return handleErrors(req, res, next)(err);
+ }
+});
+
+router.get('/api/superadmin/feature-flags/:id/overrides', async (req, res, next) => {
+ try {
+ await assertSuperAdmin(req);
+ const flag = await FeatureFlag.findByPk(req.params.id);
+ if (!flag) {
+ throw new NotFoundError(new Error('Feature flag not found'));
+ }
+ const [communityOverrides, userOverrides] = await Promise.all([
+ FeatureFlagCommunity.findAll({
+ where: { featureFlagId: flag.id },
+ include: [
+ { model: Community, as: 'community', attributes: ['id', 'title', 'subdomain'] },
+ ],
+ }),
+ FeatureFlagUser.findAll({
+ where: { featureFlagId: flag.id },
+ include: [
+ {
+ model: User,
+ as: 'user',
+ attributes: ['id', 'fullName', 'slug', 'avatar', 'initials'],
+ },
+ ],
+ }),
+ ]);
+ return res.json({
+ communities: communityOverrides
+ .map((o) => ({
+ communityId: o.communityId,
+ enabled: Boolean(o.enabled),
+ title: o.community?.title ?? '(unknown)',
+ subdomain: o.community?.subdomain ?? '',
+ }))
+ .sort((a, b) => a.title.localeCompare(b.title)),
+ users: userOverrides
+ .map((o) => ({
+ userId: o.userId,
+ enabled: Boolean(o.enabled),
+ fullName: o.user?.fullName ?? '(unknown)',
+ slug: o.user?.slug ?? '',
+ avatar: o.user?.avatar ?? null,
+ initials: o.user?.initials ?? '?',
+ }))
+ .sort((a, b) => a.fullName.localeCompare(b.fullName)),
+ });
+ } catch (err) {
+ return handleErrors(req, res, next)(err);
+ }
+});
+
+router.put('/api/superadmin/feature-flags/:id/community-override', async (req, res, next) => {
+ try {
+ await assertSuperAdmin(req);
+ const flag = await FeatureFlag.findByPk(req.params.id);
+ if (!flag) {
+ throw new NotFoundError(new Error('Feature flag not found'));
+ }
+ const state = parseOverrideState(req.body.state);
+ if (!req.body.communityId) {
+ throw new BadRequestError(new Error('communityId is required'));
+ }
+ const community = await resolveCommunity(req.body.communityId);
+ await setFeatureFlagOverrideForCommunity(flag.id, community.id, state);
+ return res.json({
+ communityId: community.id,
+ title: community.title,
+ subdomain: community.subdomain,
+ state,
+ });
+ } catch (err) {
+ return handleErrors(req, res, next)(err);
+ }
+});
+
+router.put('/api/superadmin/feature-flags/:id/user-override', async (req, res, next) => {
+ try {
+ await assertSuperAdmin(req);
+ const flag = await FeatureFlag.findByPk(req.params.id);
+ if (!flag) {
+ throw new NotFoundError(new Error('Feature flag not found'));
+ }
+ const state = parseOverrideState(req.body.state);
+ if (!req.body.userId) {
+ throw new BadRequestError(new Error('userId is required'));
+ }
+ const user = await User.findByPk(String(req.body.userId).trim(), {
+ attributes: ['id', 'fullName', 'slug', 'avatar', 'initials'],
+ });
+ if (!user) {
+ throw new NotFoundError(new Error('User not found'));
+ }
+ await setFeatureFlagOverrideForUser(flag.id, user.id, state);
+ return res.json({
+ userId: user.id,
+ fullName: user.fullName,
+ slug: user.slug,
+ avatar: user.avatar,
+ initials: user.initials,
+ state,
+ });
+ } catch (err) {
+ return handleErrors(req, res, next)(err);
+ }
+});
+
// JSON API for lazy-loading suggested-hubs sidebar summaries
router.get('/api/superadmin/suggested-hubs-summaries', async (req, res, next) => {
try {
diff --git a/server/server.ts b/server/server.ts
index 079bed4f2f..227449ff1e 100755
--- a/server/server.ts
+++ b/server/server.ts
@@ -33,6 +33,7 @@ if (env.NODE_ENV !== 'test') {
require('server/utils/serverModuleOverwrite');
}
+import { doilyWebhookRouter } from './doily/webhook';
import { communityBanGuard } from './middleware/communityBanGuard';
import { deduplicateSlash } from './middleware/deduplicateSlash';
import { kfSessionCheckMiddleware } from './middleware/kfSessionCheck';
@@ -109,6 +110,16 @@ if (env.NODE_ENV === 'production') {
}
appRouter.use(deduplicateSlash());
appRouter.use(noSlash());
+
+// ACHTUNG: this must stay ABOVE express.json(). Doily signs the exact bytes of
+// the webhook body, and once body-parser has turned them into an object the
+// signed bytes are unrecoverable: re-serializing produces different key order
+// and whitespace, so every delivery would fail verification. The router mounts
+// express.raw() on its one path, so nothing else is affected. It also lands
+// ahead of session/passport deliberately: the HMAC is the authentication, there
+// is no cookie or user involved.
+appRouter.use(doilyWebhookRouter);
+
appRouter.use(express.json({ limit: '50mb' }));
appRouter.use(express.urlencoded({ limit: '50mb', extended: true }));
appRouter.use(cookieParser());
diff --git a/server/utils/citations/generateCitationHtml.ts b/server/utils/citations/generateCitationHtml.ts
index 123ffac70f..6306864618 100644
--- a/server/utils/citations/generateCitationHtml.ts
+++ b/server/utils/citations/generateCitationHtml.ts
@@ -6,6 +6,7 @@ import { type CitationStyleKind, renderJournalCitationForCitations } from 'utils
import getCollectionDoi from 'utils/collections/getCollectionDoi';
import { getPrimaryCollection } from 'utils/collections/primary';
import { getAllPubContributors } from 'utils/contributors';
+import { isDoiPublic } from 'utils/crossref/depositStatus';
import { getPubPublishedDate } from 'utils/pub/pubDates';
const getDatePartsObject = (date) => ({
@@ -103,11 +104,23 @@ export const generateCitationHtml = async (
'PubPub/6.0 (https://pubpub.org; mailto:dev@pubpub.org) Citation.js/0.7.11 Node.js/22.14.0',
);
+ // A citation is copied into someone else's bibliography and outlives the page
+ // it came from, so it may only carry a DOI that resolves. A deposit still in
+ // flight, or one Crossref rejected, is left out of the citation entirely (the
+ // pub URL still identifies the work) until the registrar confirms it. A NULL
+ // status is every pre-Doily deposit, and keeps citing as it always has.
+ const citableDoi = isDoiPublic(
+ pubData.crossrefDepositStatus,
+ pubData.crossrefDepositEverRegistered,
+ )
+ ? pubData.doi
+ : null;
+
const pubCiteObject = await Cite.async({
...commonData,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'containerDoi' does not exist on type '{ ... Remove this comment to see the full error message
- DOI: pubData.doi || commonData.containerDoi,
- ISSN: pubData.doi ? communityData.issn : null,
+ DOI: citableDoi || commonData.containerDoi,
+ ISSN: citableDoi ? communityData.issn : null,
issued: pubIssuedDate && [getDatePartsObject(pubIssuedDate)],
note: pubLink,
URL: pubLink,
diff --git a/server/utils/queryHelpers/pubSanitize.ts b/server/utils/queryHelpers/pubSanitize.ts
index c8e812590f..ef2f81876d 100644
--- a/server/utils/queryHelpers/pubSanitize.ts
+++ b/server/utils/queryHelpers/pubSanitize.ts
@@ -25,6 +25,40 @@ const filterDiscussionsByDraftOrRelease = (discussions: Discussion[], isRelease:
);
};
+/**
+ * The Crossref deposit record is internal operational data — the full registrar
+ * payload, and (once deposit state lands) registrar error text. It is eager-loaded
+ * unconditionally by pubOptions for every pub query, which means it was being
+ * serialized into __INITIAL_DATA__ on every PUBLIC pub page.
+ *
+ * Only the manage-level DOI UI needs it: AssignDoi and DataciteDeposit read
+ * depositJson via utils/crossref/parseDeposit. Everything else that cares about
+ * deposit existence uses the `crossrefDepositRecordId` scalar on the pub itself,
+ * which is unaffected.
+ */
+const sanitizeCrossrefDepositRecord = (pubData, activePermissions) =>
+ activePermissions.canManage ? pubData.crossrefDepositRecord : null;
+
+/**
+ * The one bit of deposit state that is not operational data: whether the DOI has
+ * actually been registered. Every public surface that prints the DOI needs it
+ * (the Scholar meta tags, the citation strings, the pub header), and those render
+ * for readers who will never have canManage, so it cannot come from the record
+ * the gate above nulls out. A status string on its own leaks nothing: the DOI it
+ * describes is already on the page.
+ */
+const sanitizeCrossrefDepositStatus = (pubData) => pubData.crossrefDepositRecord?.status ?? null;
+
+/**
+ * Whether this DOI has ever registered, as a boolean rather than the timestamp,
+ * because that is all any display rule needs. Required alongside the status for
+ * the same reason the status is: `status` is per attempt, so a rejected update to
+ * a live DOI reads 'failed', and a reader-facing surface keying off status alone
+ * would hide a DOI that doi.org still resolves.
+ */
+const sanitizeCrossrefDepositEverRegistered = (pubData) =>
+ Boolean(pubData.crossrefDepositRecord?.firstRegisteredAt);
+
const getFilteredExports = (pubData, isRelease) => {
const { exports, releases } = pubData;
if (!isRelease || !exports) {
@@ -101,6 +135,9 @@ export default (
...pubData,
...sanitizeHashes(pubData, activePermissions),
attributions: pubData.attributions.map(ensureUserForAttribution),
+ crossrefDepositRecord: sanitizeCrossrefDepositRecord(pubData, activePermissions),
+ crossrefDepositStatus: sanitizeCrossrefDepositStatus(pubData),
+ crossrefDepositEverRegistered: sanitizeCrossrefDepositEverRegistered(pubData),
draft: isRelease ? null : pubData.draft,
submission: isRelease ? null : pubData.submission,
discussions,
diff --git a/server/utils/ssr.tsx b/server/utils/ssr.tsx
index 3dcb38d0bb..c9bc8c4ea4 100644
--- a/server/utils/ssr.tsx
+++ b/server/utils/ssr.tsx
@@ -5,6 +5,7 @@ import React from 'react';
import * as ReactBeautifulDnD from 'react-beautiful-dnd';
import ReactDOMServer from 'react-dom/server';
+import { isDoiPublic } from 'utils/crossref/depositStatus';
import { isProd } from 'utils/environment';
export const renderToNodeStream = (res, reactElement) => {
@@ -22,6 +23,13 @@ type MetaProps = {
image?: string | null;
attributions?: Attribution[];
doi?: string | null;
+ /**
+ * Deposit status for `doi`, from the pub's crossrefDepositRecord. Undefined or
+ * null means no deposit state is recorded, which every pre-Doily DOI looks
+ * like and which must keep emitting the tags exactly as before.
+ */
+ depositStatus?: string | null;
+ depositEverRegistered?: boolean;
publishedAt?: null | Date;
unlisted?: boolean;
collection?: Collection;
@@ -57,6 +65,8 @@ export const generateMetaComponents = (metaProps: MetaProps) => {
image,
attributions,
doi,
+ depositStatus,
+ depositEverRegistered,
publishedAt,
unlisted,
collection,
@@ -351,7 +361,18 @@ export const generateMetaComponents = (metaProps: MetaProps) => {
];
}
- const finalDoi = doi || collection?.metadata?.doi;
+ // A DOI whose deposit is still in flight, or that Crossref rejected, does not
+ // resolve, and citation_doi is read by Google Scholar and by every reference
+ // manager, where an unresolvable DOI is worse than no DOI at all. When the pub
+ // has such a DOI the block is dropped entirely rather than falling through to
+ // the collection's DOI, which would advertise the collection's identifier as
+ // this pub's. isDoiPublic() is true for a NULL status, so nothing that renders
+ // today stops rendering.
+ const finalDoi = doi
+ ? isDoiPublic(depositStatus, depositEverRegistered)
+ ? doi
+ : null
+ : collection?.metadata?.doi;
if (finalDoi) {
outputComponents = [
diff --git a/tools/backfillDoilyDepositStatus.ts b/tools/backfillDoilyDepositStatus.ts
new file mode 100644
index 0000000000..c884137348
--- /dev/null
+++ b/tools/backfillDoilyDepositStatus.ts
@@ -0,0 +1,295 @@
+/**
+ * Teach every existing deposit record what actually happened to its DOI.
+ *
+ * Deposit state only started being recorded when Doily began pushing outcomes
+ * (server/doily/webhook.ts), so every row deposited before that has a NULL
+ * status. NULL renders exactly as it always did, which is the safe default and
+ * also a permanent one: without this run the dashboard keeps saying "deposited"
+ * about DOIs Crossref rejected. Doily already knows the answer for all of them,
+ * so this pages through its deposit list per organization and matches on DOI.
+ *
+ * Matching is by DOI because that is the only join available: a row deposited
+ * before this feature has no doilyDepositId. Rows it does match get the id
+ * stamped on, so from then on the webhook finds them directly.
+ *
+ * pnpm run tools backfillDoilyDepositStatus # dry run, every doily community
+ * pnpm run tools backfillDoilyDepositStatus --subdomain demo # dry run, one community
+ * pnpm run tools backfillDoilyDepositStatus --execute # write
+ *
+ * On prod use `tools-prod`, e.g.
+ * pnpm run tools-prod backfillDoilyDepositStatus --execute
+ */
+import type { DepositState } from 'server/crossrefDepositRecord/queries';
+
+import { Op } from 'sequelize';
+
+import {
+ DOILY_FLAG,
+ type DoilyDepositSummary,
+ findDoilyOrgId,
+ isDoilyConfigured,
+ listDoilyDeposits,
+} from 'server/doily/client';
+import { isFeatureFlagEnabledForUserInCommunity } from 'server/featureFlag/helpers';
+import { getFeatureFlagByName } from 'server/featureFlag/queries';
+import {
+ Collection,
+ Community,
+ CrossrefDepositRecord,
+ FeatureFlagCommunity,
+ Pub,
+} from 'server/models';
+import { isDepositStatus } from 'utils/crossref/depositStatus';
+
+const PAGE_SIZE = 200;
+const WRITE_CHUNK_SIZE = 25;
+
+const log = (message: string) => console.log(`[backfill-doily-status] ${message}`);
+const warn = (message: string) => console.warn(`[backfill-doily-status] WARN: ${message}`);
+
+const getArgValue = (flag: string) => {
+ const index = process.argv.indexOf(flag);
+ return index === -1 ? null : (process.argv[index + 1] ?? null);
+};
+
+const execute = process.argv.includes('--execute');
+
+/**
+ * Communities the flag is on for, evaluated with the same helper production
+ * uses: an explicit per-community override is the usual way it is enabled, but
+ * enabledCommunitiesFraction can switch it on without any override row, and a
+ * backfill that ignored that would silently skip those communities.
+ */
+const getDoilyCommunities = async (subdomain: string | null) => {
+ const featureFlag = await getFeatureFlagByName(DOILY_FLAG);
+ if (!featureFlag) {
+ warn(`no ${DOILY_FLAG} feature flag exists in this environment`);
+ return [];
+ }
+ const featureFlagCommunities = await FeatureFlagCommunity.findAll({
+ where: { featureFlagId: featureFlag.id },
+ });
+ const explicitlyOnIds = featureFlagCommunities
+ .filter((override) => override.enabled)
+ .map((override) => override.communityId)
+ .filter((id): id is string => Boolean(id));
+
+ const where: Record = {};
+ if (subdomain) {
+ where.subdomain = subdomain;
+ } else if (!featureFlag.enabledCommunitiesFraction) {
+ where.id = explicitlyOnIds;
+ }
+
+ const communities = await Community.findAll({
+ where,
+ attributes: ['id', 'subdomain', 'title'],
+ });
+ return communities.filter((community) =>
+ isFeatureFlagEnabledForUserInCommunity({
+ featureFlag,
+ userId: null,
+ communityId: community.id,
+ featureFlagUsers: [],
+ featureFlagCommunities,
+ }),
+ );
+};
+
+/** Every deposit Doily holds for an organization, keyed by lowercased DOI. */
+const fetchDepositsByDoi = async (organizationId: string) => {
+ const byDoi = new Map();
+ let offset = 0;
+ for (;;) {
+ // biome-ignore lint/performance/noAwaitInLoops: offset pagination is inherently sequential, the next offset depends on how many items this response returned
+ const page = await listDoilyDeposits({ organizationId, limit: PAGE_SIZE, offset });
+ for (const item of page.items) {
+ if (!item.doi) {
+ continue;
+ }
+ const key = item.doi.toLowerCase();
+ const seen = byDoi.get(key);
+ // A DOI can have been deposited more than once (an update, or a retry
+ // after a rejection). The most recently touched row is the one whose
+ // status is true now.
+ if (!seen || new Date(item.updatedAt) > new Date(seen.updatedAt)) {
+ byDoi.set(key, item);
+ }
+ }
+ offset += page.items.length;
+ if (page.items.length === 0 || offset >= page.total) {
+ return byDoi;
+ }
+ }
+};
+
+type Target = {
+ kind: 'pub' | 'collection';
+ id: string;
+ doi: string;
+ crossrefDepositRecordId: string;
+};
+
+const getDepositedTargets = async (communityId: string): Promise => {
+ const where = {
+ communityId,
+ doi: { [Op.not]: null },
+ crossrefDepositRecordId: { [Op.not]: null },
+ };
+ const [pubs, collections] = await Promise.all([
+ Pub.findAll({ where, attributes: ['id', 'doi', 'crossrefDepositRecordId'] }),
+ Collection.findAll({ where, attributes: ['id', 'doi', 'crossrefDepositRecordId'] }),
+ ]);
+ return [
+ ...pubs.map((pub) => ({
+ kind: 'pub' as const,
+ id: pub.id,
+ doi: pub.doi!,
+ crossrefDepositRecordId: pub.crossrefDepositRecordId!,
+ })),
+ ...collections.map((collection) => ({
+ kind: 'collection' as const,
+ id: collection.id,
+ doi: collection.doi!,
+ crossrefDepositRecordId: collection.crossrefDepositRecordId!,
+ })),
+ ];
+};
+
+const backfillCommunity = async (community: { id: string; subdomain: string }) => {
+ const counts = { matched: 0, updated: 0, unchanged: 0, unknownToDoily: 0, noRecord: 0 };
+
+ const organizationId = await findDoilyOrgId(community.id);
+ if (!organizationId) {
+ warn(`${community.subdomain} has no Doily organization yet, skipping`);
+ return counts;
+ }
+
+ const [depositsByDoi, targets] = await Promise.all([
+ fetchDepositsByDoi(organizationId),
+ getDepositedTargets(community.id),
+ ]);
+ log(
+ `${community.subdomain}: ${targets.length} deposited works, ${depositsByDoi.size} DOIs known to Doily`,
+ );
+
+ // One query for the whole community's deposit records rather than one per
+ // work: the biggest communities have tens of thousands of them.
+ const records = await CrossrefDepositRecord.findAll({
+ where: { id: targets.map((target) => target.crossrefDepositRecordId) },
+ });
+ const recordsById = new Map(records.map((record) => [record.id, record]));
+
+ const pendingWrites: { record: CrossrefDepositRecord; values: DepositState }[] = [];
+
+ for (const target of targets) {
+ const summary = depositsByDoi.get(target.doi.toLowerCase());
+ if (!summary) {
+ // Deposited through the legacy Crossref path, before this community
+ // was moved onto Doily. Doily has no verdict to offer, so the row
+ // stays NULL and keeps rendering as it always has.
+ counts.unknownToDoily += 1;
+ continue;
+ }
+ if (!isDepositStatus(summary.status)) {
+ warn(`${target.doi} has status ${summary.status}, which this build does not know`);
+ continue;
+ }
+ counts.matched += 1;
+
+ const record = recordsById.get(target.crossrefDepositRecordId);
+ if (!record) {
+ warn(`${target.kind} ${target.id} points at a deposit record that does not exist`);
+ counts.noRecord += 1;
+ continue;
+ }
+
+ // Doily's updatedAt, not now(): lastCheckedAt means "the state in these
+ // columns was true at this moment", and the webhook drops a stale pending
+ // assertion by comparing against it. Stamping now() would make a
+ // three-day-old status look like the freshest thing we know.
+ const lastCheckedAt = new Date(summary.updatedAt);
+ const values = {
+ status: summary.status,
+ doilyDepositId: summary.id,
+ doi: summary.doi,
+ error: summary.error ?? null,
+ lastCheckedAt: Number.isNaN(lastCheckedAt.getTime()) ? new Date() : lastCheckedAt,
+ };
+
+ const alreadyCorrect =
+ record.status === values.status &&
+ record.doilyDepositId === values.doilyDepositId &&
+ record.doi === values.doi &&
+ record.error === values.error;
+ if (alreadyCorrect) {
+ counts.unchanged += 1;
+ continue;
+ }
+
+ log(
+ `${execute ? 'updating' : 'would update'} ${target.doi} (${target.kind} ${target.id}): ${record.status ?? 'NULL'} -> ${values.status}`,
+ );
+ pendingWrites.push({ record, values });
+ counts.updated += 1;
+ }
+
+ if (execute) {
+ // Chunked rather than one big Promise.all: every row needs its own UPDATE
+ // (the values differ per row), and firing tens of thousands at once would
+ // exhaust the connection pool that the live site is also using.
+ for (let index = 0; index < pendingWrites.length; index += WRITE_CHUNK_SIZE) {
+ const chunk = pendingWrites.slice(index, index + WRITE_CHUNK_SIZE);
+ // biome-ignore lint/performance/noAwaitInLoops: the concurrency is inside the chunk, and letting chunks overlap is the pool exhaustion this avoids
+ await Promise.all(chunk.map(({ record, values }) => record.update(values)));
+ }
+ }
+
+ return counts;
+};
+
+const main = async () => {
+ if (!isDoilyConfigured()) {
+ warn('DOILY_URL and DOILY_API_TOKEN are not both set, nothing to read from');
+ process.exit(1);
+ }
+ if (!execute) {
+ log('DRY RUN. Pass --execute to write, nothing will be modified.');
+ }
+
+ const subdomain = getArgValue('--subdomain');
+ const communities = await getDoilyCommunities(subdomain);
+ if (communities.length === 0) {
+ warn(
+ `no communities with the ${DOILY_FLAG} flag${subdomain ? ` matching ${subdomain}` : ''}`,
+ );
+ process.exit(1);
+ }
+ log(`${communities.length} community/communities with ${DOILY_FLAG}`);
+
+ const totals = { matched: 0, updated: 0, unchanged: 0, unknownToDoily: 0, noRecord: 0 };
+ for (const community of communities) {
+ try {
+ // biome-ignore lint/performance/noAwaitInLoops: one community at a time, each pages through Doily and writes to the DB the live site is using, and the log is only readable in order
+ const counts = await backfillCommunity(community);
+ for (const key of Object.keys(totals) as (keyof typeof totals)[]) {
+ totals[key] += counts[key];
+ }
+ } catch (err) {
+ // One community's Doily outage must not abandon the rest of the run:
+ // the whole point is to get as many rows honest as possible.
+ warn(`${community.subdomain} failed: ${err instanceof Error ? err.message : err}`);
+ }
+ }
+
+ log(
+ `done. matched=${totals.matched} updated=${totals.updated} unchanged=${totals.unchanged} unknownToDoily=${totals.unknownToDoily} missingRecord=${totals.noRecord}`,
+ );
+ process.exit(0);
+};
+
+main().catch((err) => {
+ console.error(`[backfill-doily-status] fatal: ${err instanceof Error ? err.message : err}`);
+ console.error(err);
+ process.exit(1);
+});
diff --git a/tools/exportDoiRegistrations.ts b/tools/exportDoiRegistrations.ts
new file mode 100644
index 0000000000..043f23a868
--- /dev/null
+++ b/tools/exportDoiRegistrations.ts
@@ -0,0 +1,149 @@
+/**
+ * Export every registered DOI (pubs + collections with a stored Crossref
+ * deposit record) as NDJSON for the Doily migration. One line per DOI, shape
+ * defined by doily's src/deposit/pubpub-import/export-row.ts.
+ *
+ * npm run tools exportDoiRegistrations -- --out doi-export.ndjson
+ * npm run tools exportDoiRegistrations -- --subdomain demo --out demo.ndjson
+ */
+import { createWriteStream } from 'fs';
+import { Op } from 'sequelize';
+
+import { Collection, Community, CrossrefDepositRecord, DepositTarget, Pub } from 'server/models';
+import { isDoiPublic } from 'utils/crossref/depositStatus';
+
+const getArgValue = (name: string) => {
+ const index = process.argv.indexOf(name);
+ return index === -1 ? null : process.argv[index + 1];
+};
+
+const depositedWhere = {
+ doi: { [Op.not]: null },
+ crossrefDepositRecordId: { [Op.not]: null },
+};
+
+type CommunityLite = {
+ id: string;
+ subdomain: string;
+ title: string;
+ citeAs: string | null;
+ publishAs: string | null;
+ kfOrgId: string | null;
+};
+
+const communityAttributes = ['id', 'subdomain', 'title', 'citeAs', 'publishAs', 'kfOrgId'];
+
+async function main() {
+ const out =
+ getArgValue('--out') ?? `doi-export-${new Date().toISOString().slice(0, 10)}.ndjson`;
+ const subdomain = getArgValue('--subdomain');
+
+ let communityFilter: { communityId: string } | {} = {};
+ if (subdomain) {
+ const community = await Community.findOne({ where: { subdomain } });
+ if (!community) {
+ // eslint-disable-next-line no-console
+ console.error(`community with subdomain "${subdomain}" not found`);
+ process.exit(1);
+ }
+ communityFilter = { communityId: community.id };
+ }
+
+ const depositTargets = await DepositTarget.findAll({
+ attributes: ['communityId', 'service', 'doiPrefix'],
+ });
+ const targetByCommunity = new Map(
+ depositTargets.map((target) => [
+ target.communityId,
+ { service: target.service ?? 'crossref', doiPrefix: target.doiPrefix },
+ ]),
+ );
+
+ const include = [
+ { model: Community, as: 'community', attributes: communityAttributes },
+ { model: CrossrefDepositRecord, as: 'crossrefDepositRecord' },
+ ];
+
+ const [pubs, collections] = await Promise.all([
+ Pub.findAll({
+ where: { ...depositedWhere, ...communityFilter },
+ attributes: ['id', 'doi', 'communityId'],
+ include,
+ }),
+ Collection.findAll({
+ where: { ...depositedWhere, ...communityFilter },
+ attributes: ['id', 'doi', 'communityId'],
+ include,
+ }),
+ ]);
+
+ const stream = createWriteStream(out);
+ let written = 0;
+ let skipped = 0;
+
+ const writeRow = (
+ scope: 'pub' | 'collection',
+ item: {
+ id: string;
+ doi: string | null;
+ communityId: string;
+ community?: CommunityLite;
+ crossrefDepositRecord?: {
+ depositJson: object | null;
+ status?: string | null;
+ createdAt?: Date;
+ updatedAt?: Date;
+ };
+ },
+ ) => {
+ const record = item.crossrefDepositRecord;
+ const community = item.community;
+ if (!item.doi || !record?.depositJson || !community) {
+ return;
+ }
+ // "Has a DOI and a deposit record" used to be the only available proxy for
+ // "registered". Now that a verdict can be recorded, a deposit we know
+ // Crossref rejected (or one still in flight) is not a registration, and
+ // exporting it would import a fiction into Doily. A NULL status is every
+ // pre-verdict row and still exports, exactly as before.
+ if (record.status && !isDoiPublic(record.status)) {
+ skipped += 1;
+ return;
+ }
+ const row = {
+ scope,
+ ...(scope === 'pub' ? { pubId: item.id } : { collectionId: item.id }),
+ doi: item.doi,
+ communityId: item.communityId,
+ community: {
+ subdomain: community.subdomain,
+ title: community.title,
+ citeAs: community.citeAs,
+ publishAs: community.publishAs,
+ kfOrgId: community.kfOrgId,
+ },
+ depositTarget: targetByCommunity.get(item.communityId) ?? null,
+ depositJson: record.depositJson,
+ depositRecordCreatedAt: record.createdAt?.toISOString(),
+ depositRecordUpdatedAt: record.updatedAt?.toISOString(),
+ };
+ stream.write(`${JSON.stringify(row)}\n`);
+ written += 1;
+ };
+
+ pubs.forEach((pub) => writeRow('pub', pub as any));
+ collections.forEach((collection) => writeRow('collection', collection as any));
+
+ await new Promise((resolve) => stream.end(resolve));
+ // eslint-disable-next-line no-console
+ console.log(
+ `wrote ${written} rows (${pubs.length} pubs, ${collections.length} collections) to ${out}`,
+ );
+ if (skipped > 0) {
+ // eslint-disable-next-line no-console
+ console.log(`skipped ${skipped} rows whose deposit is pending or failed`);
+ }
+ process.exit(0);
+}
+
+main();
diff --git a/tools/fix-dois/discover.ts b/tools/fix-dois/discover.ts
index 7d69aee843..20a9700846 100644
--- a/tools/fix-dois/discover.ts
+++ b/tools/fix-dois/discover.ts
@@ -1,6 +1,7 @@
import { Op } from 'sequelize';
-import { Community, Pub } from 'server/models';
+import { Community, CrossrefDepositRecord, Pub } from 'server/models';
+import { isDoiPending } from 'utils/crossref/depositStatus';
import {
applyRange,
@@ -20,6 +21,8 @@ type DiscoveredEntry = {
communityId: string;
communitySubdomain: string;
error: string;
+ /** Recorded deposit state, when there is one: 'failed' here explains itself. */
+ depositStatus: string | null;
time: string;
};
@@ -58,7 +61,7 @@ async function main() {
log(`scanning ALL deposited pubs across all communities...`);
}
- const allPubs = await Pub.findAll({
+ const allPubsIncludingPending = await Pub.findAll({
where,
attributes: ['id', 'title', 'slug', 'doi', 'communityId'],
include: [
@@ -67,10 +70,28 @@ async function main() {
as: 'community',
attributes: ['subdomain'],
},
+ {
+ model: CrossrefDepositRecord,
+ as: 'crossrefDepositRecord',
+ attributes: ['status', 'error'],
+ },
],
order: [['createdAt', 'DESC']],
});
+ // A pub whose deposit is still in flight is *supposed* not to resolve yet, so
+ // checking it produces a false "broken DOI" that an operator then chases. A
+ // NULL status is every pre-verdict deposit and is still checked, which is the
+ // behaviour this tool has always had. Failed deposits stay in the run: they
+ // really are broken, and the recorded error explains why.
+ const allPubs = allPubsIncludingPending.filter(
+ (pub) => !isDoiPending((pub as any).crossrefDepositRecord?.status),
+ );
+ const pendingCount = allPubsIncludingPending.length - allPubs.length;
+ if (pendingCount > 0) {
+ log(`skipping ${pendingCount} pubs whose deposit is still in flight`);
+ }
+
const pubs = applyRange(allPubs);
log(`found ${allPubs.length} deposited pubs, checking ${pubs.length}`);
@@ -99,6 +120,7 @@ async function main() {
communityId: pub.communityId,
communitySubdomain: pubJson.community?.subdomain ?? 'unknown',
error: result.error ?? 'Not Found',
+ depositStatus: pubJson.crossrefDepositRecord?.status ?? null,
time: new Date().toISOString(),
});
}
diff --git a/tools/index.js b/tools/index.js
index 4080c9069d..1e69b8b9fb 100644
--- a/tools/index.js
+++ b/tools/index.js
@@ -46,6 +46,7 @@ const commandFiles = {
backfillCommunityActivity: "./activityItem/singleCommunityCli",
backfillCheckpoints: "./backfillCheckpoints",
backfillDepositTargets: "./backfillDepositTargets",
+ backfillDoilyDepositStatus: "./backfillDoilyDepositStatus",
backup: "./backup/backup",
backupDb: "./backup-db",
bootstrapCheckpoints: "./bootstrapCheckpoints",
@@ -64,6 +65,7 @@ const commandFiles = {
emailUsers: "./emailUsers",
encrypt: "./encrypt",
exportCollection: "./exportCollection",
+ exportDoiRegistrations: "./exportDoiRegistrations",
exportFirebasePubs: "./exportFirebasePubs",
figurelist: "./figurelist",
discoverBrokenDois: "./fix-dois/discover",
diff --git a/tools/migrations/2026_08_13_addDepositStateToCrossrefDepositRecords.js b/tools/migrations/2026_08_13_addDepositStateToCrossrefDepositRecords.js
new file mode 100644
index 0000000000..495d52646d
--- /dev/null
+++ b/tools/migrations/2026_08_13_addDepositStateToCrossrefDepositRecords.js
@@ -0,0 +1,63 @@
+export const up = async ({ Sequelize, sequelize }) => {
+ // All nullable, no default. A NULL status is meaningful: it marks a row that
+ // predates deposit-state tracking, which the display rule in
+ // utils/crossref/depositStatus.ts renders exactly as it always was. Giving
+ // status a default of 'draft' or 'submitted' here would quietly relabel every
+ // registered DOI in the archive as unconfirmed.
+ await sequelize.queryInterface.addColumn('CrossrefDepositRecords', 'status', {
+ type: Sequelize.TEXT,
+ allowNull: true,
+ });
+ await sequelize.queryInterface.addColumn('CrossrefDepositRecords', 'doilyDepositId', {
+ type: Sequelize.TEXT,
+ allowNull: true,
+ });
+ await sequelize.queryInterface.addColumn('CrossrefDepositRecords', 'doi', {
+ type: Sequelize.TEXT,
+ allowNull: true,
+ });
+ await sequelize.queryInterface.addColumn('CrossrefDepositRecords', 'error', {
+ type: Sequelize.TEXT,
+ allowNull: true,
+ });
+ await sequelize.queryInterface.addColumn('CrossrefDepositRecords', 'lastCheckedAt', {
+ type: Sequelize.DATE,
+ allowNull: true,
+ });
+ // When the DOI first registered, straight from Doily's firstRegisteredAt.
+ // Load-bearing rather than informational: deposit state is per attempt, so a
+ // rejected UPDATE to an already-registered record reports status 'failed'
+ // while doi.org keeps resolving it. Without this column the display rule
+ // would pull a working DOI off pub pages and out of citations over stale
+ // metadata. See isDoiPublic in utils/crossref/depositStatus.ts.
+ await sequelize.queryInterface.addColumn('CrossrefDepositRecords', 'firstRegisteredAt', {
+ type: Sequelize.DATE,
+ allowNull: true,
+ });
+ // Both columns are looked up by a webhook handler that runs once per deposit
+ // transition, so the lookup has to be an index hit rather than a scan of
+ // every deposit PubPub has ever made.
+ await sequelize.queryInterface.addIndex('CrossrefDepositRecords', ['doilyDepositId'], {
+ name: 'crossref_deposit_records_doily_deposit_id_idx',
+ });
+ await sequelize.queryInterface.addIndex('CrossrefDepositRecords', ['doi'], {
+ name: 'crossref_deposit_records_doi_idx',
+ });
+};
+
+export const down = async ({ sequelize }) => {
+ await sequelize.queryInterface.removeIndex(
+ 'CrossrefDepositRecords',
+ 'crossref_deposit_records_doi_idx',
+ );
+ await sequelize.queryInterface.removeIndex(
+ 'CrossrefDepositRecords',
+ 'crossref_deposit_records_doily_deposit_id_idx',
+ );
+ await sequelize.queryInterface.removeColumn('CrossrefDepositRecords', 'firstRegisteredAt');
+ await sequelize.queryInterface.removeColumn('CrossrefDepositRecords', 'lastCheckedAt');
+ await sequelize.queryInterface.removeColumn('CrossrefDepositRecords', 'error');
+ await sequelize.queryInterface.removeColumn('CrossrefDepositRecords', 'doi');
+ await sequelize.queryInterface.removeColumn('CrossrefDepositRecords', 'doilyDepositId');
+ await sequelize.queryInterface.removeColumn('CrossrefDepositRecords', 'status');
+};
diff --git a/types/pub.ts b/types/pub.ts
index 7c7eaa4bea..c6f58e1a97 100644
--- a/types/pub.ts
+++ b/types/pub.ts
@@ -5,6 +5,7 @@ import type {
Pub as PubModel,
Release as ReleaseModel,
} from 'server/models';
+import type { DepositStatus } from 'utils/crossref/depositStatus';
import type { PubAttribution } from './attribution';
import type { Collection, CollectionPub } from './collection';
@@ -58,6 +59,9 @@ export type PubPageDiscussion = Prettify<
export type PubPageData = DefinitelyHas, 'collectionPubs'> &
PubDocInfo & {
+ /** See SanitizedPubData: the pub page is served the sanitized shape. */
+ crossrefDepositStatus?: DepositStatus | null;
+ crossrefDepositEverRegistered?: boolean;
membersData?: {
members: Member[];
};
@@ -106,6 +110,18 @@ type CollectionPubWithAttributions = CollectionPub & {
};
export type SanitizedPubData = Pub & {
+ /**
+ * Deposit status lifted out of the (manage-only) crossrefDepositRecord so
+ * public surfaces can decide whether the DOI may be printed as final. NULL
+ * means no deposit state is recorded. See utils/crossref/depositStatus.ts.
+ */
+ crossrefDepositStatus: DepositStatus | null;
+ /**
+ * Whether the DOI ever registered. Needed alongside the status because status
+ * is per attempt: a rejected update to a live DOI reads 'failed', and hiding
+ * that DOI would be worse than showing slightly stale metadata.
+ */
+ crossrefDepositEverRegistered: boolean;
viewHash: string | null;
editHash: string | null;
reviewHash: string | null;
diff --git a/utils/api/schemas/pub.ts b/utils/api/schemas/pub.ts
index 00ea29f907..8e76492913 100644
--- a/utils/api/schemas/pub.ts
+++ b/utils/api/schemas/pub.ts
@@ -5,6 +5,8 @@ import type { getProposedMetadata } from 'workers/tasks/import/metadata';
import { extendZodWithOpenApi } from '@anatine/zod-openapi';
import { z } from 'zod';
+import { depositStatuses } from 'utils/crossref/depositStatus';
+
import { baseSchema } from '../utils/baseSchema';
import { collectionSchema } from './collection';
import { collectionAttributionSchema } from './collectionAttribution';
@@ -195,6 +197,10 @@ export const getManyQuerySchema = z.object({
export const sanitizedPubSchema = pubSchema.merge(
z.object({
+ // Nullable rather than optional: pubSanitize always sets it, and null is
+ // the meaningful value (no deposit state recorded for this pub).
+ crossrefDepositStatus: z.enum(depositStatuses).nullable(),
+ crossrefDepositEverRegistered: z.boolean(),
attributions: pubAttributionSchema.array(),
discussions: z.array(discussionSchema),
collectionPubs: z.array(
diff --git a/utils/crossref/__tests__/depositStatus.test.ts b/utils/crossref/__tests__/depositStatus.test.ts
new file mode 100644
index 0000000000..8c17f32c01
--- /dev/null
+++ b/utils/crossref/__tests__/depositStatus.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest';
+
+import { depositStatuses, getDoiDisplay, isDepositStatus, isDoiPublic } from '../depositStatus';
+
+// The whole point of these cases is the absent status. PubPub has thousands of
+// DOIs that were deposited before any outcome was recorded, and if a NULL status
+// ever renders as "not registered" they all disappear from pub pages, citations
+// and the Google Scholar meta tags at once.
+
+describe('getDoiDisplay', () => {
+ it('treats no recorded status as legacy, which renders as it always did', () => {
+ expect(getDoiDisplay(null)).toBe('legacy');
+ expect(getDoiDisplay(undefined)).toBe('legacy');
+ expect(getDoiDisplay('')).toBe('legacy');
+ });
+
+ it('treats a status this build does not know as legacy, never as hidden', () => {
+ // Doily can add a status after this build ships. A DOI must never vanish
+ // because we failed to recognize a word.
+ expect(getDoiDisplay('withdrawn')).toBe('legacy');
+ });
+
+ it('maps every status Doily can send', () => {
+ expect(getDoiDisplay('registered')).toBe('registered');
+ expect(getDoiDisplay('unverified')).toBe('unverified');
+ expect(getDoiDisplay('draft')).toBe('pending');
+ expect(getDoiDisplay('submitted')).toBe('pending');
+ expect(getDoiDisplay('queued')).toBe('pending');
+ expect(getDoiDisplay('failed')).toBe('failed');
+ });
+});
+
+describe('isDoiPublic', () => {
+ it('publishes legacy, registered and unverified DOIs', () => {
+ expect(isDoiPublic(null)).toBe(true);
+ expect(isDoiPublic('registered')).toBe(true);
+ // Unverified is visible on purpose: the DOI usually does resolve, and
+ // hiding it would be the same regression as hiding a legacy one.
+ expect(isDoiPublic('unverified')).toBe(true);
+ });
+
+ it('withholds a DOI that is in flight or was rejected', () => {
+ expect(isDoiPublic('draft')).toBe(false);
+ expect(isDoiPublic('submitted')).toBe(false);
+ expect(isDoiPublic('queued')).toBe(false);
+ expect(isDoiPublic('failed')).toBe(false);
+ });
+
+ it('keeps publishing a DOI that ever registered, whatever the last attempt did', () => {
+ // The case this exists for: re-depositing an already-registered record and
+ // having the registrar reject the UPDATE. Doily reports status 'failed'
+ // because deposit state is per attempt, but doi.org still resolves the DOI.
+ // Hiding it would pull a working identifier out of pub pages, citations and
+ // the Scholar meta tags over stale metadata.
+ expect(isDoiPublic('failed', true)).toBe(true);
+ expect(isDoiPublic('queued', true)).toBe(true);
+ expect(isDoiPublic('submitted', true)).toBe(true);
+ // And the flag never makes a never-registered DOI publishable.
+ expect(isDoiPublic('failed', false)).toBe(false);
+ expect(isDoiPublic('draft', false)).toBe(false);
+ });
+});
+
+describe('isDepositStatus', () => {
+ it('accepts exactly the vocabulary Doily uses', () => {
+ for (const status of depositStatuses) {
+ expect(isDepositStatus(status)).toBe(true);
+ }
+ });
+
+ it('rejects anything else, so an unknown word never reaches the column', () => {
+ expect(isDepositStatus('Registered')).toBe(false);
+ expect(isDepositStatus(null)).toBe(false);
+ expect(isDepositStatus(undefined)).toBe(false);
+ expect(isDepositStatus(42)).toBe(false);
+ });
+});
diff --git a/utils/crossref/depositStatus.ts b/utils/crossref/depositStatus.ts
new file mode 100644
index 0000000000..2de376d96b
--- /dev/null
+++ b/utils/crossref/depositStatus.ts
@@ -0,0 +1,93 @@
+/**
+ * Deposit state as Doily reports it, and the one rule that decides how a DOI is
+ * allowed to be displayed.
+ *
+ * PubPub used to treat "we POSTed a deposit and did not get a 4xx" as
+ * registration. Doily now pushes the registrar's actual verdict (see
+ * server/doily/webhook.ts), so a deposit record carries a status.
+ *
+ * The load-bearing case is the absent one. Every DOI deposited before this
+ * feature existed has a NULL status, and every DOI that was assigned but never
+ * deposited has no deposit record at all. Both must keep rendering exactly as
+ * they do today: if `legacy` were treated as "not confirmed", every genuinely
+ * registered DOI in the archive would vanish from pub pages, citations and the
+ * Google Scholar meta tags in one deploy. An unrecognized status (a state Doily
+ * adds after this build ships) falls into `legacy` for the same reason: a DOI is
+ * never hidden because we failed to recognize a word.
+ */
+
+export const depositStatuses = [
+ 'draft',
+ 'submitted',
+ 'queued',
+ 'registered',
+ 'unverified',
+ 'failed',
+] as const;
+
+export type DepositStatus = (typeof depositStatuses)[number];
+
+export const isDepositStatus = (value: unknown): value is DepositStatus =>
+ typeof value === 'string' && (depositStatuses as readonly string[]).includes(value);
+
+/**
+ * How a DOI may be presented:
+ *
+ * - `legacy` no status recorded (pre-feature row, undeposited DOI, or an
+ * unknown status). Render exactly as PubPub always has.
+ * - `registered` the registrar confirmed it. Safe to call final.
+ * - `unverified` deposited, but Doily could not confirm the record at the
+ * registrar. Visible, with a caveat where there is room for one,
+ * never hidden: the DOI usually does resolve.
+ * - `pending` draft / submitted / queued. In flight, so it may not resolve
+ * yet. Not published as an identifier.
+ * - `failed` the registrar rejected it. Not published as an identifier, and
+ * the dashboard needs to say so out loud.
+ */
+export type DoiDisplay = 'legacy' | 'registered' | 'unverified' | 'pending' | 'failed';
+
+export const getDoiDisplay = (status?: string | null): DoiDisplay => {
+ switch (status) {
+ case 'registered':
+ return 'registered';
+ case 'unverified':
+ return 'unverified';
+ case 'draft':
+ case 'submitted':
+ case 'queued':
+ return 'pending';
+ case 'failed':
+ return 'failed';
+ default:
+ return 'legacy';
+ }
+};
+
+/**
+ * May this DOI be published as the work's identifier: citation_doi and friends,
+ * the citation strings, a doi.org link we ask a reader to click? Only states
+ * where the DOI plausibly resolves qualify. A pending or failed deposit means
+ * doi.org answers 404, and a 404 in a Google Scholar record is worse than no
+ * record at all.
+ */
+export const isDoiPublic = (status?: string | null, everRegistered = false): boolean => {
+ // A DOI that has EVER registered stays live at the registrar even when the
+ // most recent attempt failed, because deposit state is per attempt: rejecting
+ // an *update* to an already-registered record leaves the record, and doi.org
+ // keeps resolving it. Hiding it would pull a working identifier out of pub
+ // pages, citation strings and Scholar records over stale metadata, which is
+ // strictly the worse failure. Doily reports this as firstRegisteredAt on the
+ // deposit summary it sends with every webhook.
+ if (everRegistered) {
+ return true;
+ }
+ const display = getDoiDisplay(status);
+ return display === 'legacy' || display === 'registered' || display === 'unverified';
+};
+
+/** Is the registrar still to have its say? Drives "pending" affordances. */
+export const isDoiPending = (status?: string | null): boolean =>
+ getDoiDisplay(status) === 'pending';
+
+/** Did the registrar reject the record? Drives the retry affordance. */
+export const isDoiFailed = (status?: string | null): boolean => getDoiDisplay(status) === 'failed';
diff --git a/utils/pub/__tests__/abstract.test.ts b/utils/pub/__tests__/abstract.test.ts
new file mode 100644
index 0000000000..72d0b9ce1a
--- /dev/null
+++ b/utils/pub/__tests__/abstract.test.ts
@@ -0,0 +1,118 @@
+import type { DocJson } from 'types';
+
+import { describe, expect, it } from 'vitest';
+
+import { getAbstractDocFromPubDoc, getAbstractText, matchAbstract } from '../abstract';
+
+// A pub's abstract is a convention inside the doc (H1 "Abstract" first, then one
+// paragraph), and it used to be implemented three times with three different
+// predicates. These cases pin the disagreements so they cannot silently return:
+// each one is labelled with which old implementation got it wrong.
+
+const h1 = (...content: any[]) => ({ type: 'heading', attrs: { level: 1 }, content });
+const para = (...content: any[]) => ({ type: 'paragraph', content });
+const text = (value: string, marks?: any[]) => ({
+ type: 'text',
+ text: value,
+ ...(marks && { marks }),
+});
+const doc = (...content: any[]) => ({ type: 'doc', content }) as unknown as DocJson;
+
+describe('getAbstractText', () => {
+ it('extracts a plain abstract', () => {
+ expect(getAbstractText(doc(h1(text('Abstract')), para(text('Hello world.'))))).toBe(
+ 'Hello world.',
+ );
+ });
+
+ it('trims the heading text (utils/pub/metadata.js used to miss this)', () => {
+ expect(getAbstractText(doc(h1(text(' Abstract')), para(text('Trimmed.'))))).toBe(
+ 'Trimmed.',
+ );
+ });
+
+ it('matches a heading split across marks (the pandoc path used to miss this)', () => {
+ const heading = h1(text('Abs'), text('tract', [{ type: 'strong' }]));
+ expect(getAbstractText(doc(heading, para(text('Split heading.'))))).toBe('Split heading.');
+ });
+
+ it('turns a shift-enter hard_break into a newline instead of fusing words', () => {
+ // The old implementation dropped hard_break via a `default:` case, so this
+ // rendered as "End of one.Start of two." in citation_abstract.
+ const paragraph = para(text('End of one.'), { type: 'hard_break' }, text('Start of two.'));
+ expect(getAbstractText(doc(h1(text('Abstract')), paragraph))).toBe(
+ 'End of one.\nStart of two.',
+ );
+ });
+
+ it('keeps a link target', () => {
+ const paragraph = para(
+ text('See ', [{ type: 'link', attrs: { href: 'https://example.dev' } }]),
+ text('it.'),
+ );
+ expect(getAbstractText(doc(h1(text('Abstract')), paragraph))).toBe(
+ 'See it.',
+ );
+ });
+
+ it('includes inline equation values', () => {
+ const paragraph = para(text('Where '), { type: 'equation', attrs: { value: 'x^2' } });
+ expect(getAbstractText(doc(h1(text('Abstract')), paragraph))).toBe('Where x^2');
+ });
+
+ it.each([
+ ['no abstract heading', doc(h1(text('Introduction')), para(text('Body.')))],
+ ['heading not followed by a paragraph', doc(h1(text('Abstract')), h1(text('Next')))],
+ ['heading with nothing after it', doc(h1(text('Abstract')))],
+ ['an empty doc', doc()],
+ [
+ 'a level-2 heading',
+ doc(
+ { type: 'heading', attrs: { level: 2 }, content: [text('Abstract')] },
+ para(text('x')),
+ ),
+ ],
+ [
+ // metadata.js read `firstChild.attrs.level` unguarded and threw here.
+ 'a heading with no attrs',
+ doc({ type: 'heading', content: [text('Abstract')] }, para(text('x'))),
+ ],
+ ])('returns an empty string for %s', (_label, input) => {
+ expect(getAbstractText(input as DocJson)).toBe('');
+ });
+
+ it('returns an empty string for a null doc', () => {
+ expect(getAbstractText(null)).toBe('');
+ expect(getAbstractText(undefined)).toBe('');
+ });
+});
+
+describe('matchAbstract', () => {
+ it('excludes the abstract heading and paragraph from the body', () => {
+ const input = doc(
+ h1(text('Abstract')),
+ para(text('Abs.')),
+ h1(text('Introduction')),
+ para(text('Body.')),
+ );
+ const match = matchAbstract(input);
+ expect(match).not.toBeNull();
+ expect(match!.rest).toHaveLength(2);
+ expect(JSON.stringify(match!.rest)).not.toMatch(/abstract/i);
+ });
+});
+
+describe('getAbstractDocFromPubDoc', () => {
+ it('wraps just the abstract paragraph, never the heading', () => {
+ const result = getAbstractDocFromPubDoc(doc(h1(text('Abstract')), para(text('Only me.'))));
+ expect(result).toEqual({
+ type: 'doc',
+ attrs: {},
+ content: [para(text('Only me.'))],
+ });
+ });
+
+ it('returns null when there is no abstract', () => {
+ expect(getAbstractDocFromPubDoc(doc(para(text('No heading.'))))).toBeNull();
+ });
+});
diff --git a/utils/pub/abstract.ts b/utils/pub/abstract.ts
index 9706f77ba1..d563ba872b 100644
--- a/utils/pub/abstract.ts
+++ b/utils/pub/abstract.ts
@@ -1,23 +1,111 @@
import type { DocJson } from 'types';
+/**
+ * A pub has no abstract field. The abstract is a CONVENTION inside the pub's
+ * ProseMirror doc: a level-1 heading reading "Abstract" as the document's very
+ * first node, followed by one paragraph.
+ *
+ * This module is the single definition of that convention. It used to be
+ * implemented three times, and the three disagreed:
+ *
+ * - here: trimmed the heading text
+ * - utils/pub/metadata.js: did not trim, and read `attrs.level`
+ * without a guard
+ * - workers/.../extractMetaContent.ts: additionally required the heading to be
+ * a single `Str` token
+ *
+ * So a heading of " Abstract" produced an abstract for the meta tag but not the
+ * editor, and a heading whose text was split across marks (bolded, or typed with
+ * an autocorrect boundary) produced one for the editor but not the JATS export.
+ * Everything that needs a pub's abstract now routes through `matchAbstract`
+ * below. The pandoc path operates on a different AST so it cannot share the
+ * code, but it has been aligned to the same predicate.
+ *
+ * The single-paragraph ceiling is intentional: multi-paragraph abstracts are
+ * authored with shift-enter, which inserts a `hard_break` INSIDE the one
+ * paragraph rather than starting a new node.
+ */
+
+interface AbstractMatch {
+ /** The abstract's paragraph node. Never includes the "Abstract" heading. */
+ paragraph: NonNullable[number];
+ /** Everything after the abstract — the body proper, heading excluded. */
+ rest: NonNullable;
+}
+
+/** Concatenated text of a node's immediate text children, marks ignored. */
+const textOf = (node): string =>
+ (node?.content ?? [])
+ .filter((child) => child?.type === 'text')
+ .map((child) => child.text ?? '')
+ .join('');
+
+/**
+ * Does this doc open with the abstract convention? Returns the abstract
+ * paragraph and the remaining body, or null.
+ */
+export const matchAbstract = (doc: DocJson | null | undefined): AbstractMatch | null => {
+ if (!doc?.content) return null;
+ const [firstChild, secondChild, ...rest] = doc.content;
+ if (!firstChild || !secondChild) return null;
+
+ const isAbstractHeading =
+ firstChild.type === 'heading' &&
+ firstChild.attrs?.level === 1 &&
+ textOf(firstChild).toLowerCase().trim() === 'abstract';
+
+ if (!isAbstractHeading || secondChild.type !== 'paragraph') return null;
+ return { paragraph: secondChild, rest };
+};
+
export const getAbstractDocFromPubDoc = (doc: DocJson): null | DocJson => {
- const {
- content: [firstChild, secondChild],
- } = doc;
-
- if (firstChild && secondChild) {
- const { type: firstChildType, attrs, content } = firstChild;
-
- const hasCorrectHeading =
- firstChildType === 'heading' &&
- attrs?.level === 1 &&
- content?.[0]?.text?.toLowerCase()?.trim() === 'abstract';
-
- if (hasCorrectHeading) {
- if (secondChild.type === 'paragraph') {
- return { type: 'doc', attrs: {}, content: [secondChild] };
- }
+ const match = matchAbstract(doc);
+ if (!match) return null;
+ return { type: 'doc', attrs: {}, content: [match.paragraph] };
+};
+
+/**
+ * The abstract as plain text — for `citation_abstract`, Crossref/DataCite
+ * deposits, and anywhere else a string is needed. Returns '' when the pub has no
+ * abstract, matching the previous getTextAbstract contract.
+ *
+ * `hard_break` becomes a newline. The old implementation let it fall through a
+ * `default:` case and dropped it, which silently fused the last word of one
+ * shift-enter paragraph onto the first word of the next.
+ */
+export const getAbstractText = (doc: DocJson | null | undefined): string => {
+ const match = matchAbstract(doc);
+ if (!match) return '';
+
+ let text = '';
+ for (const item of match.paragraph.content ?? []) {
+ switch (item.type) {
+ case 'text':
+ text += item.text ?? '';
+ for (const mark of item.marks ?? []) {
+ // A link's target is not otherwise representable in plain text.
+ if (mark.type === 'link' && mark.attrs?.href) {
+ text += ` <${mark.attrs.href}> `;
+ }
+ }
+ break;
+ case 'hard_break':
+ text += '\n';
+ break;
+ case 'equation':
+ case 'inline_equation':
+ text += item.attrs?.value ?? '';
+ break;
+ default:
+ // Inline nodes with no textual representation (images, citations,
+ // footnotes) contribute nothing.
+ break;
}
}
- return null;
+ // Collapse runs of whitespace that the link handling above can introduce,
+ // but keep the newlines that carry paragraph intent.
+ return text
+ .replace(/[^\S\n]+/g, ' ')
+ .replace(/ *\n */g, '\n')
+ .trim();
};
diff --git a/utils/pub/metadata.js b/utils/pub/metadata.js
index 1bd0776c4c..3d2a850181 100644
--- a/utils/pub/metadata.js
+++ b/utils/pub/metadata.js
@@ -1,3 +1,4 @@
+import { getAbstractText } from 'utils/pub/abstract';
import { getBestDownloadUrl } from 'utils/pub/downloads';
import { pubUrl } from 'utils/canonicalUrls';
@@ -9,44 +10,13 @@ export const getPdfDownloadUrl = (communityData, pubData) => {
return null;
};
-export const getTextAbstract = (docJson) => {
- let abstract = '';
- if (!docJson) return abstract;
- const { content } = docJson;
- const [firstChild, secondChild] = content;
- const firstChildIsAbstractHeader =
- firstChild &&
- firstChild.type === 'heading' &&
- firstChild.attrs.level === 1 &&
- firstChild.content &&
- firstChild.content.length > 0 &&
- (firstChild.content[0].text || '').toLowerCase() === 'abstract';
- if (firstChildIsAbstractHeader && secondChild) {
- const { content: abstractContent } = secondChild;
- if (abstractContent) {
- abstractContent.forEach((item) => {
- switch (item.type) {
- case 'text':
- abstract += item.text;
- if (item.marks) {
- item.marks.forEach((mark) => {
- if (mark.type === 'link') {
- abstract += ` <${mark.attrs.href}> `;
- }
- });
- }
- break;
- case 'equation':
- abstract += item.attrs.value;
- break;
- default:
- break;
- }
- });
- }
- }
- return abstract;
-};
+/**
+ * Kept as a re-export so existing callers are unchanged. The implementation
+ * moved to utils/pub/abstract.ts, which is now the single definition of the
+ * abstract convention — this file used to carry a second, subtly different copy
+ * of the match (no trim, unguarded attrs, hard_breaks dropped). See that module.
+ */
+export const getTextAbstract = getAbstractText;
const noteTypes = {
chapter: 'book',
diff --git a/utils/superAdmin.ts b/utils/superAdmin.ts
index a1fa59b0ea..fca7a5ed4b 100644
--- a/utils/superAdmin.ts
+++ b/utils/superAdmin.ts
@@ -1,6 +1,7 @@
export const superAdminTabKinds = [
'analytics',
'customDomains',
+ 'featureFlags',
'depositTargets',
'ftpTargets',
'exploreCommunities',
diff --git a/workers/tasks/export/transforms/extractMetaContent.ts b/workers/tasks/export/transforms/extractMetaContent.ts
index dffd518bd8..04dd46a1d1 100644
--- a/workers/tasks/export/transforms/extractMetaContent.ts
+++ b/workers/tasks/export/transforms/extractMetaContent.ts
@@ -1,15 +1,34 @@
+/**
+ * Concatenated text of a pandoc inline list. Pandoc splits a heading into
+ * several inlines whenever marks or spaces are involved, so requiring a single
+ * `Str` (as this did) rejected headings the DocJson matchers accept.
+ */
+const inlineText = (inlines): string =>
+ (inlines ?? [])
+ .map((inline) => (inline?.type === 'Str' ? (inline.content ?? '') : ' '))
+ .join('');
+
+/**
+ * Pandoc-AST twin of `matchAbstract` in utils/pub/abstract.ts. Kept in sync with
+ * it by hand — same predicate, different data structure. Note it also strips the
+ * abstract from `body`, so the heading and paragraph are not repeated in the
+ * exported document.
+ */
const extractAbstract = (pandocBlocks) => {
const [first, second, ...rest] = pandocBlocks;
- if (first.type === 'Header' && first.level === 1 && first.content.length === 1) {
- const [firstContent] = first.content;
- if (firstContent.type === 'Str' && firstContent.content.toLowerCase() === 'abstract') {
- if (second.type === 'Para') {
- return {
- abstract: { type: 'MetaBlocks', content: [second] },
- body: rest,
- };
- }
- }
+ if (!first || !second) {
+ return null;
+ }
+ if (
+ first.type === 'Header' &&
+ first.level === 1 &&
+ inlineText(first.content).toLowerCase().trim() === 'abstract' &&
+ second.type === 'Para'
+ ) {
+ return {
+ abstract: { type: 'MetaBlocks', content: [second] },
+ body: rest,
+ };
}
return null;
};