@proteinjs/user-server 1.17.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 +22 -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/routes/devLogin.d.ts +10 -0
- package/dist/src/routes/devLogin.d.ts.map +1 -1
- package/dist/src/routes/devLogin.js +28 -9
- package/dist/src/routes/devLogin.js.map +1 -1
- package/dist/src/services/Roles.d.ts +20 -1
- package/dist/src/services/Roles.d.ts.map +1 -1
- package/dist/src/services/Roles.js +79 -3
- package/dist/src/services/Roles.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/DevLogin.test.js +13 -41
- package/dist/test/DevLogin.test.js.map +1 -1
- package/dist/test/DevLoginBootstrapAdmin.test.d.ts +2 -0
- package/dist/test/DevLoginBootstrapAdmin.test.d.ts.map +1 -0
- package/dist/test/DevLoginBootstrapAdmin.test.js +384 -0
- package/dist/test/DevLoginBootstrapAdmin.test.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/dist/test/devLoginHarness.d.ts +16 -0
- package/dist/test/devLoginHarness.d.ts.map +1 -0
- package/dist/test/devLoginHarness.js +79 -0
- package/dist/test/devLoginHarness.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/routes/devLogin.ts +17 -0
- package/src/services/Roles.ts +49 -4
- package/src/services/UserPresence.ts +26 -0
- package/test/DevLogin.test.ts +5 -36
- package/test/DevLoginBootstrapAdmin.test.ts +175 -0
- package/test/UserActivityStamp.integration.test.ts +38 -50
- package/test/UserCacheDoesNotStampPresence.integration.test.ts +44 -0
- package/test/devLoginHarness.ts +39 -0
package/src/services/Roles.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getDbAsSystem } from '@proteinjs/db';
|
|
1
|
+
import { getDbAsSystem, QueryBuilderFactory } from '@proteinjs/db';
|
|
2
2
|
import { RolesService, RolesCatalog, UserAuth, UserRepo, tables, USER_PERMISSIONS } from '@proteinjs/user';
|
|
3
3
|
import { Logger } from '@proteinjs/logger';
|
|
4
4
|
import { Service } from '@proteinjs/service';
|
|
@@ -9,7 +9,9 @@ import { Service } from '@proteinjs/service';
|
|
|
9
9
|
* audited: the role update and its `role_grant_event` row (actor, target, role, action; `created`
|
|
10
10
|
* is the timestamp) commit in one transaction, so the trail cannot diverge from the grants.
|
|
11
11
|
*
|
|
12
|
-
* Break-glass roles are never granted
|
|
12
|
+
* Break-glass roles are never granted through the service door — see `changeRole`; revoking one
|
|
13
|
+
* stays allowed. The ONE break-glass grant in code is `bootstrapAdmin`, the dev first-admin door
|
|
14
|
+
* (server-internal; reachable only through `/dev/login`'s gates, never over RPC).
|
|
13
15
|
*
|
|
14
16
|
* Nobody edits their OWN roles: separating 'roles' from 'users' means nothing if the holder can
|
|
15
17
|
* simply grant themselves more, and a self-revoke is the mirror hazard (the last holder locking
|
|
@@ -30,6 +32,41 @@ export class Roles implements RolesService {
|
|
|
30
32
|
await this.changeRole(userId, role, 'revoke');
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The dev first-admin door's grant — `/dev/login` honoring `DEV_BOOTSTRAP_ADMIN_EMAIL`
|
|
37
|
+
* (routes/devLogin.ts). A fresh dev database has no privileged account to grant from, and the
|
|
38
|
+
* only other path to break-glass is a manual UPDATE in Spanner Studio — right for prod and every
|
|
39
|
+
* clone-backed instance, wrong for a lane's throwaway estate on a real dev database, where raw
|
|
40
|
+
* writes are forbidden. So this grants 'admin' to `email`'s account exactly when NO account
|
|
41
|
+
* carries it yet (the membership test `UserAuth.hasRole` makes, over every row), audited like
|
|
42
|
+
* any grant — actor = the account itself, because the door acts for nobody else. Once an admin
|
|
43
|
+
* exists it never grants again: every later grant rides Admin → Users.
|
|
44
|
+
*
|
|
45
|
+
* Server-internal, like `Signup.createAccount`: absent from `RolesService`, so never
|
|
46
|
+
* RPC-reachable. The caller's two gates (DEVELOPMENT + DEV_AUTO_LOGIN_EMAIL) are the only way
|
|
47
|
+
* in, and test/prod never set the variable.
|
|
48
|
+
*/
|
|
49
|
+
async bootstrapAdmin(email: string): Promise<'granted' | 'admin-exists' | 'no-account'> {
|
|
50
|
+
const logger = new Logger({ name: 'Roles.bootstrapAdmin' });
|
|
51
|
+
const db = getDbAsSystem();
|
|
52
|
+
if (await this.adminExists()) {
|
|
53
|
+
return 'admin-exists';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const user = await db.get(tables.User, { email: email.toLowerCase() });
|
|
57
|
+
if (!user) {
|
|
58
|
+
return 'no-account';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const roles = user.roles ?? [];
|
|
62
|
+
await db.runTransaction(async () => {
|
|
63
|
+
await db.update(tables.User, { id: user.id, roles: [...roles, 'admin'] });
|
|
64
|
+
await db.insert(tables.RoleGrantEvent, { actor: user.id, target: user.id, role: 'admin', action: 'grant' });
|
|
65
|
+
});
|
|
66
|
+
logger.info({ message: 'Break-glass admin granted by the dev first-admin door', obj: { target: user.id, email } });
|
|
67
|
+
return 'granted';
|
|
68
|
+
}
|
|
69
|
+
|
|
33
70
|
private async changeRole(userId: string, role: string, action: 'grant' | 'revoke'): Promise<void> {
|
|
34
71
|
const logger = new Logger({ name: `Roles.${action}Role` });
|
|
35
72
|
const entry = RolesCatalog.getEntry(role);
|
|
@@ -39,8 +76,9 @@ export class Roles implements RolesService {
|
|
|
39
76
|
|
|
40
77
|
// Break-glass passes every permission check, so no permission-mapped role — including
|
|
41
78
|
// 'roles' — may mint it: the only path to break-glass is a manual UPDATE on the user row
|
|
42
|
-
// in Spanner Studio, by a human, on purpose
|
|
43
|
-
// "held by nobody day-to-day"
|
|
79
|
+
// in Spanner Studio, by a human, on purpose (and, on a DEV server only, the first-admin door
|
|
80
|
+
// `bootstrapAdmin`). Revoke stays open: de-escalation toward "held by nobody day-to-day"
|
|
81
|
+
// should ride the audited path, not require database access.
|
|
44
82
|
if (entry.breakGlass && action === 'grant') {
|
|
45
83
|
throw new Error(
|
|
46
84
|
`'${role}' is a break-glass role — this service refuses to grant it. The only path to ` +
|
|
@@ -106,4 +144,11 @@ export class Roles implements RolesService {
|
|
|
106
144
|
obj: { actor: actor.id, target: userId, role },
|
|
107
145
|
});
|
|
108
146
|
}
|
|
147
|
+
|
|
148
|
+
/** Whether any account carries the break-glass role — `UserAuth.hasRole`'s membership test, over every row. */
|
|
149
|
+
private async adminExists(): Promise<boolean> {
|
|
150
|
+
const qb = new QueryBuilderFactory().getQueryBuilder(tables.User).select({ fields: ['id', 'roles'] });
|
|
151
|
+
const users = await getDbAsSystem().query(tables.User, qb);
|
|
152
|
+
return users.some((user) => (user.roles ?? []).includes('admin'));
|
|
153
|
+
}
|
|
109
154
|
}
|
|
@@ -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
|
+
}
|
package/test/DevLogin.test.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { getDbAsSystem } from '@proteinjs/db';
|
|
2
2
|
import { tables } from '@proteinjs/user';
|
|
3
3
|
import { PasswordHasher } from '../src/authentication/PasswordHasher';
|
|
4
|
-
import {
|
|
5
|
-
import { createPassportRequest } from './passportSessionHarness';
|
|
4
|
+
import { invokeDevLogin } from './devLoginHarness';
|
|
6
5
|
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
7
6
|
|
|
8
7
|
const testEnv = new UserServerTestEnvironment();
|
|
@@ -15,50 +14,19 @@ const testEnv = new UserServerTestEnvironment();
|
|
|
15
14
|
* - Double gate: `DEVELOPMENT` AND `DEV_AUTO_LOGIN_EMAIL` both present, else 404 (unchanged).
|
|
16
15
|
* - Domain rail: a `?email=` param must share `DEV_AUTO_LOGIN_EMAIL`'s domain — even a dev server
|
|
17
16
|
* must not mint sessions (much less accounts) for arbitrary domains. Others 400.
|
|
17
|
+
* The first-admin door (`DEV_BOOTSTRAP_ADMIN_EMAIL`) has its own suite, DevLoginBootstrapAdmin.test.ts;
|
|
18
|
+
* here the variable is unset, so every created account is role-less.
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
const ENV_EMAIL = 'dev@test.local';
|
|
21
22
|
|
|
22
|
-
type RouteOutcome = {
|
|
23
|
-
loggedInAs?: string;
|
|
24
|
-
sessionRegenerated: boolean;
|
|
25
|
-
sessionSaved: boolean;
|
|
26
|
-
status?: number;
|
|
27
|
-
body?: unknown;
|
|
28
|
-
redirect?: string;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const invokeDevLogin = async (query?: Record<string, unknown>): Promise<RouteOutcome> => {
|
|
32
|
-
// REAL passport login machinery (see passportSessionHarness): the full contract
|
|
33
|
-
// (regenerate → login → save) is pinned by SignupRoute.test.ts — here we assert the dev door
|
|
34
|
-
// INHERITS it.
|
|
35
|
-
const { request, events } = await createPassportRequest({ query });
|
|
36
|
-
const outcome: RouteOutcome = { sessionRegenerated: false, sessionSaved: false };
|
|
37
|
-
const response = {
|
|
38
|
-
status(code: number) {
|
|
39
|
-
outcome.status = code;
|
|
40
|
-
return this;
|
|
41
|
-
},
|
|
42
|
-
send(body?: unknown) {
|
|
43
|
-
outcome.body = body;
|
|
44
|
-
},
|
|
45
|
-
redirect(path: string) {
|
|
46
|
-
outcome.redirect = path;
|
|
47
|
-
},
|
|
48
|
-
};
|
|
49
|
-
await devLogin.onRequest(request as never, response as never);
|
|
50
|
-
outcome.loggedInAs = request.session.passport?.user;
|
|
51
|
-
outcome.sessionRegenerated = events.includes('regenerate');
|
|
52
|
-
outcome.sessionSaved = events.includes('save');
|
|
53
|
-
return outcome;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
23
|
const getUserRow = async (email: string) => await getDbAsSystem().get(tables.User, { email });
|
|
57
24
|
|
|
58
25
|
describe('devLogin route', () => {
|
|
59
26
|
const originalEnv = {
|
|
60
27
|
DEVELOPMENT: process.env.DEVELOPMENT,
|
|
61
28
|
DEV_AUTO_LOGIN_EMAIL: process.env.DEV_AUTO_LOGIN_EMAIL,
|
|
29
|
+
DEV_BOOTSTRAP_ADMIN_EMAIL: process.env.DEV_BOOTSTRAP_ADMIN_EMAIL,
|
|
62
30
|
};
|
|
63
31
|
|
|
64
32
|
beforeAll(async () => {
|
|
@@ -72,6 +40,7 @@ describe('devLogin route', () => {
|
|
|
72
40
|
beforeEach(() => {
|
|
73
41
|
process.env.DEVELOPMENT = 'true';
|
|
74
42
|
process.env.DEV_AUTO_LOGIN_EMAIL = ENV_EMAIL;
|
|
43
|
+
delete process.env.DEV_BOOTSTRAP_ADMIN_EMAIL;
|
|
75
44
|
});
|
|
76
45
|
|
|
77
46
|
afterEach(() => {
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { getDbAsSystem } from '@proteinjs/db';
|
|
2
|
+
import { tables } from '@proteinjs/user';
|
|
3
|
+
import { invokeDevLogin } from './devLoginHarness';
|
|
4
|
+
import { UserServerTestEnvironment } from './UserServerTestEnvironment';
|
|
5
|
+
|
|
6
|
+
const testEnv = new UserServerTestEnvironment();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `DEV_BOOTSTRAP_ADMIN_EMAIL` — the first-admin door INSIDE `/dev/login` (n3xa plans/DEV_ESTATES.md
|
|
10
|
+
* D3, founder-ruled 2026-09-04). A dev estate on a fresh real database has no privileged account
|
|
11
|
+
* to grant from, and the no-raw-DB rule forbids the emulator-era seed scripts there — so the ONE
|
|
12
|
+
* sanctioned account door mints break-glass, once:
|
|
13
|
+
* - behind the door's existing two gates (DEVELOPMENT AND DEV_AUTO_LOGIN_EMAIL): closed = 404 as
|
|
14
|
+
* before, and the variable changes nothing;
|
|
15
|
+
* - only while NO account carries 'admin' (the same membership test the app's admin checks make);
|
|
16
|
+
* - only for the request whose resolved address equals the variable exactly (case-normalized
|
|
17
|
+
* the way every account email is);
|
|
18
|
+
* - once, audited like any grant (a role_grant_event row; actor = the account itself — the door
|
|
19
|
+
* acts for nobody else); a later call finds an admin and grants nothing.
|
|
20
|
+
* Outcomes are asserted on the rows (roles, audit), never on calls.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const ENV_EMAIL = 'dev@test.local';
|
|
24
|
+
const BOOTSTRAP_EMAIL = 'owner@test.local';
|
|
25
|
+
|
|
26
|
+
const userRow = async (email: string) => await getDbAsSystem().get(tables.User, { email });
|
|
27
|
+
const auditRows = async () => await getDbAsSystem().query(tables.RoleGrantEvent, {});
|
|
28
|
+
const adminRows = async () =>
|
|
29
|
+
(await getDbAsSystem().query(tables.User, {})).filter((user) => (user.roles ?? []).includes('admin'));
|
|
30
|
+
|
|
31
|
+
describe('devLogin — the DEV_BOOTSTRAP_ADMIN_EMAIL first-admin door', () => {
|
|
32
|
+
const originalEnv = {
|
|
33
|
+
DEVELOPMENT: process.env.DEVELOPMENT,
|
|
34
|
+
DEV_AUTO_LOGIN_EMAIL: process.env.DEV_AUTO_LOGIN_EMAIL,
|
|
35
|
+
DEV_BOOTSTRAP_ADMIN_EMAIL: process.env.DEV_BOOTSTRAP_ADMIN_EMAIL,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
await testEnv.beforeAll();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterAll(async () => {
|
|
43
|
+
await testEnv.afterAll();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
beforeEach(async () => {
|
|
47
|
+
process.env.DEVELOPMENT = 'true';
|
|
48
|
+
process.env.DEV_AUTO_LOGIN_EMAIL = ENV_EMAIL;
|
|
49
|
+
process.env.DEV_BOOTSTRAP_ADMIN_EMAIL = BOOTSTRAP_EMAIL;
|
|
50
|
+
// Every case starts from a fresh database: no accounts, no audit trail.
|
|
51
|
+
const db = getDbAsSystem();
|
|
52
|
+
await db.delete(tables.RoleGrantEvent, {});
|
|
53
|
+
await db.delete(tables.User, {});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
for (const [key, value] of Object.entries(originalEnv)) {
|
|
58
|
+
if (value === undefined) {
|
|
59
|
+
delete process.env[key];
|
|
60
|
+
} else {
|
|
61
|
+
process.env[key] = value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('a fresh database + the matching address: the account is created carrying admin, audited once, and logged in', async () => {
|
|
67
|
+
expect(await userRow(BOOTSTRAP_EMAIL)).toBeUndefined();
|
|
68
|
+
|
|
69
|
+
const outcome = await invokeDevLogin({ email: BOOTSTRAP_EMAIL });
|
|
70
|
+
|
|
71
|
+
expect(outcome.loggedInAs).toBe(BOOTSTRAP_EMAIL);
|
|
72
|
+
expect(outcome.sessionSaved).toBe(true);
|
|
73
|
+
expect(outcome.redirect).toBe('/');
|
|
74
|
+
const owner = await userRow(BOOTSTRAP_EMAIL);
|
|
75
|
+
expect(owner!.roles).toEqual(['admin']);
|
|
76
|
+
const events = await auditRows();
|
|
77
|
+
expect(events).toHaveLength(1);
|
|
78
|
+
expect(events[0]).toMatchObject({ actor: owner!.id, target: owner!.id, role: 'admin', action: 'grant' });
|
|
79
|
+
expect(await adminRows()).toHaveLength(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('a second call for the same address changes nothing: still exactly one admin, one grant', async () => {
|
|
83
|
+
await invokeDevLogin({ email: BOOTSTRAP_EMAIL });
|
|
84
|
+
|
|
85
|
+
const outcome = await invokeDevLogin({ email: BOOTSTRAP_EMAIL });
|
|
86
|
+
|
|
87
|
+
expect(outcome.loggedInAs).toBe(BOOTSTRAP_EMAIL);
|
|
88
|
+
expect((await userRow(BOOTSTRAP_EMAIL))!.roles).toEqual(['admin']);
|
|
89
|
+
expect(await auditRows()).toHaveLength(1);
|
|
90
|
+
expect(await adminRows()).toHaveLength(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('an existing role-less account at the matching address is granted admin on its next login — the door serves created and loaded accounts alike', async () => {
|
|
94
|
+
await testEnv.createUser({ name: 'Owner', email: BOOTSTRAP_EMAIL });
|
|
95
|
+
|
|
96
|
+
const outcome = await invokeDevLogin({ email: BOOTSTRAP_EMAIL });
|
|
97
|
+
|
|
98
|
+
expect(outcome.loggedInAs).toBe(BOOTSTRAP_EMAIL);
|
|
99
|
+
expect((await userRow(BOOTSTRAP_EMAIL))!.roles).toEqual(['admin']);
|
|
100
|
+
expect(await auditRows()).toHaveLength(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('the default path is a request for DEV_AUTO_LOGIN_EMAIL: when that IS the bootstrap address, the default account carries admin', async () => {
|
|
104
|
+
process.env.DEV_BOOTSTRAP_ADMIN_EMAIL = ENV_EMAIL;
|
|
105
|
+
|
|
106
|
+
const outcome = await invokeDevLogin();
|
|
107
|
+
|
|
108
|
+
expect(outcome.loggedInAs).toBe(ENV_EMAIL);
|
|
109
|
+
expect((await userRow(ENV_EMAIL))!.roles).toEqual(['admin']);
|
|
110
|
+
expect(await auditRows()).toHaveLength(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('the match is case-normalized like every account email — never a second account for a differently-cased address', async () => {
|
|
114
|
+
process.env.DEV_BOOTSTRAP_ADMIN_EMAIL = 'Owner@Test.local';
|
|
115
|
+
|
|
116
|
+
await invokeDevLogin({ email: 'owner@test.local' });
|
|
117
|
+
|
|
118
|
+
expect((await userRow(BOOTSTRAP_EMAIL))!.roles).toEqual(['admin']);
|
|
119
|
+
expect(await adminRows()).toHaveLength(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('an admin already exists: the matching address gets an ordinary account — no grant, no audit row', async () => {
|
|
123
|
+
await testEnv.createUser({ name: 'Standing admin', email: 'standing-admin@test.local', roles: ['admin'] });
|
|
124
|
+
|
|
125
|
+
const outcome = await invokeDevLogin({ email: BOOTSTRAP_EMAIL });
|
|
126
|
+
|
|
127
|
+
expect(outcome.loggedInAs).toBe(BOOTSTRAP_EMAIL);
|
|
128
|
+
expect((await userRow(BOOTSTRAP_EMAIL))!.roles).toEqual([]);
|
|
129
|
+
expect(await auditRows()).toHaveLength(0);
|
|
130
|
+
expect((await adminRows()).map((user) => user.email)).toEqual(['standing-admin@test.local']);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('a different same-domain address: an ordinary account, no grant — and the bootstrap address itself stays uncreated', async () => {
|
|
134
|
+
const outcome = await invokeDevLogin({ email: 'agent@test.local' });
|
|
135
|
+
|
|
136
|
+
expect(outcome.loggedInAs).toBe('agent@test.local');
|
|
137
|
+
expect((await userRow('agent@test.local'))!.roles).toEqual([]);
|
|
138
|
+
expect(await auditRows()).toHaveLength(0);
|
|
139
|
+
expect(await userRow(BOOTSTRAP_EMAIL)).toBeUndefined();
|
|
140
|
+
expect(await adminRows()).toHaveLength(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('the address must match exactly — a plus-suffixed variant is a different account and gets nothing', async () => {
|
|
144
|
+
const outcome = await invokeDevLogin({ email: 'owner+lane@test.local' });
|
|
145
|
+
|
|
146
|
+
expect(outcome.loggedInAs).toBe('owner+lane@test.local');
|
|
147
|
+
expect((await userRow('owner+lane@test.local'))!.roles).toEqual([]);
|
|
148
|
+
expect(await auditRows()).toHaveLength(0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('with the variable unset the matching address is an ordinary account — the omission is the safety (test/prod never set it)', async () => {
|
|
152
|
+
delete process.env.DEV_BOOTSTRAP_ADMIN_EMAIL;
|
|
153
|
+
|
|
154
|
+
await invokeDevLogin({ email: BOOTSTRAP_EMAIL });
|
|
155
|
+
|
|
156
|
+
expect((await userRow(BOOTSTRAP_EMAIL))!.roles).toEqual([]);
|
|
157
|
+
expect(await auditRows()).toHaveLength(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it.each([['DEVELOPMENT'], ['DEV_AUTO_LOGIN_EMAIL']])(
|
|
161
|
+
'gate closed (%s unset): 404 exactly as before — no session, no account, no admin; the variable changes nothing',
|
|
162
|
+
async (gate) => {
|
|
163
|
+
delete process.env[gate];
|
|
164
|
+
|
|
165
|
+
const outcome = await invokeDevLogin({ email: BOOTSTRAP_EMAIL });
|
|
166
|
+
|
|
167
|
+
expect(outcome.status).toBe(404);
|
|
168
|
+
expect(outcome.loggedInAs).toBeUndefined();
|
|
169
|
+
expect(outcome.sessionSaved).toBe(false);
|
|
170
|
+
expect(await userRow(BOOTSTRAP_EMAIL)).toBeUndefined();
|
|
171
|
+
expect(await auditRows()).toHaveLength(0);
|
|
172
|
+
expect(await adminRows()).toHaveLength(0);
|
|
173
|
+
}
|
|
174
|
+
);
|
|
175
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { devLogin } from '../src/routes/devLogin';
|
|
2
|
+
import { createPassportRequest } from './passportSessionHarness';
|
|
3
|
+
|
|
4
|
+
/** What one `GET /dev/login` did, seen from outside: the session it minted (or not) and the response it sent. */
|
|
5
|
+
export type DevLoginOutcome = {
|
|
6
|
+
loggedInAs?: string;
|
|
7
|
+
sessionRegenerated: boolean;
|
|
8
|
+
sessionSaved: boolean;
|
|
9
|
+
status?: number;
|
|
10
|
+
body?: unknown;
|
|
11
|
+
redirect?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Drives the devLogin route once over the REAL passport login machinery (see
|
|
16
|
+
* passportSessionHarness): the full session-establishment contract (regenerate → login → save)
|
|
17
|
+
* is pinned by SignupRoute.test.ts — the dev-door suites assert the door INHERITS it.
|
|
18
|
+
*/
|
|
19
|
+
export const invokeDevLogin = async (query?: Record<string, unknown>): Promise<DevLoginOutcome> => {
|
|
20
|
+
const { request, events } = await createPassportRequest({ query });
|
|
21
|
+
const outcome: DevLoginOutcome = { sessionRegenerated: false, sessionSaved: false };
|
|
22
|
+
const response = {
|
|
23
|
+
status(code: number) {
|
|
24
|
+
outcome.status = code;
|
|
25
|
+
return this;
|
|
26
|
+
},
|
|
27
|
+
send(body?: unknown) {
|
|
28
|
+
outcome.body = body;
|
|
29
|
+
},
|
|
30
|
+
redirect(path: string) {
|
|
31
|
+
outcome.redirect = path;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
await devLogin.onRequest(request as never, response as never);
|
|
35
|
+
outcome.loggedInAs = request.session.passport?.user;
|
|
36
|
+
outcome.sessionRegenerated = events.includes('regenerate');
|
|
37
|
+
outcome.sessionSaved = events.includes('save');
|
|
38
|
+
return outcome;
|
|
39
|
+
};
|