@proteinjs/user-server 1.16.1 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/generated/index.js +1 -1
  3. package/dist/generated/index.js.map +1 -1
  4. package/dist/src/services/Roles.d.ts +4 -0
  5. package/dist/src/services/Roles.d.ts.map +1 -1
  6. package/dist/src/services/Roles.js +13 -2
  7. package/dist/src/services/Roles.js.map +1 -1
  8. package/dist/src/services/UpdateUserInfo.d.ts +24 -1
  9. package/dist/src/services/UpdateUserInfo.d.ts.map +1 -1
  10. package/dist/src/services/UpdateUserInfo.js +79 -18
  11. package/dist/src/services/UpdateUserInfo.js.map +1 -1
  12. package/dist/test/MachineAccounts.integration.test.js.map +1 -1
  13. package/dist/test/Roles.integration.test.js +76 -0
  14. package/dist/test/Roles.integration.test.js.map +1 -1
  15. package/dist/test/UpdateUserInfoClearAvatarByAdmin.test.d.ts +2 -0
  16. package/dist/test/UpdateUserInfoClearAvatarByAdmin.test.d.ts.map +1 -0
  17. package/dist/test/UpdateUserInfoClearAvatarByAdmin.test.js +224 -0
  18. package/dist/test/UpdateUserInfoClearAvatarByAdmin.test.js.map +1 -0
  19. package/dist/test/UpdateUserInfoRefresh.test.d.ts +2 -0
  20. package/dist/test/UpdateUserInfoRefresh.test.d.ts.map +1 -0
  21. package/dist/test/UpdateUserInfoRefresh.test.js +99 -0
  22. package/dist/test/UpdateUserInfoRefresh.test.js.map +1 -0
  23. package/generated/index.ts +1 -1
  24. package/package.json +3 -3
  25. package/src/services/Roles.ts +13 -1
  26. package/src/services/UpdateUserInfo.ts +68 -13
  27. package/test/Roles.integration.test.ts +41 -0
  28. package/test/UpdateUserInfoClearAvatarByAdmin.test.ts +109 -0
  29. package/test/UpdateUserInfoRefresh.test.ts +40 -0
@@ -1,15 +1,18 @@
1
1
  import sharp from 'sharp';
2
2
  import heicDecode from 'heic-decode';
3
3
  import { getDbAsSystem } from '@proteinjs/db';
4
- import { FileStorage } from '@proteinjs/db-file';
4
+ import { File, FileStorage, tables as fileTables } from '@proteinjs/db-file';
5
5
  import {
6
6
  tables,
7
7
  User,
8
+ UserAuth,
8
9
  UserRepo,
10
+ USER_PERMISSIONS,
9
11
  UpdatePasswordResponse,
10
12
  UpdatedUser,
11
13
  UpdateUserInfoService,
12
14
  AvatarCrop,
15
+ getScopedDbAsSystem,
13
16
  } from '@proteinjs/user';
14
17
  import { EmailSender, getDefaultPasswordUpdatedEmailConfigFactory } from '@proteinjs/email-server';
15
18
  import { PasswordHasher } from '../authentication/PasswordHasher';
@@ -131,8 +134,22 @@ export class UpdateUserInfo implements UpdateUserInfoService {
131
134
  return await this.setAvatar({ avatarEmoji: trimmed, avatarFileId: null });
132
135
  }
133
136
 
