diff --git a/src/backend/src/modules/data-access/AppService.comp.test.js b/src/backend/src/modules/data-access/AppService.comp.test.js index b264b1bb1..3a73e2ecf 100644 --- a/src/backend/src/modules/data-access/AppService.comp.test.js +++ b/src/backend/src/modules/data-access/AppService.comp.test.js @@ -18,9 +18,23 @@ import { AppIconService } from '../apps/AppIconService'; import { AppInformationService } from '../apps/AppInformationService'; import { OldAppNameService } from '../apps/OldAppNameService'; import AppService from './AppService'; +import config from '../../config.js'; import { describe, expect, it } from 'vitest'; +const getHostedIndexUrl = subdomain => { + const hostedDomainCandidate = [ + config.static_hosting_domain_alt, + config.static_hosting_domain, + config.private_app_hosting_domain_alt, + config.private_app_hosting_domain, + ].find(domainValue => typeof domainValue === 'string' && domainValue.trim()); + const hostedDomain = hostedDomainCandidate + ? hostedDomainCandidate.trim().toLowerCase().replace(/^\./, '').split(':')[0] + : 'site.puter.localhost'; + return `https://${subdomain}.${hostedDomain}`; +}; + const ES_APP_ARGS = { entity: 'app', upstream: ESBuilder.create([ @@ -736,6 +750,335 @@ describe('AppService Regression Prevention Tests', () => { }); }); + it('should allow duplicate non-hosted index_url', async () => { + await testWithEachService(async ({ kernel, key }) => { + const service = kernel.services.get(key); + const crudQ = service.constructor.IMPLEMENTS['crud-q']; + + const first = await crudQ.create.call(service, { + object: { + name: 'non-hosted-duplicate-1', + title: 'Non Hosted Duplicate 1', + index_url: 'https://example.com/shared-origin', + }, + }); + + const second = await crudQ.create.call(service, { + object: { + name: 'non-hosted-duplicate-2', + title: 'Non Hosted Duplicate 2', + index_url: 'https://example.com/shared-origin', + }, + }); + + expect(first.uid).toBeDefined(); + expect(second.uid).toBeDefined(); + expect(second.uid).not.toBe(first.uid); + }); + }); + + it('should join existing unowned hosted index_url app on create', async () => { + await testWithEachService(async ({ kernel, key, user }) => { + const service = kernel.services.get(key); + const crudQ = service.constructor.IMPLEMENTS['crud-q']; + const db = kernel.services.get('database').get('write', 'test'); + const hostedIndexUrl = getHostedIndexUrl('joinable-site'); + const existingUid = 'app-11111111-1111-4111-8111-111111111111'; + + kernel.services.set('puter-site', { + get_subdomain: async (subdomain) => { + const rows = await db.read( + 'SELECT * FROM subdomains WHERE subdomain = ? LIMIT 1', + [subdomain], + ); + return rows[0] || null; + }, + }); + + await db.write( + 'INSERT INTO subdomains (uuid, subdomain, user_id, root_dir_id) VALUES (?, ?, ?, ?)', + ['sd-11111111-1111-4111-8111-111111111111', 'joinable-site', user.id, 111], + ); + await db.write( + 'INSERT INTO apps (uid, name, title, description, index_url, owner_user_id) VALUES (?, ?, ?, ?, ?, ?)', + [existingUid, 'joinable-existing-app', 'Joinable Existing App', 'Created from origin', hostedIndexUrl, null], + ); + + const joined = await crudQ.create.call(service, { + object: { + name: 'joinable-hosted-app', + title: 'Joinable Hosted App', + description: 'Claimed by owner', + index_url: hostedIndexUrl, + }, + }); + + expect(joined.uid).toBe(existingUid); + + const joinedRows = await db.read( + 'SELECT uid, name, owner_user_id FROM apps WHERE index_url = ?', + [hostedIndexUrl], + ); + expect(joinedRows).toHaveLength(1); + expect(joinedRows[0].uid).toBe(existingUid); + expect(joinedRows[0].name).toBe('joinable-hosted-app'); + expect(joinedRows[0].owner_user_id).toBe(user.id); + }); + }); + + it('should join existing unowned hosted index_url app on update', async () => { + await testWithEachService(async ({ kernel, key, user }) => { + const service = kernel.services.get(key); + const crudQ = service.constructor.IMPLEMENTS['crud-q']; + const db = kernel.services.get('database').get('write', 'test'); + const hostedIndexUrl = getHostedIndexUrl('joinable-update-site'); + const existingUid = 'app-33333333-3333-4333-8333-333333333333'; + + kernel.services.set('puter-site', { + get_subdomain: async (subdomain) => { + const rows = await db.read( + 'SELECT * FROM subdomains WHERE subdomain = ? LIMIT 1', + [subdomain], + ); + return rows[0] || null; + }, + }); + + await db.write( + 'INSERT INTO subdomains (uuid, subdomain, user_id, root_dir_id) VALUES (?, ?, ?, ?)', + ['sd-33333333-3333-4333-8333-333333333333', 'joinable-update-site', user.id, 333], + ); + await db.write( + 'INSERT INTO apps (uid, name, title, description, index_url, owner_user_id) VALUES (?, ?, ?, ?, ?, ?)', + [existingUid, 'joinable-update-existing', 'Joinable Update Existing', 'Auto-created app', hostedIndexUrl, null], + ); + + const appToUpdate = await crudQ.create.call(service, { + object: { + name: 'joinable-update-source', + title: 'Joinable Update Source', + description: 'Source app to be merged', + index_url: 'https://example.com/update-source', + }, + }); + + const joined = await crudQ.update.call(service, { + object: { + uid: appToUpdate.uid, + name: 'joinable-update-merged', + title: 'Joinable Update Merged', + description: 'Merged by owner', + index_url: hostedIndexUrl, + }, + }); + + expect(joined.uid).toBe(existingUid); + + const joinedRows = await db.read( + 'SELECT uid, name, title, owner_user_id FROM apps WHERE index_url = ?', + [hostedIndexUrl], + ); + expect(joinedRows).toHaveLength(1); + expect(joinedRows[0].uid).toBe(existingUid); + expect(joinedRows[0].name).toBe('joinable-update-existing'); + expect(joinedRows[0].title).toBe('Joinable Update Merged'); + expect(joinedRows[0].owner_user_id).toBe(user.id); + + const sourceRows = await db.read( + 'SELECT uid FROM apps WHERE uid = ?', + [appToUpdate.uid], + ); + expect(sourceRows).toHaveLength(0); + + const aliasedRead = await crudQ.read.call(service, { + uid: appToUpdate.uid, + }); + expect(aliasedRead.uid).toBe(existingUid); + }); + }); + + it('should join on update when name matches source app name', async () => { + await testWithEachService(async ({ kernel, key, user }) => { + const service = kernel.services.get(key); + const crudQ = service.constructor.IMPLEMENTS['crud-q']; + const db = kernel.services.get('database').get('write', 'test'); + const hostedIndexUrl = getHostedIndexUrl('joinable-update-self-name'); + const existingUid = 'app-44444444-4444-4444-8444-444444444444'; + + kernel.services.set('puter-site', { + get_subdomain: async (subdomain) => { + const rows = await db.read( + 'SELECT * FROM subdomains WHERE subdomain = ? LIMIT 1', + [subdomain], + ); + return rows[0] || null; + }, + }); + + await db.write( + 'INSERT INTO subdomains (uuid, subdomain, user_id, root_dir_id) VALUES (?, ?, ?, ?)', + ['sd-44444444-4444-4444-8444-444444444444', 'joinable-update-self-name', user.id, 444], + ); + await db.write( + 'INSERT INTO apps (uid, name, title, description, index_url, owner_user_id) VALUES (?, ?, ?, ?, ?, ?)', + [existingUid, 'existing-target-name', 'Existing Target', 'Auto-created app', hostedIndexUrl, null], + ); + + const source = await crudQ.create.call(service, { + object: { + name: 'staging-app-center', + title: 'Source App', + description: 'Source app before join', + index_url: 'https://example.com/staging-source', + }, + }); + + const joined = await crudQ.update.call(service, { + object: { + uid: source.uid, + name: 'staging-app-center', + title: 'Merged Title', + index_url: hostedIndexUrl, + }, + }); + + expect(joined.uid).toBe(existingUid); + + const targetRows = await db.read( + 'SELECT uid, name, title FROM apps WHERE uid = ?', + [existingUid], + ); + expect(targetRows).toHaveLength(1); + expect(targetRows[0].name).toBe('existing-target-name'); + expect(targetRows[0].title).toBe('Merged Title'); + + const sourceRows = await db.read( + 'SELECT uid FROM apps WHERE uid = ?', + [source.uid], + ); + expect(sourceRows).toHaveLength(0); + + const aliasedRead = await crudQ.read.call(service, { + uid: source.uid, + }); + expect(aliasedRead.uid).toBe(existingUid); + }); + }); + + it('should join owned bootstrap hosted app on update', async () => { + await testWithEachService(async ({ kernel, key, user }) => { + const service = kernel.services.get(key); + const crudQ = service.constructor.IMPLEMENTS['crud-q']; + const db = kernel.services.get('database').get('write', 'test'); + const hostedIndexUrl = getHostedIndexUrl('joinable-owned-bootstrap'); + const existingUid = 'app-55555555-5555-4555-8555-555555555555'; + + kernel.services.set('puter-site', { + get_subdomain: async (subdomain) => { + const rows = await db.read( + 'SELECT * FROM subdomains WHERE subdomain = ? LIMIT 1', + [subdomain], + ); + return rows[0] || null; + }, + }); + + await db.write( + 'INSERT INTO subdomains (uuid, subdomain, user_id, root_dir_id) VALUES (?, ?, ?, ?)', + ['sd-55555555-5555-4555-8555-555555555555', 'joinable-owned-bootstrap', user.id, 555], + ); + await db.write( + 'INSERT INTO apps (uid, name, title, description, index_url, owner_user_id) VALUES (?, ?, ?, ?, ?, ?)', + [ + existingUid, + existingUid, + existingUid, + `App created from origin ${hostedIndexUrl}`, + hostedIndexUrl, + user.id, + ], + ); + + const source = await crudQ.create.call(service, { + object: { + name: 'owned-bootstrap-source', + title: 'Owned Bootstrap Source', + description: 'Source app to be merged', + index_url: 'https://example.com/owned-bootstrap-source', + }, + }); + + const joined = await crudQ.update.call(service, { + object: { + uid: source.uid, + title: 'Merged Bootstrap Title', + index_url: hostedIndexUrl, + }, + }); + + expect(joined.uid).toBe(existingUid); + + const targetRows = await db.read( + 'SELECT uid, title, owner_user_id FROM apps WHERE uid = ?', + [existingUid], + ); + expect(targetRows).toHaveLength(1); + expect(targetRows[0].title).toBe('Merged Bootstrap Title'); + expect(targetRows[0].owner_user_id).toBe(user.id); + }); + }); + + it('should reject hosted duplicate index_url owned by another user', async () => { + await testWithEachService(async ({ kernel, key, user }) => { + const service = kernel.services.get(key); + const crudQ = service.constructor.IMPLEMENTS['crud-q']; + const db = kernel.services.get('database').get('write', 'test'); + const hostedIndexUrl = getHostedIndexUrl('foreign-owned'); + + kernel.services.set('puter-site', { + get_subdomain: async (subdomain) => { + const rows = await db.read( + 'SELECT * FROM subdomains WHERE subdomain = ? LIMIT 1', + [subdomain], + ); + return rows[0] || null; + }, + }); + + await db.write( + 'INSERT INTO user (uuid, username, free_storage) VALUES (?, ?, ?)', + ['user-uuid-2', 'otheruser', 1024 * 1024 * 1024], + ); + const otherUsers = await db.read('SELECT id FROM user WHERE uuid = ?', ['user-uuid-2']); + const otherUserId = otherUsers[0].id; + + await db.write( + 'INSERT INTO subdomains (uuid, subdomain, user_id, root_dir_id) VALUES (?, ?, ?, ?)', + ['sd-22222222-2222-4222-8222-222222222222', 'foreign-owned', user.id, 222], + ); + await db.write( + 'INSERT INTO apps (uid, name, title, description, index_url, owner_user_id) VALUES (?, ?, ?, ?, ?, ?)', + ['app-22222222-2222-4222-8222-222222222222', 'foreign-owned-existing', 'Foreign Owned Existing', 'Owned by another user', hostedIndexUrl, otherUserId], + ); + + let errorThrown = false; + try { + await crudQ.create.call(service, { + object: { + name: 'foreign-owned-new', + title: 'Foreign Owned New', + index_url: hostedIndexUrl, + }, + }); + } catch ( error ) { + errorThrown = true; + const code = error.fields?.code || error.code; + expect(code).toBe('app_index_url_already_in_use'); + } + expect(errorThrown).toBe(true); + }); + }); + it('should dedupe name with dedupe_name option', async () => { await testWithEachService(async ({ kernel, key }) => { const service = kernel.services.get(key); diff --git a/src/backend/src/modules/data-access/AppService.js b/src/backend/src/modules/data-access/AppService.js index 22804a0da..410ba1b83 100644 --- a/src/backend/src/modules/data-access/AppService.js +++ b/src/backend/src/modules/data-access/AppService.js @@ -28,6 +28,7 @@ const APP_ICON_ENDPOINT_PATH_REGEX = /^\/app-icon\/([^/?#]+)(?:\/(\d+))?\/?$/; const LEGACY_APP_ICON_FILE_PATH_REGEX = /^\/(app-[^/?#]+?)(?:-(\d+))?\.png$/; const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/; const RAW_BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/; +const APP_UID_ALIAS_KEY_PREFIX = 'app:canonicalUidAlias'; const indexUrlUniquenessExemptionCandidates = [ 'https://dev-center.puter.com/coming-soon', ]; @@ -468,11 +469,13 @@ export default class AppService extends BaseService { // Build WHERE clause based on identifier type let whereClause; let whereValues; + let canonicalUidAliasPromise = null; if ( uid !== undefined ) { // Simple uid lookup whereClause = 'apps.uid = ?'; whereValues = [uid]; + canonicalUidAliasPromise = this.#readCanonicalAppUidAlias(uid); } else if ( id !== null && typeof id === 'object' && !Array.isArray(id) ) { // Complex id lookup (e.g., { name: 'editor' }) const { clause, values } = this.#build_complex_id_where(id); @@ -493,7 +496,18 @@ export default class AppService extends BaseService { `WHERE ${whereClause} ` + 'LIMIT 1'; - const rows = await db.read(stmt, whereValues); + let rows = await db.read(stmt, whereValues); + + if ( rows.length === 0 && canonicalUidAliasPromise ) { + const canonicalUid = await canonicalUidAliasPromise; + if ( + typeof canonicalUid === 'string' + && canonicalUid + && canonicalUid !== uid + ) { + rows = await db.read(stmt, [canonicalUid]); + } + } if ( rows.length === 0 ) { throw APIError.create('entity_not_found', null, { @@ -736,6 +750,14 @@ export default class AppService extends BaseService { // Ensure puter.site subdomain is owned by user (if index_url uses it) await this.#ensure_puter_site_subdomain_is_owned(object.index_url, user); + const joinedApp = await this.#maybeJoinOwnedHostedIndexUrlAppOnCreate({ + object, + options, + user, + }); + if ( joinedApp ) { + return joinedApp; + } await this.#ensureIndexUrlNotAlreadyInUse({ indexUrl: object.index_url, }); @@ -1005,6 +1027,15 @@ export default class AppService extends BaseService { // Ensure puter.site subdomain is owned by user (if index_url changed) if ( object.index_url && object.index_url !== old_app.index_url ) { await this.#ensure_puter_site_subdomain_is_owned(object.index_url, user); + const joinedApp = await this.#maybeJoinOwnedHostedIndexUrlAppOnCreate({ + object, + options, + user, + excludeAppId: old_app.id, + }); + if ( joinedApp ) { + return joinedApp; + } await this.#ensureIndexUrlNotAlreadyInUse({ indexUrl: object.index_url, excludeAppId: old_app.id, @@ -1156,6 +1187,10 @@ export default class AppService extends BaseService { return null; } + #isPuterHostedIndexUrl (indexUrl) { + return !!this.#extractPuterHostedSubdomain(indexUrl); + } + #buildEquivalentIndexUrlCandidates (indexUrl) { if ( typeof indexUrl !== 'string' || !indexUrl.trim() ) { return []; @@ -1185,14 +1220,18 @@ export default class AppService extends BaseService { } } - async #ensureIndexUrlNotAlreadyInUse ({ indexUrl, excludeAppId } = {}) { + async #findIndexUrlConflictRow ({ indexUrl, excludeAppId } = {}) { + if ( ! this.#isPuterHostedIndexUrl(indexUrl) ) { + return null; + } + const indexUrlCandidates = this.#buildEquivalentIndexUrlCandidates(indexUrl); - if ( indexUrlCandidates.length === 0 ) return; - if ( hasIndexUrlUniquenessExemption(indexUrlCandidates) ) return; + if ( indexUrlCandidates.length === 0 ) return null; + if ( hasIndexUrlUniquenessExemption(indexUrlCandidates) ) return null; const placeholders = indexUrlCandidates.map(() => '?').join(', '); const parameters = [...indexUrlCandidates]; - let query = `SELECT id, uid, index_url FROM apps WHERE index_url IN (${placeholders})`; + let query = `SELECT id, uid, owner_user_id, index_url FROM apps WHERE index_url IN (${placeholders})`; if ( Number.isInteger(excludeAppId) && excludeAppId > 0 ) { query += ' AND id != ?'; @@ -1208,6 +1247,11 @@ export default class AppService extends BaseService { } return true; }); + return conflictRow || null; + } + + async #ensureIndexUrlNotAlreadyInUse ({ indexUrl, excludeAppId } = {}) { + const conflictRow = await this.#findIndexUrlConflictRow({ indexUrl, excludeAppId }); if ( conflictRow ) { throw APIError.create('app_index_url_already_in_use', null, { index_url: indexUrl, @@ -1216,6 +1260,168 @@ export default class AppService extends BaseService { } } + async #claimAppOwnershipByIdForUser ({ appId, userId }) { + if ( !Number.isInteger(appId) || appId <= 0 ) return; + if ( !Number.isInteger(userId) || userId <= 0 ) return; + + await this.db_write.write( + 'UPDATE apps SET owner_user_id = ? WHERE id = ? AND owner_user_id IS NULL', + [userId, appId], + ); + } + + #buildCanonicalAppUidAliasKey (oldAppUid) { + return `${APP_UID_ALIAS_KEY_PREFIX}:${oldAppUid}`; + } + + async #readCanonicalAppUidAlias (oldAppUid) { + if ( typeof oldAppUid !== 'string' || !oldAppUid ) return null; + + const kvStore = this.services.get('puter-kvstore'); + const suService = this.services.get('su'); + if ( !kvStore || typeof kvStore.get !== 'function' ) return null; + if ( !suService || typeof suService.sudo !== 'function' ) return null; + + const key = this.#buildCanonicalAppUidAliasKey(oldAppUid); + try { + const canonicalAppUid = await suService.sudo(() => kvStore.get({ key })); + if ( typeof canonicalAppUid === 'string' && canonicalAppUid ) { + return canonicalAppUid; + } + } catch { + // Alias reads are best-effort. + } + return null; + } + + async #writeCanonicalAppUidAlias ({ oldAppUid, canonicalAppUid }) { + if ( typeof oldAppUid !== 'string' || !oldAppUid ) return; + if ( typeof canonicalAppUid !== 'string' || !canonicalAppUid ) return; + if ( oldAppUid === canonicalAppUid ) return; + + const kvStore = this.services.get('puter-kvstore'); + const suService = this.services.get('su'); + if ( !kvStore || typeof kvStore.set !== 'function' ) return; + if ( !suService || typeof suService.sudo !== 'function' ) return; + + const key = this.#buildCanonicalAppUidAliasKey(oldAppUid); + try { + await suService.sudo(() => kvStore.set({ + key, + value: canonicalAppUid, + })); + } catch { + // Alias writes are best-effort. + } + } + + async #maybeJoinOwnedHostedIndexUrlAppOnCreate ({ + object, + options, + user, + excludeAppId, + } = {}) { + const indexUrl = object?.index_url; + const sourceAppUid = object?.uid; + if ( ! this.#isPuterHostedIndexUrl(indexUrl) ) { + return null; + } + + const conflictRow = await this.#findIndexUrlConflictRow({ + indexUrl, + excludeAppId, + }); + if ( ! conflictRow ) { + return null; + } + + const conflictOwnerUserId = Number(conflictRow.owner_user_id); + if ( + Number.isInteger(conflictOwnerUserId) + && conflictOwnerUserId > 0 + && conflictOwnerUserId !== user.id + ) { + throw APIError.create('app_index_url_already_in_use', null, { + index_url: indexUrl, + app_uid: conflictRow.uid, + }); + } + + if ( !Number.isInteger(conflictOwnerUserId) || conflictOwnerUserId <= 0 ) { + await this.#claimAppOwnershipByIdForUser({ + appId: conflictRow.id, + userId: user.id, + }); + } + + const appToJoin = await this.#read({ + uid: conflictRow.uid, + backend_only_options: { + no_filter_owner: true, + }, + }); + if ( !appToJoin || appToJoin.uid !== conflictRow.uid ) { + throw APIError.create('app_index_url_already_in_use', null, { + index_url: indexUrl, + app_uid: conflictRow.uid, + }); + } + const appToJoinOwnerId = Number(appToJoin.owner?.id); + if ( !Number.isInteger(appToJoinOwnerId) || appToJoinOwnerId !== user.id ) { + throw APIError.create('app_index_url_already_in_use', null, { + index_url: indexUrl, + app_uid: conflictRow.uid, + }); + } + if ( + Number.isInteger(conflictOwnerUserId) + && conflictOwnerUserId === user.id + && !this.#isOriginBootstrapApp(appToJoin) + ) { + // Prevent merging arbitrary same-owner apps; only allow the + // auto-created origin bootstrap app to be absorbed. + throw APIError.create('app_index_url_already_in_use', null, { + index_url: indexUrl, + app_uid: conflictRow.uid, + }); + } + + const joinedObject = { + ...object, + uid: appToJoin.uid, + }; + if ( object?.uid && joinedObject.name !== undefined ) { + delete joinedObject.name; + } + + const joinedApp = await this.#update({ + object: joinedObject, + options, + }); + + if ( sourceAppUid && sourceAppUid !== appToJoin.uid ) { + await this.#writeCanonicalAppUidAlias({ + oldAppUid: sourceAppUid, + canonicalAppUid: appToJoin.uid, + }); + const svc_appInformation = this.services.get('app-information'); + if ( svc_appInformation?.delete_app ) { + await svc_appInformation.delete_app(sourceAppUid); + } + } + + return joinedApp; + } + + #isOriginBootstrapApp (app) { + if ( !app || typeof app !== 'object' ) return false; + if ( typeof app.uid !== 'string' || !app.uid ) return false; + if ( app.name !== app.uid ) return false; + if ( app.title !== app.uid ) return false; + if ( typeof app.description !== 'string' ) return false; + return app.description.startsWith('App created from origin '); + } + async #handle_name_conflict (object, old_app, options) { const new_name = object.name; const old_name = old_app.name; diff --git a/src/backend/src/modules/data-access/AppService.test.js b/src/backend/src/modules/data-access/AppService.test.js index d73c8e1dd..621ce22d8 100644 --- a/src/backend/src/modules/data-access/AppService.test.js +++ b/src/backend/src/modules/data-access/AppService.test.js @@ -62,6 +62,9 @@ describe('AppService', () => { let mockPermissionService; let mockPuterSiteService; let mockOldAppNameService; + let mockAppInformationService; + let mockKvStoreService; + let mockSuService; // Helper to create a mock database row const createMockAppRow = (overrides = {}) => ({ @@ -153,6 +156,23 @@ describe('AppService', () => { remove_name: vi.fn().mockResolvedValue(undefined), }; + // Mock app-information service + mockAppInformationService = { + delete_app: vi.fn().mockResolvedValue(undefined), + }; + + mockKvStoreService = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(true), + }; + + mockSuService = { + sudo: vi.fn(async (actorOrCallback, maybeCallback) => { + const callback = maybeCallback || actorOrCallback; + return await callback(); + }), + }; + // Mock services mockServices = { get: vi.fn().mockImplementation((serviceName) => { @@ -168,6 +188,9 @@ describe('AppService', () => { if ( serviceName === 'permission' ) return mockPermissionService; if ( serviceName === 'puter-site' ) return mockPuterSiteService; if ( serviceName === 'old-app-name' ) return mockOldAppNameService; + if ( serviceName === 'app-information' ) return mockAppInformationService; + if ( serviceName === 'puter-kvstore' ) return mockKvStoreService; + if ( serviceName === 'su' ) return mockSuService; return null; }), }; @@ -236,6 +259,30 @@ describe('AppService', () => { }); }); + it('should resolve app by canonical uid alias when old uid is missing', async () => { + const canonicalRow = createMockAppRow({ + uid: 'app-canonical-uid-123', + }); + mockDb.read + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([canonicalRow]); + mockKvStoreService.get.mockResolvedValue('app-canonical-uid-123'); + + const crudQ = AppService.IMPLEMENTS['crud-q']; + const result = await crudQ.read.call(appService, { uid: 'app-old-uid-123' }); + + expect(result.uid).toBe('app-canonical-uid-123'); + expect(mockSuService.sudo).toHaveBeenCalled(); + expect(mockKvStoreService.get).toHaveBeenCalledWith({ + key: 'app:canonicalUidAlias:app-old-uid-123', + }); + expect(mockDb.read).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('WHERE apps.uid = ?'), + ['app-canonical-uid-123'], + ); + }); + it('should throw an error when neither uid nor id is provided', async () => { const crudQ = AppService.IMPLEMENTS['crud-q']; @@ -724,7 +771,7 @@ describe('AppService', () => { // Mock the read after insert mockDb.read.mockImplementation(async (query) => { - if ( typeof query === 'string' && query.includes('SELECT id, uid, index_url FROM apps WHERE index_url IN') ) { + if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) { return []; } return [createMockAppRow({ @@ -892,13 +939,14 @@ describe('AppService', () => { })).rejects.toThrow(); }); - it('should throw when equivalent index_url is already in use on create', async () => { + it('should allow equivalent index_url already in use on create for non-hosted origins', async () => { setupContextForWrite(createMockUserActor(1)); mockDb.read.mockImplementation(async (query) => { - if ( typeof query === 'string' && query.includes('SELECT id, uid, index_url FROM apps WHERE index_url IN') ) { + if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) { return [{ id: 999, uid: 'app-existing-uid', + owner_user_id: 1, index_url: 'https://example.com/', }]; } @@ -913,16 +961,17 @@ describe('AppService', () => { title: 'New App', index_url: 'https://example.com/index.html', }, - })).rejects.toThrow(); + })).resolves.toBeDefined(); }); it('should allow duplicate dev-center placeholder index_url on create', async () => { setupContextForWrite(createMockUserActor(1)); mockDb.read.mockImplementation(async (query) => { - if ( typeof query === 'string' && query.includes('SELECT id, uid, index_url FROM apps WHERE index_url IN') ) { + if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) { return [{ id: 999, uid: 'app-existing-placeholder', + owner_user_id: 1, index_url: 'https://dev-center.puter.com/coming-soon.html', }]; } @@ -940,6 +989,75 @@ describe('AppService', () => { })).resolves.toBeDefined(); }); + it('should join existing hosted app when index_url is owned and already used', async () => { + setupContextForWrite(createMockUserActor(1)); + mockPuterSiteService.get_subdomain.mockResolvedValue({ user_id: 1 }); + mockDb.read.mockImplementation(async (query) => { + if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) { + return [{ + id: 999, + uid: 'app-existing-hosted', + owner_user_id: null, + index_url: 'https://mysite.puter.site', + }]; + } + return [createMockAppRow({ + id: 999, + uid: 'app-existing-hosted', + name: 'existing-hosted-app', + title: 'Existing Hosted App', + index_url: 'https://mysite.puter.site', + owner_user_id: 1, + })]; + }); + + const crudQ = AppService.IMPLEMENTS['crud-q']; + const joined = await crudQ.create.call(appService, { + object: { + name: 'joined-hosted-app', + title: 'Joined Hosted App', + index_url: 'https://mysite.puter.site', + }, + }); + + expect(joined.uid).toBe('app-existing-hosted'); + expect(mockDbWrite.write).not.toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO apps'), + expect.any(Array), + ); + expect(mockKvStoreService.set).not.toHaveBeenCalled(); + }); + + it('should throw when hosted index_url is already in use by another owner on create', async () => { + setupContextForWrite(createMockUserActor(1)); + mockPuterSiteService.get_subdomain.mockResolvedValue({ user_id: 1 }); + mockDb.read.mockImplementation(async (query) => { + if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) { + return [{ + id: 999, + uid: 'app-existing-hosted', + owner_user_id: 2, + index_url: 'https://mysite.puter.site', + }]; + } + return [createMockAppRow()]; + }); + + const crudQ = AppService.IMPLEMENTS['crud-q']; + + await expect(crudQ.create.call(appService, { + object: { + name: 'new-app', + title: 'New App', + index_url: 'https://mysite.puter.site', + }, + })).rejects.toMatchObject({ + fields: { + code: 'app_index_url_already_in_use', + }, + }); + }); + it('should set app_owner when actor is AppUnderUserActorType', async () => { setupContextForWrite(createMockAppUnderUserActor(1, 100)); mockDb.read.mockResolvedValue([createMockAppRow()]); @@ -1190,7 +1308,7 @@ describe('AppService', () => { it('should call validate_url for index_url', async () => { setupContextForWrite(createMockUserActor(1)); mockDb.read.mockImplementation(async (query) => { - if ( typeof query === 'string' && query.includes('SELECT id, uid, index_url FROM apps WHERE index_url IN') ) { + if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) { return []; } return [createMockAppRow()]; @@ -1670,13 +1788,14 @@ describe('AppService', () => { ); }); - it('should throw when equivalent index_url is already in use on update', async () => { + it('should allow equivalent index_url already in use on update for non-hosted origins', async () => { setupContextForWrite(createMockUserActor(1)); mockDb.read.mockImplementation(async (query) => { - if ( typeof query === 'string' && query.includes('SELECT id, uid, index_url FROM apps WHERE index_url IN') ) { + if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) { return [{ id: 777, uid: 'app-conflict-uid', + owner_user_id: 2, index_url: 'https://updated.com/', }]; } @@ -1689,16 +1808,17 @@ describe('AppService', () => { uid: 'app-uid-123', index_url: 'https://updated.com/index.html', }, - })).rejects.toThrow(); + })).resolves.toBeDefined(); }); it('should allow duplicate dev-center placeholder index_url on update', async () => { setupContextForWrite(createMockUserActor(1)); mockDb.read.mockImplementation(async (query) => { - if ( typeof query === 'string' && query.includes('SELECT id, uid, index_url FROM apps WHERE index_url IN') ) { + if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) { return [{ id: 777, uid: 'app-existing-placeholder', + owner_user_id: 1, index_url: 'https://dev-center.puter.com/coming-soon.html', }]; } @@ -1714,6 +1834,115 @@ describe('AppService', () => { })).resolves.toBeDefined(); }); + it('should join existing unowned hosted app when index_url is already in use on update', async () => { + setupContextForWrite(createMockUserActor(1)); + mockPuterSiteService.get_subdomain.mockResolvedValue({ user_id: 1 }); + mockDb.read.mockImplementation(async (query, params) => { + if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) { + return [{ + id: 777, + uid: 'app-conflict-uid', + owner_user_id: null, + index_url: 'https://mysite.puter.site/', + }]; + } + if ( Array.isArray(params) && params[0] === 'app-conflict-uid' ) { + return [createMockAppRow({ + id: 777, + uid: 'app-conflict-uid', + name: 'existing-hosted-app', + title: 'Existing Hosted App', + index_url: 'https://mysite.puter.site/', + owner_user_id: 1, + })]; + } + return [createMockAppRow({ + id: 1, + uid: 'app-uid-123', + name: 'updating-app', + title: 'Updating App', + index_url: 'https://other.puter.site', + owner_user_id: 1, + })]; + }); + + const crudQ = AppService.IMPLEMENTS['crud-q']; + const result = await crudQ.update.call(appService, { + object: { + uid: 'app-uid-123', + title: 'Joined Update Title', + index_url: 'https://mysite.puter.site/index.html', + }, + }); + + expect(result.uid).toBe('app-conflict-uid'); + expect(mockDbWrite.write).toHaveBeenCalledWith( + expect.stringContaining('UPDATE apps SET'), + expect.arrayContaining(['Joined Update Title', 777]), + ); + expect(mockAppInformationService.delete_app).toHaveBeenCalledWith('app-uid-123'); + expect(mockKvStoreService.set).toHaveBeenCalledWith({ + key: 'app:canonicalUidAlias:app-uid-123', + value: 'app-conflict-uid', + }); + }); + + it('should throw when owned hosted index_url is already in use on update', async () => { + setupContextForWrite(createMockUserActor(1)); + mockPuterSiteService.get_subdomain.mockResolvedValue({ user_id: 1 }); + mockDb.read.mockImplementation(async (query) => { + if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) { + return [{ + id: 777, + uid: 'app-conflict-uid', + owner_user_id: 1, + index_url: 'https://mysite.puter.site/', + }]; + } + return [createMockAppRow()]; + }); + + const crudQ = AppService.IMPLEMENTS['crud-q']; + await expect(crudQ.update.call(appService, { + object: { + uid: 'app-uid-123', + index_url: 'https://mysite.puter.site/index.html', + }, + })).rejects.toMatchObject({ + fields: { + code: 'app_index_url_already_in_use', + }, + }); + }); + + it('should throw when equivalent hosted index_url is already in use on update', async () => { + setupContextForWrite(createMockUserActor(1)); + mockPuterSiteService.get_subdomain.mockResolvedValue({ user_id: 1 }); + mockDb.read.mockImplementation(async (query) => { + if ( typeof query === 'string' && query.includes('FROM apps WHERE index_url IN') ) { + return [{ + id: 777, + uid: 'app-conflict-uid', + owner_user_id: 2, + index_url: 'https://mysite.puter.site/', + }]; + } + return [createMockAppRow()]; + }); + + const crudQ = AppService.IMPLEMENTS['crud-q']; + await expect(crudQ.update.call(appService, { + object: { + uid: 'app-uid-123', + index_url: 'https://mysite.puter.site/index.html', + }, + })).rejects.toMatchObject({ + fields: { + code: 'app_index_url_already_in_use', + }, + }); + }); + it('should throw forbidden when app actor does not own the entity (AppLimitedES behavior)', async () => { // App actor trying to update an app it didn't create setupContextForWrite(createMockAppUnderUserActor(1, 999)); @@ -1811,8 +2040,6 @@ describe('AppService', () => { }); describe('#delete', () => { - let mockAppInformationService; - beforeEach(() => { // Mock app-information service mockAppInformationService = { @@ -1834,6 +2061,8 @@ describe('AppService', () => { if ( serviceName === 'puter-site' ) return mockPuterSiteService; if ( serviceName === 'old-app-name' ) return mockOldAppNameService; if ( serviceName === 'app-information' ) return mockAppInformationService; + if ( serviceName === 'puter-kvstore' ) return mockKvStoreService; + if ( serviceName === 'su' ) return mockSuService; return null; }); diff --git a/src/backend/src/om/entitystorage/AppES.js b/src/backend/src/om/entitystorage/AppES.js index e307ad8d7..a92fcb613 100644 --- a/src/backend/src/om/entitystorage/AppES.js +++ b/src/backend/src/om/entitystorage/AppES.js @@ -27,8 +27,10 @@ const { Context } = require('../../util/context'); const { origin_from_url } = require('../../util/urlutil'); const { Eq, Like, Or, And } = require('../query/query'); const { BaseES } = require('./BaseES'); +const { Entity } = require('./Entity'); const uuidv4 = require('uuid').v4; +const APP_UID_ALIAS_KEY_PREFIX = 'app:canonicalUidAlias'; const indexUrlUniquenessExemptionCandidates = [ 'https://dev-center.puter.com/coming-soon', ]; @@ -40,6 +42,54 @@ const hasIndexUrlUniquenessExemption = (candidates) => { } return false; }; + +const normalizeConfiguredHostedDomain = (domainValue) => { + if ( typeof domainValue !== 'string' ) return null; + const normalizedDomainValue = domainValue.trim().toLowerCase().replace(/^\./, ''); + if ( ! normalizedDomainValue ) return null; + return normalizedDomainValue.split(':')[0] || null; +}; + +const getConfiguredHostedDomains = () => { + const hostedDomains = new Set(); + for ( const configuredDomain of [ + config.static_hosting_domain, + config.static_hosting_domain_alt, + config.private_app_hosting_domain, + config.private_app_hosting_domain_alt, + ] ) { + const normalizedDomain = normalizeConfiguredHostedDomain(configuredDomain); + if ( normalizedDomain ) { + hostedDomains.add(normalizedDomain); + } + } + return [...hostedDomains]; +}; + +const extractPuterHostedSubdomainFromIndexUrl = (indexUrl) => { + if ( typeof indexUrl !== 'string' || !indexUrl ) return null; + + let hostname; + try { + hostname = (new URL(indexUrl)).hostname.toLowerCase(); + } catch { + return null; + } + + const hostedDomains = getConfiguredHostedDomains() + .sort((domainA, domainB) => domainB.length - domainA.length); + + for ( const hostedDomain of hostedDomains ) { + const suffix = `.${hostedDomain}`; + if ( hostname.endsWith(suffix) ) { + const subdomain = hostname.slice(0, hostname.length - suffix.length); + return subdomain || null; + } + } + + return null; +}; + let privateLaunchAccessModulePromise; const getPrivateLaunchAccessModule = async () => { if ( ! privateLaunchAccessModulePromise ) { @@ -80,6 +130,25 @@ class AppES extends BaseES { await svc_appInformation.delete_app(uid); }, + async read (uid) { + if ( typeof uid !== 'string' || !uid ) { + return await this.upstream.read(uid); + } + + const canonicalUidAliasPromise = this.read_canonical_app_uid_alias_(uid); + const entity = await this.upstream.read(uid); + if ( entity ) { + return entity; + } + + const canonicalUid = await canonicalUidAliasPromise; + if ( !canonicalUid || canonicalUid === uid ) { + return null; + } + + return await this.upstream.read(canonicalUid); + }, + /** * Filters app selection based on user permissions and visibility settings * @param {Object} options - Selection options including predicates @@ -123,16 +192,24 @@ class AppES extends BaseES { * @returns {Promise} Upsert operation results */ async upsert (entity, extra) { + extra = extra || {}; const actor = Context.get('actor'); const user = actor?.type?.user; + const preJoinFullEntity = extra.old_entity + ? await (await extra.old_entity.clone()).apply(entity) + : entity + ; + await this.ensurePuterSiteSubdomainIsOwned(preJoinFullEntity, extra, user); + + await this.maybe_join_owned_hosted_index_url_app_on_create_(entity, extra, user); + const full_entity = extra.old_entity ? await (await extra.old_entity.clone()).apply(entity) : entity ; - await this.ensure_puter_site_subdomain_is_owned_(full_entity, extra, user); - await this.ensure_index_url_unique_(full_entity, extra); + await this.ensureIndexUrlUnique(full_entity, extra); if ( await app_name_exists(await entity.get('name')) ) { const { old_entity } = extra; @@ -268,6 +345,17 @@ class AppES extends BaseES { }); } + if ( extra.joined_source_app_uid ) { + await this.write_canonical_app_uid_alias_({ + oldAppUid: extra.joined_source_app_uid, + canonicalAppUid: await full_entity.get('uid'), + }); + const svc_appInformation = this.context.get('services').get('app-information'); + if ( svc_appInformation?.delete_app ) { + await svc_appInformation.delete_app(extra.joined_source_app_uid); + } + } + return result; }, async retry_predicate_rewrite ({ predicate }) { @@ -497,7 +585,7 @@ class AppES extends BaseES { * Ensures that when an app uses a puter.site subdomain as its index_url, * the subdomain belongs to the user creating/updating the app. */ - async ensure_puter_site_subdomain_is_owned_ (entity, extra, user) { + async ensurePuterSiteSubdomainIsOwned (entity, extra, user) { if ( ! user ) return; // Only enforce when the index_url is being set or changed @@ -510,33 +598,7 @@ class AppES extends BaseES { } } - let hostname; - try { - hostname = (new URL(new_index_url)).hostname.toLowerCase(); - } catch { - return; - } - - const hostedDomains = [ - config.static_hosting_domain, - config.static_hosting_domain_alt, - config.private_app_hosting_domain, - config.private_app_hosting_domain_alt, - ] - .map(value => typeof value === 'string' - ? value.trim().toLowerCase().replace(/^\./, '').split(':')[0] - : null) - .filter(Boolean) - .sort((domainA, domainB) => domainB.length - domainA.length); - - let subdomain = null; - for ( const hostedDomain of hostedDomains ) { - const suffix = `.${hostedDomain}`; - if ( hostname.endsWith(suffix) ) { - subdomain = hostname.slice(0, hostname.length - suffix.length); - break; - } - } + const subdomain = extractPuterHostedSubdomainFromIndexUrl(new_index_url); if ( ! subdomain ) return; const svc_puterSite = this.context.get('services').get('puter-site'); @@ -547,50 +609,52 @@ class AppES extends BaseES { } }, - async ensure_index_url_unique_ (entity, extra) { - const new_index_url = await entity.get('index_url'); - if ( ! new_index_url ) return; + is_puter_hosted_index_url_ (index_url) { + return !!extractPuterHostedSubdomainFromIndexUrl(index_url); + }, - if ( extra.old_entity ) { - const old_index_url = await extra.old_entity.get('index_url'); - if ( old_index_url === new_index_url ) { - return; - } + build_equivalent_index_url_candidates_ (index_url) { + if ( typeof index_url !== 'string' || !index_url.trim() ) { + return []; } - const candidates = (() => { - try { - const parsedUrl = new URL(new_index_url); - const origin = `${parsedUrl.protocol}//${parsedUrl.host.toLowerCase()}`; - const pathname = parsedUrl.pathname || '/'; - const values = new Set(); - if ( pathname === '/' || pathname.toLowerCase() === '/index.html' ) { - values.add(origin); - values.add(`${origin}/`); - values.add(`${origin}/index.html`); - } else { - const normalizedPath = pathname.endsWith('/') - ? pathname.slice(0, -1) - : pathname; - values.add(`${origin}${normalizedPath}`); - values.add(`${origin}${normalizedPath}/`); - } - return [...values]; - } catch { - return [new_index_url]; + try { + const parsedUrl = new URL(index_url); + const origin = `${parsedUrl.protocol}//${parsedUrl.host.toLowerCase()}`; + const pathname = parsedUrl.pathname || '/'; + const values = new Set(); + if ( pathname === '/' || pathname.toLowerCase() === '/index.html' ) { + values.add(origin); + values.add(`${origin}/`); + values.add(`${origin}/index.html`); + } else { + const normalizedPath = pathname.endsWith('/') + ? pathname.slice(0, -1) + : pathname; + values.add(`${origin}${normalizedPath}`); + values.add(`${origin}${normalizedPath}/`); } - })(); + return [...values]; + } catch { + return [index_url.trim()]; + } + }, - if ( candidates.length === 0 ) return; - if ( hasIndexUrlUniquenessExemption(candidates) ) return; + async find_index_url_conflict_ ({ indexUrl, excludeMysqlId }) { + if ( ! this.is_puter_hosted_index_url_(indexUrl) ) { + return null; + } + + const candidates = this.build_equivalent_index_url_candidates_(indexUrl); + if ( candidates.length === 0 ) return null; + if ( hasIndexUrlUniquenessExemption(candidates) ) return null; const placeholders = candidates.map(() => '?').join(', '); const parameters = [...candidates]; - let query = `SELECT id, uid, index_url FROM apps WHERE index_url IN (${placeholders})`; - const currentMysqlId = extra.old_entity?.private_meta?.mysql_id; - if ( Number.isInteger(currentMysqlId) && currentMysqlId > 0 ) { + let query = `SELECT id, uid, owner_user_id, index_url FROM apps WHERE index_url IN (${placeholders})`; + if ( Number.isInteger(excludeMysqlId) && excludeMysqlId > 0 ) { query += ' AND id != ?'; - parameters.push(currentMysqlId); + parameters.push(excludeMysqlId); } query += ' ORDER BY timestamp ASC, id ASC LIMIT 1'; @@ -601,6 +665,174 @@ class AppES extends BaseES { } return true; }); + return conflictRow || null; + }, + + async claim_app_ownership_by_id_for_user_ ({ appId, userId }) { + if ( !Number.isInteger(appId) || appId <= 0 ) return; + if ( !Number.isInteger(userId) || userId <= 0 ) return; + + await this.db.write( + 'UPDATE apps SET owner_user_id = ? WHERE id = ? AND owner_user_id IS NULL', + [userId, appId], + ); + }, + + build_canonical_app_uid_alias_key_ (oldAppUid) { + return `${APP_UID_ALIAS_KEY_PREFIX}:${oldAppUid}`; + }, + + async read_canonical_app_uid_alias_ (oldAppUid) { + if ( typeof oldAppUid !== 'string' || !oldAppUid ) return null; + + const services = this.context.get('services'); + const kvStore = services.get('puter-kvstore'); + const suService = services.get('su'); + if ( !kvStore || typeof kvStore.get !== 'function' ) return null; + if ( !suService || typeof suService.sudo !== 'function' ) return null; + + const key = this.build_canonical_app_uid_alias_key_(oldAppUid); + try { + const canonicalAppUid = await suService.sudo(() => kvStore.get({ key })); + if ( typeof canonicalAppUid === 'string' && canonicalAppUid ) { + return canonicalAppUid; + } + } catch { + // Alias reads are best-effort. + } + return null; + }, + + async write_canonical_app_uid_alias_ ({ oldAppUid, canonicalAppUid }) { + if ( typeof oldAppUid !== 'string' || !oldAppUid ) return; + if ( typeof canonicalAppUid !== 'string' || !canonicalAppUid ) return; + if ( oldAppUid === canonicalAppUid ) return; + + const services = this.context.get('services'); + const kvStore = services.get('puter-kvstore'); + const suService = services.get('su'); + if ( !kvStore || typeof kvStore.set !== 'function' ) return; + if ( !suService || typeof suService.sudo !== 'function' ) return; + + const key = this.build_canonical_app_uid_alias_key_(oldAppUid); + try { + await suService.sudo(() => kvStore.set({ + key, + value: canonicalAppUid, + })); + } catch { + // Alias writes are best-effort. + } + }, + + async maybe_join_owned_hosted_index_url_app_on_create_ (entity, extra, user) { + if ( ! user ) return; + + const new_index_url = await entity.get('index_url'); + const source_entity = extra.old_entity; + const currentMysqlId = extra.old_entity?.private_meta?.mysql_id; + const conflictRow = await this.find_index_url_conflict_({ + indexUrl: new_index_url, + excludeMysqlId: currentMysqlId, + }); + if ( ! conflictRow ) return; + + const conflictOwnerUserId = Number(conflictRow.owner_user_id); + if ( + Number.isInteger(conflictOwnerUserId) + && conflictOwnerUserId > 0 + && conflictOwnerUserId !== user.id + ) { + throw APIError.create('app_index_url_already_in_use', null, { + index_url: new_index_url, + app_uid: conflictRow.uid, + }); + } + + if ( !Number.isInteger(conflictOwnerUserId) || conflictOwnerUserId <= 0 ) { + await this.claim_app_ownership_by_id_for_user_({ + appId: conflictRow.id, + userId: user.id, + }); + } + + const old_entity = await this.upstream.read(conflictRow.uid); + const owner = await old_entity?.get('owner'); + let ownerUserId = owner?.id ?? owner; + if ( owner instanceof Entity ) { + ownerUserId = owner.private_meta.mysql_id; + } + ownerUserId = Number(ownerUserId); + if ( !old_entity || !Number.isInteger(ownerUserId) || ownerUserId !== user.id ) { + throw APIError.create('app_index_url_already_in_use', null, { + index_url: new_index_url, + app_uid: conflictRow.uid, + }); + } + if ( + Number.isInteger(conflictOwnerUserId) + && conflictOwnerUserId === user.id + && !await this.is_origin_bootstrap_app_entity_(old_entity) + ) { + // Prevent merging arbitrary same-owner apps; only allow the + // auto-created origin bootstrap app to be absorbed. + throw APIError.create('app_index_url_already_in_use', null, { + index_url: new_index_url, + app_uid: conflictRow.uid, + }); + } + + if ( source_entity ) { + const sourceUid = await source_entity.get('uid'); + const targetUid = await old_entity.get('uid'); + const requestedName = await entity.get('name'); + + if ( + sourceUid + && targetUid + && sourceUid !== targetUid + && requestedName !== undefined + ) { + entity.del('name'); + } + + if ( sourceUid && targetUid && sourceUid !== targetUid ) { + extra.joined_source_app_uid = sourceUid; + } + } + + await entity.set('uid', await old_entity.get('uid')); + extra.old_entity = old_entity; + }, + + async is_origin_bootstrap_app_entity_ (entity) { + if ( ! entity ) return false; + const uid = await entity.get('uid'); + if ( typeof uid !== 'string' || !uid ) return false; + if ( await entity.get('name') !== uid ) return false; + if ( await entity.get('title') !== uid ) return false; + const description = await entity.get('description'); + if ( typeof description !== 'string' ) return false; + return description.startsWith('App created from origin '); + }, + + async ensureIndexUrlUnique (entity, extra) { + const new_index_url = await entity.get('index_url'); + if ( ! new_index_url ) return; + if ( ! this.is_puter_hosted_index_url_(new_index_url) ) return; + + if ( extra.old_entity ) { + const old_index_url = await extra.old_entity.get('index_url'); + if ( old_index_url === new_index_url ) { + return; + } + } + + const currentMysqlId = extra.old_entity?.private_meta?.mysql_id; + const conflictRow = await this.find_index_url_conflict_({ + indexUrl: new_index_url, + excludeMysqlId: currentMysqlId, + }); if ( conflictRow ) { throw APIError.create('app_index_url_already_in_use', null, { index_url: new_index_url,