feat: integration v2 and API key PART 1/3
This commit is contained in:
1 parent
7e9bd9ba30
commit
844842fdae
40 files changed
+3782
-64
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
domainAPIKey "RenewCMS/internal/domain/apikey"
|
||||||
domainArticle "RenewCMS/internal/domain/article"
|
domainArticle "RenewCMS/internal/domain/article"
|
||||||
domainImage "RenewCMS/internal/domain/image"
|
domainImage "RenewCMS/internal/domain/image"
|
||||||
domainInvite "RenewCMS/internal/domain/invite"
|
domainInvite "RenewCMS/internal/domain/invite"
|
||||||
@@ -18,9 +19,11 @@ import (
|
|||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
@@ -29,6 +32,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type UseCases struct {
|
type UseCases struct {
|
||||||
|
apiKeyAuthOnce sync.Once
|
||||||
|
apiKeyAuthMiddleware func(http.Handler) http.Handler
|
||||||
Token *jwtauth.JWTAuth
|
Token *jwtauth.JWTAuth
|
||||||
CreateArticleUseCase *useCases.CreateArticleUseCase
|
CreateArticleUseCase *useCases.CreateArticleUseCase
|
||||||
GetArticleUseCase *useCases.GetArticleUseCase
|
GetArticleUseCase *useCases.GetArticleUseCase
|
||||||
@@ -63,6 +68,10 @@ type UseCases struct {
|
|||||||
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
|
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
|
||||||
RequestRestartUseCase *useCases.RequestRestartUseCase
|
RequestRestartUseCase *useCases.RequestRestartUseCase
|
||||||
RecordRunningInfoUseCase *useCases.RecordRunningInfoUseCase
|
RecordRunningInfoUseCase *useCases.RecordRunningInfoUseCase
|
||||||
|
CreateAPIKeyUseCase *useCases.CreateAPIKeyUseCase
|
||||||
|
ListAPIKeysUseCase *useCases.ListAPIKeysUseCase
|
||||||
|
RevokeAPIKeyUseCase *useCases.RevokeAPIKeyUseCase
|
||||||
|
AuthenticateAPIKeyUseCase *useCases.AuthenticateAPIKeyUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
type Repositories struct {
|
type Repositories struct {
|
||||||
@@ -70,6 +79,7 @@ type Repositories struct {
|
|||||||
UserRepo domainUser.Repository
|
UserRepo domainUser.Repository
|
||||||
ImageRepo domainImage.Repository
|
ImageRepo domainImage.Repository
|
||||||
InviteRepo domainInvite.Repository
|
InviteRepo domainInvite.Repository
|
||||||
|
APIKeyRepo domainAPIKey.Repository
|
||||||
MailRepo domainMail.Repository
|
MailRepo domainMail.Repository
|
||||||
ReleaseFeed domainUpdate.ReleaseFeed
|
ReleaseFeed domainUpdate.ReleaseFeed
|
||||||
UpdateStore domainUpdate.StatusStore
|
UpdateStore domainUpdate.StatusStore
|
||||||
@@ -106,7 +116,7 @@ func getDb(dbName string, uploadDir string) *gorm.DB {
|
|||||||
panic("Unable to open the database: " + err.Error())
|
panic("Unable to open the database: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}); err != nil {
|
if err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}); err != nil {
|
||||||
panic("Failed to migrate database: " + err.Error())
|
panic("Failed to migrate database: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +146,7 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
|
|||||||
UserRepo: persistence.NewUserRepository(db),
|
UserRepo: persistence.NewUserRepository(db),
|
||||||
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir),
|
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir),
|
||||||
InviteRepo: persistence.NewInviteRepository(db),
|
InviteRepo: persistence.NewInviteRepository(db),
|
||||||
|
APIKeyRepo: persistence.NewAPIKeyRepository(db),
|
||||||
MailRepo: mailer.NewMailRepository(settings.Smtp),
|
MailRepo: mailer.NewMailRepository(settings.Smtp),
|
||||||
ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
|
ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
|
||||||
UpdateStore: update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)),
|
UpdateStore: update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)),
|
||||||
@@ -154,6 +165,7 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
|
|||||||
func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
||||||
hasher := hashing.NewBcryptHasher()
|
hasher := hashing.NewBcryptHasher()
|
||||||
renderer := markdown.NewConverter()
|
renderer := markdown.NewConverter()
|
||||||
|
keyGenerator := hashing.NewSHA256KeyGenerator()
|
||||||
|
|
||||||
return &UseCases{
|
return &UseCases{
|
||||||
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
|
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
|
||||||
@@ -168,7 +180,7 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
|||||||
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher),
|
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher),
|
||||||
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
|
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
|
||||||
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
|
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
|
||||||
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher),
|
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now),
|
||||||
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo),
|
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo),
|
||||||
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
|
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
|
||||||
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL),
|
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL),
|
||||||
@@ -190,6 +202,10 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
|||||||
StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings),
|
StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings),
|
||||||
RequestRestartUseCase: useCases.NewRequestRestartUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now),
|
RequestRestartUseCase: useCases.NewRequestRestartUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now),
|
||||||
RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now),
|
RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now),
|
||||||
|
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now),
|
||||||
|
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
|
||||||
|
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
|
||||||
|
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package apikey
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
"RenewCMS/api/pkg/claims"
|
||||||
|
validator "RenewCMS/api/pkg/validator"
|
||||||
|
domain "RenewCMS/internal/domain/apikey"
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
CreateUseCase *useCases.CreateAPIKeyUseCase
|
||||||
|
ListUseCase *useCases.ListAPIKeysUseCase
|
||||||
|
RevokeUseCase *useCases.RevokeAPIKeyUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateAPIKeyRequest struct {
|
||||||
|
Name string `json:"name" validate:"required,max=60"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createdKeyResponse struct {
|
||||||
|
ID uint32 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type keyListItem struct {
|
||||||
|
ID uint32 `json:"id"`
|
||||||
|
UserID uint32 `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
Status domain.Status `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
LastUsedAt *time.Time `json:"last_used_at"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
|
RevokedAt *time.Time `json:"revoked_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(create *useCases.CreateAPIKeyUseCase, list *useCases.ListAPIKeysUseCase, revoke *useCases.RevokeAPIKeyUseCase) *Handler {
|
||||||
|
return &Handler{CreateUseCase: create, ListUseCase: list, RevokeUseCase: revoke}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request CreateAPIKeyRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validator.Validate.Struct(request); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId, ok := claims.UserId(r)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := h.CreateUseCase.CreateAPIKey(useCases.CreateAPIKeyCommand{
|
||||||
|
UserID: userId,
|
||||||
|
Name: request.Name,
|
||||||
|
ExpiresAt: request.ExpiresAt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrExpiryNotInFuture) {
|
||||||
|
http.Error(w, apperr.APIKeyExpiryErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(createdKeyResponse{
|
||||||
|
ID: created.Key.ID,
|
||||||
|
Name: created.Key.Name,
|
||||||
|
Prefix: created.Key.Prefix,
|
||||||
|
Key: created.RawKey,
|
||||||
|
CreatedAt: created.Key.CreatedAt,
|
||||||
|
ExpiresAt: created.Key.ExpiresAt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userId, ok := claims.UserId(r)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := h.ListUseCase.ListAPIKeys(userId)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, user.ErrUserNotFound) {
|
||||||
|
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]keyListItem, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
key := entry.Key
|
||||||
|
items = append(items, keyListItem{
|
||||||
|
ID: key.ID,
|
||||||
|
UserID: key.UserID,
|
||||||
|
Name: key.Name,
|
||||||
|
Prefix: key.Prefix,
|
||||||
|
Status: entry.Status,
|
||||||
|
CreatedAt: key.CreatedAt,
|
||||||
|
LastUsedAt: key.LastUsedAt,
|
||||||
|
ExpiresAt: key.ExpiresAt,
|
||||||
|
RevokedAt: key.RevokedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(items)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId, ok := claims.UserId(r)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.RevokeUseCase.RevokeAPIKey(userId, uint32(id)); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, domain.ErrAPIKeyNotFound):
|
||||||
|
http.Error(w, apperr.APIKeyNotFoundErrorMessage, http.StatusNotFound)
|
||||||
|
case errors.Is(err, user.ErrUserNotFound):
|
||||||
|
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||||
|
default:
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
"RenewCMS/internal/domain/apikey"
|
||||||
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/jwtauth/v5"
|
||||||
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const bearerScheme = "bearer "
|
||||||
|
|
||||||
|
type apiKeyIdContextKey struct{}
|
||||||
|
|
||||||
|
func bearerAPIKey(r *http.Request) (string, bool) {
|
||||||
|
header := r.Header.Get("Authorization")
|
||||||
|
if len(header) < len(bearerScheme) || !strings.EqualFold(header[:len(bearerScheme)], bearerScheme) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
credential := strings.TrimSpace(header[len(bearerScheme):])
|
||||||
|
return credential, strings.HasPrefix(credential, apikey.RawKeyPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiKeyIdFrom(r *http.Request) (uint32, bool) {
|
||||||
|
id, ok := r.Context().Value(apiKeyIdContextKey{}).(uint32)
|
||||||
|
return id, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequireSession(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, isKey := bearerAPIKey(r); isKey {
|
||||||
|
http.Error(w, apperr.SessionRequiredErrorMessage, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func APIKeyAuth(authenticate *useCases.AuthenticateAPIKeyUseCase, maxRequests int, window time.Duration) func(http.Handler) http.Handler {
|
||||||
|
limiter := newKeyedRateLimiter(maxRequests, window, apiKeyRateLimitKey)
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
limited := limiter(next)
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rawKey, isKey := bearerAPIKey(r)
|
||||||
|
if !isKey {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticated, err := authenticate.AuthenticateAPIKey(rawKey)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIKeyError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := sessionTokenFor(authenticated)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := jwtauth.NewContext(r.Context(), token, nil)
|
||||||
|
ctx = context.WithValue(ctx, apiKeyIdContextKey{}, authenticated.Key.ID)
|
||||||
|
limited.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAPIKeyError(w http.ResponseWriter, err error) {
|
||||||
|
if errors.Is(err, apikey.ErrAPIKeyInvalid) {
|
||||||
|
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionTokenFor(authenticated useCases.AuthenticatedAPIKey) (jwt.Token, error) {
|
||||||
|
token := jwt.New()
|
||||||
|
if err := token.Set("user_id", float64(authenticated.User.ID)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := token.Set("token_version", float64(authenticated.User.TokenVersion)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiKeyRateLimitKey(r *http.Request) string {
|
||||||
|
id, _ := apiKeyIdFrom(r)
|
||||||
|
return strconv.FormatUint(uint64(id), 10)
|
||||||
|
}
|
||||||
@@ -35,3 +35,6 @@ const NothingToUpdateErrorMessage = "There is nothing to update."
|
|||||||
const UpdateRefusedErrorMessage = "This update cannot be downloaded."
|
const UpdateRefusedErrorMessage = "This update cannot be downloaded."
|
||||||
const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the page and try again."
|
const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the page and try again."
|
||||||
const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html, json."
|
const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html, json."
|
||||||
|
const APIKeyNotFoundErrorMessage = "API key not found"
|
||||||
|
const APIKeyExpiryErrorMessage = "The expiry must be a date in the future."
|
||||||
|
const SessionRequiredErrorMessage = "This action needs a real session."
|
||||||
+43
-1
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"RenewCMS/api/handlers/apikey"
|
||||||
"RenewCMS/api/handlers/article"
|
"RenewCMS/api/handlers/article"
|
||||||
"RenewCMS/api/handlers/auth"
|
"RenewCMS/api/handlers/auth"
|
||||||
"RenewCMS/api/handlers/frontend"
|
"RenewCMS/api/handlers/frontend"
|
||||||
@@ -33,8 +34,21 @@ func GetHelloWorld(w http.ResponseWriter, _ *http.Request) {
|
|||||||
_, _ = w.Write(msg)
|
_, _ = w.Write(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
apiKeyMaxRequests = 120
|
||||||
|
apiKeyWindow = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
|
||||||
|
c.apiKeyAuthOnce.Do(func() {
|
||||||
|
c.apiKeyAuthMiddleware = middleware.APIKeyAuth(c.AuthenticateAPIKeyUseCase, apiKeyMaxRequests, apiKeyWindow)
|
||||||
|
})
|
||||||
|
return c.apiKeyAuthMiddleware
|
||||||
|
}
|
||||||
|
|
||||||
func NewArticleRouter(container *UseCases) http.Handler {
|
func NewArticleRouter(container *UseCases) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
r.Use(container.apiKeyAuth())
|
||||||
|
|
||||||
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase)
|
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase)
|
||||||
|
|
||||||
@@ -85,11 +99,18 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
|||||||
r.Post("/invite/accept", acceptInviteHandler.AcceptInvite)
|
r.Post("/invite/accept", acceptInviteHandler.AcceptInvite)
|
||||||
})
|
})
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireSession)
|
||||||
r.Use(jwtauth.Verifier(container.Token))
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
r.Use(jwtauth.Authenticator(container.Token))
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
r.Post("/verify", verificationHandler.Verify)
|
r.Post("/verify", verificationHandler.Verify)
|
||||||
r.Delete("/register", verificationHandler.CancelRegistration)
|
r.Delete("/register", verificationHandler.CancelRegistration)
|
||||||
|
})
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
|
r.Use(container.apiKeyAuth())
|
||||||
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
r.Get("/me", meHandler.Me)
|
r.Get("/me", meHandler.Me)
|
||||||
})
|
})
|
||||||
return r
|
return r
|
||||||
@@ -102,6 +123,7 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
|
|||||||
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
|
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
|
||||||
|
|
||||||
r.Use(jwtauth.Verifier(container.Token))
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
|
r.Use(container.apiKeyAuth())
|
||||||
r.Use(jwtauth.Authenticator(container.Token))
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
@@ -124,6 +146,7 @@ func NewSettingsRouter(container *UseCases) http.Handler {
|
|||||||
settingsHandler := settings.NewHandler(container.GetSettingsUseCase)
|
settingsHandler := settings.NewHandler(container.GetSettingsUseCase)
|
||||||
|
|
||||||
r.Use(jwtauth.Verifier(container.Token))
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
|
r.Use(container.apiKeyAuth())
|
||||||
r.Use(jwtauth.Authenticator(container.Token))
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
@@ -143,6 +166,7 @@ func NewSystemRouter(container *UseCases) http.Handler {
|
|||||||
|
|
||||||
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase, container.GetUserUseCase)
|
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase, container.GetUserUseCase)
|
||||||
|
|
||||||
|
r.Use(middleware.RequireSession)
|
||||||
r.Use(jwtauth.Verifier(container.Token))
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
r.Use(jwtauth.Authenticator(container.Token))
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
@@ -165,12 +189,29 @@ func NewSystemRouter(container *UseCases) http.Handler {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewAPIKeyRouter(container *UseCases) http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
apiKeyHandler := apikey.NewHandler(container.CreateAPIKeyUseCase, container.ListAPIKeysUseCase, container.RevokeAPIKeyUseCase)
|
||||||
|
|
||||||
|
r.Use(middleware.RequireSession)
|
||||||
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
|
r.Post("/", apiKeyHandler.Create)
|
||||||
|
r.Get("/", apiKeyHandler.List)
|
||||||
|
r.Delete("/{id}", apiKeyHandler.Revoke)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
func NewMediaRouter(container *UseCases) http.Handler {
|
func NewMediaRouter(container *UseCases) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase)
|
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase)
|
||||||
|
|
||||||
r.Use(jwtauth.Verifier(container.Token))
|
r.Use(jwtauth.Verifier(container.Token))
|
||||||
|
r.Use(container.apiKeyAuth())
|
||||||
r.Use(jwtauth.Authenticator(container.Token))
|
r.Use(jwtauth.Authenticator(container.Token))
|
||||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||||
@@ -198,6 +239,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
|||||||
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
|
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
|
||||||
r.Mount("/settings", NewSettingsRouter(container))
|
r.Mount("/settings", NewSettingsRouter(container))
|
||||||
r.Mount("/media", NewMediaRouter(container))
|
r.Mount("/media", NewMediaRouter(container))
|
||||||
|
r.Mount("/api-key", NewAPIKeyRouter(container))
|
||||||
r.Mount("/system", NewSystemRouter(container))
|
r.Mount("/system", NewSystemRouter(container))
|
||||||
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
||||||
|
|
||||||
@@ -225,7 +267,7 @@ func enableCorsIfConfigured(r *chi.Mux, origins []string) {
|
|||||||
}
|
}
|
||||||
r.Use(cors.Handler(cors.Options{
|
r.Use(cors.Handler(cors.Options{
|
||||||
AllowedOrigins: origins,
|
AllowedOrigins: origins,
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||||
ExposedHeaders: []string{"Link"},
|
ExposedHeaders: []string{"Link"},
|
||||||
AllowCredentials: false,
|
AllowCredentials: false,
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# API keys
|
||||||
|
|
||||||
|
Developers can authenticate against `/v1` with a named API key sent as `Authorization: Bearer rcms_...` instead of the `jwt` cookie. A key acts with exactly the rights of the user who created it.
|
||||||
|
|
||||||
|
## Managing keys
|
||||||
|
|
||||||
|
Key management needs a real session (the `jwt` cookie from a verified user).
|
||||||
|
|
||||||
|
- `POST /v1/api-key` with `{"name": "ci", "expires_at": "<RFC3339, optional>"}` answers `201` with `id`, `name`, `prefix`, `key`, `created_at`, `expires_at`. The full `key` is shown once and never again.
|
||||||
|
- `GET /v1/api-key` lists the caller's keys (all keys for the owner), newest first, with a `status` of `active`, `expired` or `revoked`.
|
||||||
|
- `DELETE /v1/api-key/{id}` revokes a key and answers `204`. Revoking twice keeps the first `revoked_at`.
|
||||||
|
|
||||||
|
Only the SHA-256 hash and a 12 character prefix are stored.
|
||||||
|
|
||||||
|
## Where a key is accepted
|
||||||
|
|
||||||
|
`/v1/article` (public and protected routes), `/v1/user`, `/v1/media`, `/v1/settings` and `GET /v1/auth/me`. An invalid, expired or revoked `rcms_` key answers `401` and is never treated as anonymous. Each key is limited to 120 requests per minute (`429` with `Retry-After`). `last_used_at` is updated at most once per minute.
|
||||||
|
|
||||||
|
## Session-only routes
|
||||||
|
|
||||||
|
`/v1/api-key`, `POST /v1/auth/verify`, `DELETE /v1/auth/register` and every `/v1/system` route answer `403 This action needs a real session.` to any `rcms_` Bearer header. The public auth routes ignore the header.
|
||||||
|
|
||||||
|
A successful password reset revokes every key of that user.
|
||||||
@@ -31,7 +31,8 @@ The visual spec of record for the back-office SPA and the transactional emails,
|
|||||||
| [Editor](screens/editor.md) | `/article/:id/edit` | 22 | `source/RenewCMS Screens Back-office.dc.html` |
|
| [Editor](screens/editor.md) | `/article/:id/edit` | 22 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||||
| [Media](screens/media.md) | `/media` | 41 | `source/RenewCMS Screens Back-office.dc.html` |
|
| [Media](screens/media.md) | `/media` | 41 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||||
| [Media picker](screens/media-picker.md) | `/article/:id/edit` (dialog) | 13 | `source/RenewCMS Screens Back-office.dc.html` |
|
| [Media picker](screens/media-picker.md) | `/article/:id/edit` (dialog) | 13 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||||
| [Integration](screens/integration.md) | `/integration` | 2 | `source/RenewCMS Screens Back-office.dc.html` |
|
| [Integration](screens/integration.md) | `/integration` | 8 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||||
|
| [API keys](screens/integration.md) | `/integration/api-keys` | 12 | `source/RenewCMS Screens API keys.dc.html` |
|
||||||
| [Users](screens/users.md) | `/users` | 17 | `source/RenewCMS Screens Back-office.dc.html` |
|
| [Users](screens/users.md) | `/users` | 17 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||||
| [Settings](screens/settings.md) | `/settings` | 25 | `source/RenewCMS Screens Back-office.dc.html` |
|
| [Settings](screens/settings.md) | `/settings` | 25 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||||
| [Profile](screens/profile.md) | `/profile` | 13 | `source/RenewCMS Screens Back-office.dc.html` |
|
| [Profile](screens/profile.md) | `/profile` | 13 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||||
@@ -41,6 +42,7 @@ The visual spec of record for the back-office SPA and the transactional emails,
|
|||||||
## Other boards
|
## Other boards
|
||||||
|
|
||||||
- `source/MediaFrame.dc.html`: the frame that draws the Media and Media picker states (the Back-office board imports it). Like `AppFrame.dc.html`, it holds the exact values.
|
- `source/MediaFrame.dc.html`: the frame that draws the Media and Media picker states (the Back-office board imports it). Like `AppFrame.dc.html`, it holds the exact values.
|
||||||
|
- `source/IntegrationPage.dc.html` and `source/ApiKeysPage.dc.html`: the page components for the Integration and API keys screens (imported by `AppFrame.dc.html`). They hold the exact values.
|
||||||
- `source/RenewCMS Prototype.dc.html`: the clickable flows (first run, edit and publish, password reset, delete, session expiry mid-edit).
|
- `source/RenewCMS Prototype.dc.html`: the clickable flows (first run, edit and publish, password reset, delete, session expiry mid-edit).
|
||||||
- `source/RenewCMS Handoff.dc.html`: the handoff page the first markdown here was generated from. It predates the Users and Profile screens and the invitation email, so it does not list them. Those are written by hand from the Back-office board, `AppFrame.dc.html`, the Prototype and `emails/invitation.html`.
|
- `source/RenewCMS Handoff.dc.html`: the handoff page the first markdown here was generated from. It predates the Users and Profile screens and the invitation email, so it does not list them. Those are written by hand from the Back-office board, `AppFrame.dc.html`, the Prototype and `emails/invitation.html`.
|
||||||
- `source/RenewCMS Components Product.dc.html`: not regenerated for the new screens either. The matching component changes are in `components.md`, under "Added with the Users and Profile screens" and "Added with roles, invite actions and Settings".
|
- `source/RenewCMS Components Product.dc.html`: not regenerated for the new screens either. The matching component changes are in `components.md`, under "Added with the Users and Profile screens" and "Added with roles, invite actions and Settings".
|
||||||
|
|||||||
@@ -30,7 +30,12 @@
|
|||||||
| PasswordStrengthHint | custom (in UFormField #help) | rules: { label, met }[], showErrors: boolean | none | none | Register, Reset, Accept invite |
|
| PasswordStrengthHint | custom (in UFormField #help) | rules: { label, met }[], showErrors: boolean | none | none | Register, Reset, Accept invite |
|
||||||
| ResultPanel | custom + UButton | tone: 'success'\|'error'\|'info', icon, title, description, actions: ButtonProps[] | none | default | Pending, Verify, Reset request, Reset, Accept invite, Register (403) |
|
| ResultPanel | custom + UButton | tone: 'success'\|'error'\|'info', icon, title, description, actions: ButtonProps[] | none | default | Pending, Verify, Reset request, Reset, Accept invite, Register (403) |
|
||||||
| CodeBlock | custom (shiki) + UButton | code: string, lang: string, label? | none | none | Integration |
|
| CodeBlock | custom (shiki) + UButton | code: string, lang: string, label? | none | none | Integration |
|
||||||
| EndpointCard | UCard, UBadge, UTabs | method: 'GET', path, description, examples: { fetch, curl, response } | none | none | Integration |
|
| EndpointCard | UCollapsible, UBadge, UTable, UTabs | method: 'GET'\|'POST'\|'PATCH'\|'DELETE', path, description, access, keyAccess, body?, examples: { fetch, curl, response }, errors, sessionOnly? | none | none | Integration |
|
||||||
|
| SectionIndex | custom nav (desktop), USelectMenu "Jump to" (mobile) | sections: { id, label }[], active: string | jump | none | Integration |
|
||||||
|
| SessionOnlyGroup | UAlert-style note + list rows | routes: { method, path }[] | none | none | Integration |
|
||||||
|
| ApiKeysTable | UTable, UBadge | keys: ApiKey[], showCreatedBy: boolean | revoke | none | API keys |
|
||||||
|
| CreateApiKeyModal | UModal, UForm, UFormField, UInput | open: boolean | create | none | API keys |
|
||||||
|
| ApiKeyReveal | UModal, UAlert, UButton | key: string | done | none | API keys |
|
||||||
| ErrorView | custom + UButton | kind: 'not-found'\|'error' | retry | none | Not found, error boundary |
|
| ErrorView | custom + UButton | kind: 'not-found'\|'error' | retry | none | Not found, error boundary |
|
||||||
| OfflineBanner | UBanner | none (reads useOnline) | none | none | DashboardShell |
|
| OfflineBanner | UBanner | none (reads useOnline) | none | none | DashboardShell |
|
||||||
| AppBoot | USkeleton inside DashboardShell | none | none | none | First load of any verified route |
|
| AppBoot | USkeleton inside DashboardShell | none | none | none | First load of any verified route |
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ A global `router.beforeEach` guard reads `meta.auth`. On the first navigation it
|
|||||||
| `/article/:id/edit` | DashboardShell | verified | EditorPage | GET /{id} · PATCH /{id} · PATCH /{id}/publish\|unpublish · GET /v1/media/ · POST /v1/media/ · DELETE /{id} |
|
| `/article/:id/edit` | DashboardShell | verified | EditorPage | GET /{id} · PATCH /{id} · PATCH /{id}/publish\|unpublish · GET /v1/media/ · POST /v1/media/ · DELETE /{id} |
|
||||||
| `/media` | DashboardShell | verified | MediaPage | GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage |
|
| `/media` | DashboardShell | verified | MediaPage | GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage |
|
||||||
| `/integration` | DashboardShell | verified | IntegrationPage | none |
|
| `/integration` | DashboardShell | verified | IntegrationPage | none |
|
||||||
|
| `/integration/api-keys` | DashboardShell | verified | ApiKeysPage + CreateApiKeyModal | GET /v1/api-key · POST /v1/api-key · DELETE /v1/api-key/{id} |
|
||||||
| `/users` | DashboardShell | verified | UsersPage + InviteUserModal | GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id · POST /v1/user/invite/:id/renew |
|
| `/users` | DashboardShell | verified | UsersPage + InviteUserModal | GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id · POST /v1/user/invite/:id/renew |
|
||||||
| `/settings` | DashboardShell | verified | SettingsPage | GET /v1/settings · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart |
|
| `/settings` | DashboardShell | verified | SettingsPage | GET /v1/settings · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart |
|
||||||
| `/profile` | DashboardShell | verified | ProfilePage | GET /v1/auth/me · PATCH /v1/user/me · POST /v1/auth/password-reset |
|
| `/profile` | DashboardShell | verified | ProfilePage | GET /v1/auth/me · PATCH /v1/user/me · POST /v1/auth/password-reset |
|
||||||
|
|||||||
@@ -1,22 +1,50 @@
|
|||||||
# Connect your website
|
# Integration (API reference) and API keys
|
||||||
|
|
||||||
- **Route:** `/integration`
|
Routes: `/integration` and `/integration/api-keys`. Boards: `RenewCMS Screens Back-office.dc.html` (group "Integration") and `RenewCMS Screens API keys.dc.html`. Components: `IntegrationPage.dc.html`, `ApiKeysPage.dc.html`, mounted by `AppFrame`.
|
||||||
- **API:** none (static)
|
|
||||||
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Integration" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
|
|
||||||
|
|
||||||
Written for a developer. The base URL is window.location.origin.
|
## Navigation decision
|
||||||
|
Sticky section index on the right (desktop), "Jump to" select (mobile). A reference is read in one scroll and searched with the browser, so tabs would hide content and break find-in-page and deep links; the index keeps one page with anchors and shows position.
|
||||||
|
|
||||||
## States
|
## Page copy
|
||||||
|
- Header: breadcrumb "Integration", title "API reference", description "Everything a developer needs to build on RenewCMS."
|
||||||
|
- Intro: "RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office."
|
||||||
|
- Base URL: label "Base URL", button "Copy" / "Copied" (2 s, announced).
|
||||||
|
- Authentication, card "Public": "No key needed. Two read-only routes, and only online articles are returned." `GET /v1/article/`, `GET /v1/article/{id}`.
|
||||||
|
- Card "API key": "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator." CodeBlock tabs curl, fetch (GET /v1/auth/me). "Keys start with `rcms_` and are shown in full once, when you create them." Link "Manage API keys".
|
||||||
|
- Note 1: "A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified."
|
||||||
|
- Note 2: "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header."
|
||||||
|
- Groups: Articles (7), Media (6), Users and invites (6), Settings (1), Session only (12 routes, 3 sub-groups). Session-only banner: "These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session.""
|
||||||
|
- Errors: table 400, 401, 403, 404, 409, 422, 429, 500 plus the JSON body.
|
||||||
|
- Notes: image URL prefix, CORS (existing copy), and "Article routes accept `?format=html` or `?format=markdown` to choose how `body` is returned."
|
||||||
|
|
||||||
### Default
|
## EndpointCard states
|
||||||
|
- Collapsed (44 px row): method badge, mono path, one-line description, chips, chevron. Desktop is one line; mobile is three (method and path, description, chips).
|
||||||
|
- Expanded: "Request body" table (Name, Type, Required, Rule; "No request body." when empty), tabs fetch / curl / Response with one CodeBlock and Copy, then "Errors": status chip plus one-line caption (also its `title` tooltip).
|
||||||
|
- Session only: dashed border, muted fill, lock icon, "Session only" chip, no method colour, no expand.
|
||||||
|
- Chips: access "Public", "Verified user", "Owner only"; key "API key OK" (success soft) or "Session only" (dashed, muted).
|
||||||
|
- Method badges: GET `--ui-bg-inverted`, POST success soft, PATCH warning soft, DELETE error soft. Existing tokens only.
|
||||||
|
|
||||||
**Trigger:** Opened from the sidebar or the Home card.
|
## Board states (desktop 1440×900, mobile 390×844, light and dark)
|
||||||
|
1 Default · 2 Media group, POST /v1/media/ expanded with curl tab · 3a Base URL copied · 3b CodeBlock copied · 4 Authentication · 5 Session only · 6 Errors · 7 Navigation (index active item Media; mobile select open).
|
||||||
|
|
||||||
- Breadcrumbs: Integration (mobile title "Integration")
|
## API keys screen
|
||||||
|
Placement: under Integration (sub-page), because keys exist to call the API and admins can create them; Settings is owner-only today. Session only.
|
||||||
|
- Columns: Name, Key (prefix, mono), Status (active, expired, revoked), Created by (owners only), Created, Last used, Expires, Revoke. Owners see all keys; admins see their own.
|
||||||
|
- Create key modal: Name (required, up to 60, counter), Expiry date (optional, must be in the future). Errors: "Enter a name." / "Use 60 characters or fewer." / "Choose a date in the future."
|
||||||
|
- One-time reveal: warning "You won't be able to see this key again.", monospace key, Copy / Copied, "Closing this window discards the key from the screen. Copy it first." Button "Done".
|
||||||
|
- Revoke: "Revoke this key?" / ""Nightly media import" (rcms_Mq7b…) will stop working right away. Requests that use it answer 401. This can't be undone." Buttons "Keep key", "Revoke key". Toast "Key revoked".
|
||||||
|
- Empty: "No API keys yet" / "Create a key to call the API from a script or service." Error: "Couldn't load your API keys" / "Check your connection and try again."
|
||||||
|
- Note: "Resetting a user's password revokes all of their keys."
|
||||||
|
|
||||||
### Copied
|
## Component inventory and Nuxt UI v4
|
||||||
|
- EndpointCard (extended): `UCollapsible` (trigger is a button with aria-expanded), `UBadge` (method, chips, status), `UTable` (body fields), `UTabs` (fetch, curl, Response), CodeBlock.
|
||||||
|
- SectionIndex (new): custom `<nav>` of anchor buttons with `aria-current`; sticky, scroll-spy. Mobile: `USelectMenu` labelled "Jump to".
|
||||||
|
- SessionOnlyGroup (new): `UAlert`-style info note plus list rows; no new colour.
|
||||||
|
- ErrorTable: `UTable`. Info notes: existing note style (or `UAlert` variant `naked`).
|
||||||
|
- API keys: `UTable`, `UBadge`, `UModal` with `UForm`/`UFormField`/`UInput` (type date), `UAlert` color warning, `UButton`, `useClipboard`.
|
||||||
|
|
||||||
**Trigger:** The base URL was copied. The button reads "Copied" for 2 s and this is announced.
|
## Assumptions to confirm
|
||||||
|
Paths not in the brief: `/v1/settings`, `/v1/auth/api-key`, `/v1/system/update/*`, `POST /v1/auth/register|verify|logout`; body fields `content`, `keepId`, `mergeIds`, `displayName`, `name`; error body shape `{ "statusCode", "message" }`. Code samples use a simple keyword and string colouring.
|
||||||
|
|
||||||
- Breadcrumbs: Integration (mobile title "Integration")
|
## Tokens
|
||||||
- copied: true
|
No new tokens. Foundations check: colours, radius (0) and spacing come from existing `--ui-*` and `--rc-*` values; every control has a 2 px primary focus ring; index, select and cards are keyboard operable. Open question: the board hard-codes a few pixel sizes, as the other screens do.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<div style="flex:1;min-height:0;overflow:hidden;background:var(--ui-bg-muted);color:var(--ui-text);font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px;padding:{{ pad }};display:flex;flex-direction:column;gap:16px">
|
||||||
|
<div style="display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap">
|
||||||
|
<div style="flex:1;min-width:200px;display:flex;flex-direction:column;gap:4px"><span style="color:var(--ui-text-muted)">Integration › API keys</span><span style="font:600 {{ h1 }} 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API keys</span><span style="color:var(--ui-text-muted);text-wrap:pretty">{{ sub }}</span></div>
|
||||||
|
<sc-if value="{{ desk }}"><span style="height:36px;padding:0 14px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">Create key</span></sc-if>
|
||||||
|
</div>
|
||||||
|
<sc-if value="{{ err }}"><div role="alert" style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="flex:1"><strong>Couldn't load your API keys</strong><br>Check your connection and try again.</span><span style="font-weight:500;color:var(--ui-text-highlighted)">Try again</span></div></sc-if>
|
||||||
|
<sc-if value="{{ empty }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:48px 24px;display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">No API keys yet</span><span style="color:var(--ui-text-toned);max-width:360px;text-wrap:pretty">Create a key to call the API from a script or service.</span><span style="height:36px;padding:0 14px;display:flex;align-items:center;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">Create key</span></div></sc-if>
|
||||||
|
<sc-if value="{{ showList }}">
|
||||||
|
<sc-if value="{{ desk }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
|
||||||
|
<div style="display:grid;grid-template-columns:{{ cols }};gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Name</span><span>Key</span><span>Status</span><sc-if value="{{ owner }}"><span>Created by</span></sc-if><span>Created</span><span>Last used</span><span>Expires</span><span></span></div>
|
||||||
|
<sc-for list="{{ rows }}" as="r" hint-placeholder-count="4"><div style="display:grid;grid-template-columns:{{ cols }};gap:12px;height:48px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border);background:{{ r.bg }}">
|
||||||
|
<sc-if value="{{ r.skel }}"><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:70%;background:var(--ui-bg-accented)"></span><span style="height:20px;width:56px;background:var(--ui-bg-accented)"></span><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span></span><span></span><span></span></sc-if>
|
||||||
|
<sc-if value="{{ r.name }}"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;color:{{ r.fg }}">{{ r.name }}</span><span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ r.fg2 }}">{{ r.prefix }}</span><span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:{{ r.sbg }};color:{{ r.sfg }};font-size:12px;font-weight:500">{{ r.status }}</span></span>
|
||||||
|
<sc-if value="{{ owner }}"><span style="color:{{ r.fg2 }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ r.by }}</span></sc-if><span style="color:{{ r.fg2 }}">{{ r.created }}</span><span style="color:{{ r.fg2 }}">{{ r.used }}</span><span style="color:{{ r.fg2 }}">{{ r.exp }}</span>
|
||||||
|
<span style="display:flex;justify-content:flex-end"><sc-if value="{{ r.canRevoke }}"><span style="height:28px;padding:0 10px;display:flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">Revoke</span></sc-if></span></sc-if>
|
||||||
|
</div></sc-for></div></sc-if>
|
||||||
|
<sc-if value="{{ mob }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);margin:0 -16px;border-left:0;border-right:0">
|
||||||
|
<sc-for list="{{ rows }}" as="r" hint-placeholder-count="4"><div style="display:flex;flex-direction:column;gap:6px;padding:12px 16px;border-bottom:1px solid var(--ui-border);background:{{ r.bg }}">
|
||||||
|
<sc-if value="{{ r.skel }}"><span style="height:14px;width:55%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:40%;background:var(--ui-bg-accented)"></span></sc-if>
|
||||||
|
<sc-if value="{{ r.name }}"><div style="display:flex;align-items:center;gap:8px"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;font-size:15px;color:{{ r.fg }}">{{ r.name }}</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:{{ r.sbg }};color:{{ r.sfg }};font-size:12px;font-weight:500">{{ r.status }}</span></div>
|
||||||
|
<span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ r.fg2 }}">{{ r.prefix }}</span>
|
||||||
|
<span style="color:{{ r.fg2 }}">Created {{ r.created }}<sc-if value="{{ owner }}"> by {{ r.by }}</sc-if> · Last used {{ r.used }} · Expires {{ r.exp }}</span>
|
||||||
|
<sc-if value="{{ r.canRevoke }}"><span style="align-self:flex-start;height:44px;padding:0 14px;display:flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">Revoke</span></sc-if></sc-if>
|
||||||
|
</div></sc-for></div></sc-if>
|
||||||
|
</sc-if>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:8px;font-size:14px;line-height:22px;color:var(--ui-text-toned)">
|
||||||
|
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Resetting a user's password revokes all of their keys.</span></div>
|
||||||
|
</div>
|
||||||
|
<sc-if value="{{ toast }}"><div role="status" style="position:absolute;{{ toastPos }};z-index:60;display:flex;gap:10px;padding:12px 14px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);color:var(--ui-success)"><span style="display:flex;flex-direction:column"><strong>Key revoked</strong><span style="color:var(--ui-text-toned)">"Nightly media import" can no longer be used.</span></span></div></sc-if>
|
||||||
|
</div>
|
||||||
|
<sc-if value="{{ modal }}"><div style="position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:{{ mAlign }};justify-content:center;padding:{{ mPad }}">
|
||||||
|
<div role="dialog" aria-modal="true" style="width:{{ mW }};max-width:100%;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-modal);padding:20px;display:flex;flex-direction:column;gap:14px;color:var(--ui-text)">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:4px"><span style="font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ m.title }}</span><span style="color:var(--ui-text-toned);font-size:14px;line-height:22px;text-wrap:pretty">{{ m.desc }}</span></div>
|
||||||
|
<sc-if value="{{ m.form }}">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px"><div style="display:flex;justify-content:space-between"><span style="font-weight:500;color:var(--ui-text-highlighted)">Name</span><span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ m.cntFg }}">{{ m.count }}</span></div><span style="height:40px;padding:0 12px;display:flex;align-items:center;border:1px solid {{ m.nameBd }};background:var(--ui-bg);color:var(--ui-text-highlighted)">{{ m.name }}</span><span style="color:{{ m.nameFg }}">{{ m.nameHelp }}</span></div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px"><span style="font-weight:500;color:var(--ui-text-highlighted)">Expiry date <span style="font-weight:400;color:var(--ui-text-muted)">(optional)</span></span><span style="height:40px;padding:0 12px;display:flex;align-items:center;border:1px solid {{ m.expBd }};background:var(--ui-bg);color:{{ m.expTx }}">{{ m.exp }}</span><span style="color:{{ m.expFg }}">{{ m.expHelp }}</span></div>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ m.reveal }}">
|
||||||
|
<div style="display:flex;gap:10px;padding:10px 12px;background:var(--rc-warning-soft);color:var(--ui-warning)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/triangle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/triangle-alert.svg') center / contain no-repeat"></span></span><span style="font-weight:500">You won't be able to see this key again.</span></div>
|
||||||
|
<div style="display:flex;flex-direction:{{ keyDir }};border:1px solid var(--ui-border-accented);background:var(--ui-bg-elevated)"><span style="flex:1;min-width:0;padding:10px 12px;font:400 12px/20px 'IBM Plex Mono',monospace;word-break:break-all;color:var(--ui-text-highlighted)">rcms_Kf3x9QwL0aBcT7nV2uYdE5sHjR8mPz4WqXg6C1oIyNe</span><span style="height:44px;padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;border-left:1px solid var(--ui-border-accented);font-weight:500;color:{{ m.cpFg }}"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ m.cpIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ m.cpIcon }}.svg') center / contain no-repeat"></span>{{ m.cpT }}</span></div>
|
||||||
|
<span style="color:var(--ui-text-muted)">Closing this window discards the key from the screen. Copy it first.</span>
|
||||||
|
</sc-if>
|
||||||
|
<div style="display:flex;flex-direction:{{ btnDir }};justify-content:flex-end;gap:8px"><sc-if value="{{ m.cancel }}"><span style="height:44px;padding:0 14px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">{{ m.cancel }}</span></sc-if><span style="height:44px;padding:0 14px;display:flex;align-items:center;justify-content:center;font-weight:500;background:{{ m.cbg }};color:{{ m.cfg }};opacity:{{ m.cop }}">{{ m.confirm }}</span></div>
|
||||||
|
</div></div></sc-if>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script>
|
||||||
|
class Component extends DCLogic {
|
||||||
|
renderVals() {
|
||||||
|
const mob = !!this.props.mobile, k = (this.props.s && this.props.s.ak) || {}
|
||||||
|
const owner = k.view !== 'admin'
|
||||||
|
const R = (name, prefix, status, by, created, used, exp) => {
|
||||||
|
const rv = status === 'revoked', tone = { active: ['var(--rc-success-soft)', 'var(--ui-success)'], expired: ['var(--rc-warning-soft)', 'var(--ui-warning)'], revoked: ['var(--ui-bg-elevated)', 'var(--ui-text-muted)'] }[status]
|
||||||
|
return { name, prefix, status, by, created, used, exp, sbg: tone[0], sfg: tone[1], bg: rv ? 'var(--ui-bg-muted)' : 'var(--ui-bg)', fg: rv ? 'var(--ui-text-muted)' : 'var(--ui-text-highlighted)', fg2: rv ? 'var(--ui-text-muted)' : 'var(--ui-text-toned)', canRevoke: !rv }
|
||||||
|
}
|
||||||
|
let all = owner
|
||||||
|
? [R('Website build', 'rcms_Kf3x…', 'active', 'Marta Alves', '12 Mar 2026', '3 Oct 2026', 'None'), R('Nightly media import', 'rcms_Mq7b…', k.revoked ? 'revoked' : 'active', 'Jonas', '2 Apr 2026', '3 Oct 2026', '31 Dec 2026'), R('Staging preview', 'rcms_Zt2d…', 'expired', 'Marta Alves', '14 Jun 2026', '20 Aug 2026', '30 Aug 2026'), R('Old migration script', 'rcms_Hn5w…', 'revoked', 'Inès Duarte', '3 Apr 2026', '9 May 2026', 'None')]
|
||||||
|
: [R('Nightly media import', 'rcms_Mq7b…', 'active', 'Jonas', '2 Apr 2026', '3 Oct 2026', '31 Dec 2026'), R('Old CI token', 'rcms_Lp9c…', 'revoked', 'Jonas', '18 Jan 2026', '2 Feb 2026', 'None')]
|
||||||
|
const st = k.state || 'list'
|
||||||
|
const rows = st === 'loading' ? Array.from({ length: 4 }, () => ({ skel: true, bg: 'var(--ui-bg)' })) : all
|
||||||
|
const cp = { reveal: true }
|
||||||
|
const b = (o) => ({ cancel: 'Cancel', cbg: 'var(--ui-primary)', cfg: 'var(--ui-bg)', cop: '1', ...o })
|
||||||
|
const form = (o) => b({ form: true, title: 'Create API key', desc: 'The key acts as you, with your role. It is shown in full once.', confirm: 'Create key', name: 'Nightly media import', count: '20 / 60', cntFg: 'var(--ui-text-muted)', nameBd: 'var(--ui-border-accented)', nameFg: 'var(--ui-text-muted)', nameHelp: 'Required. Up to 60 characters.', exp: '31 Dec 2026', expTx: 'var(--ui-text-highlighted)', expBd: 'var(--ui-border-accented)', expFg: 'var(--ui-text-muted)', expHelp: 'Optional. Must be in the future. Leave it empty for a key that does not expire.', ...o })
|
||||||
|
const M = {
|
||||||
|
create: form({}),
|
||||||
|
invalid: form({ name: '', count: '0 / 60', nameBd: 'var(--ui-error)', nameFg: 'var(--ui-error)', nameHelp: 'Enter a name.', exp: '12 Mar 2026', expBd: 'var(--ui-error)', expFg: 'var(--ui-error)', expHelp: 'Choose a date in the future.' }),
|
||||||
|
toolong: form({ name: 'Nightly import of the new media library from the Brussels office file server', count: '75 / 60', cntFg: 'var(--ui-error)', nameBd: 'var(--ui-error)', nameFg: 'var(--ui-error)', nameHelp: 'Use 60 characters or fewer.' }),
|
||||||
|
reveal: b({ reveal: true, title: 'Your new API key', desc: '"Nightly media import" is ready. Copy the key and store it somewhere safe.', cancel: '', confirm: 'Done', cpT: 'Copy', cpIcon: 'copy', cpFg: 'var(--ui-text-highlighted)' }),
|
||||||
|
copied: b({ reveal: true, title: 'Your new API key', desc: '"Nightly media import" is ready. Copy the key and store it somewhere safe.', cancel: '', confirm: 'Done', cpT: 'Copied', cpIcon: 'check', cpFg: 'var(--ui-success)' }),
|
||||||
|
revoke: b({ title: 'Revoke this key?', desc: '"Nightly media import" (rcms_Mq7b…) will stop working right away. Requests that use it answer 401. This can\'t be undone.', cancel: 'Keep key', confirm: 'Revoke key', cbg: 'var(--ui-error)', cfg: 'var(--ui-bg)' })
|
||||||
|
}
|
||||||
|
const m = k.modal ? M[k.modal] : null
|
||||||
|
return {
|
||||||
|
mob, desk: !mob, owner, rows, pad: mob ? '20px 16px' : '32px', h1: mob ? '20px/28px' : '28px/36px',
|
||||||
|
sub: owner ? 'Keys act as the user who created them. As an owner you see everyone\'s keys.' : 'Keys act as you, with your role. You only see your own keys.',
|
||||||
|
err: st === 'error', empty: st === 'empty', showList: st === 'list' || st === 'loading',
|
||||||
|
cols: owner ? 'minmax(0,1.3fr) 110px 76px minmax(0,1fr) 100px 100px 100px 80px' : 'minmax(0,1.3fr) 110px 76px 100px 100px 100px 80px',
|
||||||
|
toast: !!k.toast, toastPos: mob ? 'left:16px;right:16px;bottom:16px' : 'right:24px;bottom:24px;width:320px',
|
||||||
|
modal: !!m, m: m || {}, mAlign: mob ? 'flex-end' : 'flex-start', mPad: mob ? '16px' : '120px 16px 16px', mW: mob ? '100%' : '480px', btnDir: mob ? 'column-reverse' : 'row', keyDir: mob ? 'column' : 'row'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -177,25 +177,8 @@
|
|||||||
</sc-if>
|
</sc-if>
|
||||||
</sc-if>
|
</sc-if>
|
||||||
|
|
||||||
<sc-if value="{{ isInteg }}">
|
<sc-if value="{{ isInteg }}"><div style="position:relative;flex:1;min-height:0;overflow:hidden"><dc-import name="IntegrationPage" s="{{ s }}" mobile="{{ mob }}" style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden" hint-size="100%,100%"></dc-import></div></sc-if>
|
||||||
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:24px;max-width:860px;overflow:hidden">
|
<sc-if value="{{ isKeys }}"><div style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden"><dc-import name="ApiKeysPage" s="{{ s }}" mobile="{{ mob }}" style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden" hint-size="100%,100%"></dc-import></div></sc-if>
|
||||||
<div style="display:flex;flex-direction:column;gap:4px"><span style="font:600 20px/28px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Connect your website</span><span style="color:var(--ui-text-muted)">A guide for the developer who builds your public site.</span></div>
|
|
||||||
<p style="margin:0;font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty">RenewCMS is headless: it stores and publishes your articles but doesn't show them to visitors. Your website fetches published articles from the two addresses below and displays them in its own design.</p>
|
|
||||||
<div style="display:flex;flex-direction:column;gap:6px"><span style="font-weight:500;color:var(--ui-text-highlighted)">Base URL</span><div style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)"><span style="flex:1;min-width:0;height:40px;padding:0 12px;display:flex;align-items:center;font:400 13px 'IBM Plex Mono',monospace;overflow:hidden;white-space:nowrap">https://cms.renew.example</span><span data-act="copy" style="height:40px;padding:0 12px;display:flex;align-items:center;gap:6px;border-left:1px solid var(--ui-border-accented);font-weight:500;color:{{ copyFg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ copyIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ copyIcon }}.svg') center / contain no-repeat"></span>{{ copyText }}</span></div></div>
|
|
||||||
<sc-for list="{{ endpoints }}" as="e" hint-placeholder-count="2">
|
|
||||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
|
|
||||||
<div style="padding:16px;display:flex;flex-direction:column;gap:8px;border-bottom:1px solid var(--ui-border)"><div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font:500 12px 'IBM Plex Mono',monospace">GET</span><span style="font:500 14px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ e.path }}</span></div><span style="color:var(--ui-text-toned)">{{ e.desc }}</span></div>
|
|
||||||
<div style="display:flex;gap:20px;padding:0 16px;border-bottom:1px solid var(--ui-border)"><span style="height:36px;display:flex;align-items:center;border-bottom:2px solid {{ e.l1 }};margin-bottom:-1px;font-weight:500;color:var(--ui-text-highlighted)">fetch</span><span style="height:36px;display:flex;align-items:center;color:var(--ui-text-toned)">curl</span><span style="height:36px;display:flex;align-items:center;color:var(--ui-text-toned)">Response</span></div>
|
|
||||||
<div style="background:var(--ui-bg-elevated)"><div style="height:32px;padding:0 8px 0 16px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">JavaScript</span><span style="display:flex;align-items:center;gap:6px;color:var(--ui-text-toned);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/copy.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/copy.svg') center / contain no-repeat"></span>Copy</span></div><pre style="margin:0;padding:14px 16px;overflow:hidden;font:400 12px/19px 'IBM Plex Mono',monospace;color:var(--ui-text);white-space:pre">{{ e.code }}</pre></div>
|
|
||||||
</div>
|
|
||||||
</sc-for>
|
|
||||||
<div style="display:flex;flex-direction:column;gap:10px;font-size:14px;line-height:22px;color:var(--ui-text-toned)">
|
|
||||||
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Anonymous callers only ever get online articles. Drafts stay private until you publish them.</span></div>
|
|
||||||
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Images in <code>body</code> use paths like <code>/static/uploadedImages/<uuid>.png</code>. Prefix them with the base URL before rendering.</span></div>
|
|
||||||
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</sc-if>
|
|
||||||
|
|
||||||
<sc-if value="{{ isUsers }}">
|
<sc-if value="{{ isUsers }}">
|
||||||
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:16px;flex:1;min-height:0">
|
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:16px;flex:1;min-height:0">
|
||||||
@@ -442,7 +425,7 @@ class Component extends DCLogic {
|
|||||||
desk: !m, mob: m, notBoot: !s.boot,
|
desk: !m, mob: m, notBoot: !s.boot,
|
||||||
fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row',
|
fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row',
|
||||||
mLead: s.mLead || 'menu',
|
mLead: s.mLead || 'menu',
|
||||||
isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isError: k === 'error', isUsers: k === 'users',
|
isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isKeys: k === 'apikeys', isError: k === 'error', isUsers: k === 'users',
|
||||||
usRows, usError: !!US.error, usOnly: !!US.only && !US.error, usTable: !m && !US.error, usCards: m && !US.error,
|
usRows, usError: !!US.error, usOnly: !!US.only && !US.error, usTable: !m && !US.error, usCards: m && !US.error,
|
||||||
pad: m ? '20px 16px 88px' : '32px', gap: m ? '20px' : '28px', cols3: m ? 'repeat(3,minmax(0,1fr))' : 'repeat(3,minmax(0,1fr))',
|
pad: m ? '20px 16px 88px' : '32px', gap: m ? '20px' : '28px', cols3: m ? 'repeat(3,minmax(0,1fr))' : 'repeat(3,minmax(0,1fr))',
|
||||||
upBannerAlign: m ? 'stretch' : 'center', upCmdDir: m ? 'column' : 'row', btnH: m ? '44px' : '32px', rowH: m ? '52px' : '44px', inF: m ? '16px' : '14px',
|
upBannerAlign: m ? 'stretch' : 'center', upCmdDir: m ? 'column' : 'row', btnH: m ? '44px' : '32px', rowH: m ? '52px' : '44px', inF: m ? '16px' : '14px',
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<div ref="{{ rootRef }}" onScroll="{{ onScroll }}" style="position:absolute;inset:0;overflow-y:auto;overflow-x:hidden;scrollbar-width:thin;background:var(--ui-bg-muted);color:var(--ui-text);font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px">
|
||||||
|
<sc-if value="{{ mob }}">
|
||||||
|
<div style="position:sticky;top:0;z-index:5;background:var(--ui-bg);border-bottom:1px solid var(--ui-border);padding:8px 16px;display:flex;flex-direction:column;gap:4px">
|
||||||
|
<button aria-haspopup="listbox" aria-expanded="{{ jumpOpen }}" aria-label="Jump to section" onClick="{{ toggleJump }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:44px;padding:0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-size:14px" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px"><span style="color:var(--ui-text-muted)">Jump to</span><span style="flex:1;font-weight:500;color:var(--ui-text-highlighted)">{{ activeLabel }}</span><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:var(--ui-text-muted);-webkit-mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat"></span></button>
|
||||||
|
<sc-if value="{{ jumpOpen }}"><div role="listbox" aria-label="Sections" style="position:absolute;left:16px;right:16px;top:56px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px">
|
||||||
|
<sc-for list="{{ index }}" as="i" hint-placeholder-count="8"><button role="option" aria-selected="{{ i.on }}" onClick="{{ i.go }}" style="all:unset;box-sizing:border-box;cursor:pointer;width:100%;height:44px;padding:0 10px;display:flex;align-items:center;justify-content:space-between;font-size:14px;background:{{ i.bg }};color:var(--ui-text-highlighted);font-weight:{{ i.fw }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">{{ i.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ i.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></button></sc-for>
|
||||||
|
</div></sc-if>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
<div style="position:relative;padding:{{ pad }};display:flex;gap:40px;align-items:flex-start">
|
||||||
|
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:{{ secGap }}">
|
||||||
|
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px">
|
||||||
|
<span style="color:var(--ui-text-muted)">Integration</span>
|
||||||
|
<span style="font:600 {{ h1 }} 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API reference</span>
|
||||||
|
<span style="color:var(--ui-text-muted);font-size:14px;line-height:22px">Everything a developer needs to build on RenewCMS.</span>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0;font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty;max-width:720px">RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office.</p>
|
||||||
|
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px">
|
||||||
|
<span style="font-weight:500;color:var(--ui-text-highlighted)">Base URL</span>
|
||||||
|
<div style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)"><span style="flex:1;min-width:0;height:40px;padding:0 12px;display:flex;align-items:center;font:400 13px 'IBM Plex Mono',monospace;overflow:hidden;white-space:nowrap">https://cms.renew.example</span>
|
||||||
|
<button aria-label="Copy base URL" onClick="{{ copyBase }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:40px;padding:0 12px;display:flex;align-items:center;gap:6px;border-left:1px solid var(--ui-border-accented);font-weight:500;color:{{ baseCopy.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ baseCopy.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ baseCopy.icon }}.svg') center / contain no-repeat"></span><span aria-live="polite">{{ baseCopy.t }}</span></button></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section data-anchor="auth" aria-labelledby="h-auth" style="display:flex;flex-direction:column;gap:14px">
|
||||||
|
<h2 id="h-auth" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Authentication</h2>
|
||||||
|
<div style="display:grid;grid-template-columns:{{ authCols }};gap:12px;align-items:start">
|
||||||
|
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:10px">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px"><span style="font:600 15px/22px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Public</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px">No key</span></div>
|
||||||
|
<span style="color:var(--ui-text-toned);text-wrap:pretty">No key needed. Two read-only routes, and only online articles are returned.</span>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)"><span>GET /v1/article/</span><span>GET /v1/article/{id}</span></div>
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:10px;min-width:0">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px"><span style="font:600 15px/22px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API key</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:var(--rc-success-soft);color:var(--ui-success);font-size:12px;font-weight:500">Full access</span></div>
|
||||||
|
<span style="color:var(--ui-text-toned);text-wrap:pretty">Send <code>Authorization: Bearer rcms_…</code>. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator.</span>
|
||||||
|
<div style="border:1px solid var(--ui-border);min-width:0">
|
||||||
|
<div role="tablist" aria-label="Example language" style="display:flex;gap:16px;padding:0 12px;border-bottom:1px solid var(--ui-border)"><sc-for list="{{ authTabs }}" as="t" hint-placeholder-count="2"><button role="tab" aria-selected="{{ t.on }}" onClick="{{ t.pick }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:36px;display:flex;align-items:center;border-bottom:2px solid {{ t.bd }};margin-bottom:-1px;font-weight:500;color:{{ t.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">{{ t.t }}</button></sc-for></div>
|
||||||
|
<div style="background:var(--ui-bg-elevated)"><div style="height:32px;padding:0 4px 0 12px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ authLang }}</span><button aria-label="Copy example" onClick="{{ copyAuth }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:28px;padding:0 8px;display:flex;align-items:center;gap:6px;font-weight:500;color:{{ authCopy.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ authCopy.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ authCopy.icon }}.svg') center / contain no-repeat"></span><span aria-live="polite">{{ authCopy.t }}</span></button></div>
|
||||||
|
<pre tabindex="0" style="margin:0;padding:12px;overflow-x:auto;font:400 12px/19px 'IBM Plex Mono',monospace;color:var(--ui-text)">{{ authCode }}</pre></div>
|
||||||
|
</div>
|
||||||
|
<span style="color:var(--ui-text-muted);text-wrap:pretty">Keys start with <code>rcms_</code> and are shown in full once, when you create them.</span>
|
||||||
|
<a href="#" style="align-self:flex-start;display:inline-flex;align-items:center;gap:6px;min-height:{{ linkH }};font-weight:500;color:var(--ui-text-highlighted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">Manage API keys<span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/arrow-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/arrow-right.svg') center / contain no-repeat"></span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:10px;font-size:14px;line-height:22px;color:var(--ui-text-toned)">
|
||||||
|
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under <code>/v1/system</code>. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified.</span></div>
|
||||||
|
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Each key can make 120 requests per minute. Beyond that the API answers 429 with a <code>Retry-After</code> header.</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="4">
|
||||||
|
<section data-anchor="{{ g.key }}" style="display:flex;flex-direction:column;gap:10px">
|
||||||
|
<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap"><h2 style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2><span style="color:var(--ui-text-muted)">{{ g.count }}</span></div>
|
||||||
|
<span style="color:var(--ui-text-toned);text-wrap:pretty">{{ g.desc }}</span>
|
||||||
|
<div style="border:1px solid var(--ui-border);border-top:0;background:var(--ui-bg)">
|
||||||
|
<sc-for list="{{ g.eps }}" as="ep" hint-placeholder-count="6">
|
||||||
|
<div data-anchor="{{ ep.anchor }}" style="border-top:1px solid var(--ui-border)">
|
||||||
|
<button aria-expanded="{{ ep.open }}" aria-label="{{ ep.label }}" onClick="{{ ep.toggle }}" style="all:unset;box-sizing:border-box;cursor:pointer;width:100%;display:{{ rowDisplay }};grid-template-columns:{{ rowCols }};flex-direction:column;align-items:{{ rowAlign }};gap:{{ rowGap }};padding:{{ rowPad }};min-height:44px;background:{{ ep.rowBg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">
|
||||||
|
<span style="display:flex;align-items:center;gap:10px;min-width:0"><span style="width:56px;height:22px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:{{ ep.mBg }};color:{{ ep.mFg }};border:1px solid {{ ep.mBd }};font:500 11px 'IBM Plex Mono',monospace">{{ ep.m }}</span><span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ ep.p }}</span><sc-if value="{{ mob }}"><span style="flex:1"></span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;color:var(--ui-text-muted);background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ ep.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ ep.chev }}.svg') center / contain no-repeat"></span></sc-if></span>
|
||||||
|
<span style="min-width:0;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:{{ descWrap }}">{{ ep.d }}</span>
|
||||||
|
<span style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-start"><sc-for list="{{ ep.chips }}" as="c" hint-placeholder-count="2"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;background:{{ c.bg }};color:{{ c.fg }};border:1px {{ c.bs }} {{ c.bd }};font-size:12px;font-weight:{{ c.fw }}"><sc-if value="{{ c.icon }}"><span aria-hidden="true" style="display:inline-block;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat"></span></sc-if>{{ c.t }}</span></sc-for></span>
|
||||||
|
<sc-if value="{{ desk }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;color:var(--ui-text-muted);background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ ep.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ ep.chev }}.svg') center / contain no-repeat"></span></sc-if>
|
||||||
|
</button>
|
||||||
|
<sc-if value="{{ ep.open }}">
|
||||||
|
<div style="padding:4px {{ detX }} 16px;display:flex;flex-direction:column;gap:14px;border-top:1px solid var(--ui-border-muted)">
|
||||||
|
<div style="padding-top:10px;display:flex;flex-direction:column;gap:6px"><span style="font-weight:600;color:var(--ui-text-highlighted)">{{ ep.bodyTitle }}</span>
|
||||||
|
<sc-if value="{{ ep.hasFields }}"><div style="border:1px solid var(--ui-border)">
|
||||||
|
<div style="display:grid;grid-template-columns:{{ fCols }};gap:4px 12px;padding:6px 10px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Name</span><span>Type</span><span>Required</span><sc-if value="{{ desk }}"><span>Rule</span></sc-if></div>
|
||||||
|
<sc-for list="{{ ep.fields }}" as="f" hint-placeholder-count="2"><div style="display:grid;grid-template-columns:{{ fCols }};gap:2px 12px;padding:8px 10px;border-top:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ f.n }}</span><span style="font:400 12px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)">{{ f.t }}</span><span style="color:var(--ui-text-toned)">{{ f.req }}</span><span style="grid-column:{{ ruleCol }};color:var(--ui-text-toned);text-wrap:pretty">{{ f.rule }}</span></div></sc-for></div></sc-if>
|
||||||
|
<sc-if value="{{ ep.noFields }}"><span style="color:var(--ui-text-muted)">No request body.</span></sc-if></div>
|
||||||
|
<div style="border:1px solid var(--ui-border);min-width:0">
|
||||||
|
<div role="tablist" aria-label="Example" style="display:flex;gap:16px;padding:0 12px;border-bottom:1px solid var(--ui-border)"><sc-for list="{{ ep.tabs }}" as="t" hint-placeholder-count="3"><button role="tab" aria-selected="{{ t.on }}" onClick="{{ t.pick }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:{{ tabH }};display:flex;align-items:center;border-bottom:2px solid {{ t.bd }};margin-bottom:-1px;font-weight:500;color:{{ t.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">{{ t.t }}</button></sc-for></div>
|
||||||
|
<div style="background:var(--ui-bg-elevated)"><div style="height:32px;padding:0 4px 0 12px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ ep.lang }}</span><button aria-label="{{ ep.copyLabel }}" onClick="{{ ep.copy }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:28px;padding:0 8px;display:flex;align-items:center;gap:6px;font-weight:500;color:{{ ep.cFg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ ep.cIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ ep.cIcon }}.svg') center / contain no-repeat"></span><span aria-live="polite">{{ ep.cT }}</span></button></div>
|
||||||
|
<pre tabindex="0" style="margin:0;padding:12px;overflow-x:auto;font:400 12px/19px 'IBM Plex Mono',monospace;color:var(--ui-text)">{{ ep.code }}</pre></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px"><span style="font-weight:600;color:var(--ui-text-highlighted)">Errors</span>
|
||||||
|
<sc-for list="{{ ep.errs }}" as="e" hint-placeholder-count="2"><div title="{{ e.cap }}" style="display:flex;align-items:baseline;gap:10px"><span style="width:36px;height:20px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">{{ e.c }}</span><span style="min-width:0;color:var(--ui-text-toned);text-wrap:pretty">{{ e.cap }}</span></div></sc-for></div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</sc-for>
|
||||||
|
|
||||||
|
<section data-anchor="session" aria-labelledby="h-sess" style="display:flex;flex-direction:column;gap:10px">
|
||||||
|
<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap"><h2 id="h-sess" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Session only</h2><span style="color:var(--ui-text-muted)">12 routes, not available to API keys</span></div>
|
||||||
|
<div style="background:var(--ui-bg-muted);border:1px dashed var(--ui-border-accented)">
|
||||||
|
<div style="display:flex;gap:10px;padding:12px 14px;color:var(--ui-text-toned);text-wrap:pretty"><span style="display:flex;padding-top:2px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span><span>These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session."</span></div>
|
||||||
|
<sc-for list="{{ sessGroups }}" as="sg" hint-placeholder-count="3">
|
||||||
|
<div style="border-top:1px dashed var(--ui-border-accented)">
|
||||||
|
<div style="padding:8px 14px 4px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">{{ sg.t }}</div>
|
||||||
|
<sc-for list="{{ sg.rows }}" as="r" hint-placeholder-count="4"><div style="display:{{ rowDisplay }};grid-template-columns:{{ sessCols }};flex-direction:column;align-items:{{ rowAlign }};gap:{{ sessGap }};padding:{{ sessPad }};min-height:36px"><span style="display:flex;align-items:center;gap:10px;min-width:0"><span style="width:56px;flex-shrink:0;font:500 11px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ r.m }}</span><span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:400 13px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)">{{ r.p }}</span></span><span style="color:var(--ui-text-muted)">{{ r.d }}</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;align-self:{{ chipSelf }};border:1px dashed var(--ui-border-accented);color:var(--ui-text-muted);font-size:12px;white-space:nowrap"><span aria-hidden="true" style="display:inline-block;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span>Session only</span></div></sc-for>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section data-anchor="errors" aria-labelledby="h-err" style="display:flex;flex-direction:column;gap:10px">
|
||||||
|
<h2 id="h-err" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Errors</h2>
|
||||||
|
<span style="color:var(--ui-text-toned);text-wrap:pretty">Errors use the HTTP status and a JSON body.</span>
|
||||||
|
<div style="border:1px solid var(--ui-border);background:var(--ui-bg);overflow:hidden">
|
||||||
|
<div style="display:grid;grid-template-columns:{{ errCols }};gap:12px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Meaning</span><sc-if value="{{ desk }}"><span>Typical cause</span></sc-if></div>
|
||||||
|
<sc-for list="{{ errRows }}" as="r" hint-placeholder-count="8"><div style="display:grid;grid-template-columns:{{ errCols }};gap:2px 12px;padding:10px 12px;border-top:1px solid var(--ui-border)"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.c }}</span><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ r.m }}</span><span style="grid-column:{{ errCause }};color:var(--ui-text-toned);text-wrap:pretty">{{ r.w }}</span></div></sc-for>
|
||||||
|
</div>
|
||||||
|
<div style="border:1px solid var(--ui-border);min-width:0"><div style="background:var(--ui-bg-elevated)"><div style="height:32px;padding:0 12px;display:flex;align-items:center;border-bottom:1px solid var(--ui-border);font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">Error body · JSON</div>
|
||||||
|
<pre tabindex="0" style="margin:0;padding:12px;overflow-x:auto;font:400 12px/19px 'IBM Plex Mono',monospace;color:var(--ui-text)">{{ errBody }}</pre></div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section data-anchor="notes" aria-labelledby="h-notes" style="display:flex;flex-direction:column;gap:10px">
|
||||||
|
<h2 id="h-notes" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Notes</h2>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:10px;font-size:14px;line-height:22px;color:var(--ui-text-toned)">
|
||||||
|
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Images in <code>body</code> use paths like <code>/static/uploadedImages/<uuid>.png</code>. Prefix them with the base URL before rendering.</span></div>
|
||||||
|
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.</span></div>
|
||||||
|
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Article routes accept <code>?format=html</code> or <code>?format=markdown</code> to choose how <code>body</code> is returned.</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<sc-if value="{{ desk }}">
|
||||||
|
<nav aria-label="On this page" style="position:sticky;top:32px;width:168px;flex-shrink:0;display:flex;flex-direction:column;gap:2px;border-left:1px solid var(--ui-border)">
|
||||||
|
<span style="padding:0 12px 6px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">On this page</span>
|
||||||
|
<sc-for list="{{ index }}" as="i" hint-placeholder-count="8"><button aria-current="{{ i.cur }}" onClick="{{ i.go }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:28px;padding:0 12px;margin-left:-1px;display:flex;align-items:center;border-left:2px solid {{ i.bd }};color:{{ i.fg }};font-weight:{{ i.fw }}" style-hover="color:var(--ui-text-highlighted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">{{ i.t }}</button></sc-for>
|
||||||
|
</nav>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script>
|
||||||
|
class Component extends DCLogic {
|
||||||
|
state = (() => { const i = (this.props.s && this.props.s.int) || {}; return { open: i.open ? { [i.open]: true } : {}, tab: i.open ? { [i.open]: i.tab || 'fetch' } : {}, copied: i.copied || null, jump: !!i.jump, active: i.scroll === 'ep-' + i.open ? 'media' : (i.scroll || 'auth'), authTab: i.authTab || 'curl' } })()
|
||||||
|
rootRef = React.createRef()
|
||||||
|
BASE = 'https://cms.renew.example'
|
||||||
|
KEY = 'rcms_Kf3x9QwL0aBc…'
|
||||||
|
IDX = [['auth', 'Authentication'], ['articles', 'Articles'], ['media', 'Media'], ['users', 'Users and invites'], ['settings', 'Settings'], ['session', 'Session only'], ['errors', 'Errors'], ['notes', 'Notes']]
|
||||||
|
componentDidMount() {
|
||||||
|
const i = (this.props.s && this.props.s.int) || {}
|
||||||
|
const go = () => { if (i.scroll) this.scrollTo(i.scroll, !!this.props.mobile) }
|
||||||
|
go(); setTimeout(go, 250); setTimeout(go, 900)
|
||||||
|
if (document.fonts && document.fonts.ready) document.fonts.ready.then(go)
|
||||||
|
}
|
||||||
|
scrollTo(a, mob) {
|
||||||
|
const r = this.rootRef.current; if (!r) return
|
||||||
|
const el = r.querySelector('[data-anchor="' + a + '"]'); if (!el) return
|
||||||
|
const off = a.indexOf('ep-') === 0 ? 24 : 0
|
||||||
|
r.scrollTop = Math.max(0, this.yOf(el, r) - (mob ? 64 : 32) - off)
|
||||||
|
}
|
||||||
|
yOf(el, r) { const w = el.offsetParent; return el.offsetTop + (w && w !== r ? w.offsetTop : 0) }
|
||||||
|
onScroll = () => {
|
||||||
|
const r = this.rootRef.current; if (!r) return
|
||||||
|
let cur = this.IDX[0][0]
|
||||||
|
for (const [k] of this.IDX) { const el = r.querySelector('[data-anchor="' + k + '"]'); if (el && this.yOf(el, r) <= r.scrollTop + 120) cur = k }
|
||||||
|
if (cur !== this.state.active) this.setState({ active: cur })
|
||||||
|
}
|
||||||
|
flash(k) {
|
||||||
|
this.setState({ copied: k }); clearTimeout(this._t); this._t = setTimeout(() => this.setState({ copied: null }), 2000)
|
||||||
|
}
|
||||||
|
hl(code) {
|
||||||
|
const re = /("[^"\n]*"|'[^'\n]*')|\b(const|await|fetch|curl|new)\b|\b(\d+)\b/g
|
||||||
|
const out = []; let last = 0, m, k = 0
|
||||||
|
while ((m = re.exec(code))) {
|
||||||
|
if (m.index > last) out.push(code.slice(last, m.index))
|
||||||
|
const c = m[1] ? 'var(--ui-success)' : m[2] ? 'var(--ui-info)' : 'var(--ui-warning)'
|
||||||
|
out.push(React.createElement('span', { key: k++, style: { color: c } }, m[0])); last = m.index + m[0].length
|
||||||
|
}
|
||||||
|
out.push(code.slice(last)); return out
|
||||||
|
}
|
||||||
|
eps() {
|
||||||
|
const A = (id, m, p, d, acc, key, errs, fields, x = {}) => ({ id, m, p, d, acc, key, errs, fields, ...x })
|
||||||
|
const S = (n, t, req, rule) => ({ n, t, req, rule })
|
||||||
|
const e401 = [401, 'The key is invalid, unknown, expired or revoked, or its user was deleted or unverified.']
|
||||||
|
const e404 = [404, 'No article with this id.']
|
||||||
|
const m404 = [404, 'No image with this id.']
|
||||||
|
const j = o => JSON.stringify(o, null, 2)
|
||||||
|
const G = {
|
||||||
|
articles: { title: 'Articles', desc: 'Read, write and publish articles. Titles can\'t be changed after creation.', eps: [
|
||||||
|
A('art-list', 'GET', '/v1/article/', 'List articles. Anonymous callers get online articles only.', 'Public', 1, [e401], [], { pub: 1, r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }),
|
||||||
|
A('art-get', 'GET', '/v1/article/{id}', 'Get one article. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ id: 12, title: 'How we price a bathroom renovation', status: 'online', body: '<p>…</p>' }) }),
|
||||||
|
A('art-create', 'POST', '/v1/article/', 'Create a draft article.', 'Verified user', 1, [[400, 'Title shorter than 3 or longer than 200 characters.'], e401], [S('title', 'string', 'Yes', '3 to 200 characters. Can\'t be changed later.'), S('content', 'string', 'Yes', 'HTML, up to 10 000 characters.')], { body: { title: 'Choosing tiles for a small bathroom', content: '<p>Small rooms show every grout line.</p>' }, r: j({ id: 13, status: 'draft' }) }),
|
||||||
|
A('art-update', 'PATCH', '/v1/article/{id}', 'Update the body of an article.', 'Verified user', 1, [[400, 'Content over 10 000 characters.'], e401, e404], [S('content', 'string', 'Yes', 'HTML, up to 10 000 characters.')], { body: { content: '<p>Updated text.</p>' }, r: j({ id: 12, status: 'draft' }) }),
|
||||||
|
A('art-pub', 'PATCH', '/v1/article/{id}/publish', 'Publish an article.', 'Verified user', 1, [e401, e404], [], { r: j({ id: 12, status: 'online' }) }),
|
||||||
|
A('art-unpub', 'PATCH', '/v1/article/{id}/unpublish', 'Unpublish an article.', 'Verified user', 1, [e401, e404], [], { r: j({ id: 12, status: 'draft' }) }),
|
||||||
|
A('art-del', 'DELETE', '/v1/article/{id}', 'Delete an article and its images.', 'Verified user', 1, [e401, e404], [], { r: '204 No Content' })
|
||||||
|
] },
|
||||||
|
media: { title: 'Media', desc: 'The image library. One image can be used in many articles.', eps: [
|
||||||
|
A('med-list', 'GET', '/v1/media/', 'List images.', 'Verified user', 1, [e401], [], { r: j([{ id: 4, filename: 'north-wall.jpg', size: 3900000 }]) }),
|
||||||
|
A('med-up', 'POST', '/v1/media/', 'Upload an image (multipart).', 'Verified user', 1, [[400, 'The server couldn\'t read the image.'], e401, [413, 'The file is over 10 MB.'], [415, 'Not a PNG, JPEG, WEBP or SVG image.']], [S('file', 'file', 'Yes', 'PNG, JPEG, WEBP or SVG, up to 10 MB.'), S('name', 'string', 'No', 'Filename without extension. Defaults to the file\'s own name.')], { multi: true, form: [['file', '@north-wall.jpg'], ['name', 'north-wall']], r: j({ id: 4, filename: 'north-wall.jpg', url: '/static/uploadedImages/3f9c.jpg' }) }),
|
||||||
|
A('med-ren', 'PATCH', '/v1/media/{id}', 'Rename an image. The URL stays the same.', 'Verified user', 1, [[400, 'The name is empty or only symbols.'], e401, m404], [S('name', 'string', 'Yes', 'Filename without extension.')], { body: { name: 'north-wall-01' }, r: j({ id: 4, filename: 'north-wall-01.jpg' }) }),
|
||||||
|
A('med-del', 'DELETE', '/v1/media/{id}', 'Delete an image. Articles that use it show a broken image.', 'Verified user', 1, [e401, m404], [], { r: '204 No Content' }),
|
||||||
|
A('med-merge', 'POST', '/v1/media/merge', 'Merge identical copies into one image.', 'Verified user', 1, [e401, [409, 'A copy was deleted or added since you loaded the list.']], [S('keepId', 'number', 'Yes', 'The image to keep.'), S('mergeIds', 'number[]', 'Yes', 'Identical copies to delete. Articles switch to the kept image.')], { body: { keepId: 4, mergeIds: [9, 11] }, r: j({ merged: 2, articlesUpdated: 5 }) }),
|
||||||
|
A('med-use', 'GET', '/v1/media/{id}/usage', 'List the articles that use an image.', 'Verified user', 1, [e401, m404], [], { r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) })
|
||||||
|
] },
|
||||||
|
users: { title: 'Users and invites', desc: 'People who can sign in, and pending invitations.', eps: [
|
||||||
|
A('usr-list', 'GET', '/v1/user/', 'List users and invites.', 'Verified user', 1, [e401], [], { r: j([{ email: 'marta@renew.example', role: 'owner', status: 'accepted' }]) }),
|
||||||
|
A('usr-me', 'PATCH', '/v1/user/me', 'Update my profile.', 'Verified user', 1, [[400, 'Display name over 60 characters.'], e401], [S('displayName', 'string', 'No', 'Up to 60 characters. Empty goes back to the username.')], { body: { displayName: 'Marta Alves' }, r: j({ displayName: 'Marta Alves' }) }),
|
||||||
|
A('usr-inv', 'POST', '/v1/user/invite', 'Invite a user by email.', 'Verified user', 1, [[400, 'Not a full email address.'], e401, [409, 'The address already has an account or a pending invite.']], [S('email', 'string', 'Yes', 'A full email address. The link expires after 3 days.')], { body: { email: 'lea.martin@renew.example' }, r: j({ id: 7, status: 'pending' }) }),
|
||||||
|
A('usr-cancel', 'DELETE', '/v1/user/invite/{id}', 'Cancel a pending invite.', 'Verified user', 1, [e401, [404, 'No pending invite with this id.']], [], { r: '204 No Content' }),
|
||||||
|
A('usr-renew', 'POST', '/v1/user/invite/{id}/renew', 'Renew an expired invite.', 'Verified user', 1, [e401, [404, 'No invite with this id.']], [], { r: j({ id: 7, status: 'pending' }) }),
|
||||||
|
A('auth-me', 'GET', '/v1/auth/me', 'Get the current user. With a key, the key\'s owner.', 'Verified user', 1, [e401], [], { r: j({ username: 'marta', email: 'marta@renew.example', role: 'owner' }) })
|
||||||
|
] },
|
||||||
|
settings: { title: 'Settings', desc: 'Read the site settings.', eps: [
|
||||||
|
A('set-get', 'GET', '/v1/settings', 'Get settings.', 'Verified user', 1, [e401], [], { r: j({ version: 'v2.2.0-beta' }) })
|
||||||
|
] }
|
||||||
|
}
|
||||||
|
const MB = { GET: ['var(--ui-bg-inverted)', 'var(--ui-text-inverted)', 'var(--ui-bg-inverted)'], POST: ['var(--rc-success-soft)', 'var(--ui-success)', 'var(--ui-success)'], PATCH: ['var(--rc-warning-soft)', 'var(--ui-warning)', 'var(--ui-warning)'], DELETE: ['var(--rc-error-soft)', 'var(--ui-error)', 'var(--ui-error)'] }
|
||||||
|
const mob = !!this.props.mobile
|
||||||
|
return Object.keys(G).map(key => {
|
||||||
|
const g = G[key]
|
||||||
|
return { key, title: g.title, desc: g.desc, count: g.eps.length + (g.eps.length === 1 ? ' route' : ' routes'), eps: g.eps.map(e => {
|
||||||
|
const open = !!this.state.open[e.id], tab = this.state.tab[e.id] || 'fetch', url = this.BASE + e.p.replace('{id}', '12')
|
||||||
|
const hdr = e.pub ? [] : ['Authorization: Bearer ' + this.KEY]
|
||||||
|
let curl = 'curl ' + (e.m === 'GET' ? '' : '-X ' + e.m + ' ') + '"' + url + '"'
|
||||||
|
hdr.forEach(h => curl += ' \\\n -H "' + h + '"')
|
||||||
|
if (e.body) curl += ' \\\n -H "Content-Type: application/json" \\\n -d \'' + JSON.stringify(e.body) + '\''
|
||||||
|
if (e.form) e.form.forEach(f => curl += ' \\\n -F "' + f[0] + '=' + f[1] + '"')
|
||||||
|
let fe = (e.form ? 'const form = new FormData()\n' + e.form.map(f => f[1][0] === '@' ? "form.append('file', file)" : "form.append('" + f[0] + "', '" + f[1] + "')").join('\n') + '\n\n' : '') + "const res = await fetch('" + url + "'"
|
||||||
|
const opts = []
|
||||||
|
if (e.m !== 'GET') opts.push("method: '" + e.m + "'")
|
||||||
|
if (hdr.length || e.body) opts.push('headers: { ' + [hdr.length ? "Authorization: 'Bearer " + this.KEY + "'" : null, e.body ? "'Content-Type': 'application/json'" : null].filter(Boolean).join(', ') + ' }')
|
||||||
|
if (e.body) opts.push('body: JSON.stringify(' + JSON.stringify(e.body) + ')')
|
||||||
|
if (e.form) opts.push('body: form')
|
||||||
|
fe += opts.length ? ', {\n ' + opts.join(',\n ') + '\n})' : ')'
|
||||||
|
const code = tab === 'curl' ? curl : tab === 'fetch' ? fe : e.r
|
||||||
|
const lang = tab === 'curl' ? 'Shell' : tab === 'fetch' ? 'JavaScript' : 'JSON'
|
||||||
|
const cp = this.state.copied === e.id
|
||||||
|
const chip = (t, o) => ({ t, bg: 'transparent', fg: 'var(--ui-text-toned)', bd: 'var(--ui-border-accented)', bs: 'solid', fw: 400, ...o })
|
||||||
|
const chips = [chip(e.acc, e.acc === 'Owner only' ? { bg: 'var(--ui-bg-elevated)', fg: 'var(--ui-text-highlighted)', fw: 500 } : {}), chip('API key OK', { icon: 'check', bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', bd: 'transparent', fw: 500 })]
|
||||||
|
return { anchor: 'ep-' + e.id, open, m: e.m, p: e.p, d: e.d, mBg: MB[e.m][0], mFg: MB[e.m][1], mBd: MB[e.m][2], chev: open ? 'chevron-up' : 'chevron-down', rowBg: open ? 'var(--ui-bg-muted)' : 'transparent', label: e.m + ' ' + e.p + '. ' + e.d, chips,
|
||||||
|
toggle: () => this.setState(s => ({ open: { ...s.open, [e.id]: !s.open[e.id] } })),
|
||||||
|
bodyTitle: e.multi ? 'Request body · multipart/form-data' : e.body ? 'Request body · JSON' : 'Request body', hasFields: e.fields.length > 0, noFields: e.fields.length === 0, fields: e.fields,
|
||||||
|
tabs: ['fetch', 'curl', 'Response'].map(t => { const k = t === 'Response' ? 'response' : t, on = (k === tab) || (t === tab); return { t, on, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', pick: () => this.setState(s => ({ tab: { ...s.tab, [e.id]: k } })) } }),
|
||||||
|
lang, code: this.hl(code), copyLabel: 'Copy ' + lang + ' example', cT: cp ? 'Copied' : 'Copy', cIcon: cp ? 'check' : 'copy', cFg: cp ? 'var(--ui-success)' : 'var(--ui-text-toned)',
|
||||||
|
copy: () => { try { navigator.clipboard && navigator.clipboard.writeText(code) } catch (x) { } this.flash(e.id) },
|
||||||
|
errs: e.errs.map(([c, cap]) => ({ c, cap })) }
|
||||||
|
}) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderVals() {
|
||||||
|
const mob = !!this.props.mobile, st = this.state
|
||||||
|
const code = {
|
||||||
|
curl: 'curl "' + this.BASE + '/v1/auth/me" \\\n -H "Authorization: Bearer ' + this.KEY + '"',
|
||||||
|
fetch: "const res = await fetch('" + this.BASE + "/v1/auth/me', {\n headers: { Authorization: 'Bearer " + this.KEY + "' }\n})\nconst me = await res.json()"
|
||||||
|
}[st.authTab]
|
||||||
|
const cp = k => ({ t: st.copied === k ? 'Copied' : 'Copy', icon: st.copied === k ? 'check' : 'copy', fg: st.copied === k ? 'var(--ui-success)' : 'var(--ui-text-toned)' })
|
||||||
|
const bc = cp('base'); if (st.copied !== 'base') bc.fg = 'var(--ui-text-highlighted)'
|
||||||
|
const index = this.IDX.map(([k, t]) => { const on = st.active === k; return { t, on, cur: on ? 'true' : undefined, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on ? 500 : 400, bg: on ? 'var(--ui-bg-elevated)' : 'transparent', op: on ? 1 : 0, go: () => { this.setState({ jump: false, active: k }); this.scrollTo(k, mob) } } })
|
||||||
|
const sg = (t, rows) => ({ t, rows: rows.map(([m, p, d]) => ({ m, p, d })) })
|
||||||
|
const D = (a) => 'rowDisplay'
|
||||||
|
return {
|
||||||
|
rootRef: this.rootRef, onScroll: this.onScroll, mob, desk: !mob, jumpOpen: st.jump, toggleJump: () => this.setState(s => ({ jump: !s.jump })),
|
||||||
|
activeLabel: (this.IDX.find(x => x[0] === st.active) || this.IDX[0])[1], index,
|
||||||
|
pad: mob ? '20px 16px 96px' : '32px', secGap: mob ? '28px' : '32px', h1: mob ? '20px/28px' : '28px/36px', detX: mob ? '12px' : '16px', linkH: mob ? '44px' : '28px', tabH: mob ? '44px' : '36px',
|
||||||
|
authCols: mob ? 'minmax(0,1fr)' : 'minmax(0,5fr) minmax(0,7fr)',
|
||||||
|
baseCopy: bc, copyBase: () => { try { navigator.clipboard && navigator.clipboard.writeText(this.BASE) } catch (x) { } this.flash('base') },
|
||||||
|
authTabs: ['curl', 'fetch'].map(t => { const on = st.authTab === t; return { t, on, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', pick: () => this.setState({ authTab: t }) } }),
|
||||||
|
authLang: st.authTab === 'curl' ? 'Shell' : 'JavaScript', authCode: this.hl(code), authCopy: cp('auth'), copyAuth: () => { try { navigator.clipboard && navigator.clipboard.writeText(code) } catch (x) { } this.flash('auth') },
|
||||||
|
groups: this.eps(),
|
||||||
|
rowDisplay: mob ? 'flex' : 'grid', rowCols: mob ? 'none' : '300px minmax(0,1fr) auto 16px', rowAlign: mob ? 'stretch' : 'center', rowGap: mob ? '6px' : '16px', rowPad: mob ? '12px 12px' : '0 16px', descWrap: mob ? 'normal' : 'nowrap',
|
||||||
|
fCols: mob ? 'minmax(0,1fr) auto auto' : '120px 72px 72px minmax(0,1fr)', ruleCol: mob ? '1 / -1' : 'auto',
|
||||||
|
sessGroups: [sg('System · owner only', [['GET', '/v1/system/update', 'Update status'], ['POST', '/v1/system/update/check', 'Check for update'], ['POST', '/v1/system/update/download', 'Download update'], ['POST', '/v1/system/restart', 'Restart']]), sg('Sign in and account', [['POST', '/v1/auth/login', 'Log in'], ['POST', '/v1/auth/logout', 'Log out'], ['POST', '/v1/auth/register', 'Register'], ['POST', '/v1/auth/verify', 'Verify email'], ['POST', '/v1/auth/password-reset', 'Reset password']]), sg('API key management', [['GET', '/v1/auth/api-key', 'List keys'], ['POST', '/v1/auth/api-key', 'Create a key'], ['DELETE', '/v1/auth/api-key/{id}', 'Revoke a key']])],
|
||||||
|
sessCols: mob ? 'none' : '280px minmax(0,1fr) auto', sessGap: mob ? '2px' : '16px', sessPad: mob ? '8px 14px' : '0 14px', chipSelf: mob ? 'flex-start' : 'center',
|
||||||
|
errCols: mob ? '56px minmax(0,1fr)' : '64px 200px minmax(0,1fr)', errCause: mob ? '2' : 'auto',
|
||||||
|
errRows: [
|
||||||
|
{ c: 400, m: 'Bad request', w: 'A field breaks its rule, such as a title under 3 characters.' },
|
||||||
|
{ c: 401, m: 'Unauthorized', w: 'An invalid, unknown, expired or revoked key, even on public routes. Also a key whose user was deleted or unverified.' },
|
||||||
|
{ c: 403, m: 'Forbidden', w: 'The key\'s role is too low, or the route is session only: "This action needs a real session."' },
|
||||||
|
{ c: 404, m: 'Not found', w: 'The id doesn\'t exist. Drafts answer 404 to anonymous callers.' },
|
||||||
|
{ c: 409, m: 'Conflict', w: 'The data changed since you read it, or the address already has an account.' },
|
||||||
|
{ c: 422, m: 'Unprocessable', w: 'The request is well formed but can\'t be applied, such as an update file that fails its check.' },
|
||||||
|
{ c: 429, m: 'Too many requests', w: 'More than 120 requests per minute for one key. Wait for the number of seconds in the Retry-After header.' },
|
||||||
|
{ c: 500, m: 'Server error', w: 'Something failed on the server. Nothing was changed.' }
|
||||||
|
],
|
||||||
|
errBody: this.hl('{\n "statusCode": 403,\n "message": "This action needs a real session."\n}')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<meta name="design_doc_mode" content="canvas">
|
||||||
|
<link rel="icon" href="brand/favicon.svg">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&display=swap" rel="stylesheet">
|
||||||
|
<style>body{margin:0;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px}*{box-sizing:border-box}a{color:inherit;text-underline-offset:3px}a:hover{opacity:.8}code{font-family:'IBM Plex Mono',monospace;font-size:12px}</style>
|
||||||
|
</helmet>
|
||||||
|
<div ref="{{ rootRef }}" data-screen-label="Back-office screens" style="--ui-bg:#ffffff;--ui-bg-muted:#fafafa;--ui-bg-elevated:#f3f2f0;--ui-bg-accented:#e6e4e0;--ui-bg-inverted:#111111;--ui-text-dimmed:#999999;--ui-text-muted:#666666;--ui-text-toned:#444444;--ui-text:#222222;--ui-text-highlighted:#111111;--ui-text-inverted:#ffffff;--ui-border:#e6e4e0;--ui-border-muted:#f3f2f0;--ui-border-accented:#8a8783;--ui-primary:#111111;--ui-secondary:#635643;--ui-success:#315b33;--ui-info:#34536d;--ui-warning:#6c4700;--ui-error:#882c21;--rc-sand:#e8dfd1;--rc-success-soft:#eaefea;--rc-info-soft:#ebeef0;--rc-warning-soft:#f0ede6;--rc-error-soft:#f3eae9;--rc-overlay:rgb(10 10 10 / 0.4);--rc-shadow-pop:0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06);--rc-shadow-modal:0 16px 48px rgb(17 17 17 / 0.18);--rc-canvas:#ecebe8;background:var(--rc-canvas);color:var(--ui-text);padding:64px;display:flex;flex-direction:column;gap:72px;width:max-content;min-width:100%">
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
|
||||||
|
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
|
||||||
|
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screen: API keys</h1>
|
||||||
|
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">The screen the Authentication section of <a href="RenewCMS Screens Back-office.dc.html">the Integration screen</a> links to. 1440 px at 50%, 390 px at 75%. The theme switch shows every state in dark mode.</p>
|
||||||
|
</div>
|
||||||
|
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
|
||||||
|
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
|
||||||
|
<button role="radio" aria-checked="{{ m.on }}" onClick="{{ m.pick }}" style="all:unset;cursor:pointer;height:36px;padding:0 14px;display:flex;align-items:center;font-weight:500;font-size:14px;background:{{ m.bg }};color:{{ m.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">{{ m.label }}</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="7">
|
||||||
|
<section data-screen-label="{{ g.label }}" style="display:flex;flex-direction:column;gap:28px">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;max-width:1100px">
|
||||||
|
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2>
|
||||||
|
<div style="display:flex;gap:16px;flex-wrap:wrap;font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><span>{{ g.route }}</span><span style="color:var(--ui-text-muted)">{{ g.api }}</span></div>
|
||||||
|
<sc-if value="{{ g.note }}"><p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">{{ g.note }}</p></sc-if>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start">
|
||||||
|
<sc-for list="{{ g.states }}" as="st" hint-placeholder-count="4">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:2px;max-width:1040px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}</span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
|
||||||
|
<div style="display:flex;gap:24px;align-items:flex-start">
|
||||||
|
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if></div></div>
|
||||||
|
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<sc-if value="{{ g.table }}"><div style="display:flex;flex-direction:column;gap:8px;max-width:1240px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.tableTitle }}</span><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><div style="display:grid;grid-template-columns:90px 240px minmax(0,1fr);padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Where</span><span>Copy</span></div><sc-for list="{{ g.table }}" as="r" hint-placeholder-count="3"><div style="display:grid;grid-template-columns:90px 240px minmax(0,1fr);gap:0;padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-toned);padding-right:16px">{{ r.b }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.c }}</span></div></sc-for></div></div></sc-if>
|
||||||
|
</section>
|
||||||
|
</sc-for>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"theme":{"editor":"enum","options":["system","light","dark"],"default":"light","tsType":"'system' | 'light' | 'dark'"}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
state = { mode: this.props.theme ?? 'light' }
|
||||||
|
rootRef = React.createRef()
|
||||||
|
light = { '--ui-bg': '#ffffff', '--ui-bg-muted': '#fafafa', '--ui-bg-elevated': '#f3f2f0', '--ui-bg-accented': '#e6e4e0', '--ui-bg-inverted': '#111111', '--ui-text-dimmed': '#999999', '--ui-text-muted': '#666666', '--ui-text-toned': '#444444', '--ui-text': '#222222', '--ui-text-highlighted': '#111111', '--ui-text-inverted': '#ffffff', '--ui-border': '#e6e4e0', '--ui-border-muted': '#f3f2f0', '--ui-border-accented': '#8a8783', '--ui-primary': '#111111', '--ui-secondary': '#635643', '--ui-success': '#315b33', '--ui-info': '#34536d', '--ui-warning': '#6c4700', '--ui-error': '#882c21', '--rc-sand': '#e8dfd1', '--rc-success-soft': '#eaefea', '--rc-info-soft': '#ebeef0', '--rc-warning-soft': '#f0ede6', '--rc-error-soft': '#f3eae9', '--rc-overlay': 'rgb(10 10 10 / 0.4)', '--rc-shadow-pop': '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)', '--rc-shadow-modal': '0 16px 48px rgb(17 17 17 / 0.18)', '--rc-canvas': '#ecebe8' }
|
||||||
|
dark = { '--ui-bg': '#111111', '--ui-bg-muted': '#0a0a0a', '--ui-bg-elevated': '#1a1a1a', '--ui-bg-accented': '#222222', '--ui-bg-inverted': '#fafafa', '--ui-text-dimmed': '#666666', '--ui-text-muted': '#999999', '--ui-text-toned': '#cfccc7', '--ui-text': '#e6e4e0', '--ui-text-highlighted': '#fafafa', '--ui-text-inverted': '#111111', '--ui-border': '#333333', '--ui-border-muted': '#222222', '--ui-border-accented': '#666666', '--ui-primary': '#fafafa', '--ui-secondary': '#d4c5b0', '--ui-success': '#a3caa3', '--ui-info': '#a5c2db', '--ui-warning': '#dfb67d', '--ui-error': '#fba394', '--rc-sand': '#d4c5b0', '--rc-success-soft': '#1f241f', '--rc-info-soft': '#202428', '--rc-warning-soft': '#2a251e', '--rc-error-soft': '#2c201f', '--rc-overlay': 'rgb(0 0 0 / 0.6)', '--rc-shadow-pop': '0 4px 16px rgb(0 0 0 / 0.5)', '--rc-shadow-modal': '0 16px 48px rgb(0 0 0 / 0.6)', '--rc-canvas': '#1c1c1c' }
|
||||||
|
resolved() {
|
||||||
|
if (this.state.mode === 'system') return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
return this.state.mode
|
||||||
|
}
|
||||||
|
apply() {
|
||||||
|
const el = this.rootRef.current
|
||||||
|
if (!el) return
|
||||||
|
const vars = this.resolved() === 'dark' ? this.dark : this.light
|
||||||
|
for (const k in vars) el.style.setProperty(k, vars[k])
|
||||||
|
document.body.style.background = vars['--rc-canvas']
|
||||||
|
}
|
||||||
|
componentDidMount() { this.apply() }
|
||||||
|
componentDidUpdate(p) {
|
||||||
|
if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme })
|
||||||
|
this.apply()
|
||||||
|
}
|
||||||
|
groups() {
|
||||||
|
const withM = (st, mo = {}) => ({ ...st, m: { ...st, ...mo } })
|
||||||
|
return this.finish([
|
||||||
|
{ label: 'API keys', title: 'API keys', route: '/integration/api-keys', api: 'GET /v1/auth/api-key · POST /v1/auth/api-key · DELETE /v1/auth/api-key/{id} (session only)', note: 'Recommendation: place it under Integration, as a sub-page linked from the Authentication section and from the sidebar item. Keys exist to call the API, so they belong next to the reference. Settings is owner-only today, but admins can also create keys. The screen is session only: an API key cannot manage keys. Key name: required, up to 60 characters. Expiry: optional, in the future. A key is shown in full once; afterwards only its prefix is visible.', states: [
|
||||||
|
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Owner: list", trigger: "Owners see every user's keys and a \"Created by\" column. Expired keys keep their Revoke action.", ak: {"view":"owner"} }, { ak: {"view":"owner"} }),
|
||||||
|
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Admin: own keys only", trigger: "Admins see only the keys they created, with no \"Created by\" column.", ak: {"view":"admin"} }, { ak: {"view":"admin"} }),
|
||||||
|
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Loading", trigger: "The list request is in flight. Skeleton rows appear after 150 ms.", ak: {"state":"loading"} }, { ak: {"state":"loading"} }),
|
||||||
|
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Empty", trigger: "No keys yet.", ak: {"state":"empty"} }, { ak: {"state":"empty"} }),
|
||||||
|
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Error", trigger: "A 500 or network failure on the list request.", ak: {"state":"error"} }, { ak: {"state":"error"} }),
|
||||||
|
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Create key", trigger: "\"Create key\" in the page header, or the bottom action on mobile. Name is required and limited to 60 characters; expiry is optional.", ak: {"modal":"create"} }, { ak: {"modal":"create"} }),
|
||||||
|
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Create key: validation", trigger: "On submit or blur. Nothing is sent. The name is empty and the expiry is in the past.", ak: {"modal":"invalid"} }, { ak: {"modal":"invalid"} }),
|
||||||
|
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Create key: name too long", trigger: "Over 60 characters. Checked as you type.", ak: {"modal":"toolong"} }, { ak: {"modal":"toolong"} }),
|
||||||
|
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "One-time reveal", trigger: "201: the full key is shown once. Closing the dialog discards it from the screen.", ak: {"modal":"reveal"} }, { ak: {"modal":"reveal"} }),
|
||||||
|
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "One-time reveal: copied", trigger: "Copy was pressed. The button reads \"Copied\" for 2 s and this is announced.", ak: {"modal":"copied"} }, { ak: {"modal":"copied"} }),
|
||||||
|
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Revoke: confirm", trigger: "Revoke on an active or expired row.", ak: {"modal":"revoke"} }, { ak: {"modal":"revoke"} }),
|
||||||
|
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Revoked", trigger: "204: the key stays in the list, greyed, with no Revoke action. A toast confirms.", ak: {"revoked":1,"toast":1} }, { ak: {"revoked":1,"toast":1} })
|
||||||
|
] }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
finish(gs) { return gs.map(g => ({ ...g, states: g.states.map(st => ({ notMedia: !st.isMedia, ...st, m: { ...st.m } })) })) }
|
||||||
|
renderVals() {
|
||||||
|
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => {
|
||||||
|
const on = this.state.mode === k
|
||||||
|
return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) }
|
||||||
|
})
|
||||||
|
return { rootRef: this.rootRef, modes, groups: this.groups() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -265,9 +265,15 @@ class Component extends DCLogic {
|
|||||||
MS({ kind: 'picker', inArticle: true, picker: false, modal: { k: 'remove' }, name: 'Remove image: confirm', trigger: 'Remove image on an image in the article. It now only removes it from this article, and the copy says the file stays in the library. The confirm button is neutral, not red, because nothing is deleted. Title: "Remove this image from the article?"' }),
|
MS({ kind: 'picker', inArticle: true, picker: false, modal: { k: 'remove' }, name: 'Remove image: confirm', trigger: 'Remove image on an image in the article. It now only removes it from this article, and the copy says the file stays in the library. The confirm button is neutral, not red, because nothing is deleted. Title: "Remove this image from the article?"' }),
|
||||||
MS({ kind: 'picker', name: 'Removed', trigger: 'Confirmed. The image leaves the article body, which now has unsaved changes.', toasts: [TT('success', 'circle-check', 'Image removed', 'It\'s still in your library.')] })
|
MS({ kind: 'picker', name: 'Removed', trigger: 'Confirmed. The image leaves the article body, which now has unsaved changes.', toasts: [TT('success', 'circle-check', 'Image removed', 'It\'s still in your library.')] })
|
||||||
] },
|
] },
|
||||||
{ label: 'Integration', title: 'Connect your website', route: '/integration', api: 'none (static)', note: 'Written for a developer. The base URL is window.location.origin.', states: [
|
{ label: 'Integration', title: 'Integration: API reference', route: '/integration', api: 'none (static). Replaces "Connect your website".', note: 'Navigation: a sticky section index on the right on desktop, and a "Jump to" select on mobile. A long reference is read in one scroll and searched with the browser, so tabs would hide content and break find-in-page; the index keeps every group on one page, links to anchors and shows where you are. Written for a developer. The base URL is window.location.origin. The API keys screen has its own board.', states: [
|
||||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: 'Default', trigger: 'Opened from the sidebar or the Home card.' }),
|
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "1. Default", trigger: "Opened from the sidebar or the Home card. Every endpoint card is collapsed. Desktop shows the sticky index on the right; mobile shows the \"Jump to\" select above the content.", int: {} }, { int: {} }),
|
||||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', copied: true, name: 'Copied', trigger: 'The base URL was copied. The button reads "Copied" for 2 s and this is announced.' })
|
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "2. Group scrolled, one card expanded", trigger: "POST /v1/media/ opened in the Media group: multipart body with 2 fields, curl tab shown. Only one example block is visible per card. Other cards stay collapsed.", int: {"scroll":"media","open":"med-up","tab":"curl"} }, { int: {"scroll":"ep-med-up","open":"med-up","tab":"curl"} }),
|
||||||
|
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "3a. Copied: base URL", trigger: "The base URL was copied. The button reads \"Copied\" for 2 s and this is announced.", int: {"copied":"base"} }, { int: {"copied":"base"} }),
|
||||||
|
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "3b. Copied: CodeBlock", trigger: "The Copy button in the API key example after a click. Back to \"Copy\" after 2 s.", int: {"scroll":"auth","copied":"auth"} }, { int: {"scroll":"auth","copied":"auth"} }),
|
||||||
|
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "4. Authentication", trigger: "The API key card with its example, the \"Manage API keys\" link (goes to the API keys board) and the two notes.", int: {"scroll":"auth"} }, { int: {"scroll":"auth"} }),
|
||||||
|
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "5. Session only", trigger: "Routes API keys cannot call. Dashed, muted and locked, so they read as unavailable and not as an error.", int: {"scroll":"session"} }, { int: {"scroll":"session"} }),
|
||||||
|
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "6. Errors", trigger: "Status table and the JSON error shape. Key-specific causes are in the 401, 403 and 429 rows.", int: {"scroll":"errors"} }, { int: {"scroll":"errors"} }),
|
||||||
|
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "7. Navigation", trigger: "Desktop: the sticky index with the active item (Media). Mobile: the \"Jump to\" select open. Keyboard: the index buttons and the select are in the tab order, Enter jumps, Escape closes the list.", int: {"scroll":"media"} }, { int: {"scroll":"media","jump":true} })
|
||||||
] },
|
] },
|
||||||
{ label: 'Users', title: 'Users', route: '/users', api: 'GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id (pending) · POST /v1/user/invite/:id/renew (pending)', note: 'One shared workspace: every user sees the same articles and this same list. Columns: email, display name, role (owner or admin), invite status and created date. A pending invite can be cancelled and an expired one renewed; accepted accounts have no row actions. An invite is Pending until its link is used, Expired after 3 days, and Accepted once the account is created.', states: [
|
{ label: 'Users', title: 'Users', route: '/users', api: 'GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id (pending) · POST /v1/user/invite/:id/renew (pending)', note: 'One shared workspace: every user sees the same articles and this same list. Columns: email, display name, role (owner or admin), invite status and created date. A pending invite can be cancelled and an expired one renewed; accepted accounts have no row actions. An invite is Pending until its link is used, Expired after 3 days, and Accepted once the account is created.', states: [
|
||||||
withM({ ...U(), name: 'Default', trigger: 'The list loaded, newest first.' }),
|
withM({ ...U(), name: 'Default', trigger: 'The list loaded, newest first.' }),
|
||||||
|
|||||||
+20
-2
@@ -134,8 +134,26 @@ Every state is drawn at desktop and mobile on the screen boards, with final copy
|
|||||||
| Media picker | `/article/:id/edit` | Inserted | Insert image from either tab | [media-picker](screens/media-picker.md) |
|
| Media picker | `/article/:id/edit` | Inserted | Insert image from either tab | [media-picker](screens/media-picker.md) |
|
||||||
| Media picker | `/article/:id/edit` | Remove image: confirm | Remove image on an image in the article | [media-picker](screens/media-picker.md) |
|
| Media picker | `/article/:id/edit` | Remove image: confirm | Remove image on an image in the article | [media-picker](screens/media-picker.md) |
|
||||||
| Media picker | `/article/:id/edit` | Removed | Confirmed | [media-picker](screens/media-picker.md) |
|
| Media picker | `/article/:id/edit` | Removed | Confirmed | [media-picker](screens/media-picker.md) |
|
||||||
| Integration | `/integration` | Default | Page load | [integration](screens/integration.md) |
|
| Integration | `/integration` | Default | Page load, every endpoint card collapsed | [integration](screens/integration.md) |
|
||||||
| Integration | `/integration` | Copied | Copy clicked (2 s) | [integration](screens/integration.md) |
|
| Integration | `/integration` | Group scrolled, one card expanded | A card opened, one example visible | [integration](screens/integration.md) |
|
||||||
|
| Integration | `/integration` | Copied: base URL | Copy clicked (2 s) | [integration](screens/integration.md) |
|
||||||
|
| Integration | `/integration` | Copied: CodeBlock | Copy clicked in a code block (2 s) | [integration](screens/integration.md) |
|
||||||
|
| Integration | `/integration` | Authentication | API key card, link and notes | [integration](screens/integration.md) |
|
||||||
|
| Integration | `/integration` | Session only | Routes API keys cannot call | [integration](screens/integration.md) |
|
||||||
|
| Integration | `/integration` | Errors | Status table and JSON error shape | [integration](screens/integration.md) |
|
||||||
|
| Integration | `/integration` | Navigation | Section index active item; mobile Jump to open | [integration](screens/integration.md) |
|
||||||
|
| API keys | `/integration/api-keys` | Owner: list | Owner sees every key, with Created by | [integration](screens/integration.md) |
|
||||||
|
| API keys | `/integration/api-keys` | Admin: own keys only | Admin sees only their own keys | [integration](screens/integration.md) |
|
||||||
|
| API keys | `/integration/api-keys` | Loading | First load or retry | [integration](screens/integration.md) |
|
||||||
|
| API keys | `/integration/api-keys` | Empty | No keys yet | [integration](screens/integration.md) |
|
||||||
|
| API keys | `/integration/api-keys` | Error | 500 or network | [integration](screens/integration.md) |
|
||||||
|
| API keys | `/integration/api-keys` | Create key | Modal opened | [integration](screens/integration.md) |
|
||||||
|
| API keys | `/integration/api-keys` | Create key: validation | Name empty or expiry not in the future | [integration](screens/integration.md) |
|
||||||
|
| API keys | `/integration/api-keys` | Create key: name too long | More than 60 characters | [integration](screens/integration.md) |
|
||||||
|
| API keys | `/integration/api-keys` | One-time reveal | Key created, shown once | [integration](screens/integration.md) |
|
||||||
|
| API keys | `/integration/api-keys` | One-time reveal: copied | Copy clicked (2 s) | [integration](screens/integration.md) |
|
||||||
|
| API keys | `/integration/api-keys` | Revoke: confirm | Revoke pressed on a key | [integration](screens/integration.md) |
|
||||||
|
| API keys | `/integration/api-keys` | Revoked | Confirmed, key stays listed as revoked | [integration](screens/integration.md) |
|
||||||
| Users | `/users` | Default | List loaded, newest first | [users](screens/users.md) |
|
| Users | `/users` | Default | List loaded, newest first | [users](screens/users.md) |
|
||||||
| Users | `/users` | Loading | First load or retry | [users](screens/users.md) |
|
| Users | `/users` | Loading | First load or retry | [users](screens/users.md) |
|
||||||
| Users | `/users` | Only you | Nobody invited yet | [users](screens/users.md) |
|
| Users | `/users` | Only you | Nobody invited yet | [users](screens/users.md) |
|
||||||
|
|||||||
@@ -0,0 +1,457 @@
|
|||||||
|
Feature: Developers authenticate against /v1 with named API keys instead of the jwt cookie
|
||||||
|
|
||||||
|
As a developer integrating with RenewCMS
|
||||||
|
I want to create named API keys and send them as a Bearer token
|
||||||
|
So that I can manage articles, media and users without the UI, with exactly the rights of the
|
||||||
|
user who created the key and never more
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given internal/domain/apikey defines the entity APIKey with ID uint32, UserID uint32, Name,
|
||||||
|
Prefix, HashedKey, ExpiresAt *time.Time, RevokedAt *time.Time, LastUsedAt *time.Time and
|
||||||
|
CreatedAt, with snake_case json tags
|
||||||
|
And internal/domain/apikey defines the sentinels apikey.ErrAPIKeyNotFound,
|
||||||
|
apikey.ErrAPIKeyInvalid and apikey.ErrExpiryNotInFuture
|
||||||
|
And apikey.ErrAPIKeyInvalid covers an unknown, malformed, expired or revoked key and a key
|
||||||
|
whose user is deleted or unverified
|
||||||
|
And internal/domain/apikey defines the interface Repository with Create, Get(id),
|
||||||
|
GetByHashedKey(hashedKey), ListByUser(userId), ListAll(), Revoke(id, at),
|
||||||
|
RevokeAllByUser(userId, at) and TouchLastUsed(id, at)
|
||||||
|
And RevokeAllByUser sets RevokedAt to at on every key of that user that is not yet revoked and
|
||||||
|
leaves the RevokedAt of already revoked keys and every other user's keys untouched
|
||||||
|
And internal/domain/apikey defines the interface KeyGenerator with Generate() returning the
|
||||||
|
raw key, its display prefix and its SHA-256 hash, and Hash(rawKey) returning the SHA-256 hash
|
||||||
|
And internal/infrastructure/hashing implements KeyGenerator, asserted with
|
||||||
|
var _ apikey.KeyGenerator = &SHA256KeyGenerator{}
|
||||||
|
And a generated raw key is "rcms_" followed by the base64url encoding, without padding, of 32
|
||||||
|
random bytes, so it matches ^rcms_[A-Za-z0-9_-]{43}$
|
||||||
|
And the display prefix is the first 12 characters of the raw key
|
||||||
|
And only the SHA-256 hash and the prefix are persisted, never the raw key
|
||||||
|
And the use cases CreateAPIKey, ListAPIKeys, RevokeAPIKey and AuthenticateAPIKey depend only
|
||||||
|
on apikey.Repository, apikey.KeyGenerator, user.Repository and an injected clock
|
||||||
|
And ResetPasswordUseCase additionally receives apikey.Repository and an injected clock, and
|
||||||
|
after a successful password reset calls RevokeAllByUser(userId, now); it never reaches
|
||||||
|
persistence directly
|
||||||
|
And persistence has a GORM model, a mapper and a repository for the entity, the model is
|
||||||
|
added to the AutoMigrate list in api/dependecyInjection.go, and wiring happens only in
|
||||||
|
api/dependecyInjection.go
|
||||||
|
And a new middleware resolves "Authorization: Bearer rcms_<...>" to a user and places a jwt in
|
||||||
|
the same jwtauth request context the cookie path fills, with the claims user_id and
|
||||||
|
token_version taken from the user's current record
|
||||||
|
And that middleware runs after jwtauth.Verifier and ahead of jwtauth.Authenticator on the
|
||||||
|
/article routes (public and protected), /user, /media, /settings and GET /auth/me
|
||||||
|
And jwtauth.Authenticator, RequireCurrentToken, RequireVerifiedUser, RequireOwner and every
|
||||||
|
handler stay unchanged
|
||||||
|
And a new middleware RequireSession, mounted ahead of jwtauth.Verifier, answers 403 with
|
||||||
|
apperr.SessionRequiredErrorMessage whenever the Authorization header is a Bearer token that
|
||||||
|
starts with "rcms_", whether that key is valid, expired, revoked, unknown or garbage
|
||||||
|
And RequireSession is mounted on the key management routes POST /v1/api-key, GET /v1/api-key
|
||||||
|
and DELETE /v1/api-key/{id}, on the session-only auth routes POST /v1/auth/verify and
|
||||||
|
DELETE /v1/auth/register, and on every /v1/system route (all under /v1/system/update)
|
||||||
|
And a request on those routes without a "Bearer rcms_" header passes through RequireSession
|
||||||
|
unchanged, so cookie-only and jwt-Bearer behaviour there does not change
|
||||||
|
And the public auth routes login, register, registration, logout, password-reset,
|
||||||
|
password-reset/confirm, invite and invite/accept do not use the key middleware or
|
||||||
|
RequireSession and behave exactly as they do without a key header
|
||||||
|
And the routes are mounted under /v1 inside the jwtauth.Verifier, jwtauth.Authenticator,
|
||||||
|
RequireCurrentToken and RequireVerifiedUser group:
|
||||||
|
POST /v1/api-key, GET /v1/api-key and DELETE /v1/api-key/{id}
|
||||||
|
And a Bearer key on a key-accepting route that cannot authenticate gets 401 with
|
||||||
|
apperr.UnauthorizedErrorMessage, and a request without an rcms_ key keeps the existing
|
||||||
|
jwtauth.Authenticator response, status 401, unchanged by this feature
|
||||||
|
And new apperr strings: APIKeyNotFoundErrorMessage = "API key not found",
|
||||||
|
APIKeyExpiryErrorMessage = "The expiry must be a date in the future." and
|
||||||
|
SessionRequiredErrorMessage = "This action needs a real session."
|
||||||
|
And the per-key rate limit is 120 requests per minute, as constants in api/router.go
|
||||||
|
And enableCorsIfConfigured allows the methods GET, POST, PUT, PATCH, DELETE and OPTIONS
|
||||||
|
And users: owner id 1 "marta" (verified), admin id 2 "dev" (verified), admin id 3 "pat"
|
||||||
|
(verified), unverified id 4 "quinn"
|
||||||
|
|
||||||
|
Scenario: Creating a key returns the full key exactly once
|
||||||
|
Given a valid jwt cookie for admin 2
|
||||||
|
When the client sends POST /v1/api-key with body {"name": "ci-deploy"}
|
||||||
|
Then the response status is 201
|
||||||
|
And the header Cache-Control is "no-store"
|
||||||
|
And the body has exactly the keys id, name, prefix, key, created_at, expires_at
|
||||||
|
And name is "ci-deploy", expires_at is null, and key matches ^rcms_[A-Za-z0-9_-]{43}$
|
||||||
|
And prefix equals the first 12 characters of key
|
||||||
|
And the stored record has user_id 2 and HashedKey equal to the SHA-256 of key
|
||||||
|
And neither the raw key nor the stored hash appears in any later response
|
||||||
|
|
||||||
|
Scenario: The key can't be retrieved afterwards
|
||||||
|
Given admin 2 created the key "ci-deploy" and holds its raw key
|
||||||
|
When the same jwt cookie sends GET /v1/api-key
|
||||||
|
Then the response status is 200
|
||||||
|
And the body is a JSON array with one entry whose keys are exactly id, user_id, name, prefix,
|
||||||
|
status, created_at, last_used_at, expires_at, revoked_at
|
||||||
|
And the entry has no "key" and no "hashed_key" field
|
||||||
|
And the raw key and its hash appear nowhere in the body
|
||||||
|
And the entry's prefix is the first 12 characters of the raw key
|
||||||
|
|
||||||
|
Scenario: Creating a key with an expiry
|
||||||
|
Given a valid jwt cookie for admin 2
|
||||||
|
When the client sends POST /v1/api-key with body
|
||||||
|
{"name": "temp", "expires_at": "<now + 30 days, RFC3339>"}
|
||||||
|
Then the response status is 201
|
||||||
|
And expires_at in the body equals the requested instant
|
||||||
|
|
||||||
|
Scenario Outline: Creating a key with an invalid body is refused and creates nothing
|
||||||
|
Given a valid jwt cookie for admin 2
|
||||||
|
When the client sends POST /v1/api-key with <body>
|
||||||
|
Then the response status is 400
|
||||||
|
And the response body is <message>
|
||||||
|
And no key exists in the repository
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| body | message |
|
||||||
|
| body not-json | apperr.BodyErrorMessage |
|
||||||
|
| body {} | text containing 'Name' and 'required' |
|
||||||
|
| body {"name": ""} | text containing 'Name' and 'required' |
|
||||||
|
| body {"name": "<61 characters>"} | text containing 'Name' and 'max' |
|
||||||
|
| body {"name": "x", "expires_at": "yesterday"} | apperr.BodyErrorMessage |
|
||||||
|
| body {"name": "x", "expires_at": "<now - 1h>"} | apperr.APIKeyExpiryErrorMessage |
|
||||||
|
|
||||||
|
Scenario: A request body can't choose the owner or the role of a new key
|
||||||
|
Given a valid jwt cookie for admin 2
|
||||||
|
When the client sends POST /v1/api-key with body {"name": "sneaky", "user_id": 1, "role": "owner"}
|
||||||
|
Then the response status is 201
|
||||||
|
And the stored key belongs to user 2
|
||||||
|
And using that key on POST /v1/user/invite returns 403 with apperr.OwnerRequiredErrorMessage
|
||||||
|
|
||||||
|
Scenario Outline: Key management needs a verified real session
|
||||||
|
Given <caller>
|
||||||
|
When the client sends POST /v1/api-key with body {"name": "x"}
|
||||||
|
Then the response status is <status>
|
||||||
|
And the response body is <message>
|
||||||
|
And no key exists in the repository
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| caller | status | message |
|
||||||
|
| no credentials | 401 | the existing Authenticator response |
|
||||||
|
| a jwt cookie for user 4 | 403 | apperr.VerificationRequiredErrorMessage |
|
||||||
|
| a stale token_version cookie | 401 | the existing Authenticator response |
|
||||||
|
|
||||||
|
Scenario Outline: A valid key acts as its creator on every key-accepting route
|
||||||
|
Given an active key created by admin 2
|
||||||
|
When the client sends <method> <path> with "Authorization: Bearer <that key>" and no cookie <body>
|
||||||
|
Then the response status is <status>
|
||||||
|
And the effect is attributed to user 2
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| method | path | body | status |
|
||||||
|
| GET | /v1/settings | | 200 |
|
||||||
|
| GET | /v1/user | | 200 |
|
||||||
|
| PATCH | /v1/user/me | and body {"display_name": "Dev"} | 200 |
|
||||||
|
| POST | /v1/article | and body {"title": "A valid title", "body": "x"} | 201 |
|
||||||
|
| PATCH | /v1/article/1/publish | | 200 |
|
||||||
|
| DELETE | /v1/article/1 | | 204 |
|
||||||
|
| GET | /v1/media | | 200 |
|
||||||
|
| DELETE | /v1/media/1 | | 204 |
|
||||||
|
|
||||||
|
Scenario: A key can read its creator through GET /v1/auth/me
|
||||||
|
Given an active key created by admin 2
|
||||||
|
When the client sends GET /v1/auth/me with that key and no cookie
|
||||||
|
Then the response status is 200
|
||||||
|
And the body is the same JSON the jwt cookie of admin 2 gets on GET /v1/auth/me
|
||||||
|
And the body describes user 2 "dev"
|
||||||
|
|
||||||
|
Scenario: A key that can't authenticate gets 401 on GET /v1/auth/me
|
||||||
|
When the client sends GET /v1/auth/me with "Authorization: Bearer rcms_unknown"
|
||||||
|
Then the response status is 401
|
||||||
|
And the response body is apperr.UnauthorizedErrorMessage
|
||||||
|
|
||||||
|
Scenario: A key sees offline articles on the public article routes, an anonymous caller does not
|
||||||
|
Given an offline article 5 and an online article 6
|
||||||
|
And an active key created by admin 2
|
||||||
|
When the client sends GET /v1/article with that key
|
||||||
|
Then the body lists articles 5 and 6
|
||||||
|
When the client sends GET /v1/article with no credentials
|
||||||
|
Then the body lists only article 6
|
||||||
|
When the client sends GET /v1/article/5 with that key
|
||||||
|
Then the response status is 200
|
||||||
|
When the client sends GET /v1/article/5 with no credentials
|
||||||
|
Then the response status is 404
|
||||||
|
|
||||||
|
Scenario: An invalid key on a public article route is refused, not treated as anonymous
|
||||||
|
When the client sends GET /v1/article with "Authorization: Bearer rcms_unknown"
|
||||||
|
Then the response status is 401
|
||||||
|
And the response body is apperr.UnauthorizedErrorMessage
|
||||||
|
|
||||||
|
Scenario: An owner's key can do owner-only things
|
||||||
|
Given an active key created by owner 1
|
||||||
|
When the client sends POST /v1/user/invite with that key and body {"email": "new@example.com"}
|
||||||
|
Then the response status is 201
|
||||||
|
And the invite's invited_by is 1
|
||||||
|
|
||||||
|
Scenario: An admin's key can not do owner-only things
|
||||||
|
Given an active key created by admin 2
|
||||||
|
When the client sends POST /v1/user/invite with that key and body {"email": "new@example.com"}
|
||||||
|
Then the response status is 403
|
||||||
|
And the response body is apperr.OwnerRequiredErrorMessage
|
||||||
|
And no invite exists
|
||||||
|
|
||||||
|
Scenario Outline: A key that can't authenticate gets the same 401 as a bad jwt
|
||||||
|
Given <state>
|
||||||
|
When the client sends GET /v1/settings with "Authorization: Bearer <credential>"
|
||||||
|
Then the response status is 401
|
||||||
|
And the response body is apperr.UnauthorizedErrorMessage
|
||||||
|
And no handler or use case behind the route runs
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| state | credential |
|
||||||
|
| no key stored for it | rcms_unknownunknownunknownunknownunknownunknown1 |
|
||||||
|
| a key whose expires_at is one second ago | that key |
|
||||||
|
| a key that was revoked | that key |
|
||||||
|
| a key of user 1 whose user record was deleted | that key |
|
||||||
|
| a key of an unverified user | that key |
|
||||||
|
| nothing after the prefix | rcms_ |
|
||||||
|
| a key with one character changed | that key, last character changed |
|
||||||
|
| a key's hash passed instead of the key | the stored HashedKey, status 401 only |
|
||||||
|
|
||||||
|
Scenario: Key behaviour does not change the jwt cookie path
|
||||||
|
Given a valid jwt cookie for admin 2
|
||||||
|
When the client sends GET /v1/settings with only that cookie
|
||||||
|
Then the response status is 200
|
||||||
|
When the client sends GET /v1/settings with an Authorization header "Bearer <a valid jwt>" and no cookie
|
||||||
|
Then the response status is 200
|
||||||
|
When the client sends GET /v1/settings with a stale token_version cookie
|
||||||
|
Then the response status is 401
|
||||||
|
|
||||||
|
Scenario: A Bearer key wins over the cookie, and a bad key is not rescued by a good cookie
|
||||||
|
Given an active key created by admin 3 and a valid jwt cookie for owner 1
|
||||||
|
When the client sends POST /v1/user/invite with both the key and the cookie
|
||||||
|
Then the response status is 403
|
||||||
|
When the client sends GET /v1/settings with "Authorization: Bearer rcms_unknown" and the owner cookie
|
||||||
|
Then the response status is 401
|
||||||
|
|
||||||
|
Scenario Outline: Session-only routes refuse any rcms_ credential with 403
|
||||||
|
Given <state>
|
||||||
|
When the client sends <method> <path> with "Authorization: Bearer <credential>" <body>
|
||||||
|
Then the response status is 403
|
||||||
|
And the response body is apperr.SessionRequiredErrorMessage
|
||||||
|
And no use case behind the route runs
|
||||||
|
And no key was created, listed or revoked
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| state | credential | method | path | body |
|
||||||
|
| an active key of owner 1 | that key | POST | /v1/api-key | and body {"name": "child"} |
|
||||||
|
| an active key of owner 1 | that key | GET | /v1/api-key | |
|
||||||
|
| an active key of owner 1 | that key | DELETE | /v1/api-key/1 | |
|
||||||
|
| an active key of owner 1 | that key | POST | /v1/auth/verify | and body {"code": "x"} |
|
||||||
|
| an active key of owner 1 | that key | DELETE | /v1/auth/register | |
|
||||||
|
| an active key of owner 1 | that key | GET | /v1/system/update | |
|
||||||
|
| an active key of owner 1 | that key | POST | /v1/system/update/check | |
|
||||||
|
| an active key of owner 1 | that key | POST | /v1/system/update/download | |
|
||||||
|
| an active key of owner 1 | that key | POST | /v1/system/update/restart | |
|
||||||
|
| a revoked key | that key | GET | /v1/api-key | |
|
||||||
|
| no key stored for it | rcms_unknownunknownunknownunknownunknownunknown1 | POST | /v1/system/update/check | |
|
||||||
|
| nothing | rcms_ | DELETE | /v1/auth/register | |
|
||||||
|
|
||||||
|
Scenario Outline: The session-only routes still work as before with a session
|
||||||
|
Given a valid jwt cookie for owner 1
|
||||||
|
When the client sends <method> <path> with only that cookie <body>
|
||||||
|
Then the response is the same as before this feature
|
||||||
|
And a request with no credentials still gets status 401 from the existing Authenticator
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| method | path | body |
|
||||||
|
| POST | /v1/api-key | and body {"name": "x"} |
|
||||||
|
| GET | /v1/api-key | |
|
||||||
|
| GET | /v1/system/update | |
|
||||||
|
| POST | /v1/system/update/check | |
|
||||||
|
|
||||||
|
Scenario Outline: A key never obtains a session through the public auth routes
|
||||||
|
Given an active key created by owner 1
|
||||||
|
When the client sends <method> <path> with that key and body <body>
|
||||||
|
Then the response is the same as without the key
|
||||||
|
And no jwt cookie is set unless the credentials in the body are valid
|
||||||
|
And the stored user and password are unchanged
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| method | path | body |
|
||||||
|
| POST | /v1/auth/login | {"username": "marta", "password": "wrong-pass-1"} |
|
||||||
|
| POST | /v1/auth/password-reset/confirm | {"email": "marta@example.com", "code": "nope", "password": "newpassword1"} |
|
||||||
|
|
||||||
|
Scenario: A successful password reset revokes every key of that user
|
||||||
|
Given user 2 has active keys "a" and "b", and each works on GET /v1/settings
|
||||||
|
And user 2's stored PasswordResetCode is the hash of "reset-code-1" with an unexpired expiration
|
||||||
|
When the client sends POST /v1/auth/password-reset/confirm with body
|
||||||
|
{"email": "dev@example.com", "code": "reset-code-1", "password": "newpassword1"}
|
||||||
|
Then the response status is 200
|
||||||
|
And GET /v1/settings with key "a" returns 401 with apperr.UnauthorizedErrorMessage
|
||||||
|
And GET /v1/settings with key "b" returns 401 with apperr.UnauthorizedErrorMessage
|
||||||
|
|
||||||
|
Scenario: A password reset leaves other users' keys untouched
|
||||||
|
Given user 2 has an active key "a" and user 3 has an active key "c"
|
||||||
|
And user 2 has a valid password reset code "reset-code-1"
|
||||||
|
When user 2's password reset is confirmed with "reset-code-1"
|
||||||
|
Then GET /v1/settings with key "c" returns 200
|
||||||
|
And key "c" has no revoked_at
|
||||||
|
|
||||||
|
Scenario: Keys revoked by a password reset show as revoked
|
||||||
|
Given user 2 has active keys "a" and "b", and a clock at T
|
||||||
|
And user 2 has a valid password reset code "reset-code-1"
|
||||||
|
When user 2's password reset is confirmed with "reset-code-1"
|
||||||
|
And admin 2 logs in again with the new password and sends GET /v1/api-key with the new cookie
|
||||||
|
Then the entries "a" and "b" have status "revoked" and revoked_at T
|
||||||
|
|
||||||
|
Scenario: A password reset does not move the revoked_at of a key that was already revoked
|
||||||
|
Given user 2 has a key "old" revoked at T0 and an active key "a"
|
||||||
|
And user 2 has a valid password reset code "reset-code-1"
|
||||||
|
When user 2's password reset is confirmed with "reset-code-1" at a later time T
|
||||||
|
Then key "old" still has revoked_at T0
|
||||||
|
And key "a" has revoked_at T
|
||||||
|
|
||||||
|
Scenario Outline: A failed password reset confirmation revokes nothing
|
||||||
|
Given user 2 has an active key "a"
|
||||||
|
And user 2's stored PasswordResetCode is the hash of "reset-code-1" with <expiration>
|
||||||
|
When the client sends POST /v1/auth/password-reset/confirm with <body>
|
||||||
|
Then the response status is not 200
|
||||||
|
And key "a" has no revoked_at
|
||||||
|
And GET /v1/settings with key "a" returns 200
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| expiration | body |
|
||||||
|
| an unexpired expiration | {"email": "dev@example.com", "code": "wrong", "password": "newpassword1"} |
|
||||||
|
| an expiration in the past | {"email": "dev@example.com", "code": "reset-code-1", "password": "newpassword1"} |
|
||||||
|
| an unexpired expiration | {"email": "nobody@example.com", "code": "reset-code-1", "password": "newpassword1"} |
|
||||||
|
|
||||||
|
Scenario: Listing as an admin shows only that admin's keys
|
||||||
|
Given keys "a" and "b" created by admin 2 and key "c" created by admin 3
|
||||||
|
When admin 2 sends GET /v1/api-key with a jwt cookie
|
||||||
|
Then the body lists exactly "a" and "b"
|
||||||
|
|
||||||
|
Scenario: Listing as the owner shows every user's keys, with the creator's id
|
||||||
|
Given the same three keys and key "d" created by owner 1
|
||||||
|
When owner 1 sends GET /v1/api-key with a jwt cookie
|
||||||
|
Then the body lists "a", "b", "c" and "d" newest first
|
||||||
|
And each entry has the user_id of its creator
|
||||||
|
|
||||||
|
Scenario Outline: The list reports each key's state
|
||||||
|
Given a key that is <state>
|
||||||
|
When its creator sends GET /v1/api-key with a jwt cookie
|
||||||
|
Then the entry's status is "<status>", revoked_at is <revoked_at>, and last_used_at is <last_used>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| state | status | revoked_at | last_used |
|
||||||
|
| active, never used | active | null | null |
|
||||||
|
| active, used 5 minutes ago | active | null | that time |
|
||||||
|
| expired one hour ago | expired | null | unchanged |
|
||||||
|
| revoked | revoked | a time | unchanged |
|
||||||
|
| both expired and revoked | revoked | a time | unchanged |
|
||||||
|
|
||||||
|
Scenario: Revoking a key ends it immediately
|
||||||
|
Given an active key "ci-deploy" created by admin 2
|
||||||
|
And the key works on GET /v1/settings
|
||||||
|
When admin 2 sends DELETE /v1/api-key/<its id> with a jwt cookie
|
||||||
|
Then the response status is 204 and the body is empty
|
||||||
|
And GET /v1/settings with that key returns 401 with apperr.UnauthorizedErrorMessage
|
||||||
|
And GET /v1/api-key lists it with status "revoked"
|
||||||
|
|
||||||
|
Scenario: Revoking twice is idempotent
|
||||||
|
Given a key revoked at time T
|
||||||
|
When its creator sends DELETE /v1/api-key/<its id> again
|
||||||
|
Then the response status is 204
|
||||||
|
And revoked_at is still T
|
||||||
|
|
||||||
|
Scenario: An admin can't revoke someone else's key, and learns nothing about it
|
||||||
|
Given a key created by admin 3
|
||||||
|
When admin 2 sends DELETE /v1/api-key/<its id> with a jwt cookie
|
||||||
|
Then the response status is 404
|
||||||
|
And the response body is apperr.APIKeyNotFoundErrorMessage
|
||||||
|
And the key still works
|
||||||
|
|
||||||
|
Scenario: The owner can revoke any key
|
||||||
|
Given a key created by admin 3
|
||||||
|
When owner 1 sends DELETE /v1/api-key/<its id> with a jwt cookie
|
||||||
|
Then the response status is 204
|
||||||
|
And the key gets 401 on GET /v1/settings
|
||||||
|
|
||||||
|
Scenario Outline: Revoking with a bad id
|
||||||
|
Given a valid jwt cookie for admin 2
|
||||||
|
When the client sends DELETE /v1/api-key/<id>
|
||||||
|
Then the response status is <status>
|
||||||
|
And the response body is <message>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| id | status | message |
|
||||||
|
| abc | 400 | apperr.IdUint32ErrorMessage |
|
||||||
|
| -1 | 400 | apperr.IdUint32ErrorMessage |
|
||||||
|
| 4294967296 | 400 | apperr.IdUint32ErrorMessage |
|
||||||
|
| 999 | 404 | apperr.APIKeyNotFoundErrorMessage |
|
||||||
|
|
||||||
|
Scenario: Using a key records last_used_at, at most once per minute
|
||||||
|
Given an active key never used, and a fake clock at T
|
||||||
|
When the key authenticates a request at T
|
||||||
|
Then last_used_at is T
|
||||||
|
When it authenticates again at T + 30s
|
||||||
|
Then no write happened and last_used_at is still T
|
||||||
|
When it authenticates again at T + 61s
|
||||||
|
Then last_used_at is T + 61s
|
||||||
|
|
||||||
|
Scenario: A failure to record last_used_at doesn't fail the request
|
||||||
|
Given an active key and a repository whose TouchLastUsed returns an error
|
||||||
|
When the client sends GET /v1/settings with that key
|
||||||
|
Then the response status is 200
|
||||||
|
|
||||||
|
Scenario: A rejected key never records last_used_at
|
||||||
|
Given an expired key and a revoked key
|
||||||
|
When each is used on GET /v1/settings
|
||||||
|
Then each gets 401 and no last_used_at is written
|
||||||
|
|
||||||
|
Scenario: Each key has its own rate limit
|
||||||
|
Given two active keys "a" and "b" and a limit of N requests per minute per key
|
||||||
|
When key "a" sends N+1 requests to GET /v1/settings
|
||||||
|
Then request N+1 gets 429 with apperr.TooManyRequestsErrorMessage and a Retry-After header
|
||||||
|
And key "b" still gets 200
|
||||||
|
And a jwt cookie request still gets 200
|
||||||
|
|
||||||
|
Scenario: The rate limit does not apply to unauthenticated requests
|
||||||
|
When the client sends N+1 requests with "Authorization: Bearer rcms_unknown"
|
||||||
|
Then every response is 401, never 429
|
||||||
|
|
||||||
|
Scenario: The CORS preflight allows PATCH
|
||||||
|
Given settings with CorsAllowedOrigins ["https://app.example.com"]
|
||||||
|
When the client sends OPTIONS /v1/article/1 with Origin "https://app.example.com" and
|
||||||
|
Access-Control-Request-Method "PATCH"
|
||||||
|
Then the response is a successful preflight
|
||||||
|
And Access-Control-Allow-Methods includes PATCH
|
||||||
|
And a preflight for method "TRACE" is not allowed
|
||||||
|
|
||||||
|
Scenario: Use case: creating a key hashes it and stores no plaintext
|
||||||
|
Given a fake KeyGenerator returning raw "rcms_fake", prefix "rcms_fake", hash "hash:rcms_fake"
|
||||||
|
When CreateAPIKey is called with user 2 and name "ci"
|
||||||
|
Then the repository's Create receives HashedKey "hash:rcms_fake", Prefix "rcms_fake", UserID 2
|
||||||
|
And the result carries the raw key, which is not stored
|
||||||
|
|
||||||
|
Scenario: Use case: authenticating returns the user and rejects every invalid case with one sentinel
|
||||||
|
Given a fake KeyGenerator and keys that are unknown, expired, revoked, and owned by a missing or
|
||||||
|
unverified user
|
||||||
|
When AuthenticateAPIKey is called with each raw key
|
||||||
|
Then every call returns apikey.ErrAPIKeyInvalid, asserted with errors.Is
|
||||||
|
And a key expiring exactly at the clock's now is expired
|
||||||
|
And a valid key returns the user from user.Repository
|
||||||
|
|
||||||
|
Scenario: Use case: a failing repository is not reported as an invalid key
|
||||||
|
Given a repository whose GetByHashedKey returns an unexpected error
|
||||||
|
When AuthenticateAPIKey is called
|
||||||
|
Then the error is returned and errors.Is(err, apikey.ErrAPIKeyInvalid) is false
|
||||||
|
And the middleware answers 500 "Internal server error"
|
||||||
|
|
||||||
|
Scenario: Use case: ResetPassword revokes the user's keys through the domain repository
|
||||||
|
Given a fake apikey.Repository holding active keys of users 2 and 3, a fake clock at T, and a
|
||||||
|
user.Repository fake with a valid reset code for user 2
|
||||||
|
When ResetPasswordUseCase.ResetPassword succeeds for user 2
|
||||||
|
Then the fake apikey.Repository received exactly one RevokeAllByUser(2, T)
|
||||||
|
And user 3's keys are untouched
|
||||||
|
|
||||||
|
Scenario: Use case: ResetPassword does not call RevokeAllByUser when the reset fails
|
||||||
|
Given a fake apikey.Repository and a user.Repository fake with a wrong or expired reset code
|
||||||
|
When ResetPasswordUseCase.ResetPassword is called
|
||||||
|
Then it returns the existing invalid or expired sentinel
|
||||||
|
And RevokeAllByUser is never called
|
||||||
|
|
||||||
|
Scenario: Use case: a failure to revoke keys after a reset is reported
|
||||||
|
Given a valid reset code and an apikey.Repository whose RevokeAllByUser returns an error
|
||||||
|
When ResetPasswordUseCase.ResetPassword is called
|
||||||
|
Then the use case returns that error
|
||||||
|
And the password was already changed
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# QA procedure: API keys
|
||||||
|
|
||||||
|
Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
|
||||||
|
|
||||||
|
## Setup shared by every case
|
||||||
|
1. Call `validator.InitValidator()` once per test.
|
||||||
|
2. Add in-memory fakes with unique type names across the package: `mockAPIKeyRepo`
|
||||||
|
(implements `apikey.Repository` including `RevokeAllByUser`, with a `fail` switch per method such as
|
||||||
|
`failNext("TouchLastUsed")`, a `touchCalls` counter and a `revokeAllCalls` log of `(userId, at)`) and
|
||||||
|
`stubKeyGenerator` (deterministic raw/prefix/hash). Reuse `mockUserRepo`, `stubHasher` and the
|
||||||
|
article, media and invite fakes.
|
||||||
|
3. HTTP tests use the real SHA-256 generator, so key format and hashing are real. Unit-level tests
|
||||||
|
use `stubKeyGenerator`.
|
||||||
|
4. Build the handler stack from a `*api.UseCases` container holding fakes and mount
|
||||||
|
`api.InitBackendRoutes(container, settings)` on a test server (unused use cases stay nil). It is the
|
||||||
|
most faithful way to test the middleware order and the exclusions. If a nil use case breaks a
|
||||||
|
handler constructor, fall back to a fresh `chi.NewRouter()` that mirrors `api/router.go`:
|
||||||
|
`jwtauth.Verifier`, the key middleware, `jwtauth.Authenticator`, `RequireCurrentToken`,
|
||||||
|
`RequireVerifiedUser` (and `RequireOwner` on the invite group) for the key-accepting routes
|
||||||
|
(including `GET /auth/me`); `RequireSession` ahead of `jwtauth.Verifier` on `/api-key`,
|
||||||
|
`POST /auth/verify`, `DELETE /auth/register` and `/system`; and the public auth routes with neither.
|
||||||
|
The test token is `jwtauth.New("HS256", []byte("test-secret"), nil)`.
|
||||||
|
5. Inject a fake clock (`now func() time.Time`) into the key use cases and `ResetPasswordUseCase`.
|
||||||
|
6. Seed users: owner 1 "marta", admin 2 "dev", admin 3 "pat" (all verified), unverified 4 "quinn".
|
||||||
|
7. Helpers: `cookieFor(userId, tokenVersion)` mints a jwt cookie with
|
||||||
|
`token.Encode(map[string]any{"user_id": id, "token_version": v})`; `bearer(raw)` sets the header.
|
||||||
|
Create real keys through `POST /v1/api-key` with the cookie so the shown-once value is genuine.
|
||||||
|
|
||||||
|
## POST /v1/api-key
|
||||||
|
8. Admin 2 cookie, `{"name":"ci-deploy"}` -> `201`, `Cache-Control: no-store`, key set is exactly `id`,
|
||||||
|
`name`, `prefix`, `key`, `created_at`, `expires_at`; `key` matches `^rcms_[A-Za-z0-9_-]{43}$`; prefix
|
||||||
|
is `key[:12]`; stored `HashedKey` equals the generator's `Hash(key)`; `UserID` 2; the raw key is
|
||||||
|
stored nowhere in the fake.
|
||||||
|
9. Two creations give two different keys and ids.
|
||||||
|
10. With `expires_at` 30 days ahead -> `201`, echoed instant.
|
||||||
|
11. Invalid bodies (table): not-json -> `400` `apperr.BodyErrorMessage`; `{}`, `{"name":""}` -> `400`,
|
||||||
|
body contains `'Name'` and `required`; 61-char name -> `400`, contains `max`; `expires_at`
|
||||||
|
"yesterday" -> `400` `BodyErrorMessage`; past `expires_at` -> `400` `apperr.APIKeyExpiryErrorMessage`.
|
||||||
|
Each: repository still empty.
|
||||||
|
12. Body `{"name":"sneaky","user_id":1,"role":"owner"}` with admin 2 -> `201`; stored `UserID` 2; that
|
||||||
|
key on `POST /v1/user/invite` -> `403` `apperr.OwnerRequiredErrorMessage`, no invite.
|
||||||
|
13. Access table: no credentials -> `401`; user 4 cookie -> `403` `apperr.VerificationRequiredErrorMessage`;
|
||||||
|
stale `token_version` -> `401`. Each: repository empty.
|
||||||
|
|
||||||
|
## Shown once
|
||||||
|
14. After case 8, `GET /v1/api-key` -> entry keys exactly `id`, `user_id`, `name`, `prefix`, `status`,
|
||||||
|
`created_at`, `last_used_at`, `expires_at`, `revoked_at`; assert the response bytes contain neither
|
||||||
|
the raw key nor the stored hash. Also assert the raw key and hash are absent from the `DELETE`
|
||||||
|
response and from every error body in this file.
|
||||||
|
15. There is no route that returns a key again: `GET /v1/api-key/{id}` -> `404` or `405` (whatever the
|
||||||
|
router yields; assert not `200`).
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
16. Table over `GET /v1/settings`, `GET /v1/user`, `PATCH /v1/user/me`, `POST /v1/article`,
|
||||||
|
`PATCH /v1/article/{id}/publish`, `DELETE /v1/article/{id}`, `GET /v1/media`, `DELETE /v1/media/{id}`
|
||||||
|
with only the Bearer key of admin 2 -> `200` each; the stored side effects (author, display name)
|
||||||
|
belong to user 2.
|
||||||
|
17. Public article routes: seed offline article 5, online article 6. Key -> list has both, `GET /5` is
|
||||||
|
`200`. No credentials -> list has only 6, `GET /5` is `404`. `Bearer rcms_unknown` on `GET /v1/article`
|
||||||
|
-> `401` `apperr.UnauthorizedErrorMessage`.
|
||||||
|
18. Owner key `POST /v1/user/invite` -> `201`, `invited_by` 1. Admin key -> `403`
|
||||||
|
`apperr.OwnerRequiredErrorMessage`, no invite and no mail.
|
||||||
|
19. Table of 401 cases on `GET /v1/settings`, all with body `apperr.UnauthorizedErrorMessage` exactly (the
|
||||||
|
stored-hash case has no `rcms_` prefix, so it takes the jwt path: assert status `401` only):
|
||||||
|
unknown well-formed key; expired (clock one second past `expires_at`); revoked; creator deleted from
|
||||||
|
the user fake; creator unverified (set `IsVerified=false` in the fake); `Bearer rcms_`; one
|
||||||
|
character changed; the stored hash sent as the key. Assert each also leaves `touchCalls == 0`.
|
||||||
|
Without an `rcms_` key the existing `jwtauth.Authenticator` response is unchanged: assert status `401`.
|
||||||
|
20. A key whose `ExpiresAt` equals the clock's now is rejected; one second later than now is accepted.
|
||||||
|
21. Cookie path unchanged: valid cookie -> `200`; valid jwt in `Authorization: Bearer` with no cookie ->
|
||||||
|
`200`; stale `token_version` -> `401`; no credentials on a protected route -> `401`.
|
||||||
|
22. Precedence: valid key of admin 3 plus an owner cookie on `POST /v1/user/invite` -> `403`
|
||||||
|
(acts as admin 3); `Bearer rcms_unknown` plus an owner cookie on `GET /v1/settings` -> `401`.
|
||||||
|
23. Role is read live: change the stored role of user 2 from admin to owner in the fake (test-only) and
|
||||||
|
assert the key is now owner-capable, then back, and assert it is not. This pins "never more
|
||||||
|
rights than the creator's current rights".
|
||||||
|
24. `GET /v1/auth/me` with only admin 2's key -> `200`, body byte-equal to the body admin 2's cookie gets
|
||||||
|
on the same route, describing user 2. `Bearer rcms_unknown` on `GET /v1/auth/me` -> `401`
|
||||||
|
`apperr.UnauthorizedErrorMessage`.
|
||||||
|
|
||||||
|
## Session-only routes (RequireSession)
|
||||||
|
25. Table, each with body `apperr.SessionRequiredErrorMessage`, status `403`, assert the underlying use
|
||||||
|
cases (key repository, verify, cancel registration, update status/check/download/restart) were
|
||||||
|
not invoked and the key repository is unchanged:
|
||||||
|
- owner 1's active key on `POST /v1/api-key`, `GET /v1/api-key`, `DELETE /v1/api-key/1`,
|
||||||
|
`POST /v1/auth/verify`, `DELETE /v1/auth/register`, `GET /v1/system/update`,
|
||||||
|
`POST /v1/system/update/check`, `POST /v1/system/update/download`, `POST /v1/system/update/restart`;
|
||||||
|
- a revoked key on `GET /v1/api-key`;
|
||||||
|
- an unknown well-formed `rcms_...` key on `POST /v1/system/update/check`;
|
||||||
|
- the bare string `rcms_` on `DELETE /v1/auth/register`.
|
||||||
|
The 403 comes before any authentication, so assert the same 403 for an owner key and a garbage key.
|
||||||
|
26. Session routes unchanged for sessions: owner cookie on `POST /v1/api-key`, `GET /v1/api-key`,
|
||||||
|
`GET /v1/system/update`, `POST /v1/system/update/check` -> the same status and body as before this
|
||||||
|
feature; no credentials -> status `401` (existing Authenticator); a jwt in `Authorization: Bearer`
|
||||||
|
(not starting with `rcms_`) -> the same result as the cookie.
|
||||||
|
27. Public auth routes with an owner key attached: `POST /v1/auth/login` with wrong credentials -> `403`
|
||||||
|
`apperr.LogsErrorMessage`, no `jwt` Set-Cookie; `POST /v1/auth/password-reset/confirm` with a wrong
|
||||||
|
code -> the same response as without the key, and the key is not rejected with
|
||||||
|
`SessionRequiredErrorMessage`. The user's stored password hash and `TokenVersion` are unchanged.
|
||||||
|
|
||||||
|
## Password reset revokes keys
|
||||||
|
28. Seed user 2 with active keys "a" and "b" (each `200` on `GET /v1/settings`), user 3 with key "c", and a
|
||||||
|
reset code for user 2 (hash of "reset-code-1", unexpired). `POST /v1/auth/password-reset/confirm`
|
||||||
|
`{"email":"dev@example.com","code":"reset-code-1","password":"newpassword1"}` -> `200`; then keys "a"
|
||||||
|
and "b" get `401` `apperr.UnauthorizedErrorMessage` on `GET /v1/settings`.
|
||||||
|
29. Same seed: key "c" still gets `200` and has no `revoked_at`.
|
||||||
|
30. Same seed with the fake clock at T: log in as user 2 with the new password, `GET /v1/api-key` with the
|
||||||
|
new cookie; "a" and "b" have status `revoked` and `revoked_at` equal to T.
|
||||||
|
31. A key of user 2 already revoked at T0 keeps `revoked_at` T0 after a reset at a later T; the active
|
||||||
|
key gets T.
|
||||||
|
32. Failed confirms (table: wrong code; expired code; unknown email) -> status not `200`; key "a" has no
|
||||||
|
`revoked_at`, still `200` on `GET /v1/settings`, and `revokeAllCalls` is empty.
|
||||||
|
|
||||||
|
## Listing
|
||||||
|
33. Admin 2 sees only own keys; owner 1 sees all, newest first, each with its `user_id`.
|
||||||
|
34. Status table: active never used (`active`, nulls), active used (timestamp kept), expired
|
||||||
|
(`expired`), revoked (`revoked`, `revoked_at` set), expired-and-revoked (`revoked`).
|
||||||
|
|
||||||
|
## Revoke
|
||||||
|
35. Own key -> `204`, empty body; the key then gets `401` on `GET /v1/settings`; the list shows `revoked`.
|
||||||
|
36. Second `DELETE` -> `204`, `revoked_at` unchanged.
|
||||||
|
37. Admin 2 revoking admin 3's key -> `404` `apperr.APIKeyNotFoundErrorMessage`; the key still works.
|
||||||
|
Owner revoking it -> `204`; it then gets `401`.
|
||||||
|
38. Ids `abc`, `-1`, `4294967296` -> `400` `apperr.IdUint32ErrorMessage`; unknown id -> `404`.
|
||||||
|
39. Repository failure on `Revoke` -> `500` "Internal server error"; the key still works.
|
||||||
|
|
||||||
|
## last_used_at
|
||||||
|
40. Use-case level with the fake clock: first auth at T writes T; at T+30s no write
|
||||||
|
(`touchCalls` unchanged); at T+61s writes T+61s.
|
||||||
|
41. `TouchLastUsed` failing -> the request still returns `200`.
|
||||||
|
42. Rejected keys (case 19) never write.
|
||||||
|
|
||||||
|
## Rate limit
|
||||||
|
43. Mirror a group using the new per-key limiter with a limit of 2 (an exported constructor taking max
|
||||||
|
attempts and window): third request with key A -> `429`, body `apperr.TooManyRequestsErrorMessage`,
|
||||||
|
`Retry-After` header; key B and a cookie request still `200`; invalid keys stay `401`, never `429`.
|
||||||
|
44. Through `api.InitBackendRoutes`, send 121 requests with one key to `GET /v1/settings` -> the 121st is
|
||||||
|
`429`, the first 120 are `200`.
|
||||||
|
|
||||||
|
## CORS
|
||||||
|
45. Through `api.InitRoutes` with `CorsAllowedOrigins` `["https://app.example.com"]`: `OPTIONS /v1/article/1`
|
||||||
|
with `Origin` and `Access-Control-Request-Method: PATCH` -> success, `Access-Control-Allow-Methods`
|
||||||
|
includes `PATCH`; with `TRACE` it is not allowed.
|
||||||
|
|
||||||
|
## Use case level (no HTTP)
|
||||||
|
46. `CreateAPIKey` with `stubKeyGenerator`: repository receives the hash and the prefix and the user id;
|
||||||
|
the raw key appears only in the return value; a generator error -> returned, nothing stored; past
|
||||||
|
expiry -> `errors.Is(err, apikey.ErrExpiryNotInFuture)`.
|
||||||
|
47. `AuthenticateAPIKey`: each invalid case in 19 and 20 -> `errors.Is(err, apikey.ErrAPIKeyInvalid)`; a
|
||||||
|
valid key returns the user; an unexpected repository error is returned and is not `ErrAPIKeyInvalid`.
|
||||||
|
48. `ListAPIKeys` for admin and owner; `RevokeAPIKey` own, other's (`ErrAPIKeyNotFound` for admin),
|
||||||
|
owner any, unknown (`ErrAPIKeyNotFound`).
|
||||||
|
49. `ResetPasswordUseCase` built over `mockUserRepo`, `stubHasher`, `mockAPIKeyRepo` and a fake clock at T:
|
||||||
|
a successful reset for user 2 -> exactly one `RevokeAllByUser(2, T)` and user 3's keys untouched; a
|
||||||
|
wrong or expired code -> the existing sentinel and `revokeAllCalls` empty; `RevokeAllByUser`
|
||||||
|
failing -> that error is returned (the password was already changed).
|
||||||
|
50. `mockAPIKeyRepo.RevokeAllByUser` semantics: sets `RevokedAt` only on that user's not-yet-revoked
|
||||||
|
keys.
|
||||||
|
51. SHA-256 generator: raw matches the regex, `Hash(raw)` equals the generated hash, two generated keys
|
||||||
|
differ, prefix is the first 12 characters.
|
||||||
|
|
||||||
|
## Manual verification (not exercised by the Go tests)
|
||||||
|
- Real SQLite: start `task dev:api`, confirm `AutoMigrate` creates the api_keys table with a unique index
|
||||||
|
on the hashed key, create a key, restart, and check it still authenticates.
|
||||||
|
- With `curl`, run `Authorization: Bearer <key>` against a running server for an article create, a media
|
||||||
|
upload, an owner invite and `GET /v1/auth/me`, and confirm `PATCH` works from a browser origin listed
|
||||||
|
in `CORS_ALLOWED_ORIGINS`.
|
||||||
|
- Confirm the full key is shown only in the creation response and appears in no server log
|
||||||
|
(`ENVIRONMENT=development` enables httplog).
|
||||||
|
- A real reverse proxy: confirm `Authorization` is forwarded and `Retry-After` reaches the client.
|
||||||
|
- A real password reset by email: confirm the keys stop working after the link is used.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package apikey
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrAPIKeyNotFound = errors.New("api key not found")
|
||||||
|
var ErrAPIKeyInvalid = errors.New("api key invalid")
|
||||||
|
var ErrExpiryNotInFuture = errors.New("api key expiry not in the future")
|
||||||
|
|
||||||
|
const RawKeyPrefix = "rcms_"
|
||||||
|
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusActive Status = "active"
|
||||||
|
StatusExpired Status = "expired"
|
||||||
|
StatusRevoked Status = "revoked"
|
||||||
|
)
|
||||||
|
|
||||||
|
type APIKey struct {
|
||||||
|
ID uint32 `json:"id"`
|
||||||
|
UserID uint32 `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
HashedKey string `json:"hashed_key"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
|
RevokedAt *time.Time `json:"revoked_at"`
|
||||||
|
LastUsedAt *time.Time `json:"last_used_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k APIKey) StatusAt(now time.Time) Status {
|
||||||
|
if k.RevokedAt != nil {
|
||||||
|
return StatusRevoked
|
||||||
|
}
|
||||||
|
if k.ExpiresAt != nil && !now.Before(*k.ExpiresAt) {
|
||||||
|
return StatusExpired
|
||||||
|
}
|
||||||
|
return StatusActive
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package apikey
|
||||||
|
|
||||||
|
type KeyGenerator interface {
|
||||||
|
Generate() (rawKey string, prefix string, hashedKey string)
|
||||||
|
Hash(rawKey string) string
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package apikey
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
Create(key APIKey) (APIKey, error)
|
||||||
|
Get(id uint32) (APIKey, error)
|
||||||
|
GetByHashedKey(hashedKey string) (APIKey, error)
|
||||||
|
ListByUser(userId uint32) ([]APIKey, error)
|
||||||
|
ListAll() ([]APIKey, error)
|
||||||
|
Revoke(id uint32, at time.Time) error
|
||||||
|
RevokeAllByUser(userId uint32, at time.Time) error
|
||||||
|
TouchLastUsed(id uint32, at time.Time) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package hashing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/apikey"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
apiKeyRandomBytes = 32
|
||||||
|
apiKeyPrefixLen = 12
|
||||||
|
)
|
||||||
|
|
||||||
|
type SHA256KeyGenerator struct{}
|
||||||
|
|
||||||
|
func NewSHA256KeyGenerator() *SHA256KeyGenerator {
|
||||||
|
return &SHA256KeyGenerator{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *SHA256KeyGenerator) Generate() (string, string, string) {
|
||||||
|
random := make([]byte, apiKeyRandomBytes)
|
||||||
|
_, _ = rand.Read(random)
|
||||||
|
rawKey := apikey.RawKeyPrefix + base64.RawURLEncoding.EncodeToString(random)
|
||||||
|
return rawKey, rawKey[:apiKeyPrefixLen], g.Hash(rawKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *SHA256KeyGenerator) Hash(rawKey string) string {
|
||||||
|
sum := sha256.Sum256([]byte(rawKey))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ apikey.KeyGenerator = &SHA256KeyGenerator{}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package mappers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/apikey"
|
||||||
|
"RenewCMS/internal/infrastructure/persistence/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func APIKeyToDomain(m models.APIKey) apikey.APIKey {
|
||||||
|
return apikey.APIKey{
|
||||||
|
ID: m.ID,
|
||||||
|
UserID: m.UserID,
|
||||||
|
Name: m.Name,
|
||||||
|
Prefix: m.Prefix,
|
||||||
|
HashedKey: m.HashedKey,
|
||||||
|
ExpiresAt: m.ExpiresAt,
|
||||||
|
RevokedAt: m.RevokedAt,
|
||||||
|
LastUsedAt: m.LastUsedAt,
|
||||||
|
CreatedAt: m.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func APIKeyToModel(k apikey.APIKey) models.APIKey {
|
||||||
|
return models.APIKey{
|
||||||
|
ID: k.ID,
|
||||||
|
UserID: k.UserID,
|
||||||
|
Name: k.Name,
|
||||||
|
Prefix: k.Prefix,
|
||||||
|
HashedKey: k.HashedKey,
|
||||||
|
ExpiresAt: k.ExpiresAt,
|
||||||
|
RevokedAt: k.RevokedAt,
|
||||||
|
LastUsedAt: k.LastUsedAt,
|
||||||
|
CreatedAt: k.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type APIKey struct {
|
||||||
|
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||||
|
UserID uint32 `gorm:"index;not null"`
|
||||||
|
Name string `gorm:"not null"`
|
||||||
|
Prefix string `gorm:"not null"`
|
||||||
|
HashedKey string `gorm:"uniqueIndex;not null"`
|
||||||
|
ExpiresAt *time.Time
|
||||||
|
RevokedAt *time.Time
|
||||||
|
LastUsedAt *time.Time
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package persistence
|
||||||
|
|
||||||
|
import (
|
||||||
|
domainAPIKey "RenewCMS/internal/domain/apikey"
|
||||||
|
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||||
|
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type APIKeyRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAPIKeyRepository(db *gorm.DB) *APIKeyRepository {
|
||||||
|
return &APIKeyRepository{db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIKeyRepository) Create(key domainAPIKey.APIKey) (domainAPIKey.APIKey, error) {
|
||||||
|
model := mappers.APIKeyToModel(key)
|
||||||
|
model.ID = 0
|
||||||
|
if err := a.db.Create(&model).Error; err != nil {
|
||||||
|
return domainAPIKey.APIKey{}, err
|
||||||
|
}
|
||||||
|
return mappers.APIKeyToDomain(model), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIKeyRepository) Get(id uint32) (domainAPIKey.APIKey, error) {
|
||||||
|
var model entity.APIKey
|
||||||
|
if err := a.db.First(&model, id).Error; err != nil {
|
||||||
|
return domainAPIKey.APIKey{}, mapNotFoundErr(err, domainAPIKey.ErrAPIKeyNotFound)
|
||||||
|
}
|
||||||
|
return mappers.APIKeyToDomain(model), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIKeyRepository) GetByHashedKey(hashedKey string) (domainAPIKey.APIKey, error) {
|
||||||
|
var model entity.APIKey
|
||||||
|
if err := a.db.Where("hashed_key = ?", hashedKey).First(&model).Error; err != nil {
|
||||||
|
return domainAPIKey.APIKey{}, mapNotFoundErr(err, domainAPIKey.ErrAPIKeyNotFound)
|
||||||
|
}
|
||||||
|
return mappers.APIKeyToDomain(model), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIKeyRepository) ListByUser(userId uint32) ([]domainAPIKey.APIKey, error) {
|
||||||
|
return a.list(a.db.Where("user_id = ?", userId))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIKeyRepository) ListAll() ([]domainAPIKey.APIKey, error) {
|
||||||
|
return a.list(a.db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIKeyRepository) list(query *gorm.DB) ([]domainAPIKey.APIKey, error) {
|
||||||
|
var found []entity.APIKey
|
||||||
|
if err := query.Order("created_at DESC, id DESC").Find(&found).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]domainAPIKey.APIKey, 0, len(found))
|
||||||
|
for _, model := range found {
|
||||||
|
keys = append(keys, mappers.APIKeyToDomain(model))
|
||||||
|
}
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIKeyRepository) Revoke(id uint32, at time.Time) error {
|
||||||
|
result := a.db.Model(&entity.APIKey{}).
|
||||||
|
Where("id = ? AND revoked_at IS NULL", id).
|
||||||
|
Update("revoked_at", at)
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIKeyRepository) RevokeAllByUser(userId uint32, at time.Time) error {
|
||||||
|
return a.db.Model(&entity.APIKey{}).
|
||||||
|
Where("user_id = ? AND revoked_at IS NULL", userId).
|
||||||
|
Update("revoked_at", at).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *APIKeyRepository) TouchLastUsed(id uint32, at time.Time) error {
|
||||||
|
return a.db.Model(&entity.APIKey{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Update("last_used_at", at).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ domainAPIKey.Repository = &APIKeyRepository{}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package useCases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/apikey"
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const lastUsedWriteInterval = time.Minute
|
||||||
|
|
||||||
|
type AuthenticateAPIKeyUseCase struct {
|
||||||
|
apiKeyRepository apikey.Repository
|
||||||
|
keyGenerator apikey.KeyGenerator
|
||||||
|
userRepository user.Repository
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthenticatedAPIKey struct {
|
||||||
|
Key apikey.APIKey
|
||||||
|
User user.User
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthenticateAPIKeyUseCase(apiKeyRepository apikey.Repository, keyGenerator apikey.KeyGenerator, userRepository user.Repository, now func() time.Time) *AuthenticateAPIKeyUseCase {
|
||||||
|
return &AuthenticateAPIKeyUseCase{apiKeyRepository, keyGenerator, userRepository, now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthenticateAPIKeyUseCase) AuthenticateAPIKey(rawKey string) (AuthenticatedAPIKey, error) {
|
||||||
|
if !strings.HasPrefix(rawKey, apikey.RawKeyPrefix) {
|
||||||
|
return AuthenticatedAPIKey{}, apikey.ErrAPIKeyInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := a.apiKeyRepository.GetByHashedKey(a.keyGenerator.Hash(rawKey))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, apikey.ErrAPIKeyNotFound) {
|
||||||
|
return AuthenticatedAPIKey{}, apikey.ErrAPIKeyInvalid
|
||||||
|
}
|
||||||
|
return AuthenticatedAPIKey{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := a.now()
|
||||||
|
if key.StatusAt(now) != apikey.StatusActive {
|
||||||
|
return AuthenticatedAPIKey{}, apikey.ErrAPIKeyInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
owner, err := a.userRepository.Get(key.UserID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, user.ErrUserNotFound) {
|
||||||
|
return AuthenticatedAPIKey{}, apikey.ErrAPIKeyInvalid
|
||||||
|
}
|
||||||
|
return AuthenticatedAPIKey{}, err
|
||||||
|
}
|
||||||
|
if !owner.IsVerified {
|
||||||
|
return AuthenticatedAPIKey{}, apikey.ErrAPIKeyInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
a.recordUse(key, now)
|
||||||
|
return AuthenticatedAPIKey{Key: key, User: owner}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthenticateAPIKeyUseCase) recordUse(key apikey.APIKey, now time.Time) {
|
||||||
|
if key.LastUsedAt != nil && now.Sub(*key.LastUsedAt) < lastUsedWriteInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = a.apiKeyRepository.TouchLastUsed(key.ID, now)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package useCases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/apikey"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CreateAPIKeyUseCase struct {
|
||||||
|
apiKeyRepository apikey.Repository
|
||||||
|
keyGenerator apikey.KeyGenerator
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateAPIKeyCommand struct {
|
||||||
|
UserID uint32
|
||||||
|
Name string
|
||||||
|
ExpiresAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreatedAPIKey struct {
|
||||||
|
Key apikey.APIKey
|
||||||
|
RawKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCreateAPIKeyUseCase(apiKeyRepository apikey.Repository, keyGenerator apikey.KeyGenerator, now func() time.Time) *CreateAPIKeyUseCase {
|
||||||
|
return &CreateAPIKeyUseCase{apiKeyRepository, keyGenerator, now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CreateAPIKeyUseCase) CreateAPIKey(command CreateAPIKeyCommand) (CreatedAPIKey, error) {
|
||||||
|
now := c.now()
|
||||||
|
if command.ExpiresAt != nil && !command.ExpiresAt.After(now) {
|
||||||
|
return CreatedAPIKey{}, apikey.ErrExpiryNotInFuture
|
||||||
|
}
|
||||||
|
|
||||||
|
rawKey, prefix, hashedKey := c.keyGenerator.Generate()
|
||||||
|
stored, err := c.apiKeyRepository.Create(apikey.APIKey{
|
||||||
|
UserID: command.UserID,
|
||||||
|
Name: command.Name,
|
||||||
|
Prefix: prefix,
|
||||||
|
HashedKey: hashedKey,
|
||||||
|
ExpiresAt: command.ExpiresAt,
|
||||||
|
CreatedAt: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return CreatedAPIKey{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreatedAPIKey{Key: stored, RawKey: rawKey}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package useCases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/apikey"
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ListAPIKeysUseCase struct {
|
||||||
|
apiKeyRepository apikey.Repository
|
||||||
|
userRepository user.Repository
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIKeyEntry struct {
|
||||||
|
Key apikey.APIKey
|
||||||
|
Status apikey.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListAPIKeysUseCase(apiKeyRepository apikey.Repository, userRepository user.Repository, now func() time.Time) *ListAPIKeysUseCase {
|
||||||
|
return &ListAPIKeysUseCase{apiKeyRepository, userRepository, now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ListAPIKeysUseCase) ListAPIKeys(requesterId uint32) ([]APIKeyEntry, error) {
|
||||||
|
requester, err := l.userRepository.Get(requesterId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := l.keysVisibleTo(requester)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(keys, func(i, j int) bool {
|
||||||
|
if keys[i].CreatedAt.Equal(keys[j].CreatedAt) {
|
||||||
|
return keys[i].ID > keys[j].ID
|
||||||
|
}
|
||||||
|
return keys[i].CreatedAt.After(keys[j].CreatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
now := l.now()
|
||||||
|
entries := make([]APIKeyEntry, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
entries = append(entries, APIKeyEntry{Key: key, Status: key.StatusAt(now)})
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ListAPIKeysUseCase) keysVisibleTo(requester user.User) ([]apikey.APIKey, error) {
|
||||||
|
if requester.Role == user.RoleOwner {
|
||||||
|
return l.apiKeyRepository.ListAll()
|
||||||
|
}
|
||||||
|
return l.apiKeyRepository.ListByUser(requester.ID)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package useCases
|
package useCases
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"RenewCMS/internal/domain/apikey"
|
||||||
"RenewCMS/internal/domain/hash"
|
"RenewCMS/internal/domain/hash"
|
||||||
"RenewCMS/internal/domain/user"
|
"RenewCMS/internal/domain/user"
|
||||||
"time"
|
"time"
|
||||||
@@ -9,10 +10,12 @@ import (
|
|||||||
type ResetPasswordUseCase struct {
|
type ResetPasswordUseCase struct {
|
||||||
userRepository user.Repository
|
userRepository user.Repository
|
||||||
hasher hash.Hasher
|
hasher hash.Hasher
|
||||||
|
apiKeys apikey.Repository
|
||||||
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewResetPasswordUseCase(userRepository user.Repository, hasher hash.Hasher) *ResetPasswordUseCase {
|
func NewResetPasswordUseCase(userRepository user.Repository, hasher hash.Hasher, apiKeys apikey.Repository, now func() time.Time) *ResetPasswordUseCase {
|
||||||
return &ResetPasswordUseCase{userRepository, hasher}
|
return &ResetPasswordUseCase{userRepository, hasher, apiKeys, now}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ResetPasswordUseCase) ResetPassword(email string, code string, newPassword string) error {
|
func (r *ResetPasswordUseCase) ResetPassword(email string, code string, newPassword string) error {
|
||||||
@@ -25,7 +28,7 @@ func (r *ResetPasswordUseCase) ResetPassword(email string, code string, newPassw
|
|||||||
return user.ErrInvalidPasswordResetCode
|
return user.ErrInvalidPasswordResetCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if time.Now().After(dbUser.PasswordResetExpiration) {
|
if r.now().After(dbUser.PasswordResetExpiration) {
|
||||||
return user.ErrPasswordResetCodeExpired
|
return user.ErrPasswordResetCodeExpired
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +37,9 @@ func (r *ResetPasswordUseCase) ResetPassword(email string, code string, newPassw
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = r.userRepository.ResetPassword(dbUser.ID, hashedPassword)
|
if _, err = r.userRepository.ResetPassword(dbUser.ID, hashedPassword); err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.apiKeys.RevokeAllByUser(dbUser.ID, r.now())
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package useCases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/apikey"
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RevokeAPIKeyUseCase struct {
|
||||||
|
apiKeyRepository apikey.Repository
|
||||||
|
userRepository user.Repository
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRevokeAPIKeyUseCase(apiKeyRepository apikey.Repository, userRepository user.Repository, now func() time.Time) *RevokeAPIKeyUseCase {
|
||||||
|
return &RevokeAPIKeyUseCase{apiKeyRepository, userRepository, now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RevokeAPIKeyUseCase) RevokeAPIKey(requesterId uint32, keyId uint32) error {
|
||||||
|
key, err := r.apiKeyRepository.Get(keyId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
requester, err := r.userRepository.Get(requesterId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if requester.Role != user.RoleOwner && key.UserID != requester.ID {
|
||||||
|
return apikey.ErrAPIKeyNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if key.RevokedAt != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.apiKeyRepository.Revoke(key.ID, r.now())
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,642 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"RenewCMS/internal/domain/apikey"
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
|
"RenewCMS/internal/infrastructure/hashing"
|
||||||
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type apiKeyRevocation struct {
|
||||||
|
userId uint32
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockAPIKeyRepo struct {
|
||||||
|
keys map[uint32]apikey.APIKey
|
||||||
|
nextID uint32
|
||||||
|
fail failSwitch
|
||||||
|
createCalls []apikey.APIKey
|
||||||
|
touchCalls []time.Time
|
||||||
|
revokeCalls int
|
||||||
|
revokeAllCalls []apiKeyRevocation
|
||||||
|
getByHashedCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockAPIKeyRepo() *mockAPIKeyRepo {
|
||||||
|
return &mockAPIKeyRepo{keys: make(map[uint32]apikey.APIKey)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAPIKeyRepo) sorted() []apikey.APIKey {
|
||||||
|
result := make([]apikey.APIKey, 0, len(m.keys))
|
||||||
|
for _, key := range m.keys {
|
||||||
|
result = append(result, key)
|
||||||
|
}
|
||||||
|
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAPIKeyRepo) Create(key apikey.APIKey) (apikey.APIKey, error) {
|
||||||
|
if err := m.fail.trigger("Create"); err != nil {
|
||||||
|
return apikey.APIKey{}, err
|
||||||
|
}
|
||||||
|
m.createCalls = append(m.createCalls, key)
|
||||||
|
m.nextID++
|
||||||
|
key.ID = m.nextID
|
||||||
|
m.keys[key.ID] = key
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAPIKeyRepo) Get(id uint32) (apikey.APIKey, error) {
|
||||||
|
if err := m.fail.trigger("Get"); err != nil {
|
||||||
|
return apikey.APIKey{}, err
|
||||||
|
}
|
||||||
|
key, ok := m.keys[id]
|
||||||
|
if !ok {
|
||||||
|
return apikey.APIKey{}, apikey.ErrAPIKeyNotFound
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAPIKeyRepo) GetByHashedKey(hashedKey string) (apikey.APIKey, error) {
|
||||||
|
m.getByHashedCalls++
|
||||||
|
if err := m.fail.trigger("GetByHashedKey"); err != nil {
|
||||||
|
return apikey.APIKey{}, err
|
||||||
|
}
|
||||||
|
for _, key := range m.sorted() {
|
||||||
|
if key.HashedKey == hashedKey {
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return apikey.APIKey{}, apikey.ErrAPIKeyNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAPIKeyRepo) ListByUser(userId uint32) ([]apikey.APIKey, error) {
|
||||||
|
if err := m.fail.trigger("ListByUser"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]apikey.APIKey, 0)
|
||||||
|
for _, key := range m.sorted() {
|
||||||
|
if key.UserID == userId {
|
||||||
|
result = append(result, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAPIKeyRepo) ListAll() ([]apikey.APIKey, error) {
|
||||||
|
if err := m.fail.trigger("ListAll"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m.sorted(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAPIKeyRepo) Revoke(id uint32, at time.Time) error {
|
||||||
|
m.revokeCalls++
|
||||||
|
if err := m.fail.trigger("Revoke"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
key, ok := m.keys[id]
|
||||||
|
if !ok || key.RevokedAt != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
key.RevokedAt = &at
|
||||||
|
m.keys[id] = key
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAPIKeyRepo) RevokeAllByUser(userId uint32, at time.Time) error {
|
||||||
|
m.revokeAllCalls = append(m.revokeAllCalls, apiKeyRevocation{userId, at})
|
||||||
|
if err := m.fail.trigger("RevokeAllByUser"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for id, key := range m.keys {
|
||||||
|
if key.UserID == userId && key.RevokedAt == nil {
|
||||||
|
revokedAt := at
|
||||||
|
key.RevokedAt = &revokedAt
|
||||||
|
m.keys[id] = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAPIKeyRepo) TouchLastUsed(id uint32, at time.Time) error {
|
||||||
|
m.touchCalls = append(m.touchCalls, at)
|
||||||
|
if err := m.fail.trigger("TouchLastUsed"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
key, ok := m.keys[id]
|
||||||
|
if !ok {
|
||||||
|
return apikey.ErrAPIKeyNotFound
|
||||||
|
}
|
||||||
|
key.LastUsedAt = &at
|
||||||
|
m.keys[id] = key
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ apikey.Repository = &mockAPIKeyRepo{}
|
||||||
|
|
||||||
|
type stubKeyGenerator struct {
|
||||||
|
raw string
|
||||||
|
prefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubKeyGenerator) Generate() (string, string, string) {
|
||||||
|
return s.raw, s.prefix, s.Hash(s.raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubKeyGenerator) Hash(rawKey string) string {
|
||||||
|
return "hash:" + rawKey
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ apikey.KeyGenerator = &stubKeyGenerator{}
|
||||||
|
|
||||||
|
var apiKeyUnitNow = time.Date(2026, time.October, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
func apiKeyUnitClock() time.Time {
|
||||||
|
return apiKeyUnitNow
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedStubKey(repo *mockAPIKeyRepo, raw string, mutate func(*apikey.APIKey)) apikey.APIKey {
|
||||||
|
key := apikey.APIKey{UserID: 2, Name: "k", Prefix: raw, HashedKey: "hash:" + raw, CreatedAt: apiKeyUnitNow.Add(-time.Hour)}
|
||||||
|
if mutate != nil {
|
||||||
|
mutate(&key)
|
||||||
|
}
|
||||||
|
stored, _ := repo.Create(key)
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiKeyUnitUsers() *mockUserRepo {
|
||||||
|
users := newMockUserRepo()
|
||||||
|
users.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, Role: user.RoleOwner}
|
||||||
|
users.users[2] = user.User{ID: 2, Username: "dev", IsVerified: true, Role: user.RoleAdmin, TokenVersion: 3}
|
||||||
|
users.users[3] = user.User{ID: 3, Username: "pat", IsVerified: true, Role: user.RoleAdmin}
|
||||||
|
users.users[4] = user.User{ID: 4, Username: "quinn", IsVerified: false, Role: user.RoleAdmin}
|
||||||
|
return users
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSHA256KeyGenerator(t *testing.T) {
|
||||||
|
generator := hashing.NewSHA256KeyGenerator()
|
||||||
|
rawKeyPattern := regexp.MustCompile(`^rcms_[A-Za-z0-9_-]{43}$`)
|
||||||
|
|
||||||
|
t.Run("A generated key has the rcms_ format, a 12 character prefix and its SHA-256 hash", func(t *testing.T) {
|
||||||
|
raw, prefix, hashed := generator.Generate()
|
||||||
|
if !rawKeyPattern.MatchString(raw) {
|
||||||
|
t.Fatalf("unexpected raw key %q", raw)
|
||||||
|
}
|
||||||
|
if prefix != raw[:12] {
|
||||||
|
t.Errorf("expected prefix %q, got %q", raw[:12], prefix)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(raw))
|
||||||
|
if hashed != hex.EncodeToString(sum[:]) {
|
||||||
|
t.Errorf("expected the SHA-256 of the raw key, got %q", hashed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Hash is deterministic and never the raw key", func(t *testing.T) {
|
||||||
|
if generator.Hash("rcms_abc") != generator.Hash("rcms_abc") {
|
||||||
|
t.Errorf("expected a deterministic hash")
|
||||||
|
}
|
||||||
|
if generator.Hash("rcms_abc") == generator.Hash("rcms_abd") {
|
||||||
|
t.Errorf("expected different hashes for different keys")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Two generated keys differ", func(t *testing.T) {
|
||||||
|
first, _, _ := generator.Generate()
|
||||||
|
second, _, _ := generator.Generate()
|
||||||
|
if first == second {
|
||||||
|
t.Errorf("expected two different keys")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateAPIKeyUseCase(t *testing.T) {
|
||||||
|
t.Run("Use case: creating a key hashes it and stores no plaintext", func(t *testing.T) {
|
||||||
|
repo := newMockAPIKeyRepo()
|
||||||
|
uc := useCases.NewCreateAPIKeyUseCase(repo, &stubKeyGenerator{raw: "rcms_fake", prefix: "rcms_fake"}, apiKeyUnitClock)
|
||||||
|
|
||||||
|
created, err := uc.CreateAPIKey(useCases.CreateAPIKeyCommand{UserID: 2, Name: "ci"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(repo.createCalls) != 1 {
|
||||||
|
t.Fatalf("expected one Create call, got %d", len(repo.createCalls))
|
||||||
|
}
|
||||||
|
stored := repo.createCalls[0]
|
||||||
|
if stored.HashedKey != "hash:rcms_fake" || stored.Prefix != "rcms_fake" || stored.UserID != 2 || stored.Name != "ci" {
|
||||||
|
t.Errorf("unexpected stored key: %+v", stored)
|
||||||
|
}
|
||||||
|
if !stored.CreatedAt.Equal(apiKeyUnitNow) {
|
||||||
|
t.Errorf("expected CreatedAt from the clock, got %v", stored.CreatedAt)
|
||||||
|
}
|
||||||
|
if created.RawKey != "rcms_fake" {
|
||||||
|
t.Errorf("expected the raw key in the result, got %q", created.RawKey)
|
||||||
|
}
|
||||||
|
if stored.HashedKey == created.RawKey || stored.Prefix == "" {
|
||||||
|
t.Errorf("the raw key must not be stored")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("An expiry that is not in the future is refused and nothing is stored", func(t *testing.T) {
|
||||||
|
for name, expiry := range map[string]time.Time{"in the past": apiKeyUnitNow.Add(-time.Hour), "exactly now": apiKeyUnitNow} {
|
||||||
|
repo := newMockAPIKeyRepo()
|
||||||
|
uc := useCases.NewCreateAPIKeyUseCase(repo, &stubKeyGenerator{raw: "rcms_fake", prefix: "rcms_fake"}, apiKeyUnitClock)
|
||||||
|
|
||||||
|
_, err := uc.CreateAPIKey(useCases.CreateAPIKeyCommand{UserID: 2, Name: "ci", ExpiresAt: &expiry})
|
||||||
|
if !errors.Is(err, apikey.ErrExpiryNotInFuture) {
|
||||||
|
t.Errorf("%s: expected ErrExpiryNotInFuture, got %v", name, err)
|
||||||
|
}
|
||||||
|
if len(repo.createCalls) != 0 {
|
||||||
|
t.Errorf("%s: expected nothing stored", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A repository failure is returned", func(t *testing.T) {
|
||||||
|
repo := newMockAPIKeyRepo()
|
||||||
|
repo.fail.failNext("Create")
|
||||||
|
uc := useCases.NewCreateAPIKeyUseCase(repo, &stubKeyGenerator{raw: "rcms_fake", prefix: "rcms_fake"}, apiKeyUnitClock)
|
||||||
|
|
||||||
|
if _, err := uc.CreateAPIKey(useCases.CreateAPIKeyCommand{UserID: 2, Name: "ci"}); !errors.Is(err, errRepositoryFailure) {
|
||||||
|
t.Errorf("expected the repository failure, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticateAPIKeyUseCase(t *testing.T) {
|
||||||
|
newUseCase := func(repo *mockAPIKeyRepo, users *mockUserRepo) *useCases.AuthenticateAPIKeyUseCase {
|
||||||
|
return useCases.NewAuthenticateAPIKeyUseCase(repo, &stubKeyGenerator{}, users, apiKeyUnitClock)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Use case: authenticating returns the user and rejects every invalid case with one sentinel", func(t *testing.T) {
|
||||||
|
repo := newMockAPIKeyRepo()
|
||||||
|
expiredAt := apiKeyUnitNow.Add(-time.Second)
|
||||||
|
exactlyNow := apiKeyUnitNow
|
||||||
|
revokedAt := apiKeyUnitNow.Add(-time.Minute)
|
||||||
|
seedStubKey(repo, "rcms_expired", func(k *apikey.APIKey) { k.ExpiresAt = &expiredAt })
|
||||||
|
seedStubKey(repo, "rcms_exactlynow", func(k *apikey.APIKey) { k.ExpiresAt = &exactlyNow })
|
||||||
|
seedStubKey(repo, "rcms_revoked", func(k *apikey.APIKey) { k.RevokedAt = &revokedAt })
|
||||||
|
seedStubKey(repo, "rcms_missinguser", func(k *apikey.APIKey) { k.UserID = 99 })
|
||||||
|
seedStubKey(repo, "rcms_unverified", func(k *apikey.APIKey) { k.UserID = 4 })
|
||||||
|
uc := newUseCase(repo, apiKeyUnitUsers())
|
||||||
|
|
||||||
|
for _, raw := range []string{"rcms_unknown", "rcms_", "", "nope", "rcms_expired", "rcms_exactlynow", "rcms_revoked", "rcms_missinguser", "rcms_unverified"} {
|
||||||
|
if _, err := uc.AuthenticateAPIKey(raw); !errors.Is(err, apikey.ErrAPIKeyInvalid) {
|
||||||
|
t.Errorf("%q: expected ErrAPIKeyInvalid, got %v", raw, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(repo.touchCalls) != 0 {
|
||||||
|
t.Errorf("a rejected key must never record last_used_at")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A valid key returns the user from the user repository", func(t *testing.T) {
|
||||||
|
repo := newMockAPIKeyRepo()
|
||||||
|
stored := seedStubKey(repo, "rcms_valid", nil)
|
||||||
|
uc := newUseCase(repo, apiKeyUnitUsers())
|
||||||
|
|
||||||
|
authenticated, err := uc.AuthenticateAPIKey("rcms_valid")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if authenticated.User.ID != 2 || authenticated.User.TokenVersion != 3 || authenticated.Key.ID != stored.ID {
|
||||||
|
t.Errorf("unexpected result: %+v", authenticated)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A key expiring in the future is valid", func(t *testing.T) {
|
||||||
|
repo := newMockAPIKeyRepo()
|
||||||
|
later := apiKeyUnitNow.Add(time.Second)
|
||||||
|
seedStubKey(repo, "rcms_later", func(k *apikey.APIKey) { k.ExpiresAt = &later })
|
||||||
|
|
||||||
|
if _, err := newUseCase(repo, apiKeyUnitUsers()).AuthenticateAPIKey("rcms_later"); err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Use case: a failing repository is not reported as an invalid key", func(t *testing.T) {
|
||||||
|
repo := newMockAPIKeyRepo()
|
||||||
|
seedStubKey(repo, "rcms_valid", nil)
|
||||||
|
repo.fail.failNext("GetByHashedKey")
|
||||||
|
|
||||||
|
_, err := newUseCase(repo, apiKeyUnitUsers()).AuthenticateAPIKey("rcms_valid")
|
||||||
|
if !errors.Is(err, errRepositoryFailure) || errors.Is(err, apikey.ErrAPIKeyInvalid) {
|
||||||
|
t.Errorf("expected the raw repository failure, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A failing user repository is not reported as an invalid key", func(t *testing.T) {
|
||||||
|
repo := newMockAPIKeyRepo()
|
||||||
|
seedStubKey(repo, "rcms_valid", nil)
|
||||||
|
users := apiKeyUnitUsers()
|
||||||
|
users.fail.failNext("Get")
|
||||||
|
|
||||||
|
_, err := newUseCase(repo, users).AuthenticateAPIKey("rcms_valid")
|
||||||
|
if !errors.Is(err, errRepositoryFailure) || errors.Is(err, apikey.ErrAPIKeyInvalid) {
|
||||||
|
t.Errorf("expected the raw repository failure, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Using a key records last_used_at at most once per minute", func(t *testing.T) {
|
||||||
|
repo := newMockAPIKeyRepo()
|
||||||
|
stored := seedStubKey(repo, "rcms_valid", nil)
|
||||||
|
now := apiKeyUnitNow
|
||||||
|
uc := useCases.NewAuthenticateAPIKeyUseCase(repo, &stubKeyGenerator{}, apiKeyUnitUsers(), func() time.Time { return now })
|
||||||
|
|
||||||
|
steps := []struct {
|
||||||
|
offset time.Duration
|
||||||
|
writes int
|
||||||
|
lastUsed time.Duration
|
||||||
|
}{
|
||||||
|
{0, 1, 0},
|
||||||
|
{30 * time.Second, 1, 0},
|
||||||
|
{60 * time.Second, 2, 60 * time.Second},
|
||||||
|
{61*time.Second + 30*time.Second, 2, 60 * time.Second},
|
||||||
|
{130 * time.Second, 3, 130 * time.Second},
|
||||||
|
}
|
||||||
|
for _, step := range steps {
|
||||||
|
now = apiKeyUnitNow.Add(step.offset)
|
||||||
|
if _, err := uc.AuthenticateAPIKey("rcms_valid"); err != nil {
|
||||||
|
t.Fatalf("unexpected error at +%v: %v", step.offset, err)
|
||||||
|
}
|
||||||
|
if len(repo.touchCalls) != step.writes {
|
||||||
|
t.Errorf("at +%v: expected %d writes, got %d", step.offset, step.writes, len(repo.touchCalls))
|
||||||
|
}
|
||||||
|
if got := repo.keys[stored.ID].LastUsedAt; got == nil || !got.Equal(apiKeyUnitNow.Add(step.lastUsed)) {
|
||||||
|
t.Errorf("at +%v: expected last_used_at +%v, got %v", step.offset, step.lastUsed, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A failure to record last_used_at doesn't fail the authentication", func(t *testing.T) {
|
||||||
|
repo := newMockAPIKeyRepo()
|
||||||
|
seedStubKey(repo, "rcms_valid", nil)
|
||||||
|
repo.fail.failNext("TouchLastUsed")
|
||||||
|
|
||||||
|
if _, err := newUseCase(repo, apiKeyUnitUsers()).AuthenticateAPIKey("rcms_valid"); err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAPIKeysUseCase(t *testing.T) {
|
||||||
|
seed := func(repo *mockAPIKeyRepo) {
|
||||||
|
seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.UserID = 2; k.CreatedAt = apiKeyUnitNow.Add(-3 * time.Hour) })
|
||||||
|
seedStubKey(repo, "rcms_b", func(k *apikey.APIKey) { k.UserID = 2; k.CreatedAt = apiKeyUnitNow.Add(-2 * time.Hour) })
|
||||||
|
seedStubKey(repo, "rcms_c", func(k *apikey.APIKey) { k.UserID = 3; k.CreatedAt = apiKeyUnitNow.Add(-90 * time.Minute) })
|
||||||
|
seedStubKey(repo, "rcms_d", func(k *apikey.APIKey) { k.UserID = 1; k.CreatedAt = apiKeyUnitNow.Add(-time.Hour) })
|
||||||
|
}
|
||||||
|
prefixes := func(entries []useCases.APIKeyEntry) []string {
|
||||||
|
result := make([]string, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
result = append(result, entry.Key.Prefix)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("An admin lists only their own keys, newest first", func(t *testing.T) {
|
||||||
|
repo := newMockAPIKeyRepo()
|
||||||
|
seed(repo)
|
||||||
|
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got := prefixes(entries); len(got) != 2 || got[0] != "rcms_b" || got[1] != "rcms_a" {
|
||||||
|
t.Errorf("unexpected listing %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("The owner lists every key, newest first", func(t *testing.T) {
|
||||||
|
repo := newMockAPIKeyRepo()
|
||||||
|
seed(repo)
|
||||||
|
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
got := prefixes(entries)
|
||||||
|
want := []string{"rcms_d", "rcms_c", "rcms_b", "rcms_a"}
|
||||||
|
for i := range want {
|
||||||
|
if len(got) != len(want) || got[i] != want[i] {
|
||||||
|
t.Fatalf("expected %v, got %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Keys created at the same instant are listed by descending id", func(t *testing.T) {
|
||||||
|
repo := newMockAPIKeyRepo()
|
||||||
|
same := apiKeyUnitNow.Add(-time.Hour)
|
||||||
|
for _, raw := range []string{"rcms_first", "rcms_second", "rcms_third"} {
|
||||||
|
seedStubKey(repo, raw, func(k *apikey.APIKey) { k.CreatedAt = same })
|
||||||
|
}
|
||||||
|
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
got := prefixes(entries)
|
||||||
|
want := []string{"rcms_third", "rcms_second", "rcms_first"}
|
||||||
|
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] {
|
||||||
|
t.Errorf("expected %v, got %v", want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Each entry carries its status at the clock's now", func(t *testing.T) {
|
||||||
|
repo := newMockAPIKeyRepo()
|
||||||
|
expired := apiKeyUnitNow.Add(-time.Hour)
|
||||||
|
revoked := apiKeyUnitNow.Add(-time.Minute)
|
||||||
|
seedStubKey(repo, "rcms_active", nil)
|
||||||
|
seedStubKey(repo, "rcms_expired", func(k *apikey.APIKey) { k.ExpiresAt = &expired })
|
||||||
|
seedStubKey(repo, "rcms_both", func(k *apikey.APIKey) { k.ExpiresAt = &expired; k.RevokedAt = &revoked })
|
||||||
|
|
||||||
|
entries, _ := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2)
|
||||||
|
statuses := map[string]apikey.Status{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
statuses[entry.Key.Prefix] = entry.Status
|
||||||
|
}
|
||||||
|
if statuses["rcms_active"] != apikey.StatusActive || statuses["rcms_expired"] != apikey.StatusExpired || statuses["rcms_both"] != apikey.StatusRevoked {
|
||||||
|
t.Errorf("unexpected statuses %v", statuses)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("An unknown requester and repository failures are returned", func(t *testing.T) {
|
||||||
|
repo := newMockAPIKeyRepo()
|
||||||
|
uc := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
|
||||||
|
if _, err := uc.ListAPIKeys(99); !errors.Is(err, user.ErrUserNotFound) {
|
||||||
|
t.Errorf("expected ErrUserNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
repo.fail.failNext("ListByUser")
|
||||||
|
if _, err := uc.ListAPIKeys(2); !errors.Is(err, errRepositoryFailure) {
|
||||||
|
t.Errorf("expected the repository failure, got %v", err)
|
||||||
|
}
|
||||||
|
repo.fail.failNext("ListAll")
|
||||||
|
if _, err := uc.ListAPIKeys(1); !errors.Is(err, errRepositoryFailure) {
|
||||||
|
t.Errorf("expected the repository failure, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevokeAPIKeyUseCase(t *testing.T) {
|
||||||
|
t.Run("A creator revokes their own key at the clock's now", func(t *testing.T) {
|
||||||
|
repo := newMockAPIKeyRepo()
|
||||||
|
stored := seedStubKey(repo, "rcms_a", nil)
|
||||||
|
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
|
||||||
|
|
||||||
|
if err := uc.RevokeAPIKey(2, stored.ID); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got := repo.keys[stored.ID].RevokedAt; got == nil || !got.Equal(apiKeyUnitNow) {
|
||||||
|
t.Errorf("expected revoked_at %v, got %v", apiKeyUnitNow, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Another admin gets not found and the key stays active", func(t *testing.T) {
|
||||||
|
repo := newMockAPIKeyRepo()
|
||||||
|
stored := seedStubKey(repo, "rcms_a", nil)
|
||||||
|
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
|
||||||
|
|
||||||
|
if err := uc.RevokeAPIKey(3, stored.ID); !errors.Is(err, apikey.ErrAPIKeyNotFound) {
|
||||||
|
t.Errorf("expected ErrAPIKeyNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
if repo.keys[stored.ID].RevokedAt != nil || repo.revokeCalls != 0 {
|
||||||
|
t.Errorf("expected the key untouched")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("The owner revokes any key", func(t *testing.T) {
|
||||||
|
repo := newMockAPIKeyRepo()
|
||||||
|
stored := seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.UserID = 3 })
|
||||||
|
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
|
||||||
|
|
||||||
|
if err := uc.RevokeAPIKey(1, stored.ID); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if repo.keys[stored.ID].RevokedAt == nil {
|
||||||
|
t.Errorf("expected the key revoked")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Revoking an already revoked key keeps its original revoked_at", func(t *testing.T) {
|
||||||
|
repo := newMockAPIKeyRepo()
|
||||||
|
original := apiKeyUnitNow.Add(-time.Hour)
|
||||||
|
stored := seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.RevokedAt = &original })
|
||||||
|
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
|
||||||
|
|
||||||
|
if err := uc.RevokeAPIKey(2, stored.ID); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got := repo.keys[stored.ID].RevokedAt; got == nil || !got.Equal(original) {
|
||||||
|
t.Errorf("expected revoked_at %v, got %v", original, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("An unknown key, an unknown requester and repository failures are returned", func(t *testing.T) {
|
||||||
|
repo := newMockAPIKeyRepo()
|
||||||
|
stored := seedStubKey(repo, "rcms_a", nil)
|
||||||
|
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
|
||||||
|
|
||||||
|
if err := uc.RevokeAPIKey(2, 999); !errors.Is(err, apikey.ErrAPIKeyNotFound) {
|
||||||
|
t.Errorf("expected ErrAPIKeyNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
if err := uc.RevokeAPIKey(99, stored.ID); !errors.Is(err, user.ErrUserNotFound) {
|
||||||
|
t.Errorf("expected ErrUserNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
repo.fail.failNext("Revoke")
|
||||||
|
if err := uc.RevokeAPIKey(2, stored.ID); !errors.Is(err, errRepositoryFailure) {
|
||||||
|
t.Errorf("expected the repository failure, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetPasswordRevokesAPIKeys(t *testing.T) {
|
||||||
|
resetUser := func(expiration time.Time) *mockUserRepo {
|
||||||
|
repo := newMockUserRepo()
|
||||||
|
repo.users[2] = user.User{ID: 2, Email: "dev@example.com", Password: "hashed:old", PasswordResetCode: "hashed:reset-code-1", PasswordResetExpiration: expiration}
|
||||||
|
repo.users[3] = user.User{ID: 3, Email: "pat@example.com"}
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Use case: ResetPassword revokes the user's keys through the domain repository", func(t *testing.T) {
|
||||||
|
keys := newMockAPIKeyRepo()
|
||||||
|
seedStubKey(keys, "rcms_a", func(k *apikey.APIKey) { k.UserID = 2 })
|
||||||
|
other := seedStubKey(keys, "rcms_c", func(k *apikey.APIKey) { k.UserID = 3 })
|
||||||
|
uc := useCases.NewResetPasswordUseCase(resetUser(apiKeyUnitNow.Add(time.Hour)), &stubHasher{}, keys, apiKeyUnitClock)
|
||||||
|
|
||||||
|
if err := uc.ResetPassword("dev@example.com", "reset-code-1", "newpassword1"); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(keys.revokeAllCalls) != 1 || keys.revokeAllCalls[0].userId != 2 || !keys.revokeAllCalls[0].at.Equal(apiKeyUnitNow) {
|
||||||
|
t.Errorf("expected exactly one RevokeAllByUser(2, T), got %+v", keys.revokeAllCalls)
|
||||||
|
}
|
||||||
|
if keys.keys[other.ID].RevokedAt != nil {
|
||||||
|
t.Errorf("expected user 3's key untouched")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Use case: ResetPassword does not call RevokeAllByUser when the reset fails", func(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
expiration time.Time
|
||||||
|
code string
|
||||||
|
email string
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"wrong code", apiKeyUnitNow.Add(time.Hour), "wrong", "dev@example.com", user.ErrInvalidPasswordResetCode},
|
||||||
|
{"expired code", apiKeyUnitNow.Add(-time.Second), "reset-code-1", "dev@example.com", user.ErrPasswordResetCodeExpired},
|
||||||
|
{"unknown email", apiKeyUnitNow.Add(time.Hour), "reset-code-1", "nobody@example.com", user.ErrInvalidPasswordResetCode},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
keys := newMockAPIKeyRepo()
|
||||||
|
uc := useCases.NewResetPasswordUseCase(resetUser(tc.expiration), &stubHasher{}, keys, apiKeyUnitClock)
|
||||||
|
|
||||||
|
if err := uc.ResetPassword(tc.email, tc.code, "newpassword1"); !errors.Is(err, tc.want) {
|
||||||
|
t.Errorf("expected %v, got %v", tc.want, err)
|
||||||
|
}
|
||||||
|
if len(keys.revokeAllCalls) != 0 {
|
||||||
|
t.Errorf("expected RevokeAllByUser never called")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("A failed password change and a failed hash revoke nothing", func(t *testing.T) {
|
||||||
|
keys := newMockAPIKeyRepo()
|
||||||
|
users := resetUser(apiKeyUnitNow.Add(time.Hour))
|
||||||
|
users.fail.failNext("ResetPassword")
|
||||||
|
uc := useCases.NewResetPasswordUseCase(users, &stubHasher{}, keys, apiKeyUnitClock)
|
||||||
|
if err := uc.ResetPassword("dev@example.com", "reset-code-1", "newpassword1"); !errors.Is(err, errRepositoryFailure) {
|
||||||
|
t.Errorf("expected the repository failure, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hashErr := errors.New("hash failed")
|
||||||
|
uc = useCases.NewResetPasswordUseCase(resetUser(apiKeyUnitNow.Add(time.Hour)), &stubHasher{hashErr: hashErr}, keys, apiKeyUnitClock)
|
||||||
|
if err := uc.ResetPassword("dev@example.com", "reset-code-1", "newpassword1"); !errors.Is(err, hashErr) {
|
||||||
|
t.Errorf("expected the hash error, got %v", err)
|
||||||
|
}
|
||||||
|
if len(keys.revokeAllCalls) != 0 {
|
||||||
|
t.Errorf("expected RevokeAllByUser never called")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Use case: a failure to revoke keys after a reset is reported", func(t *testing.T) {
|
||||||
|
keys := newMockAPIKeyRepo()
|
||||||
|
keys.fail.failNext("RevokeAllByUser")
|
||||||
|
users := resetUser(apiKeyUnitNow.Add(time.Hour))
|
||||||
|
uc := useCases.NewResetPasswordUseCase(users, &stubHasher{}, keys, apiKeyUnitClock)
|
||||||
|
|
||||||
|
err := uc.ResetPassword("dev@example.com", "reset-code-1", "newpassword1")
|
||||||
|
if !errors.Is(err, errRepositoryFailure) {
|
||||||
|
t.Errorf("expected the revoke failure, got %v", err)
|
||||||
|
}
|
||||||
|
if users.users[2].Password != "hashed:newpassword1" {
|
||||||
|
t.Errorf("expected the password already changed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ func newRateLimitedAuthRouter(t *testing.T, userRepo user.Repository, mailRepo *
|
|||||||
)
|
)
|
||||||
passwordResetHandler := auth.NewPasswordResetHandler(
|
passwordResetHandler := auth.NewPasswordResetHandler(
|
||||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||||
useCases.NewResetPasswordUseCase(userRepo, hasher),
|
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func newRequestPasswordResetRouter(userRepo *mockUserRepo, mailRepo *mockMailRep
|
|||||||
hasher := &stubHasher{}
|
hasher := &stubHasher{}
|
||||||
passwordResetHandler := auth.NewPasswordResetHandler(
|
passwordResetHandler := auth.NewPasswordResetHandler(
|
||||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, publicURL),
|
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, publicURL),
|
||||||
useCases.NewResetPasswordUseCase(userRepo, hasher),
|
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
@@ -46,7 +47,7 @@ func resetRequestWith(mailRepo *mockMailRepo, email string) (*httptest.ResponseR
|
|||||||
hasher := resetMailFailureHasher{}
|
hasher := resetMailFailureHasher{}
|
||||||
handler := auth.NewPasswordResetHandler(
|
handler := auth.NewPasswordResetHandler(
|
||||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||||
useCases.NewResetPasswordUseCase(userRepo, hasher),
|
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
|
||||||
)
|
)
|
||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
router.Post("/v1/auth/password-reset", handler.RequestPasswordReset)
|
router.Post("/v1/auth/password-reset", handler.RequestPasswordReset)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func newResetPasswordRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux
|
|||||||
mailRepo := newMockMailRepo()
|
mailRepo := newMockMailRepo()
|
||||||
passwordResetHandler := auth.NewPasswordResetHandler(
|
passwordResetHandler := auth.NewPasswordResetHandler(
|
||||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||||
useCases.NewResetPasswordUseCase(userRepo, hasher),
|
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func TestResetPasswordUseCase(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("The use case itself sets the password and clears the code in one write, independently of any handler", func(t *testing.T) {
|
t.Run("The use case itself sets the password and clears the code in one write, independently of any handler", func(t *testing.T) {
|
||||||
repo := newRepo(time.Now().Add(time.Hour))
|
repo := newRepo(time.Now().Add(time.Hour))
|
||||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
|
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
|
||||||
|
|
||||||
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
|
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ func TestResetPasswordUseCase(t *testing.T) {
|
|||||||
PasswordResetCode: "hashed:reset-code-1",
|
PasswordResetCode: "hashed:reset-code-1",
|
||||||
PasswordResetExpiration: time.Now().Add(time.Hour),
|
PasswordResetExpiration: time.Now().Add(time.Hour),
|
||||||
}
|
}
|
||||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
|
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
|
||||||
|
|
||||||
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
|
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ func TestResetPasswordUseCase(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("The use case rejects a wrong code without touching the repository's ResetPassword method", func(t *testing.T) {
|
t.Run("The use case rejects a wrong code without touching the repository's ResetPassword method", func(t *testing.T) {
|
||||||
repo := newRepo(time.Now().Add(time.Hour))
|
repo := newRepo(time.Now().Add(time.Hour))
|
||||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
|
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
|
||||||
|
|
||||||
err := uc.ResetPassword("alice@example.com", "wrong-code", "newpassword1")
|
err := uc.ResetPassword("alice@example.com", "wrong-code", "newpassword1")
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ func TestResetPasswordUseCase(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("The use case rejects an expired code without touching the repository's ResetPassword method", func(t *testing.T) {
|
t.Run("The use case rejects an expired code without touching the repository's ResetPassword method", func(t *testing.T) {
|
||||||
repo := newRepo(time.Now().Add(-time.Hour))
|
repo := newRepo(time.Now().Add(-time.Hour))
|
||||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
|
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
|
||||||
|
|
||||||
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
|
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ func TestResetPasswordUseCase(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("The use case treats an unknown email the same as a wrong code", func(t *testing.T) {
|
t.Run("The use case treats an unknown email the same as a wrong code", func(t *testing.T) {
|
||||||
repo := newMockUserRepo()
|
repo := newMockUserRepo()
|
||||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
|
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
|
||||||
|
|
||||||
err := uc.ResetPassword("nobody@example.com", "any-code", "newpassword1")
|
err := uc.ResetPassword("nobody@example.com", "any-code", "newpassword1")
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ func TestResetPasswordUseCase(t *testing.T) {
|
|||||||
repo := newMockUserRepo()
|
repo := newMockUserRepo()
|
||||||
repo.users[3] = user.User{ID: 3, Email: "alice@example.com", PasswordResetCode: ""}
|
repo.users[3] = user.User{ID: 3, Email: "alice@example.com", PasswordResetCode: ""}
|
||||||
hasher := &stubHasher{}
|
hasher := &stubHasher{}
|
||||||
uc := useCases.NewResetPasswordUseCase(repo, hasher)
|
uc := useCases.NewResetPasswordUseCase(repo, hasher, newMockAPIKeyRepo(), time.Now)
|
||||||
|
|
||||||
err := uc.ResetPassword("alice@example.com", "anything", "newpassword1")
|
err := uc.ResetPassword("alice@example.com", "anything", "newpassword1")
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ func TestResetPasswordUseCase(t *testing.T) {
|
|||||||
t.Run("The use case treats any GetByEmail failure, not just not-found, as an invalid code", func(t *testing.T) {
|
t.Run("The use case treats any GetByEmail failure, not just not-found, as an invalid code", func(t *testing.T) {
|
||||||
repo := newRepo(time.Now().Add(time.Hour))
|
repo := newRepo(time.Now().Add(time.Hour))
|
||||||
repo.fail.failNextWith("GetByEmail", errors.New("db is down"))
|
repo.fail.failNextWith("GetByEmail", errors.New("db is down"))
|
||||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
|
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
|
||||||
|
|
||||||
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
|
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ func TestResetPasswordUseCase(t *testing.T) {
|
|||||||
t.Run("A hashing failure propagates and the repository's ResetPassword method is never called", func(t *testing.T) {
|
t.Run("A hashing failure propagates and the repository's ResetPassword method is never called", func(t *testing.T) {
|
||||||
repo := newRepo(time.Now().Add(time.Hour))
|
repo := newRepo(time.Now().Add(time.Hour))
|
||||||
hashErr := errors.New("hash blew up")
|
hashErr := errors.New("hash blew up")
|
||||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{hashErr: hashErr})
|
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{hashErr: hashErr}, newMockAPIKeyRepo(), time.Now)
|
||||||
|
|
||||||
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
|
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ func TestResetPasswordUseCase(t *testing.T) {
|
|||||||
t.Run("A failure in the repository's ResetPassword method propagates", func(t *testing.T) {
|
t.Run("A failure in the repository's ResetPassword method propagates", func(t *testing.T) {
|
||||||
repo := newRepo(time.Now().Add(time.Hour))
|
repo := newRepo(time.Now().Add(time.Hour))
|
||||||
repo.fail.failNext("ResetPassword")
|
repo.fail.failNext("ResetPassword")
|
||||||
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{})
|
uc := useCases.NewResetPasswordUseCase(repo, &stubHasher{}, newMockAPIKeyRepo(), time.Now)
|
||||||
|
|
||||||
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
|
err := uc.ResetPassword("alice@example.com", "reset-code-1", "newpassword1")
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr
|
|||||||
)
|
)
|
||||||
passwordResetHandler := auth.NewPasswordResetHandler(
|
passwordResetHandler := auth.NewPasswordResetHandler(
|
||||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||||
useCases.NewResetPasswordUseCase(userRepo, hasher),
|
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func newTrustedProxyAuthRouter(t *testing.T, userRepo user.Repository, mailRepo
|
|||||||
)
|
)
|
||||||
passwordResetHandler := auth.NewPasswordResetHandler(
|
passwordResetHandler := auth.NewPasswordResetHandler(
|
||||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||||
useCases.NewResetPasswordUseCase(userRepo, hasher),
|
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func newUserFieldHygieneRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, t
|
|||||||
)
|
)
|
||||||
passwordResetHandler := auth.NewPasswordResetHandler(
|
passwordResetHandler := auth.NewPasswordResetHandler(
|
||||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||||
useCases.NewResetPasswordUseCase(userRepo, hasher),
|
useCases.NewResetPasswordUseCase(userRepo, hasher, newMockAPIKeyRepo(), time.Now),
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|||||||
Reference in new issue
Block a user