From 927317bc4e9b8dc0cebbbbaf9d7a375019bf44bd Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Sun, 6 Sep 2026 22:44:07 -0700 Subject: [PATCH] fix: events hardening (#3814) --- .../controllers/auth/AuthController.test.ts | 21 ++++ .../controllers/auth/AuthController.ts | 42 +++---- src/backend/services/events/EventsService.ts | 10 ++ .../kvHandleDelegation.integration.test.ts | 85 +++++++++++++- src/backend/services/events/kvShares.test.ts | 108 +++++++++++++++++- src/backend/services/events/kvShares.ts | 33 ++++++ src/backend/services/fs/FSService.test.ts | 44 +++++++ src/backend/stores/fs/FSEntryStore.test.ts | 89 +++++++++++++++ src/backend/stores/fs/FSEntryStore.ts | 14 ++- src/docs/src/Events.md | 2 + 10 files changed, 418 insertions(+), 30 deletions(-) diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index faba1e1f8..16bb03651 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -1327,6 +1327,18 @@ describe('AuthController.handleLogin', () => { // No session cookie set yet — login isn't complete. expect(res.cookies['puter_auth_token']).toBeUndefined(); }); + + it('rejects with 400 (not a TypeError) when the request has no body', async () => { + await expect( + controller.handleLogin( + { ...makeReq({}), body: undefined }, + makeRes(), + ), + ).rejects.toMatchObject({ + statusCode: 400, + legacyCode: 'bad_request', + }); + }); }); // ── Login: OTP / recovery-code branches ───────────────────────────── @@ -1341,6 +1353,15 @@ describe('AuthController.handleLoginOtp + handleLoginRecoveryCode', () => { ).rejects.toMatchObject({ statusCode: 400 }); }); + it('handleLoginOtp rejects with 400 (not a TypeError) when the request has no body', async () => { + await expect( + controller.handleLoginOtp( + { ...makeReq({}), body: undefined }, + makeRes(), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + it('handleLoginOtp rejects a valid JWT with the wrong purpose', async () => { const wrongPurposeJwt = server.services.token.sign( 'otp', diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index 8c43883e4..328ccb87e 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -350,7 +350,7 @@ export class AuthController extends PuterController { ], }) async loginSet(req: Request, res: Response) { - const { session, auth_token } = req.body; + const { session, auth_token } = req.body ?? {}; if (!session || !auth_token || !validateUuid(session)) { throw new HttpError(400, 'session and auth_token are required.', { legacyCode: 'bad_request', @@ -389,7 +389,7 @@ export class AuthController extends PuterController { ], }) async handleLogin(req: Request, res: Response): Promise { - const { username, email, password } = req.body; + const { username, email, password } = req.body ?? {}; if (!username && !email) { throw new HttpError(400, 'Username or email is required.', { @@ -506,7 +506,7 @@ export class AuthController extends PuterController { ], }) async handleLoginOtp(req: Request, res: Response): Promise { - const { token, code } = req.body; + const { token, code } = req.body ?? {}; if (!token) throw new HttpError(400, 'token is required.', { legacyCode: 'bad_request', @@ -572,7 +572,7 @@ export class AuthController extends PuterController { ], }) async handleLoginRecoveryCode(req: Request, res: Response): Promise { - const { token, code } = req.body; + const { token, code } = req.body ?? {}; if (!token) throw new HttpError(400, 'token is required.', { legacyCode: 'bad_request', @@ -1072,7 +1072,7 @@ export class AuthController extends PuterController { (req.headers?.origin as string | null) ?? null, signup_server: (this.config as { serverId?: string }) .serverId, - referrer: req.body.referrer ?? null, + referrer: body.referrer ?? null, last_activity_ts: signupSqlTs, reputation: validateEvent.reputation, // Phone collected later in the verification dialog (null now). @@ -3216,8 +3216,8 @@ export class AuthController extends PuterController { rateLimit: GRANT_LIMIT, }) async handleGrantUserApp(req: Request, res: Response): Promise { - let { app_uid } = req.body; - const { origin, permission, permissions, extra, meta } = req.body; + let { app_uid } = req.body ?? {}; + const { origin, permission, permissions, extra, meta } = req.body ?? {}; this.#validateAppPermissionParams({ app_uid, origin, @@ -3274,7 +3274,7 @@ export class AuthController extends PuterController { rateLimit: GRANT_LIMIT, }) async handleRevokeUserUser(req: Request, res: Response): Promise { - const { target_username, permission, meta } = req.body; + const { target_username, permission, meta } = req.body ?? {}; if (!target_username || !permission) { throw new HttpError( 400, @@ -3304,8 +3304,8 @@ export class AuthController extends PuterController { rateLimit: GRANT_LIMIT, }) async handleRevokeUserApp(req: Request, res: Response): Promise { - let { app_uid } = req.body; - const { origin, permission, permissions, meta } = req.body; + let { app_uid } = req.body ?? {}; + const { origin, permission, permissions, meta } = req.body ?? {}; this.#validateAppPermissionParams({ app_uid, origin, @@ -3350,7 +3350,7 @@ export class AuthController extends PuterController { rateLimit: AUTH_CHECK_LIMIT, }) async handleCheckPermissions(req: Request, res: Response): Promise { - const { permissions } = req.body; + const { permissions } = req.body ?? {}; if (!Array.isArray(permissions)) { throw new HttpError(400, 'Missing or invalid `permissions` array', { legacyCode: 'bad_request', @@ -3391,7 +3391,7 @@ export class AuthController extends PuterController { // mandatory: an access token must not be able to revoke its own // issuing web session. async handleRevokeSession(req: Request, res: Response): Promise { - const { uuid } = req.body; + const { uuid } = req.body ?? {}; if (!uuid || typeof uuid !== 'string') { throw new HttpError(400, 'Missing or invalid `uuid`', { legacyCode: 'bad_request', @@ -3468,8 +3468,8 @@ export class AuthController extends PuterController { rateLimit: GRANT_LIMIT, }) async handleGrantDevApp(req: Request, res: Response): Promise { - let { app_uid } = req.body; - const { origin, permission, extra, meta } = req.body; + let { app_uid } = req.body ?? {}; + const { origin, permission, extra, meta } = req.body ?? {}; if (origin && !app_uid) { // Registered apps only, for the same reason the user-app handlers // insist on it: a synthesised `app-` is resolved @@ -3502,8 +3502,8 @@ export class AuthController extends PuterController { rateLimit: GRANT_LIMIT, }) async handleRevokeDevApp(req: Request, res: Response): Promise { - let { app_uid } = req.body; - const { origin, permission, meta } = req.body; + let { app_uid } = req.body ?? {}; + const { origin, permission, meta } = req.body ?? {}; if (origin && !app_uid) { // Registered apps only — see handleGrantDevApp. app_uid = await this.#registeredAppUidFromOrigin(origin); @@ -3632,8 +3632,8 @@ export class AuthController extends PuterController { rateLimit: { ...AUTH_CHECK_LIMIT, scope: 'app-token', limit: 120 }, }) async handleGetUserAppToken(req: Request, res: Response): Promise { - let { app_uid } = req.body; - const { origin } = req.body; + let { app_uid } = req.body ?? {}; + const { origin } = req.body ?? {}; const resolvedFromOrigin = !app_uid && !!origin; if (!app_uid && origin) { app_uid = await this.services.auth.appUidFromOrigin(origin); @@ -3754,8 +3754,8 @@ export class AuthController extends PuterController { rateLimit: AUTH_CHECK_LIMIT, }) async handleCheckApp(req: Request, res: Response): Promise { - let { app_uid } = req.body; - const { origin } = req.body; + let { app_uid } = req.body ?? {}; + const { origin } = req.body ?? {}; if (!app_uid && origin) { app_uid = await this.services.auth.appUidFromOrigin(origin); } @@ -3858,7 +3858,7 @@ export class AuthController extends PuterController { // mandatory: a leaked access token must not be able to silently // revoke its own siblings. async handleRevokeAccessToken(req: Request, res: Response): Promise { - let { tokenOrUuid } = req.body; + let { tokenOrUuid } = req.body ?? {}; if (!tokenOrUuid || typeof tokenOrUuid !== 'string') { throw new HttpError(400, 'Missing `tokenOrUuid`', { legacyCode: 'bad_request', diff --git a/src/backend/services/events/EventsService.ts b/src/backend/services/events/EventsService.ts index 2926cdd06..f1e189c11 100644 --- a/src/backend/services/events/EventsService.ts +++ b/src/backend/services/events/EventsService.ts @@ -184,6 +184,7 @@ import { assertShareableAppUid, assertShareablePermission, assertShareablePrefix, + kvShareAppDelegateImplicator, kvShareGrantCovers, kvShareManageNamespaceRoot, kvShareManagePermission, @@ -1298,6 +1299,15 @@ export class EventsService extends PuterService { // which is what lets its owner mint a handle on their own data. this.services.permission.registerImplicator(kvShareOwnerImplicator()); + // Lets an app-under-user actor exercise a share grant its user holds, + // scoped to the app the grant's namespace names. + this.services.permission.registerImplicator( + kvShareAppDelegateImplicator({ + userHolds: (actor, permission) => + this.services.permission.check(actor, permission), + }), + ); + this.#armExpirySweep(); this.#armPendingSweep(); this.#armCreditSweep(); diff --git a/src/backend/services/events/kvHandleDelegation.integration.test.ts b/src/backend/services/events/kvHandleDelegation.integration.test.ts index ff51cdaad..60cebb584 100644 --- a/src/backend/services/events/kvHandleDelegation.integration.test.ts +++ b/src/backend/services/events/kvHandleDelegation.integration.test.ts @@ -18,14 +18,17 @@ */ /** - * An app minting a share handle on its user's data. + * An app minting a share handle on its user's data, and a grantee reading one + * through their own app. * * The bounds are the ones sharing already puts on an app handing out its * user's files: the authority is the user's, the consent is a `manage:` grant * the user gave this app on this region, and the reach is whatever the * credential structurally holds — for key-value that is one namespace. What * these cases pin is that each of those is actually load-bearing, and that a - * handle minted this way is in every other respect an ordinary one. + * handle minted this way is in every other respect an ordinary one — including + * for a grantee who exercises it while running as an app, which only works + * bound to the same app the region was shared to. */ import { v4 as uuidv4 } from 'uuid'; @@ -54,6 +57,7 @@ let env: PuterTestEnv; let owner: TestUser; let guest: TestUser; let appUid: string; +let appId: number; let appActor: Actor; const events = () => env.server.services.events; @@ -146,6 +150,7 @@ beforeAll(async () => { { ownerUserId: owner.id }, ); appUid = app.uid; + appId = app.id; appActor = makeActor({ user: owner.actor.user as never, app: { uid: app.uid, id: app.id }, @@ -448,3 +453,79 @@ describe('a handle an app minted', () => { expect(delivered).toEqual([]); }); }); + +describe('a grantee subscribing through their own app actor', () => { + beforeAll(async () => { + await delegate(); + }); + + it('receives the writes when it runs as the region’s own app', async () => { + await clearRows(); + const { handle } = await mint(); + const guestAppActor = makeActor({ + user: guest.actor.user as never, + app: { uid: appUid, id: appId }, + }); + + const { sub } = await events().subscribe(guestAppActor, SOCKET_ID, { + subject: `kv:${handle}:*`, + }); + delivered.length = 0; + + await appWrites(`${PREFIX}messages:1`, { body: 'hello' }); + await settled(); + + expect(delivered).toHaveLength(1); + expect(delivered[0].subId).toBe(sub.subId); + }); + + it('is refused when it runs as a different app than the one the region was shared to', async () => { + await clearRows(); + const { handle } = await mint(); + // A real app row with an id, so the user-to-app scanner actually runs + // and the refusal is the app binding rather than a lookup finding + // nothing to read. + const name = `kv-other-${uuidv4().slice(0, 8)}`; + const other = await env.server.stores.app.create( + { + name, + title: 'Another App', + index_url: `https://${name}.example.test/index.html`, + }, + { ownerUserId: guest.id }, + ); + const otherAppActor = makeActor({ + user: guest.actor.user as never, + app: { uid: other.uid, id: other.id }, + }); + + await expect( + events().subscribe(otherAppActor, SOCKET_ID, { + subject: `kv:${handle}:*`, + }), + ).rejects.toMatchObject({ legacyCode: 'subject_does_not_exist' }); + }); + + it('stops receiving deliveries once the owner revokes the handle', async () => { + await clearRows(); + const { handle } = await mint(); + const guestAppActor = makeActor({ + user: guest.actor.user as never, + app: { uid: appUid, id: appId }, + }); + const { sub } = await events().subscribe(guestAppActor, SOCKET_ID, { + subject: `kv:${handle}:*`, + }); + + await appWrites(`${PREFIX}live:1`, 1); + await settled(); + expect(delivered[0].subId).toBe(sub.subId); + + await events().revokeKvHandle(owner.actor, handle); + delivered.length = 0; + + await appWrites(`${PREFIX}live:2`, 2); + await quiet(); + expect(delivered).toEqual([]); + }); +}); diff --git a/src/backend/services/events/kvShares.test.ts b/src/backend/services/events/kvShares.test.ts index 1ab2a6f28..fa8f7ff66 100644 --- a/src/backend/services/events/kvShares.test.ts +++ b/src/backend/services/events/kvShares.test.ts @@ -18,7 +18,7 @@ */ import { v4 as uuidv4 } from 'uuid'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { Actor } from '../../core/actor.js'; import { HttpError } from '../../core/http/HttpError.js'; import { PermissionUtil } from '../permission/permissionUtil.js'; @@ -28,6 +28,7 @@ import { assertShareablePermission, assertShareablePrefix, keyPrefixSegments, + kvShareAppDelegateImplicator, kvShareGrantCovers, kvShareManagePermission, kvShareOwnerImplicator, @@ -41,10 +42,18 @@ import { isKvHandleId, kvHandleFromSubject } from './subjects.js'; const OWNER = '2a1b0c9d-0000-4000-8000-000000000001'; const OTHER = '2a1b0c9d-0000-4000-8000-000000000002'; const APP = 'app-1234'; +const OTHER_APP = 'app-5678'; const userActor = (uuid: string): Actor => ({ user: { uuid }, effectiveApp: null }) as unknown as Actor; +const appActor = (uuid: string, appUid: string): Actor => + ({ + user: { uuid }, + app: { uid: appUid }, + effectiveApp: { uid: appUid }, + }) as unknown as Actor; + describe('the share permission family', () => { it('names the owner, the app and the granted prefix', () => { expect(kvSharePermission(OWNER, APP, 'workspace:abc:')).toBe( @@ -331,3 +340,100 @@ describe('the owner implicator', () => { ).toBeUndefined(); }); }); + +describe('the app delegate implicator', () => { + const permission = kvSharePermission(OWNER, APP, 'workspace:abc:'); + + it('answers the share family, never its manage arm, nor other families', () => { + const implicator = kvShareAppDelegateImplicator({ + userHolds: vi.fn(), + }); + expect(implicator.matches(permission)).toBe(true); + expect(implicator.matches(`manage:${permission}`)).toBe(false); + expect(implicator.matches(`fs:${OWNER}:read`)).toBe(false); + }); + + it('grants when the actor\'s own app matches the namespace app and the user holds the grant', async () => { + const userHolds = vi.fn().mockResolvedValue(true); + const implicator = kvShareAppDelegateImplicator({ userHolds }); + const actor = appActor(OWNER, APP); + + await expect( + implicator.check({ actor, permission }), + ).resolves.toEqual({}); + + expect(userHolds).toHaveBeenCalledTimes(1); + const [calledActor, calledPermission] = userHolds.mock.calls[0]; + expect(calledActor.app).toBeUndefined(); + expect(calledActor.effectiveApp).toBeNull(); + expect(calledActor.user).toEqual({ uuid: OWNER }); + expect(calledPermission).toBe(permission); + }); + + it('refuses when the user does not hold the grant', async () => { + const implicator = kvShareAppDelegateImplicator({ + userHolds: vi.fn().mockResolvedValue(false), + }); + await expect( + implicator.check({ actor: appActor(OWNER, APP), permission }), + ).resolves.toBeUndefined(); + }); + + it('refuses when the actor\'s app differs from the namespace app', async () => { + const userHolds = vi.fn().mockResolvedValue(true); + const implicator = kvShareAppDelegateImplicator({ userHolds }); + + await expect( + implicator.check({ + actor: appActor(OWNER, OTHER_APP), + permission, + }), + ).resolves.toBeUndefined(); + expect(userHolds).not.toHaveBeenCalled(); + }); + + it('refuses a plain user actor with no app', async () => { + const userHolds = vi.fn().mockResolvedValue(true); + const implicator = kvShareAppDelegateImplicator({ userHolds }); + + await expect( + implicator.check({ actor: userActor(OWNER), permission }), + ).resolves.toBeUndefined(); + expect(userHolds).not.toHaveBeenCalled(); + }); + + it('refuses an access-token actor', async () => { + const userHolds = vi.fn().mockResolvedValue(true); + const implicator = kvShareAppDelegateImplicator({ userHolds }); + const actor = { + user: { uuid: OWNER }, + app: { uid: APP }, + accessToken: { uid: 't' }, + } as unknown as Actor; + + await expect( + implicator.check({ actor, permission }), + ).resolves.toBeUndefined(); + expect(userHolds).not.toHaveBeenCalled(); + }); + + it('refuses a permission with no key segments or no app', async () => { + const userHolds = vi.fn().mockResolvedValue(true); + const implicator = kvShareAppDelegateImplicator({ userHolds }); + const actor = appActor(OWNER, APP); + + await expect( + implicator.check({ + actor, + permission: PermissionUtil.join('kv-share', OWNER, APP), + }), + ).resolves.toBeUndefined(); + await expect( + implicator.check({ + actor, + permission: PermissionUtil.join('kv-share', OWNER), + }), + ).resolves.toBeUndefined(); + expect(userHolds).not.toHaveBeenCalled(); + }); +}); diff --git a/src/backend/services/events/kvShares.ts b/src/backend/services/events/kvShares.ts index 7ea5b2384..f092dbdf0 100644 --- a/src/backend/services/events/kvShares.ts +++ b/src/backend/services/events/kvShares.ts @@ -18,6 +18,7 @@ */ import { randomUUID } from 'node:crypto'; +import { type Actor, userRelatedActor } from '../../core/actor.js'; import { HttpError } from '../../core/http/HttpError.js'; import { KV_GLOBAL_APP_KEY } from '../../stores/systemKv/SystemKVStore.js'; import { @@ -281,3 +282,35 @@ export const kvShareOwnerImplicator = (): PermissionImplicator => ({ return owner === uuid ? {} : undefined; }, }); + +/** + * Letting an app-under-user actor exercise a `kv-share:` grant its user holds, + * bounded to the app the grant's namespace names. + * + * Only the read arm is matched — never `manage:kv-share:…` — because answering + * the manage arm here would read as the unbounded namespace-root delegation and + * break minting for everyone (see `kvShareManageNamespaceRoot`). The + * namespace-app check keeps one app from reading a region shared with another + * app of the same user. + */ +export const kvShareAppDelegateImplicator = (deps: { + userHolds: (actor: Actor, permission: string) => Promise; +}): PermissionImplicator => ({ + id: 'kv-share-app-delegate', + shortcut: true, + matches: (permission: string): boolean => isKvSharePermission(permission), + check: async ({ actor, permission }): Promise => { + if (actor.accessToken) return undefined; + const app = actor.app; + if (!app?.uid) return undefined; + + const [, owner, namespaceApp, ...segments] = + PermissionUtil.split(permission); + if (!owner || !namespaceApp || segments.length === 0) return undefined; + if (namespaceApp !== app.uid) return undefined; + + return (await deps.userHolds(userRelatedActor(actor), permission)) + ? {} + : undefined; + }, +}); diff --git a/src/backend/services/fs/FSService.test.ts b/src/backend/services/fs/FSService.test.ts index 69c55e411..6d3610d0c 100644 --- a/src/backend/services/fs/FSService.test.ts +++ b/src/backend/services/fs/FSService.test.ts @@ -2180,6 +2180,50 @@ describe('FSService mkdir, touch, rename and shortcuts', () => { expect(await entryAt(user, '/Documents/before.txt')).toBeNull(); }); + it('rename returns the new path even when the replica lags behind the update', async () => { + const entry = await writeFile( + user, + `${user.home}/Documents/stale-rename.txt`, + 'x', + ); + + const db = server.clients.db; + const staleRows = (await db.read( + 'SELECT * FROM fsentries WHERE uuid = ?', + [entry.uuid], + )) as Array>; + for (const row of staleRows) row.subdomains_agg = null; + + const originalTryHardRead = (Object.getPrototypeOf(db) as typeof db) + .tryHardRead; + const tryHardReadSpy = vi + .spyOn(db, 'tryHardRead') + .mockImplementation(async (query: string, params: unknown[] = []) => { + if ( + query.includes('WHERE uuid = ? LIMIT 1') && + params[0] === entry.uuid + ) { + return staleRows; + } + return originalTryHardRead.call(db, query, params); + }); + try { + + const renamed = await fs.rename( + user.userId, + entry, + 'stale-rename-2.txt', + ); + + expect(renamed.path).toBe( + `${user.home}/Documents/stale-rename-2.txt`, + ); + + } finally { + tryHardReadSpy.mockRestore(); + } + }); + it('rewrites descendant paths when a directory is renamed', async () => { const dir = await fs.mkdir(user.userId, { path: `${user.home}/Documents/olddir`, diff --git a/src/backend/stores/fs/FSEntryStore.test.ts b/src/backend/stores/fs/FSEntryStore.test.ts index 614e7ddee..3c24fe9ca 100644 --- a/src/backend/stores/fs/FSEntryStore.test.ts +++ b/src/backend/stores/fs/FSEntryStore.test.ts @@ -986,6 +986,95 @@ describe('FSEntryStore timestamps and updates', () => { thumbnail: 'data:image/png;base64,BB', }); }); + + it('updateEntry returns and caches the primary row when the replica lags', async () => { + const user = await makeUser(); + const file = await createFile(user, `${user.home}/Documents/stale.txt`); + + // Pre-update row, captured before the patch — stands in for what a + // lagging replica would still be serving. + const staleRows = (await server.clients.db.read( + 'SELECT * FROM fsentries WHERE uuid = ?', + [file.uuid], + )) as Array>; + for (const row of staleRows) row.subdomains_agg = null; + + const db = server.clients.db; + const originalTryHardRead = (Object.getPrototypeOf(db) as typeof db) + .tryHardRead; + const tryHardReadSpy = vi + .spyOn(db, 'tryHardRead') + .mockImplementation(async (query: string, params: unknown[] = []) => { + if ( + query.includes('WHERE uuid = ? LIMIT 1') && + params[0] === file.uuid + ) { + return staleRows; + } + return originalTryHardRead.call(db, query, params); + }); + try { + + const newPath = `${user.home}/Documents/renamed.txt`; + const updated = await store.updateEntry(file.uuid, { + name: 'renamed.txt', + path: newPath, + }); + + expect(updated.name).toBe('renamed.txt'); + expect(updated.path).toBe(newPath); + await expect(store.getEntryByUuid(file.uuid)).resolves.toMatchObject({ + path: newPath, + }); + expect(tryHardReadSpy).not.toHaveBeenCalled(); + + } finally { + tryHardReadSpy.mockRestore(); + } + }); + + it('updateEntryThumbnailByUuidForUser returns the primary thumbnail when the replica lags', async () => { + const owner = await makeUser(); + const file = await createFile( + owner, + `${owner.home}/Documents/stale-thumb.txt`, + ); + + const staleRows = (await server.clients.db.read( + 'SELECT * FROM fsentries WHERE uuid = ?', + [file.uuid], + )) as Array>; + for (const row of staleRows) row.subdomains_agg = null; + + const db = server.clients.db; + const originalTryHardRead = (Object.getPrototypeOf(db) as typeof db) + .tryHardRead; + const tryHardReadSpy = vi + .spyOn(db, 'tryHardRead') + .mockImplementation(async (query: string, params: unknown[] = []) => { + if ( + query.includes('AND user_id = ?') && + params[0] === file.uuid + ) { + return staleRows; + } + return originalTryHardRead.call(db, query, params); + }); + try { + + const updated = await store.updateEntryThumbnailByUuidForUser( + owner.userId, + file.uuid, + 'data:image/png;base64,NEW', + ); + + expect(updated.thumbnail).toBe('data:image/png;base64,NEW'); + expect(tryHardReadSpy).not.toHaveBeenCalled(); + + } finally { + tryHardReadSpy.mockRestore(); + } + }); }); describe('FSEntryStore listing and pagination', () => { diff --git a/src/backend/stores/fs/FSEntryStore.ts b/src/backend/stores/fs/FSEntryStore.ts index 15196881a..563e665e6 100644 --- a/src/backend/stores/fs/FSEntryStore.ts +++ b/src/backend/stores/fs/FSEntryStore.ts @@ -1384,7 +1384,7 @@ export class FSEntryStore extends PuterStore { } } - const refreshedRows = (await this.clients.db.tryHardRead( + const refreshedRows = (await this.clients.db.pread( `SELECT ${this.#selectFsentriesColumns()} FROM fsentries WHERE uuid = ? AND user_id = ? LIMIT 1`, [uuid, userId], )) as unknown as FSEntryRow[]; @@ -1950,7 +1950,7 @@ export class FSEntryStore extends PuterStore { .join(', '); // By uuid alone — the rows just written belong to the // parent's owner, not necessarily the acting user. - const rows = (await this.clients.db.tryHardRead( + const rows = (await this.clients.db.pread( `SELECT ${this.#selectFsentriesColumns()} FROM fsentries WHERE uuid IN (${placeholders})`, insertUuidChunk, )) as unknown as FSEntryRow[]; @@ -2359,7 +2359,7 @@ export class FSEntryStore extends PuterStore { ], ); - const rows = (await this.clients.db.tryHardRead( + const rows = (await this.clients.db.pread( `SELECT ${this.#selectFsentriesColumns()} FROM fsentries WHERE uuid = ? LIMIT 1`, [uuid], )) as unknown as FSEntryRow[]; @@ -2413,7 +2413,7 @@ export class FSEntryStore extends PuterStore { // Re-read the row itself rather than going through `getEntryByUuid`: // that read is cache-first and would hand back the pre-touch // timestamps (and then re-cache them for another TTL). - const refreshedRows = (await this.clients.db.tryHardRead( + const refreshedRows = (await this.clients.db.pread( `SELECT ${this.#selectFsentriesColumns()} FROM fsentries WHERE uuid = ? LIMIT 1`, [uuid], )) as unknown as FSEntryRow[]; @@ -2859,7 +2859,9 @@ export class FSEntryStore extends PuterStore { [...values, uuid], ); - const refreshedRows = (await this.clients.db.tryHardRead( + // Read back from the primary: a lagging replica still holds the pre-update + // row, so tryHardRead would return it (and cache it). + const refreshedRows = (await this.clients.db.pread( `SELECT ${this.#selectFsentriesColumns()} FROM fsentries WHERE uuid = ? LIMIT 1`, [uuid], )) as unknown as FSEntryRow[]; @@ -2942,7 +2944,7 @@ export class FSEntryStore extends PuterStore { // rely on TTL (60s) to refresh — username rename is rare enough // that a broad subtree invalidation isn't worth the round-trips. await this.#invalidateEntryCache(root); - const refreshedRows = (await this.clients.db.tryHardRead( + const refreshedRows = (await this.clients.db.pread( `SELECT ${this.#selectFsentriesColumns()} FROM fsentries WHERE id = ? LIMIT 1`, [root.id], )) as unknown as FSEntryRow[]; diff --git a/src/docs/src/Events.md b/src/docs/src/Events.md index 74be07adb..c4bc74565 100644 --- a/src/docs/src/Events.md +++ b/src/docs/src/Events.md @@ -137,6 +137,8 @@ A prefix names a region, so it is taken as written: `*` and `?` are refused (`in An app can mint on its user's behalf, but only inside its own namespace and only where the user has granted it. The consent is `manage:kv-share:::` (the prefix contributing its segments, so `workspace:abc:` ends the string as `…:workspace:abc`), requested with [`puter.perms.request()`](/Perms/request/). The consent has to name a region: a request over the whole namespace is refused with `invalid_kv_share_prefix`. Minting outside the region it was given, or outside the app's own namespace, is refused with `events_kv_handle_not_delegated` and `events_kv_handle_outside_namespace` respectively. An app that mints a handle still cannot list or revoke it — `GET`/`DELETE /events/kv-handles` only ever answer an account session, and an app calling either is refused with `events_kv_handle_owner_only`. +An app may also use a handle on its user's behalf. A subscription made while running as an app works when the shared region belongs to that same app — the one named in the grant the handle stands for. Running as a different app, even for the same user, is refused the same way a handle nobody minted would be: reading the handle takes its own consent, and a grant given to one app never carries over to another. + A key under a handle is relative to the region it was granted on, so anything that reads as an attempt to leave it — a bare handle naming no key, or a key trying to walk out with `..` — is refused with `invalid_kv_handle_key` rather than composed into a path outside the grant. ### Watching something that does not exist yet