@proteinjs/user-server 1.18.0 → 1.20.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 (55) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/generated/index.d.ts.map +1 -1
  3. package/dist/generated/index.js +3 -1
  4. package/dist/generated/index.js.map +1 -1
  5. package/dist/src/authorization/UserActivityStamp.d.ts +17 -15
  6. package/dist/src/authorization/UserActivityStamp.d.ts.map +1 -1
  7. package/dist/src/authorization/UserActivityStamp.js +21 -19
  8. package/dist/src/authorization/UserActivityStamp.js.map +1 -1
  9. package/dist/src/authorization/userCache.d.ts +9 -0
  10. package/dist/src/authorization/userCache.d.ts.map +1 -1
  11. package/dist/src/authorization/userCache.js +9 -9
  12. package/dist/src/authorization/userCache.js.map +1 -1
  13. package/dist/src/routes/DevBootstrapRoles.d.ts +21 -0
  14. package/dist/src/routes/DevBootstrapRoles.d.ts.map +1 -0
  15. package/dist/src/routes/DevBootstrapRoles.js +64 -0
  16. package/dist/src/routes/DevBootstrapRoles.js.map +1 -0
  17. package/dist/src/routes/devLogin.d.ts +11 -0
  18. package/dist/src/routes/devLogin.d.ts.map +1 -1
  19. package/dist/src/routes/devLogin.js +23 -3
  20. package/dist/src/routes/devLogin.js.map +1 -1
  21. package/dist/src/services/Roles.d.ts +29 -1
  22. package/dist/src/services/Roles.d.ts.map +1 -1
  23. package/dist/src/services/Roles.js +88 -14
  24. package/dist/src/services/Roles.js.map +1 -1
  25. package/dist/src/services/UserPresence.d.ts +16 -0
  26. package/dist/src/services/UserPresence.d.ts.map +1 -0
  27. package/dist/src/services/UserPresence.js +75 -0
  28. package/dist/src/services/UserPresence.js.map +1 -0
  29. package/dist/test/DevLogin.test.js +5 -2
  30. package/dist/test/DevLogin.test.js.map +1 -1
  31. package/dist/test/DevLoginBootstrapAdmin.test.js +2 -0
  32. package/dist/test/DevLoginBootstrapAdmin.test.js.map +1 -1
  33. package/dist/test/DevLoginBootstrapRoles.test.d.ts +2 -0
  34. package/dist/test/DevLoginBootstrapRoles.test.d.ts.map +1 -0
  35. package/dist/test/DevLoginBootstrapRoles.test.js +491 -0
  36. package/dist/test/DevLoginBootstrapRoles.test.js.map +1 -0
  37. package/dist/test/UserActivityStamp.integration.test.js +57 -90
  38. package/dist/test/UserActivityStamp.integration.test.js.map +1 -1
  39. package/dist/test/UserCacheDoesNotStampPresence.integration.test.d.ts +2 -0
  40. package/dist/test/UserCacheDoesNotStampPresence.integration.test.d.ts.map +1 -0
  41. package/dist/test/UserCacheDoesNotStampPresence.integration.test.js +117 -0
  42. package/dist/test/UserCacheDoesNotStampPresence.integration.test.js.map +1 -0
  43. package/generated/index.ts +3 -1
  44. package/package.json +3 -3
  45. package/src/authorization/UserActivityStamp.ts +22 -20
  46. package/src/authorization/userCache.ts +9 -9
  47. package/src/routes/DevBootstrapRoles.ts +50 -0
  48. package/src/routes/devLogin.ts +18 -0
  49. package/src/services/Roles.ts +61 -1
  50. package/src/services/UserPresence.ts +26 -0
  51. package/test/DevLogin.test.ts +5 -2
  52. package/test/DevLoginBootstrapAdmin.test.ts +2 -0
  53. package/test/DevLoginBootstrapRoles.test.ts +248 -0
  54. package/test/UserActivityStamp.integration.test.ts +38 -50
  55. package/test/UserCacheDoesNotStampPresence.integration.test.ts +44 -0
