From f90c078301268257668a393fbb47f2022ea373cc Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Fri, 15 May 2026 23:37:10 -0400 Subject: [PATCH 01/13] oidc mostly in place, hub extraction mostly functioning --- .../CommunityCreate/CommunityCreate.tsx | 39 +- .../CommunityAdminSettings.tsx | 3 + .../CommunitySettings/TransferOwnership.tsx | 154 +++ server/community/model.ts | 4 + server/community/queries.ts | 1 + server/kf/api.ts | 990 ++++++++++++++++++ server/kf/auth.ts | 150 +++ server/routes/communityCreate.tsx | 6 +- server/routes/index.ts | 12 +- server/routes/login.kf.tsx | 16 + server/routes/passwordReset.kf.tsx | 17 + server/routes/signup.kf.tsx | 29 + .../2026_05_15_addKfOrgIdToCommunities.js | 17 + .../2026_06_15_makeKfOrgIdNotNull.js | 33 + .../2026_06_15_removePasswordColumns.js | 35 + utils/api/schemas/community.ts | 2 + 16 files changed, 1500 insertions(+), 8 deletions(-) create mode 100644 client/containers/DashboardSettings/CommunitySettings/TransferOwnership.tsx create mode 100644 server/kf/api.ts create mode 100644 server/kf/auth.ts create mode 100644 server/routes/login.kf.tsx create mode 100644 server/routes/passwordReset.kf.tsx create mode 100644 server/routes/signup.kf.tsx create mode 100644 tools/migrations/2026_05_15_addKfOrgIdToCommunities.js create mode 100644 tools/migrations/2026_06_15_makeKfOrgIdNotNull.js create mode 100644 tools/migrations/2026_06_15_removePasswordColumns.js diff --git a/client/containers/CommunityCreate/CommunityCreate.tsx b/client/containers/CommunityCreate/CommunityCreate.tsx index 79609a920..b5cba2f5f 100644 --- a/client/containers/CommunityCreate/CommunityCreate.tsx +++ b/client/containers/CommunityCreate/CommunityCreate.tsx @@ -82,10 +82,19 @@ const CommunityCreatedView = ({ subdomain, hubName }: { subdomain: string; hubNa ); }; +type KFOrg = { + id: string; + name: string; + slug: string; + type: 'personal' | 'shared'; + role: string; +}; + type Props = { hubData?: Hub | null; templates?: CommunityTemplate[]; hubCommunities?: { id: string; title: string; subdomain: string; avatar?: string | null }[]; + kfOrgs?: KFOrg[]; }; const HubBrandedHeader = ({ hub }: { hub: Hub }) => { @@ -109,7 +118,7 @@ const HubBrandedHeader = ({ hub }: { hub: Hub }) => { }; const CommunityCreate = (props: Props) => { - const { hubData, templates = [], hubCommunities = [] } = props; + const { hubData, templates = [], hubCommunities = [], kfOrgs = [] } = props; const { loginData, locationData } = usePageContext(); const altchaRef = useRef(null); const hubSlug = hubData?.slug || locationData?.query?.hub || null; @@ -126,6 +135,12 @@ const CommunityCreate = (props: Props) => { const [selectedTemplateId, setSelectedTemplateId] = useState(null); const [cloneCommunityId, setCloneCommunityId] = useState(null); + // KF org picker: default to personal org, or first available + const personalOrg = kfOrgs.find((o) => o.type === 'personal'); + const [selectedKfOrgId, setSelectedKfOrgId] = useState( + personalOrg?.id ?? kfOrgs[0]?.id ?? null, + ); + const hasHub = !!hubData; const hubAccentDark = hubData?.accentColorDark || '#2D2E2F'; @@ -163,6 +178,7 @@ const CommunityCreate = (props: Props) => { ...(selectedTemplateId === CLONE_MARKER && cloneCommunityId ? { cloneCommunityId } : {}), + ...(selectedKfOrgId ? { kfOrgId: selectedKfOrgId } : {}), }); setCreateIsLoading(false); setIsCreated(true); @@ -308,6 +324,27 @@ const CommunityCreate = (props: Props) => { onChange={onDescriptionChange} helperText={`${description.length}/280 characters`} /> + {kfOrgs.length > 1 && ( + +
+ +
+
+ )} {selectedTemplateId ? ( { + + ); diff --git a/client/containers/DashboardSettings/CommunitySettings/TransferOwnership.tsx b/client/containers/DashboardSettings/CommunitySettings/TransferOwnership.tsx new file mode 100644 index 000000000..cfb270367 --- /dev/null +++ b/client/containers/DashboardSettings/CommunitySettings/TransferOwnership.tsx @@ -0,0 +1,154 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +import { Button, Callout, Classes } from '@blueprintjs/core'; + +import { apiFetch } from 'client/utils/apiFetch'; +import { SettingsSection } from 'components'; + +type KFOrg = { + id: string; + name: string; + slug: string; + type: 'personal' | 'shared'; + role: string; +}; + +type Props = { + communityData: { + id: string; + title: string; + kfOrgId: string | null; + }; +}; + +const TransferOwnership = (props: Props) => { + const { communityData } = props; + const [orgs, setOrgs] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedOrgId, setSelectedOrgId] = useState(null); + const [isTransferring, setIsTransferring] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const loadOrgs = useCallback(async () => { + try { + const data = await apiFetch.get('/api/kf/my-orgs'); + const fetchedOrgs: KFOrg[] = data.orgs ?? []; + setOrgs(fetchedOrgs); + // Default to current org if set, otherwise first org + if (communityData.kfOrgId && fetchedOrgs.some((o) => o.id === communityData.kfOrgId)) { + setSelectedOrgId(communityData.kfOrgId); + } else if (fetchedOrgs.length > 0) { + setSelectedOrgId(fetchedOrgs[0].id); + } + } catch { + setError('Failed to load organizations'); + } finally { + setLoading(false); + } + }, [communityData.kfOrgId]); + + useEffect(() => { + loadOrgs(); + }, [loadOrgs]); + + const selectedOrg = orgs.find((o) => o.id === selectedOrgId); + const isCurrentOrg = selectedOrgId === communityData.kfOrgId; + + const handleTransfer = async () => { + if (!selectedOrgId || isCurrentOrg) return; + setIsTransferring(true); + setError(null); + setSuccess(null); + try { + await apiFetch.post('/api/kf/transfer-community', { + communityId: communityData.id, + kfOrgId: selectedOrgId, + }); + setSuccess( + `Community transferred to ${selectedOrg?.name ?? 'the selected organization'}.`, + ); + // Update the local state so the button disables + communityData.kfOrgId = selectedOrgId; + } catch (err: any) { + setError(err?.error || err?.message || 'Failed to transfer community'); + } finally { + setIsTransferring(false); + } + }; + + if (loading) { + return ( + +

Loading organizations...

+
+ ); + } + + // Need at least 2 orgs to have somewhere to transfer to + if (orgs.length < 2) { + return null; + } + + const currentOrg = orgs.find((o) => o.id === communityData.kfOrgId); + + return ( + +

+ Transfer this community to a different KF Account. The target account will become + the billing owner of this community. +

+ + {currentOrg && ( +

+ Currently owned by: {currentOrg.name} + {currentOrg.type === 'personal' ? ' (Personal)' : ''} +

+ )} + + {error && ( + + {error} + + )} + + {success && ( + + {success} + + )} + +
+
+
+ +
+
+
+
+ ); +}; + +export default TransferOwnership; diff --git a/server/community/model.ts b/server/community/model.ts index ac2d30609..26cd2cd81 100644 --- a/server/community/model.ts +++ b/server/community/model.ts @@ -241,6 +241,10 @@ export class Community extends Model< @Column(DataType.UUID) declare templateId: string | null; + /** KF Auth organization that owns this community (for billing/ownership) */ + @Column(DataType.TEXT) + declare kfOrgId: string | null; + @BelongsTo(() => CommunityTemplate, { as: 'template', foreignKey: 'templateId', diff --git a/server/community/queries.ts b/server/community/queries.ts index b1ec5c8f6..2f7d3fac0 100644 --- a/server/community/queries.ts +++ b/server/community/queries.ts @@ -88,6 +88,7 @@ export const createCommunity = async ( accentColorDark: inputValues.accentColorDark ?? '#000000', navigation: [{ type: 'page', id: homePageId }], hideCreatePubButton: true, + ...(inputValues.kfOrgId ? { kfOrgId: inputValues.kfOrgId } : {}), }, { actorId: userData.id }, ); diff --git a/server/kf/api.ts b/server/kf/api.ts new file mode 100644 index 000000000..8be970531 --- /dev/null +++ b/server/kf/api.ts @@ -0,0 +1,990 @@ +/** + * KF Auth integration routes for PubPub. + * + * OIDC login/callback: + * GET /auth/login — redirect to KF Auth + * GET /auth/callback — handle OIDC callback, create session + * POST /auth/logout — clear session + redirect to KF Auth logout + * + * Internal service-to-service endpoints (KF_INTERNAL_API_KEY): + * POST /api/kf/profile-sync — receive profile updates from KF Auth + * GET /api/kf/branding — return community branding for login page + * GET /api/kf/summary — return community list for a KF org + * GET /api/kf/billing/usage — return usage stats for billing (placeholder) + * + * Session-authenticated endpoints: + * GET /api/kf/my-orgs — return current user's KF Account memberships + * POST /api/kf/transfer-community — transfer community ownership to a different KF Account + */ + +import { promisify } from 'util'; +import { timingSafeEqual } from 'crypto'; + +import { Router } from 'express'; + +import { Community, Collection, Member, Pub, PubAttribution, Release, User } from 'server/models'; +import { sequelize } from 'server/sequelize'; +import { ensureUserIsCommunityAdmin } from 'utils/ensureUserIsCommunityAdmin'; +import { getHashedUserId } from 'utils/caching/getHashedUserId'; +import { isProd, isDuqDuq } from 'utils/environment'; + +import { + buildAuthorizeUrl, + exchangeCode, + fetchUserInfo, + fetchUserOrgs, + KF_AUTH_URL, +} from './auth'; + +// ── Helpers ────────────────────────────────────────────────────────── + +const KF_INTERNAL_API_KEY = process.env.KF_INTERNAL_API_KEY; + +function requireInternalKey( + req: any, + res: any, + next: () => void, +): void { + if (!KF_INTERNAL_API_KEY) { + res.status(500).json({ error: 'KF_INTERNAL_API_KEY not configured' }); + return; + } + const auth = req.headers.authorization; + const expected = `Bearer ${KF_INTERNAL_API_KEY}`; + // Use timing-safe comparison to prevent timing attacks on the API key + if ( + !auth || + auth.length !== expected.length || + !timingSafeEqual(Buffer.from(auth), Buffer.from(expected)) + ) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + next(); +} + +/** + * Derive the community hostname the user came from. + * Needed because the OIDC callback always hits the main domain. + */ +function getCommunityHost(req: any): string { + // Use the communityHostname header if set by the reverse proxy, + // otherwise fall back to the raw hostname. + return req.headers.communityhostname || req.hostname; +} + +// Cookie name for OIDC state (verifier stored in session for custom domain compat) +const STATE_COOKIE = 'kf_oauth_state'; + +// ── Router ─────────────────────────────────────────────────────────── + +export const router = Router(); + +// ─── OIDC login ────────────────────────────────────────────────────── + +router.get('/auth/login', (req: any, res: any) => { + const communityHost = getCommunityHost(req); + const returnTo = req.query.return_to || '/'; + + // Encode the community hostname + return path in state so we can + // redirect back after the OIDC callback. + const statePayload = JSON.stringify({ host: communityHost, returnTo }); + const stateToken = Buffer.from(statePayload).toString('base64url'); + + const { url, codeVerifier } = buildAuthorizeUrl(stateToken); + + const cookieOpts = { + httpOnly: true, + secure: isProd(), + sameSite: 'lax' as const, + path: '/', + maxAge: 600_000, // 10 minutes + // Set on .pubpub.org so the callback (on www.pubpub.org) can read it + ...(isProd() && + communityHost.indexOf('pubpub.org') > -1 && { + domain: '.pubpub.org', + }), + }; + + res.cookie(STATE_COOKIE, stateToken, cookieOpts); + + // Store verifier in session (not cookie) so it works across domains. + // Custom domain sessions are scoped to their domain, and the callback + // hits the same domain since PubPub proxies all requests. + req.session.kfOauthVerifier = codeVerifier; + req.session.save(() => { + return res.redirect(url); + }); +}); + +// ─── OIDC callback ─────────────────────────────────────────────────── + +router.get('/auth/callback', async (req: any, res: any) => { + try { + const { code, state, error } = req.query; + + if (error) { + console.error('KF Auth error:', error, req.query.error_description); + return res.redirect('/login?error=auth_failed'); + } + + if (!code || !state) { + return res.redirect('/login?error=missing_params'); + } + + // Validate state + const savedState = req.cookies[STATE_COOKIE]; + const codeVerifier = req.session?.kfOauthVerifier; + + // Clear OIDC state + res.clearCookie(STATE_COOKIE, { path: '/' }); + if (req.session) { + delete req.session.kfOauthVerifier; + } + + if (!savedState || savedState !== state) { + return res.redirect('/login?error=invalid_state'); + } + + if (!codeVerifier) { + return res.redirect('/login?error=missing_verifier'); + } + + // Exchange authorization code for tokens + const tokens = await exchangeCode(code, codeVerifier); + + // Fetch user info from KF Auth + const userInfo = await fetchUserInfo(tokens.access_token); + const kfUserId = userInfo.sub; + + // Look up PubPub user by ID (PubPub ID = KF Auth ID after seeding) + const user = await User.findOne({ where: { id: kfUserId } }); + + if (!user) { + console.error(`No PubPub user found for KF Auth ID: ${kfUserId}`); + return res.redirect('/login?error=user_not_found'); + } + + // Create a standard Passport session (indistinguishable from old login) + const logIn = promisify(req.logIn.bind(req)); + await logIn(user); + + // Set the CDN cache cookie + const hashedUserId = getHashedUserId(user); + res.cookie('pp-lic', `pp-li-${hashedUserId}`, { + ...(isProd() && + req.hostname.indexOf('pubpub.org') > -1 && { + domain: '.pubpub.org', + }), + ...(isDuqDuq() && + req.hostname.indexOf('pubpub.org') > -1 && { + domain: '.duqduq.org', + }), + maxAge: 30 * 24 * 60 * 60 * 1000, + }); + + // Parse state to get the community host + return path + let redirectUrl = '/'; + try { + const statePayload = JSON.parse( + Buffer.from(state, 'base64url').toString(), + ); + const host = statePayload.host || ''; + const returnTo = statePayload.returnTo || '/'; + + if (host && host !== req.hostname) { + // Redirect back to the community the user came from + const protocol = isProd() ? 'https' : 'http'; + redirectUrl = `${protocol}://${host}${returnTo}`; + } else { + redirectUrl = returnTo; + } + } catch { + // If state parsing fails, just go to root + redirectUrl = '/'; + } + + return res.redirect(redirectUrl); + } catch (err) { + console.error('OIDC callback error:', err); + return res.redirect('/login?error=callback_failed'); + } +}); + +// ─── Logout ────────────────────────────────────────────────────────── + +router.post('/auth/logout', (req: any, res: any) => { + // Clear local session + req.logout(() => { + // Set pp-lic to logged-out state + res.cookie('pp-lic', 'pp-lo', { + ...(isProd() && + req.hostname.indexOf('pubpub.org') > -1 && { + domain: '.pubpub.org', + }), + maxAge: 30 * 24 * 60 * 60 * 1000, + }); + + // Redirect to KF Auth's logout endpoint so the SSO session is also cleared + const returnUrl = `${process.env.APP_URL || 'http://localhost:9876'}/`; + return res.redirect( + `${KF_AUTH_URL}/api/auth/sign-out?callbackURL=${encodeURIComponent(returnUrl)}`, + ); + }); +}); + +// ─── Profile sync (webhook from KF Auth) ───────────────────────────── + +router.post('/api/kf/profile-sync', requireInternalKey, async (req: any, res: any) => { + try { + const { userId, givenName, familyName, displayName, email, image } = + req.body; + + if (!userId) { + return res.status(400).json({ error: 'userId is required' }); + } + + const user = await User.findOne({ where: { id: userId } }); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + const updates: Record = {}; + if (displayName !== undefined) updates.fullName = displayName; + if (givenName !== undefined) updates.firstName = givenName; + if (familyName !== undefined) updates.lastName = familyName; + if (email !== undefined) updates.email = email.toLowerCase(); + if (image !== undefined) updates.avatar = image; + + // Recalculate initials when name changes + if (givenName !== undefined || familyName !== undefined || displayName !== undefined) { + const first = givenName ?? user.firstName ?? ''; + const last = familyName ?? user.lastName ?? ''; + if (first || last) { + updates.initials = `${first.charAt(0)}${last.charAt(0)}`.toUpperCase(); + } + } + + if (Object.keys(updates).length > 0) { + await user.update(updates); + } + + return res.status(200).json({ ok: true }); + } catch (err) { + console.error('Profile sync error:', err); + return res.status(500).json({ error: 'Internal error' }); + } +}); + +// ─── Branding API (for KF Auth login page) ─────────────────────────── + +router.get('/api/kf/branding', requireInternalKey, async (req: any, res: any) => { + try { + const { subdomain, context } = req.query; + const slug = context || subdomain; + + if (!slug) { + return res.status(400).json({ error: 'subdomain or context param required' }); + } + + const community = await Community.findOne({ + where: { subdomain: slug }, + attributes: [ + 'title', + 'avatar', + 'headerLogo', + 'accentColorLight', + 'accentColorDark', + 'subdomain', + ], + }); + + if (!community) { + return res.status(404).json({ error: 'Community not found' }); + } + + return res.json({ + communityName: community.title, + logoUrl: community.avatar || community.headerLogo, + accentColorLight: community.accentColorLight, + accentColorDark: community.accentColorDark, + headerLogo: community.headerLogo, + subdomain: community.subdomain, + }); + } catch (err) { + console.error('Branding API error:', err); + return res.status(500).json({ error: 'Internal error' }); + } +}); + +// ─── Summary API (for KF Account roster / Hub) ────────────────────── + +router.get('/api/kf/summary', requireInternalKey, async (req: any, res: any) => { + try { + const { kf_org_id } = req.query; + if (!kf_org_id) { + return res.status(400).json({ error: 'kf_org_id is required' }); + } + + const communities = await Community.findAll({ + where: { kfOrgId: kf_org_id }, + attributes: ['id', 'title', 'subdomain', 'domain', 'avatar'], + }); + + const accounts = await Promise.all( + communities.map(async (community: any) => { + const [pubCount, memberCount] = await Promise.all([ + Pub.count({ where: { communityId: community.id } }), + Member.count({ + where: { communityId: community.id }, + }), + ]); + + const host = community.domain || `${community.subdomain}.pubpub.org`; + const protocol = isProd() ? 'https' : 'http'; + + return { + id: community.id, + slug: community.subdomain, + type: 'community', + name: community.title, + url: `${protocol}://${host}`, + avatar: community.avatar || null, + stats: { pubs: pubCount, members: memberCount }, + collections: [], + }; + }), + ); + + return res.json({ accounts }); + } catch (err) { + console.error('Summary API error:', err); + return res.status(500).json({ error: 'Internal error' }); + } +}); + +// ─── Billing usage API (placeholder) ───────────────────────────────── + +router.get('/api/kf/billing/usage', requireInternalKey, async (req: any, res: any) => { + try { + const { kf_org_id } = req.query; + if (!kf_org_id) { + return res.status(400).json({ error: 'kf_org_id is required' }); + } + + const communityCount = await Community.count({ + where: { kfOrgId: kf_org_id }, + }); + + // Placeholder — just return community count for now + return res.json({ + kf_org_id, + line_items: [ + { key: 'communities', quantity: communityCount }, + ], + }); + } catch (err) { + console.error('Billing usage API error:', err); + return res.status(500).json({ error: 'Internal error' }); + } +}); + +// ─── User's KF orgs (session-authenticated) ───────────────────────── + +router.get('/api/kf/my-orgs', async (req: any, res: any) => { + if (!req.user?.id) { + return res.status(401).json({ error: 'Not authenticated' }); + } + try { + const orgs = await fetchUserOrgs(req.user.id); + return res.json({ orgs }); + } catch (err) { + console.error('Failed to fetch KF orgs:', err); + return res.status(500).json({ error: 'Failed to fetch organizations' }); + } +}); + +// ─── Transfer community ownership ─────────────────────────────────── + +router.post('/api/kf/transfer-community', async (req: any, res: any) => { + if (!req.user?.id) { + return res.status(401).json({ error: 'Not authenticated' }); + } + + const { communityId, kfOrgId } = req.body; + if (!communityId || !kfOrgId) { + return res.status(400).json({ error: 'communityId and kfOrgId are required' }); + } + + try { + // Verify the user is an admin of this community + await ensureUserIsCommunityAdmin({ ...req, id: communityId }); + } catch { + return res.status(403).json({ error: 'You must be an admin of this community' }); + } + + try { + // Verify the user belongs to the target org + const userOrgs = await fetchUserOrgs(req.user.id); + const targetOrg = userOrgs.find((o) => o.id === kfOrgId); + if (!targetOrg) { + return res.status(403).json({ error: 'You are not a member of the target organization' }); + } + + // Update the community's kfOrgId + const [updatedCount] = await Community.update( + { kfOrgId }, + { where: { id: communityId } }, + ); + + if (updatedCount === 0) { + return res.status(404).json({ error: 'Community not found' }); + } + + return res.json({ success: true, kfOrgId }); + } catch (err) { + console.error('Transfer community error:', err); + return res.status(500).json({ error: 'Internal error' }); + } +}); + +// ─── Community detail (for Hubs dashboard) ─────────────────────────── + +router.get('/api/kf/community/:id/detail', requireInternalKey, async (req: any, res: any) => { + try { + const communityId = req.params.id; + // Optional date range params for analytics + const startDate = req.query.startDate || null; + const endDate = req.query.endDate || null; + // Determine analytics date range + const analyticsStart = startDate || new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const analyticsEnd = endDate || new Date().toISOString().slice(0, 10); + const pubsMonthsBack = startDate + ? Math.max(Math.ceil((Date.now() - new Date(startDate).getTime()) / (30 * 24 * 60 * 60 * 1000)), 3) + : 24; + + const community = await Community.findByPk(communityId, { + attributes: ['id', 'title', 'subdomain', 'domain', 'avatar', 'accentColorDark', 'accentColorLight', 'headerLogo', 'heroLogo', 'description', 'heroBackgroundImage', 'heroImage'], + }); + + if (!community) { + return res.status(404).json({ error: 'Community not found' }); + } + + const protocol = isProd() ? 'https' : 'http'; + const host = (community as any).domain || `${(community as any).subdomain}.pubpub.org`; + + // Run queries in parallel + const hasReleaseInclude = { + model: Release, + as: 'releases', + attributes: [], + required: true, + where: {}, + }; + + const [ + pubCount, + memberCount, + collectionCount, + releaseCount, + members, + recentPubRows, + pubsByMonthRows, + topAuthorsRaw, + collectionsRaw, + ] = await Promise.all([ + Pub.count({ where: { communityId }, include: [hasReleaseInclude] }), + Member.count({ where: { communityId } }), + Collection.count({ where: { communityId } }), + sequelize.query( + `SELECT COUNT(*)::int AS count FROM "Releases" r INNER JOIN "Pubs" p ON r."pubId" = p.id WHERE p."communityId" = :communityId`, + { replacements: { communityId }, type: 'SELECT' as any }, + ).then((rows: any) => rows[0]?.count ?? 0), + // Members with user details + Member.findAll({ + where: { communityId }, + attributes: ['id', 'userId', 'permissions', 'isOwner', 'createdAt'], + include: [{ + model: User, + as: 'user', + attributes: ['fullName', 'avatar', 'slug'], + }], + order: [['createdAt', 'ASC']], + limit: 500, + }), + // Recent pubs (released only) + Pub.findAll({ + where: { communityId }, + attributes: ['id', 'title', 'slug', 'description', 'avatar', 'customPublishedAt', 'createdAt'], + include: [ + hasReleaseInclude, + { + model: PubAttribution, + as: 'attributions', + attributes: ['name', 'avatar', 'order', 'isAuthor'], + where: { isAuthor: true }, + required: false, + include: [{ model: User, as: 'user', attributes: ['fullName', 'avatar', 'slug'] }], + }, + ], + order: [['createdAt', 'DESC']], + limit: 500, + }), + // Pubs by month + sequelize.query( + `SELECT + to_char(date_trunc('month', p."createdAt"), 'YYYY-MM') AS month, + COUNT(*)::int AS count + FROM "Pubs" p + INNER JOIN "Releases" r ON r."pubId" = p.id + WHERE p."communityId" = :communityId + AND p."createdAt" >= NOW() - INTERVAL '${pubsMonthsBack} months' + GROUP BY 1 ORDER BY 1`, + { + replacements: { communityId }, + type: 'SELECT' as any, + }, + ), + // Top authors + PubAttribution.findAll({ + attributes: ['userId', 'name', 'avatar'], + where: { isAuthor: true }, + include: [ + { + model: Pub, + as: 'pub', + attributes: [], + where: { communityId }, + required: true, + include: [hasReleaseInclude], + }, + { + model: User, + as: 'user', + attributes: ['fullName', 'avatar', 'slug'], + required: false, + }, + ], + }), + // Collections with pub counts + sequelize.query( + `SELECT + c."id", c."title", c."slug", c."kind", + COUNT(cp."pubId")::int AS "pubCount" + FROM "Collections" c + LEFT JOIN "CollectionPubs" cp ON cp."collectionId" = c."id" + WHERE c."communityId" = :communityId + GROUP BY c."id", c."title", c."slug", c."kind" + ORDER BY "pubCount" DESC`, + { replacements: { communityId }, type: 'SELECT' as any }, + ), + ]); + + // Format members + const memberList = members.map((m: any) => { + const mj = m.toJSON(); + return { + id: mj.id, + userId: mj.userId, + name: mj.user?.fullName ?? 'Unknown', + avatar: mj.user?.avatar ?? null, + slug: mj.user?.slug ?? null, + role: mj.isOwner ? 'owner' : (mj.permissions ?? 'view'), + createdAt: mj.createdAt, + }; + }); + + // Format recent pubs + const recentPubs = recentPubRows.map((p: any) => { + const pj = p.toJSON(); + const authors = (pj.attributions || []) + .sort((a: any, b: any) => (a.order || 0) - (b.order || 0)) + .map((attr: any) => ({ + name: attr.user?.fullName || attr.name || 'Unknown', + avatar: attr.user?.avatar || null, + slug: attr.user?.slug || null, + })); + return { + id: pj.id, + title: pj.title, + slug: pj.slug, + description: pj.description, + avatar: pj.avatar, + publishedAt: pj.customPublishedAt || pj.createdAt, + authors, + }; + }); + + // Aggregate top authors + const authorMap = new Map(); + for (const attr of topAuthorsRaw) { + const a = (attr as any).toJSON(); + const key = a.userId || `name:${a.name}`; + const existing = authorMap.get(key); + if (existing) { + existing.count++; + } else { + authorMap.set(key, { + name: a.user?.fullName || a.name || 'Unknown', + avatar: a.user?.avatar || a.avatar || null, + slug: a.user?.slug || null, + count: 1, + }); + } + } + const topAuthors = [...authorMap.values()] + .sort((a, b) => b.count - a.count); + + // Try to get analytics (daily views for selected range) from matview + let dailyViews: Array<{ date: string; views: number }> = []; + try { + dailyViews = (await sequelize.query( + `SELECT + date::text, + page_views::int AS views + FROM analytics_daily_summary + WHERE "communityId" = :communityId + AND date >= :analyticsStart::date + AND date <= :analyticsEnd::date + ORDER BY date`, + { + replacements: { communityId, analyticsStart, analyticsEnd }, + type: 'SELECT' as any, + }, + )) as any; + } catch { + // Matview may not exist in dev — that's fine + } + + // Total views/downloads from the selected range + let totalPageViews = 0; + let totalDownloads = 0; + try { + const [totals] = (await sequelize.query( + `SELECT + COALESCE(SUM(page_views), 0)::int AS views, + COALESCE(SUM(downloads), 0)::int AS downloads + FROM analytics_daily_summary + WHERE "communityId" = :communityId + AND date >= :analyticsStart::date + AND date <= :analyticsEnd::date`, + { + replacements: { communityId, analyticsStart, analyticsEnd }, + type: 'SELECT' as any, + }, + )) as any[]; + totalPageViews = totals?.views ?? 0; + totalDownloads = totals?.downloads ?? 0; + } catch { + // Matview may not exist + } + + return res.json({ + community: { + id: (community as any).id, + title: (community as any).title, + subdomain: (community as any).subdomain, + domain: (community as any).domain, + url: `${protocol}://${host}`, + avatar: (community as any).avatar, + headerLogo: (community as any).headerLogo, + heroLogo: (community as any).heroLogo, + description: (community as any).description, + accentColorDark: (community as any).accentColorDark, + accentColorLight: (community as any).accentColorLight, + heroBackgroundImage: (community as any).heroBackgroundImage, + heroImage: (community as any).heroImage, + }, + stats: { + pubs: pubCount, + members: memberCount, + collections: collectionCount, + releases: releaseCount, + totalPageViews, + totalDownloads, + }, + members: memberList, + recentPubs, + topAuthors, + pubsByMonth: pubsByMonthRows, + dailyViews, + collections: (collectionsRaw as any[]).map((c: any) => ({ + id: c.id, + title: c.title, + slug: c.slug, + kind: c.kind ?? 'tag', + pubCount: c.pubCount, + })), + }); + } catch (err) { + console.error('Community detail API error:', err); + return res.status(500).json({ error: 'Internal error' }); + } +}); + +// ─── Suggested Communities (domain-based discovery) ────────────────── + +router.get('/api/kf/suggested-communities', requireInternalKey, async (req: any, res: any) => { + try { + const domainsParam = req.query.domains as string; + const excludeIds = req.query.excludeIds as string || ''; + if (!domainsParam) return res.json([]); + + const domains = domainsParam.split(',').map((d: string) => d.trim().toLowerCase()).filter(Boolean); + if (domains.length === 0) return res.json([]); + + const excludeList = excludeIds.split(',').filter(Boolean); + + // Build domain match clause for User.email + const domainClauses: string[] = []; + const replacements: Record = {}; + domains.forEach((d, i) => { + domainClauses.push( + `(LOWER(SUBSTRING("Users"."email" FROM '@(.+)$')) = :dom${i} OR LOWER(SUBSTRING("Users"."email" FROM '@(.+)$')) LIKE :domLike${i})`, + ); + replacements[`dom${i}`] = d; + replacements[`domLike${i}`] = `%.${d}`; + }); + const domainWhere = domainClauses.join(' OR '); + + // Find communities with managers matching the domains + const managersQuery = ` + SELECT "Members"."communityId", COUNT(DISTINCT "Members"."userId")::int AS "managerCount" + FROM "Members" + INNER JOIN "Users" ON "Users"."id" = "Members"."userId" + WHERE "Members"."communityId" IS NOT NULL + AND "Members"."permissions" IN ('manage', 'admin') + AND (${domainWhere}) + GROUP BY "Members"."communityId" + `; + + // Find communities with authors matching the domains + const authorsQuery = ` + SELECT "Pubs"."communityId", COUNT(DISTINCT "PubAttributions"."userId")::int AS "authorCount" + FROM "PubAttributions" + INNER JOIN "Pubs" ON "Pubs"."id" = "PubAttributions"."pubId" + INNER JOIN "Users" ON "Users"."id" = "PubAttributions"."userId" + WHERE "PubAttributions"."isAuthor" = true + AND "PubAttributions"."userId" IS NOT NULL + AND (${domainWhere}) + GROUP BY "Pubs"."communityId" + `; + + const [managerRows, authorRows] = await Promise.all([ + sequelize.query(managersQuery, { replacements, type: 'SELECT' as any }) as any, + sequelize.query(authorsQuery, { replacements, type: 'SELECT' as any }) as any, + ]); + + // Merge counts + const communityMap = new Map(); + for (const row of managerRows) { + communityMap.set(row.communityId, { managerCount: row.managerCount, authorCount: 0 }); + } + for (const row of authorRows) { + const existing = communityMap.get(row.communityId) || { managerCount: 0, authorCount: 0 }; + existing.authorCount = row.authorCount; + communityMap.set(row.communityId, existing); + } + + if (communityMap.size === 0) return res.json([]); + + // Exclude already-added communities + for (const id of excludeList) communityMap.delete(id); + if (communityMap.size === 0) return res.json([]); + + const communityIds = [...communityMap.keys()]; + const idPlaceholders = communityIds.map((_, i) => `:cid${i}`).join(', '); + const idReplacements: Record = {}; + communityIds.forEach((id, i) => { idReplacements[`cid${i}`] = id; }); + + const communityRows = (await sequelize.query( + `SELECT "id", "title", "subdomain", "domain", "description", "heroLogo", "accentColorDark", "accentColorLight" + FROM "Communities" + WHERE "id" IN (${idPlaceholders}) + ORDER BY "title" ASC`, + { replacements: idReplacements, type: 'SELECT' as any }, + )) as any[]; + + const results = communityRows.map((c: any) => { + const counts = communityMap.get(c.id) || { managerCount: 0, authorCount: 0 }; + return { + id: c.id, + title: c.title, + subdomain: c.subdomain, + domain: c.domain, + description: c.description, + heroLogo: c.heroLogo, + accentColorDark: c.accentColorDark, + accentColorLight: c.accentColorLight, + managerCount: counts.managerCount, + authorCount: counts.authorCount, + }; + }); + + return res.json(results); + } catch (err) { + console.error('Suggested communities API error:', err); + return res.status(500).json({ error: 'Internal error' }); + } +}); + +// ─── Suggested Pubs (full-text search discovery) ───────────────────── + +router.get('/api/kf/suggested-pubs', requireInternalKey, async (req: any, res: any) => { + try { + const termsParam = req.query.terms as string; + const excludeCommunityIds = req.query.excludeCommunityIds as string || ''; + const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 200); + if (!termsParam) return res.json([]); + + const terms = termsParam.split(',').map((t: string) => t.trim()).filter(Boolean); + if (terms.length === 0) return res.json([]); + + const excludeList = excludeCommunityIds.split(',').filter(Boolean); + + // Build tsquery from terms (OR them together) + const tsQuery = terms.map((t) => t.split(/\s+/).join(' & ')).join(' | '); + + let excludeClause = ''; + const replacements: Record = { tsQuery, limit }; + if (excludeList.length > 0) { + const excludePlaceholders = excludeList.map((_, i) => `:excl${i}`).join(', '); + excludeList.forEach((id, i) => { replacements[`excl${i}`] = id; }); + excludeClause = `AND p."communityId" NOT IN (${excludePlaceholders})`; + } + + const rows = (await sequelize.query( + `SELECT + p."id", + p."title", + p."slug", + p."description", + p."avatar", + p."communityId", + c."title" AS "communityTitle", + c."subdomain" AS "communitySubdomain", + c."domain" AS "communityDomain", + ts_rank(p."searchVector", to_tsquery('english', :tsQuery)) AS "rank" + FROM "Pubs" p + INNER JOIN "Communities" c ON c."id" = p."communityId" + INNER JOIN "Releases" r ON r."pubId" = p."id" + WHERE p."searchVector" @@ to_tsquery('english', :tsQuery) + ${excludeClause} + GROUP BY p."id", c."id" + ORDER BY "rank" DESC + LIMIT :limit`, + { replacements, type: 'SELECT' as any }, + )) as any[]; + + return res.json(rows.map((r: any) => ({ + id: r.id, + title: r.title, + slug: r.slug, + description: r.description, + avatar: r.avatar, + communityId: r.communityId, + communityTitle: r.communityTitle, + communitySubdomain: r.communitySubdomain, + communityDomain: r.communityDomain, + rank: parseFloat(r.rank), + }))); + } catch (err) { + console.error('Suggested pubs API error:', err); + return res.status(500).json({ error: 'Internal error' }); + } +}); + +// ─── Graph data (cross-community people network) ──────────────────── + +router.get('/api/kf/graph-data', requireInternalKey, async (req: any, res: any) => { + try { + const communityIdsParam = req.query.communityIds as string; + if (!communityIdsParam) return res.json({ nodes: [], links: [] }); + + const communityIds = communityIdsParam.split(',').filter(Boolean); + if (communityIds.length === 0) return res.json({ nodes: [], links: [] }); + + const idPlaceholders = communityIds.map((_: string, i: number) => `:cid${i}`).join(', '); + const replacements: Record = {}; + communityIds.forEach((id: string, i: number) => { replacements[`cid${i}`] = id; }); + + // Get communities + const communities = (await sequelize.query( + `SELECT "id", "title", "subdomain", "accentColorDark" + FROM "Communities" + WHERE "id" IN (${idPlaceholders})`, + { replacements, type: 'SELECT' as any }, + )) as any[]; + + // Get people who appear in multiple communities (managers + authors) + const peopleQuery = ` + SELECT + u."id" AS "userId", + u."fullName" AS "name", + u."avatar", + array_agg(DISTINCT sub."communityId") AS "communityIds", + array_agg(DISTINCT sub."role") AS "roles" + FROM ( + SELECT m."userId", m."communityId", 'member' AS "role" + FROM "Members" m + WHERE m."communityId" IN (${idPlaceholders}) + AND m."userId" IS NOT NULL + UNION ALL + SELECT pa."userId", p."communityId", 'author' AS "role" + FROM "PubAttributions" pa + INNER JOIN "Pubs" p ON p."id" = pa."pubId" + WHERE p."communityId" IN (${idPlaceholders}) + AND pa."userId" IS NOT NULL + AND pa."isAuthor" = true + ) sub + INNER JOIN "Users" u ON u."id" = sub."userId" + GROUP BY u."id", u."fullName", u."avatar" + HAVING COUNT(DISTINCT sub."communityId") >= 2 + ORDER BY COUNT(DISTINCT sub."communityId") DESC + LIMIT 200 + `; + + const people = (await sequelize.query(peopleQuery, { + replacements, + type: 'SELECT' as any, + })) as any[]; + + // Build graph nodes and links + type GraphNode = { id: string; label: string; type: 'community' | 'person'; color?: string; avatar?: string }; + type GraphLink = { source: string; target: string; roles: string[] }; + + const nodes: GraphNode[] = [ + ...communities.map((c: any) => ({ + id: c.id, + label: c.title, + type: 'community' as const, + color: c.accentColorDark ?? '#5c7080', + })), + ...people.map((p: any) => ({ + id: p.userId, + label: p.name ?? 'Anonymous', + type: 'person' as const, + avatar: p.avatar, + })), + ]; + + const links: GraphLink[] = []; + for (const p of people) { + for (const cid of p.communityIds) { + if (communityIds.includes(cid)) { + links.push({ + source: p.userId, + target: cid, + roles: p.roles ?? [], + }); + } + } + } + + return res.json({ nodes, links, communities: communities.length, people: people.length }); + } catch (err) { + console.error('Graph data API error:', err); + return res.status(500).json({ error: 'Internal error' }); + } +}); diff --git a/server/kf/auth.ts b/server/kf/auth.ts new file mode 100644 index 000000000..00d91c57d --- /dev/null +++ b/server/kf/auth.ts @@ -0,0 +1,150 @@ +/** + * Lightweight OIDC client for KF Auth (PubPub edition). + * + * Two base URLs: + * KF_AUTH_INTERNAL_URL — server-to-server (e.g. kf-auth:3000 on Hetzner internal network) + * KF_AUTH_URL — browser-facing (e.g. https://auth.knowledgefutures.org) + */ + +import crypto from 'node:crypto'; + +/** Browser-facing URL for auth redirects. */ +const KF_AUTH_URL = process.env.KF_AUTH_URL ?? 'http://localhost:3000'; +/** Server-side URL for token exchange / userinfo. Falls back to KF_AUTH_URL. */ +const KF_AUTH_INTERNAL_URL = process.env.KF_AUTH_INTERNAL_URL ?? KF_AUTH_URL; +const KF_AUTH_CLIENT_ID = process.env.KF_AUTH_CLIENT_ID ?? 'kf_pubpub'; +const KF_AUTH_CLIENT_SECRET = process.env.KF_AUTH_CLIENT_SECRET ?? ''; +const APP_URL = process.env.APP_URL ?? 'http://localhost:9876'; +const REDIRECT_URI = `${APP_URL}/auth/callback`; + +// BetterAuth OIDC endpoints +const AUTHORIZE_PATH = '/api/auth/oauth2/authorize'; +const TOKEN_PATH = '/api/auth/oauth2/token'; +const USERINFO_PATH = '/api/auth/oauth2/userinfo'; + +// ── PKCE helpers ───────────────────────────────────────────────────── + +export function generateCodeVerifier(): string { + return crypto.randomBytes(32).toString('base64url'); +} + +export function generateCodeChallenge(verifier: string): string { + return crypto.createHash('sha256').update(verifier).digest('base64url'); +} + +// ── Authorize URL ──────────────────────────────────────────────────── + +/** + * Build the URL to redirect the user to for authentication. + * `state` should include the community subdomain/domain for post-login redirect. + */ +export function buildAuthorizeUrl(state: string): { + url: string; + codeVerifier: string; +} { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(codeVerifier); + const params = new URLSearchParams({ + client_id: KF_AUTH_CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: 'code', + scope: 'openid profile email', + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }); + return { url: `${KF_AUTH_URL}${AUTHORIZE_PATH}?${params}`, codeVerifier }; +} + +// ── Token exchange ─────────────────────────────────────────────────── + +interface TokenResponse { + access_token: string; + token_type: string; + expires_in: number; + id_token?: string; + refresh_token?: string; +} + +export async function exchangeCode( + code: string, + codeVerifier: string, +): Promise { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT_URI, + client_id: KF_AUTH_CLIENT_ID, + client_secret: KF_AUTH_CLIENT_SECRET, + code_verifier: codeVerifier, + }); + + const res = await fetch(`${KF_AUTH_INTERNAL_URL}${TOKEN_PATH}`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Token exchange failed: ${res.status} ${text}`); + } + + return res.json() as Promise; +} + +// ── UserInfo ───────────────────────────────────────────────────────── + +export interface KFOrg { + id: string; + name: string; + slug: string; + type: 'personal' | 'shared'; + role: string; +} + +export interface KFUserInfo { + sub: string; + name?: string; + email?: string; + picture?: string; + given_name?: string; + family_name?: string; + 'https://knowledgefutures.org/orgs'?: KFOrg[]; +} + +export async function fetchUserInfo(accessToken: string): Promise { + const res = await fetch(`${KF_AUTH_INTERNAL_URL}${USERINFO_PATH}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!res.ok) { + throw new Error(`UserInfo failed: ${res.status}`); + } + + return res.json() as Promise; +} + +/** + * Fetch a user's current KF orgs from KF Auth's internal API. + * Used for the ownership picker when creating communities. + */ +export async function fetchUserOrgs( + userId: string, +): Promise { + const key = process.env.KF_INTERNAL_API_KEY; + if (!key) return []; + + const res = await fetch( + `${KF_AUTH_INTERNAL_URL}/api/internal/users/${userId}/orgs`, + { + headers: { Authorization: `Bearer ${key}` }, + }, + ); + + if (!res.ok) return []; + const data = (await res.json()) as { orgs?: KFOrg[] }; + return data.orgs ?? []; +} + +export { KF_AUTH_URL, KF_AUTH_CLIENT_ID, APP_URL, REDIRECT_URI }; diff --git a/server/routes/communityCreate.tsx b/server/routes/communityCreate.tsx index ab3653f50..cf2d9ba8f 100644 --- a/server/routes/communityCreate.tsx +++ b/server/routes/communityCreate.tsx @@ -10,6 +10,7 @@ import { getHubWithCommunities, isUserHubManager, } from 'server/hub/queries'; +import { fetchUserOrgs } from 'server/kf/auth'; import { handleErrors } from 'server/utils/errors'; import { getInitialData } from 'server/utils/initData'; import { hostIsValid } from 'server/utils/routes'; @@ -27,8 +28,9 @@ router.get('/community/create', (req, res, next) => { return Promise.all([ getInitialData(req), hubSlug ? getHubBySlug(hubSlug) : Promise.resolve(null), + req.user?.id ? fetchUserOrgs(req.user.id) : Promise.resolve([]), ]) - .then(async ([initialData, hubData]) => { + .then(async ([initialData, hubData, kfOrgs]) => { const templates = hubData ? await getActiveTemplatesForHub(hubData.id) : []; // Fetch hub communities for the clone-from-community picker @@ -81,7 +83,7 @@ router.get('/community/create', (req, res, next) => { { + const returnTo = req.query.redirect || req.query.return_to || '/'; + return res.redirect(`/auth/login?return_to=${encodeURIComponent(String(returnTo))}`); +}); diff --git a/server/routes/passwordReset.kf.tsx b/server/routes/passwordReset.kf.tsx new file mode 100644 index 000000000..6e4e4437e --- /dev/null +++ b/server/routes/passwordReset.kf.tsx @@ -0,0 +1,17 @@ +/** + * Phase C: Password reset redirect. + * + * Password management now happens through KF Auth. + * Redirect both the request-reset page and the reset-with-hash page. + */ + +import { Router } from 'express'; + +import { KF_AUTH_URL } from 'server/kf/auth'; + +export const router = Router(); + +router.get(['/password-reset', '/password-reset/:resetHash/:slug'], (req, res) => { + // Old reset links won't work; redirect to KF Auth's password reset flow + return res.redirect(`${KF_AUTH_URL}/forgot-password`); +}); diff --git a/server/routes/signup.kf.tsx b/server/routes/signup.kf.tsx new file mode 100644 index 000000000..ac7f59e06 --- /dev/null +++ b/server/routes/signup.kf.tsx @@ -0,0 +1,29 @@ +/** + * Phase C: Signup page redirect. + * + * Instead of rendering the PubPub signup page, redirect to KF Auth's + * sign-up flow. KF Auth handles account creation now. + */ + +import { Router } from 'express'; + +import { KF_AUTH_URL, KF_AUTH_CLIENT_ID, APP_URL } from 'server/kf/auth'; + +export const router = Router(); + +router.get('/signup', (req, res) => { + // Redirect to KF Auth's sign-up page, passing the PubPub client_id + // so KF Auth shows PubPub-branded signup and redirects back after. + const params = new URLSearchParams({ + client_id: KF_AUTH_CLIENT_ID, + redirect_uri: `${APP_URL}/auth/callback`, + }); + return res.redirect(`${KF_AUTH_URL}/sign-up?${params}`); +}); + +// Also redirect the /user/create/:hash route (email verification step) +// These links in old verification emails won't work after migration; +// users who click them should be directed to sign up fresh via KF Auth. +router.get('/user/create/:hash', (req, res) => { + return res.redirect(`${KF_AUTH_URL}/sign-up`); +}); diff --git a/tools/migrations/2026_05_15_addKfOrgIdToCommunities.js b/tools/migrations/2026_05_15_addKfOrgIdToCommunities.js new file mode 100644 index 000000000..6b2e95e71 --- /dev/null +++ b/tools/migrations/2026_05_15_addKfOrgIdToCommunities.js @@ -0,0 +1,17 @@ +export const up = async ({ Sequelize, sequelize }) => { + await sequelize.queryInterface.addColumn('Communities', 'kfOrgId', { + type: Sequelize.TEXT, + allowNull: true, + }); + await sequelize.queryInterface.addIndex('Communities', ['kfOrgId'], { + name: 'communities_kf_org_id_idx', + }); +}; + +export const down = async ({ sequelize }) => { + await sequelize.queryInterface.removeIndex( + 'Communities', + 'communities_kf_org_id_idx', + ); + await sequelize.queryInterface.removeColumn('Communities', 'kfOrgId'); +}; diff --git a/tools/migrations/2026_06_15_makeKfOrgIdNotNull.js b/tools/migrations/2026_06_15_makeKfOrgIdNotNull.js new file mode 100644 index 000000000..f9ec0a880 --- /dev/null +++ b/tools/migrations/2026_06_15_makeKfOrgIdNotNull.js @@ -0,0 +1,33 @@ +/** + * Phase D cleanup: Make kfOrgId NOT NULL on Communities. + * + * Run this ONLY after confirming all communities have been assigned + * a kfOrgId value (from the seed script + new community creation). + */ + +export const up = async ({ Sequelize, sequelize }) => { + // First verify there are no NULL values + const [results] = await sequelize.query( + `SELECT count(*) as count FROM "Communities" WHERE "kfOrgId" IS NULL`, + ); + const nullCount = parseInt(results[0].count, 10); + + if (nullCount > 0) { + throw new Error( + `Cannot make kfOrgId NOT NULL: ${nullCount} communities still have NULL kfOrgId. ` + + `Assign ownership first, then re-run this migration.`, + ); + } + + await sequelize.queryInterface.changeColumn('Communities', 'kfOrgId', { + type: Sequelize.TEXT, + allowNull: false, + }); +}; + +export const down = async ({ Sequelize, sequelize }) => { + await sequelize.queryInterface.changeColumn('Communities', 'kfOrgId', { + type: Sequelize.TEXT, + allowNull: true, + }); +}; diff --git a/tools/migrations/2026_06_15_removePasswordColumns.js b/tools/migrations/2026_06_15_removePasswordColumns.js new file mode 100644 index 000000000..2e9d602c8 --- /dev/null +++ b/tools/migrations/2026_06_15_removePasswordColumns.js @@ -0,0 +1,35 @@ +/** + * Phase D cleanup: Remove password-related columns from Users table. + * + * After the 30-day transition period, all users authenticate via KF Auth. + * These columns are no longer needed in PubPub's database. + * + * Run this ONLY after confirming all old sessions have expired and + * the OIDC login flow is working reliably. + */ + +export const up = async ({ Sequelize, sequelize }) => { + const qi = sequelize.queryInterface; + + // Remove password-related columns + await qi.removeColumn('Users', 'hash'); + await qi.removeColumn('Users', 'salt'); + await qi.removeColumn('Users', 'passwordDigest'); + await qi.removeColumn('Users', 'sha3hashedPassword'); + await qi.removeColumn('Users', 'resetHash'); + await qi.removeColumn('Users', 'resetHashExpiration'); +}; + +export const down = async ({ Sequelize, sequelize }) => { + const qi = sequelize.queryInterface; + + // Restore password-related columns (data is gone though) + await qi.addColumn('Users', 'hash', { type: Sequelize.TEXT }); + await qi.addColumn('Users', 'salt', { type: Sequelize.TEXT }); + await qi.addColumn('Users', 'passwordDigest', { type: Sequelize.TEXT }); + await qi.addColumn('Users', 'sha3hashedPassword', { type: Sequelize.TEXT }); + await qi.addColumn('Users', 'resetHash', { type: Sequelize.TEXT }); + await qi.addColumn('Users', 'resetHashExpiration', { + type: Sequelize.DATE, + }); +}; diff --git a/utils/api/schemas/community.ts b/utils/api/schemas/community.ts index 9cc73c780..528b0b879 100644 --- a/utils/api/schemas/community.ts +++ b/utils/api/schemas/community.ts @@ -98,6 +98,7 @@ export const communitySchema = baseSchema.extend({ spamTagId: z.string().uuid().nullable(), scopeSummaryId: z.string().uuid().nullable(), templateId: z.string().uuid().nullable(), + kfOrgId: z.string().nullable(), accentTextColor: z.string(), analyticsSettings: analyticsSettingsSchema, }) satisfies z.ZodType; @@ -125,6 +126,7 @@ export const communityCreateSchema = communitySchema altcha: z.string().optional(), _honeypot: z.string().optional(), templateId: z.string().uuid().nullish(), + kfOrgId: z.string().nullish(), }); export const communityUpdateSchema = communitySchema From dcf6dcd4b9cb492b003791c863d96d962baa6c25 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Sat, 16 May 2026 01:14:51 -0400 Subject: [PATCH 02/13] Send over more assets --- server/kf/api.ts | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/server/kf/api.ts b/server/kf/api.ts index 8be970531..505a8d66d 100644 --- a/server/kf/api.ts +++ b/server/kf/api.ts @@ -799,10 +799,11 @@ router.get('/api/kf/suggested-communities', requireInternalKey, async (req: any, communityIds.forEach((id, i) => { idReplacements[`cid${i}`] = id; }); const communityRows = (await sequelize.query( - `SELECT "id", "title", "subdomain", "domain", "description", "heroLogo", "accentColorDark", "accentColorLight" - FROM "Communities" - WHERE "id" IN (${idPlaceholders}) - ORDER BY "title" ASC`, + `SELECT c."id", c."title", c."subdomain", c."domain", c."description", c."heroLogo", c."accentColorDark", c."accentColorLight", c."createdAt", + (SELECT COUNT(*)::int FROM "Pubs" p INNER JOIN "Releases" r ON r."pubId" = p."id" WHERE p."communityId" = c."id") AS "pubCount" + FROM "Communities" c + WHERE c."id" IN (${idPlaceholders}) + ORDER BY c."title" ASC`, { replacements: idReplacements, type: 'SELECT' as any }, )) as any[]; @@ -817,6 +818,8 @@ router.get('/api/kf/suggested-communities', requireInternalKey, async (req: any, heroLogo: c.heroLogo, accentColorDark: c.accentColorDark, accentColorLight: c.accentColorLight, + createdAt: c.createdAt, + pubCount: c.pubCount ?? 0, managerCount: counts.managerCount, authorCount: counts.authorCount, }; @@ -843,8 +846,16 @@ router.get('/api/kf/suggested-pubs', requireInternalKey, async (req: any, res: a const excludeList = excludeCommunityIds.split(',').filter(Boolean); - // Build tsquery from terms (OR them together) - const tsQuery = terms.map((t) => t.split(/\s+/).join(' & ')).join(' | '); + // Build tsquery from terms — use adjacency operator (<->) for exact phrase matching + // e.g. "Mellon Foundation" → "mellon <-> foundation", single words get prefix match + const tsQuery = terms.map((t) => { + const words = t.trim().toLowerCase().replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return null; + if (words.length === 1) return `${words[0]}:*`; + return `(${words.join(' <-> ')})`; + }).filter(Boolean).join(' | '); + + if (!tsQuery) return res.json([]); let excludeClause = ''; const replacements: Record = { tsQuery, limit }; @@ -861,11 +872,22 @@ router.get('/api/kf/suggested-pubs', requireInternalKey, async (req: any, res: a p."slug", p."description", p."avatar", + p."customPublishedAt", p."communityId", c."title" AS "communityTitle", c."subdomain" AS "communitySubdomain", c."domain" AS "communityDomain", - ts_rank(p."searchVector", to_tsquery('english', :tsQuery)) AS "rank" + ts_rank(p."searchVector", to_tsquery('english', :tsQuery)) AS "rank", + CASE WHEN p."description" IS NOT NULL AND p."description" != '' + THEN ts_headline('english', p."description", to_tsquery('english', :tsQuery), 'StartSel=,StopSel=,MaxWords=60,MinWords=20,MaxFragments=2,FragmentDelimiter= … ') + ELSE NULL + END AS "snippet", + ( + SELECT string_agg(COALESCE(u2."fullName", pa2."name"), ', ' ORDER BY pa2."order" ASC) + FROM "PubAttributions" pa2 + LEFT JOIN "Users" u2 ON u2."id" = pa2."userId" + WHERE pa2."pubId" = p."id" AND pa2."isAuthor" = true + ) AS "byline" FROM "Pubs" p INNER JOIN "Communities" c ON c."id" = p."communityId" INNER JOIN "Releases" r ON r."pubId" = p."id" @@ -887,6 +909,9 @@ router.get('/api/kf/suggested-pubs', requireInternalKey, async (req: any, res: a communityTitle: r.communityTitle, communitySubdomain: r.communitySubdomain, communityDomain: r.communityDomain, + byline: r.byline ?? null, + snippet: r.snippet ?? null, + publishedAt: r.customPublishedAt ?? null, rank: parseFloat(r.rank), }))); } catch (err) { From 20c1907662b614240ea85e81efeed6713825edf1 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Sat, 16 May 2026 22:35:01 -0400 Subject: [PATCH 03/13] Update for kf auth deploy --- .gitignore | 1 + infra/.env.dev.enc | 96 +++++++++++++++++++----------------- infra/.env.local.enc | 59 ++++++++++++++++++++++ infra/.sops.yaml | 2 +- infra/docker-compose.dev.yml | 4 +- package.json | 2 + scripts/confirm-encrypt.sh | 4 ++ server/kf/api.ts | 16 +++--- server/kf/auth.ts | 14 ++---- 9 files changed, 135 insertions(+), 63 deletions(-) create mode 100644 infra/.env.local.enc diff --git a/.gitignore b/.gitignore index 48735d7ba..0069a25b7 100755 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ yarn-error.log* .env.* !.env.enc !.env.dev.enc +!.env.local.enc !infra/.env.test diff --git a/infra/.env.dev.enc b/infra/.env.dev.enc index 6db5bf1c2..eb0c10569 100644 --- a/infra/.env.dev.enc +++ b/infra/.env.dev.enc @@ -1,53 +1,59 @@ -AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:YwdwuazMHhri6N2Lxs4mNbm5hwRSJm3yop5LxmzaB/6f36IbuVcPL9bZEpUhqSbbza0Uw1FYAzimLF5PMfor2Q==,iv:CImEY++dwcJjJ6shRdnhEgwFUwvBRvWL5XejfKZJVB4=,tag:wlbsUyJlvVCPpkDWwuaB8g==,type:str] -ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:m3h3mTBUsQQVKP9vqfHzgB3yLPNSuAMX8von2pALFWLl11sw5oeuyetx0oznddZCQjE596h7WAo0KN6sQJslmw==,iv:Mcojr/cgUKz4DQuNESe/1tDnlVeUaz42EhxxCOKOdiE=,tag:hsPq+qm9WqczCBR5zVFXlA==,type:str] -AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:V2EPX34kkLSzVkapq/AXTANt0RA=,iv:Vd2Hi6LxL7FXu4NdkQPvQUQHhJJecqay4IN955eTxEM=,tag:fpW+1Z1AkfqbvhasRmKD2Q==,type:str] -AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:9vHS7UaaLe4qzIMv4+/TVj/iNiU=,iv:RhGRvIoWNWsh00Eo95b14gKBUCWhL/qK+kaWWXYKIG0=,tag:PSse94gE1uwo/s+v8WojWw==,type:str] -AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:15It93JTjRwIeF+K3iC9C3fbb7oec44it5ZaQ6SZNS3BJoQG705sSQ==,iv:ur3MbUSWqnh7z9O5Ow1UbcVHLM3sQgRVucz7KDjvMz4=,tag:yWPCfshCsQRtB+7cOO8INg==,type:str] -AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:nLVLV6QiLG6DLhG/+Gqo5DmprPCNlubKUXw+1v9DXHD3AI/qnDJ4pA==,iv:meLdta+HFrOZuC7T1FHC7yAvK1ZPJqqDQ3gRigEPnvA=,tag:Gvl7KH+IwN5sDoLFl0MuKw==,type:str] -BACKUPS_SECRET=ENC[AES256_GCM,data:5ey56vyaaaFDg0yAau+rdD6DIp2ybUbsd19KkP04icjMWtYq3odwPgBYYt4=,iv:rYABukoHrmO2Q7XIMryVADsDf1li/R0R98Ct9rDtpfU=,tag:Va1tZW37BUqEQXl5Odx6cQ==,type:str] -CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:JKYGVdcx+fzXXIP/ir32J6sDbPXsUQJCs0cH8QWFOAMkHd0pORIWHtRoAbNCc+asWZKh6ts=,iv:8DIZcel5/3crIrEdmh4Acvme+K6xdXDT7J/VPqJ+IpY=,tag:3ptWLOiCtOvckryH6RaWDw==,type:str] -CLOUDFLARE_CUSTOM_HOSTNAME_API_TOKEN=ENC[AES256_GCM,data:ZqLdXUQ0kgruGjEJuF2tZ8UoqzGW5yVATq94JZy6zokILEQ9claOL06u/Kug2uXmfknUjys=,iv:rO0avhy+tZfwdOazdkNFzU0Dlno6c5M+Vhx8EUlA4rI=,tag:U3kr2EzLaJeaT/cPS72JwA==,type:str] -CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:qyoMHUDQLyTQxilvK//0sjA/UMlqC1AHE9laplkY7dc=,iv:xvM3bWtP73b+qre2Yn2ECQjle4Wz/wS/CZ3NGmehxEk=,tag:Quf9aeUIIQi7OzQwBChLEA==,type:str] -CONTENT_SEARCH_TERMS=ENC[AES256_GCM,data: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,iv:ToxQVOKx3ZP+oYt0596Xa3JacoF/9qB3q4TjiUycp/g=,tag:2uQZ/E/ZytYDWidA7ATlXg==,type:str] -DATABASE_URL=ENC[AES256_GCM,data:zaYEq9/FoXQqIjXAH1Xm2KZhTs7hxCrxaT6geoxQEJoEhgX9pVxTzlC4zbI=,iv:Zfo6DSvryrww6u+QPhAiisQsMeFKNwlDPeQE+EyHSEo=,tag:Gm1j50yWe9nD1enIV2NAxw==,type:str] -DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:O61IgJI1xGiPCxmRPDkMsMlgB9ZAxZC2Yxohb7W72DCZ3A==,iv:FaFM0GoDNfz+6lM0pvyUAM8CqrDaapcm6K2kKtEX3TU=,tag:bc59T0LvR+cxoX/ayDsF1A==,type:str] -DOI_LOGIN_ID=ENC[AES256_GCM,data:/N76Zjh9,iv:wSJ/F6ycYlxoQ0dDJK4zEECrUw7F1uRzQIpaut7XLy0=,tag:O/CHgRWTPWs/MEZf1ADiNg==,type:str] -DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:KPJsTBKbjm7Lh65xeQN4MeynW2k=,iv:2aHPo98X4HSuFWvcbqwFxFLHRekvsxxLCQ081ZNtvg0=,tag:W0f5abzYvJkya/GG96zT4A==,type:str] -DOI_SUBMISSION_URL=ENC[AES256_GCM,data:c/BcNuFqQPbYVi++NUbkbdNV2JYLaFkcmBtzn8EVN+A/HWjc8BlaOOs=,iv:phyuU35fFCr1VLNgKS+0NxoTg7BQoYhOo2MEI9rb5T4=,tag:uEe04YOPrHWTcz6jniLztA==,type:str] -FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:ttl2E8BgOJ9ij+/p2b3XjmHzDGtuSyo18R9QxfQtCug=,iv:baGXWnNtKe5YuKwTYvEx4+tJFAoyohpcEjr+yN714g0=,tag:ZHJj9haQ/gU6lw+GK31tWQ==,type:str] -FASTLY_SERVICE_ID=ENC[AES256_GCM,data:1cqxB+w/8LxrCzkvUVJydCNVv4xSSA==,iv:4fjl+V4U5fAT1h+90lOx3hw1NIuZyO7m/i+2wbZCu+U=,tag:Jkfs38M72erSxnA31qPEDw==,type:str] -FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:308cafiJL9MAG0WrbbF4pWkw/8Pj/1FBMIRvCoFFjDY=,tag:OqsPaBf57YWIxpWH9/lraA==,type:str] -IS_DUQDUQ=ENC[AES256_GCM,data:Ik/xiQ==,iv:lXXGaZyiKVQuiL2NcPDzr/necCT3JAAtVNANJ1Yhpi4=,tag:akOgfXrGkwfp/8BVaCZ53g==,type:str] -JWT_SIGNING_SECRET=ENC[AES256_GCM,data:mzWTmqVKGdMCnV6AMXaTXZOU9ZySaziFpLtRhcwgKGmmIUjb9r9uL+VaptpkMj7F53O61aSiJahGAQ4fmVstzVPr8LV8dm9d1rzkBBtKOy2AV3uNtB85yzuoAwgs5cFzdTIAqrUlYq7kEFiqdZA9Q3OKwdlhihLyTVy/Jpp6nDyxfQzhBGtV0babZz28b/pX0uwWMTkxentuRWPXaogw/fvv+T+U74TrxliB1yNSZgH4AUDs1yrt2St8Kr3N/hobnwl87a6YpsH5QlFZxjw6L8aWuUXXHX2YhGVuuyyrokBaHb/yUs6bnzDsu43LBit/J9QTl1NwhgTK7+jO5azaKZBVDYQmi7OI7BiGdtFSlAr/mLrz+86nVa3mRQJxdTdoEwS7U/noKkkbXYvSKn3jXol6ZauivSYDC5/FVVcluikaHPYhZzD37gSu1TmDEyaTjKDrxKhf6wMUo41Q7BBWjYUF4dIYZkMp16Yoj8vbAkvKDmyfel7qh95ZNaztsQ+isgeduygnWl77cjQQXfQXjNG3JZD+pXQtBgRDaZRgQy2sXshx624ZLxrCxc93SA+ysEgdDxabT8WcStPC1Mf0NyOGckpJ5WshNbv9oLK+tAZYArzhIQHlXPsPEdIXSjEsuCLnRgQXCmRnIwH4dQg/+OycGdCutNBoE2xzCzLwFtpmQCfC6svIVkaFhDxBIQrYvU9JB8xOPKwGt+VmsVSPC5BAXXspk9YgAd1OBK2jwK5rC4WZyN42OG6yecVflJrXBw4vUN/qHtmVGsb6cQ1kqqrwJMPVK7XLrheFqxWbh5Qo5Sd0iiDfeU0QMLbqjZ7PM6QBctZYO7FxOoO3oKakYLJHsuIsydVjmuUAQU2XTkWDA3goD7e4IQcTk/dt96pchQ89slvr7X6GtKZ8xXv/W8fVS556sWgtPGj3CsBgnbKHK54eM562VjX7momuTeyyPvMqnh/2cDH35D2sqmlSAVsF5ZMaqCCvSk3sxgtG47Nrgwr0JfjMKj31qP/tlKrRiOPRCyU4vQj+PNpL+YN6aN7IaTJSJEuZHzMxU3oYFZJi0oaIRKZWGz7mz0AVvjBNBsCWUWr8lNeWBiapqg3dfeyEhr7WC7A1wJM2+uxtTWJtn1QouOh8QwfhoheJ5hFdJJsM3fW/i6hzm42FPeB7YqkuxjSx/kIFzlfg0P/V6Wrq0A/OO4oreXbcXAHes8U9RaZhal/0OBXDvlF2zpnvnAX8LaDU59vPtJ4pD+F9b6g9fwdnv47/FeBJEnyguSYPSKLyB7XbmICPn6r6ACdstj6QxWc6TdTQfxNlMHy/hZ5lUrsYflt7cm4E6FESD0VHHAGzl1O6spg7cYFnEiN/aA==,iv:CgCL+WKalNud/pqASKfp7nmx+0TkUi68OAHtILql8mE=,tag:1pS2XW4Lp7KW7uzgQPh3+w==,type:str] -MAILCHIMP_API_KEY=ENC[AES256_GCM,data:xGUlZ+DQO5MYWoNzIKzU+60AOfikW1xZ0yPYNRP/jxElzxWf,iv:QcGbONp8pHdWTwCZDcA2K/Kcv1d8q8JdxoEB9oGWY50=,tag:BEMdL4hqukE4FEH8fU4VkA==,type:str] -MAILGUN_API_KEY=ENC[AES256_GCM,data:HWakd4g1OjIDcxbRd0KqAD6eBXPIWK8/T51m2ldipfpprNjo,iv:TTGZYCS/l9xs1n4+MED7AGc9w0oiXdn9sq9Jn8j2usY=,tag:+hbpxadiNfoqjR7MXyn+6A==,type:str] -NODE_ENV=ENC[AES256_GCM,data:doZX128C2L0Okw==,iv:xWVM/BmZjaYZAW8p9ZnFBn+Zu8vkqZgY7Dowz4FOVY8=,tag:VtA8xKVXjV/ub/9A2uP4AQ==,type:str] -S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:TTi/idrf7erJ2MP0Ly5WKiM5QI8=,iv:TZartUZmgMyiiq3pgsdzjAlLawNAUlJmdYbrDGCLGOI=,tag:7KfnXeYBbQOsWonYH2633Q==,type:str] -S3_BACKUP_BUCKET=ENC[AES256_GCM,data:BGLP2jKBGO+oYas=,iv:2bQ0SVZbBTQfGMF4Bp5LEcOCtB+c9Ea7pX/RbhcZeq8=,tag:giBYs8Ou2iYKciiv6HQU4w==,type:str] -S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:iRWyMNJIXCLnNF+PkbN08jGaUrcgvdkF01pwiaQP8cf41J8=,iv:9wSXMPJWDfhB1t+7yKYmC9qS7RZ/BnPvYqoA5sRl0nw=,tag:SYXmtg2zwVzd1n0FP1Hw6g==,type:str] -S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:evC1NR7vP8pplfT6aoTrhm+z6NzIW6gJFk+3MXTpvvaBIeV2ZRSh0A==,iv:V0TMCtGnkYLr5k19a3QPyUOAyAvwbe1Wunful0RMd80=,tag:S7pMGtvS6x4dyRMF3spKgQ==,type:str] -SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:sh7B89Ys9jaTqSERyLWvgdutKyFQiXGCmC++cCbPonBTF1Zel4vx/7D/3yyVgB1fD9hnHE+f+vaR9PucgkBaRlaV6h+L55X6jGsy5LKBGKZ1F2SenuuYNy3apZzRjTeWvqamM2IyxscD7bYZE49WeFjDdI/TIadh2h8d0Wj0ioO85nTKaDu/59rBqCcHcxwERwZqdjwLgtEGQhNap4w7n2N4lSKj3riYQzODaMQumFZDuRn7fuNiGBGGhQ==,iv:IHUsQRxm8+dF6p30VUsBK79DrPM3ROCdHkNK14Jb4Us=,tag:lSvhTbS2qiKRB40sWabvnQ==,type:str] -SENTRY_ORG=ENC[AES256_GCM,data:NLcL,iv:T9ACrxbQ+t1F38Iinem4fP7x52Whb1DK3UBGomMvc5Q=,tag:ysro3OF2u/biNgQkJ88RwA==,type:str] -SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:6Nw=,iv:AW/nNSkLutTFL1LCvLpHg/B8at1QG9uT7Vgc2h6PnL0=,tag:Falok1cxL1rtzfdQM7+RCA==,type:str] -SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:ARPx73gaS6aPcu54iBL9iLoqD56bmZHxJd21T8jq7Q86kt0cDQzmXCooOfnySMAZyGu6Dr/3rQAPDos5err2C4e/Vo5Cq9edEHZg7ZlX1A==,iv:LaIjGlLb3du7SIiEaOVs9QH8EmHV0khZD9dirOk1qJo=,tag:aempM2mdF/dIzGOT3ZiStg==,type:str] -SMTP_HOST=ENC[AES256_GCM,data:GXxeWN1vPBoJ3rBcZhLcOBpjrXm6TC0Pw6wqOBTkxmId7g==,iv:9mI4C0+LF29B3LnkcT57N2QZ6nefJdFb/FJjm3v7BNs=,tag:Vjg8oswBxEERkuLgIHOrJA==,type:str] -SMTP_PASS=ENC[AES256_GCM,data:MGIc/933R7Snd6zRCqVpRXGFUpURbHRB85NDM10Ogrzu3BLP2myEz+jCy6c=,iv:DfEFgvs/A+GZhgHtos6Uh2IJ0bco0gXhZFxf1XrjoiM=,tag:zcgatj2yVIxBsilAWkLo+Q==,type:str] -SMTP_USER=ENC[AES256_GCM,data:pDD/xGWqvm01AyFJCKWlJJ6Y+cU=,iv:zPV32VpVrwofsuz8jd7qC/W89v4lhxDp4xaaddHRq4g=,tag:HxyXmJP9ujO57ZBf244lfQ==,type:str] -ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:8M7oq5CzSxu6aB6K7fC8CWEfdNY=,iv:W2yDnL7dA74ALw2ORhxar1mZaTE/Ds34ni/B8GpIJYA=,tag:7in8tKyOxLb/tYWT8Od0WQ==,type:str] -ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:mnLljrjAzBnNKaFImzs000XvJe8=,iv:85pHkDq/R6gSajKQJ0A6x71e/WUySvSjLpWnxp8oNig=,tag:qIP1AQQqUgTVlTT3DQzt2w==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAxM1RhaGJ2M0lVZVhIWDdG\ncjVCMEYwTWYvdjMzM0RqSVJTM2daKytuUVFZCmhIcEVSSFg4dHNRYkdXbG5Wenk0\nTVdscmFXZDdSNnd2SG1wdjVTS0dkREkKLS0tIEtHV0JOSHE3eHZXWmlMQTVYdU1K\nNE5nUjJaYk5KRTBRZXUyU2NhZmFOUDQKucEo4hRAKMOUTY1g4M8HjvThhqdWrTHU\nFrMJ2q25jtSNHtB0uNq1fMmV9V5UqbEABWRYKBbgDBucxMp9F6+gqw==\n-----END AGE ENCRYPTED FILE-----\n +AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:Xja6pWJuVFV+Y/aYvSB6pVk5sRgYhGtz38T4XlESV2U3BicdxOzJiblI64hp9ptivW434emiC2CRBQEoY2iGIg==,iv:BkfH04v7u+nQJ5C6oSsbvG7qfclnq4WUvNZJ5LvbcYg=,tag:y23Bn78rh6p0BXzvDrGvpA==,type:str] +ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:gM4u13AU5rGlfvuK1TygB2EHhXgNuM/ymNkYHnUH7F6ibMwIMANf3DPCxKRfAD+Hg6bwFoziroxz93bw7CeSdQ==,iv:BV0GSx28moDNPmGd3Nr0+tl2Kap4Fcn/5P+sNZmmTPY=,tag:Kug/H8MvvINSBESdeVLJmA==,type:str] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:vF++ggIsvfjqhnVOXWFAVunjsVY=,iv:LjsUFh31LTLn8LLjVLFPBiqFnbEZutKQZl59yInZXwg=,tag:bM53fg0cur5DoT0OrQGMlA==,type:str] +AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:3XOOm6S08GV3Srzpq4YUarG9wRA=,iv:peWv8G0v/2oc5pBczqP6gMFDM5xAiqQcHTQdUOrtzhg=,tag:St5ojbZCyyluwEGTOt3xxg==,type:str] +AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:1RGXw9lVjLekUyIheth5Hp7ieyOP2J1tgUL1gr9xKP7i9kRS2wCrjw==,iv:kaPbn3r8bCb3Kx4rG512hi5SkopmGI0avvKLbMtfXiE=,tag:vLmLBh3eLyQeHQRoiJcaow==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:Pdrg+Cny7TbtBD1h3b++V2vqL7jQCDWL6KqMNMHn3ol/0iFyy1gTUg==,iv:b/rit8FMdaV3zByD22TdcnrZ6qHSVjzDYOGvERO8SiQ=,tag:eSqtBcFUuhCcw/6PaLJf0A==,type:str] +BACKUPS_SECRET=ENC[AES256_GCM,data:jhMNkb/JM5C6c9OC+ML0a1vFd1y0ngo9HkSeCmYEKLR8RHal1Xtra49vDHU=,iv:FoJbGu/srmg8hK2evv6QZvBNih2dvG72PIxSZi5ieUA=,tag:T/CEkRDshX53EdWLZhHe1g==,type:str] +CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:YG3oxF79I2Op7m74ioI6fU7qndkls3XZa409iyPe3TgsAb8oNk0sb0UM2pPWuvQd0/lXxiY=,iv:QHQlzlUahFh/BwCP/Qt86fEP6PTZlxCyMNsa1kBajcI=,tag:3YcBpFEVj/rXLjjEh+kkkQ==,type:str] +CLOUDFLARE_CUSTOM_HOSTNAME_API_TOKEN=ENC[AES256_GCM,data:xhEGXVdEz6AseRerwAXQSZp7G/USThj89U9OMHlNq2pfG8nekHDgoyDQ0cS5kkin6JSKpP0=,iv:YMXXosY3TOjIf7QKo6bd7Soqa4S0ojliNpwn9gVdTFc=,tag:DAn2bpuZg4rnkiAikBa5Rw==,type:str] +CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:7W8jZBTLIMYB6m593J5q7Xs+Zd9fvnn/loX3thddkcU=,iv:lUJwLq4D/eus8rGxC26cEaioVCNOu7RPLQFizR77Qtw=,tag:MtTY3QbPePlPa262w7arpw==,type:str] +CONTENT_SEARCH_TERMS=ENC[AES256_GCM,data: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,iv:bppDCBSkEcnBiCMbq4vRCIHcllN92JPPWw/lVg9pk0w=,tag:0LG4zm0f2qYRkmpwjEY5gA==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:AzarkHcGAEqaIy5jF6fuFGBBUvOqtJwsURw5eQjB73jjsu9edl9kJuJniYs=,iv:nMVI5p/YvwG2ui9Io/0SmkiAUQVYsafgejmKP54eg1k=,tag:Ckzfcv5Bqb9nWSsO/4Zl/g==,type:str] +DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:RtWHvl9rL7czXLByOpD6TMrWV9hWcfl07F9k9S3frHTdcg==,iv:4FgMHgbL7meB7xsoP0ZgDqHzmAYdotGMwB5rjQz0+4w=,tag:v3qVTk944kaCFrSElDLl9g==,type:str] +DOI_LOGIN_ID=ENC[AES256_GCM,data:eJXP310Y,iv:hHm0RYxErafZsNWIp4c3GMkGlwD3WiHIYi1dqDf4MY8=,tag:roFFgJRFOSCp7GLTAqW9Nw==,type:str] +DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:H3gIlkMt/TFB68Qnahf027i3CPM=,iv:zF5rBVnU7mooeiMvxfoCVv9VyNaroy0WvGbukhO53vM=,tag:NL+GYVrWtF7m0/q7kMnduw==,type:str] +DOI_SUBMISSION_URL=ENC[AES256_GCM,data:e10KvblYLyoovn0wTiD7n1COW3codFR+PRD9VcW3VREgkM63SVQM2zE=,iv:L5MWiPcqkIYq3J7rodg3Z0dMmrOZazcKgJwACjl2MgE=,tag:2/5NDyx2uqypSPd+tE0wqQ==,type:str] +FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:F2/m+nPABzbg1IIOu9q29+MsQxIlUHAReMmrIp2u8oE=,iv:T1mSVzxGt3yrVifSsP30KKyufXAQxavCeAl7u1g4tsg=,tag:f9aRwYgw67Ti2UuGBWftCA==,type:str] +FASTLY_SERVICE_ID=ENC[AES256_GCM,data:aSOOgoDfcVnm08avr+zcCLkiO2KP5g==,iv:TExbbOCbHwy1zARIMQA8zIkrMuqrvWMDdq0lob1E2ZE=,tag:aYa5JaThXA1AJdGyiOj6NA==,type:str] +FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:/jadVet/Hz82+n/ApBYIa7KUGQ+IwEWf6Y7+m9h3mnI=,tag:5I7IMYbVqBRdTHkbXefM+g==,type:str] +IS_DUQDUQ=ENC[AES256_GCM,data:/Nx7Hw==,iv:uDInaGYuPtRuTFP4sb6NBPVEwoYKV4/Cqy4Vi7ePuHk=,tag:e3+FWrP0odei40mpHRQRSQ==,type:str] +JWT_SIGNING_SECRET=ENC[AES256_GCM,data:vAGHcGOpR52+MOVWM2e0g5+FVYpeNIq6dnyEwEAn8EDP+iCi+nKI7vGNWL3MYoD9RRFCpDiOQiPPc8NVF//Wzxg/jrCvEhpM1cXYJvdSlrR1wsxAFpEzBUemwbQhOMlcml5wAxQfajxGdsK284QabHc+DLz5eO7KY/549L6vg5xCu7JdMPhY7pjEyZuK8/n8vRGeZ41zipCuztszn0USF0yLbQVr1VpujszMRnKnKjKhL6QVJLC9zSC0jiiKM+/NwrviINRWDRkSPPWENTwx6d/P7zsQ8h3zY5JyCEZ+8O/YHG54KuFAS8JK3ZfM6ttyP6Mv/fRbeDeeK8S7Etdtu89R/n6m+itdd3wz7LDjgn3WsTLhoMrsc2i5vpld6NwhIVoQ5N7Lc2qjhkTUZdSF2WUTCKjtQb2TV2IlnQUGcwIC8SJUPeJY3mVlALnX9gOY1IIeIzWUUs0CO8cFl6lsWnTTU/EtiF4g40G9m0w6B07CAnvClpH2gHNl/+MHMR/Gu4VS7cb7NajToamNeg/s7POTGcl4RYxWpJUXMSWoDP0DPHzY46WqmtqPRfWIvrl8fYUneUfqDpTqGtp2NBzauvbqKxRqQzERE3W5PK65hDzY508Rgf1ybQVnH7/BDiS49UJpzoZ08d8/JR94Q3L9UiBsCweiUdgqWSamvQI8uAy945K59lPKsFT2MNo9nMXnTaAuym0T14kk+eMrNZnE4Zi7vW1k1RxyBobVIBRAICx+ZgcPWesimbu7xeG5QyX0VvWwpMdgF93dULDwGTNtPyzx/wFXgv7YVzTi1ssNMd1hjIkp15lxIq62poNpaO7N2ylcHlrbXMlKwT+bB//WxJg6MBtlHmuC5yH6MzKu2Ffr7BnEcwevH+mmssbbGxkXasAZJpFtJ+BbO6aQMHHz7aN4TsxG/wHD2CLSsMVFNHRo55g1ZT6NNok/uGQLocexlu046Abjd6LArf9t+1U56Td8fguLYZ5t5JnjrpePsRzJzX7h5fRUH/5V/kQOJAiqZ6iB5MomV6tVN+uGUDrOgqfWsG/LH4RkBMtyT3mNEKtJfWLKvA1lO8S9YBB94YJ/FSFMyAeriDg5n8XrlE0z+I1JN3exGRB0+c+Oq0MlWfoUb05v3bpMUWqKmgP1Ata/DCyOyKrZEXhwFY+no3i6bf5j57gmBxxECjv0fyqvJM3AViVKQ9OvyhQoyCYEOVPNfc5ZBogc38pItrzL5a6hB5W4Z7MBeyxhvOe2vaoCPsR4qP1It5K2+3bmtEX4KXTy7tXtkQ4xCg/rvpOeN7ZjnEt3r3MaZEvKgpCWbQAF3JRzFKVb2mtVC5z/lqeffCvNhJN7XP/ztdvrFQNfDEIzjA==,iv:BnISOMJ3PNy/6165fq9oeFykKx666yHzuq0UQkaiZlI=,tag:zkhJAgY0EvR+PmOZLNwOEg==,type:str] +MAILCHIMP_API_KEY=ENC[AES256_GCM,data:NtsS50tkEddOu5T9H2spXFd749cAEsnQZgEh3tgBjRWK+CiU,iv:7MpAPJzuVnaIdHYeHmEN1l6xGgw3+fvUvRKJEX6Kid4=,tag:tW1bBQM0GF6BqbkaY7USsQ==,type:str] +MAILGUN_API_KEY=ENC[AES256_GCM,data:MH6seKkqHR6Wj9JZH4mVmWHF4R3FrKsTat2W+r9tQPVByFIZ,iv:YjlSJ/VbJ09RPs3txF7gs5PEut19/QTn/c2zRZUUxFg=,tag:HikxvDvrLHUuvfPIFJApiw==,type:str] +NODE_ENV=ENC[AES256_GCM,data:eXjT6u9VZktBhw==,iv:y7svJfKogEh+/dxAxH+HLd6mAQyb+eGzjgbuxOkT9fw=,tag:yYUGMmBStX9EnWKgKVgk6w==,type:str] +S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:eYFRgCAIWsY8Shhr2HZHCR72r7c=,iv:5M5nftqJi1le1sDZz8YZa32QwqrCxOu5ClZ7knWvQQg=,tag:hE65DX9ap9wCXeisDc54HQ==,type:str] +S3_BACKUP_BUCKET=ENC[AES256_GCM,data:x1ZgjjdMObQ9WFg=,iv:VqTy+9ZCg3jzgU9CtYxGVgLLzGfMnmhqmCHB8dGIo/Y=,tag:kSWhM9gxKQK1jf/XX3mT8g==,type:str] +S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:UVnBLUILntsJ+ledQ6auXqP53cAFaDerWgsdGOgOZ84ZyrQ=,iv:iXlsZg56X7yD6Yu9xOvUnSL0kYi3pxyLYTQOhUtv3ow=,tag:QZG374UhNDOw4I4rc4fI1w==,type:str] +S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:6c30LpSh1ix7R2GDNCKcenjpEaZ0TuFmzGhiuoOtdYkXYQO7X98jag==,iv:8c9sE/sNIwnaJtdufSOLruvN9tHVUN0B0dVYLvoi3/A=,tag:h2JN5JtLI6B5Jn7/m6QpGg==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:P8D2K73O9Oc+uPMwIgtB942HOIjvpNSIl/pit7TPpJgM1HW7LMSUf/4BCM0Iu269p6lehrUpf5ZcSIL6yCnotfXRZFY4G0syqXvKGvqiTpklVttg+kjsOFxsHEWBe0vRvnSfyUfqVmrfWAh+83qu6kLrwNlQOrE4FZm7sPijZ6H3AW3KMLqNm0i3zKDgDT1vLEBxc6o9M09MUauHsE5l3qzeQe3kFruxhqk11lOaZIOL+ZjnrDWYbjxNlQ==,iv:XfwOlinRFKp5OKtWeFID/inAWGqMfbReHwLuuaFMH0I=,tag:BPKr/6ghaqka7xYpSn8t5A==,type:str] +SENTRY_ORG=ENC[AES256_GCM,data:adqt,iv:ujBPuKnFCHJYOjTs4gyvdNCSumNo92AzFVAhF6q1cu4=,tag:5iWhjeGmU9KghzzBlYZLbA==,type:str] +SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:q7Y=,iv:S5SKSAzNZJAVbIthcYuqhloyQTvCp6us2gA2qAQKW+M=,tag:mwwPbkoC0pzswsO5iDN6LA==,type:str] +SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:GNmQUBEwYvDbHVMbKs1SKlUa9dLDOr9e1iEdQMR4P0C+pQw2CohRVarg95gL+/AbE2mxRBg4d2v//L0zs4v1SqPT35wOOmNICnzX1py7wQ==,iv:AsMZhFlFr4eD2j2wfrQQD2l/fNxtFxqAtln7Enu/xQ0=,tag:prqSzsxXMjqc4KF3Q3xIAg==,type:str] +SMTP_HOST=ENC[AES256_GCM,data:HSZEWRhNJCx8qsxwo+RKZK6JlbPbjyqOcYhSqiixouIp8A==,iv:u5uSGvmkVfpY8kggb9lhfj02nkGH51KQv9VJCJ2WYMg=,tag:5i2VVrAltQxY2sfH1lAOqQ==,type:str] +SMTP_PASS=ENC[AES256_GCM,data:/LbUwUWza4AygxRJBxV/IytK+v+WCdmIV/XnuhsIBrhI6PtIXwtD1s/7c38=,iv:lI0ZV/pqFg2x01LwtFx01T1Ji3M7eRLPUViSOu9WCSc=,tag:UTZ40VeCJc4QuG20E1WJ/g==,type:str] +SMTP_USER=ENC[AES256_GCM,data:nbkdNZYxZWwVxfliNb2hj3+2mEM=,iv:m8om1mOpz3XdWoFq40JrUJJWXMUWuDZNhLcTjp4zCwY=,tag:jE5smruSBVenqp7QCqBYow==,type:str] +ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:QrW07ag3c1b2MJEkvflkYcZ9Jic=,iv:PTjKpT/HGxgUM0frWG9jLYzyXnyZUx/mwn1a87BNij8=,tag:afxyr2HJADre/rORYA8TOQ==,type:str] +ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:rTqa6mWSJSteDn2HkkpAeKjTRvA=,iv:R9wUAudkXbs9PqO/gXbx8WUihWoV0li/buHhFL0lvQw=,tag:9XDZyDaPzK7SYRO3fp6tww==,type:str] +#ENC[AES256_GCM,data:SjEixq+m46e8vo26CpWPqTcFYSs=,iv:+ksMsnLErAhThWqJa88IP7lowNiDAZG4aUCFMcy+7Pk=,tag:wuFJe+nn7Tsl9VWQtn/evw==,type:comment] +KF_AUTH_URL=ENC[AES256_GCM,data:PQaNOYW1ZSe5hy+2xVnRSe9mPCOe4IN/LuIdhIk/UZSBJ4sd,iv:Pd5ZQGOqEBcqPCi1t1qZQ5qnF8Qcqnteljg6b4tKhwY=,tag:l5OJIQnh8vruOuboIjN5BA==,type:str] +KF_AUTH_CLIENT_ID=ENC[AES256_GCM,data:+59Jiev83Fof,iv:hcD8yUMGkiKD22hGFfWvpz1fL6tyAB9yLKVE8N18Xr8=,tag:DQRVc+FYeyXC4dvW81Ml3A==,type:str] +KF_AUTH_CLIENT_SECRET=ENC[AES256_GCM,data:CluRm400lyJf10aNLoJAMWH6xADMBP7DivlinSdqWMeqdU7/ImpDbieNw4adJD9/6pDX9EGRTjEEg88zABiCSQ==,iv:tlwGBDQFPt1Xvl3FHAQb/M60v2/J0QN+QKf71pO31SA=,tag:2nNTBSLXUZ+Bvwd0uVxtBQ==,type:str] +KF_INTERNAL_API_KEY=ENC[AES256_GCM,data:NaWwUjdSLWwZmC0kO3Y7o3TRtwZQm/4Nwiz013um8g9GowbnNTGYwvkOjXQZ5PiMnfn+PaswSDGKR3+W/0OE1Q==,iv:UqeEm1DvkVThTIoY/igLRGngaSAHjwixEIYmdm8fQG0=,tag:QxEbPAQCj2z/tCZ6rgsItg==,type:str] +APP_URL=ENC[AES256_GCM,data:hjPOso+xBu5uNa9Tda+GbDG0qQxB,iv:a+E73avCFL7xmuztLpuFHLah7GQOwtJpO6ljsTW4ong=,tag:2WdXA3bH21ajwzs9ga/Tuw==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBta2RjbVBPN0I5ZUFqSGdU\nTGlSV3NERmg3ZFJPZk5CM3BDRk1TN3d3bUE4Ck1yVTFZTisvcGVYczdrSnhqWm1n\nbU1peWtsSUQ2MmpiK3ZFS2NmUlFNTE0KLS0tIFFtUXlkSTg5VFJFdWtZNDZBVXUy\nNGx3R1BtdWtTcm1wTlkwcFVVcUZMbVkKxYYUSSeUDsK4TePTTQzItWrBJEMoWmOj\nLkITwYDxj2YlLnMC4129N732nNaBFLy2Aruc3XaivO8Fr6eCtvRF7g==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr -sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPck9EQUNKMm1nQjhaSFBk\nVElzU3FJRWZXbHl2M2VBSW5ZaWJtcDM1Zkd3CmJQd0Q4TFh4M2NrU1Q3dlBrc1Yw\nQ1dBZXdOYmJtTUpGYk40K3k0ZjFyVUEKLS0tIHFGS1cxTzNza1lHbHZyeW83aEhZ\nZ2JhYzRVNkRQUFpSTDhHOFJBdnhpQUkK4RTqWukZ5TW8vluCWDbfVt7Ft3RctxSs\nzSe6OWr0/9+geSyTS3LDdYnwI881c4utCkLT38iRbDNWrHB+wevhyg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBOTko5Y0N1SnBTd2ZmOHZj\nSFJxbzFRR2NLWFRYV3dqbVpQNkMzMnA2aVNjCmJhdnNXWk1TTCs2NkFqeWhScVJz\nNFJEUTFROTQvN3Azakx6Y1pXWEFPbXcKLS0tIElKMjNHN2tEalZESkJQK3FHMzFH\nT2VONFJVZkFiaTliNHcyOGwvLzY2UlEKzWX6cJJxrv+7v8iacGd57UC69iNPbSek\ns/E9E285dy5wF5gVAsA2wJOoEgENdwRa7EAPcBsMI7VOl0bILGWiQQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj -sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtbXpUUXJUL2tyV0lzeWlv\ndDd4VW5SVUM2alpxSC9ySDZmTitJVEhlWUI0CnYxanpwaHpGc3RJaU5yaHdlZGxI\nL1d6S3FzR3h3UnFvRVUxZCtrTk45ZmcKLS0tIFZwTDZtNi9qMjRqTWxQYjQ1TzAv\nS1dWeGZHQ1pWcnFTZW9UWHJ3YXBSazgKtiUFAtwXghA0PDm45wWKNY+ZyaDPfC+5\nWFUsd7CSnTnKrFvnIJpJKvxoP2a40dDv6D1/FgIr/2FgOuTP7N92NA==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2NGlMTXJ1RExKOHVGUVVD\nQURUUm5samc1cnZSR3JjVE41RmdGeDZhV2pjCmtUMHg5bHE5eGxHTytla21IVk9X\nL01HN01vcXl5dkUrM2l4NWpaUUx1NnMKLS0tIDZIQVp4V25DMEVoYzl1NXNXNW1w\nbnhlcTNDVnBkRkF6eUxFcVVLV0JmK2sKyKBrZGPL5Rx/Q+rpzglShyJcjbDiWJEf\nYOnY5JDDB7P08hRW9nB2zcppXAHVfCeUPAWxsN+4BfAQb8FdO8bqwA==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk -sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4NWRLUkN6dGk2UFhmMU9h\nTXJuZ3M5U2luV1lJcy9mWktaZGVlMkRMVERzCkRaeG9HbXpPOGYvWUhJTDl2Smc2\nWW51WmV6bFFiNVF3WTBUTVZ1azQ3ZFEKLS0tIFQvaDd0RFZOV3FJVi95MDYrMzBV\nNUk0aWxBRkNDVkduYVhXTGFabWVEdmcKGTZOOe4yUfGjZS3uIe6NZSEjj/u2bpkb\nd4Bd259G2uVrG5RkK7AptKM1QHVsolAwF+MsdlopAux1sQJe75ZpKA==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5YjJGMVk1M3ZuYisvT0hR\ndWV0NVl1MGVvQkR3VzV6VVNLVi85aDltT2tVCnI2ZENiWXlDd0xwK1hPR21TL1dr\nWXdoVndlL3liR1F2aG0rNXZPek9qdEUKLS0tIHJMR05IbjlMa2N1RTVkYmhScjhD\nMW5Tbitlc08rNXdWSUZnSWhMQWYzelkK3bPNfxxb7E44O11ehhkIUxqhSEgUoT7o\nrsQSxnZFPuJeXEon1VO8c92SeD+JZy6BzXzYo03gHLRxcVBGput2jQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h -sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsb1FEdjNNQmk0S24rRzFn\nR1VjNXk4QzJ3K3lBY3dMNUQwU3ozUzk2aEZvCm56cmdjVEw0V2N0ejFLdk1sWHZz\nZlNUcEcxbGIzUDd6azhTaXcwYzRBUm8KLS0tIHBCWTgwUVRKZUFrWUpIdGh0NWV4\nditXdkFzQmUzRnJOMjBqNW1oMlhBekkKMmDJYcIz3iU6q34K9Ni/NGsyr6piekCJ\nIElWygu7hkVO3lzzZFyMCAZLpG/Y0I9xqcA7ovbkxOtabhoHNU/5qg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGOG1ueUpIektJazFCT0pj\nVzMvbExOQy9jK1psa25Rd1FjTUZXUmw3YkdjClljdmVHdVNWWHZ4MEpzditOaTNs\nRSt3NFVLOFdEQlhlakduTFpDTjF3aVEKLS0tIFh2bFZKUktqcmx1S0hEK1JvYVRi\nUVJTYkpJUitzT1NOaDhjcFJ2NEl6TzQKLtlw9zz/nSfvxHkNTKWb7ZITpKaNP3L4\nGH+FUZ4cQCJgUoNxtrGFeHBpgz5RCfhkCnVg03B7oZwZO4NZk+YQhg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx -sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwcmxSVVRqamVmdW9vckxx\nZ1hpWmJyeHNWK3hzTnpkNXN1N1VHakV6TWhFCmlabXdXUkZzeWRpL3N3TjdBZEdw\nZHJVNm9CMDg3emdRQmZSSUYwbEdScjgKLS0tIEFmblNNelQ1Q0d2dmxOdGNjTmxr\nSnAyUGo3Ui9oTUxac0krZmFSdndDMlEKpd4ME/6Lt0muiNUsq1AY+7NFbvUd5Rez\n7XAP+16L/nQsqCX2CE3w+m+ezlL3Xqd10RbTgxhG+wQWtON6NoRbRw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUbHE4Q0JVdHJIMnNzdFJa\nb1lweEROcyt5UlFzNHEwNndpdkVVdDVjdDBrClFNTVd6WkJCbkpUSFF4ZWVaRENS\nYWhsTURhaXdSMnNkU2REZDBONUZDejgKLS0tIHFRN1dXQzlYTzd3TTFtVTJGcDd1\nU1hqUkN2dFFSNVl0d1F3NDNtS3B3VlkKpN8Gn9oTEjCozAiYXT0ZUdXAc2wAKaED\n8oJ5r7aRuEsjidoBVNK6fgb1Jgk8qpa0g1/A0SSQRLXHspDrEz2HYg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy -sops_lastmodified=2026-04-23T17:35:43Z -sops_mac=ENC[AES256_GCM,data:bLZQR9QReCmUAYgBkxVBdZmw+3im+oo5G+lxghn68KX5vRczkf2tS2cyFOhirXD6PvvHKbsQpZlB8bHoF3uD2bsuEzdPpbtawYlUJIrh46uK9b5MbzfzqNUmKV7i7m4AHaOYxyly3PebRvUzbgoX/A/R8fYvCNz/vemaBWZzc/A=,iv:MGRNy+Q7PZcs9NpWS1XeBdOOBvUbErkEeJWCesGa904=,tag:wQhGaa70yAn4eV8Z9BgbJg==,type:str] +sops_lastmodified=2026-05-17T02:33:30Z +sops_mac=ENC[AES256_GCM,data:mz8tWY+WNLhcwTxQo+NvxyDLKHLVW8T0SrGrxRUnqzVuF/RtmEF/cXt/tNuB91tt8nCXNRHxGmvzNglUOytCVGZwZ9pB7rMMY38EcEYGn2weQS6zhOXFg+dX31HsgYa5/uMqPrOlmGEb1IHSj0lRtRMZ6HNakEaQGd5rGzQdzGQ=,iv:cn0cIvKtW16gKIy8p72soVuKJdsjZvSEnE0yR0umxNM=,tag:UdbXCfyxx4p7CmMkxNLgSQ==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.11.0 diff --git a/infra/.env.local.enc b/infra/.env.local.enc new file mode 100644 index 000000000..3b8465871 --- /dev/null +++ b/infra/.env.local.enc @@ -0,0 +1,59 @@ +AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:XRrbCOuZdK8OysMC8g+jAaifrbpYSxVywFEbb3HjnUfoHizjXn+NlWOUAVmpqN5BGo8diM34p2EVduU1XZyzSQ==,iv:jqCPzyTAdqSGOiCFMT88AkvZFYVeEfcwRfu7IwpBlsA=,tag:Dm7+P6d5FXCH0iC016L0Tg==,type:str] +ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:XuAZOjWIOkJ07lO4XrAvlUvn693sA32GWSF4OgdkG3IJqr8UBEtSnuIcEIbdcF9/ANLBoWFgW3PXrZ70jdGDYw==,iv:3MfHLwJisbdD1FuNCN53y04pGsrZdHwFKvWZ4TiUCXw=,tag:TpZf6DbGqzTKqeaXVmmO5w==,type:str] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:n5D8UKoeZwKWuwMXuSlxOm1TRxw=,iv:R4d7oQkeBYixr2QzugIvPvbuy8fViR2FcNiPw7ielhQ=,tag:HwWiw9fDS6o9Jn2nKYs79A==,type:str] +AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:b1YKnZq9gAs6unTXjtfsOYOq3Rc=,iv:OFm9qoskNddUO+Yh5V+tY6lDbhvF74OvRFRR8gvw2Vs=,tag:F37DWCeP/BbV/mL5Jt8xiQ==,type:str] +AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:Q7HVjPiubI93ZRPzVMmAUfqnB5wQmDlyMiL3ZuswImBmSPigHafEcQ==,iv:VBi7efe5hJ9cqfyS48w/QZNekyOA1Gd/Kb2I7r3MLro=,tag:9LNAKj/DWpK8B31abTjq9w==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:dDjdPGyr483URjl+YkPmxtYsoGxhkTjFMBvdJShK43WdYgvWaFQNJw==,iv:myfObHUHH0m7nWYzrr8RrphaBffuqOuM2qdTYUASI2E=,tag:+bsEDlphkGMijOdVvMhzjA==,type:str] +BACKUPS_SECRET=ENC[AES256_GCM,data:oO4V9V5YSE1bGYv/Kr7Vxb3Xw/5qR+SveidcUmbdnBzuZhaCtde/MUZbmNM=,iv:bz7Pxf4P0XX7pDFT2axgjiY7ONner71ZWiyqpDzwk5o=,tag:be2p6MhRvvO1rYoPx3Q3fQ==,type:str] +CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:rzOukQvL+WIJ35sOLwlpX+xxUp3fmhVOhPzmQrqgVKI/Awrzp3bFdt0pPTcpnxQ2IirUCvs=,iv:RQF3iClC4OivxbvCP1jVmsduPcB7IRAsQS+VZ537M10=,tag:10AxZDcl8L94X+Ku76CgrQ==,type:str] +CLOUDFLARE_CUSTOM_HOSTNAME_API_TOKEN=ENC[AES256_GCM,data:iFvN6NpQH/KJ2mjPPcL38kM33Z7/eqbI6+uebsYuLVEs7F3yh/FOuoueb9TaTHn3DHUkzQ8=,iv:BHlee1tudmEAk3gfUkjNFftRBnAyeB8wH/pwcXxgVBA=,tag:H57LDqLFp9RH8G8vQ0NwCw==,type:str] +CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:9NbqU9+RF1BTNYBJKYoiFpckgEmQIj2RXkVNwOxa9x0=,iv:qGRIa+opCaodDmwwj8z3n3ppu8RWbzrnQZ2tG5d5s60=,tag:t1YW0H6vr+1+ObdHJ0hEDA==,type:str] +CONTENT_SEARCH_TERMS=ENC[AES256_GCM,data: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,iv:MZxp1vXHUtQbnSRC/1T7tRAVYTu8I2sIEwzkn/9srDA=,tag:skSW6FWPkMGvJrZDE8434A==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:7G44ivPxFMcsHTnssCzLCJVS9IZt5CnFpxIHIZahMEol2N/As1mN6qeb3ds=,iv:ixUkDZPnSchGhXQN/feorKtgSMdnmpBy9XeatJE3lPE=,tag:LBu5Rw+nMedECjwpP3rqag==,type:str] +DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:S3j/ilWNZs64uWEsV4qEvft07mXoTbs7ktheR20RiZwzcw==,iv:avmtNQWnEr/1svDYhVHxcdUg5IlGlm1znuYF7k4EglM=,tag:QBcmlkfdzHayGMfCS0377w==,type:str] +DOI_LOGIN_ID=ENC[AES256_GCM,data:tj3fwNl0,iv:Ndpq/BV/V1n5sdRdnOIpd5q8KdaGxESLI1gZDAr3huQ=,tag:FT+G0TYVYPdouH8L0jawEg==,type:str] +DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:6oVZvxbpyGL/uTE8Fs5TItys5ng=,iv:OiPNIe2OwBb+hsrihtrzAOZRq1T1q8TnOebgH/JXqsM=,tag:uYsY8P+/RA5hKwoayTLQUg==,type:str] +DOI_SUBMISSION_URL=ENC[AES256_GCM,data:CSrSedSfIY/JjhC7EPGIrDZsjJ+3hnPh+2jZuR3QLYkpIE1Otqu55A4=,iv:SLpluAPerRk1fEypwZMSQqEAiVJtjoiA0esgFQ2+mcs=,tag:HOt7XzTHNe9WGx832UKibQ==,type:str] +FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:q3yhtanJhbVPpkyTbUUT5XV4WJqvCCKbkIILsgecnG4=,iv:vjMlrhllN0CwVepu4nb5nTVNgL8NR6w/wY7Xu0rLB1A=,tag:NofwqwvS8y90R0sq5tiI5Q==,type:str] +FASTLY_SERVICE_ID=ENC[AES256_GCM,data:niSYhU/1i0ydC0hOglDNMx2wuWsCCQ==,iv:tjat4pUfC8/go2YQq+ZMUrbCopnfgEZ6USRY3fotoU4=,tag:Ivfr6TaLGiQ2/OSdUl8hVg==,type:str] +FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:0/5MlEPOUE2BZzhpqUqBXVsBy9jVRtzrAQqQZftSep4=,tag:sZeILTq2Y1DsvI4axQKNtw==,type:str] +IS_DUQDUQ=ENC[AES256_GCM,data:1qc8rQ==,iv:+c7K3tT3+TkXpQgsxwsUdrduoe0vbmkYrhzka94ydgo=,tag:uPUCSDFj5Upz9DncVa4I0A==,type:str] +JWT_SIGNING_SECRET=ENC[AES256_GCM,data:Wk5Mo7pnugtsZTiN1+RrWlVHfYrYuRLhnxLZaKfoN3dXztCuFv0BVX727APcHi8G5/8l2aydXZJRq7czlXzv+t5Ss81PqT3AidHR8/jLRuy4nonAO+mhMapgafO8cev0ezfOIV/ng1PQS13JRBObn6aM0oLeTZKFtGBpKYP8FyRLjAb2HlpIysQel4SAOR/1aUtNx1lPylunNVs8TjmKFFk0b4gMwdI1BBXldKn1bV/y8BkmEWk3ZkKYh9bloE71lXj+VSdwzeFk+cbJYAfPl4yUV+Ss/T+vdjjst5XrLQehZBXHJ9i8rInP5WcBfzMqXjxm61q0kPAyALQ7yLk1+Pi/OL+EpVAQkbyVJraMO+49EQsNGh8DFTKfAdW9Ef1PgbJbv6oyU3b3940/xaJeQgbDxJ+XICJTtJKRaxC+DGVTg2EgnxU+HzmWKKjHnxGLt5EP9KxKhp8NqSh6vBnofbrFtEybG8EsAPiCu0XHHhIWrUhx8ShWAAnoAVwQT6pkp06/n6gssK1Imoqi5YfJcFdoLWCgZA+8Sgu3rdrqqBu6744fgSp2HAxJ6pfT197Hd8mIyRegVtyibPxjigPEIb1kDN2b0cwLknXH9ywErNCqL63RBpxbg7EfuHQhCKTgwcEuze9fOGxWPFNWWX9ilYasj7D96q+NwgX2iuFs3unuGpx0Qekl6fMegkuKw8MKeG29GMvWqbRGsq35VWTPR2cMJZ5VfJINCDu5NbI5FSGS0doSIRzVs/fYfUZ+TKgeZbVM9dY2PETFmVwXRPEgxeIxlP1okjsPP+bZnRmzNED5yOunOo6o9cK7E3G+gzsuVKQW8zLxP80HbNcI4fAflOAyligJpBh15A1hKNdhm63n41h6kiGecX+gorjb+q141oIq+CCsU+AcGs5yGJ1+VoWbA7FFLd8n8RNaa3NXZyiL0qsjGg6+EzfFwdKj23ChLK4r+Tqw6LPLYtsHF/KkVQbLGxwhNpDs0aZT4hJbiCpjV6N9UyuBDl9HEG/loYvHfswBLbnKoagB76qFCWuATUAeJEixFAA7niuRdEaEhiNapFOgKfHFpZ3ZcfGQn2++IsemEeEnECeS/aRTM/3p9SNNOv6c9Z7DUCVLAELrRqi/mwEg7BNh3amyt4cE7NiW6Mh1zzrFW9jl4Ny8G2AZiydjkTlmNcXJMSrU5z/bxWqdWQrKCtBTYkLCxnv/xLRHxF4F+m0ZOcJvESXnyWgzDFyNkrPOKNOQGO8rhDRQpu1ET4+SqbIvyhIiloT9U2ugGhzSJB5YW8wn9/f0rb9cFcx7r4kbhFerno0FPHUoyoQ+8RCoXcvNkdaL/3uiJYE+jiBiXDUvnCEbA5RYYHjafg==,iv:PP4PbASMABdy8mQeNpyWI9FeMjWhRNJ8nycgiaLXBKA=,tag:igUoRywjNWwreBkgdEW36w==,type:str] +MAILCHIMP_API_KEY=ENC[AES256_GCM,data:CIWa32ygnpkK6QPoq0FVw9YWaXZrd2LFbjOYDTrt2wOACfmr,iv:ooTxYS/0p3/6TMde7+/Y7w+h83CEAzcRDTIXjz5lZ/Q=,tag:s9pG1ttTwSiVtLXnatdjmw==,type:str] +MAILGUN_API_KEY=ENC[AES256_GCM,data:nPH6Km5hixRtj+btl7WHAUYGkONxHmcDbf1H/Bpt0TWgC47K,iv:aHpsZo2EAzPzjfg1/mp11ucH3qkQaRQfySM17PlxV6g=,tag:3cuYn5jLt3F13Scq+nmZNA==,type:str] +NODE_ENV=ENC[AES256_GCM,data:1isuh3tOeAJGcw==,iv:AfPbLyMVmBArq26Fs01ZmMc43HMtws2EnQXlOwsmESM=,tag:DVyzGVtVutoSPGL4ZeIBFA==,type:str] +S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:IINWnDfgucDNxdFzpZRcOduZaeQ=,iv:aIadDQwcnXocicLhMf2pEeUQJu9iqgprkL6wGYFe+Mk=,tag:6Yyg5aHJcYR4yk7I/0jALQ==,type:str] +S3_BACKUP_BUCKET=ENC[AES256_GCM,data:aq9F254VFxzc0Cw=,iv:0wltZ9vfiP8tEgf06FGxQP4y8KdZ7U3rS8csQiY4y5Y=,tag:aYs7N/azr2+boByKnEJ6iQ==,type:str] +S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:DdUQumcLYE7SZegJXxUcsdOKz37KGenLXy2fyzvbDj5DG/E=,iv:JUsYadOwVEqpGsIa6mEzvLQ72ISC1F59HnTxbW5mJIg=,tag:Xj2HVARtYEPRT+BB64F27A==,type:str] +S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:4/naQ8RAJSEU2caBe1P4nHTL7uG202WyXg6M15ZfiUXGs0d4dwzWnw==,iv:7N/t00CkNVMdMS6RhYFICxRu/jW0DtCGvvGYNPmBWCQ=,tag:LdomInGvbX54AutRFxJdEQ==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:u7mXp1lTtqGIx1M6HfJKGh+/S0nRWvrtAVJe4iBUdKvaWFW8/KFbjHOE6/RQSf6LTVVpdsfl63YVAVLlH2ghgydz3sh9hfALJQTiOP+0CjvjMKYhEo440zv6sEp5kjnJm9rac7DG8bYGjmrJCwMmmsS4xgXDboGgzH73gxukHodyUEL80LdmOQe4mbvHSHePcK+xWymhfI3n2XEQpFtlccIYYrqWFTUxGchF71OsplQxZ9nBoE5XG3VHqQ==,iv:GR5L7mr4dH2q2ldnpTjjJaEA/FeaUnZZoMhCaZd1SgU=,tag:ZI6OUhh61uq3CFvU3Wt0Yg==,type:str] +SENTRY_ORG=ENC[AES256_GCM,data:X2C1,iv:8yd1SEWhJaI6cekgE5Ia/J257OXB4qh7YTgRolFDpxQ=,tag:nO2NzUHCDecfpvrKEtE0Vg==,type:str] +SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:8no=,iv:Kl9l9odW748QyrJaryvDadS1Fkd1jEB3tFQmhBRHE6k=,tag:qVJDNkOMtpIzClxnd1wzLg==,type:str] +SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:qkAAX7U3crQ70/0/MaGOpHfPUH/w9w1fidjLelKIoikNzP0L6P309TQSLCbKp7VDptW7/N+Oi7pZTDohDp2ttod6C2jPp5IOVNWE7mRsEQ==,iv:mXHfd0fBQULasBu1rlxxdbqV/j5qrHMHHE60vPuiAhM=,tag:DD5QdKWP3xqZmPq99vA5UQ==,type:str] +SMTP_HOST=ENC[AES256_GCM,data:R4KgQe6/5afRANw/Kn1d4ZzY0s0HeyVWkGdx9lPPnhbIIQ==,iv:3I9jJLlj786XEUN5toTOlC2LmMC4hSK1FBBMouShBh8=,tag:KZEHGBB9wUlOKPjLCG+ZzA==,type:str] +SMTP_PASS=ENC[AES256_GCM,data:fnsP0UPfDLcpOjXl4LGeVKGo7RajA4d7HHNyYSc1c0WgcvX6DlOK7/25qRA=,iv:nsvsFK8QS5h809xUY+1s1SzcA36HQkDPbeHM7ClUqLU=,tag:duccNhq5B5gIJvuHZMakkg==,type:str] +SMTP_USER=ENC[AES256_GCM,data:yQorJa7k/UKc9AoQPcb1ckqdlt8=,iv:e0rsCh/UcNcfeQ8VKJ+uJwoyndieXc7a6+yawXMZrls=,tag:oX3nOk2EpYOV4ulcBALPPQ==,type:str] +ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:fkual9M7pkW4txUAltrX/tD68h8=,iv:1mPi+2+uYPl8H5N0UF534eVCs0dOAamSzm4NPQCPRjs=,tag:xQWloLt+CyDNBXlZVMLfdA==,type:str] +ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:CUjzaTb0VbjLeYVuy7KPHizZO0Q=,iv:7jE6QAtqysUaZReAzHRZ+8xxGJeV91drO5WwsGR4bXs=,tag:hNaHJ3r0GZp3w5u++mnoVg==,type:str] +#ENC[AES256_GCM,data:V7juNWxQv+I0TtmWJLnwUkOjx2k=,iv:cnoWnTmHi6M60F8ow+m9lBOyXFtI3oCN7VotEwVyQoU=,tag:fxVnVAswhFRdRiWcOf+3hA==,type:comment] +KF_AUTH_URL=ENC[AES256_GCM,data:qeMJ5xv54dvFedtgr3lV7K/m/lc4,iv:/4N/v3kQb+wv4PmOm77KbpexrUL0MJwO3817cGUOYw8=,tag:QiLkz5XtE1kQhBMqo+o2Qg==,type:str] +KF_AUTH_CLIENT_ID=ENC[AES256_GCM,data:VLNknIRyka/w,iv:/xUAi1wHXKurI0S9ZTqQ+dr5GKHuEhEP1ef6IUXQfKY=,tag:mLTbofx/DA24V/o1vMlgEg==,type:str] +KF_AUTH_CLIENT_SECRET=ENC[AES256_GCM,data:SGTsRe3QYFCcT5KwszFBBsC6VrKeHzTQakFYt2imMz0wuVWRsLjEMgfOKz4ZvLal0nBsxve3+8FyfJUSpVQcgQ==,iv:F6CPMbfUKMn7i3EnKtQ2HOKNiVFyfU+ANQ3EQNG3FN4=,tag:HCgyY7QtHpKjMn8OO2f6Qw==,type:str] +KF_INTERNAL_API_KEY=ENC[AES256_GCM,data:LDgrx6R5bsUTCAGHdVZtwT6wjebGqoJlo7lr/Q/dnFk=,iv:nIh12E7DPPY15Bc4/o63HnbbxFAGptahSlZoilr+zO0=,tag:vaTXyeG5ymBuQIwumIpqyQ==,type:str] +APP_URL=ENC[AES256_GCM,data:2tvfTs4bnVCU6XmIoPGdX8jcZfiW,iv:DImM0gX/v/EZXaH4ymBLh8UvSRWyoBkjzVqE+1dx/ic=,tag:+AOWRnr0NGHOAJfTPpr5UA==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwOUZ3eGZKN3RWNVB3c3FR\nRWxxeTUyVXV0UlZlZlVSWEQvNTFtelZFUGdZCm5Zc2prcGFFWjhieTZrYVAyQUN4\nUTB3dTFyQlVSOERDMWpLcWk4R0R2V0EKLS0tIDFGbTgzQkEzMnJuRXVrdWx5MDdx\nZEE2S1pXRmZzTDRBekw1TVFFb2IvM0UKZ39NqCi5GS7bBAnpTKeRw0LOrTt59xpd\n+LE7iOT/VMI/iw1SUTv95JyPjaNLK+Ve5b+niAyhWcV3rOO/Z5tUdw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNdmMxSWlyYUFLZm0xWjBj\ndjB5MFFROHdad3ovVEFobVFNc3d3L1VCaGlrCmw4U3I5QkFRcjVoRFpCd1F1ZFpl\nYUhTSmZtbDUwK2Ewci90VG44cFlsZ3cKLS0tIEU1NnFLdTJ5Tk1UOUJRS0tLU01O\nZE5oYWROa0pPa1hHY3I1VDJwSk5kZVEKZbKKwgkCtQZ8z/HUFjN9oRZYo21qbi4R\nRMSpWxHizxEEt3VUdJ4lWaBDqeuc+b4YhS3vYW+6Z078Vuhc735qag==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA5alcvZVdKMmZWZE51bFlP\nVndoMlp2VURUd00yYVRKQ25Kci9uUkJpZnpBCm83OSt2WGRzaHpRTlhwb2xDSDRG\nWFBnM0luWnNMRTI5d0M2Z21WNUpveFEKLS0tIHNyZFZQdW1WdmVaSkFXV21oRTJE\nVEJXQWZaeHRqeE5ncUZCODZhTU9FY2MKcXs55f9lmq8thCt6XtdR4pPGBIM0nOhs\nyb/hxCd5dSnXxpwh1oV45pyFqy44GiEL0Qzccl2VsQsTbvSOgVgacQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZL2hHdk9PK05iS2c3Ull4\nL0x2SXl5OG91QzlwN25DZ3NnU1JYTmJtUFRJCm9OSVJkNldPRnY2VXRBZE5UNjMz\ncFl6TVpDNFFiR1JPYW12dVc5aFpnb28KLS0tIFNkekhwL3B4RzNOV2hveXI2SHZ5\naE9BM3pLMlEweEZLa2dCV3J1eDh2VnMKp/NkvwDan14XxLtX91U60uQ7r/OuUySC\nAoRmRhOVBOwFhtC/06rdL8wf9AM7TPKV9uwMuIopNW2qFovs8x23Sw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqb0VsSExNRG9ONlZlb0Vy\nam9jdDFqamN2Q0YybTBKK0tqQVM0NU9kUWdjCjJVZk9hb1lwdHNaQ051QjVLU1Qr\nMFR4bXZCaFptMkY3ZzhEdk9hVytXVjgKLS0tIFB5UnAzWnhOcGtEMkVyd2ZxZVBS\nZlJzVDFSTXFxc2RSWk5BWk81N2MxajQKIGxV8cHYxKmZ79Fp7r4RMt8FJ93znZGo\nmBuEvkO/M2EctnjO+SQDQIbhNpx/G3++3LmrdLShImQ+fK/lDdHNfw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBnNlpDaUNGMDlycXZRUG0v\naldWSUp6WGwxU2lVUnFwcndCMit6eGJSTkc4Cm5MbE9uejdtWGVLUmgwZ2VwWlcr\nbHQyRWVldm1CVWVjZk5sckhYZ3ZrVTAKLS0tIHRDbXZOa0RXZERNZFMraVptTlpO\ndU5yeEFwelBPeDlReUYrSStWZDlEQWsKQ711aKw9Jbcq88IczNO/1esls+JRaV1S\nPIyPCTL2YTuO3SSpHZIq1OjmHhNmDN9nEQ8sw8Hg2FmHvPEfozrxZw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy +sops_lastmodified=2026-05-17T02:34:04Z +sops_mac=ENC[AES256_GCM,data:h/n+dajxY6dwasJsndS96YBIdlMX07zbF6gC1Ecealo2Zgn7YQDT7W9hYebJiClf338j87x9NSHbDL5m/TUxMfKgANziXViP/7EzBMpoEEMhQMXwXz/Ul6sRYfByMZI0kDyiZy8FiMX+hwc2elKZdI1cKcJST+VCnLBNZbHaBz4=,iv:OqlNfTwi/w976o43EOE/ZLvIYSodumuhfyPpVPrlYY4=,tag:qrPSlr8rB17wV+wn8GYzoQ==,type:str] +sops_unencrypted_suffix=_unencrypted +sops_version=3.11.0 diff --git a/infra/.sops.yaml b/infra/.sops.yaml index 86ab8b2af..a03f7aa73 100644 --- a/infra/.sops.yaml +++ b/infra/.sops.yaml @@ -1,5 +1,5 @@ creation_rules: - - path_regex: \.env(\.dev)?(\.enc)?$ + - path_regex: \.env(\.dev|\.local)?(\.enc)?$ age: - age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr - age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index 6d069a00c..c3511a261 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -7,7 +7,7 @@ services: dockerfile: Dockerfile target: dev env_file: - - .env.dev + - .env.local # healthcheck: # test: # - CMD-SHELL @@ -38,7 +38,7 @@ services: dockerfile: Dockerfile target: dev env_file: - - .env.dev + - .env.local environment: NODE_ENV: development CLOUDAMQP_URL: amqp://appuser:apppassword@rabbitmq:5672/appvhost diff --git a/package.json b/package.json index 508b3afbe..363c55ff0 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,10 @@ "continue": "concurrently --kill-others \"pnpm run api-dev\" \"pnpm run build-dev\"", "secrets:encrypt": "bash scripts/confirm-encrypt.sh && cd infra && sops -e --input-type dotenv --output-type dotenv --output .env.enc .env", "secrets:encrypt:dev": "bash scripts/confirm-encrypt.sh dev && cd infra && sops -e --input-type dotenv --output-type dotenv --output .env.dev.enc .env.dev", + "secrets:encrypt:local": "bash scripts/confirm-encrypt.sh local && cd infra && sops -e --input-type dotenv --output-type dotenv --output .env.local.enc .env.local", "secrets:decrypt": "cd infra && sops -d --input-type dotenv --output-type dotenv --output .env .env.enc", "secrets:decrypt:dev": "cd infra && sops -d --input-type dotenv --output-type dotenv --output .env.dev .env.dev.enc", + "secrets:decrypt:local": "cd infra && sops -d --input-type dotenv --output-type dotenv --output .env.local .env.local.enc", "start": "pnpm run build-dev-once && pnpm run continue", "storybook": "start-storybook -p 9001 -c .storybook -s ./", "test": "pnpm run lint && pnpm run test-no-lint", diff --git a/scripts/confirm-encrypt.sh b/scripts/confirm-encrypt.sh index c347f5813..689669209 100755 --- a/scripts/confirm-encrypt.sh +++ b/scripts/confirm-encrypt.sh @@ -9,6 +9,10 @@ if [ "${1:-}" = "dev" ]; then ENC_FILE="infra/.env.dev.enc" PLAIN_FILE="infra/.env.dev" LABEL="dev" +elif [ "${1:-}" = "local" ]; then + ENC_FILE="infra/.env.local.enc" + PLAIN_FILE="infra/.env.local" + LABEL="local" else ENC_FILE="infra/.env.enc" PLAIN_FILE="infra/.env" diff --git a/server/kf/api.ts b/server/kf/api.ts index 505a8d66d..3a946a252 100644 --- a/server/kf/api.ts +++ b/server/kf/api.ts @@ -84,7 +84,9 @@ export const router = Router(); router.get('/auth/login', (req: any, res: any) => { const communityHost = getCommunityHost(req); - const returnTo = req.query.return_to || '/'; + const rawReturn = req.query.return_to || '/'; + // Validate return_to is a safe relative path (prevent open redirect) + const returnTo = typeof rawReturn === 'string' && rawReturn.startsWith('/') && !rawReturn.startsWith('//') ? rawReturn : '/'; // Encode the community hostname + return path in state so we can // redirect back after the OIDC callback. @@ -177,7 +179,7 @@ router.get('/auth/callback', async (req: any, res: any) => { domain: '.pubpub.org', }), ...(isDuqDuq() && - req.hostname.indexOf('pubpub.org') > -1 && { + req.hostname.indexOf('duqduq.org') > -1 && { domain: '.duqduq.org', }), maxAge: 30 * 24 * 60 * 60 * 1000, @@ -190,7 +192,9 @@ router.get('/auth/callback', async (req: any, res: any) => { Buffer.from(state, 'base64url').toString(), ); const host = statePayload.host || ''; - const returnTo = statePayload.returnTo || '/'; + const rawReturn = statePayload.returnTo || '/'; + // Validate returnTo is a safe relative path + const returnTo = typeof rawReturn === 'string' && rawReturn.startsWith('/') && !rawReturn.startsWith('//') ? rawReturn : '/'; if (host && host !== req.hostname) { // Redirect back to the community the user came from @@ -460,7 +464,7 @@ router.get('/api/kf/community/:id/detail', requireInternalKey, async (req: any, const analyticsStart = startDate || new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); const analyticsEnd = endDate || new Date().toISOString().slice(0, 10); const pubsMonthsBack = startDate - ? Math.max(Math.ceil((Date.now() - new Date(startDate).getTime()) / (30 * 24 * 60 * 60 * 1000)), 3) + ? Math.max(Math.ceil((Date.now() - new Date(startDate).getTime()) / (30 * 24 * 60 * 60 * 1000)), 3) || 24 : 24; const community = await Community.findByPk(communityId, { @@ -539,10 +543,10 @@ router.get('/api/kf/community/:id/detail', requireInternalKey, async (req: any, FROM "Pubs" p INNER JOIN "Releases" r ON r."pubId" = p.id WHERE p."communityId" = :communityId - AND p."createdAt" >= NOW() - INTERVAL '${pubsMonthsBack} months' + AND p."createdAt" >= :pubsCutoff GROUP BY 1 ORDER BY 1`, { - replacements: { communityId }, + replacements: { communityId, pubsCutoff: new Date(Date.now() - pubsMonthsBack * 30 * 24 * 60 * 60 * 1000).toISOString() }, type: 'SELECT' as any, }, ), diff --git a/server/kf/auth.ts b/server/kf/auth.ts index 00d91c57d..8e171f533 100644 --- a/server/kf/auth.ts +++ b/server/kf/auth.ts @@ -1,17 +1,13 @@ /** * Lightweight OIDC client for KF Auth (PubPub edition). * - * Two base URLs: - * KF_AUTH_INTERNAL_URL — server-to-server (e.g. kf-auth:3000 on Hetzner internal network) - * KF_AUTH_URL — browser-facing (e.g. https://auth.knowledgefutures.org) + * KF_AUTH_URL is used for both browser redirects and server-side calls + * (token exchange, userinfo). */ import crypto from 'node:crypto'; -/** Browser-facing URL for auth redirects. */ const KF_AUTH_URL = process.env.KF_AUTH_URL ?? 'http://localhost:3000'; -/** Server-side URL for token exchange / userinfo. Falls back to KF_AUTH_URL. */ -const KF_AUTH_INTERNAL_URL = process.env.KF_AUTH_INTERNAL_URL ?? KF_AUTH_URL; const KF_AUTH_CLIENT_ID = process.env.KF_AUTH_CLIENT_ID ?? 'kf_pubpub'; const KF_AUTH_CLIENT_SECRET = process.env.KF_AUTH_CLIENT_SECRET ?? ''; const APP_URL = process.env.APP_URL ?? 'http://localhost:9876'; @@ -79,7 +75,7 @@ export async function exchangeCode( code_verifier: codeVerifier, }); - const res = await fetch(`${KF_AUTH_INTERNAL_URL}${TOKEN_PATH}`, { + const res = await fetch(`${KF_AUTH_URL}${TOKEN_PATH}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, @@ -114,7 +110,7 @@ export interface KFUserInfo { } export async function fetchUserInfo(accessToken: string): Promise { - const res = await fetch(`${KF_AUTH_INTERNAL_URL}${USERINFO_PATH}`, { + const res = await fetch(`${KF_AUTH_URL}${USERINFO_PATH}`, { headers: { Authorization: `Bearer ${accessToken}` }, }); @@ -136,7 +132,7 @@ export async function fetchUserOrgs( if (!key) return []; const res = await fetch( - `${KF_AUTH_INTERNAL_URL}/api/internal/users/${userId}/orgs`, + `${KF_AUTH_URL}/api/internal/users/${userId}/orgs`, { headers: { Authorization: `Bearer ${key}` }, }, From 64e1506266879112bf0bda6c947a2cf9a4ef7f3b Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Sat, 16 May 2026 22:56:48 -0400 Subject: [PATCH 04/13] lint --- server/kf/api.ts | 224 +++++++++++++++++++++++------------- server/kf/auth.ts | 18 +-- server/routes/index.ts | 5 +- server/routes/signup.kf.tsx | 2 +- 4 files changed, 152 insertions(+), 97 deletions(-) diff --git a/server/kf/api.ts b/server/kf/api.ts index 3a946a252..4972b4e11 100644 --- a/server/kf/api.ts +++ b/server/kf/api.ts @@ -17,34 +17,23 @@ * POST /api/kf/transfer-community — transfer community ownership to a different KF Account */ -import { promisify } from 'util'; import { timingSafeEqual } from 'crypto'; - import { Router } from 'express'; +import { promisify } from 'util'; -import { Community, Collection, Member, Pub, PubAttribution, Release, User } from 'server/models'; +import { Collection, Community, Member, Pub, PubAttribution, Release, User } from 'server/models'; import { sequelize } from 'server/sequelize'; -import { ensureUserIsCommunityAdmin } from 'utils/ensureUserIsCommunityAdmin'; import { getHashedUserId } from 'utils/caching/getHashedUserId'; -import { isProd, isDuqDuq } from 'utils/environment'; +import { ensureUserIsCommunityAdmin } from 'utils/ensureUserIsCommunityAdmin'; +import { isDuqDuq, isProd } from 'utils/environment'; -import { - buildAuthorizeUrl, - exchangeCode, - fetchUserInfo, - fetchUserOrgs, - KF_AUTH_URL, -} from './auth'; +import { buildAuthorizeUrl, exchangeCode, fetchUserInfo, fetchUserOrgs, KF_AUTH_URL } from './auth'; // ── Helpers ────────────────────────────────────────────────────────── const KF_INTERNAL_API_KEY = process.env.KF_INTERNAL_API_KEY; -function requireInternalKey( - req: any, - res: any, - next: () => void, -): void { +function requireInternalKey(req: any, res: any, next: () => void): void { if (!KF_INTERNAL_API_KEY) { res.status(500).json({ error: 'KF_INTERNAL_API_KEY not configured' }); return; @@ -86,7 +75,10 @@ router.get('/auth/login', (req: any, res: any) => { const communityHost = getCommunityHost(req); const rawReturn = req.query.return_to || '/'; // Validate return_to is a safe relative path (prevent open redirect) - const returnTo = typeof rawReturn === 'string' && rawReturn.startsWith('/') && !rawReturn.startsWith('//') ? rawReturn : '/'; + const returnTo = + typeof rawReturn === 'string' && rawReturn.startsWith('/') && !rawReturn.startsWith('//') + ? rawReturn + : '/'; // Encode the community hostname + return path in state so we can // redirect back after the OIDC callback. @@ -188,13 +180,16 @@ router.get('/auth/callback', async (req: any, res: any) => { // Parse state to get the community host + return path let redirectUrl = '/'; try { - const statePayload = JSON.parse( - Buffer.from(state, 'base64url').toString(), - ); + const statePayload = JSON.parse(Buffer.from(state, 'base64url').toString()); const host = statePayload.host || ''; const rawReturn = statePayload.returnTo || '/'; // Validate returnTo is a safe relative path - const returnTo = typeof rawReturn === 'string' && rawReturn.startsWith('/') && !rawReturn.startsWith('//') ? rawReturn : '/'; + const returnTo = + typeof rawReturn === 'string' && + rawReturn.startsWith('/') && + !rawReturn.startsWith('//') + ? rawReturn + : '/'; if (host && host !== req.hostname) { // Redirect back to the community the user came from @@ -241,8 +236,7 @@ router.post('/auth/logout', (req: any, res: any) => { router.post('/api/kf/profile-sync', requireInternalKey, async (req: any, res: any) => { try { - const { userId, givenName, familyName, displayName, email, image } = - req.body; + const { userId, givenName, familyName, displayName, email, image } = req.body; if (!userId) { return res.status(400).json({ error: 'userId is required' }); @@ -383,9 +377,7 @@ router.get('/api/kf/billing/usage', requireInternalKey, async (req: any, res: an // Placeholder — just return community count for now return res.json({ kf_org_id, - line_items: [ - { key: 'communities', quantity: communityCount }, - ], + line_items: [{ key: 'communities', quantity: communityCount }], }); } catch (err) { console.error('Billing usage API error:', err); @@ -432,14 +424,13 @@ router.post('/api/kf/transfer-community', async (req: any, res: any) => { const userOrgs = await fetchUserOrgs(req.user.id); const targetOrg = userOrgs.find((o) => o.id === kfOrgId); if (!targetOrg) { - return res.status(403).json({ error: 'You are not a member of the target organization' }); + return res + .status(403) + .json({ error: 'You are not a member of the target organization' }); } // Update the community's kfOrgId - const [updatedCount] = await Community.update( - { kfOrgId }, - { where: { id: communityId } }, - ); + const [updatedCount] = await Community.update({ kfOrgId }, { where: { id: communityId } }); if (updatedCount === 0) { return res.status(404).json({ error: 'Community not found' }); @@ -461,14 +452,33 @@ router.get('/api/kf/community/:id/detail', requireInternalKey, async (req: any, const startDate = req.query.startDate || null; const endDate = req.query.endDate || null; // Determine analytics date range - const analyticsStart = startDate || new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const analyticsStart = + startDate || new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); const analyticsEnd = endDate || new Date().toISOString().slice(0, 10); const pubsMonthsBack = startDate - ? Math.max(Math.ceil((Date.now() - new Date(startDate).getTime()) / (30 * 24 * 60 * 60 * 1000)), 3) || 24 + ? Math.max( + Math.ceil( + (Date.now() - new Date(startDate).getTime()) / (30 * 24 * 60 * 60 * 1000), + ), + 3, + ) || 24 : 24; const community = await Community.findByPk(communityId, { - attributes: ['id', 'title', 'subdomain', 'domain', 'avatar', 'accentColorDark', 'accentColorLight', 'headerLogo', 'heroLogo', 'description', 'heroBackgroundImage', 'heroImage'], + attributes: [ + 'id', + 'title', + 'subdomain', + 'domain', + 'avatar', + 'accentColorDark', + 'accentColorLight', + 'headerLogo', + 'heroLogo', + 'description', + 'heroBackgroundImage', + 'heroImage', + ], }); if (!community) { @@ -501,26 +511,38 @@ router.get('/api/kf/community/:id/detail', requireInternalKey, async (req: any, Pub.count({ where: { communityId }, include: [hasReleaseInclude] }), Member.count({ where: { communityId } }), Collection.count({ where: { communityId } }), - sequelize.query( - `SELECT COUNT(*)::int AS count FROM "Releases" r INNER JOIN "Pubs" p ON r."pubId" = p.id WHERE p."communityId" = :communityId`, - { replacements: { communityId }, type: 'SELECT' as any }, - ).then((rows: any) => rows[0]?.count ?? 0), + sequelize + .query( + `SELECT COUNT(*)::int AS count FROM "Releases" r INNER JOIN "Pubs" p ON r."pubId" = p.id WHERE p."communityId" = :communityId`, + { replacements: { communityId }, type: 'SELECT' as any }, + ) + .then((rows: any) => rows[0]?.count ?? 0), // Members with user details Member.findAll({ where: { communityId }, attributes: ['id', 'userId', 'permissions', 'isOwner', 'createdAt'], - include: [{ - model: User, - as: 'user', - attributes: ['fullName', 'avatar', 'slug'], - }], + include: [ + { + model: User, + as: 'user', + attributes: ['fullName', 'avatar', 'slug'], + }, + ], order: [['createdAt', 'ASC']], limit: 500, }), // Recent pubs (released only) Pub.findAll({ where: { communityId }, - attributes: ['id', 'title', 'slug', 'description', 'avatar', 'customPublishedAt', 'createdAt'], + attributes: [ + 'id', + 'title', + 'slug', + 'description', + 'avatar', + 'customPublishedAt', + 'createdAt', + ], include: [ hasReleaseInclude, { @@ -529,7 +551,9 @@ router.get('/api/kf/community/:id/detail', requireInternalKey, async (req: any, attributes: ['name', 'avatar', 'order', 'isAuthor'], where: { isAuthor: true }, required: false, - include: [{ model: User, as: 'user', attributes: ['fullName', 'avatar', 'slug'] }], + include: [ + { model: User, as: 'user', attributes: ['fullName', 'avatar', 'slug'] }, + ], }, ], order: [['createdAt', 'DESC']], @@ -546,7 +570,12 @@ router.get('/api/kf/community/:id/detail', requireInternalKey, async (req: any, AND p."createdAt" >= :pubsCutoff GROUP BY 1 ORDER BY 1`, { - replacements: { communityId, pubsCutoff: new Date(Date.now() - pubsMonthsBack * 30 * 24 * 60 * 60 * 1000).toISOString() }, + replacements: { + communityId, + pubsCutoff: new Date( + Date.now() - pubsMonthsBack * 30 * 24 * 60 * 60 * 1000, + ).toISOString(), + }, type: 'SELECT' as any, }, ), @@ -621,7 +650,10 @@ router.get('/api/kf/community/:id/detail', requireInternalKey, async (req: any, }); // Aggregate top authors - const authorMap = new Map(); + const authorMap = new Map< + string, + { name: string; avatar: string | null; slug: string | null; count: number } + >(); for (const attr of topAuthorsRaw) { const a = (attr as any).toJSON(); const key = a.userId || `name:${a.name}`; @@ -637,8 +669,7 @@ router.get('/api/kf/community/:id/detail', requireInternalKey, async (req: any, }); } } - const topAuthors = [...authorMap.values()] - .sort((a, b) => b.count - a.count); + const topAuthors = [...authorMap.values()].sort((a, b) => b.count - a.count); // Try to get analytics (daily views for selected range) from matview let dailyViews: Array<{ date: string; views: number }> = []; @@ -732,10 +763,13 @@ router.get('/api/kf/community/:id/detail', requireInternalKey, async (req: any, router.get('/api/kf/suggested-communities', requireInternalKey, async (req: any, res: any) => { try { const domainsParam = req.query.domains as string; - const excludeIds = req.query.excludeIds as string || ''; + const excludeIds = (req.query.excludeIds as string) || ''; if (!domainsParam) return res.json([]); - const domains = domainsParam.split(',').map((d: string) => d.trim().toLowerCase()).filter(Boolean); + const domains = domainsParam + .split(',') + .map((d: string) => d.trim().toLowerCase()) + .filter(Boolean); if (domains.length === 0) return res.json([]); const excludeList = excludeIds.split(',').filter(Boolean); @@ -786,7 +820,10 @@ router.get('/api/kf/suggested-communities', requireInternalKey, async (req: any, communityMap.set(row.communityId, { managerCount: row.managerCount, authorCount: 0 }); } for (const row of authorRows) { - const existing = communityMap.get(row.communityId) || { managerCount: 0, authorCount: 0 }; + const existing = communityMap.get(row.communityId) || { + managerCount: 0, + authorCount: 0, + }; existing.authorCount = row.authorCount; communityMap.set(row.communityId, existing); } @@ -800,7 +837,9 @@ router.get('/api/kf/suggested-communities', requireInternalKey, async (req: any, const communityIds = [...communityMap.keys()]; const idPlaceholders = communityIds.map((_, i) => `:cid${i}`).join(', '); const idReplacements: Record = {}; - communityIds.forEach((id, i) => { idReplacements[`cid${i}`] = id; }); + communityIds.forEach((id, i) => { + idReplacements[`cid${i}`] = id; + }); const communityRows = (await sequelize.query( `SELECT c."id", c."title", c."subdomain", c."domain", c."description", c."heroLogo", c."accentColorDark", c."accentColorLight", c."createdAt", @@ -841,23 +880,36 @@ router.get('/api/kf/suggested-communities', requireInternalKey, async (req: any, router.get('/api/kf/suggested-pubs', requireInternalKey, async (req: any, res: any) => { try { const termsParam = req.query.terms as string; - const excludeCommunityIds = req.query.excludeCommunityIds as string || ''; + const excludeCommunityIds = (req.query.excludeCommunityIds as string) || ''; const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 200); if (!termsParam) return res.json([]); - const terms = termsParam.split(',').map((t: string) => t.trim()).filter(Boolean); + const terms = termsParam + .split(',') + .map((t: string) => t.trim()) + .filter(Boolean); if (terms.length === 0) return res.json([]); const excludeList = excludeCommunityIds.split(',').filter(Boolean); // Build tsquery from terms — use adjacency operator (<->) for exact phrase matching // e.g. "Mellon Foundation" → "mellon <-> foundation", single words get prefix match - const tsQuery = terms.map((t) => { - const words = t.trim().toLowerCase().replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim().split(/\s+/).filter(Boolean); - if (words.length === 0) return null; - if (words.length === 1) return `${words[0]}:*`; - return `(${words.join(' <-> ')})`; - }).filter(Boolean).join(' | '); + const tsQuery = terms + .map((t) => { + const words = t + .trim() + .toLowerCase() + .replace(/[^\w\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .split(/\s+/) + .filter(Boolean); + if (words.length === 0) return null; + if (words.length === 1) return `${words[0]}:*`; + return `(${words.join(' <-> ')})`; + }) + .filter(Boolean) + .join(' | '); if (!tsQuery) return res.json([]); @@ -865,7 +917,9 @@ router.get('/api/kf/suggested-pubs', requireInternalKey, async (req: any, res: a const replacements: Record = { tsQuery, limit }; if (excludeList.length > 0) { const excludePlaceholders = excludeList.map((_, i) => `:excl${i}`).join(', '); - excludeList.forEach((id, i) => { replacements[`excl${i}`] = id; }); + excludeList.forEach((id, i) => { + replacements[`excl${i}`] = id; + }); excludeClause = `AND p."communityId" NOT IN (${excludePlaceholders})`; } @@ -903,21 +957,23 @@ router.get('/api/kf/suggested-pubs', requireInternalKey, async (req: any, res: a { replacements, type: 'SELECT' as any }, )) as any[]; - return res.json(rows.map((r: any) => ({ - id: r.id, - title: r.title, - slug: r.slug, - description: r.description, - avatar: r.avatar, - communityId: r.communityId, - communityTitle: r.communityTitle, - communitySubdomain: r.communitySubdomain, - communityDomain: r.communityDomain, - byline: r.byline ?? null, - snippet: r.snippet ?? null, - publishedAt: r.customPublishedAt ?? null, - rank: parseFloat(r.rank), - }))); + return res.json( + rows.map((r: any) => ({ + id: r.id, + title: r.title, + slug: r.slug, + description: r.description, + avatar: r.avatar, + communityId: r.communityId, + communityTitle: r.communityTitle, + communitySubdomain: r.communitySubdomain, + communityDomain: r.communityDomain, + byline: r.byline ?? null, + snippet: r.snippet ?? null, + publishedAt: r.customPublishedAt ?? null, + rank: parseFloat(r.rank), + })), + ); } catch (err) { console.error('Suggested pubs API error:', err); return res.status(500).json({ error: 'Internal error' }); @@ -936,7 +992,9 @@ router.get('/api/kf/graph-data', requireInternalKey, async (req: any, res: any) const idPlaceholders = communityIds.map((_: string, i: number) => `:cid${i}`).join(', '); const replacements: Record = {}; - communityIds.forEach((id: string, i: number) => { replacements[`cid${i}`] = id; }); + communityIds.forEach((id: string, i: number) => { + replacements[`cid${i}`] = id; + }); // Get communities const communities = (await sequelize.query( @@ -980,7 +1038,13 @@ router.get('/api/kf/graph-data', requireInternalKey, async (req: any, res: any) })) as any[]; // Build graph nodes and links - type GraphNode = { id: string; label: string; type: 'community' | 'person'; color?: string; avatar?: string }; + type GraphNode = { + id: string; + label: string; + type: 'community' | 'person'; + color?: string; + avatar?: string; + }; type GraphLink = { source: string; target: string; roles: string[] }; const nodes: GraphNode[] = [ diff --git a/server/kf/auth.ts b/server/kf/auth.ts index 8e171f533..850bc028d 100644 --- a/server/kf/auth.ts +++ b/server/kf/auth.ts @@ -62,10 +62,7 @@ interface TokenResponse { refresh_token?: string; } -export async function exchangeCode( - code: string, - codeVerifier: string, -): Promise { +export async function exchangeCode(code: string, codeVerifier: string): Promise { const body = new URLSearchParams({ grant_type: 'authorization_code', code, @@ -125,18 +122,13 @@ export async function fetchUserInfo(accessToken: string): Promise { * Fetch a user's current KF orgs from KF Auth's internal API. * Used for the ownership picker when creating communities. */ -export async function fetchUserOrgs( - userId: string, -): Promise { +export async function fetchUserOrgs(userId: string): Promise { const key = process.env.KF_INTERNAL_API_KEY; if (!key) return []; - const res = await fetch( - `${KF_AUTH_URL}/api/internal/users/${userId}/orgs`, - { - headers: { Authorization: `Bearer ${key}` }, - }, - ); + const res = await fetch(`${KF_AUTH_URL}/api/internal/users/${userId}/orgs`, { + headers: { Authorization: `Bearer ${key}` }, + }); if (!res.ok) return []; const data = (await res.json()) as { orgs?: KFOrg[] }; diff --git a/server/routes/index.ts b/server/routes/index.ts index 86cd64c24..a82692dec 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -1,5 +1,7 @@ import { Router } from 'express'; +// KF Auth integration (OIDC + internal API) +import { router as kfAuthRouter } from '../kf/api'; /* import { router as picingRouter} from './picing'); // Route: '/pricing' */ import { router as adminDashboardRouter } from './adminDashboard'; // Route: '/admin' (redirect to superadmin) import { router as authenticateRouter } from './authenticate'; // Route: '/auth' @@ -7,9 +9,6 @@ import { router as collectionRouter } from './collection'; // Route: /collection /* Routes for PubPub */ import { router as communityCreateRouter } from './communityCreate'; // Route: '/community/create' import { router as dashboardActivityRouter } from './dashboardActivity'; - -// KF Auth integration (OIDC + internal API) -import { router as kfAuthRouter } from '../kf/api'; import { router as dashboardCollectionLayoutRouter } from './dashboardCollectionLayout'; import { router as dashboardCollectionOverviewRouter } from './dashboardCollectionOverview'; import { router as dashboardCommunityOverviewRouter } from './dashboardCommunityOverview'; diff --git a/server/routes/signup.kf.tsx b/server/routes/signup.kf.tsx index ac7f59e06..cc173f962 100644 --- a/server/routes/signup.kf.tsx +++ b/server/routes/signup.kf.tsx @@ -7,7 +7,7 @@ import { Router } from 'express'; -import { KF_AUTH_URL, KF_AUTH_CLIENT_ID, APP_URL } from 'server/kf/auth'; +import { APP_URL, KF_AUTH_CLIENT_ID, KF_AUTH_URL } from 'server/kf/auth'; export const router = Router(); From 22746d218edaa7ba2eff5571df6052bdef5bb529 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Sat, 16 May 2026 23:02:18 -0400 Subject: [PATCH 05/13] Add index --- server/community/model.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/community/model.ts b/server/community/model.ts index 26cd2cd81..83e562e97 100644 --- a/server/community/model.ts +++ b/server/community/model.ts @@ -18,6 +18,7 @@ import { DefaultScope, ForeignKey, HasMany, + Index, Is, IsLowercase, Length, @@ -242,6 +243,7 @@ export class Community extends Model< declare templateId: string | null; /** KF Auth organization that owns this community (for billing/ownership) */ + @Index @Column(DataType.TEXT) declare kfOrgId: string | null; From d1eecc354a830fade9bd00b4153089f5499c63a7 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Sat, 16 May 2026 23:23:44 -0400 Subject: [PATCH 06/13] fix http --- infra/.env.dev.enc | 102 ++++++++++++++++++++++----------------------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/infra/.env.dev.enc b/infra/.env.dev.enc index eb0c10569..56a947e43 100644 --- a/infra/.env.dev.enc +++ b/infra/.env.dev.enc @@ -1,59 +1,59 @@ -AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:Xja6pWJuVFV+Y/aYvSB6pVk5sRgYhGtz38T4XlESV2U3BicdxOzJiblI64hp9ptivW434emiC2CRBQEoY2iGIg==,iv:BkfH04v7u+nQJ5C6oSsbvG7qfclnq4WUvNZJ5LvbcYg=,tag:y23Bn78rh6p0BXzvDrGvpA==,type:str] -ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:gM4u13AU5rGlfvuK1TygB2EHhXgNuM/ymNkYHnUH7F6ibMwIMANf3DPCxKRfAD+Hg6bwFoziroxz93bw7CeSdQ==,iv:BV0GSx28moDNPmGd3Nr0+tl2Kap4Fcn/5P+sNZmmTPY=,tag:Kug/H8MvvINSBESdeVLJmA==,type:str] -AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:vF++ggIsvfjqhnVOXWFAVunjsVY=,iv:LjsUFh31LTLn8LLjVLFPBiqFnbEZutKQZl59yInZXwg=,tag:bM53fg0cur5DoT0OrQGMlA==,type:str] -AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:3XOOm6S08GV3Srzpq4YUarG9wRA=,iv:peWv8G0v/2oc5pBczqP6gMFDM5xAiqQcHTQdUOrtzhg=,tag:St5ojbZCyyluwEGTOt3xxg==,type:str] -AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:1RGXw9lVjLekUyIheth5Hp7ieyOP2J1tgUL1gr9xKP7i9kRS2wCrjw==,iv:kaPbn3r8bCb3Kx4rG512hi5SkopmGI0avvKLbMtfXiE=,tag:vLmLBh3eLyQeHQRoiJcaow==,type:str] -AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:Pdrg+Cny7TbtBD1h3b++V2vqL7jQCDWL6KqMNMHn3ol/0iFyy1gTUg==,iv:b/rit8FMdaV3zByD22TdcnrZ6qHSVjzDYOGvERO8SiQ=,tag:eSqtBcFUuhCcw/6PaLJf0A==,type:str] -BACKUPS_SECRET=ENC[AES256_GCM,data:jhMNkb/JM5C6c9OC+ML0a1vFd1y0ngo9HkSeCmYEKLR8RHal1Xtra49vDHU=,iv:FoJbGu/srmg8hK2evv6QZvBNih2dvG72PIxSZi5ieUA=,tag:T/CEkRDshX53EdWLZhHe1g==,type:str] -CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:YG3oxF79I2Op7m74ioI6fU7qndkls3XZa409iyPe3TgsAb8oNk0sb0UM2pPWuvQd0/lXxiY=,iv:QHQlzlUahFh/BwCP/Qt86fEP6PTZlxCyMNsa1kBajcI=,tag:3YcBpFEVj/rXLjjEh+kkkQ==,type:str] -CLOUDFLARE_CUSTOM_HOSTNAME_API_TOKEN=ENC[AES256_GCM,data:xhEGXVdEz6AseRerwAXQSZp7G/USThj89U9OMHlNq2pfG8nekHDgoyDQ0cS5kkin6JSKpP0=,iv:YMXXosY3TOjIf7QKo6bd7Soqa4S0ojliNpwn9gVdTFc=,tag:DAn2bpuZg4rnkiAikBa5Rw==,type:str] -CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:7W8jZBTLIMYB6m593J5q7Xs+Zd9fvnn/loX3thddkcU=,iv:lUJwLq4D/eus8rGxC26cEaioVCNOu7RPLQFizR77Qtw=,tag:MtTY3QbPePlPa262w7arpw==,type:str] -CONTENT_SEARCH_TERMS=ENC[AES256_GCM,data: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,iv:bppDCBSkEcnBiCMbq4vRCIHcllN92JPPWw/lVg9pk0w=,tag:0LG4zm0f2qYRkmpwjEY5gA==,type:str] -DATABASE_URL=ENC[AES256_GCM,data:AzarkHcGAEqaIy5jF6fuFGBBUvOqtJwsURw5eQjB73jjsu9edl9kJuJniYs=,iv:nMVI5p/YvwG2ui9Io/0SmkiAUQVYsafgejmKP54eg1k=,tag:Ckzfcv5Bqb9nWSsO/4Zl/g==,type:str] -DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:RtWHvl9rL7czXLByOpD6TMrWV9hWcfl07F9k9S3frHTdcg==,iv:4FgMHgbL7meB7xsoP0ZgDqHzmAYdotGMwB5rjQz0+4w=,tag:v3qVTk944kaCFrSElDLl9g==,type:str] -DOI_LOGIN_ID=ENC[AES256_GCM,data:eJXP310Y,iv:hHm0RYxErafZsNWIp4c3GMkGlwD3WiHIYi1dqDf4MY8=,tag:roFFgJRFOSCp7GLTAqW9Nw==,type:str] -DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:H3gIlkMt/TFB68Qnahf027i3CPM=,iv:zF5rBVnU7mooeiMvxfoCVv9VyNaroy0WvGbukhO53vM=,tag:NL+GYVrWtF7m0/q7kMnduw==,type:str] -DOI_SUBMISSION_URL=ENC[AES256_GCM,data:e10KvblYLyoovn0wTiD7n1COW3codFR+PRD9VcW3VREgkM63SVQM2zE=,iv:L5MWiPcqkIYq3J7rodg3Z0dMmrOZazcKgJwACjl2MgE=,tag:2/5NDyx2uqypSPd+tE0wqQ==,type:str] -FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:F2/m+nPABzbg1IIOu9q29+MsQxIlUHAReMmrIp2u8oE=,iv:T1mSVzxGt3yrVifSsP30KKyufXAQxavCeAl7u1g4tsg=,tag:f9aRwYgw67Ti2UuGBWftCA==,type:str] -FASTLY_SERVICE_ID=ENC[AES256_GCM,data:aSOOgoDfcVnm08avr+zcCLkiO2KP5g==,iv:TExbbOCbHwy1zARIMQA8zIkrMuqrvWMDdq0lob1E2ZE=,tag:aYa5JaThXA1AJdGyiOj6NA==,type:str] -FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:/jadVet/Hz82+n/ApBYIa7KUGQ+IwEWf6Y7+m9h3mnI=,tag:5I7IMYbVqBRdTHkbXefM+g==,type:str] -IS_DUQDUQ=ENC[AES256_GCM,data:/Nx7Hw==,iv:uDInaGYuPtRuTFP4sb6NBPVEwoYKV4/Cqy4Vi7ePuHk=,tag:e3+FWrP0odei40mpHRQRSQ==,type:str] -JWT_SIGNING_SECRET=ENC[AES256_GCM,data:vAGHcGOpR52+MOVWM2e0g5+FVYpeNIq6dnyEwEAn8EDP+iCi+nKI7vGNWL3MYoD9RRFCpDiOQiPPc8NVF//Wzxg/jrCvEhpM1cXYJvdSlrR1wsxAFpEzBUemwbQhOMlcml5wAxQfajxGdsK284QabHc+DLz5eO7KY/549L6vg5xCu7JdMPhY7pjEyZuK8/n8vRGeZ41zipCuztszn0USF0yLbQVr1VpujszMRnKnKjKhL6QVJLC9zSC0jiiKM+/NwrviINRWDRkSPPWENTwx6d/P7zsQ8h3zY5JyCEZ+8O/YHG54KuFAS8JK3ZfM6ttyP6Mv/fRbeDeeK8S7Etdtu89R/n6m+itdd3wz7LDjgn3WsTLhoMrsc2i5vpld6NwhIVoQ5N7Lc2qjhkTUZdSF2WUTCKjtQb2TV2IlnQUGcwIC8SJUPeJY3mVlALnX9gOY1IIeIzWUUs0CO8cFl6lsWnTTU/EtiF4g40G9m0w6B07CAnvClpH2gHNl/+MHMR/Gu4VS7cb7NajToamNeg/s7POTGcl4RYxWpJUXMSWoDP0DPHzY46WqmtqPRfWIvrl8fYUneUfqDpTqGtp2NBzauvbqKxRqQzERE3W5PK65hDzY508Rgf1ybQVnH7/BDiS49UJpzoZ08d8/JR94Q3L9UiBsCweiUdgqWSamvQI8uAy945K59lPKsFT2MNo9nMXnTaAuym0T14kk+eMrNZnE4Zi7vW1k1RxyBobVIBRAICx+ZgcPWesimbu7xeG5QyX0VvWwpMdgF93dULDwGTNtPyzx/wFXgv7YVzTi1ssNMd1hjIkp15lxIq62poNpaO7N2ylcHlrbXMlKwT+bB//WxJg6MBtlHmuC5yH6MzKu2Ffr7BnEcwevH+mmssbbGxkXasAZJpFtJ+BbO6aQMHHz7aN4TsxG/wHD2CLSsMVFNHRo55g1ZT6NNok/uGQLocexlu046Abjd6LArf9t+1U56Td8fguLYZ5t5JnjrpePsRzJzX7h5fRUH/5V/kQOJAiqZ6iB5MomV6tVN+uGUDrOgqfWsG/LH4RkBMtyT3mNEKtJfWLKvA1lO8S9YBB94YJ/FSFMyAeriDg5n8XrlE0z+I1JN3exGRB0+c+Oq0MlWfoUb05v3bpMUWqKmgP1Ata/DCyOyKrZEXhwFY+no3i6bf5j57gmBxxECjv0fyqvJM3AViVKQ9OvyhQoyCYEOVPNfc5ZBogc38pItrzL5a6hB5W4Z7MBeyxhvOe2vaoCPsR4qP1It5K2+3bmtEX4KXTy7tXtkQ4xCg/rvpOeN7ZjnEt3r3MaZEvKgpCWbQAF3JRzFKVb2mtVC5z/lqeffCvNhJN7XP/ztdvrFQNfDEIzjA==,iv:BnISOMJ3PNy/6165fq9oeFykKx666yHzuq0UQkaiZlI=,tag:zkhJAgY0EvR+PmOZLNwOEg==,type:str] -MAILCHIMP_API_KEY=ENC[AES256_GCM,data:NtsS50tkEddOu5T9H2spXFd749cAEsnQZgEh3tgBjRWK+CiU,iv:7MpAPJzuVnaIdHYeHmEN1l6xGgw3+fvUvRKJEX6Kid4=,tag:tW1bBQM0GF6BqbkaY7USsQ==,type:str] -MAILGUN_API_KEY=ENC[AES256_GCM,data:MH6seKkqHR6Wj9JZH4mVmWHF4R3FrKsTat2W+r9tQPVByFIZ,iv:YjlSJ/VbJ09RPs3txF7gs5PEut19/QTn/c2zRZUUxFg=,tag:HikxvDvrLHUuvfPIFJApiw==,type:str] -NODE_ENV=ENC[AES256_GCM,data:eXjT6u9VZktBhw==,iv:y7svJfKogEh+/dxAxH+HLd6mAQyb+eGzjgbuxOkT9fw=,tag:yYUGMmBStX9EnWKgKVgk6w==,type:str] -S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:eYFRgCAIWsY8Shhr2HZHCR72r7c=,iv:5M5nftqJi1le1sDZz8YZa32QwqrCxOu5ClZ7knWvQQg=,tag:hE65DX9ap9wCXeisDc54HQ==,type:str] -S3_BACKUP_BUCKET=ENC[AES256_GCM,data:x1ZgjjdMObQ9WFg=,iv:VqTy+9ZCg3jzgU9CtYxGVgLLzGfMnmhqmCHB8dGIo/Y=,tag:kSWhM9gxKQK1jf/XX3mT8g==,type:str] -S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:UVnBLUILntsJ+ledQ6auXqP53cAFaDerWgsdGOgOZ84ZyrQ=,iv:iXlsZg56X7yD6Yu9xOvUnSL0kYi3pxyLYTQOhUtv3ow=,tag:QZG374UhNDOw4I4rc4fI1w==,type:str] -S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:6c30LpSh1ix7R2GDNCKcenjpEaZ0TuFmzGhiuoOtdYkXYQO7X98jag==,iv:8c9sE/sNIwnaJtdufSOLruvN9tHVUN0B0dVYLvoi3/A=,tag:h2JN5JtLI6B5Jn7/m6QpGg==,type:str] -SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:P8D2K73O9Oc+uPMwIgtB942HOIjvpNSIl/pit7TPpJgM1HW7LMSUf/4BCM0Iu269p6lehrUpf5ZcSIL6yCnotfXRZFY4G0syqXvKGvqiTpklVttg+kjsOFxsHEWBe0vRvnSfyUfqVmrfWAh+83qu6kLrwNlQOrE4FZm7sPijZ6H3AW3KMLqNm0i3zKDgDT1vLEBxc6o9M09MUauHsE5l3qzeQe3kFruxhqk11lOaZIOL+ZjnrDWYbjxNlQ==,iv:XfwOlinRFKp5OKtWeFID/inAWGqMfbReHwLuuaFMH0I=,tag:BPKr/6ghaqka7xYpSn8t5A==,type:str] -SENTRY_ORG=ENC[AES256_GCM,data:adqt,iv:ujBPuKnFCHJYOjTs4gyvdNCSumNo92AzFVAhF6q1cu4=,tag:5iWhjeGmU9KghzzBlYZLbA==,type:str] -SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:q7Y=,iv:S5SKSAzNZJAVbIthcYuqhloyQTvCp6us2gA2qAQKW+M=,tag:mwwPbkoC0pzswsO5iDN6LA==,type:str] -SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:GNmQUBEwYvDbHVMbKs1SKlUa9dLDOr9e1iEdQMR4P0C+pQw2CohRVarg95gL+/AbE2mxRBg4d2v//L0zs4v1SqPT35wOOmNICnzX1py7wQ==,iv:AsMZhFlFr4eD2j2wfrQQD2l/fNxtFxqAtln7Enu/xQ0=,tag:prqSzsxXMjqc4KF3Q3xIAg==,type:str] -SMTP_HOST=ENC[AES256_GCM,data:HSZEWRhNJCx8qsxwo+RKZK6JlbPbjyqOcYhSqiixouIp8A==,iv:u5uSGvmkVfpY8kggb9lhfj02nkGH51KQv9VJCJ2WYMg=,tag:5i2VVrAltQxY2sfH1lAOqQ==,type:str] -SMTP_PASS=ENC[AES256_GCM,data:/LbUwUWza4AygxRJBxV/IytK+v+WCdmIV/XnuhsIBrhI6PtIXwtD1s/7c38=,iv:lI0ZV/pqFg2x01LwtFx01T1Ji3M7eRLPUViSOu9WCSc=,tag:UTZ40VeCJc4QuG20E1WJ/g==,type:str] -SMTP_USER=ENC[AES256_GCM,data:nbkdNZYxZWwVxfliNb2hj3+2mEM=,iv:m8om1mOpz3XdWoFq40JrUJJWXMUWuDZNhLcTjp4zCwY=,tag:jE5smruSBVenqp7QCqBYow==,type:str] -ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:QrW07ag3c1b2MJEkvflkYcZ9Jic=,iv:PTjKpT/HGxgUM0frWG9jLYzyXnyZUx/mwn1a87BNij8=,tag:afxyr2HJADre/rORYA8TOQ==,type:str] -ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:rTqa6mWSJSteDn2HkkpAeKjTRvA=,iv:R9wUAudkXbs9PqO/gXbx8WUihWoV0li/buHhFL0lvQw=,tag:9XDZyDaPzK7SYRO3fp6tww==,type:str] -#ENC[AES256_GCM,data:SjEixq+m46e8vo26CpWPqTcFYSs=,iv:+ksMsnLErAhThWqJa88IP7lowNiDAZG4aUCFMcy+7Pk=,tag:wuFJe+nn7Tsl9VWQtn/evw==,type:comment] -KF_AUTH_URL=ENC[AES256_GCM,data:PQaNOYW1ZSe5hy+2xVnRSe9mPCOe4IN/LuIdhIk/UZSBJ4sd,iv:Pd5ZQGOqEBcqPCi1t1qZQ5qnF8Qcqnteljg6b4tKhwY=,tag:l5OJIQnh8vruOuboIjN5BA==,type:str] -KF_AUTH_CLIENT_ID=ENC[AES256_GCM,data:+59Jiev83Fof,iv:hcD8yUMGkiKD22hGFfWvpz1fL6tyAB9yLKVE8N18Xr8=,tag:DQRVc+FYeyXC4dvW81Ml3A==,type:str] -KF_AUTH_CLIENT_SECRET=ENC[AES256_GCM,data:CluRm400lyJf10aNLoJAMWH6xADMBP7DivlinSdqWMeqdU7/ImpDbieNw4adJD9/6pDX9EGRTjEEg88zABiCSQ==,iv:tlwGBDQFPt1Xvl3FHAQb/M60v2/J0QN+QKf71pO31SA=,tag:2nNTBSLXUZ+Bvwd0uVxtBQ==,type:str] -KF_INTERNAL_API_KEY=ENC[AES256_GCM,data:NaWwUjdSLWwZmC0kO3Y7o3TRtwZQm/4Nwiz013um8g9GowbnNTGYwvkOjXQZ5PiMnfn+PaswSDGKR3+W/0OE1Q==,iv:UqeEm1DvkVThTIoY/igLRGngaSAHjwixEIYmdm8fQG0=,tag:QxEbPAQCj2z/tCZ6rgsItg==,type:str] -APP_URL=ENC[AES256_GCM,data:hjPOso+xBu5uNa9Tda+GbDG0qQxB,iv:a+E73avCFL7xmuztLpuFHLah7GQOwtJpO6ljsTW4ong=,tag:2WdXA3bH21ajwzs9ga/Tuw==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBta2RjbVBPN0I5ZUFqSGdU\nTGlSV3NERmg3ZFJPZk5CM3BDRk1TN3d3bUE4Ck1yVTFZTisvcGVYczdrSnhqWm1n\nbU1peWtsSUQ2MmpiK3ZFS2NmUlFNTE0KLS0tIFFtUXlkSTg5VFJFdWtZNDZBVXUy\nNGx3R1BtdWtTcm1wTlkwcFVVcUZMbVkKxYYUSSeUDsK4TePTTQzItWrBJEMoWmOj\nLkITwYDxj2YlLnMC4129N732nNaBFLy2Aruc3XaivO8Fr6eCtvRF7g==\n-----END AGE ENCRYPTED FILE-----\n +AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:0/pThcyBgD71JouwgwyrGv4WpahqQdOOLmttTM86s+YGgimz70ah/MFhPdsDFJJWumb/TPWxX/VeP6NsbfV9FQ==,iv:6MDo+RbTa8NBQgC0fCWYeTy6BJV2A2rQHdDjUEn02N0=,tag:4kV5AVKtr7LlN9sWWxXBlA==,type:str] +ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:68lcVheah2pt9Nbir4yaUq6ocmDBd/BEY1P3DQQc6mNMjYIEhXnA5SB/DEismNpCL3VCQWNylDVHXsILJLvOxQ==,iv:tDx7V9M3ci7t8buYjvMhkfJq+wFiEgKzLGP2Dg4mHS8=,tag:cblmvhpbTd2mU30oIQ4gKQ==,type:str] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:+a//C1kWWbLomU2p7rYX5u4rkAs=,iv:oIoKmSZ2yTY8mndEOmyqBfAdafoIDH/8LKkG8kABQMw=,tag:nGjdlaF1l34lxqOWZpAHPg==,type:str] +AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:NtmyfFACf+Xu1AvD9loJp035oio=,iv:lz0uz2ycbWhkabM0IF+NJ5HhlelkZ/eNfK8sLW7dVBw=,tag:QBkvDiEFbvEMYDzP0JjRBA==,type:str] +AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:AX8joyH10Ub+N+NDAyLpH2EedX9mE3A/6TNKOe+P86g6c6OnWAnPaQ==,iv:dR5DYNDvwZU+n8k/Lr6xpOUiXc3QQ2kIX6q3Tqspva8=,tag:MyYLKYDJZ3fhacH92Q794w==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:QXj9kz2WoScLvEj/3IkatJlbicQpSAwU7E86jV2icbSOys1iigzKrQ==,iv:lCLu4jzI7XqrIwxcwlcMdBpkwc41R4e+i0CiH/ZPUC0=,tag:16fNUIHWScxkhB6Gh5soew==,type:str] +BACKUPS_SECRET=ENC[AES256_GCM,data:5XW1JuOwA6QMhFDb3HAsC3VvAAfRrpZJtyo3MfywxF9/xLfSsB1qtSA8kkQ=,iv:treasNyESOYrObwki6S/3VtaedqGEeuWiwtRFqHJhpo=,tag:XsWwzPgkm5xs4sZ3Ao2U8A==,type:str] +CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:rQ1Mn3NjQ4Uz0ThThWZQcfsdvYF5w/oijEiISguRXmQ5dSXfgRPJZNk7THkiRCt/WQpmOpw=,iv:Fkl134fgT0Y1js7NJAg2DiMVHO5EIjLP+4e35nva/ls=,tag:RRJ9XeCFRioJSopuWO+5Bw==,type:str] +CLOUDFLARE_CUSTOM_HOSTNAME_API_TOKEN=ENC[AES256_GCM,data:MbQ0eYpUzwqsF0/JQrNI0ifrwKxdqFjkAouuq5q2AZFyKkkDeQ7PbyunOcbnxyM9BXajekg=,iv:MT5nY39XTrdo06Qg8R2zuwX/Pj8cIQ2BF1HrL1HWg3o=,tag:3ZsabAWhA4mNvJDqApf+MA==,type:str] +CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:T0eYzCGVinoAShLqgPsW85b4gFBBxnLE0PmS5RKdUks=,iv:n+V8Ct0R4Mw1D22/YEwjB91eLnoIro3/UjyEg6dIhmw=,tag:IV7PLYkwa9RgGO98vZkUSw==,type:str] +CONTENT_SEARCH_TERMS=ENC[AES256_GCM,data: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,iv:hdYZ5t2pU2BtIF7KSkOgdq7S3myDPI9Hx4p4fqTY++Y=,tag:YV0Jdn3cGAXnfDFe4vu2sA==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:GyGXrI48jnJ86TcdsZGucGtPcbMgd2nKh8+FxNd3MEfVpbXw4k7v/OQv5I8=,iv:B8PzKy2Vs3G0VPtexyGTZcJPobgVsAi+tgz3zYjIjc8=,tag:Wnu/vP2aY19efDSdm973rw==,type:str] +DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:1MwvuSNog94kdsVmWzkdmVg+aabVMNPT/o5I2HBL11SBSA==,iv:ocvKFMH+WNParAfV07UP0DDkRC4CCW00CIkRhP+IU+A=,tag:eerYbjt0k/mPUm9s42WYwg==,type:str] +DOI_LOGIN_ID=ENC[AES256_GCM,data:Ocyz8s1k,iv:vut+asYLKbSnxHY23yqIb2eBuYpE5JqGa/ZUvxJwysE=,tag:8i4+DdWNu+f/Mi740d8rYg==,type:str] +DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:Q3Z1IamNafwJ2r94e0qccqbGJ4U=,iv:ezsIfdXsL1zrcai+pYrdCifKP2fbaUo6/mj+ZGsRLKk=,tag:MR5KmCPlT6JDcflnhWk5bw==,type:str] +DOI_SUBMISSION_URL=ENC[AES256_GCM,data:pi+uH+cznIJP80v1aOG2MLj3o3V+blM4cMH6kaNL3hEeipfInMAMf+A=,iv:0HA7zy8zJ5Fjvg3YyUWwE5d9pe1d4J21qU1vPW0whn0=,tag:1K8N8x1g263FL/4JO5oOfQ==,type:str] +FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:KiEKh/s3uRZmtaoXNNpihnm4bj9wAC0PeLbk1U2V1uY=,iv:UWWrQYLqVucyts0yiNFoP/GksRKboSLHfV3N4xv6wiM=,tag:N1d1eoA8rW5Sjlt+sXzjHQ==,type:str] +FASTLY_SERVICE_ID=ENC[AES256_GCM,data:M1VmWapcad3acXAUX2Hibm0IdjXG9w==,iv:0bUgfSHGlAKVA1pXjdNdIy0ZlZ3wR5TyWPrGolVVcPs=,tag:rHRkh3P2gISFlNooVt/kDg==,type:str] +FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data:9UmhOKejzxWWVYgoIu9aZHI0iDhWTCvk/T0bTU5CWTvkzmukf3lBO5A38COgAuXo8OxaFdW81M2MdyZz1208FQoUjNu6s0LFP4DiN2FZHFL6bYbz4qTGDg00JnIA656rxjqkc0Zz7KLeOY1QEbj/mGcu+NUYlpWcSCjvyEllJuKh0phqDe8irzNIjP0Wq1n9bqLxEyFt6P/DQ4uRIvnrVDTJ+lkjyvpsdisMCAOSHq9GF74tyWJ324NcfE5MggrQiRhV4P/gqsOEuBvLGP7OufRpBWLhQVPqaZjb2T5RBrZiQEaURUjnNQrd/1DhP5BSs6a/eDI178IjQdqjPdyRjvF/yFe07rAMxXw+5jqpZQvb2FFe0PLD35OuMaLIXxGMK5XtDL2Ik1bboQwwYj719D1FVsAb8kZRk/Wa8jrLSn6ILkWQDHECd27S+i6G+tFI5xiJOox7bi+DQTBPtV/0nvB09ZxXi3m+gx+OI41Vt++FUABNMpZvzLamjF9zY1grhlb2/pkJt4s5IIEucI4iGMQZ/8jNEna+Cs6pv0b1wJ/80CuP5NEZvzwN4VeApsALliHJpXvcDH/JV2ImqA1LLLadsLjVOICHT6YqNe4x0P0phoMAtamDkIxnJmSPl+LtCdLspkCaiXbORHi2Iy3vVntAGyfeRj9+4VcRrPfdKwo8FrxQNNF8fTGZGb8MyymefEpXcbF+C5TI6yyXQ35m7LRKyTv/p/8quaoqXUMgbJBdB9zs+eKZCoc73ROg3nRzyResZAhRnqzShIJuEq5nUCargHjiQOgh0SbXNUU7xm4aWS5p9I1o2/2kjQQ3AUh/2+TGbHCf+zmm42wKPI4gCvfOx+Vu5N+l2V4VUbkvdwFNa++y0m2bpNlNCowZTnlbSvMNtc+Lpw946nIYfkDBKCi0f0kcIS+9IraiRzy8++KcFJL8iAytqSGIKRatrbmgvWSAgcajGDNHQHE/m992EHkTQ6p9Y78dMun9OCliQ/U4o4DMrFLU+CGI4RBr1Y2P3mdyCyd3JBNTZVQMzJkcL6QDyqcvzZI7HkC6uskqJ2BK25mlpTkaKrm29PJmiIejCHnFoDZZwWhZ2LFtV2OyZ3rL7yHQFACbBWf16Xex/Fwfp4o94aBTsR5q3Cv1egHo6v4mFLSBRtL+IdFRsVmeULRAnMUVOmWpvV5yzi8C/b2fE/wULLE/YUm2MPM02gagC/H7stkFI7yVOvoQQ0dV3e3300csF4gFu3pYCjuvv5h+UG4DAUmXitpFEQogT9fm0kFJXRVfl+1OLZjCw8dCY+kEIAZ5Ak0+PHuWuFx6p5GIIpIzGqKgpBkGljNnGO7x9mvZYFFc0KAbSyuIcuiCyAvCQOYJez4dzmA4547BkevQXKHq8ISsEutQuVI0zTUi5WANyOuT04WUvzdQLyp5kYWmuun+ZFPMXPxIqXKqFGXSwLzFDgnbx3T9nKFEZI+yxb8V2KOpHIFVca6/eEQxqX81lqP8jwKoqzu+Mk7ym+O62iE9+I7E97zWliZSzoroSyEHx+qD6uhsnQjyd8oRTdbFfukRMyA26khIr8QALJJaV7VBDz791Mm/tG0pFPbnievBKpx9rJqqaEUfiT6D8pYRulBunDlCDfRMI/JZXpZyJ4cCVAMANdZCsFL5DfAlhVeHpCCU/a+ZSwIhYH+emfqgDqXCuNCxnpcZrHkqYNPfBtvRiQyae5jiFiLjtLOGvpDUUtvFdWbQD9bahL6J2y58Opipp7YOlHbg5FQ8zYs4rHJVNcQhlrEm79RjGgC4xpNIOovDlesOKc7w34rMkxzZX6WN0f0NfK3/YuzrDvk4w38g+/J5LadQQMwz7EJSKaLNakKI/bHCGhHJB3z1EJVt+zXFJDHAogZkeeOKcoRmQLuqu2W9RbwuDBHByrcsfrN1GzD/QxywPMGqVnG1ooKNj4bpdBXNUzuxYLkwg6nQ0T66punZEt37qae0DMNcO3I5Lt43H5idROTZ4Xko0PGqgUaeYJHehKiWwH7H9Q+r1ZMRUsbjetBZFdJbxKWZyV2n2brvo2sGGubsYSM6W8w3OKZYluxIXL8MOxLabRkEmVlFEwwICn7tTJ8qt3bW4dpooxBPToOvsSF/+zoLvMy/kdu+Ixbe2JLFJjrXH3aAFV9ZQQh7sM5Vo+7cLyXkcKx874QWm2CdUV/9Ebpl9QPUvM2d87CrLSdcC4mQ2TkIs9KyDxOxj0q4luSMN6AFivkBrvA0eUty+INR4Dl+wgAhhkV0WFSNqCkUx+k8D//dJqjW16no0Zmtdr+DXtPN9YM7MnaLZztomTLoOF6g1wzPucUb27RvqyS1j90s6/hMNqasQxsdedlrAqoAjLIFwWKAMLbEACXWHHulFlGx80mWZaoRapeqblCdIctUW+iGKsWx62UHnawPRgPZSwyRNdKcXMUSc3vMME9HbCbe9phH9sXw+DeDdJhFoNvFgCVDp7sJEYDKqnsePKB/uMrOBCZGZeIoUzt1Z2qL7WGZ/FPplaVhA/l+o/4wMC+K60wloVN2ZoDhqjvDCufjzDuMl9la1OyaZVdoZRSBDage4jKq68rLdNDV7BYJmb+ZbJAEMGV51A/eDIHoJ5A98ChyEQ+4rnKtoRW+egZyq7vxXaLEo6doW9ZoI+/zgEuAdcrUVSdtkdpA0JdqK//Mu6zPXK8c9nyizmFzI2uJAZvo/9jrvMVCgvzQCutxowexO8gQGORlSxckRHSTafwSuSfXtm0yJ6ILDkCSR3QU7FQi+NmCVOd0Bpr2PgZb8Opo1sT39//iNAt17r6kRRugGTh46kVuVZt4VP+vhV7pIlWu7ANjT+j8VcpzmyyzIz90dt7WrmdWiBODRNcY0GgGoZZRoRdssfiQIiAgNcoNs/CYD3MK03Vx3CPOqeAWmXbIEGXHM2lyNHlJTmoZv92BxEz/GegwvY+AEcI5rgQWv8mAcmdan4Gu7Nf7WmXwmWp/mNbcu+AXgLJVzSMZ9GMgHNocr7Ia7Jd76yUtAOtOMvRYGdiavZGdwk59TFpwZzzHvIbMlvxVtBiC3JiBcoaO4XqLgwc+dsmA2LavUkaGSCsdeInV+7hOnVzI+G2KXow35KzKt7F5xSaRtxDAAOtEyaEEGe9UdOt5EhMrcV03KSuHZYJQvRSp6gAfpW8yo72lKHyEcawqfWi3OpiVyOGnKSGlvakT6DWFC0T/TSopkWewTBqh61Ws596TpIjiBcfs3dFv+6GUwDI5+JagwGSVZhbtdeyR8UrQ+G6OKsk9YwgIcS5YL0QjPfJf4ZEabjEXzqVt7k3tZLq7tTiTu0dRkYWN4K1k0AatBYq+tTRXUw+M//psaAtOuPSncPjc61LqF44TDi/P98i6bZIhn3FaoMkGeKHjtnCBR3L9FcqkPNBI/wkGpTfLIWBHTf/EPONlZFkqPkt/JEu2yf/bSFOc0fxsp8ly5eJomCveT+7AqECWDx5sn7OFO6UZrhYvKf7eVSVvuXZBZWUb9hbH7drH+wvUCssM9nwVFAS4FHa04HPjE04AruCFmowq83l8w8b+vthb4SwdlrfFL3Tmz0rbzQ+Assz4PELLLW2IUGXPbFcKDQDVRnXTPnmFt65Mhgh69VGfv5oFrO78C2w74t7ss/TKYdZNj0zP/aQ3CydfZkOj9P1ft/SDPcsBch7OixJq9GwfT7g9iAAzIoxhC+aZCPkHXgG6bTH70E9jL6uRnCYdzhDLsiFQZAhmL3h2bGTsVzoNd36SPnjrVPaPB0DUvnTIeFLUyju8IfISGFLd8XrIqCz5r/OD+8kQPbRcu28zPfEYu5jwiGWC5I9A2YQbuGtN5ErHJ0gYkw13kaLIHvcUcIw0DZSfLPicTkx0+6rmCbiiHog0Nqpg+DzMNBaeltYufeXHCZ3FJifaJ4OzpQcAZI2RLG352Zl0t8WAe+s2xM3RmRcEJbAAvR5i3WFDgnGWnpB5mmzPv1lGlVzoDjeqydX03TFxjwM3nG5EQ1+Y2jA8S3ouWINAwiDkmZoav5CqJzlbE9pZJWpS3WhphrzEm+wUcLMBD9cSQT52w+tU02ez6ilideYtzLAlxCs6GQrCl9707H+cGJLlf/PQ/7VMV6OVrq8SO/fdUWNuuIpiCjs=,iv:LVt3Bb5jVgVy6ham5LyIBcqxyfD9x6GtYgXfrqkEktM=,tag:0e4k1QJjf8Mp1PVplP+uOw==,type:str] +IS_DUQDUQ=ENC[AES256_GCM,data:u2MRZg==,iv:PO7Ra8bZIwtV9/1XZc4XV0oh4uPxmnVya/PiHpm5mq4=,tag:4etbO8o2OfJhkL+g1lt8oQ==,type:str] +JWT_SIGNING_SECRET=ENC[AES256_GCM,data:GRq9iWaZffs7+ROtfQ4M81nNXktRXYwNs9UPzWkZ4OgE7y7WqUA2ZvVIb4wMecfgAnQkqC0tydnOyhQ3P1V9W/tSov/4p/KLZ8wTNV5wuiUk7rIJ2UNkpR5QWZUavTm9QYVKEkdAq5MvJJi1ByJJ2MLclOMX8wB87l5+X6NWwwv935Oo9+148ikwhjvs5IRgy6exaJsktdNCZqGTP1U3Noz1j/3onaOTGH/rGJTPNj4AjBfWZDJF5qlvVIcvLqwBXI4dp9yicQErhafRCa+SOGzvphdvzf9VV2vq9IwQoBL1NnyIVttTdbbcx9CHDDZljSBxcQixHCxw8lVbakEhY3lb9ARiZcxh2tFkrdorrxeNPhwUtsTjkf8U7S/WTJnSsp+sqdaMMjbhWm0WJxrZ/wF0dj60jfxp5kT3EjpVqnjezTRkeSvn+EyMvH5R0SGQABeXaMzWE1dgUcVL2wCymtjw+sluxo+J+kxHhsFADaj6sP94pLBdoN2cjg5jAgtsfgPYNvS3P4Jy6OBDaQd3YLfNcoFbmP4jMGVb6O7hx75jixe+oGSYIRskk2k02b6i4rtQznS7kqYDGVD4L85QgNIQew4XMPVT7jc/gWC28FNt/MuhinaiMt2lQSlNYvCsm4YcDuTMZXZjbMIBUXUVrLhAhMkyTjZQ9ZzBSSX7pufbkYy5TqUfejPKD2F+Bh91zrFntNdxMNbleJb581PQuQEPkmVTe/FENnOlQKlua70BsSqegVbff0y+uU4Z8H7tYGDgo9TqiPQPbtNSEjloby6adeZ5dd+lyL6a+b3kmVncPzHwdb+5pVTzm1+UOrarMmX4qXFCvCNJiSsJN3bUf544frCHcacCujVfKCVekJT7VrSEv79M2aei8D0ysRCK/Sb7o9P99zrcppYdykvprKKJHhE7ZndICUdx9kxR3+VgMySgTLxFjPljIezKIzbLlrYrYiNQv7/pJMuOYlKX1eLhKdQO8MSt8H1Ue4dB8GUMpj4DRfB9fMhVmXsi2HivzVxksXrSyDC/ifLCJZstP2P2Y/My1HhWQuqE3t+VSE8699k5RpmaXzKxy7qzxulQot7r/hendOkWbIHnezP70nBZU/lqnszgGoMSg0PsZmtuas1e6si9L4hMXYCtnXA01j1y0tbGHzlj9A+5h7wuhIVJb3jIaBaOXgHQbcK0bvh/OTOv5eH4+lgW3w6I1AY7PpH2u0+GxJXP3Ro8BRGvoMTf5vTC7goF8thbZ5nMAPVh4MSDFbOaNKjJoZ2pSQYXJO5xBZHF8cqErNXCPuf9FZIOh4E39scfBe+LFv3cv+R0MZGwMNB1lL2fvPQJ4oyuesSubmEHhUKlSrk1Zcm98w==,iv:9fRAfd2+6XMI0PMZxDr4mp1ThSQEucVkql2OVxD2R3E=,tag:jI6s2ypxFiaBmjPVrdIO5A==,type:str] +MAILCHIMP_API_KEY=ENC[AES256_GCM,data:jF6R33ZGlXhAFAtOI0oYSLXqLFNKtZmACvIn0QJDe/PjsShT,iv:uVPL04rmnBS1ahAiWSWtXnLkijxFQ6jgSux92cO7M0g=,tag:Tq0uTnH1PWgXWnod19u21Q==,type:str] +MAILGUN_API_KEY=ENC[AES256_GCM,data:8PZlwqWZuejs3bCd8CUQAfISkNDdNl57Uj/ZpRSuSDcnFLAU,iv:63ToWd6DHBEB0DsFyd5sZiqJJTdrOlqab+gwtZl9/JQ=,tag:hkYg6BdJy4uf2T7ewTVX6Q==,type:str] +NODE_ENV=ENC[AES256_GCM,data:hlr1cwV1vwwpwg==,iv:NZV8TgQe1SAIjBmysz/d2w+eyKI4kC8nWLMtZw9Kssw=,tag:6AOb8S6Dv8vyx0caYojDCw==,type:str] +S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:vyKU23+grCh8wJWiDWEZ9eVWl6E=,iv:mz3oPKUjTBicxWDJ0BhssfyYDp31dcMxPZh8uzwGiXM=,tag:O2L4/z13JUrikRdWDshCow==,type:str] +S3_BACKUP_BUCKET=ENC[AES256_GCM,data:n41bjhL7Yvzd91w=,iv:ZUMCmYQe/Uf5ioA8/knN0B1B38feD36V074uaQnS5Ns=,tag:i5pQHk7PC8moP+jt1wEebA==,type:str] +S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:xZB7IA8mLlx9Yewnfc6hN533HlONjtO0Y8bZ5IfJO65PWPo=,iv:j5ktjZUdrQ8G7xezU52WhQPH+Lz7b8HtvYFFvOM7rBQ=,tag:qOr3T14vn3fAuJU/i7LQOA==,type:str] +S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:6xpkj2ofmkrrfih1TlxCUIsvtGoDYYFKbmvx+RBtx+80PVDZAcgGaw==,iv:hVppfnf0JHUhhX2U6pCZeBa+Ps/zLmhhcflLv36ZwUQ=,tag:m9vYdOnPya+0BVpu9s5isQ==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:z6ipQkDdq9GbJxR72urZ8mIzLUKg7Q6DUmhpPtwr1on4gm0RpNRx6Z8pl44O1zEW2uE8EyHAoGMUmfIQa9B4W246S6xC8rqYVDrwyeQpdU64UiPW1UGokei33XcfMVhsKXcXcmbs87p25+gzn+UsV4ofu/rKH/Qls+Wdrsg2vNdaUCrmJKnsDDESfEBs4ym4r1BaByRke6hiKUYZObv+4s62zh01sUtVz1AlVDsG2bZP//GrEaCqRWM+DQ==,iv:WRAMvRFdKMvtsS+uy/fRbwdW71nI9kUt8szDMQCgJeQ=,tag:tcqnU2rdhJAS7Nyw5UwIog==,type:str] +SENTRY_ORG=ENC[AES256_GCM,data:W1Oi,iv:n9GFTs+0EwIA/gCdrgRnlXito1Zvy6i0hdbOzOU/LPc=,tag:C0zE1Awo82BjHDQ70ZxNIg==,type:str] +SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:fHE=,iv:MNRtxZZzKFnAHn8LLOiUKJbHekUcq8vNmDEHYBPYm+A=,tag:w27ZGhzAJa3fX7QQgw2c0Q==,type:str] +SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:RrqmKuvPRi3rSsubaz/9h2LdVoqSI1txmxHicswPgV/E/1mh2VZ3BuuvUGBds6gprbC18PgWjNnaZBGacMI6AvFNRucL01cG3euyGZKJyA==,iv:yN2LwK47xu97jxdYpu9IHrHkbuGesk3OPpBkkaaAtSk=,tag:FvGv0czDWdluoBdmlOLi8w==,type:str] +SMTP_HOST=ENC[AES256_GCM,data:tOPjXmNyn0q+kYtK4F5rg6etqk6hp5+yuc5+N03ObkRFgQ==,iv:WC2Ls3J3XzXdt9Djy0JnYlMNiaWT5Fp1IDXWzGWe8gc=,tag:tqI83ltJkYiCQF5UjCDSbg==,type:str] +SMTP_PASS=ENC[AES256_GCM,data:wXNewdWOxvaZ+s9LId5xzHJ6A5WQpRkTKzk7kBAEUAHAepY2iy1l7W62ePE=,iv:wCgt/HSwae8fuLnjSV7wS6/EWVw5XqNxbs6p9HaXthY=,tag:UHodP4PYlGNNU5RFhDOrsg==,type:str] +SMTP_USER=ENC[AES256_GCM,data:ZuX4HpPffhSZy1x3NrNMPK4CY7c=,iv:2zvisVfHxmZWMIY1S2jKq7mncbU0PbJmIS9QYBCNSdc=,tag:3Fd4HoKLpwNtMehHu1V0oA==,type:str] +ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:SGMj93kLneTedJhzzJLHCnoaSEU=,iv:r/1SdPDEcG5syCG8abaSBckSzlqQ8E+rAwqDNttoqyY=,tag:wa85xP4YWM+ix5Kn3ClJlg==,type:str] +ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:UL//USfKy+IgxEAtnCtx9JhJMVw=,iv:OUPQlDAOoqNMM2ZHdgdmcuP9twotPV+p6VrwaRJ3czk=,tag:gQUd0KEhoVbiMtonu2ceyw==,type:str] +#ENC[AES256_GCM,data:L2euC8ocqeH9DQ1Fgpm5Zv+6jAw=,iv:Y6siwsJ1bxXFubgpx1lpEL4xp6/GrGlSCnbAJKvMcwY=,tag:SoTTKg7MInia/fWY2SG0PQ==,type:comment] +KF_AUTH_URL=ENC[AES256_GCM,data:6sWKG13lAk4QFM8CQAeX/GSra+6tSZy6U2DDRg/utT86yeJVtA==,iv:5W3w81BiQjuDdV8Hu+UsDeQGQ4I2qCzINSgrw0CV88w=,tag:bNkbMnatnizs+dKaSOP2rQ==,type:str] +KF_AUTH_CLIENT_ID=ENC[AES256_GCM,data:YJzzZFJlh0EE,iv:mAFUBC0UVhcv+CU6V6D9eREZWFq+xdKynKpIV1pMS6k=,tag:zU7037uBK3K6DEcrKyrSfg==,type:str] +KF_AUTH_CLIENT_SECRET=ENC[AES256_GCM,data:MGFckQuY7PvDok4NC8uavb26yLeoPdw0pnCd/jujY5A3D2PXNME5I6xKwAVLve4jlxqlPgS1Pnuel7pEwG2eMw==,iv:4tHujDdiNKsvh76PRgbPm5i3k5yS5gi6/+xxrjmSs5U=,tag:qY/rCWsXjHZ3KbwIUf34HQ==,type:str] +KF_INTERNAL_API_KEY=ENC[AES256_GCM,data:ICCPMzS8V2CbcXWhv8bTN28IT3YbdCVuhkGB0L/TShFDxQvNuuCepXST8pyTQCWxzuiY44qINdwQiyf4P241zA==,iv:Yy7d3C4uid6x5vqA8cB3i+vjp68AdataVFq3slgKoL8=,tag:ZlmchFCXAXJc6wweoYirUg==,type:str] +APP_URL=ENC[AES256_GCM,data:fJOA0h8+Rgh6bcrWhAW8XqEwPkExyA==,iv:DUBOurc6b8WSbaXxNg7SAW8Bf7g1pfj/gAuepZzH2qc=,tag:JhmteJepQ6J6PzKUYZQFfg==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrTFM5bDlKNWZDUVozb20z\nMi9zUG9EK0JYdFZ6cDd1alVhMGd0a3Fma0NnCjQ1TlJkakpjMDdtUlRJUGJtVUtK\nNUIyMTNVaUgzdElVT0pKWnhwOUVzL0UKLS0tIFFYZ2huK3p2dHFaT2s2bm0vQVh4\nRlN0UVM0aFRzeXAyUkdHWU5URVJaZGcKfXe8NRpAOYM9PZ+uOqxS4dO27GXTc0K6\nH0j4kwyE0yOEay4e4G3AzcKjgrRmRyUs3R0Z9gM75FHH25liYXa1Cw==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr -sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBOTko5Y0N1SnBTd2ZmOHZj\nSFJxbzFRR2NLWFRYV3dqbVpQNkMzMnA2aVNjCmJhdnNXWk1TTCs2NkFqeWhScVJz\nNFJEUTFROTQvN3Azakx6Y1pXWEFPbXcKLS0tIElKMjNHN2tEalZESkJQK3FHMzFH\nT2VONFJVZkFiaTliNHcyOGwvLzY2UlEKzWX6cJJxrv+7v8iacGd57UC69iNPbSek\ns/E9E285dy5wF5gVAsA2wJOoEgENdwRa7EAPcBsMI7VOl0bILGWiQQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBpZ0dZYXlESGZJOXB1MlZN\nQnFNaktpSFdKRFlzWDVQZE8vL1ljRnkxR0E0CmdRREtjNEJsQ2xBWmhSMGJKYjB6\naFFoYkZ6RzhLcW9tWFBnQ1VJbEJ6Z1kKLS0tIDJaN05hWFQ5U2xyL1FCZnduVWVQ\nTWlLZ0ZLQ3FqeU5JUWRxT2NXT0hYYm8KaIgXaWz/dGqhCJwIHbwTeoSxs2ansOhr\n/wiwQlXmoM+0EdsMabjOQpdIN6uEtwCwLBnEbKocMPLJ+gPzYvKtIQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj -sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2NGlMTXJ1RExKOHVGUVVD\nQURUUm5samc1cnZSR3JjVE41RmdGeDZhV2pjCmtUMHg5bHE5eGxHTytla21IVk9X\nL01HN01vcXl5dkUrM2l4NWpaUUx1NnMKLS0tIDZIQVp4V25DMEVoYzl1NXNXNW1w\nbnhlcTNDVnBkRkF6eUxFcVVLV0JmK2sKyKBrZGPL5Rx/Q+rpzglShyJcjbDiWJEf\nYOnY5JDDB7P08hRW9nB2zcppXAHVfCeUPAWxsN+4BfAQb8FdO8bqwA==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBKay9XQ1MxOHpOV3VOT3Bt\nOGE3ZCtoL1pBRXNlWDIzNkFpeTJNV0NyVkRrCkgzSk50RC9RUWh5cWxiWVQ4WjZW\na2c2TXMvSmZia29IekRpeGNab3VOWWsKLS0tIDgyZmpXUENFd1orMGVxRUQ1MWoy\nTzd6U0J0MmtMK20wdFZhTFlHOXBpQ0UKdUiBRCW7ZXwTbHEkEoggSQUllJpHJHN5\nOVW4Q4chJ5PZHif7llPuzA9WfSKbLTwigwJMgx5QEkuHoFdLYsMudg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk -sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5YjJGMVk1M3ZuYisvT0hR\ndWV0NVl1MGVvQkR3VzV6VVNLVi85aDltT2tVCnI2ZENiWXlDd0xwK1hPR21TL1dr\nWXdoVndlL3liR1F2aG0rNXZPek9qdEUKLS0tIHJMR05IbjlMa2N1RTVkYmhScjhD\nMW5Tbitlc08rNXdWSUZnSWhMQWYzelkK3bPNfxxb7E44O11ehhkIUxqhSEgUoT7o\nrsQSxnZFPuJeXEon1VO8c92SeD+JZy6BzXzYo03gHLRxcVBGput2jQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwVzZWUUVrVVBUOEUraEgx\nTTBmcFYxRWUxU2JacUZJTWZTZUd5aHo1amdZCmhDVVdPeHlETDRSMG5rd1FoTmZX\nbjdQYmRpMW9MWEE1UE8rSENXUjFKM1EKLS0tIHV3bXZBQWExblpENE8relRzb0JF\nYzl1TFZIVnhSZ3dBTktTdGlBeXh6YjgKI5XX2g3fnYUsTlE+nsEMoKF2JDZhNcBS\na6ignKJZ44q0KPPU0gZO2ue1S8XhQkM8hQ9zcWQU+m311qa3lKDPYA==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h -sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGOG1ueUpIektJazFCT0pj\nVzMvbExOQy9jK1psa25Rd1FjTUZXUmw3YkdjClljdmVHdVNWWHZ4MEpzditOaTNs\nRSt3NFVLOFdEQlhlakduTFpDTjF3aVEKLS0tIFh2bFZKUktqcmx1S0hEK1JvYVRi\nUVJTYkpJUitzT1NOaDhjcFJ2NEl6TzQKLtlw9zz/nSfvxHkNTKWb7ZITpKaNP3L4\nGH+FUZ4cQCJgUoNxtrGFeHBpgz5RCfhkCnVg03B7oZwZO4NZk+YQhg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHL29qZU9SRzZiU2dadnFw\nTk01RzMvSGJjNUVmWjZpWG1PSTN1UExOemtnCnYwR2hUT1JOb0FQRnFGMG5sYVdF\nWGgwNjZzdjAyVkJhU2l3YmRMcHpySncKLS0tIFBTVkdCdkduaG1IYWZyQW9ldG4y\nem9lUHlKRWZsTm9UQUFyWE1NKzVuVG8KCYKl5pmdth3jC50UBQP3CVOljXvRVVCX\nf5UDIP6ytEl9DEG3pe8mSpLPxlNw29fAMzU434b53SUW6q1+y1Yzgw==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx -sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUbHE4Q0JVdHJIMnNzdFJa\nb1lweEROcyt5UlFzNHEwNndpdkVVdDVjdDBrClFNTVd6WkJCbkpUSFF4ZWVaRENS\nYWhsTURhaXdSMnNkU2REZDBONUZDejgKLS0tIHFRN1dXQzlYTzd3TTFtVTJGcDd1\nU1hqUkN2dFFSNVl0d1F3NDNtS3B3VlkKpN8Gn9oTEjCozAiYXT0ZUdXAc2wAKaED\n8oJ5r7aRuEsjidoBVNK6fgb1Jgk8qpa0g1/A0SSQRLXHspDrEz2HYg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBQRTRKdGkyR3AvRDYrdzlj\nMDBxMUpSRHk3OFplTDMwbXlPTXdlWnlJM2o0CkxLUXRPUDFRWE1HR0FUb3VIOURw\nNlFhS1Z5TWdUUzhKSm45RXJUNmJrRGsKLS0tIFFjbkdLTDZBaG9jRS9qVzByeW4z\nYTJDdTMxYVo4OUt6UnBUMDdyZUtCM1EK9RVBqUv5IhF+zvkYbS+Sv/ylYiQ/2Fyj\n+DP/hJtziGHCSo7rhosNwl2Cg+QXJnNMlw4EEKodlOcyEDHZUd/MuA==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy -sops_lastmodified=2026-05-17T02:33:30Z -sops_mac=ENC[AES256_GCM,data:mz8tWY+WNLhcwTxQo+NvxyDLKHLVW8T0SrGrxRUnqzVuF/RtmEF/cXt/tNuB91tt8nCXNRHxGmvzNglUOytCVGZwZ9pB7rMMY38EcEYGn2weQS6zhOXFg+dX31HsgYa5/uMqPrOlmGEb1IHSj0lRtRMZ6HNakEaQGd5rGzQdzGQ=,iv:cn0cIvKtW16gKIy8p72soVuKJdsjZvSEnE0yR0umxNM=,tag:UdbXCfyxx4p7CmMkxNLgSQ==,type:str] +sops_lastmodified=2026-05-17T03:23:35Z +sops_mac=ENC[AES256_GCM,data:aVDfMyoI4MZSPrGsYxPoFPuoAZRzrKo+aISv9EMPFjSb2dCp2sZbNi6beeMnd2UjLLHiivbFHRH9FgB4arzppTALJ+zTxA7oCzVejAte9up+o52/DQQ1eQyywaB1geyjxr58EBuHAEthz5FMm+X6Cf9Oa6iE6x84aRZ9Per1kfw=,iv:sK6WhmyvZmrmzwKrdwsTaDMqK5yTIChP8V46ra7OfMA=,tag:bxEM8WoaZSegeNSxKjVaBQ==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.11.0 From 17046d2baf1a3a852a70f9d771fcb72b869bdfd2 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Sun, 17 May 2026 00:29:37 -0400 Subject: [PATCH 07/13] Handle redirect for subdomain and custom --- server/kf/api.ts | 231 +++++++++++++++++++++++++++------------------- server/kf/auth.ts | 47 +++++++++- 2 files changed, 179 insertions(+), 99 deletions(-) diff --git a/server/kf/api.ts b/server/kf/api.ts index 4972b4e11..a01b60980 100644 --- a/server/kf/api.ts +++ b/server/kf/api.ts @@ -2,9 +2,10 @@ * KF Auth integration routes for PubPub. * * OIDC login/callback: - * GET /auth/login — redirect to KF Auth - * GET /auth/callback — handle OIDC callback, create session - * POST /auth/logout — clear session + redirect to KF Auth logout + * GET /auth/login — redirect to KF Auth + * GET /auth/callback — handle OIDC callback, create session + * GET /auth/session-set — establish session on custom domains (via encrypted token) + * POST /auth/logout — clear session + redirect to KF Auth logout * * Internal service-to-service endpoints (KF_INTERNAL_API_KEY): * POST /api/kf/profile-sync — receive profile updates from KF Auth @@ -17,6 +18,7 @@ * POST /api/kf/transfer-community — transfer community ownership to a different KF Account */ +import { Op } from 'sequelize'; import { timingSafeEqual } from 'crypto'; import { Router } from 'express'; import { promisify } from 'util'; @@ -25,9 +27,19 @@ import { Collection, Community, Member, Pub, PubAttribution, Release, User } fro import { sequelize } from 'server/sequelize'; import { getHashedUserId } from 'utils/caching/getHashedUserId'; import { ensureUserIsCommunityAdmin } from 'utils/ensureUserIsCommunityAdmin'; -import { isDuqDuq, isProd } from 'utils/environment'; - -import { buildAuthorizeUrl, exchangeCode, fetchUserInfo, fetchUserOrgs, KF_AUTH_URL } from './auth'; +import { isDevelopment, isDuqDuq, isProd } from 'utils/environment'; +import { slugifyString } from 'utils/strings'; + +import { + buildAuthorizeUrl, + decryptPayload, + encryptPayload, + exchangeCode, + fetchUserInfo, + fetchUserOrgs, + generateCodeVerifier, + KF_AUTH_URL, +} from './auth'; // ── Helpers ────────────────────────────────────────────────────────── @@ -55,15 +67,24 @@ function requireInternalKey(req: any, res: any, next: () => void): void { /** * Derive the community hostname the user came from. * Needed because the OIDC callback always hits the main domain. + * Note: PubPub's hostname middleware rewrites duqduq.org → pubpub.org + * for community resolution, so we reverse that here. */ function getCommunityHost(req: any): string { - // Use the communityHostname header if set by the reverse proxy, - // otherwise fall back to the raw hostname. - return req.headers.communityhostname || req.hostname; + const host: string = req.headers.communityhostname || req.hostname; + if (isDuqDuq() && host.includes('pubpub.org')) { + return host.replace('pubpub.org', 'duqduq.org'); + } + return host; } -// Cookie name for OIDC state (verifier stored in session for custom domain compat) -const STATE_COOKIE = 'kf_oauth_state'; +/** + * Returns true if the host is a platform subdomain (*.pubpub.org or *.duqduq.org) + * where the shared session cookie works across subdomains. + */ +function isPlatformSubdomain(host: string): boolean { + return host.endsWith('.pubpub.org') || host.endsWith('.duqduq.org'); +} // ── Router ─────────────────────────────────────────────────────────── @@ -80,35 +101,15 @@ router.get('/auth/login', (req: any, res: any) => { ? rawReturn : '/'; - // Encode the community hostname + return path in state so we can - // redirect back after the OIDC callback. - const statePayload = JSON.stringify({ host: communityHost, returnTo }); - const stateToken = Buffer.from(statePayload).toString('base64url'); - - const { url, codeVerifier } = buildAuthorizeUrl(stateToken); - - const cookieOpts = { - httpOnly: true, - secure: isProd(), - sameSite: 'lax' as const, - path: '/', - maxAge: 600_000, // 10 minutes - // Set on .pubpub.org so the callback (on www.pubpub.org) can read it - ...(isProd() && - communityHost.indexOf('pubpub.org') > -1 && { - domain: '.pubpub.org', - }), - }; + // Generate verifier first, then encrypt it with routing info into state. + // This avoids cookies/session for OIDC state, so it works across + // domains (custom domains → callback on www.duqduq.org). + const codeVerifier = generateCodeVerifier(); + const stateToken = encryptPayload({ v: codeVerifier, h: communityHost, r: returnTo }); - res.cookie(STATE_COOKIE, stateToken, cookieOpts); + const { url } = buildAuthorizeUrl(stateToken, codeVerifier); - // Store verifier in session (not cookie) so it works across domains. - // Custom domain sessions are scoped to their domain, and the callback - // hits the same domain since PubPub proxies all requests. - req.session.kfOauthVerifier = codeVerifier; - req.session.save(() => { - return res.redirect(url); - }); + return res.redirect(url); }); // ─── OIDC callback ─────────────────────────────────────────────────── @@ -119,30 +120,24 @@ router.get('/auth/callback', async (req: any, res: any) => { if (error) { console.error('KF Auth error:', error, req.query.error_description); - return res.redirect('/login?error=auth_failed'); + return res.status(400).send('Authentication failed. Please try again.'); } if (!code || !state) { - return res.redirect('/login?error=missing_params'); + return res.status(400).send('Missing authentication parameters.'); } - // Validate state - const savedState = req.cookies[STATE_COOKIE]; - const codeVerifier = req.session?.kfOauthVerifier; - - // Clear OIDC state - res.clearCookie(STATE_COOKIE, { path: '/' }); - if (req.session) { - delete req.session.kfOauthVerifier; - } - - if (!savedState || savedState !== state) { - return res.redirect('/login?error=invalid_state'); + // Decrypt state → {v: codeVerifier, h: host, r: returnTo} + const stateData = decryptPayload<{ v: string; h: string; r: string }>(state); + if (!stateData || !stateData.v) { + return res.status(400).send('Invalid or expired authentication state.'); } - if (!codeVerifier) { - return res.redirect('/login?error=missing_verifier'); - } + const { v: codeVerifier, h: host, r: rawReturn } = stateData; + const returnTo = + typeof rawReturn === 'string' && rawReturn.startsWith('/') && !rawReturn.startsWith('//') + ? rawReturn + : '/'; // Exchange authorization code for tokens const tokens = await exchangeCode(code, codeVerifier); @@ -151,62 +146,106 @@ router.get('/auth/callback', async (req: any, res: any) => { const userInfo = await fetchUserInfo(tokens.access_token); const kfUserId = userInfo.sub; - // Look up PubPub user by ID (PubPub ID = KF Auth ID after seeding) - const user = await User.findOne({ where: { id: kfUserId } }); + // Look up PubPub user by ID, or auto-create on first login + let user = await User.findOne({ where: { id: kfUserId } }); if (!user) { - console.error(`No PubPub user found for KF Auth ID: ${kfUserId}`); - return res.redirect('/login?error=user_not_found'); + const firstName = (userInfo.given_name || userInfo.name || 'New').trim(); + const lastName = (userInfo.family_name || 'User').trim(); + const fullName = `${firstName} ${lastName}`; + const initials = `${firstName[0] || '?'}${lastName[0] || '?'}`; + const baseSlug = slugifyString(fullName) || 'user'; + const existingSlugCount = await User.count({ + where: { slug: { [Op.like]: `${baseSlug}%` } }, + }); + const slug = existingSlugCount ? `${baseSlug}-${existingSlugCount + 1}` : baseSlug; + + user = await User.create({ + id: kfUserId, + slug, + firstName, + lastName, + fullName, + initials, + email: userInfo.email || `${kfUserId}@placeholder.invalid`, + avatar: userInfo.picture || null, + } as any); + console.log(`Auto-created PubPub user ${user.id} (${user.slug}) from KF Auth`); } - // Create a standard Passport session (indistinguishable from old login) + const protocol = isDevelopment() ? 'http' : 'https'; + + // For custom domains, we can't set a session here (different domain). + // Create a one-time encrypted token and redirect to session-set on the origin. + if (host && !isPlatformSubdomain(host)) { + const sessionToken = encryptPayload({ + u: user.id, + r: returnTo, + exp: Date.now() + 60_000, // 60 seconds + }); + const sessionSetUrl = `${protocol}://${host}/auth/session-set?token=${encodeURIComponent(sessionToken)}`; + return res.redirect(sessionSetUrl); + } + + // For platform subdomains: create session directly (shared cookie on .pubpub.org / .duqduq.org) const logIn = promisify(req.logIn.bind(req)); await logIn(user); - // Set the CDN cache cookie const hashedUserId = getHashedUserId(user); res.cookie('pp-lic', `pp-li-${hashedUserId}`, { - ...(isProd() && - req.hostname.indexOf('pubpub.org') > -1 && { - domain: '.pubpub.org', - }), - ...(isDuqDuq() && - req.hostname.indexOf('duqduq.org') > -1 && { - domain: '.duqduq.org', - }), + ...(isProd() && { domain: '.pubpub.org' }), + ...(isDuqDuq() && { domain: '.duqduq.org' }), maxAge: 30 * 24 * 60 * 60 * 1000, }); - // Parse state to get the community host + return path - let redirectUrl = '/'; - try { - const statePayload = JSON.parse(Buffer.from(state, 'base64url').toString()); - const host = statePayload.host || ''; - const rawReturn = statePayload.returnTo || '/'; - // Validate returnTo is a safe relative path - const returnTo = - typeof rawReturn === 'string' && - rawReturn.startsWith('/') && - !rawReturn.startsWith('//') - ? rawReturn - : '/'; - - if (host && host !== req.hostname) { - // Redirect back to the community the user came from - const protocol = isProd() ? 'https' : 'http'; - redirectUrl = `${protocol}://${host}${returnTo}`; - } else { - redirectUrl = returnTo; - } - } catch { - // If state parsing fails, just go to root - redirectUrl = '/'; + if (host && host !== req.hostname) { + return res.redirect(`${protocol}://${host}${returnTo}`); } - - return res.redirect(redirectUrl); + return res.redirect(returnTo); } catch (err) { console.error('OIDC callback error:', err); - return res.redirect('/login?error=callback_failed'); + return res.status(500).send('Login failed. Please try again.'); + } +}); + +// ─── Session transfer for custom domains ───────────────────────────── + +router.get('/auth/session-set', async (req: any, res: any) => { + try { + const { token } = req.query; + if (!token) { + return res.status(400).send('Missing session token.'); + } + + const data = decryptPayload<{ u: string; r: string; exp: number }>(token); + if (!data || !data.u) { + return res.status(400).send('Invalid session token.'); + } + + if (Date.now() > data.exp) { + return res.status(400).send('Session token expired. Please log in again.'); + } + + const user = await User.findOne({ where: { id: data.u } }); + if (!user) { + return res.status(400).send('User not found.'); + } + + // Create Passport session on this domain + const logIn = promisify(req.logIn.bind(req)); + await logIn(user); + + // Set the CDN cache cookie on this domain + const hashedUserId = getHashedUserId(user); + res.cookie('pp-lic', `pp-li-${hashedUserId}`, { + maxAge: 30 * 24 * 60 * 60 * 1000, + }); + + const returnTo = data.r || '/'; + return res.redirect(returnTo); + } catch (err) { + console.error('Session-set error:', err); + return res.status(500).send('Failed to establish session. Please try again.'); } }); diff --git a/server/kf/auth.ts b/server/kf/auth.ts index 850bc028d..79a7ab4c1 100644 --- a/server/kf/auth.ts +++ b/server/kf/auth.ts @@ -13,6 +13,43 @@ const KF_AUTH_CLIENT_SECRET = process.env.KF_AUTH_CLIENT_SECRET ?? ''; const APP_URL = process.env.APP_URL ?? 'http://localhost:9876'; const REDIRECT_URI = `${APP_URL}/auth/callback`; +// ── Symmetric encryption (AES-256-GCM) ────────────────────────────── + +/** Derive a 32-byte key from the client secret for AES-256-GCM. */ +function deriveKey(): Buffer { + return crypto.createHash('sha256').update(KF_AUTH_CLIENT_SECRET).digest(); +} + +/** Encrypt a JSON-serializable object → base64url token. */ +export function encryptPayload(data: object): string { + const key = deriveKey(); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const plaintext = JSON.stringify(data); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + // Layout: iv (12) + tag (16) + ciphertext + return Buffer.concat([iv, tag, encrypted]).toString('base64url'); +} + +/** Decrypt a base64url token → parsed object, or null on failure. */ +export function decryptPayload(token: string): T | null { + try { + const key = deriveKey(); + const buf = Buffer.from(token, 'base64url'); + if (buf.length < 29) return null; // iv(12) + tag(16) + at least 1 byte + const iv = buf.subarray(0, 12); + const tag = buf.subarray(12, 28); + const ciphertext = buf.subarray(28); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return JSON.parse(decrypted.toString('utf8')) as T; + } catch { + return null; + } +} + // BetterAuth OIDC endpoints const AUTHORIZE_PATH = '/api/auth/oauth2/authorize'; const TOKEN_PATH = '/api/auth/oauth2/token'; @@ -32,13 +69,17 @@ export function generateCodeChallenge(verifier: string): string { /** * Build the URL to redirect the user to for authentication. - * `state` should include the community subdomain/domain for post-login redirect. + * `state` is the OIDC state parameter (encrypted payload with verifier + routing info). + * `existingVerifier` allows passing a pre-generated verifier (when it's encrypted in state). */ -export function buildAuthorizeUrl(state: string): { +export function buildAuthorizeUrl( + state: string, + existingVerifier?: string, +): { url: string; codeVerifier: string; } { - const codeVerifier = generateCodeVerifier(); + const codeVerifier = existingVerifier ?? generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); const params = new URLSearchParams({ client_id: KF_AUTH_CLIENT_ID, From b5bc32fbe29571f4e7d973ae365afc3d5f98c23f Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Sun, 17 May 2026 00:29:48 -0400 Subject: [PATCH 08/13] lint --- server/kf/api.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/kf/api.ts b/server/kf/api.ts index a01b60980..fad848253 100644 --- a/server/kf/api.ts +++ b/server/kf/api.ts @@ -18,9 +18,9 @@ * POST /api/kf/transfer-community — transfer community ownership to a different KF Account */ -import { Op } from 'sequelize'; import { timingSafeEqual } from 'crypto'; import { Router } from 'express'; +import { Op } from 'sequelize'; import { promisify } from 'util'; import { Collection, Community, Member, Pub, PubAttribution, Release, User } from 'server/models'; @@ -135,7 +135,9 @@ router.get('/auth/callback', async (req: any, res: any) => { const { v: codeVerifier, h: host, r: rawReturn } = stateData; const returnTo = - typeof rawReturn === 'string' && rawReturn.startsWith('/') && !rawReturn.startsWith('//') + typeof rawReturn === 'string' && + rawReturn.startsWith('/') && + !rawReturn.startsWith('//') ? rawReturn : '/'; From 2eab36eebedb7a6f7955cccd7c1a059cdef07411 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Sun, 17 May 2026 01:06:51 -0400 Subject: [PATCH 09/13] fix new accoutn on the fly --- server/kf/api.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/server/kf/api.ts b/server/kf/api.ts index fad848253..6d66d6ee2 100644 --- a/server/kf/api.ts +++ b/server/kf/api.ts @@ -2,7 +2,7 @@ * KF Auth integration routes for PubPub. * * OIDC login/callback: - * GET /auth/login — redirect to KF Auth +docker service logs auth_auth --tail 50 2>&1 | grep -i "error\|invalid\|authorize\|token" * GET /auth/login — redirect to KF Auth * GET /auth/callback — handle OIDC callback, create session * GET /auth/session-set — establish session on custom domains (via encrypted token) * POST /auth/logout — clear session + redirect to KF Auth logout @@ -160,7 +160,20 @@ router.get('/auth/callback', async (req: any, res: any) => { const existingSlugCount = await User.count({ where: { slug: { [Op.like]: `${baseSlug}%` } }, }); - const slug = existingSlugCount ? `${baseSlug}-${existingSlugCount + 1}` : baseSlug; + const slug = existingSlugCount + ? `${baseSlug}-${existingSlugCount + 1}` + : baseSlug; + + // Use KF Auth email if available and not already taken + let email = `${kfUserId}@placeholder.invalid`; + if (userInfo.email) { + const emailTaken = await User.findOne({ + where: { email: userInfo.email.toLowerCase() }, + }); + if (!emailTaken) { + email = userInfo.email.toLowerCase(); + } + } user = await User.create({ id: kfUserId, @@ -169,8 +182,10 @@ router.get('/auth/callback', async (req: any, res: any) => { lastName, fullName, initials, - email: userInfo.email || `${kfUserId}@placeholder.invalid`, + email, avatar: userInfo.picture || null, + hash: '', + salt: '', } as any); console.log(`Auto-created PubPub user ${user.id} (${user.slug}) from KF Auth`); } @@ -204,9 +219,10 @@ router.get('/auth/callback', async (req: any, res: any) => { return res.redirect(`${protocol}://${host}${returnTo}`); } return res.redirect(returnTo); - } catch (err) { + } catch (err: any) { console.error('OIDC callback error:', err); - return res.status(500).send('Login failed. Please try again.'); + const detail = isDuqDuq() ? ` (${err?.message || err})` : ''; + return res.status(500).send(`Login failed. Please try again.${detail}`); } }); From 398ddd1a7238d18f117e0a7861f250c1d203b262 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Sun, 17 May 2026 01:07:11 -0400 Subject: [PATCH 10/13] lint --- server/kf/api.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server/kf/api.ts b/server/kf/api.ts index 6d66d6ee2..8dec6de77 100644 --- a/server/kf/api.ts +++ b/server/kf/api.ts @@ -160,9 +160,7 @@ router.get('/auth/callback', async (req: any, res: any) => { const existingSlugCount = await User.count({ where: { slug: { [Op.like]: `${baseSlug}%` } }, }); - const slug = existingSlugCount - ? `${baseSlug}-${existingSlugCount + 1}` - : baseSlug; + const slug = existingSlugCount ? `${baseSlug}-${existingSlugCount + 1}` : baseSlug; // Use KF Auth email if available and not already taken let email = `${kfUserId}@placeholder.invalid`; From 6275f2bd6719c2b7178f355d79cf297b88c33cc6 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Sun, 17 May 2026 15:53:55 -0400 Subject: [PATCH 11/13] Add context endpoints --- server/kf/api.ts | 36 ++++++++++++++++++++++++++++++------ server/kf/auth.ts | 2 ++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/server/kf/api.ts b/server/kf/api.ts index 8dec6de77..4fa4161cf 100644 --- a/server/kf/api.ts +++ b/server/kf/api.ts @@ -107,7 +107,7 @@ router.get('/auth/login', (req: any, res: any) => { const codeVerifier = generateCodeVerifier(); const stateToken = encryptPayload({ v: codeVerifier, h: communityHost, r: returnTo }); - const { url } = buildAuthorizeUrl(stateToken, codeVerifier); + const { url } = buildAuthorizeUrl(stateToken, codeVerifier, communityHost); return res.redirect(url); }); @@ -329,6 +329,29 @@ router.post('/api/kf/profile-sync', requireInternalKey, async (req: any, res: an } }); +// ─── Context listing (for KF Auth playground) ─────────────────────── + +router.get('/api/kf/contexts', requireInternalKey, async (req: any, res: any) => { + try { + const communities = await Community.findAll({ + attributes: ['subdomain', 'title', 'avatar'], + order: [['title', 'ASC']], + limit: 200, + }); + + return res.json( + communities.map((c: any) => ({ + slug: c.subdomain, + title: c.title, + avatar: c.avatar || null, + })), + ); + } catch (err) { + console.error('Contexts listing error:', err); + return res.status(500).json({ error: 'Internal error' }); + } +}); + // ─── Branding API (for KF Auth login page) ─────────────────────────── router.get('/api/kf/branding', requireInternalKey, async (req: any, res: any) => { @@ -357,11 +380,12 @@ router.get('/api/kf/branding', requireInternalKey, async (req: any, res: any) => } return res.json({ - communityName: community.title, - logoUrl: community.avatar || community.headerLogo, - accentColorLight: community.accentColorLight, - accentColorDark: community.accentColorDark, - headerLogo: community.headerLogo, + // Fields expected by kf-auth's loadAppContext() + display_name: community.title, + logo_url: community.avatar || community.headerLogo || null, + brand_color: community.accentColorDark || null, + background_color: community.accentColorLight || null, + // Extra fields for backwards compat / debugging subdomain: community.subdomain, }); } catch (err) { diff --git a/server/kf/auth.ts b/server/kf/auth.ts index 79a7ab4c1..be1730417 100644 --- a/server/kf/auth.ts +++ b/server/kf/auth.ts @@ -75,6 +75,7 @@ export function generateCodeChallenge(verifier: string): string { export function buildAuthorizeUrl( state: string, existingVerifier?: string, + context?: string, ): { url: string; codeVerifier: string; @@ -89,6 +90,7 @@ export function buildAuthorizeUrl( state, code_challenge: codeChallenge, code_challenge_method: 'S256', + ...(context && { context }), }); return { url: `${KF_AUTH_URL}${AUTHORIZE_PATH}?${params}`, codeVerifier }; } From 5aa45b05b7dabfffc6ce7fd182c308d8ca47e717 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Sun, 17 May 2026 16:42:05 -0400 Subject: [PATCH 12/13] context fix for login --- server/kf/api.ts | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/server/kf/api.ts b/server/kf/api.ts index 4fa4161cf..2359288df 100644 --- a/server/kf/api.ts +++ b/server/kf/api.ts @@ -107,6 +107,8 @@ router.get('/auth/login', (req: any, res: any) => { const codeVerifier = generateCodeVerifier(); const stateToken = encryptPayload({ v: codeVerifier, h: communityHost, r: returnTo }); + // Pass the community hostname as context for per-community branding. + // The branding endpoint resolves hostnames → slugs. const { url } = buildAuthorizeUrl(stateToken, codeVerifier, communityHost); return res.redirect(url); @@ -357,14 +359,22 @@ router.get('/api/kf/contexts', requireInternalKey, async (req: any, res: any) => router.get('/api/kf/branding', requireInternalKey, async (req: any, res: any) => { try { const { subdomain, context } = req.query; - const slug = context || subdomain; + let identifier = context || subdomain; - if (!slug) { + if (!identifier) { return res.status(400).json({ error: 'subdomain or context param required' }); } - const community = await Community.findOne({ - where: { subdomain: slug }, + // If the identifier looks like a platform hostname, extract the subdomain slug. + // e.g. "mycommunity.duqduq.org" → "mycommunity", "mycommunity.pubpub.org" → "mycommunity" + const platformMatch = identifier.match(/^([^.]+)\.(pubpub\.org|duqduq\.org)$/); + if (platformMatch && platformMatch[1] !== 'www') { + identifier = platformMatch[1]; + } + + // Try by subdomain slug first + let community = await Community.findOne({ + where: { subdomain: identifier }, attributes: [ 'title', 'avatar', @@ -375,6 +385,21 @@ router.get('/api/kf/branding', requireInternalKey, async (req: any, res: any) => ], }); + // If not found and identifier looks like a hostname, try as custom domain + if (!community && identifier.includes('.')) { + community = await Community.findOne({ + where: { domain: identifier }, + attributes: [ + 'title', + 'avatar', + 'headerLogo', + 'accentColorLight', + 'accentColorDark', + 'subdomain', + ], + }); + } + if (!community) { return res.status(404).json({ error: 'Community not found' }); } From cf8c58e064caf9cb61b5c7128f23882854e1ee83 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Sun, 17 May 2026 23:04:14 -0400 Subject: [PATCH 13/13] Fix local auth dev --- .gitignore | 2 + infra/.env.local.enc | 103 ++++++++++++++++++++++--------------------- server/kf/auth.ts | 13 +++--- 3 files changed, 62 insertions(+), 56 deletions(-) diff --git a/.gitignore b/.gitignore index 0069a25b7..717ce9e8c 100755 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,5 @@ tsconfig.tsbuildinfo infra/pgdata/ tmp/ + +planning/ \ No newline at end of file diff --git a/infra/.env.local.enc b/infra/.env.local.enc index 3b8465871..61d94c74a 100644 --- a/infra/.env.local.enc +++ b/infra/.env.local.enc @@ -1,59 +1,60 @@ -AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:XRrbCOuZdK8OysMC8g+jAaifrbpYSxVywFEbb3HjnUfoHizjXn+NlWOUAVmpqN5BGo8diM34p2EVduU1XZyzSQ==,iv:jqCPzyTAdqSGOiCFMT88AkvZFYVeEfcwRfu7IwpBlsA=,tag:Dm7+P6d5FXCH0iC016L0Tg==,type:str] -ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:XuAZOjWIOkJ07lO4XrAvlUvn693sA32GWSF4OgdkG3IJqr8UBEtSnuIcEIbdcF9/ANLBoWFgW3PXrZ70jdGDYw==,iv:3MfHLwJisbdD1FuNCN53y04pGsrZdHwFKvWZ4TiUCXw=,tag:TpZf6DbGqzTKqeaXVmmO5w==,type:str] -AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:n5D8UKoeZwKWuwMXuSlxOm1TRxw=,iv:R4d7oQkeBYixr2QzugIvPvbuy8fViR2FcNiPw7ielhQ=,tag:HwWiw9fDS6o9Jn2nKYs79A==,type:str] -AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:b1YKnZq9gAs6unTXjtfsOYOq3Rc=,iv:OFm9qoskNddUO+Yh5V+tY6lDbhvF74OvRFRR8gvw2Vs=,tag:F37DWCeP/BbV/mL5Jt8xiQ==,type:str] -AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:Q7HVjPiubI93ZRPzVMmAUfqnB5wQmDlyMiL3ZuswImBmSPigHafEcQ==,iv:VBi7efe5hJ9cqfyS48w/QZNekyOA1Gd/Kb2I7r3MLro=,tag:9LNAKj/DWpK8B31abTjq9w==,type:str] -AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:dDjdPGyr483URjl+YkPmxtYsoGxhkTjFMBvdJShK43WdYgvWaFQNJw==,iv:myfObHUHH0m7nWYzrr8RrphaBffuqOuM2qdTYUASI2E=,tag:+bsEDlphkGMijOdVvMhzjA==,type:str] -BACKUPS_SECRET=ENC[AES256_GCM,data:oO4V9V5YSE1bGYv/Kr7Vxb3Xw/5qR+SveidcUmbdnBzuZhaCtde/MUZbmNM=,iv:bz7Pxf4P0XX7pDFT2axgjiY7ONner71ZWiyqpDzwk5o=,tag:be2p6MhRvvO1rYoPx3Q3fQ==,type:str] -CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:rzOukQvL+WIJ35sOLwlpX+xxUp3fmhVOhPzmQrqgVKI/Awrzp3bFdt0pPTcpnxQ2IirUCvs=,iv:RQF3iClC4OivxbvCP1jVmsduPcB7IRAsQS+VZ537M10=,tag:10AxZDcl8L94X+Ku76CgrQ==,type:str] -CLOUDFLARE_CUSTOM_HOSTNAME_API_TOKEN=ENC[AES256_GCM,data:iFvN6NpQH/KJ2mjPPcL38kM33Z7/eqbI6+uebsYuLVEs7F3yh/FOuoueb9TaTHn3DHUkzQ8=,iv:BHlee1tudmEAk3gfUkjNFftRBnAyeB8wH/pwcXxgVBA=,tag:H57LDqLFp9RH8G8vQ0NwCw==,type:str] -CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:9NbqU9+RF1BTNYBJKYoiFpckgEmQIj2RXkVNwOxa9x0=,iv:qGRIa+opCaodDmwwj8z3n3ppu8RWbzrnQZ2tG5d5s60=,tag:t1YW0H6vr+1+ObdHJ0hEDA==,type:str] -CONTENT_SEARCH_TERMS=ENC[AES256_GCM,data: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,iv:MZxp1vXHUtQbnSRC/1T7tRAVYTu8I2sIEwzkn/9srDA=,tag:skSW6FWPkMGvJrZDE8434A==,type:str] -DATABASE_URL=ENC[AES256_GCM,data:7G44ivPxFMcsHTnssCzLCJVS9IZt5CnFpxIHIZahMEol2N/As1mN6qeb3ds=,iv:ixUkDZPnSchGhXQN/feorKtgSMdnmpBy9XeatJE3lPE=,tag:LBu5Rw+nMedECjwpP3rqag==,type:str] -DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:S3j/ilWNZs64uWEsV4qEvft07mXoTbs7ktheR20RiZwzcw==,iv:avmtNQWnEr/1svDYhVHxcdUg5IlGlm1znuYF7k4EglM=,tag:QBcmlkfdzHayGMfCS0377w==,type:str] -DOI_LOGIN_ID=ENC[AES256_GCM,data:tj3fwNl0,iv:Ndpq/BV/V1n5sdRdnOIpd5q8KdaGxESLI1gZDAr3huQ=,tag:FT+G0TYVYPdouH8L0jawEg==,type:str] -DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:6oVZvxbpyGL/uTE8Fs5TItys5ng=,iv:OiPNIe2OwBb+hsrihtrzAOZRq1T1q8TnOebgH/JXqsM=,tag:uYsY8P+/RA5hKwoayTLQUg==,type:str] -DOI_SUBMISSION_URL=ENC[AES256_GCM,data:CSrSedSfIY/JjhC7EPGIrDZsjJ+3hnPh+2jZuR3QLYkpIE1Otqu55A4=,iv:SLpluAPerRk1fEypwZMSQqEAiVJtjoiA0esgFQ2+mcs=,tag:HOt7XzTHNe9WGx832UKibQ==,type:str] -FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:q3yhtanJhbVPpkyTbUUT5XV4WJqvCCKbkIILsgecnG4=,iv:vjMlrhllN0CwVepu4nb5nTVNgL8NR6w/wY7Xu0rLB1A=,tag:NofwqwvS8y90R0sq5tiI5Q==,type:str] -FASTLY_SERVICE_ID=ENC[AES256_GCM,data:niSYhU/1i0ydC0hOglDNMx2wuWsCCQ==,iv:tjat4pUfC8/go2YQq+ZMUrbCopnfgEZ6USRY3fotoU4=,tag:Ivfr6TaLGiQ2/OSdUl8hVg==,type:str] -FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:0/5MlEPOUE2BZzhpqUqBXVsBy9jVRtzrAQqQZftSep4=,tag:sZeILTq2Y1DsvI4axQKNtw==,type:str] -IS_DUQDUQ=ENC[AES256_GCM,data:1qc8rQ==,iv:+c7K3tT3+TkXpQgsxwsUdrduoe0vbmkYrhzka94ydgo=,tag:uPUCSDFj5Upz9DncVa4I0A==,type:str] -JWT_SIGNING_SECRET=ENC[AES256_GCM,data:Wk5Mo7pnugtsZTiN1+RrWlVHfYrYuRLhnxLZaKfoN3dXztCuFv0BVX727APcHi8G5/8l2aydXZJRq7czlXzv+t5Ss81PqT3AidHR8/jLRuy4nonAO+mhMapgafO8cev0ezfOIV/ng1PQS13JRBObn6aM0oLeTZKFtGBpKYP8FyRLjAb2HlpIysQel4SAOR/1aUtNx1lPylunNVs8TjmKFFk0b4gMwdI1BBXldKn1bV/y8BkmEWk3ZkKYh9bloE71lXj+VSdwzeFk+cbJYAfPl4yUV+Ss/T+vdjjst5XrLQehZBXHJ9i8rInP5WcBfzMqXjxm61q0kPAyALQ7yLk1+Pi/OL+EpVAQkbyVJraMO+49EQsNGh8DFTKfAdW9Ef1PgbJbv6oyU3b3940/xaJeQgbDxJ+XICJTtJKRaxC+DGVTg2EgnxU+HzmWKKjHnxGLt5EP9KxKhp8NqSh6vBnofbrFtEybG8EsAPiCu0XHHhIWrUhx8ShWAAnoAVwQT6pkp06/n6gssK1Imoqi5YfJcFdoLWCgZA+8Sgu3rdrqqBu6744fgSp2HAxJ6pfT197Hd8mIyRegVtyibPxjigPEIb1kDN2b0cwLknXH9ywErNCqL63RBpxbg7EfuHQhCKTgwcEuze9fOGxWPFNWWX9ilYasj7D96q+NwgX2iuFs3unuGpx0Qekl6fMegkuKw8MKeG29GMvWqbRGsq35VWTPR2cMJZ5VfJINCDu5NbI5FSGS0doSIRzVs/fYfUZ+TKgeZbVM9dY2PETFmVwXRPEgxeIxlP1okjsPP+bZnRmzNED5yOunOo6o9cK7E3G+gzsuVKQW8zLxP80HbNcI4fAflOAyligJpBh15A1hKNdhm63n41h6kiGecX+gorjb+q141oIq+CCsU+AcGs5yGJ1+VoWbA7FFLd8n8RNaa3NXZyiL0qsjGg6+EzfFwdKj23ChLK4r+Tqw6LPLYtsHF/KkVQbLGxwhNpDs0aZT4hJbiCpjV6N9UyuBDl9HEG/loYvHfswBLbnKoagB76qFCWuATUAeJEixFAA7niuRdEaEhiNapFOgKfHFpZ3ZcfGQn2++IsemEeEnECeS/aRTM/3p9SNNOv6c9Z7DUCVLAELrRqi/mwEg7BNh3amyt4cE7NiW6Mh1zzrFW9jl4Ny8G2AZiydjkTlmNcXJMSrU5z/bxWqdWQrKCtBTYkLCxnv/xLRHxF4F+m0ZOcJvESXnyWgzDFyNkrPOKNOQGO8rhDRQpu1ET4+SqbIvyhIiloT9U2ugGhzSJB5YW8wn9/f0rb9cFcx7r4kbhFerno0FPHUoyoQ+8RCoXcvNkdaL/3uiJYE+jiBiXDUvnCEbA5RYYHjafg==,iv:PP4PbASMABdy8mQeNpyWI9FeMjWhRNJ8nycgiaLXBKA=,tag:igUoRywjNWwreBkgdEW36w==,type:str] -MAILCHIMP_API_KEY=ENC[AES256_GCM,data:CIWa32ygnpkK6QPoq0FVw9YWaXZrd2LFbjOYDTrt2wOACfmr,iv:ooTxYS/0p3/6TMde7+/Y7w+h83CEAzcRDTIXjz5lZ/Q=,tag:s9pG1ttTwSiVtLXnatdjmw==,type:str] -MAILGUN_API_KEY=ENC[AES256_GCM,data:nPH6Km5hixRtj+btl7WHAUYGkONxHmcDbf1H/Bpt0TWgC47K,iv:aHpsZo2EAzPzjfg1/mp11ucH3qkQaRQfySM17PlxV6g=,tag:3cuYn5jLt3F13Scq+nmZNA==,type:str] -NODE_ENV=ENC[AES256_GCM,data:1isuh3tOeAJGcw==,iv:AfPbLyMVmBArq26Fs01ZmMc43HMtws2EnQXlOwsmESM=,tag:DVyzGVtVutoSPGL4ZeIBFA==,type:str] -S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:IINWnDfgucDNxdFzpZRcOduZaeQ=,iv:aIadDQwcnXocicLhMf2pEeUQJu9iqgprkL6wGYFe+Mk=,tag:6Yyg5aHJcYR4yk7I/0jALQ==,type:str] -S3_BACKUP_BUCKET=ENC[AES256_GCM,data:aq9F254VFxzc0Cw=,iv:0wltZ9vfiP8tEgf06FGxQP4y8KdZ7U3rS8csQiY4y5Y=,tag:aYs7N/azr2+boByKnEJ6iQ==,type:str] -S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:DdUQumcLYE7SZegJXxUcsdOKz37KGenLXy2fyzvbDj5DG/E=,iv:JUsYadOwVEqpGsIa6mEzvLQ72ISC1F59HnTxbW5mJIg=,tag:Xj2HVARtYEPRT+BB64F27A==,type:str] -S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:4/naQ8RAJSEU2caBe1P4nHTL7uG202WyXg6M15ZfiUXGs0d4dwzWnw==,iv:7N/t00CkNVMdMS6RhYFICxRu/jW0DtCGvvGYNPmBWCQ=,tag:LdomInGvbX54AutRFxJdEQ==,type:str] -SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:u7mXp1lTtqGIx1M6HfJKGh+/S0nRWvrtAVJe4iBUdKvaWFW8/KFbjHOE6/RQSf6LTVVpdsfl63YVAVLlH2ghgydz3sh9hfALJQTiOP+0CjvjMKYhEo440zv6sEp5kjnJm9rac7DG8bYGjmrJCwMmmsS4xgXDboGgzH73gxukHodyUEL80LdmOQe4mbvHSHePcK+xWymhfI3n2XEQpFtlccIYYrqWFTUxGchF71OsplQxZ9nBoE5XG3VHqQ==,iv:GR5L7mr4dH2q2ldnpTjjJaEA/FeaUnZZoMhCaZd1SgU=,tag:ZI6OUhh61uq3CFvU3Wt0Yg==,type:str] -SENTRY_ORG=ENC[AES256_GCM,data:X2C1,iv:8yd1SEWhJaI6cekgE5Ia/J257OXB4qh7YTgRolFDpxQ=,tag:nO2NzUHCDecfpvrKEtE0Vg==,type:str] -SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:8no=,iv:Kl9l9odW748QyrJaryvDadS1Fkd1jEB3tFQmhBRHE6k=,tag:qVJDNkOMtpIzClxnd1wzLg==,type:str] -SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:qkAAX7U3crQ70/0/MaGOpHfPUH/w9w1fidjLelKIoikNzP0L6P309TQSLCbKp7VDptW7/N+Oi7pZTDohDp2ttod6C2jPp5IOVNWE7mRsEQ==,iv:mXHfd0fBQULasBu1rlxxdbqV/j5qrHMHHE60vPuiAhM=,tag:DD5QdKWP3xqZmPq99vA5UQ==,type:str] -SMTP_HOST=ENC[AES256_GCM,data:R4KgQe6/5afRANw/Kn1d4ZzY0s0HeyVWkGdx9lPPnhbIIQ==,iv:3I9jJLlj786XEUN5toTOlC2LmMC4hSK1FBBMouShBh8=,tag:KZEHGBB9wUlOKPjLCG+ZzA==,type:str] -SMTP_PASS=ENC[AES256_GCM,data:fnsP0UPfDLcpOjXl4LGeVKGo7RajA4d7HHNyYSc1c0WgcvX6DlOK7/25qRA=,iv:nsvsFK8QS5h809xUY+1s1SzcA36HQkDPbeHM7ClUqLU=,tag:duccNhq5B5gIJvuHZMakkg==,type:str] -SMTP_USER=ENC[AES256_GCM,data:yQorJa7k/UKc9AoQPcb1ckqdlt8=,iv:e0rsCh/UcNcfeQ8VKJ+uJwoyndieXc7a6+yawXMZrls=,tag:oX3nOk2EpYOV4ulcBALPPQ==,type:str] -ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:fkual9M7pkW4txUAltrX/tD68h8=,iv:1mPi+2+uYPl8H5N0UF534eVCs0dOAamSzm4NPQCPRjs=,tag:xQWloLt+CyDNBXlZVMLfdA==,type:str] -ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:CUjzaTb0VbjLeYVuy7KPHizZO0Q=,iv:7jE6QAtqysUaZReAzHRZ+8xxGJeV91drO5WwsGR4bXs=,tag:hNaHJ3r0GZp3w5u++mnoVg==,type:str] -#ENC[AES256_GCM,data:V7juNWxQv+I0TtmWJLnwUkOjx2k=,iv:cnoWnTmHi6M60F8ow+m9lBOyXFtI3oCN7VotEwVyQoU=,tag:fxVnVAswhFRdRiWcOf+3hA==,type:comment] -KF_AUTH_URL=ENC[AES256_GCM,data:qeMJ5xv54dvFedtgr3lV7K/m/lc4,iv:/4N/v3kQb+wv4PmOm77KbpexrUL0MJwO3817cGUOYw8=,tag:QiLkz5XtE1kQhBMqo+o2Qg==,type:str] -KF_AUTH_CLIENT_ID=ENC[AES256_GCM,data:VLNknIRyka/w,iv:/xUAi1wHXKurI0S9ZTqQ+dr5GKHuEhEP1ef6IUXQfKY=,tag:mLTbofx/DA24V/o1vMlgEg==,type:str] -KF_AUTH_CLIENT_SECRET=ENC[AES256_GCM,data:SGTsRe3QYFCcT5KwszFBBsC6VrKeHzTQakFYt2imMz0wuVWRsLjEMgfOKz4ZvLal0nBsxve3+8FyfJUSpVQcgQ==,iv:F6CPMbfUKMn7i3EnKtQ2HOKNiVFyfU+ANQ3EQNG3FN4=,tag:HCgyY7QtHpKjMn8OO2f6Qw==,type:str] -KF_INTERNAL_API_KEY=ENC[AES256_GCM,data:LDgrx6R5bsUTCAGHdVZtwT6wjebGqoJlo7lr/Q/dnFk=,iv:nIh12E7DPPY15Bc4/o63HnbbxFAGptahSlZoilr+zO0=,tag:vaTXyeG5ymBuQIwumIpqyQ==,type:str] -APP_URL=ENC[AES256_GCM,data:2tvfTs4bnVCU6XmIoPGdX8jcZfiW,iv:DImM0gX/v/EZXaH4ymBLh8UvSRWyoBkjzVqE+1dx/ic=,tag:+AOWRnr0NGHOAJfTPpr5UA==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwOUZ3eGZKN3RWNVB3c3FR\nRWxxeTUyVXV0UlZlZlVSWEQvNTFtelZFUGdZCm5Zc2prcGFFWjhieTZrYVAyQUN4\nUTB3dTFyQlVSOERDMWpLcWk4R0R2V0EKLS0tIDFGbTgzQkEzMnJuRXVrdWx5MDdx\nZEE2S1pXRmZzTDRBekw1TVFFb2IvM0UKZ39NqCi5GS7bBAnpTKeRw0LOrTt59xpd\n+LE7iOT/VMI/iw1SUTv95JyPjaNLK+Ve5b+niAyhWcV3rOO/Z5tUdw==\n-----END AGE ENCRYPTED FILE-----\n +AES_ENCRYPTION_KEY=ENC[AES256_GCM,data:jSedz6UDHpTk1faKoJKFEVIIkIhX3KJc1/3l5AEPqPfuNN+5C8+PQqTYUENQQ8Vx9DqIVeK9KkUUSBgB/XEe+A==,iv:czCsVvUb1eD4eZq4k9ZcSbYKbryCLOYNLYQOc6NDpA0=,tag:+M+bdWK8tDXyV2Fd6VRsdg==,type:str] +ALTCHA_HMAC_KEY=ENC[AES256_GCM,data:3hwgS/yhoJlWU4rB9XhZHAS5g1hpx4spbVR8L/b/NkSgAlnSScEIqIiKL2nkSMdkdZoUKbUWXhNOI0GwYkWYqQ==,iv:aqfix2BNDJl9m3QzWYSbSh4qIlFiFCodEnixm+humXc=,tag:kdsSoiae/y+SCHXEsgJEKQ==,type:str] +AWS_ACCESS_KEY_ID=ENC[AES256_GCM,data:pghnhjqo8hd3qNrpWbfCRFWxoTw=,iv:vznb6J2VN/W1lUoAvD/r6Z99hkzcJdem75wWiEW6d4s=,tag:UZTpPmIMgcaThABQLWz1rQ==,type:str] +AWS_BACKUP_ACCESS_KEY_ID=ENC[AES256_GCM,data:gnCEFsqMpAIVdIlF0BAWuxCFGWs=,iv:l2BVc+myiioI/XWZCxRiRFFs3Xg4g6jx4nA+ycUpEtw=,tag:Bal4LYtDSipG+Mahi3Oe4w==,type:str] +AWS_BACKUP_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:2div+Eg4zDzDIPgLJtObkjka2JRKAsbWZPTRL0FZgfFYsAovx63Liw==,iv:dvyGJyuMgj5zQHzdmoTk1rrYKKxIo2+kQuFwbCTm5CY=,tag:WjkI3qAlFwA8MwrUFVyuww==,type:str] +AWS_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:z46Hakr2vI5gvWFpaIAK3Ql3DIoCw/lL47j7cd0giSQTh/ABxsJ/JA==,iv:kGlx7FjwdJJckKOWnBEZjoytOsgzFytpuWc2y84DxOM=,tag:FvNS10I5wpTrs3CPR6AHwg==,type:str] +BACKUPS_SECRET=ENC[AES256_GCM,data:AlfLtRkXyDqqZfu6wohDkcWtT0V6JIoKJ3z2Vz5ZMupFUBC5D+14aLZAMzg=,iv:KWoi+M42Nw92Fqetpq5ofr2eyP4L0SSOOCqbX3SwBOo=,tag:zoocybd+rcwDGiMMSQbmSg==,type:str] +CLOUDFLARE_ANALYTICS_API_TOKEN=ENC[AES256_GCM,data:yxgHI3i36FFS4L0TdzUN1bpXLIHpf2OEDCnUgD9XgDGOxf1z3OQu/3QViRmFeqRWcEtppvU=,iv:EIud/glaSfEoZf51OMOLkzBteSjDPmRoI8ivyoIFfZE=,tag:5PtJoBaPnEBtSzBhvAwgwA==,type:str] +CLOUDFLARE_CUSTOM_HOSTNAME_API_TOKEN=ENC[AES256_GCM,data:VS/NWdB8sMVehnPUZ3Udpw+aO3Jactk/3LHo04Z7l0IPq+0tVy6Nut4RQUFtqAgXyzob85o=,iv:6GEbd0TGl2eYst6IhB3dcoPaY5x+LxtYTp7LPFNgjmw=,tag:dYqoHnquH19vA7oZ8xEVqw==,type:str] +CLOUDFLARE_ZONE_TAG=ENC[AES256_GCM,data:/D8G2dWVZuLTAI0vfbL+PIDmhOvwMsuQOtimeBBK9nY=,iv:QReP2lYx4uUjQIfduUxdNq1u8ASOjvPFgtK86u+3MOc=,tag:RQWhCVs8KGdhE0yu8GD/zw==,type:str] +CONTENT_SEARCH_TERMS=ENC[AES256_GCM,data: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,iv:TGAwib9icDVyKupEv1nm9PCNNwmm0mA825ZtUl6DwLg=,tag:Sal4TWsNght8fIHf7dienw==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:RUDE3AQcaV6KAn590pOwOl7qe87k5JP9URV0Zo8ASERD/Dka5nYxSwN/Dgc=,iv:z8K+k+RmM1dviYH4yGymshUh+RW89+65pi+kbNVUJuw=,tag:VjZrmiVBSUK1UuUv4Jz/Hw==,type:str] +DATACITE_DEPOSIT_URL=ENC[AES256_GCM,data:pmB/N3baHwHUvFsz7PriWNv1BtTGHAcRnbpmncjQJS19FA==,iv:cHr48cYEFnlsRs8gDXiKVmrjGETnizZEo08GLOypHrE=,tag:NWD4Cq5ZWRPqd+qxL1ZCdg==,type:str] +DOI_LOGIN_ID=ENC[AES256_GCM,data:CIIDza5/,iv:xuxBJyAT2bRzLsQVxPBZX2OiBt8U1UO0w5+vUEYRtDI=,tag:4rWD6V3RicNKvsJGwhhtrw==,type:str] +DOI_LOGIN_PASSWORD=ENC[AES256_GCM,data:vHEy/13TldjpwEDl6nqV4sq7VSU=,iv:WsGxcJfyuwBQBqR/YjhLJ5hW58OoQptTbvGH+vt1ba0=,tag:moDv//PwLAwjxCgq1b8Ohg==,type:str] +DOI_SUBMISSION_URL=ENC[AES256_GCM,data:fgWMFf/1ZqhdLUeBd+gEOHz3P26EpPU6oTTtzhK92nx5fO9K7XJf+0U=,iv:dqUlPW/NqK19Kmg4ojbg8sasJyJWxWqlGMPNdNguU7U=,tag:/l7OBHJMcFVVfJjpKKB4lw==,type:str] +FASTLY_PURGE_TOKEN=ENC[AES256_GCM,data:KdNtZ/QZfSrkEGbF2IO6JWAiSJx6b00mp/WnciZVWLk=,iv:04tjQifpUV5MBo6NJYNdVRS5/BVyhAl/L3gtpv6jGLI=,tag:oTos0XQAAlhjs76t1guGFQ==,type:str] +FASTLY_SERVICE_ID=ENC[AES256_GCM,data:YPXtwpSnGf/DbBnnRrmEP7aNc/G/zw==,iv:vxcyPgXXLe+NZS8v9o2JN1ORH/c+fEKrRkFschVQyvM=,tag:P4MnzQ58vJWIee9uP+xpJQ==,type:str] +FIREBASE_SERVICE_ACCOUNT_BASE64=ENC[AES256_GCM,data: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,iv:OcaGHN+EuI59SIKw4OZ2CrN+HC1lFjrHFbaEN6Hi5mY=,tag:hScOgvAJGVeSWhzDcltcFg==,type:str] +IS_DUQDUQ=ENC[AES256_GCM,data:O/gsEg==,iv:YNfBOpFYY7UMdOHpDf/WV34wQcvIxZK2YgQTKTwFZ7Q=,tag:FEbGo0u7Ym22lKUZEHL4mQ==,type:str] +JWT_SIGNING_SECRET=ENC[AES256_GCM,data:1XHMU1zNhoopCNSNWb7pZF+4mR3tdGiKEr0UU1P90i7TpiGMpKOfNrNrmIkk+udvSljHbDkIB41X9f0xmHvUk2wJ7nJikGHyUb5HRahWm4FJfbleec6mWn6xPJSV5Kx7Jfnbkhy2+aI5YkhLHed7sgiJEv+Q0aU1nyGsnS8zmOhgwgGp/UjwfNY2yaEs34tEWY71pTbaBOeS9VaX+Odeho4uvvSeNLcxrVDo7h/kXiPqMd+9Jdec/l+mIN5b/yQyCen2U441CyN3UwKjxIELdbocU5mslQ68y5As1r6km5sQkUfKUPF07iGGBeeTb0hqJQSJRQLN8gw4H45Ka5weolF/+Wz1Bl2IfQrcj7vZ3uVFiXfa96VryJCFCqDMX+jhUpS5BV0NrC4eusIdMODbRHRK3Wr5x+EkhS+BRGAf8rn3mp5Jux7KYBhg+KA1Bd4LduIqVU781HK6Nhw41palxv4IGZqn9HacUzdyise3KSPUVwtUGmXGi7s3/heBXkrXEDGXx0ACMWeWc7hKWC0ENSbmXm0hMk7VBtMscvH4t44BqZ+ECZEqqK321pal+dHoYeluXfMI4XU/ev6Ynd9qm9crZN3GHDZUM7wiYXXZPagn9sDosTKPbmOtw4jQn6hQE4aHQTgDf+WD0FUD6LUebhK1lP0HXKIxpir5lhOGccQSgUL4EMPXEmD+byEp5AyemmKy1xBzN8b0jUj+dtq1QtHaHosTDO9Cp7emuPA7e4bECeuVqBfwMDY8xMCFw7oOOqd03+8rNhGB67cEzx+ktijixXqvHJbBzzeK2D1LVl5GC5vdl2S53VzOyNtj5hUp4aZiYkJCooj/i5zEo5buAei8Rzbh+ewczzx4FK70qjByS+gdyxoJqDh7NE2i5QbgyeY/Rq8PNGmAV2TD/IyXTJx57TPoEoJkZmxkjqbjjRGNgEsdyl//t+rttlScsmjQ7qVLvaxNLl4JPYpOUPOWRNMgrUryDeWg3Z/EnJKBA7JJdbs+S9XanqFthESIDWN61tw597UaUXfuYSr/Ffv/+pq+OS7I/bRJMRZZip6cJC1dGpUt2M6pL5I6R2wgiinWCKUgZB0N20ozeZsOIUOF2uBQLEsyiLAoRaZ1U/QVCJog+meDiSh7zE6E+V4bvMnfE74ICDIlCl5HvCkr/6SFKkGT2yJjRI4fIlX+dt0ZNaqVzBt20vX+clGzLhEYdbh1J8pRQq1zm/pV3uoQBpqDt5TN0Q+edUAJKYF2+LepwVAG93/WdHkwP6mJJckU8v4TviIODUrI804JtUCb0SvoYRAb2j+LK4LZq4wIivoYovrBssJVEaTGKzmiR5PtZcsWD84dG5lbI6qk5fJY3K5AaQ==,iv:3mbAXwl4QGmjHGU4aB9QUlpt9KvQKsSWYMpaXi9ysW4=,tag:JjU4qZyuHK3Q1HKNjoEjsQ==,type:str] +MAILCHIMP_API_KEY=ENC[AES256_GCM,data:gOuM/Prm9bwa3yXobLCTNsCKbZ3jO/KWk+cCvMuQuNGclrS8,iv:7EBEv3SyFa3xs0J3wIHrwbIwSd1bejq+5wOS4Ays4lY=,tag:729R4cKLmAuml/hy+x5Gjw==,type:str] +MAILGUN_API_KEY=ENC[AES256_GCM,data:GnxtESWgeGP2MjQsjFQHQH5Snq2t4/G9caCpRXaBUoOO3Eyp,iv:se/jsTlvXSV2DuKVknXVZXtO6lsySj7zvzcmzHMFFGo=,tag:e2ec7U2dT+i40vNxkYL5yw==,type:str] +NODE_ENV=ENC[AES256_GCM,data:iLFf6vCev5kiEw==,iv:05NnJOb8WPCGiPOXzkZQyodMi1omKHbNemOgjZ8ZpQE=,tag:uNj+7bCOmGO8D4Y2V3PiUg==,type:str] +S3_BACKUP_ACCESS_KEY=ENC[AES256_GCM,data:PVyNAr6WAKO4js1Ocv78iFU2jsM=,iv:mQb6jYNoJbtPqI8K9V11dfiqyBgXCtBrNlF2S0f8DzI=,tag:RUNh9eIltlh2Cpk576Js4Q==,type:str] +S3_BACKUP_BUCKET=ENC[AES256_GCM,data:4sAN2LhI2mbaH+g=,iv:2zUntfNLSgclMPr2eeJZzxbRmCUDcn5nqaXEGQ/f9XM=,tag:rcll/08aCIB7SK0Z8m7bQw==,type:str] +S3_BACKUP_ENDPOINT=ENC[AES256_GCM,data:96eTnbhuGGWxzL6ykeNsayHyQvNqtCt+UGTuGJMOFkt2/Lw=,iv:qwevMnwR+vakTJW/TpXPRPDRevZSnLEpXrdwcG1GodI=,tag:VW05PcHalxwiRTOLc3yMKg==,type:str] +S3_BACKUP_SECRET_KEY=ENC[AES256_GCM,data:qfQISkTnYWCbTVcJaIyy4hlkd+9wuxqxbfrcvbz41Lqv3yMBMaeptg==,iv:oEHr5Rb3lffBabX7cFyptQjOAtvtGWHXVWyw3/YpESI=,tag:OPWBEnr+EeWCvUE2/QTHQw==,type:str] +SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:GLarhotVHAlZbhK8cvnU+8VSt5wisz73FWCVktzil3BxhsxQ2KOzwzbPtHWa1UvTwy0rcP2LZdsnV7V2c5k7VzASlfC3Z8LS3HxQNv/78p3IIv3ywQ0owfV26s4OVvwBpMt58xH1Wx7vjnI8m/MuSYZRS7CxKuJIcL2HEnpIU6fUpLxvaAoffvOz1WG+HysCh0A3tiGDTV/bDwt9KBqOOxNY+qYmgYY8O0ccPNfWdHpusbYhuFGlJz79Zw==,iv:hg0In5HCufBdHEofe6gc6gUbrI2hd2TJfG9xlDkYgYI=,tag:/58RUX2CsLssG4Yd8GoSRQ==,type:str] +SENTRY_ORG=ENC[AES256_GCM,data:KnMS,iv:LoXVVgSgi8yxtEcAiILZoOW20DRn0YhhjTn7JfW0nnI=,tag:JUaJD3hOyGzQMrwABfVdsA==,type:str] +SEQUELIZE_MAX_CONNECTIONS=ENC[AES256_GCM,data:ppc=,iv:+aCiASdlFM3P9oSx1r3rTdusdagt0wmFN8/3hNtO680=,tag:7y5KrA+iq5GDUZ+JnrW9cg==,type:str] +SLACK_WEBHOOK_URL=ENC[AES256_GCM,data:F/ei0MQ6i9fYmFdiD4JgBxoVhB6y80zWOM36zHSrb+dYD6pHWp6eS02TzjbxtpzS5g2vhBKSl9mOD4L6q5gMRLQYQiXCIQZWqtXCEfUNSQ==,iv:N/3PyzJu4d8crIwUj/pNj851KTfV0Oyjaw8W7DnEjAo=,tag:qQt7chkB4M5aoMypmSJqig==,type:str] +SMTP_HOST=ENC[AES256_GCM,data:gVTlsn0G+AhFLFOxLxK8oMKYWe96NdlIOpC0DwVTe84ZTQ==,iv:srScSfqsWzn4dgbgKYd9z3SqxR+ymRZE//gBzX+73fw=,tag:0ZXFNbDQ0tKw1LlYspet+g==,type:str] +SMTP_PASS=ENC[AES256_GCM,data:56wzIiVMlBllselrpby/wZzD9sUQmlcRWYhOMgpbOeH/kbmfBtHJvFXbGI8=,iv:1YIdyFiy00wp58iLGoQOFO+JcTTCkuvGDgzB7nhvaCk=,tag:vREQ3leI5cBFILpCuTXSGg==,type:str] +SMTP_USER=ENC[AES256_GCM,data:VIpWGzlKNb/lcCiSjiJtd7vOMtU=,iv:JfyqNy1ipaHSt87N9uC1JfbJDpKr+PscOOpcjdZUpo0=,tag:7KcTBnoZeARwLm0I7E7cuw==,type:str] +ZOTERO_CLIENT_KEY=ENC[AES256_GCM,data:rx9rzHedBMIS4/GAjmjCXwCz3h8=,iv:XhnqcyHNRYBvx7+DZpc43k8DzM/VvztnziKW8dl2630=,tag:r+tWGKa8o18umkwuRbbV+Q==,type:str] +ZOTERO_CLIENT_SECRET=ENC[AES256_GCM,data:feog2Hy8yngxKnbfwhP4VNz4YmM=,iv:VLBtulj7qymav2/4kUiuNja4tc2kBWPbuWWEE0qfmtk=,tag:sd680z4kuNk8mUjzIzM4Ew==,type:str] +#ENC[AES256_GCM,data:9r89kx3dfaK6GdKZVg7Y0OhI4u8=,iv:QX8i7/6PB4kfnUB5lPnbZw2pUOn1nIw2vHvhIxOb1kA=,tag:74LfENAk3IvQECE0ps6x7g==,type:comment] +KF_AUTH_URL=ENC[AES256_GCM,data:UV18YeLuWs0PYNp5oACK3wRqKIJs,iv:2uazkMr4+AGIWBqhzvEitAqCQqHPwdcnd3m23L9m/C4=,tag:fQU0BPaBnKiqQVbeUIe3Bw==,type:str] +KF_AUTH_INTERNAL_URL=ENC[AES256_GCM,data:uIJnZqR+IK66B+7k3xuiio59uk24FF+5Y0FdDRvG/fE=,iv:NEAfay/7ImliuCgB7PPNh4vPRWzbMdf+41YZtip+H5Y=,tag:Nl3uCR/QjyGFnhWG2Ms5LQ==,type:str] +KF_AUTH_CLIENT_ID=ENC[AES256_GCM,data:qRkkoQHy+DX7,iv:1jhXxKMGenzoWFRejJH9T0IzDkezzCc21STDJcM8ssw=,tag:H/tCmD9m30n9eVUC2h3bNA==,type:str] +KF_AUTH_CLIENT_SECRET=ENC[AES256_GCM,data:B8bo7z4N0zbPCQ7+1TqM3OWJLXmFOkYotnzMSiDCLIGFysrLpUag20aQvqJAz76AwEbryHUNh936MbmcXCJ3sg==,iv:6YL0UjxSY6hbciHofFAIOFfubPnR3WGyYQiKwLxllP4=,tag:7JjW5uy1AnYPqXKQUJLzEA==,type:str] +KF_INTERNAL_API_KEY=ENC[AES256_GCM,data:fCvQg2WVAYFSRWKZIECDN3Sy4L7Ki7vYmlkDixuwrUA=,iv:WOyPgNVagUqxBRW6ouDd0F57CJx7e4Tyyqahl+3if7o=,tag:Ztg2c7TSk1bT+g4blHJ8uA==,type:str] +APP_URL=ENC[AES256_GCM,data:uCl5JwHh/O7v6J+erNQKkfrzpoua,iv:lWu9gDqOhEuiQDCWCr2Ku0Irsn8JMz+VpLWmtR8l7HI=,tag:Z6477YmpoC0AuEZr8ipupg==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBLbXY1ZVFLczFrK1JoaEpn\nZ0tDUXNBQWJQWTVSUjlCMmZyTXhDT2NiMEdJCjlDMFhJUVRhWjI4WVEvalRCcW5W\nUHAzYktPbEtLK2N2Z0NMc3Bwakd6WUEKLS0tIFVjNWxabTFTUEg0UFBvNWdtUEFz\nM24xNFZ3SU5RMmlOd3k1VzBXY09haG8KOa2DnFK2pCqALNx6Qmxe6mvHqVTJlnoC\nffAqsrvuyyTB3UhmjfP1F2WSJWRhxkvzFuOoNJvKfTKoIVojFlfuhQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr -sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNdmMxSWlyYUFLZm0xWjBj\ndjB5MFFROHdad3ovVEFobVFNc3d3L1VCaGlrCmw4U3I5QkFRcjVoRFpCd1F1ZFpl\nYUhTSmZtbDUwK2Ewci90VG44cFlsZ3cKLS0tIEU1NnFLdTJ5Tk1UOUJRS0tLU01O\nZE5oYWROa0pPa1hHY3I1VDJwSk5kZVEKZbKKwgkCtQZ8z/HUFjN9oRZYo21qbi4R\nRMSpWxHizxEEt3VUdJ4lWaBDqeuc+b4YhS3vYW+6Z078Vuhc735qag==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBXWGxwMi9FUzkyQU1OemVx\nYnlSL2tSdm91WFpzVHYxV0lkTitsbFV1Ylg0CnAzVzJaZytDM2E1MnNoUDdiWXZy\nM0c3NE9nK1pXTTBvNCtqOW8vU2RNMFkKLS0tIFVnTU5tMkdNa3JoRmgwaWhNQVUw\nYXBTRTlhRk5YV1ZnQ2syMHVZVnl2ek0KPy9O6dvlcWbkdnXVeR7z0pON9dLqXuPh\nSg4LyILguj83IGChxv5ijA4gsf+6FK1fB1597rwE02FWek/wFHRrEA==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_1__map_recipient=age1vhftscteyrwphx0jpp0yl60xxrjs77jq05mkzv88js7ckc926vcqepp2cj -sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA5alcvZVdKMmZWZE51bFlP\nVndoMlp2VURUd00yYVRKQ25Kci9uUkJpZnpBCm83OSt2WGRzaHpRTlhwb2xDSDRG\nWFBnM0luWnNMRTI5d0M2Z21WNUpveFEKLS0tIHNyZFZQdW1WdmVaSkFXV21oRTJE\nVEJXQWZaeHRqeE5ncUZCODZhTU9FY2MKcXs55f9lmq8thCt6XtdR4pPGBIM0nOhs\nyb/hxCd5dSnXxpwh1oV45pyFqy44GiEL0Qzccl2VsQsTbvSOgVgacQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUdmR6alcxTGExSUJSUVVE\nRUliQVFVOW5TaXNaZUgrU2Z3Y2swZ2VxM1djCnNhTVhjMFJtclpiQXFCRVZjckl6\nQURZOTZienVlTldhak5lTCtVcGQxNjQKLS0tIGZ3Rk5GZDBYcVBMbmVVQkgvd1R0\nQk5zem5COUJvTG02TDAwZTBvNFJIMFEKVSk4/xkCx2XtGVINAGKd305+C3f+7KJh\nF/k5Rg5YxrQoKVpfN8npC6CM6iW7TbIeN6q3A4wpIrym3SwRXJRA3w==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_2__map_recipient=age1slx6e48k7fre0ddyu7dtm2wwcqaywn5ke2mkngym3rpazxwvvuyq9qjknk -sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZL2hHdk9PK05iS2c3Ull4\nL0x2SXl5OG91QzlwN25DZ3NnU1JYTmJtUFRJCm9OSVJkNldPRnY2VXRBZE5UNjMz\ncFl6TVpDNFFiR1JPYW12dVc5aFpnb28KLS0tIFNkekhwL3B4RzNOV2hveXI2SHZ5\naE9BM3pLMlEweEZLa2dCV3J1eDh2VnMKp/NkvwDan14XxLtX91U60uQ7r/OuUySC\nAoRmRhOVBOwFhtC/06rdL8wf9AM7TPKV9uwMuIopNW2qFovs8x23Sw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBXOTVKRmpSNW9lYmdlS2dE\nWmxrcklMbWJrMnY5VFhKY1dSU1dLQkxvVlNRCitaWlloRGM2Q0ZEa3JleEpLaEww\nU3JQbDJYRjJZK0U2MUJmOGFWRXNjMXMKLS0tIGcvQWVaMFZDVG9XSFRERURJcVpv\nc0tpQm9IQWFoNitxejFXaFNaOExxSUUKs9CvxdSQCTgT4CqoA3A2E34PKpvSqkhT\nWl3wB7VfX7gW0yKnTZYWwT0oHDi8ihXk6wU6mgI83h1s0pZgItSZgQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_3__map_recipient=age1vfhyk6wmt993dezz5wjf6n3ynkd6xptv2dr0qdl6kmttev9lh5dsfjfs3h -sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqb0VsSExNRG9ONlZlb0Vy\nam9jdDFqamN2Q0YybTBKK0tqQVM0NU9kUWdjCjJVZk9hb1lwdHNaQ051QjVLU1Qr\nMFR4bXZCaFptMkY3ZzhEdk9hVytXVjgKLS0tIFB5UnAzWnhOcGtEMkVyd2ZxZVBS\nZlJzVDFSTXFxc2RSWk5BWk81N2MxajQKIGxV8cHYxKmZ79Fp7r4RMt8FJ93znZGo\nmBuEvkO/M2EctnjO+SQDQIbhNpx/G3++3LmrdLShImQ+fK/lDdHNfw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBXQ2RmMGJYZThRTG9DWHM0\nbnMwWUNReEVvNHZQVkY4bHpYMEJmTVlYZkNjClQrVHl2anZRK3Jxb2crT3ZMVWJz\nbzFoT1dHSzk1TE5nWjBuRk8xZ2hZdDAKLS0tIE13U1h6QzAxV0pzNU94RCtRV2Iv\nbE9xMk9NVHY1cndUcmdYK0tNcDQycHcK+52eaN10NAi0zGLjQwJCgxdolSgBH6HG\nGZQs75Vi4y/0gOcE3cL2aqPYMlCrVdgazLODEhn8qoDFjTvYSqwSFg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_4__map_recipient=age1jwuvzyghfer7rx3qtqa4vs0gxyff0sg6pqgmqvp5zlhnpmvrkdlsdha4kx -sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBnNlpDaUNGMDlycXZRUG0v\naldWSUp6WGwxU2lVUnFwcndCMit6eGJSTkc4Cm5MbE9uejdtWGVLUmgwZ2VwWlcr\nbHQyRWVldm1CVWVjZk5sckhYZ3ZrVTAKLS0tIHRDbXZOa0RXZERNZFMraVptTlpO\ndU5yeEFwelBPeDlReUYrSStWZDlEQWsKQ711aKw9Jbcq88IczNO/1esls+JRaV1S\nPIyPCTL2YTuO3SSpHZIq1OjmHhNmDN9nEQ8sw8Hg2FmHvPEfozrxZw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_5__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB1Z2pNaEhFdGw1enZwK1BL\nWXNHN1VvdlFKakJKVGVMNjdrVE9tcTFwV1hvCjgrZHNpclFaMU1JdWlvWWNXZ3JF\nKzhGcHpVTlUvcnozVEUwdEZ4cGtTYVEKLS0tIHVmMlpvbm1KRGNUbTRZd3gzaTlP\nNlVka3pFVVJST1FVNTF0WDUvL1lHaVUKu/p2zf717p95Qwpsy0DFFzFki3Tj+T3V\nKCb0qMq+uhq/1wzOXNp15oqWmYHWeEbxzOiOuAPGajd8bv7+CyMQMg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_5__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy -sops_lastmodified=2026-05-17T02:34:04Z -sops_mac=ENC[AES256_GCM,data:h/n+dajxY6dwasJsndS96YBIdlMX07zbF6gC1Ecealo2Zgn7YQDT7W9hYebJiClf338j87x9NSHbDL5m/TUxMfKgANziXViP/7EzBMpoEEMhQMXwXz/Ul6sRYfByMZI0kDyiZy8FiMX+hwc2elKZdI1cKcJST+VCnLBNZbHaBz4=,iv:OqlNfTwi/w976o43EOE/ZLvIYSodumuhfyPpVPrlYY4=,tag:qrPSlr8rB17wV+wn8GYzoQ==,type:str] +sops_lastmodified=2026-05-18T03:04:04Z +sops_mac=ENC[AES256_GCM,data:hxv3pWG3ojHyAo1vVf17zvUtT28Jzqt2aEaE8bmqeDoXe/Vhq4SgU3OejmCBUMP7mkEMDjSTpixqA0yeXsHlF9bWCWvlCTQGCGpwiQffJbu7u1llajqHjobpEHdbFtgLHA/T2zKpAKoknER/VTn6IzKdzWVkFv6Sg+LRJhH17ig=,iv:iNqN1A5VBVCBsze1ZN2ba3V01esd7MUiALs1gcPWp9E=,tag:Ov3Kz+aqOMcT8NjwnyrXrg==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.11.0 diff --git a/server/kf/auth.ts b/server/kf/auth.ts index be1730417..17f32ac5f 100644 --- a/server/kf/auth.ts +++ b/server/kf/auth.ts @@ -1,13 +1,16 @@ /** * Lightweight OIDC client for KF Auth (PubPub edition). * - * KF_AUTH_URL is used for both browser redirects and server-side calls - * (token exchange, userinfo). + * Two base URLs: + * KF_AUTH_URL — browser-facing (e.g. localhost:3000) + * KF_AUTH_INTERNAL_URL — server-to-server (e.g. host.docker.internal:3000 in Docker) + * Falls back to KF_AUTH_URL when not set (production). */ import crypto from 'node:crypto'; const KF_AUTH_URL = process.env.KF_AUTH_URL ?? 'http://localhost:3000'; +const KF_AUTH_INTERNAL_URL = process.env.KF_AUTH_INTERNAL_URL ?? KF_AUTH_URL; const KF_AUTH_CLIENT_ID = process.env.KF_AUTH_CLIENT_ID ?? 'kf_pubpub'; const KF_AUTH_CLIENT_SECRET = process.env.KF_AUTH_CLIENT_SECRET ?? ''; const APP_URL = process.env.APP_URL ?? 'http://localhost:9876'; @@ -115,7 +118,7 @@ export async function exchangeCode(code: string, codeVerifier: string): Promise< code_verifier: codeVerifier, }); - const res = await fetch(`${KF_AUTH_URL}${TOKEN_PATH}`, { + const res = await fetch(`${KF_AUTH_INTERNAL_URL}${TOKEN_PATH}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, @@ -150,7 +153,7 @@ export interface KFUserInfo { } export async function fetchUserInfo(accessToken: string): Promise { - const res = await fetch(`${KF_AUTH_URL}${USERINFO_PATH}`, { + const res = await fetch(`${KF_AUTH_INTERNAL_URL}${USERINFO_PATH}`, { headers: { Authorization: `Bearer ${accessToken}` }, }); @@ -169,7 +172,7 @@ export async function fetchUserOrgs(userId: string): Promise { const key = process.env.KF_INTERNAL_API_KEY; if (!key) return []; - const res = await fetch(`${KF_AUTH_URL}/api/internal/users/${userId}/orgs`, { + const res = await fetch(`${KF_AUTH_INTERNAL_URL}/api/internal/users/${userId}/orgs`, { headers: { Authorization: `Bearer ${key}` }, });