@ceramicnetwork/stream-model-instance-handler
Version:
Ceramic Model Instance Document stream handler
231 lines • 11.3 kB
JavaScript
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