diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 9f26edb..ce9aff9 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -11,7 +11,31 @@
"Bash(Out-Null)",
"Bash(Write-Output \"done\")",
"Bash(./mvnw.cmd compile *)",
- "Bash(git *)"
+ "Bash(git *)",
+ "Bash(Get-ChildItem *)",
+ "Bash(xargs grep -l \"enroll\\\\|heartbeat\")",
+ "Bash(findstr /C:\"hypersistence\" /C:\"jackson\" /C:\"lombok\" /C:\"java.version\" /C:\"
+ * Topics published by the backend: + *
+ * CONNECT: validates the JWT carried in the {@code Authorization} native header. + * Browsers cannot set arbitrary headers on the WebSocket HTTP upgrade, so we authenticate + * at the STOMP layer rather than the HTTP handshake. Subsequent frames inherit the resolved + * principal via {@link StompHeaderAccessor#setUser(Principal)}. + *
+ * SUBSCRIBE: a valid JWT is not enough to read the device topics. Command results + * are published to {@code /topic/devices/{id}/commands} including the agent's raw output, + * which for {@code chrome.screenshot} is an image of a display and for {@code logs.tail} is + * agent internals. Reading those requires {@link Permission#BOARD_TELEMETRY_SUBSCRIBE}. + * Without this check any verified user — including a student who signed up only to upload a + * photo — could subscribe and receive them. + *
+ * Pattern destinations are refused outright, before any permission is considered. The + * broker treats a subscription destination as an Ant pattern, so {@code /topic/**} would + * otherwise pass a prefix test while still receiving every device message. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class StompJwtChannelInterceptor implements ChannelInterceptor { + + /** + * The only destination prefix the backend publishes to today (see DeviceEventPublisher). + * A new topic family must make its own authorization decision here. + */ + private static final String DEVICE_TOPIC_PREFIX = "/topic/devices"; + + /** + * AntPathMatcher metacharacters. {@code DefaultSubscriptionRegistry} matches a + * subscription destination against published destinations as a pattern, so any + * of these turns a SUBSCRIBE into a wildcard that a prefix test cannot reason about: + * {@code /topic/**} does not start with {@code /topic/devices} yet receives everything + * published there. Nothing in this application subscribes by pattern, so they are + * refused outright rather than pattern-matched against a policy. + */ + private static final char[] PATTERN_CHARS = {'*', '?', '{'}; + + private final JwtUtils jwtUtils; + private final UserDetailsServiceImpl userDetailsService; + + @SuppressWarnings("null") // accessor is null-checked prior to switch statement + @Override + public Message> preSend(@NonNull Message> message, @NonNull MessageChannel channel) { + StompHeaderAccessor accessor = StompHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + if (accessor == null || accessor.getCommand() == null) { + return message; + } + + switch (accessor.getCommand()) { + case CONNECT -> authenticate(accessor); + case SUBSCRIBE -> authorizeSubscription(accessor); + default -> { /* other frames inherit the principal resolved at CONNECT */ } + } + return message; + } + + private void authenticate(StompHeaderAccessor accessor) { + String header = accessor.getFirstNativeHeader("Authorization"); + String token = stripBearer(header); + if (token == null) { + log.warn("STOMP CONNECT rejected: missing Authorization header"); + throw new IllegalArgumentException("Missing Authorization header on STOMP CONNECT"); + } + if (!jwtUtils.validateJwtToken(token)) { + log.warn("STOMP CONNECT rejected: invalid JWT"); + throw new IllegalArgumentException("Invalid JWT on STOMP CONNECT"); + } + + String username = jwtUtils.getUserNameFromJwtToken(token); + UserDetails user = userDetailsService.loadUserByUsername(username); + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); + accessor.setUser(auth); + log.debug("STOMP authenticated as {}", username); + } + + private void authorizeSubscription(StompHeaderAccessor accessor) { + String destination = accessor.getDestination(); + if (destination == null) { + return; + } + + // Checked before the prefix test, and regardless of what the subscriber holds: a + // pattern is not a destination this gate can authorize, so there is no version of it + // that is allowed through. + if (isPattern(destination)) { + log.warn("STOMP SUBSCRIBE rejected: pattern destination {}", destination); + throw new AccessDeniedException( + "Pattern subscriptions are not permitted; subscribe to an exact destination"); + } + + if (!destination.startsWith(DEVICE_TOPIC_PREFIX)) { + return; + } + + Principal principal = accessor.getUser(); + if (!(principal instanceof Authentication auth) || !auth.isAuthenticated()) { + log.warn("STOMP SUBSCRIBE to {} rejected: no authenticated principal", destination); + throw new AccessDeniedException("Not authenticated"); + } + if (!hasAuthority(auth, Permission.BOARD_TELEMETRY_SUBSCRIBE)) { + log.warn("STOMP SUBSCRIBE to {} rejected for {}: missing {}", + destination, auth.getName(), Permission.BOARD_TELEMETRY_SUBSCRIBE.getAuthority()); + throw new AccessDeniedException("Missing permission " + + Permission.BOARD_TELEMETRY_SUBSCRIBE.getAuthority()); + } + log.debug("STOMP SUBSCRIBE to {} authorized for {}", destination, auth.getName()); + } + + private static boolean isPattern(String destination) { + for (char c : PATTERN_CHARS) { + if (destination.indexOf(c) >= 0) return true; + } + return false; + } + + private static boolean hasAuthority(Authentication auth, Permission permission) { + for (GrantedAuthority granted : auth.getAuthorities()) { + if (permission.getAuthority().equals(granted.getAuthority())) { + return true; + } + } + return false; + } + + private static String stripBearer(String header) { + if (header == null) return null; + if (header.regionMatches(true, 0, "Bearer ", 0, 7)) { + return header.substring(7).trim(); + } + return header.trim(); + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java index 75b3da1..dc429a9 100644 --- a/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java +++ b/src/main/java/com/ibrasoft/lensbridge/controller/AdminController.java @@ -1,41 +1,65 @@ package com.ibrasoft.lensbridge.controller; -import com.ibrasoft.lensbridge.model.audit.AdminAction; +import com.ibrasoft.lensbridge.dto.audit.AuditEventDto; +import com.ibrasoft.lensbridge.model.audit.AuditAction; import com.ibrasoft.lensbridge.service.AdminAuditService; -import com.ibrasoft.lensbridge.model.audit.AuditEvent; -import com.ibrasoft.lensbridge.dto.request.SignupRequest; -import com.ibrasoft.lensbridge.dto.response.AdminUploadDto; -import com.ibrasoft.lensbridge.dto.response.MessageResponse; +import com.ibrasoft.lensbridge.dto.auth.request.CreateUserRequest; +import com.ibrasoft.lensbridge.dto.auth.request.VerifyUserRequest; +import com.ibrasoft.lensbridge.dto.auth.response.PermissionResponse; +import com.ibrasoft.lensbridge.dto.auth.response.RoleDefinitionResponse; +import com.ibrasoft.lensbridge.dto.auth.response.UserInfoResponse; +import com.ibrasoft.lensbridge.dto.upload.response.AdminUploadDto; +import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse; +import com.ibrasoft.lensbridge.model.auth.Permission; import com.ibrasoft.lensbridge.model.auth.Role; import com.ibrasoft.lensbridge.model.auth.User; -import com.ibrasoft.lensbridge.model.event.Event; -import com.ibrasoft.lensbridge.model.event.EventStatus; -import com.ibrasoft.lensbridge.security.services.UserDetailsImpl; +import com.ibrasoft.lensbridge.model.upload.MediaEvent; +import com.ibrasoft.lensbridge.model.upload.EventStatus; import com.ibrasoft.lensbridge.service.EventsService; import com.ibrasoft.lensbridge.service.UploadService; import com.ibrasoft.lensbridge.service.UserService; +import io.swagger.v3.oas.annotations.Operation; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; -import java.time.LocalDateTime; -import java.util.Map; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; import java.util.UUID; import java.util.function.Consumer; import java.util.function.Function; - +import org.springdoc.core.annotations.ParameterObject; + +/** + * Media moderation, identity administration, and audit reporting. + *
+ * The class-level gate covers the legacy media endpoints only — the identity and audit + * methods below override it with their own permission. Media authorization is unchanged: + * the same people reach the same endpoints they always did. + *
+ * The permission is accepted alongside the role because the role check alone is
+ * name-based, and a name-based check cannot see that {@code ROOT}'s bundle already contains
+ * {@code media:upload:moderate}. Without this, a ROOT-only account is refused media
+ * moderation while holding, on paper, every permission in the system — and the only fix
+ * would be granting it {@code ROLE_ADMIN} as well, which is the hidden role coupling this
+ * redesign exists to remove.
+ */
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
-@PreAuthorize("hasRole('" + Role.ADMIN + "')")
+// Hacky fix until I remove media-related permissions from the legacy ROLE_ADMIN
+@PreAuthorize("hasRole('" + Role.Authority.ADMIN + "') "
+ + "or hasAuthority('" + Permission.Authority.MEDIA_UPLOAD_MODERATE + "')")
@Slf4j
public class AdminController {
@@ -44,263 +68,255 @@ public class AdminController {
private final AdminAuditService auditService;
private final UserService userService;
- private ResponseEntity> executeUploadAction(UUID uploadId, HttpServletRequest request, Consumer
+ * No auth required (the token is the credential), but rate-limiting belongs at the gateway.
+ */
+@RestController
+@RequestMapping("/api/agent")
+@RequiredArgsConstructor
+@Slf4j
+public class AgentEnrollmentController {
+
+ private final DeviceEnrollmentService enrollmentService;
+
+ @Value("${musallahboard.agent.websocketUrl}")
+ private String websocketUrl;
+
+ @Operation(operationId = "enrollAgent",
+ summary = "Exchange a one-time enrollment token for a device identity",
+ description = "Called once per device by the MusallahBoard agent. The returned websocketUrl is "
+ + "persisted verbatim into the agent's config and never requested again.")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "Device enrolled; identity and websocket URL returned"),
+ @ApiResponse(responseCode = "400", description = "Public key malformed or unsupported",
+ content = @Content(schema = @Schema(implementation = MessageResponse.class))),
+ @ApiResponse(responseCode = "401", description = "Enrollment token invalid, expired, or already used",
+ content = @Content(schema = @Schema(implementation = MessageResponse.class)))
+ })
+ @PostMapping("/enroll")
+ public ResponseEntity
+ * Authorization is per-endpoint by permission, not by role. There is deliberately no
+ * class-level {@code @PreAuthorize}: reading a poster and moving a board's coordinates are
+ * not the same privilege, and a blanket annotation is what made them the same before.
+ * See {@code docs/PERMISSIONS.md}.
*/
@RestController
@RequestMapping("/api/admin/board")
@RequiredArgsConstructor
-@PreAuthorize("hasRole('" + Role.ROOT + "')")
@Slf4j
public class BoardAdminController {
private final PosterService posterService;
+ private final PromotableSocialMediaService socialMediaService;
private final BoardService boardService;
private final AdminAuditService auditService;
- @Autowired
- private SignboardHandler signboardHandler;
-
+ private final BoardStreamHandler boardStream;
+
// ==================== Board Config Endpoints ====================
- /**
- * Get all board configurations.
- */
@GetMapping("/configs")
- public ResponseEntity
+ * Distinct from {@link SignupRequest} solely because of the password: on self-signup it is
+ * mandatory, and here it is optional. Relaxing it on {@code SignupRequest} instead would
+ * have let anyone register a passwordless account through the public endpoint.
+ *
+ * Leaving the password out is the normal case — the account is created disabled and the
+ * invitee sets their own password from the reset email. Supplying one is for the rare case
+ * where the account has to be usable immediately, and hands the administrator a credential
+ * they had to invent and then transmit somehow.
+ */
+@Data
+public class CreateUserRequest {
+
+ @NotBlank
+ @Size(max = 20)
+ @Pattern(regexp = "^[A-Za-z]+([ '-][A-Za-z]+)*$", message =
+ "First name can only contain letters, spaces, hyphens, and apostrophes")
+ private String firstName;
+
+ @NotBlank
+ @Size(max = 20)
+ @Pattern(regexp = "^[A-Za-z]+([ '-][A-Za-z]+)*$", message = "Last name can only contain letters, spaces, hyphens, and apostrophes")
+ private String lastName;
+
+ @NotBlank
+ @Size(max = 10)
+ private String studentNumber;
+
+ @NotBlank
+ @Size(max = 50)
+ @Email
+ // @Pattern(regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]*\\.utoronto\\.ca$", message = "Email must be a valid University of Toronto email address (*.utoronto.ca)")
+ private String email;
+
+ @Size(min = 6, max = 40, message = "Password must be between 6 and 40 characters long")
+ @Schema(description = "Optional. Omit it to create a disabled account and email the user a "
+ + "password reset link; supply it to create an account that can sign in right away.",
+ requiredMode = Schema.RequiredMode.NOT_REQUIRED)
+ private String password;
+
+ public void setEmail(String email) {
+ this.email = email.toLowerCase();
+ }
+
+ /** Blank is treated as absent, so {@code ""} invites rather than failing @Size validation. */
+ public void setPassword(String password) {
+ this.password = (password == null || password.isBlank()) ? null : password;
+ }
+
+ @Schema(hidden = true)
+ public boolean hasPassword() {
+ return password != null;
+ }
+}
diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/LoginRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/LoginRequest.java
similarity index 87%
rename from src/main/java/com/ibrasoft/lensbridge/dto/request/LoginRequest.java
rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/request/LoginRequest.java
index 6722eba..0531f33 100644
--- a/src/main/java/com/ibrasoft/lensbridge/dto/request/LoginRequest.java
+++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/LoginRequest.java
@@ -1,4 +1,4 @@
-package com.ibrasoft.lensbridge.dto.request;
+package com.ibrasoft.lensbridge.dto.auth.request;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ResetPasswordRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ResetPasswordRequest.java
new file mode 100644
index 0000000..5117c2c
--- /dev/null
+++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ResetPasswordRequest.java
@@ -0,0 +1,24 @@
+package com.ibrasoft.lensbridge.dto.auth.request;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * Body for POST /api/auth/reset-password.
+ *
+ * Both fields were previously query parameters. A URL query string is recorded in
+ * server access logs, proxy logs and browser history, so the reset token and the
+ * new password were being written to places that outlive the request and are not
+ * treated as secret storage. A request body is not logged by default.
+ */
+@Data
+public class ResetPasswordRequest {
+
+ @NotBlank(message = "Reset token is required")
+ private String token;
+
+ @NotBlank(message = "New password is required")
+ @Size(min = 6, max = 256, message = "Password must be between 6 and 256 characters long")
+ private String newPassword;
+}
diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/SignupRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/SignupRequest.java
similarity index 95%
rename from src/main/java/com/ibrasoft/lensbridge/dto/request/SignupRequest.java
rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/request/SignupRequest.java
index b623778..4ec4785 100644
--- a/src/main/java/com/ibrasoft/lensbridge/dto/request/SignupRequest.java
+++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/SignupRequest.java
@@ -1,4 +1,4 @@
-package com.ibrasoft.lensbridge.dto.request;
+package com.ibrasoft.lensbridge.dto.auth.request;
import lombok.Data;
import jakarta.validation.constraints.*;
diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/TokenRefreshRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/TokenRefreshRequest.java
similarity index 80%
rename from src/main/java/com/ibrasoft/lensbridge/dto/request/TokenRefreshRequest.java
rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/request/TokenRefreshRequest.java
index e2a656d..b4eb877 100644
--- a/src/main/java/com/ibrasoft/lensbridge/dto/request/TokenRefreshRequest.java
+++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/TokenRefreshRequest.java
@@ -1,4 +1,4 @@
-package com.ibrasoft.lensbridge.dto.request;
+package com.ibrasoft.lensbridge.dto.auth.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateProfileRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/UpdateProfileRequest.java
similarity index 92%
rename from src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateProfileRequest.java
rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/request/UpdateProfileRequest.java
index c136e24..d7688bf 100644
--- a/src/main/java/com/ibrasoft/lensbridge/dto/request/UpdateProfileRequest.java
+++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/UpdateProfileRequest.java
@@ -1,4 +1,4 @@
-package com.ibrasoft.lensbridge.dto.request;
+package com.ibrasoft.lensbridge.dto.auth.request;
import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.Pattern;
diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ValidateResetTokenRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ValidateResetTokenRequest.java
new file mode 100644
index 0000000..6948d73
--- /dev/null
+++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/ValidateResetTokenRequest.java
@@ -0,0 +1,18 @@
+package com.ibrasoft.lensbridge.dto.auth.request;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+/**
+ * Body for POST /api/auth/validate-reset-token.
+ *
+ * Deliberately separate from {@link VerifyEmailRequest}, which is also a lone
+ * token field: the two carry different credentials, and sharing one type would
+ * name the wrong thing in every generated client.
+ */
+@Data
+public class ValidateResetTokenRequest {
+
+ @NotBlank(message = "Reset token is required")
+ private String token;
+}
diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/VerifyEmailRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/VerifyEmailRequest.java
new file mode 100644
index 0000000..c33ba87
--- /dev/null
+++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/VerifyEmailRequest.java
@@ -0,0 +1,11 @@
+package com.ibrasoft.lensbridge.dto.auth.request;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+@Data
+public class VerifyEmailRequest {
+
+ @NotBlank(message = "Verification token is required")
+ private String token;
+}
diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/VerifyUserRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/VerifyUserRequest.java
new file mode 100644
index 0000000..3a6200d
--- /dev/null
+++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/request/VerifyUserRequest.java
@@ -0,0 +1,13 @@
+package com.ibrasoft.lensbridge.dto.auth.request;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+import java.util.UUID;
+
+@Data
+public class VerifyUserRequest {
+
+ @NotNull(message = "userId is required")
+ private UUID userId;
+}
diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/response/JwtResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/JwtResponse.java
similarity index 81%
rename from src/main/java/com/ibrasoft/lensbridge/dto/response/JwtResponse.java
rename to src/main/java/com/ibrasoft/lensbridge/dto/auth/response/JwtResponse.java
index 45fa3c1..fe3c827 100644
--- a/src/main/java/com/ibrasoft/lensbridge/dto/response/JwtResponse.java
+++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/JwtResponse.java
@@ -1,4 +1,4 @@
-package com.ibrasoft.lensbridge.dto.response;
+package com.ibrasoft.lensbridge.dto.auth.response;
import java.util.List;
import java.util.UUID;
@@ -17,4 +17,5 @@ public class JwtResponse {
private UUID id;
private String email;
private List
+ * {@code domain} is the namespace segment {@code board}, {@code media}, {@code iam},
+ * {@code audit} which is enough for the console to group the picker without hardcoding
+ * a list of groups that would then drift.
+ */
+public record PermissionResponse(
+ String name,
+ String authority,
+ String domain
+) {
+ public static PermissionResponse of(Permission permission) {
+ String authority = permission.getAuthority();
+ int firstColon = authority.indexOf(':');
+ String domain = firstColon > 0 ? authority.substring(0, firstColon) : authority;
+ return new PermissionResponse(permission.name(), authority, domain);
+ }
+}
diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/RoleDefinitionResponse.java b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/RoleDefinitionResponse.java
new file mode 100644
index 0000000..dce1245
--- /dev/null
+++ b/src/main/java/com/ibrasoft/lensbridge/dto/auth/response/RoleDefinitionResponse.java
@@ -0,0 +1,26 @@
+package com.ibrasoft.lensbridge.dto.auth.response;
+
+import com.ibrasoft.lensbridge.model.auth.Permission;
+import com.ibrasoft.lensbridge.model.auth.Role;
+
+import java.util.List;
+
+/**
+ * A role plus what it actually confers, so the admin console can show an
+ * operator what
+ * they are about to hand someone instead of an opaque enum name.
+ */
+public record RoleDefinitionResponse(
+ String name,
+ String description,
+ List
+ * The signature covers a fixed canonical string. See {@link com.ibrasoft.lensbridge.service.agent.handshake.AuthSignaturePayload}.
+ */
+@Getter
+@Setter
+@EqualsAndHashCode(callSuper = true)
+public class AuthFrame extends IncomingAgentFrame {
+
+ private UUID deviceId;
+
+ /** Unix milliseconds; server enforces |timestamp - now| within a skew window. */
+ private long timestamp;
+
+ /** Base64-encoded 64-byte Ed25519 signature. */
+ private String signature;
+}
diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandAckFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandAckFrame.java
new file mode 100644
index 0000000..168390d
--- /dev/null
+++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandAckFrame.java
@@ -0,0 +1,13 @@
+package com.ibrasoft.lensbridge.dto.board.agent;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.UUID;
+
+/** Agent → backend: "I received and accepted command {@code commandId}, will execute". */
+@Getter
+@Setter
+public class CommandAckFrame extends IncomingAgentFrame {
+ private UUID commandId;
+}
diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandProgressFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandProgressFrame.java
new file mode 100644
index 0000000..6c2cb98
--- /dev/null
+++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandProgressFrame.java
@@ -0,0 +1,17 @@
+package com.ibrasoft.lensbridge.dto.board.agent;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.UUID;
+
+/** Agent → backend: optional progress update for a long-running command. */
+@Getter
+@Setter
+public class CommandProgressFrame extends IncomingAgentFrame {
+ private UUID commandId;
+ private String stage;
+ private String message;
+ /** 0..100, optional. */
+ private Integer percent;
+}
diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandResultFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandResultFrame.java
new file mode 100644
index 0000000..d9a7d79
--- /dev/null
+++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/CommandResultFrame.java
@@ -0,0 +1,22 @@
+package com.ibrasoft.lensbridge.dto.board.agent;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.UUID;
+
+/**
+ * Agent → backend: terminal frame for a command. {@link #status} is one of
+ * {@code "ok"}, {@code "error"}, {@code "timeout"}, {@code "rejected"} (free-form
+ * to keep the wire flexible; the backend maps to {@link com.ibrasoft.lensbridge.model.board.DeviceCommandStatus}).
+ */
+@Getter
+@Setter
+public class CommandResultFrame extends IncomingAgentFrame {
+ private UUID commandId;
+ private String status;
+ private JsonNode output;
+ private String errorMessage;
+ private Long durationMs;
+}
diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/HeartbeatFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/HeartbeatFrame.java
new file mode 100644
index 0000000..ccdc0f3
--- /dev/null
+++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/HeartbeatFrame.java
@@ -0,0 +1,41 @@
+package com.ibrasoft.lensbridge.dto.board.agent;
+
+import com.fasterxml.jackson.annotation.JsonAlias;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.List;
+
+@Getter
+@Setter
+@EqualsAndHashCode(callSuper = true)
+public class HeartbeatFrame extends IncomingAgentFrame {
+
+ private Telemetry telemetry;
+
+ @Getter
+ @Setter
+ public static class Telemetry {
+ private Long uptimeSec;
+ private Double cpuTempC;
+ private String throttleFlags;
+ private Integer memUsedMb;
+ private Integer memTotalMb;
+ private Integer diskUsedPct;
+ private Boolean kioskAlive;
+ private List
+ * {@code kind} is the polymorphic discriminator (must match a registered
+ * {@link com.ibrasoft.lensbridge.model.board.commands.CommandPayload} subtype).
+ * {@code payload} is the kind-specific JSON body (may be empty for parameterless commands).
+ *
+ * {@code deadlineMs} bounds execution once the agent starts. {@code ttlSeconds} bounds how
+ * long the command stays deliverable while the device is offline. Keep it short for
+ * anything disruptive (a reboot that fires a day late is a bug, not a feature ;) ). Both fall
+ * back to server defaults when null.
+ */
+public record IssueCommandRequest(
+ @NotBlank
+ @Pattern(regexp = "[a-z]+\\.[a-z_]+", message = "kind must be in dotted lowercase form, e.g. chrome.reload")
+ String kind,
+ JsonNode payload,
+ @Positive Integer deadlineMs,
+ @Positive Integer ttlSeconds
+) {}
diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/IssueEnrollmentTokenRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/IssueEnrollmentTokenRequest.java
new file mode 100644
index 0000000..82505a3
--- /dev/null
+++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/IssueEnrollmentTokenRequest.java
@@ -0,0 +1,21 @@
+package com.ibrasoft.lensbridge.dto.board.request;
+
+import com.ibrasoft.lensbridge.model.board.Audience;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+import lombok.Data;
+
+@Data
+public class IssueEnrollmentTokenRequest {
+
+ @NotBlank
+ private String displayName;
+
+ @NotNull
+ private Audience audience;
+
+ /** Lifetime of the token in minutes. Server clamps to a sane range. */
+ @Positive
+ private Integer expiresInMinutes;
+}
diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java
new file mode 100644
index 0000000..e1976d1
--- /dev/null
+++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/request/UpdateBoardConfigRequest.java
@@ -0,0 +1,40 @@
+package com.ibrasoft.lensbridge.dto.board.request;
+
+import com.ibrasoft.lensbridge.model.board.Location;
+import com.ibrasoft.lensbridge.model.board.embedded.DeviceConfig;
+
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import lombok.*;
+import java.util.List;
+
+@Data @Builder @NoArgsConstructor @AllArgsConstructor
+public class UpdateBoardConfigRequest {
+ private Location location;
+ private Boolean darkModeAfterIsha;
+ private Boolean enableScrollingMessage;
+ private List
+ * Split out from {@link UpdateBoardConfigRequest} so ticker copy can be edited with
+ * {@code board:ticker:write} alone. The rest of the config sets the board's coordinates,
+ * which silently change every prayer time on it — not something a copy edit should reach.
+ * Both fields are nullable and ignored when null, matching the PATCH semantics of the
+ * config endpoint.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class UpdateTickerRequest {
+
+ private Boolean enableScrollingMessage;
+
+ private List
+ * Buckets are emitted for every day in the window, including days with no events — the board
+ * renders an empty day as "nothing scheduled", which it could not distinguish from "day outside
+ * the window" if empty buckets were dropped.
+ *
+ * Deliberately carries no {@code isToday} flag. A board can run for days without a payload
+ * refresh, so any "today" marker computed here goes stale at the first midnight. The client
+ * compares its own current date against {@link #date} instead.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class DayBucket {
+
+ /** Local date in the device's timezone, not UTC. */
+ private LocalDate date;
+
+ /** Events overlapping this day, earliest first. Never null; empty for a free day. */
+ private List
+ * Without it, {@link #handleGenericException} below was the only handler that matched
+ * them, and {@code ExceptionHandlerExceptionResolver} runs ahead of
+ * {@code DefaultHandlerExceptionResolver} — so a request that was merely malformed came
+ * back as a 500 "An unexpected error occurred". That masked a real incident: a client
+ * sending a signup body with no {@code Content-Type} header produced a 500 rather than a
+ * 415, and the response said nothing about the header being the problem.
+ *
+ * Bodies stay as {@link MessageResponse} rather than the {@code ProblemDetail} the parent
+ * class produces by default, because the frontend reads {@code error.message} everywhere.
+ * {@link #handleExceptionInternal} performs that conversion in one place.
+ */
@RestControllerAdvice
@Slf4j
-public class GlobalExceptionHandler {
+public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
+
+ /**
+ * Rewrites every body produced by the parent class into a {@link MessageResponse},
+ * leaving the status and headers it chose alone.
+ */
+ @Override
+ protected ResponseEntity> getAllEvents() {
+ return ResponseEntity.ok(eventsService.getAllEvents());
}
@GetMapping("/users")
- @PreAuthorize("hasRole('" + Role.ROOT + "')")
- public ResponseEntity> getAllUsers(Pageable pageable) {
- try {
- log.debug("Admin retrieving all users, page: {}", pageable.getPageNumber());
- return ResponseEntity.ok(userService.getAllUsers(pageable));
- } catch (Exception e) {
- log.error("Error retrieving users: {}", e.getMessage(), e);
- return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new MessageResponse("Failed to retrieve users: " + e.getMessage()));
- }
+ @PreAuthorize("hasAuthority('" + Permission.Authority.IAM_USER_READ + "')")
+ public ResponseEntity
> getAvailableRoles() {
+ return ResponseEntity.ok(Arrays.stream(Role.values())
+ .map(RoleDefinitionResponse::of)
+ .toList());
+ }
+
+ @Operation(operationId = "getAvailablePermissions",
+ summary = "List every permission, for building a direct-grant picker")
+ @GetMapping("/permissions")
+ @PreAuthorize("hasAuthority('" + Permission.Authority.IAM_USER_READ + "')")
+ public ResponseEntity
> getAvailablePermissions() {
+ return ResponseEntity.ok(Arrays.stream(Permission.values())
+ .map(PermissionResponse::of)
+ .toList());
}
// Audit Reporting Endpoints
+ //
+ // Annotated with audit:read rather than inheriting the class-level ADMIN role, because
+ // the audit log now covers board and device actions too — a BOARD_ADMIN needs to read
+ // it without being handed media moderation.
+
@GetMapping("/audit")
- public ResponseEntity> getAuditEvents(Pageable pageable) {
- try {
- log.debug("Admin retrieving audit events, page: {}", pageable.getPageNumber());
- Page
> getUploadAuditHistory(@PathVariable UUID uploadId) {
+ return ResponseEntity.ok(auditService.getAuditEventsByEntity("Upload", uploadId));
}
@GetMapping("/audit/action/{action}")
- public ResponseEntity> getAuditEventsByAction(@PathVariable AdminAction action, Pageable pageable) {
- try {
- log.debug("Admin retrieving audit events for action: {}", action);
- Page
> getAvailableActions() {
+ return ResponseEntity.ok(List.of(AuditAction.values()));
+ }
+
+ private String getCurrentUserEmail() {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ return authentication != null ? authentication.getName() : "unknown";
}
}
diff --git a/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java b/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java
new file mode 100644
index 0000000..f51708f
--- /dev/null
+++ b/src/main/java/com/ibrasoft/lensbridge/controller/AgentEnrollmentController.java
@@ -0,0 +1,88 @@
+package com.ibrasoft.lensbridge.controller;
+
+import com.ibrasoft.lensbridge.dto.board.request.AgentEnrollRequest;
+import com.ibrasoft.lensbridge.dto.board.response.AgentEnrollResponse;
+import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse;
+import com.ibrasoft.lensbridge.exception.ApiResponseException;
+import com.ibrasoft.lensbridge.service.agent.DeviceEnrollmentService;
+import com.ibrasoft.lensbridge.service.agent.DeviceEnrollmentService.Outcome;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * Public endpoint used by a freshly-flashed agent to convert a one-time enrollment token
+ * into a Device row + Ed25519 public-key registration.
+ *
> getAllBoardConfigs() {
+ @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONFIG_READ + "')")
+ public ResponseEntity
> getAllBoardConfigs() {
log.debug("Admin fetching all board configs");
return ResponseEntity.ok(boardService.getAllBoardConfigs());
}
- /**
- * Get board configuration for a specific location.
- */
- @GetMapping("/configs/{boardLocation}")
- public ResponseEntity
> getAllWeeklyContent() {
log.debug("Admin fetching all weekly content");
return ResponseEntity.ok(boardService.getAllWeeklyContent());
}
- /**
- * Get weekly content for a specific year.
- */
@GetMapping("/weekly-content/year/{year}")
+ @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')")
public ResponseEntity
> getWeeklyContentByYear(@PathVariable int year) {
log.debug("Admin fetching weekly content for year: {}", year);
return ResponseEntity.ok(boardService.getWeeklyContentByYear(year));
}
- /**
- * Get weekly content for a specific week.
- */
@GetMapping("/weekly-content/{year}/{weekNumber}")
+ @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')")
+ @Operation(operationId = "getAdminWeeklyContent", summary = "Fetch weekly content as an administrator")
public ResponseEntity
> getAllPosters() {
log.debug("Admin fetching all posters");
return ResponseEntity.ok(posterService.getAllPosters());
}
- /**
- * Get posters for a specific board location.
- * Returns posters matching the board's audience or BOTH, sorted by newest first.
- */
- @GetMapping("/posters/by-board")
- public ResponseEntity
> getPostersForBoard(
- @RequestParam("board") BoardLocation boardLocation) {
- log.debug("Admin fetching posters for board: {}", boardLocation);
- return ResponseEntity.ok(posterService.getPostersForBoard(boardLocation));
+ @GetMapping("/posters/by-audience")
+ @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')")
+ public ResponseEntity
> getPostersForAudience(@RequestParam Audience audience) {
+ log.debug("Admin fetching posters for audience: {}", audience);
+ return ResponseEntity.ok(posterService.getPostersForAudience(audience));
}
- /**
- * Get a single poster by ID.
- */
@GetMapping("/posters/{posterId}")
+ @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')")
public ResponseEntity
> getAllSocials() {
+ log.debug("Admin fetching all promotable social media");
+ return ResponseEntity.ok(socialMediaService.getAll());
+ }
+
+ @GetMapping("/socials/by-audience")
+ @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')")
+ public ResponseEntity
> getSocialsForAudience(@RequestParam Audience audience) {
+ log.debug("Admin fetching promotable social media for audience: {}", audience);
+ return ResponseEntity.ok(socialMediaService.getForAudience(audience));
+ }
+
+ @GetMapping("/socials/{socialId}")
+ @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')")
+ public ResponseEntity
> getAllEvents() {
+ @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')")
+ @Operation(operationId = "getBoardEvents", summary = "List every board event")
+ public ResponseEntity
> getAllEvents() {
log.debug("Admin fetching all calendar events");
- List
> getEventsForBoard(
- @RequestParam("board") BoardLocation boardLocation) {
- log.debug("Admin fetching calendar events for board: {}", boardLocation);
- List
> getEventsForAudience(@RequestParam Audience audience) {
+ log.debug("Admin fetching calendar events for audience: {}", audience);
+ return ResponseEntity.ok(boardService.getEventsForAudience(audience));
}
- /**
- * Get a single calendar event by ID.
- */
@GetMapping("/events/{eventId}")
- public ResponseEntity
> list() {
+ return ResponseEntity.ok(
+ deviceRepository.findAll().stream().map(DeviceSummary::of).toList()
+ );
+ }
+
+ @Operation(operationId = "getDevice", summary = "Fetch a single board device")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "Device found"),
+ @ApiResponse(responseCode = "404", description = "No device with that id",
+ content = @Content(schema = @Schema(implementation = MessageResponse.class)))
+ })
+ @GetMapping("/{deviceId}")
+ @PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_DEVICE_READ + "')")
+ public ResponseEntity
> recentCommands(@PathVariable UUID deviceId) {
+ List
> getAllEvents() {
- eventsService.cleanUpOldEvents();
- List
> getAllEvents() {
+ return ResponseEntity.ok(eventsService.getPublicVisibleEvents());
}
-
@GetMapping("/{id}")
- public ResponseEntity
> getActivePosterFrames(
- @RequestParam("board") BoardLocation boardLocation) {
- log.debug("Musallah board fetching active poster frames for: {}", boardLocation);
- List
> getUpcomingEvents(
- @RequestParam("board") BoardLocation boardLocation) {
- log.debug("Musallah board fetching upcoming events for: {}", boardLocation);
- List
> getUpcomingEvents(@RequestParam Audience audience) {
+ log.debug("Musallah board fetching upcoming events for audience: {}", audience);
+ List
> getEventsInRange(
- @RequestParam("board") BoardLocation boardLocation,
- @RequestParam("start") long rangeStart,
- @RequestParam("end") long rangeEnd) {
- log.debug("Musallah board fetching events for {} in range {} - {}", boardLocation, rangeStart, rangeEnd);
- List
> getEventsInRange(
+ @RequestParam Audience audience,
+ @RequestParam("start") Instant rangeStart,
+ @RequestParam("end") Instant rangeEnd) {
+ log.debug("Musallah board fetching events for {} in range {} - {}", audience, rangeStart, rangeEnd);
+ List