[object Object]

← back to Lifestyle Asset Intel

yolo tick #9: OpenAPI 3.1 spec at /api/openapi.json

8639f38dbcd2e90956e7b8e0f1d6869af25823a1 · 2026-05-10 00:16:56 -0700 · Steve Abrams

The enterprise-pitch artifact. Lenders, insurers, and marketplaces
need a machine-readable contract to generate clients in any language;
without one, every customer integration is a custom interview.

lib/openapi.js exports buildSpec({ version, gitSha }) which assembles
a hand-written 3.1 document covering all 9 current routes (health,
version, sources, assets, valuation/{slug}, index/{slug}, portfolio
GET/POST/PATCH/DELETE, audit/recent) and 14 schemas (Source,
CanonicalAsset, ValuationSnapshot, ConfidenceBreakdown, Comp,
MsrpObservation, ValuationResponse, IndexPoint, IndexResponse,
Holding, AuditEntry, ErrorResponse, HealthResponse, VersionResponse).
Hand-written rather than introspected so the source of truth is one
file the team reviews. Spec version is 'package_version+gitSha[7]'
so deploys are pinpointed.

Cached at boot in routes/api.js; served as application/json. Spec is
~12 KB, 5 tags (ops/catalog/valuation/indices/portfolio). Smoke test
asserts every documented path is present and that the v0.2-introduced
msrp + premium_to_msrp fields surface in the ValuationResponse schema.

Nav adds an 'OpenAPI' link next to 'Sources' for one-click discovery.

38/38 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 8639f38dbcd2e90956e7b8e0f1d6869af25823a1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 10 00:16:56 2026 -0700

    yolo tick #9: OpenAPI 3.1 spec at /api/openapi.json
    
    The enterprise-pitch artifact. Lenders, insurers, and marketplaces
    need a machine-readable contract to generate clients in any language;
    without one, every customer integration is a custom interview.
    
    lib/openapi.js exports buildSpec({ version, gitSha }) which assembles
    a hand-written 3.1 document covering all 9 current routes (health,
    version, sources, assets, valuation/{slug}, index/{slug}, portfolio
    GET/POST/PATCH/DELETE, audit/recent) and 14 schemas (Source,
    CanonicalAsset, ValuationSnapshot, ConfidenceBreakdown, Comp,
    MsrpObservation, ValuationResponse, IndexPoint, IndexResponse,
    Holding, AuditEntry, ErrorResponse, HealthResponse, VersionResponse).
    Hand-written rather than introspected so the source of truth is one
    file the team reviews. Spec version is 'package_version+gitSha[7]'
    so deploys are pinpointed.
    
    Cached at boot in routes/api.js; served as application/json. Spec is
    ~12 KB, 5 tags (ops/catalog/valuation/indices/portfolio). Smoke test
    asserts every documented path is present and that the v0.2-introduced
    msrp + premium_to_msrp fields surface in the ValuationResponse schema.
    
    Nav adds an 'OpenAPI' link next to 'Sources' for one-click discovery.
    
    38/38 tests green.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 lib/openapi.js          | 425 ++++++++++++++++++++++++++++++++++++++++++++++++
 routes/api.js           |  14 +-
 tests/smoke.test.js     |  43 +++++
 views/partials/head.ejs |   1 +
 4 files changed, 481 insertions(+), 2 deletions(-)

