@proteinjs/user-server 1.12.0 → 1.14.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.
@@ -0,0 +1,255 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __generator = (this && this.__generator) || function (thisArg, body) {
12
+ var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
13
+ return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
14
+ function verb(n) { return function (v) { return step([n, v]); }; }
15
+ function step(op) {
16
+ if (f) throw new TypeError("Generator is already executing.");
17
+ while (g && (g = 0, op[0] && (_ = 0)), _) try {
18
+ if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
19
+ if (y = 0, t) op = [op[0] & 2, t.value];
20
+ switch (op[0]) {
21
+ case 0: case 1: t = op; break;
22
+ case 4: _.label++; return { value: op[1], done: false };
23
+ case 5: _.label++; y = op[1]; op = [0]; continue;
24
+ case 7: op = _.ops.pop(); _.trys.pop(); continue;
25
+ default:
26
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
27
+ if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
28
+ if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
29
+ if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
30
+ if (t[2]) _.ops.pop();
31
+ _.trys.pop(); continue;
32
+ }
33
+ op = body.call(thisArg, _);
34
+ } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
35
+ if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
36
+ }
37
+ };
38
+ var __importDefault = (this && this.__importDefault) || function (mod) {
39
+ return (mod && mod.__esModule) ? mod : { "default": mod };
40
+ };
41
+ Object.defineProperty(exports, "__esModule", { value: true });
42
+ var moment_1 = __importDefault(require("moment"));
43
+ var db_1 = require("@proteinjs/db");
44
+ var user_1 = require("@proteinjs/user");
45
+ var userCache_1 = require("../src/authorization/userCache");
46
+ var UserActivityStamp_1 = require("../src/authorization/UserActivityStamp");
47
+ var UserServerTestEnvironment_1 = require("./UserServerTestEnvironment");
48
+ var testEnv = new UserServerTestEnvironment_1.UserServerTestEnvironment();
49
+ var stampInternals = function (stamp) { return stamp.constructor; };
50
+ var activityRows = function (scope) { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
51
+ switch (_a.label) {
52
+ case 0: return [4 /*yield*/, (0, db_1.getDbAsSystem)().query(user_1.tables.UserActivity, { scope: scope })];
53
+ case 1: return [2 /*return*/, _a.sent()];
54
+ }
55
+ }); }); };
56
+ /** The stamp write is fire-and-forget off userCache — poll the OUTCOME (row present) briefly. */
57
+ var waitForActivityRow = function (scope) { return __awaiter(void 0, void 0, void 0, function () {
58
+ var attempt, rows;
59
+ return __generator(this, function (_a) {
60
+ switch (_a.label) {
61
+ case 0:
62
+ attempt = 0;
63
+ _a.label = 1;
64
+ case 1:
65
+ if (!(attempt < 50)) return [3 /*break*/, 5];
66
+ return [4 /*yield*/, activityRows(scope)];
67
+ case 2:
68
+ rows = _a.sent();
69
+ if (rows.length > 0) {
70
+ return [2 /*return*/, rows[0]];
71
+ }
72
+ return [4 /*yield*/, new Promise(function (resolve) { return setTimeout(resolve, 100); })];
73
+ case 3:
74
+ _a.sent();
75
+ _a.label = 4;
76
+ case 4:
77
+ attempt++;
78
+ return [3 /*break*/, 1];
79
+ case 5: throw new Error("No user_activity row appeared for scope ".concat(scope));
80
+ }
81
+ });
82
+ }); };
83
+ /**
84
+ * LAST ACTIVITY = HUMAN PRESENCE (UserActivityTable's contract): the stamp fires from
85
+ * userCache.create — the once-per-interactive-request session-cache build — and from nowhere
86
+ * else. These tests pin the seam's outcomes: a human's request lands the presence row; machine
87
+ * accounts (real sessions, e.g. the error bridge's polling login) never do; repeated stamps
88
+ * keep ONE row per user; the throttle holds writes to the interval.
89
+ */
90
+ describe('UserActivityStamp — interactive presence', function () {
91
+ beforeAll(function () { return __awaiter(void 0, void 0, void 0, function () {
92
+ return __generator(this, function (_a) {
93
+ switch (_a.label) {
94
+ case 0: return [4 /*yield*/, testEnv.beforeAll()];
95
+ case 1:
96
+ _a.sent();
97
+ return [2 /*return*/];
98
+ }
99
+ });
100
+ }); });
101
+ afterAll(function () { return __awaiter(void 0, void 0, void 0, function () {
102
+ return __generator(this, function (_a) {
103
+ switch (_a.label) {
104
+ case 0: return [4 /*yield*/, testEnv.afterAll()];
105
+ case 1:
106
+ _a.sent();
107
+ return [2 /*return*/];
108
+ }
109
+ });
110
+ }); });
111
+ beforeEach(function () {
112
+ // Each test controls its own throttle history.
113
+ stampInternals(new UserActivityStamp_1.UserActivityStamp()).lastStampMs.clear();
114
+ });
115
+ it('stamps presence when a signed-in human makes an interactive request (through userCache.create)', function () { return __awaiter(void 0, void 0, void 0, function () {
116
+ var user, resolved, row;
117
+ return __generator(this, function (_a) {
118
+ switch (_a.label) {
119
+ case 0: return [4 /*yield*/, testEnv.createUser({ name: 'Present Human', email: 'present-human@test.local' })];
120
+ case 1:
121
+ user = _a.sent();
122
+ return [4 /*yield*/, userCache_1.userCache.create('interactive-session', user.email)];
123
+ case 2:
124
+ resolved = _a.sent();
125
+ expect(resolved.id).toBe(user.id);
126
+ return [4 /*yield*/, waitForActivityRow(user.id)];
127
+ case 3:
128
+ row = _a.sent();
129
+ expect((0, moment_1.default)(row.lastActiveAt).isAfter((0, moment_1.default)().subtract(1, 'minute'))).toBe(true);
130
+ return [2 /*return*/];
131
+ }
132
+ });
133
+ }); });
134
+ it('keeps ONE row per user and advances it on later stamps (scope-unique invariant)', function () { return __awaiter(void 0, void 0, void 0, function () {
135
+ var user, stamp, first, rows;
136
+ return __generator(this, function (_a) {
137
+ switch (_a.label) {
138
+ case 0: return [4 /*yield*/, testEnv.createUser({ name: 'Returning Human', email: 'returning-human@test.local' })];
139
+ case 1:
140
+ user = _a.sent();
141
+ stamp = new UserActivityStamp_1.UserActivityStamp();
142
+ return [4 /*yield*/, stamp.recordInteractiveRequest(user)];
143
+ case 2:
144
+ _a.sent();
145
+ return [4 /*yield*/, waitForActivityRow(user.id)];
146
+ case 3:
147
+ first = _a.sent();
148
+ // Clear the throttle so the second request stamps immediately.
149
+ stampInternals(stamp).lastStampMs.clear();
150
+ return [4 /*yield*/, new Promise(function (resolve) { return setTimeout(resolve, 5); })];
151
+ case 4:
152
+ _a.sent();
153
+ return [4 /*yield*/, stamp.recordInteractiveRequest(user)];
154
+ case 5:
155
+ _a.sent();
156
+ return [4 /*yield*/, activityRows(user.id)];
157
+ case 6:
158
+ rows = _a.sent();
159
+ expect(rows).toHaveLength(1);
160
+ expect((0, moment_1.default)(rows[0].lastActiveAt).valueOf()).toBeGreaterThanOrEqual((0, moment_1.default)(first.lastActiveAt).valueOf());
161
+ return [2 /*return*/];
162
+ }
163
+ });
164
+ }); });
165
+ it('never stamps a machine account, even though its requests ride a real session', function () { return __awaiter(void 0, void 0, void 0, function () {
166
+ var machine, resolved, _a;
167
+ return __generator(this, function (_b) {
168
+ switch (_b.label) {
169
+ case 0: return [4 /*yield*/, (0, db_1.getDbAsSystem)().insert(user_1.tables.User, {
170
+ name: 'Ops machine',
171
+ email: 'stamp-machine@test.local',
172
+ password: 'test',
173
+ emailVerified: true,
174
+ roles: [],
175
+ isLoadedFromSource: true,
176
+ })];
177
+ case 1:
178
+ machine = _b.sent();
179
+ // Await the stamp DIRECTLY (deterministic absence — no fire-and-forget race), then confirm
180
+ // the transport seam agrees by resolving the machine session through userCache too.
181
+ return [4 /*yield*/, new UserActivityStamp_1.UserActivityStamp().recordInteractiveRequest(machine)];
182
+ case 2:
183
+ // Await the stamp DIRECTLY (deterministic absence — no fire-and-forget race), then confirm
184
+ // the transport seam agrees by resolving the machine session through userCache too.
185
+ _b.sent();
186
+ return [4 /*yield*/, userCache_1.userCache.create('machine-session', machine.email)];
187
+ case 3:
188
+ resolved = _b.sent();
189
+ expect(resolved.id).toBe(machine.id);
190
+ return [4 /*yield*/, new Promise(function (resolve) { return setTimeout(resolve, 300); })];
191
+ case 4:
192
+ _b.sent();
193
+ _a = expect;
194
+ return [4 /*yield*/, activityRows(machine.id)];
195
+ case 5:
196
+ _a.apply(void 0, [_b.sent()]).toHaveLength(0);
197
+ return [2 /*return*/];
198
+ }
199
+ });
200
+ }); });
201
+ it('throttles: a second request inside the interval writes nothing', function () { return __awaiter(void 0, void 0, void 0, function () {
202
+ var user, stamp, first, rows;
203
+ return __generator(this, function (_a) {
204
+ switch (_a.label) {
205
+ case 0: return [4 /*yield*/, testEnv.createUser({ name: 'Rapid Human', email: 'rapid-human@test.local' })];
206
+ case 1:
207
+ user = _a.sent();
208
+ stamp = new UserActivityStamp_1.UserActivityStamp();
209
+ return [4 /*yield*/, stamp.recordInteractiveRequest(user)];
210
+ case 2:
211
+ _a.sent();
212
+ return [4 /*yield*/, waitForActivityRow(user.id)];
213
+ case 3:
214
+ first = _a.sent();
215
+ // Throttle history now holds this user; a second stamp inside the interval must not write.
216
+ return [4 /*yield*/, stamp.recordInteractiveRequest(user)];
217
+ case 4:
218
+ // Throttle history now holds this user; a second stamp inside the interval must not write.
219
+ _a.sent();
220
+ return [4 /*yield*/, new Promise(function (resolve) { return setTimeout(resolve, 300); })];
221
+ case 5:
222
+ _a.sent();
223
+ return [4 /*yield*/, activityRows(user.id)];
224
+ case 6:
225
+ rows = _a.sent();
226
+ expect(rows).toHaveLength(1);
227
+ expect((0, moment_1.default)(rows[0].lastActiveAt).valueOf()).toBe((0, moment_1.default)(first.lastActiveAt).valueOf());
228
+ return [2 /*return*/];
229
+ }
230
+ });
231
+ }); });
232
+ it('a session for a missing account still resolves guest and stamps nothing (no throw)', function () { return __awaiter(void 0, void 0, void 0, function () {
233
+ var before, resolved, _a;
234
+ return __generator(this, function (_b) {
235
+ switch (_b.label) {
236
+ case 0: return [4 /*yield*/, (0, db_1.getDbAsSystem)().query(user_1.tables.UserActivity, {})];
237
+ case 1:
238
+ before = (_b.sent()).length;
239
+ return [4 /*yield*/, userCache_1.userCache.create('stale-session', 'no-such-account@test.local')];
240
+ case 2:
241
+ resolved = _b.sent();
242
+ expect(resolved.id).toBe('guest');
243
+ return [4 /*yield*/, new Promise(function (resolve) { return setTimeout(resolve, 200); })];
244
+ case 3:
245
+ _b.sent();
246
+ _a = expect;
247
+ return [4 /*yield*/, (0, db_1.getDbAsSystem)().query(user_1.tables.UserActivity, {})];
248
+ case 4:
249
+ _a.apply(void 0, [(_b.sent()).length]).toBe(before);
250
+ return [2 /*return*/];
251
+ }
252
+ });
253
+ }); });
254
+ });
255
+ //# sourceMappingURL=UserActivityStamp.integration.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"UserActivityStamp.integration.test.js","sourceRoot":"","sources":["../../test/UserActivityStamp.integration.test.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/user-server",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "description": "User server components",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -22,14 +22,14 @@
22
22
  "test": "jest --runInBand --passWithNoTests"
