• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

knowledgepixels / nanodash / 27741419211

18 Jun 2026 06:35AM UTC coverage: 26.602% (-0.4%) from 26.963%
27741419211

Pull #484

github

web-flow
Merge eb7ba5ef8 into 0f6281554
Pull Request #484: Space-ref disambiguation: conflict notice, claimants overview, ref-pinned pages

1552 of 6853 branches covered (22.65%)

Branch coverage included in aggregate %.

3418 of 11830 relevant lines covered (28.89%)

4.25 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

70.81
src/main/java/com/knowledgepixels/nanodash/SpaceMemberRole.java
1
package com.knowledgepixels.nanodash;
2

3
import com.google.common.collect.Multimap;
4
import com.knowledgepixels.nanodash.domain.AbstractResourceWithProfile;
5
import com.knowledgepixels.nanodash.domain.IndividualAgent;
6
import com.knowledgepixels.nanodash.domain.Space;
7
import com.knowledgepixels.nanodash.template.Template;
8
import com.knowledgepixels.nanodash.template.TemplateData;
9
import com.knowledgepixels.nanodash.vocabulary.KPXL_TERMS;
10
import org.eclipse.rdf4j.model.IRI;
11
import org.nanopub.extra.services.ApiResponseEntry;
12

13
import java.io.Serializable;
14
import java.util.Set;
15
import java.util.stream.Stream;
16

17
/**
18
 * A role that a space member can have, with associated properties.
19
 */
