diff --git a/apps/tangle-cloud/src/blueprintApps/authoring.spec.ts b/apps/tangle-cloud/src/blueprintApps/authoring.spec.ts index a49c234e02..a9089e1a5f 100644 --- a/apps/tangle-cloud/src/blueprintApps/authoring.spec.ts +++ b/apps/tangle-cloud/src/blueprintApps/authoring.spec.ts @@ -25,23 +25,36 @@ describe('blueprint ui authoring', () => { expect(document).toEqual({ name: 'Trading Agent', description: 'Automates strategy execution.', - category: 'Trading', author: 'Tangle', - codeRepository: 'https://github.com/tangle/trading-blueprint', + category: 'Trading', + image: null, + logo: null, website: 'https://tangle.tools', + codeRepository: 'https://github.com/tangle/trading-blueprint', blueprintUi: { - slug: 'trading', + displayName: 'Trading Agent', + description: 'Automates strategy execution.', + requestedSlug: 'trading', publisher: { + name: 'Tangle', namespace: 'tangle', + verified: false, }, - displayName: 'Trading Agent', - tagline: 'Trading blueprint on Tangle', - description: 'Automates strategy execution.', - surfaces: DEFAULT_BLUEPRINT_UI_DRAFT.surfaces, resources: { - serviceNoun: 'service', - resourceNoun: 'bot', - resourceRoute: 'bots', + serviceLabel: 'Service', + itemLabel: 'bot', + itemRoute: 'bots', + }, + surfaces: { + genericOverview: true, + serviceExplorer: true, + serviceConsole: false, + actionsPanel: true, + resources: true, + chat: false, + vaults: false, + metrics: true, + permissions: false, }, }, }); @@ -61,8 +74,8 @@ describe('blueprint ui authoring', () => { ...DEFAULT_BLUEPRINT_UI_DRAFT, requestedSlug: 'trading', publisherNamespace: 'tangle', - serviceNoun: 'service', - resourceNoun: 'resource', + serviceNoun: 'Service', + resourceNoun: 'Resource', surfaces: ['chat', 'resources'], }); }); diff --git a/apps/tangle-cloud/src/blueprintApps/authoring.ts b/apps/tangle-cloud/src/blueprintApps/authoring.ts index c01747505a..ef9a7d4959 100644 --- a/apps/tangle-cloud/src/blueprintApps/authoring.ts +++ b/apps/tangle-cloud/src/blueprintApps/authoring.ts @@ -1,21 +1,10 @@ -import type { - BlueprintExternalAppMode, - BlueprintResourceRoute, - BlueprintUiManifest, - BlueprintUiSurface, -} from './types'; -import { isTrustedExternalAppHost } from './resolver'; +import { + buildBlueprintUiMetadataDocument as buildSharedBlueprintUiMetadataDocument, + DEFAULT_BLUEPRINT_UI_DRAFT as SHARED_DEFAULT_BLUEPRINT_UI_DRAFT, +} from '@tangle-network/tangle-shared-ui/blueprintApps/authoring'; +import type { BlueprintUiAuthoringDraft } from '@tangle-network/tangle-shared-ui/blueprintApps/types'; -export type BlueprintUiAuthoringDraft = { - requestedSlug: string; - publisherNamespace: string; - serviceNoun: string; - resourceNoun: string; - resourceRoute: BlueprintResourceRoute; - surfaces: BlueprintUiSurface[]; - externalAppUrl: string; - externalAppMode: BlueprintExternalAppMode; -}; +export type { BlueprintUiAuthoringDraft }; export type BuildBlueprintUiMetadataDocumentParams = { name: string; @@ -28,24 +17,8 @@ export type BuildBlueprintUiMetadataDocumentParams = { draft: BlueprintUiAuthoringDraft; }; -export const DEFAULT_BLUEPRINT_UI_SURFACES: BlueprintUiSurface[] = [ - 'generic-overview', - 'service-explorer', - 'service-console', - 'actions-panel', - 'resources', - 'permissions', -]; - -export const DEFAULT_BLUEPRINT_UI_DRAFT: BlueprintUiAuthoringDraft = { - requestedSlug: '', - publisherNamespace: '', - serviceNoun: 'service', - resourceNoun: 'resource', - resourceRoute: 'custom', - surfaces: DEFAULT_BLUEPRINT_UI_SURFACES, - externalAppUrl: '', - externalAppMode: 'link', +export const DEFAULT_BLUEPRINT_UI_DRAFT = { + ...SHARED_DEFAULT_BLUEPRINT_UI_DRAFT, }; const trim = (value: string): string => value.trim(); @@ -55,79 +28,17 @@ export const sanitizeBlueprintUiDraft = ( ): BlueprintUiAuthoringDraft => ({ requestedSlug: trim(draft.requestedSlug), publisherNamespace: trim(draft.publisherNamespace), - serviceNoun: trim(draft.serviceNoun) || 'service', - resourceNoun: trim(draft.resourceNoun) || 'resource', + serviceNoun: trim(draft.serviceNoun) || 'Service', + resourceNoun: trim(draft.resourceNoun) || 'Resource', resourceRoute: draft.resourceRoute, surfaces: draft.surfaces.length > 0 ? Array.from(new Set(draft.surfaces)) - : DEFAULT_BLUEPRINT_UI_SURFACES, + : DEFAULT_BLUEPRINT_UI_DRAFT.surfaces, externalAppUrl: trim(draft.externalAppUrl), externalAppMode: draft.externalAppMode, }); -const buildTagline = (name: string, category?: string): string => { - const trimmedCategory = category?.trim(); - if (trimmedCategory) { - return `${trimmedCategory} blueprint on Tangle`; - } - - return `${name.trim() || 'Blueprint'} on Tangle`; -}; - -const buildManifest = ({ - name, - description, - category, - draft, -}: { - name: string; - description: string; - category?: string; - draft: BlueprintUiAuthoringDraft; -}): BlueprintUiManifest => { - const sanitizedDraft = sanitizeBlueprintUiDraft(draft); - let externalApp: BlueprintUiManifest['externalApp']; - - if (sanitizedDraft.externalAppUrl) { - try { - const host = new URL(sanitizedDraft.externalAppUrl).hostname; - const trust = isTrustedExternalAppHost(host) ? 'trusted' : 'restricted'; - - externalApp = { - url: sanitizedDraft.externalAppUrl, - mode: sanitizedDraft.externalAppMode, - label: 'Open blueprint app', - host, - trust, - ...(trust === 'restricted' - ? { - reason: - 'External app host is not on the trusted Tangle allowlist yet.', - } - : {}), - }; - } catch { - externalApp = undefined; - } - } - - return { - displayName: trim(name) || 'Untitled blueprint', - tagline: buildTagline(name, category), - description: - trim(description) || - 'Blueprint metadata published for the shared Tangle Cloud host surface.', - surfaces: sanitizedDraft.surfaces, - resources: { - serviceNoun: sanitizedDraft.serviceNoun, - resourceNoun: sanitizedDraft.resourceNoun, - resourceRoute: sanitizedDraft.resourceRoute, - }, - ...(externalApp ? { externalApp } : {}), - }; -}; - export const buildBlueprintUiMetadataDocument = ({ name, description, @@ -137,36 +48,14 @@ export const buildBlueprintUiMetadataDocument = ({ website, author, draft, -}: BuildBlueprintUiMetadataDocumentParams): Record => { - const sanitizedDraft = sanitizeBlueprintUiDraft(draft); - - return { - ...(trim(name) ? { name: trim(name) } : {}), - ...(trim(description) ? { description: trim(description) } : {}), - ...(trim(category ?? '') ? { category: trim(category ?? '') } : {}), - ...(trim(author ?? '') ? { author: trim(author ?? '') } : {}), - ...(trim(codeRepository ?? '') - ? { codeRepository: trim(codeRepository ?? '') } - : {}), - ...(trim(logo ?? '') ? { logo: trim(logo ?? '') } : {}), - ...(trim(website ?? '') ? { website: trim(website ?? '') } : {}), - blueprintUi: { - ...(sanitizedDraft.requestedSlug - ? { slug: sanitizedDraft.requestedSlug } - : {}), - ...(sanitizedDraft.publisherNamespace - ? { - publisher: { - namespace: sanitizedDraft.publisherNamespace, - }, - } - : {}), - ...buildManifest({ - name, - description, - category, - draft: sanitizedDraft, - }), - }, - }; -}; +}: BuildBlueprintUiMetadataDocumentParams): Record => + buildSharedBlueprintUiMetadataDocument({ + name, + description, + category: category ?? '', + codeRepository: codeRepository ?? '', + logo: logo ?? '', + website: website ?? '', + author: author ?? '', + draft: sanitizeBlueprintUiDraft(draft), + }); diff --git a/apps/tangle-cloud/src/blueprintApps/hostContract.spec.ts b/apps/tangle-cloud/src/blueprintApps/hostContract.spec.ts new file mode 100644 index 0000000000..f03cbff0f5 --- /dev/null +++ b/apps/tangle-cloud/src/blueprintApps/hostContract.spec.ts @@ -0,0 +1,99 @@ +import { + parseBlueprintMetadataDocument, + parseBlueprintMetadataJsonText, +} from '@tangle-network/tangle-shared-ui/blueprintApps/authoring'; + +describe('shared blueprint host contract', () => { + it('parses rich declarative tier 2 metadata', () => { + const parsed = parseBlueprintMetadataDocument({ + name: 'Atlas', + description: 'Operator coordination workspace', + author: 'Northstar', + blueprintUi: { + displayName: 'Atlas Workspace', + description: 'Shared hosted app for Atlas operators.', + requestedSlug: 'atlas', + publisher: { + name: 'Northstar', + namespace: 'northstar', + verified: true, + }, + resources: { + serviceLabel: 'Workspace', + itemLabel: 'Run', + itemRoute: 'runs', + }, + surfaces: ['generic-overview', 'resources', 'metrics'], + theme: { + accentColor: '#0F766E', + badgeLabel: 'Curated', + icon: 'bot', + }, + overviewCards: [ + { + id: 'uptime', + kind: 'stat', + title: 'Operator Uptime', + value: '99.9%', + }, + ], + actions: [ + { + id: 'provision', + label: 'Provision workspace', + target: 'service', + fields: [ + { + key: 'workspace_name', + label: 'Workspace name', + input: 'text', + required: true, + }, + ], + }, + ], + resourceViews: [ + { + id: 'runs', + title: 'Run ledger', + kind: 'table', + target: 'resource', + columns: [ + { key: 'status', label: 'Status', emphasis: true }, + { key: 'updated_at', label: 'Updated' }, + ], + }, + ], + modules: [ + { + module: 'metrics-overview', + slot: 'overview', + metricKeys: ['success_rate', 'latency_p95'], + }, + ], + }, + }); + + expect(parsed.blueprintUi?.tier).toBe('declarative'); + expect(parsed.blueprintUi?.theme?.accentColor).toBe('#0F766E'); + expect(parsed.blueprintUi?.overviewCards?.[0]?.title).toBe( + 'Operator Uptime', + ); + expect(parsed.blueprintUi?.actions?.[0]?.fields?.[0]?.key).toBe( + 'workspace_name', + ); + expect(parsed.blueprintUi?.resourceViews?.[0]?.columns).toHaveLength(2); + expect(parsed.blueprintUi?.modules?.[0]?.module).toBe('metrics-overview'); + }); + + it('rejects oversized metadata payloads', () => { + const oversized = JSON.stringify({ + name: 'Large Blueprint', + description: 'x'.repeat(70_000), + }); + + expect(() => parseBlueprintMetadataJsonText(oversized)).toThrow( + 'Blueprint metadata exceeded', + ); + }); +}); diff --git a/apps/tangle-cloud/src/blueprintApps/manifest.spec.ts b/apps/tangle-cloud/src/blueprintApps/manifest.spec.ts index 82c5208c8e..2854752aec 100644 --- a/apps/tangle-cloud/src/blueprintApps/manifest.spec.ts +++ b/apps/tangle-cloud/src/blueprintApps/manifest.spec.ts @@ -1,5 +1,19 @@ import { buildBlueprintManifestFromMetadata } from './manifest'; +const verifiedMetadata = { + status: 'verified' as const, + productionReady: true, + source: 'ipfs' as const, + reason: 'verified', +}; + +const unverifiedMetadata = { + status: 'unverified' as const, + productionReady: false, + source: 'ipfs' as const, + reason: 'unverified', +}; + describe('blueprint app manifest parsing', () => { it('falls back to generic protocol rendering when no app metadata exists', () => { const { entry, source } = buildBlueprintManifestFromMetadata({ @@ -19,6 +33,7 @@ describe('blueprint app manifest parsing', () => { imageUrl: null, codeUrl: null, website: null, + metadataVerification: unverifiedMetadata, rawMetadata: null, }); @@ -47,6 +62,7 @@ describe('blueprint app manifest parsing', () => { imageUrl: null, codeUrl: null, website: null, + metadataVerification: verifiedMetadata, rawMetadata: { blueprintUi: { slug: 'research-studio', @@ -100,6 +116,7 @@ describe('blueprint app manifest parsing', () => { imageUrl: null, codeUrl: null, website: null, + metadataVerification: unverifiedMetadata, rawMetadata: { tangleCloud: { slug: 'external-trading', @@ -112,11 +129,9 @@ describe('blueprint app manifest parsing', () => { }, }); - expect(entry.tier).toBe('external-app'); - expect(entry.manifest.externalApp?.url).toBe( - 'https://apps.acme.test/trading', - ); - expect(entry.manifest.externalApp?.trust).toBe('restricted'); + expect(entry.tier).toBe('generic'); + expect(entry.manifest.externalApp).toBeUndefined(); + expect(entry.manifest.description).toContain('unverified'); }); it('marks trusted Tangle external app hosts as trusted', () => { @@ -137,8 +152,13 @@ describe('blueprint app manifest parsing', () => { imageUrl: null, codeUrl: null, website: null, + metadataVerification: verifiedMetadata, rawMetadata: { blueprintUi: { + publisher: { + namespace: 'tangle', + verification: 'verified', + }, externalApp: { url: 'https://cloud.tangle.tools/blueprints/sandbox', mode: 'iframe', @@ -147,6 +167,50 @@ describe('blueprint app manifest parsing', () => { }, }); + expect(entry.tier).toBe('external-app'); expect(entry.manifest.externalApp?.trust).toBe('trusted'); + expect(entry.manifest.externalApp?.mode).toBe('link'); + }); + + it('falls back to protocol rendering when metadata is present but not verified', () => { + const { entry } = buildBlueprintManifestFromMetadata({ + id: '11', + blueprintId: 11n, + owner: '0x0000000000000000000000000000000000000001', + manager: null, + metadataUri: 'ipfs://example', + active: true, + createdAt: 1n, + updatedAt: 1n, + operatorCount: 0n, + name: 'Secure Agent', + description: 'Protocol indexed blueprint', + author: 'Alice Labs', + category: 'AI', + imageUrl: null, + codeUrl: null, + website: null, + metadataVerification: { + status: 'invalid', + productionReady: false, + source: 'ipfs', + reason: 'signature mismatch', + }, + rawMetadata: { + blueprintUi: { + slug: 'secure-agent', + surfaces: ['generic-overview', 'chat'], + }, + }, + }); + + expect(entry.tier).toBe('generic'); + expect(entry.manifest.surfaces).toEqual([ + 'generic-overview', + 'service-explorer', + 'actions-panel', + 'permissions', + ]); + expect(entry.manifest.description).toContain('signature mismatch'); }); }); diff --git a/apps/tangle-cloud/src/blueprintApps/manifest.ts b/apps/tangle-cloud/src/blueprintApps/manifest.ts index eb210b133a..f49839e463 100644 --- a/apps/tangle-cloud/src/blueprintApps/manifest.ts +++ b/apps/tangle-cloud/src/blueprintApps/manifest.ts @@ -164,6 +164,10 @@ function parsePermissions( function parseExternalApp( value: unknown, + options: { + publisherVerified: boolean; + metadataVerified: boolean; + }, ): BlueprintExternalAppConfig | undefined { const record = readRecord(value); if (!record) { @@ -190,16 +194,41 @@ function parseExternalApp( } const trust = isTrustedExternalAppHost(host) ? 'trusted' : 'restricted'; + const requestedMode = mode as BlueprintExternalAppConfig['mode']; + const reasons: string[] = []; + + if (requestedMode === 'iframe') { + reasons.push('Iframe embedding is disabled by policy; verified apps must open in a new tab.'); + } + + if (!options.publisherVerified) { + reasons.push('Publisher is not verified for advanced external app handoff.'); + } + + if (!options.metadataVerified) { + reasons.push( + 'Metadata provenance was not verified against the onchain blueprint owner.', + ); + } return { url, - mode: mode as BlueprintExternalAppConfig['mode'], + mode: 'link', label: readString(record.label) ?? undefined, host, - trust, - ...(trust === 'restricted' + trust: + trust === 'trusted' && + options.publisherVerified && + options.metadataVerified + ? 'trusted' + : 'restricted', + ...(trust === 'restricted' || + !options.publisherVerified || + !options.metadataVerified || + requestedMode === 'iframe' ? { reason: + reasons[0] ?? 'External app host is not on the trusted Tangle allowlist yet.', } : {}), @@ -240,7 +269,7 @@ export function buildBlueprintManifestFromMetadata( const publisherNamespace = readString(readRecord(manifestRoot?.publisher)?.namespace) ?? sanitizeBlueprintSlugPart(blueprint.author); - const publisherVerified = + const publisherDeclaredVerified = readString(readRecord(manifestRoot?.publisher)?.verification) === 'verified'; const requestedSlug = @@ -255,10 +284,12 @@ export function buildBlueprintManifestFromMetadata( label: blueprint.author || 'Unknown publisher', namespace: publisherNamespace || undefined, visibility: 'third-party', - verification: publisherVerified + verification: publisherDeclaredVerified ? 'verified' : getPublisherVerificationForNamespace(publisherNamespace), }; + const metadataVerified = blueprint.metadataVerification?.status === 'verified'; + const publisherVerified = publisher.verification === 'verified'; const slugPolicy = canPublisherClaimSlug(normalizedRequestedSlug, publisher) ? 'publisher-scoped' : 'public-requested'; @@ -275,21 +306,38 @@ export function buildBlueprintManifestFromMetadata( baseManifest.resources, ), permissions: parsePermissions(manifestRoot?.permissions), - externalApp: parseExternalApp(manifestRoot?.externalApp), + externalApp: parseExternalApp(manifestRoot?.externalApp, { + publisherVerified, + metadataVerified, + }), }; + const allowDeclarativeTier = + manifestRoot !== null && blueprint.metadataVerification?.productionReady === true; + const trustedExternalApp = + manifest.externalApp?.trust === 'trusted' ? manifest.externalApp : undefined; + const entry: BlueprintAppEntry = { slug: normalizedRequestedSlug, canonicalSlug: normalizedRequestedSlug, blueprintId: blueprint.blueprintId, publisher, - tier: manifest.externalApp + tier: trustedExternalApp ? 'external-app' - : manifestRoot + : allowDeclarativeTier ? 'declarative' : 'generic', slugPolicy, - manifest, + manifest: allowDeclarativeTier + ? { + ...manifest, + externalApp: trustedExternalApp, + } + : { + ...baseManifest, + description: + blueprint.metadataVerification?.reason ?? baseManifest.description, + }, }; entry.canonicalSlug = buildCanonicalBlueprintSlug(entry); diff --git a/apps/tangle-cloud/src/blueprintApps/policy.spec.ts b/apps/tangle-cloud/src/blueprintApps/policy.spec.ts index 62f2125914..112265a005 100644 --- a/apps/tangle-cloud/src/blueprintApps/policy.spec.ts +++ b/apps/tangle-cloud/src/blueprintApps/policy.spec.ts @@ -9,8 +9,8 @@ describe('blueprint platform policy', () => { it('ships safe defaults for reserved slugs and trusted hosts', () => { expect(reservedBlueprintSlugs.has('trading')).toBe(true); expect(reservedBlueprintSlugs.has('sandbox')).toBe(true); - expect(trustedExternalAppHosts).toContain('tangle.tools'); - expect(trustedExternalAppHosts).toContain('tangle.network'); + expect(trustedExternalAppHosts).toContain('cloud.tangle.tools'); + expect(trustedExternalAppHosts).toContain('apps.tangle.tools'); }); it('marks known publisher namespaces as verified', () => { diff --git a/apps/tangle-cloud/src/blueprintApps/policy.ts b/apps/tangle-cloud/src/blueprintApps/policy.ts index be45908c5f..50c343f5b6 100644 --- a/apps/tangle-cloud/src/blueprintApps/policy.ts +++ b/apps/tangle-cloud/src/blueprintApps/policy.ts @@ -7,7 +7,11 @@ const splitEnvList = (value: string | undefined): string[] => .filter(Boolean); const DEFAULT_RESERVED_SLUGS = ['trading', 'sandbox', 'training']; -const DEFAULT_TRUSTED_EXTERNAL_APP_HOSTS = ['tangle.tools', 'tangle.network']; +const DEFAULT_TRUSTED_EXTERNAL_APP_HOSTS = [ + 'cloud.tangle.tools', + 'app.tangle.tools', + 'apps.tangle.tools', +]; const DEFAULT_VERIFIED_PUBLISHERS = ['tangle', 'tangle-labs']; export const reservedBlueprintSlugs = new Set([ diff --git a/apps/tangle-cloud/src/components/blueprintApps/BlueprintHostCard.tsx b/apps/tangle-cloud/src/components/blueprintApps/BlueprintHostCard.tsx index 6922d76c9e..a802037082 100644 --- a/apps/tangle-cloud/src/components/blueprintApps/BlueprintHostCard.tsx +++ b/apps/tangle-cloud/src/components/blueprintApps/BlueprintHostCard.tsx @@ -88,10 +88,32 @@ const BlueprintHostCard = ({ blueprint, serviceId }: Props) => { label="Service Label" value={blueprintUi.resources.serviceLabel} /> + + + +
+
{(blueprintUi.requestedSlug || blueprintUi.canonicalSlug) && ( @@ -123,6 +145,181 @@ const BlueprintHostCard = ({ blueprint, serviceId }: Props) => { )} + {blueprintUi.theme && ( +
+ + Theme tokens + +
+ {blueprintUi.theme.badgeLabel && ( + + )} + {blueprintUi.theme.icon && ( + + )} + {blueprintUi.theme.accentColor && ( + + )} + {blueprintUi.theme.secondaryColor && ( + + )} +
+
+ )} + + {blueprintUi.overviewCards && blueprintUi.overviewCards.length > 0 && ( +
+ + Overview cards + +
+ {blueprintUi.overviewCards.map((card) => ( +
+
+ + {card.title} + + + {card.kind} + {card.tone ? ` • ${card.tone}` : ''} + +
+ {card.description && ( + + {card.description} + + )} + {card.value && ( + + {card.value} + + )} + {card.items && card.items.length > 0 && ( + + {card.items.join(' • ')} + + )} + {card.links && card.links.length > 0 && ( + + {card.links.map((link) => link.label).join(' • ')} + + )} +
+ ))} +
+
+ )} + + {blueprintUi.actions && blueprintUi.actions.length > 0 && ( +
+ + Declarative actions + +
+ {blueprintUi.actions.map((action) => ( +
+
+ + {action.label} + + + {action.target} + + {action.href && ( + + link action + + )} + {action.fields && ( + + {action.fields.length} field form + + )} +
+ {action.description && ( + + {action.description} + + )} + {action.fields && action.fields.length > 0 && ( + + {action.fields + .map((field) => + `${field.label} (${field.input}${field.required ? ', required' : ''})`, + ) + .join(' • ')} + + )} +
+ ))} +
+
+ )} + + {blueprintUi.resourceViews && blueprintUi.resourceViews.length > 0 && ( +
+ + Resource views + +
+ {blueprintUi.resourceViews.map((view) => ( +
+
+ + {view.title} + + + {view.kind} • {view.target} + +
+ + {view.columns + .map((column) => + column.emphasis + ? `${column.label} (emphasis)` + : column.label, + ) + .join(' • ')} + +
+ ))} +
+
+ )} + + {blueprintUi.modules && blueprintUi.modules.length > 0 && ( +
+ + Approved host modules + +
+ {blueprintUi.modules.map((module) => ( + + {module.module} • {module.slot} + + ))} +
+
+ )} + {blueprintUi.externalApp && (
diff --git a/apps/tangle-cloud/src/pages/blueprints/create/page.tsx b/apps/tangle-cloud/src/pages/blueprints/create/page.tsx index bed25fb508..ac0f0c3aed 100644 --- a/apps/tangle-cloud/src/pages/blueprints/create/page.tsx +++ b/apps/tangle-cloud/src/pages/blueprints/create/page.tsx @@ -24,6 +24,11 @@ import { useCreateBlueprintTx, type BlueprintDefinition, } from '@tangle-network/tangle-shared-ui/data/graphql'; +import { + computeBlueprintMetadataPayloadHash, + isAllowedBlueprintMetadataUri, + requiresIpfsForBlueprintMetadata, +} from '@tangle-network/tangle-shared-ui/blueprintApps/authoring'; import { parseSchemaJson, encodeSchemaToHex, @@ -191,6 +196,18 @@ const initialFormState: FormState = { sources: [], }; +const buildMetadataDocument = (form: FormState) => + buildBlueprintUiMetadataDocument({ + name: form.name, + description: form.description, + category: form.category, + codeRepository: form.codeRepository, + logo: form.logo, + website: form.website, + author: form.author, + draft: form.uiDraft, + }); + // Convert form to ABI-compatible definition const formToDefinition = ( form: FormState, @@ -205,6 +222,7 @@ const formToDefinition = ( new TextEncoder().encode(form.registrationSchema), ); const requestBytes = toHex(new TextEncoder().encode(form.requestSchema)); + const metadataDocument = buildMetadataDocument(form); // Convert jobs const jobs = form.jobs.map((job, index) => ({ @@ -259,6 +277,7 @@ const formToDefinition = ( return { metadataUri: form.metadataUri, + metadataHash: computeBlueprintMetadataPayloadHash(metadataDocument), manager: (form.manager || zeroAddress) as Address, masterManagerRevision: 0, hasConfig: true, @@ -292,20 +311,7 @@ const formToDefinition = ( }; const buildMetadataPreview = (form: FormState): string => - JSON.stringify( - buildBlueprintUiMetadataDocument({ - name: form.name, - description: form.description, - category: form.category, - codeRepository: form.codeRepository, - logo: form.logo, - website: form.website, - author: form.author, - draft: form.uiDraft, - }), - null, - 2, - ); + JSON.stringify(buildMetadataDocument(form), null, 2); const toggleBlueprintSurface = ( draft: BlueprintUiAuthoringDraft, @@ -359,6 +365,12 @@ const CreateBlueprintPage: FC = () => { ); return false; } + if (!isAllowedBlueprintMetadataUri(form.metadataUri.trim())) { + setValidationError( + 'Production hosted blueprints must publish metadata to an ipfs:// URI.', + ); + return false; + } if (form.uiDraft.surfaces.length === 0) { setValidationError( 'Select at least one shared host surface for blueprintUi metadata', @@ -367,11 +379,9 @@ const CreateBlueprintPage: FC = () => { } if ( form.uiDraft.externalAppUrl.trim() && - !/^https?:\/\/.+/.test(form.uiDraft.externalAppUrl.trim()) + !/^https:\/\/.+/.test(form.uiDraft.externalAppUrl.trim()) ) { - setValidationError( - 'External app URL must start with https:// or http://', - ); + setValidationError('External app URL must start with https://'); return false; } break; @@ -868,6 +878,11 @@ const BasicInfoStep: FC = ({ Publish the JSON preview below at this URI so cloud.tangle.tools can resolve your hosted blueprint surfaces and shared runtime metadata. + New SDK blueprints ship the same contract shape in + `metadata/blueprint-metadata.json`. + {requiresIpfsForBlueprintMetadata() + ? ' Production hosting only accepts ipfs:// metadata URIs.' + : ' Local development can still use https:// metadata previews.'}
@@ -892,7 +907,9 @@ const BasicInfoStep: FC = ({ This drives the shared hosted blueprint pages, generic service - surfaces, and optional safe link-out handoff for publisher apps. + surfaces, optional safe link-out handoff for publisher apps, and + richer tier-2 cards, forms, resource views, theming, and approved + modules when present in the published JSON.