← 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 };