UNPKG

@ceramicnetwork/stream-model-instance-handler

Version:

Ceramic Model Instance Document stream handler

231 lines • 11.3 kB
import jsonpatch from 'fast-json-patch'; import { ModelInstanceDocument, validateContentLength, } from '@ceramicnetwork/stream-model-instance'; import { AnchorStatus, EventType, SignatureStatus, StreamUtils, UnreachableCaseError, } from '@ceramicnetwork/common'; import { StreamID } from '@ceramicnetwork/streamid'; import { SchemaValidation } from './schema-utils.js'; import { Model, ModelDefinitionV2 } from '@ceramicnetwork/stream-model'; import { applyAnchorCommit, SignatureUtils } from '@ceramicnetwork/stream-handler-common'; import { toString } from 'uint8arrays'; const MODEL_STREAM_TYPE_ID = 2; export class ModelInstanceDocumentHandler { constructor() { this._schemaValidator = new SchemaValidation(); } get type() { return ModelInstanceDocument.STREAM_TYPE_ID; } get name() { return ModelInstanceDocument.STREAM_TYPE_NAME; } get stream_constructor() { return ModelInstanceDocument; } async applyCommit(commitData, context, state) { if (state == null) { return this._applyGenesis(commitData, context); } if (StreamUtils.isAnchorCommitData(commitData)) { return this._applyAnchor(commitData, state); } return this._applySigned(commitData, state, context); } async _applyGenesis(commitData, context) { const payload = commitData.commit; const { controllers, model, context: ctx, unique } = payload.header; const controller = controllers[0]; const modelStreamID = StreamID.fromBytes(model); const streamId = new StreamID(ModelInstanceDocument.STREAM_TYPE_ID, commitData.cid); const metadata = { controllers: [controller], model: modelStreamID, unique, }; if (ctx) { metadata.context = StreamID.fromBytes(ctx); } if (!(payload.header.controllers && payload.header.controllers.length === 1)) { throw new Error('Exactly one controller must be specified'); } if (!StreamUtils.validDIDString(payload.header.controllers[0])) { throw new Error(`Attempting to create a ModelInstanceDocument with an invalid DID string: ${payload.header.controllers[0]}`); } if (modelStreamID.type != MODEL_STREAM_TYPE_ID) { throw new Error(`Model for ModelInstanceDocument must refer to a StreamID of a Model stream`); } const isSigned = StreamUtils.isSignedCommitData(commitData); if (isSigned) { await SignatureUtils.verifyCommitSignature(commitData, context.signer, controller, modelStreamID, streamId); } else if (payload.data) { throw Error('ModelInstanceDocument genesis commit with content must be signed'); } const modelStream = await context.loadStream(metadata.model); this._validateModel(modelStream); await this._validateContent(context, modelStream, payload.data, true); await this._validateHeader(modelStream, payload.header); return { type: ModelInstanceDocument.STREAM_TYPE_ID, content: payload.data || null, metadata, signature: SignatureStatus.SIGNED, anchorStatus: AnchorStatus.NOT_REQUESTED, log: [StreamUtils.commitDataToLogEntry(commitData, EventType.INIT)], }; } async _applySigned(commitData, state, context) { const deterministicTypes = ['set', 'single']; const payload = commitData.commit; StreamUtils.assertCommitLinksToState(state, payload); const metadata = state.metadata; const controller = metadata.controllers[0]; const model = metadata.model; const streamId = StreamUtils.streamIdFromState(state); await SignatureUtils.verifyCommitSignature(commitData, context.signer, controller, model, streamId); if (payload.header) { const { shouldIndex, ...others } = payload.header; const otherKeys = Object.keys(others); if (otherKeys.length) { throw new Error(`Updating metadata for ModelInstanceDocument Streams is not allowed. Tried to change metadata for Stream ${streamId} from ${JSON.stringify(state.metadata)} to ${JSON.stringify(payload.header)}\``); } if (shouldIndex != null) { state.metadata.shouldIndex = shouldIndex; } } const oldContent = state.content ?? {}; const newContent = jsonpatch.applyPatch(oldContent, payload.data).newDocument; const modelStream = await context.loadStream(metadata.model); const isDetType = deterministicTypes.includes(modelStream.content.accountRelation.type); const isFirstDataCommit = !state.log.some((c) => c.type === EventType.DATA); await this._validateContent(context, modelStream, newContent, false, payload, isDetType && isFirstDataCommit); await this._validateUnique(modelStream, metadata, newContent); state.signature = SignatureStatus.SIGNED; state.anchorStatus = AnchorStatus.NOT_REQUESTED; state.content = newContent; state.log.push(StreamUtils.commitDataToLogEntry(commitData, EventType.DATA)); return state; } async _applyAnchor(commitData, state) { return applyAnchorCommit(commitData, state); } _validateModel(model) { if (model.content.version !== '1.0' && model.content.interface) { throw new Error(`ModelInstanceDocument Streams cannot be created on interface Models. Use a different model than ${model.id.toString()} to create the ModelInstanceDocument.`); } } async _validateContent(ceramic, model, content, genesis, payload, skipImmutableFieldsCheck) { if (genesis && (model.content.accountRelation.type === 'single' || model.content.accountRelation.type === 'set')) { if (content) { throw new Error(`Deterministic genesis commits for ModelInstanceDocuments must not have content`); } return; } validateContentLength(content); this._schemaValidator.validateSchema(content, model.content.schema, model.commitId.toString()); await this._validateRelationsContent(ceramic, model, content); if (!genesis && payload && !skipImmutableFieldsCheck) { await this._validateLockedFieldsUpdate(model, payload); } } async _validateRelationsContent(ceramic, model, content) { if (!model.content.relations) { return; } for (const [fieldName, relationDefinition] of Object.entries(model.content.relations)) { const relationType = relationDefinition.type; switch (relationType) { case 'account': continue; case 'document': { if (content[fieldName] == null) { continue; } let midStreamId; try { midStreamId = StreamID.fromString(content[fieldName]); } catch (err) { throw new Error(`Error while parsing relation from field ${fieldName}: Invalid StreamID: ${err.toString()}`); } const linkedMid = await ModelInstanceDocument.load(ceramic, midStreamId); const expectedModelStreamId = relationDefinition.model; if (expectedModelStreamId == null) { continue; } const foundModelStreamId = linkedMid.metadata.model.toString(); if (foundModelStreamId === expectedModelStreamId) { continue; } const linkedModel = await Model.load(ceramic, foundModelStreamId); if (linkedModel.content.version !== '1.0' && linkedModel.content.implements.includes(expectedModelStreamId)) { continue; } throw new Error(`Relation on field ${fieldName} points to Stream ${midStreamId.toString()}, which belongs to Model ${foundModelStreamId}, but this Stream's Model (${model.id.toString()}) specifies that this relation must be to a Stream in the Model ${expectedModelStreamId}`); } default: throw new UnreachableCaseError(relationType, 'Unknown relation type'); } } } async _validateHeader(model, header) { const relationType = model.content.accountRelation.type; switch (relationType) { case 'single': if (header.unique) { throw new Error(`ModelInstanceDocuments for models with SINGLE accountRelations must be created deterministically`); } break; case 'set': if (!header.unique) { throw new Error(`ModelInstanceDocuments for models with SET accountRelations must be created with a unique field containing data from the fields providing the set semantics`); } break; case 'list': if (!header.unique) { throw new Error(`ModelInstanceDocuments for models with LIST accountRelations must be created with a unique field`); } break; case 'none': break; default: throw new UnreachableCaseError(relationType, `Unsupported account relation ${relationType} found in Model ${model.content.name}`); } } async _validateLockedFieldsUpdate(model, payload) { if (!ModelDefinitionV2.is(model.content)) return; const immutableFields = model.content.immutableFields; const hasImmutableFields = immutableFields && immutableFields.length > 0; if (!hasImmutableFields) return; for (const lockedField of immutableFields) { const mutated = payload.data.some((entry) => entry.path.slice(1).split('/').shift() === lockedField); if (mutated) { throw new Error(`Immutable field "${lockedField}" cannot be updated`); } } } async _validateUnique(model, metadata, content) { if (model.content.accountRelation.type !== 'set') { return; } if (metadata.unique == null) { throw new Error('Missing unique metadata value'); } if (content == null) { throw new Error('Missing content'); } const unique = model.content.accountRelation.fields .map((field) => { const value = content[field]; return value ? String(value) : ''; }) .join('|'); if (unique !== toString(metadata.unique)) { throw new Error('Unique content fields value does not match metadata. If you are trying to change the value of these fields, this is causing this error: these fields values are not mutable.'); } } } //# sourceMappingURL=model-instance-document-handler.js.map