001/*
002 * Copyright 2022-2026 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.soklet;
018
019import org.jspecify.annotations.NonNull;
020import org.jspecify.annotations.Nullable;
021
022import javax.annotation.concurrent.NotThreadSafe;
023import javax.annotation.concurrent.ThreadSafe;
024import java.util.Collections;
025import java.util.EnumSet;
026import java.util.LinkedHashSet;
027import java.util.Objects;
028import java.util.Optional;
029import java.util.Set;
030
031import static java.util.Objects.requireNonNull;
032
033/**
034 * Immutable security policy and presentation hints for an MCP Apps resource.
035 *
036 * <p>Permissions request capabilities rather than grant them. Hosts enforce
037 * content security policy and may further restrict it; this value does not
038 * construct a browser sandbox or establish host acceptance of a domain hint.
039 *
040 * @author <a href="https://www.revetkn.com">Mark Allen</a>
041 */
042@ThreadSafe
043public final class McpAppResourceMetadata {
044        @Nullable
045        private final ContentSecurityPolicy contentSecurityPolicy;
046        @NonNull
047        private final Set<@NonNull Permission> permissions;
048        @Nullable
049        private final String domain;
050        @Nullable
051        private final Boolean prefersBorder;
052
053        /** @return mutable builder with empty permissions and omitted hints */
054        @NonNull
055        public static Builder builder() {
056                return new Builder();
057        }
058
059        private McpAppResourceMetadata(@NonNull Builder builder) {
060                this.contentSecurityPolicy = builder.contentSecurityPolicy;
061                this.permissions = builder.permissions;
062                this.domain = builder.domain;
063                this.prefersBorder = builder.prefersBorder;
064        }
065
066        /**
067         * Returns the explicitly supplied content security policy.
068         * <p>An empty optional means CSP metadata is omitted, not that sandbox
069         * protection is disabled. MCP Apps hosts must still apply restrictive defaults.
070         *
071         * @return content security policy, or empty when omitted
072         */
073        @NonNull
074        public Optional<@NonNull ContentSecurityPolicy> getContentSecurityPolicy() {
075                return Optional.ofNullable(this.contentSecurityPolicy);
076        }
077
078        /** @return immutable requested permissions in enum declaration order */
079        @NonNull
080        public Set<@NonNull Permission> getPermissions() {
081                return this.permissions;
082        }
083
084        /** @return optional host-specific sandbox-origin hint */
085        @NonNull
086        public Optional<@NonNull String> getDomain() {
087                return Optional.ofNullable(this.domain);
088        }
089
090        /**
091         * Returns the host presentation preference for a visible border and background.
092         * Omission leaves the presentation to the host; explicit false requests an
093         * unframed presentation. This hint does not configure CSS inside the App.
094         *
095         * @return border preference, preserving omission separately from false
096         */
097        @NonNull
098        public Optional<@NonNull Boolean> getPrefersBorder() {
099                return Optional.ofNullable(this.prefersBorder);
100        }
101
102        /** @return whether policy, permissions, and optional hints match */
103        @Override
104        public boolean equals(@Nullable Object other) {
105                if (this == other)
106                        return true;
107                if (!(other instanceof McpAppResourceMetadata metadata))
108                        return false;
109                return Objects.equals(this.contentSecurityPolicy, metadata.contentSecurityPolicy)
110                                && this.permissions.equals(metadata.permissions)
111                                && Objects.equals(this.domain, metadata.domain)
112                                && Objects.equals(this.prefersBorder, metadata.prefersBorder);
113        }
114
115        /** @return structural hash code */
116        @Override
117        public int hashCode() {
118                return Objects.hash(this.contentSecurityPolicy, this.permissions,
119                                this.domain, this.prefersBorder);
120        }
121
122        /** @return diagnostic rendering without policy or hint values */
123        @Override
124        @NonNull
125        public String toString() {
126                return "McpAppResourceMetadata{contentSecurityPolicy=<redacted>, "
127                                + "permissions=<redacted>, domain=<redacted>, prefersBorder=<redacted>}";
128        }
129
130        /**
131         * Browser capabilities that an Apps resource may request from its host.
132         *
133         * <p>This domain is closed for 4.0. Adding a permission requires an explicit
134         * domain and API-evolution amendment; raw metadata cannot bypass this domain.
135         *
136         * <p>
137         * This is a closed domain; exhaustive switches are supported, and any new alternative requires an explicit reviewed contract amendment.
138         *
139         * @author <a href="https://www.revetkn.com">Mark Allen</a>
140         */
141        public enum Permission {
142                /** Camera access. */
143                CAMERA,
144                /** Microphone access. */
145                MICROPHONE,
146                /** Geolocation access. */
147                GEOLOCATION,
148                /** Clipboard write access. */
149                CLIPBOARD_WRITE
150        }
151
152        /**
153         * Mutable builder for immutable Apps resource metadata.
154         *
155         * @author <a href="https://www.revetkn.com">Mark Allen</a>
156         */
157        @NotThreadSafe
158        public static final class Builder {
159                @Nullable
160                private ContentSecurityPolicy contentSecurityPolicy;
161                @NonNull
162                private Set<@NonNull Permission> permissions = Collections.emptySet();
163                @Nullable
164                private String domain;
165                @Nullable
166                private Boolean prefersBorder;
167
168                private Builder() {}
169
170                /**
171                 * Sets a host-enforced content security policy.
172                 *
173                 * @param contentSecurityPolicy immutable policy
174                 * @return this builder
175                 */
176                @NonNull
177                public Builder contentSecurityPolicy(
178                                @NonNull ContentSecurityPolicy contentSecurityPolicy) {
179                        this.contentSecurityPolicy = requireNonNull(contentSecurityPolicy);
180                        return this;
181                }
182
183                /**
184                 * Replaces requested permissions with an immutable ordered snapshot.
185                 *
186                 * @param permissions non-null permissions, possibly empty
187                 * @return this builder
188                 */
189                @NonNull
190                public Builder permissions(@NonNull Set<@NonNull Permission> permissions) {
191                        requireNonNull(permissions);
192                        EnumSet<Permission> ordered = EnumSet.noneOf(Permission.class);
193                        for (Permission permission : permissions)
194                                ordered.add(requireNonNull(permission));
195                        this.permissions = Collections.unmodifiableSet(new LinkedHashSet<>(ordered));
196                        return this;
197                }
198
199                /**
200                 * Sets a host-specific sandbox-origin hint without provisioning DNS.
201                 *
202                 * @param domain lowercase ASCII DNS name with LDH labels and no trailing dot
203                 * @return this builder
204                 * @throws IllegalArgumentException if the baseline DNS-name syntax is invalid
205                 */
206                @NonNull
207                public Builder domain(@NonNull String domain) {
208                        this.domain = McpAppMetadataValidation.requireDomain(requireNonNull(domain));
209                        return this;
210                }
211
212                /**
213                 * Requests a host-provided border and background when true, or an
214                 * unframed presentation when false. Omitting this call lets the host decide.
215                 * This presentation hint does not change the App's security policy.
216                 *
217                 * @param prefersBorder whether a border is preferred
218                 * @return this builder
219                 */
220                @NonNull
221                public Builder prefersBorder(@NonNull Boolean prefersBorder) {
222                        this.prefersBorder = requireNonNull(prefersBorder);
223                        return this;
224                }
225
226                /** @return immutable resource metadata */
227                @NonNull
228                public McpAppResourceMetadata build() {
229                        return new McpAppResourceMetadata(this);
230                }
231        }
232
233        /**
234         * Immutable allowlists of canonical ASCII origins, not raw CSP header sources.
235         *
236         * <p>Empty collections allow no additional external connection, resource, or
237         * frame origins and retain same-origin-only document base URIs. Host enforcement
238         * may impose further restrictions. A wildcard is permitted only as one leftmost
239         * DNS label, such as {@code https://*.example.com}.
240         *
241         * @author <a href="https://www.revetkn.com">Mark Allen</a>
242         */
243        @ThreadSafe
244        public static final class ContentSecurityPolicy {
245                @NonNull
246                private static final ContentSecurityPolicy DEFAULT_INSTANCE = builder().build();
247                @NonNull
248                private final Set<@NonNull String> connectDomains;
249                @NonNull
250                private final Set<@NonNull String> resourceDomains;
251                @NonNull
252                private final Set<@NonNull String> frameDomains;
253                @NonNull
254                private final Set<@NonNull String> baseUriDomains;
255
256                /**
257                 * Returns the immutable default policy, equivalent to {@code builder().build()}.
258                 * <p>All four origin allowlists are empty: no additional external connection,
259                 * resource, or frame origins are declared, and document base URIs remain
260                 * same-origin-only. Enforcement belongs to the host, which may further restrict
261                 * the policy.
262                 * <p>Supplying this value explicitly emits CSP metadata. Leaving the policy
263                 * unspecified omits that metadata, but still requires the host to apply
264                 * restrictive defaults; omission does not disable sandbox protection.
265                 *
266                 * @return default content security policy
267                 */
268                @NonNull
269                public static ContentSecurityPolicy defaultInstance() {
270                        return DEFAULT_INSTANCE;
271                }
272
273                /** @return mutable builder with empty origin allowlists */
274                @NonNull
275                public static Builder builder() {
276                        return new Builder();
277                }
278
279                private ContentSecurityPolicy(@NonNull Builder builder) {
280                        this.connectDomains = builder.connectDomains;
281                        this.resourceDomains = builder.resourceDomains;
282                        this.frameDomains = builder.frameDomains;
283                        this.baseUriDomains = builder.baseUriDomains;
284                }
285
286                /** @return immutable connection origins in canonical ASCII order */
287                @NonNull
288                public Set<@NonNull String> getConnectDomains() {
289                        return this.connectDomains;
290                }
291
292                /** @return immutable asset origins in canonical ASCII order */
293                @NonNull
294                public Set<@NonNull String> getResourceDomains() {
295                        return this.resourceDomains;
296                }
297
298                /** @return immutable iframe origins in canonical ASCII order */
299                @NonNull
300                public Set<@NonNull String> getFrameDomains() {
301                        return this.frameDomains;
302                }
303
304                /** @return immutable document-base origins in canonical ASCII order */
305                @NonNull
306                public Set<@NonNull String> getBaseUriDomains() {
307                        return this.baseUriDomains;
308                }
309
310                /** @return whether all four origin allowlists match */
311                @Override
312                public boolean equals(@Nullable Object other) {
313                        if (this == other)
314                                return true;
315                        if (!(other instanceof ContentSecurityPolicy policy))
316                                return false;
317                        return this.connectDomains.equals(policy.connectDomains)
318                                        && this.resourceDomains.equals(policy.resourceDomains)
319                                        && this.frameDomains.equals(policy.frameDomains)
320                                        && this.baseUriDomains.equals(policy.baseUriDomains);
321                }
322
323                /** @return structural hash code */
324                @Override
325                public int hashCode() {
326                        return Objects.hash(this.connectDomains, this.resourceDomains,
327                                        this.frameDomains, this.baseUriDomains);
328                }
329
330                /** @return diagnostic rendering without origin values */
331                @Override
332                @NonNull
333                public String toString() {
334                        return "ContentSecurityPolicy{connectDomains=<redacted>, "
335                                        + "resourceDomains=<redacted>, frameDomains=<redacted>, "
336                                        + "baseUriDomains=<redacted>}";
337                }
338
339                /**
340                 * Mutable builder for an immutable content security policy.
341                 *
342                 * @author <a href="https://www.revetkn.com">Mark Allen</a>
343                 */
344                @NotThreadSafe
345                public static final class Builder {
346                        @NonNull
347                        private Set<@NonNull String> connectDomains = Collections.emptySet();
348                        @NonNull
349                        private Set<@NonNull String> resourceDomains = Collections.emptySet();
350                        @NonNull
351                        private Set<@NonNull String> frameDomains = Collections.emptySet();
352                        @NonNull
353                        private Set<@NonNull String> baseUriDomains = Collections.emptySet();
354
355                        private Builder() {}
356
357                        /**
358                         * Replaces connection origins with a validated, sorted snapshot.
359                         *
360                         * @param connectDomains canonical HTTPS/WSS origins, or HTTP/WS loopback origins
361                         * @return this builder
362                         * @throws IllegalArgumentException if any origin is invalid
363                         */
364                        @NonNull
365                        public Builder connectDomains(@NonNull Set<@NonNull String> connectDomains) {
366                                this.connectDomains = McpAppMetadataValidation.immutableOrigins(
367                                                requireNonNull(connectDomains), true);
368                                return this;
369                        }
370
371                        /**
372                         * Replaces asset origins with a validated, sorted snapshot.
373                         *
374                         * @param resourceDomains canonical HTTPS origins, or HTTP loopback origins
375                         * @return this builder
376                         * @throws IllegalArgumentException if any origin is invalid
377                         */
378                        @NonNull
379                        public Builder resourceDomains(@NonNull Set<@NonNull String> resourceDomains) {
380                                this.resourceDomains = McpAppMetadataValidation.immutableOrigins(
381                                                requireNonNull(resourceDomains), false);
382                                return this;
383                        }
384
385                        /**
386                         * Replaces iframe origins with a validated, sorted snapshot.
387                         *
388                         * @param frameDomains canonical HTTPS origins, or HTTP loopback origins
389                         * @return this builder
390                         * @throws IllegalArgumentException if any origin is invalid
391                         */
392                        @NonNull
393                        public Builder frameDomains(@NonNull Set<@NonNull String> frameDomains) {
394                                this.frameDomains = McpAppMetadataValidation.immutableOrigins(
395                                                requireNonNull(frameDomains), false);
396                                return this;
397                        }
398
399                        /**
400                         * Replaces document-base origins with a validated, sorted snapshot.
401                         *
402                         * @param baseUriDomains canonical HTTPS origins, or HTTP loopback origins
403                         * @return this builder
404                         * @throws IllegalArgumentException if any origin is invalid
405                         */
406                        @NonNull
407                        public Builder baseUriDomains(@NonNull Set<@NonNull String> baseUriDomains) {
408                                this.baseUriDomains = McpAppMetadataValidation.immutableOrigins(
409                                                requireNonNull(baseUriDomains), false);
410                                return this;
411                        }
412
413                        /** @return immutable content security policy */
414                        @NonNull
415                        public ContentSecurityPolicy build() {
416                                return new ContentSecurityPolicy(this);
417                        }
418                }
419        }
420}