feat: local-first storage and metadata backends #3

Merged
iu merged 5 commits from feature/local-fs-storage-backend into develop 2026-10-11 07:44:15 +00:00
29 changed files with 489 additions and 30 deletions
Showing only changes of commit cb272398ce - Show all commits
+3 -3
View File
@@ -24,13 +24,13 @@ S3_USE_PATH_STYLE=true
STORAGE_BACKEND=fs
# Root directory for object blobs (STORAGE_BACKEND=fs)
FS_ROOT=./data
# Signs object URLs; falls back to JWT_SECRET when empty
# Signs object URLs; falls back to the metadata-layer secret when empty
URL_SECRET=
# Lifetime of signed object URLs
OBJECT_URL_TTL=1h
# Auth
JWT_SECRET=change_me_long_random
# Auth. The token signing secret is generated on first start and stored in the
# metadata layer (app_settings); it is not configured here.
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=720h
+4 -2
View File
@@ -30,8 +30,10 @@ cp .env.example .env
```
Ключевые переменные: `PG_*`, `S3_*` (обязательны `UsePathStyle=true` и регион
`garage`), `JWT_SECRET`, `JWT_ACCESS_TTL`, `JWT_REFRESH_TTL`, `HTTP_ADDR`,
`SSE_HEARTBEAT_INTERVAL`, `SSE_CLIENT_BUFFER`.
`garage`), `JWT_ACCESS_TTL`, `JWT_REFRESH_TTL`, `HTTP_ADDR`,
`SSE_HEARTBEAT_INTERVAL`, `SSE_CLIENT_BUFFER`. Секрет подписи токенов не задаётся
в окружении: он генерируется при первом старте и хранится в слое метаданных
(`app_settings`).
Слои данных выбираются параметрами (по умолчанию — локальный режим):
+11 -4
View File
@@ -9,8 +9,9 @@ import (
"shared-album/internal/config"
"shared-album/internal/db"
"shared-album/internal/photos"
"shared-album/internal/settings"
"shared-album/internal/storage"
"shared-album/internal/store"
)
func main() {
@@ -35,14 +36,20 @@ func main() {
}
defer pool.Close()
store, err := storage.Open(ctx, cfg)
repos := store.NewPostgres(pool)
secret, err := repos.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret)
if err != nil {
slog.Error("secret init failed", "error", err)
os.Exit(1)
}
blob, err := storage.Open(ctx, cfg, secret)
if err != nil {
slog.Error("storage init failed", "error", err)
os.Exit(1)
}
photoStore := photos.NewStore(pool)
updated, err := photoStore.BackfillEXIF(ctx, store.Get)
updated, err := repos.Photos.BackfillEXIF(ctx, blob.Get)
if err != nil {
slog.Error("backfill failed", "error", err)
os.Exit(1)
+8 -1
View File
@@ -18,6 +18,7 @@ import (
"shared-album/internal/config"
"shared-album/internal/db"
"shared-album/internal/reconcile"
"shared-album/internal/settings"
"shared-album/internal/storage"
"shared-album/internal/store"
)
@@ -50,7 +51,13 @@ func main() {
}
defer closer()
blob, err := storage.Open(ctx, cfg)
secret, err := repos.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret)
if err != nil {
slog.Error("secret init failed", "error", err)
os.Exit(1)
}
blob, err := storage.Open(ctx, cfg, secret)
if err != nil {
slog.Error("storage init failed", "error", err)
os.Exit(1)
+11 -2
View File
@@ -15,8 +15,10 @@ import (
assets "shared-album"
"shared-album/internal/app"
"shared-album/internal/auth"
"shared-album/internal/config"
"shared-album/internal/db"
"shared-album/internal/settings"
"shared-album/internal/sse"
"shared-album/internal/storage"
"shared-album/internal/store"
@@ -84,7 +86,14 @@ func main() {
defer closer()
slog.Info("migrations applied", "db_backend", cfg.Database.Backend)
blob, err := storage.Open(ctx, cfg)
secret, err := repos.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret)
if err != nil {
slog.Error("secret init failed", "error", err)
os.Exit(1)
}
tm := auth.NewTokenManager(secret, cfg.Auth.AccessTTL, cfg.Auth.RefreshTTL)
blob, err := storage.Open(ctx, cfg, secret)
if err != nil {
slog.Error("storage init failed", "error", err)
os.Exit(1)
@@ -96,7 +105,7 @@ func main() {
}
hub := sse.NewHub(cfg.SSE.ClientBuffer)
server := app.New(cfg, repos, ping, blob, hub)
server := app.New(cfg, repos, ping, tm, blob, hub)
go server.StartWorker(ctx)
+1 -2
View File
@@ -50,8 +50,7 @@ type Server struct {
// New wires the domain services over the selected metadata repositories. ping
// reports the health of the metadata backend so health stays backend-agnostic.
func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) error, st storage.Backend, hub *sse.Hub) *Server {
tm := auth.NewTokenManager(cfg.Auth.JWTSecret, cfg.Auth.AccessTTL, cfg.Auth.RefreshTTL)
func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) error, tm *auth.TokenManager, st storage.Backend, hub *sse.Hub) *Server {
authSvc := auth.NewService(repos.Auth, tm)
notifSvc := notifications.NewService(repos.Notifications, hub)
+3 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/gin-gonic/gin"
"shared-album/internal/auth"
"shared-album/internal/config"
"shared-album/internal/sse"
"shared-album/internal/storage"
@@ -30,7 +31,8 @@ func newTestServerWithFS(t *testing.T) (*storage.FS, *gin.Engine) {
t.Fatalf("new fs: %v", err)
}
noop := func(context.Context) error { return nil }
srv := New(&config.Config{}, store.Repos{}, noop, fs, sse.NewHub(1))
tm := auth.NewTokenManager("test-secret", 15*time.Minute, time.Hour)
srv := New(&config.Config{}, store.Repos{}, noop, tm, fs, sse.NewHub(1))
return fs, srv.Router()
}
+2 -1
View File
@@ -71,7 +71,8 @@ func runShareFlow(t *testing.T, backend storage.Backend, verify func(t *testing.
gin.SetMode(gin.TestMode)
hub := sse.NewHub(1)
srv := New(&config.Config{}, store.NewPostgres(pool), pool.Ping, backend, hub)
tm := auth.NewTokenManager("test-secret", 15*time.Minute, time.Hour)
srv := New(&config.Config{}, store.NewPostgres(pool), pool.Ping, tm, backend, hub)
router := srv.Router()
authStore := auth.NewStore(pool)
-6
View File
@@ -31,7 +31,6 @@ type S3 struct {
}
type Auth struct {
JWTSecret string
AccessTTL time.Duration
RefreshTTL time.Duration
}
@@ -154,7 +153,6 @@ func Load() (*Config, error) {
SQLitePath: getEnv("SQLITE_PATH", "./galo.db"),
},
Auth: Auth{
JWTSecret: getEnv("JWT_SECRET", ""),
AccessTTL: accessTTL,
RefreshTTL: refreshTTL,
},
@@ -173,10 +171,6 @@ func Load() (*Config, error) {
},
}
if cfg.Auth.JWTSecret == "" {
return nil, fmt.Errorf("JWT_SECRET is required")
}
switch cfg.Database.Backend {
case "postgres":
if cfg.Postgres.Password == "" {
+48
View File
@@ -17,6 +17,7 @@ import (
"shared-album/internal/auth"
"shared-album/internal/db"
"shared-album/internal/photos"
"shared-album/internal/settings"
"shared-album/internal/sse"
"shared-album/internal/storage"
"shared-album/internal/store"
@@ -137,6 +138,53 @@ func TestSQLiteEndToEndWithRestart(t *testing.T) {
})
}
func TestJWTSecretPersistsAcrossRestart(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "galo.db")
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
open := func() (*sql.DB, store.Repos) {
t.Helper()
if err := db.MigrateSQLite(path, migFS); err != nil {
t.Fatalf("migrate: %v", err)
}
database, err := db.OpenSQLite(path)
if err != nil {
t.Fatalf("open: %v", err)
}
return database, store.NewSQLite(database)
}
db1, repos1 := open()
secret1, err := repos1.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret)
if err != nil || secret1 == "" {
t.Fatalf("ensure secret: %v %q", err, secret1)
}
tm1 := auth.NewTokenManager(secret1, 15*time.Minute, time.Hour)
token, err := tm1.GenerateAccess("user-1", "u@test.local")
if err != nil {
t.Fatalf("generate: %v", err)
}
_ = db1.Close()
db2, repos2 := open()
defer db2.Close()
secret2, err := repos2.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret)
if err != nil {
t.Fatalf("ensure after restart: %v", err)
}
if secret2 != secret1 {
t.Fatalf("secret changed across restart")
}
tm2 := auth.NewTokenManager(secret2, 15*time.Minute, time.Hour)
if _, err := tm2.ParseAccess(token); err != nil {
t.Fatalf("token issued before restart not valid after: %v", err)
}
}
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
@@ -15,6 +15,7 @@ import (
"shared-album/internal/models"
"shared-album/internal/notifications"
"shared-album/internal/photos"
"shared-album/internal/settings"
"shared-album/internal/sharelinks"
"shared-album/internal/social"
)
@@ -29,6 +30,7 @@ type Repos struct {
Social social.Repository
Notifications notifications.Repository
ShareLinks sharelinks.Repository
Settings settings.Repository
Reset func(ctx context.Context) error
}
@@ -47,6 +49,7 @@ func Run(t *testing.T, r Repos) {
{"Social", testSocial},
{"Photos", testPhotos},
{"Objects", testObjects},
{"Settings", testSettings},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -523,6 +526,30 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
}
}
func testSettings(t *testing.T, ctx context.Context, r Repos) {
v, err := r.Settings.Ensure(ctx, settings.JWTSecretKey, func() (string, error) { return "generated-1", nil })
if err != nil || v != "generated-1" {
t.Fatalf("ensure first: %v %q", err, v)
}
v2, err := r.Settings.Ensure(ctx, settings.JWTSecretKey, func() (string, error) { return "generated-2", nil })
if err != nil || v2 != "generated-1" {
t.Fatalf("ensure not idempotent: %v %q", err, v2)
}
got, ok, err := r.Settings.Get(ctx, settings.JWTSecretKey)
if err != nil || !ok || got != "generated-1" {
t.Fatalf("get: %v ok=%v %q", err, ok, got)
}
if _, ok, _ := r.Settings.Get(ctx, "missing"); ok {
t.Fatalf("missing key reported present")
}
if err := r.Settings.Set(ctx, settings.JWTSecretKey, "rotated"); err != nil {
t.Fatalf("set: %v", err)
}
if got, _, _ := r.Settings.Get(ctx, settings.JWTSecretKey); got != "rotated" {
t.Fatalf("set not applied: %q", got)
}
}
func contains(list []string, v string) bool {
for _, x := range list {
if x == v {
@@ -17,6 +17,7 @@ import (
"shared-album/internal/members"
"shared-album/internal/notifications"
"shared-album/internal/photos"
"shared-album/internal/settings"
"shared-album/internal/sharelinks"
"shared-album/internal/social"
)
@@ -70,8 +71,9 @@ func TestPostgresContract(t *testing.T) {
Social: social.NewStore(pool),
Notifications: notifications.NewStore(pool),
ShareLinks: sharelinks.NewStore(pool),
Settings: settings.NewStore(pool),
Reset: func(ctx context.Context) error {
_, err := pool.Exec(ctx, `TRUNCATE users, objects CASCADE`)
_, err := pool.Exec(ctx, `TRUNCATE users, objects, app_settings CASCADE`)
return err
},
})
@@ -13,6 +13,7 @@ import (
"shared-album/internal/members"
"shared-album/internal/notifications"
"shared-album/internal/photos"
"shared-album/internal/settings"
"shared-album/internal/sharelinks"
"shared-album/internal/social"
)
@@ -40,11 +41,12 @@ func TestSQLiteContract(t *testing.T) {
Social: social.NewSQLiteStore(database),
Notifications: notifications.NewSQLiteStore(database),
ShareLinks: sharelinks.NewSQLiteStore(database),
Settings: settings.NewSQLiteStore(database),
Reset: func(ctx context.Context) error {
tables := []string{
"variant_jobs", "photo_objects", "objects", "photo_tags", "tags",
"refresh_tokens", "share_links", "notifications", "likes", "comments",
"invites", "album_members", "photos", "albums", "users",
"invites", "album_members", "photos", "albums", "users", "app_settings",
}
for _, tbl := range tables {
if _, err := database.ExecContext(ctx, "DELETE FROM "+tbl); err != nil {
+15
View File
@@ -0,0 +1,15 @@
package settings
import (
"crypto/rand"
"encoding/base64"
)
// GenerateSecret returns a new random secret suitable for HMAC signing.
func GenerateSecret() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
+20
View File
@@ -0,0 +1,20 @@
// Package settings persists application-level key/value settings in the
// metadata layer. The main use is the token signing secret, generated on first
// use and reused afterwards.
package settings
import "context"
// JWTSecretKey is the settings key holding the token signing secret.
const JWTSecretKey = "jwt_secret"
// Repository reads and writes application settings.
type Repository interface {
// Get returns the value for key and whether it exists.
Get(ctx context.Context, key string) (string, bool, error)
// Set stores or replaces the value for key.
Set(ctx context.Context, key, value string) error
// Ensure returns the existing value for key, or generates and stores one
// using generate when absent. Concurrent first use yields one value.
Ensure(ctx context.Context, key string, generate func() (string, error)) (string, error)
}
+63
View File
@@ -0,0 +1,63 @@
package settings
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"shared-album/internal/sqliteutil"
)
// SQLiteStore is the SQLite implementation of Repository.
type SQLiteStore struct {
db *sql.DB
}
func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
func (s *SQLiteStore) Get(ctx context.Context, key string) (string, bool, error) {
var v string
err := s.db.QueryRowContext(ctx, `SELECT value FROM app_settings WHERE key = $1`, key).Scan(&v)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
return "", false, err
}
return v, true, nil
}
func (s *SQLiteStore) Set(ctx context.Context, key, value string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO app_settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = $3`,
key, value, sqliteutil.FormatTime(time.Now()))
return err
}
func (s *SQLiteStore) Ensure(ctx context.Context, key string, generate func() (string, error)) (string, error) {
if v, ok, err := s.Get(ctx, key); err != nil {
return "", err
} else if ok {
return v, nil
}
v, err := generate()
if err != nil {
return "", err
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`,
key, v); err != nil {
return "", err
}
got, ok, err := s.Get(ctx, key)
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("settings: %q missing after insert", key)
}
return got, nil
}
+62
View File
@@ -0,0 +1,62 @@
package settings
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store is the PostgreSQL implementation of Repository.
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
func (s *Store) Get(ctx context.Context, key string) (string, bool, error) {
var v string
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, key).Scan(&v)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
return "", false, err
}
return v, true, nil
}
func (s *Store) Set(ctx context.Context, key, value string) error {
_, err := s.pool.Exec(ctx,
`INSERT INTO app_settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
key, value)
return err
}
func (s *Store) Ensure(ctx context.Context, key string, generate func() (string, error)) (string, error) {
if v, ok, err := s.Get(ctx, key); err != nil {
return "", err
} else if ok {
return v, nil
}
v, err := generate()
if err != nil {
return "", err
}
if _, err := s.pool.Exec(ctx,
`INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`,
key, v); err != nil {
return "", err
}
got, ok, err := s.Get(ctx, key)
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("settings: %q missing after insert", key)
}
return got, nil
}
+7 -3
View File
@@ -98,11 +98,15 @@ func (c *S3) Bucket() string { return c.bucket }
// Open builds the storage backend selected by configuration: S3-compatible by
// default, or the local file system when STORAGE_BACKEND=fs. Every backend
// delivers blobs through the app using a shared Signer.
func Open(ctx context.Context, cfg *config.Config) (Backend, error) {
// delivers blobs through the app using a shared Signer. jwtSecret is the
// application secret used to sign object URLs when URL_SECRET is not set.
func Open(ctx context.Context, cfg *config.Config, jwtSecret string) (Backend, error) {
secret := cfg.Storage.URLSecret
if secret == "" {
secret = cfg.Auth.JWTSecret
secret = jwtSecret
}
if secret == "" {
return nil, errors.New("object URL secret is not available")
}
signer, err := NewSigner(cfg.Server.BaseURL, secret, cfg.Storage.URLTTL)
if err != nil {
+4
View File
@@ -13,6 +13,7 @@ import (
"shared-album/internal/members"
"shared-album/internal/notifications"
"shared-album/internal/photos"
"shared-album/internal/settings"
"shared-album/internal/sharelinks"
"shared-album/internal/social"
)
@@ -26,6 +27,7 @@ type Repos struct {
Social social.Repository
Notifications notifications.Repository
ShareLinks sharelinks.Repository
Settings settings.Repository
}
// NewPostgres builds the repositories backed by PostgreSQL.
@@ -38,6 +40,7 @@ func NewPostgres(pool *pgxpool.Pool) Repos {
Social: social.NewStore(pool),
Notifications: notifications.NewStore(pool),
ShareLinks: sharelinks.NewStore(pool),
Settings: settings.NewStore(pool),
}
}
@@ -51,5 +54,6 @@ func NewSQLite(db *sql.DB) Repos {
Social: social.NewSQLiteStore(db),
Notifications: notifications.NewSQLiteStore(db),
ShareLinks: sharelinks.NewSQLiteStore(db),
Settings: settings.NewSQLiteStore(db),
}
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS app_settings;
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS app_settings (
key text PRIMARY KEY,
value text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS app_settings;
@@ -0,0 +1,6 @@
CREATE TABLE app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-11
@@ -0,0 +1,76 @@
# Design
## Context
См. `proposal.md` — Why. Текущее состояние:
- `JWT_SECRET` обязателен в env (`config.Load`), используется в `auth.NewTokenManager`
(HS256-подпись access-токенов) и как fallback для `URL_SECRET` в `storage.Open`.
- Refresh-токены не подписываются секретом (случайные, хранятся хэшем).
- Есть два бэкенда метаданных (`postgres`, `sqlite`) за интерфейсом `Repository` и
фабрикой `store.NewPostgres/NewSQLite`; миграции раздельные (pg-набор и `migrations/sqlite`).
- Порядок старта в `cmd/server`: миграции → метаданные → storage → app.
## Goals / Non-Goals
**Goals:**
- Хранить секрет приложения в слое метаданных; генерировать при первом использовании.
- Убрать обязательный `JWT_SECRET` из окружения.
- Единый стабильный секрет на инсталляцию; корректное поведение при конкурентном старте.
**Non-Goals:**
- Шифрование секрета в БД (БД доверенная).
- UI/команда ротации секрета.
- Миграция существующих env-секретов в БД.
- Изменение формата токенов или наблюдаемого поведения аутентификации для клиента.
## Decisions
### D1. Таблица `app_settings`
`app_settings(key TEXT PRIMARY KEY, value TEXT NOT NULL, created_at, updated_at)` в обоих
бэкендах. Ключ `jwt_secret`. Отдельные миграции: PostgreSQL `000010_app_settings`,
SQLite `000002_app_settings` (консолидированная 000001 уже применена на существующих БД).
### D2. Репозиторий `internal/settings`
Интерфейс `Repository { Get(ctx,key) (value string, ok bool, err error); Set(ctx,key,value) error; Ensure(ctx,key, generate func() (string,error)) (string, error) }`.
Реализации pg и sqlite. `Ensure`: `SELECT` → если есть, вернуть; иначе сгенерировать,
`INSERT ... ON CONFLICT(key) DO NOTHING`, затем `SELECT` (единый секрет при гонке).
Добавляется в `store.Repos`.
### D3. Генерация секрета
`crypto/rand` 32 байта → base64url (без padding). Значение хранится как строка; в
TokenManager и Signer передаётся как есть.
### D4. Порядок старта и проводка
`cmd/server`: миграции → метаданные → `secret := repos.Settings.Ensure("jwt_secret", generate)`
→ `tm := auth.NewTokenManager(secret, accessTTL, refreshTTL)` → `storage.Open(ctx, cfg, secret)`
(где `URL_SECRET`, если задан, имеет приоритет, иначе используется `secret`) → `app.New`.
`app.New` перестаёт строить TokenManager из конфига и принимает готовый `*auth.TokenManager`
(или секрет).
### D5. Конфиг
Поле `Auth.JWTSecret` и проверка обязательности удаляются; `JWT_ACCESS_TTL`/`JWT_REFRESH_TTL`
остаются. `URL_SECRET` остаётся необязательным. `storage.Open` получает секрет параметром
вместо чтения из конфига.
## Risks / Trade-offs
- [Смена источника секрета у существующих деплоев] → access-токены инвалидируются, но
refresh-токены остаются валидными (не подписаны секретом), клиент молча обновляет access;
в худшем случае — перелогин. Отмечаем в доках.
- [Конкурентный первый старт на общей БД] → `INSERT ... ON CONFLICT DO NOTHING` + повторный
`SELECT` (D2).
- [Секрет в открытом виде в БД] → БД доверенная; шифрование — вне рамок.
- [Тихая смена поведения] → при `URL_SECRET` не задан подпись URL зависит от секрета из БД;
явный `URL_SECRET` снимает зависимость.
## Migration Plan
1. Добавить таблицу и репозиторий секретов; сид отсутствует — генерируется при старте.
2. Переключить `cmd/server`/`app`/`storage` на секрет из метаданных; убрать `JWT_SECRET`.
3. Обновить доки и `.env.example`.
4. Откат: вернуть чтение `JWT_SECRET` из env (код), данные не меняются.
## Open Questions
- Нужна ли операция ротации секрета (сброс значения и инвалидация access-токенов).
@@ -0,0 +1,36 @@
# Proposal
## Why
`JWT_SECRET` сейчас обязателен в окружении: его нужно задавать вручную и одинаково на
всех инстансах. Хранение секрета в слое метаданных с автогенерацией убирает обязательный
секрет из env и даёт стабильное значение на инсталляцию.
## What Changes
- Убрать обязательный `JWT_SECRET` из конфига и окружения.
- Добавить таблицу `app_settings` (key/value) в оба бэкенда метаданных (PostgreSQL и
SQLite) и репозиторий чтения/создания секрета.
- При старте после открытия метаданных читать секрет или генерировать его
(`crypto/rand`) и сохранять; использовать для `TokenManager` и как fallback при подписи
URL объектов.
- `URL_SECRET` остаётся необязательным env-оверрайдом.
- Обновить `.env.example`, `project.md`, `README.md`.
## Capabilities
### New Capabilities
- `app-secrets`: хранение секретов приложения (JWT) в слое метаданных с автогенерацией при первом использовании.
### Modified Capabilities
<!-- Нет изменений требований существующих спек: для клиента поведение аутентификации не меняется. -->
## Impact
- `backend/internal/config` (убрать `JWTSecret` и его обязательность).
- `backend/internal/app`, `backend/cmd/server` (порядок старта: метаданные → секрет →
storage/app).
- `backend/internal/storage` (fallback-секрет для подписи URL).
- Новая settings-репозиция (`internal/settings`), миграции PostgreSQL и SQLite.
- Тесты (контрактные, e2e), `.env.example`, `project.md`, `README.md`.
- Не затрагивает формат данных клиента.
@@ -0,0 +1,48 @@
# Spec Delta
## Purpose
Хранение секретов приложения в слое метаданных с автогенерацией: единый стабильный
секрет на инсталляцию без обязательной настройки окружения.
## ADDED Requirements
### Requirement: Хранение секрета приложения
Слой метаданных SHALL хранить секрет подписи токенов приложения и SHALL возвращать один
и тот же секрет при последующих запусках.
#### Scenario: Первый запуск
- **WHEN** секрет ещё не сохранён
- **THEN** система генерирует случайный секрет и сохраняет его в слое метаданных
#### Scenario: Повторный запуск
- **WHEN** секрет уже сохранён
- **THEN** система использует сохранённый секрет и не генерирует новый
### Requirement: Отсутствие ручного секрета
Система SHALL работать без секрета, заданного в окружении, и SHALL не требовать его
наличия при старте.
#### Scenario: Секрет не задан в окружении
- **WHEN** переменная секрета не задана
- **THEN** система запускается, используя секрет из слоя метаданных
### Requirement: Конкурентный первый запуск
При одновременном первом запуске нескольких инстансов на общей базе метаданных система
SHALL обеспечивать единый сохранённый секрет для всех инстансов.
#### Scenario: Гонка первого запуска
- **WHEN** два инстанса одновременно обнаруживают отсутствие секрета
- **THEN** сохраняется ровно один секрет, и оба инстанса используют его
### Requirement: Использование секрета для подписи URL
При отсутствии отдельного секрета подписи URL система SHALL использовать секрет из слоя
метаданных.
#### Scenario: URL_SECRET не задан
- **WHEN** отдельный секрет подписи URL не задан
- **THEN** подпись URL объектов использует секрет приложения из слоя метаданных
#### Scenario: URL_SECRET задан
- **WHEN** отдельный секрет подписи URL задан
- **THEN** он используется независимо от секрета приложения
@@ -0,0 +1,15 @@
# Tasks
## 1. Репозиторий секретов и миграции
- [x] 1.1 Добавить таблицу `app_settings` миграциями: PostgreSQL `000010_app_settings` и SQLite `000002_app_settings`; проверка — применение на временных БД и идемпотентность.
- [x] 1.2 Реализовать `internal/settings` (`Get`/`Set`/`Ensure`) для pg и sqlite и добавить в `store.Repos`; проверка — контрактный тест: `Ensure` идемпотентен и возвращает одно значение.
## 2. Проводка и конфиг
- [x] 2.1 Убрать `Auth.JWTSecret` и его обязательность из конфига; `app.New` принимает готовый `*auth.TokenManager`; `storage.Open` принимает секрет-fallback; проверка — `go build ./...`.
- [x] 2.2 В `cmd/server` после открытия метаданных получить/сгенерировать секрет и собрать `TokenManager` и storage; проверка — e2e: перезапуск процесса даёт тот же секрет (токен, выпущенный до перезапуска, валиден после).
## 3. Документация
- [x] 3.1 Обновить `.env.example`, `project.md`, `README.md`: убрать `JWT_SECRET`, описать автогенерацию секрета в слое метаданных и роль `URL_SECRET`; проверка — документированный запуск без `JWT_SECRET` стартует.
+3 -3
View File
@@ -159,11 +159,11 @@ S3_USE_PATH_STYLE=true
# Storage backend
STORAGE_BACKEND=fs # fs (default, local filesystem) | s3
FS_ROOT=./data # root directory for object blobs (fs)
URL_SECRET=change_me # signs object URLs; falls back to JWT_SECRET
URL_SECRET=change_me # signs object URLs; falls back to the metadata-layer secret
OBJECT_URL_TTL=1h # lifetime of signed object URLs
# Auth
JWT_SECRET=change_me_long_random
# Auth. Token signing secret is generated on first start and stored in the
# metadata layer (app_settings) — not configured via env.
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=720h