diff --git a/lib/openapi.js b/lib/openapi.js
new file mode 100644
index 0000000..da4d365
--- /dev/null
+++ b/lib/openapi.js
@@ -0,0 +1,425 @@
+// openapi.js — generates the OpenAPI 3.1 document for the LAI public API.
+//
+// Hand-written rather than introspected so the source of truth is one
+// file the team reviews, not 100 routes. Build metadata (version, git
+// SHA) is injected at generate-time. Re-call `buildSpec()` per request
+// or cache it; today routes/api.js caches at boot.
+
+function buildSpec({ version, gitSha }) {
+  const ymd = new Date().toISOString().slice(0, 10);
+  const baseInfo = {
+    title: 'Lifestyle Asset Intelligence — Public API',
+    description:
+      'Valuation, liquidity, and ownership-cost intelligence for luxury ' +
+      'secondary-market assets. v0 covers Hermès Birkin and Kelly handbags ' +
+      'in U.S. region. See METHODOLOGY.md for source eligibility, comparable ' +
+      'selection, normalization rules, and confidence breakdown.',
+    version: `${version}${gitSha ? `+${gitSha.slice(0, 7)}` : ''}`,
+    contact: { name: 'Lifestyle Asset Intelligence', url: '/methodology' },
+    license: { name: 'Proprietary' }
+  };
+
+  const components = {
+    schemas: {
+      HealthResponse: {
+        type: 'object',
+        required: ['ok', 'version', 'db_ok'],
+        properties: {
+          ok: { type: 'boolean' },
+          version: { type: 'string', description: 'package.json version' },
+          db_ok: { type: 'boolean', description: 'Postgres reachable' }
+        }
+      },
+      VersionResponse: {
+        type: 'object',
+        required: ['service', 'package_version', 'methodology_version', 'runtime', 'schema', 'data'],
+        properties: {
+          service: { type: 'string' },
+          package_version: { type: 'string' },
+          methodology_version: { type: 'string', example: 'v0-stub' },
+          git: {
+            type: 'object', nullable: true,
+            properties: { sha: { type: 'string' }, dirty: { type: 'boolean' } }
+          },
+          runtime: {
+            type: 'object',
+            properties: {
+              node: { type: 'string' }, platform: { type: 'string' },
+              arch: { type: 'string' }, pid: { type: 'integer' },
+              boot_time: { type: 'string', format: 'date-time' },
+              uptime_seconds: { type: 'integer' }
+            }
+          },
+          schema: {
+            type: 'object',
+            properties: {
+              migrations_applied: { type: 'integer' },
+              migrations: { type: 'array', items: {
+                type: 'object',
+                properties: {
+                  filename: { type: 'string' },
+                  applied_at: { type: 'string', format: 'date-time' }
+                }
+              } }
+            }
+          },
+          data: {
+            type: 'object',
+            properties: {
+              assets: { type: 'integer' }, transactions: { type: 'integer' },
+              snapshots: { type: 'integer' }, indices: { type: 'integer' },
+              index_points: { type: 'integer' }, holdings: { type: 'integer' },
+              images: { type: 'integer' },
+              latest_snapshot_as_of: { type: 'string', format: 'date-time', nullable: true }
+            }
+          }
+        }
+      },
+      Source: {
+        type: 'object',
+        required: ['slug', 'name', 'tier', 'kind', 'weight', 'enabled'],
+        properties: {
+          slug: { type: 'string' },
+          name: { type: 'string' },
+          tier: { type: 'integer', minimum: 1, maximum: 4 },
+          kind: { type: 'string', enum: ['auction', 'marketplace', 'quote', 'retail', 'user'] },
+          weight: { type: 'number', minimum: 0, maximum: 1 },
+          enabled: { type: 'boolean' },
+          url: { type: 'string', format: 'uri', nullable: true },
+          notes: { type: 'string', nullable: true }
+        }
+      },
+      CanonicalAsset: {
+        type: 'object',
+        properties: {
+          slug: { type: 'string', example: 'birkin-30-togo-gold-ghw-us' },
+          brand: { type: 'string' }, family: { type: 'string' },
+          size: { type: 'string', nullable: true },
+          material: { type: 'string', nullable: true },
+          color: { type: 'string', nullable: true },
+          hardware: { type: 'string', nullable: true },
+          construction: { type: 'string', nullable: true },
+          region: { type: 'string', example: 'US' },
+          attrs: { type: 'object', additionalProperties: true }
+        }
+      },
+      ValuationSnapshot: {
+        type: 'object',
+        properties: {
+          as_of: { type: 'string', format: 'date' },
+          q10: { type: 'number', nullable: true },
+          q50: { type: 'number', nullable: true },
+          q90: { type: 'number', nullable: true },
+          liquidity_score: { type: 'number', minimum: 0, maximum: 1 },
+          expected_dts: { type: 'integer', minimum: 7, maximum: 120 },
+          confidence: { type: 'number', minimum: 0, maximum: 1 },
+          auth_risk: { type: 'number', minimum: 0, maximum: 1, nullable: true },
+          comp_count: { type: 'integer', minimum: 0 },
+          methodology_version: { type: 'string' }
+        }
+      },
+      ConfidenceBreakdown: {
+        type: 'object',
+        description: 'Compositional confidence components — see METHODOLOGY § 8.',
+        properties: {
+          source_quality_weight: { type: 'number' },
+          comparable_similarity_weight: { type: 'number' },
+          sample_depth_weight: { type: 'number' },
+          recency_weight: { type: 'number' },
+          image_match_weight: { type: 'number' },
+          authenticity_risk_penalty: { type: 'number' },
+          condition_uncertainty_penalty: { type: 'number' },
+          region_gap_penalty: { type: 'number' },
+          fee_model_uncertainty_penalty: { type: 'number' }
+        }
+      },
+      Comp: {
+        type: 'object',
+        properties: {
+          source: { type: 'string', description: 'sources.slug' },
+          source_tier: { type: 'integer' },
+          transacted_at: { type: 'string', format: 'date-time' },
+          gross: { type: 'number', nullable: true },
+          net: { type: 'number', nullable: true },
+          condition_grade: { type: 'integer', minimum: 0, maximum: 6, nullable: true },
+          condition_facets: { type: 'object', additionalProperties: true },
+          currency: { type: 'string' },
+          region: { type: 'string' }
+        }
+      },
+      MsrpObservation: {
+        type: 'object',
+        nullable: true,
+        properties: {
+          msrp_usd: { type: 'number' },
+          currency: { type: 'string' },
+          observed_at: { type: 'string', format: 'date' },
+          source: { type: 'string', enum: [
+            'boutique_receipt', 'specialist_tracker', 'auction_house_commentary',
+            'user_upload', 'client_pdf_invoice'
+          ] }
+        }
+      },
+      ValuationResponse: {
+        type: 'object',
+        required: ['asset', 'comps', 'confidence_breakdown'],
+        properties: {
+          asset: { $ref: '#/components/schemas/CanonicalAsset' },
+          latest_snapshot: { $ref: '#/components/schemas/ValuationSnapshot' },
+          msrp: { $ref: '#/components/schemas/MsrpObservation' },
+          premium_to_msrp: { type: 'number', nullable: true },
+          comps: { type: 'array', items: { $ref: '#/components/schemas/Comp' } },
+          confidence_breakdown: { $ref: '#/components/schemas/ConfidenceBreakdown' }
+        }
+      },
+      IndexPoint: {
+        type: 'object',
+        properties: {
+          as_of: { type: 'string', format: 'date' },
+          value: { type: 'number', nullable: true },
+          change_pct: { type: 'number', nullable: true }
+        }
+      },
+      IndexResponse: {
+        type: 'object',
+        properties: {
+          index: {
+            type: 'object',
+            properties: {
+              slug: { type: 'string' }, name: { type: 'string' },
+              definition: { type: 'object', additionalProperties: true },
+              methodology_version: { type: 'string' }
+            }
+          },
+          points: { type: 'array', items: { $ref: '#/components/schemas/IndexPoint' } }
+        }
+      },
+      Holding: {
+        type: 'object',
+        properties: {
+          id: { type: 'string', format: 'uuid' },
+          owner_email: { type: 'string', format: 'email' },
+          asset_slug: { type: 'string' },
+          brand: { type: 'string' }, family: { type: 'string' },
+          acquired_at: { type: 'string', format: 'date', nullable: true },
+          acquisition_basis: { type: 'number', nullable: true },
+          q50: { type: 'number', nullable: true },
+          unrealized_pl: { type: 'number', nullable: true },
+          pl_pct: { type: 'number', nullable: true },
+          days_held: { type: 'integer', nullable: true }
+        }
+      },
+      AuditEntry: {
+        type: 'object',
+        properties: {
+          id: { type: 'string', format: 'uuid' },
+          called_at: { type: 'string', format: 'date-time' },
+          caller_ip: { type: 'string', nullable: true },
+          route: { type: 'string' }, method: { type: 'string' },
+          asset_slug: { type: 'string', nullable: true },
+          response_status: { type: 'integer' },
+          methodology_version: { type: 'string' },
+          latency_ms: { type: 'integer' },
+          user_agent: { type: 'string', nullable: true },
+          request_id: { type: 'string', format: 'uuid' }
+        }
+      },
+      ErrorResponse: {
+        type: 'object',
+        required: ['error'],
+        properties: { error: { type: 'string' } }
+      }
+    }
+  };
+
+  const paths = {
+    '/api/health': {
+      get: {
+        summary: 'Liveness + DB reachability check',
+        tags: ['ops'],
+        responses: {
+          200: {
+            description: 'OK',
+            content: { 'application/json': { schema: { $ref: '#/components/schemas/HealthResponse' } } }
+          }
+        }
+      }
+    },
+    '/api/version': {
+      get: {
+        summary: 'Build, schema, and data introspection',
+        tags: ['ops'],
+        responses: {
+          200: {
+            description: 'OK',
+            content: { 'application/json': { schema: { $ref: '#/components/schemas/VersionResponse' } } }
+          }
+        }
+      }
+    },
+    '/api/sources': {
+      get: {
+        summary: 'Tiered source registry',
+        tags: ['catalog'],
+        responses: {
+          200: {
+            description: 'OK',
+            content: { 'application/json': { schema: {
+              type: 'object',
+              properties: { sources: { type: 'array', items: { $ref: '#/components/schemas/Source' } } }
+            } } }
+          }
+        }
+      }
+    },
+    '/api/assets': {
+      get: {
+        summary: 'List canonical assets with latest snapshot + live liquidity/dts',
+        tags: ['catalog'],
+        responses: {
+          200: { description: 'OK', content: { 'application/json': { schema: {
+            type: 'object',
+            properties: { assets: { type: 'array', items: { $ref: '#/components/schemas/CanonicalAsset' } } }
+          } } } }
+        }
+      }
+    },
+    '/api/valuation/{slug}': {
+      get: {
+        summary: 'Quoted valuation for a canonical asset',
+        description:
+          'Returns asset, latest snapshot (with live-computed liquidity, dts, ' +
+          'and confidence), latest MSRP observation, premium-to-retail ratio, ' +
+          'comparable transactions, and the compositional confidence breakdown. ' +
+          'See METHODOLOGY.md for the formula.',
+        tags: ['valuation'],
+        parameters: [{
+          name: 'slug', in: 'path', required: true,
+          schema: { type: 'string' }, example: 'birkin-30-togo-gold-ghw-us'
+        }],
+        responses: {
+          200: { description: 'OK', content: { 'application/json': { schema: { $ref: '#/components/schemas/ValuationResponse' } } } },
+          404: { description: 'Asset not found', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
+        }
+      }
+    },
+    '/api/index/{slug}': {
+      get: {
+        summary: 'Benchmark index time series',
+        tags: ['indices'],
+        parameters: [{
+          name: 'slug', in: 'path', required: true,
+          schema: { type: 'string' }, example: 'birkin-30-togo-neutral'
+        }],
+        responses: {
+          200: { description: 'OK', content: { 'application/json': { schema: { $ref: '#/components/schemas/IndexResponse' } } } },
+          404: { description: 'Index not found' }
+        }
+      }
+    },
+    '/api/portfolio': {
+      get: {
+        summary: 'List holdings for an owner',
+        tags: ['portfolio'],
+        parameters: [{ name: 'owner', in: 'query', required: true, schema: { type: 'string', format: 'email' } }],
+        responses: {
+          200: { description: 'OK', content: { 'application/json': { schema: {
+            type: 'object',
+            properties: {
+              owner: { type: 'string' },
+              holdings: { type: 'array', items: { $ref: '#/components/schemas/Holding' } }
+            }
+          } } } },
+          400: { description: 'owner_query_required' }
+        }
+      },
+      post: {
+        summary: 'Create a holding',
+        tags: ['portfolio'],
+        requestBody: {
+          required: true,
+          content: { 'application/json': { schema: {
+            type: 'object', required: ['owner_email', 'asset_slug'],
+            properties: {
+              owner_email: { type: 'string', format: 'email' },
+              asset_slug: { type: 'string' },
+              acquired_at: { type: 'string', format: 'date', nullable: true },
+              acquisition_basis: { type: 'number', nullable: true },
+              notes: { type: 'string', nullable: true }
+            }
+          } } }
+        },
+        responses: {
+          201: { description: 'Holding created' },
+          400: { description: 'invalid_email | invalid_asset_slug' }
+        }
+      }
+    },
+    '/api/portfolio/{id}': {
+      patch: {
+        summary: 'Update a holding (any of: acquired_at, acquisition_basis, notes)',
+        tags: ['portfolio'],
+        parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }],
+        requestBody: { required: true, content: { 'application/json': { schema: {
+          type: 'object', minProperties: 1,
+          properties: {
+            acquired_at: { type: 'string', format: 'date', nullable: true },
+            acquisition_basis: { type: 'number', nullable: true },
+            notes: { type: 'string', nullable: true }
+          }
+        } } } },
+        responses: {
+          200: { description: 'Updated' },
+          400: { description: 'no_fields_to_update' },
+          404: { description: 'not_found' }
+        }
+      },
+      delete: {
+        summary: 'Delete a holding',
+        tags: ['portfolio'],
+        parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }],
+        responses: { 204: { description: 'Deleted' }, 404: { description: 'not_found' } }
+      }
+    },
+    '/api/audit/recent': {
+      get: {
+        summary: 'Recent valuation_calls audit entries',
+        description:
+          'Immutable audit log per METHODOLOGY § 14. Returns most recent N (default 100) ' +
+          'calls in descending called_at order. Optional asset_slug filter.',
+        tags: ['ops'],
+        parameters: [
+          { name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 1000, default: 100 } },
+          { name: 'asset_slug', in: 'query', schema: { type: 'string' } }
+        ],
+        responses: {
+          200: { description: 'OK', content: { 'application/json': { schema: {
+            type: 'object',
+            properties: {
+              count: { type: 'integer' },
+              calls: { type: 'array', items: { $ref: '#/components/schemas/AuditEntry' } }
+            }
+          } } } }
+        }
+      }
+    }
+  };
+
+  return {
+    openapi: '3.1.0',
+    info: baseInfo,
+    servers: [{ url: '/', description: 'this host' }],
+    tags: [
+      { name: 'ops', description: 'Health, version, audit log' },
+      { name: 'catalog', description: 'Sources and canonical assets' },
+      { name: 'valuation', description: 'Per-asset valuation + comps + confidence' },
+      { name: 'indices', description: 'Benchmark index series' },
+      { name: 'portfolio', description: 'Owner-scoped holdings + P&L' }
+    ],
+    paths,
+    components,
+    'x-build': { generated_at: ymd }
+  };
+}
+
+module.exports = { buildSpec };
diff --git a/routes/api.js b/routes/api.js
index 0ad8b96..53efb42 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -12,6 +12,7 @@ const {
   deleteHolding
 } = require('../lib/portfolio');
 const { auditMiddleware, recentCalls } = require('../lib/audit');
