refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
This commit is contained in:
177
modules/wristband/stores.go
Normal file
177
modules/wristband/stores.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package wristband
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrClientCapReached is returned by ClientStore.CreateWithCap when the
|
||||
// unrevoked client count is already at or above the configured cap
|
||||
// (D-03/D-21, T-08-DCR-FLOOD). Register translates it into the exact PHP
|
||||
// invalid_client_metadata "Registration temporarily unavailable" body.
|
||||
var ErrClientCapReached = errors.New("wristband: dynamic client registration cap reached")
|
||||
|
||||
// ClientRecord is the app-agnostic persisted shape of an OAuth client row.
|
||||
// fonoteka's GORM adapter (classes/auth/oauth_store.go) converts to/from
|
||||
// its models.OAuthClient; wristband never imports GORM or fonoteka.
|
||||
type ClientRecord struct {
|
||||
ID uint
|
||||
ClientID string
|
||||
ClientSecretHash *string // nil for public (auth method "none") clients
|
||||
ClientName string
|
||||
RedirectURIs []string
|
||||
GrantTypes []string
|
||||
TokenEndpointAuthMethod string
|
||||
RegistrationIP *string // nil for artisan-issued clients (D-19); never swept
|
||||
ConsentedAt *time.Time
|
||||
RevokedAt *time.Time
|
||||
ScopeCeiling []string // nil/empty means no ceiling
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AuthCodeRecord is the app-agnostic persisted shape of a pending or issued
|
||||
// authorization row. Its full lifecycle (issue, exchange, replay) belongs to
|
||||
// a later Phase 8 plan (08-03/08-04); this plan only needs the shape and the
|
||||
// row-lock read so the Tx bundle is complete for T-08-CODE-REPLAY.
|
||||
type AuthCodeRecord struct {
|
||||
ID uint
|
||||
RequestID *string // non-nil while pending; nulled once a code is issued
|
||||
CodeHash *string // nil while pending; set once a code is issued
|
||||
ClientID string
|
||||
UserID *uint // nil until consent
|
||||
RedirectURI string
|
||||
Scopes []string
|
||||
CollectionIDs []uint
|
||||
CodeChallenge string
|
||||
CodeChallengeMethod string
|
||||
Resource *string
|
||||
State *string
|
||||
ExpiresAt time.Time
|
||||
UsedAt *time.Time
|
||||
OfflineAccess bool
|
||||
}
|
||||
|
||||
// RefreshTokenRecord is the app-agnostic persisted shape of a refresh-token
|
||||
// lineage row. Rotation/replay semantics belong to a later plan (08-04);
|
||||
// this plan only needs the shape and the row-lock read for T-08-REFRESH-REPLAY.
|
||||
type RefreshTokenRecord struct {
|
||||
ID uint
|
||||
TokenHash string
|
||||
APITokenID *uint
|
||||
ClientID string
|
||||
UserID uint
|
||||
Scopes []string
|
||||
CollectionIDs []uint
|
||||
ExpiresAt time.Time
|
||||
RevokedAt *time.Time
|
||||
RotatedToID *uint
|
||||
OfflineAccess bool
|
||||
}
|
||||
|
||||
// IssuedToken is what an AccessTokenIssuer mints: the one-time raw secret
|
||||
// plus the persisted row id (for later Revoke calls).
|
||||
type IssuedToken struct {
|
||||
ID uint
|
||||
Secret string
|
||||
}
|
||||
|
||||
// ClientStore persists OAuthClient rows. This plan (08-02) exercises
|
||||
// ByClientID, CreateWithCap and SweepUnconsented through Register; Revoke
|
||||
// belongs to a later connected-apps plan.
|
||||
type ClientStore interface {
|
||||
ByClientID(ctx context.Context, clientID string) (*ClientRecord, error)
|
||||
// CreateWithCap creates rec only when the unrevoked client count is
|
||||
// below cap, atomically with the count check (T-08-DCR-FLOOD). It
|
||||
// returns ErrClientCapReached, leaving no row created, when the cap is
|
||||
// already reached. On success it fills rec.ID and rec.CreatedAt.
|
||||
CreateWithCap(ctx context.Context, rec *ClientRecord, cap int) error
|
||||
// SweepUnconsented deletes dynamically-registered (non-nil
|
||||
// RegistrationIP), still-unconsented clients created before olderThan.
|
||||
// Artisan-issued clients (nil RegistrationIP) are never swept (D-19).
|
||||
SweepUnconsented(ctx context.Context, olderThan time.Time) error
|
||||
// MarkConsented stamps ConsentedAt once for clientID unless it is
|
||||
// already set (idempotent; PHP OAuthConsentController::store's "if
|
||||
// ($client->consented_at === null)" guard, 08-05-PLAN.md D-08). A
|
||||
// consented client is never later swept by SweepUnconsented.
|
||||
MarkConsented(ctx context.Context, clientID string) error
|
||||
}
|
||||
|
||||
// AuthCodeStore persists pending/issued authorization rows. ByCodeHashForUpdate
|
||||
// is the row-lock read a later plan's code-exchange/replay-kill logic needs
|
||||
// (T-08-CODE-REPLAY); it ships now so the Tx bundle does not change shape
|
||||
// later.
|
||||
type AuthCodeStore interface {
|
||||
CreatePending(ctx context.Context, rec *AuthCodeRecord) error
|
||||
ByRequestID(ctx context.Context, requestID string) (*AuthCodeRecord, error)
|
||||
ByCodeHashForUpdate(ctx context.Context, codeHash string) (*AuthCodeRecord, error)
|
||||
// MarkIssued turns a pending row into an issued authorization code
|
||||
// (PHP OAuthCodeManager::issueCode, 08-05-PLAN.md D-08): it nulls
|
||||
// RequestID, sets CodeHash/UserID, overwrites Scopes/CollectionIDs
|
||||
// with the consent-granted values (never the originally requested
|
||||
// ones), and extends ExpiresAt to the fresh code TTL.
|
||||
MarkIssued(ctx context.Context, id uint, codeHash string, userID uint, scopes []string, collectionIDs []uint, expiresAt time.Time) error
|
||||
MarkUsed(ctx context.Context, id uint) error
|
||||
// DeleteExpiredCodes removes pending and issued authorization-code rows
|
||||
// whose ExpiresAt is before now (D-17: wristband adds an expiry sweep
|
||||
// PHP lacks). used_at/request_id status is irrelevant to the decision:
|
||||
// only expiry drives deletion, so unexpired issued-but-unused rows and
|
||||
// unexpired used rows both survive untouched.
|
||||
DeleteExpiredCodes(ctx context.Context, now time.Time) error
|
||||
}
|
||||
|
||||
// RefreshTokenStore persists refresh-token lineage rows. ByTokenHashForUpdate
|
||||
// is the row-lock read a later plan's rotation/replay-kill logic needs
|
||||
// (T-08-REFRESH-REPLAY).
|
||||
type RefreshTokenStore interface {
|
||||
Create(ctx context.Context, rec *RefreshTokenRecord) error
|
||||
ByTokenHashForUpdate(ctx context.Context, tokenHash string) (*RefreshTokenRecord, error)
|
||||
// ByAPITokenIDForUpdate row-locks the refresh row currently linked to
|
||||
// apiTokenID, if any (08-06-PLAN.md D-08). It is the seam a
|
||||
// connected-app revoke uses to find the lineage to kill without
|
||||
// wristband inventing its own SQL join; a nil result (no linked
|
||||
// refresh row) is not an error.
|
||||
ByAPITokenIDForUpdate(ctx context.Context, apiTokenID uint) (*RefreshTokenRecord, error)
|
||||
// MarkRotated links a spent-by-rotation predecessor to its successor
|
||||
// (PHP OAuthCodeManager::rotateRefresh's `$record->rotated_to_id =
|
||||
// $successor->id`). The predecessor's own RevokedAt stays nil: a
|
||||
// rotated-but-not-yet-replayed row remains retrievable as replay
|
||||
// evidence (D-17); "already rotated" is signaled by RotatedToID, not
|
||||
// RevokedAt.
|
||||
MarkRotated(ctx context.Context, id uint, successorID uint) error
|
||||
// RevokeLineage stamps RevokedAt on startID and every row it was
|
||||
// rotated to (walking forward through RotatedToID), and revokes each
|
||||
// visited row's linked access token too (T-08-REFRESH-REPLAY). Rows
|
||||
// are not deleted: unexpired revoked rows stay as replay evidence
|
||||
// (D-17).
|
||||
RevokeLineage(ctx context.Context, startID uint) error
|
||||
// DeleteExpiredRefreshTokens removes refresh rows whose ExpiresAt is
|
||||
// before now (D-17). Revoked/rotated-but-unexpired rows are untouched
|
||||
// so replay detection and connected-apps history stay correct.
|
||||
DeleteExpiredRefreshTokens(ctx context.Context, now time.Time) error
|
||||
}
|
||||
|
||||
// AccessTokenIssuer mints/revokes the app's ordinary personal access token
|
||||
// (fonoteka: an inv_ token via ApiTokenManager) and stamps the owning OAuth
|
||||
// client id.
|
||||
type AccessTokenIssuer interface {
|
||||
Mint(ctx context.Context, userID uint, name string, scopes []string, expiresAt time.Time, collectionIDs []uint, clientID string) (IssuedToken, error)
|
||||
Revoke(ctx context.Context, tokenID uint) error
|
||||
}
|
||||
|
||||
// Tx bundles every store/issuer onto one transaction-scoped handle so
|
||||
// sweep+cap+create (this plan) and later code-exchange/refresh-rotation
|
||||
// cannot straddle two transactions (08-RESEARCH.md Pattern 1).
|
||||
type Tx interface {
|
||||
ClientStore
|
||||
AuthCodeStore
|
||||
RefreshTokenStore
|
||||
AccessTokenIssuer
|
||||
}
|
||||
|
||||
// Backend opens one transaction-scoped Tx per call. The GORM adapter lives
|
||||
// in fonoteka.go's classes/auth package (D-07): wristband never imports
|
||||
// gorm.io/gorm or a fonoteka model.
|
||||
type Backend interface {
|
||||
WithinTx(ctx context.Context, fn func(Tx) error) error
|
||||
}
|
||||
Reference in New Issue
Block a user