mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-09-30 09:33:15 +00:00
Some checks are pending
Deploy / deploy (push) Waiting to run
* feat(api): add procurement-api stack - read API + OpenAPI docs page Third CDK stack: API Gateway REST API (IAM SigV4) over both pipelines' tables, replacing SHOC's retired SyncController cross-account DynamoDB scan as the reconciliation/backfill path. - lambdas/api/: handler (healthcheck + docs-token gate + router dispatch), router (single route table), pagination (opaque cursor, hostile -> 400), Decimal-safe serialization, wo_repo/po_repo reads. No VendorReplies. - OpenAPI 3.1 spec as source of truth incl. top-level webhooks section documenting the outbound SHOC feed; phase-2 write endpoints x-planned (router answers 501). Self-contained /docs page, no CDN. - Auth: AWS_IAM on data routes + resource policy scoped to exactly arn:aws:iam::396287094661:role/shoc-backend-dev on GET/*; /docs and /openapi.json carve-out is token-gated in the Lambda via shared web_ui_auth (fail-closed, INFRA-74 posture). - KMS: explicit Decrypt/DescribeKey on the DynamoDB CMK from SSM (name-imported table drops the key association - INFRA-104 class). - Alarms: errors/throttles/duration(p99>=22.5s) + gateway 5xx, ALARM-only to site-alerts. No access logging in v1 (docs ?token= shim stays out of logs); cloud_watch_role=False. - Tests: handler auth-seam + routing + Decimal round-trip; moto cursor pagination incl. hostile cursors; spec<->router drift gate; bundle AST pins for the api command; pytest.ini --cov + loader siblings. - Deploy role: third stack DescribeStacks ARN + procurement-api smoke invoke ARN (re-run create-deploy-role.sh before merge). * harden(api): apply sh-security-review findings to procurement-api Fan-out (6 detectors) + review findings resolved: Correctness / DoS: - pagination: require EXACT key-set match (was subset) so a partial/foreign composite cursor can't reach DynamoDB as an inconsistent ExclusiveStartKey -> ValidationException -> 500; comments Query now pins the cursor's work_order_id to the path entity. - handler: map botocore ValidationException to 400 (defense in depth) so a crafted cursor can't drive the zero-threshold 5xx alarm. - web_ui_auth: compare tokens as bytes; a non-ASCII presented token now fails closed (401) instead of crashing hmac.compare_digest into a 500. Resolves the pre-existing xfail(strict) follow-up test; hardens the web UIs too. Docs page: - typeStr() now escapes the one spec-derived string that reached innerHTML. - spec inlined into the docs <script> block escapes "<" -> < (</script> breakout guard); /openapi.json still served byte-faithful. - Cache-Control: no-store + Referrer-Policy: no-referrer on docs responses so the ?token= URL stays out of caches/Referer. - spec-drift test asserts the committed spec carries no "</" / "<!--". IAM / IaC: - resource policy enumerates the 7 data GET resources instead of GET/* so a future GET route can't silently inherit SHOC cross-account reach. - kms:Decrypt grant gains a kms:ViaService=dynamodb condition. - stage throttling (50 rps / 100 burst) bounds the unauthenticated /docs blast radius below the 10k account default. - corrected the PATCH/POST comment (same-account callers aren't blocked by the resource policy; 501 handler + absent write grant are the gate). - documented the RETAIN log-group first-deploy rollback trap and the resource-policy-needs-redeploy gotcha in-stack. Mandatory GPT-4.1 cross-family review of the full policy surface: no BLOCK/FIX. 675 tests pass, ruff clean, cdk synth green.
229 lines
11 KiB
HTML
229 lines
11 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Procurement Ingest API</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f1f5f9; color: #1e293b; line-height: 1.55; }
|
|
.wrap { max-width: 960px; margin: 0 auto; padding: 24px 20px 80px; }
|
|
h1 { font-size: 24px; margin-bottom: 4px; }
|
|
h2 { font-size: 18px; margin: 36px 0 14px; padding-bottom: 6px; border-bottom: 2px solid #e2e8f0; }
|
|
.muted { color: #64748b; font-size: 14px; }
|
|
.desc { margin: 14px 0; font-size: 14px; color: #334155; }
|
|
.desc p { margin-bottom: 8px; }
|
|
code { background: #e2e8f0; border-radius: 4px; padding: 1px 5px; font-size: 12.5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
.card { background: #fff; border-radius: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 12px; overflow: hidden; }
|
|
.op-head { display: flex; align-items: center; gap: 12px; padding: 12px 16px; cursor: pointer; }
|
|
.op-head:hover { background: #f8fafc; }
|
|
.method { font-size: 12px; font-weight: 700; padding: 3px 10px; border-radius: 6px; color: #fff; min-width: 58px; text-align: center; }
|
|
.m-get { background: #10b981; } .m-post { background: #f59e0b; } .m-patch { background: #8b5cf6; }
|
|
.m-webhook { background: #3b82f6; }
|
|
.path { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; font-weight: 600; }
|
|
.summary { color: #64748b; font-size: 13px; flex: 1; }
|
|
.planned { background: #fef3c7; color: #92400e; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; }
|
|
.op-body { display: none; padding: 4px 16px 16px; border-top: 1px solid #f1f5f9; }
|
|
.open .op-body { display: block; }
|
|
table { width: 100%; border-collapse: collapse; margin: 8px 0 14px; font-size: 13px; }
|
|
th { text-align: left; padding: 6px 8px; color: #64748b; font-size: 11px; text-transform: uppercase; border-bottom: 2px solid #e2e8f0; }
|
|
td { padding: 6px 8px; border-bottom: 1px solid #f1f5f9; vertical-align: top; }
|
|
.sec-label { font-size: 12px; font-weight: 700; color: #64748b; text-transform: uppercase; margin: 14px 0 4px; }
|
|
.schema-name { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600; }
|
|
.type { color: #7c3aed; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
|
.req { color: #dc2626; font-size: 11px; font-weight: 700; }
|
|
.badge-auth { background: #dbeafe; color: #1d4ed8; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 10px; }
|
|
a { color: #3b82f6; text-decoration: none; }
|
|
.enum { color: #0f766e; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap" id="app"><p class="muted">Loading spec…</p></div>
|
|
<script id="spec" type="application/json">__OPENAPI_SPEC_JSON__</script>
|
|
<script>
|
|
"use strict";
|
|
const spec = JSON.parse(document.getElementById("spec").textContent);
|
|
const el = (tag, cls, html) => {
|
|
const node = document.createElement(tag);
|
|
if (cls) node.className = cls;
|
|
if (html !== undefined) node.innerHTML = html;
|
|
return node;
|
|
};
|
|
const escapeHtml = s => String(s).replace(/[&<>"']/g,
|
|
c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
|
const md = s => escapeHtml(s || "")
|
|
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
|
.split(/\n\n+/).map(p => `<p>${p.replace(/\n/g, "<br>")}</p>`).join("");
|
|
|
|
function deref(node) {
|
|
if (node && node.$ref) {
|
|
const parts = node.$ref.replace("#/", "").split("/");
|
|
let cur = spec;
|
|
for (const p of parts) cur = cur[p];
|
|
return { name: node.$ref.split("/").pop(), ...cur };
|
|
}
|
|
return node;
|
|
}
|
|
const typeStr = schema => {
|
|
// Returns an HTML-escaped fragment: this is the one spec-derived string
|
|
// that reaches innerHTML (the type cells), so it must escape like every
|
|
// other renderer here. The array<...> wrapper composes already-escaped
|
|
// pieces, so its literal </> are safe.
|
|
if (!schema) return "";
|
|
if (schema.$ref) return escapeHtml(schema.$ref.split("/").pop());
|
|
if (Array.isArray(schema.type) && schema.type.includes("array") && schema.items)
|
|
return `array<${typeStr(schema.items)}>`;
|
|
if (schema.type === "array" && schema.items) return `array<${typeStr(schema.items)}>`;
|
|
const t = Array.isArray(schema.type) ? schema.type.join(" | ") : (schema.type || "");
|
|
return escapeHtml(t);
|
|
};
|
|
|
|
function schemaTable(schema, depth = 0) {
|
|
schema = deref(schema);
|
|
if (schema.allOf) {
|
|
const merged = { properties: {}, required: [] };
|
|
for (const part of schema.allOf.map(deref)) {
|
|
Object.assign(merged.properties, part.properties || {});
|
|
merged.required.push(...(part.required || []));
|
|
}
|
|
schema = merged;
|
|
}
|
|
if (!schema.properties) return null;
|
|
const table = el("table");
|
|
table.appendChild(el("tr", "", "<th>Field</th><th>Type</th><th>Description</th>"));
|
|
for (const [name, raw] of Object.entries(schema.properties)) {
|
|
const prop = raw.$ref ? deref(raw) : raw;
|
|
const required = (schema.required || []).includes(name);
|
|
let desc = md(prop.description);
|
|
if (prop.enum) desc += `<div class="enum">${prop.enum.filter(v => v !== null).map(escapeHtml).join(" | ")}</div>`;
|
|
const tr = el("tr", "", `
|
|
<td><span class="schema-name">${escapeHtml(name)}</span>${required ? ' <span class="req">required</span>' : ""}</td>
|
|
<td><span class="type">${typeStr(raw)}</span></td>
|
|
<td>${desc}</td>`);
|
|
table.appendChild(tr);
|
|
const inner = prop.type === "object" || (Array.isArray(prop.type) && prop.type.includes("object"));
|
|
if (inner && prop.properties && depth < 2) {
|
|
const cell = el("td", "", "");
|
|
cell.colSpan = 3;
|
|
cell.style.paddingLeft = "24px";
|
|
cell.appendChild(schemaTable(prop, depth + 1));
|
|
const row = el("tr");
|
|
row.appendChild(cell);
|
|
table.appendChild(row);
|
|
}
|
|
}
|
|
return table;
|
|
}
|
|
|
|
function opCard(method, path, op, isWebhook) {
|
|
const card = el("div", "card");
|
|
const head = el("div", "op-head");
|
|
const cls = isWebhook ? "m-webhook" : `m-${method.toLowerCase()}`;
|
|
head.appendChild(el("span", `method ${cls}`, isWebhook ? "EVENT" : method.toUpperCase()));
|
|
head.appendChild(el("span", "path", escapeHtml(path)));
|
|
head.appendChild(el("span", "summary", escapeHtml(op.summary || "")));
|
|
if (op["x-planned"]) head.appendChild(el("span", "planned", "PLANNED"));
|
|
const secSchemes = op.security || spec.security || [];
|
|
const secName = secSchemes.length ? Object.keys(secSchemes[0])[0] : null;
|
|
if (!isWebhook && secName) head.appendChild(el("span", "badge-auth", escapeHtml(secName)));
|
|
const body = el("div", "op-body");
|
|
if (op.description) body.appendChild(el("div", "desc", md(op.description)));
|
|
|
|
const params = (op.parameters || []).map(deref);
|
|
if (params.length) {
|
|
body.appendChild(el("div", "sec-label", "Parameters"));
|
|
const table = el("table");
|
|
table.appendChild(el("tr", "", "<th>Name</th><th>In</th><th>Type</th><th>Description</th>"));
|
|
for (const p of params) {
|
|
table.appendChild(el("tr", "", `
|
|
<td><span class="schema-name">${escapeHtml(p.name)}</span>${p.required ? ' <span class="req">required</span>' : ""}</td>
|
|
<td>${escapeHtml(p.in)}</td>
|
|
<td><span class="type">${typeStr(p.schema)}</span></td>
|
|
<td>${md(p.description)}</td>`));
|
|
}
|
|
body.appendChild(table);
|
|
}
|
|
|
|
const reqSchema = op.requestBody && op.requestBody.content &&
|
|
op.requestBody.content["application/json"] && op.requestBody.content["application/json"].schema;
|
|
if (reqSchema) {
|
|
body.appendChild(el("div", "sec-label", `Payload (${typeStr(reqSchema)})`));
|
|
const table = schemaTable(reqSchema);
|
|
if (table) body.appendChild(table);
|
|
}
|
|
|
|
body.appendChild(el("div", "sec-label", "Responses"));
|
|
const rtable = el("table");
|
|
rtable.appendChild(el("tr", "", "<th>Status</th><th>Description</th><th>Schema</th>"));
|
|
for (const [code, raw] of Object.entries(op.responses || {})) {
|
|
const resp = deref(raw);
|
|
const schema = resp.content && resp.content["application/json"] && resp.content["application/json"].schema;
|
|
rtable.appendChild(el("tr", "", `
|
|
<td><span class="schema-name">${escapeHtml(code)}</span></td>
|
|
<td>${md(resp.description)}</td>
|
|
<td><span class="type">${schema ? typeStr(schema) : ""}</span></td>`));
|
|
if (code.startsWith("2") && schema) {
|
|
const cell = el("td", "", "");
|
|
cell.colSpan = 3;
|
|
cell.style.paddingLeft = "24px";
|
|
const table = schemaTable(schema);
|
|
if (table) cell.appendChild(table);
|
|
const row = el("tr");
|
|
row.appendChild(cell);
|
|
rtable.appendChild(row);
|
|
}
|
|
}
|
|
body.appendChild(rtable);
|
|
head.addEventListener("click", () => card.classList.toggle("open"));
|
|
card.appendChild(head);
|
|
card.appendChild(body);
|
|
return card;
|
|
}
|
|
|
|
const app = document.getElementById("app");
|
|
app.innerHTML = "";
|
|
app.appendChild(el("h1", "", escapeHtml(spec.info.title)));
|
|
app.appendChild(el("div", "muted", `v${escapeHtml(spec.info.version)} · ${escapeHtml((spec.servers && spec.servers[0] && spec.servers[0].url) || "")}`));
|
|
app.appendChild(el("div", "desc", md(spec.info.description)));
|
|
|
|
app.appendChild(el("h2", "", "Authentication"));
|
|
for (const [name, scheme] of Object.entries((spec.components || {}).securitySchemes || {})) {
|
|
const card = el("div", "card");
|
|
card.appendChild(el("div", "op-head",
|
|
`<span class="badge-auth">${escapeHtml(name)}</span>
|
|
<span class="summary">${escapeHtml(scheme.name)} (${escapeHtml(scheme.in)}) — ${md(scheme.description)}</span>`));
|
|
app.appendChild(card);
|
|
}
|
|
|
|
app.appendChild(el("h2", "", "Endpoints (this API can be called)"));
|
|
for (const [path, ops] of Object.entries(spec.paths || {})) {
|
|
for (const [method, op] of Object.entries(ops)) {
|
|
app.appendChild(opCard(method, path, op, false));
|
|
}
|
|
}
|
|
|
|
app.appendChild(el("h2", "", "Outbound webhooks (this API calls you)"));
|
|
for (const [name, ops] of Object.entries(spec.webhooks || {})) {
|
|
for (const [method, op] of Object.entries(ops)) {
|
|
app.appendChild(opCard(method, name, op, true));
|
|
}
|
|
}
|
|
|
|
app.appendChild(el("h2", "", "Schemas"));
|
|
for (const [name, schema] of Object.entries((spec.components || {}).schemas || {})) {
|
|
const card = el("div", "card open");
|
|
const head = el("div", "op-head");
|
|
head.appendChild(el("span", "schema-name", escapeHtml(name)));
|
|
head.appendChild(el("span", "summary", escapeHtml(schema.description || "")));
|
|
card.appendChild(head);
|
|
const body = el("div", "op-body");
|
|
const table = schemaTable(schema);
|
|
if (table) body.appendChild(table);
|
|
card.appendChild(body);
|
|
head.addEventListener("click", () => card.classList.toggle("open"));
|
|
app.appendChild(card);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|