134
- async clearAvatar(): Promise<UpdatedUser> {
135
- return await this.setAvatar({ avatarEmoji: null, avatarFileId: null });
137
+ async clearAvatar(userId?: string): Promise<UpdatedUser> {
138
+ return await this.setAvatar({ avatarEmoji: null, avatarFileId: null }, userId);
139
+ }
140
+
141
+ /**
142
+ * The read-only half of `saveUserInfo`: no write, just the stored row pulled back into the
143
+ * session cache. Every mutation through this service already refreshes the cache, so this is
144
+ * for changes made ELSEWHERE — another device, another tab, a user manager — which the cached
145
+ * user would otherwise keep hiding until the next sign-in.
146
+ */
147
+ async refresh(): Promise<UpdatedUser> {
148
+ const userRepo = new UserRepo();
149
+ const updated = await getDbAsSystem().get(tables.User, { id: userRepo.getUser().id });
150
+ delete (updated as Partial<User>).password; // the same password-less shape userCache caches
151
+ userRepo.setUser(updated);
152
+ return updated;
136
153
  }
137
154
 
138
155
  /**
@@ -140,13 +157,25 @@ export class UpdateUserInfo implements UpdateUserInfoService {
140
157
  * the exactly-one-active invariant (photo OR emoji, never both). Also the previous photo's
141
158
  * cleanup point: an avatar file the user row no longer points to is unreachable (the /avatar
142
159
  * route serves only the id on the user row), so it is deleted here, after the row moves off it.
160
+ *
161
+ * `userId` names ANOTHER person (see {@link resolveTarget} for the permission that admits it);
162
+ * everything below then applies to that person's row and their previous photo.
143
163
  */
144
- private async setAvatar(avatar: { avatarEmoji: string | null; avatarFileId: string | null }): Promise<UpdatedUser> {
145
- const userId = new UserRepo().getUser().id;
146
- const previousAvatarFileId = (await getDbAsSystem().get(tables.User, { id: userId })).avatarFileId;
147
- const updated = await this.saveUserInfo(avatar);
164
+ private async setAvatar(
165
+ avatar: { avatarEmoji: string | null; avatarFileId: string | null },
166
+ userId?: string
167
+ ): Promise<UpdatedUser> {
168
+ const { targetUserId } = this.resolveTarget(userId);
169
+ const previousAvatarFileId = (await getDbAsSystem().get(tables.User, { id: targetUserId })).avatarFileId;
170
+ const updated = await this.saveUserInfo(avatar, userId);
148
171
  if (previousAvatarFileId && previousAvatarFileId !== avatar.avatarFileId) {
149
- await new FileStorage().deleteFile(previousAvatarFileId);
172
+ // Deleted as SYSTEM, like the user-row write it accompanies: the file lives in the TARGET's
173
+ // scope, and a caller-scoped delete (`FileStorage.deleteFile`) would silently match nothing
174
+ // when a user manager clears someone else's photo, orphaning the bytes. This door has
175
+ // already made its own access decision, the same shape as the /avatar route's system read.
176
+ // The bytes still die with the row — `FileStorageTableWatcher` fires for every file-row
177
+ // delete path, system sweeps included.
178
+ await getScopedDbAsSystem<File>().delete(fileTables.File, { id: previousAvatarFileId });
150
179
  }
151
180
 
152
181
  return updated;
@@ -159,18 +188,44 @@ export class UpdateUserInfo implements UpdateUserInfoService {
159
188
  * long-lived contexts that keep their session data (sockets) — serving the STALE user until
160
189
  * re-login (the rename wart). Fusing persist + refresh here means no future mutation can
161
190
  * reintroduce that class of staleness.
191
+ *
192
+ * The refresh is the CALLER's own cache, so it happens only when the caller is the target. A
193
+ * user manager changing someone else writes the row and stops there: refreshing would hand the
194
+ * admin the target's row as their own identity, and there is no way to reach the target's
195
+ * session from here anyway — their next sign-in reads the row.
162
196
  */
163
- private async saveUserInfo(changes: Partial<User>): Promise<UpdatedUser> {
197
+ private async saveUserInfo(changes: Partial<User>, userId?: string): Promise<UpdatedUser> {
164
198
  const userRepo = new UserRepo();
165
- const userId = userRepo.getUser().id;
199
+ const { targetUserId, targetIsCaller } = this.resolveTarget(userId);
166
200
  const db = getDbAsSystem();
167
- await db.update(tables.User, changes, { id: userId });
168
- const updated = await db.get(tables.User, { id: userId });
201
+ await db.update(tables.User, changes, { id: targetUserId });
202
+ const updated = await db.get(tables.User, { id: targetUserId });
169
203
  delete (updated as Partial<User>).password; // the same password-less shape userCache caches
170
- userRepo.setUser(updated);
204
+ if (targetIsCaller) {
205
+ userRepo.setUser(updated);
206
+ }
207
+
171
208
  return updated;
172
209
  }
173
210
 
211
+ /**
212
+ * The user a mutation targets, and whether that is the caller.
213
+ *
214
+ * The service door is `allUsers` — it has to be, every signed-in user manages their own
215
+ * profile through it — so naming ANOTHER person's id is authorized here, in-body and
216
+ * fail-closed: only a `users` permission holder may do it.
217
+ */
218
+ private resolveTarget(userId?: string): { targetUserId: string; targetIsCaller: boolean } {
219
+ const callerId = new UserRepo().getUser().id;
220
+ const targetUserId = userId ?? callerId;
221
+ const targetIsCaller = targetUserId === callerId;
222
+ if (!targetIsCaller && !UserAuth.hasPermission(USER_PERMISSIONS.users)) {
223
+ throw new Error(`Only a user manager can change another person's avatar.`);
224
+ }
225
+
226
+ return { targetUserId, targetIsCaller };
227
+ }
228
+
174
229
  /**
175
230
  * THE avatar image pipeline — the only place avatar pixels are ever resampled: decode,
176
231
  * auto-orient from EXIF, extract the crop frame (client-framed, or the centered square),
@@ -214,6 +214,47 @@ describe('Roles service — grant/revoke outcomes and audit trail', () => {
214
214
  expect(events[0]).toMatchObject({ actor: 'actor-1', target: targetId, role: 'data-access', action: 'grant' });
215
215
  }, 60000);
216
216
 
217
+ /**
218
+ * Nobody edits their own roles. Self-service escalation is the whole reason the 'roles'
219
+ * permission is separated from 'users', and a self-REVOKE is the mirror hazard: the last
220
+ * holder locking themselves out of the door they were meant to keep. Both are refused before
221
+ * the no-change early return, so a self-target is always an error and never a silent no-op,
222
+ * and before the transaction, so nothing lands in the audit ledger.
223
+ */
224
+ describe('self-targeted role changes', () => {
225
+ const selfEvents = async () =>
226
+ (await auditRows()).filter((event) => event.actor === actor.id && event.target === actor.id);
227
+
228
+ it('refuses a self GRANT — the actor keeps their roles and nothing is audited', async () => {
229
+ await expect(new Roles().grantRole(actor.id, 'ops')).rejects.toThrow(
230
+ `You can't grant your own roles — ask another user manager.`
231
+ );
232
+
233
+ const actorRow = await getDbAsSystem().get(tables.User, { id: actor.id });
234
+ expect(actorRow.roles).toEqual(['admin']);
235
+ expect(await selfEvents()).toHaveLength(0);
236
+ }, 60000);
237
+
238
+ it('refuses a self REVOKE of a role the actor holds — the role stays, nothing is audited', async () => {
239
+ await getDbAsSystem().update(tables.User, { id: actor.id, roles: ['admin', 'ops'] });
240
+
241
+ await expect(new Roles().revokeRole(actor.id, 'ops')).rejects.toThrow(
242
+ `You can't revoke your own roles — ask another user manager.`
243
+ );
244
+
245
+ const actorRow = await getDbAsSystem().get(tables.User, { id: actor.id });
246
+ expect(actorRow.roles).toEqual(['admin', 'ops']);
247
+ expect(await selfEvents()).toHaveLength(0);
248
+ }, 60000);
249
+
250
+ it('refuses a self target the catalog would otherwise no-op — a self-target is never silent', async () => {
251
+ // The actor does NOT hold 'ops', so a revoke would have hit the no-change early return and
252
+ // returned quietly. The refusal comes first: the caller always learns the act was refused.
253
+ await expect(new Roles().revokeRole(actor.id, 'ops')).rejects.toThrow(/your own roles/);
254
+ expect(await selfEvents()).toHaveLength(0);
255
+ }, 60000);
256
+ });
257
+
217
258
  it('a roles-holder can still REVOKE an admin-grant-only role — de-escalation stays open (the break-glass precedent)', async () => {
218
259
  await new Roles().grantRole(targetId, 'data-access');
219
260
  new UserRepo().setUser({ ...actor, roles: ['roles'] });
@@ -0,0 +1,109 @@
1
+ import sharp from 'sharp';
2
+ import { getDbAsSystem } from '@proteinjs/db';
3
+ import { File, tables as fileTables } from '@proteinjs/db-file';
4
+ import { SourceRepository } from '@proteinjs/reflection';
5
+ import { tables, UpdateUserInfoService, User, UserRepo, getScopedDbAsSystem } from '@proteinjs/user';
6
+ import { UserServerTestEnvironment } from './UserServerTestEnvironment';
7
+ import { UpdateUserInfo } from '../src/services/UpdateUserInfo';
8
+
9
+ const testEnv = new UserServerTestEnvironment();
10
+
11
+ const getFileRow = async (fileId: string) => await getScopedDbAsSystem<File>().get(fileTables.File, { id: fileId });
12
+ const userRow = async (id: string) => await getDbAsSystem().get(tables.User, { id });
13
+
14
+ /**
15
+ * Clearing SOMEONE ELSE'S avatar — the user-manager act behind the admin user surface.
16
+ *
17
+ * The service door is `allUsers` (every signed-in user manages their own profile through it), so
18
+ * the cross-user authorization is in-body and fail-closed: naming another person's id requires
19
+ * the `users` permission. Two invariants beyond the row write:
20
+ * - the ACTOR's session cache is never overwritten with the target's row (the mutation path
21
+ * refreshes the caller's cached user, and doing that for another person's row would leave the
22
+ * admin browsing as their target);
23
+ * - the target's own session cache is NOT refreshed — the write is to their row, and their next
24
+ * sign-in reads it.
25
+ * Outcomes asserted against a real Spanner emulator: rows, files, cached identity.
26
+ */
27
+ describe('UpdateUserInfo — clearing another person avatar', () => {
28
+ const service: UpdateUserInfoService = new UpdateUserInfo();
29
+ let admin: User;
30
+ let kevin: User;
31
+
32
+ beforeAll(async () => {
33
+ await testEnv.beforeAll();
34
+ // The in-body permission check resolves the CALLER through UserAuth — the same session-backed
35
+ // repo the services use (the environment seeds session storage but not this registration).
36
+ (SourceRepository.get() as any).objectCache['@proteinjs/user-auth/AuthenticatedUserRepo'] = [new UserRepo()];
37
+ });
38
+
39
+ afterAll(async () => {
40
+ await testEnv.afterAll();
41
+ });
42
+
43
+ beforeEach(async () => {
44
+ admin = await testEnv.createUser({ name: 'Ada Admin', email: `admin-${Date.now()}@test.local`, roles: ['admin'] });
45
+ kevin = await testEnv.createUser({ name: 'Kevin', email: `kevin-${Date.now()}@test.local` });
46
+ testEnv.actAs(admin);
47
+ });
48
+
49
+ it("clears the target's avatar, returns the target's row, and leaves the actor's session alone", async () => {
50
+ await getDbAsSystem().update(tables.User, { id: kevin.id, avatarEmoji: '🦊' });
51
+
52
+ const updated = await service.clearAvatar(kevin.id);
53
+
54
+ // The TARGET's row moved.
55
+ const kevinRow = await userRow(kevin.id);
56
+ expect(kevinRow.avatarEmoji).toBeFalsy();
57
+ expect(kevinRow.avatarFileId).toBeFalsy();
58
+
59
+ // The returned user is the target's row, password-less.
60
+ expect(updated.id).toBe(kevin.id);
61
+ expect(updated.name).toBe('Kevin');
62
+ expect((updated as any).password).toBeUndefined();
63
+
64
+ // The actor is still signed in as themselves — the session cache was not handed the target.
65
+ expect(new UserRepo().getUser().id).toBe(admin.id);
66
+ expect(new UserRepo().getUser().name).toBe('Ada Admin');
67
+ });
68
+
69
+ it("deletes the target's stored avatar photo", async () => {
70
+ testEnv.actAs(kevin);
71
+ const png = await sharp({ create: { width: 64, height: 64, channels: 3, background: { r: 4, g: 5, b: 6 } } })
72
+ .png()
73
+ .toBuffer();
74
+ const withPhoto = await service.updateAvatarPhoto(png.toString('base64'), 'image/png');
75
+ expect(await getFileRow(withPhoto.avatarFileId!)).toBeTruthy();
76
+ testEnv.actAs(admin);
77
+
78
+ await service.clearAvatar(kevin.id);
79
+
80
+ expect(await getFileRow(withPhoto.avatarFileId!)).toBeFalsy();
81
+ expect((await userRow(kevin.id)).avatarFileId).toBeFalsy();
82
+ });
83
+
84
+ it("refuses an actor without the users permission, and the target's avatar is untouched", async () => {
85
+ const bystander = await testEnv.createUser({ name: 'Bystander', email: `bystander-${Date.now()}@test.local` });
86
+ await getDbAsSystem().update(tables.User, { id: kevin.id, avatarEmoji: '🦊' });
87
+ testEnv.actAs(bystander);
88
+
89
+ await expect(service.clearAvatar(kevin.id)).rejects.toThrow(
90
+ `Only a user manager can change another person's avatar.`
91
+ );
92
+
93
+ expect((await userRow(kevin.id)).avatarEmoji).toBe('🦊');
94
+ });
95
+
96
+ it('with no id it still clears the CALLER own avatar and refreshes their session (the existing contract)', async () => {
97
+ testEnv.actAs(kevin);
98
+ await service.updateAvatarEmoji('🦞');
99
+ expect(new UserRepo().getUser().avatarEmoji).toBe('🦞');
100
+
101
+ const updated = await service.clearAvatar();
102
+
103
+ expect(updated.id).toBe(kevin.id);
104
+ expect(updated.avatarEmoji).toBeFalsy();
105
+ expect((await userRow(kevin.id)).avatarEmoji).toBeFalsy();
106
+ // Self path: the session cache is refreshed in the same stroke.
107
+ expect(new UserRepo().getUser().avatarEmoji).toBeFalsy();
108
+ });
109
+ });
@@ -0,0 +1,40 @@
1
+ import { getDbAsSystem } from '@proteinjs/db';
2
+ import { tables, UserRepo } from '@proteinjs/user';
3
+ import { UserServerTestEnvironment } from './UserServerTestEnvironment';
4
+ import { UpdateUserInfo } from '../src/services/UpdateUserInfo';
5
+
6
+ const testEnv = new UserServerTestEnvironment();
7
+
8
+ /**
9
+ * `refresh()` is the read-only half of the persist-and-refresh helper: no write, just the stored
10
+ * row pulled back into the session cache. It exists for changes this session did not make — an
11
+ * avatar set on the phone, a rename from another tab, an admin clearing an avatar — which
12
+ * otherwise stay invisible here until the next sign-in, because the cached user is only ever
13
+ * rewritten by this session's OWN mutations.
14
+ */
15
+ describe('UpdateUserInfo.refresh — re-reads the stored row into the session cache', () => {
16
+ beforeAll(async () => {
17
+ await testEnv.beforeAll();
18
+ });
19
+
20
+ afterAll(async () => {
21
+ await testEnv.afterAll();
22
+ });
23
+
24
+ it('picks up a change made outside this session, and returns the row password-less', async () => {
25
+ const user = await testEnv.createUser({ name: 'Two Devices', email: 'refresh@test.local' });
26
+ testEnv.actAs(user);
27
+
28
+ // The other device's write: the row moves, this session's cached user does not.
29
+ await getDbAsSystem().update(tables.User, { avatarEmoji: '🦊' }, { id: user.id });
30
+ expect(new UserRepo().getUser().avatarEmoji).toBeFalsy();
31
+
32
+ const updated = await new UpdateUserInfo().refresh();
33
+
34
+ expect(updated.id).toBe(user.id);
35
+ expect(updated.avatarEmoji).toBe('🦊');
36
+ expect((updated as any).password).toBeUndefined();
37
+ // The point of the call: the cached user carries it now, with no re-login.
38
+ expect(new UserRepo().getUser().avatarEmoji).toBe('🦊');
39
+ });
40
+ });