← back to Lifestyle Asset Intel
lib/openapi.js
602 lines
// 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 }
}
},
FamilyResponse: {
type: 'object',
properties: {
family: {
type: 'object',
properties: { slug: { type: 'string' }, name: { type: 'string' } }
},
brand: {
type: 'object',
properties: { slug: { type: 'string' }, name: { type: 'string' } }
},
summary: {
type: 'object',
properties: {
asset_count: { type: 'integer' },
avg_q50: { type: 'number', nullable: true },
total_comps: { type: 'integer' },
avg_liquidity: { type: 'number', nullable: true }
}
},
assets: { type: 'array', items: { $ref: '#/components/schemas/CanonicalAsset' } },
indices: { type: 'array', items: {
type: 'object',
properties: {
slug: { type: 'string' }, name: { type: 'string' },
definition: { type: 'object', additionalProperties: true },
methodology_version: { type: 'string' }
}
} }
}
},
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',
description:
'Returns every configured data source (auction houses, marketplaces, ' +
'quote desks, retail) with its tier, kind, weight, and enabled flag. ' +
'Default order is by tier then name. Pass ?sort= to switch — supported ' +
'modes: name, slug, tier, tier-desc, weight-desc, weight-asc, kind, ' +
'enabled. Unknown sort values fall back to the default order. ' +
'Pass ?q= for a case-insensitive substring filter against slug, name, ' +
'kind, or notes; composes with ?sort=.',
tags: ['catalog'],
parameters: [
{ name: 'q', in: 'query', required: false,
schema: { type: 'string' }, example: 'auction' },
{ name: 'sort', in: 'query', required: false,
schema: { type: 'string' }, example: 'weight-desc' }
],
responses: {
200: {
description: 'OK',
content: { 'application/json': { schema: {
type: 'object',
properties: {
count: { type: 'integer' },
q: { type: 'string', nullable: true },
sort: { type: 'string', nullable: true },
sources: { type: 'array', items: { $ref: '#/components/schemas/Source' } }
}
} } }
}
}
}
},
'/api/assets': {
get: {
summary: 'List canonical assets with latest snapshot + live liquidity/dts',
description:
'Optional `q` filter (case-insensitive across slug/brand/family/size/' +
'material/color/hardware/construction). Optional `sort` (default ordering ' +
'is by brand → family → size → material → color).',
tags: ['catalog'],
parameters: [
{ name: 'q', in: 'query', schema: { type: 'string' } },
{ name: 'sort', in: 'query', schema: {
type: 'string',
enum: ['default', 'value-desc', 'value-asc', 'liquidity-desc',
'comps-desc', 'newest-comp', 'slug']
} }
],
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/family': {
get: {
summary: 'List every model family with cohort summary',
description:
'Returns one row per model family: brand identity, asset_count, ' +
'total_comps_24m (transactions in trailing 24 months), and avg_q50 ' +
'(unweighted mean of latest snapshot Q50 across the cohort). ' +
'Default order is brand name then family name. Optional ?q= filter ' +
'narrows by case-insensitive substring against family slug/name or ' +
'brand slug/name. Optional ?sort= switches order — supported modes: ' +
'name, slug, assets-desc, comps-desc, value-desc, value-asc. ' +
'Unknown sort values fall back to default ordering.',
tags: ['catalog'],
parameters: [
{ name: 'q', in: 'query', required: false,
schema: { type: 'string' }, example: 'birkin' },
{ name: 'sort', in: 'query', required: false,
schema: { type: 'string' }, example: 'assets-desc' }
],
responses: {
200: { description: 'OK', content: { 'application/json': { schema: {
type: 'object',
properties: {
count: { type: 'integer' },
q: { type: 'string', nullable: true },
sort: { type: 'string', nullable: true },
families: { type: 'array', items: {
type: 'object',
properties: {
slug: { type: 'string' }, name: { type: 'string' },
brand: {
type: 'object',
properties: { slug: { type: 'string' }, name: { type: 'string' } }
},
asset_count: { type: 'integer' },
total_comps_24m: { type: 'integer' },
avg_q50: { type: 'number', nullable: true }
}
} }
}
} } } }
}
}
},
'/api/family/{slug}': {
get: {
summary: 'Cohort detail for a model family',
description:
'Returns the family + brand identity, a cohort summary block ' +
'(asset_count, avg_q50, total_comps, avg_liquidity), the list of ' +
'canonical assets in the family with their live snapshots, and ' +
'related indices.',
tags: ['catalog'],
parameters: [{
name: 'slug', in: 'path', required: true,
schema: { type: 'string' }, example: 'birkin'
}],
responses: {
200: { description: 'OK', content: { 'application/json': { schema: { $ref: '#/components/schemas/FamilyResponse' } } } },
404: { description: 'Family not found' }
}
}
},
'/api/index': {
get: {
summary: 'List every benchmark index with latest point summary',
description:
'Returns one row per benchmark index: slug, name, definition, ' +
'methodology_version, point_count, first_as_of, plus the latest ' +
'point (latest_as_of, latest_value, latest_change_pct). Default ' +
'order is by slug. Pass ?sort= to switch — supported modes: ' +
'slug, name, value-desc, value-asc, change-desc, change-asc, ' +
'points-desc, recent-desc. Unknown sort values fall back to ' +
'default ordering. Pass ?q= for a case-insensitive substring ' +
'filter on slug or name; composes with ?sort=.',
tags: ['indices'],
parameters: [{
name: 'q', in: 'query', required: false,
schema: { type: 'string' }, example: 'birkin'
}, {
name: 'sort', in: 'query', required: false,
schema: { type: 'string' }, example: 'value-desc'
}],
responses: {
200: { description: 'OK', content: { 'application/json': { schema: {
type: 'object',
properties: {
count: { type: 'integer' },
q: { type: 'string', nullable: true },
sort: { type: 'string', nullable: true },
indices: { type: 'array', items: {
type: 'object',
properties: {
slug: { type: 'string' }, name: { type: 'string' },
definition: { type: 'object', additionalProperties: true },
methodology_version: { type: 'string' },
point_count: { type: 'integer' },
first_as_of: { type: 'string', format: 'date', nullable: true },
latest_as_of: { type: 'string', format: 'date', nullable: true },
latest_value: { type: 'number', nullable: true },
latest_change_pct: { type: 'number', nullable: true }
}
} }
}
} } } }
}
}
},
'/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 filters compose with AND: ' +
'asset_slug (exact match), route (case-insensitive substring), method (exact, case-insensitive), status (exact 3-digit code OR 1-digit class prefix "2"/"4"/"5" for status-class match), since (ISO-8601 lower bound on called_at), until (ISO-8601 upper bound on called_at).',
tags: ['ops'],
parameters: [
{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 1000, default: 100 } },
{ name: 'asset_slug', in: 'query', schema: { type: 'string' } },
{ name: 'route', in: 'query', description: 'Case-insensitive substring match against route template', schema: { type: 'string' } },
{ name: 'method', in: 'query', description: 'Exact HTTP method, case-insensitive', schema: { type: 'string', example: 'GET' } },
{ name: 'status', in: 'query', description: 'Exact 3-digit response status (200, 404, …) OR a 1-digit class prefix (2 → 2xx). Invalid input is silently ignored.', schema: { type: 'string', example: '404' } },
{ name: 'since', in: 'query', description: 'ISO-8601 timestamp; only rows with called_at >= since are returned. Unparseable input is silently ignored.', schema: { type: 'string', format: 'date-time' } },
{ name: 'until', in: 'query', description: 'ISO-8601 timestamp; only rows with called_at <= until are returned. Composes with since to bracket a window. Unparseable input is silently ignored.', schema: { type: 'string', format: 'date-time' } }
],
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 };