Initial commit: add .gitignore and README
Some checks failed
CI / lint-and-test (push) Has been cancelled
Some checks failed
CI / lint-and-test (push) Has been cancelled
This commit is contained in:
17
docs/api-error-format.md
Normal file
17
docs/api-error-format.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# API error response format
|
||||
|
||||
All API errors use a consistent JSON body:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Human-readable message",
|
||||
"code": "UNAUTHORIZED",
|
||||
"details": {}
|
||||
}
|
||||
```
|
||||
|
||||
- **error** (string): Message for clients and logs.
|
||||
- **code** (string, optional): Machine-readable code. One of `BAD_REQUEST`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `CONFLICT`, `INTERNAL_ERROR`.
|
||||
- **details** (object, optional): Extra data (e.g. validation errors under `details` when `code` is `BAD_REQUEST`).
|
||||
|
||||
HTTP status matches the error (400, 401, 403, 404, 409, 500). The OpenAPI spec references the `ApiError` schema in `components.schemas`.
|
||||
20
docs/architecture.md
Normal file
20
docs/architecture.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Sankofa HW Infra — Architecture
|
||||
|
||||
## Component diagram
|
||||
|
||||
See the plan file for the Mermaid flowchart (Control Plane UI, API, Workflow Engine, PostgreSQL, S3, Integration Layer, IAM, Audit, Logging).
|
||||
|
||||
## Components
|
||||
|
||||
- **Control Plane UI**: React SPA; inventory, procurement, sites, approvals, audit.
|
||||
- **API Layer**: REST `/api/v1`; CRUD for core entities; JWT + RBAC/ABAC; file upload to S3.
|
||||
- **Workflow Engine**: Purchase approvals, inspection checklists (Phase 1+).
|
||||
- **PostgreSQL**: Transactions, core entities, audit_events (append-only).
|
||||
- **Object Storage (S3)**: Invoices, packing lists, inspection photos, serial dumps.
|
||||
- **Integration Layer**: UniFi, Proxmox, Redfish connectors; credentials in Vault.
|
||||
- **IAM**: Roles, permissions; ABAC attributes (site_id, project_id).
|
||||
- **Audit Log**: Who/when/what, before/after; WORM retention.
|
||||
|
||||
## Sovereign cloud positioning
|
||||
|
||||
Sankofa Phoenix operates as a **sovereign cloud services provider**. Multi-tenant isolation is per sovereign (org); UniFi, Proxmox, and hardware inventory form **one source of truth** for determinism and compliance. UniFi telemetry (with product intelligence), rack/power metadata, and Proxmox workloads are synthesized for root-cause analysis, capacity planning, and enforced hardware standards per sovereign profile. See [sovereign-controller-topology.md](sovereign-controller-topology.md), [rbac-sovereign-operations.md](rbac-sovereign-operations.md), and [purchasing-feedback-loop.md](purchasing-feedback-loop.md).
|
||||
7
docs/capacity-dashboard-spec.md
Normal file
7
docs/capacity-dashboard-spec.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Capacity planning dashboard spec
|
||||
|
||||
- **RU utilization**: Per site, sum of assigned positions vs total RU (from racks); show percentage. **Implemented.** API: `GET /api/v1/capacity/sites/:siteId` returns `usedRu`, `totalRu`, and `utilizationPercent`.
|
||||
- **Power headroom**: From rack `power_feeds` (circuit limits). **Implemented.** API: `GET /api/v1/capacity/sites/:siteId/power` returns `circuitLimitWatts`, `measuredDrawWatts` (null until Phase 4), `headroomWatts` (null). Measured draw can be added when telemetry is available.
|
||||
- **GPU inventory**: By type (part number) and location. **Implemented.** API: `GET /api/v1/capacity/gpu-inventory` returns `total`, `bySite`, and `byType`.
|
||||
- **Read-only**: All capacity endpoints are read-only; no edits.
|
||||
- **Web**: Capacity dashboard at `/capacity` shows RU utilization, power headroom, and GPU inventory by site and type.
|
||||
11
docs/cicd.md
Normal file
11
docs/cicd.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# CI/CD pipeline
|
||||
|
||||
- Lint: `pnpm run lint` (ESLint over apps and packages)
|
||||
- Test: `pnpm run test` (Vitest per package)
|
||||
- Build: `pnpm run build` (all workspace packages)
|
||||
|
||||
GitHub Actions: `.github/workflows/ci.yml` runs on push/PR to main: install, lint, test, build.
|
||||
|
||||
Environments: Dev (local + docker-compose), Staging/Production (set DATABASE_URL, S3_*, JWT_SECRET).
|
||||
|
||||
Runbook: Start Postgres via `infra/docker-compose up -d`. Migrate: `pnpm db:migrate`. API: `pnpm --filter @sankofa/api run dev`. Web: `pnpm --filter @sankofa/web run dev`.
|
||||
23
docs/compliance-profiles.md
Normal file
23
docs/compliance-profiles.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Compliance profiles
|
||||
|
||||
Compliance profiles define **firmware freeze**, **allowed hardware generations**, and **approved SKUs** per sovereign (org) or per site. They feed purchasing (approved buy lists) and UniFi device approval.
|
||||
|
||||
## Purpose
|
||||
|
||||
- **Firmware freeze:** Lock to a version or range (e.g. 2024.Q2, or min/max version) so only compliant firmware is allowed.
|
||||
- **Allowed generations:** Restrict hardware to e.g. Gen2 and Enterprise only (from UniFi product catalog).
|
||||
- **Approved SKUs:** Explicit list of SKUs that may be purchased or deployed; optional per-site override.
|
||||
|
||||
Profiles are attached to `org_id` (sovereign/tenant); optionally `site_id` for site-specific rules.
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/v1/compliance-profiles` — list profiles for the current org.
|
||||
- `GET /api/v1/compliance-profiles/:id` — get one profile.
|
||||
- `POST /api/v1/compliance-profiles` — create (body: name, firmwareFreezePolicy, allowedGenerations, approvedSkus, siteId).
|
||||
- `PATCH /api/v1/compliance-profiles/:id` — update.
|
||||
- `DELETE /api/v1/compliance-profiles/:id` — delete.
|
||||
|
||||
## Use in validation
|
||||
|
||||
When generating the **approved purchasing catalog** or when syncing UniFi devices, filter or flag by compliance profile: only SKUs in `approved_skus` or in `allowed_generations` (from the UniFi product catalog) are considered approved for that sovereign/site.
|
||||
70
docs/erd.md
Normal file
70
docs/erd.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Database ERD
|
||||
|
||||
## Entity relationship overview
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
org_units ||--o{ org_units : parent
|
||||
org_units ||--o{ users : org_unit
|
||||
users ||--o{ user_roles : user
|
||||
roles ||--o{ user_roles : role
|
||||
sites ||--o{ user_roles : scope_site
|
||||
|
||||
vendors ||--o{ vendor_bank_details : vendor
|
||||
vendors ||--o{ offers : vendor
|
||||
vendors ||--o{ purchase_orders : vendor
|
||||
|
||||
regions ||--o{ sites : region
|
||||
sites ||--o{ rooms : site
|
||||
rooms ||--o{ rows : room
|
||||
rows ||--o{ racks : row
|
||||
racks ||--o{ positions : rack
|
||||
sites ||--o{ assets : site
|
||||
positions ||--o{ assets : position
|
||||
users ||--o{ assets : owner
|
||||
|
||||
assets ||--o{ asset_components : parent
|
||||
assets ||--o{ asset_components : child
|
||||
assets ||--o{ provisioning_records : asset
|
||||
assets ||--o{ maintenances : asset
|
||||
|
||||
purchase_orders }o--|| sites : inspection_site
|
||||
purchase_orders }o--|| sites : delivery_site
|
||||
purchase_orders ||--o{ shipments : po
|
||||
users ||--o{ audit_events : actor
|
||||
|
||||
org_units { uuid id text name uuid parent_id text org_id }
|
||||
users { uuid id text email text org_id uuid org_unit_id }
|
||||
vendors { uuid id text org_id text legal_name text trust_tier }
|
||||
offers { uuid id text org_id uuid vendor_id int quantity decimal unit_price text status }
|
||||
purchase_orders { uuid id text org_id uuid vendor_id jsonb line_items text status }
|
||||
shipments { uuid id uuid purchase_order_id text tracking text status }
|
||||
regions { uuid id text org_id text name }
|
||||
sites { uuid id text org_id uuid region_id text name jsonb network_metadata }
|
||||
rooms { uuid id uuid site_id text name }
|
||||
rows { uuid id uuid room_id text name }
|
||||
racks { uuid id uuid row_id text name int ru_total jsonb power_feeds }
|
||||
positions { uuid id uuid rack_id int ru_start int ru_end uuid asset_id }
|
||||
assets { uuid id text org_id text asset_id text category text status uuid site_id uuid position_id }
|
||||
asset_components { uuid id uuid parent_asset_id uuid child_asset_id text role }
|
||||
provisioning_records { uuid id uuid asset_id text hypervisor_node text cluster_id }
|
||||
maintenances { uuid id text org_id uuid asset_id text type text status }
|
||||
audit_events { uuid id text org_id uuid actor_id text action text resource_type text resource_id jsonb before_state jsonb after_state timestamp occurred_at }
|
||||
roles { uuid id text name jsonb permissions }
|
||||
user_roles { uuid user_id uuid role_id uuid scope_site_id text scope_project_id }
|
||||
```
|
||||
|
||||
## Core tables
|
||||
|
||||
- **org_units**, **users**: Tenancy and org hierarchy.
|
||||
- **vendors**, **vendor_bank_details**: Vendor master; versioned bank details with dual approval.
|
||||
- **offers**: SKU/MPN, quantity, price, evidence_refs, risk_score, status.
|
||||
- **purchase_orders**: Line items, approval_stage, escrow_terms, inspection_site_id, delivery_site_id.
|
||||
- **shipments**: PO link, tracking, customs_docs_refs.
|
||||
- **regions**, **sites**, **rooms**, **rows**, **racks**, **positions**: Site hierarchy and RU mapping.
|
||||
- **assets**: asset_id, category, serials, proof_artifact_refs, site_id, position_id, status, chain_of_custody.
|
||||
- **asset_components**: parent_asset_id, child_asset_id, role (gpu/cpu/dimm/nic).
|
||||
- **provisioning_records**: OS image, hypervisor node, cluster_id.
|
||||
- **maintenances**: RMA/incident/part_swap; vendor_ticket_ref.
|
||||
- **audit_events**: Append-only; actor_id, action, resource_type, resource_id, before_state, after_state.
|
||||
- **roles**, **user_roles**: RBAC; scope_site_id, scope_project_id for ABAC.
|
||||
2
docs/integration-spec-proxmox.md
Normal file
2
docs/integration-spec-proxmox.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# Proxmox integration spec
|
||||
Use cases: nodes, inventory. Auth: token per site (Vault). Map Asset to node via integration_mappings.
|
||||
2
docs/integration-spec-redfish.md
Normal file
2
docs/integration-spec-redfish.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# Redfish integration spec
|
||||
Use cases: verify serials, power cycle. Credentials in Vault per site.
|
||||
21
docs/integration-spec-unifi.md
Normal file
21
docs/integration-spec-unifi.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# UniFi integration spec
|
||||
|
||||
UniFi is positioned as a **hardware identity and telemetry source**, a **product-line intelligence feed**, and a **procurement and lifecycle signal**—not only as networking gear. The platform integrates UniFi OS, UniFi Network Application, firmware catalogs, device generation, and support-horizon mapping so Sankofa Phoenix can answer: what exact hardware is deployed, what generation and firmware lineage, what support status, and is this infrastructure policy-compliant for this sovereign body?
|
||||
|
||||
**Use cases:** Discover devices, map ports, push port profiles; plus hardware identity, EoL/support horizon, and compliance-relevant metadata. Auth: API token per site (Vault). Sync: nightly; store in integration_mappings.
|
||||
|
||||
## UniFi Product Intelligence layer
|
||||
|
||||
UniFi is used as a **hardware identity and telemetry source**, not only networking. The platform maintains a canonical **UniFi product catalog** (`unifi_product_catalog`) with:
|
||||
|
||||
- SKU, model name, generation (Gen1 / Gen2 / Enterprise)
|
||||
- Performance class, EoL date, support horizon
|
||||
- `approved_sovereign_default` for purchasing and compliance
|
||||
|
||||
**API:** `GET /api/v1/integrations/unifi/product-catalog` (optional `?generation=`, `?approved_sovereign=true`), `GET /api/v1/integrations/unifi/product-catalog/:sku`. Device list `GET .../unifi/sites/:siteId/devices` returns devices enriched with `generation` and `support_horizon` from the catalog when the device model matches.
|
||||
|
||||
This layer feeds **purchasing** (approved buy lists, BOMs) and **compliance** (approved SKUs per sovereign, support-risk views).
|
||||
|
||||
## Sovereign-safe controller architecture
|
||||
|
||||
Per-sovereign UniFi controller domains with no cross-sovereign write. See [sovereign-controller-topology.md](sovereign-controller-topology.md) for the diagram and trust boundaries. Optionally store controller endpoints in the `unifi_controllers` table (org_id, site_id, base_url, role: sovereign_write | oversight_read_only, region); credentials remain in Vault. API: CRUD under `GET/POST/PATCH/DELETE /api/v1/unifi-controllers`, scoped by org_id.
|
||||
111
docs/next-steps-before-swagger-and-ui.md
Normal file
111
docs/next-steps-before-swagger-and-ui.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Next steps before full Swagger docs and UX/UI
|
||||
|
||||
Do these in order so the API contract is stable and the front end has a clear target.
|
||||
|
||||
---
|
||||
|
||||
## 1. Auth and identity
|
||||
|
||||
- **Login / token endpoint**
|
||||
There is no in-app login. JWTs are assumed to come from an external IdP. Before UI:
|
||||
- Either add **POST /auth/login** (or /auth/token) that accepts credentials, looks up `users` + `user_roles`, and returns a JWT with `roles` and (for vendor users) `vendorId`, **or**
|
||||
- Document the exact JWT shape and how your IdP must set `roles` and `vendorId` so the UI can integrate.
|
||||
- **User and role management (optional)**
|
||||
Schema has `users`, `roles`, `user_roles`, but no API. For a self-contained product, add **CRUD for users** and **assignment of roles** (and `vendor_id` for vendor users) so admins can onboard users and vendors without touching the DB directly.
|
||||
|
||||
---
|
||||
|
||||
## 2. API contract and behavior
|
||||
|
||||
- **Request validation**
|
||||
Add JSON Schema (or Zod) for request bodies and path/query params on all routes so invalid input returns **400** with a consistent error shape instead of 500 or undefined behavior.
|
||||
- **Error response format**
|
||||
Standardize error payloads (e.g. `{ error: string, code?: string, details?: unknown }`) and document them so Swagger and the UI can show the same errors.
|
||||
- **Optional: list pagination**
|
||||
List endpoints (vendors, offers, assets, sites, etc.) return full arrays. Add `limit`/`offset` or `page`/`pageSize` and a total/cursor so the UI and docs can assume a stable list contract.
|
||||
|
||||
---
|
||||
|
||||
## 3. RBAC enforcement
|
||||
|
||||
- **Wire permissions to routes**
|
||||
`requirePermission` exists but is not used on route handlers. For each route, add the appropriate `requirePermission(...)` (or equivalent) so that missing permission returns **403** with a clear message. This makes the API safe to document and use from the UI.
|
||||
|
||||
---
|
||||
|
||||
## 4. OpenAPI completeness (prerequisite for Swagger)
|
||||
|
||||
- **Document all paths**
|
||||
OpenAPI currently documents only health, vendors, offers, purchase-orders, and ingestion. Add the rest so Swagger matches the real API:
|
||||
- **Assets**: GET/POST /assets, GET/PATCH/DELETE /assets/:id
|
||||
- **Sites**: GET/POST /sites, GET/PATCH/DELETE /sites/:id, and nested (rooms, rows, racks, positions) if exposed
|
||||
- **Workflow**: POST /workflow/offers/:id/risk-score, POST /workflow/purchase-orders/:id/submit, approve, reject, PATCH status
|
||||
- **Inspection**: templates and runs
|
||||
- **Shipments**: CRUD
|
||||
- **Asset components**: CRUD
|
||||
- **Capacity**: GET endpoints
|
||||
- **Integrations**: UniFi, product-catalog, Proxmox, mappings
|
||||
- **Maintenances**: CRUD
|
||||
- **Compliance profiles**: CRUD
|
||||
- **UniFi controllers**: CRUD
|
||||
- **Reports**: BOM, support-risk
|
||||
- **Upload**: POST /upload (multipart)
|
||||
- **Request/response schemas**
|
||||
For each path, add `requestBody` and `responses` with schema (or $ref to `components/schemas`) so Swagger can show request/response bodies and generate client types.
|
||||
- **Security per path**
|
||||
Mark which paths use BearerAuth, which use IngestionApiKey, and which are public (e.g. health).
|
||||
|
||||
---
|
||||
|
||||
## 5. Environment and config
|
||||
|
||||
- **env.example**
|
||||
Add `INGESTION_API_KEY` (and any OIDC/SSO vars if you add login) so deployers and the docs know what to set.
|
||||
- **API base URL for web**
|
||||
Ensure the web app can be configured with the API base URL (e.g. env `VITE_API_URL` or similar) so Swagger and the UI both target the same backend.
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing
|
||||
|
||||
- **Stabilize the contract**
|
||||
Add or expand API tests for critical paths (e.g. vendors, offers, purchase-orders, workflow, ingestion) so that when you add Swagger and the UI, changes to the API are caught by tests.
|
||||
- **Optional: contract tests**
|
||||
Consider testing that responses match a minimal schema (e.g. required fields) so the OpenAPI spec and the implementation stay in sync.
|
||||
|
||||
---
|
||||
|
||||
## 7. Web app baseline (before full UX/UI)
|
||||
|
||||
- **API client**
|
||||
Add a minimal API client (fetch or axios) that sends the JWT (and `x-org-id` if required) so all UI calls go through one place and can be swapped for generated clients later.
|
||||
- **Auth in the client**
|
||||
Implement login (or redirect to IdP), store the token, and attach it to every request; handle 401 (e.g. redirect to login or refresh).
|
||||
- **Feature flags or minimal nav**
|
||||
Add a simple nav or list of areas (e.g. Vendors, Offers, Purchase orders, Assets, Sites) so the “full UX/UI” phase can fill in one screen at a time without redoing routing.
|
||||
|
||||
---
|
||||
|
||||
## 8. Then: full Swagger and UX/UI
|
||||
|
||||
After the above:
|
||||
|
||||
- **Full Swagger**
|
||||
Serve the OpenAPI spec (e.g. from `/api/openapi.json` or `/api/docs`) and mount Swagger UI (or Redoc) so all operations and schemas are discoverable and try-it-now works.
|
||||
- **Full UX/UI**
|
||||
Build out screens, forms, and flows using the stable API and client; keep OpenAPI and the UI in sync via the same base URL and error format.
|
||||
|
||||
---
|
||||
|
||||
## Summary checklist
|
||||
|
||||
| # | Area | Action |
|
||||
|---|-------------------|--------|
|
||||
| 1 | Auth | Login/token endpoint or IdP contract; optional users/roles API |
|
||||
| 2 | API contract | Request validation; consistent error format; optional pagination |
|
||||
| 3 | RBAC | Use requirePermission on routes; return 403 where appropriate |
|
||||
| 4 | OpenAPI | Document all paths, request/response schemas, security |
|
||||
| 5 | Env | env.example (INGESTION_API_KEY, etc.); web API base URL |
|
||||
| 6 | Tests | Broader API tests; optional contract/schema tests |
|
||||
| 7 | Web baseline | API client, auth (token + 401), minimal nav/routes |
|
||||
| 8 | Swagger + UI | Serve spec + Swagger UI; build out full screens |
|
||||
2
docs/observability.md
Normal file
2
docs/observability.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# Observability
|
||||
Central logging: ELK or OpenSearch. Metrics: Prometheus + Grafana. Alerting on API errors and integration sync failures. API uses Fastify logger (structured).
|
||||
99
docs/offer-ingestion.md
Normal file
99
docs/offer-ingestion.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Offer ingestion (scrape and email)
|
||||
|
||||
Offers can be ingested from external sources so they appear in the database for potential purchases, without manual data entry.
|
||||
|
||||
## Sources
|
||||
|
||||
1. **Scraped** – e.g. site content from theserverstore.com (Peter as Manager). A scraper job fetches pages, parses offer-like content, and creates offer records.
|
||||
2. **Email** – a dedicated mailbox accepts messages (e.g. from Sergio and others); a pipeline parses them and creates offer records.
|
||||
|
||||
Ingested offers are stored with:
|
||||
|
||||
- `source`: `scraped` or `email`
|
||||
- `source_ref`: URL (scrape) or email message id (email)
|
||||
- `source_metadata`: optional JSON (e.g. sender, subject, page title, contact name)
|
||||
- `ingested_at`: timestamp of ingestion
|
||||
- `vendor_id`: optional; may be null until procurement assigns the offer to a vendor
|
||||
|
||||
## API: ingestion endpoint
|
||||
|
||||
Internal or automated callers use a dedicated endpoint, secured by an API key (no user JWT).
|
||||
|
||||
**POST** `/api/v1/ingestion/offers`
|
||||
|
||||
- **Auth:** Header `x-ingestion-api-key` must equal the environment variable `INGESTION_API_KEY`. If missing or wrong, returns `401`.
|
||||
- **Org:** Header `x-org-id` (default `default`) specifies the org for the new offer.
|
||||
|
||||
**Body (JSON):**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `source` | `"scraped"` \| `"email"` | yes | Ingestion source |
|
||||
| `source_ref` | string | no | URL or message id |
|
||||
| `source_metadata` | object | no | e.g. `{ "sender": "Sergio", "subject": "...", "page_url": "..." }` |
|
||||
| `vendor_id` | UUID | no | Vendor to attach; omit for unassigned |
|
||||
| `sku` | string | no | |
|
||||
| `mpn` | string | no | |
|
||||
| `quantity` | number | yes | |
|
||||
| `unit_price` | string | yes | Decimal |
|
||||
| `incoterms` | string | no | |
|
||||
| `lead_time_days` | number | no | |
|
||||
| `country_of_origin` | string | no | |
|
||||
| `condition` | string | no | |
|
||||
| `warranty` | string | no | |
|
||||
| `evidence_refs` | array | no | `[{ "key": "s3-key", "hash": "..." }]` |
|
||||
|
||||
**Response:** `201` with the created offer (including `id`, `source`, `source_ref`, `source_metadata`, `ingested_at`).
|
||||
|
||||
Example (scrape):
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "scraped",
|
||||
"source_ref": "https://theserverstore.com/...",
|
||||
"source_metadata": { "contact": "Peter", "site": "theserverstore.com" },
|
||||
"vendor_id": null,
|
||||
"sku": "DL380-G9",
|
||||
"quantity": 2,
|
||||
"unit_price": "450.00",
|
||||
"condition": "refurbished"
|
||||
}
|
||||
```
|
||||
|
||||
Example (email):
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "email",
|
||||
"source_ref": "msg-12345",
|
||||
"source_metadata": { "from": "sergio@example.com", "subject": "Quote for R630" },
|
||||
"vendor_id": null,
|
||||
"mpn": "PowerEdge R630",
|
||||
"quantity": 1,
|
||||
"unit_price": "320.00"
|
||||
}
|
||||
```
|
||||
|
||||
## Scraper (e.g. theserverstore.com)
|
||||
|
||||
- **Responsibility:** Fetch pages (respecting robots.txt and rate limits), extract product/offer fields, then POST to `POST /api/v1/ingestion/offers` for each offer.
|
||||
- **Where:** Can run as a scheduled job in `apps/` or `packages/`, or as an external service that calls the API. No scraper implementation is in-repo yet; this doc defines the contract.
|
||||
- **Vendor:** If the site is known (e.g. The Server Store, Peter as Manager), the scraper can resolve or create a vendor and pass `vendor_id`; otherwise leave null for procurement to assign later.
|
||||
- **Idempotency:** Use `source_ref` (e.g. canonical product URL) so the same offer is not duplicated; downstream you can upsert by `(org_id, source, source_ref)` if desired.
|
||||
|
||||
## Email intake (e.g. Sergio and others)
|
||||
|
||||
- **Flow:** Incoming messages to a dedicated mailbox (e.g. `offers@your-org.com`) are read by an IMAP poller or processed via an inbound webhook (SendGrid, Mailgun, etc.). The pipeline parses sender, subject, body, and optional attachments, then POSTs one or more payloads to `POST /api/v1/ingestion/offers`.
|
||||
- **Storing raw email:** Attachments or full message can be uploaded to object storage (e.g. S3/MinIO) and referenced in `evidence_refs` or `source_metadata` (e.g. `raw_message_key`).
|
||||
- **Vendor matching:** Match sender address or name to an existing vendor and set `vendor_id` when possible; otherwise leave null and set `source_metadata.sender` / `from` for later assignment.
|
||||
|
||||
## Configuration
|
||||
|
||||
- Set `INGESTION_API_KEY` in the environment where the API runs. Scraper and email pipeline must use the same value in `x-ingestion-api-key`.
|
||||
- Use `x-org-id` on each request to target the correct org.
|
||||
|
||||
## Procurement workflow
|
||||
|
||||
- Ingested offers appear in the offers list with `source` = `scraped` or `email` and optional `vendor_id`.
|
||||
- Offers with `vendor_id` null are “unassigned”; procurement can assign them to a vendor (PATCH offer or create/link vendor then update offer).
|
||||
- Existing RBAC and org/site scoping apply; audit can track creation via `ingested_at` and `source_metadata`.
|
||||
175
docs/openapi.yaml
Normal file
175
docs/openapi.yaml
Normal file
@@ -0,0 +1,175 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Sankofa HW Infra API
|
||||
version: 0.1.0
|
||||
servers:
|
||||
- url: /api/v1
|
||||
security:
|
||||
- BearerAuth: []
|
||||
components:
|
||||
schemas:
|
||||
ApiError:
|
||||
type: object
|
||||
properties:
|
||||
error: { type: string, description: Human-readable message }
|
||||
code: { type: string, enum: [BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, CONFLICT, INTERNAL_ERROR] }
|
||||
details: { type: object, description: Optional validation or extra data }
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: JWT with optional vendorId for vendor users
|
||||
IngestionApiKey:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: x-ingestion-api-key
|
||||
description: Required for POST /ingestion/offers (env INGESTION_API_KEY)
|
||||
paths:
|
||||
/health:
|
||||
get:
|
||||
summary: Health
|
||||
security: []
|
||||
/auth/token:
|
||||
post:
|
||||
summary: Get JWT token
|
||||
description: Exchange email (and optional password) for a JWT with roles and vendorId. No auth required.
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [email]
|
||||
properties:
|
||||
email: { type: string, format: email }
|
||||
password: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: Token and user info
|
||||
"401":
|
||||
description: Invalid credentials
|
||||
/vendors:
|
||||
get:
|
||||
summary: List vendors
|
||||
description: If JWT contains vendorId (vendor user), returns only that vendor.
|
||||
post:
|
||||
summary: Create vendor
|
||||
description: Forbidden for vendor users.
|
||||
/vendors/{id}:
|
||||
get:
|
||||
summary: Get vendor
|
||||
description: Vendor users may only request their own vendor id.
|
||||
/offers:
|
||||
get:
|
||||
summary: List offers
|
||||
description: If JWT contains vendorId, returns only that vendor's offers.
|
||||
post:
|
||||
summary: Create offer
|
||||
description: Vendor users' vendorId is forced to their vendor.
|
||||
/offers/{id}:
|
||||
get:
|
||||
summary: Get offer
|
||||
patch:
|
||||
summary: Update offer
|
||||
delete:
|
||||
summary: Delete offer
|
||||
/purchase-orders:
|
||||
get:
|
||||
summary: List purchase orders
|
||||
description: If JWT contains vendorId, returns only POs for that vendor.
|
||||
/purchase-orders/{id}:
|
||||
get:
|
||||
summary: Get purchase order
|
||||
/ingestion/offers:
|
||||
post:
|
||||
summary: Ingest offer (scrape or email)
|
||||
description: Creates an offer with source (scraped|email), source_ref, source_metadata. Secured by x-ingestion-api-key only; no JWT. Use x-org-id for target org.
|
||||
security:
|
||||
- IngestionApiKey: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [source, quantity, unit_price]
|
||||
properties:
|
||||
source:
|
||||
type: string
|
||||
enum: [scraped, email]
|
||||
source_ref:
|
||||
type: string
|
||||
description: URL or email message id
|
||||
source_metadata:
|
||||
type: object
|
||||
vendor_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
sku:
|
||||
type: string
|
||||
mpn:
|
||||
type: string
|
||||
quantity:
|
||||
type: integer
|
||||
unit_price:
|
||||
type: string
|
||||
incoterms:
|
||||
type: string
|
||||
lead_time_days:
|
||||
type: integer
|
||||
country_of_origin:
|
||||
type: string
|
||||
condition:
|
||||
type: string
|
||||
warranty:
|
||||
type: string
|
||||
evidence_refs:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key: { type: string }
|
||||
hash: { type: string }
|
||||
responses:
|
||||
"201":
|
||||
description: Offer created
|
||||
"401":
|
||||
description: Invalid or missing x-ingestion-api-key
|
||||
/capacity/sites/{siteId}:
|
||||
get:
|
||||
summary: RU utilization for a site
|
||||
description: Returns usedRu, totalRu, utilizationPercent for the site (from racks and assigned positions).
|
||||
parameters:
|
||||
- name: siteId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
responses:
|
||||
"200":
|
||||
description: Site capacity (usedRu, totalRu, utilizationPercent)
|
||||
"404":
|
||||
description: Site not found
|
||||
/capacity/sites/{siteId}/power:
|
||||
get:
|
||||
summary: Power headroom for a site
|
||||
description: Returns circuitLimitWatts from rack power_feeds; measuredDrawWatts/headroomWatts null until Phase 4.
|
||||
parameters:
|
||||
- name: siteId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
responses:
|
||||
"200":
|
||||
description: Power info (circuitLimitWatts, measuredDrawWatts, headroomWatts)
|
||||
"404":
|
||||
description: Site not found
|
||||
/capacity/gpu-inventory:
|
||||
get:
|
||||
summary: GPU inventory
|
||||
description: Returns total, bySite, and byType (part number) counts.
|
||||
responses:
|
||||
"200":
|
||||
description: GPU counts (total, bySite, byType)
|
||||
95
docs/operational-baseline.md
Normal file
95
docs/operational-baseline.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Operational baseline — current hardware running / in-hand
|
||||
|
||||
Hardware already deployed, active, or physically in-hand (not part of available wholesale inventory). Quantities marked **TBD** are to be confirmed and locked during physical audit. Once confirmed, this document is the **authoritative operational baseline** for Sankofa Phoenix.
|
||||
|
||||
---
|
||||
|
||||
## A1. Compute servers (operational)
|
||||
|
||||
### HPE ProLiant ML110 series
|
||||
|
||||
- **Role:** Core services / management / utility workloads
|
||||
- **Form factor:** Tower / rack-convertible
|
||||
- **Status:** Running / in-hand
|
||||
- **Quantity:** TBD
|
||||
- **Notes:** Suitable for control-plane services, monitoring, identity, light virtualization
|
||||
|
||||
### Dell PowerEdge R630
|
||||
|
||||
- **Role:** General compute / virtualization / legacy workloads
|
||||
- **Form factor:** 1U rackmount
|
||||
- **Status:** Running / in-hand
|
||||
- **Quantity:** TBD
|
||||
- **Notes:** Ideal for Proxmox clusters, utility VMs, staging environments
|
||||
|
||||
---
|
||||
|
||||
## A2. Network and edge infrastructure
|
||||
|
||||
### UniFi Dream Machine Pro (UDM Pro)
|
||||
|
||||
- **Role:** Edge gateway, UniFi OS controller, firewall
|
||||
- **Status:** Running / in-hand
|
||||
- **Quantity:** TBD
|
||||
- **Notes:** Per-site edge control; candidate for per-sovereign controller domains
|
||||
|
||||
### UniFi XG switches
|
||||
|
||||
- **Role:** High-throughput aggregation / core switching
|
||||
- **Status:** Running / in-hand
|
||||
- **Quantity:** TBD
|
||||
- **Notes:** 10G/25G backbone for compute and storage traffic
|
||||
|
||||
---
|
||||
|
||||
## A3. ISP and external connectivity
|
||||
|
||||
### Spectrum Business cable modems
|
||||
|
||||
- **Role:** Primary or secondary WAN connectivity
|
||||
- **Status:** Installed / in-hand
|
||||
- **Quantity:** TBD
|
||||
- **Notes:** Business-class internet access; typically paired with UDM Pro
|
||||
|
||||
---
|
||||
|
||||
## A4. Physical infrastructure and power
|
||||
|
||||
### APC equipment cabinets
|
||||
|
||||
- **Role:** Secure rack enclosure
|
||||
- **Status:** Installed / in-hand
|
||||
- **Quantity:** TBD
|
||||
- **Notes:** Houses compute, network, and power equipment
|
||||
|
||||
### APC UPS units
|
||||
|
||||
- **Role:** Power conditioning and battery backup
|
||||
- **Status:** Installed / in-hand
|
||||
- **Quantity:** TBD
|
||||
- **Notes:** Runtime and load to be captured per site for capacity planning
|
||||
|
||||
---
|
||||
|
||||
## A5. Operational classification summary
|
||||
|
||||
| Category | Status | Quantity |
|
||||
| ------------------- | --------- | -------- |
|
||||
| ML110 servers | Running | TBD |
|
||||
| Dell R630 servers | Running | TBD |
|
||||
| UDM Pro | Running | TBD |
|
||||
| UniFi XG switches | Running | TBD |
|
||||
| Spectrum modems | Installed | TBD |
|
||||
| APC cabinets | Installed | TBD |
|
||||
| APC UPS units | Installed | TBD |
|
||||
|
||||
---
|
||||
|
||||
## A6. Next actions (to finalize baseline)
|
||||
|
||||
1. **Physical audit** — Lock quantities and serials per site/rack.
|
||||
2. **Import into sankofa-hw-infra** — Create as **Operational Assets** (assets with category, site, rack position).
|
||||
3. **Attach to sites, racks, power feeds** — Populate site hierarchy and power metadata.
|
||||
4. **Enable integrations** — UniFi (device mapping), Proxmox (node ↔ server), UPS monitoring where supported.
|
||||
|
||||
After quantities and serials are confirmed, this appendix is the authoritative operational baseline for capacity planning, BOM, and compliance.
|
||||
29
docs/purchasing-feedback-loop.md
Normal file
29
docs/purchasing-feedback-loop.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Purchasing feedback loop
|
||||
|
||||
How UniFi telemetry and product intelligence drive approved buy lists, BOMs, and support-risk views.
|
||||
|
||||
## Data flow
|
||||
|
||||
1. **UniFi device sync** — Devices are synced from each sovereign’s controller; device list includes model/SKU.
|
||||
2. **Product catalog lookup** — Each device model/SKU is matched against `unifi_product_catalog` (generation, EoL, support horizon).
|
||||
3. **Outputs:**
|
||||
- **SKU-normalized BOM** per sovereign/site: which exact hardware is deployed, with generation and support status.
|
||||
- **Support-risk heatmap:** devices near EoL or with short support horizon.
|
||||
- **Firmware divergence alerts:** when firmware versions drift from policy (see compliance profiles).
|
||||
- **Approved purchasing catalog:** only SKUs that meet the sovereign’s compliance profile (allowed generations, approved_skus).
|
||||
|
||||
## Approved buy list
|
||||
|
||||
The “approved buy list” is the intersection of:
|
||||
|
||||
- Devices in use or recommended (from UniFi + catalog), and
|
||||
- Catalog entries with `approved_sovereign_default` or matching the org’s **compliance profile** (allowed_generations, approved_skus).
|
||||
|
||||
So operations (what we have and what’s supported) drives procurement (what we’re allowed to buy), not the other way around.
|
||||
|
||||
## Optional API
|
||||
|
||||
- `GET /api/v1/reports/bom?org_id=&site_id=` — Aggregate assets + UniFi mappings + catalog for a BOM.
|
||||
- `GET /api/v1/reports/support-risk?org_id=&horizon_months=12` — Devices with EoL or support horizon within the next N months.
|
||||
|
||||
These can be implemented as thin wrappers over existing schema, `unifi_product_catalog`, and `integration_mappings`.
|
||||
36
docs/rbac-sovereign-operations.md
Normal file
36
docs/rbac-sovereign-operations.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# RBAC matrix for sovereign operations
|
||||
|
||||
Who can **see**, who can **change**, and who can **approve** (by role and by site/sovereign) for UniFi, compliance, and purchasing.
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | Description |
|
||||
|------------|-------------|
|
||||
| unifi:read | Read UniFi devices and product catalog within assigned site/org |
|
||||
| unifi:write | Change UniFi mappings and controller config within assigned site/org |
|
||||
| unifi_oversight:read | Read-only across sovereigns (central oversight; no write) |
|
||||
| compliance:read | View compliance profiles |
|
||||
| compliance:write | Create/update/delete compliance profiles |
|
||||
| purchasing_catalog:read | View approved buy lists and BOMs |
|
||||
|
||||
## Role vs permission (sovereign-relevant)
|
||||
|
||||
| Role | unifi:read | unifi:write | unifi_oversight:read | compliance:read | compliance:write | purchasing_catalog:read |
|
||||
|------|:----------:|:-----------:|:--------------------:|:----------------:|:-----------------:|:------------------------:|
|
||||
| super_admin | yes | yes | yes | yes | yes | yes |
|
||||
| security_admin | | | yes | yes | yes | |
|
||||
| procurement_manager | yes | | | | | yes |
|
||||
| finance_approver | | | | | | yes |
|
||||
| site_admin | yes | yes | | yes | | |
|
||||
| noc_operator | yes | | | | | |
|
||||
| read_only_auditor | yes | | | yes | | yes |
|
||||
| partner_inspector | | | | | | |
|
||||
|
||||
## Scoping rules
|
||||
|
||||
- **unifi:read** and **unifi:write** apply only within the operator’s assigned **site** or **org** (via `user_roles.scope_site_id` / org). No cross-sovereign write.
|
||||
- **unifi_oversight:read** is the only cross-sovereign read; used by central Sankofa Phoenix oversight. No write authority.
|
||||
- **compliance:read** / **compliance:write** are scoped by org (sovereign); enforce in API so users only see/edit profiles for their org.
|
||||
- **purchasing_catalog:read** is scoped by org/site so approved lists and BOMs are sovereign-specific.
|
||||
|
||||
Existing ABAC (e.g. `scope_site_id` on user_roles) enforces these boundaries; ensure new integration and compliance endpoints check permission and org/site scope.
|
||||
4
docs/runbooks/incident-response.md
Normal file
4
docs/runbooks/incident-response.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# Incident response runbook
|
||||
1. Triage: check audit log and health endpoints.
|
||||
2. Isolate affected assets or revoke credentials if compromise.
|
||||
3. Notify; post-mortem and update runbooks.
|
||||
6
docs/runbooks/provisioning-and-integration.md
Normal file
6
docs/runbooks/provisioning-and-integration.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Runbook: Provisioning and integration checks
|
||||
|
||||
- **Proxmox**: Register node; add mapping via POST /api/v1/integrations/mappings (provider=proxmox, externalId=node name). Sync nodes via scheduled job or manual trigger.
|
||||
- **UniFi**: Map switch/port to rack position; store in integration_mappings with metadata (device id, port index).
|
||||
- **Redfish**: At receiving, optionally call Redfish to verify serial and firmware; store result in asset proof artifacts.
|
||||
- **Checks**: Verify mapping exists for asset before provisioning; confirm credentials in Vault for the site.
|
||||
9
docs/runbooks/receiving-and-inspection.md
Normal file
9
docs/runbooks/receiving-and-inspection.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Runbook: Receiving and Inspection
|
||||
|
||||
## Inspection
|
||||
1. Create inspection run from template.
|
||||
2. Upload evidence; set pass/fail.
|
||||
3. If fail: claim. If pass: approve release.
|
||||
|
||||
## Receiving
|
||||
1. Reconcile shipment with PO. 2. Assign rack; set asset Received then Staged.
|
||||
13
docs/runbooks/receiving-and-racking.md
Normal file
13
docs/runbooks/receiving-and-racking.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Runbook: Receiving and Racking
|
||||
|
||||
## Receiving
|
||||
1. Create shipment for PO; scan items.
|
||||
2. POST /api/v1/shipments/:id/receive with assetIds to set assets to received.
|
||||
3. Update shipment status to received.
|
||||
|
||||
## Racking
|
||||
1. Assign asset to position: PATCH /api/v1/assets/:id with positionId.
|
||||
2. Set asset status to staged.
|
||||
|
||||
## Capacity
|
||||
GET /api/v1/capacity/sites/:siteId and GET /api/v1/capacity/gpu-inventory for dashboards.
|
||||
2
docs/security.md
Normal file
2
docs/security.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# Security
|
||||
Secrets: Vault/KMS; rotate API tokens. MFA for privileged roles. Dual control: vendor bank details and PO final approval (Phase 1). Attachment malware scanning (Phase 4). Data retention policies by doc type.
|
||||
42
docs/sovereign-controller-topology.md
Normal file
42
docs/sovereign-controller-topology.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Sovereign controller topology
|
||||
|
||||
Per-sovereign UniFi controller domains, regionally isolated management planes, and a central read-only oversight layer. No cross-sovereign write authority.
|
||||
|
||||
## Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph sovereignA [Sovereign A]
|
||||
CtrlA[UniFi Controller A]
|
||||
CtrlA -->|write| NetA[Network A]
|
||||
end
|
||||
|
||||
subgraph sovereignB [Sovereign B]
|
||||
CtrlB[UniFi Controller B]
|
||||
CtrlB -->|write| NetB[Network B]
|
||||
end
|
||||
|
||||
subgraph oversight [Central oversight]
|
||||
Phoenix[Sankofa Phoenix]
|
||||
end
|
||||
|
||||
Phoenix -->|read only| CtrlA
|
||||
Phoenix -->|read only| CtrlB
|
||||
CtrlA -.->|no write| CtrlB
|
||||
CtrlB -.->|no write| CtrlA
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Per-sovereign controller domains:** Each sovereign (org/tenant) has its own UniFi controller(s). Write authority stays within that sovereign.
|
||||
- **Regional isolation:** Controllers and management planes can be deployed per region so data and control stay in-region.
|
||||
- **Central read-only oversight:** Sankofa Phoenix has a read-only view across controllers for audit, BOM, support-risk, and compliance—no write into any sovereign’s controller.
|
||||
- **Trust boundaries:** No cross-sovereign write; sovereign A cannot change sovereign B’s network or config.
|
||||
|
||||
This satisfies sovereignty, auditability, compartmentalization, and trust boundaries between different bodies (e.g. governmental).
|
||||
|
||||
## Optional: controller registry
|
||||
|
||||
If you store controller endpoints in the DB, use a table `unifi_controllers` with: org_id, site_id (optional), base_url, role (sovereign_write | oversight_read_only), region. Credentials remain in Vault; the table only stores topology and role. API: CRUD for controllers scoped by org_id.
|
||||
|
||||
See [integration-spec-unifi.md](integration-spec-unifi.md) for the “Sovereign-safe controller architecture” subsection.
|
||||
49
docs/vendor-portal.md
Normal file
49
docs/vendor-portal.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Vendor portal and vendor users
|
||||
|
||||
Selected vendors can log in to assist in fulfilling needs: view and update their offers, and see purchase orders relevant to them.
|
||||
|
||||
## Model
|
||||
|
||||
- **Vendor user:** A user record with `vendor_id` set is a *vendor user*. That user can be assigned the role `vendor_user` and receive a JWT that includes `vendorId` in the payload.
|
||||
- **Scoping:** When the API sees `req.user.vendorId`, it restricts:
|
||||
- **Vendors:** List returns only that vendor; GET/PATCH/DELETE only for that vendor; POST (create vendor) is forbidden.
|
||||
- **Offers:** List/GET/PATCH/DELETE only offers for that vendor; on create, `vendorId` is forced to the logged-in vendor.
|
||||
- **Purchase orders:** List/GET only POs for that vendor.
|
||||
|
||||
## Onboarding a vendor user
|
||||
|
||||
1. Create or select a **Vendor** in the org (e.g. "The Server Store", "Sergio's Hardware").
|
||||
2. Create a **User** with:
|
||||
- `org_id` = same as org
|
||||
- `vendor_id` = that vendor's ID
|
||||
- `email` / `name` as needed
|
||||
3. Assign the role **vendor_user** to that user (via your IdP or `user_roles` if you manage roles in-app).
|
||||
4. At **login**, ensure the issued JWT includes:
|
||||
- `roles`: e.g. `["vendor_user"]`
|
||||
- `vendorId`: the vendor's UUID
|
||||
|
||||
Then the vendor can call the same API under `/api/v1` with that JWT (and `x-org-id`). They will only see and modify data for their vendor.
|
||||
|
||||
## Permissions for vendor_user
|
||||
|
||||
The role `vendor_user` has:
|
||||
|
||||
- `vendor:read_own` – read own vendor
|
||||
- `vendor:write_offers_own` – create/update own offers
|
||||
- `vendor:view_pos_own` – view POs for their vendor
|
||||
- `offers:read`, `offers:write` – used in combination with `vendorId` scoping above
|
||||
- `purchase_orders:read` – used with vendor filter
|
||||
|
||||
Vendor users cannot create/update/delete vendor records, nor see other vendors' offers or POs.
|
||||
|
||||
## API surface (vendor portal)
|
||||
|
||||
Vendor users use the same endpoints as procurement, with automatic scoping:
|
||||
|
||||
- **GET /api/v1/vendors** – Returns only their vendor.
|
||||
- **GET /api/v1/vendors/:id** – Allowed only when `:id` is their vendor.
|
||||
- **GET/POST/PATCH/DELETE /api/v1/offers** – Only their vendor's offers; POST forces their vendorId.
|
||||
- **GET /api/v1/purchase-orders** – Only POs where vendorId is their vendor.
|
||||
- **GET /api/v1/purchase-orders/:id** – Allowed only for POs of their vendor.
|
||||
|
||||
No changes to URLs or request bodies are required; scoping is derived from the JWT `vendorId`.
|
||||
Reference in New Issue
Block a user