23
23
  },
24
24
  "dependencies": {
25
- "@proteinjs/db": "^1.35.0",
25
+ "@proteinjs/db": "^1.37.0",
26
26
  "@proteinjs/db-file": "^1.5.3",
27
27
  "@proteinjs/email-server": "^1.3.0",
28
28
  "@proteinjs/logger": "^1.0.21",
29
29
  "@proteinjs/reflection": "^1.2.0",
30
30
  "@proteinjs/server": "^3.5.1",
31
31
  "@proteinjs/server-api": "^3.0.11",
32
- "@proteinjs/user": "^1.13.0",
32
+ "@proteinjs/user": "^1.15.0",
33
33
  "@proteinjs/util": "^1.6.1",
34
34
  "argon2": "^0.45.1",
35
35
  "crypto-js": "4.0.0",
@@ -59,5 +59,5 @@
59
59
  },
60
60
  "main": "./dist/generated/index.js",
61
61
  "types": "./dist/generated/index.d.ts",
62
- "gitHead": "fd6695114449df3a990504f37aab15e4342240cf"
62
+ "gitHead": "2f490c4aa828ed0a241421fb01d9fd372d88e4a2"
63
63
  }
@@ -0,0 +1,66 @@
1
+ import moment from 'moment';
2
+ import { getDbAsSystem } from '@proteinjs/db';
3
+ import { Logger } from '@proteinjs/logger';
4
+ import { tables, type User, type UserActivity } from '@proteinjs/user';
5
+
6
+ /**
7
+ * Writes the LAST-ACTIVITY presence stamp (`user_activity`, one row per user — see
8
+ * UserActivityTable's contract): "this human made an interactive request just now".
9
+ *
10
+ * The TRANSPORT keying lives at the call site — userCache.create runs once per session-cookie
11
+ * request (wrapRoute's session-cache build) and only there, so seeded/background contexts
12
+ * (runInUserScope sets session data directly) structurally never reach this class. What this
13
+ * class owns is the ACCOUNT predicate: machine accounts (`isLoadedFromSource`) are refused —
14
+ * their requests do arrive over real sessions (e.g. the error bridge's polling), but a machine
15
+ * logging in is not a person being present.
16
+ *
17
+ * Write behavior mirrors DbSessionStore's touch: throttled per user (a presence fact consumed at
18
+ * day grain needs no finer cadence, and an unthrottled stamp would put a write on EVERY request),
19
+ * fail-open (a lost stamp is a few minutes of staleness, never a failed request — the returned
20
+ * promise NEVER rejects), and race-tolerant (concurrent first stamps contend on the scope-unique
21
+ * index; the loser's error is swallowed as debug).
22
+ */
23
+ export class UserActivityStamp {
24
+ /** Stamp at most this often per user — same cadence class as DbSessionStore.TOUCH_INTERVAL. */
25
+ private static readonly STAMP_INTERVAL_MS = 1000 * 60 * 5;
26
+ /** Process-wide: userCache is a plain object, so throttle state can't live per-instance. */
27
+ private static lastStampMs = new Map<string, number>();
28
+
29
+ private logger = new Logger({ name: this.constructor.name });
30
+
31
+ /**
32
+ * Record that `user` is present on an interactive request. Fire-and-forget safe: errors are
33
+ * handled (and logged) here, so callers may `void` the returned promise.
34
+ */
35
+ recordInteractiveRequest(user: User): Promise<void> {
36
+ if (!user.id || user.isLoadedFromSource === true) {
37
+ return Promise.resolve();
38
+ }
39
+ const last = UserActivityStamp.lastStampMs.get(user.id) ?? 0;
40
+ const now = Date.now();
41
+ if (now - last < UserActivityStamp.STAMP_INTERVAL_MS) {
42
+ return Promise.resolve();
43
+ }
44
+ UserActivityStamp.lastStampMs.set(user.id, now);
45
+ if (UserActivityStamp.lastStampMs.size > 10000) {
46
+ UserActivityStamp.lastStampMs.clear(); // bounded memory; worst case is one extra stamp per user
47
+ }
48
+ return this.upsert(user.id).catch((error) => {
49
+ // Contention (a concurrent request stamped first, racing the scope-unique index) and real
50
+ // failures land here alike; both are harmless to the request. Un-throttle so the next
51
+ // request retries instead of waiting out a full interval on a stamp that never landed.
52
+ UserActivityStamp.lastStampMs.delete(user.id);
53
+ this.logger.error({ message: 'Failed to write user activity stamp', error });
54
+ });
55
+ }
56
+
57
+ private async upsert(userId: string): Promise<void> {
58
+ const db = getDbAsSystem();
59
+ const existing = await db.get(tables.UserActivity, { scope: userId });
60
+ if (existing) {
61
+ await db.update(tables.UserActivity, { id: existing.id, lastActiveAt: moment() } as UserActivity);
62
+ } else {
63
+ await db.insert(tables.UserActivity, { scope: userId, lastActiveAt: moment() } as UserActivity);
64
+ }
65
+ }
66
+ }
@@ -4,8 +4,10 @@ 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';
7
8
 
8
9
  const logger = new Logger({ name: 'userCache' });
10
+ const userActivityStamp = new UserActivityStamp();
9
11
 
10
12
  export const userCache: SessionDataCache<User> = {
11
13
  key: USER_SESSION_CACHE_KEY,
@@ -38,6 +40,13 @@ export const userCache: SessionDataCache<User> = {
38
40
  } else if (accountUser) {
39
41
  delete (accountUser as any)['password'];
40
42
  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);
41
50
  } else {
42
51
  // A session can outlive its account (row deleted, or a dev auto-login for a never-created
43
52
  // email). Resolve it to the unauthenticated guest session — the client sees no