From 37c1678897856b6e684054a193da70c093b5a11a Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Fri, 2 Oct 2026 14:53:34 -0700 Subject: [PATCH 1/3] fix(fs): keep a vacated username off-limits to other accounts for an hour (#4039) * fix(fs): keep a vacated username off-limits to other accounts for an hour Renaming a home rewrites descendant paths in the database, but their cached entries keep the old path until they expire. ACL grants a home by path prefix, so whoever takes the old name next must not be able to before those entries are gone. renameUserHome now records the vacated name, and the claim check every username-claim site already runs treats it as taken for anyone but the account that left it. * test: seed recommended apps that are still in the default list The default list no longer includes editor or camera, so the ordering test found neither. --- .../controllers/auth/AuthController.test.ts | 42 +++++++++++++ .../apps/RecommendedAppsService.test.ts | 16 ++--- src/backend/stores/fs/FSEntryStore.test.ts | 40 ++++++++++-- src/backend/stores/fs/FSEntryStore.ts | 63 ++++++++++++++++--- 4 files changed, 140 insertions(+), 21 deletions(-) diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index 63d7a1671..c52e7852a 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -6043,6 +6043,48 @@ describe('AuthController user-protected mutations (validation paths)', () => { expect(after!.username).toBe(mover.username); }); + it('change-username: a name just vacated stays closed to other accounts, not to its last holder', async () => { + const { user: leaver, actor: leaverActor } = await makeUserAndActor(); + const vacated = leaver.username; + // Warm the entry cache under the old path, as any read would. + const docs = (await server.stores.fsEntry.getEntryByPath( + `/${vacated}/Documents`, + ))!; + await controller.handleChangeUsername( + makeReq({ new_username: `r_${uniq()}` }, { actor: leaverActor }), + makeRes(), + ); + + // The cache still maps the old path to the leaver's folder, so a new + // holder of the name would pass ACL on it. + const cached = await server.stores.fsEntry.getEntryByPath( + `/${vacated}/Documents`, + ); + expect(cached?.uuid).toBe(docs.uuid); + + const { user: claimer, actor: claimerActor } = await makeUserAndActor(); + await expect( + controller.handleChangeUsername( + makeReq({ new_username: vacated }, { actor: claimerActor }), + makeRes(), + ), + ).rejects.toMatchObject({ + statusCode: 400, + legacyCode: 'username_already_in_use', + }); + const after = await server.stores.user.getById(claimer.id, { + force: true, + }); + expect(after!.username).toBe(claimer.username); + + const res = makeRes(); + await controller.handleChangeUsername( + makeReq({ new_username: vacated }, { actor: leaverActor }), + res, + ); + expect(res.body).toEqual({ username: vacated }); + }); + it('change-email: 400 on missing/invalid email and on a confirmed-account collision', async () => { const { actor } = await makeUserAndActor(); await expect( diff --git a/src/backend/services/apps/RecommendedAppsService.test.ts b/src/backend/services/apps/RecommendedAppsService.test.ts index 2535bd88b..aac07f12a 100644 --- a/src/backend/services/apps/RecommendedAppsService.test.ts +++ b/src/backend/services/apps/RecommendedAppsService.test.ts @@ -20,7 +20,7 @@ describe('RecommendedAppsService', () => { free_storage: 1024, requires_email_confirmation: false, }); - for (const name of ['editor', 'camera', 'custom-recommendation']) { + for (const name of ['builder', 'contacts', 'custom-recommendation']) { if (!(await server.stores.app.getByName(name))) { await server.stores.app.create( { @@ -52,8 +52,10 @@ describe('RecommendedAppsService', () => { const apps = await server.services.recommendedApps.getRecommendedApps(); expect(apps).toEqual(defaults); const names = apps.map((app) => app.name); - expect(names).toContain('editor'); - expect(names.indexOf('editor')).toBeLessThan(names.indexOf('camera')); + expect(names).toContain('builder'); + expect(names.indexOf('builder')).toBeLessThan( + names.indexOf('contacts'), + ); expect(names).not.toContain(customApp.name); }); @@ -61,18 +63,18 @@ describe('RecommendedAppsService', () => { extension.on('app.recommended', async (_key, data) => { await new Promise((resolve) => setTimeout(resolve, 0)); data.appNames = [ - 'camera', + 'contacts', 'custom-recommendation', 'missing-recommendation', - 'editor', + 'builder', ]; }); const apps = await server.services.recommendedApps.getRecommendedApps(); expect(apps.map((app) => app.name)).toEqual([ - 'camera', + 'contacts', 'custom-recommendation', - 'editor', + 'builder', ]); expect(apps[1]).toMatchObject({ uuid: customApp.uid, diff --git a/src/backend/stores/fs/FSEntryStore.test.ts b/src/backend/stores/fs/FSEntryStore.test.ts index c47fb3f85..4e2b2f4d6 100644 --- a/src/backend/stores/fs/FSEntryStore.test.ts +++ b/src/backend/stores/fs/FSEntryStore.test.ts @@ -1499,17 +1499,18 @@ describe('FSEntryStore home and prefix rewrites', () => { ); await store.invalidateEntryCacheByUuid(docs.uuid); - const foreign = await store.findHomePathConflict(freeName, undefined, { - includeDescendants: true, - }); - expect(foreign?.userId).toBe(owner.userId); + await expect( + store.findHomePathConflict(freeName, undefined, { + includeDescendants: true, + }), + ).resolves.toBe(true); // The name's own owner is excluded, same as the exact-path check. await expect( store.findHomePathConflict(freeName, owner.userId, { includeDescendants: true, }), - ).resolves.toBeNull(); + ).resolves.toBe(false); // Without the flag, a descendant-only conflict is invisible — the // exact-path check this call used to be stays unchanged. @@ -1532,6 +1533,35 @@ describe('FSEntryStore home and prefix rewrites', () => { store.findHomePathConflict('free_abc', undefined, { includeDescendants: true, }), + ).resolves.toBe(false); + }); + + it('holds a vacated home against every claimant but the account that left it', async () => { + const leaver = await makeUser(); + const other = await makeUser(); + await store.renameUserHome(leaver.userId, `${leaver.username}-moved`); + + // No row sits under the old home any more; only the hold remains. + await expect( + store.findHomePathConflict(leaver.username, other.userId, { + includeDescendants: true, + }), + ).resolves.toBe(true); + await expect( + store.findHomePathConflict( + leaver.username.toUpperCase(), + undefined, + { includeDescendants: true }, + ), + ).resolves.toBe(true); + await expect( + store.findHomePathConflict(leaver.username, leaver.userId, { + includeDescendants: true, + }), + ).resolves.toBe(false); + // Exact-path callers (heal, provisioning backstop) see rows only. + await expect( + store.findHomePathConflict(leaver.username, other.userId), ).resolves.toBeNull(); }); diff --git a/src/backend/stores/fs/FSEntryStore.ts b/src/backend/stores/fs/FSEntryStore.ts index 584f913e4..0c5d71eae 100644 --- a/src/backend/stores/fs/FSEntryStore.ts +++ b/src/backend/stores/fs/FSEntryStore.ts @@ -95,6 +95,12 @@ const descendantSortValue = (row: FSEntryRow, sortBy: DescendantSortField) => { }; const ENTRY_CACHE_TTL_SECONDS = 60; +// A renamed home's descendants stay cached under the old path until their TTL +// (longer behind a lagging replica), and ACL grants a home by path prefix, so +// no other account may take the vacated name until well after that. +const VACATED_HOME_HOLD_SECONDS = 60 * 60; +const vacatedHomeKey = (username: string) => + `prodfsv2:vacated-home:${username.toLowerCase()}`; const BULK_QUERY_CHUNK_SIZE = 200; const DEFAULT_DB_CHUNK_CONCURRENCY = 4; @@ -3091,14 +3097,25 @@ export class FSEntryStore extends PuterStore { * the primary: a racing signup has to see the row just written. * * `includeDescendants` also reports a foreign row under `/{username}/…` - * (ACL grants by path prefix). Claim sites only — the heal and the - * provisioning backstop stay exact-path. + * (ACL grants by path prefix) and a home another account vacated recently. + * Claim sites only — the heal and the provisioning backstop stay + * exact-path. */ + async findHomePathConflict( + username: string, + ownerUserId?: number, + options?: { includeDescendants?: false }, + ): Promise; + async findHomePathConflict( + username: string, + ownerUserId: number | undefined, + options: { includeDescendants: true }, + ): Promise; async findHomePathConflict( username: string, ownerUserId?: number, options?: { includeDescendants?: boolean }, - ): Promise { + ): Promise { const path = this.#normalizePath(`/${username}`); if (!options?.includeDescendants) { @@ -3117,19 +3134,41 @@ export class FSEntryStore extends PuterStore { return null; } - // One read for both; callers only test truthiness. No ORDER BY, so the - // planner stays on the path index. + if (await this.#isVacatedHomeHeld(username, ownerUserId)) return true; + + // One read for both. No ORDER BY, so the planner stays on the path + // index. const likePattern = `${this.#escapeLikePattern(path)}/%`; const params: unknown[] = [path, likePattern]; if (ownerUserId !== undefined) params.push(ownerUserId); const rows = (await this.clients.db.pread( - `SELECT ${this.#selectFsentriesColumns()} FROM fsentries + `SELECT id FROM fsentries WHERE (path = ? OR path LIKE ? ESCAPE '!')${ownerUserId !== undefined ? ' AND user_id <> ?' : ''} LIMIT 1`, params, - )) as unknown as FSEntryRow[]; - const row = rows[0]; - return row ? this.#mapFSEntryRow(row) : null; + )) as unknown as Array<{ id: number }>; + return rows.length > 0; + } + + async #holdVacatedHome(homePath: string, userId: number): Promise { + const name = homePath.slice(1); + if (!name || name.includes('/')) return; + await this.stores.kv.set({ + key: vacatedHomeKey(name), + value: userId, + expireAt: Math.floor(Date.now() / 1000) + VACATED_HOME_HOLD_SECONDS, + }); + } + + async #isVacatedHomeHeld( + username: string, + ownerUserId?: number, + ): Promise { + const { res } = await this.stores.kv.get({ + key: vacatedHomeKey(username), + consistentRead: true, + }); + return res !== null && res !== undefined && Number(res) !== ownerUserId; } // Heal a user's home tree to `/{username}`: if the root entry's path/name @@ -3161,6 +3200,12 @@ export class FSEntryStore extends PuterStore { const oldPath = root.path; const now = Math.floor(Date.now() / 1000); + // Before the rewrite, so a claim never sees the old path empty and + // unheld. + if (oldPath && oldPath !== newPath) { + await this.#holdVacatedHome(oldPath, userId); + } + await this.clients.db.write( `UPDATE fsentries SET name = ?, path = ?, modified = ? WHERE id = ?`, From 303ee6c2c76ac07dc793baf4496dc69c88c9ac28 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:02:56 -0700 Subject: [PATCH 2/3] chore(deps-dev): bump brace-expansion from 1.1.18 to 1.1.21 (#4015) Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.18 to 1.1.21. - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.18...v1.1.21) --- updated-dependencies: - dependency-name: brace-expansion dependency-version: 1.1.21 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index d10bfa761..99e032063 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2043,9 +2043,9 @@ "license": "MIT" }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -2124,9 +2124,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -7517,9 +7517,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9192,9 +9192,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -14871,9 +14871,9 @@ "license": "MIT" }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -16181,9 +16181,9 @@ "license": "MIT" }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { From d0da8c5b1e21cd417b377d2bc0caa1f7a361a153 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:03:34 -0700 Subject: [PATCH 3/3] chore(deps): bump engine.io from 6.6.9 to 6.6.11 (#4017) Bumps [engine.io](https://github.com/socketio/socket.io) from 6.6.9 to 6.6.11. - [Release notes](https://github.com/socketio/socket.io/releases) - [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md) - [Commits](https://github.com/socketio/socket.io/compare/engine.io@6.6.9...engine.io@6.6.11) --- updated-dependencies: - dependency-name: engine.io dependency-version: 6.6.11 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 99e032063..34dcb9a75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7283,15 +7283,6 @@ ], "license": "MIT" }, - "node_modules/base64id": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", - "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", - "license": "MIT", - "engines": { - "node": "^4.5.0 || >= 5.9" - } - }, "node_modules/baseline-browser-mapping": { "version": "2.11.21", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", @@ -8767,16 +8758,15 @@ } }, "node_modules/engine.io": { - "version": "6.6.9", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", - "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", + "version": "6.6.11", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.11.tgz", + "integrity": "sha512-z34mGpLoY/82ZbkX1LqGMIaf3wjVMPL6gVC681QDqnmzJ26synI8qAE32P+a7UazmpJZ8UGrFIeAavgRfXzYrg==", "license": "MIT", "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", - "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1",