From 4e67aa3e44a3fd48fbf533930a5ac7a652dd9c2b Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 10 Aug 2026 15:58:02 +0200 Subject: [PATCH 1/4] feat: add feature flag superadmin dash --- .../FeatureFlags/FeatureFlagCard.tsx | 615 ++++++++++++++++++ .../FeatureFlags/FeatureFlags.tsx | 211 ++++++ .../FeatureFlags/featureFlags.scss | 189 ++++++ .../SuperAdminDashboard/FeatureFlags/index.ts | 1 + .../SuperAdminDashboard/FeatureFlags/types.ts | 40 ++ .../containers/SuperAdminDashboard/tabs.tsx | 5 + server/routes/superAdminDashboard.tsx | 246 ++++++- 7 files changed, 1306 insertions(+), 1 deletion(-) create mode 100644 client/containers/SuperAdminDashboard/FeatureFlags/FeatureFlagCard.tsx create mode 100644 client/containers/SuperAdminDashboard/FeatureFlags/FeatureFlags.tsx create mode 100644 client/containers/SuperAdminDashboard/FeatureFlags/featureFlags.scss create mode 100644 client/containers/SuperAdminDashboard/FeatureFlags/index.ts create mode 100644 client/containers/SuperAdminDashboard/FeatureFlags/types.ts diff --git a/client/containers/SuperAdminDashboard/FeatureFlags/FeatureFlagCard.tsx b/client/containers/SuperAdminDashboard/FeatureFlags/FeatureFlagCard.tsx new file mode 100644 index 000000000..b72c9d22a --- /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; +}) => ( + + + +
+ {error && ( + + {error} + + )} + {renderRolloutSection()} + {isLoadingOverrides && } + {overrides && ( +
+ {renderCommunityOverrides()} + {renderUserOverrides()} +
+ )} +
+
+
+
+ + ); +}; + +export default FeatureFlagCard; diff --git a/client/containers/SuperAdminDashboard/FeatureFlags/FeatureFlags.tsx b/client/containers/SuperAdminDashboard/FeatureFlags/FeatureFlags.tsx new file mode 100644 index 000000000..6a57f3bc6 --- /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. +