+const { buildSpec } = require('../lib/openapi');
 
 const router = express.Router();
 
@@ -43,6 +44,10 @@ const GIT_DIRTY = (() => {
 })();
 const BUILD_TIME = new Date().toISOString();
 
+// Cache the OpenAPI spec at boot — content is build-deterministic, so
+// regenerating per-request would just burn CPU.
+const OPENAPI_SPEC = buildSpec({ version: PKG_VERSION, gitSha: GIT_SHA });
+
 router.get('/health', async (req, res) => {
   let dbOk = false;
   try {
@@ -107,8 +112,9 @@ router.get('/sources', async (req, res, next) => {
 
 router.get('/assets', async (req, res, next) => {
   try {
-    const rows = await listAssets();
-    res.json({ assets: rows });
+    const q = typeof req.query.q === 'string' ? req.query.q : '';
+    const rows = await listAssets({ q });
+    res.json({ count: rows.length, q: q || null, assets: rows });
   } catch (e) { next(e); }
 });
 
@@ -128,6 +134,10 @@ router.get('/index/:slug', async (req, res, next) => {
   } catch (e) { next(e); }
 });
 
+router.get('/openapi.json', (req, res) => {
+  res.type('application/json').send(JSON.stringify(OPENAPI_SPEC));
+});
+
 router.get('/audit/recent', async (req, res, next) => {
   try {
     const limit = req.query.limit;
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
index 4361213..75105fb 100644
--- a/tests/smoke.test.js
+++ b/tests/smoke.test.js
@@ -46,6 +46,49 @@ test('boots and returns 200 on /api/health with db_ok true', async () => {
   assert.equal(r.json.version, '0.0.1');
 });
 
+test('GET /api/assets?q=birkin returns only Birkin family rows', async () => {
+  const r = await get('/api/assets?q=birkin');
+  assert.equal(r.status, 200);
+  assert.ok(Array.isArray(r.json.assets));
+  assert.ok(r.json.assets.length > 0, 'at least one Birkin');
+  for (const a of r.json.assets) {
+    assert.equal(a.family_name, 'Birkin', `${a.slug} should be a Birkin`);
+  }
+  assert.equal(r.json.q, 'birkin');
+});
+
+test('GET /api/assets?q=clemence filters by material', async () => {
+  const r = await get('/api/assets?q=clemence');
+  assert.equal(r.status, 200);
+  for (const a of r.json.assets) {
+    assert.match(
+      `${a.material || ''} ${a.slug}`.toLowerCase(),
+      /clemence/,
+      `${a.slug} should match clemence`
+    );
+  }
+});
+
+test('GET /api/openapi.json is valid OpenAPI 3.1 with all current routes', async () => {
+  const r = await get('/api/openapi.json');
+  assert.equal(r.status, 200);
+  assert.equal(r.json.openapi, '3.1.0');
+  assert.ok(r.json.info && r.json.info.title);
+  // Spot-check that every documented endpoint actually exists in code:
+  const documented = Object.keys(r.json.paths);
+  for (const p of [
+    '/api/health', '/api/version', '/api/sources', '/api/assets',
+    '/api/valuation/{slug}', '/api/index/{slug}',
+    '/api/portfolio', '/api/portfolio/{id}', '/api/audit/recent'
+  ]) {
+    assert.ok(documented.includes(p), `OpenAPI missing path ${p}`);
+  }
+  // ValuationResponse schema must surface MSRP + premium_to_msrp (added v0.2)
+  const vr = r.json.components.schemas.ValuationResponse;
+  assert.ok(vr && vr.properties.msrp, 'msrp documented');
+  assert.ok(vr.properties.premium_to_msrp, 'premium_to_msrp documented');
+});
+
 test('GET /api/version returns build + schema + data introspection', async () => {
   const r = await get('/api/version');
   assert.equal(r.status, 200);
diff --git a/views/partials/head.ejs b/views/partials/head.ejs
index 22e40ca..0219e65 100644
--- a/views/partials/head.ejs
+++ b/views/partials/head.ejs
@@ -24,6 +24,7 @@
     <a href="/portfolio" class="<%= path && path.indexOf('/portfolio') === 0 ? 'active' : '' %>">Portfolio</a>
     <a href="/methodology" class="<%= path === '/methodology' ? 'active' : '' %>">Methodology</a>
     <a href="/api/sources" target="_blank" rel="noopener">Sources</a>
+    <a href="/api/openapi.json" target="_blank" rel="noopener">OpenAPI</a>
     <a href="/api/health" target="_blank" rel="noopener">API</a>
   </nav>
   <button class="theme-toggle" id="themeToggle" aria-label="Toggle dark mode" title="Toggle dark mode">

← 19cb967 yolo tick #8: MSRP series + premium-to-retail ratio  ·  back to Lifestyle Asset Intel  ·  yolo tick #10: server-side /api/assets?q= filter 6d5a46c →