@fonderie/auth 7.18.0 → 7.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/brain/outcomes.md +1 -1
- package/brain/signatures.md +2 -1
- package/dist/index.cjs +45 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +43 -15
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs +12 -4
- package/dist/middlewares/index.cjs.map +1 -1
- package/dist/middlewares/index.js +12 -4
- package/dist/middlewares/index.js.map +1 -1
- package/package.json +1 -1
package/brain/outcomes.md
CHANGED
|
@@ -145,7 +145,7 @@ Raw SQL ships in `node_modules/@fonderie/auth/dist/migrations/sql/` — read it
|
|
|
145
145
|
| GET | `/_admin/users` | `async (ctx) => { const params = new URL(ctx.request.url).searchParams; const email = params.get('email')?.trim().toLowerCase(); if (email) { const user = await users.findByEmail(email); return user ? setApiResponse(HTTP.OK, 'USER', 'User', toAdminUserDTO(user)) : NOT_FOUND(); } const rawLimit = Number(params.get('limit') ?? 50); const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50; const cursorParam = params.get('cursor'); const cursor = cursorParam ? decodeLoginCursor(cursorParam) : null; if (cursorParam && !cursor) return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'Invalid cursor'); const deleted = params.get('deleted') === '1' || params.get('deleted') === 'true'; const page = await users.list({ limit, ...(cursor ? { cursor } : {}), ...(deleted ? { deleted } : {}), }); return setApiResponse(HTTP.OK, 'USERS', 'Users', toAdminUserPageDTO(page)); }` |
|
|
146
146
|
| GET | `/_admin/users/:id` | `async (ctx) => { // Read-only view: a soft-deleted account resolves (deletedAt set), so // an operator arriving from billing sees what happened to it. const user = await users.findByIdIncludingDeleted(idOf(ctx)); return user ? setApiResponse(HTTP.OK, 'USER', 'User', toAdminUserDTO(user)) : NOT_FOUND(); }` |
|
|
147
147
|
| GET | `/_admin/users/:id/login-history` | `async (ctx) => { if (!(await users.findById(idOf(ctx)))) return NOT_FOUND(); const params = new URL(ctx.request.url).searchParams; const rawLimit = Number(params.get('limit') ?? 50); const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50; const cursorParam = params.get('cursor'); const cursor = cursorParam ? decodeLoginCursor(cursorParam) : null; if (cursorParam && !cursor) return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'Invalid cursor'); const page = await events.listByUser({ userId: idOf(ctx), limit, ...(cursor ? { cursor } : {}), }); return setApiResponse( HTTP.OK, 'LOGIN_HISTORY', 'Login history', toLoginHistoryPageDTO(page), ); }` |
|
|
148
|
-
| DELETE | `/_admin/users/:id/sessions` | `async (ctx) => { if (!(await users.findById(idOf(ctx)))) return NOT_FOUND(); await sessions.deleteByUser(idOf(ctx)); return setApiResponse(HTTP.OK, 'SESSIONS_REVOKED', 'All sessions revoked'); }` |
|
|
148
|
+
| DELETE | `/_admin/users/:id/sessions` | `async (ctx) => { if (!(await users.findById(idOf(ctx)))) return NOT_FOUND(); await sessions.deleteByUser(idOf(ctx)); await background(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: idOf(ctx), sids: null, reason: 'admin' } satisfies ISessionRevokedEvent)); return setApiResponse(HTTP.OK, 'SESSIONS_REVOKED', 'All sessions revoked'); }` |
|
|
149
149
|
| GET | `/_admin/users/:id/sessions` | `async (ctx) => { if (!(await users.findById(idOf(ctx)))) return NOT_FOUND(); const rows = await sessions.listLiveByUser(idOf(ctx)); return setApiResponse( HTTP.OK, 'SESSIONS', 'Live sessions', rows.map((r) => toSessionDTO(r, null)), ); }` |
|
|
150
150
|
| POST | `/_admin/users/:id/suspend` | `setSuspended(true)` |
|
|
151
151
|
| POST | `/_admin/users/:id/unsuspend` | `setSuspended(false)` |
|
package/brain/signatures.md
CHANGED
|
@@ -52,6 +52,7 @@ new AuthModule(store: IStoreAdapter, config: IAuthConfig, bus?: EventBus | undef
|
|
|
52
52
|
.name: "@fonderie/auth"
|
|
53
53
|
.version: string
|
|
54
54
|
.describeAdmin(): IAdminDescription
|
|
55
|
+
.describeEvents(): IEventCatalogEntry<unknown>[]
|
|
55
56
|
.checkReadiness(): IReadinessProblem[]
|
|
56
57
|
.install(app: IFonderieApp): void
|
|
57
58
|
|
|
@@ -267,7 +268,7 @@ interface IAdminUserDTO extends IUserDTO {
|
|
|
267
268
|
createdAt: string;
|
|
268
269
|
}
|
|
269
270
|
|
|
270
|
-
function describeAuthAdminRoutes(store: IStoreAdapter): IAdminRoute[]
|
|
271
|
+
function describeAuthAdminRoutes(store: IStoreAdapter, bus?: EventBus | undefined): IAdminRoute[]
|
|
271
272
|
|
|
272
273
|
function toAdminUserDTO(user: IUser): IAdminUserDTO
|
|
273
274
|
|
package/dist/index.cjs
CHANGED
|
@@ -335,8 +335,10 @@ var EVENT_KEYS = {
|
|
|
335
335
|
userPurged: "fonderie.user.purged",
|
|
336
336
|
emailVerified: "fonderie.user.email_verified",
|
|
337
337
|
passwordChanged: "fonderie.user.password_changed",
|
|
338
|
-
//
|
|
339
|
-
//
|
|
338
|
+
// Sessions the server revoked: { userId, sids (null = all), reason }. Reaches
|
|
339
|
+
// the user's devices live (@fonderie/sse) so a revoked device signs out at
|
|
340
|
+
// once. Reasons: 'terminated' (signed out from the devices list),
|
|
341
|
+
// 'password-changed', 'admin', 'refresh-reuse' (theft signal).
|
|
340
342
|
sessionRevoked: "fonderie.session.revoked"
|
|
341
343
|
};
|
|
342
344
|
|
|
@@ -1227,22 +1229,30 @@ var SessionModel = class {
|
|
|
1227
1229
|
// Terminate one session by row id, scoped to its owner (a user can never
|
|
1228
1230
|
// delete another user's session). Returns whether a row was removed.
|
|
1229
1231
|
async terminateById(userId, id) {
|
|
1232
|
+
return await this.terminateByIdReturningSid(userId, id) !== void 0;
|
|
1233
|
+
}
|
|
1234
|
+
/** Terminate one of the user's sessions; its sid (null if unbound), or undefined if none was removed. */
|
|
1235
|
+
async terminateByIdReturningSid(userId, id) {
|
|
1230
1236
|
const rows = await this.store.query(
|
|
1231
|
-
`DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING
|
|
1237
|
+
`DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING sid`,
|
|
1232
1238
|
[id, userId]
|
|
1233
1239
|
);
|
|
1234
|
-
return rows.length
|
|
1240
|
+
return rows.length ? rows[0]?.sid ?? null : void 0;
|
|
1235
1241
|
}
|
|
1236
1242
|
// Terminate all of a user's sessions except the current one (by sid).
|
|
1237
1243
|
// Returns the number of sessions removed.
|
|
1238
1244
|
async terminateOthers(userId, keepSid) {
|
|
1245
|
+
return (await this.terminateOthersReturningSids(userId, keepSid)).length;
|
|
1246
|
+
}
|
|
1247
|
+
/** Terminate all of the user's sessions but `keepSid`; the sids removed. */
|
|
1248
|
+
async terminateOthersReturningSids(userId, keepSid) {
|
|
1239
1249
|
const rows = await this.store.query(
|
|
1240
1250
|
`DELETE FROM fonderie_sessions
|
|
1241
1251
|
WHERE user_id = $1 AND (sid IS DISTINCT FROM $2)
|
|
1242
|
-
RETURNING
|
|
1252
|
+
RETURNING sid`,
|
|
1243
1253
|
[userId, keepSid]
|
|
1244
1254
|
);
|
|
1245
|
-
return rows.
|
|
1255
|
+
return rows.map((r) => r.sid);
|
|
1246
1256
|
}
|
|
1247
1257
|
async exists(token) {
|
|
1248
1258
|
const rows = await this.store.query(
|
|
@@ -2117,7 +2127,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
|
|
|
2117
2127
|
if (found.kind === "reused") {
|
|
2118
2128
|
await sessions.revokeById(found.row.id);
|
|
2119
2129
|
await (0, import_core6.background)(
|
|
2120
|
-
bus?.emit(EVENT_KEYS.sessionRevoked, { userId: found.row.userId,
|
|
2130
|
+
bus?.emit(EVENT_KEYS.sessionRevoked, { userId: found.row.userId, sids: [found.row.sid], reason: "refresh-reuse" })
|
|
2121
2131
|
);
|
|
2122
2132
|
return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
|
|
2123
2133
|
}
|
|
@@ -2555,10 +2565,11 @@ function userController(store, config, bus) {
|
|
|
2555
2565
|
if (!id) {
|
|
2556
2566
|
return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
|
|
2557
2567
|
}
|
|
2558
|
-
const removed = await sessions.
|
|
2559
|
-
if (
|
|
2568
|
+
const removed = await sessions.terminateByIdReturningSid(ctx.user.id, id);
|
|
2569
|
+
if (removed === void 0) {
|
|
2560
2570
|
return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
|
|
2561
2571
|
}
|
|
2572
|
+
await (0, import_core8.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: [removed], reason: "terminated" }));
|
|
2562
2573
|
return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "SESSION_TERMINATED", "Session terminated.", { id });
|
|
2563
2574
|
},
|
|
2564
2575
|
terminateOtherSessions: async (ctx) => {
|
|
@@ -2570,7 +2581,9 @@ function userController(store, config, bus) {
|
|
|
2570
2581
|
"Cannot terminate others without a current session context"
|
|
2571
2582
|
);
|
|
2572
2583
|
}
|
|
2573
|
-
const
|
|
2584
|
+
const sids = await sessions.terminateOthersReturningSids(ctx.user.id, currentSid);
|
|
2585
|
+
const count = sids.length;
|
|
2586
|
+
if (count) await (0, import_core8.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids, reason: "terminated" }));
|
|
2574
2587
|
return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
|
|
2575
2588
|
},
|
|
2576
2589
|
updateProfile: async (ctx) => {
|
|
@@ -2732,6 +2745,7 @@ function userController(store, config, bus) {
|
|
|
2732
2745
|
const hash = await hashPassword2(newPassword);
|
|
2733
2746
|
await users.updatePassword(ctx.user.id, hash);
|
|
2734
2747
|
await sessions.deleteByUser(ctx.user.id);
|
|
2748
|
+
await (0, import_core8.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: null, reason: "password-changed" }));
|
|
2735
2749
|
return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "PASSWORD_CHANGED", "Password updated successfully.");
|
|
2736
2750
|
},
|
|
2737
2751
|
/**
|
|
@@ -3626,6 +3640,7 @@ function extractToken(request) {
|
|
|
3626
3640
|
|
|
3627
3641
|
// src/admin.ts
|
|
3628
3642
|
var import_core12 = require("@fonderie/core");
|
|
3643
|
+
var import_core13 = require("@fonderie/core");
|
|
3629
3644
|
function toAdminUserDTO(user) {
|
|
3630
3645
|
return {
|
|
3631
3646
|
...toUserDTO(user),
|
|
@@ -3642,7 +3657,7 @@ function toAdminUserPageDTO(page) {
|
|
|
3642
3657
|
var ADMIN_PREFIX = "/_admin";
|
|
3643
3658
|
var idOf = (ctx) => ctx.meta.params?.["id"] ?? "";
|
|
3644
3659
|
var NOT_FOUND = () => (0, import_core12.setApiResponse)(import_core12.HTTP.NOT_FOUND, "NOT_FOUND", "No such user");
|
|
3645
|
-
function adminRouteTable(store) {
|
|
3660
|
+
function adminRouteTable(store, bus) {
|
|
3646
3661
|
const users = new UserModel(store);
|
|
3647
3662
|
const sessions = new SessionModel(store);
|
|
3648
3663
|
const events = new LoginEventModel(store);
|
|
@@ -3715,6 +3730,7 @@ function adminRouteTable(store) {
|
|
|
3715
3730
|
async (ctx) => {
|
|
3716
3731
|
if (!await users.findById(idOf(ctx))) return NOT_FOUND();
|
|
3717
3732
|
await sessions.deleteByUser(idOf(ctx));
|
|
3733
|
+
await (0, import_core13.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: idOf(ctx), sids: null, reason: "admin" }));
|
|
3718
3734
|
return (0, import_core12.setApiResponse)(import_core12.HTTP.OK, "SESSIONS_REVOKED", "All sessions revoked");
|
|
3719
3735
|
}
|
|
3720
3736
|
],
|
|
@@ -3747,8 +3763,8 @@ function adminRouteTable(store) {
|
|
|
3747
3763
|
["POST", "/_admin/users/:id/unsuspend", setSuspended(false)]
|
|
3748
3764
|
];
|
|
3749
3765
|
}
|
|
3750
|
-
function describeAuthAdminRoutes(store) {
|
|
3751
|
-
return adminRouteTable(store).map(([method, path, h]) => ({
|
|
3766
|
+
function describeAuthAdminRoutes(store, bus) {
|
|
3767
|
+
return adminRouteTable(store, bus).map(([method, path, h]) => ({
|
|
3752
3768
|
method,
|
|
3753
3769
|
path: path.slice(ADMIN_PREFIX.length),
|
|
3754
3770
|
handlers: [h]
|
|
@@ -3769,10 +3785,22 @@ var AuthModule = class {
|
|
|
3769
3785
|
name = "@fonderie/auth";
|
|
3770
3786
|
// Baked in at build time by tsup.base, so the operator's Modules page can
|
|
3771
3787
|
// say what is actually deployed rather than 'not reported'.
|
|
3772
|
-
version = "7.
|
|
3788
|
+
version = "7.19.0";
|
|
3773
3789
|
// Users, sessions, login history, suspend — only through @fonderie/admin.
|
|
3774
3790
|
describeAdmin() {
|
|
3775
|
-
return { routes: describeAuthAdminRoutes(this.store) };
|
|
3791
|
+
return { routes: describeAuthAdminRoutes(this.store, this.bus) };
|
|
3792
|
+
}
|
|
3793
|
+
// Live sign-out (docs/SESSION-DESIGN.md, Phase 5): a revoked device hears it
|
|
3794
|
+
// at once over @fonderie/sse — only the user it is about, ids only.
|
|
3795
|
+
describeEvents() {
|
|
3796
|
+
const revoked = {
|
|
3797
|
+
type: EVENT_KEYS.sessionRevoked,
|
|
3798
|
+
description: "Some of your sessions were signed out \u2014 sign out if this device is one of them",
|
|
3799
|
+
audience: "user",
|
|
3800
|
+
scope: (p) => ({ userId: p.userId }),
|
|
3801
|
+
project: (p) => ({ sids: p.sids, reason: p.reason })
|
|
3802
|
+
};
|
|
3803
|
+
return [revoked];
|
|
3776
3804
|
}
|
|
3777
3805
|
// Report config problems for app.checkProductionReadiness() (data, not throw).
|
|
3778
3806
|
checkReadiness() {
|
|
@@ -3788,7 +3816,7 @@ var AuthModule = class {
|
|
|
3788
3816
|
};
|
|
3789
3817
|
|
|
3790
3818
|
// src/templates.ts
|
|
3791
|
-
var
|
|
3819
|
+
var import_core14 = require("@fonderie/core");
|
|
3792
3820
|
|
|
3793
3821
|
// src/templates.es.ts
|
|
3794
3822
|
var ES_TEMPLATES = {
|
|
@@ -4265,7 +4293,7 @@ change your password \u2014 someone else may be able to sign in as you.`
|
|
|
4265
4293
|
If you did this, you're all set. If not, contact support right away.`
|
|
4266
4294
|
}
|
|
4267
4295
|
};
|
|
4268
|
-
var DEFAULT_TEMPLATES = (0,
|
|
4296
|
+
var DEFAULT_TEMPLATES = (0, import_core14.withTranslations)(EN_TEMPLATES, { fr: FR_TEMPLATES, es: ES_TEMPLATES });
|
|
4269
4297
|
var SAMPLE_PAYLOADS = {
|
|
4270
4298
|
[MESSAGE_KEYS.passwordRevoked]: { provider: "Google" },
|
|
4271
4299
|
[MESSAGE_KEYS.oauthRegistration]: { provider: "Google", appName: "Fonderie" },
|