@nexpress/core 0.1.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/LICENSE +21 -0
- package/README.md +69 -0
- package/dist/audit-54XLVCWD.js +14 -0
- package/dist/audit-54XLVCWD.js.map +1 -0
- package/dist/auth.d.ts +640 -0
- package/dist/auth.js +94 -0
- package/dist/auth.js.map +1 -0
- package/dist/can-YLUHRJAB.js +19 -0
- package/dist/can-YLUHRJAB.js.map +1 -0
- package/dist/chunk-2G264RCD.js +68 -0
- package/dist/chunk-2G264RCD.js.map +1 -0
- package/dist/chunk-2YDGE7YX.js +92 -0
- package/dist/chunk-2YDGE7YX.js.map +1 -0
- package/dist/chunk-473S4TER.js +538 -0
- package/dist/chunk-473S4TER.js.map +1 -0
- package/dist/chunk-4ZLMEKFX.js +18 -0
- package/dist/chunk-4ZLMEKFX.js.map +1 -0
- package/dist/chunk-55FU6WED.js +179 -0
- package/dist/chunk-55FU6WED.js.map +1 -0
- package/dist/chunk-6YI5K2TI.js +1959 -0
- package/dist/chunk-6YI5K2TI.js.map +1 -0
- package/dist/chunk-BHK3AD3Q.js +41 -0
- package/dist/chunk-BHK3AD3Q.js.map +1 -0
- package/dist/chunk-CRUQBZUF.js +39 -0
- package/dist/chunk-CRUQBZUF.js.map +1 -0
- package/dist/chunk-CTSQ7BRI.js +175 -0
- package/dist/chunk-CTSQ7BRI.js.map +1 -0
- package/dist/chunk-DK2JBJH7.js +81 -0
- package/dist/chunk-DK2JBJH7.js.map +1 -0
- package/dist/chunk-DP2PREDU.js +597 -0
- package/dist/chunk-DP2PREDU.js.map +1 -0
- package/dist/chunk-EQ2Z3KMD.js +24 -0
- package/dist/chunk-EQ2Z3KMD.js.map +1 -0
- package/dist/chunk-FZ7O6DWI.js +305 -0
- package/dist/chunk-FZ7O6DWI.js.map +1 -0
- package/dist/chunk-ISLYFQWL.js +1270 -0
- package/dist/chunk-ISLYFQWL.js.map +1 -0
- package/dist/chunk-JJL74ZPK.js +68 -0
- package/dist/chunk-JJL74ZPK.js.map +1 -0
- package/dist/chunk-JKXAPSU4.js +24 -0
- package/dist/chunk-JKXAPSU4.js.map +1 -0
- package/dist/chunk-KU5M27ZC.js +24 -0
- package/dist/chunk-KU5M27ZC.js.map +1 -0
- package/dist/chunk-LSHHRDVR.js +34 -0
- package/dist/chunk-LSHHRDVR.js.map +1 -0
- package/dist/chunk-M43PGOQY.js +715 -0
- package/dist/chunk-M43PGOQY.js.map +1 -0
- package/dist/chunk-MEJAHXIO.js +150 -0
- package/dist/chunk-MEJAHXIO.js.map +1 -0
- package/dist/chunk-NUCGHWCF.js +101 -0
- package/dist/chunk-NUCGHWCF.js.map +1 -0
- package/dist/chunk-OK5HOCQI.js +845 -0
- package/dist/chunk-OK5HOCQI.js.map +1 -0
- package/dist/chunk-OROPGO65.js +13 -0
- package/dist/chunk-OROPGO65.js.map +1 -0
- package/dist/chunk-PPAS4SZR.js +176 -0
- package/dist/chunk-PPAS4SZR.js.map +1 -0
- package/dist/chunk-PPBWRKO2.js +171 -0
- package/dist/chunk-PPBWRKO2.js.map +1 -0
- package/dist/chunk-PZ5AY32C.js +10 -0
- package/dist/chunk-PZ5AY32C.js.map +1 -0
- package/dist/chunk-QO7LAQZH.js +321 -0
- package/dist/chunk-QO7LAQZH.js.map +1 -0
- package/dist/chunk-QVJ2HCAX.js +225 -0
- package/dist/chunk-QVJ2HCAX.js.map +1 -0
- package/dist/chunk-RIPHIRPP.js +68 -0
- package/dist/chunk-RIPHIRPP.js.map +1 -0
- package/dist/chunk-S27S42QY.js +134 -0
- package/dist/chunk-S27S42QY.js.map +1 -0
- package/dist/chunk-SBCVAC2Z.js +40 -0
- package/dist/chunk-SBCVAC2Z.js.map +1 -0
- package/dist/chunk-TFJ4MKPH.js +694 -0
- package/dist/chunk-TFJ4MKPH.js.map +1 -0
- package/dist/chunk-THX3SHYA.js +75 -0
- package/dist/chunk-THX3SHYA.js.map +1 -0
- package/dist/chunk-UGQSQO5B.js +222 -0
- package/dist/chunk-UGQSQO5B.js.map +1 -0
- package/dist/chunk-V2UNHGAP.js +26 -0
- package/dist/chunk-V2UNHGAP.js.map +1 -0
- package/dist/chunk-VGTPQXNQ.js +2790 -0
- package/dist/chunk-VGTPQXNQ.js.map +1 -0
- package/dist/chunk-VNIHXQ7W.js +194 -0
- package/dist/chunk-VNIHXQ7W.js.map +1 -0
- package/dist/chunk-WV272MPW.js +31 -0
- package/dist/chunk-WV272MPW.js.map +1 -0
- package/dist/chunk-X5KKBOUS.js +26 -0
- package/dist/chunk-X5KKBOUS.js.map +1 -0
- package/dist/chunk-XANPEOJC.js +17 -0
- package/dist/chunk-XANPEOJC.js.map +1 -0
- package/dist/chunk-XPVQIHAQ.js +83 -0
- package/dist/chunk-XPVQIHAQ.js.map +1 -0
- package/dist/chunk-ZCINJSS4.js +75 -0
- package/dist/chunk-ZCINJSS4.js.map +1 -0
- package/dist/community.d.ts +1425 -0
- package/dist/community.js +206 -0
- package/dist/community.js.map +1 -0
- package/dist/config-2GDU7PCK.js +32 -0
- package/dist/config-2GDU7PCK.js.map +1 -0
- package/dist/context-MNZ4QXPC.js +16 -0
- package/dist/context-MNZ4QXPC.js.map +1 -0
- package/dist/db-schema.d.ts +4 -0
- package/dist/db-schema.js +102 -0
- package/dist/db-schema.js.map +1 -0
- package/dist/db.d.ts +7 -0
- package/dist/db.js +117 -0
- package/dist/db.js.map +1 -0
- package/dist/digest-SY42GQSU.js +17 -0
- package/dist/digest-SY42GQSU.js.map +1 -0
- package/dist/errors-5OS3S2J3.js +22 -0
- package/dist/errors-5OS3S2J3.js.map +1 -0
- package/dist/host-OBOI4MJK.js +51 -0
- package/dist/host-OBOI4MJK.js.map +1 -0
- package/dist/i18n.d.ts +301 -0
- package/dist/i18n.js +68 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index-B6-_vr_m.d.ts +590 -0
- package/dist/index-CY55LC0u.d.ts +4722 -0
- package/dist/index-CeiTvwbp.d.ts +168 -0
- package/dist/index-XwP1ET8b.d.ts +61 -0
- package/dist/index.d.ts +2037 -0
- package/dist/index.js +2205 -0
- package/dist/index.js.map +1 -0
- package/dist/job-log-VZXWQUDK.js +24 -0
- package/dist/job-log-VZXWQUDK.js.map +1 -0
- package/dist/jobs.d.ts +4 -0
- package/dist/jobs.js +76 -0
- package/dist/jobs.js.map +1 -0
- package/dist/logger-DqGaOU_j.d.ts +29 -0
- package/dist/logger-S7REWDNE.js +16 -0
- package/dist/logger-S7REWDNE.js.map +1 -0
- package/dist/media.d.ts +5 -0
- package/dist/media.js +41 -0
- package/dist/media.js.map +1 -0
- package/dist/mentions-2IHFVSHW.js +23 -0
- package/dist/mentions-2IHFVSHW.js.map +1 -0
- package/dist/mutes-EWAE5FZR.js +21 -0
- package/dist/mutes-EWAE5FZR.js.map +1 -0
- package/dist/notification-prefs-VPJDU7I6.js +21 -0
- package/dist/notification-prefs-VPJDU7I6.js.map +1 -0
- package/dist/observability.d.ts +156 -0
- package/dist/observability.js +32 -0
- package/dist/observability.js.map +1 -0
- package/dist/profanity-adapter-NU2JQSLX.js +12 -0
- package/dist/profanity-adapter-NU2JQSLX.js.map +1 -0
- package/dist/queue-XE5BC75T.js +14 -0
- package/dist/queue-XE5BC75T.js.map +1 -0
- package/dist/rate-limit.d.ts +99 -0
- package/dist/rate-limit.js +14 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/registry-XIXDEPVI.js +31 -0
- package/dist/registry-XIXDEPVI.js.map +1 -0
- package/dist/reputation-JRL2YQHM.js +11 -0
- package/dist/reputation-JRL2YQHM.js.map +1 -0
- package/dist/routes.d.ts +43 -0
- package/dist/routes.js +12 -0
- package/dist/routes.js.map +1 -0
- package/dist/scheduled-CIQM57HT.js +20 -0
- package/dist/scheduled-CIQM57HT.js.map +1 -0
- package/dist/seo.d.ts +410 -0
- package/dist/seo.js +44 -0
- package/dist/seo.js.map +1 -0
- package/dist/settings-FOBIESPB.js +17 -0
- package/dist/settings-FOBIESPB.js.map +1 -0
- package/dist/spam-adapter-XX3G737Z.js +12 -0
- package/dist/spam-adapter-XX3G737Z.js.map +1 -0
- package/dist/strings-VAE47B2C.js +29 -0
- package/dist/strings-VAE47B2C.js.map +1 -0
- package/dist/templates-IFVJMCJ6.js +12 -0
- package/dist/templates-IFVJMCJ6.js.map +1 -0
- package/dist/types-TlsbXS0T.d.ts +871 -0
- package/package.json +129 -0
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
import {
|
|
2
|
+
recordAuditEvent
|
|
3
|
+
} from "./chunk-RIPHIRPP.js";
|
|
4
|
+
import {
|
|
5
|
+
getCommunitySettings
|
|
6
|
+
} from "./chunk-PPBWRKO2.js";
|
|
7
|
+
import {
|
|
8
|
+
NpAuthError,
|
|
9
|
+
NpForbiddenError,
|
|
10
|
+
NpNotFoundError,
|
|
11
|
+
NpValidationError
|
|
12
|
+
} from "./chunk-ZCINJSS4.js";
|
|
13
|
+
import {
|
|
14
|
+
readEnvPositiveInt
|
|
15
|
+
} from "./chunk-OROPGO65.js";
|
|
16
|
+
import {
|
|
17
|
+
getDb
|
|
18
|
+
} from "./chunk-XANPEOJC.js";
|
|
19
|
+
import {
|
|
20
|
+
npMemberIdentities,
|
|
21
|
+
npMemberSessions,
|
|
22
|
+
npMembers,
|
|
23
|
+
npSessions,
|
|
24
|
+
npUserOAuthIdentities,
|
|
25
|
+
npUsers
|
|
26
|
+
} from "./chunk-M43PGOQY.js";
|
|
27
|
+
|
|
28
|
+
// src/config/access.ts
|
|
29
|
+
var authenticated = ({ user }) => !!user;
|
|
30
|
+
var isAdmin = ({ user }) => user?.role === "admin";
|
|
31
|
+
var isEditorOrAbove = ({ user }) => !!user && (user.role === "admin" || user.role === "editor");
|
|
32
|
+
var isOwnerOrAdmin = ({ user, doc }) => user?.role === "admin" || doc?.createdBy === user?.id;
|
|
33
|
+
|
|
34
|
+
// src/auth/token.ts
|
|
35
|
+
import { randomBytes } from "crypto";
|
|
36
|
+
import { jwtVerify, SignJWT, errors as joseErrors } from "jose";
|
|
37
|
+
var textEncoder = new TextEncoder();
|
|
38
|
+
async function signToken(user, secret, expirationSeconds = 7200, tokenUse = "access") {
|
|
39
|
+
const secretKey = textEncoder.encode(secret);
|
|
40
|
+
return new SignJWT({
|
|
41
|
+
sub: user.id,
|
|
42
|
+
role: user.role,
|
|
43
|
+
ver: user.tokenVersion,
|
|
44
|
+
use: tokenUse
|
|
45
|
+
}).setProtectedHeader({ alg: "HS256" }).setJti(randomBytes(16).toString("base64url")).setIssuedAt().setExpirationTime(Math.floor(Date.now() / 1e3) + expirationSeconds).sign(secretKey);
|
|
46
|
+
}
|
|
47
|
+
async function verifyToken(token, secret, expectedUse) {
|
|
48
|
+
const secretKey = textEncoder.encode(secret);
|
|
49
|
+
const { payload } = await jwtVerify(token, secretKey);
|
|
50
|
+
const typed = payload;
|
|
51
|
+
if (typed.use !== "access" && typed.use !== "refresh") {
|
|
52
|
+
throw new NpAuthError("Staff token missing `use` claim");
|
|
53
|
+
}
|
|
54
|
+
const use = typed.use;
|
|
55
|
+
if (expectedUse && use !== expectedUse) {
|
|
56
|
+
throw new NpAuthError(
|
|
57
|
+
`Staff token use mismatch: expected ${expectedUse}, got ${use}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return { ...typed, use };
|
|
61
|
+
}
|
|
62
|
+
function isTokenVerificationError(err) {
|
|
63
|
+
if (err instanceof NpAuthError) return true;
|
|
64
|
+
if (err instanceof joseErrors.JOSEError) return true;
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/auth/password.ts
|
|
69
|
+
import { hash, verify } from "@node-rs/argon2";
|
|
70
|
+
var ARGON2_OPTIONS = {
|
|
71
|
+
memoryCost: 19456,
|
|
72
|
+
timeCost: 2,
|
|
73
|
+
outputLen: 32,
|
|
74
|
+
parallelism: 1
|
|
75
|
+
};
|
|
76
|
+
var TEST_ARGON2_OPTIONS = {
|
|
77
|
+
memoryCost: 8,
|
|
78
|
+
timeCost: 1,
|
|
79
|
+
outputLen: 32,
|
|
80
|
+
parallelism: 1
|
|
81
|
+
};
|
|
82
|
+
function hashPassword(password) {
|
|
83
|
+
return hash(
|
|
84
|
+
password,
|
|
85
|
+
process.env.NP_TEST_FAST_HASH === "1" ? TEST_ARGON2_OPTIONS : ARGON2_OPTIONS
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
function verifyPassword(passwordHash, password) {
|
|
89
|
+
return verify(passwordHash, password);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/auth/csrf.ts
|
|
93
|
+
var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
|
|
94
|
+
function verifyCsrf(method, cookieToken, headerToken) {
|
|
95
|
+
if (SAFE_METHODS.has(method.toUpperCase())) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
return Boolean(cookieToken && headerToken && cookieToken === headerToken);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/auth/oauth-providers.ts
|
|
102
|
+
var providers = /* @__PURE__ */ new Map();
|
|
103
|
+
function registerOAuthProvider(provider) {
|
|
104
|
+
if (!provider.id || typeof provider.id !== "string") {
|
|
105
|
+
throw new Error("OAuth provider must have a non-empty string id");
|
|
106
|
+
}
|
|
107
|
+
if (typeof provider.authorize !== "function" || typeof provider.exchange !== "function") {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`OAuth provider "${provider.id}" must implement authorize() and exchange()`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
providers.set(provider.id, provider);
|
|
113
|
+
}
|
|
114
|
+
function getOAuthProvider(id) {
|
|
115
|
+
return providers.get(id);
|
|
116
|
+
}
|
|
117
|
+
function listOAuthProviders() {
|
|
118
|
+
return Array.from(providers.values());
|
|
119
|
+
}
|
|
120
|
+
function resetOAuthProviders() {
|
|
121
|
+
providers.clear();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// src/auth/oauth-resolve.ts
|
|
125
|
+
import { eq, and, sql } from "drizzle-orm";
|
|
126
|
+
var SYNTHETIC_EMAIL_SUFFIX = ".oauth.local";
|
|
127
|
+
function syntheticEmail(provider, providerUserId) {
|
|
128
|
+
return `${providerUserId}@${provider}${SYNTHETIC_EMAIL_SUFFIX}`;
|
|
129
|
+
}
|
|
130
|
+
function deriveName(profile, fallbackEmail) {
|
|
131
|
+
if (profile.name && profile.name.trim().length > 0) return profile.name.trim();
|
|
132
|
+
const localPart = fallbackEmail.split("@")[0];
|
|
133
|
+
return localPart && localPart.length > 0 ? localPart : "Member";
|
|
134
|
+
}
|
|
135
|
+
async function resolveOAuthLogin(input) {
|
|
136
|
+
const db = getDb();
|
|
137
|
+
const provider = input.provider;
|
|
138
|
+
const profile = input.profile;
|
|
139
|
+
const role = input.defaultRole ?? "viewer";
|
|
140
|
+
const [existingLink] = await db.select({
|
|
141
|
+
userId: npUserOAuthIdentities.userId,
|
|
142
|
+
identityId: npUserOAuthIdentities.id
|
|
143
|
+
}).from(npUserOAuthIdentities).where(
|
|
144
|
+
and(
|
|
145
|
+
eq(npUserOAuthIdentities.provider, provider),
|
|
146
|
+
eq(npUserOAuthIdentities.providerUserId, profile.providerUserId)
|
|
147
|
+
)
|
|
148
|
+
).limit(1);
|
|
149
|
+
if (existingLink) {
|
|
150
|
+
const metadata = mergeMetadata(profile);
|
|
151
|
+
await db.update(npUserOAuthIdentities).set({ metadata, updatedAt: /* @__PURE__ */ new Date() }).where(eq(npUserOAuthIdentities.id, existingLink.identityId));
|
|
152
|
+
const user = await loadUser(db, existingLink.userId);
|
|
153
|
+
return { user, created: false, linked: false };
|
|
154
|
+
}
|
|
155
|
+
if (profile.email) {
|
|
156
|
+
const normalizedEmail = profile.email.trim().toLowerCase();
|
|
157
|
+
const [existingUser] = await db.select({
|
|
158
|
+
id: npUsers.id,
|
|
159
|
+
email: npUsers.email,
|
|
160
|
+
name: npUsers.name,
|
|
161
|
+
role: npUsers.role,
|
|
162
|
+
tokenVersion: npUsers.tokenVersion
|
|
163
|
+
}).from(npUsers).where(eq(sql`lower(${npUsers.email})`, normalizedEmail)).limit(1);
|
|
164
|
+
if (existingUser) {
|
|
165
|
+
await db.insert(npUserOAuthIdentities).values({
|
|
166
|
+
userId: existingUser.id,
|
|
167
|
+
provider,
|
|
168
|
+
providerUserId: profile.providerUserId,
|
|
169
|
+
metadata: mergeMetadata(profile)
|
|
170
|
+
});
|
|
171
|
+
return { user: existingUser, created: false, linked: true };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const email = profile.email && profile.email.trim().length > 0 ? profile.email.trim().toLowerCase() : syntheticEmail(provider, profile.providerUserId);
|
|
175
|
+
const name = deriveName(profile, email);
|
|
176
|
+
const placeholderPassword = await hashPassword(
|
|
177
|
+
crypto.randomUUID() + crypto.randomUUID()
|
|
178
|
+
);
|
|
179
|
+
const [created] = await db.insert(npUsers).values({
|
|
180
|
+
email,
|
|
181
|
+
name,
|
|
182
|
+
password: placeholderPassword,
|
|
183
|
+
role
|
|
184
|
+
}).returning({
|
|
185
|
+
id: npUsers.id,
|
|
186
|
+
email: npUsers.email,
|
|
187
|
+
name: npUsers.name,
|
|
188
|
+
role: npUsers.role,
|
|
189
|
+
tokenVersion: npUsers.tokenVersion
|
|
190
|
+
});
|
|
191
|
+
await db.insert(npUserOAuthIdentities).values({
|
|
192
|
+
userId: created.id,
|
|
193
|
+
provider,
|
|
194
|
+
providerUserId: profile.providerUserId,
|
|
195
|
+
metadata: mergeMetadata(profile)
|
|
196
|
+
});
|
|
197
|
+
return { user: created, created: true, linked: true };
|
|
198
|
+
}
|
|
199
|
+
function mergeMetadata(profile) {
|
|
200
|
+
const base = {};
|
|
201
|
+
if (profile.avatarUrl) base.avatarUrl = profile.avatarUrl;
|
|
202
|
+
if (profile.email) base.email = profile.email;
|
|
203
|
+
if (profile.name) base.name = profile.name;
|
|
204
|
+
if (profile.metadata) Object.assign(base, profile.metadata);
|
|
205
|
+
return base;
|
|
206
|
+
}
|
|
207
|
+
async function loadUser(db, userId) {
|
|
208
|
+
const [row] = await db.select({
|
|
209
|
+
id: npUsers.id,
|
|
210
|
+
email: npUsers.email,
|
|
211
|
+
name: npUsers.name,
|
|
212
|
+
role: npUsers.role,
|
|
213
|
+
tokenVersion: npUsers.tokenVersion
|
|
214
|
+
}).from(npUsers).where(eq(npUsers.id, userId)).limit(1);
|
|
215
|
+
if (!row) {
|
|
216
|
+
throw new Error(`User ${userId} referenced by oauth identity is missing`);
|
|
217
|
+
}
|
|
218
|
+
return row;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// src/auth/oauth-resolve-member.ts
|
|
222
|
+
import { and as and2, eq as eq2, sql as sql2 } from "drizzle-orm";
|
|
223
|
+
var SYNTHETIC_EMAIL_SUFFIX2 = ".oauth.local";
|
|
224
|
+
var HANDLE_FALLBACK = "user";
|
|
225
|
+
var HANDLE_RANDOM_SUFFIX_BYTES = 4;
|
|
226
|
+
function syntheticEmail2(provider, providerUserId) {
|
|
227
|
+
return `${providerUserId}@${provider}${SYNTHETIC_EMAIL_SUFFIX2}`;
|
|
228
|
+
}
|
|
229
|
+
function generateHandle(profile, fallbackEmail) {
|
|
230
|
+
const seed = profile.metadata && typeof profile.metadata.login === "string" && profile.metadata.login || profile.name || fallbackEmail.split("@")[0] || HANDLE_FALLBACK;
|
|
231
|
+
const sanitized = String(seed).toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^[-_]+/, "").slice(0, 20);
|
|
232
|
+
const base = sanitized.length >= 3 ? sanitized : HANDLE_FALLBACK;
|
|
233
|
+
const suffix = Math.random().toString(36).slice(2, 2 + HANDLE_RANDOM_SUFFIX_BYTES);
|
|
234
|
+
return `${base}-${suffix}`.slice(0, 30);
|
|
235
|
+
}
|
|
236
|
+
function deriveDisplayName(profile, fallbackEmail) {
|
|
237
|
+
if (profile.name && profile.name.trim().length > 0) return profile.name.trim();
|
|
238
|
+
const localPart = fallbackEmail.split("@")[0];
|
|
239
|
+
return localPart && localPart.length > 0 ? localPart : "Member";
|
|
240
|
+
}
|
|
241
|
+
function mergeMetadata2(profile) {
|
|
242
|
+
const base = {};
|
|
243
|
+
if (profile.avatarUrl) base.avatarUrl = profile.avatarUrl;
|
|
244
|
+
if (profile.email) base.email = profile.email;
|
|
245
|
+
if (profile.name) base.name = profile.name;
|
|
246
|
+
if (profile.metadata) Object.assign(base, profile.metadata);
|
|
247
|
+
return base;
|
|
248
|
+
}
|
|
249
|
+
async function loadMember(db, memberId) {
|
|
250
|
+
const [row] = await db.select({
|
|
251
|
+
id: npMembers.id,
|
|
252
|
+
email: npMembers.email,
|
|
253
|
+
handle: npMembers.handle,
|
|
254
|
+
displayName: npMembers.displayName,
|
|
255
|
+
status: npMembers.status,
|
|
256
|
+
tokenVersion: npMembers.tokenVersion
|
|
257
|
+
}).from(npMembers).where(eq2(npMembers.id, memberId)).limit(1);
|
|
258
|
+
if (!row) {
|
|
259
|
+
throw new Error(`Member ${memberId} referenced by oauth identity is missing`);
|
|
260
|
+
}
|
|
261
|
+
return row;
|
|
262
|
+
}
|
|
263
|
+
async function resolveMemberOAuthLogin(input) {
|
|
264
|
+
const db = getDb();
|
|
265
|
+
const { provider, profile } = input;
|
|
266
|
+
const [existingLink] = await db.select({ memberId: npMemberIdentities.memberId, identityId: npMemberIdentities.id }).from(npMemberIdentities).where(
|
|
267
|
+
and2(
|
|
268
|
+
eq2(npMemberIdentities.provider, provider),
|
|
269
|
+
eq2(npMemberIdentities.subject, profile.providerUserId)
|
|
270
|
+
)
|
|
271
|
+
).limit(1);
|
|
272
|
+
if (existingLink) {
|
|
273
|
+
await db.update(npMemberIdentities).set({ metadata: mergeMetadata2(profile), updatedAt: /* @__PURE__ */ new Date() }).where(eq2(npMemberIdentities.id, existingLink.identityId));
|
|
274
|
+
const member = await loadMember(db, existingLink.memberId);
|
|
275
|
+
return { member, created: false, linked: false };
|
|
276
|
+
}
|
|
277
|
+
if (profile.email) {
|
|
278
|
+
const normalizedEmail = profile.email.trim().toLowerCase();
|
|
279
|
+
const [existingMember] = await db.select({
|
|
280
|
+
id: npMembers.id,
|
|
281
|
+
email: npMembers.email,
|
|
282
|
+
handle: npMembers.handle,
|
|
283
|
+
displayName: npMembers.displayName,
|
|
284
|
+
status: npMembers.status,
|
|
285
|
+
tokenVersion: npMembers.tokenVersion
|
|
286
|
+
}).from(npMembers).where(eq2(sql2`lower(${npMembers.email})`, normalizedEmail)).limit(1);
|
|
287
|
+
if (existingMember) {
|
|
288
|
+
if (existingMember.status !== "active") {
|
|
289
|
+
return { member: existingMember, created: false, linked: false };
|
|
290
|
+
}
|
|
291
|
+
await db.insert(npMemberIdentities).values({
|
|
292
|
+
memberId: existingMember.id,
|
|
293
|
+
provider,
|
|
294
|
+
subject: profile.providerUserId,
|
|
295
|
+
email: profile.email,
|
|
296
|
+
metadata: mergeMetadata2(profile)
|
|
297
|
+
});
|
|
298
|
+
return { member: existingMember, created: false, linked: true };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const settings = await getCommunitySettings();
|
|
302
|
+
if (!settings.registrationEnabled) {
|
|
303
|
+
throw new NpForbiddenError("members", "register");
|
|
304
|
+
}
|
|
305
|
+
const email = profile.email && profile.email.trim().length > 0 ? profile.email.trim().toLowerCase() : syntheticEmail2(provider, profile.providerUserId);
|
|
306
|
+
const displayName = deriveDisplayName(profile, email);
|
|
307
|
+
const handle = generateHandle(profile, email);
|
|
308
|
+
const placeholderPassword = await hashPassword(
|
|
309
|
+
crypto.randomUUID() + crypto.randomUUID()
|
|
310
|
+
);
|
|
311
|
+
const [created] = await db.insert(npMembers).values({
|
|
312
|
+
email,
|
|
313
|
+
handle,
|
|
314
|
+
displayName,
|
|
315
|
+
password: placeholderPassword,
|
|
316
|
+
// OAuth verifies the address out-of-band (the provider showed the
|
|
317
|
+
// user a real login screen for it), so skip the email-verify
|
|
318
|
+
// dance that password registration goes through.
|
|
319
|
+
emailVerified: true,
|
|
320
|
+
status: "active"
|
|
321
|
+
}).returning({
|
|
322
|
+
id: npMembers.id,
|
|
323
|
+
email: npMembers.email,
|
|
324
|
+
handle: npMembers.handle,
|
|
325
|
+
displayName: npMembers.displayName,
|
|
326
|
+
status: npMembers.status,
|
|
327
|
+
tokenVersion: npMembers.tokenVersion
|
|
328
|
+
});
|
|
329
|
+
await db.insert(npMemberIdentities).values({
|
|
330
|
+
memberId: created.id,
|
|
331
|
+
provider,
|
|
332
|
+
subject: profile.providerUserId,
|
|
333
|
+
email: profile.email ?? null,
|
|
334
|
+
metadata: mergeMetadata2(profile)
|
|
335
|
+
});
|
|
336
|
+
return { member: created, created: true, linked: true };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/auth/oauth-state.ts
|
|
340
|
+
import { createHmac, randomBytes as randomBytes2, timingSafeEqual } from "crypto";
|
|
341
|
+
var STATE_TTL_SECONDS = readEnvPositiveInt("NP_OAUTH_STATE_TTL_SECONDS", 600);
|
|
342
|
+
var CODE_VERIFIER_BYTES = 32;
|
|
343
|
+
function b64url(input) {
|
|
344
|
+
return Buffer.from(input).toString("base64url");
|
|
345
|
+
}
|
|
346
|
+
function sign(payload, secret) {
|
|
347
|
+
return createHmac("sha256", secret).update(payload).digest("base64url");
|
|
348
|
+
}
|
|
349
|
+
function issueOAuthState(providerId, secret) {
|
|
350
|
+
const nonce = randomBytes2(16).toString("base64url");
|
|
351
|
+
const codeVerifier = randomBytes2(CODE_VERIFIER_BYTES).toString("base64url");
|
|
352
|
+
const expSeconds = Math.floor(Date.now() / 1e3) + STATE_TTL_SECONDS;
|
|
353
|
+
const payload = { providerId, nonce, expSeconds, codeVerifier };
|
|
354
|
+
const encoded = b64url(JSON.stringify(payload));
|
|
355
|
+
const sig = sign(encoded, secret);
|
|
356
|
+
return { token: `${encoded}.${sig}`, codeVerifier };
|
|
357
|
+
}
|
|
358
|
+
function verifyOAuthState(token, expectedProviderId, secret) {
|
|
359
|
+
if (typeof token !== "string" || !token.includes(".")) {
|
|
360
|
+
return { ok: false, reason: "format" };
|
|
361
|
+
}
|
|
362
|
+
const [encoded, sig] = token.split(".");
|
|
363
|
+
if (!encoded || !sig) {
|
|
364
|
+
return { ok: false, reason: "format" };
|
|
365
|
+
}
|
|
366
|
+
const expectedSig = sign(encoded, secret);
|
|
367
|
+
const sigBuf = Buffer.from(sig);
|
|
368
|
+
const expectedBuf = Buffer.from(expectedSig);
|
|
369
|
+
if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
|
|
370
|
+
return { ok: false, reason: "signature" };
|
|
371
|
+
}
|
|
372
|
+
let payload;
|
|
373
|
+
try {
|
|
374
|
+
payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
375
|
+
} catch {
|
|
376
|
+
return { ok: false, reason: "format" };
|
|
377
|
+
}
|
|
378
|
+
if (!payload || typeof payload.providerId !== "string" || typeof payload.nonce !== "string" || typeof payload.expSeconds !== "number" || typeof payload.codeVerifier !== "string" || payload.codeVerifier.length === 0) {
|
|
379
|
+
return { ok: false, reason: "format" };
|
|
380
|
+
}
|
|
381
|
+
if (payload.providerId !== expectedProviderId) {
|
|
382
|
+
return { ok: false, reason: "signature" };
|
|
383
|
+
}
|
|
384
|
+
if (payload.expSeconds <= Math.floor(Date.now() / 1e3)) {
|
|
385
|
+
return { ok: false, reason: "expired" };
|
|
386
|
+
}
|
|
387
|
+
return { ok: true, payload };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// src/auth/oauth-arctic.ts
|
|
391
|
+
function fromArctic(factory, opts) {
|
|
392
|
+
const usePkce = opts.pkce !== false;
|
|
393
|
+
const scopes = opts.scopes ?? [];
|
|
394
|
+
return {
|
|
395
|
+
id: opts.id,
|
|
396
|
+
label: opts.label,
|
|
397
|
+
authorize({ state, redirectUri, codeVerifier }) {
|
|
398
|
+
const arctic = factory(redirectUri);
|
|
399
|
+
const url = usePkce ? arctic.createAuthorizationURL(state, codeVerifier, scopes) : arctic.createAuthorizationURL(state, scopes);
|
|
400
|
+
return url.toString();
|
|
401
|
+
},
|
|
402
|
+
async exchange({ code, redirectUri, codeVerifier }) {
|
|
403
|
+
const arctic = factory(redirectUri);
|
|
404
|
+
const tokens = usePkce ? await arctic.validateAuthorizationCode(code, codeVerifier) : await arctic.validateAuthorizationCode(code);
|
|
405
|
+
return opts.fetchProfile(tokens.accessToken(), tokens);
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// src/auth/session.ts
|
|
411
|
+
import { webcrypto } from "crypto";
|
|
412
|
+
import { eq as eq3, sql as sql3 } from "drizzle-orm";
|
|
413
|
+
async function sha256(input) {
|
|
414
|
+
const digest = await webcrypto.subtle.digest(
|
|
415
|
+
"SHA-256",
|
|
416
|
+
new TextEncoder().encode(input)
|
|
417
|
+
);
|
|
418
|
+
return Array.from(
|
|
419
|
+
new Uint8Array(digest),
|
|
420
|
+
(byte) => byte.toString(16).padStart(2, "0")
|
|
421
|
+
).join("");
|
|
422
|
+
}
|
|
423
|
+
async function verifyTokenFull(token, secret, db, expectedUse = "access") {
|
|
424
|
+
const payload = await verifyToken(token, secret, expectedUse);
|
|
425
|
+
const [user] = await db.select({
|
|
426
|
+
id: npUsers.id,
|
|
427
|
+
email: npUsers.email,
|
|
428
|
+
name: npUsers.name,
|
|
429
|
+
role: npUsers.role,
|
|
430
|
+
tokenVersion: npUsers.tokenVersion
|
|
431
|
+
}).from(npUsers).where(eq3(npUsers.id, payload.sub)).limit(1);
|
|
432
|
+
if (!user || user.tokenVersion !== payload.ver) {
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
return user;
|
|
436
|
+
}
|
|
437
|
+
async function invalidateAllSessions(userId, db) {
|
|
438
|
+
await db.transaction(async (tx) => {
|
|
439
|
+
await tx.update(npUsers).set({
|
|
440
|
+
tokenVersion: sql3`${npUsers.tokenVersion} + 1`
|
|
441
|
+
}).where(eq3(npUsers.id, userId));
|
|
442
|
+
await tx.delete(npSessions).where(eq3(npSessions.userId, userId));
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// src/auth/identities-admin.ts
|
|
447
|
+
import { and as and3, desc, eq as eq4 } from "drizzle-orm";
|
|
448
|
+
async function assertUserExists(userId) {
|
|
449
|
+
const db = getDb();
|
|
450
|
+
const [row] = await db.select({ id: npUsers.id }).from(npUsers).where(eq4(npUsers.id, userId)).limit(1);
|
|
451
|
+
if (!row) throw new NpNotFoundError("user", userId);
|
|
452
|
+
}
|
|
453
|
+
async function assertMemberExists(memberId) {
|
|
454
|
+
const db = getDb();
|
|
455
|
+
const [row] = await db.select({ id: npMembers.id }).from(npMembers).where(eq4(npMembers.id, memberId)).limit(1);
|
|
456
|
+
if (!row) throw new NpNotFoundError("member", memberId);
|
|
457
|
+
}
|
|
458
|
+
async function listUserIdentities(userId) {
|
|
459
|
+
await assertUserExists(userId);
|
|
460
|
+
const db = getDb();
|
|
461
|
+
const rows = await db.select().from(npUserOAuthIdentities).where(eq4(npUserOAuthIdentities.userId, userId)).orderBy(desc(npUserOAuthIdentities.createdAt));
|
|
462
|
+
return rows;
|
|
463
|
+
}
|
|
464
|
+
async function listMemberIdentities(memberId) {
|
|
465
|
+
await assertMemberExists(memberId);
|
|
466
|
+
const db = getDb();
|
|
467
|
+
const rows = await db.select().from(npMemberIdentities).where(eq4(npMemberIdentities.memberId, memberId)).orderBy(desc(npMemberIdentities.createdAt));
|
|
468
|
+
return rows;
|
|
469
|
+
}
|
|
470
|
+
async function revokeUserIdentity(userId, identityId, actor) {
|
|
471
|
+
const db = getDb();
|
|
472
|
+
const [existing] = await db.select().from(npUserOAuthIdentities).where(
|
|
473
|
+
and3(
|
|
474
|
+
eq4(npUserOAuthIdentities.id, identityId),
|
|
475
|
+
eq4(npUserOAuthIdentities.userId, userId)
|
|
476
|
+
)
|
|
477
|
+
).limit(1);
|
|
478
|
+
if (!existing) {
|
|
479
|
+
throw new NpNotFoundError("identity", identityId);
|
|
480
|
+
}
|
|
481
|
+
const deleted = await db.delete(npUserOAuthIdentities).where(eq4(npUserOAuthIdentities.id, identityId)).returning({ id: npUserOAuthIdentities.id });
|
|
482
|
+
if (deleted.length === 0) return;
|
|
483
|
+
await recordAuditEvent({
|
|
484
|
+
actor: { kind: "staff", userId: actor.staffUserId },
|
|
485
|
+
action: "user.identity.revoke",
|
|
486
|
+
targetType: "user",
|
|
487
|
+
targetId: userId,
|
|
488
|
+
payload: {
|
|
489
|
+
identityId,
|
|
490
|
+
provider: existing.provider,
|
|
491
|
+
providerUserId: existing.providerUserId
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
async function revokeMemberIdentity(memberId, identityId, actor) {
|
|
496
|
+
const db = getDb();
|
|
497
|
+
const [existing] = await db.select().from(npMemberIdentities).where(
|
|
498
|
+
and3(
|
|
499
|
+
eq4(npMemberIdentities.id, identityId),
|
|
500
|
+
eq4(npMemberIdentities.memberId, memberId)
|
|
501
|
+
)
|
|
502
|
+
).limit(1);
|
|
503
|
+
if (!existing) throw new NpNotFoundError("identity", identityId);
|
|
504
|
+
const deleted = await db.delete(npMemberIdentities).where(eq4(npMemberIdentities.id, identityId)).returning({ id: npMemberIdentities.id });
|
|
505
|
+
if (deleted.length === 0) return;
|
|
506
|
+
await recordAuditEvent({
|
|
507
|
+
actor: { kind: "staff", userId: actor.staffUserId },
|
|
508
|
+
action: "member.identity.revoke",
|
|
509
|
+
targetType: "member",
|
|
510
|
+
targetId: memberId,
|
|
511
|
+
payload: {
|
|
512
|
+
identityId,
|
|
513
|
+
provider: existing.provider,
|
|
514
|
+
subject: existing.subject
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// src/auth/reset-token.ts
|
|
520
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
521
|
+
import { and as and4, eq as eq5, gt, isNotNull, sql as sql4 } from "drizzle-orm";
|
|
522
|
+
var MIN_PASSWORD_LENGTH = 8;
|
|
523
|
+
function generateRawToken() {
|
|
524
|
+
return randomBytes3(32).toString("hex");
|
|
525
|
+
}
|
|
526
|
+
async function createPasswordResetToken(db, options) {
|
|
527
|
+
const token = generateRawToken();
|
|
528
|
+
const tokenHash = await sha256(token);
|
|
529
|
+
const expiresAt = new Date(Date.now() + options.ttlMs);
|
|
530
|
+
await db.update(npUsers).set({
|
|
531
|
+
passwordResetTokenHash: tokenHash,
|
|
532
|
+
passwordResetExpiresAt: expiresAt,
|
|
533
|
+
passwordResetPurpose: options.purpose,
|
|
534
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
535
|
+
}).where(eq5(npUsers.id, options.userId));
|
|
536
|
+
return { token, expiresAt, purpose: options.purpose };
|
|
537
|
+
}
|
|
538
|
+
async function requestPasswordReset(db, email, ttlMs) {
|
|
539
|
+
const normalizedEmail = email.trim().toLowerCase();
|
|
540
|
+
const [user] = await db.select({
|
|
541
|
+
id: npUsers.id,
|
|
542
|
+
email: npUsers.email,
|
|
543
|
+
name: npUsers.name
|
|
544
|
+
}).from(npUsers).where(eq5(npUsers.email, normalizedEmail)).limit(1);
|
|
545
|
+
if (!user) {
|
|
546
|
+
return { userId: null, name: null, email: null, issued: null };
|
|
547
|
+
}
|
|
548
|
+
const issued = await createPasswordResetToken(db, {
|
|
549
|
+
userId: user.id,
|
|
550
|
+
purpose: "reset",
|
|
551
|
+
ttlMs
|
|
552
|
+
});
|
|
553
|
+
return { userId: user.id, name: user.name, email: user.email, issued };
|
|
554
|
+
}
|
|
555
|
+
async function consumePasswordResetToken(db, options) {
|
|
556
|
+
if (!options.token || typeof options.token !== "string") {
|
|
557
|
+
throw new NpValidationError("Invalid input", [
|
|
558
|
+
{ field: "token", message: "Reset token is required." }
|
|
559
|
+
]);
|
|
560
|
+
}
|
|
561
|
+
if (!options.newPassword || options.newPassword.length < MIN_PASSWORD_LENGTH) {
|
|
562
|
+
throw new NpValidationError("Invalid input", [
|
|
563
|
+
{
|
|
564
|
+
field: "password",
|
|
565
|
+
message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters.`
|
|
566
|
+
}
|
|
567
|
+
]);
|
|
568
|
+
}
|
|
569
|
+
const tokenHash = await sha256(options.token);
|
|
570
|
+
const now = /* @__PURE__ */ new Date();
|
|
571
|
+
const [user] = await db.select({
|
|
572
|
+
id: npUsers.id,
|
|
573
|
+
email: npUsers.email,
|
|
574
|
+
purpose: npUsers.passwordResetPurpose
|
|
575
|
+
}).from(npUsers).where(
|
|
576
|
+
and4(
|
|
577
|
+
eq5(npUsers.passwordResetTokenHash, tokenHash),
|
|
578
|
+
isNotNull(npUsers.passwordResetExpiresAt),
|
|
579
|
+
gt(npUsers.passwordResetExpiresAt, now)
|
|
580
|
+
)
|
|
581
|
+
).limit(1);
|
|
582
|
+
if (!user) {
|
|
583
|
+
throw new NpValidationError("Invalid input", [
|
|
584
|
+
{ field: "token", message: "Reset link is invalid or has expired." }
|
|
585
|
+
]);
|
|
586
|
+
}
|
|
587
|
+
const newPasswordHash = await hashPassword(options.newPassword);
|
|
588
|
+
await db.transaction(async (tx) => {
|
|
589
|
+
await tx.update(npUsers).set({
|
|
590
|
+
password: newPasswordHash,
|
|
591
|
+
passwordResetTokenHash: null,
|
|
592
|
+
passwordResetExpiresAt: null,
|
|
593
|
+
passwordResetPurpose: null,
|
|
594
|
+
loginAttempts: 0,
|
|
595
|
+
lockUntil: null,
|
|
596
|
+
tokenVersion: sql4`${npUsers.tokenVersion} + 1`,
|
|
597
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
598
|
+
}).where(eq5(npUsers.id, user.id));
|
|
599
|
+
await tx.delete(npSessions).where(eq5(npSessions.userId, user.id));
|
|
600
|
+
});
|
|
601
|
+
return {
|
|
602
|
+
userId: user.id,
|
|
603
|
+
email: user.email,
|
|
604
|
+
purpose: user.purpose ?? "reset"
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// src/auth/member-token.ts
|
|
609
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
610
|
+
import { jwtVerify as jwtVerify2, SignJWT as SignJWT2 } from "jose";
|
|
611
|
+
var textEncoder2 = new TextEncoder();
|
|
612
|
+
var MEMBER_AUDIENCE = "member";
|
|
613
|
+
async function signMemberToken(member, secret, expirationSeconds = 7200, tokenUse = "access") {
|
|
614
|
+
const secretKey = textEncoder2.encode(secret);
|
|
615
|
+
return new SignJWT2({ sub: member.id, ver: member.tokenVersion, use: tokenUse }).setProtectedHeader({ alg: "HS256" }).setAudience(MEMBER_AUDIENCE).setJti(randomBytes4(16).toString("base64url")).setIssuedAt().setExpirationTime(Math.floor(Date.now() / 1e3) + expirationSeconds).sign(secretKey);
|
|
616
|
+
}
|
|
617
|
+
async function verifyMemberToken(token, secret, expectedUse) {
|
|
618
|
+
const secretKey = textEncoder2.encode(secret);
|
|
619
|
+
const { payload } = await jwtVerify2(token, secretKey, { audience: MEMBER_AUDIENCE });
|
|
620
|
+
const typed = payload;
|
|
621
|
+
if (typed.use !== "access" && typed.use !== "refresh") {
|
|
622
|
+
throw new NpAuthError("Member token missing `use` claim");
|
|
623
|
+
}
|
|
624
|
+
const use = typed.use;
|
|
625
|
+
if (expectedUse && use !== expectedUse) {
|
|
626
|
+
throw new NpAuthError(
|
|
627
|
+
`Member token use mismatch: expected ${expectedUse}, got ${use}`
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
return { ...typed, aud: MEMBER_AUDIENCE, use };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// src/auth/member-session.ts
|
|
634
|
+
import { and as and5, eq as eq6, gt as gt2, sql as sql5 } from "drizzle-orm";
|
|
635
|
+
async function getMemberFromTokenPayload(db, payload, accessToken) {
|
|
636
|
+
const [row] = await db.select({
|
|
637
|
+
id: npMembers.id,
|
|
638
|
+
email: npMembers.email,
|
|
639
|
+
handle: npMembers.handle,
|
|
640
|
+
displayName: npMembers.displayName,
|
|
641
|
+
status: npMembers.status,
|
|
642
|
+
tokenVersion: npMembers.tokenVersion
|
|
643
|
+
}).from(npMembers).where(eq6(npMembers.id, payload.sub)).limit(1);
|
|
644
|
+
if (!row) return null;
|
|
645
|
+
if (row.tokenVersion !== payload.ver) return null;
|
|
646
|
+
if (accessToken) {
|
|
647
|
+
const tokenHash = await sha256(accessToken);
|
|
648
|
+
const now = /* @__PURE__ */ new Date();
|
|
649
|
+
const [session] = await db.select({ id: npMemberSessions.id }).from(npMemberSessions).where(
|
|
650
|
+
and5(
|
|
651
|
+
eq6(npMemberSessions.memberId, row.id),
|
|
652
|
+
eq6(npMemberSessions.tokenHash, tokenHash),
|
|
653
|
+
gt2(npMemberSessions.expiresAt, now)
|
|
654
|
+
)
|
|
655
|
+
).limit(1);
|
|
656
|
+
if (!session) return null;
|
|
657
|
+
}
|
|
658
|
+
return row;
|
|
659
|
+
}
|
|
660
|
+
async function invalidateAllMemberSessions(db, memberId) {
|
|
661
|
+
await db.transaction(async (tx) => {
|
|
662
|
+
await tx.update(npMembers).set({
|
|
663
|
+
tokenVersion: sql5`${npMembers.tokenVersion} + 1`,
|
|
664
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
665
|
+
}).where(eq6(npMembers.id, memberId));
|
|
666
|
+
await tx.delete(npMemberSessions).where(eq6(npMemberSessions.memberId, memberId));
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// src/auth/member-credentials.ts
|
|
671
|
+
import { randomBytes as randomBytes5 } from "crypto";
|
|
672
|
+
import { and as and6, eq as eq7, gt as gt3, isNotNull as isNotNull2, sql as sql6 } from "drizzle-orm";
|
|
673
|
+
var MIN_PASSWORD_LENGTH2 = 8;
|
|
674
|
+
function generateRawToken2() {
|
|
675
|
+
return randomBytes5(32).toString("hex");
|
|
676
|
+
}
|
|
677
|
+
async function createMemberEmailVerifyToken(db, memberId, ttlMs) {
|
|
678
|
+
const token = generateRawToken2();
|
|
679
|
+
const tokenHash = await sha256(token);
|
|
680
|
+
const expiresAt = new Date(Date.now() + ttlMs);
|
|
681
|
+
await db.update(npMembers).set({
|
|
682
|
+
emailVerifyTokenHash: tokenHash,
|
|
683
|
+
emailVerifyExpiresAt: expiresAt,
|
|
684
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
685
|
+
}).where(eq7(npMembers.id, memberId));
|
|
686
|
+
return { token, expiresAt };
|
|
687
|
+
}
|
|
688
|
+
async function consumeMemberEmailVerifyToken(db, token) {
|
|
689
|
+
if (!token || typeof token !== "string") {
|
|
690
|
+
throw new NpValidationError("Invalid input", [
|
|
691
|
+
{ field: "token", message: "Verification token is required." }
|
|
692
|
+
]);
|
|
693
|
+
}
|
|
694
|
+
const tokenHash = await sha256(token);
|
|
695
|
+
const now = /* @__PURE__ */ new Date();
|
|
696
|
+
const [member] = await db.select({
|
|
697
|
+
id: npMembers.id,
|
|
698
|
+
email: npMembers.email,
|
|
699
|
+
handle: npMembers.handle,
|
|
700
|
+
displayName: npMembers.displayName
|
|
701
|
+
}).from(npMembers).where(
|
|
702
|
+
and6(
|
|
703
|
+
eq7(npMembers.emailVerifyTokenHash, tokenHash),
|
|
704
|
+
isNotNull2(npMembers.emailVerifyExpiresAt),
|
|
705
|
+
gt3(npMembers.emailVerifyExpiresAt, now)
|
|
706
|
+
)
|
|
707
|
+
).limit(1);
|
|
708
|
+
if (!member) {
|
|
709
|
+
throw new NpValidationError("Invalid input", [
|
|
710
|
+
{ field: "token", message: "Verification link is invalid or has expired." }
|
|
711
|
+
]);
|
|
712
|
+
}
|
|
713
|
+
await db.update(npMembers).set({
|
|
714
|
+
emailVerified: true,
|
|
715
|
+
// Pending → active on first verify so login can succeed afterwards.
|
|
716
|
+
// Suspended/deleted members stay where they are; the mod UI flips
|
|
717
|
+
// those statuses, never the verify endpoint.
|
|
718
|
+
status: sql6`case when ${npMembers.status} = 'pending' then 'active' else ${npMembers.status} end`,
|
|
719
|
+
emailVerifyTokenHash: null,
|
|
720
|
+
emailVerifyExpiresAt: null,
|
|
721
|
+
updatedAt: now
|
|
722
|
+
}).where(eq7(npMembers.id, member.id));
|
|
723
|
+
return {
|
|
724
|
+
memberId: member.id,
|
|
725
|
+
email: member.email,
|
|
726
|
+
handle: member.handle,
|
|
727
|
+
displayName: member.displayName
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
async function requestMemberPasswordReset(db, email, ttlMs) {
|
|
731
|
+
const normalizedEmail = email.trim().toLowerCase();
|
|
732
|
+
const [member] = await db.select({
|
|
733
|
+
id: npMembers.id,
|
|
734
|
+
email: npMembers.email,
|
|
735
|
+
displayName: npMembers.displayName,
|
|
736
|
+
status: npMembers.status
|
|
737
|
+
}).from(npMembers).where(eq7(npMembers.email, normalizedEmail)).limit(1);
|
|
738
|
+
if (!member || member.status === "deleted") {
|
|
739
|
+
return { memberId: null, displayName: null, email: null, issued: null };
|
|
740
|
+
}
|
|
741
|
+
const token = generateRawToken2();
|
|
742
|
+
const tokenHash = await sha256(token);
|
|
743
|
+
const expiresAt = new Date(Date.now() + ttlMs);
|
|
744
|
+
await db.update(npMembers).set({
|
|
745
|
+
passwordResetTokenHash: tokenHash,
|
|
746
|
+
passwordResetExpiresAt: expiresAt,
|
|
747
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
748
|
+
}).where(eq7(npMembers.id, member.id));
|
|
749
|
+
return {
|
|
750
|
+
memberId: member.id,
|
|
751
|
+
displayName: member.displayName,
|
|
752
|
+
email: member.email,
|
|
753
|
+
issued: { token, expiresAt }
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
async function consumeMemberPasswordReset(db, token, newPassword) {
|
|
757
|
+
if (!token || typeof token !== "string") {
|
|
758
|
+
throw new NpValidationError("Invalid input", [
|
|
759
|
+
{ field: "token", message: "Reset token is required." }
|
|
760
|
+
]);
|
|
761
|
+
}
|
|
762
|
+
if (!newPassword || newPassword.length < MIN_PASSWORD_LENGTH2) {
|
|
763
|
+
throw new NpValidationError("Invalid input", [
|
|
764
|
+
{
|
|
765
|
+
field: "password",
|
|
766
|
+
message: `Password must be at least ${MIN_PASSWORD_LENGTH2} characters.`
|
|
767
|
+
}
|
|
768
|
+
]);
|
|
769
|
+
}
|
|
770
|
+
const tokenHash = await sha256(token);
|
|
771
|
+
const now = /* @__PURE__ */ new Date();
|
|
772
|
+
const [member] = await db.select({ id: npMembers.id, email: npMembers.email }).from(npMembers).where(
|
|
773
|
+
and6(
|
|
774
|
+
eq7(npMembers.passwordResetTokenHash, tokenHash),
|
|
775
|
+
isNotNull2(npMembers.passwordResetExpiresAt),
|
|
776
|
+
gt3(npMembers.passwordResetExpiresAt, now)
|
|
777
|
+
)
|
|
778
|
+
).limit(1);
|
|
779
|
+
if (!member) {
|
|
780
|
+
throw new NpValidationError("Invalid input", [
|
|
781
|
+
{ field: "token", message: "Reset link is invalid or has expired." }
|
|
782
|
+
]);
|
|
783
|
+
}
|
|
784
|
+
const newPasswordHash = await hashPassword(newPassword);
|
|
785
|
+
await db.transaction(async (tx) => {
|
|
786
|
+
await tx.update(npMembers).set({
|
|
787
|
+
password: newPasswordHash,
|
|
788
|
+
passwordResetTokenHash: null,
|
|
789
|
+
passwordResetExpiresAt: null,
|
|
790
|
+
loginAttempts: 0,
|
|
791
|
+
lockUntil: null,
|
|
792
|
+
// Bump tokenVersion in-place so existing JWTs are invalidated. Also
|
|
793
|
+
// mark email as verified — completing a reset on an unverified
|
|
794
|
+
// account is itself proof of email ownership.
|
|
795
|
+
tokenVersion: sql6`${npMembers.tokenVersion} + 1`,
|
|
796
|
+
emailVerified: true,
|
|
797
|
+
status: sql6`case when ${npMembers.status} = 'pending' then 'active' else ${npMembers.status} end`,
|
|
798
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
799
|
+
}).where(eq7(npMembers.id, member.id));
|
|
800
|
+
await tx.delete(npMemberSessions).where(eq7(npMemberSessions.memberId, member.id));
|
|
801
|
+
});
|
|
802
|
+
return { memberId: member.id, email: member.email };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
export {
|
|
806
|
+
authenticated,
|
|
807
|
+
isAdmin,
|
|
808
|
+
isEditorOrAbove,
|
|
809
|
+
isOwnerOrAdmin,
|
|
810
|
+
signToken,
|
|
811
|
+
verifyToken,
|
|
812
|
+
isTokenVerificationError,
|
|
813
|
+
ARGON2_OPTIONS,
|
|
814
|
+
hashPassword,
|
|
815
|
+
verifyPassword,
|
|
816
|
+
verifyCsrf,
|
|
817
|
+
registerOAuthProvider,
|
|
818
|
+
getOAuthProvider,
|
|
819
|
+
listOAuthProviders,
|
|
820
|
+
resetOAuthProviders,
|
|
821
|
+
resolveOAuthLogin,
|
|
822
|
+
resolveMemberOAuthLogin,
|
|
823
|
+
issueOAuthState,
|
|
824
|
+
verifyOAuthState,
|
|
825
|
+
fromArctic,
|
|
826
|
+
sha256,
|
|
827
|
+
verifyTokenFull,
|
|
828
|
+
invalidateAllSessions,
|
|
829
|
+
listUserIdentities,
|
|
830
|
+
listMemberIdentities,
|
|
831
|
+
revokeUserIdentity,
|
|
832
|
+
revokeMemberIdentity,
|
|
833
|
+
createPasswordResetToken,
|
|
834
|
+
requestPasswordReset,
|
|
835
|
+
consumePasswordResetToken,
|
|
836
|
+
signMemberToken,
|
|
837
|
+
verifyMemberToken,
|
|
838
|
+
getMemberFromTokenPayload,
|
|
839
|
+
invalidateAllMemberSessions,
|
|
840
|
+
createMemberEmailVerifyToken,
|
|
841
|
+
consumeMemberEmailVerifyToken,
|
|
842
|
+
requestMemberPasswordReset,
|
|
843
|
+
consumeMemberPasswordReset
|
|
844
|
+
};
|
|
845
|
+
//# sourceMappingURL=chunk-OK5HOCQI.js.map
|