Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccfdfcbc24 | ||
|
|
91f89cbbb8 | ||
|
|
3631dfdf93 | ||
|
|
1b992fe1a3 | ||
|
|
5d5b8ed39d | ||
|
|
c31f47216d | ||
|
|
fc04542552 |
@@ -0,0 +1,92 @@
|
||||
name: Sync Portal Docs
|
||||
|
||||
# Regenerates the portal Developer Docs manifest from the Stirling docs repo and
|
||||
# opens a PR when it changes. Runs weekly, on manual dispatch, or when the docs
|
||||
# repo fires a `docs-updated` repository_dispatch.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Docs repo ref (branch or tag) to sync from"
|
||||
required: false
|
||||
default: "main"
|
||||
repository_dispatch:
|
||||
types: [docs-updated]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Sync docs manifest
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
env:
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true"
|
||||
run: npm ci --ignore-scripts --audit=false --fund=false
|
||||
|
||||
- name: Regenerate docs manifest
|
||||
working-directory: frontend
|
||||
env:
|
||||
DOCS_REF: ${{ github.event.inputs.ref || github.event.client_payload.ref || 'main' }}
|
||||
GITHUB_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
run: npm run docs:sync
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Sync portal docs from docs repo"
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: sync-portal-docs
|
||||
base: main
|
||||
title: "Sync portal docs from docs repo"
|
||||
body: |
|
||||
Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot].
|
||||
|
||||
Regenerates `frontend/editor/src/portal/generated/docsManifest.json`
|
||||
from the Stirling docs repo via `npm run docs:sync`.
|
||||
labels: documentation,github-actions,frontend
|
||||
add-paths: frontend/editor/src/portal/generated/docsManifest.json
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
+2
-13
@@ -54,21 +54,10 @@ public class CustomAuditEventRepository implements AuditEventRepository {
|
||||
return;
|
||||
}
|
||||
String rid = MDC.get("requestId");
|
||||
String apiKeyLabel =
|
||||
MDC.get(
|
||||
stirling.software.proprietary.security.service
|
||||
.ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY);
|
||||
|
||||
if (rid != null || apiKeyLabel != null) {
|
||||
if (rid != null) {
|
||||
clean = new java.util.HashMap<>(clean);
|
||||
if (rid != null) {
|
||||
clean.put("requestId", rid);
|
||||
}
|
||||
// Named key that made the request; surfaces as the doc source in the processor
|
||||
// feed.
|
||||
if (apiKeyLabel != null) {
|
||||
clean.put("__apiKeyLabel", apiKeyLabel);
|
||||
}
|
||||
clean.put("requestId", rid);
|
||||
}
|
||||
|
||||
String source = MDC.get("auditSource");
|
||||
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
|
||||
import stirling.software.proprietary.model.api.apikey.CreateApiKeyRequest;
|
||||
import stirling.software.proprietary.model.api.apikey.CreatedApiKeyDto;
|
||||
import stirling.software.proprietary.model.api.apikey.PortalApiKeysResponse;
|
||||
import stirling.software.proprietary.security.service.ApiKeyManagementService;
|
||||
|
||||
/**
|
||||
* Real backing for the portal Infrastructure → API Keys tab: list/create/revoke named, personal API
|
||||
* keys. Replaces the former portal-only mock endpoint. Not gated behind an Enterprise license - API
|
||||
* keys are a core auth feature available on every self-hosted instance.
|
||||
*/
|
||||
@ProprietaryUiDataApi
|
||||
@RequiredArgsConstructor
|
||||
public class PortalApiKeysController {
|
||||
|
||||
private final ApiKeyManagementService apiKeyManagementService;
|
||||
|
||||
// tier accepted for endpoint symmetry with the other infra tabs; ignored here.
|
||||
@GetMapping("/infrastructure/api-keys")
|
||||
@Operation(summary = "List API keys", description = "The caller's personal API keys.")
|
||||
public ResponseEntity<PortalApiKeysResponse> list(
|
||||
@RequestParam(value = "tier", required = false) String tier) {
|
||||
return ResponseEntity.ok(apiKeyManagementService.listVisibleKeys());
|
||||
}
|
||||
|
||||
@PostMapping("/infrastructure/api-keys")
|
||||
@Operation(
|
||||
summary = "Create an API key",
|
||||
description = "Mints a personal key and returns its one-time secret.")
|
||||
public ResponseEntity<CreatedApiKeyDto> create(@RequestBody CreateApiKeyRequest request) {
|
||||
return ResponseEntity.ok(apiKeyManagementService.createKey(request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/infrastructure/api-keys/{id}")
|
||||
@Operation(summary = "Revoke an API key", description = "Disables a key the caller owns.")
|
||||
public ResponseEntity<Void> revoke(@PathVariable("id") Long id) {
|
||||
apiKeyManagementService.revokeKey(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -24,8 +24,8 @@ import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* API-key auth for the MCP endpoint: validates a Stirling API key and binds the request to that
|
||||
* user with the MCP scopes.
|
||||
* API-key auth for the MCP endpoint: validates a Stirling per-user API key and binds the request to
|
||||
* that user with the MCP scopes.
|
||||
*/
|
||||
@Slf4j
|
||||
public class McpApiKeyAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
package stirling.software.proprietary.model.api.apikey;
|
||||
|
||||
/** Create-key request body from the portal: just a display name for the new personal key. */
|
||||
public record CreateApiKeyRequest(String name) {}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package stirling.software.proprietary.model.api.apikey;
|
||||
|
||||
import lombok.Builder;
|
||||
|
||||
/** Returned once when a key is created: the row plus the plaintext secret, never persisted. */
|
||||
@Builder
|
||||
public record CreatedApiKeyDto(PortalApiKeyDto key, String secret) {}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
package stirling.software.proprietary.model.api.apikey;
|
||||
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* One API key as shown in the portal Infrastructure → API Keys tab. Never carries the secret; that
|
||||
* is returned once from {@link CreatedApiKeyDto} at creation time.
|
||||
*/
|
||||
@Builder
|
||||
public record PortalApiKeyDto(
|
||||
String id,
|
||||
String name,
|
||||
String prefix,
|
||||
String created,
|
||||
String lastUsed,
|
||||
/** "active" | "revoked". */
|
||||
String status,
|
||||
long usageToday,
|
||||
long usageMonth,
|
||||
/** Lifetime request count for the key. */
|
||||
long usageTotal) {}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
package stirling.software.proprietary.model.api.apikey;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import lombok.Builder;
|
||||
|
||||
/** Payload for the API Keys tab: the personal keys the caller owns. */
|
||||
@Builder
|
||||
public record PortalApiKeysResponse(List<PortalApiKeyDto> keys) {}
|
||||
+2
-5
@@ -57,7 +57,6 @@ import stirling.software.proprietary.security.oauth2.TauriAuthorizationRequestRe
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationSuccessHandler;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2ResponseAuthenticationConverter;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
|
||||
import stirling.software.proprietary.security.service.CustomOAuth2UserService;
|
||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
@@ -485,14 +484,12 @@ public class SecurityConfiguration {
|
||||
}
|
||||
|
||||
@Bean
|
||||
public JwtAuthenticationFilter jwtAuthenticationFilter(
|
||||
ApiKeyAuthenticationService apiKeyAuthenticationService) {
|
||||
public JwtAuthenticationFilter jwtAuthenticationFilter() {
|
||||
return new JwtAuthenticationFilter(
|
||||
jwtService,
|
||||
userService,
|
||||
userDetailsService,
|
||||
jwtAuthenticationEntryPoint,
|
||||
securityProperties,
|
||||
apiKeyAuthenticationService);
|
||||
securityProperties);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-20
@@ -11,7 +11,6 @@ import java.sql.SQLException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
@@ -34,9 +33,8 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
|
||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
@@ -50,15 +48,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
private final AuthenticationEntryPoint authenticationEntryPoint;
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
// Start clean so a pooled thread can't inherit a prior request's key label. This filter
|
||||
// runs before UserAuthenticationFilter, so in JWT mode it owns the API-key label lifecycle.
|
||||
MDC.remove(ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY);
|
||||
if (!jwtService.isJwtEnabled()) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
@@ -137,14 +131,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
try {
|
||||
// Resolve through the shared service so the multi-key table (then the legacy
|
||||
// per-user key) is consulted and per-key usage is recorded; the key runs as its
|
||||
// owner. It also yields a per-key label for the processor's document
|
||||
// attribution.
|
||||
Optional<ApiKeyAuthentication> resolved =
|
||||
apiKeyAuthenticationService.authenticate(apiKey);
|
||||
Optional<User> user = userService.getUserByApiKey(apiKey);
|
||||
|
||||
if (resolved.isEmpty()) {
|
||||
if (user.isEmpty()) {
|
||||
handleAuthenticationFailure(
|
||||
request,
|
||||
response,
|
||||
@@ -154,13 +143,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
authentication =
|
||||
new ApiKeyAuthenticationToken(
|
||||
resolved.get().user(), apiKey, resolved.get().authorities());
|
||||
user.get(), apiKey, user.get().getAuthorities());
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
if (resolved.get().auditLabel() != null) {
|
||||
MDC.put(
|
||||
ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY,
|
||||
resolved.get().auditLabel());
|
||||
}
|
||||
return true;
|
||||
} catch (AuthenticationException e) {
|
||||
handleAuthenticationFailure(
|
||||
|
||||
+5
-27
@@ -6,7 +6,6 @@ import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
@@ -34,8 +33,6 @@ import stirling.software.common.util.RequestUriUtils;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
|
||||
@@ -44,24 +41,18 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
@Profile("!saas")
|
||||
public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
/** MDC key carrying the resolved key's label into audit events for the processor feed. */
|
||||
public static final String API_KEY_LABEL_MDC = ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY;
|
||||
|
||||
private final ApplicationProperties.Security securityProp;
|
||||
private final UserService userService;
|
||||
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
private final SessionPersistentRegistry sessionPersistentRegistry;
|
||||
private final boolean loginEnabledValue;
|
||||
|
||||
public UserAuthenticationFilter(
|
||||
@Lazy ApplicationProperties.Security securityProp,
|
||||
@Lazy UserService userService,
|
||||
ApiKeyAuthenticationService apiKeyAuthenticationService,
|
||||
SessionPersistentRegistry sessionPersistentRegistry,
|
||||
@Qualifier("loginEnabled") boolean loginEnabledValue) {
|
||||
this.securityProp = securityProp;
|
||||
this.userService = userService;
|
||||
this.apiKeyAuthenticationService = apiKeyAuthenticationService;
|
||||
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
||||
this.loginEnabledValue = loginEnabledValue;
|
||||
}
|
||||
@@ -71,14 +62,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
// Start each request clean so a pooled thread can't inherit a prior request's key label -
|
||||
// but keep a label an upstream filter (JwtAuthenticationFilter) already set for a request
|
||||
// it API-key-authenticated, otherwise per-key attribution is lost on the JWT path.
|
||||
if (!(SecurityContextHolder.getContext().getAuthentication()
|
||||
instanceof ApiKeyAuthenticationToken)) {
|
||||
MDC.remove(API_KEY_LABEL_MDC);
|
||||
}
|
||||
|
||||
if (!loginEnabledValue) {
|
||||
// If login is not enabled, just pass all requests without authentication
|
||||
filterChain.doFilter(request, response);
|
||||
@@ -106,23 +89,18 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
String apiKey = request.getHeader("X-API-KEY");
|
||||
if (apiKey != null && !apiKey.trim().isEmpty()) {
|
||||
try {
|
||||
// Resolves the multi-key table then the legacy key, records usage, and yields a
|
||||
// per-key label for the processor's document-source attribution.
|
||||
Optional<ApiKeyAuthentication> resolved =
|
||||
apiKeyAuthenticationService.authenticate(apiKey);
|
||||
if (resolved.isEmpty()) {
|
||||
// Use API key to authenticate. This requires you to have an authentication
|
||||
// provider for API keys.
|
||||
Optional<User> user = userService.getUserByApiKey(apiKey);
|
||||
if (user.isEmpty()) {
|
||||
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||
response.getWriter().write("Invalid API Key.");
|
||||
return;
|
||||
}
|
||||
User user = resolved.get().user();
|
||||
authentication =
|
||||
new ApiKeyAuthenticationToken(
|
||||
user, apiKey, resolved.get().authorities());
|
||||
user.get(), apiKey, user.get().getAuthorities());
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
if (resolved.get().auditLabel() != null) {
|
||||
MDC.put(API_KEY_LABEL_MDC, resolved.get().auditLabel());
|
||||
}
|
||||
} catch (AuthenticationException e) {
|
||||
// If API key authentication fails, deny the request
|
||||
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||
|
||||
+15
-16
@@ -11,6 +11,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
@@ -57,25 +58,23 @@ public class UserBasedRateLimitingFilter extends OncePerRequestFilter {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
// Bucket by the resolved user (the auth filter runs first and populates the context, even
|
||||
// for X-API-KEY requests), so all of a user's API keys share ONE per-user quota - minting
|
||||
// extra keys can't multiply the daily limit. Fall back to the raw key / IP only when the
|
||||
// request is unauthenticated.
|
||||
String identifier = null;
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null
|
||||
&& authentication.isAuthenticated()
|
||||
&& !"anonymousUser".equals(authentication.getName())) {
|
||||
identifier = authentication.getName();
|
||||
}
|
||||
if (identifier == null) {
|
||||
String apiKey = request.getHeader("X-API-KEY");
|
||||
if (apiKey != null && !apiKey.trim().isEmpty()) {
|
||||
identifier = "API_KEY_" + apiKey;
|
||||
} else {
|
||||
identifier = request.getRemoteAddr();
|
||||
// Check for API key in the request headers
|
||||
String apiKey = request.getHeader("X-API-KEY");
|
||||
if (apiKey != null && !apiKey.trim().isEmpty()) {
|
||||
identifier = // Prefix to distinguish between API keys and usernames
|
||||
"API_KEY_" + apiKey;
|
||||
} else {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null && authentication.isAuthenticated()) {
|
||||
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
|
||||
identifier = userDetails.getUsername();
|
||||
}
|
||||
}
|
||||
// If neither API key nor an authenticated user is present, use IP address
|
||||
if (identifier == null) {
|
||||
identifier = request.getRemoteAddr();
|
||||
}
|
||||
Role userRole =
|
||||
getRoleFromAuthentication(SecurityContextHolder.getContext().getAuthentication());
|
||||
if (request.getHeader("X-API-KEY") != null) {
|
||||
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
package stirling.software.proprietary.security.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* A named, personal API key belonging to a user. The raw secret is shown once at creation and never
|
||||
* stored; only its SHA-256 hash is persisted, so a leaked database row cannot be replayed. Distinct
|
||||
* from the legacy single {@code users.apiKey} column, which stays a per-user key for backward
|
||||
* compatibility and is lazily represented here.
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "api_keys",
|
||||
indexes = {
|
||||
@Index(name = "idx_api_key_hash", columnList = "key_hash", unique = true),
|
||||
@Index(name = "idx_api_key_owner", columnList = "owner_user_id")
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ApiKey implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "id")
|
||||
private Long id;
|
||||
|
||||
@Column(name = "name", nullable = false, length = 100)
|
||||
private String name;
|
||||
|
||||
/** SHA-256 hex of the raw key; the raw value is never persisted. */
|
||||
@Column(name = "key_hash", nullable = false, unique = true, length = 64)
|
||||
private String keyHash;
|
||||
|
||||
/** Non-secret leading fragment of the raw key, shown in listings (e.g. {@code sk_a1b2c3d4}). */
|
||||
@Column(name = "prefix", nullable = false, length = 32)
|
||||
private String prefix;
|
||||
|
||||
/** The user who created and owns the key; the key authenticates as this user. */
|
||||
@Column(name = "owner_user_id", nullable = false)
|
||||
private Long ownerUserId;
|
||||
|
||||
@Column(name = "enabled", nullable = false)
|
||||
private boolean enabled;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "last_used_at")
|
||||
private Instant lastUsedAt;
|
||||
|
||||
@Column(name = "revoked_at")
|
||||
private Instant revokedAt;
|
||||
|
||||
/** Active = enabled and not revoked; only active keys authenticate. */
|
||||
public boolean isActive() {
|
||||
return enabled && revokedAt == null;
|
||||
}
|
||||
}
|
||||
-1
@@ -5,7 +5,6 @@ import java.util.Collection;
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
|
||||
/** Authentication produced from an {@code X-API-KEY} header; runs as the key's owner. */
|
||||
public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
|
||||
|
||||
private final Object principal;
|
||||
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
package stirling.software.proprietary.security.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.IdClass;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* One UTC day's request tally for an API key. Rolling "today"/"this month" usage is summed from
|
||||
* these rows, keeping the table at one row per key per active day rather than one per request.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "api_key_daily_usage")
|
||||
@IdClass(ApiKeyDailyUsageId.class)
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class ApiKeyDailyUsage implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "api_key_id")
|
||||
private Long apiKeyId;
|
||||
|
||||
@Id
|
||||
@Column(name = "epoch_day")
|
||||
private long epochDay;
|
||||
|
||||
@Column(name = "count")
|
||||
private long count;
|
||||
|
||||
public ApiKeyDailyUsage(Long apiKeyId, long epochDay, long count) {
|
||||
this.apiKeyId = apiKeyId;
|
||||
this.epochDay = epochDay;
|
||||
this.count = count;
|
||||
}
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
package stirling.software.proprietary.security.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Composite key for {@link ApiKeyDailyUsage}: one row per key per UTC day. */
|
||||
public class ApiKeyDailyUsageId implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long apiKeyId;
|
||||
private long epochDay;
|
||||
|
||||
public ApiKeyDailyUsageId() {}
|
||||
|
||||
public ApiKeyDailyUsageId(Long apiKeyId, long epochDay) {
|
||||
this.apiKeyId = apiKeyId;
|
||||
this.epochDay = epochDay;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (!(o instanceof ApiKeyDailyUsageId other)) {
|
||||
return false;
|
||||
}
|
||||
return epochDay == other.epochDay && Objects.equals(apiKeyId, other.apiKeyId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(apiKeyId, epochDay);
|
||||
}
|
||||
}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
package stirling.software.proprietary.security.repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.proprietary.security.model.ApiKeyDailyUsage;
|
||||
import stirling.software.proprietary.security.model.ApiKeyDailyUsageId;
|
||||
|
||||
@Repository
|
||||
public interface ApiKeyDailyUsageRepository
|
||||
extends JpaRepository<ApiKeyDailyUsage, ApiKeyDailyUsageId> {
|
||||
|
||||
/** Atomically bump today's tally; returns 0 when no row exists yet (caller then inserts). */
|
||||
@Modifying
|
||||
@Query(
|
||||
"UPDATE ApiKeyDailyUsage u SET u.count = u.count + 1 "
|
||||
+ "WHERE u.apiKeyId = :apiKeyId AND u.epochDay = :epochDay")
|
||||
int incrementIfPresent(@Param("apiKeyId") Long apiKeyId, @Param("epochDay") long epochDay);
|
||||
|
||||
@Query(
|
||||
"SELECT COALESCE(SUM(u.count), 0) FROM ApiKeyDailyUsage u "
|
||||
+ "WHERE u.apiKeyId = :apiKeyId AND u.epochDay >= :fromDayInclusive")
|
||||
long sumSince(
|
||||
@Param("apiKeyId") Long apiKeyId, @Param("fromDayInclusive") long fromDayInclusive);
|
||||
|
||||
@Query(
|
||||
"SELECT u.count FROM ApiKeyDailyUsage u "
|
||||
+ "WHERE u.apiKeyId = :apiKeyId AND u.epochDay = :epochDay")
|
||||
Long countForDay(@Param("apiKeyId") Long apiKeyId, @Param("epochDay") long epochDay);
|
||||
|
||||
/** Batched today-count for many keys in one query (avoids N+1 when listing keys). */
|
||||
@Query(
|
||||
"SELECT u.apiKeyId AS apiKeyId, u.count AS total FROM ApiKeyDailyUsage u "
|
||||
+ "WHERE u.apiKeyId IN :ids AND u.epochDay = :epochDay")
|
||||
List<ApiKeyUsageSum> countForDayByIds(
|
||||
@Param("ids") Collection<Long> ids, @Param("epochDay") long epochDay);
|
||||
|
||||
/** Batched trailing-window sum for many keys in one query. */
|
||||
@Query(
|
||||
"SELECT u.apiKeyId AS apiKeyId, SUM(u.count) AS total FROM ApiKeyDailyUsage u "
|
||||
+ "WHERE u.apiKeyId IN :ids AND u.epochDay >= :fromDayInclusive "
|
||||
+ "GROUP BY u.apiKeyId")
|
||||
List<ApiKeyUsageSum> sumSinceByIds(
|
||||
@Param("ids") Collection<Long> ids, @Param("fromDayInclusive") long fromDayInclusive);
|
||||
|
||||
void deleteByApiKeyId(Long apiKeyId);
|
||||
|
||||
List<ApiKeyDailyUsage> findByApiKeyId(Long apiKeyId);
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
package stirling.software.proprietary.security.repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.proprietary.security.model.ApiKey;
|
||||
|
||||
@Repository
|
||||
public interface ApiKeyRepository extends JpaRepository<ApiKey, Long> {
|
||||
|
||||
Optional<ApiKey> findByKeyHash(String keyHash);
|
||||
|
||||
boolean existsByKeyHash(String keyHash);
|
||||
|
||||
List<ApiKey> findByOwnerUserIdOrderByCreatedAtDesc(Long ownerUserId);
|
||||
}
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
package stirling.software.proprietary.security.repository;
|
||||
|
||||
/** Projection: a key id and a usage total, for batching per-key usage into one query. */
|
||||
public interface ApiKeyUsageSum {
|
||||
Long getApiKeyId();
|
||||
|
||||
Long getTotal();
|
||||
}
|
||||
-107
@@ -1,107 +0,0 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.ApiKey;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyRepository;
|
||||
|
||||
/**
|
||||
* Resolves an incoming {@code X-API-KEY} to its owning user and records per-key usage. Depends only
|
||||
* on repositories (never {@code UserService}) so {@code UserService} can delegate here without a
|
||||
* bean cycle.
|
||||
*
|
||||
* <p>Resolution order: the multi-key {@code api_keys} table first (by hash), then the legacy
|
||||
* per-user {@code users.apiKey} column. Legacy keys therefore keep working unchanged. Every key is
|
||||
* personal and authenticates as its owner with the owner's authorities.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ApiKeyAuthenticationService {
|
||||
|
||||
/**
|
||||
* MDC key that carries the resolved key's label into audit events so the processor's Documents
|
||||
* feed can attribute a document to the specific key. Set by the auth filters (both flavors),
|
||||
* read by {@code CustomAuditEventRepository}.
|
||||
*/
|
||||
public static final String AUDIT_LABEL_MDC_KEY = "apiKeyLabel";
|
||||
|
||||
private final ApiKeyRepository apiKeyRepository;
|
||||
private final ApiKeyUsageRecorder usageRecorder;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
/** The user a raw key authenticates as, or empty if it matches no active key. */
|
||||
public Optional<User> resolveUser(String rawKey) {
|
||||
return authenticate(rawKey).map(ApiKeyAuthentication::user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a raw key, recording usage as a side effect. Returns the owning user, a display label
|
||||
* for the resolved key ({@code null} for the legacy per-user key), and the owner's authorities.
|
||||
*/
|
||||
public Optional<ApiKeyAuthentication> authenticate(String rawKey) {
|
||||
if (rawKey == null || rawKey.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
ApiKey key = apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(rawKey)).orElse(null);
|
||||
if (key != null) {
|
||||
if (!key.isActive()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
User owner = userRepository.findById(key.getOwnerUserId()).orElse(null);
|
||||
if (owner == null || !owner.isEnabled()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
usageRecorder.record(key.getId());
|
||||
return Optional.of(
|
||||
new ApiKeyAuthentication(owner, auditLabel(key), owner.getAuthorities()));
|
||||
}
|
||||
|
||||
// Legacy single per-user key: keep working, always a personal key for its user.
|
||||
return userRepository
|
||||
.findByApiKey(rawKey)
|
||||
.filter(User::isEnabled)
|
||||
.map(user -> new ApiKeyAuthentication(user, null, user.getAuthorities()));
|
||||
}
|
||||
|
||||
/** "Production ingest (sk_a1b2c3d4)" - shown against API-sourced docs in the processor feed. */
|
||||
private static String auditLabel(ApiKey key) {
|
||||
return key.getName() + " (" + key.getPrefix() + ")";
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke the {@code api_keys} row that mirrors a given raw key, if any. Called when the legacy
|
||||
* per-user key is rotated so the migrated shadow row can't keep authenticating the old secret.
|
||||
*/
|
||||
@Transactional
|
||||
public void revokeMigratedKey(String rawKey) {
|
||||
if (rawKey == null || rawKey.isBlank()) {
|
||||
return;
|
||||
}
|
||||
apiKeyRepository
|
||||
.findByKeyHash(ApiKeyHasher.hash(rawKey))
|
||||
.filter(ApiKey::isActive)
|
||||
.ifPresent(
|
||||
k -> {
|
||||
k.setEnabled(false);
|
||||
k.setRevokedAt(Instant.now());
|
||||
apiKeyRepository.save(k);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A resolved key: the user, an optional processor-feed label, and the authorities to run as.
|
||||
*/
|
||||
public record ApiKeyAuthentication(
|
||||
User user, String auditLabel, Collection<? extends GrantedAuthority> authorities) {}
|
||||
}
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.HexFormat;
|
||||
|
||||
/** Generates opaque API-key secrets and hashes them for storage/lookup. */
|
||||
public final class ApiKeyHasher {
|
||||
|
||||
/** Human-recognisable prefix so a leaked string is identifiable as a Stirling API key. */
|
||||
public static final String KEY_PREFIX = "sk_";
|
||||
|
||||
/** Chars of the raw key kept for non-secret display (includes the {@code sk_} prefix). */
|
||||
private static final int DISPLAY_PREFIX_LENGTH = 11;
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private ApiKeyHasher() {}
|
||||
|
||||
/** A fresh opaque secret: {@code sk_} followed by 40 hex chars of cryptographic randomness. */
|
||||
public static String generateRawKey() {
|
||||
byte[] bytes = new byte[20];
|
||||
RANDOM.nextBytes(bytes);
|
||||
return KEY_PREFIX + HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
|
||||
/** SHA-256 hex of a raw key; the value stored and looked up, never the raw key. */
|
||||
public static String hash(String rawKey) {
|
||||
try {
|
||||
byte[] digest =
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(rawKey.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(digest);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Leading, non-secret fragment shown in listings (e.g. {@code sk_a1b2c3d4}). */
|
||||
public static String displayPrefix(String rawKey) {
|
||||
if (rawKey == null) {
|
||||
return "";
|
||||
}
|
||||
return rawKey.length() <= DISPLAY_PREFIX_LENGTH
|
||||
? rawKey
|
||||
: rawKey.substring(0, DISPLAY_PREFIX_LENGTH);
|
||||
}
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.security.model.ApiKey;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyRepository;
|
||||
|
||||
/**
|
||||
* Inserts the shadow {@code api_keys} row that mirrors a user's legacy {@code users.apiKey} in its
|
||||
* OWN ({@code REQUIRES_NEW}) transaction. Kept a separate bean so the write is isolated from the
|
||||
* caller's listing transaction: when two concurrent first-loads race to insert the same hash, the
|
||||
* loser's unique-key clash rolls back only this insert instead of poisoning the caller's
|
||||
* transaction (on Postgres a failed statement aborts the whole transaction). The {@code
|
||||
* DataIntegrityViolationException} is left to propagate so the caller can treat it as "already
|
||||
* migrated".
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
class ApiKeyLegacyMigrator {
|
||||
|
||||
private final ApiKeyRepository apiKeyRepository;
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void insertMigratedKey(ApiKey key) {
|
||||
apiKeyRepository.saveAndFlush(key);
|
||||
}
|
||||
}
|
||||
-235
@@ -1,235 +0,0 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.api.apikey.CreateApiKeyRequest;
|
||||
import stirling.software.proprietary.model.api.apikey.CreatedApiKeyDto;
|
||||
import stirling.software.proprietary.model.api.apikey.PortalApiKeyDto;
|
||||
import stirling.software.proprietary.model.api.apikey.PortalApiKeysResponse;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.ApiKey;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyRepository;
|
||||
|
||||
/**
|
||||
* Portal-facing CRUD for named, personal API keys: lists, creates, and revokes the caller's own
|
||||
* keys. Every key belongs to exactly one user and authenticates as that user; there is no sharing.
|
||||
*
|
||||
* <p>Every pre-existing single {@code users.apiKey} is lazily represented as a key owned by that
|
||||
* user, so historic keys list uniformly.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ApiKeyManagementService {
|
||||
|
||||
private static final DateTimeFormatter CREATED_FORMAT =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneOffset.UTC);
|
||||
private static final DateTimeFormatter LAST_USED_FORMAT =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneOffset.UTC);
|
||||
private static final int MONTH_WINDOW_DAYS = 30;
|
||||
|
||||
/** Bounds a key name so it can't bloat storage or the audit/processor feed. */
|
||||
private static final int MAX_NAME_LENGTH = 100;
|
||||
|
||||
/** Caps active keys per user so key creation can't be used to multiply rate-limit budget. */
|
||||
private static final int MAX_ACTIVE_KEYS_PER_USER = 50;
|
||||
|
||||
private final ApiKeyRepository apiKeyRepository;
|
||||
private final ApiKeyDailyUsageRepository usageRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final UserService userService;
|
||||
private final ApiKeyLegacyMigrator legacyMigrator;
|
||||
|
||||
/** All keys the caller owns. */
|
||||
@Transactional
|
||||
public PortalApiKeysResponse listVisibleKeys() {
|
||||
User caller = requireCaller();
|
||||
migrateLegacyKey(caller);
|
||||
|
||||
List<ApiKey> visible =
|
||||
apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(caller.getId());
|
||||
|
||||
// Batch usage for all keys into three queries rather than two-per-key (avoids N+1).
|
||||
long today = Instant.now().atZone(ZoneOffset.UTC).toLocalDate().toEpochDay();
|
||||
List<Long> ids = visible.stream().map(ApiKey::getId).toList();
|
||||
Map<Long, Long> todayById = new HashMap<>();
|
||||
Map<Long, Long> monthById = new HashMap<>();
|
||||
Map<Long, Long> totalById = new HashMap<>();
|
||||
if (!ids.isEmpty()) {
|
||||
usageRepository
|
||||
.countForDayByIds(ids, today)
|
||||
.forEach(r -> todayById.put(r.getApiKeyId(), r.getTotal()));
|
||||
usageRepository
|
||||
.sumSinceByIds(ids, today - (MONTH_WINDOW_DAYS - 1))
|
||||
.forEach(r -> monthById.put(r.getApiKeyId(), r.getTotal()));
|
||||
usageRepository
|
||||
.sumSinceByIds(ids, Long.MIN_VALUE)
|
||||
.forEach(r -> totalById.put(r.getApiKeyId(), r.getTotal()));
|
||||
}
|
||||
|
||||
List<PortalApiKeyDto> keys =
|
||||
visible.stream()
|
||||
.map(
|
||||
k ->
|
||||
toDto(
|
||||
k,
|
||||
zeroIfNull(todayById.get(k.getId())),
|
||||
zeroIfNull(monthById.get(k.getId())),
|
||||
zeroIfNull(totalById.get(k.getId()))))
|
||||
.toList();
|
||||
return PortalApiKeysResponse.builder().keys(keys).build();
|
||||
}
|
||||
|
||||
private static long zeroIfNull(Long value) {
|
||||
return value == null ? 0L : value;
|
||||
}
|
||||
|
||||
/** Create a personal key and return its one-time secret. */
|
||||
@Transactional
|
||||
public CreatedApiKeyDto createKey(CreateApiKeyRequest request) {
|
||||
User caller = requireCaller();
|
||||
String name = request == null ? null : request.name();
|
||||
if (name == null || name.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Key name is required");
|
||||
}
|
||||
if (name.trim().length() > MAX_NAME_LENGTH) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Key name must be " + MAX_NAME_LENGTH + " characters or fewer");
|
||||
}
|
||||
long activeOwned =
|
||||
apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(caller.getId()).stream()
|
||||
.filter(ApiKey::isActive)
|
||||
.count();
|
||||
if (activeOwned >= MAX_ACTIVE_KEYS_PER_USER) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
"You have reached the maximum of "
|
||||
+ MAX_ACTIVE_KEYS_PER_USER
|
||||
+ " active API keys; revoke one before creating another");
|
||||
}
|
||||
|
||||
String rawKey = ApiKeyHasher.generateRawKey();
|
||||
ApiKey saved =
|
||||
apiKeyRepository.save(
|
||||
ApiKey.builder()
|
||||
.name(name.trim())
|
||||
.keyHash(ApiKeyHasher.hash(rawKey))
|
||||
.prefix(ApiKeyHasher.displayPrefix(rawKey))
|
||||
.ownerUserId(caller.getId())
|
||||
.enabled(true)
|
||||
.createdAt(Instant.now())
|
||||
.build());
|
||||
|
||||
return CreatedApiKeyDto.builder().key(toDto(saved, 0L, 0L, 0L)).secret(rawKey).build();
|
||||
}
|
||||
|
||||
/** Soft-revoke a key the caller owns; also clears the legacy column if it is that key. */
|
||||
@Transactional
|
||||
public void revokeKey(Long id) {
|
||||
User caller = requireCaller();
|
||||
ApiKey key =
|
||||
apiKeyRepository
|
||||
.findById(id)
|
||||
.orElseThrow(
|
||||
() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "No key"));
|
||||
if (!key.getOwnerUserId().equals(caller.getId())) {
|
||||
// Not-found rather than forbidden so a caller can't probe other users' key ids.
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No key");
|
||||
}
|
||||
key.setEnabled(false);
|
||||
key.setRevokedAt(Instant.now());
|
||||
apiKeyRepository.save(key);
|
||||
clearLegacyColumnIfMatches(key);
|
||||
}
|
||||
|
||||
/** Represent a user's pre-existing single key as a row so it lists uniformly. */
|
||||
private void migrateLegacyKey(User user) {
|
||||
String legacy = user.getApiKey();
|
||||
if (legacy == null || legacy.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String hash = ApiKeyHasher.hash(legacy);
|
||||
if (apiKeyRepository.existsByKeyHash(hash)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Insert in its own transaction so a concurrent-insert clash can't poison this
|
||||
// listing transaction (see ApiKeyLegacyMigrator).
|
||||
legacyMigrator.insertMigratedKey(
|
||||
ApiKey.builder()
|
||||
.name("Default key")
|
||||
.keyHash(hash)
|
||||
.prefix(ApiKeyHasher.displayPrefix(legacy))
|
||||
.ownerUserId(user.getId())
|
||||
.enabled(true)
|
||||
.createdAt(Instant.now())
|
||||
.build());
|
||||
} catch (DataIntegrityViolationException alreadyMigrated) {
|
||||
// A concurrent first-load won the race and inserted the same hash; that's fine.
|
||||
log.debug("Legacy key already migrated concurrently for user {}", user.getId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If a revoked key is the owner's legacy {@code users.apiKey}, null it so it stops resolving.
|
||||
*/
|
||||
private void clearLegacyColumnIfMatches(ApiKey key) {
|
||||
userRepository
|
||||
.findById(key.getOwnerUserId())
|
||||
.ifPresent(
|
||||
owner -> {
|
||||
String legacy = owner.getApiKey();
|
||||
if (legacy != null
|
||||
&& ApiKeyHasher.hash(legacy).equals(key.getKeyHash())) {
|
||||
owner.setApiKey(null);
|
||||
userRepository.save(owner);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private PortalApiKeyDto toDto(ApiKey key, long usageToday, long usageMonth, long usageTotal) {
|
||||
return PortalApiKeyDto.builder()
|
||||
.id(String.valueOf(key.getId()))
|
||||
.name(key.getName())
|
||||
.prefix(key.getPrefix())
|
||||
.created(
|
||||
key.getCreatedAt() == null ? "" : CREATED_FORMAT.format(key.getCreatedAt()))
|
||||
.lastUsed(
|
||||
key.getLastUsedAt() == null
|
||||
? "Never"
|
||||
: LAST_USED_FORMAT.format(key.getLastUsedAt()))
|
||||
.status(key.isActive() ? "active" : "revoked")
|
||||
.usageToday(usageToday)
|
||||
.usageMonth(usageMonth)
|
||||
.usageTotal(usageTotal)
|
||||
.build();
|
||||
}
|
||||
|
||||
private User requireCaller() {
|
||||
String username = userService.getCurrentUsername();
|
||||
if (username == null || username.isBlank() || "anonymousUser".equalsIgnoreCase(username)) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
return userService
|
||||
.findByUsernameIgnoreCase(username)
|
||||
.orElseThrow(
|
||||
() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unknown user"));
|
||||
}
|
||||
}
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Records per-key usage off the request thread. Kept a separate bean so the {@code @Async} proxy is
|
||||
* honoured (a self-invocation from the resolver would run inline). Best-effort: never fails a
|
||||
* request. The actual writes go through {@link ApiKeyUsageWriter} so each step commits in its own
|
||||
* transaction and a first-write race can't drop a count.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ApiKeyUsageRecorder {
|
||||
|
||||
private final ApiKeyUsageWriter writer;
|
||||
|
||||
/** Bump today's tally for the key and stamp last-used. */
|
||||
@Async("auditExecutor")
|
||||
public void record(Long apiKeyId) {
|
||||
if (apiKeyId == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
long epochDay = Instant.now().atZone(ZoneOffset.UTC).toLocalDate().toEpochDay();
|
||||
// First writer of the day inserts the row; everyone else (and the loser of an insert
|
||||
// race) increments. Separate transactions mean a unique-key clash never rolls back an
|
||||
// already-counted request.
|
||||
if (writer.increment(apiKeyId, epochDay) == 0
|
||||
&& !firstUseInserted(apiKeyId, epochDay)) {
|
||||
writer.increment(apiKeyId, epochDay);
|
||||
}
|
||||
writer.stampLastUsed(apiKeyId);
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to record API key usage for id={}", apiKeyId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether we inserted the day's first row. A lost insert race can surface either as a {@code
|
||||
* false} return or - when the failed flush marked the REQUIRES_NEW transaction rollback-only,
|
||||
* so its commit throws - as an exception; both mean "someone else inserted", so we treat any
|
||||
* failure as not-inserted and let the caller fall back to an increment rather than dropping the
|
||||
* count.
|
||||
*/
|
||||
private boolean firstUseInserted(Long apiKeyId, long epochDay) {
|
||||
try {
|
||||
return writer.tryInsertFirstUse(apiKeyId, epochDay);
|
||||
} catch (RuntimeException raced) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.security.model.ApiKeyDailyUsage;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyRepository;
|
||||
|
||||
/**
|
||||
* Per-step transactional writes for {@link ApiKeyUsageRecorder}. Each method runs in its own
|
||||
* ({@code REQUIRES_NEW}) transaction so a unique-key clash when two requests race to insert the
|
||||
* day's first row rolls back only that failed insert - never an already-counted request or the
|
||||
* last-used stamp.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
class ApiKeyUsageWriter {
|
||||
|
||||
private final ApiKeyRepository apiKeyRepository;
|
||||
private final ApiKeyDailyUsageRepository usageRepository;
|
||||
|
||||
/** Bump today's tally if the row already exists; returns rows updated (0 if none yet). */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public int increment(Long apiKeyId, long epochDay) {
|
||||
return usageRepository.incrementIfPresent(apiKeyId, epochDay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert today's row with a count of 1. Flushes so a concurrent first-write's unique-key clash
|
||||
* surfaces here (returning false) instead of at commit; the caller then increments instead.
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public boolean tryInsertFirstUse(Long apiKeyId, long epochDay) {
|
||||
try {
|
||||
usageRepository.saveAndFlush(new ApiKeyDailyUsage(apiKeyId, epochDay, 1));
|
||||
return true;
|
||||
} catch (DataIntegrityViolationException raced) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void stampLastUsed(Long apiKeyId) {
|
||||
apiKeyRepository
|
||||
.findById(apiKeyId)
|
||||
.ifPresent(
|
||||
key -> {
|
||||
key.setLastUsedAt(Instant.now());
|
||||
apiKeyRepository.save(key);
|
||||
});
|
||||
}
|
||||
}
|
||||
+10
-16
@@ -95,7 +95,6 @@ public class UserService implements UserServiceInterface {
|
||||
private final ResourceGrantRepository resourceGrantRepository;
|
||||
private final IntegrationConfigRepository integrationConfigRepository;
|
||||
private final TeamMembershipService teamMembershipService;
|
||||
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
|
||||
@Transactional
|
||||
public void processSSOPostLogin(
|
||||
@@ -148,16 +147,15 @@ public class UserService implements UserServiceInterface {
|
||||
}
|
||||
|
||||
public Authentication getAuthentication(String apiKey) {
|
||||
// Resolve through the shared service (multi-key table, then the legacy per-user column).
|
||||
// The key runs as its owner with the owner's authorities.
|
||||
var resolved =
|
||||
apiKeyAuthenticationService
|
||||
.authenticate(apiKey)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("API key is not valid"));
|
||||
return new UsernamePasswordAuthenticationToken(
|
||||
resolved.user(), // principal
|
||||
null, // credentials (we don't expose the password or API key here)
|
||||
resolved.authorities()); // the owner's authorities
|
||||
Optional<User> user = getUserByApiKey(apiKey);
|
||||
if (user.isEmpty()) {
|
||||
throw new UsernameNotFoundException("API key is not valid");
|
||||
}
|
||||
// Convert the user into an Authentication object
|
||||
return new UsernamePasswordAuthenticationToken( // principal (typically the user)
|
||||
user, // credentials (we don't expose the password or API key here)
|
||||
null, // user's authorities (roles/permissions)
|
||||
getAuthorities(user.get()));
|
||||
}
|
||||
|
||||
private Collection<? extends GrantedAuthority> getAuthorities(User user) {
|
||||
@@ -175,9 +173,6 @@ public class UserService implements UserServiceInterface {
|
||||
|
||||
public User addApiKeyToUser(String username) {
|
||||
Optional<User> userOpt = findByUsernameIgnoreCase(username);
|
||||
// Rotating/regenerating the legacy key must also revoke its migrated api_keys shadow row,
|
||||
// otherwise the old secret keeps authenticating (it resolves from api_keys first).
|
||||
userOpt.map(User::getApiKey).ifPresent(apiKeyAuthenticationService::revokeMigratedKey);
|
||||
User user = saveUser(userOpt, generateApiKey());
|
||||
try {
|
||||
databaseService.exportDatabase();
|
||||
@@ -225,8 +220,7 @@ public class UserService implements UserServiceInterface {
|
||||
}
|
||||
|
||||
public Optional<User> getUserByApiKey(String apiKey) {
|
||||
// Resolves the multi-key api_keys table first, then the legacy per-user column.
|
||||
return apiKeyAuthenticationService.resolveUser(apiKey);
|
||||
return userRepository.findByApiKey(apiKey);
|
||||
}
|
||||
|
||||
public Optional<User> loadUserByApiKey(String apiKey) {
|
||||
|
||||
+4
-9
@@ -65,13 +65,12 @@ public class PortalDocumentsService {
|
||||
// "API". The automation marker distinguishes a policy-run step from real API traffic.
|
||||
boolean automation = isAutomation(data);
|
||||
String policyName = asString(data.get("policyName"));
|
||||
String origin = asString(data.get("__origin"));
|
||||
String source =
|
||||
automation
|
||||
? (policyName != null && !policyName.isBlank()
|
||||
? "Policy: " + policyName
|
||||
: "Policy automation")
|
||||
: sourceLabel(origin, asString(data.get("__apiKeyLabel")));
|
||||
: sourceLabel(asString(data.get("__origin")));
|
||||
String product = automation ? "Automation" : productLabel(source);
|
||||
String action = prettyTool(path);
|
||||
boolean failed = isFailure(data);
|
||||
@@ -174,12 +173,9 @@ public class PortalDocumentsService {
|
||||
return code instanceof Number n && n.intValue() >= 400;
|
||||
}
|
||||
|
||||
private static String sourceLabel(String origin, String apiKeyLabel) {
|
||||
private static String sourceLabel(String origin) {
|
||||
if ("API".equals(origin)) {
|
||||
// Attribute to the specific named key when known, else the generic API channel.
|
||||
return apiKeyLabel != null && !apiKeyLabel.isBlank()
|
||||
? "API key · " + apiKeyLabel
|
||||
: "API integration";
|
||||
return "API integration";
|
||||
}
|
||||
if ("SYSTEM".equals(origin)) {
|
||||
return "System";
|
||||
@@ -188,8 +184,7 @@ public class PortalDocumentsService {
|
||||
}
|
||||
|
||||
private static String productLabel(String source) {
|
||||
// Covers both the generic "API integration" and per-key "API key · <label>" sources.
|
||||
return source != null && source.startsWith("API") ? "API" : "Editor";
|
||||
return "API integration".equals(source) ? "API" : "Editor";
|
||||
}
|
||||
|
||||
private static boolean isAutomation(Map<String, Object> data) {
|
||||
|
||||
+1
-4
@@ -29,7 +29,6 @@ import stirling.software.proprietary.security.database.repository.PersistentLogi
|
||||
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
|
||||
import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
|
||||
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
|
||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
@@ -161,9 +160,7 @@ class SecurityConfigurationTest {
|
||||
@Test
|
||||
@DisplayName("jwtAuthenticationFilter is created")
|
||||
void jwtAuthenticationFilter() {
|
||||
JwtAuthenticationFilter filter =
|
||||
newConfig(true)
|
||||
.jwtAuthenticationFilter(mock(ApiKeyAuthenticationService.class));
|
||||
JwtAuthenticationFilter filter = newConfig(true).jwtAuthenticationFilter();
|
||||
assertThat(filter).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
+3
-14
@@ -29,8 +29,6 @@ import org.springframework.security.core.session.SessionInformation;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
|
||||
@@ -39,7 +37,6 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
class UserAuthenticationFilterTest {
|
||||
|
||||
@Mock private UserService userService;
|
||||
@Mock private ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
@Mock private SessionPersistentRegistry sessionPersistentRegistry;
|
||||
|
||||
private ApplicationProperties.Security securityProp;
|
||||
@@ -63,11 +60,7 @@ class UserAuthenticationFilterTest {
|
||||
|
||||
private UserAuthenticationFilter filter(boolean loginEnabled) {
|
||||
return new UserAuthenticationFilter(
|
||||
securityProp,
|
||||
userService,
|
||||
apiKeyAuthenticationService,
|
||||
sessionPersistentRegistry,
|
||||
loginEnabled);
|
||||
securityProp, userService, sessionPersistentRegistry, loginEnabled);
|
||||
}
|
||||
|
||||
private static User enabledUser(String username) {
|
||||
@@ -106,11 +99,7 @@ class UserAuthenticationFilterTest {
|
||||
User user = enabledUser("api-user");
|
||||
user.addAuthority(
|
||||
new stirling.software.proprietary.security.model.Authority("ROLE_USER", user));
|
||||
when(apiKeyAuthenticationService.authenticate("good-key"))
|
||||
.thenReturn(
|
||||
Optional.of(
|
||||
new ApiKeyAuthentication(
|
||||
user, "Prod (sk_demo0000)", user.getAuthorities())));
|
||||
when(userService.getUserByApiKey("good-key")).thenReturn(Optional.of(user));
|
||||
when(userService.usernameExistsIgnoreCase("api-user")).thenReturn(true);
|
||||
when(userService.isUserDisabled("api-user")).thenReturn(false);
|
||||
when(sessionPersistentRegistry.getAllSessions(any(), anyBoolean()))
|
||||
@@ -128,7 +117,7 @@ class UserAuthenticationFilterTest {
|
||||
void invalidApiKeyRejected() throws Exception {
|
||||
request.setRequestURI("/api/v1/some/protected");
|
||||
request.addHeader("X-API-KEY", "bad-key");
|
||||
when(apiKeyAuthenticationService.authenticate("bad-key")).thenReturn(Optional.empty());
|
||||
when(userService.getUserByApiKey("bad-key")).thenReturn(Optional.empty());
|
||||
|
||||
filter(true).doFilter(request, response, filterChain);
|
||||
|
||||
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
package stirling.software.proprietary.security.filter;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.mock.web.MockFilterChain;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("UserBasedRateLimitingFilter")
|
||||
class UserBasedRateLimitingFilterTest {
|
||||
|
||||
@AfterEach
|
||||
void clear() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
private void authenticateAs(String username) {
|
||||
User u = new User();
|
||||
u.setUsername(username);
|
||||
u.setEnabled(true);
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(
|
||||
new ApiKeyAuthenticationToken(
|
||||
u,
|
||||
"irrelevant",
|
||||
List.of(new SimpleGrantedAuthority(Role.USER.getRoleId()))));
|
||||
}
|
||||
|
||||
private long remainingAfterApiPost(UserBasedRateLimitingFilter filter, String apiKey)
|
||||
throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/x");
|
||||
req.addHeader("X-API-KEY", apiKey);
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
filter.doFilter(req, res, new MockFilterChain());
|
||||
return Long.parseLong(res.getHeader("X-Rate-Limit-Remaining"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("all of a user's keys share ONE bucket - minting keys can't multiply the quota")
|
||||
void keysShareOnePerUserBucket() throws Exception {
|
||||
UserBasedRateLimitingFilter filter = new UserBasedRateLimitingFilter(true);
|
||||
authenticateAs("alice");
|
||||
|
||||
long afterKeyA = remainingAfterApiPost(filter, "key-A");
|
||||
long afterKeyB = remainingAfterApiPost(filter, "key-B"); // different key, same user
|
||||
|
||||
// The second (different) key drew from the SAME per-user bucket, so remaining fell by one.
|
||||
// If it were keyed per-API-key, both would report the same remaining.
|
||||
assertThat(afterKeyB).isEqualTo(afterKeyA - 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-POST requests are not rate limited")
|
||||
void nonPostPassesThrough() throws Exception {
|
||||
UserBasedRateLimitingFilter filter = new UserBasedRateLimitingFilter(true);
|
||||
authenticateAs("alice");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/general/x");
|
||||
req.addHeader("X-API-KEY", "key-A");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
assertThat(res.getHeader("X-Rate-Limit-Remaining")).isNull();
|
||||
assertThat(chain.getRequest()).isNotNull(); // passed down the chain
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rate limiting disabled: passes through untouched")
|
||||
void disabledPassesThrough() throws Exception {
|
||||
UserBasedRateLimitingFilter filter = new UserBasedRateLimitingFilter(false);
|
||||
authenticateAs("alice");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/x");
|
||||
req.addHeader("X-API-KEY", "key-A");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(req, res, new MockFilterChain());
|
||||
|
||||
assertThat(res.getHeader("X-Rate-Limit-Remaining")).isNull();
|
||||
}
|
||||
}
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.ApiKey;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyRepository;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("ApiKeyAuthenticationService")
|
||||
class ApiKeyAuthenticationServiceTest {
|
||||
|
||||
@Mock private ApiKeyRepository apiKeyRepository;
|
||||
@Mock private ApiKeyUsageRecorder usageRecorder;
|
||||
@Mock private UserRepository userRepository;
|
||||
@InjectMocks private ApiKeyAuthenticationService service;
|
||||
|
||||
private User user(long id, boolean enabled) {
|
||||
User u = new User();
|
||||
u.setId(id);
|
||||
u.setUsername("user" + id);
|
||||
u.setEnabled(enabled);
|
||||
return u;
|
||||
}
|
||||
|
||||
private ApiKey key(long id, long ownerId, boolean enabled, Instant revoked) {
|
||||
return ApiKey.builder()
|
||||
.id(id)
|
||||
.name("Production ingest")
|
||||
.keyHash(ApiKeyHasher.hash("raw-" + id))
|
||||
.prefix("sk_demo0000")
|
||||
.ownerUserId(ownerId)
|
||||
.enabled(enabled)
|
||||
.revokedAt(revoked)
|
||||
.createdAt(Instant.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolves an active multi-key to its owner and records usage")
|
||||
void resolvesActiveKey() {
|
||||
String raw = "raw-1";
|
||||
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
|
||||
.thenReturn(Optional.of(key(1, 7, true, null)));
|
||||
when(userRepository.findById(7L)).thenReturn(Optional.of(user(7, true)));
|
||||
|
||||
var result = service.authenticate(raw);
|
||||
|
||||
assertThat(result).isPresent();
|
||||
assertThat(result.get().user().getId()).isEqualTo(7L);
|
||||
assertThat(result.get().auditLabel()).isEqualTo("Production ingest (sk_demo0000)");
|
||||
verify(usageRecorder).record(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a revoked key without recording usage")
|
||||
void rejectsRevokedKey() {
|
||||
String raw = "raw-2";
|
||||
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
|
||||
.thenReturn(Optional.of(key(2, 7, true, Instant.now())));
|
||||
|
||||
assertThat(service.authenticate(raw)).isEmpty();
|
||||
verifyNoInteractions(usageRecorder);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a key whose owner is disabled")
|
||||
void rejectsDisabledOwner() {
|
||||
String raw = "raw-3";
|
||||
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
|
||||
.thenReturn(Optional.of(key(3, 8, true, null)));
|
||||
when(userRepository.findById(8L)).thenReturn(Optional.of(user(8, false)));
|
||||
|
||||
assertThat(service.authenticate(raw)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to the legacy per-user column, with no per-key label")
|
||||
void legacyFallback() {
|
||||
String raw = "legacy-key";
|
||||
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw))).thenReturn(Optional.empty());
|
||||
when(userRepository.findByApiKey(raw)).thenReturn(Optional.of(user(9, true)));
|
||||
|
||||
var result = service.authenticate(raw);
|
||||
|
||||
assertThat(result).isPresent();
|
||||
assertThat(result.get().user().getId()).isEqualTo(9L);
|
||||
assertThat(result.get().auditLabel()).isNull();
|
||||
verifyNoInteractions(usageRecorder);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank keys resolve to nothing")
|
||||
void blankKey() {
|
||||
assertThat(service.authenticate(" ")).isEmpty();
|
||||
assertThat(service.resolveUser(null)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a key authenticates with its owner's authorities (owner acts as self)")
|
||||
void keyKeepsOwnerAuthorities() {
|
||||
String raw = "raw-6";
|
||||
User owner = user(8, true);
|
||||
owner.addAuthority(new Authority(Role.ADMIN.getRoleId(), owner));
|
||||
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
|
||||
.thenReturn(Optional.of(key(6, 8, true, null)));
|
||||
when(userRepository.findById(8L)).thenReturn(Optional.of(owner));
|
||||
|
||||
var result = service.authenticate(raw);
|
||||
|
||||
List<String> auths =
|
||||
result.get().authorities().stream().map(GrantedAuthority::getAuthority).toList();
|
||||
assertThat(auths).contains(Role.ADMIN.getRoleId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("revokeMigratedKey disables the shadow row so a rotated legacy key stops working")
|
||||
void revokeMigratedKeyRevokesRow() {
|
||||
String raw = "raw-9";
|
||||
ApiKey shadow = key(9, 1, true, null);
|
||||
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
|
||||
.thenReturn(Optional.of(shadow));
|
||||
|
||||
service.revokeMigratedKey(raw);
|
||||
|
||||
assertThat(shadow.isEnabled()).isFalse();
|
||||
assertThat(shadow.getRevokedAt()).isNotNull();
|
||||
verify(apiKeyRepository).save(shadow);
|
||||
}
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@DisplayName("ApiKeyHasher")
|
||||
class ApiKeyHasherTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("generated keys are unique, prefixed, and hash deterministically")
|
||||
void generateAndHash() {
|
||||
String a = ApiKeyHasher.generateRawKey();
|
||||
String b = ApiKeyHasher.generateRawKey();
|
||||
|
||||
assertThat(a).startsWith("sk_").isNotEqualTo(b);
|
||||
// Same input hashes the same; SHA-256 hex is 64 chars.
|
||||
assertThat(ApiKeyHasher.hash(a)).isEqualTo(ApiKeyHasher.hash(a)).hasSize(64);
|
||||
assertThat(ApiKeyHasher.hash(a)).isNotEqualTo(ApiKeyHasher.hash(b));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hash never returns the raw key")
|
||||
void hashHidesRaw() {
|
||||
String raw = ApiKeyHasher.generateRawKey();
|
||||
assertThat(ApiKeyHasher.hash(raw)).isNotEqualTo(raw);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("display prefix is a short non-secret leading fragment")
|
||||
void displayPrefix() {
|
||||
String raw = ApiKeyHasher.generateRawKey();
|
||||
String prefix = ApiKeyHasher.displayPrefix(raw);
|
||||
assertThat(prefix).hasSize(11).startsWith("sk_");
|
||||
assertThat(raw).startsWith(prefix);
|
||||
}
|
||||
}
|
||||
-198
@@ -1,198 +0,0 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.proprietary.model.api.apikey.CreateApiKeyRequest;
|
||||
import stirling.software.proprietary.model.api.apikey.CreatedApiKeyDto;
|
||||
import stirling.software.proprietary.model.api.apikey.PortalApiKeysResponse;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.ApiKey;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyRepository;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("ApiKeyManagementService")
|
||||
class ApiKeyManagementServiceTest {
|
||||
|
||||
@Mock private ApiKeyRepository apiKeyRepository;
|
||||
@Mock private ApiKeyDailyUsageRepository usageRepository;
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private UserService userService;
|
||||
@Mock private ApiKeyLegacyMigrator legacyMigrator;
|
||||
@InjectMocks private ApiKeyManagementService service;
|
||||
|
||||
private User caller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
caller = new User();
|
||||
caller.setId(1L);
|
||||
caller.setUsername("alice");
|
||||
lenient().when(userService.getCurrentUsername()).thenReturn("alice");
|
||||
lenient()
|
||||
.when(userService.findByUsernameIgnoreCase("alice"))
|
||||
.thenReturn(Optional.of(caller));
|
||||
lenient().when(apiKeyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
lenient().when(usageRepository.countForDayByIds(any(), anyLong())).thenReturn(List.of());
|
||||
lenient().when(usageRepository.sumSinceByIds(any(), anyLong())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
private ApiKey personalKey(long id, long ownerId) {
|
||||
return ApiKey.builder()
|
||||
.id(id)
|
||||
.name("Key " + id)
|
||||
.keyHash("hash" + id)
|
||||
.prefix("sk_demo0000")
|
||||
.ownerUserId(ownerId)
|
||||
.enabled(true)
|
||||
.createdAt(Instant.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
// ---- migration safety ---------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("an existing legacy key migrates to an owner-only row")
|
||||
void legacyKeyMigratesAsPersonal() {
|
||||
caller.setApiKey("legacy-raw-key");
|
||||
when(apiKeyRepository.existsByKeyHash(ApiKeyHasher.hash("legacy-raw-key")))
|
||||
.thenReturn(false);
|
||||
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L)).thenReturn(List.of());
|
||||
|
||||
service.listVisibleKeys();
|
||||
|
||||
// Migration insert is isolated in its own transaction (ApiKeyLegacyMigrator).
|
||||
ArgumentCaptor<ApiKey> saved = ArgumentCaptor.forClass(ApiKey.class);
|
||||
verify(legacyMigrator).insertMigratedKey(saved.capture());
|
||||
ApiKey migrated = saved.getValue();
|
||||
assertThat(migrated.getOwnerUserId()).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("migration is idempotent - an already-migrated legacy key is not re-saved")
|
||||
void legacyKeyMigrationIdempotent() {
|
||||
caller.setApiKey("legacy-raw-key");
|
||||
when(apiKeyRepository.existsByKeyHash(ApiKeyHasher.hash("legacy-raw-key")))
|
||||
.thenReturn(true);
|
||||
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L)).thenReturn(List.of());
|
||||
|
||||
service.listVisibleKeys();
|
||||
|
||||
verify(legacyMigrator, never()).insertMigratedKey(any());
|
||||
}
|
||||
|
||||
// ---- personal isolation -------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("listing scopes keys to the caller by owner id")
|
||||
void personalKeysScopedToOwner() {
|
||||
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L))
|
||||
.thenReturn(List.of(personalKey(10, 1L)));
|
||||
|
||||
PortalApiKeysResponse res = service.listVisibleKeys();
|
||||
|
||||
assertThat(res.keys()).singleElement().satisfies(k -> assertThat(k.id()).isEqualTo("10"));
|
||||
// Isolation: the query is keyed by the caller's id, never a broad scan.
|
||||
verify(apiKeyRepository).findByOwnerUserIdOrderByCreatedAtDesc(1L);
|
||||
}
|
||||
|
||||
// ---- creation -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("a user creates a personal key and gets a one-time secret")
|
||||
void createPersonalKey() {
|
||||
CreatedApiKeyDto created = service.createKey(new CreateApiKeyRequest("My key"));
|
||||
|
||||
assertThat(created.secret()).startsWith("sk_");
|
||||
ArgumentCaptor<ApiKey> saved = ArgumentCaptor.forClass(ApiKey.class);
|
||||
verify(apiKeyRepository).save(saved.capture());
|
||||
assertThat(saved.getValue().getOwnerUserId()).isEqualTo(1L);
|
||||
assertThat(saved.getValue().getName()).isEqualTo("My key");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects an over-long key name")
|
||||
void rejectsLongName() {
|
||||
String longName = "a".repeat(101);
|
||||
assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest(longName)))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("characters or fewer");
|
||||
verify(apiKeyRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a blank key name")
|
||||
void rejectsBlankName() {
|
||||
assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest(" ")))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("required");
|
||||
verify(apiKeyRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects creating a key past the per-user active-key cap")
|
||||
void rejectsPastActiveKeyCap() {
|
||||
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L))
|
||||
.thenReturn(java.util.Collections.nCopies(50, personalKey(100, 1L)));
|
||||
|
||||
assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest("One too many")))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
verify(apiKeyRepository, never()).save(any());
|
||||
}
|
||||
|
||||
// ---- revocation ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("owner revokes their key and the legacy column is cleared")
|
||||
void revokePersonalClearsLegacy() {
|
||||
caller.setApiKey("legacy-raw-key");
|
||||
ApiKey legacyRow = personalKey(30, 1L);
|
||||
legacyRow.setKeyHash(ApiKeyHasher.hash("legacy-raw-key"));
|
||||
when(apiKeyRepository.findById(30L)).thenReturn(Optional.of(legacyRow));
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(caller));
|
||||
|
||||
service.revokeKey(30L);
|
||||
|
||||
assertThat(legacyRow.isEnabled()).isFalse();
|
||||
assertThat(legacyRow.getRevokedAt()).isNotNull();
|
||||
assertThat(caller.getApiKey()).isNull();
|
||||
verify(userRepository).save(caller);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"a non-owner cannot revoke someone else's key (404, not 403, so ids can't be probed)")
|
||||
void revokeForeignKeyForbidden() {
|
||||
when(apiKeyRepository.findById(31L)).thenReturn(Optional.of(personalKey(31, 999L)));
|
||||
|
||||
assertThatThrownBy(() -> service.revokeKey(31L))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(404));
|
||||
verify(apiKeyRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
/**
|
||||
* Unit tests for the increment/insert/increment race protocol. {@code @Async} has no proxy in a
|
||||
* plain Mockito test, so {@code record()} runs inline and is directly testable.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("ApiKeyUsageRecorder")
|
||||
class ApiKeyUsageRecorderTest {
|
||||
|
||||
private static final long KEY = 7L;
|
||||
|
||||
@Mock private ApiKeyUsageWriter writer;
|
||||
@InjectMocks private ApiKeyUsageRecorder recorder;
|
||||
|
||||
@Test
|
||||
@DisplayName("a null key id is a no-op")
|
||||
void nullIdIsNoOp() {
|
||||
recorder.record(null);
|
||||
verifyNoInteractions(writer);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("row already exists: one increment, never inserts")
|
||||
void rowExistsFastPath() {
|
||||
when(writer.increment(eq(KEY), anyLong())).thenReturn(1);
|
||||
|
||||
recorder.record(KEY);
|
||||
|
||||
verify(writer, times(1)).increment(eq(KEY), anyLong());
|
||||
verify(writer, never()).tryInsertFirstUse(anyLong(), anyLong());
|
||||
verify(writer).stampLastUsed(KEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("first writer of the day: increment misses, insert wins, no second increment")
|
||||
void firstWriterInserts() {
|
||||
when(writer.increment(eq(KEY), anyLong())).thenReturn(0);
|
||||
when(writer.tryInsertFirstUse(eq(KEY), anyLong())).thenReturn(true);
|
||||
|
||||
recorder.record(KEY);
|
||||
|
||||
verify(writer, times(1)).increment(eq(KEY), anyLong());
|
||||
verify(writer).tryInsertFirstUse(eq(KEY), anyLong());
|
||||
verify(writer).stampLastUsed(KEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"lost the insert race: falls back to a second increment so the count is not dropped")
|
||||
void lostInsertRaceReincrements() {
|
||||
when(writer.increment(eq(KEY), anyLong())).thenReturn(0);
|
||||
when(writer.tryInsertFirstUse(eq(KEY), anyLong())).thenReturn(false);
|
||||
|
||||
recorder.record(KEY);
|
||||
|
||||
verify(writer, times(2)).increment(eq(KEY), anyLong());
|
||||
verify(writer).stampLastUsed(KEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("insert throws (rollback-only commit): still re-increments, count not dropped")
|
||||
void insertThrowsStillReincrements() {
|
||||
when(writer.increment(eq(KEY), anyLong())).thenReturn(0);
|
||||
when(writer.tryInsertFirstUse(eq(KEY), anyLong()))
|
||||
.thenThrow(new RuntimeException("UnexpectedRollbackException"));
|
||||
|
||||
recorder.record(KEY);
|
||||
|
||||
verify(writer, times(2)).increment(eq(KEY), anyLong());
|
||||
verify(writer).stampLastUsed(KEY);
|
||||
}
|
||||
}
|
||||
+2
-5
@@ -41,7 +41,6 @@ import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.storage.repository.FileShareAccessRepository;
|
||||
import stirling.software.proprietary.storage.repository.FileShareRepository;
|
||||
@@ -77,7 +76,6 @@ class UserServiceMoreTest {
|
||||
integrationConfigRepository;
|
||||
|
||||
@Mock private TeamMembershipService teamMembershipService;
|
||||
@Mock private ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
|
||||
@InjectMocks private UserService userService;
|
||||
|
||||
@@ -101,8 +99,7 @@ class UserServiceMoreTest {
|
||||
void getAuthenticationValid() {
|
||||
User u = user("api");
|
||||
u.addAuthority(new Authority("ROLE_USER", u));
|
||||
when(apiKeyAuthenticationService.authenticate("k"))
|
||||
.thenReturn(Optional.of(new ApiKeyAuthentication(u, null, u.getAuthorities())));
|
||||
when(userRepository.findByApiKey("k")).thenReturn(Optional.of(u));
|
||||
|
||||
assertThat(userService.getAuthentication("k")).isNotNull();
|
||||
}
|
||||
@@ -110,7 +107,7 @@ class UserServiceMoreTest {
|
||||
@Test
|
||||
@DisplayName("getAuthentication throws when key is unknown")
|
||||
void getAuthenticationInvalid() {
|
||||
when(apiKeyAuthenticationService.authenticate("bad")).thenReturn(Optional.empty());
|
||||
when(userRepository.findByApiKey("bad")).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> userService.getAuthentication("bad"))
|
||||
.isInstanceOf(UsernameNotFoundException.class);
|
||||
|
||||
-22
@@ -71,7 +71,6 @@ class UserServiceTest {
|
||||
integrationConfigRepository;
|
||||
|
||||
@Mock private TeamMembershipService teamMembershipService;
|
||||
@Mock private ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
|
||||
@Spy @InjectMocks private UserService userService;
|
||||
|
||||
@@ -197,27 +196,6 @@ class UserServiceTest {
|
||||
verify(userRepository).save(user);
|
||||
}
|
||||
|
||||
@Test
|
||||
void addApiKeyToUserRevokesOldMigratedShadowRow() {
|
||||
User user = new User();
|
||||
user.setUsername("user");
|
||||
user.setApiKey("old-secret");
|
||||
when(userRepository.findByUsernameIgnoreCase("user")).thenReturn(Optional.of(user));
|
||||
when(userRepository.findByApiKey(any())).thenReturn(Optional.empty());
|
||||
when(userRepository.save(any(User.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
User updated = userService.addApiKeyToUser("user");
|
||||
|
||||
// Rotating a legacy key must revoke its migrated api_keys shadow row with the OLD secret,
|
||||
// and do so before the new key is generated - otherwise the old secret keeps
|
||||
// authenticating.
|
||||
org.mockito.InOrder inOrder = inOrder(apiKeyAuthenticationService, userRepository);
|
||||
inOrder.verify(apiKeyAuthenticationService).revokeMigratedKey("old-secret");
|
||||
inOrder.verify(userRepository).save(user);
|
||||
assertNotEquals("old-secret", updated.getApiKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getApiKeyForUserCreatesWhenMissing() {
|
||||
User user = new User();
|
||||
|
||||
-14
@@ -70,18 +70,4 @@ class PortalDocumentsServiceTest {
|
||||
assertThat(doc.getProduct()).isEqualTo("API");
|
||||
assertThat(doc.getSource()).isEqualTo("API integration");
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiDocumentIsAttributedToItsNamedKey() {
|
||||
PortalReviewDocumentDto doc =
|
||||
onlyDoc(
|
||||
"{\"path\":\"/api/v1/misc/compress-pdf\",\"__origin\":\"API\","
|
||||
+ "\"__apiKeyLabel\":\"Production ingest (sk_demo0000)\","
|
||||
+ "\"files\":[{\"name\":\"a.pdf\",\"type\":\"application/pdf\"}],"
|
||||
+ "\"statusCode\":200}");
|
||||
|
||||
// The specific key label surfaces as the source; product stays "API".
|
||||
assertThat(doc.getProduct()).isEqualTo("API");
|
||||
assertThat(doc.getSource()).isEqualTo("API key · Production ingest (sk_demo0000)");
|
||||
}
|
||||
}
|
||||
|
||||
+5
-20
@@ -18,7 +18,6 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
@@ -44,8 +43,6 @@ import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.saas.model.AmrMethod;
|
||||
@@ -68,7 +65,6 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
|
||||
private final SupabaseUserService supabaseUserService;
|
||||
private final SaasTeamService saasTeamService;
|
||||
private final JwtDecoder jwtDecoder;
|
||||
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
private final AuthenticationEntryPoint authenticationEntryPoint =
|
||||
new BearerTokenAuthenticationEntryPoint();
|
||||
|
||||
@@ -77,14 +73,12 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
|
||||
UserService userService,
|
||||
SupabaseUserService supabaseUserService,
|
||||
SaasTeamService saasTeamService,
|
||||
JwtDecoder jwtDecoder,
|
||||
ApiKeyAuthenticationService apiKeyAuthenticationService) {
|
||||
JwtDecoder jwtDecoder) {
|
||||
this.teamService = teamService;
|
||||
this.userService = userService;
|
||||
this.supabaseUserService = supabaseUserService;
|
||||
this.saasTeamService = saasTeamService;
|
||||
this.jwtDecoder = jwtDecoder;
|
||||
this.apiKeyAuthenticationService = apiKeyAuthenticationService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -92,9 +86,6 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
// Start clean so a pooled thread can't inherit a prior request's API-key label.
|
||||
MDC.remove(ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY);
|
||||
|
||||
if (isStaticResource(request.getContextPath(), request.getRequestURI())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
@@ -415,22 +406,16 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolves the multi-key table then the legacy key, records per-key usage, and yields a
|
||||
// label for the processor's document-source attribution.
|
||||
Optional<ApiKeyAuthentication> resolved = apiKeyAuthenticationService.authenticate(apiKey);
|
||||
if (resolved.isEmpty()) {
|
||||
Optional<User> user = userService.getUserByApiKey(apiKey);
|
||||
if (user.isEmpty()) {
|
||||
throw new InvalidBearerTokenException("Invalid API Key.");
|
||||
}
|
||||
User user = resolved.get().user();
|
||||
|
||||
userService.trackApiKeyFirstUse(user);
|
||||
userService.trackApiKeyFirstUse(user.get());
|
||||
|
||||
ApiKeyAuthenticationToken authToken =
|
||||
new ApiKeyAuthenticationToken(user, apiKey, resolved.get().authorities());
|
||||
new ApiKeyAuthenticationToken(user.get(), apiKey, user.get().getAuthorities());
|
||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||
if (resolved.get().auditLabel() != null) {
|
||||
MDC.put(ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY, resolved.get().auditLabel());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.RequestUriUtils;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.saas.accountlink.DeviceCredentialAuthenticationFilter;
|
||||
@@ -70,7 +69,6 @@ public class SupabaseSecurityConfig {
|
||||
private final SupabaseUserService supabaseUserService;
|
||||
private final SaasTeamService saasTeamService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
|
||||
@Value("${app.supabase.issuer:}")
|
||||
private String issuer;
|
||||
@@ -127,8 +125,7 @@ public class SupabaseSecurityConfig {
|
||||
userService,
|
||||
supabaseUserService,
|
||||
saasTeamService,
|
||||
jwtDecoder,
|
||||
apiKeyAuthenticationService),
|
||||
jwtDecoder),
|
||||
BearerTokenAuthenticationFilter.class)
|
||||
.exceptionHandling(
|
||||
ex ->
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
-- Named, multi-key personal API keys, plus per-key daily usage.
|
||||
-- Idempotent: Hibernate ddl-auto=update may already have created these on some deployments.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
key_hash VARCHAR(64) NOT NULL,
|
||||
prefix VARCHAR(32) NOT NULL,
|
||||
owner_user_id BIGINT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_api_key_hash ON api_keys (key_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_key_owner ON api_keys (owner_user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_key_daily_usage (
|
||||
api_key_id BIGINT NOT NULL,
|
||||
epoch_day BIGINT NOT NULL,
|
||||
count BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (api_key_id, epoch_day)
|
||||
);
|
||||
+2
-16
@@ -58,10 +58,6 @@ class SupabaseAuthenticationFilterMoreTest {
|
||||
@Mock private SaasTeamService saasTeamService;
|
||||
@Mock private JwtDecoder jwtDecoder;
|
||||
|
||||
@Mock
|
||||
private stirling.software.proprietary.security.service.ApiKeyAuthenticationService
|
||||
apiKeyAuthenticationService;
|
||||
|
||||
private SupabaseAuthenticationFilter filter;
|
||||
private MockHttpServletRequest request;
|
||||
private MockHttpServletResponse response;
|
||||
@@ -72,12 +68,7 @@ class SupabaseAuthenticationFilterMoreTest {
|
||||
SecurityContextHolder.clearContext();
|
||||
filter =
|
||||
new SupabaseAuthenticationFilter(
|
||||
teamService,
|
||||
userService,
|
||||
supabaseUserService,
|
||||
saasTeamService,
|
||||
jwtDecoder,
|
||||
apiKeyAuthenticationService);
|
||||
teamService, userService, supabaseUserService, saasTeamService, jwtDecoder);
|
||||
request = new MockHttpServletRequest();
|
||||
response = new MockHttpServletResponse();
|
||||
chain = new MockFilterChain();
|
||||
@@ -243,12 +234,7 @@ class SupabaseAuthenticationFilterMoreTest {
|
||||
@DisplayName("returns true and skips lookup when an api key sets an authenticated context")
|
||||
void apiKeyValidStillAuthenticates() throws Exception {
|
||||
User user = newUser("alice");
|
||||
when(apiKeyAuthenticationService.authenticate("k1"))
|
||||
.thenReturn(
|
||||
Optional.of(
|
||||
new stirling.software.proprietary.security.service
|
||||
.ApiKeyAuthenticationService.ApiKeyAuthentication(
|
||||
user, null, user.getAuthorities())));
|
||||
when(userService.getUserByApiKey("k1")).thenReturn(Optional.of(user));
|
||||
|
||||
request.setRequestURI("/api/v1/something");
|
||||
request.setMethod("POST");
|
||||
|
||||
+3
-17
@@ -48,10 +48,6 @@ class SupabaseAuthenticationFilterTest {
|
||||
@Mock private stirling.software.saas.service.SaasTeamService saasTeamService;
|
||||
@Mock private JwtDecoder jwtDecoder;
|
||||
|
||||
@Mock
|
||||
private stirling.software.proprietary.security.service.ApiKeyAuthenticationService
|
||||
apiKeyAuthenticationService;
|
||||
|
||||
private SupabaseAuthenticationFilter filter;
|
||||
private MockHttpServletRequest request;
|
||||
private MockHttpServletResponse response;
|
||||
@@ -62,12 +58,7 @@ class SupabaseAuthenticationFilterTest {
|
||||
SecurityContextHolder.clearContext();
|
||||
filter =
|
||||
new SupabaseAuthenticationFilter(
|
||||
teamService,
|
||||
userService,
|
||||
supabaseUserService,
|
||||
saasTeamService,
|
||||
jwtDecoder,
|
||||
apiKeyAuthenticationService);
|
||||
teamService, userService, supabaseUserService, saasTeamService, jwtDecoder);
|
||||
request = new MockHttpServletRequest();
|
||||
response = new MockHttpServletResponse();
|
||||
chain = new MockFilterChain();
|
||||
@@ -94,12 +85,7 @@ class SupabaseAuthenticationFilterTest {
|
||||
void apiKeyHeaderPopulatesSecurityContext() throws Exception {
|
||||
User user = newUser("alice");
|
||||
user.setApiKey("api-key-123");
|
||||
when(apiKeyAuthenticationService.authenticate("api-key-123"))
|
||||
.thenReturn(
|
||||
Optional.of(
|
||||
new stirling.software.proprietary.security.service
|
||||
.ApiKeyAuthenticationService.ApiKeyAuthentication(
|
||||
user, null, user.getAuthorities())));
|
||||
when(userService.getUserByApiKey("api-key-123")).thenReturn(Optional.of(user));
|
||||
|
||||
request.setRequestURI("/api/v1/something");
|
||||
request.setMethod("POST");
|
||||
@@ -117,7 +103,7 @@ class SupabaseAuthenticationFilterTest {
|
||||
|
||||
@Test
|
||||
void invalidApiKeyTriggers401() throws Exception {
|
||||
when(apiKeyAuthenticationService.authenticate("nope")).thenReturn(Optional.empty());
|
||||
when(userService.getUserByApiKey("nope")).thenReturn(Optional.empty());
|
||||
|
||||
request.setRequestURI("/api/v1/something");
|
||||
request.setMethod("POST");
|
||||
|
||||
+1
-10
@@ -43,18 +43,9 @@ class SupabaseSecurityConfigMoreTest {
|
||||
@Mock private SupabaseUserService supabaseUserService;
|
||||
@Mock private SaasTeamService saasTeamService;
|
||||
|
||||
@Mock
|
||||
private stirling.software.proprietary.security.service.ApiKeyAuthenticationService
|
||||
apiKeyAuthenticationService;
|
||||
|
||||
private SupabaseSecurityConfig config(ApplicationProperties props) {
|
||||
return new SupabaseSecurityConfig(
|
||||
userService,
|
||||
teamService,
|
||||
supabaseUserService,
|
||||
saasTeamService,
|
||||
props,
|
||||
apiKeyAuthenticationService);
|
||||
userService, teamService, supabaseUserService, saasTeamService, props);
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
||||
-22
@@ -21,7 +21,6 @@ import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.model.TeamMembership;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
@@ -108,27 +107,6 @@ class TeamSecurityExpressionsTest {
|
||||
assertNull(expressions().currentUserTeamId());
|
||||
}
|
||||
|
||||
private User leaderUser() {
|
||||
User leader = new User();
|
||||
leader.setId(USER_ID);
|
||||
Team team = new Team();
|
||||
team.setId(TEAM_ID);
|
||||
leader.setTeam(team);
|
||||
return leader;
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiKeyOfALeaderStillLeads() {
|
||||
// A key acts as the owner; if they lead the team, the key leads.
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(
|
||||
new ApiKeyAuthenticationToken(leaderUser(), "sk_personal", List.of()));
|
||||
when(membershipRepository.findByTeamIdAndUserId(TEAM_ID, USER_ID))
|
||||
.thenReturn(Optional.of(membershipWithRole(TeamRole.LEADER)));
|
||||
|
||||
assertTrue(expressions().isCurrentUserTeamLeader());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unauthenticatedIsNotLeader() {
|
||||
// No authentication set on the context.
|
||||
|
||||
@@ -12,6 +12,8 @@ editor/public/mockServiceWorker.js
|
||||
# Auto-generated OG/social-preview metadata (scripts/generate-og-metadata.mjs); regenerated verbatim.
|
||||
editor/public/og-metadata.json
|
||||
editor/src/core/data/ogImageMap.json
|
||||
# Auto-generated portal docs manifest (scripts/sync-portal-docs.mts); regenerated verbatim.
|
||||
editor/src/portal/generated/docsManifest.json
|
||||
editor/public/pdfjs*/
|
||||
editor/public/js/thirdParty/
|
||||
editor/public/css/cookieconsent.css
|
||||
|
||||
@@ -6603,6 +6603,10 @@ title = "No components available"
|
||||
description = "GA components are available on Pay-as-you-go; a few Beta components are enterprise-only. Locked cards show an upgrade nudge."
|
||||
title = "Some components need a paid plan"
|
||||
|
||||
[portal.docs]
|
||||
browse = "Browse docs"
|
||||
viewSource = "View source on GitHub"
|
||||
|
||||
[portal.docs.authentication]
|
||||
codeCaption = "every request"
|
||||
eyebrow = "GETTING STARTED"
|
||||
@@ -6688,11 +6692,19 @@ title = "Official SDKs"
|
||||
beta = "Beta"
|
||||
deprecated = "Deprecated"
|
||||
|
||||
[portal.docs.search]
|
||||
empty = "No matching docs"
|
||||
placeholder = "Search docs"
|
||||
results = "{{count}} results"
|
||||
|
||||
[portal.docs.skills]
|
||||
eyebrow = "SKILLS"
|
||||
lead = "Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached."
|
||||
title = "Agent skills"
|
||||
|
||||
[portal.docs.toc]
|
||||
title = "On this page"
|
||||
|
||||
[portal.docs.webhooks]
|
||||
codeCaption = "document.processed"
|
||||
eyebrow = "API REFERENCE"
|
||||
@@ -7246,7 +7258,7 @@ storage = "Storage"
|
||||
[portal.nav]
|
||||
agent-builder = "Agent Builder"
|
||||
components = "Components"
|
||||
docs = "Developer Docs"
|
||||
docs = "Documentation"
|
||||
documents = "Documents"
|
||||
editor = "Editor"
|
||||
home = "Home"
|
||||
|
||||
@@ -6563,6 +6563,10 @@ bucket = "Bucket"
|
||||
name = "Name"
|
||||
region = "Region"
|
||||
|
||||
[portal.docs]
|
||||
browse = "Browse docs"
|
||||
viewSource = "View source on GitHub"
|
||||
|
||||
[portal.docs.authentication]
|
||||
codeCaption = "every request"
|
||||
eyebrow = "GETTING STARTED"
|
||||
@@ -6648,11 +6652,19 @@ title = "Official SDKs"
|
||||
beta = "Beta"
|
||||
deprecated = "Deprecated"
|
||||
|
||||
[portal.docs.search]
|
||||
empty = "No matching docs"
|
||||
placeholder = "Search docs"
|
||||
results = "{{count}} results"
|
||||
|
||||
[portal.docs.skills]
|
||||
eyebrow = "SKILLS"
|
||||
lead = "Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached."
|
||||
title = "Agent skills"
|
||||
|
||||
[portal.docs.toc]
|
||||
title = "On this page"
|
||||
|
||||
[portal.docs.webhooks]
|
||||
codeCaption = "document.processed"
|
||||
eyebrow = "API REFERENCE"
|
||||
@@ -6951,31 +6963,31 @@ subtitle = "Deployments, credentials, security posture, storage, and the audit t
|
||||
title = "Infrastructure"
|
||||
|
||||
# Fixed-enum label maps rendered via t(MAP[value]) in the infrastructure tabs.
|
||||
[portal.infrastructure.apiKeyPermission]
|
||||
admin = "Admin"
|
||||
read = "Read"
|
||||
write = "Write"
|
||||
|
||||
[portal.infrastructure.apiKeys]
|
||||
createKey = "Create key"
|
||||
heading = "API keys"
|
||||
subheading = "Personal credentials, each with its own usage tracking."
|
||||
subheading = "Scoped credentials with per-key rate limits, permissions, and IP allowlists."
|
||||
|
||||
[portal.infrastructure.apiKeys.card]
|
||||
allowedIps = "Allowed IPs"
|
||||
anyIp = "Any IP (no allowlist)"
|
||||
created = "Created"
|
||||
lastUsed = "Last used"
|
||||
revoke = "Revoke key"
|
||||
permissions = "Permissions"
|
||||
rateLimit = "Rate limit"
|
||||
rateLimitValue = "{{value}} req/min"
|
||||
usageMonth = "Usage this month"
|
||||
usageToday = "Usage today"
|
||||
|
||||
[portal.infrastructure.apiKeys.empty]
|
||||
description = "Create a key to start calling the Stirling API."
|
||||
description = "Create a scoped key to start calling the Stirling API."
|
||||
title = "No API keys yet"
|
||||
|
||||
[portal.infrastructure.apiKeys.error]
|
||||
load = "Couldn't load your API keys. Please try again."
|
||||
|
||||
[portal.infrastructure.apiKeys.revoke]
|
||||
body = "Revoke \"{{name}}\"? Any integration still using this key will immediately start receiving 401 errors. This can't be undone."
|
||||
cancel = "Cancel"
|
||||
confirm = "Revoke key"
|
||||
title = "Revoke API key"
|
||||
|
||||
[portal.infrastructure.attestationLabel]
|
||||
attested = "Attested"
|
||||
inScope = "In scope"
|
||||
@@ -7061,11 +7073,14 @@ notStarted = "Not started"
|
||||
cancel = "Cancel"
|
||||
createKey = "Create key"
|
||||
done = "Done"
|
||||
ipAllowlistHelper = "Comma-separated CIDR ranges. Leave blank to allow any IP."
|
||||
ipAllowlistLabel = "IP allowlist"
|
||||
keyNameLabel = "Key name"
|
||||
keyNamePlaceholder = "e.g. Production ingest"
|
||||
keyNamePlaceholder = "e.g. Production · ingest"
|
||||
permissionsLabel = "Permissions"
|
||||
secretKeyCaption = "Secret key"
|
||||
secretWarning = "Store this in a secrets manager. Stirling only ever stores a hash, so there is no way to recover it later."
|
||||
subtitle = "Give the key a name so you can recognise it later. You can revoke it at any time."
|
||||
secretWarning = "Store this in a secrets manager. Stirling only ever stores a hash — there is no way to recover it later."
|
||||
subtitle = "Scope the key to the minimum it needs. You can rotate or revoke at any time."
|
||||
subtitleCreated = "Copy this secret now — it won't be shown again."
|
||||
title = "Create API key"
|
||||
titleCreated = "Key created"
|
||||
@@ -7114,6 +7129,7 @@ title = "No regions deployed"
|
||||
[portal.infrastructure.keyLabel]
|
||||
active = "Active"
|
||||
revoked = "Revoked"
|
||||
rotateSoon = "Rotate soon"
|
||||
|
||||
[portal.infrastructure.modelLabel]
|
||||
active = "Active"
|
||||
@@ -7313,7 +7329,7 @@ storage = "Storage"
|
||||
[portal.nav]
|
||||
agent-builder = "Agent Builder"
|
||||
components = "Components"
|
||||
docs = "Developer Docs"
|
||||
docs = "Documentation"
|
||||
documents = "Documents"
|
||||
editor = "Editor"
|
||||
home = "Home"
|
||||
@@ -8018,7 +8034,6 @@ title = "Sources"
|
||||
[portal.sources.actions]
|
||||
agentBuilder = "Agent Builder"
|
||||
connectSource = "Connect source"
|
||||
createApiKey = "Create API key"
|
||||
|
||||
[portal.sources.builder]
|
||||
back = "Back to sources"
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Sync the portal Developer Docs from the Stirling docs repo.
|
||||
*
|
||||
* Fetches the docs repo tarball, extracts `docs/**` in-process (no external tar
|
||||
* binary, no per-file GitHub rate limits), shapes it with the pure transforms in
|
||||
* src/portal/docs/manifest/transform.ts, and writes the committed manifest that
|
||||
* the portal docs view renders. Re-run with `npm run docs:sync`.
|
||||
*
|
||||
* Env: DOCS_REPO, DOCS_REF, DOCS_ROOT override the defaults below.
|
||||
*/
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
// tsx/node16 can't resolve the @portal alias here, so import by relative .ts path.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
buildManifest,
|
||||
type CategoryMap,
|
||||
type RawDoc,
|
||||
} from "../src/portal/docs/manifest/transform.ts";
|
||||
|
||||
const REPO = process.env.DOCS_REPO ?? "Stirling-Tools/Stirling-Tools.github.io";
|
||||
const REF = process.env.DOCS_REF ?? "main";
|
||||
const ROOT = process.env.DOCS_ROOT ?? "docs";
|
||||
const SITE = "https://docs.stirlingpdf.com";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const OUT = resolve(HERE, "../src/portal/generated/docsManifest.json");
|
||||
|
||||
/* ── Minimal tar reader (ustar + pax/GNU long names) ─────────────────────── */
|
||||
|
||||
interface TarEntry {
|
||||
name: string;
|
||||
type: string;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
function readTar(buf: Buffer): TarEntry[] {
|
||||
const entries: TarEntry[] = [];
|
||||
let offset = 0;
|
||||
let longName: string | null = null;
|
||||
let paxPath: string | null = null;
|
||||
|
||||
const str = (start: number, len: number) => {
|
||||
const slice = buf.subarray(start, start + len);
|
||||
const end = slice.indexOf(0);
|
||||
return slice.toString("utf8", 0, end === -1 ? len : end);
|
||||
};
|
||||
|
||||
while (offset + 512 <= buf.length) {
|
||||
const header = buf.subarray(offset, offset + 512);
|
||||
// Two consecutive zero blocks mark the end of the archive.
|
||||
if (header.every((b) => b === 0)) break;
|
||||
|
||||
const name = str(offset, 100);
|
||||
const prefix = str(offset + 345, 155);
|
||||
const sizeStr = str(offset + 124, 12).trim();
|
||||
const size = parseInt(sizeStr, 8) || 0;
|
||||
const type = String.fromCharCode(header[156]);
|
||||
const dataStart = offset + 512;
|
||||
const data = buf.subarray(dataStart, dataStart + size);
|
||||
|
||||
let fullName = prefix ? `${prefix}/${name}` : name;
|
||||
if (longName) {
|
||||
fullName = longName;
|
||||
longName = null;
|
||||
}
|
||||
if (paxPath) {
|
||||
fullName = paxPath;
|
||||
paxPath = null;
|
||||
}
|
||||
|
||||
if (type === "L") {
|
||||
// GNU long name: the payload is the real name of the next entry.
|
||||
longName = data.toString("utf8").replace(/\0+$/, "");
|
||||
} else if (type === "x") {
|
||||
// pax extended header: pull a `path=` record for the next entry.
|
||||
const record = /(?:^|\n)\d+ path=([^\n]+)\n/.exec(data.toString("utf8"));
|
||||
if (record) paxPath = record[1];
|
||||
} else if (type === "0" || type === "\0" || type === "") {
|
||||
entries.push({ name: fullName, type, data: Buffer.from(data) });
|
||||
}
|
||||
|
||||
offset = dataStart + Math.ceil(size / 512) * 512;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/* ── Fetch + shape ───────────────────────────────────────────────────────── */
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const url = `https://api.github.com/repos/${REPO}/tarball/${REF}`;
|
||||
console.log(`Fetching ${REPO}@${REF} …`);
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "stirling-portal-docs-sync",
|
||||
...(process.env.GITHUB_TOKEN
|
||||
? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`GitHub tarball fetch failed: ${res.status} ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
const gz = Buffer.from(await res.arrayBuffer());
|
||||
const entries = readTar(gunzipSync(gz));
|
||||
|
||||
// Strip the "<repo>-<sha>/" wrapper dir and keep only files under docs root.
|
||||
const prefix = `${ROOT}/`;
|
||||
const rawDocs: RawDoc[] = [];
|
||||
const categories: CategoryMap = {};
|
||||
for (const entry of entries) {
|
||||
const rel = entry.name.replace(/^[^/]+\//, "");
|
||||
if (!rel.startsWith(prefix)) continue;
|
||||
const inner = rel.slice(prefix.length);
|
||||
if (!inner) continue;
|
||||
if (inner.endsWith("/_category_.json")) {
|
||||
const dir = inner.slice(0, -"/_category_.json".length);
|
||||
try {
|
||||
categories[dir] = JSON.parse(entry.data.toString("utf8"));
|
||||
} catch {
|
||||
console.warn(` skipping unparseable _category_.json in ${dir}`);
|
||||
}
|
||||
} else if (/\.mdx?$/i.test(inner)) {
|
||||
rawDocs.push({ relPath: inner, content: entry.data.toString("utf8") });
|
||||
}
|
||||
}
|
||||
|
||||
if (rawDocs.length === 0) {
|
||||
throw new Error(`No markdown found under ${ROOT}/ — wrong repo/ref/root?`);
|
||||
}
|
||||
|
||||
const manifest = buildManifest(rawDocs, categories, {
|
||||
repo: REPO,
|
||||
ref: REF,
|
||||
root: ROOT,
|
||||
siteBaseUrl: SITE,
|
||||
});
|
||||
|
||||
mkdirSync(dirname(OUT), { recursive: true });
|
||||
writeFileSync(OUT, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
||||
|
||||
const items = manifest.nav.reduce((n, s) => n + s.items.length, 0);
|
||||
console.log(
|
||||
`Wrote ${manifest.nav.length} sections, ${items} docs → ${OUT.replace(/.*[/\\]frontend[/\\]/, "frontend/")}`,
|
||||
);
|
||||
for (const s of manifest.nav) {
|
||||
console.log(` ${s.icon} ${s.label} (${s.items.length})`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -4,7 +4,9 @@
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16",
|
||||
"types": ["node"],
|
||||
"noEmit": true
|
||||
"noEmit": true,
|
||||
// sync-portal-docs.mts imports the shared transform by its .ts path (run via tsx).
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.mts"]
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
deserializeToolStep,
|
||||
getExecutableTools,
|
||||
serializeStepFromEndpoint,
|
||||
serializeToolStep,
|
||||
stepRequiresUpload,
|
||||
type WorkingToolStep,
|
||||
@@ -198,6 +199,41 @@ describe("serialize/deserialize round-trip", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeStepFromEndpoint", () => {
|
||||
test("maps a wizard step's UI params to the backend contract, filling defaults", () => {
|
||||
// The shape the policy setup wizard holds: an endpoint plus UI-shaped params
|
||||
// (redact's `wordsToRedact`), with several fields left to their defaults.
|
||||
const api = serializeStepFromEndpoint(
|
||||
"/api/v1/security/auto-redact",
|
||||
{ mode: "automatic", useRegex: true, wordsToRedact: ["ssn", "card"] },
|
||||
dynamicRegistry,
|
||||
);
|
||||
|
||||
expect(api.operation).toBe("/api/v1/security/auto-redact");
|
||||
// wordsToRedact -> listOfText (the field the backend actually reads), and the
|
||||
// frontend-only `mode` is dropped.
|
||||
expect(api.parameters).toMatchObject({ listOfText: "ssn\ncard" });
|
||||
expect(api.parameters).not.toHaveProperty("wordsToRedact");
|
||||
expect(api.parameters).not.toHaveProperty("mode");
|
||||
// Fields the wizard never set still get their defaults so the body is complete.
|
||||
expect(api.parameters).toHaveProperty("wholeWordSearch");
|
||||
expect(api.parameters).toHaveProperty("customPadding");
|
||||
});
|
||||
|
||||
test("passes an unmapped endpoint's params through unchanged", () => {
|
||||
expect(
|
||||
serializeStepFromEndpoint(
|
||||
"/api/v1/unknown/thing",
|
||||
{ keep: true },
|
||||
dynamicRegistry,
|
||||
),
|
||||
).toEqual({
|
||||
operation: "/api/v1/unknown/thing",
|
||||
parameters: { keep: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("stepRequiresUpload", () => {
|
||||
const step = (params: Record<string, unknown>): WorkingToolStep => ({
|
||||
toolId: "compress" as ToolId,
|
||||
|
||||
@@ -197,6 +197,31 @@ export function serializeToolStep(
|
||||
return { operation, parameters };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a step held as an endpoint path plus frontend-shaped params - the form the policy setup
|
||||
* wizard keeps, where params match the tool's UI shape (e.g. redact's `wordsToRedact`) rather than
|
||||
* the backend contract - into the backend step contract, mapping params through the tool's
|
||||
* `toApiParams` (merged over its defaults, so fields the wizard never set still get their defaults).
|
||||
* The endpoint maps to a tool by path, so this works for dynamic-endpoint tools whose config
|
||||
* endpoint is a function. Endpoints that map to no known tool pass through unchanged.
|
||||
*/
|
||||
export function serializeStepFromEndpoint(
|
||||
operation: string,
|
||||
params: ErasedToolParams,
|
||||
registry: Partial<ToolRegistry>,
|
||||
): ToolApiStep {
|
||||
const match = findToolByEndpoint({ operation, parameters: params }, registry);
|
||||
const config = match?.[1].operationConfig;
|
||||
if (!config) return { operation, parameters: params };
|
||||
const merged = { ...(config.defaultParameters ?? {}), ...params };
|
||||
return {
|
||||
operation: resolveEndpoint(config, merged) ?? operation,
|
||||
parameters: config.toApiParams
|
||||
? (config.toApiParams(merged) as Record<string, unknown>)
|
||||
: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the registry tool for a stored step's endpoint: exact match for static endpoints, else
|
||||
* membership in a dynamic tool's declared `endpoints` set (replaying its function can't recover a
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describeToolOperation } from "@app/hooks/tools/shared/toolOperationDescriptor";
|
||||
|
||||
interface Params {
|
||||
a: number;
|
||||
}
|
||||
|
||||
// A minimal config that type-checks against the flatten endpoint's model.
|
||||
const CONFIG = {
|
||||
endpoint: "/api/v1/misc/flatten" as const,
|
||||
defaultParameters: { a: 1 } satisfies Params,
|
||||
toApiParams: (p: Params) => ({ renderDpi: p.a }),
|
||||
fromApiParams: (api: { renderDpi?: number }) => ({ a: api.renderDpi ?? 0 }),
|
||||
};
|
||||
|
||||
describe("describeToolOperation", () => {
|
||||
test("wraps the config's mappers and endpoint into a descriptor", () => {
|
||||
const d = describeToolOperation("/api/v1/misc/flatten", CONFIG);
|
||||
expect(d.endpoint).toBe("/api/v1/misc/flatten");
|
||||
expect(d.toApi({ a: 200 })).toEqual({ renderDpi: 200 });
|
||||
});
|
||||
|
||||
test("fromApi merges the mapped values over the defaults", () => {
|
||||
const d = describeToolOperation("/api/v1/misc/flatten", CONFIG);
|
||||
expect(d.fromApi({ renderDpi: 72 })).toEqual({ a: 72 });
|
||||
});
|
||||
|
||||
test("throws when the config lacks a mapper", () => {
|
||||
expect(() =>
|
||||
describeToolOperation("/api/v1/misc/flatten", {
|
||||
endpoint: "/api/v1/misc/flatten" as const,
|
||||
defaultParameters: { a: 1 },
|
||||
toApiParams: (p: Params) => ({ renderDpi: p.a }),
|
||||
}),
|
||||
).toThrow(/mappers/);
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Typed wrapper over a tool's `toApiParams`/`fromApiParams` mappers, binding one endpoint to safe
|
||||
* frontend<->backend parameter conversion.
|
||||
*/
|
||||
|
||||
import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
|
||||
|
||||
export interface ToolOperationDescriptor<E extends ToolEndpoint, TParams> {
|
||||
readonly endpoint: E;
|
||||
readonly defaultParameters: TParams;
|
||||
toApi(params: TParams): ToolApiParams[E];
|
||||
/** Backend model -> full frontend params (defaults merged under the mapped values). */
|
||||
fromApi(api: ToolApiParams[E]): TParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural subset of a tool's config. `CE` is the config's declared endpoint type, inferred from
|
||||
* the `endpoint` field: the literal for static tools, or the whole `ToolEndpoint` union for
|
||||
* dynamic-endpoint tools (whose endpoint is a function typed against the union).
|
||||
*/
|
||||
export interface BidirectionalToolConfig<TParams, CE extends ToolEndpoint> {
|
||||
endpoint: CE | null | ((params: TParams) => CE | null);
|
||||
defaultParameters?: TParams;
|
||||
toApiParams?(params: TParams): ToolApiParams[CE];
|
||||
fromApiParams?(api: ToolApiParams[CE]): Partial<TParams>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin a config to `endpoint` (passed explicitly, since dynamic-endpoint tools declare `endpoint` as
|
||||
* a function). `E extends CE` rejects pairing a static tool's config with the wrong endpoint, while
|
||||
* allowing a dynamic tool whose `CE` is the full union. Throws when mappers or defaults are missing.
|
||||
*/
|
||||
export function describeToolOperation<
|
||||
E extends CE,
|
||||
CE extends ToolEndpoint,
|
||||
TParams,
|
||||
>(
|
||||
endpoint: E,
|
||||
config: BidirectionalToolConfig<TParams, CE>,
|
||||
): ToolOperationDescriptor<E, TParams> {
|
||||
const { toApiParams, fromApiParams, defaultParameters } = config;
|
||||
if (!toApiParams || !fromApiParams || defaultParameters === undefined) {
|
||||
throw new Error(
|
||||
`describeToolOperation: "${endpoint}" is missing mappers or defaults`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
endpoint,
|
||||
defaultParameters,
|
||||
// A dynamic tool's mapper is typed against the union; narrow to this endpoint (sound - the
|
||||
// runtime mapper produces this endpoint's model).
|
||||
toApi: (params) => toApiParams(params) as ToolApiParams[E],
|
||||
fromApi: (api) =>
|
||||
({
|
||||
...defaultParameters,
|
||||
...fromApiParams(api as ToolApiParams[CE]),
|
||||
}) as TParams,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { Home } from "@portal/views/Home";
|
||||
import { Users } from "@portal/views/Users";
|
||||
@@ -12,10 +13,16 @@ import { Components } from "@portal/views/Components";
|
||||
import { EditorAdmin } from "@portal/views/EditorAdmin";
|
||||
import { Infrastructure } from "@portal/views/Infrastructure";
|
||||
import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate";
|
||||
import { DeveloperDocs } from "@portal/views/DeveloperDocs";
|
||||
import { Procurement } from "@portal/views/Procurement";
|
||||
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
|
||||
|
||||
// Lazy so the generated docs manifest (bundled JSON) lands in its own chunk.
|
||||
const DeveloperDocs = lazy(() =>
|
||||
import("@portal/views/DeveloperDocs").then((m) => ({
|
||||
default: m.DeveloperDocs,
|
||||
})),
|
||||
);
|
||||
|
||||
// The portal mounts as a route-set under /processor/* in the editor app, so these
|
||||
// child routes are relative to that base: strip the leading slash from the
|
||||
// logical VIEW_PATHS, and home is the index route. Redirects use toPortalPath
|
||||
@@ -59,7 +66,14 @@ export function ViewRouter() {
|
||||
/>
|
||||
<Route path={rel(VIEW_PATHS.usage)} element={<PortalBillingGate />} />
|
||||
<Route path={rel(VIEW_PATHS.procurement)} element={<Procurement />} />
|
||||
<Route path={rel(VIEW_PATHS.docs)} element={<DeveloperDocs />} />
|
||||
<Route
|
||||
path={rel(VIEW_PATHS.docs)}
|
||||
element={
|
||||
<Suspense fallback={null}>
|
||||
<DeveloperDocs />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
{/* Account-link is now a Settings panel; redirect legacy bookmarks home. */}
|
||||
<Route
|
||||
path="account-link"
|
||||
|
||||
@@ -43,33 +43,23 @@ export interface RecentDeployment {
|
||||
/* API Keys */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type ApiKeyStatus = "active" | "revoked";
|
||||
export type ApiKeyStatus = "active" | "revoked" | "rotate-soon";
|
||||
export type ApiKeyPermission = "Read" | "Write" | "Admin";
|
||||
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Non-secret leading fragment, e.g. "sk_a3f81b2c". */
|
||||
/** Masked prefix shown in the list, e.g. "sk_live_a3f8…". */
|
||||
prefix: string;
|
||||
created: string;
|
||||
/** Formatted last-use time, or "Never". */
|
||||
lastUsed: string;
|
||||
status: ApiKeyStatus;
|
||||
/** Requests made today (UTC). */
|
||||
/** Requests/min ceiling. */
|
||||
rateLimit: number;
|
||||
permissions: ApiKeyPermission[];
|
||||
allowedIps: string[];
|
||||
usageToday: number;
|
||||
/** Requests in the trailing 30 days. */
|
||||
usageMonth: number;
|
||||
/** Lifetime request count. */
|
||||
usageTotal: number;
|
||||
}
|
||||
|
||||
export interface ApiKeysResponse {
|
||||
keys: ApiKey[];
|
||||
}
|
||||
|
||||
/** Returned once on creation: the listed row plus the plaintext secret, shown once. */
|
||||
export interface CreatedApiKey {
|
||||
key: ApiKey;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
@@ -288,34 +278,11 @@ export async function fetchDeployments(
|
||||
);
|
||||
}
|
||||
|
||||
const API_KEYS_PATH = "/api/v1/proprietary/ui-data/infrastructure/api-keys";
|
||||
|
||||
/** GET the caller's personal API keys; SaaS or local, scoped server-side per user. */
|
||||
export async function fetchApiKeys(): Promise<ApiKeysResponse> {
|
||||
return apiClient.saas.isConfigured()
|
||||
? apiClient.saas.json<ApiKeysResponse>(API_KEYS_PATH)
|
||||
: apiClient.local.json<ApiKeysResponse>(API_KEYS_PATH);
|
||||
}
|
||||
|
||||
/** POST a new key; the response carries the one-time secret. */
|
||||
export async function createApiKey(body: {
|
||||
name: string;
|
||||
}): Promise<CreatedApiKey> {
|
||||
const opts = { method: "POST" as const, body };
|
||||
return apiClient.saas.isConfigured()
|
||||
? apiClient.saas.json<CreatedApiKey>(API_KEYS_PATH, opts)
|
||||
: apiClient.local.json<CreatedApiKey>(API_KEYS_PATH, opts);
|
||||
}
|
||||
|
||||
/** DELETE (revoke) a key the caller owns. */
|
||||
export async function revokeApiKey(id: string): Promise<void> {
|
||||
const path = `${API_KEYS_PATH}/${encodeURIComponent(id)}`;
|
||||
const opts = { method: "DELETE" as const };
|
||||
if (apiClient.saas.isConfigured()) {
|
||||
await apiClient.saas.json<void>(path, opts);
|
||||
} else {
|
||||
await apiClient.local.json<void>(path, opts);
|
||||
}
|
||||
/** GET /v1/infrastructure/api-keys?tier=… */
|
||||
export async function fetchApiKeys(tier: Tier): Promise<ApiKey[]> {
|
||||
return apiClient.local.json<ApiKey[]>(
|
||||
`/v1/infrastructure/api-keys${q(tier)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /v1/infrastructure/security?tier=… */
|
||||
|
||||
@@ -13,8 +13,6 @@ import type { TFunction } from "i18next";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import { fromWirePolicy, toWirePolicy } from "@app/policies/codec";
|
||||
import { runsToActivity, runsToStats } from "@app/policies/runs";
|
||||
import { policyStep, type PolicyToolStep } from "@app/policies/operations";
|
||||
import type { ToolEndpoint } from "@app/types/toolApiTypes";
|
||||
import type {
|
||||
PolicyDecodedState,
|
||||
PolicyRunView,
|
||||
@@ -68,7 +66,7 @@ export interface PolicyConfigDef {
|
||||
rules: string[];
|
||||
scopeLabel: string;
|
||||
fields: PolicyField[];
|
||||
defaultOperations: PolicyToolStep[];
|
||||
defaultOperations: WirePipelineStep[];
|
||||
}
|
||||
|
||||
export interface PolicyState {
|
||||
@@ -130,11 +128,20 @@ export interface CatalogueEntry {
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Endpoint display labels */
|
||||
/* Tool → endpoint registry */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** i18n keys keyed by {@link ToolEndpoint}; labels stored steps in the detail view. */
|
||||
export const ENDPOINT_LABELS: Partial<Record<ToolEndpoint, string>> = {
|
||||
export const TOOL_ENDPOINTS: Record<string, string> = {
|
||||
redact: "/api/v1/security/auto-redact",
|
||||
sanitize: "/api/v1/security/sanitize-pdf",
|
||||
watermark: "/api/v1/security/add-watermark",
|
||||
ocr: "/api/v1/misc/ocr-pdf",
|
||||
flatten: "/api/v1/misc/flatten",
|
||||
compress: "/api/v1/misc/compress-pdf",
|
||||
};
|
||||
|
||||
/** Values are i18n keys — render with t(). */
|
||||
export const ENDPOINT_LABELS: Record<string, string> = {
|
||||
"/api/v1/security/auto-redact": "portal.policies.endpoints.autoRedact",
|
||||
"/api/v1/security/sanitize-pdf": "portal.policies.endpoints.sanitizePdf",
|
||||
"/api/v1/security/add-watermark": "portal.policies.endpoints.addWatermark",
|
||||
@@ -147,8 +154,7 @@ export function humanizeEndpoint(
|
||||
path: string,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
const label = ENDPOINT_LABELS[path as ToolEndpoint];
|
||||
if (label) return t(label);
|
||||
if (ENDPOINT_LABELS[path]) return t(ENDPOINT_LABELS[path]);
|
||||
const last = path.split("/").filter(Boolean).pop() ?? path;
|
||||
return last
|
||||
.replace(/-/g, " ")
|
||||
@@ -224,7 +230,10 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
"portal.policies.config.ingestion.rules.3",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [policyStep("ocr"), policyStep("flatten")],
|
||||
defaultOperations: [
|
||||
{ operation: TOOL_ENDPOINTS.ocr, parameters: {} },
|
||||
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.ingestion.fields.minConfidence",
|
||||
@@ -251,16 +260,32 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [
|
||||
// Flatten to image so redactions can't be lifted off.
|
||||
policyStep("redact", {
|
||||
useRegex: true,
|
||||
convertPDFToImage: true,
|
||||
wordsToRedact: DEFAULT_PII_PATTERNS,
|
||||
}),
|
||||
// JavaScript removal only; the tool enables removeEmbeddedFiles by default, so turn it off.
|
||||
policyStep("sanitize", { removeEmbeddedFiles: false }),
|
||||
// Bake in via image so it can't be stripped.
|
||||
policyStep("watermark", { convertPDFToImage: true }),
|
||||
{
|
||||
operation: TOOL_ENDPOINTS.redact,
|
||||
parameters: {
|
||||
mode: "automatic",
|
||||
useRegex: true,
|
||||
convertPDFToImage: true,
|
||||
wordsToRedact: DEFAULT_PII_PATTERNS,
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: TOOL_ENDPOINTS.sanitize,
|
||||
parameters: {
|
||||
removeJavaScript: true,
|
||||
removeEmbeddedFiles: false,
|
||||
removeMetadata: false,
|
||||
removeLinks: false,
|
||||
removeFonts: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: TOOL_ENDPOINTS.watermark,
|
||||
// convertPDFToImage bakes the watermark in so it can't be stripped
|
||||
parameters: {
|
||||
convertPDFToImage: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
fields: [],
|
||||
},
|
||||
@@ -272,7 +297,10 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
"portal.policies.config.compliance.rules.2",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [policyStep("sanitize"), policyStep("flatten")],
|
||||
defaultOperations: [
|
||||
{ operation: TOOL_ENDPOINTS.sanitize, parameters: {} },
|
||||
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.compliance.fields.frameworks",
|
||||
@@ -315,7 +343,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
"portal.policies.config.routing.rules.2",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [policyStep("compress")],
|
||||
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.routing.fields.destination",
|
||||
@@ -346,7 +374,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
"portal.policies.config.retention.rules.2",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [policyStep("compress")],
|
||||
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.retention.fields.keepFor",
|
||||
|
||||
@@ -1,8 +1,45 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Skeleton, StatusBadge } from "@app/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DocsNavSection } from "@portal/api/docs";
|
||||
|
||||
/** Left-hand documentation nav tree; each leaf selects an in-page section. */
|
||||
/**
|
||||
* Left-hand documentation nav: a hierarchical accordion. Section ids encode their
|
||||
* path ("functionality/security" is a child of "functionality"), so sub-sections
|
||||
* nest under their parent. The root "Overview" section is static (always open, no
|
||||
* toggle); every other section collapses, and only the branch leading to the
|
||||
* active doc opens by default. (Search lives in DocsSearch above this.)
|
||||
*/
|
||||
|
||||
// Matches the generator's ROOT_SECTION_ID: the intro section is never collapsible.
|
||||
const STATIC_SECTION_ID = "overview";
|
||||
|
||||
interface NavNode {
|
||||
section: DocsNavSection;
|
||||
children: NavNode[];
|
||||
}
|
||||
|
||||
/** Split "a/b/c" → "a/b"; null for a top-level id. */
|
||||
function parentId(id: string): string | null {
|
||||
const i = id.lastIndexOf("/");
|
||||
return i === -1 ? null : id.slice(0, i);
|
||||
}
|
||||
|
||||
/** Build the section tree from the flat, pre-sorted section list. */
|
||||
function buildTree(sections: DocsNavSection[]): NavNode[] {
|
||||
const byId = new Map<string, NavNode>(
|
||||
sections.map((s) => [s.id, { section: s, children: [] }]),
|
||||
);
|
||||
const roots: NavNode[] = [];
|
||||
for (const node of byId.values()) {
|
||||
const pid = parentId(node.section.id);
|
||||
const parent = pid ? byId.get(pid) : undefined;
|
||||
if (parent) parent.children.push(node);
|
||||
else roots.push(node);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
export function DocsNav({
|
||||
sections,
|
||||
active,
|
||||
@@ -13,50 +50,125 @@ export function DocsNav({
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Per-section manual open/close, overriding the "active branch only" default.
|
||||
const [toggled, setToggled] = useState<Record<string, boolean>>({});
|
||||
const activeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const activeSectionId = useMemo(
|
||||
() => sections.find((s) => s.items.some((i) => i.id === active))?.id,
|
||||
[sections, active],
|
||||
);
|
||||
|
||||
const tree = useMemo(() => buildTree(sections), [sections]);
|
||||
|
||||
// Keep the active item in view when navigating (e.g. via a cross-link).
|
||||
useEffect(() => {
|
||||
activeRef.current?.scrollIntoView?.({ block: "nearest" });
|
||||
}, [active]);
|
||||
|
||||
const isOpen = (id: string): boolean => {
|
||||
if (id === STATIC_SECTION_ID) return true;
|
||||
// Default-open the branch containing the active doc (self or ancestor).
|
||||
const onActivePath =
|
||||
!!activeSectionId &&
|
||||
(activeSectionId === id || activeSectionId.startsWith(id + "/"));
|
||||
return toggled[id] ?? onActivePath;
|
||||
};
|
||||
|
||||
const renderNode = (node: NavNode) => {
|
||||
const { section, children } = node;
|
||||
const isStatic = section.id === STATIC_SECTION_ID;
|
||||
const open = isOpen(section.id);
|
||||
return (
|
||||
<div key={section.id} className="portal-docs__nav-group">
|
||||
{isStatic ? (
|
||||
<div className="portal-docs__nav-head">{section.label}</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
fullWidth
|
||||
justify="between"
|
||||
className="portal-docs__nav-head portal-docs__nav-head--button"
|
||||
aria-expanded={open}
|
||||
onClick={() =>
|
||||
setToggled((prev) => ({ ...prev, [section.id]: !open }))
|
||||
}
|
||||
rightSection={
|
||||
<span className="portal-docs__nav-count">
|
||||
{section.items.length}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span className="portal-docs__nav-head-main">
|
||||
<span
|
||||
className={
|
||||
"portal-docs__nav-chevron" + (open ? " is-open" : "")
|
||||
}
|
||||
aria-hidden
|
||||
>
|
||||
▸
|
||||
</span>
|
||||
<span className="portal-docs__nav-headlabel">
|
||||
{section.label}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<>
|
||||
{section.items.length > 0 && (
|
||||
<ul className="portal-docs__nav-list">
|
||||
{section.items.map((item) => {
|
||||
const isActive = item.id === active;
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<Button
|
||||
ref={isActive ? activeRef : undefined}
|
||||
variant="tertiary"
|
||||
justify="start"
|
||||
fullWidth
|
||||
className={
|
||||
"portal-docs__nav-link" +
|
||||
(isActive ? " is-active" : "")
|
||||
}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
onClick={() => onSelect(item.id)}
|
||||
>
|
||||
<span className="portal-docs__nav-label">
|
||||
{item.label}
|
||||
</span>
|
||||
{item.badge && (
|
||||
<StatusBadge
|
||||
tone={item.badge === "New" ? "success" : "info"}
|
||||
size="sm"
|
||||
>
|
||||
{item.badge}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{children.length > 0 && (
|
||||
<div className="portal-docs__nav-children">
|
||||
{children.map(renderNode)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="portal-docs__nav"
|
||||
aria-label={t("portal.docs.nav.ariaLabel")}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<div key={section.id} className="portal-docs__nav-group">
|
||||
<div className="portal-docs__nav-head">
|
||||
<span className="portal-docs__nav-icon" aria-hidden>
|
||||
{section.icon}
|
||||
</span>
|
||||
{section.label}
|
||||
</div>
|
||||
<ul className="portal-docs__nav-list">
|
||||
{section.items.map((item) => {
|
||||
const isActive = item.id === active;
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
justify="start"
|
||||
fullWidth
|
||||
className={
|
||||
"portal-docs__nav-link" + (isActive ? " is-active" : "")
|
||||
}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
onClick={() => onSelect(item.id)}
|
||||
>
|
||||
<span className="portal-docs__nav-label">{item.label}</span>
|
||||
{item.badge && (
|
||||
<StatusBadge
|
||||
tone={item.badge === "New" ? "success" : "info"}
|
||||
size="sm"
|
||||
>
|
||||
{item.badge}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
{tree.map(renderNode)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -64,14 +176,16 @@ export function DocsNav({
|
||||
export function DocsNavSkeleton() {
|
||||
return (
|
||||
<nav className="portal-docs__nav" aria-hidden>
|
||||
{Array.from({ length: 4 }).map((_, gi) => (
|
||||
{Array.from({ length: 5 }).map((_, gi) => (
|
||||
<div key={gi} className="portal-docs__nav-group">
|
||||
<Skeleton width="7rem" height="0.75rem" />
|
||||
<div className="portal-docs__nav-list">
|
||||
{Array.from({ length: 3 }).map((_, li) => (
|
||||
<Skeleton key={li} width="80%" height="0.875rem" />
|
||||
))}
|
||||
</div>
|
||||
{gi === 0 && (
|
||||
<div className="portal-docs__nav-list">
|
||||
{Array.from({ length: 4 }).map((_, li) => (
|
||||
<Skeleton key={li} width="80%" height="0.875rem" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui";
|
||||
import type { SearchResult, Segment } from "@portal/docs/search";
|
||||
|
||||
/** Render highlighted segments, wrapping matched runs in <mark>. */
|
||||
function Highlighted({ segments }: { segments: Segment[] }) {
|
||||
return (
|
||||
<>
|
||||
{segments.map((s, i) =>
|
||||
s.hit ? (
|
||||
<mark key={i} className="portal-docs__hl">
|
||||
{s.text}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i}>{s.text}</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Docs search box + results. While a query is active it shows a ranked list of
|
||||
* matching docs — each with its section, a highlighted title, and a content
|
||||
* snippet — that navigates on click (or Enter). Arrow keys move the selection.
|
||||
*/
|
||||
export function DocsSearch({
|
||||
query,
|
||||
onQueryChange,
|
||||
results,
|
||||
onSelect,
|
||||
}: {
|
||||
query: string;
|
||||
onQueryChange: (q: string) => void;
|
||||
results: SearchResult[];
|
||||
onSelect: (docId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// -1 = nothing pre-selected; arrow keys drive this, the mouse uses CSS :hover.
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
const hasQuery = query.trim().length > 0;
|
||||
|
||||
useEffect(() => setActiveIndex(-1), [query]);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current
|
||||
?.querySelector<HTMLElement>('[data-active="true"]')
|
||||
?.scrollIntoView?.({ block: "nearest" });
|
||||
}, [activeIndex]);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
onQueryChange("");
|
||||
return;
|
||||
}
|
||||
if (!results.length) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const hit = results[activeIndex >= 0 ? activeIndex : 0];
|
||||
if (hit) onSelect(hit.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="portal-docs__search">
|
||||
<div className="portal-docs__search-box">
|
||||
<span className="portal-docs__search-icon" aria-hidden>
|
||||
⌕
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
className="portal-docs__search-input"
|
||||
placeholder={t("portal.docs.search.placeholder")}
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
aria-label={t("portal.docs.search.placeholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasQuery && (
|
||||
<div className="portal-docs__results">
|
||||
{results.length === 0 ? (
|
||||
<p className="portal-docs__nav-empty">
|
||||
{t("portal.docs.search.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="portal-docs__results-count">
|
||||
{t("portal.docs.search.results", { count: results.length })}
|
||||
</div>
|
||||
<ul ref={listRef} className="portal-docs__results-list">
|
||||
{results.map((r, i) => (
|
||||
<li key={r.id}>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
fullWidth
|
||||
justify="start"
|
||||
className={
|
||||
"portal-docs__result" +
|
||||
(i === activeIndex ? " is-active" : "")
|
||||
}
|
||||
data-active={i === activeIndex}
|
||||
onClick={() => onSelect(r.id)}
|
||||
>
|
||||
<span className="portal-docs__result-body">
|
||||
<span className="portal-docs__result-head">
|
||||
<span className="portal-docs__result-title">
|
||||
<Highlighted segments={r.titleSegments} />
|
||||
</span>
|
||||
<span className="portal-docs__result-section">
|
||||
{r.sectionLabel}
|
||||
</span>
|
||||
</span>
|
||||
<span className="portal-docs__result-snippet">
|
||||
<Highlighted segments={r.snippet} />
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState, type RefObject } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Heading } from "@portal/docs/headings";
|
||||
|
||||
/**
|
||||
* "On this page" table of contents. Lists the current doc's H2/H3 headings,
|
||||
* scrolls the reading pane to a heading on click, and highlights the section
|
||||
* currently in view (scroll-spy against the pane's scroll container).
|
||||
*/
|
||||
export function DocsToc({
|
||||
headings,
|
||||
scrollRef,
|
||||
}: {
|
||||
headings: Heading[];
|
||||
scrollRef: RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [active, setActive] = useState<string>(headings[0]?.slug ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
const root = scrollRef.current;
|
||||
if (!root || headings.length === 0) return;
|
||||
setActive(headings[0].slug);
|
||||
|
||||
const visible = new Set<string>();
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) visible.add(e.target.id);
|
||||
else visible.delete(e.target.id);
|
||||
}
|
||||
// The topmost heading currently within the active zone wins.
|
||||
const current = headings.find((h) => visible.has(h.slug));
|
||||
if (current) setActive(current.slug);
|
||||
},
|
||||
// Active zone = the top ~30% of the reading pane.
|
||||
{ root, rootMargin: "0px 0px -70% 0px", threshold: 0 },
|
||||
);
|
||||
|
||||
const els = headings
|
||||
.map((h) => root.querySelector(`[id="${h.slug}"]`))
|
||||
.filter((el): el is Element => el !== null);
|
||||
els.forEach((el) => observer.observe(el));
|
||||
return () => observer.disconnect();
|
||||
}, [headings, scrollRef]);
|
||||
|
||||
const onSelect = (slug: string) => {
|
||||
scrollRef.current
|
||||
?.querySelector(`[id="${slug}"]`)
|
||||
?.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
setActive(slug);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="portal-docs__toc" aria-label={t("portal.docs.toc.title")}>
|
||||
<div className="portal-docs__toc-title">{t("portal.docs.toc.title")}</div>
|
||||
<ul className="portal-docs__toc-list">
|
||||
{headings.map((h) => (
|
||||
<li key={h.slug}>
|
||||
<a
|
||||
href={`#${h.slug}`}
|
||||
className={
|
||||
"portal-docs__toc-link" +
|
||||
(h.level === 3 ? " is-sub" : "") +
|
||||
(active === h.slug ? " is-active" : "")
|
||||
}
|
||||
aria-current={active === h.slug ? "location" : undefined}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(h.slug);
|
||||
}}
|
||||
>
|
||||
{h.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { isValidElement, useState, type ReactNode } from "react";
|
||||
import ReactMarkdown, {
|
||||
defaultUrlTransform,
|
||||
type Components,
|
||||
} from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Button } from "@app/ui";
|
||||
import { makeSlugger } from "@portal/docs/headings";
|
||||
|
||||
/** Flatten a heading's React children to plain text for its anchor id. */
|
||||
function childText(node: ReactNode): string {
|
||||
if (typeof node === "string" || typeof node === "number") return String(node);
|
||||
if (Array.isArray(node)) return node.map(childText).join("");
|
||||
if (isValidElement(node)) {
|
||||
return childText((node.props as { children?: ReactNode }).children);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Keep our internal `doc:` scheme; sanitize every other URL as react-markdown
|
||||
// would by default (it strips unknown protocols, which would kill doc: links).
|
||||
function urlTransform(url: string): string {
|
||||
return url.startsWith("doc:") ? url : defaultUrlTransform(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a doc's normalised markdown. Internal cross-doc links carry the
|
||||
* `doc:` scheme (see the sync transform) and are intercepted here so they
|
||||
* navigate within the portal instead of leaving the app.
|
||||
*/
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
className="portal-docs__md-copy"
|
||||
onClick={() =>
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
})
|
||||
}
|
||||
>
|
||||
{copied ? "✓ Copied" : "Copy"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function buildComponents(
|
||||
onNavigate: (docId: string) => void,
|
||||
slug: (text: string) => string,
|
||||
): Components {
|
||||
return {
|
||||
h2: ({ children }) => <h2 id={slug(childText(children))}>{children}</h2>,
|
||||
h3: ({ children }) => <h3 id={slug(childText(children))}>{children}</h3>,
|
||||
a: ({ href, children }) => {
|
||||
if (href?.startsWith("doc:")) {
|
||||
const id = href.slice(4);
|
||||
return (
|
||||
<a
|
||||
href={`#${id}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onNavigate(id);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
const external = /^https?:/i.test(href ?? "");
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={external ? "_blank" : undefined}
|
||||
rel={external ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
// Eager, not lazy: lazy-loading inside the docs' own scroll container isn't
|
||||
// reliably triggered, and docs pages have only a handful of images.
|
||||
img: ({ node: _node, ...props }) => (
|
||||
<img {...props} className="portal-docs__md-img" />
|
||||
),
|
||||
pre: ({ children }) => {
|
||||
const code = isValidElement(children)
|
||||
? String(
|
||||
(children.props as { children?: unknown }).children ?? "",
|
||||
).replace(/\n$/, "")
|
||||
: String(children ?? "");
|
||||
return (
|
||||
<div className="portal-docs__md-pre">
|
||||
<pre>{children}</pre>
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
table: ({ children }) => (
|
||||
<div className="portal-docs__md-tablewrap">
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function MarkdownDoc({
|
||||
markdown,
|
||||
onNavigate,
|
||||
}: {
|
||||
markdown: string;
|
||||
onNavigate: (docId: string) => void;
|
||||
}) {
|
||||
// A fresh de-duping slugger per render; react-markdown invokes h2/h3 in
|
||||
// document order, so ids line up with the TOC's extractHeadings slugs.
|
||||
const slug = makeSlugger();
|
||||
return (
|
||||
<div className="portal-docs__md">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
urlTransform={urlTransform}
|
||||
components={buildComponents(onNavigate, slug)}
|
||||
>
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,20 +6,21 @@ import "@portal/views/Infrastructure.css";
|
||||
const BASE: ApiKey = {
|
||||
id: "key-1",
|
||||
name: "Production · ingest",
|
||||
prefix: "sk_a3f81b2c",
|
||||
created: "2026-03-02",
|
||||
lastUsed: "2026-07-10 09:14",
|
||||
prefix: "sk_live_a3f8…",
|
||||
created: "Mar 2, 2026",
|
||||
lastUsed: "2m ago",
|
||||
status: "active",
|
||||
rateLimit: 1200,
|
||||
permissions: ["Read", "Write"],
|
||||
allowedIps: ["52.14.0.0/16", "18.221.0.0/16"],
|
||||
usageToday: 84210,
|
||||
usageMonth: 2410933,
|
||||
usageTotal: 9820145,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof ApiKeyCard> = {
|
||||
title: "Portal/Infrastructure/ApiKeyCard",
|
||||
component: ApiKeyCard,
|
||||
parameters: { layout: "padded" },
|
||||
args: { onRevoke: (key: ApiKey) => console.log("revoke", key.id) },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "44rem" }}>
|
||||
@@ -31,18 +32,37 @@ const meta: Meta<typeof ApiKeyCard> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ApiKeyCard>;
|
||||
|
||||
export const Personal: Story = { args: { apiKey: BASE } };
|
||||
export const Active: Story = { args: { apiKey: BASE } };
|
||||
|
||||
export const RotateSoon: Story = {
|
||||
args: {
|
||||
apiKey: {
|
||||
...BASE,
|
||||
name: "Ops · admin (legacy)",
|
||||
status: "rotate-soon",
|
||||
permissions: ["Read", "Write", "Admin"],
|
||||
allowedIps: ["203.0.113.7/32"],
|
||||
usageToday: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Revoked: Story = {
|
||||
args: {
|
||||
apiKey: {
|
||||
...BASE,
|
||||
name: "Sandbox · webhook tester",
|
||||
prefix: "sk_2c4a91de",
|
||||
prefix: "sk_test_2c4a…",
|
||||
status: "revoked",
|
||||
lastUsed: "Never",
|
||||
lastUsed: "never",
|
||||
permissions: ["Read"],
|
||||
allowedIps: [],
|
||||
usageToday: 0,
|
||||
usageMonth: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoIpAllowlist: Story = {
|
||||
args: { apiKey: { ...BASE, allowedIps: [] } },
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Card, StatusBadge } from "@app/ui";
|
||||
import { Button, Card, Chip, StatusBadge } from "@app/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ApiKey } from "@portal/api/infrastructure";
|
||||
import {
|
||||
@@ -8,17 +8,9 @@ import {
|
||||
} from "@portal/components/infrastructure/infraFormat";
|
||||
|
||||
/** Collapsible row for a single API key: header summary + expandable detail grid. */
|
||||
export function ApiKeyCard({
|
||||
apiKey,
|
||||
onRevoke,
|
||||
}: {
|
||||
apiKey: ApiKey;
|
||||
/** Ask to revoke this key; the parent confirms before the destructive call. */
|
||||
onRevoke: (key: ApiKey) => void;
|
||||
}) {
|
||||
export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const revocable = apiKey.status === "active";
|
||||
return (
|
||||
<Card padding="default" className="portal-infra__key">
|
||||
<Button
|
||||
@@ -59,6 +51,14 @@ export function ApiKeyCard({
|
||||
<dt>{t("portal.infrastructure.apiKeys.card.lastUsed")}</dt>
|
||||
<dd>{apiKey.lastUsed}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("portal.infrastructure.apiKeys.card.rateLimit")}</dt>
|
||||
<dd className="portal-infra__mono">
|
||||
{t("portal.infrastructure.apiKeys.card.rateLimitValue", {
|
||||
value: apiKey.rateLimit.toLocaleString(),
|
||||
})}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("portal.infrastructure.apiKeys.card.usageToday")}</dt>
|
||||
<dd className="portal-infra__mono">
|
||||
@@ -71,20 +71,36 @@ export function ApiKeyCard({
|
||||
{apiKey.usageMonth.toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{revocable && (
|
||||
<div className="portal-infra__modal-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
accent="danger"
|
||||
size="sm"
|
||||
onClick={() => onRevoke(apiKey)}
|
||||
>
|
||||
{t("portal.infrastructure.apiKeys.card.revoke")}
|
||||
</Button>
|
||||
<div>
|
||||
<dt>{t("portal.infrastructure.apiKeys.card.permissions")}</dt>
|
||||
<dd className="portal-infra__chips">
|
||||
{apiKey.permissions.map((p) => (
|
||||
<Chip key={p} size="sm">
|
||||
{t(
|
||||
`portal.infrastructure.apiKeyPermission.${p.toLowerCase()}`,
|
||||
p,
|
||||
)}
|
||||
</Chip>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="portal-infra__kv-wide">
|
||||
<dt>{t("portal.infrastructure.apiKeys.card.allowedIps")}</dt>
|
||||
<dd className="portal-infra__chips">
|
||||
{apiKey.allowedIps.length === 0 ? (
|
||||
<span className="portal-infra__muted">
|
||||
{t("portal.infrastructure.apiKeys.card.anyIp")}
|
||||
</span>
|
||||
) : (
|
||||
apiKey.allowedIps.map((ip) => (
|
||||
<Chip key={ip} size="sm">
|
||||
<span className="portal-infra__mono">{ip}</span>
|
||||
</Chip>
|
||||
))
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -20,19 +20,14 @@ type Story = StoryObj<typeof ApiKeysTab>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
const EMPTY = { keys: [] };
|
||||
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get(
|
||||
"*/api/v1/proprietary/ui-data/infrastructure/api-keys",
|
||||
async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json(EMPTY);
|
||||
},
|
||||
),
|
||||
http.get("/v1/infrastructure/api-keys", async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -42,9 +37,7 @@ export const Empty: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("*/api/v1/proprietary/ui-data/infrastructure/api-keys", () =>
|
||||
HttpResponse.json(EMPTY),
|
||||
),
|
||||
http.get("/v1/infrastructure/api-keys", () => HttpResponse.json([])),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,48 +1,20 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, EmptyState, Modal, Skeleton } from "@app/ui";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
fetchApiKeys,
|
||||
revokeApiKey,
|
||||
type ApiKey,
|
||||
type ApiKeysResponse,
|
||||
} from "@portal/api/infrastructure";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import { Button, EmptyState, Skeleton } from "@app/ui";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import { fetchApiKeys, type ApiKey } from "@portal/api/infrastructure";
|
||||
import { ApiKeyCard } from "@portal/components/infrastructure/ApiKeyCard";
|
||||
import { CreateKeyModal } from "@portal/components/infrastructure/CreateKeyModal";
|
||||
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
|
||||
|
||||
export function ApiKeysTab() {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingRevoke, setPendingRevoke] = useState<ApiKey | null>(null);
|
||||
const [revoking, setRevoking] = useState(false);
|
||||
const state = useAsync<ApiKeysResponse>(() => fetchApiKeys(), [reloadKey]);
|
||||
const { data, loading, error: loadError } = state;
|
||||
|
||||
const reload = () => setReloadKey((n) => n + 1);
|
||||
const keys = data?.keys ?? [];
|
||||
const isLoading = loading && data === null;
|
||||
// A failed load must not masquerade as a genuinely empty list.
|
||||
const isEmpty = !loading && !loadError && keys.length === 0;
|
||||
|
||||
async function confirmRevoke() {
|
||||
if (!pendingRevoke) return;
|
||||
setError(null);
|
||||
setRevoking(true);
|
||||
try {
|
||||
await revokeApiKey(pendingRevoke.id);
|
||||
setPendingRevoke(null);
|
||||
reload();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setRevoking(false);
|
||||
}
|
||||
}
|
||||
const state = useAsync<ApiKey[]>(() => fetchApiKeys(tier), [tier]);
|
||||
const { data: keys } = state;
|
||||
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||
|
||||
return (
|
||||
<div className="portal-infra__stack">
|
||||
@@ -60,14 +32,6 @@ export function ApiKeysTab() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
{!loading && loadError && (
|
||||
<Banner
|
||||
tone="danger"
|
||||
description={t("portal.infrastructure.apiKeys.error.load")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="portal-infra__stack" aria-hidden>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
@@ -84,52 +48,15 @@ export function ApiKeysTab() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{keys.length > 0 && (
|
||||
{keys && keys.length > 0 && (
|
||||
<div className="portal-infra__keys">
|
||||
{keys.map((k) => (
|
||||
<ApiKeyCard key={k.id} apiKey={k} onRevoke={setPendingRevoke} />
|
||||
<ApiKeyCard key={k.id} apiKey={k} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateKeyModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onCreated={reload}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={pendingRevoke !== null}
|
||||
onClose={() => !revoking && setPendingRevoke(null)}
|
||||
width="sm"
|
||||
title={t("portal.infrastructure.apiKeys.revoke.title")}
|
||||
footer={
|
||||
<div className="portal-infra__modal-actions">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
disabled={revoking}
|
||||
onClick={() => setPendingRevoke(null)}
|
||||
>
|
||||
{t("portal.infrastructure.apiKeys.revoke.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
accent="danger"
|
||||
loading={revoking}
|
||||
onClick={confirmRevoke}
|
||||
>
|
||||
{t("portal.infrastructure.apiKeys.revoke.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
{t("portal.infrastructure.apiKeys.revoke.body", {
|
||||
name: pendingRevoke?.name ?? "",
|
||||
})}
|
||||
</p>
|
||||
</Modal>
|
||||
<CreateKeyModal open={modalOpen} onClose={() => setModalOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,7 @@ const meta: Meta<typeof CreateKeyModal> = {
|
||||
title: "Portal/Infrastructure/CreateKeyModal",
|
||||
component: CreateKeyModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: {
|
||||
open: true,
|
||||
onClose: () => console.log("close"),
|
||||
onCreated: () => console.log("created"),
|
||||
},
|
||||
args: { open: true, onClose: () => console.log("close") },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CreateKeyModal>;
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Deterministic i18n: keys returned verbatim, so assertions are stable.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Stub the API layer so no real request is made; capture the create payload.
|
||||
// vi.hoisted keeps the mock fn defined before the hoisted vi.mock factory runs.
|
||||
const { createApiKey } = vi.hoisted(() => ({ createApiKey: vi.fn() }));
|
||||
vi.mock("@portal/api/infrastructure", () => ({ createApiKey }));
|
||||
|
||||
import { CreateKeyModal } from "@portal/components/infrastructure/CreateKeyModal";
|
||||
|
||||
const K = "portal.infrastructure.createKey";
|
||||
|
||||
function renderModal(props: Partial<ComponentProps<typeof CreateKeyModal>>) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<CreateKeyModal open onClose={() => {}} onCreated={() => {}} {...props} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("CreateKeyModal", () => {
|
||||
it("gates the create button on a non-empty name", () => {
|
||||
renderModal({});
|
||||
const cta = screen.getByRole("button", { name: `${K}.createKey` });
|
||||
expect(cta).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(`${K}.keyNamePlaceholder`), {
|
||||
target: { value: "Production ingest" },
|
||||
});
|
||||
expect(cta).toBeEnabled();
|
||||
});
|
||||
|
||||
it("creates a key and reveals the returned secret", async () => {
|
||||
createApiKey.mockResolvedValueOnce({
|
||||
key: { id: "1", name: "Production ingest" },
|
||||
secret: "sk_live_demo_key_rotate_in_prod",
|
||||
});
|
||||
const onCreated = vi.fn();
|
||||
renderModal({ onCreated });
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(`${K}.keyNamePlaceholder`), {
|
||||
target: { value: "Production ingest" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: `${K}.createKey` }));
|
||||
|
||||
expect(await screen.findByText(`${K}.secretWarning`)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("sk_live_demo_key_rotate_in_prod"),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() => expect(onCreated).toHaveBeenCalled());
|
||||
expect(createApiKey).toHaveBeenCalledWith({ name: "Production ingest" });
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,40 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, CodeBlock, FormField, Input, Modal } from "@app/ui";
|
||||
import { createApiKey, type CreatedApiKey } from "@portal/api/infrastructure";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Checkbox,
|
||||
CodeBlock,
|
||||
FormField,
|
||||
Input,
|
||||
Modal,
|
||||
} from "@app/ui";
|
||||
import type { ApiKeyPermission } from "@portal/api/infrastructure";
|
||||
|
||||
const PERMISSION_OPTS: ApiKeyPermission[] = ["Read", "Write", "Admin"];
|
||||
|
||||
// Shown once after a key is created. TODO(backend): use the one-time secret
|
||||
// returned by POST /v1/infrastructure/api-keys — it is never persisted server-side.
|
||||
const DEMO_NEW_KEY_SECRET = "sk_live_demo_key_rotate_in_prod";
|
||||
|
||||
export function CreateKeyModal({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Called after a successful create so the tab can refresh its list. */
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [created, setCreated] = useState<CreatedApiKey | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [perms, setPerms] = useState<ApiKeyPermission[]>(["Read"]);
|
||||
const [ips, setIps] = useState("");
|
||||
const [created, setCreated] = useState(false);
|
||||
|
||||
function reset() {
|
||||
setName("");
|
||||
setCreated(null);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
setPerms(["Read"]);
|
||||
setIps("");
|
||||
setCreated(false);
|
||||
}
|
||||
|
||||
function close() {
|
||||
@@ -33,18 +43,16 @@ export function CreateKeyModal({
|
||||
setTimeout(reset, 200);
|
||||
}
|
||||
|
||||
async function createKey() {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await createApiKey({ name: name.trim() });
|
||||
setCreated(result);
|
||||
onCreated();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
function togglePerm(p: ApiKeyPermission) {
|
||||
setPerms((prev) =>
|
||||
prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p],
|
||||
);
|
||||
}
|
||||
|
||||
function createKey() {
|
||||
// TODO(backend): POST /v1/infrastructure/api-keys { name, perms, ips }
|
||||
// and render the one-time secret from the response instead of the fixture.
|
||||
setCreated(true);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -64,7 +72,7 @@ export function CreateKeyModal({
|
||||
}
|
||||
footer={
|
||||
created ? (
|
||||
<Button variant="primary" onClick={close}>
|
||||
<Button variant="primary" accent="premium" onClick={close}>
|
||||
{t("portal.infrastructure.createKey.done")}
|
||||
</Button>
|
||||
) : (
|
||||
@@ -74,7 +82,8 @@ export function CreateKeyModal({
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={name.trim() === "" || submitting}
|
||||
accent="premium"
|
||||
disabled={name.trim() === "" || perms.length === 0}
|
||||
onClick={createKey}
|
||||
>
|
||||
{t("portal.infrastructure.createKey.createKey")}
|
||||
@@ -86,7 +95,7 @@ export function CreateKeyModal({
|
||||
{created ? (
|
||||
<div className="portal-infra__stack">
|
||||
<CodeBlock
|
||||
code={created.secret}
|
||||
code={DEMO_NEW_KEY_SECRET}
|
||||
lang="bash"
|
||||
caption={t("portal.infrastructure.createKey.secretKeyCaption")}
|
||||
/>
|
||||
@@ -97,8 +106,6 @@ export function CreateKeyModal({
|
||||
</div>
|
||||
) : (
|
||||
<div className="portal-infra__form">
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
|
||||
<FormField
|
||||
label={t("portal.infrastructure.createKey.keyNameLabel")}
|
||||
required
|
||||
@@ -111,6 +118,35 @@ export function CreateKeyModal({
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t("portal.infrastructure.createKey.permissionsLabel")}
|
||||
>
|
||||
<div className="portal-infra__perm-row">
|
||||
{PERMISSION_OPTS.map((p) => (
|
||||
<Checkbox
|
||||
key={p}
|
||||
label={t(
|
||||
`portal.infrastructure.apiKeyPermission.${p.toLowerCase()}`,
|
||||
p,
|
||||
)}
|
||||
checked={perms.includes(p)}
|
||||
onChange={() => togglePerm(p)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t("portal.infrastructure.createKey.ipAllowlistLabel")}
|
||||
helperText={t("portal.infrastructure.createKey.ipAllowlistHelper")}
|
||||
>
|
||||
<Input
|
||||
value={ips}
|
||||
onChange={(e) => setIps(e.target.value)}
|
||||
placeholder="52.14.0.0/16, 203.0.113.7/32"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -55,11 +55,13 @@ export const DEPLOY_LABEL: Record<DeploymentStatus, string> = {
|
||||
export const KEY_TONE: Record<ApiKeyStatus, StatusTone> = {
|
||||
active: "success",
|
||||
revoked: "danger",
|
||||
"rotate-soon": "warning",
|
||||
};
|
||||
|
||||
export const KEY_LABEL: Record<ApiKeyStatus, string> = {
|
||||
active: "portal.infrastructure.keyLabel.active",
|
||||
revoked: "portal.infrastructure.keyLabel.revoked",
|
||||
"rotate-soon": "portal.infrastructure.keyLabel.rotateSoon",
|
||||
};
|
||||
|
||||
export const CERT_TONE: Record<CertStatus, StatusTone> = {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
|
||||
import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// A stand-in tool-settings UI that uses the shared editor Tooltip. The Tooltip
|
||||
// pulls in the Preferences + Sidebar contexts, which the portal does not mount
|
||||
// app-wide — so this reproduces the "usePreferences must be used within a
|
||||
// PreferencesProvider" crash unless PipelineStepSettings supplies them.
|
||||
function TooltipSettings() {
|
||||
return (
|
||||
<Tooltip content="help">
|
||||
<button type="button">field</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const step = {
|
||||
support: "editable",
|
||||
toolId: "compress",
|
||||
params: {},
|
||||
} as unknown as WorkingToolStep;
|
||||
|
||||
const registry = {
|
||||
compress: { automationSettings: TooltipSettings },
|
||||
} as unknown as Partial<ToolRegistry>;
|
||||
|
||||
describe("PipelineStepSettings", () => {
|
||||
it("renders reused editor tool settings (which use the shared Tooltip) without app-wide Preferences/Sidebar providers", () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<MantineProvider>
|
||||
<PipelineStepSettings
|
||||
step={step}
|
||||
registry={registry}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(screen.getByText("field")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner } from "@app/ui";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import { SidebarProvider } from "@app/contexts/SidebarContext";
|
||||
import { type ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import { type ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes";
|
||||
import { type WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
|
||||
@@ -48,18 +46,14 @@ export function PipelineStepSettings({
|
||||
}
|
||||
|
||||
return (
|
||||
<PreferencesProvider>
|
||||
<SidebarProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Settings
|
||||
parameters={step.params}
|
||||
onParameterChange={(key, value) =>
|
||||
onChange({ ...step.params, [key]: value })
|
||||
}
|
||||
disabled={false}
|
||||
/>
|
||||
</Suspense>
|
||||
</SidebarProvider>
|
||||
</PreferencesProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Settings
|
||||
parameters={step.params}
|
||||
onParameterChange={(key, value) =>
|
||||
onChange({ ...step.params, [key]: value })
|
||||
}
|
||||
disabled={false}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fireEvent,
|
||||
render as baseRender,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
|
||||
import {
|
||||
POLICY_CATEGORIES,
|
||||
POLICY_CONFIG,
|
||||
type CatalogueEntry,
|
||||
type DecoratedPolicy,
|
||||
type PolicySetupResult,
|
||||
type PipelineStep,
|
||||
} from "@portal/api/policies";
|
||||
|
||||
const render = (ui: Parameters<typeof baseRender>[0]) =>
|
||||
baseRender(ui, { wrapper: MantineProvider });
|
||||
|
||||
// Deterministic i18n: return the fallback when given, else the key. initReactI18next is stubbed
|
||||
// because the import graph pulls core/i18n.ts, which registers it as a plugin.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
// Second arg is a string fallback in some call sites and an interpolation object in others;
|
||||
// only treat a string as the fallback.
|
||||
t: (key: string, fallback?: unknown) =>
|
||||
typeof fallback === "string" ? fallback : key,
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
initReactI18next: { type: "3rdParty", init: vi.fn() },
|
||||
}));
|
||||
|
||||
const fetchSources = vi.fn();
|
||||
vi.mock("@portal/api/sources", () => ({
|
||||
fetchSources: () => fetchSources(),
|
||||
}));
|
||||
|
||||
const CONTINUE = "portal.policies.wizard.actions.continue";
|
||||
const SAVE_CHANGES = "portal.policies.wizard.actions.saveChanges";
|
||||
const ENABLE = "portal.policies.wizard.actions.enablePolicy";
|
||||
|
||||
const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
|
||||
const securityConfig = POLICY_CONFIG.security;
|
||||
|
||||
function editEntry(steps: PipelineStep[]): CatalogueEntry {
|
||||
const policy: DecoratedPolicy = {
|
||||
category: security,
|
||||
config: securityConfig,
|
||||
state: {
|
||||
configured: true,
|
||||
status: "active",
|
||||
sources: ["editor"],
|
||||
scopeTypes: [],
|
||||
reviewerEmail: "",
|
||||
fieldValues: {},
|
||||
runOn: "upload",
|
||||
outputMode: "new_version",
|
||||
outputName: "",
|
||||
outputNamePosition: "suffix",
|
||||
maxRetries: 0,
|
||||
retryDelayMinutes: 0,
|
||||
backendId: "pol-1",
|
||||
isDefault: true,
|
||||
},
|
||||
steps,
|
||||
stats: { enforced: 0, dataProcessed: "-", activeFor: "-" },
|
||||
activity: [],
|
||||
};
|
||||
return { category: security, config: securityConfig, policy };
|
||||
}
|
||||
|
||||
/** Advance the wizard from the workflow tab to the settings tab and submit. */
|
||||
async function submitWizard(saveLabel: string) {
|
||||
fireEvent.click(await screen.findByRole("button", { name: CONTINUE }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: saveLabel }));
|
||||
}
|
||||
|
||||
describe("PolicySetupWizard", () => {
|
||||
beforeEach(() => {
|
||||
fetchSources.mockResolvedValue({ sources: [] });
|
||||
});
|
||||
|
||||
it("round-trips a saved step's backend params on edit", async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
const entry = editEntry([
|
||||
{
|
||||
operation: "/api/v1/security/auto-redact",
|
||||
parameters: { listOfText: "foo\nbar", useRegex: true },
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={onSubmit} />,
|
||||
);
|
||||
await submitWizard(SAVE_CHANGES);
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
const result = onSubmit.mock.calls[0][1] as PolicySetupResult;
|
||||
// Only the saved tool is enabled on edit, and its patterns survive the wire -> UI -> wire trip.
|
||||
expect(result.steps).toEqual([
|
||||
expect.objectContaining({
|
||||
operation: "/api/v1/security/auto-redact",
|
||||
parameters: expect.objectContaining({ listOfText: "foo\nbar" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("seeds the preset chain for a new policy (redact + sanitize on, watermark off)", async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
const entry: CatalogueEntry = {
|
||||
category: security,
|
||||
config: securityConfig,
|
||||
policy: null,
|
||||
};
|
||||
|
||||
render(
|
||||
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={onSubmit} />,
|
||||
);
|
||||
await submitWizard(ENABLE);
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
const result = onSubmit.mock.calls[0][1] as PolicySetupResult;
|
||||
const endpoints = result.steps.map((s) => s.operation);
|
||||
expect(endpoints).toEqual([
|
||||
"/api/v1/security/auto-redact",
|
||||
"/api/v1/security/sanitize-pdf",
|
||||
]);
|
||||
// Redact carries the preset PII patterns as the backend's listOfText.
|
||||
const redact = result.steps[0].parameters as { listOfText?: string };
|
||||
expect(redact.listOfText).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -13,24 +13,23 @@ import {
|
||||
} from "@app/ui";
|
||||
import { SettingsRow } from "@app/ui/SettingsRow";
|
||||
import {
|
||||
TOOL_ENDPOINTS,
|
||||
humanizeEndpoint,
|
||||
type CatalogueEntry,
|
||||
type PipelineStep,
|
||||
type PolicySetupResult,
|
||||
} from "@portal/api/policies";
|
||||
import type { ToolRegistry, ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
import {
|
||||
policyEndpoint,
|
||||
policyStepFromWire,
|
||||
policyStepToWire,
|
||||
type PolicyParams,
|
||||
type PolicyToolId,
|
||||
type PolicyToolStep,
|
||||
} from "@app/policies/operations";
|
||||
deserializeToolStep,
|
||||
serializeStepFromEndpoint,
|
||||
} from "@app/hooks/tools/shared/toolAutomation";
|
||||
import { fetchSources } from "@portal/api/sources";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow";
|
||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
import { sourceTypeMeta } from "@portal/components/sources/sourceTypes";
|
||||
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
||||
import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
|
||||
import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig";
|
||||
import "@portal/views/Policies.css";
|
||||
@@ -48,8 +47,12 @@ interface PolicySetupWizardProps {
|
||||
|
||||
type Step = "workflow" | "settings";
|
||||
|
||||
/** A policy step plus whether it runs. */
|
||||
type ToolState = PolicyToolStep & { enabled: boolean };
|
||||
/** A configurable tool in the workflow step: whether it runs + its params. */
|
||||
interface ToolState {
|
||||
operation: string;
|
||||
enabled: boolean;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Resolve each field's effective value: saved override, else definition default. */
|
||||
function resolveFieldValues(
|
||||
@@ -66,8 +69,9 @@ function resolveFieldValues(
|
||||
* round-trips); otherwise the category preset's default chain. Each preset step
|
||||
* starts enabled — the user toggles tools off in the workflow.
|
||||
*/
|
||||
// Temporary until the catalogue carries a defaultEnabled flag.
|
||||
const DISABLED_BY_DEFAULT = new Set<PolicyToolId>(["watermark"]);
|
||||
// Temporary: tracks which tools start disabled until the tool registry lands in
|
||||
// the portal and can drive this via registry metadata or a defaultEnabled flag.
|
||||
const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]);
|
||||
|
||||
/**
|
||||
* Policy-facing framing for each capability a policy can include. Labels and
|
||||
@@ -77,43 +81,43 @@ const DISABLED_BY_DEFAULT = new Set<PolicyToolId>(["watermark"]);
|
||||
* the humanised endpoint name with no description.
|
||||
*/
|
||||
const CAPABILITY_META: Record<
|
||||
PolicyToolId,
|
||||
string,
|
||||
{ labelKey: string; labelEn: string; descKey: string; descEn: string }
|
||||
> = {
|
||||
redact: {
|
||||
[TOOL_ENDPOINTS.redact]: {
|
||||
labelKey: "portal.policies.wizard.capability.redact.label",
|
||||
labelEn: "Redact sensitive information",
|
||||
descKey: "portal.policies.wizard.capability.redact.desc",
|
||||
descEn:
|
||||
"Finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read.",
|
||||
},
|
||||
sanitize: {
|
||||
[TOOL_ENDPOINTS.sanitize]: {
|
||||
labelKey: "portal.policies.wizard.capability.sanitize.label",
|
||||
labelEn: "Strip active content",
|
||||
descKey: "portal.policies.wizard.capability.sanitize.desc",
|
||||
descEn:
|
||||
"Removes hidden JavaScript so nothing can run automatically when the document is opened.",
|
||||
},
|
||||
watermark: {
|
||||
[TOOL_ENDPOINTS.watermark]: {
|
||||
labelKey: "portal.policies.wizard.capability.watermark.label",
|
||||
labelEn: "Apply a watermark",
|
||||
descKey: "portal.policies.wizard.capability.watermark.desc",
|
||||
descEn: "Stamps a visible mark (e.g. “Confidential”) across every page.",
|
||||
},
|
||||
ocr: {
|
||||
[TOOL_ENDPOINTS.ocr]: {
|
||||
labelKey: "portal.policies.wizard.capability.ocr.label",
|
||||
labelEn: "Make text searchable",
|
||||
descKey: "portal.policies.wizard.capability.ocr.desc",
|
||||
descEn: "Runs OCR so scanned pages become selectable, searchable text.",
|
||||
},
|
||||
flatten: {
|
||||
[TOOL_ENDPOINTS.flatten]: {
|
||||
labelKey: "portal.policies.wizard.capability.flatten.label",
|
||||
labelEn: "Flatten the document",
|
||||
descKey: "portal.policies.wizard.capability.flatten.desc",
|
||||
descEn:
|
||||
"Merges form fields and annotations into the page so they can't be edited.",
|
||||
},
|
||||
compress: {
|
||||
[TOOL_ENDPOINTS.compress]: {
|
||||
labelKey: "portal.policies.wizard.capability.compress.label",
|
||||
labelEn: "Reduce file size",
|
||||
descKey: "portal.policies.wizard.capability.compress.desc",
|
||||
@@ -121,24 +125,29 @@ const CAPABILITY_META: Record<
|
||||
},
|
||||
};
|
||||
|
||||
function seedTools(entry: CatalogueEntry): ToolState[] {
|
||||
function seedTools(
|
||||
entry: CatalogueEntry,
|
||||
registry: Partial<ToolRegistry>,
|
||||
): ToolState[] {
|
||||
const savedSteps = entry.policy?.steps ?? [];
|
||||
const savedByTool = new Map<PolicyToolId, PolicyToolStep>();
|
||||
for (const wire of savedSteps) {
|
||||
const step = policyStepFromWire(wire);
|
||||
if (step) savedByTool.set(step.toolId, step);
|
||||
}
|
||||
// defaultOperations is the canonical list (so tools added later still show on edit); a saved
|
||||
// step's params win over the preset.
|
||||
return entry.config.defaultOperations.map((preset) => {
|
||||
const saved = savedByTool.get(preset.toolId);
|
||||
const savedByOp = new Map(savedSteps.map((s) => [s.operation, s]));
|
||||
// Always use defaultOperations as the canonical list so tools added after a
|
||||
// policy was first saved still appear when editing.
|
||||
return entry.config.defaultOperations.map((s) => {
|
||||
const saved = savedByOp.get(s.operation);
|
||||
return {
|
||||
...(saved ?? preset),
|
||||
operation: s.operation,
|
||||
enabled: saved
|
||||
? true
|
||||
: savedSteps.length > 0
|
||||
? false
|
||||
: !DISABLED_BY_DEFAULT.has(preset.toolId),
|
||||
: !DISABLED_BY_DEFAULT.has(s.operation),
|
||||
// Saved steps are in the backend contract shape; map them back to the UI
|
||||
// shape the config controls edit (e.g. `listOfText` -> `wordsToRedact`).
|
||||
// Presets are already authored in the UI shape, so use them as-is.
|
||||
parameters: saved
|
||||
? deserializeToolStep(saved, registry).params
|
||||
: s.parameters,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -176,12 +185,26 @@ function PolicySetupWizardBody({
|
||||
onSubmit: (entry: CatalogueEntry, result: PolicySetupResult) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { allTools: toolRegistry } = useToolRegistry();
|
||||
|
||||
// Portal tool operations are endpoint paths (/api/v1/…), not short registry IDs.
|
||||
// Build a reverse map so we can look up icons and display names by endpoint.
|
||||
const registryByEndpoint = useMemo(() => {
|
||||
const map = new Map<string, ToolRegistryEntry>();
|
||||
for (const entry of Object.values(toolRegistry)) {
|
||||
const ep = (entry as ToolRegistryEntry).operationConfig?.endpoint;
|
||||
if (typeof ep === "string") map.set(ep, entry as ToolRegistryEntry);
|
||||
}
|
||||
return map;
|
||||
}, [toolRegistry]);
|
||||
|
||||
const { category, config, policy } = entry;
|
||||
const isEdit = policy != null;
|
||||
|
||||
const [step, setStep] = useState<Step>("workflow");
|
||||
const [tools, setTools] = useState<ToolState[]>(() => seedTools(entry));
|
||||
const [tools, setTools] = useState<ToolState[]>(() =>
|
||||
seedTools(entry, toolRegistry),
|
||||
);
|
||||
const [fieldValues, setFieldValues] = useState(() =>
|
||||
resolveFieldValues(entry),
|
||||
);
|
||||
@@ -190,11 +213,22 @@ function PolicySetupWizardBody({
|
||||
);
|
||||
|
||||
const sourcesAsync = useAsync(() => fetchSources(), []);
|
||||
const availableSources = useMemo(
|
||||
() =>
|
||||
(sourcesAsync.data?.sources ?? []).filter((s) => s.status !== "disabled"),
|
||||
[sourcesAsync.data],
|
||||
);
|
||||
const availableSources = useMemo(() => {
|
||||
const backendSources = (sourcesAsync.data?.sources ?? []).filter(
|
||||
(s) => s.status !== "disabled",
|
||||
);
|
||||
const editorSource = {
|
||||
id: "editor",
|
||||
name: t("portal.sources.types.editor.label"),
|
||||
type: "editor",
|
||||
status: "active" as const,
|
||||
referenceCount: 0,
|
||||
referencingPolicies: [],
|
||||
config: [],
|
||||
docsTotal: null,
|
||||
};
|
||||
return [editorSource, ...backendSources];
|
||||
}, [sourcesAsync.data, t]);
|
||||
// Document-type scoping has no UI; preserve any saved scope on edit and
|
||||
// default new policies to all document types.
|
||||
const [scopeTypes] = useState<string[]>(policy?.state.scopeTypes ?? []);
|
||||
@@ -222,20 +256,9 @@ function PolicySetupWizardBody({
|
||||
|
||||
const enabledTools = useMemo(() => tools.filter((tl) => tl.enabled), [tools]);
|
||||
|
||||
function setToolEnabled(toolId: PolicyToolId, enabled: boolean) {
|
||||
function patchTool(operation: string, patch: Partial<ToolState>) {
|
||||
setTools((prev) =>
|
||||
prev.map((tl) => (tl.toolId === toolId ? { ...tl, enabled } : tl)),
|
||||
);
|
||||
}
|
||||
|
||||
function setToolParams<Id extends PolicyToolId>(
|
||||
toolId: Id,
|
||||
params: PolicyParams<Id>,
|
||||
) {
|
||||
setTools((prev) =>
|
||||
prev.map((tl) =>
|
||||
tl.toolId === toolId ? ({ ...tl, params } as ToolState) : tl,
|
||||
),
|
||||
prev.map((tl) => (tl.operation === operation ? { ...tl, ...patch } : tl)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -254,8 +277,11 @@ function PolicySetupWizardBody({
|
||||
}
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
// Map each tool's UI-shaped params (e.g. redact's `wordsToRedact`) into the
|
||||
// backend step contract (e.g. `listOfText`) via its `toApiParams`; saving the
|
||||
// UI shape verbatim would drop those fields and the step would run with none.
|
||||
const steps: PipelineStep[] = enabledTools.map((tl) =>
|
||||
policyStepToWire(tl),
|
||||
serializeStepFromEndpoint(tl.operation, tl.parameters, toolRegistry),
|
||||
);
|
||||
try {
|
||||
await onSubmit(entry, {
|
||||
@@ -360,16 +386,20 @@ function PolicySetupWizardBody({
|
||||
<Card padding="none">
|
||||
<div className="portal-policies__capabilities">
|
||||
{tools.map((tl) => {
|
||||
const meta = CAPABILITY_META[tl.toolId];
|
||||
const meta = CAPABILITY_META[tl.operation];
|
||||
const label = meta
|
||||
? t(meta.labelKey, meta.labelEn)
|
||||
: humanizeEndpoint(policyEndpoint(tl.toolId), t);
|
||||
: (registryByEndpoint.get(tl.operation)?.name ??
|
||||
humanizeEndpoint(tl.operation, t));
|
||||
const description = meta
|
||||
? t(meta.descKey, meta.descEn)
|
||||
: undefined;
|
||||
const hasConfig =
|
||||
tl.operation === TOOL_ENDPOINTS.redact ||
|
||||
tl.operation === TOOL_ENDPOINTS.watermark;
|
||||
return (
|
||||
<div
|
||||
key={tl.toolId}
|
||||
key={tl.operation}
|
||||
className="portal-policies__capability"
|
||||
data-on={tl.enabled || undefined}
|
||||
>
|
||||
@@ -381,27 +411,27 @@ function PolicySetupWizardBody({
|
||||
size="sm"
|
||||
checked={tl.enabled}
|
||||
onChange={(checked) =>
|
||||
setToolEnabled(tl.toolId, checked)
|
||||
patchTool(tl.operation, { enabled: checked })
|
||||
}
|
||||
label=""
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{tl.enabled && (
|
||||
{tl.enabled && hasConfig && (
|
||||
<div className="portal-policies__capability-config">
|
||||
{tl.toolId === "redact" && (
|
||||
{tl.operation === TOOL_ENDPOINTS.redact && (
|
||||
<PolicyRedactConfig
|
||||
parameters={tl.params}
|
||||
onChange={(params) =>
|
||||
setToolParams("redact", params)
|
||||
parameters={tl.parameters}
|
||||
onChange={(parameters) =>
|
||||
patchTool(tl.operation, { parameters })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{tl.toolId === "watermark" && (
|
||||
{tl.operation === TOOL_ENDPOINTS.watermark && (
|
||||
<PolicyWatermarkConfig
|
||||
parameters={tl.params}
|
||||
onChange={(params) =>
|
||||
setToolParams("watermark", params)
|
||||
parameters={tl.parameters}
|
||||
onChange={(parameters) =>
|
||||
patchTool(tl.operation, { parameters })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -445,8 +475,9 @@ function PolicySetupWizardBody({
|
||||
{t("portal.policies.wizard.sources.loading")}
|
||||
</p>
|
||||
) : (
|
||||
// The backend always returns the editor as a virtual source, so the
|
||||
// loaded list is never empty - no "no sources" state exists.
|
||||
// The editor is always an available source (unconditionally prepended
|
||||
// to availableSources), so the list is never empty — no "no sources"
|
||||
// state exists.
|
||||
<div className="portal-policies__sources">
|
||||
{availableSources.map((src) => (
|
||||
// A selectable multi-line tile (icon + name + type + check).
|
||||
|
||||
@@ -20,10 +20,6 @@ export interface NavEntry {
|
||||
externalUrl?: string;
|
||||
}
|
||||
|
||||
// Developer docs has no built-in portal page yet, so the tab opens the hosted docs
|
||||
// site in a new tab rather than routing to an empty page.
|
||||
const DEVELOPER_DOCS_URL = "https://docs.stirlingpdf.com/";
|
||||
|
||||
// Sidebar nav groups. This is a flavor seam: the SaaS build shadows this file to
|
||||
// drop sections not yet shipped there (see src/portal-saas/components/sidebarGroups).
|
||||
export const GROUP_PRIMARY: NavEntry[] = [{ id: "home", icon: <HomeIcon /> }];
|
||||
@@ -40,5 +36,5 @@ export const GROUP_OPERATIONAL: NavEntry[] = [
|
||||
export const GROUP_PLATFORM: NavEntry[] = [
|
||||
{ id: "infrastructure", icon: <InfrastructureIcon /> },
|
||||
{ id: "usage", icon: <UsageIcon /> },
|
||||
{ id: "docs", icon: <DocsIcon />, externalUrl: DEVELOPER_DOCS_URL },
|
||||
{ id: "docs", icon: <DocsIcon /> },
|
||||
];
|
||||
|
||||
@@ -30,7 +30,7 @@ export const VIEW_LABELS: Record<ViewId, string> = {
|
||||
components: "Components",
|
||||
infrastructure: "Infrastructure",
|
||||
usage: "Usage & Billing",
|
||||
docs: "Developer Docs",
|
||||
docs: "Documentation",
|
||||
procurement: "Procurement",
|
||||
settings: "Settings",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractHeadings, slugify } from "@portal/docs/headings";
|
||||
|
||||
describe("slugify", () => {
|
||||
it("lowercases, hyphenates, and trims punctuation", () => {
|
||||
expect(slugify("How it Works!")).toBe("how-it-works");
|
||||
expect(slugify(" Trailing & spaces ")).toBe("trailing-spaces");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractHeadings", () => {
|
||||
it("extracts H2/H3 only, with slugs matching the rendered ids", () => {
|
||||
const md = [
|
||||
"# Page Title",
|
||||
"## How it Works",
|
||||
"text",
|
||||
"### Sub Section",
|
||||
"#### Too Deep",
|
||||
"## Operations",
|
||||
].join("\n");
|
||||
expect(extractHeadings(md)).toEqual([
|
||||
{ level: 2, text: "How it Works", slug: "how-it-works" },
|
||||
{ level: 3, text: "Sub Section", slug: "sub-section" },
|
||||
{ level: 2, text: "Operations", slug: "operations" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("de-duplicates repeated heading text into unique slugs", () => {
|
||||
const md = ["## What Changed", "### What Changed", "## What Changed"].join(
|
||||
"\n",
|
||||
);
|
||||
expect(extractHeadings(md).map((h) => h.slug)).toEqual([
|
||||
"what-changed",
|
||||
"what-changed-1",
|
||||
"what-changed-2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores headings inside fenced code and strips inline marks", () => {
|
||||
const md = ["```", "## not a heading", "```", "## `Code` and *em*"].join(
|
||||
"\n",
|
||||
);
|
||||
expect(extractHeadings(md)).toEqual([
|
||||
{ level: 2, text: "Code and em", slug: "code-and-em" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Heading extraction for the "On this page" table of contents. The same
|
||||
* `slugify` is used here and in MarkdownDoc's heading renderer, so the TOC links
|
||||
* and the rendered heading ids always match.
|
||||
*/
|
||||
|
||||
export interface Heading {
|
||||
/** 2 or 3 (H2/H3). */
|
||||
level: number;
|
||||
text: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
/** "How it Works!" → "how-it-works" (the base id, before de-duplication). */
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* A stateful slugger that de-duplicates: repeated heading text gets `-1`, `-2`, …
|
||||
* The TOC extraction and MarkdownDoc's heading renderer each make one and feed it
|
||||
* headings in document order, so their slugs (and thus link ↔ id) always match.
|
||||
*/
|
||||
export function makeSlugger(): (text: string) => string {
|
||||
const seen = new Map<string, number>();
|
||||
return (text: string) => {
|
||||
const base = slugify(text) || "section";
|
||||
const n = seen.get(base) ?? 0;
|
||||
seen.set(base, n + 1);
|
||||
return n === 0 ? base : `${base}-${n}`;
|
||||
};
|
||||
}
|
||||
|
||||
/** Extract H2/H3 headings from a doc body, skipping fenced code blocks. */
|
||||
export function extractHeadings(markdown: string): Heading[] {
|
||||
const noCode = markdown.replace(/^(```|~~~)[\s\S]*?^\1[ \t]*$/gm, "");
|
||||
const slug = makeSlugger();
|
||||
const headings: Heading[] = [];
|
||||
for (const m of noCode.matchAll(/^ {0,3}(#{2,3})[ \t]+(.+?)[ \t]*#*$/gm)) {
|
||||
const text = m[2].replace(/[`*_]/g, "").trim();
|
||||
if (text) headings.push({ level: m[1].length, text, slug: slug(text) });
|
||||
}
|
||||
return headings;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Runtime accessors over the generated docs manifest. This is the only module
|
||||
* that imports the (large) JSON, so lazy-loading the docs view keeps it in its
|
||||
* own chunk. Regenerate the JSON with `npm run docs:sync`.
|
||||
*/
|
||||
// Imported as a raw string (not a JSON module) so tsc doesn't infer a ~half-MB
|
||||
// literal type; parsed once here into the typed manifest.
|
||||
import manifestRaw from "@portal/generated/docsManifest.json?raw";
|
||||
import type {
|
||||
DocEntry,
|
||||
DocsManifest,
|
||||
DocsNavSection,
|
||||
} from "@portal/docs/manifest/transform";
|
||||
|
||||
const manifest = JSON.parse(manifestRaw) as DocsManifest;
|
||||
|
||||
/** Provenance of the current manifest (repo + ref it was generated from). */
|
||||
export const docsSource = manifest.source;
|
||||
|
||||
/** The auto-sorted nav tree (sections → items). */
|
||||
export function loadDocsNav(): DocsNavSection[] {
|
||||
return manifest.nav;
|
||||
}
|
||||
|
||||
/** A single doc by id, or undefined if it isn't in the manifest. */
|
||||
export function loadDoc(id: string): DocEntry | undefined {
|
||||
return manifest.docs[id];
|
||||
}
|
||||
|
||||
/** Every doc, for building the search index. */
|
||||
export function allDocs(): DocEntry[] {
|
||||
return Object.values(manifest.docs);
|
||||
}
|
||||
|
||||
/** The first doc id (first item of the first section) — the default landing. */
|
||||
export function firstDocId(): string | undefined {
|
||||
return manifest.nav[0]?.items[0]?.id;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildManifest,
|
||||
convertAdmonitions,
|
||||
demoteHeadings,
|
||||
docIdForPath,
|
||||
humanize,
|
||||
parseFrontmatter,
|
||||
resolveRelative,
|
||||
rewriteReferences,
|
||||
sectionIcon,
|
||||
stripJsxTags,
|
||||
stripMdxImports,
|
||||
stripRedundantH1,
|
||||
type CategoryMap,
|
||||
type RawDoc,
|
||||
} from "@portal/docs/manifest/transform";
|
||||
|
||||
const OPTS = {
|
||||
repo: "Owner/Repo",
|
||||
ref: "main",
|
||||
root: "docs",
|
||||
siteBaseUrl: "https://docs.example.com",
|
||||
};
|
||||
|
||||
describe("parseFrontmatter", () => {
|
||||
it("splits scalar YAML frontmatter from the body", () => {
|
||||
const { data, body } = parseFrontmatter(
|
||||
"---\ntitle: OCR\nsidebar_position: 7\n---\n# Heading\ntext",
|
||||
);
|
||||
expect(data.title).toBe("OCR");
|
||||
expect(data.sidebar_position).toBe(7);
|
||||
expect(body).toBe("# Heading\ntext");
|
||||
});
|
||||
|
||||
it("returns the whole content as body when there is no frontmatter", () => {
|
||||
const { data, body } = parseFrontmatter("# Just a doc\nbody");
|
||||
expect(data).toEqual({});
|
||||
expect(body).toBe("# Just a doc\nbody");
|
||||
});
|
||||
|
||||
it("normalises CRLF line endings", () => {
|
||||
const { data } = parseFrontmatter("---\r\nid: x\r\n---\r\nbody");
|
||||
expect(data.id).toBe("x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("id + label helpers", () => {
|
||||
it("slugifies nested paths", () => {
|
||||
expect(docIdForPath("Configuration/OCR.md")).toBe("configuration/ocr");
|
||||
expect(docIdForPath("Getting Started.md")).toBe("getting-started");
|
||||
});
|
||||
|
||||
it("humanises file/dir names", () => {
|
||||
expect(humanize("Getting-Started.md")).toBe("Getting Started");
|
||||
});
|
||||
|
||||
it("picks a section icon from the label", () => {
|
||||
expect(sectionIcon("Configuration")).toBe("⚙");
|
||||
expect(sectionIcon("Totally Unknown")).toBe("◇");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MDX normalisation", () => {
|
||||
it("converts admonitions to titled blockquotes", () => {
|
||||
const out = convertAdmonitions(":::tip Upgrading?\nread this\n:::");
|
||||
expect(out).toContain("> **💡 Tip: Upgrading?**");
|
||||
expect(out).toContain("> read this");
|
||||
});
|
||||
|
||||
it("strips import/export statements", () => {
|
||||
const out = stripMdxImports("import Tabs from '@theme/Tabs';\n# Keep");
|
||||
expect(out).toBe("# Keep");
|
||||
});
|
||||
|
||||
it("removes JSX component tags but keeps inner content", () => {
|
||||
expect(
|
||||
stripJsxTags("<Tabs>\n<TabItem value='a'>keep</TabItem>\n</Tabs>"),
|
||||
).toContain("keep");
|
||||
expect(stripJsxTags("<TabItem>x</TabItem>")).not.toMatch(/<TabItem/);
|
||||
});
|
||||
|
||||
it("demotes body H1 to H2 but leaves code comments alone", () => {
|
||||
const md = "# Title\n\n```bash\n# a shell comment\n```";
|
||||
const out = demoteHeadings(md);
|
||||
expect(out).toContain("## Title");
|
||||
expect(out).toContain("# a shell comment");
|
||||
});
|
||||
|
||||
it("strips a leading H1 that duplicates the page title", () => {
|
||||
expect(stripRedundantH1("# OCR\nbody", "OCR")).toBe("body");
|
||||
expect(stripRedundantH1("# Other\nbody", "OCR")).toBe("# Other\nbody");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRelative", () => {
|
||||
it("collapses ./ and ../ against a base dir", () => {
|
||||
expect(resolveRelative("Configuration", "./OCR.md")).toBe(
|
||||
"Configuration/OCR.md",
|
||||
);
|
||||
expect(
|
||||
resolveRelative("Configuration", "../Functionality/Compare.md"),
|
||||
).toBe("Functionality/Compare.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteReferences", () => {
|
||||
const ctx = {
|
||||
dir: "Configuration",
|
||||
// Keys are lowercased file paths (spaces preserved), as buildManifest builds them.
|
||||
pathToId: new Map([
|
||||
[
|
||||
"configuration/system and security",
|
||||
"configuration/system-and-security",
|
||||
],
|
||||
]),
|
||||
rawBase: "https://raw.example.com/Owner/Repo/main",
|
||||
siteBaseUrl: "https://docs.example.com",
|
||||
};
|
||||
|
||||
it("rewrites resolvable internal links to the doc: scheme (decoded + case-insensitive)", () => {
|
||||
const out = rewriteReferences(
|
||||
"see [sec](./System%20and%20Security.md)",
|
||||
ctx,
|
||||
"docs",
|
||||
);
|
||||
expect(out).toBe("see [sec](doc:configuration/system-and-security)");
|
||||
});
|
||||
|
||||
it("falls back to the live docs site for unresolved internal links", () => {
|
||||
const out = rewriteReferences("[x](./Missing.md)", ctx, "docs");
|
||||
expect(out).toBe("[x](https://docs.example.com/Configuration/Missing)");
|
||||
});
|
||||
|
||||
it("leaves absolute and anchor links untouched", () => {
|
||||
const md = "[a](https://x.com) and [b](#top)";
|
||||
expect(rewriteReferences(md, ctx, "docs")).toBe(md);
|
||||
});
|
||||
|
||||
it("rewrites relative images to absolute raw URLs", () => {
|
||||
expect(rewriteReferences("", ctx, "docs")).toBe(
|
||||
"",
|
||||
);
|
||||
expect(rewriteReferences("", ctx, "docs")).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not rewrite inside fenced code blocks", () => {
|
||||
const md = "```\n[x](./y.md)\n```";
|
||||
expect(rewriteReferences(md, ctx, "docs")).toBe(md);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildManifest", () => {
|
||||
const rawDocs: RawDoc[] = [
|
||||
{
|
||||
relPath: "Getting Started.md",
|
||||
content: "---\nsidebar_position: 0\n---\nintro",
|
||||
},
|
||||
{
|
||||
relPath: "Configuration/OCR.md",
|
||||
content: "---\ntitle: OCR\nsidebar_position: 7\n---\n# OCR\nbody",
|
||||
},
|
||||
{
|
||||
relPath: "Configuration/DATABASE.md",
|
||||
content: "---\nsidebar_position: 1\n---\n# Database\nsee [ocr](./OCR.md)",
|
||||
},
|
||||
];
|
||||
const categories: CategoryMap = {
|
||||
Configuration: { label: "Configuration", position: 5 },
|
||||
};
|
||||
|
||||
it("auto-sorts sections (root Overview first, then by category position)", () => {
|
||||
const m = buildManifest(rawDocs, categories, OPTS);
|
||||
expect(m.nav.map((s) => s.id)).toEqual(["overview", "configuration"]);
|
||||
expect(m.nav[0].label).toBe("Overview");
|
||||
expect(m.nav[1].label).toBe("Configuration");
|
||||
});
|
||||
|
||||
it("orders nav items by sidebar_position", () => {
|
||||
const m = buildManifest(rawDocs, categories, OPTS);
|
||||
const config = m.nav.find((s) => s.id === "configuration")!;
|
||||
expect(config.items.map((i) => i.id)).toEqual([
|
||||
"configuration/database",
|
||||
"configuration/ocr",
|
||||
]);
|
||||
});
|
||||
|
||||
it("derives titles from frontmatter, heading, then filename", () => {
|
||||
const m = buildManifest(rawDocs, categories, OPTS);
|
||||
expect(m.docs["configuration/ocr"].title).toBe("OCR");
|
||||
expect(m.docs["getting-started"].title).toBe("Getting Started");
|
||||
});
|
||||
|
||||
it("resolves cross-doc links and records source/edit urls", () => {
|
||||
const m = buildManifest(rawDocs, categories, OPTS);
|
||||
expect(m.docs["configuration/database"].markdown).toContain(
|
||||
"[ocr](doc:configuration/ocr)",
|
||||
);
|
||||
expect(m.docs["configuration/ocr"].sourcePath).toBe(
|
||||
"docs/Configuration/OCR.md",
|
||||
);
|
||||
expect(m.docs["configuration/ocr"].editUrl).toBe(
|
||||
"https://github.com/Owner/Repo/blob/main/docs/Configuration/OCR.md",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* Pure transforms that turn the Docusaurus docs repo into the portal docs
|
||||
* manifest. No I/O and no external deps so `tsx` (the sync CLI) and vitest can
|
||||
* both use it. The sync CLI does the fetching; this module does the shaping.
|
||||
*
|
||||
* The auto-sort rules:
|
||||
* - every directory that directly holds markdown becomes a nav section,
|
||||
* labelled + ordered by its `_category_.json` (root files → "Overview"),
|
||||
* - each `.md`/`.mdx` file becomes a nav item, ordered by frontmatter
|
||||
* `sidebar_position` then title,
|
||||
* - Docusaurus MDX is normalised to plain GitHub-flavoured markdown that
|
||||
* react-markdown can render (admonitions, JSX, relative links, images).
|
||||
*/
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Manifest shape (mirrored structurally by @portal/api/docs) */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface DocsNavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
export interface DocsNavSection {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
items: DocsNavItem[];
|
||||
}
|
||||
|
||||
export interface DocEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
section: string;
|
||||
markdown: string;
|
||||
sourcePath: string;
|
||||
editUrl: string;
|
||||
}
|
||||
|
||||
export interface DocsManifest {
|
||||
source: { repo: string; ref: string; root: string };
|
||||
nav: DocsNavSection[];
|
||||
docs: Record<string, DocEntry>;
|
||||
}
|
||||
|
||||
/** One markdown file read from the repo, before shaping. */
|
||||
export interface RawDoc {
|
||||
/** Posix path relative to the docs root, e.g. "Configuration/OCR.md". */
|
||||
relPath: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** `_category_.json` contents, keyed by posix dir path relative to docs root. */
|
||||
export type CategoryMap = Record<string, { label?: string; position?: number }>;
|
||||
|
||||
export interface BuildOptions {
|
||||
repo: string;
|
||||
ref: string;
|
||||
/** Docs root within the repo, e.g. "docs". */
|
||||
root: string;
|
||||
/** Live docs site base, used as the fallback for unresolved internal links. */
|
||||
siteBaseUrl: string;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Small helpers */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const SECTION_ICONS: Array<[RegExp, string]> = [
|
||||
[/overview|getting started|start/i, "▶"],
|
||||
[/config/i, "⚙"],
|
||||
[/function|tool|feature/i, "▤"],
|
||||
[/install|deploy/i, "⤓"],
|
||||
[/migrat|upgrade/i, "⇄"],
|
||||
[/security|sign|auth/i, "🛡"],
|
||||
[/convert/i, "⇋"],
|
||||
[/page/i, "▦"],
|
||||
[/api|develop/i, "{ }"],
|
||||
];
|
||||
|
||||
/** A single-glyph icon for a section, chosen from its label. */
|
||||
export function sectionIcon(label: string): string {
|
||||
for (const [re, glyph] of SECTION_ICONS) if (re.test(label)) return glyph;
|
||||
return "◇";
|
||||
}
|
||||
|
||||
/** "Getting-Started_Guide" → "Getting Started Guide". */
|
||||
export function humanize(name: string): string {
|
||||
return name
|
||||
.replace(/\.mdx?$/i, "")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Lowercase, hyphenated, url-safe id for a path segment. */
|
||||
export function slugifySegment(name: string): string {
|
||||
return name
|
||||
.replace(/\.mdx?$/i, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/** Docs-root-relative posix path → stable doc id, e.g. "configuration/ocr". */
|
||||
export function docIdForPath(relPath: string): string {
|
||||
return relPath.split("/").map(slugifySegment).filter(Boolean).join("/");
|
||||
}
|
||||
|
||||
/** Posix dirname ("" for a root-level file). */
|
||||
export function dirOf(relPath: string): string {
|
||||
const i = relPath.lastIndexOf("/");
|
||||
return i === -1 ? "" : relPath.slice(0, i);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Frontmatter */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface Frontmatter {
|
||||
data: Record<string, string | number>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** Split leading `--- ... ---` YAML frontmatter (scalar keys only) from body. */
|
||||
export function parseFrontmatter(content: string): Frontmatter {
|
||||
const normalised = content.replace(/\r\n/g, "\n");
|
||||
if (!normalised.startsWith("---\n")) return { data: {}, body: normalised };
|
||||
const end = normalised.indexOf("\n---", 4);
|
||||
if (end === -1) return { data: {}, body: normalised };
|
||||
const raw = normalised.slice(4, end);
|
||||
const rest = normalised.slice(end + 4).replace(/^\n/, "");
|
||||
const data: Record<string, string | number> = {};
|
||||
for (const line of raw.split("\n")) {
|
||||
const m = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line);
|
||||
if (!m) continue;
|
||||
let value: string | number = m[2].trim().replace(/^["']|["']$/g, "");
|
||||
if (/^-?\d+(\.\d+)?$/.test(value)) value = Number(value);
|
||||
data[m[1]] = value;
|
||||
}
|
||||
return { data, body: rest };
|
||||
}
|
||||
|
||||
/** First `# H1` heading text in a body, if any. */
|
||||
export function firstHeading(body: string): string | undefined {
|
||||
const m = /^#\s+(.+?)\s*$/m.exec(stripCodeFences(body));
|
||||
return m ? m[1].trim() : undefined;
|
||||
}
|
||||
|
||||
/** Blank out fenced code blocks so heading/link scans ignore their contents. */
|
||||
function stripCodeFences(md: string): string {
|
||||
return md.replace(/^(```|~~~)[\s\S]*?^\1\s*$/gm, "");
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Body normalisation (MDX → plain markdown) */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Run `fn` over the non-fenced-code spans of `md`, leaving code blocks intact. */
|
||||
function mapOutsideCode(md: string, fn: (text: string) => string): string {
|
||||
const parts = md.split(/(^(?:```|~~~)[\s\S]*?^(?:```|~~~)\s*$)/gm);
|
||||
return parts.map((part, i) => (i % 2 === 0 ? fn(part) : part)).join("");
|
||||
}
|
||||
|
||||
const ADMONITION_META: Record<string, { icon: string; label: string }> = {
|
||||
tip: { icon: "💡", label: "Tip" },
|
||||
note: { icon: "📝", label: "Note" },
|
||||
info: { icon: "ℹ️", label: "Info" },
|
||||
warning: { icon: "⚠️", label: "Warning" },
|
||||
caution: { icon: "⚠️", label: "Caution" },
|
||||
danger: { icon: "🚫", label: "Danger" },
|
||||
};
|
||||
|
||||
/** `:::tip Title\n…\n:::` → a blockquote with a bold titled first line. */
|
||||
export function convertAdmonitions(md: string): string {
|
||||
const re = /^:::(\w+)[ \t]*(.*)\n([\s\S]*?)^:::[ \t]*$/gm;
|
||||
return md.replace(re, (_all, type: string, title: string, body: string) => {
|
||||
const meta = ADMONITION_META[type.toLowerCase()] ?? {
|
||||
icon: "•",
|
||||
label: humanize(type),
|
||||
};
|
||||
const heading = title.trim()
|
||||
? `${meta.label}: ${title.trim()}`
|
||||
: meta.label;
|
||||
const quoted = body
|
||||
.replace(/\s+$/, "")
|
||||
.split("\n")
|
||||
.map((l) => (l ? `> ${l}` : ">"))
|
||||
.join("\n");
|
||||
return `> **${meta.icon} ${heading}**\n>\n${quoted}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop MDX `import`/`export` statement lines (outside code). */
|
||||
export function stripMdxImports(md: string): string {
|
||||
return md
|
||||
.split("\n")
|
||||
.filter((l) => !/^\s*(import|export)\s.+from\s.+;?\s*$/.test(l))
|
||||
.filter((l) => !/^\s*import\s+['"][^'"]+['"];?\s*$/.test(l))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Remove JSX component tags (Capitalised), keeping any inner content. */
|
||||
export function stripJsxTags(md: string): string {
|
||||
return md.replace(/<\/?[A-Z][A-Za-z0-9.]*(?:\s[^>]*?)?\/?>/g, "");
|
||||
}
|
||||
|
||||
/** Resolve a relative posix path against a base dir, collapsing `.`/`..`. */
|
||||
export function resolveRelative(baseDir: string, target: string): string {
|
||||
const stack = baseDir ? baseDir.split("/") : [];
|
||||
for (const seg of target.split("/")) {
|
||||
if (seg === "" || seg === ".") continue;
|
||||
if (seg === "..") stack.pop();
|
||||
else stack.push(seg);
|
||||
}
|
||||
return stack.join("/");
|
||||
}
|
||||
|
||||
interface LinkContext {
|
||||
dir: string;
|
||||
pathToId: Map<string, string>;
|
||||
rawBase: string;
|
||||
siteBaseUrl: string;
|
||||
}
|
||||
|
||||
/** Split "path#anchor" → [path, "#anchor" | ""]. */
|
||||
function splitAnchor(target: string): [string, string] {
|
||||
const i = target.indexOf("#");
|
||||
return i === -1 ? [target, ""] : [target.slice(0, i), target.slice(i)];
|
||||
}
|
||||
|
||||
/** Percent-decode a link path, tolerating malformed escapes. */
|
||||
function decodePath(p: string): string {
|
||||
try {
|
||||
return decodeURIComponent(p);
|
||||
} catch {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the doc id for a relative link target. Keys in pathToId are lowercased
|
||||
* so links work whether they use the filename or a slug, in any case, and with
|
||||
* percent-encoded spaces (the repo links to "System%20and%20Security").
|
||||
*/
|
||||
function resolveDocId(ctx: LinkContext, rawPath: string): string | undefined {
|
||||
const resolved = resolveRelative(ctx.dir, decodePath(rawPath)).toLowerCase();
|
||||
const noExt = resolved.replace(/\.mdx?$/i, "");
|
||||
const candidates = [
|
||||
resolved,
|
||||
noExt,
|
||||
`${noExt.replace(/\/$/, "")}/index`,
|
||||
noExt.replace(/\/index$/i, ""),
|
||||
];
|
||||
for (const c of candidates) {
|
||||
const id = ctx.pathToId.get(c);
|
||||
if (id) return id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Rewrite a single markdown link target to a portal-usable href. */
|
||||
function rewriteLinkTarget(ctx: LinkContext, target: string): string {
|
||||
const trimmed = target.trim();
|
||||
if (/^(https?:|mailto:|tel:|#|doc:)/i.test(trimmed)) return trimmed;
|
||||
const [path, anchor] = splitAnchor(trimmed);
|
||||
if (!path) return trimmed;
|
||||
const id = resolveDocId(ctx, path);
|
||||
if (id) return `doc:${id}`;
|
||||
// Unresolved internal link → fall back to the live docs site (encode spaces).
|
||||
const slug = resolveRelative(ctx.dir, decodePath(path)).replace(
|
||||
/\.mdx?$/i,
|
||||
"",
|
||||
);
|
||||
const encoded = slug.split("/").map(encodeURIComponent).join("/");
|
||||
return `${ctx.siteBaseUrl}/${encoded}${anchor}`;
|
||||
}
|
||||
|
||||
/** Rewrite a relative image src to an absolute raw-content URL. */
|
||||
function rewriteImageSrc(ctx: LinkContext, src: string, root: string): string {
|
||||
const trimmed = src.trim();
|
||||
if (/^(https?:|data:)/i.test(trimmed)) return trimmed;
|
||||
if (trimmed.startsWith("/")) return `${ctx.rawBase}/static${trimmed}`;
|
||||
const resolved = resolveRelative(`${root}/${ctx.dir}`, trimmed);
|
||||
return `${ctx.rawBase}/${resolved}`;
|
||||
}
|
||||
|
||||
/** Rewrite markdown links + images (outside code) to portal/absolute targets. */
|
||||
export function rewriteReferences(
|
||||
md: string,
|
||||
ctx: LinkContext,
|
||||
root: string,
|
||||
): string {
|
||||
return mapOutsideCode(md, (text) => {
|
||||
// Images first so their `!` prefix isn't eaten by the link pattern.
|
||||
let out = text.replace(
|
||||
/!\[([^\]]*)\]\(([^)\s]+)([^)]*)\)/g,
|
||||
(_m, alt: string, src: string, tail: string) =>
|
||||
`}${tail})`,
|
||||
);
|
||||
out = out.replace(
|
||||
/(^|[^!])\[([^\]]+)\]\(([^)\s]+)([^)]*)\)/g,
|
||||
(_m, pre: string, label: string, href: string, tail: string) =>
|
||||
`${pre}[${label}](${rewriteLinkTarget(ctx, href)}${tail})`,
|
||||
);
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a leading `# H1` whose text equals the page title (avoids a dup head). */
|
||||
export function stripRedundantH1(md: string, title: string): string {
|
||||
const m = /^\s*#\s+(.+?)\s*(\n|$)/.exec(md);
|
||||
if (m && m[1].trim().toLowerCase() === title.trim().toLowerCase()) {
|
||||
return md.slice(m[0].length).replace(/^\n+/, "");
|
||||
}
|
||||
return md;
|
||||
}
|
||||
|
||||
/** Demote body `# H1` headings to `## H2` so the page title is the sole H1. */
|
||||
export function demoteHeadings(md: string): string {
|
||||
return mapOutsideCode(md, (text) => text.replace(/^# (?=\S)/gm, "## "));
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Section ordering */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const ROOT_SECTION_ID = "overview";
|
||||
|
||||
/** Composite order key: `_category_.json.position` down the dir path. */
|
||||
function sectionOrderKey(dir: string, categories: CategoryMap): number[] {
|
||||
if (dir === "") return [-1];
|
||||
const key: number[] = [];
|
||||
const segs = dir.split("/");
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
const sub = segs.slice(0, i + 1).join("/");
|
||||
key.push(categories[sub]?.position ?? 999);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function compareKeys(a: number[], b: number[]): number {
|
||||
const n = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = (a[i] ?? 0) - (b[i] ?? 0);
|
||||
if (d !== 0) return d;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Build */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface ShapedDoc extends DocEntry {
|
||||
navLabel: string;
|
||||
sidebarPosition: number;
|
||||
orderKey: number[];
|
||||
sectionLabel: string;
|
||||
}
|
||||
|
||||
/** Turn raw docs + category metadata into the full portal docs manifest. */
|
||||
export function buildManifest(
|
||||
rawDocs: RawDoc[],
|
||||
categories: CategoryMap,
|
||||
opts: BuildOptions,
|
||||
): DocsManifest {
|
||||
const rawBase = `https://raw.githubusercontent.com/${opts.repo}/${opts.ref}`;
|
||||
const editBase = `https://github.com/${opts.repo}/blob/${opts.ref}`;
|
||||
|
||||
// First pass: assign stable ids so links between docs can resolve. Keys are
|
||||
// lowercased (case-insensitive link matching); both with and without ext.
|
||||
const pathToId = new Map<string, string>();
|
||||
for (const doc of rawDocs) {
|
||||
const id = docIdForPath(doc.relPath);
|
||||
const lower = doc.relPath.toLowerCase();
|
||||
pathToId.set(lower, id);
|
||||
pathToId.set(lower.replace(/\.mdx?$/i, ""), id);
|
||||
}
|
||||
|
||||
const shaped: ShapedDoc[] = rawDocs.map((doc) => {
|
||||
const dir = dirOf(doc.relPath);
|
||||
const { data, body } = parseFrontmatter(doc.content);
|
||||
const title =
|
||||
(typeof data.title === "string" && data.title) ||
|
||||
firstHeading(body) ||
|
||||
humanize(doc.relPath.split("/").pop() ?? doc.relPath);
|
||||
const navLabel =
|
||||
(typeof data.sidebar_label === "string" && data.sidebar_label) || title;
|
||||
const description =
|
||||
typeof data.description === "string" ? data.description : undefined;
|
||||
|
||||
const ctx: LinkContext = {
|
||||
dir,
|
||||
pathToId,
|
||||
rawBase,
|
||||
siteBaseUrl: opts.siteBaseUrl.replace(/\/$/, ""),
|
||||
};
|
||||
let markdown = body;
|
||||
markdown = stripMdxImports(markdown);
|
||||
markdown = convertAdmonitions(markdown);
|
||||
markdown = stripJsxTags(markdown);
|
||||
markdown = rewriteReferences(markdown, ctx, opts.root);
|
||||
markdown = stripRedundantH1(markdown, title);
|
||||
markdown = demoteHeadings(markdown).trim();
|
||||
|
||||
const sectionId = dir === "" ? ROOT_SECTION_ID : docIdForPath(dir);
|
||||
const sectionLabel =
|
||||
dir === ""
|
||||
? "Overview"
|
||||
: (categories[dir]?.label ?? humanize(dir.split("/").pop() ?? dir));
|
||||
|
||||
return {
|
||||
id: docIdForPath(doc.relPath),
|
||||
title,
|
||||
navLabel,
|
||||
description,
|
||||
section: sectionId,
|
||||
markdown,
|
||||
sourcePath: `${opts.root}/${doc.relPath}`,
|
||||
editUrl: `${editBase}/${opts.root}/${doc.relPath}`,
|
||||
sidebarPosition:
|
||||
typeof data.sidebar_position === "number" ? data.sidebar_position : 999,
|
||||
orderKey: sectionOrderKey(dir, categories),
|
||||
sectionLabel,
|
||||
};
|
||||
});
|
||||
|
||||
// Group into sections and order everything deterministically.
|
||||
const bySection = new Map<string, ShapedDoc[]>();
|
||||
for (const doc of shaped) {
|
||||
const list = bySection.get(doc.section) ?? [];
|
||||
list.push(doc);
|
||||
bySection.set(doc.section, list);
|
||||
}
|
||||
|
||||
const nav: DocsNavSection[] = [...bySection.entries()]
|
||||
.map(([id, docs]) => {
|
||||
const first = docs[0];
|
||||
const items = [...docs]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.sidebarPosition - b.sidebarPosition ||
|
||||
a.navLabel.localeCompare(b.navLabel),
|
||||
)
|
||||
.map<DocsNavItem>((d) => ({ id: d.id, label: d.navLabel }));
|
||||
return {
|
||||
id,
|
||||
label: first.sectionLabel,
|
||||
icon: sectionIcon(first.sectionLabel),
|
||||
items,
|
||||
_key: first.orderKey,
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(a, b) => compareKeys(a._key, b._key) || a.label.localeCompare(b.label),
|
||||
)
|
||||
.map(({ _key, ...section }) => section);
|
||||
|
||||
const docs: Record<string, DocEntry> = {};
|
||||
for (const d of shaped) {
|
||||
docs[d.id] = {
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
description: d.description,
|
||||
section: d.section,
|
||||
markdown: d.markdown,
|
||||
sourcePath: d.sourcePath,
|
||||
editUrl: d.editUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
source: { repo: opts.repo, ref: opts.ref, root: opts.root },
|
||||
nav,
|
||||
docs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildSnippet,
|
||||
highlight,
|
||||
searchDocs,
|
||||
toPlainText,
|
||||
type SearchDoc,
|
||||
} from "@portal/docs/search";
|
||||
|
||||
const DOCS: SearchDoc[] = [
|
||||
{
|
||||
id: "ocr",
|
||||
title: "OCR Guide",
|
||||
sectionLabel: "Configuration",
|
||||
text: "Stirling PDF uses Tesseract for its text recognition and language packs.",
|
||||
},
|
||||
{
|
||||
id: "docker",
|
||||
title: "Docker Install",
|
||||
sectionLabel: "Installation",
|
||||
text: "Run Stirling with docker compose up to start the container.",
|
||||
},
|
||||
{
|
||||
id: "ranky",
|
||||
title: "Something else",
|
||||
sectionLabel: "Misc",
|
||||
text: "docker docker docker appears many times in the body here.",
|
||||
},
|
||||
];
|
||||
|
||||
describe("toPlainText", () => {
|
||||
it("strips headings, links, inline code, and code fences", () => {
|
||||
const md =
|
||||
"# Title\n\nSee [the guide](doc:x) and run `npm i`.\n\n```bash\nnpm run build\n```";
|
||||
const out = toPlainText(md);
|
||||
expect(out).toContain("Title");
|
||||
expect(out).toContain("the guide");
|
||||
expect(out).toContain("npm i");
|
||||
expect(out).toContain("npm run build"); // code text kept, fences dropped
|
||||
expect(out).not.toContain("#");
|
||||
expect(out).not.toContain("```");
|
||||
expect(out).not.toContain("](");
|
||||
});
|
||||
});
|
||||
|
||||
describe("highlight", () => {
|
||||
it("splits text into hit/non-hit segments", () => {
|
||||
expect(highlight("Hello world", ["world"])).toEqual([
|
||||
{ text: "Hello ", hit: false },
|
||||
{ text: "world", hit: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("is safe against regex metacharacters in terms", () => {
|
||||
expect(() => highlight("a (b) c", ["("])).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSnippet", () => {
|
||||
it("windows around the first match and marks it", () => {
|
||||
const text =
|
||||
"lorem ipsum ".repeat(20) + "the TARGET keyword " + "dolor ".repeat(20);
|
||||
const segs = buildSnippet(text, ["target"]);
|
||||
expect(segs.some((s) => s.hit && /target/i.test(s.text))).toBe(true);
|
||||
// Windowed, so it should be far shorter than the full text.
|
||||
expect(segs.map((s) => s.text).join("").length).toBeLessThan(text.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchDocs", () => {
|
||||
it("returns nothing for an empty query", () => {
|
||||
expect(searchDocs(DOCS, " ")).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches body content, not just titles (with a snippet)", () => {
|
||||
const res = searchDocs(DOCS, "tesseract");
|
||||
expect(res.map((r) => r.id)).toEqual(["ocr"]);
|
||||
expect(res[0].snippet.some((s) => s.hit && /tesseract/i.test(s.text))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("ranks title matches above body matches", () => {
|
||||
const res = searchDocs(DOCS, "docker");
|
||||
// "Docker Install" (title hit) outranks "Something else" (body-only hits).
|
||||
expect(res[0].id).toBe("docker");
|
||||
expect(res.map((r) => r.id)).toContain("ranky");
|
||||
});
|
||||
|
||||
it("requires every term to match (AND)", () => {
|
||||
expect(searchDocs(DOCS, "docker compose").map((r) => r.id)).toEqual([
|
||||
"docker",
|
||||
]);
|
||||
expect(searchDocs(DOCS, "docker tesseract")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not throw on regex-special-character queries", () => {
|
||||
expect(() => searchDocs(DOCS, "a(b")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Full-text search over the docs manifest. Pure + dependency-free so it's unit
|
||||
* testable. Indexes each doc's title + plaintext body; ranks title matches above
|
||||
* body matches; returns highlighted title + a content snippet per hit.
|
||||
*/
|
||||
|
||||
export interface SearchDoc {
|
||||
id: string;
|
||||
title: string;
|
||||
sectionLabel: string;
|
||||
/** Plaintext body (markdown stripped), original case. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** A run of result text, flagged when it matches a query term (for <mark>). */
|
||||
export interface Segment {
|
||||
text: string;
|
||||
hit: boolean;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
sectionLabel: string;
|
||||
titleSegments: Segment[];
|
||||
snippet: Segment[];
|
||||
score: number;
|
||||
}
|
||||
|
||||
/** Strip markdown/MDX down to readable plaintext for indexing + snippets. */
|
||||
export function toPlainText(md: string): string {
|
||||
return md
|
||||
.replace(/^(```|~~~).*$/gm, " ") // fence delimiters (keep the code text)
|
||||
.replace(/`([^`]+)`/g, "$1") // inline code
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ") // images
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") // links → their text
|
||||
.replace(/^\s{0,3}>\s?/gm, "") // blockquotes
|
||||
.replace(/^\s{0,3}#{1,6}\s+/gm, "") // headings
|
||||
.replace(/^\s*[-*+]\s+/gm, "") // list bullets
|
||||
.replace(/[*_~]/g, "") // emphasis marks
|
||||
.replace(/\|/g, " ") // table pipes
|
||||
.replace(/\s+/g, " ") // collapse whitespace
|
||||
.trim();
|
||||
}
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function countOccurrences(haystack: string, needle: string): number {
|
||||
let count = 0;
|
||||
let i = haystack.indexOf(needle);
|
||||
while (i !== -1) {
|
||||
count++;
|
||||
i = haystack.indexOf(needle, i + needle.length);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Split `text` into segments, flagging any run that matches a query term. */
|
||||
export function highlight(text: string, terms: string[]): Segment[] {
|
||||
const cleaned = terms.map(escapeRegExp).filter(Boolean);
|
||||
if (!cleaned.length) return [{ text, hit: false }];
|
||||
const re = new RegExp(`(${cleaned.join("|")})`, "gi");
|
||||
const segments: Segment[] = [];
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
if (m.index > last) {
|
||||
segments.push({ text: text.slice(last, m.index), hit: false });
|
||||
}
|
||||
segments.push({ text: m[0], hit: true });
|
||||
last = m.index + m[0].length;
|
||||
if (m.index === re.lastIndex) re.lastIndex++; // guard against zero-width
|
||||
}
|
||||
if (last < text.length) segments.push({ text: text.slice(last), hit: false });
|
||||
return segments.length ? segments : [{ text, hit: false }];
|
||||
}
|
||||
|
||||
/** Build a ~context-window snippet around the earliest term match. */
|
||||
export function buildSnippet(
|
||||
text: string,
|
||||
terms: string[],
|
||||
radius = 90,
|
||||
): Segment[] {
|
||||
const lower = text.toLowerCase();
|
||||
let pos = -1;
|
||||
for (const term of terms) {
|
||||
const i = lower.indexOf(term);
|
||||
if (i !== -1 && (pos === -1 || i < pos)) pos = i;
|
||||
}
|
||||
if (pos === -1) {
|
||||
const head = text.slice(0, radius * 2);
|
||||
return highlight(head + (text.length > head.length ? "…" : ""), terms);
|
||||
}
|
||||
let start = Math.max(0, pos - radius);
|
||||
let end = Math.min(text.length, pos + radius);
|
||||
// Snap to word boundaries so we don't slice mid-word.
|
||||
if (start > 0) {
|
||||
const space = text.indexOf(" ", start);
|
||||
if (space !== -1 && space < pos) start = space + 1;
|
||||
}
|
||||
if (end < text.length) {
|
||||
const space = text.lastIndexOf(" ", end);
|
||||
if (space > pos) end = space;
|
||||
}
|
||||
let snippet = text.slice(start, end).trim();
|
||||
if (start > 0) snippet = "…" + snippet;
|
||||
if (end < text.length) snippet = snippet + "…";
|
||||
return highlight(snippet, terms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank docs against a query. A doc matches when every term appears in its title
|
||||
* or body; title hits score highest.
|
||||
*/
|
||||
export function searchDocs(
|
||||
docs: SearchDoc[],
|
||||
query: string,
|
||||
limit = 40,
|
||||
): SearchResult[] {
|
||||
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (!terms.length) return [];
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
for (const doc of docs) {
|
||||
const titleLower = doc.title.toLowerCase();
|
||||
const textLower = doc.text.toLowerCase();
|
||||
const matchesAll = terms.every(
|
||||
(term) => titleLower.includes(term) || textLower.includes(term),
|
||||
);
|
||||
if (!matchesAll) continue;
|
||||
|
||||
let score = 0;
|
||||
for (const term of terms) {
|
||||
if (titleLower.includes(term)) score += 10;
|
||||
if (titleLower.startsWith(term)) score += 5;
|
||||
score += Math.min(countOccurrences(textLower, term), 5);
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
sectionLabel: doc.sectionLabel,
|
||||
titleSegments: highlight(doc.title, terms),
|
||||
snippet: buildSnippet(doc.text, terms),
|
||||
score,
|
||||
});
|
||||
}
|
||||
|
||||
results.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
|
||||
return results.slice(0, limit);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -25,48 +25,10 @@ export const infrastructureHandlers = [
|
||||
});
|
||||
}),
|
||||
|
||||
// Real backend route; wildcard prefix intercepts both local (same-origin) and
|
||||
// SaaS (absolute) callers.
|
||||
http.get(
|
||||
"*/api/v1/proprietary/ui-data/infrastructure/api-keys",
|
||||
async ({ request }) => {
|
||||
await delay(120);
|
||||
return HttpResponse.json(apiKeysFor(tierFrom(request)));
|
||||
},
|
||||
),
|
||||
|
||||
// Create returns a one-time secret; the mock is non-persistent (dev/Storybook only).
|
||||
http.post(
|
||||
"*/api/v1/proprietary/ui-data/infrastructure/api-keys",
|
||||
async ({ request }) => {
|
||||
await delay(120);
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
name?: string;
|
||||
};
|
||||
return HttpResponse.json({
|
||||
key: {
|
||||
id: `key-${Date.now()}`,
|
||||
name: body.name ?? "New key",
|
||||
prefix: "sk_demo0000",
|
||||
created: "2026-07-10",
|
||||
lastUsed: "Never",
|
||||
status: "active",
|
||||
usageToday: 0,
|
||||
usageMonth: 0,
|
||||
usageTotal: 0,
|
||||
},
|
||||
secret: "sk_live_demo_key_rotate_in_prod",
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
http.delete(
|
||||
"*/api/v1/proprietary/ui-data/infrastructure/api-keys/:id",
|
||||
async () => {
|
||||
await delay(120);
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
},
|
||||
),
|
||||
http.get("/v1/infrastructure/api-keys", async ({ request }) => {
|
||||
await delay(120);
|
||||
return HttpResponse.json(apiKeysFor(tierFrom(request)));
|
||||
}),
|
||||
|
||||
http.get("/v1/infrastructure/security", async ({ request }) => {
|
||||
await delay(120);
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import type {
|
||||
ApiKey,
|
||||
ApiKeysResponse,
|
||||
AuditEvent,
|
||||
AuditLogResponse,
|
||||
AuditSummary,
|
||||
@@ -167,57 +166,61 @@ const API_KEYS_ALL: ApiKey[] = [
|
||||
{
|
||||
id: "key-1",
|
||||
name: "Production · ingest",
|
||||
prefix: "sk_a3f81b2c",
|
||||
created: "2026-03-02",
|
||||
lastUsed: "2026-07-10 09:14",
|
||||
prefix: "sk_live_a3f8…",
|
||||
created: "Mar 2, 2026",
|
||||
lastUsed: "2m ago",
|
||||
status: "active",
|
||||
rateLimit: 1200,
|
||||
permissions: ["Read", "Write"],
|
||||
allowedIps: ["52.14.0.0/16", "18.221.0.0/16"],
|
||||
usageToday: 84210,
|
||||
usageMonth: 2410933,
|
||||
usageTotal: 2410933,
|
||||
},
|
||||
{
|
||||
id: "key-2",
|
||||
name: "Nightly batch",
|
||||
prefix: "sk_77be0f42",
|
||||
created: "2026-01-18",
|
||||
lastUsed: "2026-07-10 08:33",
|
||||
name: "Analytics · read-only",
|
||||
prefix: "sk_live_77be…",
|
||||
created: "Jan 18, 2026",
|
||||
lastUsed: "41m ago",
|
||||
status: "active",
|
||||
rateLimit: 300,
|
||||
permissions: ["Read"],
|
||||
allowedIps: [],
|
||||
usageToday: 6120,
|
||||
usageMonth: 188400,
|
||||
usageTotal: 188400,
|
||||
},
|
||||
{
|
||||
id: "key-3",
|
||||
name: "CI pipeline",
|
||||
prefix: "sk_d9013ab7",
|
||||
created: "2025-08-09",
|
||||
lastUsed: "2026-07-04 17:02",
|
||||
status: "active",
|
||||
name: "Ops · admin (legacy)",
|
||||
prefix: "sk_live_d901…",
|
||||
created: "Aug 9, 2025",
|
||||
lastUsed: "6d ago",
|
||||
status: "rotate-soon",
|
||||
rateLimit: 600,
|
||||
permissions: ["Read", "Write", "Admin"],
|
||||
allowedIps: ["203.0.113.7/32"],
|
||||
usageToday: 0,
|
||||
usageMonth: 14200,
|
||||
usageTotal: 14200,
|
||||
},
|
||||
{
|
||||
id: "key-4",
|
||||
name: "Sandbox · webhook tester",
|
||||
prefix: "sk_2c4a91de",
|
||||
created: "2026-05-30",
|
||||
lastUsed: "Never",
|
||||
prefix: "sk_test_2c4a…",
|
||||
created: "May 30, 2026",
|
||||
lastUsed: "never",
|
||||
status: "revoked",
|
||||
rateLimit: 60,
|
||||
permissions: ["Read"],
|
||||
allowedIps: [],
|
||||
usageToday: 0,
|
||||
usageMonth: 0,
|
||||
usageTotal: 0,
|
||||
},
|
||||
];
|
||||
|
||||
export function apiKeysFor(tier: Tier): ApiKeysResponse {
|
||||
const keys =
|
||||
tier === "free"
|
||||
? API_KEYS_ALL.slice(0, 1)
|
||||
: tier === "pro"
|
||||
? API_KEYS_ALL.slice(0, 3)
|
||||
: API_KEYS_ALL;
|
||||
return { keys };
|
||||
export function apiKeysFor(tier: Tier): ApiKey[] {
|
||||
if (tier === "free") return API_KEYS_ALL.slice(0, 1);
|
||||
if (tier === "pro") return API_KEYS_ALL.slice(0, 3);
|
||||
return API_KEYS_ALL;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -4,33 +4,13 @@
|
||||
* only builds seed data for the MSW handlers and tests.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PolicyRunView,
|
||||
WirePipelineStep,
|
||||
WirePolicy,
|
||||
} from "@app/policies/types";
|
||||
import type { PolicyRunView, WirePolicy } from "@app/policies/types";
|
||||
import { POLICY_CONFIG } from "@portal/api/policies";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Seed data — real backend wire format */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
// Literal wire steps (not derived from the catalogue) so this fixtures module stays independent of
|
||||
// @portal/api/policies and its heavy tool-operation import graph.
|
||||
const SECURITY_STEPS: WirePipelineStep[] = [
|
||||
{
|
||||
operation: "/api/v1/security/auto-redact",
|
||||
parameters: {
|
||||
listOfText: "",
|
||||
useRegex: true,
|
||||
convertPDFToImage: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: "/api/v1/security/sanitize-pdf",
|
||||
parameters: { removeJavaScript: true },
|
||||
},
|
||||
];
|
||||
|
||||
export function seedPolicies(): WirePolicy[] {
|
||||
return [
|
||||
{
|
||||
@@ -39,7 +19,7 @@ export function seedPolicies(): WirePolicy[] {
|
||||
owner: "security@acme.com",
|
||||
enabled: true,
|
||||
trigger: null,
|
||||
steps: SECURITY_STEPS,
|
||||
steps: POLICY_CONFIG.security.defaultOperations,
|
||||
output: {
|
||||
type: "inline",
|
||||
options: {
|
||||
|
||||
@@ -1,73 +1,254 @@
|
||||
/* Full-height docs surface: the sidebar and reading pane each scroll on their
|
||||
own inside the portal shell's scroll container, so a 70+ item nav no longer
|
||||
drags the whole page around. */
|
||||
.portal-docs {
|
||||
display: grid;
|
||||
grid-template-columns: 15rem minmax(0, 1fr);
|
||||
gap: 2rem;
|
||||
padding: 1.5rem;
|
||||
max-width: 84rem;
|
||||
margin: 0 auto;
|
||||
align-items: start;
|
||||
grid-template-columns: 16rem minmax(0, 1fr);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
.portal-docs {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.25rem;
|
||||
/* With an "On this page" TOC, add a right-hand column. */
|
||||
.portal-docs--with-toc {
|
||||
grid-template-columns: 16rem minmax(0, 1fr) 15rem;
|
||||
}
|
||||
|
||||
/* Not enough room for three columns → drop the TOC. */
|
||||
@media (max-width: 72rem) {
|
||||
.portal-docs--with-toc {
|
||||
grid-template-columns: 16rem minmax(0, 1fr);
|
||||
}
|
||||
.portal-docs__toc-col {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Left nav ──────────────────────────────────────────────────────────── */
|
||||
.portal-docs--empty {
|
||||
display: block;
|
||||
padding: 3rem 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Left nav (own scroll column) ──────────────────────────────────────── */
|
||||
|
||||
.portal-docs__sidebar {
|
||||
position: sticky;
|
||||
top: 1.5rem;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
.portal-docs__sidebar {
|
||||
position: static;
|
||||
}
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--color-border-light);
|
||||
padding: 1.25rem 0;
|
||||
}
|
||||
|
||||
.portal-docs__nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.portal-docs__search {
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.portal-docs__search-box {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.portal-docs__search-icon {
|
||||
position: absolute;
|
||||
left: 0.625rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text-4);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.portal-docs__search-input {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.6rem 0.4rem 1.9rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-1);
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.portal-docs__search-input:focus {
|
||||
border-color: var(--color-blue);
|
||||
}
|
||||
|
||||
.portal-docs__nav-empty {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-4);
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Search results ────────────────────────────────────────────────────── */
|
||||
|
||||
.portal-docs__results {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-docs__results-count {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-4);
|
||||
padding: 0 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
.portal-docs__results-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Hairline divider between results for clear, calm separation. */
|
||||
.portal-docs__results-list li + li {
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.portal-docs__result {
|
||||
height: auto;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.portal-docs__result:hover,
|
||||
.portal-docs__result.is-active {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.portal-docs__result-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Title + section share one line; the title truncates before the section. */
|
||||
.portal-docs__result-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-docs__result-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
line-height: 1.3;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.portal-docs__result-section {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-docs__result-snippet {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-4);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Subtle match emphasis — coloured text, not a filled block. */
|
||||
.portal-docs__hl {
|
||||
color: var(--color-blue);
|
||||
font-weight: 600;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.portal-docs__nav-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
|
||||
/* Section label: shared typography for the static Overview label and the
|
||||
collapsible accordion headers below. */
|
||||
.portal-docs__nav-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.75rem 0.2rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-4);
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.portal-docs__nav-icon {
|
||||
display: inline-flex;
|
||||
/* Collapsible section header (Overview stays a static label). */
|
||||
.portal-docs__nav-head--button:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.portal-docs__nav-head-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
font-size: 0.6875rem;
|
||||
border-radius: var(--radius-sm);
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-docs__nav-headlabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.portal-docs__nav-chevron {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-4);
|
||||
transition: transform var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-docs__nav-chevron.is-open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.portal-docs__nav-count {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-4);
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-3);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 0.05rem 0.4rem;
|
||||
}
|
||||
|
||||
/* Space consecutive sections apart (not before the first). */
|
||||
.portal-docs__nav-group + .portal-docs__nav-group {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
/* Nested sub-sections: indented under their parent with a guide line. */
|
||||
.portal-docs__nav-children {
|
||||
margin: 0.125rem 0 0.25rem 0.85rem;
|
||||
padding-left: 0.4rem;
|
||||
border-left: 1px solid var(--color-border-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
|
||||
.portal-docs__nav-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
margin: 0 0 0.375rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -80,10 +261,10 @@
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.5rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
cursor: pointer;
|
||||
@@ -110,10 +291,129 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Content shell ─────────────────────────────────────────────────────── */
|
||||
/* Mobile-only nav toggle (a drawer button); hidden on desktop. */
|
||||
.portal-docs__nav-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Reading pane (own scroll column) ──────────────────────────────────── */
|
||||
|
||||
.portal-docs__content {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem 2rem 4rem;
|
||||
}
|
||||
|
||||
.portal-docs__content-inner {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── On this page (right rail) ─────────────────────────────────────────── */
|
||||
|
||||
.portal-docs__toc-col {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid var(--color-border-light);
|
||||
padding: 1.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.portal-docs__toc {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.portal-docs__toc-title {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-4);
|
||||
padding: 0 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
.portal-docs__toc-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
|
||||
.portal-docs__toc-link {
|
||||
display: block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-left: 2px solid transparent;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.35;
|
||||
color: var(--color-text-3);
|
||||
text-decoration: none;
|
||||
transition: color var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-docs__toc-link.is-sub {
|
||||
padding-left: 1.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-docs__toc-link:hover {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-docs__toc-link.is-active {
|
||||
color: var(--color-blue);
|
||||
border-left-color: var(--color-blue);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Scroll-to-heading lands just below the pane top, not flush against it. */
|
||||
.portal-docs__md h2,
|
||||
.portal-docs__md h3 {
|
||||
scroll-margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* ── Small screens: nav collapses into a drawer above the content ──────── */
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
.portal-docs {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.portal-docs__nav-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 1rem 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.portal-docs__sidebar {
|
||||
display: none;
|
||||
height: auto;
|
||||
max-height: 60vh;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.portal-docs__sidebar.is-open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.portal-docs__content {
|
||||
padding: 1.25rem 1.25rem 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-docs__section {
|
||||
@@ -523,3 +823,160 @@
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* ── Markdown content ──────────────────────────────────────────────────── */
|
||||
|
||||
.portal-docs__md {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.65;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.portal-docs__md h1,
|
||||
.portal-docs__md h2,
|
||||
.portal-docs__md h3,
|
||||
.portal-docs__md h4 {
|
||||
color: var(--color-text-1);
|
||||
font-weight: 650;
|
||||
line-height: 1.25;
|
||||
margin: 1.75rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.portal-docs__md h2 {
|
||||
font-size: 1.3125rem;
|
||||
padding-bottom: 0.3rem;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.portal-docs__md h3 {
|
||||
font-size: 1.0625rem;
|
||||
}
|
||||
|
||||
.portal-docs__md h4 {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.portal-docs__md > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.portal-docs__md p,
|
||||
.portal-docs__md ul,
|
||||
.portal-docs__md ol {
|
||||
margin: 0 0 0.875rem;
|
||||
}
|
||||
|
||||
.portal-docs__md li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.portal-docs__md a {
|
||||
color: var(--color-blue);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.portal-docs__md a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.portal-docs__md code {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.85em;
|
||||
background: var(--color-bg-subtle);
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.portal-docs__md blockquote {
|
||||
margin: 0 0 0.875rem;
|
||||
padding: 0.125rem 0.875rem;
|
||||
border-left: 3px solid var(--color-blue);
|
||||
background: var(--color-bg-subtle);
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-docs__md blockquote p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.portal-docs__md-img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.portal-docs__md hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.portal-docs__md-pre {
|
||||
position: relative;
|
||||
margin: 0 0 0.875rem;
|
||||
}
|
||||
|
||||
.portal-docs__md-pre pre {
|
||||
margin: 0;
|
||||
padding: 0.875rem 4rem 0.875rem 0.875rem;
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
overflow-x: auto;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.portal-docs__md-pre pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.portal-docs__md-copy {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-docs__md-tablewrap {
|
||||
overflow-x: auto;
|
||||
margin: 0 0 0.875rem;
|
||||
}
|
||||
|
||||
.portal-docs__md-tablewrap table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.portal-docs__md-tablewrap th,
|
||||
.portal-docs__md-tablewrap td {
|
||||
border: 1px solid var(--color-border-light);
|
||||
padding: 0.4rem 0.625rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.portal-docs__md-tablewrap th {
|
||||
background: var(--color-bg-subtle);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.portal-docs__source {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.portal-docs__source a {
|
||||
color: var(--color-text-3);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.portal-docs__source a:hover {
|
||||
color: var(--color-blue);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DeveloperDocs } from "@portal/views/DeveloperDocs";
|
||||
|
||||
// Renders the real docs manifest (generated by scripts/sync-portal-docs.mts), so
|
||||
// this story is a live view of the auto-built Documentation browser. The view
|
||||
// fills its container's height (independent sidebar/content scroll), so the
|
||||
// decorator gives it a viewport-height frame.
|
||||
const meta: Meta<typeof DeveloperDocs> = {
|
||||
title: "Portal/DeveloperDocs/View",
|
||||
component: DeveloperDocs,
|
||||
parameters: { layout: "fullscreen" },
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: "100vh" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DeveloperDocs>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
// Deterministic labels; the view + DocsNav + MarkdownDoc all use useTranslation.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
// 2nd arg may be a default string or i18next interpolation options ({count}).
|
||||
t: (key: string, opts?: unknown) => (typeof opts === "string" ? opts : key),
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { DeveloperDocs } from "@portal/views/DeveloperDocs";
|
||||
|
||||
const renderDocs = (ui: ReactElement) =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/processor/docs"]}>
|
||||
<MantineProvider>{ui}</MantineProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
describe("DeveloperDocs — markdown browser over the generated manifest", () => {
|
||||
it("keeps Overview static (open, no toggle) and other sections collapsed", () => {
|
||||
renderDocs(<DeveloperDocs />);
|
||||
expect(screen.getByRole("searchbox")).toBeInTheDocument();
|
||||
// Overview is static: its items show, and it has no toggle button.
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Production Deployment Guide" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /^Overview/i }),
|
||||
).not.toBeInTheDocument();
|
||||
// Other sections collapse, so their items are hidden until expanded.
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Kubernetes Guide" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/locally hosted web application/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands a collapsible section when its header is clicked", () => {
|
||||
renderDocs(<DeveloperDocs />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Installation/i }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Kubernetes Guide" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("searches doc content (not just titles), shows a snippet, and navigates", async () => {
|
||||
renderDocs(<DeveloperDocs />);
|
||||
// "Tesseract" appears in the OCR doc body but in no doc title — a result
|
||||
// whose snippet contains it proves full-text (content) search.
|
||||
fireEvent.change(screen.getByRole("searchbox"), {
|
||||
target: { value: "Tesseract" },
|
||||
});
|
||||
const hits = await screen.findAllByRole("button", { name: /Tesseract/i });
|
||||
expect(hits.length).toBeGreaterThan(0);
|
||||
fireEvent.click(hits[0]);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText(/locally hosted web application/i),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("follows an internal doc: link inside the rendered markdown", async () => {
|
||||
renderDocs(<DeveloperDocs />);
|
||||
// The Getting Started body links to the Migration guide via the doc: scheme.
|
||||
fireEvent.click(screen.getByRole("link", { name: /Migration Guide/i }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText(/locally hosted web application/i),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,97 +1,140 @@
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EmptyState } from "@app/ui";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Button, EmptyState } from "@app/ui";
|
||||
import { DocsNav } from "@portal/components/docs/DocsNav";
|
||||
import { DocsSearch } from "@portal/components/docs/DocsSearch";
|
||||
import { DocsSection } from "@portal/components/docs/DocsSection";
|
||||
import { DocsToc } from "@portal/components/docs/DocsToc";
|
||||
import { MarkdownDoc } from "@portal/components/docs/MarkdownDoc";
|
||||
import { extractHeadings } from "@portal/docs/headings";
|
||||
import {
|
||||
fetchDocsContent,
|
||||
fetchDocsNav,
|
||||
type DocsContent,
|
||||
type DocsNavSection,
|
||||
} from "@portal/api/docs";
|
||||
import { DocsNav, DocsNavSkeleton } from "@portal/components/docs/DocsNav";
|
||||
import { GettingStartedSection } from "@portal/components/docs/GettingStartedSection";
|
||||
import { AuthenticationSection } from "@portal/components/docs/AuthenticationSection";
|
||||
import { RateLimitsSection } from "@portal/components/docs/RateLimitsSection";
|
||||
import { EndpointReferenceSection } from "@portal/components/docs/EndpointReferenceSection";
|
||||
import { ErrorsSection } from "@portal/components/docs/ErrorsSection";
|
||||
import { WebhooksSection } from "@portal/components/docs/WebhooksSection";
|
||||
import { SdksSection } from "@portal/components/docs/SdksSection";
|
||||
import { ComponentsSection } from "@portal/components/docs/ComponentsSection";
|
||||
import { PlaybooksSection } from "@portal/components/docs/PlaybooksSection";
|
||||
import { SkillsSection } from "@portal/components/docs/SkillsSection";
|
||||
allDocs,
|
||||
firstDocId,
|
||||
loadDoc,
|
||||
loadDocsNav,
|
||||
} from "@portal/docs/manifest/registry";
|
||||
import { searchDocs, toPlainText, type SearchDoc } from "@portal/docs/search";
|
||||
import "@portal/views/DeveloperDocs.css";
|
||||
|
||||
/** Renders the content pane for the active nav leaf against fetched content. */
|
||||
function DocsContentPane({
|
||||
active,
|
||||
content,
|
||||
}: {
|
||||
active: string;
|
||||
content: DocsContent;
|
||||
}) {
|
||||
switch (active) {
|
||||
case "authentication":
|
||||
return <AuthenticationSection />;
|
||||
case "rate-limits":
|
||||
return <RateLimitsSection rateLimit={content.rateLimit} />;
|
||||
case "endpoints":
|
||||
return <EndpointReferenceSection />;
|
||||
case "errors":
|
||||
return <ErrorsSection errors={content.errors} />;
|
||||
case "webhooks":
|
||||
return <WebhooksSection />;
|
||||
case "sdk-overview":
|
||||
return <SdksSection sdks={content.sdks} />;
|
||||
case "component-library":
|
||||
return <ComponentsSection components={content.components} />;
|
||||
case "recipes":
|
||||
return <PlaybooksSection playbooks={content.playbooks} />;
|
||||
case "skill-catalog":
|
||||
return <SkillsSection skills={content.skills} />;
|
||||
default:
|
||||
return (
|
||||
<GettingStartedSection
|
||||
samples={content.quickstartSamples}
|
||||
response={content.quickstartResponse}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Developer Docs — a markdown browser over the docs manifest generated from the
|
||||
* Stirling docs repo (see scripts/sync-portal-docs.mts). The nav is auto-sorted
|
||||
* from the repo's folders + frontmatter; content is the repo markdown, and the
|
||||
* search box does full-text search across every doc.
|
||||
*/
|
||||
export function DeveloperDocs() {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const [active, setActive] = useState("quickstart");
|
||||
const { hash } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const contentRef = useRef<HTMLElement>(null);
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const navState = useAsync<DocsNavSection[]>(() => fetchDocsNav(), []);
|
||||
const { data: nav } = navState;
|
||||
const { isLoading, isEmpty } = useSectionFlags(navState);
|
||||
const nav = useMemo(() => loadDocsNav(), []);
|
||||
const fallback = useMemo(() => firstDocId(), []);
|
||||
|
||||
const { data: content } = useAsync<DocsContent>(
|
||||
() => fetchDocsContent(tier),
|
||||
[tier],
|
||||
// Full-text index over every doc's plaintext body (built once).
|
||||
const index = useMemo<SearchDoc[]>(() => {
|
||||
const labels = new Map(nav.map((s) => [s.id, s.label]));
|
||||
return allDocs().map((d) => ({
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
sectionLabel: labels.get(d.section) ?? "",
|
||||
text: toPlainText(d.markdown),
|
||||
}));
|
||||
}, [nav]);
|
||||
const results = useMemo(() => searchDocs(index, query), [index, query]);
|
||||
const searching = query.trim().length > 0;
|
||||
|
||||
// Deep-link support: the active doc id lives in the URL hash.
|
||||
const hashId = decodeURIComponent(hash.replace(/^#/, ""));
|
||||
const activeId = hashId && loadDoc(hashId) ? hashId : fallback;
|
||||
const doc = activeId ? loadDoc(activeId) : undefined;
|
||||
const section = useMemo(
|
||||
() => nav.find((s) => s.items.some((i) => i.id === activeId)),
|
||||
[nav, activeId],
|
||||
);
|
||||
// "On this page" headings for the current doc.
|
||||
const headings = useMemo(
|
||||
() => (doc ? extractHeadings(doc.markdown) : []),
|
||||
[doc],
|
||||
);
|
||||
|
||||
// Navigating closes the mobile drawer, clears the search, and resets the pane.
|
||||
const onSelect = useCallback(
|
||||
(id: string) => {
|
||||
navigate({ hash: id });
|
||||
setNavOpen(false);
|
||||
setQuery("");
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
contentRef.current?.scrollTo?.({ top: 0 });
|
||||
}, [activeId]);
|
||||
|
||||
if (nav.length === 0 || !doc) {
|
||||
return (
|
||||
<div className="portal-docs portal-docs--empty">
|
||||
<EmptyState
|
||||
title={t("portal.docs.nav.empty.title")}
|
||||
description={t("portal.docs.nav.empty.description")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasToc = headings.length > 0;
|
||||
|
||||
return (
|
||||
<div className="portal-docs">
|
||||
<aside className="portal-docs__sidebar">
|
||||
{isLoading && <DocsNavSkeleton />}
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={t("portal.docs.nav.empty.title")}
|
||||
description={t("portal.docs.nav.empty.description")}
|
||||
/>
|
||||
)}
|
||||
{nav && nav.length > 0 && (
|
||||
<DocsNav sections={nav} active={active} onSelect={setActive} />
|
||||
<div className={"portal-docs" + (hasToc ? " portal-docs--with-toc" : "")}>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="portal-docs__nav-toggle"
|
||||
aria-expanded={navOpen}
|
||||
onClick={() => setNavOpen((open) => !open)}
|
||||
leftSection={<span aria-hidden>☰</span>}
|
||||
>
|
||||
{t("portal.docs.browse")}
|
||||
</Button>
|
||||
|
||||
<aside className={"portal-docs__sidebar" + (navOpen ? " is-open" : "")}>
|
||||
<DocsSearch
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
results={results}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
{!searching && (
|
||||
<DocsNav sections={nav} active={activeId ?? ""} onSelect={onSelect} />
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="portal-docs__content">
|
||||
{content && <DocsContentPane active={active} content={content} />}
|
||||
<main className="portal-docs__content" ref={contentRef}>
|
||||
<div className="portal-docs__content-inner">
|
||||
<DocsSection
|
||||
id={doc.id}
|
||||
eyebrow={section?.label ?? ""}
|
||||
title={doc.title}
|
||||
lead={doc.description}
|
||||
>
|
||||
<MarkdownDoc markdown={doc.markdown} onNavigate={onSelect} />
|
||||
<div className="portal-docs__source">
|
||||
<a href={doc.editUrl} target="_blank" rel="noopener noreferrer">
|
||||
{t("portal.docs.viewSource")}
|
||||
</a>
|
||||
</div>
|
||||
</DocsSection>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{hasToc && (
|
||||
<aside className="portal-docs__toc-col">
|
||||
<DocsToc headings={headings} scrollRef={contentRef} />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { MultiSelect } from "@app/ui/MultiSelect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PII_PRESETS } from "@app/data/policyDefinitions";
|
||||
import type { RedactParameters } from "@app/hooks/tools/redact/useRedactParameters";
|
||||
|
||||
/** The set of preset regexes — used to separate preset words from custom ones. */
|
||||
export const PRESET_PATTERNS = new Set(PII_PRESETS.map((p) => p.pattern));
|
||||
@@ -9,8 +8,8 @@ const PATTERN_BY_VALUE = new Map(PII_PRESETS.map((p) => [p.value, p.pattern]));
|
||||
const VALUE_BY_PATTERN = new Map(PII_PRESETS.map((p) => [p.pattern, p.value]));
|
||||
|
||||
interface PolicyPiiFieldProps {
|
||||
parameters: RedactParameters;
|
||||
onChange: (parameters: RedactParameters) => void;
|
||||
parameters: Record<string, unknown>;
|
||||
onChange: (parameters: Record<string, unknown>) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -27,7 +26,9 @@ export function PolicyPiiField({
|
||||
disabled,
|
||||
}: PolicyPiiFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const words = parameters.wordsToRedact;
|
||||
const words = Array.isArray(parameters.wordsToRedact)
|
||||
? (parameters.wordsToRedact as string[])
|
||||
: [];
|
||||
const selected = words
|
||||
.map((w) => VALUE_BY_PATTERN.get(w))
|
||||
.filter((v): v is string => Boolean(v));
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
import { PolicyPiiField } from "@app/components/policies/PolicyPiiField";
|
||||
import type { RedactParameters } from "@app/hooks/tools/redact/useRedactParameters";
|
||||
|
||||
interface PolicyRedactConfigProps {
|
||||
parameters: RedactParameters;
|
||||
onChange: (parameters: RedactParameters) => void;
|
||||
parameters: Record<string, unknown>;
|
||||
onChange: (parameters: Record<string, unknown>) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import AddWatermarkSingleStepSettings from "@app/components/tools/addWatermark/A
|
||||
import type { AddWatermarkParameters } from "@app/hooks/tools/addWatermark/useAddWatermarkParameters";
|
||||
|
||||
interface PolicyWatermarkConfigProps {
|
||||
parameters: AddWatermarkParameters;
|
||||
onChange: (parameters: AddWatermarkParameters) => void;
|
||||
parameters: Record<string, unknown>;
|
||||
onChange: (parameters: Record<string, unknown>) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function PolicyWatermarkConfig({
|
||||
disabled,
|
||||
}: PolicyWatermarkConfigProps) {
|
||||
useEffect(() => {
|
||||
const patch: Partial<AddWatermarkParameters> = {};
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (parameters.convertPDFToImage !== true) patch.convertPDFToImage = true;
|
||||
// Policies only support text watermarks.
|
||||
if (parameters.watermarkType !== "text") patch.watermarkType = "text";
|
||||
@@ -29,7 +29,7 @@ export function PolicyWatermarkConfig({
|
||||
|
||||
return (
|
||||
<AddWatermarkSingleStepSettings
|
||||
parameters={parameters}
|
||||
parameters={parameters as unknown as AddWatermarkParameters}
|
||||
onParameterChange={(key, value) =>
|
||||
onChange({ ...parameters, [key]: value })
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
POLICY_OPERATIONS,
|
||||
policyEndpoint,
|
||||
policyStep,
|
||||
policyStepFromWire,
|
||||
policyStepToWire,
|
||||
policyToolIdForEndpoint,
|
||||
type PolicyToolId,
|
||||
} from "@app/policies/operations";
|
||||
|
||||
const ALL_TOOL_IDS = Object.keys(POLICY_OPERATIONS) as PolicyToolId[];
|
||||
|
||||
describe("POLICY_OPERATIONS", () => {
|
||||
test("every category operation is a typed descriptor with a known endpoint", () => {
|
||||
// The catalogue uses these six across all categories; each must be wired.
|
||||
expect(ALL_TOOL_IDS.sort()).toEqual([
|
||||
"compress",
|
||||
"flatten",
|
||||
"ocr",
|
||||
"redact",
|
||||
"sanitize",
|
||||
"watermark",
|
||||
]);
|
||||
for (const id of ALL_TOOL_IDS) {
|
||||
expect(POLICY_OPERATIONS[id].endpoint).toBe(policyEndpoint(id));
|
||||
expect(typeof POLICY_OPERATIONS[id].toApi).toBe("function");
|
||||
expect(typeof POLICY_OPERATIONS[id].fromApi).toBe("function");
|
||||
}
|
||||
});
|
||||
|
||||
test("policyEndpoint returns the pinned endpoint literal", () => {
|
||||
expect(policyEndpoint("redact")).toBe("/api/v1/security/auto-redact");
|
||||
expect(policyEndpoint("sanitize")).toBe("/api/v1/security/sanitize-pdf");
|
||||
expect(policyEndpoint("watermark")).toBe("/api/v1/security/add-watermark");
|
||||
expect(policyEndpoint("ocr")).toBe("/api/v1/misc/ocr-pdf");
|
||||
expect(policyEndpoint("flatten")).toBe("/api/v1/misc/flatten");
|
||||
expect(policyEndpoint("compress")).toBe("/api/v1/misc/compress-pdf");
|
||||
});
|
||||
|
||||
test("policyToolIdForEndpoint maps endpoints back, and rejects non-policy ones", () => {
|
||||
for (const id of ALL_TOOL_IDS) {
|
||||
expect(policyToolIdForEndpoint(policyEndpoint(id))).toBe(id);
|
||||
}
|
||||
expect(policyToolIdForEndpoint("/api/v1/misc/repair")).toBeNull();
|
||||
expect(policyToolIdForEndpoint("not-an-endpoint")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("policyStep", () => {
|
||||
test("merges partial params over the tool's defaults", () => {
|
||||
const step = policyStep("redact", {
|
||||
useRegex: true,
|
||||
wordsToRedact: ["ssn", "card"],
|
||||
});
|
||||
expect(step.toolId).toBe("redact");
|
||||
// Overrides applied...
|
||||
expect(step.params.useRegex).toBe(true);
|
||||
expect(step.params.wordsToRedact).toEqual(["ssn", "card"]);
|
||||
// ...and untouched fields fall back to the tool's defaults.
|
||||
expect(step.params.mode).toBe("automatic");
|
||||
expect(step.params.redactColor).toBe("#000000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wire conversion", () => {
|
||||
test("redact maps frontend params to the backend request model (wordsToRedact -> listOfText)", () => {
|
||||
const wire = policyStepToWire(
|
||||
policyStep("redact", {
|
||||
useRegex: true,
|
||||
convertPDFToImage: true,
|
||||
wordsToRedact: ["ssn", "card"],
|
||||
}),
|
||||
);
|
||||
expect(wire.operation).toBe("/api/v1/security/auto-redact");
|
||||
// The backend field the endpoint actually reads, and no frontend-only `mode`/`wordsToRedact`.
|
||||
expect(wire.parameters).toMatchObject({
|
||||
listOfText: "ssn\ncard",
|
||||
useRegex: true,
|
||||
convertPDFToImage: true,
|
||||
});
|
||||
expect(wire.parameters).not.toHaveProperty("wordsToRedact");
|
||||
expect(wire.parameters).not.toHaveProperty("mode");
|
||||
});
|
||||
|
||||
test("every policy operation round-trips through wire and back", () => {
|
||||
for (const id of ALL_TOOL_IDS) {
|
||||
const step = policyStep(id);
|
||||
const back = policyStepFromWire(policyStepToWire(step));
|
||||
expect(back?.toolId).toBe(id);
|
||||
}
|
||||
});
|
||||
|
||||
test("redact round-trip preserves the configured patterns", () => {
|
||||
const step = policyStep("redact", {
|
||||
useRegex: true,
|
||||
wordsToRedact: ["ssn", "card"],
|
||||
});
|
||||
const back = policyStepFromWire(policyStepToWire(step));
|
||||
expect(back?.toolId).toBe("redact");
|
||||
if (back?.toolId === "redact") {
|
||||
expect(back.params.wordsToRedact).toEqual(["ssn", "card"]);
|
||||
expect(back.params.useRegex).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("a non-policy endpoint decodes to null", () => {
|
||||
expect(
|
||||
policyStepFromWire({ operation: "/api/v1/misc/repair", parameters: {} }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* The tool operations the Policies feature can run, each a typed {@link ToolOperationDescriptor}.
|
||||
* Source of truth for the catalogue, wizard, and wire conversion. Add a tool here to use it in a
|
||||
* policy - the catalogue can't reference an untyped operation.
|
||||
*/
|
||||
|
||||
import { describeToolOperation } from "@app/hooks/tools/shared/toolOperationDescriptor";
|
||||
import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation";
|
||||
import { sanitizeOperationConfig } from "@app/hooks/tools/sanitize/useSanitizeOperation";
|
||||
import { addWatermarkOperationConfig } from "@app/hooks/tools/addWatermark/useAddWatermarkOperation";
|
||||
import { ocrOperationConfig } from "@app/hooks/tools/ocr/useOCROperation";
|
||||
import { flattenOperationConfig } from "@app/hooks/tools/flatten/useFlattenOperation";
|
||||
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
|
||||
import type { ToolOperationDescriptor } from "@app/hooks/tools/shared/toolOperationDescriptor";
|
||||
import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
|
||||
import type { WirePipelineStep } from "@app/policies/types";
|
||||
|
||||
export const POLICY_OPERATIONS = {
|
||||
redact: describeToolOperation(
|
||||
"/api/v1/security/auto-redact",
|
||||
redactOperationConfig,
|
||||
),
|
||||
sanitize: describeToolOperation(
|
||||
"/api/v1/security/sanitize-pdf",
|
||||
sanitizeOperationConfig,
|
||||
),
|
||||
watermark: describeToolOperation(
|
||||
"/api/v1/security/add-watermark",
|
||||
addWatermarkOperationConfig,
|
||||
),
|
||||
ocr: describeToolOperation("/api/v1/misc/ocr-pdf", ocrOperationConfig),
|
||||
flatten: describeToolOperation(
|
||||
"/api/v1/misc/flatten",
|
||||
flattenOperationConfig,
|
||||
),
|
||||
compress: describeToolOperation(
|
||||
"/api/v1/misc/compress-pdf",
|
||||
compressOperationConfig,
|
||||
),
|
||||
} as const;
|
||||
|
||||
export type PolicyToolId = keyof typeof POLICY_OPERATIONS;
|
||||
|
||||
export type PolicyParams<Id extends PolicyToolId> =
|
||||
(typeof POLICY_OPERATIONS)[Id] extends ToolOperationDescriptor<
|
||||
ToolEndpoint,
|
||||
infer P
|
||||
>
|
||||
? P
|
||||
: never;
|
||||
|
||||
/** Discriminated on `toolId` so `params` matches the tool. */
|
||||
export type PolicyToolStep = {
|
||||
[Id in PolicyToolId]: { toolId: Id; params: PolicyParams<Id> };
|
||||
}[PolicyToolId];
|
||||
|
||||
export type PolicyToolStepOf<Id extends PolicyToolId> = Extract<
|
||||
PolicyToolStep,
|
||||
{ toolId: Id }
|
||||
>;
|
||||
|
||||
const POLICY_TOOL_IDS = Object.keys(POLICY_OPERATIONS) as PolicyToolId[];
|
||||
|
||||
const TOOL_ID_BY_ENDPOINT = new Map<string, PolicyToolId>(
|
||||
POLICY_TOOL_IDS.map((id) => [POLICY_OPERATIONS[id].endpoint, id]),
|
||||
);
|
||||
|
||||
export function policyEndpoint(toolId: PolicyToolId): ToolEndpoint {
|
||||
return POLICY_OPERATIONS[toolId].endpoint;
|
||||
}
|
||||
|
||||
/** Tool id for an endpoint path, or null if it isn't a policy tool. */
|
||||
export function policyToolIdForEndpoint(endpoint: string): PolicyToolId | null {
|
||||
return TOOL_ID_BY_ENDPOINT.get(endpoint) ?? null;
|
||||
}
|
||||
|
||||
/** A step for `toolId`, partial params merged over the tool's defaults. */
|
||||
export function policyStep<Id extends PolicyToolId>(
|
||||
toolId: Id,
|
||||
params: Partial<PolicyParams<Id>> = {},
|
||||
): PolicyToolStepOf<Id> {
|
||||
const defaults = POLICY_OPERATIONS[toolId].defaultParameters as object;
|
||||
return {
|
||||
toolId,
|
||||
params: { ...defaults, ...(params as object) },
|
||||
} as PolicyToolStepOf<Id>;
|
||||
}
|
||||
|
||||
export function policyStepToWire(step: PolicyToolStep): WirePipelineStep {
|
||||
return serializeStep(step);
|
||||
}
|
||||
|
||||
// Generic over the id so `params` stays correlated with the descriptor; TS can't do that through
|
||||
// the union, so `op` is widened here (a contained cast at the wire boundary).
|
||||
function serializeStep<Id extends PolicyToolId>(step: {
|
||||
toolId: Id;
|
||||
params: PolicyParams<Id>;
|
||||
}): WirePipelineStep {
|
||||
const op = POLICY_OPERATIONS[step.toolId] as ToolOperationDescriptor<
|
||||
ToolEndpoint,
|
||||
PolicyParams<Id>
|
||||
>;
|
||||
return {
|
||||
operation: op.endpoint,
|
||||
parameters: op.toApi(step.params) as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
/** Wire step -> typed policy step, or null if the endpoint isn't a policy tool. */
|
||||
export function policyStepFromWire(
|
||||
wire: WirePipelineStep,
|
||||
): PolicyToolStep | null {
|
||||
const toolId = policyToolIdForEndpoint(wire.operation);
|
||||
if (!toolId) return null;
|
||||
return deserializeStep(toolId, wire.parameters);
|
||||
}
|
||||
|
||||
function deserializeStep<Id extends PolicyToolId>(
|
||||
toolId: Id,
|
||||
parameters: Record<string, unknown>,
|
||||
): PolicyToolStepOf<Id> {
|
||||
const op = POLICY_OPERATIONS[toolId] as ToolOperationDescriptor<
|
||||
ToolEndpoint,
|
||||
PolicyParams<Id>
|
||||
>;
|
||||
// Wire params are untyped JSON; this is the one point they enter the typed model.
|
||||
const params = op.fromApi(
|
||||
parameters as unknown as ToolApiParams[ToolEndpoint],
|
||||
);
|
||||
return { toolId, params } as unknown as PolicyToolStepOf<Id>;
|
||||
}
|
||||
@@ -83,6 +83,7 @@
|
||||
"web-vitals": "^5.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"docs:sync": "tsx editor/scripts/sync-portal-docs.mts",
|
||||
"update:minor": "node scripts/update-minor.js",
|
||||
"update:major": "npx npm-check-updates -u && npm install",
|
||||
"update:interactive": "npx npm-check-updates -i",
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
@jwt @auth @apikey
|
||||
Feature: API Keys management API
|
||||
|
||||
Tests for the portal API-keys REST API, which lets a user mint, list, and
|
||||
revoke named personal API keys.
|
||||
|
||||
Endpoints (all @EnterpriseEndpoint, JWT/ROLE required):
|
||||
- GET /api/v1/proprietary/ui-data/infrastructure/api-keys (list)
|
||||
- POST /api/v1/proprietary/ui-data/infrastructure/api-keys (create)
|
||||
- DELETE /api/v1/proprietary/ui-data/infrastructure/api-keys/{id} (revoke)
|
||||
|
||||
Because these are @EnterpriseEndpoint, authenticated responses may be 200
|
||||
(enterprise enabled) or 403 (feature not in this build). Unauthenticated
|
||||
requests must always be rejected with 401.
|
||||
|
||||
The legacy single per-user key (the global API key) must keep working so no
|
||||
key created before multi-key support is ever lost.
|
||||
|
||||
Admin credentials: username=admin, password=stirling
|
||||
Global API key: 123456789
|
||||
|
||||
# =========================================================================
|
||||
# LIST
|
||||
# =========================================================================
|
||||
|
||||
@positive
|
||||
Scenario: Admin can list API keys
|
||||
Given I am logged in as admin
|
||||
When I send a GET request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication
|
||||
Then the response status code should be one of "200, 403"
|
||||
|
||||
@negative
|
||||
Scenario: Unauthenticated list request returns 401
|
||||
When I send a GET request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with no authentication
|
||||
Then the response status code should be 401
|
||||
|
||||
# =========================================================================
|
||||
# CREATE
|
||||
# =========================================================================
|
||||
|
||||
@positive
|
||||
Scenario: Admin can create a personal API key
|
||||
Given I am logged in as admin
|
||||
When I send a JSON POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication and body '{"name": "bdd_personal_key"}'
|
||||
Then the response status code should be one of "200, 403"
|
||||
|
||||
@negative
|
||||
Scenario: Creating a key without a name is rejected
|
||||
Given I am logged in as admin
|
||||
When I send a JSON POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication and body '{"name": ""}'
|
||||
Then the response status code should be one of "400, 403"
|
||||
|
||||
@negative
|
||||
Scenario: Unauthenticated create request returns 401
|
||||
When I send a POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with no authentication
|
||||
Then the response status code should be 401
|
||||
|
||||
# =========================================================================
|
||||
# REVOKE
|
||||
# =========================================================================
|
||||
|
||||
@negative
|
||||
Scenario: Unauthenticated revoke request returns 401
|
||||
When I send a DELETE request to "/api/v1/proprietary/ui-data/infrastructure/api-keys/1" with no authentication
|
||||
Then the response status code should be 401
|
||||
|
||||
@positive
|
||||
Scenario: Admin revoking a non-existent key is handled, not a bypass
|
||||
Given I am logged in as admin
|
||||
When I send a DELETE request to "/api/v1/proprietary/ui-data/infrastructure/api-keys/999999" with JWT authentication
|
||||
Then the response status code should be one of "204, 403, 404"
|
||||
|
||||
# =========================================================================
|
||||
# LEGACY KEY BACKWARD COMPATIBILITY
|
||||
# =========================================================================
|
||||
|
||||
@positive
|
||||
Scenario: The legacy global API key still authenticates
|
||||
When I send a GET request to "/api/v1/auth/me" with API key "123456789"
|
||||
Then the response status code should be 200
|
||||
And the response JSON should have field "username"
|
||||
|
||||
@negative
|
||||
Scenario: An unknown API key is rejected
|
||||
When I send a GET request to "/api/v1/auth/me" with API key "not-a-real-api-key-000"
|
||||
Then the response status code should be 401
|
||||
Reference in New Issue
Block a user