procurement-ingest/lambdas/api/docs.html
Adam Moussa f67d8b9907
Some checks are pending
Deploy / deploy (push) Waiting to run
feat(api): procurement-api read stack + OpenAPI docs (SHOC reconciliation path) (#127)
* 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.
2026-07-23 19:32:20 -04:00

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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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 &lt;/&gt; 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&lt;${typeStr(schema.items)}&gt;`;
if (schema.type === "array" && schema.items) return `array&lt;${typeStr(schema.items)}&gt;`;
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)} &middot; ${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)}) &mdash; ${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>