├── .circleci └── config.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── README.md ├── fixtures └── zora20210101_minified.json ├── jest.config.js ├── package.json ├── schemas ├── amulet │ └── 20210221.json ├── catalog │ └── 20210202.json └── zora │ ├── 20210101.json │ └── 20210604.json ├── src ├── generator.ts ├── index.ts ├── parser.ts ├── validator.ts └── versions.ts ├── tests ├── generator.test.ts ├── parser.test.ts ├── schemas.test.ts └── validator.test.ts ├── tsconfig.json ├── types ├── amulet │ └── 20210221.d.ts ├── catalog │ └── 20210202.d.ts ├── types.ts └── zora │ ├── 20210101.d.ts │ └── 20210604.d.ts └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | env_defaults: &env_defaults 2 | working_directory: ~ 3 | docker: 4 | - image: circleci/node:14.15.1 5 | 6 | version: 2.1 7 | jobs: 8 | prepare: 9 | <<: *env_defaults 10 | steps: 11 | - checkout 12 | 13 | # Download and cache dependencies 14 | - restore_cache: 15 | keys: 16 | - v1.0-dependencies-{{ checksum "yarn.lock" }} 17 | # fallback to using the latest cache if no exact match is found 18 | - v1.0-dependencies- 19 | 20 | - node/install-packages: 21 | pkg-manager: yarn 22 | 23 | - run: yarn install 24 | 25 | - save_cache: 26 | paths: 27 | - node_modules 28 | key: v1.0-dependencies-{{ checksum "yarn.lock" }} 29 | 30 | - persist_to_workspace: 31 | root: . 32 | paths: 33 | - node_modules 34 | 35 | test: 36 | <<: *env_defaults 37 | steps: 38 | - checkout 39 | - attach_workspace: 40 | at: . 41 | - run: 42 | command: yarn run test 43 | name: Run Tests 44 | 45 | build: 46 | <<: *env_defaults 47 | steps: 48 | - checkout 49 | - attach_workspace: 50 | at: . 51 | - run: 52 | command: yarn run build 53 | name: Build 54 | 55 | orbs: 56 | node: circleci/node@4.1.0 57 | workflows: 58 | testAndBuild: 59 | jobs: 60 | - prepare 61 | - test: 62 | requires: 63 | - prepare 64 | - build: 65 | requires: 66 | - test 67 | 68 | 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /tmp 4 | 5 | # IDEs and editors 6 | .idea 7 | .project 8 | .classpath 9 | .c9/ 10 | *.launch 11 | .settings/ 12 | *.sublime-workspace 13 | 14 | # IDE - VSCode 15 | .vscode/* 16 | !.vscode/settings.json 17 | !.vscode/tasks.json 18 | !.vscode/launch.json 19 | !.vscode/extensions.json 20 | 21 | # Logs 22 | logs 23 | *.log 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # Dependency directories 29 | node_modules/ 30 | 31 | # Optional npm cache directory 32 | .npm 33 | 34 | # Optional eslint cache 35 | .eslintcache 36 | 37 | # Output of 'npm pack' 38 | *.tgz 39 | 40 | # Yarn Integrity file 41 | .yarn-integrity 42 | 43 | # dotenv environment variables file 44 | .env 45 | 46 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "printWidth": 90 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.1.1] - 2021-02-22 10 | ### Added 11 | - catalog-20210202 schema 12 | 13 | 14 | ## [0.1.0] - 2021-01-25 15 | ### Added 16 | - zora-20210101 schema 17 | - Metadata Generator, Validator, Parser 18 | - Schema type codegen 19 | - Tests 20 | - README.md 21 | 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Media Metadata Schemas 2 | 3 | ## Overview 4 | 5 | The Zora Protocol requires media that is minted on its smart contracts contain a URI pointing to its metadata. 6 | 7 | The Zora Protocol maintains zero opinion about the structure of that metadata. It is explicitly not enforceable at the blockchain level. 8 | 9 | As such, this repository will serve as the source of truth of community supported metadata schemas described by JSON Schema, and will generate Types, Parsers, Generators, and Validators that will be served through the [Zora Development Kit (ZDK)](https://github.com/ourzora/zdk) 10 | 11 | ## Usage 12 | 13 | ### Generate 14 | 15 | Given a schema version and some nonformatted json it generates a valid, minified, alphabetized json 16 | 17 | ```typescript 18 | const metadata = { 19 | version: 'zora-20210101', 20 | name: randomName, 21 | description: randomDescription, 22 | mimeType: mimeType, 23 | } 24 | const generator = new Generator(metadata.version) 25 | const minified = generator.generate(metadata) 26 | ``` 27 | 28 | ### Validate 29 | 30 | ```typescript 31 | const metadata = { 32 | version: 'zora-20210101', 33 | name: randomName, 34 | description: randomDescription, 35 | mimeType: mimeType, 36 | } 37 | 38 | const validator = new Validator(metadata.version) 39 | const validated = validator.validate(metadata) 40 | ``` 41 | 42 | ### Parse 43 | 44 | ```typescript 45 | const json = ` 46 | { 47 | "version": "zora-20210101", 48 | "name": "randomName", 49 | "description": "randomDescription", 50 | "mimeType": "mimeType" 51 | } 52 | ` 53 | 54 | const parser = new Parser('zora-20210101') 55 | const parsed = parser.parse(json) 56 | ``` 57 | 58 | ## Tests 59 | 60 | `yarn test` 61 | 62 | ## Define a New Schema 63 | 64 | To define a new schema version, locate the directory of your project's name in `schemas/`. If a directory does not already exist create one. 65 | Within the project directory create a new file with the desired calendar version as the file name example: `schemas/zora/20210101.json` 66 | 67 | * Define the schema according to JSON Schema specification. 68 | * Write some tests in the `schema.tests.ts` file. 69 | * run `yarn codegen` to generate type specifically for your new schema 70 | * Add your version to the `supportedVersions` and `supportedVersionsTypeMapping` in `versions.ts` 71 | * Add your version type to the exported types in `types.ts` 72 | 73 | Submit a PR! 74 | 75 | Someone on our team will review and merge. 76 | 77 | ## Further Reading 78 | 79 | - JSON-schema spec: https://tools.ietf.org/html/draft-zyp-json-schema-04 80 | - JSON-schema wiki: https://github.com/json-schema/json-schema/wiki 81 | -------------------------------------------------------------------------------- /fixtures/zora20210101_minified.json: -------------------------------------------------------------------------------- 1 | {"description":"internet renaissance","mimeType":"application/json","name":"zora whitepaper","version":"zora-20210101"} -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { '^.+\\.ts?$': 'ts-jest' }, 3 | testEnvironment: 'node', 4 | testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$', 5 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zoralabs/media-metadata-schemas", 3 | "version": "0.1.4", 4 | "main": "dist/src/index.js", 5 | "typings": "dist/src/index.d.ts", 6 | "files": [ 7 | "dist" 8 | ], 9 | "repository": { 10 | "type": "git", 11 | "url": "git+ssh://git@github.com/ourzora/media-metadata-schemas.git" 12 | }, 13 | "author": " ", 14 | "license": "UNLICENSED", 15 | "scripts": { 16 | "clean": "trash dist", 17 | "build": "yarn clean && tsc && cp -R schemas dist && cpy types/**/*.d.ts dist --no-overwrite --parents", 18 | "test": "jest", 19 | "codegen": "json2ts -i schemas/ -o types/", 20 | "prepublish": "yarn codegen && yarn build" 21 | }, 22 | "dependencies": { 23 | "@types/jsonschema": "^1.1.1", 24 | "jsonschema": "^1.4.0", 25 | "ts-node": "^9.1.1", 26 | "tslib": "^2.0.3", 27 | "typescript": "^4.1.3" 28 | }, 29 | "devDependencies": { 30 | "@types/jest": "^26.0.20", 31 | "cpy-cli": "^3.1.1", 32 | "jest": "^26.6.3", 33 | "json-schema-to-typescript": "^10.1.1", 34 | "trash-cli": "^4.0.0", 35 | "ts-jest": "^26.4.4" 36 | }, 37 | "description": "Library for Defining and Interacting with Zora Media Metadata Schemas", 38 | "bugs": { 39 | "url": "https://github.com/ourzora/media-metadata-schemas/issues" 40 | }, 41 | "homepage": "https://github.com/ourzora/media-metadata-schemas#readme", 42 | "directories": { 43 | "test": "tests" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /schemas/amulet/20210221.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "This schema describes version 20210221 of the metadata standard for the amulet poem format, designed for the Zora protocol.", 3 | "title": "Amulet20210221", 4 | "$id": "http://text.bargains/schema.json", 5 | "type": "object", 6 | "properties": { 7 | "carbonOffsetURL": { 8 | "description": "An HTTPS link to the carbon offset purchased in this poem's name. This is not required in this metadata version, but if it is not specified in this field, it should be included in the `description` field.", 9 | "examples": [ 10 | "https://dashboard.cloverly.com/receipt/20210223-9e38b918ecfd9bfb051287bf71556736" 11 | ], 12 | "type": "string" 13 | }, 14 | "description": { 15 | "description": "The description of the amulet. You can feel free to have fun & be evocative with this; including a link to the current formal definition is probably wise, but not required.", 16 | "examples": [ 17 | "This is an amulet, a short poem with a lucky SHA-256 hash, explained here: https://text.bargains/ This poem's rarity is COMMON. Here is this poem's carbon offset: https://dashboard.cloverly.com/receipt/20210223-9e38b918ecfd9bfb051287bf71556736" 18 | ], 19 | "type": "string" 20 | }, 21 | "mimeType": { 22 | "description": "The mimeType of the amulet. This will always be text/plain.", 23 | "examples": [ 24 | "text/plain" 25 | ], 26 | "type": "string", 27 | "enum": ["text/plain"] 28 | }, 29 | "name": { 30 | "description": "The title of the amulet.", 31 | "examples": [ 32 | "morning amulet" 33 | ], 34 | "type": "string" 35 | }, 36 | "poemText": { 37 | "description": "The text of the amulet. You should include this if possible, but, in this metadata version, it's not formally required. The text at the contentURI in the Zora NFT is considered the canonical version.", 38 | "examples": [ 39 | "DON'T WORRY." 40 | ], 41 | "type": "string" 42 | }, 43 | "rarity": { 44 | "description": "The rarity level of the amulet.", 45 | "examples": [ 46 | "common" 47 | ], 48 | "type": "string", 49 | "enum": ["common", "uncommon", "rare", "epic", "legendary", "mythic", "beyond mythic"] 50 | }, 51 | "version": { 52 | "description": "The calendar version of the schema.", 53 | "const": "amulet-20210221", 54 | "type": "string" 55 | } 56 | }, 57 | "additionalProperties": true, 58 | "examples": [ 59 | { 60 | "description": "This is an amulet, a short poem with a lucky SHA-256 hash, explained here: https://text.bargains/ This poem's rarity is COMMON. Here is this poem's carbon offset: https://dashboard.cloverly.com/receipt/20210223-9e38b918ecfd9bfb051287bf71556736", 61 | "mimeType": "text/plain", 62 | "name": "morning amulet", 63 | "poemText": "DON'T WORRY.", 64 | "rarity": "common", 65 | "version": "amulet-20210221" 66 | } 67 | ], 68 | "required": [ 69 | "description", 70 | "mimeType", 71 | "name", 72 | "rarity", 73 | "version" 74 | ] 75 | } -------------------------------------------------------------------------------- /schemas/catalog/20210202.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "http://catalog.works/schemas/CatalogMetadata.json", 3 | "type": "object", 4 | "title": "Catalog20210202", 5 | "description": "This schema describes version 20210202 of the Catalog Metadata Standard", 6 | "properties": { 7 | "body": { 8 | "type": "object", 9 | "description": "Descriptive metadata properties of a Catalog record", 10 | "examples": [ 11 | { 12 | "version": "catalog-20210202", 13 | "title": "Our Taproot", 14 | "artist": "Omari Jazz", 15 | "notes": null, 16 | "duration": 65.881, 17 | "mimeType": "audio/aiff", 18 | "trackNumber": 9, 19 | "project": { 20 | "title": "Dream Child", 21 | "artwork": { 22 | "isNft": false, 23 | "info": { 24 | "uri": "https://ipfs.io/ipfs/bafybeibmxbxuw5n5hwmrhtyp2qlzgiu56qpzzwi3pbflbnbb4ykhhgy7gu", 25 | "mimeType": "image/jpeg" 26 | }, 27 | "nft": null 28 | } 29 | }, 30 | "artwork": { 31 | "isNft": false, 32 | "info": { 33 | "uri": "https://ipfs.io/ipfs/bafybeibmxbxuw5n5hwmrhtyp2qlzgiu56qpzzwi3pbflbnbb4ykhhgy7gu", 34 | "mimeType": "image/jpeg" 35 | }, 36 | "nft": null 37 | }, 38 | "visualizer": { 39 | "isNft": false, 40 | "info": { 41 | "uri": "https://ipfs.io/ipfs/QmQnDQbc7wDnVdtXbdrbY5ifwGw6QPAQGLwBvrV444vPhH", 42 | "mimeType": "video/mp4" 43 | }, 44 | "nft": null 45 | } 46 | } 47 | ], 48 | "required": [ 49 | "version", 50 | "title", 51 | "artist", 52 | "notes", 53 | "duration", 54 | "mimeType", 55 | "trackNumber", 56 | "project", 57 | "artwork", 58 | "visualizer" 59 | ], 60 | "properties": { 61 | "version": { 62 | "type": "string", 63 | "description": "Calendar version of the schema so that consumers can correctly parse the json", 64 | "const": "catalog-20210202" 65 | }, 66 | "title": { 67 | "type": "string", 68 | "description": "Title of the track", 69 | "examples": ["Our Taproot"] 70 | }, 71 | "artist": { 72 | "type": "string", 73 | "description": "Name of the artist who created the track", 74 | "minLength": 1, 75 | "examples": ["Omari Jazz"] 76 | }, 77 | "notes": { 78 | "type": ["string", "null"], 79 | "description": "An optional property for describing the track", 80 | "examples": ["Dreams are avatars of the subconscious", null] 81 | }, 82 | "duration": { 83 | "type": "number", 84 | "description": "Length of the audio file in seconds (must be > 1ms)", 85 | "minimum": 0.001, 86 | "examples": [65.881] 87 | }, 88 | "mimeType": { 89 | "type": "string", 90 | "description": "MimeType of the audio file. Only lossless formats (aif, wav, flac) are supported.", 91 | "enum": ["audio/x-aiff", "audio/aiff", "audio/wav", "audio/x-wav", "audio/flac"], 92 | "examples": ["audio/aiff"] 93 | }, 94 | "trackNumber": { 95 | "type": ["integer", "null"], 96 | "description": "The place which the track appears in its project (e.g. track 4 off an album)", 97 | "minimum": 1, 98 | "examples": [1, 9, null] 99 | }, 100 | "project": { 101 | "type": ["object", "null"], 102 | "description": "Describes the body of work the record is a part of (e.g. an album, EP, or compilation)", 103 | "required": [ 104 | "title", 105 | "artwork" 106 | ], 107 | "examples": [ 108 | { 109 | "title": "Dream Child", 110 | "artwork": { 111 | "isNft": false, 112 | "info": { 113 | "uri": "https://ipfs.io/ipfs/bafybeibmxbxuw5n5hwmrhtyp2qlzgiu56qpzzwi3pbflbnbb4ykhhgy7gu", 114 | "mimeType": "image/jpeg" 115 | }, 116 | "nft": null 117 | } 118 | } 119 | ], 120 | "properties": { 121 | "title": { 122 | "type": "string", 123 | "description": "The name of the project this record is on", 124 | "minLength": 1, 125 | "examples": ["Dream Child"] 126 | }, 127 | "artwork": { 128 | "oneOf": [{ "$ref": "#/definitions/artwork" }, { "type": "null" }], 129 | "description": "Artwork for the project (e.g. an album cover)" 130 | } 131 | } 132 | }, 133 | "artwork": { 134 | "$ref": "#/definitions/artwork", 135 | "description": "Cover art for the record" 136 | }, 137 | "visualizer": { 138 | "type": ["object", "null"], 139 | "required": [ 140 | "isNft", 141 | "info", 142 | "nft" 143 | ], 144 | "examples": [ 145 | { 146 | "isNft": false, 147 | "info": { 148 | "uri": "https://ipfs.io/ipfs/QmQnDQbc7wDnVdtXbdrbY5ifwGw6QPAQGLwBvrV444vPhH", 149 | "mimeType": "video/mp4" 150 | }, 151 | "nft": null 152 | } 153 | ], 154 | "properties": { 155 | "isNft": { 156 | "type": "boolean", 157 | "description": "Denotes if the visualizer is a separate NFT or is embedded into the Catalog record", 158 | "examples": [true, false] 159 | }, 160 | "info": { 161 | "type": ["object", "null"], 162 | "description": "Information about how to display the visualizer", 163 | "required": [ 164 | "uri", 165 | "mimeType" 166 | ], 167 | "examples": [ 168 | { 169 | "uri": "https://ipfs.io/ipfs/QmQnDQbc7wDnVdtXbdrbY5ifwGw6QPAQGLwBvrV444vPhH", 170 | "mimeType": "video/mp4" 171 | } 172 | ], 173 | "properties": { 174 | "uri": { 175 | "type": "string", 176 | "description": "Pointer to the visualizer", 177 | "examples": ["https://ipfs.io/ipfs/QmQnDQbc7wDnVdtXbdrbY5ifwGw6QPAQGLwBvrV444vPhH"] 178 | }, 179 | "mimeType": { 180 | "type": "string", 181 | "description": "MimeType of the visualizer", 182 | "examples": ["video/mp4"] 183 | } 184 | } 185 | }, 186 | "nft": { 187 | "type": ["object", "null"], 188 | "description": "Information about how to find the NFT", 189 | "required": [ 190 | "chainId", 191 | "contractAddress", 192 | "tokenId" 193 | ], 194 | "examples": [{ 195 | "chainId": 1, 196 | "contractAddress": "0xabEFBc9fD2F806065b4f3C237d4b59D9A97Bcac7", 197 | "tokenId": 147 198 | }], 199 | "properties": { 200 | "chainId": { 201 | "type": "integer", 202 | "description": "Ethereum network that the artwork NFT exists on", 203 | "minimum": 1, 204 | "examples": [1, 4] 205 | }, 206 | "contractAddress": { 207 | "type": "string", 208 | "description": "Address of the factory contract that was used to mint the visualizer NFT", 209 | "examples": ["0xabEFBc9fD2F806065b4f3C237d4b59D9A97Bcac7", "0x7C2668BD0D3c050703CEcC956C11Bd520c26f7d4"] 210 | }, 211 | "tokenId": { 212 | "type": "integer", 213 | "description": "Token ID of the artwork NFT", 214 | "minimum": 0, 215 | "examples": [69, 420] 216 | } 217 | }, 218 | "additionalProperties": false 219 | } 220 | } 221 | } 222 | }, 223 | "additionalProperties": false 224 | }, 225 | "origin": { 226 | "type": ["object", "null"], 227 | "description": "Information used to verify the authenticity of the record", 228 | "examples": [ 229 | { 230 | "algorithm": "secp256k1", 231 | "encoding": "rlp", 232 | "publicKey": "0x0CcCcDAd491D8255d19475d4cC18c954AE185b0e", 233 | "signature": "0xd97ea5d9857f526537c3fed95eaedc22043fd530874beb41484902aa998cd36629b0fdd67fcceb6528f475aab018dc2994a9a5d06a2528aed0fdd0e68cfa4d781b" 234 | } 235 | ], 236 | "required": [ 237 | "algorithm", 238 | "encoding", 239 | "publicKey", 240 | "signature" 241 | ], 242 | "properties": { 243 | "algorithm": { 244 | "type": "string", 245 | "description": "Algorithm used to sign the metadata body", 246 | "examples": ["secp256k1"] 247 | }, 248 | "encoding": { 249 | "type": "string", 250 | "description": "Encoding used in the signature", 251 | "examples": ["rlp"] 252 | }, 253 | "publicKey": { 254 | "type": "string", 255 | "description": "Public key used to verify the record's origin signature", 256 | "examples": ["0x0CcCcDAd491D8255d19475d4cC18c954AE185b0e"] 257 | }, 258 | "signature": { 259 | "type": "string", 260 | "description": "The result of the public key's corresponding private key signing the body of the record metadata. We sign the output of JSON.stringify(body), where body is the alphabetized and minified JSON object retrieved from the body key in the record's metadata.", 261 | "examples": ["0xd97ea5d9857f526537c3fed95eaedc22043fd530874beb41484902aa998cd36629b0fdd67fcceb6528f475aab018dc2994a9a5d06a2528aed0fdd0e68cfa4d781b"] 262 | } 263 | }, 264 | "additionalProperties": false 265 | } 266 | }, 267 | "examples": [ 268 | { 269 | "body": { 270 | "version": "catalog-20210202", 271 | "title": "Our Taproot", 272 | "artist": "Omari Jazz", 273 | "notes": null, 274 | "duration": 65.881, 275 | "mimeType": "audio/aiff", 276 | "trackNumber": 9, 277 | "project": { 278 | "title": "Dream Child", 279 | "artwork": { 280 | "isNft": false, 281 | "info": { 282 | "uri": "https://ipfs.io/ipfs/bafybeibmxbxuw5n5hwmrhtyp2qlzgiu56qpzzwi3pbflbnbb4ykhhgy7gu", 283 | "mimeType": "image/jpeg" 284 | }, 285 | "nft": null 286 | }, 287 | "notes": "DREAM CHILD\n\nDreams are avatars of the subconscious\nRefractions of the waking life\nI offer these songs in ritual\nTo tender my people's collective dream\n\nASHE" 288 | }, 289 | "artwork": { 290 | "isNft": false, 291 | "info": { 292 | "uri": "https://ipfs.io/ipfs/bafybeibmxbxuw5n5hwmrhtyp2qlzgiu56qpzzwi3pbflbnbb4ykhhgy7gu", 293 | "mimeType": "image/jpeg" 294 | }, 295 | "nft": null 296 | }, 297 | "visualizer": { 298 | "isNft": false, 299 | "info": { 300 | "uri": "https://ipfs.io/ipfs/QmQnDQbc7wDnVdtXbdrbY5ifwGw6QPAQGLwBvrV444vPhH", 301 | "mimeType": "video/mp4" 302 | }, 303 | "nft": null 304 | } 305 | }, 306 | "origin": { 307 | "algorithm": "secp256k1", 308 | "encoding": "rlp", 309 | "publicKey": "0x0CcCcDAd491D8255d19475d4cC18c954AE185b0e", 310 | "signature": "0xd97ea5d9857f526537c3fed95eaedc22043fd530874beb41484902aa998cd36629b0fdd67fcceb6528f475aab018dc2994a9a5d06a2528aed0fdd0e68cfa4d781b" 311 | } 312 | } 313 | ], 314 | "required": [ 315 | "body", 316 | "origin" 317 | ], 318 | "additionalProperties": false, 319 | "definitions": { 320 | "artwork": { 321 | "type": "object", 322 | "required": [ 323 | "isNft", 324 | "info", 325 | "nft" 326 | ], 327 | "properties": { 328 | "isNft": { 329 | "type": "boolean", 330 | "description": "Denotes if the artwork is a separate NFT or is embedded into the Catalog record", 331 | "examples": [true, false] 332 | }, 333 | "info": { 334 | "type": ["object", "null"], 335 | "description": "Information about how to display the artwork", 336 | "required": [ 337 | "uri", 338 | "mimeType" 339 | ], 340 | "examples": [ 341 | { 342 | "uri": "https://ipfs.io/ipfs/bafybeibmxbxuw5n5hwmrhtyp2qlzgiu56qpzzwi3pbflbnbb4ykhhgy7gu", 343 | "mimeType": "image/jpeg" 344 | } 345 | ], 346 | "properties": { 347 | "uri": { 348 | "type": "string", 349 | "description": "Pointer to the artwork", 350 | "examples": ["https://ipfs.io/ipfs/bafybeibmxbxuw5n5hwmrhtyp2qlzgiu56qpzzwi3pbflbnbb4ykhhgy7gu"] 351 | }, 352 | "mimeType": { 353 | "type": "string", 354 | "description": "MimeType of the artwork", 355 | "enum": ["image/jpeg", "image/pjpeg", "image/png"], 356 | "examples": ["image/jpeg", "image/pjpeg", "image/png"] 357 | } 358 | }, 359 | "additionalProperties": false 360 | }, 361 | "nft": { 362 | "type": ["object", "null"], 363 | "description": "Information about how to find the NFT", 364 | "required": [ 365 | "chainId", 366 | "contractAddress", 367 | "tokenId" 368 | ], 369 | "examples": [{ 370 | "chainId": 1, 371 | "contractAddress": "0xabEFBc9fD2F806065b4f3C237d4b59D9A97Bcac7", 372 | "tokenId": 321 373 | }], 374 | "properties": { 375 | "chainId": { 376 | "type": "integer", 377 | "description": "Ethereum network that the artwork NFT exists on", 378 | "minimum": 1, 379 | "examples": [1, 4] 380 | }, 381 | "contractAddress": { 382 | "type": "string", 383 | "description": "Address of the factory contract that was used to mint the artwork NFT", 384 | "examples": ["0xabEFBc9fD2F806065b4f3C237d4b59D9A97Bcac7", "0x7C2668BD0D3c050703CEcC956C11Bd520c26f7d4"] 385 | }, 386 | "tokenId": { 387 | "type": "integer", 388 | "description": "Token ID of the artwork NFT", 389 | "minimum": 0, 390 | "examples": [69, 147, 420] 391 | } 392 | }, 393 | "additionalProperties": false 394 | } 395 | }, 396 | "additionalProperties": false 397 | } 398 | } 399 | } -------------------------------------------------------------------------------- /schemas/zora/20210101.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "This schema describes version 20210101 of the Zora Metadata Standard", 3 | "title": "Zora20210101", 4 | "$id": "http://zora.co/schemas/ZoraMetadata.json", 5 | "type": "object", 6 | "properties": { 7 | "description": { 8 | "description": "The description of the media", 9 | "examples": [ 10 | "This paper describes protocol to create, share and exchange universally accessible and valuable information on the internet." 11 | ], 12 | "type": "string" 13 | }, 14 | "mimeType": { 15 | "description": "The mimeType of the media", 16 | "examples": [ 17 | "text/plain" 18 | ], 19 | "type": "string" 20 | }, 21 | "name": { 22 | "description": "This property is the name of the Media", 23 | "examples": [ 24 | "Zora Whitepaper" 25 | ], 26 | "type": "string" 27 | }, 28 | "version": { 29 | "description": "This property defines the calendar version of the schema so that consumers can correctly parse the json", 30 | "examples": [ 31 | "zora-20210101" 32 | ], 33 | "type": "string" 34 | } 35 | }, 36 | "additionalProperties": false, 37 | "examples": [ 38 | { 39 | "version": "zora-20210101", 40 | "name": "Zora Whitepaper", 41 | "description": "This paper describes protocol to create, share and exchange universally accessible and valuable information on the internet.", 42 | "mimeType": "text/plain" 43 | } 44 | ], 45 | "required": [ 46 | "version", 47 | "name", 48 | "description", 49 | "mimeType" 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /schemas/zora/20210604.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "This schema describes version 20210604 of the Zora Metadata Standard", 3 | "title": "Zora20210604", 4 | "$id": "https://zora.co/schemas/ZoraMetadata.json", 5 | "$defs": { 6 | "attribute": { 7 | "type": "object", 8 | "required": ["trait_type", "value"], 9 | "properties": { 10 | "trait_type": { 11 | "type": "string", 12 | "description": "The name of the trait" 13 | }, 14 | "value": { 15 | "type": ["string", "number", "boolean"], 16 | "description": "The value of the trait" 17 | }, 18 | "display_type": { 19 | "type": "string", 20 | "description": "A field indicating how the `value` data should be displayed. Defaults to 'string'" 21 | } 22 | } 23 | } 24 | }, 25 | "type": "object", 26 | "properties": { 27 | "description": { 28 | "description": "The description of the media", 29 | "examples": [ 30 | "This paper describes protocol to create, share and exchange universally accessible and valuable information on the internet." 31 | ], 32 | "type": "string" 33 | }, 34 | "mimeType": { 35 | "description": "The mimeType of the media", 36 | "examples": [ 37 | "text/plain" 38 | ], 39 | "type": "string" 40 | }, 41 | "name": { 42 | "description": "This property is the name of the Media", 43 | "examples": [ 44 | "Zora Whitepaper" 45 | ], 46 | "type": "string" 47 | }, 48 | "version": { 49 | "description": "This property defines the calendar version of the schema so that consumers can correctly parse the json", 50 | "examples": [ 51 | "zora-20210604" 52 | ], 53 | "type": "string" 54 | }, 55 | "image": { 56 | "description": "This property defines an optional preview image URL for the media", 57 | "examples": [ 58 | "https://ipfs.io/ipfs/bafkreig4qxcms7msakiafuapwensj2lamszmdbbukqxxcpfesvgd2jzswa" 59 | ], 60 | "type": "string" 61 | }, 62 | "external_url": { 63 | "type": "string", 64 | "description": "This property defines an optional external URL that can reference a webpage or external asset for the NFT", 65 | "examples": [ 66 | "https://zora.co/zora/61" 67 | ] 68 | }, 69 | "animation_url": { 70 | "type": "string", 71 | "description": "A URL to a multi-media attachment for the item. The file extensions GLTF, GLB, WEBM, MP4, M4V, OGV, and OGG are supported, along with the audio-only extensions MP3, WAV, and OGA. Animation_url also supports HTML pages, allowing you to build rich experiences using JavaScript canvas, WebGL, and more. Access to browser extensions is not supported" 72 | }, 73 | "attributes": { 74 | "type": "array", 75 | "description": "This property defines any additional attributes for the item", 76 | "items": { 77 | "$ref": "#/$defs/attribute" 78 | } 79 | } 80 | }, 81 | "additionalProperties": false, 82 | "examples": [ 83 | { 84 | "version": "zora-20210604", 85 | "name": "Zora Whitepaper", 86 | "description": "This paper describes protocol to create, share and exchange universally accessible and valuable information on the internet.", 87 | "mimeType": "text/plain" 88 | } 89 | ], 90 | "required": [ 91 | "version", 92 | "name", 93 | "description", 94 | "mimeType" 95 | ] 96 | } 97 | -------------------------------------------------------------------------------- /src/generator.ts: -------------------------------------------------------------------------------- 1 | import { Validator } from './validator' 2 | import { validateVersion } from './versions' 3 | 4 | export class Generator { 5 | public name: string 6 | public calVer: string 7 | 8 | constructor(version: string) { 9 | validateVersion(version) 10 | 11 | const [name, calVer] = version.split('-') 12 | this.name = name 13 | this.calVer = calVer 14 | } 15 | 16 | /** 17 | * Generates valid, minfied, and ordered (alphabetized keys) schema 18 | * Raises if the unordered json does not Validate against the Generator's schema 19 | * 20 | * @param unordered 21 | */ 22 | public generateJSON(unordered: { [key: string]: any }): string { 23 | // validate the schema 24 | const version = this.name.concat('-').concat(this.calVer) 25 | const validator = new Validator(version) 26 | const validated = validator.validate(unordered) 27 | if (!validated) { 28 | throw new Error(`JSON does not conform to the ${version} schema.`) 29 | } 30 | 31 | // alphabetize key 32 | const ordered: { [key: string]: {} } = {} 33 | Object.keys(unordered) 34 | .sort() 35 | .forEach(key => { 36 | ordered[key] = unordered[key] 37 | }) 38 | 39 | return JSON.stringify(ordered) // minify 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './validator' 2 | export * from './generator' 3 | export * from './versions' 4 | export * from './parser' 5 | export * from '../types/types' 6 | -------------------------------------------------------------------------------- /src/parser.ts: -------------------------------------------------------------------------------- 1 | import { MetadataLike } from '../types/types' 2 | import { validateVersion } from './versions' 3 | 4 | export class Parser { 5 | public name: string 6 | public calVer: string 7 | 8 | constructor(version: string) { 9 | validateVersion(version) 10 | 11 | const [name, calVer] = version.split('-') 12 | this.name = name 13 | this.calVer = calVer 14 | } 15 | 16 | /** 17 | * Parses the JSON string 18 | * 19 | * @param json 20 | */ 21 | public parse(json: string): MetadataLike { 22 | const parsed: MetadataLike = JSON.parse(json) 23 | return parsed 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/validator.ts: -------------------------------------------------------------------------------- 1 | import { Validator as JsonValidator } from 'jsonschema' 2 | import { validateVersion } from './versions' 3 | 4 | export class Validator { 5 | public name: string 6 | public calVer: string 7 | 8 | constructor(version: string) { 9 | // require version - 10 | validateVersion(version) 11 | 12 | const [name, calVer] = version.split('-') 13 | this.name = name 14 | this.calVer = calVer 15 | } 16 | 17 | /** 18 | * Validates the passed json against the Validator's schema 19 | * 20 | * @param json 21 | */ 22 | public validate(json: { [key: string]: any }): boolean { 23 | const jsonValidator = new JsonValidator() 24 | const schema = require(`../schemas/${this.name}/${this.calVer}.json`) 25 | return jsonValidator.validate(json, schema).valid 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/versions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | */ 4 | export const supportedVersions: { [key: string]: Array } = { 5 | zora: ['20210101', '20210604'], 6 | catalog: ['20210202'], 7 | amulet: ['20210221'] 8 | } 9 | 10 | /** 11 | * 12 | */ 13 | export const supportedVersionsTypeMapping: { 14 | [key: string]: { [key: string]: string } 15 | } = { 16 | zora: { 17 | '20210101': 'Zora20210101', 18 | '20210604': 'Zora20210604' 19 | }, 20 | catalog: { 21 | '20210202': 'Catalog20210202' 22 | }, 23 | amulet: { 24 | '20210221': 'Amulet20210221' 25 | } 26 | } 27 | 28 | /** 29 | * 30 | * @param verboseVersion 31 | */ 32 | export function validateVersion(verboseVersion: string): void { 33 | const [name, calVer] = verboseVersion.split('-') 34 | 35 | // require name exists in `versions` 36 | if (!(name in supportedVersions)) { 37 | throw new Error(`There are no versions with the ${name} project name`) 38 | } 39 | 40 | // require calVer exists in `versions` 41 | if (supportedVersions[name].indexOf(calVer) == -1) { 42 | throw new Error( 43 | `There are no versions in the ${name} namespace with the ${calVer} calendar version` 44 | ) 45 | } 46 | 47 | return 48 | } 49 | -------------------------------------------------------------------------------- /tests/generator.test.ts: -------------------------------------------------------------------------------- 1 | import { Generator } from '../src/generator' 2 | 3 | describe('Generator', () => { 4 | describe('#constructor', () => { 5 | it('raises when an unsupported schema version is specified', () => { 6 | expect(() => { 7 | new Generator('zora-20190101') 8 | }).toThrow( 9 | 'There are no versions in the zora namespace with the 20190101 calendar version' 10 | ) 11 | 12 | expect(() => { 13 | new Generator('coinbase-20190101') 14 | }).toThrow('There are no versions with the coinbase project name') 15 | }) 16 | }) 17 | 18 | describe('#generate', () => { 19 | it('generates valid alphabetized, minified json', () => { 20 | const generator = new Generator('zora-20210101') 21 | const unordered = { 22 | name: 'zora whitepaper', 23 | mimeType: 'application/json', 24 | version: 'zora-20210101', 25 | description: 'internet renaissance' 26 | } 27 | const ordered = generator.generateJSON(unordered) 28 | const expected = require('../fixtures/zora20210101_minified.json') 29 | expect(ordered).toBe(JSON.stringify(expected)) 30 | }) 31 | 32 | it("raises if the unorder json does not conform to version's schema", () => { 33 | const generator = new Generator('zora-20210101') 34 | const unordered = { 35 | name: 'zora whitepaper', 36 | mimeType: 'application/json', 37 | version: 'zora-20210101', 38 | description: 'internet renaissance', 39 | someAdditionalProperty: 'stuff' 40 | } 41 | expect(() => { 42 | generator.generateJSON(unordered) 43 | }).toThrow('JSON does not conform to the zora-20210101 schema.') 44 | }) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /tests/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { Parser } from '../src/parser' 2 | import { Zora20210101 } from '../types/types' 3 | 4 | describe('Parser', () => { 5 | describe('#constructor', () => { 6 | it('raises when an unsupported schema version is specified', () => { 7 | expect(() => { 8 | new Parser('zora-20190101') 9 | }).toThrow( 10 | 'There are no versions in the zora namespace with the 20190101 calendar version' 11 | ) 12 | 13 | expect(() => { 14 | new Parser('coinbase-20190101') 15 | }).toThrow('There are no versions with the coinbase project name') 16 | }) 17 | }) 18 | 19 | describe('#parse', () => { 20 | it('it parses the metadata', () => { 21 | const parser = new Parser('zora-20210101') 22 | const json = { 23 | description: 'blah', 24 | mimeType: 'application/json', 25 | name: 'who cares', 26 | version: 'zora-01012021' 27 | } 28 | 29 | const result = parser.parse(JSON.stringify(json)) 30 | expect(isZora20210101(result)).toBe(true) 31 | expect(result).toMatchObject(json) 32 | }) 33 | }) 34 | }) 35 | 36 | function isZora20210101(json: Object): json is Zora20210101 { 37 | return ( 38 | 'name' in json && 'mimeType' in json && 'version' in json && 'description' in json 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /tests/schemas.test.ts: -------------------------------------------------------------------------------- 1 | import { Validator } from '../src/validator' 2 | 3 | describe('schemas', () => { 4 | describe('zora', () => { 5 | describe('20210101', () => { 6 | it('requires all keys', () => { 7 | const validator = new Validator('zora-20210101') 8 | const json = { 9 | description: 'blah', 10 | mimeType: 'application/json', 11 | name: 'who cares' 12 | } 13 | 14 | const result = validator.validate(json) 15 | expect(result).toBe(false) 16 | }) 17 | 18 | it('does not allow additional properties', () => { 19 | const validator = new Validator('zora-20210101') 20 | const json = { 21 | description: 'blah', 22 | mimeType: 'application/json', 23 | name: 'who cares', 24 | version: 'zora-01012021', 25 | someAdditionalProperty: 'okay' 26 | } 27 | 28 | const result = validator.validate(json) 29 | expect(result).toBe(false) 30 | }) 31 | 32 | it('requires string values', () => { 33 | const validator = new Validator('zora-20210101') 34 | const json = { 35 | description: 'blah', 36 | mimeType: 'application/json', 37 | name: 100, 38 | version: 'zora-01012021' 39 | } 40 | 41 | const result = validator.validate(json) 42 | expect(result).toBe(false) 43 | }) 44 | 45 | it('validates a valid schema', () => { 46 | const validator = new Validator('zora-20210101') 47 | const json = { 48 | description: 'blah', 49 | mimeType: 'application/json', 50 | name: 'who cares', 51 | version: 'zora-01012021' 52 | } 53 | 54 | const result = validator.validate(json) 55 | expect(result).toBe(true) 56 | }) 57 | }) 58 | describe('20210604', () => { 59 | it('requires all keys', () => { 60 | const validator = new Validator('zora-20210604') 61 | const json = { 62 | description: 'blah', 63 | mimeType: 'application/json', 64 | name: 'who cares' 65 | } 66 | 67 | const result = validator.validate(json) 68 | expect(result).toBe(false) 69 | }) 70 | 71 | it('does not allow additional properties', () => { 72 | const validator = new Validator('zora-20210604') 73 | const json = { 74 | description: 'blah', 75 | mimeType: 'application/json', 76 | name: 'who cares', 77 | version: 'zora-20210604', 78 | someAdditionalProperty: 'okay' 79 | } 80 | 81 | const result = validator.validate(json) 82 | expect(result).toBe(false) 83 | }) 84 | 85 | it('requires string values', () => { 86 | const validator = new Validator('zora-20210604') 87 | const json = { 88 | description: 'blah', 89 | mimeType: 'application/json', 90 | name: 100, 91 | version: 'zora-20210604' 92 | } 93 | 94 | const result = validator.validate(json) 95 | expect(result).toBe(false) 96 | }) 97 | 98 | it('validates a valid minimal schema', () => { 99 | const validator = new Validator('zora-20210604') 100 | const json = { 101 | description: 'blah', 102 | mimeType: 'application/json', 103 | name: 'who cares', 104 | version: 'zora-20210604' 105 | } 106 | 107 | const result = validator.validate(json) 108 | expect(result).toBe(true) 109 | }) 110 | 111 | it('validates a valid full schema', () => { 112 | const validator = new Validator('zora-20210604') 113 | const json = { 114 | description: 'blah', 115 | mimeType: 'application/json', 116 | name: 'who cares', 117 | version: 'zora-20210604', 118 | image: 'someURL', 119 | external_url: 'zora.co', 120 | animation_url: 'zora.co', 121 | attributes: [{trait_type: 'color', value: 'blue'}, {trait_type: 'rank', value: 1, display_type: 'number'}, {trait_type: 'good?', value: true, display_type: 'boolean'}] 122 | } 123 | 124 | const result = validator.validate(json) 125 | expect(result).toBe(true) 126 | }) 127 | }) 128 | }) 129 | describe('catalog', () => { 130 | describe('20210202', () => { 131 | it('requires all keys', () => { 132 | const validator = new Validator('catalog-20210202') 133 | const json = { 134 | body: { 135 | version: "catalog-20210202", 136 | title: "Never Gonna Give You Up", 137 | notes: "An unexpected classic" 138 | } 139 | } 140 | 141 | const result = validator.validate(json) 142 | expect(result).toBe(false) 143 | }) 144 | 145 | it('does not allow additional properties', () => { 146 | const validator = new Validator('catalog-20210202') 147 | const json = { 148 | body: { 149 | version: "catalog-20210202", 150 | title: "Never Gonna Give You Up", 151 | artist: "Rick Astley", 152 | notes: "An unexpected classic", 153 | duration: 213.1, 154 | grammyNominated: "I wish", 155 | mimeType: "audio/aiff", 156 | trackNumber: null, 157 | project: null, 158 | artwork: { 159 | isNft: false, 160 | info: { 161 | uri: "https://img.discogs.com/Q8B0-mvrVFLPx5jZLlRa2zialII=/fit-in/584x579/filters:strip_icc():format(jpeg):mode_rgb():quality(90)/discogs-images/R-995860-1300387504.jpeg.jpg", 162 | mimeType: "image/jpeg" 163 | }, 164 | nft: null 165 | }, 166 | visualizer: { 167 | isNft: false, 168 | info: { 169 | uri: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", 170 | mimeType: "video/mp4" 171 | }, 172 | nft: null 173 | } 174 | }, 175 | origin: { 176 | algorithm: "secp256k1", 177 | encoding: "rlp", 178 | publicKey: "0xc236541380fc0C2C05c2F2c6c52a21ED57c37952", 179 | signature: "0x2d7c0dc8a9252bb8cf0e654c58badb0585f41941270765e46c238a1692243e6d128bbaf072c2886348d49498794365f60f1793a758a7d1c281affc9c81de61ae1b" 180 | } 181 | } 182 | 183 | const result = validator.validate(json) 184 | expect(result).toBe(false) 185 | }) 186 | it('does not allow forbidden audio file formats', () => { 187 | const validator = new Validator('catalog-20210202') 188 | const json = { 189 | body: { 190 | version: "catalog-20210202", 191 | title: "Never Gonna Give You Up", 192 | artist: "Rick Astley", 193 | notes: "An unexpected classic", 194 | duration: 213.1, 195 | mimeType: "audio/mp3", 196 | trackNumber: null, 197 | project: null, 198 | artwork: { 199 | isNft: false, 200 | info: { 201 | uri: "https://img.discogs.com/Q8B0-mvrVFLPx5jZLlRa2zialII=/fit-in/584x579/filters:strip_icc():format(jpeg):mode_rgb():quality(90)/discogs-images/R-995860-1300387504.jpeg.jpg", 202 | mimeType: "image/jpeg" 203 | }, 204 | nft: null 205 | }, 206 | visualizer: { 207 | isNft: false, 208 | info: { 209 | uri: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", 210 | mimeType: "video/mp4" 211 | }, 212 | nft: null 213 | } 214 | }, 215 | origin: { 216 | algorithm: "secp256k1", 217 | encoding: "rlp", 218 | publicKey: "0xc236541380fc0C2C05c2F2c6c52a21ED57c37952", 219 | signature: "0x2d7c0dc8a9252bb8cf0e654c58badb0585f41941270765e46c238a1692243e6d128bbaf072c2886348d49498794365f60f1793a758a7d1c281affc9c81de61ae1b" 220 | } 221 | } 222 | 223 | const result = validator.validate(json) 224 | expect(result).toBe(false) 225 | }) 226 | 227 | it('requires properties are of the correct type', () => { 228 | const validator = new Validator('catalog-20210202') 229 | const json = { 230 | body: { 231 | version: "catalog-20210202", 232 | title: "Never Gonna Give You Up", 233 | artist: "Rick Astley", 234 | notes: "An unexpected classic", 235 | duration: "213.1", 236 | grammyNominated: "I wish", 237 | mimeType: "audio/aiff", 238 | trackNumber: null, 239 | project: null, 240 | artwork: { 241 | isNft: "false", 242 | info: { 243 | uri: "https://img.discogs.com/Q8B0-mvrVFLPx5jZLlRa2zialII=/fit-in/584x579/filters:strip_icc():format(jpeg):mode_rgb():quality(90)/discogs-images/R-995860-1300387504.jpeg.jpg", 244 | mimeType: "image/jpeg" 245 | }, 246 | nft: null 247 | }, 248 | visualizer: { 249 | isNft: false, 250 | info: { 251 | uri: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", 252 | mimeType: "video/mp4" 253 | }, 254 | nft: null 255 | } 256 | }, 257 | origin: { 258 | algorithm: "secp256k1", 259 | encoding: "rlp", 260 | publicKey: "0xc236541380fc0C2C05c2F2c6c52a21ED57c37952", 261 | signature: "0x2d7c0dc8a9252bb8cf0e654c58badb0585f41941270765e46c238a1692243e6d128bbaf072c2886348d49498794365f60f1793a758a7d1c281affc9c81de61ae1b" 262 | } 263 | } 264 | 265 | const result = validator.validate(json) 266 | expect(result).toBe(false) 267 | }) 268 | 269 | it('does not allow null for non-nullable fields', () => { 270 | const validator = new Validator('catalog-20210202') 271 | const json = { 272 | body: { 273 | version: "catalog-20210202", 274 | title: null, 275 | artist: null, 276 | notes: "An unexpected classic", 277 | duration: 213.1, 278 | grammyNominated: "I wish", 279 | mimeType: "audio/aiff", 280 | trackNumber: null, 281 | project: null, 282 | artwork: { 283 | isNft: false, 284 | info: { 285 | uri: "https://img.discogs.com/Q8B0-mvrVFLPx5jZLlRa2zialII=/fit-in/584x579/filters:strip_icc():format(jpeg):mode_rgb():quality(90)/discogs-images/R-995860-1300387504.jpeg.jpg", 286 | mimeType: "image/jpeg" 287 | }, 288 | nft: null 289 | }, 290 | visualizer: { 291 | isNft: false, 292 | info: { 293 | uri: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", 294 | mimeType: "video/mp4" 295 | }, 296 | nft: null 297 | } 298 | }, 299 | origin: { 300 | algorithm: "secp256k1", 301 | encoding: "rlp", 302 | publicKey: "0xc236541380fc0C2C05c2F2c6c52a21ED57c37952", 303 | signature: "0x2d7c0dc8a9252bb8cf0e654c58badb0585f41941270765e46c238a1692243e6d128bbaf072c2886348d49498794365f60f1793a758a7d1c281affc9c81de61ae1b" 304 | } 305 | } 306 | 307 | const result = validator.validate(json) 308 | expect(result).toBe(false) 309 | }) 310 | 311 | it('does not allow an empty artist field', () => { 312 | const validator = new Validator('catalog-20210202') 313 | const json = { 314 | body: { 315 | version: "catalog-20210202", 316 | title: "Never Gonna Give You Up", 317 | artist: "", 318 | notes: "An unexpected classic", 319 | duration: 213.1, 320 | grammyNominated: "I wish", 321 | mimeType: "audio/aiff", 322 | trackNumber: null, 323 | project: null, 324 | artwork: { 325 | isNft: false, 326 | info: { 327 | uri: "https://img.discogs.com/Q8B0-mvrVFLPx5jZLlRa2zialII=/fit-in/584x579/filters:strip_icc():format(jpeg):mode_rgb():quality(90)/discogs-images/R-995860-1300387504.jpeg.jpg", 328 | mimeType: "image/jpeg" 329 | }, 330 | nft: null 331 | }, 332 | visualizer: { 333 | isNft: false, 334 | info: { 335 | uri: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", 336 | mimeType: "video/mp4" 337 | }, 338 | nft: null 339 | } 340 | }, 341 | origin: { 342 | algorithm: "secp256k1", 343 | encoding: "rlp", 344 | publicKey: "0xc236541380fc0C2C05c2F2c6c52a21ED57c37952", 345 | signature: "0x2d7c0dc8a9252bb8cf0e654c58badb0585f41941270765e46c238a1692243e6d128bbaf072c2886348d49498794365f60f1793a758a7d1c281affc9c81de61ae1b" 346 | } 347 | } 348 | 349 | const result = validator.validate(json) 350 | expect(result).toBe(false) 351 | }) 352 | 353 | it('requires the correct Catalog schema version', () => { 354 | const validator = new Validator('catalog-20210202') 355 | const json = { 356 | body: { 357 | version: "catalog-0", 358 | title: "Our Taproot", 359 | artist: "Omari Jazz", 360 | notes: null, 361 | duration: 1.0, 362 | mimeType: "audio/aiff", 363 | trackNumber: 9, 364 | project: { 365 | title: "Dream Child", 366 | artwork: { 367 | isNft: false, 368 | info: { 369 | uri: "https://ipfs.io/ipfs/QmRMDmDsQPGaNhRikBgTiCDjtk93uuBPwN5myeYXczV9Ug", 370 | mimeType: "image/jpeg" 371 | }, 372 | nft: null 373 | } 374 | }, 375 | artwork: { 376 | isNft: false, 377 | info: { 378 | uri: "https://ipfs.io/ipfs/QmRMDmDsQPGaNhRikBgTiCDjtk93uuBPwN5myeYXczV9Ug", 379 | mimeType: "image/jpeg" 380 | }, 381 | nft: null 382 | }, 383 | visualizer: { 384 | isNft: false, 385 | info: { 386 | uri: "https://ipfs.io/ipfs/QmQnDQbc7wDnVdtXbdrbY5ifwGw6QPAQGLwBvrV444vPhH", 387 | mimeType: "video/mp4" 388 | }, 389 | nft: null 390 | } 391 | }, 392 | origin: { 393 | algorithm: "secp256k1", 394 | encoding: "rlp", 395 | publicKey: "0xc236541380fc0C2C05c2F2c6c52a21ED57c37952", 396 | signature: "0x2d7c0dc8a9252bb8cf0e654c58badb0585f41941270765e46c238a1692243e6d128bbaf072c2886348d49498794365f60f1793a758a7d1c281affc9c81de61ae1b" 397 | } 398 | } 399 | 400 | const result = validator.validate(json) 401 | expect(result).toBe(false) 402 | }) 403 | 404 | it('validates a valid Catalog metadata schema (with non-NFT cover art)', () => { 405 | const validator = new Validator('catalog-20210202') 406 | const json = { 407 | body: { 408 | version: "catalog-20210202", 409 | title: "Our Taproot", 410 | artist: "Omari Jazz", 411 | notes: null, 412 | duration: 1.0, 413 | mimeType: "audio/aiff", 414 | trackNumber: 9, 415 | project: { 416 | title: "Dream Child", 417 | artwork: { 418 | isNft: false, 419 | info: { 420 | uri: "https://ipfs.io/ipfs/QmRMDmDsQPGaNhRikBgTiCDjtk93uuBPwN5myeYXczV9Ug", 421 | mimeType: "image/jpeg" 422 | }, 423 | nft: null 424 | } 425 | }, 426 | artwork: { 427 | isNft: false, 428 | info: { 429 | uri: "https://ipfs.io/ipfs/QmRMDmDsQPGaNhRikBgTiCDjtk93uuBPwN5myeYXczV9Ug", 430 | mimeType: "image/jpeg" 431 | }, 432 | nft: null 433 | }, 434 | visualizer: { 435 | isNft: false, 436 | info: { 437 | uri: "https://ipfs.io/ipfs/QmQnDQbc7wDnVdtXbdrbY5ifwGw6QPAQGLwBvrV444vPhH", 438 | mimeType: "video/mp4" 439 | }, 440 | nft: null 441 | } 442 | }, 443 | origin: { 444 | algorithm: "secp256k1", 445 | encoding: "rlp", 446 | publicKey: "0xc236541380fc0C2C05c2F2c6c52a21ED57c37952", 447 | signature: "0x2d7c0dc8a9252bb8cf0e654c58badb0585f41941270765e46c238a1692243e6d128bbaf072c2886348d49498794365f60f1793a758a7d1c281affc9c81de61ae1b" 448 | } 449 | } 450 | 451 | const result = validator.validate(json) 452 | expect(result).toBe(true) 453 | }) 454 | 455 | it('validates a valid Catalog metadata schema (with NFTs)', () => { 456 | const validator = new Validator('catalog-20210202') 457 | const json = { 458 | body: { 459 | version: "catalog-20210202", 460 | title: "Our Taproot", 461 | artist: "Omari Jazz", 462 | notes: null, 463 | duration: 1.0, 464 | mimeType: "audio/aiff", 465 | trackNumber: 9, 466 | project: { 467 | title: "Dream Child", 468 | artwork: { 469 | isNft: false, 470 | info: { 471 | uri: "https://ipfs.io/ipfs/QmRMDmDsQPGaNhRikBgTiCDjtk93uuBPwN5myeYXczV9Ug", 472 | mimeType: "image/jpeg" 473 | }, 474 | nft: null 475 | } 476 | }, 477 | artwork: { 478 | isNft: true, 479 | info: null, 480 | nft: { 481 | chainId: 1, 482 | contractAddress: "0xabEFBc9fD2F806065b4f3C237d4b59D9A97Bcac7", 483 | tokenId: 147 484 | } 485 | }, 486 | visualizer: { 487 | isNft: false, 488 | info: null, 489 | nft: { 490 | chainId: 1, 491 | contractAddress: "0xabEFBc9fD2F806065b4f3C237d4b59D9A97Bcac7", 492 | tokenId: 147 493 | } 494 | } 495 | }, 496 | origin: { 497 | algorithm: "secp256k1", 498 | encoding: "rlp", 499 | publicKey: "0xc236541380fc0C2C05c2F2c6c52a21ED57c37952", 500 | signature: "0x2d7c0dc8a9252bb8cf0e654c58badb0585f41941270765e46c238a1692243e6d128bbaf072c2886348d49498794365f60f1793a758a7d1c281affc9c81de61ae1b" 501 | } 502 | } 503 | 504 | const result = validator.validate(json) 505 | expect(result).toBe(true) 506 | }) 507 | }) 508 | }) 509 | 510 | describe('amulet', () => { 511 | describe('20210221', () => { 512 | it('requires all keys', () => { 513 | const validator = new Validator('amulet-20210221') 514 | const json = { 515 | description: 'it is an amulet, what do you want from me!', 516 | mimeType: 'text/plain', 517 | name: 'a fine amulet', 518 | } 519 | 520 | const result = validator.validate(json) 521 | expect(result).toBe(false) 522 | }) 523 | 524 | it('cannot be assigned a random rarity', () => { 525 | const validator = new Validator('amulet-20210221') 526 | const json = { 527 | version: 'amulet-20210221', 528 | description: 'it is an amulet, what do you want from me!', 529 | mimeType: 'text/plain', 530 | name: 'a fine amulet', 531 | rarity: 'hyperrare' 532 | } 533 | 534 | const result = validator.validate(json) 535 | expect(result).toBe(false) 536 | }) 537 | 538 | it('must be of mimeType text/plain', () => { 539 | const validator = new Validator('amulet-20210221') 540 | const json = { 541 | version: 'amulet-20210101', 542 | carbonOffsetURL: 'https://dashboard.cloverly.com/receipt/20210223-9e38b918ecfd9bfb051287bf71556736', 543 | description: 'it is a picture of an amulet', 544 | mimeType: 'image/jpeg', 545 | name: 'pic of my amulet', 546 | poemText: 'DON\'T WORRY.', 547 | rarity: 'common' 548 | } 549 | 550 | const result = validator.validate(json) 551 | expect(result).toBe(false) 552 | }) 553 | }) 554 | }) 555 | }) 556 | -------------------------------------------------------------------------------- /tests/validator.test.ts: -------------------------------------------------------------------------------- 1 | import { Validator } from '../src/validator' 2 | 3 | describe('Validator', () => { 4 | describe('#constructor', () => { 5 | it('raises when an unsupported schema version is specified', () => { 6 | expect(() => { 7 | new Validator('zora-20190101') 8 | }).toThrow( 9 | 'There are no versions in the zora namespace with the 20190101 calendar version' 10 | ) 11 | 12 | expect(() => { 13 | new Validator('coinbase-20190101') 14 | }).toThrow('There are no versions with the coinbase project name') 15 | }) 16 | }) 17 | 18 | describe('#validate', () => { 19 | it('it returns true if the schema is correct', () => { 20 | const validator = new Validator('zora-20210101') 21 | const json = { 22 | description: 'blah', 23 | mimeType: 'application/json', 24 | name: 'who cares', 25 | version: 'zora-01012021' 26 | } 27 | 28 | const result = validator.validate(json) 29 | expect(result).toBe(true) 30 | }) 31 | 32 | it('it returns false if the schema is incorrect', () => { 33 | const validator = new Validator('zora-20210101') 34 | const json = { 35 | description: 'blah', 36 | mimeType: 'application/json', 37 | name: 'who cares', 38 | version: 'zora-01012021', 39 | additionalProperty: 'idk' 40 | } 41 | 42 | const result = validator.validate(json) 43 | expect(result).toBe(false) 44 | }) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "target": "es2018", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "allowSyntheticDefaultImports": true, 8 | "preserveConstEnums": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "importHelpers": true, 11 | "declaration": true, 12 | "sourceMap": true, 13 | "rootDir": "./", 14 | "strict": true, 15 | "noImplicitAny": true, 16 | "strictNullChecks": true, 17 | "strictFunctionTypes": true, 18 | "strictPropertyInitialization": true, 19 | "noImplicitThis": true, 20 | "alwaysStrict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noImplicitReturns": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "baseUrl": "./", 26 | "esModuleInterop": true, 27 | "resolveJsonModule": true 28 | }, 29 | "include": ["./src", "./types", "./schemas/**/*"], 30 | "exclude": ["node_modules"] 31 | } 32 | -------------------------------------------------------------------------------- /types/amulet/20210221.d.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /** 3 | * This file was automatically generated by json-schema-to-typescript. 4 | * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, 5 | * and run json-schema-to-typescript to regenerate this file. 6 | */ 7 | 8 | /** 9 | * This schema describes version 20210221 of the metadata standard for the amulet poem format, designed for the Zora protocol. 10 | */ 11 | export interface Amulet20210221 { 12 | /** 13 | * An HTTPS link to the carbon offset purchased in this poem's name. This is not required in this metadata version, but if it is not specified in this field, it should be included in the `description` field. 14 | */ 15 | carbonOffsetURL?: string; 16 | /** 17 | * The description of the amulet. You can feel free to have fun & be evocative with this; including a link to the current formal definition is probably wise, but not required. 18 | */ 19 | description: string; 20 | /** 21 | * The mimeType of the amulet. This will always be text/plain. 22 | */ 23 | mimeType: "text/plain"; 24 | /** 25 | * The title of the amulet. 26 | */ 27 | name: string; 28 | /** 29 | * The text of the amulet. You should include this if possible, but, in this metadata version, it's not formally required. The text at the contentURI in the Zora NFT is considered the canonical version. 30 | */ 31 | poemText?: string; 32 | /** 33 | * The rarity level of the amulet. 34 | */ 35 | rarity: "common" | "uncommon" | "rare" | "epic" | "legendary" | "mythic" | "beyond mythic"; 36 | /** 37 | * The calendar version of the schema. 38 | */ 39 | version: "amulet-20210221"; 40 | [k: string]: unknown; 41 | } 42 | -------------------------------------------------------------------------------- /types/catalog/20210202.d.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /** 3 | * This file was automatically generated by json-schema-to-typescript. 4 | * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, 5 | * and run json-schema-to-typescript to regenerate this file. 6 | */ 7 | 8 | /** 9 | * This schema describes version 20210202 of the Catalog Metadata Standard 10 | */ 11 | export interface Catalog20210202 { 12 | /** 13 | * Descriptive metadata properties of a Catalog record 14 | */ 15 | body: { 16 | /** 17 | * Calendar version of the schema so that consumers can correctly parse the json 18 | */ 19 | version: "catalog-20210202"; 20 | /** 21 | * Title of the track 22 | */ 23 | title: string; 24 | /** 25 | * Name of the artist who created the track 26 | */ 27 | artist: string; 28 | /** 29 | * An optional property for describing the track 30 | */ 31 | notes: string | null; 32 | /** 33 | * Length of the audio file in seconds (must be > 1ms) 34 | */ 35 | duration: number; 36 | /** 37 | * MimeType of the audio file. Only lossless formats (aif, wav, flac) are supported. 38 | */ 39 | mimeType: "audio/x-aiff" | "audio/aiff" | "audio/wav" | "audio/x-wav" | "audio/flac"; 40 | /** 41 | * The place which the track appears in its project (e.g. track 4 off an album) 42 | */ 43 | trackNumber: number | null; 44 | /** 45 | * Describes the body of work the record is a part of (e.g. an album, EP, or compilation) 46 | */ 47 | project: { 48 | /** 49 | * The name of the project this record is on 50 | */ 51 | title: string; 52 | /** 53 | * Artwork for the project (e.g. an album cover) 54 | */ 55 | artwork: Artwork | null; 56 | [k: string]: unknown; 57 | } | null; 58 | /** 59 | * Cover art for the record 60 | */ 61 | artwork: { 62 | /** 63 | * Denotes if the artwork is a separate NFT or is embedded into the Catalog record 64 | */ 65 | isNft: boolean; 66 | /** 67 | * Information about how to display the artwork 68 | */ 69 | info: { 70 | /** 71 | * Pointer to the artwork 72 | */ 73 | uri: string; 74 | /** 75 | * MimeType of the artwork 76 | */ 77 | mimeType: "image/jpeg" | "image/pjpeg" | "image/png"; 78 | } | null; 79 | /** 80 | * Information about how to find the NFT 81 | */ 82 | nft: { 83 | /** 84 | * Ethereum network that the artwork NFT exists on 85 | */ 86 | chainId: number; 87 | /** 88 | * Address of the factory contract that was used to mint the artwork NFT 89 | */ 90 | contractAddress: string; 91 | /** 92 | * Token ID of the artwork NFT 93 | */ 94 | tokenId: number; 95 | } | null; 96 | }; 97 | visualizer: { 98 | /** 99 | * Denotes if the visualizer is a separate NFT or is embedded into the Catalog record 100 | */ 101 | isNft: boolean; 102 | /** 103 | * Information about how to display the visualizer 104 | */ 105 | info: { 106 | /** 107 | * Pointer to the visualizer 108 | */ 109 | uri: string; 110 | /** 111 | * MimeType of the visualizer 112 | */ 113 | mimeType: string; 114 | [k: string]: unknown; 115 | } | null; 116 | /** 117 | * Information about how to find the NFT 118 | */ 119 | nft: { 120 | /** 121 | * Ethereum network that the artwork NFT exists on 122 | */ 123 | chainId: number; 124 | /** 125 | * Address of the factory contract that was used to mint the visualizer NFT 126 | */ 127 | contractAddress: string; 128 | /** 129 | * Token ID of the artwork NFT 130 | */ 131 | tokenId: number; 132 | } | null; 133 | [k: string]: unknown; 134 | } | null; 135 | }; 136 | /** 137 | * Information used to verify the authenticity of the record 138 | */ 139 | origin: { 140 | /** 141 | * Algorithm used to sign the metadata body 142 | */ 143 | algorithm: string; 144 | /** 145 | * Encoding used in the signature 146 | */ 147 | encoding: string; 148 | /** 149 | * Public key used to verify the record's origin signature 150 | */ 151 | publicKey: string; 152 | /** 153 | * The result of the public key's corresponding private key signing the body of the record metadata. We sign the output of JSON.stringify(body), where body is the alphabetized and minified JSON object retrieved from the body key in the record's metadata. 154 | */ 155 | signature: string; 156 | } | null; 157 | } 158 | export interface Artwork { 159 | /** 160 | * Denotes if the artwork is a separate NFT or is embedded into the Catalog record 161 | */ 162 | isNft: boolean; 163 | /** 164 | * Information about how to display the artwork 165 | */ 166 | info: { 167 | /** 168 | * Pointer to the artwork 169 | */ 170 | uri: string; 171 | /** 172 | * MimeType of the artwork 173 | */ 174 | mimeType: "image/jpeg" | "image/pjpeg" | "image/png"; 175 | } | null; 176 | /** 177 | * Information about how to find the NFT 178 | */ 179 | nft: { 180 | /** 181 | * Ethereum network that the artwork NFT exists on 182 | */ 183 | chainId: number; 184 | /** 185 | * Address of the factory contract that was used to mint the artwork NFT 186 | */ 187 | contractAddress: string; 188 | /** 189 | * Token ID of the artwork NFT 190 | */ 191 | tokenId: number; 192 | } | null; 193 | } 194 | -------------------------------------------------------------------------------- /types/types.ts: -------------------------------------------------------------------------------- 1 | import { Zora20210101 } from './zora/20210101'; 2 | import { Zora20210604 } from './zora/20210604'; 3 | import { Catalog20210202 } from './catalog/20210202'; 4 | import { Amulet20210221 } from './amulet/20210221'; 5 | 6 | export { Zora20210101 } 7 | export { Zora20210604 } 8 | export { Catalog20210202 } 9 | export { Amulet20210221 } 10 | export type MetadataLike = Zora20210101 | Zora20210604 | Catalog20210202 | Amulet20210221; 11 | -------------------------------------------------------------------------------- /types/zora/20210101.d.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /** 3 | * This file was automatically generated by json-schema-to-typescript. 4 | * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, 5 | * and run json-schema-to-typescript to regenerate this file. 6 | */ 7 | 8 | /** 9 | * This schema describes version 20210101 of the Zora Metadata Standard 10 | */ 11 | export interface Zora20210101 { 12 | /** 13 | * The description of the media 14 | */ 15 | description: string; 16 | /** 17 | * The mimeType of the media 18 | */ 19 | mimeType: string; 20 | /** 21 | * This property is the name of the Media 22 | */ 23 | name: string; 24 | /** 25 | * This property defines the calendar version of the schema so that consumers can correctly parse the json 26 | */ 27 | version: string; 28 | } 29 | -------------------------------------------------------------------------------- /types/zora/20210604.d.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /** 3 | * This file was automatically generated by json-schema-to-typescript. 4 | * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, 5 | * and run json-schema-to-typescript to regenerate this file. 6 | */ 7 | 8 | /** 9 | * This schema describes version 20210604 of the Zora Metadata Standard 10 | */ 11 | export interface Zora20210604 { 12 | /** 13 | * The description of the media 14 | */ 15 | description: string; 16 | /** 17 | * The mimeType of the media 18 | */ 19 | mimeType: string; 20 | /** 21 | * This property is the name of the Media 22 | */ 23 | name: string; 24 | /** 25 | * This property defines the calendar version of the schema so that consumers can correctly parse the json 26 | */ 27 | version: string; 28 | /** 29 | * This property defines an optional preview image URL for the media 30 | */ 31 | image?: string; 32 | /** 33 | * This property defines an optional external URL that can reference a webpage or external asset for the NFT 34 | */ 35 | external_url?: string; 36 | /** 37 | * A URL to a multi-media attachment for the item. The file extensions GLTF, GLB, WEBM, MP4, M4V, OGV, and OGG are supported, along with the audio-only extensions MP3, WAV, and OGA. Animation_url also supports HTML pages, allowing you to build rich experiences using JavaScript canvas, WebGL, and more. Access to browser extensions is not supported 38 | */ 39 | animation_url?: string; 40 | /** 41 | * This property defines any additional attributes for the item 42 | */ 43 | attributes?: Attribute[]; 44 | } 45 | export interface Attribute { 46 | /** 47 | * The name of the trait 48 | */ 49 | trait_type: string; 50 | /** 51 | * The value of the trait 52 | */ 53 | value: string | number | boolean; 54 | /** 55 | * A field indicating how the `value` data should be displayed. Defaults to 'string' 56 | */ 57 | display_type?: string; 58 | [k: string]: unknown; 59 | } 60 | --------------------------------------------------------------------------------