Merge branch 'main' into juancastro/full-access-token-route-gates

Main fixed the recommended-apps test in parallel by swapping the hard-coded
`editor`/`camera` for the new `builder`/`contacts`, which leaves the next edit
to the list free to break it again. Kept this branch's version, which reads the
first two names off `RECOMMENDED_APP_NAMES` instead: prepending two unknown apps
to the list keeps all four cases green.
This commit is contained in:
Juan Castro committed 2026-10-02 18:15:49 -04:00
commit 2f4944eeef
4 files changed
+153 -47

No files matched your search

+21 -31
View File
@@ -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": {
@@ -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",
@@ -7517,9 +7508,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": {
@@ -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",
@@ -9192,9 +9182,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 +14861,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 +16171,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": {
@@ -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(
+35 -5
View File
@@ -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();
});
+55 -11
View File
@@ -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;
@@ -2617,8 +2623,7 @@ export class FSEntryStore extends PuterStore {
} = {},
): Promise<{ entries: FSEntry[]; cursor?: string }> {
const payload = decodeCursor(options.cursor) as
| { v: unknown; id: number; s?: string; o?: string }
| undefined;
{ v: unknown; id: number; s?: string; o?: string } | undefined;
const requestedSort = options.sortBy ?? null;
const requestedOrder = options.sortOrder ?? null;
@@ -3091,14 +3096,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<FSEntry | null>;
async findHomePathConflict(
username: string,
ownerUserId: number | undefined,
options: { includeDescendants: true },
): Promise<boolean>;
async findHomePathConflict(
username: string,
ownerUserId?: number,
options?: { includeDescendants?: boolean },
): Promise<FSEntry | null> {
): Promise<FSEntry | null | boolean> {
const path = this.#normalizePath(`/${username}`);
if (!options?.includeDescendants) {
@@ -3117,19 +3133,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<void> {
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<boolean> {
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 +3199,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 = ?`,