@tangle-network/blueprint-ui 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +38 -4
  2. package/dist/BlueprintHostPanel-L1KKLNbr.d.ts +124 -0
  3. package/dist/chunk-37ADATBT.js +55 -0
  4. package/dist/chunk-37ADATBT.js.map +1 -0
  5. package/dist/chunk-5PCH2RJF.js +1540 -0
  6. package/dist/chunk-5PCH2RJF.js.map +1 -0
  7. package/dist/components.d.ts +179 -0
  8. package/dist/components.js +1130 -0
  9. package/dist/components.js.map +1 -0
  10. package/dist/index.d.ts +8604 -0
  11. package/dist/index.js +839 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/preset.d.ts +60 -0
  14. package/dist/preset.js +7 -0
  15. package/dist/preset.js.map +1 -0
  16. package/dist/styles.css +560 -0
  17. package/dist/wallet/index.d.ts +188 -0
  18. package/dist/wallet/index.js +466 -0
  19. package/dist/wallet/index.js.map +1 -0
  20. package/package.json +39 -9
  21. package/src/components/forms/JobExecutionDialog.tsx +10 -2
  22. package/src/components.ts +3 -0
  23. package/src/contracts/abi.ts +12 -0
  24. package/src/contracts/chains.ts +4 -3
  25. package/src/contracts/publicClient.ts +2 -1
  26. package/src/hooks/useJobPrice.test.ts +214 -0
  27. package/src/hooks/useJobPrice.ts +56 -2
  28. package/src/hooks/useProvisionProgress.ts +2 -1
  29. package/src/hooks/useQuotes.ts +112 -14
  30. package/src/hooks/useSessionAuth.ts +2 -1
  31. package/src/host/components/BlueprintHostHero.tsx +91 -0
  32. package/src/host/components/BlueprintHostPanel.tsx +24 -0
  33. package/src/host/index.ts +42 -0
  34. package/src/host/resolver.ts +204 -0
  35. package/src/host/types.ts +111 -0
  36. package/src/index.ts +41 -1
  37. package/src/stores/infra.ts +3 -2
  38. package/src/styles.css +128 -0
  39. package/src/test-setup.ts +1 -0
  40. package/src/utils/env.ts +22 -0
  41. package/src/wallet/detectParentOrigin.ts +74 -0
  42. package/src/wallet/index.ts +67 -0
  43. package/src/wallet/parentBridgeConnector.ts +156 -0
  44. package/src/wallet/parentBridgeProtocol.ts +109 -0
  45. package/src/wallet/parentBridgeProvider.test.ts +209 -0
  46. package/src/wallet/parentBridgeProvider.ts +411 -0
  47. package/tsconfig.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { getEnvVar } from '../utils/env';
2
3
 
3
4
  // ---------------------------------------------------------------------------
4
5
  // Types matching sandbox-runtime/src/provision_progress.rs
