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 }