@proteinjs/user-server 1.18.0 → 1.19.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.
- package/CHANGELOG.md +11 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +3 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/src/authorization/UserActivityStamp.d.ts +17 -15
- package/dist/src/authorization/UserActivityStamp.d.ts.map +1 -1
- package/dist/src/authorization/UserActivityStamp.js +21 -19
- package/dist/src/authorization/UserActivityStamp.js.map +1 -1
- package/dist/src/authorization/userCache.d.ts +9 -0
- package/dist/src/authorization/userCache.d.ts.map +1 -1
- package/dist/src/authorization/userCache.js +9 -9
- package/dist/src/authorization/userCache.js.map +1 -1
- package/dist/src/services/UserPresence.d.ts +16 -0
- package/dist/src/services/UserPresence.d.ts.map +1 -0
- package/dist/src/services/UserPresence.js +75 -0
- package/dist/src/services/UserPresence.js.map +1 -0
- package/dist/test/UserActivityStamp.integration.test.js +57 -90
- package/dist/test/UserActivityStamp.integration.test.js.map +1 -1
- package/dist/test/UserCacheDoesNotStampPresence.integration.test.d.ts +2 -0
- package/dist/test/UserCacheDoesNotStampPresence.integration.test.d.ts.map +1 -0
- package/dist/test/UserCacheDoesNotStampPresence.integration.test.js +117 -0
- package/dist/test/UserCacheDoesNotStampPresence.integration.test.js.map +1 -0
- package/generated/index.ts +3 -1
- package/package.json +3 -3
- package/src/authorization/UserActivityStamp.ts +22 -20
- package/src/authorization/userCache.ts +9 -9
- package/src/services/UserPresence.ts +26 -0
- package/test/UserActivityStamp.integration.test.ts +38 -50
- package/test/UserCacheDoesNotStampPresence.integration.test.ts +44 -0
|
@@ -1,39 +1,41 @@
|
|
|
1
1
|
import moment from 'moment';
|
|
2
2
|
import { getDbAsSystem } from '@proteinjs/db';
|
|
3
3
|
import { Logger } from '@proteinjs/logger';
|
|
4
|
-
import { tables, type User, type UserActivity } from '@proteinjs/user';
|
|
4
|
+
import { guestUser, tables, type User, type UserActivity } from '@proteinjs/user';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Writes the LAST-ACTIVITY presence stamp (`user_activity`, one row per user — see
|
|
8
|
-
* UserActivityTable's contract): "this
|
|
8
|
+
* UserActivityTable's contract): "this person gave a page input just now".
|
|
9
9
|
*
|
|
10
|
-
* The
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* The INPUT keying lives at the call site — the UserPresence service is the only caller, and
|
|
11
|
+
* the page reports through it only from a real pointer / key / touch / wheel event. The
|
|
12
|
+
* per-request session build (userCache.create) does NOT call this: it runs for every request
|
|
13
|
+
* over a session cookie, and that transport is what an idle tab produces all day (polls, socket
|
|
14
|
+
* re-joins on reconnect, the reload a deploy pushes) — presence keyed on it read every user as
|
|
15
|
+
* "active today" (founder finding 2026-09-12). What this class owns is the ACCOUNT predicate:
|
|
16
|
+
* the guest identity and machine accounts (`machine` — the one owner of "is this a machine",
|
|
17
|
+
* founder ruling 2026-09-02) are refused, whatever door they arrive through.
|
|
16
18
|
*
|
|
17
19
|
* Write behavior mirrors DbSessionStore's touch: throttled per user (a presence fact consumed at
|
|
18
|
-
* day grain needs no finer cadence, and
|
|
19
|
-
* fail-open (a lost stamp is a few minutes of staleness, never a failed
|
|
20
|
-
* promise NEVER rejects), and race-tolerant (concurrent first stamps contend on the
|
|
21
|
-
* index; the loser's error is swallowed as debug).
|
|
20
|
+
* day grain needs no finer cadence, and the page already throttles its reports — this is the
|
|
21
|
+
* belt), fail-open (a lost stamp is a few minutes of staleness, never a failed call — the
|
|
22
|
+
* returned promise NEVER rejects), and race-tolerant (concurrent first stamps contend on the
|
|
23
|
+
* scope-unique index; the loser's error is swallowed as debug).
|
|
22
24
|
*/
|
|
23
25
|
export class UserActivityStamp {
|
|
24
26
|
/** Stamp at most this often per user — same cadence class as DbSessionStore.TOUCH_INTERVAL. */
|
|
25
27
|
private static readonly STAMP_INTERVAL_MS = 1000 * 60 * 5;
|
|
26
|
-
/** Process-wide:
|
|
28
|
+
/** Process-wide: the throttle outlives any one service instance. */
|
|
27
29
|
private static lastStampMs = new Map<string, number>();
|
|
28
30
|
|
|
29
31
|
private logger = new Logger({ name: this.constructor.name });
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
|
-
* Record that `user`
|
|
33
|
-
*
|
|
34
|
+
* Record that `user` gave a page human input. Fire-and-forget safe: errors are handled (and
|
|
35
|
+
* logged) here, so callers may `void` the returned promise.
|
|
34
36
|
*/
|
|
35
|
-
|
|
36
|
-
if (!user.id || user.
|
|
37
|
+
recordHumanInput(user: Pick<User, 'id' | 'machine'>): Promise<void> {
|
|
38
|
+
if (!user.id || user.id === guestUser.id || user.machine === true) {
|
|
37
39
|
return Promise.resolve();
|
|
38
40
|
}
|
|
39
41
|
const last = UserActivityStamp.lastStampMs.get(user.id) ?? 0;
|
|
@@ -46,9 +48,9 @@ export class UserActivityStamp {
|
|
|
46
48
|
UserActivityStamp.lastStampMs.clear(); // bounded memory; worst case is one extra stamp per user
|
|
47
49
|
}
|
|
48
50
|
return this.upsert(user.id).catch((error) => {
|
|
49
|
-
// Contention (a concurrent
|
|
50
|
-
// failures land here alike; both are harmless to the
|
|
51
|
-
//
|
|
51
|
+
// Contention (a concurrent report stamped first, racing the scope-unique index) and real
|
|
52
|
+
// failures land here alike; both are harmless to the caller. Un-throttle so the next
|
|
53
|
+
// report retries instead of waiting out a full interval on a stamp that never landed.
|
|
52
54
|
UserActivityStamp.lastStampMs.delete(user.id);
|
|
53
55
|
this.logger.error({ message: 'Failed to write user activity stamp', error });
|
|
54
56
|
});
|
|
@@ -4,11 +4,18 @@ import { getDbAsSystem } from '@proteinjs/db';
|
|
|
4
4
|
import { Logger } from '@proteinjs/logger';
|
|
5
5
|
import { User, tables, guestUser, USER_SESSION_CACHE_KEY } from '@proteinjs/user';
|
|
6
6
|
import { DefaultAdminCredentials } from '../authentication/DefaultAdminCredentials';
|
|
7
|
-
import { UserActivityStamp } from './UserActivityStamp';
|
|
8
7
|
|
|
9
8
|
const logger = new Logger({ name: 'userCache' });
|
|
10
|
-
const userActivityStamp = new UserActivityStamp();
|
|
11
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The per-request session-cache build: resolves the session's email to the account row every
|
|
12
|
+
* request that rides a session cookie (wrapRoute), and every socket event (util-server's
|
|
13
|
+
* SocketSessionContext). It resolves IDENTITY only — it does not write the presence stamp
|
|
14
|
+
* (`user_activity`, UserActivityTable's contract): this build runs for an idle tab's polls, a
|
|
15
|
+
* socket's room re-joins on every reconnect, the reload a deploy pushes onto every open tab —
|
|
16
|
+
* transport, not a person. Presence is written from the page's human-input report alone
|
|
17
|
+
* (UserPresence.recordPresence).
|
|
18
|
+
*/
|
|
12
19
|
export const userCache: SessionDataCache<User> = {
|
|
13
20
|
key: USER_SESSION_CACHE_KEY,
|
|
14
21
|
create: async (sessionId: string, userEmail: string): Promise<User> => {
|
|
@@ -40,13 +47,6 @@ export const userCache: SessionDataCache<User> = {
|
|
|
40
47
|
} else if (accountUser) {
|
|
41
48
|
delete (accountUser as any)['password'];
|
|
42
49
|
user = accountUser;
|
|
43
|
-
// LAST ACTIVITY (human presence — UserActivityTable's contract): stamped HERE because
|
|
44
|
-
// this cache build runs exactly once per session-cookie request (wrapRoute), i.e. only
|
|
45
|
-
// for interactive transport. Background/seeded contexts (runInUserScope) set session
|
|
46
|
-
// data directly and never pass through, so machinery acting as the user structurally
|
|
47
|
-
// cannot stamp; the stamp itself refuses machine accounts. Fire-and-forget: the
|
|
48
|
-
// returned promise never rejects, and a request never waits on its own stamp.
|
|
49
|
-
void userActivityStamp.recordInteractiveRequest(user);
|
|
50
50
|
} else {
|
|
51
51
|
// A session can outlive its account (row deleted, or a dev auto-login for a never-created
|
|
52
52
|
// email). Resolve it to the unauthenticated guest session — the client sees no
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Service } from '@proteinjs/service';
|
|
2
|
+
import { UserPresenceService, UserRepo, type User } from '@proteinjs/user';
|
|
3
|
+
import { UserActivityStamp } from '../authorization/UserActivityStamp';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The server end of the human-input presence door (UserActivityTable's contract): the page
|
|
7
|
+
* reports "a person interacted just now" and this writes the `user_activity` stamp for the
|
|
8
|
+
* CALLING user — the one write path onto presence. Any signed-in user may report their own
|
|
9
|
+
* presence and nobody else's (the user is the session's, never an argument); the guest identity
|
|
10
|
+
* and machine accounts are refused inside the stamp. `doNotAwait`: the page never waits on its
|
|
11
|
+
* own stamp, and a lost stamp is minutes of staleness at day grain, never a failed call.
|
|
12
|
+
*/
|
|
13
|
+
export class UserPresence implements UserPresenceService {
|
|
14
|
+
public serviceMetadata: Service['serviceMetadata'] = {
|
|
15
|
+
auth: {
|
|
16
|
+
allUsers: true,
|
|
17
|
+
},
|
|
18
|
+
doNotAwait: true,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
private stamp = new UserActivityStamp();
|
|
22
|
+
|
|
23
|
+
async recordPresence(): Promise<void> {
|
|
24
|
+
await this.stamp.recordHumanInput(new UserRepo().getUser() as Pick<User, 'id' | 'machine'>);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import moment from 'moment';
|
|
2
2
|
import { getDbAsSystem } from '@proteinjs/db';
|
|
3
|
-
import { tables, type User, type UserActivity } from '@proteinjs/user';
|
|
4
|
-
import { userCache } from '../src/authorization/userCache';
|
|
3
|
+
import { guestUser, tables, type User, type UserActivity } from '@proteinjs/user';
|
|
5
4
|
import { UserActivityStamp } from '../src/authorization/UserActivityStamp';
|
|
5
|
+
import { UserPresence } from '../src/services/UserPresence';
|
|
6
6
|
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
7
7
|
|
|
8
8
|
const testEnv = new UserServerTestEnvironment();
|
|
@@ -16,26 +16,15 @@ const stampInternals = (stamp: UserActivityStamp) => (stamp as unknown as StampI
|
|
|
16
16
|
const activityRows = async (scope: string): Promise<UserActivity[]> =>
|
|
17
17
|
await getDbAsSystem().query(tables.UserActivity, { scope });
|
|
18
18
|
|
|
19
|
-
/** The stamp write is fire-and-forget off userCache — poll the OUTCOME (row present) briefly. */
|
|
20
|
-
const waitForActivityRow = async (scope: string): Promise<UserActivity> => {
|
|
21
|
-
for (let attempt = 0; attempt < 50; attempt++) {
|
|
22
|
-
const rows = await activityRows(scope);
|
|
23
|
-
if (rows.length > 0) {
|
|
24
|
-
return rows[0];
|
|
25
|
-
}
|
|
26
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
27
|
-
}
|
|
28
|
-
throw new Error(`No user_activity row appeared for scope ${scope}`);
|
|
29
|
-
};
|
|
30
|
-
|
|
31
19
|
/**
|
|
32
|
-
* LAST ACTIVITY = HUMAN PRESENCE (UserActivityTable's contract): the stamp
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* keep ONE row per user; the
|
|
20
|
+
* LAST ACTIVITY = HUMAN PRESENCE (UserActivityTable's contract): the stamp is written from the
|
|
21
|
+
* page's human-input report — the UserPresence service door — and from nowhere else (the
|
|
22
|
+
* session build's non-stamp is pinned in UserCacheDoesNotStampPresence). These tests pin the
|
|
23
|
+
* door's outcomes: a person's report lands the presence row for the CALLING user; machine
|
|
24
|
+
* accounts and the guest identity never do; repeated reports keep ONE row per user; the
|
|
25
|
+
* throttle holds writes to the interval.
|
|
37
26
|
*/
|
|
38
|
-
describe('UserActivityStamp —
|
|
27
|
+
describe('UserActivityStamp — human-input presence', () => {
|
|
39
28
|
beforeAll(async () => {
|
|
40
29
|
await testEnv.beforeAll();
|
|
41
30
|
});
|
|
@@ -49,34 +38,36 @@ describe('UserActivityStamp — interactive presence', () => {
|
|
|
49
38
|
stampInternals(new UserActivityStamp()).lastStampMs.clear();
|
|
50
39
|
});
|
|
51
40
|
|
|
52
|
-
it('
|
|
41
|
+
it('the presence door stamps the calling user: a page report under her session lands her row', async () => {
|
|
53
42
|
const user = await testEnv.createUser({ name: 'Present Human', email: 'present-human@test.local' });
|
|
43
|
+
testEnv.actAs(user);
|
|
54
44
|
|
|
55
|
-
|
|
56
|
-
expect(resolved.id).toBe(user.id);
|
|
45
|
+
await new UserPresence().recordPresence();
|
|
57
46
|
|
|
58
|
-
const
|
|
59
|
-
expect(
|
|
47
|
+
const rows = await activityRows(user.id);
|
|
48
|
+
expect(rows).toHaveLength(1);
|
|
49
|
+
expect(moment(rows[0].lastActiveAt).isAfter(moment().subtract(1, 'minute'))).toBe(true);
|
|
60
50
|
});
|
|
61
51
|
|
|
62
|
-
it('keeps ONE row per user and advances it on later
|
|
52
|
+
it('keeps ONE row per user and advances it on later reports (scope-unique invariant)', async () => {
|
|
63
53
|
const user = await testEnv.createUser({ name: 'Returning Human', email: 'returning-human@test.local' });
|
|
64
54
|
const stamp = new UserActivityStamp();
|
|
65
55
|
|
|
66
|
-
await stamp.
|
|
67
|
-
const first = await
|
|
56
|
+
await stamp.recordHumanInput(user);
|
|
57
|
+
const [first] = await activityRows(user.id);
|
|
58
|
+
expect(first).toBeDefined();
|
|
68
59
|
|
|
69
|
-
// Clear the throttle so the second
|
|
60
|
+
// Clear the throttle so the second report stamps immediately.
|
|
70
61
|
stampInternals(stamp).lastStampMs.clear();
|
|
71
62
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
72
|
-
await stamp.
|
|
63
|
+
await stamp.recordHumanInput(user);
|
|
73
64
|
|
|
74
65
|
const rows = await activityRows(user.id);
|
|
75
66
|
expect(rows).toHaveLength(1);
|
|
76
67
|
expect(moment(rows[0].lastActiveAt).valueOf()).toBeGreaterThanOrEqual(moment(first.lastActiveAt).valueOf());
|
|
77
68
|
});
|
|
78
69
|
|
|
79
|
-
it('never stamps a machine account
|
|
70
|
+
it('never stamps a machine account (`machine` — the one owner of "is this a machine"), even through the door', async () => {
|
|
80
71
|
const machine = await getDbAsSystem().insert(tables.User, {
|
|
81
72
|
name: 'Ops machine',
|
|
82
73
|
email: 'stamp-machine@test.local',
|
|
@@ -84,39 +75,36 @@ describe('UserActivityStamp — interactive presence', () => {
|
|
|
84
75
|
emailVerified: true,
|
|
85
76
|
roles: [],
|
|
86
77
|
isLoadedFromSource: true,
|
|
78
|
+
machine: true,
|
|
87
79
|
} as unknown as User);
|
|
88
80
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
await new
|
|
92
|
-
const resolved = await userCache.create('machine-session', machine.email);
|
|
93
|
-
expect(resolved.id).toBe(machine.id);
|
|
94
|
-
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
81
|
+
await new UserActivityStamp().recordHumanInput(machine);
|
|
82
|
+
testEnv.actAs(machine);
|
|
83
|
+
await new UserPresence().recordPresence();
|
|
95
84
|
|
|
96
85
|
expect(await activityRows(machine.id)).toHaveLength(0);
|
|
97
86
|
});
|
|
98
87
|
|
|
99
|
-
it('
|
|
88
|
+
it('never stamps the guest identity (a door reached with no signed-in user)', async () => {
|
|
89
|
+
const before = (await getDbAsSystem().query(tables.UserActivity, {})).length;
|
|
90
|
+
await new UserActivityStamp().recordHumanInput(guestUser);
|
|
91
|
+
expect((await getDbAsSystem().query(tables.UserActivity, {})).length).toBe(before);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('throttles: a second report inside the interval writes nothing', async () => {
|
|
100
95
|
const user = await testEnv.createUser({ name: 'Rapid Human', email: 'rapid-human@test.local' });
|
|
101
96
|
const stamp = new UserActivityStamp();
|
|
102
97
|
|
|
103
|
-
await stamp.
|
|
104
|
-
const first = await
|
|
98
|
+
await stamp.recordHumanInput(user);
|
|
99
|
+
const [first] = await activityRows(user.id);
|
|
100
|
+
expect(first).toBeDefined();
|
|
105
101
|
|
|
106
|
-
// Throttle history now holds this user; a second
|
|
107
|
-
await stamp.
|
|
102
|
+
// Throttle history now holds this user; a second report inside the interval must not write.
|
|
103
|
+
await stamp.recordHumanInput(user);
|
|
108
104
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
109
105
|
|
|
110
106
|
const rows = await activityRows(user.id);
|
|
111
107
|
expect(rows).toHaveLength(1);
|
|
112
108
|
expect(moment(rows[0].lastActiveAt).valueOf()).toBe(moment(first.lastActiveAt).valueOf());
|
|
113
109
|
});
|
|
114
|
-
|
|
115
|
-
it('a session for a missing account still resolves guest and stamps nothing (no throw)', async () => {
|
|
116
|
-
const before = (await getDbAsSystem().query(tables.UserActivity, {})).length;
|
|
117
|
-
const resolved = await userCache.create('stale-session', 'no-such-account@test.local');
|
|
118
|
-
expect(resolved.id).toBe('guest');
|
|
119
|
-
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
120
|
-
expect((await getDbAsSystem().query(tables.UserActivity, {})).length).toBe(before);
|
|
121
|
-
});
|
|
122
110
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { getDbAsSystem } from '@proteinjs/db';
|
|
2
|
+
import { tables, type UserActivity } from '@proteinjs/user';
|
|
3
|
+
import { userCache } from '../src/authorization/userCache';
|
|
4
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
5
|
+
|
|
6
|
+
const testEnv = new UserServerTestEnvironment();
|
|
7
|
+
|
|
8
|
+
const activityRows = async (scope: string): Promise<UserActivity[]> =>
|
|
9
|
+
await getDbAsSystem().query(tables.UserActivity, { scope });
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* TRANSPORT IS NOT PRESENCE (UserActivityTable's contract; founder finding 2026-09-12 — every
|
|
13
|
+
* user on the admin usage page "last active today"). The per-request session-cache build
|
|
14
|
+
* (`userCache.create`) runs for EVERY request that rides a session cookie: an open tab's polls,
|
|
15
|
+
* a socket's room re-joins on every reconnect, the reload a deploy pushes onto every idle tab.
|
|
16
|
+
* None of that is a person. The build must therefore never write the presence stamp — the
|
|
17
|
+
* only door is the page's human-input report (UserPresence.recordPresence).
|
|
18
|
+
*
|
|
19
|
+
* Pre-fix, `userCache.create` stamped on every build: this test reads one row where the
|
|
20
|
+
* contract says none.
|
|
21
|
+
*/
|
|
22
|
+
describe('userCache.create — the session build does not stamp presence', () => {
|
|
23
|
+
beforeAll(async () => {
|
|
24
|
+
await testEnv.beforeAll();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterAll(async () => {
|
|
28
|
+
await testEnv.afterAll();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('resolving a human session (any request over a session cookie) writes NO user_activity row', async () => {
|
|
32
|
+
const user = await testEnv.createUser({ name: 'Idle Tab', email: 'idle-tab@test.local' });
|
|
33
|
+
|
|
34
|
+
// Three request-shaped builds: a poll, a reconnect re-join, a deploy reload — all the same seam.
|
|
35
|
+
for (const sessionId of ['poll', 'socket-rejoin', 'deploy-reload']) {
|
|
36
|
+
const resolved = await userCache.create(sessionId, user.email);
|
|
37
|
+
expect(resolved.id).toBe(user.id);
|
|
38
|
+
}
|
|
39
|
+
// The pre-fix stamp was fire-and-forget off the build — give it every chance to land.
|
|
40
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
41
|
+
|
|
42
|
+
expect(await activityRows(user.id)).toHaveLength(0);
|
|
43
|
+
});
|
|
44
|
+
});
|