@@ -64,7 +65,7 @@ export function useProvisionProgress({
64
65
  const [isPolling, setIsPolling] = useState(false);
65
66
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
66
67
 
67
- const baseUrl = apiUrl ?? import.meta.env.VITE_OPERATOR_API_URL ?? 'http://localhost:9090';
68
+ const baseUrl = apiUrl ?? getEnvVar('VITE_OPERATOR_API_URL') ?? 'http://localhost:9090';
68
69
 
69
70
  const fetchProgress = useCallback(async () => {
70
71
  if (callId == null) return;
@@ -18,22 +18,39 @@ import { resolveOperatorRpc } from '../utils/resolveOperatorRpc';
18
18
 
19
19
  // ── Types ──
20
20
 
21
+ type SecurityCommitment = {
22
+ asset: { kind: number; token: Address };
23
+ exposureBps: number;
24
+ };
25
+
26
+ type ResourceCommitment = {
27
+ kind: number;
28
+ count: bigint;
29
+ };
30
+
21
31
  export interface OperatorQuote {
22
32
  operator: Address;
23
33
  totalCost: bigint;
24
34
  signature: `0x${string}`;
25
35
  details: {
36
+ /**
37
+ * Address of the account that will submit `createServiceFromQuotes`.
38
+ * Required since tnt-core v0.13.0 — the contract enforces
39
+ * `requester == msg.sender` and rejects `address(0)` (no wildcard quotes).
40
+ */
41
+ requester: Address;
26
42
  blueprintId: bigint;
27
43
  ttlBlocks: bigint;
28
44
  totalCost: bigint;
29
45
  timestamp: bigint;
30
46
  expiry: bigint;
31
- securityCommitments: readonly {
32
- asset: { kind: number; token: Address };
33
- exposureBps: number;
34
- }[];
47
+ confidentiality: number;
48
+ securityCommitments: readonly SecurityCommitment[];
49
+ resourceCommitments: readonly ResourceCommitment[];
35
50
  };
36
51
  costRate: number;
52
+ teeAttested?: boolean;
53
+ teeProvider?: string;
37
54
  }
38
55
 
39
56
  export interface UseQuotesResult {
@@ -49,6 +66,20 @@ export interface UseQuotesResult {
49
66
 
50
67
  const POW_DIFFICULTY = 20;
51
68
  const WEI_PER_TNT = 1_000_000_000_000_000_000; // 10^18
69
+ const RESOURCE_KIND_TO_ID = {
70
+ CPU: 0,
71
+ MemoryMB: 1,
72
+ StorageMB: 2,
73
+ NetworkEgressMB: 3,
74
+ NetworkIngressMB: 4,
75
+ GPU: 5,
76
+ } as const;
77
+ const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Address;
78
+ const DEFAULT_RESOURCE_REQUIREMENTS = [
79
+ { kind: 'CPU', count: 1 },
80
+ { kind: 'MemoryMB', count: 1024 },
81
+ { kind: 'StorageMB', count: 10240 },
82
+ ] as const;
52
83
 
53
84
  function sha256(data: Uint8Array): Uint8Array {
54
85
  return viemSha256(data, 'bytes');
@@ -114,14 +145,71 @@ export function formatCost(totalCost: bigint): string {
114
145
  return `${tnt.toLocaleString(undefined, { maximumFractionDigits: 2 })} TNT`;
115
146
  }
116
147
 
148
+ function quoteConfidentiality(requireTee: boolean): number {
149
+ return requireTee ? 1 : 0;
150
+ }
151
+
152
+ function resourceKindToId(kind: string): number {
153
+ const mapped = RESOURCE_KIND_TO_ID[kind as keyof typeof RESOURCE_KIND_TO_ID];
154
+ if (mapped === undefined) {
155
+ throw new Error(`Unsupported resource kind in quote: ${kind}`);
156
+ }
157
+ return mapped;
158
+ }
159
+
160
+ function mapJsonSecurityCommitment(sc: any): SecurityCommitment {
161
+ return {
162
+ asset: {
163
+ kind: sc.asset?.kind ?? 0,
164
+ token: (sc.asset?.token ?? ZERO_ADDRESS) as Address,
165
+ },
166
+ exposureBps: sc.exposure_bps ?? 0,
167
+ };
168
+ }
169
+
170
+ function mapJsonResourceCommitment(resource: any): ResourceCommitment {
171
+ return {
172
+ kind: resourceKindToId(String(resource.kind ?? 'CPU')),
173
+ count: BigInt(resource.count ?? 0),
174
+ };
175
+ }
176
+
117
177
  // ── Hook ──
118
178
 
179
+ const ZERO_ADDRESS_LOWER = ZERO_ADDRESS.toLowerCase();
180
+
181
+ /**
182
+ * Fetches signed `OperatorQuote` payloads from a set of operators for
183
+ * `createServiceFromQuotes`.
184
+ *
185
+ * @param operators Operators discovered from the on-chain registry.
186
+ * @param blueprintId Target blueprint id.
187
+ * @param ttlBlocks Requested service TTL in blocks.
188
+ * @param enabled Gate to disable fetching (e.g. while inputs settle).
189
+ * @param requester Address that will submit `createServiceFromQuotes` —
190
+ * must equal `msg.sender` at submission time. Required
191
+ * since tnt-core v0.13.0; the contract rejects
192
+ * `address(0)` and any mismatch.
193
+ * @param requireTee If true, asks operators for TEE-attested quotes.
194
+ */
119
195
  export function useQuotes(
120
196
  operators: DiscoveredOperator[],
121
197
  blueprintId: bigint,
122
198
  ttlBlocks: bigint,
123
199
  enabled: boolean,
200
+ requester: Address,
201
+ requireTee = false,
124
202
  ): UseQuotesResult {
203
+ // Defensive guard: only assert when the caller has actually opted in via
204
+ // `enabled`. This lets components compute `requester` from
205
+ // `useAccount().address` and pass `enabled=false` until the wallet connects.
206
+ if (enabled && (!requester || requester.toLowerCase() === ZERO_ADDRESS_LOWER)) {
207
+ throw new Error(
208
+ 'useQuotes: `requester` is required and must be a non-zero address when `enabled=true`. ' +
209
+ 'Pass `useAccount().address` from wagmi. tnt-core v0.13.0 contracts ' +
210
+ 'reject quotes whose requester is address(0) or != msg.sender.',
211
+ );
212
+ }
125
213
  const [quotes, setQuotes] = useState<OperatorQuote[]>([]);
126
214
  const [isLoading, setIsLoading] = useState(false);
127
215
  const [isSolvingPow, setIsSolvingPow] = useState(false);
@@ -168,6 +256,8 @@ export function useQuotes(
168
256
  ttlBlocks,
169
257
  proofOfWork: proof,
170
258
  challengeTimestamp: timestamp,
259
+ requireTee,
260
+ requester,
171
261
  });
172
262
 
173
263
  if (!response) throw new Error('No quote returned from operator');
@@ -194,7 +284,7 @@ export function useQuotes(
194
284
  return () => {
195
285
  cancelled = true;
196
286
  };
197
- }, [operators, blueprintId, ttlBlocks, enabled, fetchKey]);
287
+ }, [operators, blueprintId, ttlBlocks, enabled, fetchKey, requireTee, requester]);
198
288
 
199
289
  const totalCost = quotes.reduce((sum, q) => sum + q.totalCost, 0n);
200
290
 
@@ -213,6 +303,8 @@ async function fetchPriceFromOperator(
213
303
  ttlBlocks: bigint;
214
304
  proofOfWork: Uint8Array;
215
305
  challengeTimestamp: bigint;
306
+ requireTee: boolean;
307
+ requester: Address;
216
308
  },
217
309
  ): Promise<OperatorQuote | null> {
218
310
  // Try JSON endpoint (simpler, no protobuf dependency required)
@@ -225,11 +317,12 @@ async function fetchPriceFromOperator(
225
317
  ttl_blocks: String(params.ttlBlocks),
226
318
  proof_of_work: toHex(params.proofOfWork),
227
319
  challenge_timestamp: String(params.challengeTimestamp),
228
- resource_requirements: [
229
- { kind: 'CPU', count: 1 },
230
- { kind: 'MemoryMB', count: 1024 },
231
- { kind: 'StorageMB', count: 10240 },
232
- ],
320
+ require_tee: params.requireTee,
321
+ // tnt-core v0.13.0: bind the quote to the future caller. Operators
322
+ // sign this address into QuoteDetails; the contract enforces
323
+ // `requester == msg.sender`.
324
+ requester: params.requester,
325
+ resource_requirements: DEFAULT_RESOURCE_REQUIREMENTS,
233
326
  }),
234
327
  signal: AbortSignal.timeout(10_000),
235
328
  });
@@ -242,16 +335,21 @@ async function fetchPriceFromOperator(
242
335
  totalCost: BigInt(data.total_cost ?? '0'),
243
336
  signature: (data.signature ?? '0x') as `0x${string}`,
244
337
  costRate: Number(data.cost_rate ?? 0),
338
+ teeAttested: Boolean(data.tee_attested),
339
+ teeProvider: data.tee_provider || undefined,
245
340
  details: {
341
+ // Prefer the operator-signed value; fall back to the hook's input.
342
+ // If the operator returns a mismatched requester the contract will
343
+ // revert at submission, so callers should still verify equality.
344
+ requester: ((data.details?.requester as Address | undefined) ?? params.requester),
246
345
  blueprintId: BigInt(data.details?.blueprint_id ?? params.blueprintId),
247
346
  ttlBlocks: BigInt(data.details?.ttl_blocks ?? params.ttlBlocks),
248
347
  totalCost: BigInt(data.details?.total_cost ?? '0'),
249
348
  timestamp: BigInt(data.details?.timestamp ?? params.challengeTimestamp),
250
349
  expiry: BigInt(data.details?.expiry ?? '0'),
251
- securityCommitments: (data.details?.security_commitments ?? []).map((sc: any) => ({
252
- asset: { kind: sc.asset?.kind ?? 0, token: (sc.asset?.token ?? '0x0000000000000000000000000000000000000000') as Address },
253
- exposureBps: sc.exposure_bps ?? 0,
254
- })),
350
+ confidentiality: Number(data.details?.confidentiality ?? quoteConfidentiality(params.requireTee)),
351
+ securityCommitments: (data.details?.security_commitments ?? []).map(mapJsonSecurityCommitment),
352
+ resourceCommitments: (data.details?.resources ?? []).map(mapJsonResourceCommitment),
255
353
  },
256
354
  };
257
355
  } catch {
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useState } from 'react';
2
2
  import { useSignMessage } from 'wagmi';