@@ -0,0 +1,50 @@
1
+ import type { BootstrapRolesOutcome } from '../services/Roles';
2
+
3
+ /**
4
+ * The `DEV_BOOTSTRAP_ROLES` contract of the dev role-bootstrap door (routes/devLogin.ts):
5
+ *
6
+ * DEV_BOOTSTRAP_ROLES='email:role[,role];email:role…'
7
+ *
8
+ * Addresses and roles are trimmed; addresses are lowercased (the normalization every account
9
+ * email gets); a repeated address unions its roles; an entry with no `:`, no address or no role
10
+ * is ignored (malformed input is skipped, never fatal). The door grants the listed roles the
11
+ * account does not hold and logs ONE marker line per hit for a listed address —
12
+ * `[dev-bootstrap] <email>: granted a, b; held c; refused d (why)` — which tooling that provisions
13
+ * a development database can read back from the server log as proof of the grant.
14
+ */
15
+ export class DevBootstrapRoles {
16
+ /** The roles listed for `email` in the process env (empty when the variable is unset or the address is not listed). */
17
+ static rolesFor(email: string): string[] {
18
+ return DevBootstrapRoles.parse(process.env.DEV_BOOTSTRAP_ROLES).get(email.trim().toLowerCase()) ?? [];
19
+ }
20
+
21
+ static parse(value: string | undefined): Map<string, string[]> {
22
+ const entries = new Map<string, string[]>();
23
+ for (const entry of (value ?? '').split(';')) {
24
+ const colon = entry.indexOf(':');
25
+ if (colon < 0) {
26
+ continue;
27
+ }
28
+ const email = entry.slice(0, colon).trim().toLowerCase();
29
+ const roles = entry
30
+ .slice(colon + 1)
31
+ .split(',')
32
+ .map((role) => role.trim())
33
+ .filter(Boolean);
34
+ if (!email || roles.length === 0) {
35
+ continue;
36
+ }
37
+ entries.set(email, Array.from(new Set([...(entries.get(email) ?? []), ...roles])));
38
+ }
39
+ return entries;
40
+ }
41
+
42
+ /** The one marker line per hit — a log contract its readers parse; change the shape only together with them. */
43
+ static markerLine(email: string, outcome: BootstrapRolesOutcome): string {
44
+ const list = (items: string[]) => (items.length ? items.join(', ') : '(none)');
45
+ return (
46
+ `[dev-bootstrap] ${email}: granted ${list(outcome.granted)}; held ${list(outcome.held)}; ` +
47
+ `refused ${list(outcome.refused.map((refusal) => `${refusal.role} (${refusal.why})`))}`
48
+ );
49
+ }
50
+ }
@@ -4,6 +4,7 @@ import { emailRegex } from '@proteinjs/util';
4
4
  import { establishSession } from '../authentication/establishSession';
5
5
  import { Roles } from '../services/Roles';
6
6
  import { Signup } from '../services/Signup';
7
+ import { DevBootstrapRoles } from './DevBootstrapRoles';
7
8
 
8
9
  const logger = new Logger({ name: 'devLogin' });
9
10
 
