Skip to content

Commit eb9d4bf

Browse files
committed
Mirror api key handling
1 parent 403f3b2 commit eb9d4bf

6 files changed

Lines changed: 157 additions & 67 deletions

File tree

.env.enc

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
1-
#ENC[AES256_GCM,data:5YN6f+XIQasY,iv:70tE+1xTurmic6fcjiuDCVovEbYVhDbp2lvbONhNW5A=,tag:LkAy0bLDgDhNgwrGdqmp4Q==,type:comment]
2-
DATABASE_URL=ENC[AES256_GCM,data:/28bqrOFc9GcQH4Dv4spSwNgD71BzEOzm8rKN7nlwY9qQckHL76YOvBHNToU0sZqC9v5DT4=,iv:F4oswhOvlREkllYVooBIELUjM4WECckSwdRDxcu6E8I=,tag:V7+Hxt0Veij5+52PdyvSAQ==,type:str]
3-
SESSION_SECRET=ENC[AES256_GCM,data:MirMFVzowHq3jnaGrNPe82EKONv5XOppyhg3BI7R3h4=,iv:ehkoryGJcEL2c830LD9hZt/Uf6U3brbZlaJPNyJLiVE=,tag:oP/na2LsYuQhc4vGDc4ACw==,type:str]
4-
APP_PORT=ENC[AES256_GCM,data:o8oBKw==,iv:9lXQy+YaVb3um42QaHKKH2EXA6p9VzgX4cF4yZq17qE=,tag:QK+7vAQw4Q7qHk113pM6Qw==,type:str]
5-
API_PORT=ENC[AES256_GCM,data:QNqN5Q==,iv:3ChZ+9fWEUUFab4f8MFanSJcrRLEMO1yaCy+tCajWH8=,tag:xyA9zZepwJFt1pWvml67Eg==,type:str]
6-
#ENC[AES256_GCM,data:MrMg,iv:hdJ+cS1GtNPfpcsDoAI1TdjRW4tQTDhxhD59VFKlC3w=,tag:00P1/zjgi4mUztzvc8WXRw==,type:comment]
7-
S3_BUCKET=ENC[AES256_GCM,data:x6ZvFtFlBn4oGjSjBCbJ,iv:Fs+ehEL+6ZJH0LFz8RL2Vlr3PTSNTRhLOrqTCFMJchY=,tag:8V95MpTnaj6GbTn3YEYBsw==,type:str]
8-
S3_ENDPOINT=ENC[AES256_GCM,data:cyqC2I4kUYrdGw/9OTv88LQptYbKWzDrgc3Bbz7ag1GmzdWBPS/CAf7mIkDm/X2qWyecEg3MvNvYjCM8KKXDEfE=,iv:MKalw/HDyJ5au6Q7bia6pr7aUc3UQK+QuyuVXfctTI8=,tag:ieN8kXP02mQK/D+aqTS/Ng==,type:str]
9-
S3_ACCESS_KEY=ENC[AES256_GCM,data:L0XFIxbE/yu45PhMh5pIfc2PDun6t+hppD2mb2DQVno=,iv:fCDNYLqpW1gblgQjRSWxNtDpH3Wvk/bF4AZ+SX/qSCg=,tag:J4DA77pMJbAKUOphfyGtww==,type:str]
10-
S3_SECRET_KEY=ENC[AES256_GCM,data:T+uXZRXKFhwkkeEAzGtfNaeQbPKQrQmLgo4ekWMSR84rcMh3T8mul/Fmr6zQai1bvYRDR/RqXjVB8+grN33NtA==,iv:h0AG9TD66pZP5xGJmavBuH03nHbVz+t75ePqUP/M9O4=,tag:6iCaK84XTQ4sD9o0DjuvKg==,type:str]
11-
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBuUGFGMFFlT04wMmVSNHpm\ncUJTREVQeUsxM25hT3VOQWRTY3hMa2Q0U0VNCm9NeFMwVWRtdDVXT3l0dnRaUUNT\nTXZMZXBTWEozMCszMjQwQXp2SXYzTGMKLS0tICtWbWdZNUFEZVNXQlhlWnRkdWx0\nQjU5R0k4cVZ0akJiOTFhdnYwSVVFZHMKDbZsZkRamBqEZoKh1GqeTEETECW0FNwW\ne5jgJjK0t/uLa2GASecSGI0ONRM4n7J50jsBdtmb6lqaAgY3mDQWbA==\n-----END AGE ENCRYPTED FILE-----\n
1+
#ENC[AES256_GCM,data:OTvS9wE/1Y8D,iv:TH6FV7DEWuEFrHRUbPxWDn2wQH91kRdR7GiiIIbXZXM=,tag:mFIxqN+HaHJFBXmV9HwhmQ==,type:comment]
2+
DATABASE_URL=ENC[AES256_GCM,data:peVeoz3PtbZ/FDYyiYUYeGd12nv+CehcxL+PZPnCDTH0m2jC8hUoK7ORyX24gget6nmGYRI=,iv:dm3yToRlLYxawPyqukiAqQ5pL7SZzMUeC/UqLpc8yfU=,tag:w+O5b+8mpL0mUL1+QgEEjw==,type:str]
3+
SESSION_SECRET=ENC[AES256_GCM,data:UszhKBt+K7a9Rvm0RCb32XIPewEpavJgITzdvihqbM4=,iv:yJTgIzaZKSmYR2H8y5ZbtqGNj5WrG+MIYvLVqhQbCGI=,tag:9Vg0EbMoj7mkq3FEQ31/5g==,type:str]
4+
APP_PORT=ENC[AES256_GCM,data:Vb96ZA==,iv:y/sUKEViCx0lX6kAzrpiD8yICK+bOVA+5xF6yzdNneQ=,tag:bygtiSSg3NCc4tlULR3Mlg==,type:str]
5+
API_PORT=ENC[AES256_GCM,data:cimBEw==,iv:niHYZoy2P/an06znHc6M3P0sySyBdZRhnzanirhMbyE=,tag:7HIoxyhPNwuTyywt4cZMJA==,type:str]
6+
#ENC[AES256_GCM,data:nP8u,iv:kuXejWrfQYWksTiLT2YQ07WfsrRU6/UimFBCjgPG8iU=,tag:bdyvKXmUUrgjKKnQVcks5w==,type:comment]
7+
S3_BUCKET=ENC[AES256_GCM,data:MzahjK+0uTqAw/WhA8Se,iv:xLvpcYYV/1frYiTnwrOjHo3b8TCjgpgQ5QRLg1lhAc0=,tag:+DfZ2lWTi6S1lcJVJ+XjfQ==,type:str]
8+
S3_ENDPOINT=ENC[AES256_GCM,data:ybBuRodzg5hI5fa/iWmBkzCVimT+JHinLT3/6cZ2AIEWPWRcWeQcug4A+MDoWasYsWt9w2lRSHfxS355Pr3l4wA=,iv:9n/By1VXaIPODj/R5KqZgNEz1RUirJh+cHvvLci8344=,tag:aVx8J2V7+PMOvkqhYm79FQ==,type:str]
9+
S3_ACCESS_KEY=ENC[AES256_GCM,data:rOdXiMeBLu/3K8il8ayx9Rur3IlbrjQ4FnRAKKIMPgU=,iv:S5WiEHRCf3dBn2aVLCglnfc62qkLwyRoM+6QzoRWTTw=,tag:AaVNv6uSuKMbS2xdYpK35Q==,type:str]
10+
S3_SECRET_KEY=ENC[AES256_GCM,data:Feenm4pAlv8eglOUaU/1aPIf81WzdNzoqprB87jxzEfLJPc1BxKab7AqMaJOqQqOpQ7hNrK+I2dr01gFv6x/SA==,iv:kNTslAmOSNH3wSKtnE2qg1pIoASKUnZh6EW/kdrTxiQ=,tag:VdpCsHZ75yW0kDVaNxhMnw==,type:str]
11+
#ENC[AES256_GCM,data:z6C0FEY22W4af3lK1mx1BE3j,iv:BGNFYqhy/ySTaL9y9Bth4WqaaqUOTjDr6PigI2+zljc=,tag:C1+HDy9Vfopz3cwJn+NDFg==,type:comment]
12+
UNDERLAY_UPSTREAM_API_KEY=ENC[AES256_GCM,data:21NDl9yy4rvWiZOLBh6U5HrgzsAq5B2firmYYUxr9XCSemM=,iv:C4HYm3IF6cZEo5ZicNvCIcXo5qARHWKkZ4vwVTkn0Jk=,tag:GVB3atYFyk1p4Yr9j7u1Kg==,type:str]
13+
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsenFsVmhwbU1rc3ZJWTAw\nZTl5L0xKYmJaMVVyTEZFb2pmdm5jVlV4bUZvCldtVlg5czFrdndPZmxIYVBCb3FC\nM21SY2JYTkxoQ214dGRlNitWcGhOcHcKLS0tIDJtcmVRSEs4amlraURZVjlnS3dr\nQnBaZHVTeElrTFNINGo4aVNRSmxPYVEKsiCgHDdHEyg1dK4+liXapmPIov3Twl3p\nL3JuNxYs7D2KUdvsxYjpl0gd9LKHo8BnR4x4ltOIJZS84NQEDyyE9A==\n-----END AGE ENCRYPTED FILE-----\n
1214
sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr
13-
sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSYnhoQ3hOL1V3TWVERmFy\nQ1hFNjYrK1RiS0R0T3RSbDJqRzYrcENLUTFvCnRhSmdIMCtvbkEyWlBLbkxGaFFS\nR3RiY2FnMzVRaVpXaHRYT0dITmZIMUkKLS0tIGRpRDBXSGlVRUdlYVNMeW90NGdS\nMWxuT01hRG4zWnFoOTFkYjlPNFVIRFEKc47+5w5UkcZn/cDQhoW1EqCqJ81fSU9s\nhmP2+FMkXl2iC8DvMXxvmv54lr0yVK4DH6g9NxpVR4ai/dWdEjvl9w==\n-----END AGE ENCRYPTED FILE-----\n
15+
sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBON0xxSDVqQ1hJU2NxWTRY\na1pFcmdPWE1EQ29oZDNiMXNjMzVHYmxGaVU0CjAvNTMrTzhGSndBWGVKeHNJM0hR\nckhWN0V0ZkgxcEFmNmNoYWI1aDZ1L0kKLS0tIHIrdjBBU25VWEh6SG5kZVNNTFdQ\nRDVuYjIwTUdyRHJRQU1wVExGVFJOU00KhSPc1Jd8ujgNKbddWNl5sOi4h+1t5/zw\n3Bd9k3ZZd+saU/PfOrxh9QUbR4RXS85sRGOPH54tIYhH8QoffthtJg==\n-----END AGE ENCRYPTED FILE-----\n
1416
sops_age__list_1__map_recipient=age1ysddqggsx3h8zkv7xn3z26sjak5pqms6pyqhnky9ukrvpk7es5jsayz8w7
15-
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBKbUxwNE1PRkhMNnFVM1dH\neERReFZWL0toSUg2ekRoNUFZQ1ZFL1YvVlU0CjRPN2NlNWpPakp4UWp6NURvZm1D\nblJmOFRka0I0NEorU2tNMFh2aWYzMFEKLS0tIDZhbmFoTERXNzgzVW82b094OVAr\nQmtTdTV1K0hkZXpIbkxRck1TVjZVTm8Kl2hj5e7mv0wRWLZaYCoqW/CZwncB9+vO\niUcostwDMBHR7Q9Iln+OcZnmhIQtyawZ5OU/ARynG9ps/yF++J+RGg==\n-----END AGE ENCRYPTED FILE-----\n
17+
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBuNitEVzFDNVBjZ054TWts\nNEpNQXpaS3BvWjRPY2ZTNG1BSVNwMmo5OWc4CjhQWTBtYkVQa3R6c3hsenNJTTRS\nYUgzbmtxaEtDODVqYWxaL1ZTVDNBK3MKLS0tIDR6VjNqNklRb2grN2hBdG9BbENn\nYUhFMXlkb3RsRjFmMWRXOCttN0h6WHcKqwZGyeJiTbMWS5V56EBidSOOba/eGGMM\nD1LUwZmdUKivD3gOfK1STO6M7mEvJMUdzUebJR7IyC6Ibt0vyV5Afg==\n-----END AGE ENCRYPTED FILE-----\n
1618
sops_age__list_2__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy
17-
sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBETDZhR3E3NUNPR3ZOTzBE\nY1kxRlRWY3dEeDM1TmJOOW45Ump1TWVUaUFnCkkrNEErYmRINEQ3c2UvQjFTcnJv\nOHJZbk1oUGpRWm5hM2wweGc4V1lSU2sKLS0tIEJCWTVRMzhhM0ZpYWl6aVliN1dk\nMDYzN01GUGtBRWI5NFRQdWJncUl2SGMKH2tGAaN0aTAz575Q39S4uorUjD/BO8Oa\n/aHc/ytU9Jpj2tdMkPx/dD39Tv0ZO/z+rPZijv3Oirzk/FmDeV8/2g==\n-----END AGE ENCRYPTED FILE-----\n
19+
sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUK3ZWeVRHS0VscmdpYXJt\naG5WaG13Si9JdnZaS1IxQWdvWWY3QzJSVHhnCmxpdW9WQmRBWHVPNFljeVg2c0Ry\nVCtoRkxsdXM5RGFxUUloYXhFU1g2cEUKLS0tIExCOHlXUzRKYndSYVJxVmZzcUhp\nRWJab3ZWRVlNME9sckIycUxwQ0JvRVUKr0F/Zd4TY448DpFc2+oqHybprN43ydKS\n3WmdCCHYY8GhmxgIlTSN7LCaJ2eTtOQaEX12Fhj5+JY5vmisW/cygg==\n-----END AGE ENCRYPTED FILE-----\n
1820
sops_age__list_3__map_recipient=age1qn0x93jhqjpqwvx5tgxnrwq5e3vuzur9whrkdnrvapd58esm45rqfkuxqh
19-
sops_lastmodified=2026-05-01T02:33:52Z
20-
sops_mac=ENC[AES256_GCM,data:5fsji5DmNyZFgmyV23bBj+OxmlFo0aBIH4w5Vz7gFVnYOv3p+TuResFll2aXzFPWLt02/spwhXeqwkDx/korefkL0zzKIuASRtS05UtAln098hL96zdWblP5TVzSORA2sDHvyz488EpV3BPywLvDPHRrKQu9TmEzlEBlmsWvHxo=,iv:RXQuKcZ4ybhhXPbaZUIDvGzwjZxjLTXarj7Zm84fepQ=,tag:/fPOM7H5B8bH7oGdfg/Sag==,type:str]
21+
sops_lastmodified=2026-05-02T02:32:16Z
22+
sops_mac=ENC[AES256_GCM,data:setdDRn3+KnoAJf43iTmzKRK4AG9Mgwp/jFY09FQQshVDUAB5STtH63nKB1GAOGHoDqUK53w+Fb1plFCMuHwn1/AWGisQ3pdkg9I13OhqhRAdsvsW9hI9Bx5C953K8t6GQdpvdO/kh3qDfh0RDpTQ9scv13Mmf8n7BqBj/gW9Fg=,iv:O+94d6Sy+d9pRiUp0f4njS6Eb8CouWHDqACppkAU2Gw=,tag:IWLf41HYQqMps9bjxR2wJg==,type:str]
2123
sops_unencrypted_suffix=_unencrypted
2224
sops_version=3.11.0