3
3
  import { getSession, setSession, removeSession, type SessionEntry } from '../stores/session';
4
+ import { getEnvVar } from '../utils/env';
4
5
 
5
6
  // ---------------------------------------------------------------------------
6
7
  // Types
@@ -28,7 +29,7 @@ interface UseSessionAuthOptions {
28
29
  }
29
30
 
30
31
  export function useSessionAuth({ sandboxId, apiUrl }: UseSessionAuthOptions) {
31
- const baseUrl = apiUrl ?? import.meta.env.VITE_OPERATOR_API_URL ?? 'http://localhost:9090';
32
+ const baseUrl = apiUrl ?? getEnvVar('VITE_OPERATOR_API_URL') ?? 'http://localhost:9090';
32
33
  const { signMessageAsync } = useSignMessage();
33
34
 
34
35
  const [isAuthenticating, setIsAuthenticating] = useState(false);
@@ -0,0 +1,91 @@
1
+ import * as React from 'react';
2
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../../components/ui/card';
3
+ import { Badge } from '../../components/ui/badge';
4
+ import { Button } from '../../components/ui/button';
5
+ import { cn } from '../../utils';
6
+
7
+ type Action = {
8
+ label: string;
9
+ href?: string;
10
+ onClick?: () => void;
11
+ variant?: React.ComponentProps<typeof Button>['variant'];
12
+ disabled?: boolean;
13
+ };
14
+
15
+ export type BlueprintHostHeroProps = {
16
+ title: string;
17
+ tagline?: string;
18
+ description?: string;
19
+ badges?: string[];
20
+ actions?: Action[];
21
+ children?: React.ReactNode;
22
+ className?: string;
23
+ };
24
+
25
+ export function BlueprintHostHero({
26
+ title,
27
+ tagline,
28
+ description,
29
+ badges = [],
30
+ actions = [],
31
+ children,
32
+ className,
33
+ }: BlueprintHostHeroProps) {
34
+ return (
35
+ <Card className={cn('rounded-3xl border-bp-elements-borderColor/70 bg-bp-elements-background-depth-2', className)}>
36
+ <CardHeader className="space-y-4">
37
+ {badges.length > 0 ? (
38
+ <div className="flex flex-wrap gap-2">
39
+ {badges.map((badge) => (
40
+ <Badge key={badge} variant="secondary">
41
+ {badge}
42
+ </Badge>
43
+ ))}
44
+ </div>
45
+ ) : null}
46
+ <div className="space-y-2">
47
+ <CardTitle className="text-3xl">{title}</CardTitle>
48
+ {tagline ? (
49
+ <CardDescription className="text-base text-bp-elements-textPrimary/80">
50
+ {tagline}
51
+ </CardDescription>
52
+ ) : null}
53
+ </div>
54
+ {description ? (
55
+ <p className="max-w-3xl text-sm leading-6 text-bp-elements-textSecondary">
56
+ {description}
57
+ </p>
58
+ ) : null}
59
+ </CardHeader>
60
+ {(actions.length > 0 || children) && (
61
+ <CardContent className="space-y-4">
62
+ {actions.length > 0 ? (
63
+ <div className="flex flex-wrap gap-3">
64
+ {actions.map((action) => {
65
+ const button = (
66
+ <Button
67
+ key={action.label}
68
+ variant={action.variant ?? 'default'}
69
+ onClick={action.onClick}
70
+ disabled={action.disabled}
71
+ >
72
+ {action.label}
73
+ </Button>
74
+ );
75
+
76
+ return action.href ? (
77
+ <a key={action.label} href={action.href}>
78
+ {button}
79
+ </a>
80
+ ) : (
81
+ button
82
+ );
83
+ })}
84
+ </div>
85
+ ) : null}
86
+ {children}
87
+ </CardContent>
88
+ )}
89
+ </Card>
90
+ );
91
+ }
@@ -0,0 +1,24 @@
1
+ import * as React from 'react';
2
+ import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
3
+ import { cn } from '../../utils';
4
+
5
+ export type BlueprintHostPanelProps = {
6
+ title: string;
7
+ children: React.ReactNode;
8
+ className?: string;
9
+ };
10
+
11
+ export function BlueprintHostPanel({
12
+ title,
13
+ children,
14
+ className,
15
+ }: BlueprintHostPanelProps) {
16
+ return (
17
+ <Card className={cn('rounded-3xl border-bp-elements-borderColor/70 bg-bp-elements-background-depth-2', className)}>
18
+ <CardHeader>
19
+ <CardTitle className="text-xl">{title}</CardTitle>
20
+ </CardHeader>
21
+ <CardContent>{children}</CardContent>
22
+ </Card>
23
+ );
24
+ }
@@ -0,0 +1,42 @@
1
+ export type {
2
+ BlueprintAppVisibility,
3
+ BlueprintPublisherVerification,
4
+ BlueprintExperienceTier,
5
+ BlueprintSlugPolicy,
6
+ BlueprintUiSurface,
7
+ BlueprintResourceRoute,
8
+ BlueprintPermissionScope,
9
+ BlueprintExternalAppMode,
10
+ BlueprintExternalAppTrust,
11
+ BlueprintPublisher,
12
+ BlueprintResourceModel,
13
+ BlueprintPermissionDescriptor,
14
+ BlueprintExternalAppConfig,
15
+ BlueprintUiManifest,
16
+ BlueprintAppModuleBinding,
17
+ BlueprintAppEntry,
18
+ BlueprintAppResolvedView,
19
+ } from './types';
20
+
21
+ export {
22
+ buildCanonicalBlueprintSlug,
23
+ resolveBlueprintAppView,
24
+ toBlueprintAppEntry,
25
+ getBlueprintExperienceTierLabel,
26
+ getBlueprintSlugPolicyLabel,
27
+ getBlueprintSurfaceLabel,
28
+ getBlueprintPublisherVerificationLabel,
29
+ getExternalAppTrustLabel,
30
+ isVerifiedBlueprintPublisher,
31
+ canPublisherClaimSlug,
32
+ isTrustedExternalAppHost,
33
+ getBlueprintPath,
34
+ getBlueprintServicePath,
35
+ sanitizeBlueprintSlugPart,
36
+ deriveBlueprintRequestedSlug,
37
+ } from './resolver';
38
+
39
+ export type { BlueprintHostHeroProps } from './components/BlueprintHostHero';
40
+ export { BlueprintHostHero } from './components/BlueprintHostHero';
41
+ export type { BlueprintHostPanelProps } from './components/BlueprintHostPanel';
42
+ export { BlueprintHostPanel } from './components/BlueprintHostPanel';
@@ -0,0 +1,204 @@
1
+ import type {
2
+ BlueprintAppEntry,
3
+ BlueprintAppResolvedView,
4
+ BlueprintExperienceTier,
5
+ BlueprintExternalAppTrust,
6
+ BlueprintPublisher,
7
+ BlueprintPublisherVerification,
8
+ BlueprintSlugPolicy,
9
+ BlueprintUiSurface,
10
+ } from './types';
11
+
12
+ const EXPERIENCE_TIER_LABELS: Record<BlueprintExperienceTier, string> = {
13
+ generic: 'Protocol fallback',
14
+ declarative: 'Declarative blueprint UI',
15
+ 'curated-module': 'Curated app module',
16
+ 'external-app': 'External app handoff',
17
+ };
18
+
19
+ const SLUG_POLICY_LABELS: Record<BlueprintSlugPolicy, string> = {
20
+ reserved: 'Reserved slug',
21
+ 'publisher-scoped': 'Publisher-scoped slug',
22
+ 'public-requested': 'Public requested slug',
23
+ };
24
+
25
+ const SURFACE_LABELS: Record<BlueprintUiSurface, string> = {
26
+ 'generic-overview': 'Generic overview',
27
+ 'service-explorer': 'Service explorer',
28
+ 'service-console': 'Service console',
29
+ 'actions-panel': 'Actions panel',
30
+ resources: 'Resources',
31
+ chat: 'Chat',
32
+ vaults: 'Vaults',
33
+ metrics: 'Metrics',
34
+ permissions: 'Permissions',
35
+ };
36
+
37
+ const PUBLISHER_VERIFICATION_LABELS: Record<
38
+ BlueprintPublisherVerification,
39
+ string
40
+ > = {
41
+ 'first-party': 'First-party publisher',
42
+ verified: 'Verified publisher',
43
+ unverified: 'Unverified publisher',
44
+ };
45
+
46
+ const EXTERNAL_APP_TRUST_LABELS: Record<BlueprintExternalAppTrust, string> = {
47
+ trusted: 'Trusted external app',
48
+ restricted: 'Restricted external app',
49
+ };
50
+
51
+ export function buildCanonicalBlueprintSlug(entry: BlueprintAppEntry): string {
52
+ if (entry.canonicalSlug) {
53
+ return entry.canonicalSlug;
54
+ }
55
+
56
+ if (entry.slugPolicy === 'reserved' || !entry.publisher.namespace) {
57
+ return entry.slug;
58
+ }
59
+
60
+ return `@${entry.publisher.namespace}/${entry.slug}`;
61
+ }
62
+
63
+ export function resolveBlueprintAppView(
64
+ entry: BlueprintAppEntry,
65
+ ): BlueprintAppResolvedView {
66
+ return {
67
+ slug: entry.slug,
68
+ canonicalSlug: buildCanonicalBlueprintSlug(entry),
69
+ blueprintId: entry.blueprintId,
70
+ publisher: entry.publisher,
71
+ tier: entry.tier,
72
+ slugPolicy: entry.slugPolicy,
73
+ manifest: entry.manifest,
74
+ module: entry.module,
75
+ fallbackEnabled: true,
76
+ };
77
+ }
78
+
79
+ export function toBlueprintAppEntry(
80
+ view: BlueprintAppResolvedView,
81
+ ): BlueprintAppEntry {
82
+ return {
83
+ slug: view.slug,
84
+ canonicalSlug: view.canonicalSlug,
85
+ blueprintId: view.blueprintId,
86
+ publisher: view.publisher,
87
+ tier: view.tier,
88
+ slugPolicy: view.slugPolicy,
89
+ manifest: view.manifest,
90
+ module: view.module,
91
+ };
92
+ }
93
+
94
+ export function getBlueprintExperienceTierLabel(
95
+ tier: BlueprintExperienceTier,
96
+ ): string {
97
+ return EXPERIENCE_TIER_LABELS[tier];
98
+ }
99
+
100
+ export function getBlueprintSlugPolicyLabel(policy: BlueprintSlugPolicy): string {
101
+ return SLUG_POLICY_LABELS[policy];
102
+ }
103
+
104
+ export function getBlueprintSurfaceLabel(surface: BlueprintUiSurface): string {
105
+ return SURFACE_LABELS[surface];
106
+ }
107
+
108
+ export function getBlueprintPublisherVerificationLabel(
109
+ verification: BlueprintPublisherVerification,
110
+ ): string {
111
+ return PUBLISHER_VERIFICATION_LABELS[verification];
112
+ }
113
+
114
+ export function getExternalAppTrustLabel(
115
+ trust: BlueprintExternalAppTrust,
116
+ ): string {
117
+ return EXTERNAL_APP_TRUST_LABELS[trust];
118
+ }
119
+
120
+ export function isVerifiedBlueprintPublisher(
121
+ publisher: BlueprintPublisher,
122
+ ): boolean {
123
+ return (
124
+ publisher.verification === 'first-party' ||
125
+ publisher.verification === 'verified'
126
+ );
127
+ }
128
+
129
+ export function canPublisherClaimSlug(
130
+ slug: string,
131
+ publisher?: Pick<BlueprintPublisher, 'namespace' | 'verification'>,
132
+ reservedSlugs: Set<string> = new Set(),
133
+ ): boolean {
134
+ if (reservedSlugs.has(slug)) {
135
+ return false;
136
+ }
137
+
138
+ return (
139
+ publisher?.namespace !== undefined &&
140
+ publisher.namespace.trim().length > 0 &&
141
+ (publisher.verification === 'verified' ||
142
+ publisher.verification === 'first-party')
143
+ );
144
+ }
145
+
146
+ export function isTrustedExternalAppHost(
147
+ host: string,
148
+ trustedHosts: readonly string[] = [],
149
+ ): boolean {
150
+ const normalizedHost = host.trim().toLowerCase();
151
+
152
+ return trustedHosts.some(
153
+ (trustedHost) =>
154
+ normalizedHost === trustedHost || normalizedHost.endsWith(`.${trustedHost}`),
155
+ );
156
+ }
157
+
158
+ export function getBlueprintPath(view: BlueprintAppResolvedView): string {
159
+ if (view.tier === 'curated-module' || view.tier === 'external-app') {
160
+ return `/blueprints/${view.canonicalSlug}`;
161
+ }
162
+
163
+ if (view.blueprintId !== undefined) {
164
+ return `/blueprints/${view.blueprintId.toString()}`;
165
+ }
166
+
167
+ return `/blueprints/${view.slug}`;
168
+ }
169
+
170
+ export function getBlueprintServicePath(
171
+ view: BlueprintAppResolvedView,
172
+ serviceId: string,
173
+ ): string {
174
+ if (view.tier === 'curated-module' || view.tier === 'external-app') {
175
+ return `${getBlueprintPath(view)}/${serviceId}`;
176
+ }
177
+
178
+ return `${getBlueprintPath(view)}/services/${serviceId}`;
179
+ }
180
+
181
+ export function sanitizeBlueprintSlugPart(value: string): string {
182
+ return value
183
+ .trim()
184
+ .toLowerCase()
185
+ .replace(/[^a-z0-9]+/g, '-')
186
+ .replace(/^-+|-+$/g, '')
187
+ .replace(/-{2,}/g, '-');
188
+ }
189
+
190
+ export function deriveBlueprintRequestedSlug(
191
+ blueprint: Pick<{ name: string; author: string; id: bigint }, 'name' | 'author' | 'id'>,
192
+ ): string {
193
+ const fromName = sanitizeBlueprintSlugPart(blueprint.name);
194
+ if (fromName.length > 0) {
195
+ return fromName;
196
+ }
197
+
198
+ const fromAuthor = sanitizeBlueprintSlugPart(blueprint.author);
199
+ if (fromAuthor.length > 0) {
200
+ return `${fromAuthor}-${blueprint.id.toString()}`;
201
+ }
202
+
203
+ return `blueprint-${blueprint.id.toString()}`;
204
+ }