feat(api): Redocly lint gate + SHOC-themed /docs (Redoc theming, topbar, collapsible samples) (#130)
Some checks are pending
Deploy / deploy (push) Waiting to run

* feat(api): Add @redocly/cli as a dev dependency

Signed-off-by: Adam Moussa <adam@seahavenind.com>

* feat(api): Add Redocly configuration file with custom rules

Signed-off-by: Adam Moussa <adam@seahavenind.com>

* chore(api): Redocly lint config + bring openapi.json into compliance

redocly.yaml from the Redocly guidelines builder, with three generated
rules corrected: response-contains-property had the status codes as the
required body fields (intent was the Error schema's top-level 'error';
403 exempt since API Gateway emits AWS's {message} shape, 501 not 503);
operation-4xx-problem-details-rfc7807 off (adopting RFC 7807 would be a
runtime + SHOC-contract change, decided against); the two inert casing
rules (parameter names, schema properties) removed because both name
sets are contract-pinned (gateway resource paths, DynamoDB items).

Spec changes, no runtime impact: operationIds renamed to method-prefixed
kebab-case (get-work-orders, post-work-order-comment, ...); tags added to
all 15 operations + root tags object (groups the Redoc sidebar); examples
on all six parameters; license field; server description punctuation; two
descriptions reworded to start capitalized. Real linter catches fixed:
the two x-planned ops were missing their {workOrderId} path parameter
and any 4xx response (403 added - true today, gateway rejects unsigned).

.redocly.lint-ignore.yaml pins the six deliberate exceptions: webhook
keys are the shipped SHOC contract event names (not renameable), and the
x-planned ops answer only 501 (no 2xx to document).

package.json: npm run lint:api. Verified: lint 0 errors, 675 pytest,
headless-Chrome render of the tagged docs page.

* feat(api): SHOC design-system theme for /docs (vendored fonts)

Themes the Redoc page with the canonical SHOC token set: Montserrat 600
headings / DM Sans body / JetBrains Mono code, primary #1c75bc, navy
#262262 sidebar text + right panel, #f9fafb background, 244px sidebar.
sortRequiredPropsFirst on; 200 responses pre-expanded.

Fonts ship as lambdas/api/fonts.css (latin woff2 subsets from
@fontsource 5.3.0, embedded as data URIs, ~90KB) and inline via a new
__FONTS_CSS__ placeholder with the same </style breakout guard --
the offline single-response invariant holds, nothing fetches Google
Fonts (test-pinned). Bundling cp + bundle-consistency pin + spec-drift
asset checks extended.

Verified: headless-Chrome render (theme + fonts applied), ruff, 675
pytest, cdk synth + staged-asset check.

* feat(api): SHOC gradient topbar on /docs

64px fixed header with the SHOC shell gradient token (#1b1f52 ->
#1c4f8f -> #1c75bc), Sea Haven wordmark in Montserrat 600, page name
right-aligned in DM Sans. Redoc's scrollYOffset: 64 keeps the sticky
sidebar and anchor scrolling clear of the fixed bar. Verified via
headless-Chrome render.

* style(api): normalize /docs header and right-panel blues

The right panel's #262262 is a purple-leaning navy that clashed with
the cyan-leaning gradient, and the bar's brightest point sat directly
over the dark panel. Right panel now uses #1b1f52 (the gradient's own
dark endpoint) and the gradient runs bright-to-dark so its dark end
lands flush on the panel -- no seam, one blue family. Verified via
headless-Chrome render.

* style(api): right-panel gradient on /docs via bundle-pinned override

Redoc's theme only takes solid colors (it derives shades from
rightPanel.backgroundColor), so the gradient (#1b3d79 -> #1b3068 ->
#1b1f52, continuing the topbar blend) rides as a CSS override on the
styled-components classes of the per-section right-panel divs
(.sc-iGgWBj.sc-gsFSXq + the .sc-dExYaf stub). Those names are
deterministic for the vendored 2.5.3 bundle (verified across loads) but
change on any Redoc bump: re-derive via headless probe (find elements
whose computed background equals the rightPanel color). If they stop
matching, the panel falls back to the solid #1b1f52 theme color --
cosmetic only. Verified via headless-Chrome render.

* feat(api): collapsible samples column on /docs

Redoc CE has no built-in panel toggle, so the topbar gains a Hide/Show
samples button that flips .samples-collapsed on <html>: the right-panel
divs hide (same bundle-pinned styled-components classes as the gradient
override) and each section's content half takes the full width. Choice
persists in localStorage; aria-pressed tracks state. If the pinned
classes stop matching after a Redoc bump the toggle goes inert --
cosmetic only. Both states verified via headless-Chrome render.

* ci(api): spec-lint CI gate + npm Dependabot coverage

New spec-lint job mirrors the local npm run lint:api so openapi.json
cannot drift from redocly.yaml with green CI. Dependabot gains the npm
ecosystem (package.json is new; nothing watched @redocly/cli).

* feat(api): docs finishing touches - x-tagGroups, favicon, docs:preview

x-tagGroups sections the Redoc sidebar (Read API / Meta / SHOC Feed);
inline data-URI SVG favicon (SHOC blue) stops the browser's follow-up
/favicon.ico request 403ing at the gateway; npm run docs:preview wraps
the real-handler local render (scripts/preview_docs.py); README gains a
docs-page architecture section covering the inline pattern, theme,
pinned-selector caveat, and tooling. Lint 0 errors, 675 pytest,
headless render verified.

---------

Signed-off-by: Adam Moussa <adam@seahavenind.com>
This commit is contained in:
Adam Moussa 2026-07-24 14:04:30 -04:00 • committed by GitHub
parent 24c89b43e5
commit b89e98a879
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 533 additions and 44 deletions

View file

@ -72,3 +72,12 @@ updates:
update-types: update-types:
- "minor" - "minor"
- "patch" - "patch"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"

View file

@ -14,3 +14,15 @@ jobs:
run-tests: true run-tests: true
run-cdk-synth: true run-cdk-synth: true
run-sam-validate: false run-sam-validate: false
# The published OpenAPI contract must stay compliant with redocly.yaml;
# this is the CI mirror of the local `npm run lint:api`.
spec-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint:api

17
.redocly.lint-ignore.yaml Normal file
View file

@ -0,0 +1,17 @@
# This file instructs Redocly's linter to ignore the rules contained for specific parts of your API.
# See https://redocly.com/docs/cli/ for more information.
#
# operation-2xx-response: the two x-planned phase-2 write endpoints answer
# ONLY 501 until built -- documenting a 2xx they cannot return would lie.
# paths-kebab-case: webhook keys are the shipped SHOC contract event names
# (docs/shoc-webhook-contract.md Rev 2026-07-23, event_type enum) -- not
# renameable without a contract break.
lambdas/api/openapi.json:
operation-2xx-response:
- '#/paths/~1work-orders~1{workOrderId}/patch/responses'
- '#/paths/~1work-orders~1{workOrderId}~1comments/post/responses'
paths-kebab-case:
- '#/webhooks/work_order.created'
- '#/webhooks/work_order.updated'
- '#/webhooks/work_order.cancelled'
- '#/webhooks/work_order.comment_added'

View file

@ -103,6 +103,7 @@ A read-only REST API (API Gateway + the `procurement-api` Lambda, `lambdas/api/`
- **KMS:** `purchase-orders` is CMK-encrypted; the imported-by-name table doesn't carry the key association, so the stack grants `kms:Decrypt`/`DescribeKey` on the CMK from SSM `/seahaven/dynamodb/cmk-arn` explicitly (the INFRA-104 failure class). - **KMS:** `purchase-orders` is CMK-encrypted; the imported-by-name table doesn't carry the key association, so the stack grants `kms:Decrypt`/`DescribeKey` on the CMK from SSM `/seahaven/dynamodb/cmk-arn` explicitly (the INFRA-104 failure class).
- **No access logging in v1** (keeps the `?token=` shim out of any log and avoids the account-level API Gateway CloudWatch role); rotate the docs token before ever enabling it. No CORS (server-to-server + Postman callers). - **No access logging in v1** (keeps the `?token=` shim out of any log and avoids the account-level API Gateway CloudWatch role); rotate the docs token before ever enabling it. No CORS (server-to-server + Postman callers).
- **Alarms:** `procurement-api-errors`/`-throttles`/`-duration` (p99 ≥ 22.5 s) + gateway `procurement-api-5xx`, all ALARM-only → `site-alerts`. No 4XX alarm (401/403/404 are expected traffic). - **Alarms:** `procurement-api-errors`/`-throttles`/`-duration` (p99 ≥ 22.5 s) + gateway `procurement-api-5xx`, all ALARM-only → `site-alerts`. No 4XX alarm (401/403/404 are expected traffic).
- **Docs page:** `/docs` serves Redoc (read-only reference docs; live calls go through Postman since data routes need SigV4) as ONE token-gated response: the handler inlines the vendored `redoc.standalone.js`, the design-system fonts (`fonts.css`, data-URI `@font-face` — nothing may fetch from Google Fonts, test-pinned), and the spec into `docs.html`, with `</script>`/`</style>` breakout guards on every blob. Theme = SHOC tokens (Montserrat/DM Sans/JetBrains Mono, primary `#1c75bc`, 64px gradient topbar). The right-panel gradient and the topbar's Hide/Show-samples toggle target styled-components class names that are deterministic for the pinned Redoc bundle but change on any bump — re-derive them then (headless probe: elements whose computed background equals the `rightPanel` color); stale selectors degrade to a solid panel / inert toggle, cosmetic only. Tooling: `npm run lint:api` lints the spec against `redocly.yaml` (CI job `spec-lint`; deliberate exceptions live in `.redocly.lint-ignore.yaml`), `npm run docs:preview` renders the real handler output locally.
## Architecture ## Architecture
@ -436,8 +437,9 @@ lambdas/ # Phase 2: shared Code.from_asset("../lambdas") bundling
wo_repo.py # WorkOrders/WorkOrderComments reads (paginated Scan / PK Query) wo_repo.py # WorkOrders/WorkOrderComments reads (paginated Scan / PK Query)
po_repo.py # purchase-orders/verified-sites reads (no VendorReplies -- dead table) po_repo.py # purchase-orders/verified-sites reads (no VendorReplies -- dead table)
openapi.json # OpenAPI 3.1 source of truth (paths + outbound `webhooks` section) openapi.json # OpenAPI 3.1 source of truth (paths + outbound `webhooks` section)
docs.html # Redoc shell; handler inlines the bundle/spec at request time (single token-gated request, no CDN) docs.html # Redoc shell, SHOC design-system theme; handler inlines bundle/fonts/spec at request time (single token-gated request, no CDN)
redoc.standalone.js # vendored Redoc bundle (redoc 2.5.3, MIT), inlined into /docs; read-only docs, no try-it-out redoc.standalone.js # vendored Redoc bundle (redoc 2.5.3, MIT), inlined into /docs; read-only docs, no try-it-out
fonts.css # SHOC fonts (DM Sans/Montserrat/JetBrains Mono, @fontsource latin subsets) as data URIs, inlined into /docs
po/ # PO pipeline Lambdas po/ # PO pipeline Lambdas
email_processor/ # Phase 5: God-handler decomposed into flat siblings (bare-name email_processor/ # Phase 5: God-handler decomposed into flat siblings (bare-name
# imports; the Phase 0/2/3 `cp <pipeline>/email_processor/*.py` # imports; the Phase 0/2/3 `cp <pipeline>/email_processor/*.py`

View file

@ -119,6 +119,7 @@ class ProcurementApiStack(Stack):
"cp api/openapi.json /asset-output/ && " "cp api/openapi.json /asset-output/ && "
"cp api/docs.html /asset-output/ && " "cp api/docs.html /asset-output/ && "
"cp api/redoc.standalone.js /asset-output/ && " "cp api/redoc.standalone.js /asset-output/ && "
"cp api/fonts.css /asset-output/ && "
"cp shared/web_ui_auth.py /asset-output/ && " "cp shared/web_ui_auth.py /asset-output/ && "
"rm -rf /asset-output/__pycache__", "rm -rf /asset-output/__pycache__",
], ],

View file

@ -4,38 +4,159 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Procurement Ingest API</title> <title>Procurement Ingest API</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2016%2016'%3E%3Crect%20width='16'%20height='16'%20rx='3'%20fill='%231c75bc'/%3E%3Ctext%20x='8'%20y='12'%20font-size='10'%20font-family='sans-serif'%20font-weight='700'%20text-anchor='middle'%20fill='white'%3ES%3C/text%3E%3C/svg%3E">
<!-- <!--
Redoc (redoc.standalone.js, MIT), vendored offline and inlined server-side Redoc (redoc.standalone.js, MIT), vendored offline and inlined server-side
so /docs is a single token-gated request with no CDN or follow-up asset so /docs is a single token-gated request with no CDN or follow-up asset
fetch. The bundle and the OpenAPI spec are substituted for the placeholders fetch. The bundle, the SHOC design-system fonts (fonts.css, data-URI
below by lambdas/api/handler.py::_load_docs_html. Redoc is read-only by @font-face), and the OpenAPI spec are substituted for the placeholders
design -- there is no try-it-out to disable; live calls go through Postman below by lambdas/api/handler.py::_docs_shell / _render_docs_html. Redoc is
(Authorization type "AWS Signature") since data routes require SigV4. read-only by design -- there is no try-it-out to disable; live calls go
The theme pins a system font stack: Redoc defaults to Roboto, which is not through Postman (Authorization type "AWS Signature") since data routes
vendored and must not be fetched from Google Fonts. require SigV4.
Theme = the SHOC design-system token set (the Sea Haven visual standard):
Montserrat headings / DM Sans body / JetBrains Mono code, primary #1c75bc,
navy #262262, page background #f9fafb, 244px sidebar. Fonts are vendored --
nothing here may fetch from Google Fonts.
--> -->
<style>__FONTS_CSS__</style>
<style> <style>
html, body { margin: 0; padding: 0; } html, body { margin: 0; padding: 0; background: #f9fafb; }
/* SHOC shell topbar: 64px, gradient header token. Redoc has no topbar of
its own; scrollYOffset below keeps its sticky sidebar/scroll accounting
clear of this fixed bar. Gradient runs bright-to-dark (reversed from the
SHOC shell) so its dark end (#1b1f52) sits flush over the right panel,
which uses the same color -- one continuous blue family, no seam. */
#topbar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 64px;
z-index: 10;
display: flex;
align-items: center;
padding: 0 24px;
box-sizing: border-box;
background: linear-gradient(90deg, #1c75bc 0%, #1c4f8f 45%, #1b1f52 100%);
color: #ffffff;
font-family: "Montserrat", system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
}
#topbar .brand {
font-size: 16px;
font-weight: 600;
letter-spacing: 0.02em;
}
#topbar .page {
margin-left: 16px;
font-family: "DM Sans", system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 13px;
font-weight: 400;
opacity: 0.85;
}
#samples-toggle {
margin-left: auto;
padding: 5px 14px;
background: transparent;
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.45);
border-radius: 6px;
font-family: "DM Sans", system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 12px;
cursor: pointer;
}
#samples-toggle:hover { border-color: #ffffff; background: rgba(255, 255, 255, 0.1); }
#redoc { padding-top: 64px; }
/* Collapsible samples column: Redoc CE has no built-in panel toggle, so
the topbar button flips .samples-collapsed on <html>, hiding the
right-panel divs (same bundle-pinned classes as the gradient below)
and letting each section's content half take the full width. If the
pinned classes stop matching after a Redoc bump the toggle goes inert
-- cosmetic only. Choice persists in localStorage. */
html.samples-collapsed .sc-iGgWBj.sc-gsFSXq,
html.samples-collapsed div.sc-dExYaf { display: none; }
html.samples-collapsed [data-section-id] > div:first-child { width: 100%; }
/* Right-panel gradient (#1b3d79 -> #1b1f52, continuing the topbar blend).
Redoc's theme only accepts solid colors (it derives shades from
rightPanel.backgroundColor), so the solid #1b1f52 stays in the theme as
the fallback and this override layers the gradient on top.
BUNDLE-PINNED SELECTORS: .sc-iGgWBj.sc-gsFSXq are the styled-components
classes of the per-section right-panel divs (plus .sc-dExYaf, the
full-height background stub) generated by the vendored
redoc.standalone.js 2.5.3. They are deterministic for this exact bundle
but WILL change on any Redoc bump -- re-derive them then (headless probe:
find elements whose computed background is the rightPanel color). If
they stop matching, the panel silently falls back to solid #1b1f52 --
cosmetic only, nothing breaks. Every stripe shares the same horizontal
geometry, so the per-section gradients read as one continuous column. */
.sc-iGgWBj.sc-gsFSXq,
div.sc-dExYaf {
background-image: linear-gradient(90deg, #1b3d79 0%, #1b3068 45%, #1b1f52 100%);
}
</style> </style>
</head> </head>
<body> <body>
<div id="topbar">
<span class="brand">Sea Haven Industries</span>
<button id="samples-toggle" type="button" aria-pressed="false">Hide samples</button>
<span class="page">Procurement Ingest API</span>
</div>
<div id="redoc"></div> <div id="redoc"></div>
<script>__REDOC_JS__</script> <script>__REDOC_JS__</script>
<script> <script>
Redoc.init( Redoc.init(
__OPENAPI_SPEC_JSON__, __OPENAPI_SPEC_JSON__,
{ {
sortRequiredPropsFirst: true,
expandResponses: "200",
scrollYOffset: 64,
theme: { theme: {
colors: {
primary: { main: "#1c75bc" }
},
typography: { typography: {
fontFamily: 'system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif', fontFamily: '"DM Sans", system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif',
fontWeightBold: "600",
headings: { headings: {
fontFamily: 'system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif' fontFamily: '"Montserrat", system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif',
fontWeight: "600"
},
code: {
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace'
} }
},
sidebar: {
width: "244px",
backgroundColor: "#f9fafb",
textColor: "#262262"
},
rightPanel: {
backgroundColor: "#1b1f52"
},
fab: {
backgroundColor: "#1c75bc"
} }
} }
}, },
document.getElementById("redoc") document.getElementById("redoc")
); );
(function () {
var KEY = "procurement-docs-samples-collapsed";
var btn = document.getElementById("samples-toggle");
function apply(collapsed) {
document.documentElement.classList.toggle("samples-collapsed", collapsed);
btn.textContent = collapsed ? "Show samples" : "Hide samples";
btn.setAttribute("aria-pressed", String(collapsed));
}
var initial = false;
try { initial = localStorage.getItem(KEY) === "1"; } catch (e) {}
apply(initial);
btn.addEventListener("click", function () {
var collapsed = !document.documentElement.classList.contains("samples-collapsed");
try { localStorage.setItem(KEY, collapsed ? "1" : "0"); } catch (e) {}
apply(collapsed);
});
})();
</script> </script>
</body> </body>
</html> </html>

37
lambdas/api/fonts.css Normal file

File diff suppressed because one or more lines are too long

View file

@ -28,12 +28,14 @@ _MODULE_DIR = Path(__file__).resolve().parent
_SPEC_PATH = _MODULE_DIR / "openapi.json" _SPEC_PATH = _MODULE_DIR / "openapi.json"
_DOCS_PATH = _MODULE_DIR / "docs.html" _DOCS_PATH = _MODULE_DIR / "docs.html"
_REDOC_JS_PATH = _MODULE_DIR / "redoc.standalone.js" _REDOC_JS_PATH = _MODULE_DIR / "redoc.standalone.js"
_FONTS_CSS_PATH = _MODULE_DIR / "fonts.css"
# The docs page template carries these placeholders, filled server-side so # The docs page template carries these placeholders, filled server-side so
# /docs is a single token-gated request (a browser can't attach the auth # /docs is a single token-gated request (a browser can't attach the auth
# header to a follow-up asset fetch) with the vendored Redoc bundle + spec # header to a follow-up asset fetch) with the vendored Redoc bundle, the
# inlined, no CDN. # design-system fonts, and the spec inlined, no CDN.
_SPEC_PLACEHOLDER = "__OPENAPI_SPEC_JSON__" _SPEC_PLACEHOLDER = "__OPENAPI_SPEC_JSON__"
_JS_PLACEHOLDER = "__REDOC_JS__" _JS_PLACEHOLDER = "__REDOC_JS__"
_FONTS_PLACEHOLDER = "__FONTS_CSS__"
_spec_cache = None _spec_cache = None
_docs_shell_cache = None _docs_shell_cache = None
@ -96,19 +98,24 @@ def _docs_shell() -> str:
is left intact so the per-request server-injected spec splices in cheaply is left intact so the per-request server-injected spec splices in cheaply
(the heavy ~1.1MB bundle is assembled once and cached). (the heavy ~1.1MB bundle is assembled once and cached).
The bundle is spliced into a <script> block, where the HTML parser ends Each blob is spliced into a <style>/<script> block, where the HTML parser
the element at the first literal "</script" regardless of quoting, so ends the element at the first literal "</style"/"</script" regardless of
guard against a breakout a future asset update could add (the pinned quoting, so guard against a breakout a future asset update could add (the
bundle has none today): "</script" -> "<\\/script" (equivalent inside a pinned assets have none today): JS "</script" -> "<\\/script" (equivalent
JS string/regex). inside a JS string/regex), CSS "</style" -> "<\\/style".
""" """
global _docs_shell_cache global _docs_shell_cache
if _docs_shell_cache is None: if _docs_shell_cache is None:
js = _REDOC_JS_PATH.read_text(encoding="utf-8").replace( js = _REDOC_JS_PATH.read_text(encoding="utf-8").replace(
"</script", "<\\/script" "</script", "<\\/script"
) )
_docs_shell_cache = _DOCS_PATH.read_text(encoding="utf-8").replace( fonts = _FONTS_CSS_PATH.read_text(encoding="utf-8").replace(
_JS_PLACEHOLDER, js "</style", "<\\/style"
)
_docs_shell_cache = (
_DOCS_PATH.read_text(encoding="utf-8")
.replace(_JS_PLACEHOLDER, js)
.replace(_FONTS_PLACEHOLDER, fonts)
) )
return _docs_shell_cache return _docs_shell_cache

View file

@ -3,12 +3,15 @@
"info": { "info": {
"title": "Procurement Ingest API", "title": "Procurement Ingest API",
"version": "1.0.0", "version": "1.0.0",
"license": {
"name": "Proprietary (Sea Haven Industries, internal)"
},
"description": "Read API over the procurement-ingest pipelines (work orders and purchase orders). The outbound SHOC work-order webhook feed is documented under **webhooks** below.\n\nData endpoints use AWS IAM SigV4; the `/docs` and `/openapi.json` routes use a shared token. Listings are unordered, cursor-paginated scans. Endpoints tagged **x-planned** are phase 2 and currently answer `501`.\n\nThis API replaces SHOC's retired SyncController DynamoDB scan as the reconciliation and backfill path. Full detail: the repo README and `docs/shoc-webhook-contract.md`." "description": "Read API over the procurement-ingest pipelines (work orders and purchase orders). The outbound SHOC work-order webhook feed is documented under **webhooks** below.\n\nData endpoints use AWS IAM SigV4; the `/docs` and `/openapi.json` routes use a shared token. Listings are unordered, cursor-paginated scans. Endpoints tagged **x-planned** are phase 2 and currently answer `501`.\n\nThis API replaces SHOC's retired SyncController DynamoDB scan as the reconciliation and backfill path. Full detail: the repo README and `docs/shoc-webhook-contract.md`."
}, },
"servers": [ "servers": [
{ {
"url": "https://mvul1efda2.execute-api.us-east-1.amazonaws.com/prod", "url": "https://mvul1efda2.execute-api.us-east-1.amazonaws.com/prod",
"description": "seahaven-prod (011934824531)" "description": "seahaven-prod (011934824531)."
} }
], ],
"security": [ "security": [
@ -16,10 +19,47 @@
"sigv4": [] "sigv4": []
} }
], ],
"tags": [
{
"name": "Work Orders",
"description": "Work orders ingested from APM emails (WorkOrders and WorkOrderComments tables)."
},
{
"name": "Purchase Orders",
"description": "Coupa purchase orders ingested from PO emails (purchase-orders table)."
},
{
"name": "Verified Sites",
"description": "Amazon site directory maintained by the PO site extractor (verified-sites table)."
},
{
"name": "Docs",
"description": "This documentation page and the raw OpenAPI document (token-gated)."
},
{
"name": "Outbound Webhooks",
"description": "Events pushed by workorder-shoc-emitter to the configured SHOC endpoint."
}
],
"x-tagGroups": [
{
"name": "Read API",
"tags": ["Work Orders", "Purchase Orders", "Verified Sites"]
},
{
"name": "Meta",
"tags": ["Docs"]
},
{
"name": "SHOC Feed",
"tags": ["Outbound Webhooks"]
}
],
"paths": { "paths": {
"/work-orders": { "/work-orders": {
"get": { "get": {
"operationId": "listWorkOrders", "operationId": "get-work-orders",
"tags": ["Work Orders"],
"summary": "List work orders (unordered, paginated)", "summary": "List work orders (unordered, paginated)",
"parameters": [ "parameters": [
{ {
@ -63,7 +103,8 @@
}, },
"/work-orders/{workOrderId}": { "/work-orders/{workOrderId}": {
"get": { "get": {
"operationId": "getWorkOrder", "operationId": "get-work-order",
"tags": ["Work Orders"],
"summary": "Get one work order", "summary": "Get one work order",
"parameters": [ "parameters": [
{ {
@ -91,10 +132,19 @@
}, },
"patch": { "patch": {
"x-planned": true, "x-planned": true,
"operationId": "patchWorkOrder", "operationId": "patch-work-order",
"tags": ["Work Orders"],
"summary": "PLANNED (phase 2): update dispatch fields on a work order", "summary": "PLANNED (phase 2): update dispatch fields on a work order",
"description": "Not implemented - returns 501. Phase-2 write-back for SHOC dispatch workflow (status/assignment). Writes will stamp `write_origin: shoc-write-api` so the outbound webhook never echoes SHOC's own writes back at it. Ships with its own IAM diff and cross-family review.", "description": "Not implemented - returns 501. Phase-2 write-back for SHOC dispatch workflow (status/assignment). Writes will stamp `write_origin: shoc-write-api` so the outbound webhook never echoes SHOC's own writes back at it. Ships with its own IAM diff and cross-family review.",
"parameters": [
{
"$ref": "#/components/parameters/WorkOrderId"
}
],
"responses": { "responses": {
"403": {
"$ref": "#/components/responses/Forbidden"
},
"501": { "501": {
"$ref": "#/components/responses/NotImplemented" "$ref": "#/components/responses/NotImplemented"
} }
@ -103,7 +153,8 @@
}, },
"/work-orders/{workOrderId}/comments": { "/work-orders/{workOrderId}/comments": {
"get": { "get": {
"operationId": "listWorkOrderComments", "operationId": "get-work-order-comments",
"tags": ["Work Orders"],
"summary": "List comments/events for a work order (paginated)", "summary": "List comments/events for a work order (paginated)",
"parameters": [ "parameters": [
{ {
@ -149,10 +200,19 @@
}, },
"post": { "post": {
"x-planned": true, "x-planned": true,
"operationId": "createWorkOrderComment", "operationId": "post-work-order-comment",
"tags": ["Work Orders"],
"summary": "PLANNED (phase 2): append a SHOC-authored comment", "summary": "PLANNED (phase 2): append a SHOC-authored comment",
"description": "Not implemented - returns 501. Phase-2 write-back: SHOC dispatch notes land in WorkOrderComments with `write_origin: shoc-write-api` (append-only; no field conflicts with the email pipeline). Ships with its own IAM diff and cross-family review.", "description": "Not implemented - returns 501. Phase-2 write-back: SHOC dispatch notes land in WorkOrderComments with `write_origin: shoc-write-api` (append-only; no field conflicts with the email pipeline). Ships with its own IAM diff and cross-family review.",
"parameters": [
{
"$ref": "#/components/parameters/WorkOrderId"
}
],
"responses": { "responses": {
"403": {
"$ref": "#/components/responses/Forbidden"
},
"501": { "501": {
"$ref": "#/components/responses/NotImplemented" "$ref": "#/components/responses/NotImplemented"
} }
@ -161,7 +221,8 @@
}, },
"/purchase-orders": { "/purchase-orders": {
"get": { "get": {
"operationId": "listPurchaseOrders", "operationId": "get-purchase-orders",
"tags": ["Purchase Orders"],
"summary": "List purchase orders (unordered, paginated)", "summary": "List purchase orders (unordered, paginated)",
"parameters": [ "parameters": [
{ {
@ -205,7 +266,8 @@
}, },
"/purchase-orders/{poNumber}": { "/purchase-orders/{poNumber}": {
"get": { "get": {
"operationId": "getPurchaseOrder", "operationId": "get-purchase-order",
"tags": ["Purchase Orders"],
"summary": "Get one purchase order", "summary": "Get one purchase order",
"parameters": [ "parameters": [
{ {
@ -215,7 +277,8 @@
"schema": { "schema": {
"type": "string" "type": "string"
}, },
"description": "Coupa PO number, e.g. `2D-22030794`." "description": "Coupa PO number, e.g. `2D-22030794`.",
"example": "2D-22030794"
} }
], ],
"responses": { "responses": {
@ -240,7 +303,8 @@
}, },
"/verified-sites": { "/verified-sites": {
"get": { "get": {
"operationId": "listVerifiedSites", "operationId": "get-verified-sites",
"tags": ["Verified Sites"],
"summary": "List verified Amazon sites (unordered, paginated)", "summary": "List verified Amazon sites (unordered, paginated)",
"parameters": [ "parameters": [
{ {
@ -284,7 +348,8 @@
}, },
"/verified-sites/{siteCode}": { "/verified-sites/{siteCode}": {
"get": { "get": {
"operationId": "getVerifiedSite", "operationId": "get-verified-site",
"tags": ["Verified Sites"],
"summary": "Get one verified site", "summary": "Get one verified site",
"parameters": [ "parameters": [
{ {
@ -294,7 +359,8 @@
"schema": { "schema": {
"type": "string" "type": "string"
}, },
"description": "Amazon site code, e.g. `JFK8`." "description": "Amazon site code, e.g. `JFK8`.",
"example": "JFK8"
} }
], ],
"responses": { "responses": {
@ -319,7 +385,8 @@
}, },
"/docs": { "/docs": {
"get": { "get": {
"operationId": "getDocs", "operationId": "get-docs",
"tags": ["Docs"],
"summary": "This documentation page (token-gated)", "summary": "This documentation page (token-gated)",
"security": [ "security": [
{ {
@ -334,7 +401,8 @@
"schema": { "schema": {
"type": "string" "type": "string"
}, },
"description": "Browser convenience: the docs token as a query parameter (browsers can't set headers on navigation). Prefer the X-Auth-Token header from tooling." "description": "Browser convenience: the docs token as a query parameter (browsers can't set headers on navigation). Prefer the X-Auth-Token header from tooling.",
"example": "your-docs-token"
} }
], ],
"responses": { "responses": {
@ -352,7 +420,8 @@
}, },
"/openapi.json": { "/openapi.json": {
"get": { "get": {
"operationId": "getOpenApiSpec", "operationId": "get-openapi-spec",
"tags": ["Docs"],
"summary": "This spec (token-gated)", "summary": "This spec (token-gated)",
"security": [ "security": [
{ {
@ -376,6 +445,8 @@
"webhooks": { "webhooks": {
"work_order.created": { "work_order.created": {
"post": { "post": {
"operationId": "post-work-order-created",
"tags": ["Outbound Webhooks"],
"summary": "Outbound: a work order was created", "summary": "Outbound: a work order was created",
"description": "Sent by `workorder-shoc-emitter` (seahaven-prod) to the configured SHOC endpoint. Full contract incl. HMAC verification, ordering, and retry semantics: `docs/shoc-webhook-contract.md` (Rev 2026-07-23). Requests carry `X-SH-Timestamp`, `X-SH-Key-Id`, and `X-SH-Signature: v1=hex(HMAC_SHA256(secret, \"{timestamp}.{raw_body}\"))`; verify over the raw body, constant-time, +/-300s window, fail closed.", "description": "Sent by `workorder-shoc-emitter` (seahaven-prod) to the configured SHOC endpoint. Full contract incl. HMAC verification, ordering, and retry semantics: `docs/shoc-webhook-contract.md` (Rev 2026-07-23). Requests carry `X-SH-Timestamp`, `X-SH-Key-Id`, and `X-SH-Signature: v1=hex(HMAC_SHA256(secret, \"{timestamp}.{raw_body}\"))`; verify over the raw body, constant-time, +/-300s window, fail closed.",
"requestBody": { "requestBody": {
@ -396,6 +467,8 @@
}, },
"work_order.updated": { "work_order.updated": {
"post": { "post": {
"operationId": "post-work-order-updated",
"tags": ["Outbound Webhooks"],
"summary": "Outbound: a work order changed", "summary": "Outbound: a work order changed",
"description": "Same envelope and semantics as work_order.created; `data` is the full current state, not a diff.", "description": "Same envelope and semantics as work_order.created; `data` is the full current state, not a diff.",
"requestBody": { "requestBody": {
@ -416,6 +489,8 @@
}, },
"work_order.cancelled": { "work_order.cancelled": {
"post": { "post": {
"operationId": "post-work-order-cancelled",
"tags": ["Outbound Webhooks"],
"summary": "Outbound: a work order transitioned to cancelled", "summary": "Outbound: a work order transitioned to cancelled",
"description": "A specialization of work_order.updated (same body) emitted when `wo_status` transitions to `cancelled`.", "description": "A specialization of work_order.updated (same body) emitted when `wo_status` transitions to `cancelled`.",
"requestBody": { "requestBody": {
@ -436,6 +511,8 @@
}, },
"work_order.comment_added": { "work_order.comment_added": {
"post": { "post": {
"operationId": "post-work-order-comment-added",
"tags": ["Outbound Webhooks"],
"summary": "Outbound: a comment/event record was ingested", "summary": "Outbound: a comment/event record was ingested",
"description": "One per source email (comments, updates, and cancellation event records). Dedupe on `delivery_id` or `data.comment_id`. May occasionally arrive before the work order's created event - upsert a skeleton work order and let the state event backfill it.", "description": "One per source email (comments, updates, and cancellation event records). Dedupe on `delivery_id` or `data.comment_id`. May occasionally arrive before the work order's created event - upsert a skeleton work order and let the state event backfill it.",
"requestBody": { "requestBody": {
@ -482,7 +559,8 @@
"maximum": 500, "maximum": 500,
"default": 100 "default": 100
}, },
"description": "Page size; values outside 1-500 are clamped." "description": "Page size; values outside 1-500 are clamped.",
"example": 100
}, },
"Cursor": { "Cursor": {
"name": "cursor", "name": "cursor",
@ -491,7 +569,8 @@
"schema": { "schema": {
"type": "string" "type": "string"
}, },
"description": "Opaque pagination cursor from the previous page's `next_cursor`. Malformed cursors return 400." "description": "Opaque pagination cursor from the previous page's `next_cursor`. Malformed cursors return 400.",
"example": "eyJ3b3JrX29yZGVyX2lkIjogIjExMTQ0NTgwNzMwIn0"
}, },
"WorkOrderId": { "WorkOrderId": {
"name": "workOrderId", "name": "workOrderId",
@ -501,7 +580,8 @@
"type": "string", "type": "string",
"pattern": "^[0-9]+$" "pattern": "^[0-9]+$"
}, },
"description": "Numeric APM work-order id, e.g. `11144580730`." "description": "Numeric APM work-order id, e.g. `11144580730`.",
"example": "11144580730"
} }
}, },
"responses": { "responses": {
@ -575,7 +655,7 @@
"wo_status": { "wo_status": {
"type": ["string", "null"], "type": ["string", "null"],
"enum": ["new", "assigned", "in_progress", "on_hold", "completed", "cancelled", "unknown", null], "enum": ["new", "assigned", "in_progress", "on_hold", "completed", "cancelled", "unknown", null],
"description": "`unknown` is a real emitted value - map it explicitly." "description": "The `unknown` value is genuinely emitted - map it explicitly."
}, },
"description": { "description": {
"type": ["string", "null"] "type": ["string", "null"]
@ -639,7 +719,7 @@
}, },
"comment_id": { "comment_id": {
"type": "string", "type": "string",
"description": "`{work_order_id}#{time|nocomment}#{sha256(s3_key)[:12]}` - unique per source email, stable across retries; a dedupe key." "description": "Format: `{work_order_id}#{time|nocomment}#{sha256(s3_key)[:12]}` - unique per source email, stable across retries; a dedupe key."
}, },
"record_type": { "record_type": {
"type": ["string", "null"], "type": ["string", "null"],

27
package-lock.json generated Normal file
View file

@ -0,0 +1,27 @@
{
"name": "procurement-ingest",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"@redocly/cli": "^2.40.0"
}
},
"node_modules/@redocly/cli": {
"version": "2.40.0",
"resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.40.0.tgz",
"integrity": "sha512-1uQ4GeNjhApy9EtypZgp70ZN5GC2JFfst3UkNEXSqkXgVIPGdEAnlz5Xwgax/4cEUGOvaZoM3X25iSQcqbplFg==",
"dev": true,
"license": "MIT",
"bin": {
"openapi": "bin/cli.js",
"redocly": "bin/cli.js"
},
"engines": {
"node": ">=22.12.0 || >=20.19.0 <21.0.0",
"npm": ">=10"
}
}
}
}

9
package.json Normal file
View file

@ -0,0 +1,9 @@
{
"scripts": {
"lint:api": "redocly lint lambdas/api/openapi.json --config=redocly.yaml",
"docs:preview": ".venv/bin/python scripts/preview_docs.py"
},
"devDependencies": {
"@redocly/cli": "^2.40.0"
}
}

122
redocly.yaml Normal file
View file

@ -0,0 +1,122 @@
extends:
- recommended
rules:
# Proprietary internal license -- no SPDX identifier or public URL exists.
info-license-strict: off
rule/info-title-api:
subject:
type: Info
property: title
assertions:
pattern: /.*API.*/
rule/info-description:
subject:
type: Info
property: description
assertions:
defined: true
operation-4xx-response: error
# Off: the live contract is {"error": string} as plain application/json
# (serialization.py error_response). Adopting RFC 7807 would be a runtime
# + SHOC-contract change, decided against 2026-07-24.
operation-4xx-problem-details-rfc7807: off
operation-operationId: error
rule/operationId-casing:
subject:
type: Operation
property: operationId
assertions:
casing: kebab-case
rule/operationId-prefix:
subject:
type: Operation
property: operationId
assertions:
pattern: /^GET|PUT|POST|DELETE|OPTIONS|HEAD|PATCH|TRACE/i
rule/operation-summary-period:
subject:
type: Operation
property: summary
assertions:
pattern: /[^.]$/
path-not-include-query: error
# No parameter-casing rule: path parameter names (workOrderId, poNumber,
# siteCode) are camelCase by contract -- they are baked into the API Gateway
# resource paths and read by the handler's pathParameters lookup.
rule/params-must-include-examples:
severity: error
subject:
type: Parameter
assertions:
requireAny:
- example
- examples
no-http-verbs-in-paths: error
no-ambiguous-paths: error
path-segment-plural:
severity: error
exceptions:
- docs
- openapi.json
paths-kebab-case: error
no-invalid-schema-examples: error
# No schema-properties casing rule: property names mirror the DynamoDB
# items and the shipped SHOC webhook contract -- snake_case for WO/PO
# tables, camelCase for verified-sites (legacy, issue #24). Not lintable
# to one casing without a contract break.
# Error bodies must carry the top-level "error" field (the Error schema).
# 403 is exempt: it is emitted by API Gateway's SigV4 layer with AWS's
# {"message"} shape, not by the Lambda.
response-contains-property:
severity: error
names:
'400':
- error
'401':
- error
'404':
- error
'501':
- error
request-mime-type:
severity: error
allowedValues:
- application/json
response-mime-type:
severity: error
allowedValues:
- application/json
- text/html
no-server-example.com: error
rule/no-server-localhost:
subject:
type: Server
property: url
assertions:
notPattern: /(localhost|127.0.0.1)
operation-singular-tag: error
operation-tag-defined: error
rule/tag-description:
subject:
type: Tag
property: description
assertions:
defined: true
rule/description-capitalization:
subject:
type: any
property: description
assertions:
pattern: /^([A-Z]|true|seahaven-prod)/
rule/description-punctuation:
subject:
type: any
property: description
assertions:
pattern: /(\.|server)$/
rule/avoid-words-in-descriptions:
subject:
type: any
property: description
assertions:
notPattern: /(simply|easy|easily|just|obviously|notethat)/i

34
scripts/preview_docs.py Normal file
View file

@ -0,0 +1,34 @@
"""Render the /docs page locally and open it in the default browser.
Goes through the real handler code path (vendored Redoc bundle + fonts
inlined, breakout guards applied, spec spliced), so the preview is
byte-identical to what the Lambda serves, minus the token gate. No AWS
credentials or network needed; without a request context the spec keeps
its committed fallback server URL.
Run from the repo root: `npm run docs:preview` (or
`.venv/bin/python scripts/preview_docs.py`).
"""
import sys
import tempfile
import webbrowser
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT))
from tests.support import load_lambda_module # noqa: E402
def main() -> None:
handler = load_lambda_module("api", "handler")
html = handler._render_docs_html({})
out = Path(tempfile.gettempdir()) / "procurement-docs-preview.html"
out.write_text(html, encoding="utf-8")
print(f"wrote {out} ({len(html)} bytes)")
webbrowser.open(out.as_uri())
if __name__ == "__main__":
main()

View file

@ -118,7 +118,11 @@ def test_docs_served_when_authenticated(api, monkeypatch):
assert "Redoc.init" in docs["body"] assert "Redoc.init" in docs["body"]
assert "Procurement Ingest API" in docs["body"] assert "Procurement Ingest API" in docs["body"]
# Every placeholder must be substituted, or the page renders broken. # Every placeholder must be substituted, or the page renders broken.
for placeholder in (mod._SPEC_PLACEHOLDER, mod._JS_PLACEHOLDER): for placeholder in (
mod._SPEC_PLACEHOLDER,
mod._JS_PLACEHOLDER,
mod._FONTS_PLACEHOLDER,
):
assert placeholder not in docs["body"] assert placeholder not in docs["body"]
spec = mod.handler(_event("GET", "/openapi.json"), None) spec = mod.handler(_event("GET", "/openapi.json"), None)

View file

@ -112,14 +112,20 @@ def test_docs_and_spec_files_ship_with_the_handler():
assert (api_dir / "openapi.json").is_file() assert (api_dir / "openapi.json").is_file()
assert (api_dir / "docs.html").is_file() assert (api_dir / "docs.html").is_file()
assert (api_dir / "redoc.standalone.js").is_file() assert (api_dir / "redoc.standalone.js").is_file()
assert (api_dir / "fonts.css").is_file()
docs = (api_dir / "docs.html").read_text(encoding="utf-8") docs = (api_dir / "docs.html").read_text(encoding="utf-8")
assert "__OPENAPI_SPEC_JSON__" in docs assert "__OPENAPI_SPEC_JSON__" in docs
assert "__REDOC_JS__" in docs assert "__REDOC_JS__" in docs
assert "__FONTS_CSS__" in docs
assert "Redoc.init" in docs assert "Redoc.init" in docs
# The vendored Redoc bundle must carry no raw </script>: it is inlined # The docs page must never fetch fonts (or anything else) off-box: the
# into a <script> block, and a literal </script would break it out. (The # design-system fonts ride inline as data URIs in fonts.css.
# handler also escapes it defensively, but keeping the pinned asset clean assert "fonts.googleapis.com" not in docs
# is the load-bearing guarantee and catches a bad version bump here.) # The vendored blobs must carry no raw </script>/</style>: each is inlined
# into a <script>/<style> block a literal terminator would break out of.
# (The handler also escapes them defensively, but keeping the pinned
# assets clean is the load-bearing guarantee and catches a bad bump here.)
assert "</script" not in (api_dir / "redoc.standalone.js").read_text( assert "</script" not in (api_dir / "redoc.standalone.js").read_text(
encoding="utf-8" encoding="utf-8"
) )
assert "</style" not in (api_dir / "fonts.css").read_text(encoding="utf-8")

View file

@ -113,6 +113,7 @@ API_DATA_FILES_CP_RES = (
r"(?:^|\s)api/openapi\.json(?:\s|$)", r"(?:^|\s)api/openapi\.json(?:\s|$)",
r"(?:^|\s)api/docs\.html(?:\s|$)", r"(?:^|\s)api/docs\.html(?:\s|$)",
r"(?:^|\s)api/redoc\.standalone\.js(?:\s|$)", r"(?:^|\s)api/redoc\.standalone\.js(?:\s|$)",
r"(?:^|\s)api/fonts\.css(?:\s|$)",
) )
# Pipeline-scoped glob shapes the per-stack ships-all pins accept. Phase 2 # Pipeline-scoped glob shapes the per-stack ships-all pins accept. Phase 2