diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index 0ce6c8d..dc85da3 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -1,6 +1,7 @@ package api import ( + domainAPIKey "RenewCMS/internal/domain/apikey" domainArticle "RenewCMS/internal/domain/article" domainImage "RenewCMS/internal/domain/image" domainInvite "RenewCMS/internal/domain/invite" @@ -18,9 +19,11 @@ import ( "crypto/ed25519" "fmt" "log" + "net/http" "os" "path/filepath" "runtime" + "sync" "time" "github.com/glebarez/sqlite" @@ -29,6 +32,8 @@ import ( ) type UseCases struct { + apiKeyAuthOnce sync.Once + apiKeyAuthMiddleware func(http.Handler) http.Handler Token *jwtauth.JWTAuth CreateArticleUseCase *useCases.CreateArticleUseCase GetArticleUseCase *useCases.GetArticleUseCase @@ -63,6 +68,10 @@ type UseCases struct { StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase RequestRestartUseCase *useCases.RequestRestartUseCase RecordRunningInfoUseCase *useCases.RecordRunningInfoUseCase + CreateAPIKeyUseCase *useCases.CreateAPIKeyUseCase + ListAPIKeysUseCase *useCases.ListAPIKeysUseCase + RevokeAPIKeyUseCase *useCases.RevokeAPIKeyUseCase + AuthenticateAPIKeyUseCase *useCases.AuthenticateAPIKeyUseCase } type Repositories struct { @@ -70,6 +79,7 @@ type Repositories struct { UserRepo domainUser.Repository ImageRepo domainImage.Repository InviteRepo domainInvite.Repository + APIKeyRepo domainAPIKey.Repository MailRepo domainMail.Repository ReleaseFeed domainUpdate.ReleaseFeed UpdateStore domainUpdate.StatusStore @@ -106,7 +116,7 @@ func getDb(dbName string, uploadDir string) *gorm.DB { 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()) } @@ -136,6 +146,7 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories { UserRepo: persistence.NewUserRepository(db), ImageRepo: persistence.NewImageRepository(db, settings.UploadDir), InviteRepo: persistence.NewInviteRepository(db), + APIKeyRepo: persistence.NewAPIKeyRepository(db), MailRepo: mailer.NewMailRepository(settings.Smtp), ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL), 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 { hasher := hashing.NewBcryptHasher() renderer := markdown.NewConverter() + keyGenerator := hashing.NewSHA256KeyGenerator() return &UseCases{ 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), CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo), 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), ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo), 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), 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), + 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), } } diff --git a/api/handlers/apikey/apiKeyHandler.go b/api/handlers/apikey/apiKeyHandler.go new file mode 100644 index 0000000..5b159d8 --- /dev/null +++ b/api/handlers/apikey/apiKeyHandler.go @@ -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) +} diff --git a/api/middleware/apiKey.go b/api/middleware/apiKey.go new file mode 100644 index 0000000..7c05976 --- /dev/null +++ b/api/middleware/apiKey.go @@ -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) +} diff --git a/api/pkg/apperr/messages.go b/api/pkg/apperr/messages.go index 18b684f..d358cc4 100644 --- a/api/pkg/apperr/messages.go +++ b/api/pkg/apperr/messages.go @@ -35,3 +35,6 @@ const NothingToUpdateErrorMessage = "There is nothing to update." const UpdateRefusedErrorMessage = "This update cannot be downloaded." const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the page and try again." 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." diff --git a/api/router.go b/api/router.go index 79e7063..00e600a 100644 --- a/api/router.go +++ b/api/router.go @@ -1,6 +1,7 @@ package api import ( + "RenewCMS/api/handlers/apikey" "RenewCMS/api/handlers/article" "RenewCMS/api/handlers/auth" "RenewCMS/api/handlers/frontend" @@ -33,8 +34,21 @@ func GetHelloWorld(w http.ResponseWriter, _ *http.Request) { _, _ = 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 { r := chi.NewRouter() + r.Use(container.apiKeyAuth()) 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.Group(func(r chi.Router) { + r.Use(middleware.RequireSession) r.Use(jwtauth.Verifier(container.Token)) r.Use(jwtauth.Authenticator(container.Token)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Post("/verify", verificationHandler.Verify) 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) }) return r @@ -102,6 +123,7 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings, inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase) r.Use(jwtauth.Verifier(container.Token)) + r.Use(container.apiKeyAuth()) r.Use(jwtauth.Authenticator(container.Token)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) @@ -124,6 +146,7 @@ func NewSettingsRouter(container *UseCases) http.Handler { settingsHandler := settings.NewHandler(container.GetSettingsUseCase) r.Use(jwtauth.Verifier(container.Token)) + r.Use(container.apiKeyAuth()) r.Use(jwtauth.Authenticator(container.Token)) r.Use(middleware.RequireCurrentToken(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) + r.Use(middleware.RequireSession) r.Use(jwtauth.Verifier(container.Token)) r.Use(jwtauth.Authenticator(container.Token)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) @@ -165,12 +189,29 @@ func NewSystemRouter(container *UseCases) http.Handler { 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 { r := chi.NewRouter() mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase) r.Use(jwtauth.Verifier(container.Token)) + r.Use(container.apiKeyAuth()) r.Use(jwtauth.Authenticator(container.Token)) r.Use(middleware.RequireCurrentToken(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("/settings", NewSettingsRouter(container)) r.Mount("/media", NewMediaRouter(container)) + r.Mount("/api-key", NewAPIKeyRouter(container)) r.Mount("/system", NewSystemRouter(container)) 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{ 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"}, ExposedHeaders: []string{"Link"}, AllowCredentials: false, diff --git a/docs/api/api-keys.md b/docs/api/api-keys.md new file mode 100644 index 0000000..ac67d1b --- /dev/null +++ b/docs/api/api-keys.md @@ -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": ""}` 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. diff --git a/docs/design/README.md b/docs/design/README.md index 6adf933..be3216f 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -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` | | [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` | -| [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` | | [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` | @@ -41,6 +42,7 @@ The visual spec of record for the back-office SPA and the transactional emails, ## 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/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 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". diff --git a/docs/design/components.md b/docs/design/components.md index 9c8c3fc..21f0afd 100644 --- a/docs/design/components.md +++ b/docs/design/components.md @@ -30,7 +30,12 @@ | 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) | | 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 | | OfflineBanner | UBanner | none (reads useOnline) | none | none | DashboardShell | | AppBoot | USkeleton inside DashboardShell | none | none | none | First load of any verified route | diff --git a/docs/design/routes.md b/docs/design/routes.md index e38a229..3f50769 100644 --- a/docs/design/routes.md +++ b/docs/design/routes.md @@ -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} | | `/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/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 | | `/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 | diff --git a/docs/design/screens/integration.md b/docs/design/screens/integration.md index 9cc6b78..0d44ff0 100644 --- a/docs/design/screens/integration.md +++ b/docs/design/screens/integration.md @@ -1,22 +1,50 @@ -# Connect your website +# Integration (API reference) and API keys -- **Route:** `/integration` -- **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). +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`. -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 `