.github/workflows/deploy-mirror.yml

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,9 @@ jobs:
8787
GHCR_USER: ${{ secrets.GHCR_USER }}
8888
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
8989
IMAGE_TAG: mirror-${{ github.sha }}
90-
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }}
91-
S3_REGION: ${{ secrets.S3_REGION }}
92-
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
93-
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
94-
S3_BUCKET: ${{ secrets.S3_BUCKET }}
9590
run: |
9691
ssh "${SSH_USER}@${SSH_HOST}" \
97-
"env GHCR_USER='${GHCR_USER}' GHCR_TOKEN='${GHCR_TOKEN}' IMAGE_TAG='${IMAGE_TAG}' S3_ENDPOINT='${S3_ENDPOINT}' S3_REGION='${S3_REGION}' S3_ACCESS_KEY='${S3_ACCESS_KEY}' S3_SECRET_KEY='${S3_SECRET_KEY}' S3_BUCKET='${S3_BUCKET}' bash -s -- '${REPO}' '${BRANCH}'" <<'EOS'
92+
"env GHCR_USER='${GHCR_USER}' GHCR_TOKEN='${GHCR_TOKEN}' IMAGE_TAG='${IMAGE_TAG}' bash -s -- '${REPO}' '${BRANCH}'" <<'EOS'
9893
set -euo pipefail
9994
10095
REPO="${1:?missing repo}"
@@ -107,6 +102,7 @@ jobs:
107102
STACK_NAME="underlay-mirror"
108103
REPO_NAME="${REPO##*/}"
109104
APP_DIR="/srv/${REPO_NAME}-mirror"
105+
PROD_DIR="/srv/${REPO_NAME}"
110106
REPO_SSH="git@github.com:${REPO}.git"
111107
112108
ssh-keyscan -H github.com >> ~/.ssh/known_hosts 2>/dev/null
@@ -123,6 +119,17 @@ jobs:
123119
git checkout "${BRANCH}"
124120
git pull origin "${BRANCH}"
125121
122+
# Load secrets from prod's .env (already decrypted on the server)
123+
# This gives us S3_*, UNDERLAY_UPSTREAM_API_KEY, etc.
124+
if [[ -f "${PROD_DIR}/.env" ]]; then
125+
set -a
126+
source <(grep -v '^#' "${PROD_DIR}/.env" | grep -v '^$')
127+
set +a
128+
else
129+
echo "ERROR: ${PROD_DIR}/.env not found — run a prod deploy first"
130+
exit 1
131+
fi
132+
126133
# Init swarm if not already active
127134
if ! sudo docker info --format '{{.Swarm.LocalNodeState}}' | grep -qx active; then
128135
sudo docker swarm init
@@ -132,13 +139,14 @@ jobs:
132139
133140
sudo docker pull "ghcr.io/${REPO}:${IMAGE_TAG}"
134141
135-
# Deploy using docker-compose.mirror.yml — Postgres is self-contained,
136-
# only S3 credentials need to be passed through for file storage.
142+
# Deploy using docker-compose.mirror.yml — Postgres is self-contained.
143+
# S3 creds and API key are sourced from prod .env above.
137144
sudo env \
138145
IMAGE="ghcr.io/${REPO}" IMAGE_TAG="$IMAGE_TAG" \
139-
S3_ENDPOINT="$S3_ENDPOINT" S3_REGION="$S3_REGION" \
140-
S3_ACCESS_KEY="$S3_ACCESS_KEY" S3_SECRET_KEY="$S3_SECRET_KEY" \
146+
S3_ENDPOINT="${S3_ENDPOINT:-}" S3_REGION="${S3_REGION:-auto}" \
147+
S3_ACCESS_KEY="${S3_ACCESS_KEY:-}" S3_SECRET_KEY="${S3_SECRET_KEY:-}" \
141148
S3_BUCKET="${S3_BUCKET:-underlay}" \
149+
UNDERLAY_UPSTREAM_API_KEY="${UNDERLAY_UPSTREAM_API_KEY:-}" \
142150
SESSION_SECRET="$(openssl rand -hex 32)" \
143151
docker stack deploy -c docker-compose.mirror.yml \
144152
--with-registry-auth --resolve-image always --prune "${STACK_NAME}"

