← 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
A lib/openapi.jsM routes/api.jsM tests/smoke.test.jsM views/partials/head.ejs
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 →