[grade=B] DC-131/132/133 install from any Git host
CI / Test & Lint (push) Waiting to run
CI / Security audit (push) Waiting to run

Codex source gate: urn:ump:iw2tbbe6mssyl5divymmwo42ael65sbfrciztvyowo3zhrbtircq

Generated assets gate: urn:ump:hintflviuxfpeidth42ry5fi4lwqsjhkxipzspfmk7vrvfc2cnqq
This commit is contained in:
DashCaddy Polish Loop
2026-09-14 20:10:14 -07:00
parent 7557b49b5b
commit f9cbb13a3d
7 changed files with 1119 additions and 280 deletions
@@ -0,0 +1,325 @@
/**
* DC-131/133 additions to the deploys routes tests: install-from-any-host.
*
* Covers the judge round-1 blocking issues:
* - POST /install accepts any https host/owner/repo (not just github.com)
* and forwards the optional per-request token to the bridge.
* - POST /gitea-repos carries {gitea_url, token} in the JSON body.
* - Token validation rejects non-string / oversized tokens before the
* proxy call.
*/
const FIXTURE_INSTALL = {
ok: true,
service: {
id: 'demo-hi', name: 'demo-hi', repo_url: 'https://git.example/owner/demo-hi',
subdomain: 'demo-hi', url: 'https://demo-hi.sami', logo: '',
mode: 'go-build', port: 8951, dir: '/root/repos/demo-hi',
installed_at: '2026-09-14T18:00:00Z', deploy_seconds: 28.0,
},
output: '== build ==\n== deploy ==',
};
const FIXTURE_GITEA_LIST = {
ok: true,
repos: [{ id: 'demo-hi', name: 'demo-hi', full_name: 'owner/demo-hi', url: 'https://git.example/owner/demo-hi', description: 'demo' }],
};
// ---- self-contained harness (same pattern as deploys.routes.test.js) ----
const express = require('express');
function buildApp(fetchT, env = {}) {
process.env.SHIPDECK_BRIDGE_URL = env.url !== undefined ? env.url : 'http://172.17.0.1:8977';
process.env.SHIPDECK_BRIDGE_TOKEN_FILE = env.tokenFile !== undefined ? env.tokenFile : '';
jest.resetModules();
const mod = require('../../routes/deploys');
const router = mod({
asyncHandler: (fn) => async (req, res, next) => {
try { await fn(req, res, next); } catch (e) { next(e); }
},
log: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
auditLogger: { log: jest.fn(async () => {}) },
fetchT,
});
const app = express();
app.use(express.json());
app.use('/api/v1/deploys', router);
app.use((err, req, res, next) => {
res.status(500).json({ success: false, error: err.message });
});
return app;
}
function jsonFetcher(responses) {
const calls = [];
const fetchT = jest.fn(async (url, opts) => {
const key = `${(opts && opts.method) || 'GET'} ${url.replace(/^https?:\/\/[^/]+/, '')}`;
calls.push({ key, opts });
const r = responses[key] || { status: 404, body: { ok: false, error: 'no fixture' } };
return { status: r.status, json: async () => r.body };
});
return { calls, fetchT };
}
describe('routes/deploys — DC-131/133 install from any host', () => {
test('POST /install accepts any https host URL and forwards token to the bridge', async () => {
const f = jsonFetcher({ 'POST /api/install': { status: 200, body: FIXTURE_INSTALL } });
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/install`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
repo_url: 'https://git.example/owner/demo-hi',
service: 'demo-hi',
token: 'per-request-secret',
}),
});
const body = await r.json();
server.close();
expect(r.status).toBe(200);
expect(body.success).toBe(true);
expect(body.service.id).toBe('demo-hi');
// bridge call carries the forwarded token
const sent = JSON.parse(f.calls[0].opts.body);
expect(f.calls[0].key).toBe('POST /api/install');
expect(sent.token).toBe('per-request-secret');
expect(sent.repo_url).toBe('https://git.example/owner/demo-hi');
});
test('POST /install accepts host:port URLs and .git suffixes', async () => {
const f = jsonFetcher({ 'POST /api/install': { status: 200, body: FIXTURE_INSTALL } });
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/install`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo_url: 'https://git.example:3443/owner/demo.hi.git', service: 'demo-hi' }),
});
server.close();
expect(r.status).toBe(200);
expect(JSON.parse(f.calls[0].opts.body).repo_url).toBe('https://git.example:3443/owner/demo.hi.git');
});
test('POST /install rejects non-URL garbage with 400 and never calls the bridge', async () => {
const f = jsonFetcher({});
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/install`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo_url: 'not a url', service: 'demo-hi' }),
});
server.close();
expect(r.status).toBe(400);
expect(f.calls).toHaveLength(0);
});
test('POST /install rejects non-string and oversized tokens (400, no proxy call)', async () => {
const f = jsonFetcher({});
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r1 = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/install`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo_url: 'https://git.example/owner/repo', service: 'demo-hi', token: 12345 }),
});
const r2 = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/install`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo_url: 'https://git.example/owner/repo', service: 'demo-hi', token: 'x'.repeat(513) }),
});
server.close();
expect(r1.status).toBe(400);
expect(r2.status).toBe(400);
expect(f.calls).toHaveLength(0);
});
test('POST /gitea-repos proxies {gitea_url, token} in the body', async () => {
const f = jsonFetcher({ 'POST /api/gitea/repos': { status: 200, body: FIXTURE_GITEA_LIST } });
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/gitea-repos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gitea_url: 'https://git.example', token: 'per-request-secret' }),
});
const body = await r.json();
server.close();
expect(r.status).toBe(200);
expect(body.success).toBe(true);
expect(body.repos[0].full_name).toBe('owner/demo-hi');
const sent = JSON.parse(f.calls[0].opts.body);
expect(f.calls[0].key).toBe('POST /api/gitea/repos');
expect(sent.gitea_url).toBe('https://git.example');
expect(sent.token).toBe('per-request-secret');
});
test('POST /gitea-repos works with no host/token (fleet defaults)', async () => {
const f = jsonFetcher({ 'POST /api/gitea/repos': { status: 200, body: FIXTURE_GITEA_LIST } });
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/gitea-repos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
server.close();
expect(r.status).toBe(200);
const sent = JSON.parse(f.calls[0].opts.body);
expect(sent).toEqual({});
});
test('install success persists no token in the service metadata returned to the panel', async () => {
const f = jsonFetcher({ 'POST /api/install': { status: 200, body: FIXTURE_INSTALL } });
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/install`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo_url: 'https://git.example/owner/demo-hi', service: 'demo-hi', token: 'per-request-secret' }),
});
const body = await r.json();
server.close();
expect(JSON.stringify(body)).not.toContain('per-request-secret');
});
test('POST /install accepts tokens of 201-512 chars (bridge contract matches proxy)', async () => {
// Round-2 judge: proxy allowed <=512 but the bridge capped at 200, so
// values accepted by the panel could fail downstream. The bridge now
// matches: <=512 is forwarded and must pass proxy validation.
const f = jsonFetcher({ 'POST /api/install': { status: 200, body: FIXTURE_INSTALL } });
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
for (const len of [201, 300, 512]) {
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/install`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo_url: 'https://git.example/owner/repo', service: 'demo-hi', token: 'a'.repeat(len) }),
});
expect(r.status).toBe(200);
}
server.close();
expect(f.calls).toHaveLength(3);
});
test('POST /gitea-repos rejects tokens over 512 chars (400, no proxy call)', async () => {
const f = jsonFetcher({});
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/gitea-repos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gitea_url: 'https://git.example', token: 'x'.repeat(513) }),
});
server.close();
expect(r.status).toBe(400);
expect(f.calls).toHaveLength(0);
});
test('POST /gitea-repos rejects non-string tokens (400, no proxy call)', async () => {
const f = jsonFetcher({});
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
for (const bad of [12345, {}, ['x']]) {
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/gitea-repos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gitea_url: 'https://git.example', token: bad }),
});
expect(r.status).toBe(400);
}
server.close();
expect(f.calls).toHaveLength(0);
});
test('empty-string token means explicitly anonymous: preserved on wire', async () => {
const f = jsonFetcher({ 'POST /api/gitea/repos': { status: 200, body: FIXTURE_GITEA_LIST } });
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/gitea-repos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gitea_url: 'https://git.example', token: '' }),
});
server.close();
expect(r.status).toBe(200);
const sent = JSON.parse(f.calls[0].opts.body);
expect(sent.token).toBe('');
// same explicit-anonymous wire representation on /install
const f2 = jsonFetcher({ 'POST /api/install': { status: 200, body: FIXTURE_INSTALL } });
const app2 = buildApp(f2.fetchT);
const server2 = app2.listen(0);
const port2 = server2.address().port;
const r2 = await fetch(`http://127.0.0.1:${port2}/api/v1/deploys/install`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo_url: 'https://git.example/owner/repo', service: 'demo-hi', token: '' }),
});
server2.close();
expect(r2.status).toBe(200);
const sent2 = JSON.parse(f2.calls[0].opts.body);
expect(sent2.token).toBe('');
});
test('POST /install rejects non-string tokens (400, no proxy call)', async () => {
const f = jsonFetcher({});
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/install`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo_url: 'https://git.example/owner/repo', service: 'demo-hi', token: { evil: true } }),
});
server.close();
expect(r.status).toBe(400);
expect(f.calls).toHaveLength(0);
});
test('POST /install forwards valid env unchanged', async () => {
const f = jsonFetcher({ 'POST /api/install': { status: 200, body: FIXTURE_INSTALL } });
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const env = { GREETING: 'hello world', PORT_HINT: '8950' };
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/install`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo_url: 'https://git.example/owner/repo', service: 'demo-hi', env }),
});
server.close();
expect(r.status).toBe(200);
expect(JSON.parse(f.calls[0].opts.body).env).toEqual(env);
});
test('POST /install rejects invalid env before bridge call', async () => {
const bad = [
'not-an-object', [], { COUNT: 123 }, { lowercase: 'x' },
{ TOO_LONG: 'x'.repeat(301) }, { QUOTE: 'a"b' }, { SLASH: 'a\\b' },
{ NEWLINE: 'a\nb' }, { NUL: 'a\u0000b' }, { DEL: 'a\u007fb' },
];
for (const env of bad) {
const f = jsonFetcher({});
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/install`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo_url: 'https://git.example/owner/repo', service: 'demo-hi', env }),
});
server.close();
expect(r.status).toBe(400);
expect(f.calls).toHaveLength(0);
}
});
});
+139 -115
View File
@@ -1,117 +1,141 @@
[
{
"id": "router",
"name": "Router UI",
"logo": "/assets/router.png",
"url": "https://router.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "chat",
"name": "Chat",
"logo": "/assets/chat.png",
"url": "https://chat.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "sync",
"name": "Syncthing",
"logo": "/assets/syncthing.png",
"url": "https://sync.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "torrent",
"name": "qBittorrent",
"logo": "/assets/qBittorrent.png",
"url": "https://torrent.sami",
"ip": "localhost",
"tailscaleOnly": false,
"deployedAt": "2026-01-18T06:04:55.246Z"
},
{
"id": "sonarr",
"name": "Sonarr",
"logo": "/assets/sonarr.png",
"url": "https://sonarr.sami",
"ip": "localhost",
"tailscaleOnly": false,
"deployedAt": "2026-01-18T06:04:56.612Z"
},
{
"id": "radarr",
"name": "Radarr",
"logo": "/assets/radarr.png",
"url": "https://radarr.sami",
"ip": "localhost",
"tailscaleOnly": false,
"deployedAt": "2026-01-18T08:28:12.359Z"
},
{
"id": "prowlarr",
"name": "Prowlarr",
"logo": "/assets/prowlarr.png",
"url": "https://prowlarr.sami",
"ip": "localhost",
"tailscaleOnly": false,
"deployedAt": "2026-01-18T08:28:13.739Z"
},
{
"id": "ca",
"name": "DashCA",
"logo": "/assets/certificate-icon.png",
"containerId": null,
"appTemplate": "dashca",
"tailscaleOnly": false,
"deployedAt": "2026-02-11T11:47:08.383Z",
"url": "https://ca.sami"
},
{
"id": "plex",
"name": "Plex",
"logo": "/assets/plex.png",
"containerId": null,
"appTemplate": "plex",
"tailscaleOnly": false,
"deployedAt": "2026-02-12T02:18:36.067Z",
"url": "https://plex.sami"
},
{
"id": "requests",
"name": "Seerr",
"logo": "/assets/seerr.png",
"url": "https://requests.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "git",
"name": "Gitea",
"logo": "/assets/gitea.png",
"url": "https://git.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "files",
"name": "Sami Files",
"logo": "/assets/sami-files.png",
"url": "https://files.sami",
"ip": "localhost",
"tailscaleOnly": false,
"containerId": null,
"appTemplate": "sami-files",
"deployedAt": "2026-06-19T00:00:00.000Z"
},
{
"id": "sec",
"name": "Security",
"logo": "/assets/chat.png",
"url": "https://sec.sami",
"ip": "localhost",
"tailscaleOnly": true
}
{
"id": "router",
"name": "Router UI",
"logo": "/assets/router.png",
"url": "https://router.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "chat",
"name": "Chat",
"logo": "/assets/chat.png",
"url": "https://chat.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "sync",
"name": "Syncthing",
"logo": "/assets/syncthing.png",
"url": "https://sync.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "torrent",
"name": "qBittorrent",
"logo": "/assets/qBittorrent.png",
"url": "https://torrent.sami",
"ip": "localhost",
"tailscaleOnly": false,
"deployedAt": "2026-01-18T06:04:55.246Z"
},
{
"id": "sonarr",
"name": "Sonarr",
"logo": "/assets/sonarr.png",
"url": "https://sonarr.sami",
"ip": "localhost",
"tailscaleOnly": false,
"deployedAt": "2026-01-18T06:04:56.612Z"
},
{
"id": "radarr",
"name": "Radarr",
"logo": "/assets/radarr.png",
"url": "https://radarr.sami",
"ip": "localhost",
"tailscaleOnly": false,
"deployedAt": "2026-01-18T08:28:12.359Z"
},
{
"id": "prowlarr",
"name": "Prowlarr",
"logo": "/assets/prowlarr.png",
"url": "https://prowlarr.sami",
"ip": "localhost",
"tailscaleOnly": false,
"deployedAt": "2026-01-18T08:28:13.739Z"
},
{
"id": "ca",
"name": "DashCA",
"logo": "/assets/certificate-icon.png",
"containerId": null,
"appTemplate": "dashca",
"tailscaleOnly": false,
"deployedAt": "2026-02-11T11:47:08.383Z",
"url": "https://ca.sami"
},
{
"id": "plex",
"name": "Plex",
"logo": "/assets/plex.png",
"containerId": null,
"appTemplate": "plex",
"tailscaleOnly": false,
"deployedAt": "2026-02-12T02:18:36.067Z",
"url": "https://plex.sami"
},
{
"id": "requests",
"name": "Seerr",
"logo": "/assets/seerr.png",
"url": "https://requests.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "git",
"name": "Gitea",
"logo": "/assets/gitea.png",
"url": "https://git.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "files",
"name": "Sami Files",
"logo": "/assets/sami-files.png",
"url": "https://files.sami",
"ip": "localhost",
"tailscaleOnly": false,
"containerId": null,
"appTemplate": "sami-files",
"deployedAt": "2026-06-19T00:00:00.000Z"
},
{
"id": "sec",
"name": "Security",
"logo": "/assets/chat.png",
"url": "https://sec.sami",
"ip": "localhost",
"tailscaleOnly": true
},
{
"id": "http-echo",
"name": "http-echo",
"url": "https://echo.sami",
"logo": "https://avatars.githubusercontent.com/u/761456?v=4",
"tailscaleOnly": true,
"isCustom": true
},
{
"id": "demo-hello",
"name": "demo-hello",
"url": "https://demo.sami",
"logo": "https://git.dashcaddy.net/avatars/f19511496aee4ceb8e11150676298d17",
"tailscaleOnly": true,
"isCustom": true
},
{
"id": "demo-hi",
"name": "demo-hi",
"url": "https://hi.sami",
"logo": "",
"tailscaleOnly": true,
"isCustom": true
}
]
+90
View File
@@ -197,5 +197,95 @@ module.exports = function ({ asyncHandler, log, auditLogger, fetchT }) {
}
}));
// DC-131/133: install from ANY git host — clone+detect+deploy on the bridge
// host, then the client registers the card via POST /api/v1/services.
// Same URL grammar the bridge enforces: any https host/owner/repo. The
// optional per-request token is forwarded to the bridge (validated, never
// stored by either layer).
router.post('/install', asyncHandler(async (req, res) => {
const { repo_url: repoUrl, service, subdomain, args, token, env } = req.body || {};
if (typeof repoUrl !== 'string' || !/^https:\/\/[A-Za-z0-9.-]+(?::\d+)?\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(\.git)?\/?$/.test(repoUrl)) {
return errorResponse(res, 400, 'repo_url must be a https://host/owner/repo URL');
}
if (typeof service !== 'string' || !SERVICE_RE.test(service)) {
return errorResponse(res, 400, 'invalid service name');
}
// Uniform token contract (same as /gitea-repos): supplied token must be
// a string <=512 chars. Empty string is deliberately PRESERVED on the
// wire: the bridge distinguishes "explicit anonymous" from omitted
// (omitted may use the fleet fallback credential).
let cleanToken;
if (token !== undefined) {
if (typeof token !== 'string' || token.length > 512) {
return errorResponse(res, 400, 'invalid token');
}
cleanToken = token;
}
// Mirror the bridge's fail-closed environment contract so invalid
// values never cross the proxy boundary. Valid values are forwarded
// unchanged; omitted env stays omitted.
let cleanEnv;
if (env !== undefined) {
const validEnv = env && typeof env === 'object' && !Array.isArray(env) &&
Object.entries(env).every(([k, v]) =>
/^[A-Z_][A-Z0-9_]*$/.test(k) && typeof v === 'string' &&
v.length <= 300 && !/["\\\x00-\x1f\x7f]/.test(v));
if (!validEnv) {
return errorResponse(res, 400, 'env must map valid uppercase names to strings <=300 chars without quotes, backslashes, or control characters');
}
cleanEnv = env;
}
try {
const { status, body } = await bridge('POST', '/api/install', { repo_url: repoUrl, service, subdomain, args, token: cleanToken, env: cleanEnv }, SHIPDECK_DEPLOY_TIMEOUT);
if (auditLogger) {
auditLogger.log({
action: 'deploy.install',
resource: service,
details: { repo_url: repoUrl, subdomain: subdomain || service },
outcome: body.ok ? 'success' : 'failure',
}).catch(() => {});
}
if (status !== 200 || !body.ok) {
return errorResponse(res, status === 401 ? 502 : status === 500 ? 502 : status, body.error || 'install failed', { output: (body.output || '').slice(-4000) });
}
log.info('deploys', 'Install completed from ' + (repoUrl.split('/')[2] || 'git host'), { service });
return ok(res, { service: body.service, output: body.output });
} catch (e) {
log.error('deploys', 'bridge unreachable during install', { error: e.message });
return errorResponse(res, 502, 'shipdeck bridge unreachable: ' + e.message);
}
}));
// DC-132/133: list Gitea repos installable via the bridge — from any
// instance. POST with a JSON body so host/token ride in the body (a GET
// has no body; the earlier GET handler read req.body and always saw
// undefined). The bridge never persists the token.
router.post('/gitea-repos', asyncHandler(async (req, res) => {
const payload = {};
if (req.body && typeof req.body.gitea_url === 'string' && req.body.gitea_url.trim()) {
payload.gitea_url = req.body.gitea_url.trim();
}
if (req.body && req.body.token !== undefined) {
// Uniform token contract. Empty string is DELIBERATELY preserved on
// the wire: the bridge distinguishes explicit-anonymous from omitted
// (omitted may use the fleet credential).
const t = req.body.token;
if (typeof t !== 'string' || t.length > 512) {
return errorResponse(res, 400, 'invalid token');
}
payload.token = t;
}
try {
const { status, body } = await bridge('POST', '/api/gitea/repos', payload, 20000);
if (status !== 200 || !body.ok) {
return errorResponse(res, status === 502 ? 502 : status, body.error || 'gitea listing failed');
}
return ok(res, { repos: body.repos });
} catch (e) {
return errorResponse(res, 502, 'shipdeck bridge unreachable: ' + e.message);
}
}));
return router;
};