docker-compose.mirror.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ services:
2121
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-}
2222
S3_SECRET_KEY: ${S3_SECRET_KEY:-}
2323
S3_BUCKET: ${S3_BUCKET:-underlay}
24+
# Upstream API key (for authenticated sync — higher rate limit)
25+
UNDERLAY_UPSTREAM_API_KEY: ${UNDERLAY_UPSTREAM_API_KEY:-}
2426
# Session (for admin login)
2527
SESSION_SECRET: ${SESSION_SECRET:-mirror-secret-change-me}
2628
ports:
@@ -73,6 +75,7 @@ services:
7375
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-}
7476
S3_SECRET_KEY: ${S3_SECRET_KEY:-}
7577
S3_BUCKET: ${S3_BUCKET:-underlay}
78+
UNDERLAY_UPSTREAM_API_KEY: ${UNDERLAY_UPSTREAM_API_KEY:-}
7679
command: ["node", "--import", "tsx/esm", "tools/cron.ts"]
7780
networks:
7881
- mirrornet

src/components/MirrorAdmin.tsx

Lines changed: 63 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,68 @@ interface SyncResult {
3636
errors: string[];
3737
}
3838

39+
const PAGE_SIZE = 10;
40+
41+
function PaginatedCollections({ collections }: { collections: MirrorStatus["collections"] }) {
42+
const [page, setPage] = useState(0);
43+
const totalPages = Math.ceil(collections.length / PAGE_SIZE);
44+
const visible = collections.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
45+
46+
return (
47+
<div>
48+
<table className="w-full text-sm">
49+
<thead>
50+
<tr className="border-b border-rule text-left text-ink-muted">
51+
<th className="pb-2 font-medium">Collection</th>
52+
<th className="pb-2 font-medium">Version</th>
53+
<th className="pb-2 font-medium">Last Updated</th>
54+
</tr>
55+
</thead>
56+
<tbody>
57+
{visible.map((c) => (
58+
<tr key={`${c.ownerSlug}/${c.slug}`} className="border-b border-rule/50">
59+
<td className="py-2">
60+
<a href={`/${c.ownerSlug}/${c.slug}`} className="text-ink hover:underline">
61+
{c.ownerSlug}/{c.slug}
62+
</a>
63+
<span className="text-ink-muted ml-2">{c.name}</span>
64+
</td>
65+
<td className="py-2 font-mono">v{c.localVersion}</td>
66+
<td className="py-2 text-ink-muted">
67+
{new Date(c.updatedAt).toLocaleDateString()}
68+
</td>
69+
</tr>
70+
))}
71+
</tbody>
72+
</table>
73+
{totalPages > 1 && (
74+
<div className="flex items-center justify-between mt-3 text-xs text-ink-muted">
75+
<span>{collections.length} collections</span>
76+
<div className="flex items-center gap-2">
77+
<button
78+
onClick={() => setPage((p) => Math.max(0, p - 1))}
79+
disabled={page === 0}
80+
className="px-2 py-1 border border-rule rounded disabled:opacity-30 hover:bg-parchment-dark"
81+
>
82+
← Prev
83+
</button>
84+
<span>
85+
{page + 1} / {totalPages}
86+
</span>
87+
<button
88+
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
89+
disabled={page >= totalPages - 1}
90+
className="px-2 py-1 border border-rule rounded disabled:opacity-30 hover:bg-parchment-dark"
91+
>
92+
Next →
93+
</button>
94+
</div>
95+
</div>
96+
)}
97+
</div>
98+
);
99+
}
100+
39101
export default function MirrorAdmin({ upstream, nodeName, syncSchedule }: Props) {
40102
const [status, setStatus] = useState<MirrorStatus | null>(null);
41103
const [testResult, setTestResult] = useState<TestResult | null>(null);
@@ -206,34 +268,7 @@ export default function MirrorAdmin({ upstream, nodeName, syncSchedule }: Props)
206268
Mirrored Collections
207269
</h2>
208270
{status && status.collections.length > 0 ? (
209-
<table className="w-full text-sm">
210-
<thead>
211-
<tr className="border-b border-rule text-left text-ink-muted">
212-
<th className="pb-2 font-medium">Collection</th>
213-
<th className="pb-2 font-medium">Version</th>
214-
<th className="pb-2 font-medium">Last Updated</th>
215-
</tr>
216-
</thead>
217-
<tbody>
218-
{status.collections.map((c) => (
219-
<tr key={`${c.ownerSlug}/${c.slug}`} className="border-b border-rule/50">
220-
<td className="py-2">
221-
<a
222-
href={`/${c.ownerSlug}/${c.slug}`}
223-
className="text-ink hover:underline"
224-
>
225-
{c.ownerSlug}/{c.slug}
226-
</a>
227-
<span className="text-ink-muted ml-2">{c.name}</span>
228-
</td>
229-
<td className="py-2 font-mono">v{c.localVersion}</td>
230-
<td className="py-2 text-ink-muted">
231-
{new Date(c.updatedAt).toLocaleDateString()}
232-
</td>
233-
</tr>
234-
))}
235-
</tbody>
236-
</table>
271+
<PaginatedCollections collections={status.collections} />
237272
) : status ? (
238273
<p className="text-sm text-ink-muted">
239274
No collections mirrored yet. Click "Sync Now" to pull from upstream.

src/lib/mirror-config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface MirrorConfig {
1010
upstream: string;
1111
nodeName: string;
1212
syncSchedule: string;
13+
apiKey: string;
1314
}
1415

1516
export function getMirrorConfig(): MirrorConfig {
@@ -19,5 +20,6 @@ export function getMirrorConfig(): MirrorConfig {
1920
upstream: process.env.UNDERLAY_UPSTREAM ?? "",
2021
nodeName: process.env.UNDERLAY_NODE_NAME ?? "Mirror",
2122
syncSchedule: process.env.UNDERLAY_SYNC_SCHEDULE ?? "0 0 * * 0",
23+
apiKey: process.env.UNDERLAY_UPSTREAM_API_KEY ?? "",
2224
};
2325
}

src/lib/mirror-sync.ts

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -66,16 +66,38 @@ async function ensureSchema(schemaBody: unknown): Promise<string> {
6666
return created!.id;
6767
}
6868

69-
/** Fetch JSON from the upstream server */
69+
/** Sleep for a given number of milliseconds */
70+
function sleep(ms: number): Promise<void> {
71+
return new Promise((resolve) => setTimeout(resolve, ms));
72+
}
73+
74+
/** Fetch JSON from the upstream server with retry on 429 */
7075
async function fetchUpstream<T>(upstream: string, path: string): Promise<T> {
76+
const config = getMirrorConfig();
7177
const url = `${upstream.replace(/\/$/, "")}${path}`;
72-
const res = await fetch(url, {
73-
headers: { Accept: "application/json" },
74-
});
75-
if (!res.ok) {
76-
throw new Error(`Upstream ${url} returned ${res.status}: ${await res.text()}`);
78+
const headers: Record<string, string> = { Accept: "application/json" };
79+
if (config.apiKey) {
80+
headers["Authorization"] = `Bearer ${config.apiKey}`;
81+
}
82+
83+
for (let attempt = 0; attempt < 5; attempt++) {
84+
const res = await fetch(url, { headers });
85+
86+
if (res.status === 429) {
87+
const retryAfter = parseInt(res.headers.get("retry-after") ?? "60", 10);
88+
const waitSec = Math.min(retryAfter + 2, 120);
89+
console.log(`[mirror-sync] Rate limited, waiting ${waitSec}s before retry (attempt ${attempt + 1}/5)`);
90+
await sleep(waitSec * 1000);
91+
continue;
92+
}
93+
94+
if (!res.ok) {
95+
throw new Error(`Upstream ${url} returned ${res.status}: ${await res.text()}`);
96+
}
97+
return res.json() as Promise<T>;
7798
}
78-
return res.json() as Promise<T>;
99+
100+
throw new Error(`Upstream ${url} rate limited after 5 retries`);
79101
}
80102

81103
/** Download a file from upstream by hash */
@@ -85,14 +107,32 @@ async function downloadUpstreamFile(
85107
collSlug: string,
86108
fileHash: string,
87109
): Promise<Buffer> {
110+
const config = getMirrorConfig();
88111
const url = `${upstream.replace(/\/$/, "")}/api/collections/${owner}/${collSlug}/files/${fileHash}`;
89-
const res = await fetch(url);
90-
if (!res.ok) {
91-
throw new Error(`File download failed: ${url}${res.status}`);
112+
const headers: Record<string, string> = {};
113+
if (config.apiKey) {
114+
headers["Authorization"] = `Bearer ${config.apiKey}`;
92115
}
93-
// Follow redirect to CDN or get bytes directly
94-
const arrayBuffer = await res.arrayBuffer();
95-
return Buffer.from(arrayBuffer);
116+
117+
for (let attempt = 0; attempt < 5; attempt++) {
118+
const res = await fetch(url, { headers });
119+
120+
if (res.status === 429) {
121+
const retryAfter = parseInt(res.headers.get("retry-after") ?? "60", 10);
122+
const waitSec = Math.min(retryAfter + 2, 120);
123+
console.log(`[mirror-sync] Rate limited on file download, waiting ${waitSec}s`);
124+
await sleep(waitSec * 1000);
125+
continue;
126+
}
127+
128+
if (!res.ok) {
129+
throw new Error(`File download failed: ${url}${res.status}`);
130+
}
131+
const arrayBuffer = await res.arrayBuffer();
132+
return Buffer.from(arrayBuffer);
133+
}
134+
135+
throw new Error(`File download rate limited after 5 retries: ${url}`);
96136
}
97137

98138
interface UpstreamCollection {

0 commit comments

Comments
 (0)