diff --git a/.gitignore b/.gitignore index 48735d7ba..717ce9e8c 100755 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ yarn-error.log* .env.* !.env.enc !.env.dev.enc +!.env.local.enc !infra/.env.test @@ -71,3 +72,5 @@ tsconfig.tsbuildinfo infra/pgdata/ tmp/ + +planning/ \ No newline at end of file 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/infra/.env.dev.enc b/infra/.env.dev.enc index 6db5bf1c2..56a947e43 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: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: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,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+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+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+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+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+IFgyNTUxOSA4NWRLUkN6dGk2UFhmMU9h\nTXJuZ3M5U2luV1lJcy9mWktaZGVlMkRMVERzCkRaeG9HbXpPOGYvWUhJTDl2Smc2\nWW51WmV6bFFiNVF3WTBUTVZ1azQ3ZFEKLS0tIFQvaDd0RFZOV3FJVi95MDYrMzBV\nNUk0aWxBRkNDVkduYVhXTGFabWVEdmcKGTZOOe4yUfGjZS3uIe6NZSEjj/u2bpkb\nd4Bd259G2uVrG5RkK7AptKM1QHVsolAwF+MsdlopAux1sQJe75ZpKA==\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+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+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+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+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-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-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 diff --git a/infra/.env.local.enc b/infra/.env.local.enc new file mode 100644 index 000000000..61d94c74a --- /dev/null +++ b/infra/.env.local.enc @@ -0,0 +1,60 @@ +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+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+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+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+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+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-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/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/community/model.ts b/server/community/model.ts index ac2d30609..83e562e97 100644 --- a/server/community/model.ts +++ b/server/community/model.ts @@ -18,6 +18,7 @@ import { DefaultScope, ForeignKey, HasMany, + Index, Is, IsLowercase, Length, @@ -241,6 +242,11 @@ export class Community extends Model< @Column(DataType.UUID) declare templateId: string | null; + /** KF Auth organization that owns this community (for billing/ownership) */ + @Index + @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..2359288df --- /dev/null +++ b/server/kf/api.ts @@ -0,0 +1,1187 @@ +/** + * KF Auth integration routes for PubPub. + * + * OIDC login/callback: +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 + * + * 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 { 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'; +import { sequelize } from 'server/sequelize'; +import { getHashedUserId } from 'utils/caching/getHashedUserId'; +import { ensureUserIsCommunityAdmin } from 'utils/ensureUserIsCommunityAdmin'; +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 ────────────────────────────────────────────────────────── + +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. + * Note: PubPub's hostname middleware rewrites duqduq.org → pubpub.org + * for community resolution, so we reverse that here. + */ +function getCommunityHost(req: any): string { + const host: string = req.headers.communityhostname || req.hostname; + if (isDuqDuq() && host.includes('pubpub.org')) { + return host.replace('pubpub.org', 'duqduq.org'); + } + return host; +} + +/** + * 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 ─────────────────────────────────────────────────────────── + +export const router = Router(); + +// ─── OIDC login ────────────────────────────────────────────────────── + +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 + : '/'; + + // 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 }); + + // 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); +}); + +// ─── 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.status(400).send('Authentication failed. Please try again.'); + } + + if (!code || !state) { + return res.status(400).send('Missing authentication parameters.'); + } + + // 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.'); + } + + 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); + + // Fetch user info from KF Auth + const userInfo = await fetchUserInfo(tokens.access_token); + const kfUserId = userInfo.sub; + + // Look up PubPub user by ID, or auto-create on first login + let user = await User.findOne({ where: { id: kfUserId } }); + + if (!user) { + 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; + + // 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, + slug, + firstName, + lastName, + fullName, + initials, + email, + avatar: userInfo.picture || null, + hash: '', + salt: '', + } as any); + console.log(`Auto-created PubPub user ${user.id} (${user.slug}) from KF Auth`); + } + + 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); + + const hashedUserId = getHashedUserId(user); + res.cookie('pp-lic', `pp-li-${hashedUserId}`, { + ...(isProd() && { domain: '.pubpub.org' }), + ...(isDuqDuq() && { domain: '.duqduq.org' }), + maxAge: 30 * 24 * 60 * 60 * 1000, + }); + + if (host && host !== req.hostname) { + return res.redirect(`${protocol}://${host}${returnTo}`); + } + return res.redirect(returnTo); + } catch (err: any) { + console.error('OIDC callback error:', err); + const detail = isDuqDuq() ? ` (${err?.message || err})` : ''; + return res.status(500).send(`Login failed. Please try again.${detail}`); + } +}); + +// ─── 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.'); + } +}); + +// ─── 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' }); + } +}); + +// ─── 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) => { + try { + const { subdomain, context } = req.query; + let identifier = context || subdomain; + + if (!identifier) { + return res.status(400).json({ error: 'subdomain or context param required' }); + } + + // 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', + 'headerLogo', + 'accentColorLight', + 'accentColorDark', + 'subdomain', + ], + }); + + // 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' }); + } + + return res.json({ + // 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) { + 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 + : 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" >= :pubsCutoff + GROUP BY 1 ORDER BY 1`, + { + replacements: { + communityId, + pubsCutoff: new Date( + Date.now() - pubsMonthsBack * 30 * 24 * 60 * 60 * 1000, + ).toISOString(), + }, + 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< + 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}`; + 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 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[]; + + 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, + createdAt: c.createdAt, + pubCount: c.pubCount ?? 0, + 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 — 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 }; + 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."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", + 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" + 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, + 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' }); + } +}); + +// ─── 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..17f32ac5f --- /dev/null +++ b/server/kf/auth.ts @@ -0,0 +1,184 @@ +/** + * Lightweight OIDC client for KF Auth (PubPub edition). + * + * 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'; +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'; +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` 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, + existingVerifier?: string, + context?: string, +): { + url: string; + codeVerifier: string; +} { + const codeVerifier = existingVerifier ?? 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', + ...(context && { context }), + }); + 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..cc173f962 --- /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 { APP_URL, KF_AUTH_CLIENT_ID, KF_AUTH_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