@@ -45,6 +46,17 @@ const emailDomain = (address: string) => address.slice(address.lastIndexOf('@')
45
46
  * Test and prod never set it — the omission is the safety, the same idiom as the gates. The
46
47
  * outcome is logged as ONE marker line, `Dev bootstrap admin door: <granted|admin-exists>`,
47
48
  * which the n3xa compose-estate boot proof reads from the server log to PROVE the grant landed.
49
+ *
50
+ * Role-bootstrap door (`DEV_BOOTSTRAP_ROLES='email:role[,role];email:role…'`, the grammar in
51
+ * DevBootstrapRoles.ts): the first-admin door leaves every OTHER account role-less, and a
52
+ * consumer's admin-grant-only roles then need an admin's act on every fresh development database.
53
+ * Behind the same two gates, when the resolved address is listed, the listed roles the account
54
+ * does not hold are granted through `Roles.bootstrapRoles` — once each, audited, never revoking,
55
+ * never break-glass (refused and named, like a role the catalog does not know); the grant precedes
56
+ * the session so the first page load carries the roles. ONE marker line per hit for a listed
57
+ * address, `[dev-bootstrap] <email>: granted …; held …; refused …`, is what provisioning tooling
58
+ * reads back as proof. The variable absent = nothing changes; the gates closed = 404 regardless; a
59
+ * deployment outside development never sets it.
48
60
  */
49
61
  export const devLogin: Route = {
50
62
  path: '/dev/login',
@@ -94,6 +106,12 @@ export const devLogin: Route = {
94
106
  logger.info({ message: `Dev bootstrap admin door: ${outcome}`, obj: { email } });
95
107
  }
96
108
 
109
+ const bootstrapRoles = DevBootstrapRoles.rolesFor(email);
110
+ if (bootstrapRoles.length > 0) {
111
+ const outcome = await new Roles().bootstrapRoles(email, bootstrapRoles);
112
+ logger.info({ message: DevBootstrapRoles.markerLine(email, outcome) });
113
+ }
114
+
97
115
  // establishSession commits the session row before the redirect — the redirected GET / must
98
116
  // never read the store ahead of the write (observed: first /dev/login load landed on /login).
99
117
  await establishSession(request, email);
@@ -11,12 +11,16 @@ import { Service } from '@proteinjs/service';
11
11
  *
12
12
  * Break-glass roles are never granted through the service door — see `changeRole`; revoking one
13
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).
14
+ * (server-internal; reachable only through `/dev/login`'s gates, never over RPC). Its sibling
15
+ * `bootstrapRoles` is the dev role-bootstrap door's grant — never break-glass, catalog-checked.
15
16
  *
16
17
  * Nobody edits their OWN roles: separating 'roles' from 'users' means nothing if the holder can
17
18
  * simply grant themselves more, and a self-revoke is the mirror hazard (the last holder locking
18
19
  * themselves out). Both directions are refused — see `changeRole`; ask another user manager.
19
20
  */
21
+ /** What one `bootstrapRoles` call did, per role: written now, already on the row, or refused with the reason. */
22
+ export type BootstrapRolesOutcome = { granted: string[]; held: string[]; refused: { role: string; why: string }[] };
23
+
20
24
  export class Roles implements RolesService {
21
25
  public serviceMetadata: Service['serviceMetadata'] = {
22
26
  auth: {
@@ -67,6 +71,62 @@ export class Roles implements RolesService {
67
71
  return 'granted';
68
72
  }
69
73
 
74
+ /**
75
+ * The dev role-bootstrap door's grant — `/dev/login` honoring `DEV_BOOTSTRAP_ROLES`
76
+ * (routes/devLogin.ts, the grammar in routes/DevBootstrapRoles.ts). A fresh development
77
+ * database has one admin at most (the first-admin door) and every other account role-less, and
78
+ * a consumer's `adminGrantOnly` roles can be handed out by an admin only — a manual act on
79
+ * every fresh database. So this grants `roles` to `email`'s account, each
80
+ * once: a role the account holds is `held` (no write, no audit row — idempotent); nothing is
81
+ * ever revoked; a role the catalog does not know or a break-glass role is `refused` and named
82
+ * (`bootstrapAdmin` is the ONE break-glass path, with its own rail). Admin-grant-only roles ARE
83
+ * granted here — that is the door's point. Every grant is audited like any grant, actor = the
84
+ * account itself (the door acts for nobody else), the role update and its rows in one
85
+ * transaction.
86
+ *
87
+ * Server-internal, like `bootstrapAdmin`: absent from `RolesService`, never RPC-reachable; the
88
+ * caller's two gates (DEVELOPMENT + DEV_AUTO_LOGIN_EMAIL) are the only way in, and a deployment
89
+ * outside development never sets the variable.
90
+ */
91
+ async bootstrapRoles(email: string, roles: string[]): Promise<BootstrapRolesOutcome> {
92
+ const logger = new Logger({ name: 'Roles.bootstrapRoles' });
93
+ const db = getDbAsSystem();
94
+ const user = await db.get(tables.User, { email: email.toLowerCase() });
95
+ if (!user) {
96
+ throw new Error(`bootstrapRoles: no account for ${email} — the door creates the account before it grants`);
97
+ }
98
+
99
+ const held = user.roles ?? [];
100
+ const outcome: BootstrapRolesOutcome = { granted: [], held: [], refused: [] };
101
+ for (const role of Array.from(new Set(roles))) {
102
+ const entry = RolesCatalog.getEntry(role);
103
+ if (!entry) {
104
+ outcome.refused.push({ role, why: 'unknown role' });
105
+ } else if (entry.breakGlass) {
106
+ outcome.refused.push({ role, why: 'break-glass' });
107
+ } else if (held.includes(role)) {
108
+ outcome.held.push(role);
109
+ } else {
110
+ outcome.granted.push(role);
111
+ }
112
+ }
113
+ if (outcome.granted.length === 0) {
114
+ return outcome;
115
+ }
116
+
117
+ await db.runTransaction(async () => {
118
+ await db.update(tables.User, { id: user.id, roles: [...held, ...outcome.granted] });
119
+ for (const role of outcome.granted) {
120
+ await db.insert(tables.RoleGrantEvent, { actor: user.id, target: user.id, role, action: 'grant' });
121
+ }
122
+ });
123
+ logger.info({
124
+ message: 'Roles granted by the dev role-bootstrap door',
125
+ obj: { target: user.id, email, granted: outcome.granted },
126
+ });
127
+ return outcome;
128
+ }
129
+
70
130
  private async changeRole(userId: string, role: string, action: 'grant' | 'revoke'): Promise<void> {
71
131
  const logger = new Logger({ name: `Roles.${action}Role` });
72
132
  const entry = RolesCatalog.getEntry(role);
@@ -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
+ }
@@ -14,8 +14,9 @@ const testEnv = new UserServerTestEnvironment();
14
14
  * - Double gate: `DEVELOPMENT` AND `DEV_AUTO_LOGIN_EMAIL` both present, else 404 (unchanged).
15
15
  * - Domain rail: a `?email=` param must share `DEV_AUTO_LOGIN_EMAIL`'s domain — even a dev server
16
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.
17
+ * The first-admin door (`DEV_BOOTSTRAP_ADMIN_EMAIL`) and the role-bootstrap door (`DEV_BOOTSTRAP_ROLES`)
18
+ * have their own suites (DevLoginBootstrapAdmin.test.ts, DevLoginBootstrapRoles.test.ts); here both
19
+ * variables are unset, so every created account is role-less.
19
20
  */
20
21
 
21
22
  const ENV_EMAIL = 'dev@test.local';
@@ -27,6 +28,7 @@ describe('devLogin route', () => {
27
28
  DEVELOPMENT: process.env.DEVELOPMENT,
28
29
  DEV_AUTO_LOGIN_EMAIL: process.env.DEV_AUTO_LOGIN_EMAIL,
29
30
  DEV_BOOTSTRAP_ADMIN_EMAIL: process.env.DEV_BOOTSTRAP_ADMIN_EMAIL,
31
+ DEV_BOOTSTRAP_ROLES: process.env.DEV_BOOTSTRAP_ROLES,
30
32
  };
31
33
 
32
34
  beforeAll(async () => {
@@ -41,6 +43,7 @@ describe('devLogin route', () => {
41
43
  process.env.DEVELOPMENT = 'true';
42
44
  process.env.DEV_AUTO_LOGIN_EMAIL = ENV_EMAIL;
43
45
  delete process.env.DEV_BOOTSTRAP_ADMIN_EMAIL;
46
+ delete process.env.DEV_BOOTSTRAP_ROLES;
44
47
  });
45
48
 
46
49
  afterEach(() => {
@@ -33,6 +33,7 @@ describe('devLogin — the DEV_BOOTSTRAP_ADMIN_EMAIL first-admin door', () => {
33
33
  DEVELOPMENT: process.env.DEVELOPMENT,
34
34
  DEV_AUTO_LOGIN_EMAIL: process.env.DEV_AUTO_LOGIN_EMAIL,
35
35
  DEV_BOOTSTRAP_ADMIN_EMAIL: process.env.DEV_BOOTSTRAP_ADMIN_EMAIL,
36
+ DEV_BOOTSTRAP_ROLES: process.env.DEV_BOOTSTRAP_ROLES,
36
37
  };
37
38
 
38
39
  beforeAll(async () => {
@@ -47,6 +48,7 @@ describe('devLogin — the DEV_BOOTSTRAP_ADMIN_EMAIL first-admin door', () => {
47
48
  process.env.DEVELOPMENT = 'true';
48
49
  process.env.DEV_AUTO_LOGIN_EMAIL = ENV_EMAIL;
49
50
  process.env.DEV_BOOTSTRAP_ADMIN_EMAIL = BOOTSTRAP_EMAIL;
51
+ delete process.env.DEV_BOOTSTRAP_ROLES; // the role-bootstrap door has its own suite; here it is closed
50
52
  // Every case starts from a fresh database: no accounts, no audit trail.
51
53
  const db = getDbAsSystem();
52
54
  await db.delete(tables.RoleGrantEvent, {});
@@ -0,0 +1,248 @@
1
+ import { getDbAsSystem } from '@proteinjs/db';
2
+ import { Logger } from '@proteinjs/logger';
3
+ import { SourceRepository } from '@proteinjs/reflection';
4
+ import { RoleCatalogEntry, tables } from '@proteinjs/user';
5
+ import { invokeDevLogin } from './devLoginHarness';
6
+ import { UserServerTestEnvironment } from './UserServerTestEnvironment';
7
+
8
+ const testEnv = new UserServerTestEnvironment();
9
+
10
+ /**
11
+ * `DEV_BOOTSTRAP_ROLES` — the dev role-bootstrap door INSIDE `/dev/login`. A fresh development
12
+ * database has one admin at most (the first-admin door) and every other account is role-less —
13
+ * so a consumer's `adminGrantOnly` roles needed a manual admin act on every fresh database. The
14
+ * same door, the same two gates, one more variable: `email:role[,role];email:role…` — on a hit
15
+ * whose resolved address is listed, the listed roles the account does not hold are granted, once
16
+ * each:
17
+ * - behind the door's existing two gates (DEVELOPMENT AND DEV_AUTO_LOGIN_EMAIL): closed = 404 as
18
+ * before, and the variable changes nothing;
19
+ * - only for the request whose resolved address equals a listed address exactly (case-normalized
20
+ * the way every account email is) — every other address is untouched;
21
+ * - idempotent: a role the account holds is reported `held`, never re-granted, never re-audited;
22
+ * nothing is EVER revoked (a role the list does not name stays);
23
+ * - admin-grant-only roles are granted (that is the point — a fresh development database has no
24
+ * admin to grant them); break-glass and roles the catalog does not know are REFUSED and named;
25
+ * - each grant is audited like any grant (a role_grant_event row; actor = the account itself);
26
+ * - the outcome is ONE marker line, `[dev-bootstrap] <email>: granted …; held …; refused …`, the
27
+ * line provisioning tooling reads back from the server log as its proof.
28
+ * Outcomes are asserted on the rows (roles, audit), the marker line on the logger (it IS the
29
+ * contract its readers parse).
30
+ */
31
+
32
+ const ENV_EMAIL = 'dev@test.local';
33
+ const TEAM_EMAIL = 'team@test.local';
34
+ const OPS_EMAIL = 'ops@test.local';
35
+ const ROLES_ENV = `${TEAM_EMAIL}:staff,dev;${OPS_EMAIL}:ops`;
36
+
37
+ type SourceRepositoryInternals = { objectCache: Record<string, unknown[]> };
38
+
39
+ const userRow = async (email: string) => await getDbAsSystem().get(tables.User, { email });
40
+ const auditRows = async () => await getDbAsSystem().query(tables.RoleGrantEvent, {});
41
+ const adminRows = async () =>
42
+ (await getDbAsSystem().query(tables.User, {})).filter((user) => (user.roles ?? []).includes('admin'));
43
+
44
+ describe('devLogin — the DEV_BOOTSTRAP_ROLES role-bootstrap door', () => {
45
+ const originalEnv = {
46
+ DEVELOPMENT: process.env.DEVELOPMENT,
47
+ DEV_AUTO_LOGIN_EMAIL: process.env.DEV_AUTO_LOGIN_EMAIL,
48
+ DEV_BOOTSTRAP_ADMIN_EMAIL: process.env.DEV_BOOTSTRAP_ADMIN_EMAIL,
49
+ DEV_BOOTSTRAP_ROLES: process.env.DEV_BOOTSTRAP_ROLES,
50
+ };
51
+ let infoSpy: jest.SpyInstance;
52
+ const markerLines = () =>
53
+ infoSpy.mock.calls
54
+ .map((call) => String((call[0] as { message?: string })?.message ?? ''))
55
+ .filter((message) => message.startsWith('[dev-bootstrap]'));
56
+
57
+ beforeAll(async () => {
58
+ await testEnv.beforeAll();
59
+ // A consumer's catalog shape: 'staff' is admin-grant-only, 'dev'/'ops' plain.
60
+ (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache['@proteinjs/user/RoleCatalogEntry'] = [
61
+ { role: 'staff', description: 'Staff member', adminGrantOnly: true } as RoleCatalogEntry,
62
+ { role: 'dev', description: 'Developer tooling' } as RoleCatalogEntry,
63
+ { role: 'ops', description: 'Operations' } as RoleCatalogEntry,
64
+ ];
65
+ });
66
+
67
+ afterAll(async () => {
68
+ await testEnv.afterAll();
69
+ });
70
+
71
+ beforeEach(async () => {
72
+ process.env.DEVELOPMENT = 'true';
73
+ process.env.DEV_AUTO_LOGIN_EMAIL = ENV_EMAIL;
74
+ delete process.env.DEV_BOOTSTRAP_ADMIN_EMAIL;
75
+ process.env.DEV_BOOTSTRAP_ROLES = ROLES_ENV;
76
+ infoSpy = jest.spyOn(Logger.prototype, 'info');
77
+ // Every case starts from a fresh database: no accounts, no audit trail.
78
+ const db = getDbAsSystem();
79
+ await db.delete(tables.RoleGrantEvent, {});
80
+ await db.delete(tables.User, {});
81
+ });
82
+
83
+ afterEach(() => {
84
+ infoSpy.mockRestore();
85
+ for (const [key, value] of Object.entries(originalEnv)) {
86
+ if (value === undefined) {
87
+ delete process.env[key];
88
+ } else {
89
+ process.env[key] = value;
90
+ }
91
+ }
92
+ });
93
+
94
+ it('a fresh database + a listed address: the account is created carrying the listed roles (the admin-grant-only one included), one audit row per role, logged in, and the marker line names the grants', async () => {
95
+ expect(await userRow(TEAM_EMAIL)).toBeUndefined();
96
+
97
+ const outcome = await invokeDevLogin({ email: TEAM_EMAIL });
98
+
99
+ expect(outcome.loggedInAs).toBe(TEAM_EMAIL);
100
+ expect(outcome.sessionSaved).toBe(true);
101
+ expect(outcome.redirect).toBe('/');
102
+ const team = await userRow(TEAM_EMAIL);
103
+ expect(team!.roles).toEqual(['staff', 'dev']);
104
+ const events = await auditRows();
105
+ expect(events.map((e) => ({ actor: e.actor, target: e.target, role: e.role, action: e.action }))).toEqual(
106
+ expect.arrayContaining([
107
+ { actor: team!.id, target: team!.id, role: 'staff', action: 'grant' },
108
+ { actor: team!.id, target: team!.id, role: 'dev', action: 'grant' },
109
+ ])
110
+ );
111
+ expect(events).toHaveLength(2);
112
+ expect(markerLines()).toEqual([`[dev-bootstrap] ${TEAM_EMAIL}: granted staff, dev; held (none); refused (none)`]);
113
+ });
114
+
115
+ it('a second hit for the same address writes nothing: the roles stand, the audit trail is unchanged, the marker line reports them held', async () => {
116
+ await invokeDevLogin({ email: TEAM_EMAIL });
117
+ infoSpy.mockClear();
118
+
119
+ const outcome = await invokeDevLogin({ email: TEAM_EMAIL });
120
+
121
+ expect(outcome.loggedInAs).toBe(TEAM_EMAIL);
122
+ expect((await userRow(TEAM_EMAIL))!.roles).toEqual(['staff', 'dev']);
123
+ expect(await auditRows()).toHaveLength(2);
124
+ expect(markerLines()).toEqual([`[dev-bootstrap] ${TEAM_EMAIL}: granted (none); held staff, dev; refused (none)`]);
125
+ });
126
+
127
+ it('an existing account holding one listed role and one unlisted role: only the missing role is granted, the unlisted one stays — the door never revokes', async () => {
128
+ await testEnv.createUser({ name: 'Team', email: TEAM_EMAIL, roles: ['dev', 'sessions'] });
129
+
130
+ await invokeDevLogin({ email: TEAM_EMAIL });
131
+
132
+ expect((await userRow(TEAM_EMAIL))!.roles).toEqual(['dev', 'sessions', 'staff']);
133
+ const events = await auditRows();
134
+ expect(events).toHaveLength(1);
135
+ expect(events[0]).toMatchObject({ role: 'staff', action: 'grant' });
136
+ expect(markerLines()).toEqual([`[dev-bootstrap] ${TEAM_EMAIL}: granted staff; held dev; refused (none)`]);
137
+ });
138
+
139
+ it('an address the list does not name: an ordinary account, no grant, no audit row, no marker line — and the listed addresses stay uncreated', async () => {
140
+ const outcome = await invokeDevLogin({ email: 'agent@test.local' });
141
+
142
+ expect(outcome.loggedInAs).toBe('agent@test.local');
143
+ expect((await userRow('agent@test.local'))!.roles).toEqual([]);
144
+ expect(await auditRows()).toHaveLength(0);
145
+ expect(markerLines()).toEqual([]);
146
+ expect(await userRow(TEAM_EMAIL)).toBeUndefined();
147
+ expect(await userRow(OPS_EMAIL)).toBeUndefined();
148
+ });
149
+
150
+ it('each listed address gets ITS roles only — the second entry never inherits the first', async () => {
151
+ await invokeDevLogin({ email: OPS_EMAIL });
152
+
153
+ expect((await userRow(OPS_EMAIL))!.roles).toEqual(['ops']);
154
+ expect(await auditRows()).toHaveLength(1);
155
+ expect(markerLines()).toEqual([`[dev-bootstrap] ${OPS_EMAIL}: granted ops; held (none); refused (none)`]);
156
+ });
157
+
158
+ it('the match is case-normalized like every account email, and the grammar tolerates whitespace around every token', async () => {
159
+ process.env.DEV_BOOTSTRAP_ROLES = ` Team@Test.local : staff , dev ; ${OPS_EMAIL}: ops `;
160
+
161
+ await invokeDevLogin({ email: 'team@test.local' });
162
+
163
+ expect((await userRow(TEAM_EMAIL))!.roles).toEqual(['staff', 'dev']);
164
+ expect(await auditRows()).toHaveLength(2);
165
+ });
166
+
167
+ it('a role the catalog does not know is refused and named; the known roles in the same entry are still granted', async () => {
168
+ process.env.DEV_BOOTSTRAP_ROLES = `${TEAM_EMAIL}:staff,no-such-role`;
169
+
170
+ await invokeDevLogin({ email: TEAM_EMAIL });
171
+
172
+ expect((await userRow(TEAM_EMAIL))!.roles).toEqual(['staff']);
173
+ expect(await auditRows()).toHaveLength(1);
174
+ expect(markerLines()).toEqual([
175
+ `[dev-bootstrap] ${TEAM_EMAIL}: granted staff; held (none); refused no-such-role (unknown role)`,
176
+ ]);
177
+ });
178
+
179
+ it("break-glass is never this door's to grant: 'admin' in the list is refused and named (the first-admin door is the one path)", async () => {
180
+ process.env.DEV_BOOTSTRAP_ROLES = `${TEAM_EMAIL}:admin,dev`;
181
+
182
+ await invokeDevLogin({ email: TEAM_EMAIL });
183
+
184
+ expect((await userRow(TEAM_EMAIL))!.roles).toEqual(['dev']);
185
+ expect(await adminRows()).toHaveLength(0);
186
+ expect((await auditRows()).map((e) => e.role)).toEqual(['dev']);
187
+ expect(markerLines()).toEqual([
188
+ `[dev-bootstrap] ${TEAM_EMAIL}: granted dev; held (none); refused admin (break-glass)`,
189
+ ]);
190
+ });
191
+
192
+ it('the default path is a request for DEV_AUTO_LOGIN_EMAIL: when that address is listed, the default account carries the roles', async () => {
193
+ process.env.DEV_BOOTSTRAP_ROLES = `${ENV_EMAIL}:dev`;
194
+
195
+ const outcome = await invokeDevLogin();
196
+
197
+ expect(outcome.loggedInAs).toBe(ENV_EMAIL);
198
+ expect((await userRow(ENV_EMAIL))!.roles).toEqual(['dev']);
199
+ expect(await auditRows()).toHaveLength(1);
200
+ });
201
+
202
+ it('composes with the first-admin door: the same address listed in both gets admin first, then its roles — three audited grants, one account', async () => {
203
+ process.env.DEV_BOOTSTRAP_ADMIN_EMAIL = TEAM_EMAIL;
204
+
205
+ await invokeDevLogin({ email: TEAM_EMAIL });
206
+
207
+ expect((await userRow(TEAM_EMAIL))!.roles).toEqual(['admin', 'staff', 'dev']);
208
+ expect(await auditRows()).toHaveLength(3);
209
+ expect(await adminRows()).toHaveLength(1);
210
+ });
211
+
212
+ it('with the variable unset a listed address is an ordinary account — the omission is the safety (a deployment outside development never sets it)', async () => {
213
+ delete process.env.DEV_BOOTSTRAP_ROLES;
214
+
215
+ await invokeDevLogin({ email: TEAM_EMAIL });
216
+
217
+ expect((await userRow(TEAM_EMAIL))!.roles).toEqual([]);
218
+ expect(await auditRows()).toHaveLength(0);
219
+ expect(markerLines()).toEqual([]);
220
+ });
221
+
222
+ it('malformed entries (no colon, no roles) are ignored; the well-formed entries beside them are honored', async () => {
223
+ process.env.DEV_BOOTSTRAP_ROLES = `garbage;${OPS_EMAIL}:;${TEAM_EMAIL}:dev;:ops`;
224
+
225
+ await invokeDevLogin({ email: TEAM_EMAIL });
226
+ await invokeDevLogin({ email: OPS_EMAIL });
227
+
228
+ expect((await userRow(TEAM_EMAIL))!.roles).toEqual(['dev']);
229
+ expect((await userRow(OPS_EMAIL))!.roles).toEqual([]);
230
+ expect(await auditRows()).toHaveLength(1);
231
+ });
232
+
233
+ it.each([['DEVELOPMENT'], ['DEV_AUTO_LOGIN_EMAIL']])(
234
+ 'gate closed (%s unset): 404 exactly as before — no session, no account, no grant; the variable changes nothing',
235
+ async (gate) => {
236
+ delete process.env[gate];
237
+
238
+ const outcome = await invokeDevLogin({ email: TEAM_EMAIL });
239
+
240
+ expect(outcome.status).toBe(404);
241
+ expect(outcome.loggedInAs).toBeUndefined();
242
+ expect(outcome.sessionSaved).toBe(false);
243
+ expect(await userRow(TEAM_EMAIL)).toBeUndefined();
244
+ expect(await auditRows()).toHaveLength(0);
245
+ expect(markerLines()).toEqual([]);
246
+ }
247
+ );
248
+ });
@@ -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 fires from
33
- * userCache.create — the once-per-interactive-request session-cache build — and from nowhere
34
- * else. These tests pin the seam's outcomes: a human's request lands the presence row; machine
35
- * accounts (real sessions, e.g. the error bridge's polling login) never do; repeated stamps
36
- * keep ONE row per user; the throttle holds writes to the interval.
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 — interactive presence', () => {
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('stamps presence when a signed-in human makes an interactive request (through userCache.create)', async () => {
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
- const resolved = await userCache.create('interactive-session', user.email);
56
- expect(resolved.id).toBe(user.id);
45
+ await new UserPresence().recordPresence();
57
46
 
58
- const row = await waitForActivityRow(user.id);
59
- expect(moment(row.lastActiveAt).isAfter(moment().subtract(1, 'minute'))).toBe(true);
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 stamps (scope-unique invariant)', async () => {
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.recordInteractiveRequest(user);
67
- const first = await waitForActivityRow(user.id);
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 request stamps immediately.
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.recordInteractiveRequest(user);
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, even though its requests ride a real session', async () => {
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
- // Await the stamp DIRECTLY (deterministic absence — no fire-and-forget race), then confirm
90
- // the transport seam agrees by resolving the machine session through userCache too.
91
- await new UserActivityStamp().recordInteractiveRequest(machine);
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('throttles: a second request inside the interval writes nothing', async () => {
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.recordInteractiveRequest(user);
104
- const first = await waitForActivityRow(user.id);
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 stamp inside the interval must not write.
107
- await stamp.recordInteractiveRequest(user);
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
  });