Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
b0de6e5
feat(persistence): add cursor pagination to background jobs listing
thatboyjesse Jul 23, 2026
470881e
fix(security): retire legacy SQLite API key middleware in favor of th…
Jul 23, 2026
8a5938a
docs(invoice-state): document the API contract
Osuochasam Jul 24, 2026
e5265a2
feat(escrow-read): add cursor pagination
Osuochasam Jul 24, 2026
66c2d68
test(invoices): cover PATCH field guard status and mutability matrix
Osuochasam Jul 24, 2026
c241411
test(indexer): add integration tests for v1 escrow endpoint success a…
omosvico Jul 24, 2026
23978c2
Removed Unwanted target and node_modules
GauravKarakoti Jul 24, 2026
2970960
CORS
GauravKarakoti Jul 24, 2026
a1357ee
feat(health): add cursor pagination
Bizify1370 Jul 24, 2026
6f5c4e5
feat(indexer): add cursor pagination to indexer listing endpoint
thatboyjesse Jul 24, 2026
b7cb1b8
feat(health): validate and bound request inputs against malformed pay…
omosvico Jul 24, 2026
4f41a6f
test(routes): add module-resolution smoke test for src/routes/ (#724)
Ediwise Jul 24, 2026
5edbd09
test(query-builder): cover filter/sort whitelisting and injection neu…
DANTE-1903 Jul 24, 2026
c87a802
refactor(health): extract shared validation helper (#722)
Gozirimdev Jul 24, 2026
d1211c1
feat(invoice-state): add cursor pagination (#721)
Ediwise Jul 24, 2026
c449f53
test(cors): add allowlist trailing-slash, case-variant, null-origin, …
OluwapelumiElisha Jul 24, 2026
d3918f9
test(api-keys): cover success and error paths (#719)
Essence-3 Jul 24, 2026
8840c9c
refactor(persistence): extract shared validation helper (#718)
ifygreg01-best Jul 24, 2026
8daa67a
test(health): add focused health and readiness endpoint coverage (#717)
Pafekzy Jul 24, 2026
e0ae78f
Implement graceful shutdown on SIGTERM/SIGINT (#716)
Emmzyemms Jul 24, 2026
784eeb1
feat(token-meta): add batched resolution with single-flight stampede …
Pafekzy Jul 24, 2026
2e75808
test(indexer): add integration tests for v1 escrow endpoint success a…
omosvico Jul 24, 2026
d255047
Create persistence.md (#711)
karanjakevin39-collab Jul 24, 2026
c7eca50
feat(metrics): add cursor pagination to metrics listing endpoint (#710)
samjay8 Jul 24, 2026
dae6e43
test(escrow-map): cover cacheEnabled:false and defaultEnvironment fal…
Teemi2106 Jul 24, 2026
a2f5f60
Create api-keys.md (#707)
karanjakevin39-collab Jul 24, 2026
841c064
feat(escrow-versions): read on-chain SCHEMA_VERSION via Soroban RPC (…
ZuLu0890 Jul 24, 2026
f71160f
fix(security): extend Sentry scrubbing to nested fields and query str…
samkay-ops Jul 24, 2026
7f95dd0
fix(security): harden CORS origin matching against null-origin and no…
Ukorstack Jul 24, 2026
95580e2
feat(observability): add Soroban RPC latency histograms and retry-cau…
Okorie2000-code Jul 24, 2026
c0840d1
Feature/config 01 pagination (#701)
Osuochasam Jul 24, 2026
595f8bf
Feature/escrow read 01 pagination (#698)
Osuochasam Jul 24, 2026
f673c18
docs(invoice-state): document the API contract (#697)
Osuochasam Jul 24, 2026
9fd3c81
feat(cors): validate and bound request inputs (#696)
ayandipe Jul 24, 2026
15a056e
feat(soroban-sim): add TTL- and size-bounded footprint cache with inv…
AvatarMiiDe Jul 24, 2026
2f32f1e
test(escrow-read): cover success and error paths (#694)
ruthoreaji-123 Jul 24, 2026
f3656cc
feat(invoice-state): validate and bound request inputs (Closes #648) …
Osifowora Jul 24, 2026
d30afd8
feat(config): validate and bound request inputs (#692)
matieuu1 Jul 24, 2026
a562491
refactor(metrics): extract shared validation helper (#690)
matieuu1 Jul 24, 2026
905cc7d
test(cors): cover success and error paths (#689)
maixuancanh Jul 24, 2026
c43952b
refactor(errors): unify RFC 7807 problem-detail construction in a sin…
afeezorobsco-cyber Jul 24, 2026
dd3ec00
feat(invest): serve live opportunities from investService instead of …
DammyAji Jul 24, 2026
351b2d5
feat(errors): map 409, 422, 429 and 503 to stable error codes (#636)
KorexOnchain Jul 24, 2026
d89b08e
feat(kyc): implement external provider verification with retries and …
DammyAji Jul 24, 2026
ecc5ed1
Merge branch 'pr-691'
mikewheeleer Jul 24, 2026
0dc94a5
Merge branch 'pr-699'
mikewheeleer Jul 24, 2026
8c2a589
Merge branch 'pr-700'
mikewheeleer Jul 24, 2026
b004117
Merge branch 'pr-705'
mikewheeleer Jul 24, 2026
03c42b2
Merge branch 'pr-709'
mikewheeleer Jul 24, 2026
2ae2d26
Merge branch 'pr-713'
mikewheeleer Jul 24, 2026
d543261
Merge branch 'pr-715'
mikewheeleer Jul 24, 2026
56069ea
feat(invoice-state): persist transitions to the database with tenant …
okoye-collins Jul 24, 2026
74c5b65
feat(persistence): add metrics and structured logging
YerimahOfTimes Jul 24, 2026
7f51c25
feat(notifications): add retry and dead-lettering to maturity reminde…
emmyokolo2525-cyber Jul 25, 2026
e974d26
feat(health): idempotency-key support for health write endpoints
Praiz089017 Jul 25, 2026
ad958ef
test: add authorization and tenant-scoping tests for metrics
AbuJulaybeeb Jul 25, 2026
81333f0
feat(api-keys): idempotency-key support (#804)
Praiz089017 Jul 25, 2026
fb2bd6a
test(metrics): cover success and error paths for /metrics and /api/sm…
emteebug12-jpg Jul 25, 2026
d76701a
feat(config): add per-client rate limiting (#754) (#800)
Yinklekay Jul 25, 2026
fcd51eb
docs(escrow-read): document the API contract (#799)
EmeditWeb Jul 25, 2026
d7b609c
refactor(config): add typed config accessors (#787)
TheWeirdDee Jul 25, 2026
7bff0ca
docs(persistence): add operations runbook (#786)
Yerimahjr Jul 25, 2026
c483522
test(persistence): cover success and error paths (#784)
nice-bills Jul 25, 2026
595649f
docs(cors): document the API contract (#783)
aymide1ee Jul 25, 2026
66f6ab3
feat(invoice-state): implement full transition matrix, route handlers…
GEEKYFOCUS Jul 25, 2026
938cf5f
test(config): fix stale boot-gate tests and cover PUBLIC_API_BASE_URL…
emteebug12-jpg Jul 25, 2026
807d5ee
feat(indexer): validate and bound request inputs (#780)
GEEKYFOCUS Jul 25, 2026
c0d9651
fix(security): bind SME wallet authorization to the authenticated pri…
emickwrld Jul 25, 2026
77ddcea
docs(config): document the API contract (#728)
DavidAkere204 Jul 25, 2026
48bbd05
feat(invoice-file): persist uploaded PDFs and integrity hashes in dur…
OG-wura Jul 25, 2026
276e98f
test(middleware): cover composed auth and tenant stack ordering (#725)
DANTE-1903 Jul 25, 2026
eec623f
Merge branch 'pr-726'
mikewheeleer Jul 25, 2026
c2b49a1
Merge branch 'pr-785'
mikewheeleer Jul 25, 2026
eb1e9f5
Merge branch 'pr-788'
mikewheeleer Jul 25, 2026
2ab109c
Merge branch 'pr-801'
mikewheeleer Jul 25, 2026
885c8bd
Merge branch 'pr-802'
mikewheeleer Jul 25, 2026
02c31e5
feat(api-keys): add metrics and structured logging
Ukorstack Jul 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
62 changes: 62 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,16 @@ METRICS_BEARER_TOKEN=replace-with-a-long-random-secret
# Key rotation: add the new key entry, deploy, then set "revoked": true on the
# old entry and redeploy. The old key is rejected immediately; the new key works
# from the first deploy.
#
# Security notes:
# * Authentication is performed entirely in-memory against the env-backed
# registry (src/middleware/apiKeyAuth.js + src/config/apiKeys.js).
# * No SQLite connection is opened per request; the legacy SQLite API key
# store has been retired (issue #590, formerly API_KEYS_DB_PATH).
# * Comparison uses constant-time SHA-256 hashing to prevent timing-based
# key enumeration.
# * Auth events emit structured logs through the shared logger. Raw key
# material is never written anywhere.
API_KEYS=

# --------------------
Expand All @@ -208,6 +218,22 @@ RATE_LIMIT_SENSITIVE_MAX=40 # Max requests per window (default: 40)
RATE_LIMIT_API_KEY_WINDOW_MS=900000 # Time window in ms (default: 15 min = 900000)
RATE_LIMIT_API_KEY_MAX=1000 # Max requests per window (default: 1000)

# --- Issue #754: per-client rate limit for /api/admin/config ---
# Closes the gap that let /api/admin/config (POST + GET sections) be hit
# without any per-client throttle. Each client — identified by X-API-Key
# when present, otherwise by socket IP — is allowed at most
# CONFIG_RATE_LIMIT_MAX requests per CONFIG_RATE_LIMIT_WINDOW_MS. The
# limiter is mounted BEFORE admin auth so failed authentication attempts
# still consume quota (defends against auth-flooding with bogus keys/JWTs).
#
# Defaults target admin-only reality: 20 writes per 60 s window per client
# is enough for six interactive writes per minute across the entire
# feature surface while still making accidental bursts (buggy redeploy
# loops, retry storms) fail loudly within seconds rather than silently.
# Tighten in production, never disable.
CONFIG_RATE_LIMIT_WINDOW_MS=60000 # Time window in ms (default: 60 s)
CONFIG_RATE_LIMIT_MAX=20 # Max requests per window per client (default: 20)

# Multi-instance signal (issue #429)
# WEB_CONCURRENCY is the Heroku-style dyno count. CLUSTER_WORKERS is the
# PM2 / Kubernetes alternative. Either variable set to a value > 1 is
Expand Down Expand Up @@ -256,6 +282,42 @@ KYC_PROVIDER_SECRET=replace-with-your-kyc-signing-secret
# KYC_PROVIDER_API_KEY=replace-with-your-kyc-api-key
# KYC_PROVIDER_SECRET=replace-with-your-kyc-signing-secret

# --- Issue #592: KYC provider transport hardening ---
# Bounded per-request timeout (100-30000 ms). Anything tighter than 100 ms
# effectively disables the timeout; anything looser than 30 s lets a slow
# upstream hold a connection for minutes.
KYC_PROVIDER_TIMEOUT_MS=5000

# Maximum retry attempts against the provider. 0 disables retries. Transient
# failures (network: ETIMEDOUT/ECONNRESET/ECONNREFUSED, HTTP 408/425/429/5xx)
# are retried with exponential back-off; permanent 4xx errors are not.
KYC_PROVIDER_MAX_RETRIES=3

# Exponential back-off base / cap (in ms). Zero is fine for tests; production
# should keep the defaults to avoid hammering the provider while it is degraded.
KYC_PROVIDER_BASE_DELAY_MS=200
KYC_PROVIDER_MAX_DELAY_MS=5000

# Outbound HMAC request signing (opt-in). When true and KYC_PROVIDER_SECRET is
# set, the client sends an X-KYC-Signature: t=<ts>,v1=<hex> header over the
# JSON request body. The provider can verify using the same shared secret.
KYC_PROVIDER_SIGN_REQUESTS=false

# Strict response integrity verification (opt-in). When true the client REQUIRES
# the provider to send a valid X-KYC-Signature (or X-KYC-Response-Signature)
# header; missing or invalid signatures are rejected (fail-closed). When false
# the client still defensively verifies a header if present but does not
# require it - matches providers that do not sign their responses yet.
KYC_PROVIDER_VERIFY_RESPONSE_SIGNATURE=false

# Circuit breaker tuning. After KYC_PROVIDER_CB_FAILURE_THRESHOLD consecutive
# failures the breaker opens and calls fail fast with code CIRCUIT_OPEN for
# KYC_PROVIDER_CB_RECOVERY_TIMEOUT_MS before allowing a single probe attempt.
# Tripped state surfaces on the sorobanCircuitBreakerStateTransitionsTotal
# Prometheus counter with label name=kyc.
KYC_PROVIDER_CB_FAILURE_THRESHOLD=5
KYC_PROVIDER_CB_RECOVERY_TIMEOUT_MS=10000

# ---------------------------
# JWT Hardening Options |
# ---------------------------
Expand Down
1 change: 1 addition & 0 deletions .kiro/specs/escrow-read-cursor-pagination/.config.kiro
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"specId": "85ddbad3-925c-482b-948f-12089e576ade", "workflowType": "requirements-first", "specType": "feature"}
149 changes: 149 additions & 0 deletions .kiro/specs/escrow-read-cursor-pagination/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Requirements Document

## Introduction

This feature refactors the `listInvestments` function in `src/services/investService.js` and its corresponding `GET /api/invest/opportunities` route in `src/routes/invest.js`. The current implementation uses a raw `invoiceId` string as a plain-text, unsigned cursor with only a soft page-size cap. This refactor replaces that with opaque, HMAC-signed keyset cursors (reusing the existing `encodeCursor`/`decodeCursor` infrastructure from `src/utils/cursorPagination.js`), enforces a bounded page size at the service layer, and adds standardized 400 error handling for invalid cursors. Existing consumers are preserved: response keys remain snake_case (`next_cursor`, `has_more`), item shape is unchanged, and the route path is unchanged.

## Glossary

- **InvestService**: The module at `src/services/investService.js` that contains `listInvestments` and related functions.
- **InvestRoute**: The Express router at `src/routes/invest.js` that exposes `GET /api/invest/opportunities`.
- **CursorPagination**: The utility module at `src/utils/cursorPagination.js` providing `encodeCursor`, `decodeCursor`, and `CursorError`.
- **Opaque Cursor**: A base64url-encoded, HMAC-SHA256-signed string that encodes keyset pagination state without exposing raw database IDs to consumers.
- **CursorError**: The domain error class thrown by `decodeCursor` when a cursor is malformed, tampered, has a wrong sort field, or is expired.
- **Keyset Pagination**: A pagination strategy that uses a stable, indexed column value from the last row of the current page to determine the start of the next page, rather than a row OFFSET.
- **Page Size**: The number of items returned per response page, bounded between 1 and 100.
- **HMAC**: Hash-based Message Authentication Code; used here with SHA-256 to sign cursor payloads so tampering is detectable.
- **InvestmentOpportunity**: The DTO shape returned in each element of the `data` array: `{ invoiceId, fundedBpsOfTarget, maturityAt, yieldBpsDisplay, onChain: { escrowAddress, ledgerIndex, ...enrichedFields } }`.
- **Tenant**: An authenticated organizational unit whose `tenantId` scopes all database queries.

---

## Requirements

### Requirement 1: Opaque HMAC-Signed Cursor Encoding

**User Story:** As a backend engineer, I want `listInvestments` to produce opaque, HMAC-signed cursors, so that raw database IDs are never exposed to consumers and cursor tampering is detectable.

#### Acceptance Criteria

1. WHEN `listInvestments` returns a non-empty page, THE InvestService SHALL encode the next cursor using `encodeCursor({ sortField: 'id', sortValue: lastId, id: lastId })` from CursorPagination.
2. WHEN `listInvestments` returns an empty page or the last page, THE InvestService SHALL set `meta.next_cursor` to `null` regardless of any pagination state previously computed.
3. WHEN `listInvestments` returns zero items after applying tenant, status, and keyset filters, THE InvestService SHALL set `meta.next_cursor` to `null` without calling `encodeCursor`.
3. THE InvestService SHALL NOT expose raw `invoiceId` strings directly as cursor values in `meta.next_cursor`.
4. WHEN a valid opaque cursor is supplied as input, THE InvestService SHALL pass it to `decodeCursor(cursor, 'id')` to extract the keyset position before executing the database query.
5. WHEN `decodeCursor` returns successfully, THE InvestService SHALL apply `WHERE id > decodedId` to the database query to resume from the correct page position.

---

### Requirement 2: Page Size Bounding and Clamping

**User Story:** As a backend engineer, I want the service layer to enforce a hard page-size ceiling, so that no single request can retrieve an unbounded number of records regardless of the `limit` value supplied.

#### Acceptance Criteria

1. THE InvestService SHALL apply a default page size of 20 when no `limit` parameter is provided.
2. WHEN the `limit` parameter is greater than 100, THE InvestService SHALL silently clamp it to 100.
3. WHEN the `limit` parameter is less than or equal to 0, THE InvestService SHALL use a page size of 20.
4. WHEN `limit` is between 1 and 100 inclusive, THE InvestService SHALL use the provided value as the page size.
5. THE InvestService SHALL set `meta.limit` in the response to the clamped page size actually used, not the raw input value.
6. THE InvestService SHALL perform all page-size clamping within the service layer, not the route layer.

---

### Requirement 3: Response Wrapper Invariance

**User Story:** As an existing API consumer, I want the response envelope shape and key names to remain unchanged, so that I do not need to update my client code after this refactor.

#### Acceptance Criteria

1. THE InvestService SHALL include `meta.next_cursor` (string or null) in every response from `listInvestments`.
2. THE InvestService SHALL include `meta.has_more` (boolean) in every response from `listInvestments`.
3. THE InvestService SHALL include `meta.limit` (number — the clamped limit used) in every response from `listInvestments`.
4. THE InvestService SHALL include `meta.count` (number — the count of items in the current page) in every response from `listInvestments`.
5. THE InvestService SHALL set `meta.has_more` to `true` when the number of items returned equals the clamped limit and at least one more record may exist.
6. THE InvestService SHALL set `meta.has_more` to `false` when the number of items returned is less than the clamped limit.

---

### Requirement 4: Item Schema Invariance

**User Story:** As an existing API consumer, I want the shape of each item in the `data` array to remain unchanged, so that my client-side deserialization logic continues to work without modification.

#### Acceptance Criteria

1. THE InvestService SHALL include `invoiceId` (string) on every item in the `data` array.
2. THE InvestService SHALL include `fundedBpsOfTarget` (number) on every item in the `data` array.
3. THE InvestService SHALL include `maturityAt` (ISO string or null) on every item in the `data` array.
4. THE InvestService SHALL include `yieldBpsDisplay` (number or null) on every item in the `data` array.
5. THE InvestService SHALL include `onChain` (object) with at least `escrowAddress` and `ledgerIndex` fields on every item in the `data` array.
6. THE InvestService SHALL NOT add, remove, or rename any top-level fields on individual item objects returned in the `data` array.

---

### Requirement 5: Invalid Cursor Handling

**User Story:** As an API consumer, I want a clear, structured error response when I submit a malformed or tampered cursor, so that I can distinguish a cursor error from other failures and know to start pagination from the first page.

#### Acceptance Criteria

1. WHEN `decodeCursor` throws a `CursorError`, THE InvestRoute SHALL return HTTP 400.
2. WHEN returning HTTP 400 for a cursor error, THE InvestRoute SHALL include `error.code` set to `"INVALID_CURSOR"` in the response body.
3. WHEN returning HTTP 400 for a cursor error, THE InvestRoute SHALL include `error.message` containing the human-readable description from the `CursorError` instance.
4. WHEN returning HTTP 400 for a cursor error, THE InvestRoute SHALL include `error.retryable` set to `false` in the response body.
5. IF a `CursorError` is thrown, THEN THE InvestRoute SHALL NOT propagate it to the global error handler as an unhandled exception.
6. WHEN a cursor string is absent from the request, THE InvestRoute SHALL pass `undefined` as the cursor to `listInvestments` and THE InvestService SHALL execute an unconstrained first-page query.

---

### Requirement 6: First-Page and Empty-Set Behavior

**User Story:** As an API consumer, I want predictable behavior on the first request (no cursor) and when there are no results, so that my pagination loop terminates correctly.

#### Acceptance Criteria

1. WHEN no `cursor` query parameter is present, THE InvestService SHALL return records ordered by `id ASC` starting from the first record that matches the tenant and status filters.
2. WHEN no matching records exist for the tenant and status filters, THE InvestService SHALL return `data: []`, `meta.next_cursor: null`, `meta.has_more: false`, and `meta.count: 0`.
3. WHEN `listInvestments` is called without a cursor and the total number of matching records is less than the clamped limit, THE InvestService SHALL return all matching records with `meta.has_more: false` and `meta.next_cursor: null`.

---

### Requirement 7: Exact-Page Boundary Behavior

**User Story:** As an API consumer, I want correct `has_more` and `next_cursor` values at exact page boundaries, so that my pagination loop neither drops the last page nor loops infinitely.

#### Acceptance Criteria

1. WHEN the total number of matching records is an exact multiple of the page size, THE InvestService SHALL return `meta.has_more: true` and a non-null `meta.next_cursor` on every page except the last.
2. WHEN the last page contains exactly as many items as the page size but no further records exist, THE InvestService SHALL detect this and return `meta.has_more: false` with `meta.next_cursor: null` on that final page.
3. THE InvestService SHALL use a fetch-one-extra strategy (fetch `limit + 1` rows) to determine whether more records exist without issuing a separate COUNT query.

---

### Requirement 8: Tenant Isolation Invariance

**User Story:** As a security-conscious engineer, I want the opaque cursor refactor to preserve existing tenant-scoping guarantees, so that no cursor value can be used to retrieve records belonging to a different tenant.

#### Acceptance Criteria

1. THE InvestService SHALL apply `WHERE tenant_id = tenantId` to every database query regardless of cursor content.
2. THE InvestService SHALL apply `WHERE deleted_at IS NULL` to every database query regardless of cursor content.
3. THE InvestService SHALL apply the `PUBLIC_INVESTABLE_INVOICE_STATUSES` status filter to every database query regardless of cursor content.
4. WHEN a cursor encodes a record ID that belongs to a different tenant, THE InvestService SHALL return an empty page rather than cross-tenant records, because the tenant filter takes precedence over the keyset position.

---

### Requirement 9: Test Coverage

**User Story:** As a backend engineer, I want comprehensive unit tests for the refactored `listInvestments` and the `/opportunities` route handler, so that regressions are caught automatically.

#### Acceptance Criteria

1. THE Test_Suite SHALL replace all stub tests in `src/tests/pagination.test.js` with real implementations covering the behaviors defined in Requirements 1–8.
2. WHEN testing `listInvestments`, THE Test_Suite SHALL mock the `db` (knex) query builder and `batchReadEscrowStates` to remain unit-level without requiring a live database.
3. THE Test_Suite SHALL include a test that verifies a `limit` of 1000 is silently clamped to 100 and that `meta.limit` equals 100 in the response.
4. THE Test_Suite SHALL include a test that verifies an empty result set returns `data: []`, `meta.next_cursor: null`, `meta.has_more: false`, and `meta.count: 0`.
5. THE Test_Suite SHALL include a test that verifies a malformed cursor string causes the route handler to return HTTP 400 with `error.code === "INVALID_CURSOR"`, and WHEN the cursor fails for any reason including tampering or expiry, THE InvestRoute SHALL always return HTTP 400 rather than any other error status.
6. THE Test_Suite SHALL include a test that verifies the exact-page boundary scenario (total equals a multiple of limit) produces correct `has_more` and `next_cursor` values.
7. THE Test_Suite SHALL include a test that verifies first-page behavior (no cursor) returns records ordered by `id ASC` and includes a non-null `meta.next_cursor` when more records exist.
8. THE Test_Suite SHALL achieve a minimum of 95% line and branch coverage on `src/services/investService.js` (the `listInvestments` function) and the `/opportunities` handler in `src/routes/invest.js`.
1 change: 1 addition & 0 deletions .kiro/specs/invoice-state-documentation/.config.kiro
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"specId": "85ddbad3-925c-482b-948f-12089e576ade", "workflowType": "requirements-first", "specType": "feature"}
Loading