feat: scope index_url uniqueness to hosted domains (#2644)

* feat: scope index_url uniqueness to hosted domains

Allows duplicate non-hosted origins while enforcing hosted-origin uniqueness and joining new app creation into an existing owner-matching hosted app UUID.

Restricts hosted index_url joins to unowned conflict apps, persists old-to-canonical app UID aliases in kvstore via sudo, and resolves aliases on read with parallel lookup to minimize latency.

* fix: allow merge for owned origin bootstrap apps

Restores hosted update/create merge when the conflicting app is the same-user auto-created origin bootstrap app while still blocking merges into normal same-owner apps to avoid multi-join behavior.
This commit is contained in:
Daniel Salazar
2026-03-11 18:21:09 -07:00
committed by GitHub
parent 9a78b6bffa
commit fc273d436d
4 changed files with 1092 additions and 82 deletions
@@ -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);
@@ -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;
@@ -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;
});
+297 -65
View File
@@ -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<Object>} 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,