+
+
+
+ + +
+
+
+
+ ); +}; + +export default FeatureFlags; diff --git a/client/containers/SuperAdminDashboard/FeatureFlags/featureFlags.scss b/client/containers/SuperAdminDashboard/FeatureFlags/featureFlags.scss new file mode 100644 index 000000000..b9e422e4f --- /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 000000000..09482e1de --- /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 000000000..689c07c8b --- /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 72b2d64be..d669c7555 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/server/routes/superAdminDashboard.tsx b/server/routes/superAdminDashboard.tsx index 73fc62b49..35200944e 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 { From 237db1405f9d7a08b96255c068a908d89a15454a Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 11 Aug 2026 13:18:45 +0200 Subject: [PATCH 2/4] fix: better import/exports --- server/doi/queries.ts | 37 ++++++++ server/doily/client.ts | 161 ++++++++++++++++++++++++++++++++ server/envSchema.ts | 11 +++ tools/exportDoiRegistrations.ts | 133 ++++++++++++++++++++++++++ tools/index.js | 1 + utils/superAdmin.ts | 1 + 6 files changed, 344 insertions(+) create mode 100644 server/doily/client.ts create mode 100644 tools/exportDoiRegistrations.ts diff --git a/server/doi/queries.ts b/server/doi/queries.ts index ce276df3d..beaedf416 100644 --- a/server/doi/queries.ts +++ b/server/doi/queries.ts @@ -3,6 +3,11 @@ import { updateCrossrefDepositRecord, } from 'server/crossrefDepositRecord/queries'; import { getCommunityDepositTarget } from 'server/depositTarget/queries'; +import { + DoilyUnsupportedRecordError, + isDoilyEnabledForCommunity, + submitDepositViaDoily, +} from 'server/doily/client'; import { Collection, CollectionAttribution, @@ -167,6 +172,38 @@ export const setDoiData = async ( }; const ids = { collectionId, pubId }; + const isDoilyEnabled = await isDoilyEnabledForCommunity(communityId); + console.log('isDoilyEnabled', isDoilyEnabled); + + // 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 doilyResult = await submitDepositViaDoily({ + communityId, + depositJson, + primaryDoi: expect(dois.pub ?? dois.collection ?? dois.community), + }); + await Promise.all([ + persistDoiData(ids, dois), + persistCrossrefDepositRecord(ids, { ...depositJson, doily: doilyResult }), + ]); + 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/client.ts b/server/doily/client.ts new file mode 100644 index 000000000..51b78e4b8 --- /dev/null +++ b/server/doily/client.ts @@ -0,0 +1,161 @@ +import { env } from 'server/env'; +import { getFeatureFlagForUserAndCommunity } from 'server/featureFlag/queries'; +import { Community } from 'server/models'; + +// Client for Doily, KF's DOI broker. When the `doilyDeposits` community +// feature flag is on (and DOILY_URL/DOILY_API_TOKEN are configured), Crossref +// deposits are delegated to Doily instead of the internal 4.4.1 pipeline: +// PubPub still builds its deposit JSON, Doily maps it to 5.4.0 records, +// registers them under the community's Doily organization, and owns polling. +// +// IMPORTANT (timestamp one-way door): Doily stamps deposits with +// YYYYMMDDhhmmss, which always exceeds our epoch-ms timestamps. Once Doily +// has touched a DOI, a legacy re-deposit of it is silently rejected by +// Crossref as stale. So once a community's flag is on, transient Doily +// failures must FAIL the request loudly — never fall back to the legacy path. +// Falling back is only safe for record types Doily rejects outright (422: +// conference, supplement), which the legacy path keeps owning by design. + +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(); + +/** + * 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 cached = orgIdByCommunityId.get(communityId); + if (cached) { + return cached; + } + + const community = await Community.findOne({ + where: { id: communityId }, + attributes: ['id', 'subdomain', 'title', 'kfOrgId'], + }); + 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) { + orgIdByCommunityId.set(communityId, existing.id); + return existing.id; + } + + 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 }[]; +}; + +/** + * 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 = + records.find((record) => record.doi === primaryDoi) ?? records[records.length - 1]; + 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/envSchema.ts b/server/envSchema.ts index 60f077972..9dfd920be 100644 --- a/server/envSchema.ts +++ b/server/envSchema.ts @@ -131,6 +131,17 @@ 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'), + // ── Message Queues ────────────────────────────────────────────────── CLOUDAMQP_URL: z.string().describe('CloudAMQP (RabbitMQ) connection URL'), diff --git a/tools/exportDoiRegistrations.ts b/tools/exportDoiRegistrations.ts new file mode 100644 index 000000000..78cef205c --- /dev/null +++ b/tools/exportDoiRegistrations.ts @@ -0,0 +1,133 @@ +/** + * 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'; + +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; + + const writeRow = ( + scope: 'pub' | 'collection', + item: { + id: string; + doi: string | null; + communityId: string; + community?: CommunityLite; + crossrefDepositRecord?: { + depositJson: object | null; + createdAt?: Date; + updatedAt?: Date; + }; + }, + ) => { + const record = item.crossrefDepositRecord; + const community = item.community; + if (!item.doi || !record?.depositJson || !community) { + 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}`, + ); + process.exit(0); +} + +main(); diff --git a/tools/index.js b/tools/index.js index 4080c9069..05351cadc 100644 --- a/tools/index.js +++ b/tools/index.js @@ -64,6 +64,7 @@ const commandFiles = { emailUsers: "./emailUsers", encrypt: "./encrypt", exportCollection: "./exportCollection", + exportDoiRegistrations: "./exportDoiRegistrations", exportFirebasePubs: "./exportFirebasePubs", figurelist: "./figurelist", discoverBrokenDois: "./fix-dois/discover", diff --git a/utils/superAdmin.ts b/utils/superAdmin.ts index a1fa59b0e..fca7a5ed4 100644 --- a/utils/superAdmin.ts +++ b/utils/superAdmin.ts @@ -1,6 +1,7 @@ export const superAdminTabKinds = [ 'analytics', 'customDomains', + 'featureFlags', 'depositTargets', 'ftpTargets', 'exploreCommunities', From fe8ea6b42294277a806e63fffd62765273b1d08f Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 13 Aug 2026 22:05:31 +0200 Subject: [PATCH 3/4] feat: implement webhooks and correct deposits --- client/components/Deposit/Deposit.tsx | 17 + .../Deposit/DepositStatusCallout.tsx | 95 ++++++ .../Deposit/SubmitDepositButton.tsx | 5 +- client/components/index.ts | 1 + .../DashboardSettings/PubSettings/Doi.tsx | 36 ++- .../Pub/PubHeader/details/PubDetails.tsx | 41 ++- infra/.env.dev.enc | 113 +++---- infra/.env.enc | 114 +++---- infra/.env.local.enc | 113 +++---- server/crossrefDepositRecord/model.ts | 31 +- server/crossrefDepositRecord/queries.ts | 21 +- server/doi/queries.ts | 43 ++- .../doily/__tests__/webhookContract.test.ts | 212 +++++++++++++ server/doily/client.ts | 99 ++++-- server/doily/webhook.ts | 210 +++++++++++++ server/doily/webhookContract.ts | 130 ++++++++ server/envSchema.ts | 6 + server/routes/pubDocument.tsx | 2 + server/server.ts | 11 + .../utils/citations/generateCitationHtml.ts | 17 +- server/utils/queryHelpers/pubSanitize.ts | 37 +++ server/utils/ssr.tsx | 23 +- tools/backfillDoilyDepositStatus.ts | 295 ++++++++++++++++++ tools/exportDoiRegistrations.ts | 16 + tools/fix-dois/discover.ts | 26 +- tools/index.js | 1 + ...addDepositStateToCrossrefDepositRecords.js | 63 ++++ types/pub.ts | 16 + utils/api/schemas/pub.ts | 6 + .../crossref/__tests__/depositStatus.test.ts | 77 +++++ utils/crossref/depositStatus.ts | 93 ++++++ utils/pub/__tests__/abstract.test.ts | 113 +++++++ utils/pub/abstract.ts | 122 +++++++- utils/pub/metadata.js | 46 +-- .../export/transforms/extractMetaContent.ts | 39 ++- 35 files changed, 2013 insertions(+), 277 deletions(-) create mode 100644 client/components/Deposit/DepositStatusCallout.tsx create mode 100644 server/doily/__tests__/webhookContract.test.ts create mode 100644 server/doily/webhook.ts create mode 100644 server/doily/webhookContract.ts create mode 100644 tools/backfillDoilyDepositStatus.ts create mode 100644 tools/migrations/2026_08_13_addDepositStateToCrossrefDepositRecords.js create mode 100644 utils/crossref/__tests__/depositStatus.test.ts create mode 100644 utils/crossref/depositStatus.ts create mode 100644 utils/pub/__tests__/abstract.test.ts diff --git a/client/components/Deposit/Deposit.tsx b/client/components/Deposit/Deposit.tsx index 37107b991..dad27dde7 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 000000000..f95654d7c --- /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 2149a9394..882679961 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 fbc1dc06a..319bc5632 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 6a228e2ab..5ea4b9817 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 76c7a7514..131e0e88e 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/infra/.env.dev.enc b/infra/.env.dev.enc index 467e574bb..b2921ec15 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: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,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 58736b4e6..c60fe0433 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:nSRBmRx/5k74DI+nJ0DWtzWgvNrbSAF/IVxyGsVaqKRCiqw5cnj/+j3rJNfU8LQnVTMVKtk34Bxl2YbsapuWO139AcltfKdTeX3cFfcDInxb4wlvcEg4wqs7cWHmxvo6zC9o3OJA/WcePYy4UTHrRAAKsUhNere1CI6HD+7poQgPC10FR4aBjwFa51FmMR67uk9q64TkBgEjKYGe1RjZ7z7I8pk84Bz7jxCsFPEzqt7huE5KH9vfwDnh51QH4MRofmKCGkuryz3KTmw8HZ+HzBn0wfurNsqLFcExPsTvHOmnvcaL/P1gtfEucY9TbH8WKxu9TeB1GAX87Yr8stgjjsZ3AexY/B5FZkJD0ESHaizr1fa/IAPXmchbSD5/AGPh2vPIUQBylNb9C5bWTmdzVWDPAN1sHGiyl6Iq7grqyErtkPyp7dDIz71eQr0OucHMmhTQ7lIAOOzrgbphpqWX49bW16kL9e8p+HjndS8npnoSrSReXnQRwg3k0GOkBuvBOVYktYx4ofC3X1UwFpbl6uqs4CjSwhAfwkK+Nykv1x8qIpyTh525jUy1G1jTuJVb+TAtDB5kxWKyCf2pJX1wBL/pG9uMxWgW9rezPXQ8hDpD3r6+VsjW3zEDY61fsN1nXlzHPN+Dv1uz4mLJ3DXI8xUK9AuvTOR8nOyh83LKbWPn2eENsad5/UdSOIsTgHIL5tZouPmiPEPxJfNz7Evx+FltCoJwHPVHEJdXx/+Qitz3TAswpKMmvhK6wXV/Lw7xTHdwObFFqMTG/qZDBJnqYnimxTrVmWXqKoY3G1Mo+5rCd87/COTpKHZYK0E6Ib+/cpd6IPXIX02gKNEe78njWafF922rZo/0rmWg9UPQmAMj6tpuwD2JSsyg1iMeGxxKDQLjO1seZaHHWx7P08ZFZmjqAV0XwDwL+QtoF55TVQNQOD6m5uk2a54ATGWqoKx2sQ9Ft3SrQh50814H7B2qU6kf4PJdvg3qp7MMIEWolGEJ1wCR9fJs26S1t5MBInVDHw5R/oc3iPHOHe+Zl6lqEXuhytD0z+HYTib4Hk9Ogu+sewiHk6t+ES3PPoY57IEATVn3CyzleH69VHPIbSzUcinbyel/WiFtsRdgkGCNe99xPVCdkBYKLDnbZazzUUVPwnNXcr/kDIlMLLKN+/i6Mfomwv3CGsNz+jRh5/6KH6fgd5ooYAfD1dfeKwlvYF5kj2zBOMkM0mwsaFEbcsPYL4BluNyOE44kOtYv6arA/aQ0zU6dTMVEeKUeO/iMGedH3QMee5c8df8cJotqZ4JUh7Q3wnSkVmiv6Yadq/bIqnnmxLKkg8rWW1djfvjKXiUiAjXwdTtWf/xVuEQfP6u9pN7nuGmqmXaCiXSwt3o56o1vj/A1bKnYnlBdAKQPz//1Gp6Xov4onLA8pjczZ2hSALZnxzphzlux/e4eo+U3whLSrbFhcaMUFuCK9bPGOxSxFj/xujvvsfkjSrDBCxn9ME3NcSvd24uuLr3BYKCeB4TCz4V8NZnCEWue2YESQq7oiyTq78cMrYVv+b8DHa+3oJrHCqtiDuE1aD56MyKS5j80sk+gZQQYzUrS4cNgtRULC+rOqy0qSA1Vy5x/PI7VUbFTPRzZIOoX3cYwV6n+7ztBDv9FcxcRsUVvglYwGu+eCDSlt3e9pqyus3jSL0dpEo4bsG53MKIoYUdpL8sHCJ7/1BZwq9kaA8OBtICpxrMf4itn2/7yYSxjNO9pYBzDovrSdRA3sXRnGXmdfG7So2cmFN5auaskMcU1ph8kmpORpA3O34dEoHSWYu3PKKjE6YQkCpZdGkDH47umJUitD9SenY/ye9e2bca1SNq1VcWrm26K0hxROINdpFYTNkUulHTWh8PvUnXgsB7p+oa4/R5cfzuhvIBhbvAPpuq5RKxM9IN/7n7pRIWHbbehcempE39i+2MPL6F7I0Z/MlLDSz1ba3R2Lf1iStWD2NlTd1juavA1J7jmw/hMDUSvpQqkMDwe311b36+YdYL7+IYO208wLD0MnRHqWFUyCe4qHGyjzzuHeGDupZvWHlOopcXofwzwBna+JIbHyB5XTa0hqV7LOtSMF/2W45G5VxMZ6xkXMJlpRPHzqr3P5Vftk+ADraQuYocwk4ga+qCmKb3uMCLGFnQBd/UWDXzZSim3v7DN4uMen158OI6HnGNXnny23IB8L6r+cc+TM5gJNb4UJZJcICoGTWOTs6ZsJeWLo1WuDfGc5X8LNSG4WRnqsT6Pey6AwLsnOamnlF1kp9zEyeJ2NGS0e+4Eda9sRT9dt4lBJPgoYP9OHu4XSjpfljM96jcYtP3I9Jy3VTkxqpQ/3FNYKBewgcNS8C7OZGnxrHefAnrhdbHVNweI2EwUARQLk7IgAOQu+nnTwrzhzOsKhVkNOjCbc8oT09Kw8xgtVJI2lablCt3EGlRKVAqV6sRuewU8yok2B3Tg1ZmfZiZJQj/+i5prISSwmz0wYY695xPaUt5aKi9aEOV4lpF7VPaNxoWnh4fJ+8WH/0A6Ut7vd7Qj+19VEA/DoIENg2sjznlQnXmC24iPCEhJO/JBoildk+eFOOljhFt/unvgqk1tLFwxCWxTUEYcH10KAvSqQ2AWmRTEJGJ/sgQC8c7q8AJW1E2jnh0t81yTVxqoLzn93pQ9nKY5ZcCHDJVQoueEjCrVTxT6THg83IL6sEsNVgFN0goVKTFnAAQE+9r3ds7qfj2wqR36pQuPi8Z1URrPSm0ChR1LOMpWuGuGHh6jDuZrn36xGER8j8JiIBudsgESHIQxeaZGr/O/Tqs96s889cF4k/59raPZ11iLAGdUG8rnS7TWuqKmMWV4my4VIREQItl4rK4y/dt1ieanCpoq1nTcZOq4vglTciMCxAQjTmyNEdBPsxh4x51sncfFvTwV/OwPpFnQ2ZD+FYPwTOeD+cc+l/eq1R/0Mf2iN4GtXOk9hDy9vCMxtFoSkyFAbmWqbAaJIEcqPP4i9mDiyba6CS9bRrobYwgsRoO6tsE7+k55GPryjFkylJla42EWdyg3NecDOeBcAcsds+wXs9zhI4+oHxPdE/GxNYcsWRAOCrcM7bEvTrpYZQZm3waC6IeqMjuG0upMV0Rjp5+0ImOFA32nI5xt4iujPdG5YfUhSVl3sC+ZvYX7/NSXzhcbJvYBUgt0CrGnhwroPuNsTk9tjg8oslVnCzL6fryLCYEYzQJyvRBpFJfjt+0I0hEcL0MEozeqncYKeZ3dyQBiKCof9d1DeDCStKNd0ZxQspf1mzH3po5KdkOg3vkMd9KyVj9J0NZ0e00xNjs3mepz6GJ9VXmshE2ueztWE7WMXnFRj+5xJcvE9o0otZHlbWyESUINet8XeoFwPuOexOr5GYrX5KxmE//Kls2oqdgKU7NCUKebfE5iaKJcrgGHq/nYcIO6pdVcGa9dbdCR+sAc7NhIHlbjiOWke48n7PKVPMoPgFXJtx/K9Gbwr3dKy82njIspayFkPppBQc95cYSgEWCZL+u3VFpSSa0LHO1omY7ww1TC9NjpYSst9OdXZV29xq0gEwxxZtxBIFA5zYikw5tfwkvCg7H9iMPVQGXXzBow4LFHxdM4cw4FBx/9ned6J2RXpCOi+b4qzVRg5h0pI5R7nA/sc/7kRrFrXSZnQ3gABXN/VQshAvfOLs91ZDLs/R4Nm9FYmjKdXqewHqmac4do3wt5e+E8CRyjk0lUZyUgaGqjPHmKx8YY5Aay31jxZvhqrbsotJSCd5pCnBEYgSu4npcL8EN45LdRAvQkctI4mwHnLjmXH+LAT6hbrtEax0JkjvPeCKdkNE6NblQQGF3ZLISN84AFmiZM3Yr9XJmDnEcPOD88Poer1lcjezht0EmuM9gAQmF91KBvDQoU6opMhlIPJgGysi86+7shY+MFHDfw8EUeOUwT0gaiL/HtERAZvhD17jSUpWHO3AVuJcuhDvxhTaznrWpB6l2PibLXaBF6LIGuE1Ms6hM2YqrGT0L1frKm9FZk6xzW9H/ADrGoegtz+eexHv1FipphaWjmPjkqyEEe+JDU3g7Bevkxe6OmdliiXq6ndtGFw0p7ylz4SSEJJH0QKUljA4+RRW/unFdDLtXbz1qRyJd8bS1w3ycmz/4=,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 f7e32a829..94d48dea1 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: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,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 752458173..ee639f3fa 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 1ccb3715d..6070cce04 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 beaedf416..982c8b97b 100644 --- a/server/doi/queries.ts +++ b/server/doi/queries.ts @@ -1,3 +1,5 @@ +import type { DepositState } from 'server/crossrefDepositRecord/queries'; + import { createCrossrefDepositRecord, updateCrossrefDepositRecord, @@ -5,6 +7,7 @@ import { import { getCommunityDepositTarget } from 'server/depositTarget/queries'; import { DoilyUnsupportedRecordError, + findPrimaryDoilyRecord, isDoilyEnabledForCommunity, submitDepositViaDoily, } from 'server/doily/client'; @@ -20,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'; @@ -78,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 @@ -99,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({ @@ -173,7 +185,6 @@ export const setDoiData = async ( const ids = { collectionId, pubId }; const isDoilyEnabled = await isDoilyEnabledForCommunity(communityId); - console.log('isDoilyEnabled', isDoilyEnabled); // Doily path (community feature flag `doilyDeposits`): build the deposit // JSON as usual, but hand registration to Doily, which maps it to Crossref @@ -187,14 +198,36 @@ export const setDoiData = async ( 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: expect(dois.pub ?? dois.collection ?? dois.community), + 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 }), + 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) { diff --git a/server/doily/__tests__/webhookContract.test.ts b/server/doily/__tests__/webhookContract.test.ts new file mode 100644 index 000000000..911f8fa81 --- /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 index 51b78e4b8..ab8c4efba 100644 --- a/server/doily/client.ts +++ b/server/doily/client.ts @@ -2,20 +2,6 @@ import { env } from 'server/env'; import { getFeatureFlagForUserAndCommunity } from 'server/featureFlag/queries'; import { Community } from 'server/models'; -// Client for Doily, KF's DOI broker. When the `doilyDeposits` community -// feature flag is on (and DOILY_URL/DOILY_API_TOKEN are configured), Crossref -// deposits are delegated to Doily instead of the internal 4.4.1 pipeline: -// PubPub still builds its deposit JSON, Doily maps it to 5.4.0 records, -// registers them under the community's Doily organization, and owns polling. -// -// IMPORTANT (timestamp one-way door): Doily stamps deposits with -// YYYYMMDDhhmmss, which always exceeds our epoch-ms timestamps. Once Doily -// has touched a DOI, a legacy re-deposit of it is silently rejected by -// Crossref as stale. So once a community's flag is on, transient Doily -// failures must FAIL the request loudly — never fall back to the legacy path. -// Falling back is only safe for record types Doily rejects outright (422: -// conference, supplement), which the legacy path keeps owning by design. - export const DOILY_FLAG = 'doilyDeposits'; export const isDoilyConfigured = () => Boolean(env.DOILY_URL && env.DOILY_API_TOKEN); @@ -54,11 +40,11 @@ export class DoilyUnsupportedRecordError extends Error { const orgIdByCommunityId = new Map(); /** - * 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. + * 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 resolveDoilyOrgId = async (communityId: string): Promise => { +export const findDoilyOrgId = async (communityId: string): Promise => { const cached = orgIdByCommunityId.get(communityId); if (cached) { return cached; @@ -66,7 +52,7 @@ export const resolveDoilyOrgId = async (communityId: string): Promise => const community = await Community.findOne({ where: { id: communityId }, - attributes: ['id', 'subdomain', 'title', 'kfOrgId'], + attributes: ['id', 'subdomain'], }); if (!community) { throw new Error(`Community ${communityId} not found`); @@ -78,9 +64,30 @@ export const resolveDoilyOrgId = async (communityId: string): Promise => } 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) { - orgIdByCommunityId.set(communityId, existing.id); - return existing.id; + 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) { @@ -115,6 +122,53 @@ export type DoilyRecordResult = { 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 @@ -148,8 +202,7 @@ export const submitDepositViaDoily = async (options: { } const { records } = (await res.json()) as { records: DoilyRecordResult[] }; - const primary = - records.find((record) => record.doi === primaryDoi) ?? records[records.length - 1]; + const primary = findPrimaryDoilyRecord(records, primaryDoi); if (primary && !primary.submitted && primary.action !== 'unchanged') { const messages = (primary.blockers ?? []).map((blocker) => blocker.message).join('; '); throw new Error( diff --git a/server/doily/webhook.ts b/server/doily/webhook.ts new file mode 100644 index 000000000..239e444cd --- /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 000000000..b894a85c8 --- /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 9dfd920be..17c5ab003 100644 --- a/server/envSchema.ts +++ b/server/envSchema.ts @@ -141,6 +141,12 @@ export const envSchema = z.object({ .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 e0a6ffcd8..f45287b81 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/server.ts b/server/server.ts index 079bed4f2..227449ff1 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 123ffac70..630686461 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 c8e812590..ef2f81876 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 3dcb38d0b..c9bc8c4ea 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 000000000..c88413734 --- /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 index 78cef205c..043f23a86 100644 --- a/tools/exportDoiRegistrations.ts +++ b/tools/exportDoiRegistrations.ts @@ -10,6 +10,7 @@ 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); @@ -78,6 +79,7 @@ async function main() { const stream = createWriteStream(out); let written = 0; + let skipped = 0; const writeRow = ( scope: 'pub' | 'collection', @@ -88,6 +90,7 @@ async function main() { community?: CommunityLite; crossrefDepositRecord?: { depositJson: object | null; + status?: string | null; createdAt?: Date; updatedAt?: Date; }; @@ -98,6 +101,15 @@ async function main() { 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 }), @@ -127,6 +139,10 @@ async function main() { 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); } diff --git a/tools/fix-dois/discover.ts b/tools/fix-dois/discover.ts index 7d69aee84..20a970084 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 05351cadc..1e69b8b9f 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", diff --git a/tools/migrations/2026_08_13_addDepositStateToCrossrefDepositRecords.js b/tools/migrations/2026_08_13_addDepositStateToCrossrefDepositRecords.js new file mode 100644 index 000000000..495d52646 --- /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 7c7eaa4be..c6f58e1a9 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 00ea29f90..8e7649291 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 000000000..8c17f32c0 --- /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 000000000..2de376d96 --- /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 000000000..0540e09d1 --- /dev/null +++ b/utils/pub/__tests__/abstract.test.ts @@ -0,0 +1,113 @@ +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 9706f77ba..d563ba872 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 1bd0776c4..3d2a85018 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/workers/tasks/export/transforms/extractMetaContent.ts b/workers/tasks/export/transforms/extractMetaContent.ts index dffd518bd..04dd46a1d 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; }; From d9e88b7db01832aa650daf61ea77df1cc50c902c Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 13 Aug 2026 22:05:56 +0200 Subject: [PATCH 4/4] chore: fix --- utils/pub/__tests__/abstract.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/utils/pub/__tests__/abstract.test.ts b/utils/pub/__tests__/abstract.test.ts index 0540e09d1..72d0b9ce1 100644 --- a/utils/pub/__tests__/abstract.test.ts +++ b/utils/pub/__tests__/abstract.test.ts @@ -26,7 +26,9 @@ describe('getAbstractText', () => { }); it('trims the heading text (utils/pub/metadata.js used to miss this)', () => { - expect(getAbstractText(doc(h1(text(' Abstract')), para(text('Trimmed.'))))).toBe('Trimmed.'); + 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)', () => { @@ -65,7 +67,10 @@ describe('getAbstractText', () => { ['an empty doc', doc()], [ 'a level-2 heading', - doc({ type: 'heading', attrs: { level: 2 }, content: [text('Abstract')] }, para(text('x'))), + doc( + { type: 'heading', attrs: { level: 2 }, content: [text('Abstract')] }, + para(text('x')), + ), ], [ // metadata.js read `firstChild.attrs.level` unguarded and threw here.