fix: patch app endpoints should allow partial fields (#1778)

* fix: update patch app endpoints to allow sending partial fields from the frontend

* fix: removed unnessary complexity in handlesave func

* fix: use correct subwallet app id when setting subwallet lightning address in get_info command

* fix: do not allow changing subwallet to be non-isolated

* chore: simplify update app scopes logic

* fix: patch app to remove expires at

---------

Co-authored-by: Roland Bewick <roland.bewick@gmail.com>
This commit is contained in:
Krrish Sehgal 2025-10-14 13:12:55 +05:30 committed by GitHub
parent 413b38d728
commit faf80b122d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 160 additions and 109 deletions

View file

@ -142,47 +142,57 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons
}
func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) error {
name := updateAppRequest.Name
err := api.db.Transaction(func(tx *gorm.DB) error {
// Initialize name with current app name, update if provided
name := userApp.Name
if name == "" {
return fmt.Errorf("won't update an app to have no name")
}
// Update app name if provided and different
if updateAppRequest.Name != nil {
name = *updateAppRequest.Name
maxAmount := updateAppRequest.MaxAmountSat
budgetRenewal := updateAppRequest.BudgetRenewal
if len(updateAppRequest.Scopes) == 0 {
return fmt.Errorf("won't update an app to have no request methods")
}
newScopes := updateAppRequest.Scopes
expiresAt, err := api.parseExpiresAt(updateAppRequest.ExpiresAt)
if err != nil {
return fmt.Errorf("invalid expiresAt: %v", err)
}
err = api.db.Transaction(func(tx *gorm.DB) error {
// Update app name if it is not the same
if name != userApp.Name {
err := tx.Model(&db.App{}).Where("id", userApp.ID).Update("name", name).Error
if err != nil {
return err
if name == "" {
return fmt.Errorf("won't update an app to have no name")
}
if name != userApp.Name {
err := tx.Model(&db.App{}).Where("id", userApp.ID).Update("name", name).Error
if err != nil {
return err
}
}
}
// Update app isolation if it is not the same
if updateAppRequest.Isolated != userApp.Isolated {
err := tx.Model(&db.App{}).Where("id", userApp.ID).Update("isolated", updateAppRequest.Isolated).Error
if err != nil {
return err
// Update app isolation if provided and different
if updateAppRequest.Isolated != nil {
isolated := *updateAppRequest.Isolated
if isolated != userApp.Isolated {
if !isolated {
var existingMetadata Metadata
if userApp.Metadata != nil {
err := json.Unmarshal(userApp.Metadata, &existingMetadata)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"app_id": userApp.ID,
}).Error("Failed to deserialize app metadata")
return err
}
if existingMetadata["app_store_app_id"] == constants.SUBWALLET_APPSTORE_APP_ID {
return errors.New("Cannot update sub-wallet to be non-isolated")
}
}
}
err := tx.Model(&db.App{}).Where("id", userApp.ID).Update("isolated", isolated).Error
if err != nil {
return err
}
}
}
// Update the app metadata
// Update the app metadata if provided
if updateAppRequest.Metadata != nil {
var metadataBytes []byte
var err error
metadataBytes, err = json.Marshal(updateAppRequest.Metadata)
metadataBytes, err = json.Marshal(*updateAppRequest.Metadata)
if err != nil {
logger.Logger.WithError(err).Error("Failed to serialize metadata")
return err
@ -193,54 +203,105 @@ func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) e
}
}
// Update existing permissions with new budget and expiry
err = tx.Model(&db.AppPermission{}).Where("app_id", userApp.ID).Updates(map[string]interface{}{
"ExpiresAt": expiresAt,
"MaxAmountSat": maxAmount,
"BudgetRenewal": budgetRenewal,
}).Error
if err != nil {
return err
}
// Handle permissions updates only if any permission-related field is provided
if updateAppRequest.Scopes != nil || updateAppRequest.MaxAmountSat != nil ||
updateAppRequest.BudgetRenewal != nil || updateAppRequest.ExpiresAt != nil || updateAppRequest.UpdateExpiresAt {
var existingPermissions []db.AppPermission
if err := tx.Where("app_id = ?", userApp.ID).Find(&existingPermissions).Error; err != nil {
return err
}
// Get current values or use provided ones
var maxAmount uint64
var budgetRenewal string
var expiresAt *time.Time
existingScopeMap := make(map[string]bool)
for _, perm := range existingPermissions {
existingScopeMap[perm.Scope] = true
}
if slices.Contains(newScopes, constants.SUPERUSER_SCOPE) && !existingScopeMap[constants.SUPERUSER_SCOPE] {
return fmt.Errorf(
"cannot update app to add superuser permission")
}
// Add new permissions
for _, scope := range newScopes {
if !existingScopeMap[scope] {
perm := db.AppPermission{
App: *userApp,
Scope: scope,
ExpiresAt: expiresAt,
MaxAmountSat: int(maxAmount),
BudgetRenewal: budgetRenewal,
}
if err := tx.Create(&perm).Error; err != nil {
return err
}
}
delete(existingScopeMap, scope)
}
// Remove old permissions
for scope := range existingScopeMap {
if err := tx.Where("app_id = ? AND scope = ?", userApp.ID, scope).Delete(&db.AppPermission{}).Error; err != nil {
// Get existing permissions to use as defaults
var existingPermissions []db.AppPermission
if err := tx.Where("app_id = ?", userApp.ID).Find(&existingPermissions).Error; err != nil {
return err
}
// Use existing values as defaults
if len(existingPermissions) > 0 {
// Find pay_invoice permission for budget-related fields
for _, perm := range existingPermissions {
if perm.Scope == constants.PAY_INVOICE_SCOPE {
maxAmount = uint64(perm.MaxAmountSat)
budgetRenewal = perm.BudgetRenewal
expiresAt = perm.ExpiresAt
break
}
}
}
// Override with provided values
if updateAppRequest.MaxAmountSat != nil {
maxAmount = *updateAppRequest.MaxAmountSat
}
if updateAppRequest.BudgetRenewal != nil {
budgetRenewal = *updateAppRequest.BudgetRenewal
}
if updateAppRequest.ExpiresAt != nil {
parsedExpiresAt, err := api.parseExpiresAt(*updateAppRequest.ExpiresAt)
if err != nil {
return fmt.Errorf("invalid expiresAt: %v", err)
}
expiresAt = parsedExpiresAt
}
if updateAppRequest.ExpiresAt == nil && updateAppRequest.UpdateExpiresAt {
expiresAt = nil
}
// Update existing permissions with new budget and expiry
err := tx.Model(&db.AppPermission{}).Where("app_id", userApp.ID).Updates(map[string]interface{}{
"ExpiresAt": expiresAt,
"MaxAmountSat": maxAmount,
"BudgetRenewal": budgetRenewal,
}).Error
if err != nil {
return err
}
// Handle scope changes only if scopes were provided
if updateAppRequest.Scopes != nil {
if len(updateAppRequest.Scopes) == 0 {
return fmt.Errorf("won't update an app to have no request methods")
}
existingScopeMap := make(map[string]bool)
for _, perm := range existingPermissions {
existingScopeMap[perm.Scope] = true
}
if slices.Contains(updateAppRequest.Scopes, constants.SUPERUSER_SCOPE) && !existingScopeMap[constants.SUPERUSER_SCOPE] {
return fmt.Errorf("cannot update app to add superuser permission")
}
// Add new permissions
for _, scope := range updateAppRequest.Scopes {
if !existingScopeMap[scope] {
perm := db.AppPermission{
App: *userApp,
Scope: scope,
ExpiresAt: expiresAt,
MaxAmountSat: int(maxAmount),
BudgetRenewal: budgetRenewal,
}
if err := tx.Create(&perm).Error; err != nil {
return err
}
}
delete(existingScopeMap, scope)
}
// Remove old permissions
for scope := range existingScopeMap {
if err := tx.Where("app_id = ? AND scope = ?", userApp.ID, scope).Delete(&db.AppPermission{}).Error; err != nil {
return err
}
}
}
}
// Publish update event
api.svc.GetEventPublisher().Publish(&events.Event{
Event: "nwc_app_updated",
Properties: map[string]interface{}{
@ -425,9 +486,9 @@ func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, o
if filters.SubWallets != nil && !*filters.SubWallets {
// exclude subwallets :scream:
if api.db.Dialector.Name() == "sqlite" {
query = query.Where("metadata is NULL OR JSON_EXTRACT(metadata, '$.app_store_app_id') IS NULL OR JSON_EXTRACT(metadata, '$.app_store_app_id') != ?", "uncle-jim")
query = query.Where("metadata is NULL OR JSON_EXTRACT(metadata, '$.app_store_app_id') IS NULL OR JSON_EXTRACT(metadata, '$.app_store_app_id') != ?", constants.SUBWALLET_APPSTORE_APP_ID)
} else {
query = query.Where("metadata IS NULL OR metadata->>'app_store_app_id' IS NULL OR metadata->>'app_store_app_id' != ?", "uncle-jim")
query = query.Where("metadata IS NULL OR metadata->>'app_store_app_id' IS NULL OR metadata->>'app_store_app_id' != ?", constants.SUBWALLET_APPSTORE_APP_ID)
}
}

View file

@ -116,13 +116,14 @@ type ListAppsResponse struct {
}
type UpdateAppRequest struct {
Name string `json:"name"`
MaxAmountSat uint64 `json:"maxAmount"`
BudgetRenewal string `json:"budgetRenewal"`
ExpiresAt string `json:"expiresAt"`
Scopes []string `json:"scopes"`
Metadata Metadata `json:"metadata,omitempty"`
Isolated bool `json:"isolated"`
Name *string `json:"name"`
MaxAmountSat *uint64 `json:"maxAmount"`
BudgetRenewal *string `json:"budgetRenewal"`
ExpiresAt *string `json:"expiresAt"`
UpdateExpiresAt bool `json:"updateExpiresAt"`
Scopes []string `json:"scopes"`
Metadata *Metadata `json:"metadata"`
Isolated *bool `json:"isolated"`
}
type TransferRequest struct {

View file

@ -75,3 +75,5 @@ const (
ENCRYPTION_TYPE_NIP04 = "nip04"
ENCRYPTION_TYPE_NIP44_V2 = "nip44_v2"
)
const SUBWALLET_APPSTORE_APP_ID = "uncle-jim"

View file

@ -117,13 +117,8 @@ function SupportAlby() {
}
// add the ZapPlanner subscription ID to the app metadata
// Only send metadata since that's the only thing changing
const updateAppRequest: UpdateAppRequest = {
name: createAppRequest.name,
scopes: createAppRequest.scopes,
budgetRenewal: createAppRequest.budgetRenewal!,
expiresAt: createAppRequest.expiresAt,
maxAmount,
isolated,
metadata: {
...createAppRequest.metadata,
zapplanner_subscription_id: subscriptionId,

View file

@ -132,6 +132,7 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) {
scopes: Array.from(permissions.scopes),
budgetRenewal: permissions.budgetRenewal,
expiresAt: permissions.expiresAt?.toISOString(),
updateExpiresAt: true,
maxAmount: permissions.maxAmount,
isolated: permissions.isolated,
};
@ -155,13 +156,8 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) {
const handleConvertToSubwallet = async () => {
try {
// Only send the metadata since that's the only thing changing
const updateAppRequest: UpdateAppRequest = {
name: app.name,
scopes: app.scopes,
budgetRenewal: app.budgetRenewal,
expiresAt: app.expiresAt,
maxAmount: app.maxAmount,
isolated: app.isolated,
metadata: {
...app.metadata,
app_store_app_id: SUBWALLET_APPSTORE_APP_ID,

View file

@ -316,13 +316,8 @@ export function ZapPlanner() {
}
// add the ZapPlanner subscription ID to the app metadata
// Only send metadata since that's the only thing changing
const updateAppRequest: UpdateAppRequest = {
name: createAppRequest.name,
scopes: createAppRequest.scopes,
budgetRenewal,
expiresAt: createAppRequest.expiresAt,
maxAmount,
isolated,
metadata: {
...createAppRequest.metadata,
zapplanner_subscription_id: subscriptionId,

View file

@ -276,13 +276,14 @@ export interface CreateAppResponse {
}
export type UpdateAppRequest = {
name: string;
maxAmount: number;
budgetRenewal: string;
expiresAt: string | undefined;
scopes: Scope[];
name?: string;
maxAmount?: number;
budgetRenewal?: string;
expiresAt?: string | undefined;
updateExpiresAt?: boolean;
scopes?: Scope[];
metadata?: AppMetadata;
isolated: boolean;
isolated?: boolean;
};
export type Channel = {

View file

@ -91,7 +91,7 @@ func (controller *nip47Controller) HandleGetInfoEvent(ctx context.Context, nip47
if !app.Isolated {
lightningAddress, _ := controller.albyOAuthService.GetLightningAddress()
responsePayload.LightningAddress = &lightningAddress
} else if metadata["app_store_app_id"] == "uncle_jim" && metadata["lud16"] != nil {
} else if metadata["app_store_app_id"] == constants.SUBWALLET_APPSTORE_APP_ID && metadata["lud16"] != nil {
lightningAddress := metadata["lud16"].(string)
responsePayload.LightningAddress = &lightningAddress
}

View file

@ -85,7 +85,7 @@ func TestHandleGetInfoEvent_SubwalletNoPermission(t *testing.T) {
lightningAddress := "hello@getalby.com"
metadata := map[string]interface{}{
"app_store_app_id": "uncle_jim",
"app_store_app_id": constants.SUBWALLET_APPSTORE_APP_ID,
"lud16": lightningAddress,
}
@ -248,7 +248,7 @@ func TestHandleGetInfoEvent_SubwalletWithMetadata(t *testing.T) {
lightningAddress := "hello@getalby.com"
metadata := map[string]interface{}{
"app_store_app_id": "uncle_jim",
"app_store_app_id": constants.SUBWALLET_APPSTORE_APP_ID,
"lud16": lightningAddress,
"a": 123,
}