20
public class SpaceMemberRole implements Serializable {
21

22
    private IRI id;
23
    private String label, name, title;
24
    private Template roleAssignmentTemplate = null;
18✔
25
    private IRI[] regularProperties, inverseProperties;
26
    private IRI tier;
27

28
    /**
29
     * Rank of the "everyone" floor (no role held). Below {@link #OBSERVER_RANK}.
30
     */
31
    public static final int EVERYONE_RANK = 0;
32
    private static final int OBSERVER_RANK = 1;
33
    private static final int MEMBER_RANK = 2;
34
    private static final int MAINTAINER_RANK = 3;
35
    private static final int ADMIN_RANK = 4;
36

37
    /**
38
     * Construct a SpaceMemberRole from an API response entry.
39
     *
40
     * @param e The API response entry.
41
     */
42
    public SpaceMemberRole(ApiResponseEntry e) {
6✔
43
        this.id = Utils.vf.createIRI(e.get("role"));
21✔
44
        this.label = e.get("roleLabel");
15✔
45
        this.name = e.get("roleName");
15✔
46
        this.title = e.get("roleTitle");
15✔
47
        if (e.get("roleAssignmentTemplate") != null && !e.get("roleAssignmentTemplate").isBlank()) {
12!
48
            this.roleAssignmentTemplate = TemplateData.get().getTemplate(e.get("roleAssignmentTemplate"));
×
49
        }
50
        regularProperties = stringToIriArray(e.get("regularProperties"));
18✔
51
        inverseProperties = stringToIriArray(e.get("inverseProperties"));
18✔
52
        this.tier = parseTier(e.get("roleType"));
18✔
53
    }
3✔
54

55
    private SpaceMemberRole(IRI id, String label, String name, String title, Template roleAssignmentTemplate, IRI[] regularProperties, IRI[] inverseProperties, IRI tier) {
6✔
56
        this.id = id;
9✔
57
        this.label = label;
9✔
58
        this.name = name;
9✔
59
        this.title = title;
9✔
60
        this.roleAssignmentTemplate = roleAssignmentTemplate;
9✔
61
        this.regularProperties = regularProperties;
9✔
62
        this.inverseProperties = inverseProperties;
9✔
63
        this.tier = tier;
9✔
64
    }
3✔
65

66
    /**
67
     * Parse the role tier from the {@code roleType} query column (the
68
     * server-materialized {@code npa:hasRoleType} value). Defaults to
69
     * {@link KPXL_TERMS#OBSERVER_ROLE} when absent, matching the server-side
70
     * default for roles that declare no tier subclass.
71
     *
72
     * @param roleType the role-type IRI string, or null/blank
73
     * @return the tier IRI (never null)
74
     */
75
    private static IRI parseTier(String roleType) {
76
        if (roleType == null || roleType.isBlank()) return KPXL_TERMS.OBSERVER_ROLE;
21!
77
        return Utils.vf.createIRI(roleType);
12✔
78
    }
79

80
    /**
81
     * Check if this role is the admin role.
82
     *
83
     * @return True if this role is the admin role, false otherwise.
84
     */
85
    public boolean isAdminRole() {
86
        return id.equals(ADMIN_ROLE_IRI);
15✔
87
    }
88

89
    /**
90
     * Get the IRI of this role.
91
     *
92
     * @return The IRI of this role.
93
     */
94
    public IRI getId() {
95
        return id;
9✔
96
    }
97

98
    /**
99
     * Get the label of this role.
100
     *
101
     * @return The label of this role.
102
     */
103
    public String getLabel() {
104
        return label;
9✔
105
    }
106

107
    /**
108
     * Get the name of this role.
109
     *
110
     * @return The name of this role.
111
     */
112
    public String getName() {
113
        return name;
9✔
114
    }
115

116
    /**
117
     * Get the title of this role.
118
     *
119
     * @return The title of this role.
120
     */
121
    public String getTitle() {
122
        return title;
9✔
123
    }
124

125
    /**
126
     * Get the template used for assigning this role.
127
     *
128
     * @return The template used for assigning this role.
129
     */
130
    public Template getRoleAssignmentTemplate() {
131
        return roleAssignmentTemplate;
×
132
    }
133

134
    /**
135
     * Get the regular properties associated with this role.
136
     *
137
     * @return The regular properties associated with this role.
138
     */
139
    public IRI[] getRegularProperties() {
140
        return regularProperties;
9✔
141
    }
142

143
    /**
144
     * Get the inverse properties associated with this role.
145
     *
146
     * @return The inverse properties associated with this role.
147
     */
148
    public IRI[] getInverseProperties() {
149
        return inverseProperties;
9✔
150
    }
151

152
    /**
153
     * Get the tier (role class) of this role — one of the role-tier IRIs in
154
     * {@link KPXL_TERMS} ({@code ADMIN_ROLE_TYPE} / {@code MAINTAINER_ROLE} /
155
     * {@code MEMBER_ROLE} / {@code OBSERVER_ROLE}).
156
     *
157
     * @return The tier IRI (never null; defaults to observer).
158
     */
159
    public IRI getTier() {
160
        return tier;
9✔
161
    }
162

163
    /**
164
     * Get the numeric rank of this role's tier, for threshold comparisons
165
     * (admin {@literal >} maintainer {@literal >} member {@literal >} observer).
166
     *
167
     * @return The tier rank (1..4).
168
     */
169
    public int getTierRank() {
170
        return tierRank(tier);
12✔
171
    }
172

173
    /**
174
     * Numeric rank of a role-tier IRI, for threshold comparisons. Unknown or
175
     * null tiers (the "everyone" floor) rank below observer.
176
     *
177
     * @param tier a role-tier IRI, or null
178
     * @return the rank: admin=4, maintainer=3, member=2, observer=1, else 0
179
     */
180
    public static int tierRank(IRI tier) {
181
        if (KPXL_TERMS.ADMIN_ROLE_TYPE.equals(tier)) return ADMIN_RANK;
18✔
182
        if (KPXL_TERMS.MAINTAINER_ROLE.equals(tier)) return MAINTAINER_RANK;
18✔
183
        if (KPXL_TERMS.MEMBER_ROLE.equals(tier)) return MEMBER_RANK;
18✔
184
        if (KPXL_TERMS.OBSERVER_ROLE.equals(tier)) return OBSERVER_RANK;
18✔
185
        return EVERYONE_RANK;
6✔
186
    }
187

188
    /**
189
     * Whether the given IRI is one of the known role-tier IRIs (as opposed to a
190
     * specific role IRI). Used to interpret {@code gen:isVisibleTo} objects.
191
     *
192
     * @param iri an IRI, or null
193
     * @return true if the IRI is a role tier
194
     */
195
    public static boolean isTier(IRI iri) {
196
        return KPXL_TERMS.EVERYONE_ROLE.equals(iri)
21✔
197
                || KPXL_TERMS.ADMIN_ROLE_TYPE.equals(iri)
12✔
198
                || KPXL_TERMS.MAINTAINER_ROLE.equals(iri)
12✔
199
                || KPXL_TERMS.MEMBER_ROLE.equals(iri)
12✔
200
                || KPXL_TERMS.OBSERVER_ROLE.equals(iri);
15✔
201
    }
202

203
    /**
204
     * Evaluates a {@code gen:isVisibleTo} restriction (a set of role-tier and/or
205
     * specific-role IRIs) against a viewer. Used to gate per-action visibility on
206
     * views (see docs/role-specific-views.md).
207
     *
208
     * <p>An empty restriction is visible to everyone. A role-tier IRI matches when
209
     * the viewer's highest tier in the governing space meets or exceeds it
210
     * (admin {@literal >} maintainer {@literal >} member {@literal >} observer); a
211
     * specific role IRI matches when the viewer holds exactly that role. Multiple
212
     * entries are OR-ed; there is no admin override for specific roles. When there
213
     * is no governing space (e.g. a user page), a non-empty restriction is
214
     * satisfied only for the resource owner.</p>
215
     *
216
     * @param requiredVisibility the set of {@code gen:isVisibleTo} IRIs (may be empty)
217
     * @param viewer             the viewer's agent IRI, or null if logged out
218
     * @param governingSpace     the space whose roles govern visibility, or null
219
     * @param viewerIsOwner      whether the viewer owns the resource (used only
220
     *                           when there is no governing space)
221
     * @return true if the viewer is entitled
222
     */
223
    public static boolean isViewerEntitled(Set<IRI> requiredVisibility, IRI viewer, Space governingSpace, boolean viewerIsOwner) {
224
        return isViewerEntitled(requiredVisibility, viewer, governingSpace, viewerIsOwner, null);
21✔
225
    }
226

227
    /**
228
     * Like {@link #isViewerEntitled(Set, IRI, Space, boolean)} but scopes the governing
229
     * space's authority to a specific ref (root definition) — the claimant a
230
     * {@code ?root=}-pinned page is viewing — so a viewer's tier/role is read from that ref
231
     * rather than the space's representative one. A null/empty {@code refRoot} resolves to the
232
     * representative ref (identical to the four-argument overload). See docs/space-ref-identity.md.
233
     *
234
     * @param requiredVisibility the set of {@code gen:isVisibleTo} IRIs (may be empty)
235
     * @param viewer             the current viewer IRI, or null if anonymous
236
     * @param governingSpace     the governing space, or null when there is none
237
     * @param viewerIsOwner      whether the viewer owns the resource (for user pages)
238
     * @param refRoot            the ref's root nanopub to scope authority to, or null
239
     * @return true if the viewer is entitled under that ref
240
     */
241
    public static boolean isViewerEntitled(Set<IRI> requiredVisibility, IRI viewer, Space governingSpace, boolean viewerIsOwner, String refRoot) {
242
        if (requiredVisibility == null || requiredVisibility.isEmpty()) return true;
21✔
243
        // gen:EveryoneRole is the explicit "no restriction" value (the default the
244
        // view-creation template emits since it cannot leave the statement optional);
245
        // it is visible to everyone, including anonymous viewers, so short-circuit
246
        // before the null-viewer / null-space guards below.
247
        if (requiredVisibility.contains(KPXL_TERMS.EVERYONE_ROLE)) return true;
18✔
248
        if (governingSpace == null) {
6✔
249
            // A user page is a degenerate space: the owner is its sole admin and no
250
            // other members or role assignments exist (observers may be added
251
            // later). So the owner holds the admin tier and everyone else the
252
            // everyone floor; only tier requirements can match here, and specific
253
            // role IRIs — unholdable without a space — never do.
254
            int tier = viewerIsOwner ? ADMIN_RANK : EVERYONE_RANK;
18✔
255
            for (IRI req : requiredVisibility) {
30✔
256
                if (isTier(req) && tier >= tierRank(req)) return true;
27✔
257
            }
3✔
258
            return false;
6✔
259
        }
260
        if (viewer == null) return false;
12✔
261
        // Without a pinned ref, resolve against the space's representative ref via the bare
262
        // accessors (identical to the pre-ref-scoping behaviour); only a pinned ref takes the
263
        // ref-scoped overloads. See docs/space-ref-identity.md.
264
        boolean pinned = refRoot != null && !refRoot.isEmpty();
27!
265
        for (IRI req : requiredVisibility) {
30✔
266
            if (isTier(req)) {
9✔
267
                int tier = pinned ? governingSpace.userTier(viewer, refRoot) : governingSpace.userTier(viewer);
33✔
268
                if (tier >= tierRank(req)) return true;
18✔
269
            } else {
3✔
270
                boolean holds = pinned ? governingSpace.viewerHoldsRole(viewer, req, refRoot) : governingSpace.viewerHoldsRole(viewer, req);
39✔
271
                if (holds) return true;
12✔
272
            }
273
        }
3✔
274
        return false;
6✔
275
    }
276

277
    /**
278
     * Convenience overload that resolves the current viewer, the governing space,
279
     * and ownership from a resource-with-profile, then evaluates the
280
     * {@code gen:isVisibleTo} restriction. The governing space is the resource
281
     * itself if it is a space, otherwise its owning space (null for a user page).
282
     *
283
     * @param requiredVisibility the set of {@code gen:isVisibleTo} IRIs (may be empty)
284
     * @param resource           the resource the action is being rendered for, or null
285
     * @return true if the current viewer is entitled
286
     */
287
    public static boolean isViewerEntitled(Set<IRI> requiredVisibility, AbstractResourceWithProfile resource) {
288
        return isViewerEntitled(requiredVisibility, resource, null);
×
289
    }
290

291
    /**
292
     * Like {@link #isViewerEntitled(Set, AbstractResourceWithProfile)} but scopes the
293
     * governing space's authority to a specific ref (root definition), so action visibility
294
     * on a {@code ?root=}-pinned page reflects the claimant being viewed rather than the
295
     * space's representative ref. A null/empty {@code refRoot} behaves identically to the
296
     * single-argument overload. See docs/space-ref-identity.md.
297
     *
298
     * @param requiredVisibility the set of {@code gen:isVisibleTo} IRIs (may be empty)
299
     * @param resource           the resource the action is being rendered for, or null
300
     * @param refRoot            the ref's root nanopub to scope authority to, or null
301
     * @return true if the current viewer is entitled under that ref
302
     */
303
    public static boolean isViewerEntitled(Set<IRI> requiredVisibility, AbstractResourceWithProfile resource, String refRoot) {
304
        if (requiredVisibility == null || requiredVisibility.isEmpty()) return true;
×
305
        Space governingSpace = (resource instanceof Space s) ? s : (resource != null ? resource.getSpace() : null);
×
306
        IRI viewer = NanodashSession.getCurrentUserIriOrNull();
×
307
        boolean viewerIsOwner = viewer != null && resource instanceof IndividualAgent ia && ia.isCurrentUser();
×
308
        return isViewerEntitled(requiredVisibility, viewer, governingSpace, viewerIsOwner, refRoot);
×
309
    }
310

311
    /**
312
     * Add the role parameters to the given multimap.
313
     *
314
     * @param params The multimap to add the parameters to.
315
     */
316
    public void addRoleParams(Multimap<String, String> params) {
317
        for (IRI p : regularProperties) params.put("role", p.stringValue());
69✔
318
        for (IRI p : inverseProperties) params.put("invrole", p.stringValue());
69✔
319
    }
3✔
320

321

322
    private static final IRI ADMIN_ROLE_IRI = Utils.vf.createIRI("https://w3id.org/np/RA_eEJjQbxzSqYSwPzfjzOZi5sMPpUmHskFNsgJYSws8I/adminRole");
12✔
323
    private static final String ADMIN_ROLE_ASSIGNMENT_TEMPLATE_ID = "https://w3id.org/np/RAsOQ7k3GNnuUqZuLm57PWwWopQJR_4onnCpNR457CZg8";
324

325
    /**
326
     * The predefined admin role.
327
     */
328
    public static final SpaceMemberRole ADMIN_ROLE = new SpaceMemberRole(ADMIN_ROLE_IRI, "Admin role", "admin", "Admins", TemplateData.get().getTemplate(ADMIN_ROLE_ASSIGNMENT_TEMPLATE_ID), new IRI[]{}, new IRI[]{KPXL_TERMS.HAS_ADMIN_PREDICATE}, KPXL_TERMS.ADMIN_ROLE_TYPE);
63✔
329

330
    /**
331
     * Convert a space-separated string of IRIs to an array of IRI objects.
332
     *
333
     * @param string The space-separated string of IRIs.
334
     * @return An array of IRI objects.
335
     */
336
    private static IRI[] stringToIriArray(String string) {
337
        if (string == null || string.isBlank()) return new IRI[]{};
24!
338
        return Stream.of(string.split(" ")).map(Utils.vf::createIRI).toArray(IRI[]::new);
51✔
339
    }
340

341
    /**
342
     * Check if the current user is a member of the given space.
343
     *
344
     * @param space The space to check.
345
     * @return True if the current user is a member of the space, false otherwise.
346
     */
347
    public static boolean isCurrentUserMember(Space space) {
348
        if (space == null) return false;
×
349
        IRI userIri = NanodashSession.get().getUserIri();
×
350
        if (userIri == null) return false;
×
351
        return space.isMember(userIri);
×
352
    }
353

354
    /**
355
     * Check if the current user is an admin of the given space.
356
     *
357
     * @param space The space to check.
358
     * @return True if the current user is an admin of the space, false otherwise.
359
     */
360
    public static boolean isCurrentUserAdmin(Space space) {
361
        if (space == null) return false;
×
362
        IRI userIri = NanodashSession.get().getUserIri();
×
363
        if (userIri == null) return false;
×
364
        if (space.getMemberRoles(userIri) == null) return false;
×
365
        for (SpaceMemberRoleRef spaceMemberRoleRef : space.getMemberRoles(userIri)) {
×
366
            if (spaceMemberRoleRef.getRole().isAdminRole()) return true;
×
367
        }
×
368
        return false;
×
369
    }
370

371
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc