[grade=B] DC-131/132/133 install from any Git host
Codex source gate: urn:ump:iw2tbbe6mssyl5divymmwo42ael65sbfrciztvyowo3zhrbtircq Generated assets gate: urn:ump:hintflviuxfpeidth42ry5fi4lwqsjhkxipzspfmk7vrvfc2cnqq
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user