my-crud-lib 2.0.0 → 2.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.
@@ -1,6 +1,10 @@
1
1
  import bcrypt from 'bcryptjs';
2
+ import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
3
  import { signAccessToken, signRefreshToken, verifyToken } from '../../utils/jwt.js';
3
4
  import { resolveRegisterRole } from './auth.defaults.js';
5
+ const DEFAULT_REFRESH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
6
+ const DEFAULT_PASSWORD_RESET_TTL_MS = 60 * 60 * 1000;
7
+ const DEFAULT_EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000;
4
8
  function resolvePasswordHashRounds(configured) {
5
9
  const rounds = configured ?? Number(process.env.BCRYPT_SALT);
6
10
  return Number.isInteger(rounds) && rounds > 0 ? rounds : 10;
@@ -9,15 +13,185 @@ function toAuthUser(user) {
9
13
  const { passwordHash: _passwordHash, ...safeUser } = user;
10
14
  return safeUser;
11
15
  }
12
- function issueTokens(user) {
16
+ function getNow(deps) {
17
+ return deps.now?.() ?? new Date();
18
+ }
19
+ function addMs(date, ms) {
20
+ return new Date(date.getTime() + ms);
21
+ }
22
+ function isExpired(value, now) {
23
+ return new Date(value).getTime() <= now.getTime();
24
+ }
25
+ function createId(deps) {
26
+ return deps.idFactory?.() ?? randomUUID();
27
+ }
28
+ function createSecret() {
29
+ return randomBytes(32).toString('base64url');
30
+ }
31
+ function createOpaqueToken(deps) {
32
+ const id = createId(deps);
33
+ return { id, token: `${id}.${createSecret()}` };
34
+ }
35
+ function parseOpaqueToken(token) {
36
+ const [id, secret, ...rest] = token.split('.');
37
+ if (!id || !secret || rest.length > 0)
38
+ return null;
39
+ return { id };
40
+ }
41
+ function hashToken(token, secret) {
42
+ if (secret)
43
+ return createHmac('sha256', secret).update(token).digest('hex');
44
+ return createHash('sha256').update(token).digest('hex');
45
+ }
46
+ function safeEqual(a, b) {
47
+ const left = Buffer.from(a);
48
+ const right = Buffer.from(b);
49
+ return left.length === right.length && timingSafeEqual(left, right);
50
+ }
51
+ async function issueTokens(user, deps, familyId) {
13
52
  const payload = { sub: user.id, role: user.role };
14
- return {
15
- accessToken: signAccessToken(payload),
16
- refreshToken: signRefreshToken(payload),
17
- };
53
+ const accessToken = signAccessToken(payload);
54
+ if (!deps.refreshTokenRepo) {
55
+ return {
56
+ accessToken,
57
+ refreshToken: signRefreshToken(payload),
58
+ };
59
+ }
60
+ const refreshTokenId = createId(deps);
61
+ const resolvedFamilyId = familyId ?? createId(deps);
62
+ const refreshToken = signRefreshToken({ ...payload, jti: refreshTokenId, fam: resolvedFamilyId });
63
+ const now = getNow(deps);
64
+ const expiresAt = addMs(now, deps.refreshTokenTtlMs ?? DEFAULT_REFRESH_TOKEN_TTL_MS);
65
+ await deps.refreshTokenRepo.create({
66
+ id: refreshTokenId,
67
+ userId: user.id,
68
+ tokenHash: hashToken(refreshToken, deps.tokenHashSecret),
69
+ familyId: resolvedFamilyId,
70
+ expiresAt,
71
+ });
72
+ return { accessToken, refreshToken, refreshTokenId, familyId: resolvedFamilyId };
18
73
  }
19
74
  export function makeAuthService(deps) {
20
75
  const { userRepo } = deps;
76
+ async function requestPasswordReset(params) {
77
+ if (!deps.passwordResetTokenRepo || !deps.sendPasswordReset) {
78
+ throw new Error('PASSWORD_RESET_UNSUPPORTED');
79
+ }
80
+ const user = await userRepo.findByEmail(params.email);
81
+ if (!user)
82
+ return { ok: true };
83
+ const now = getNow(deps);
84
+ const expiresAt = addMs(now, deps.passwordResetTtlMs ?? DEFAULT_PASSWORD_RESET_TTL_MS);
85
+ const { id, token } = createOpaqueToken(deps);
86
+ await deps.passwordResetTokenRepo.create({
87
+ id,
88
+ userId: user.id,
89
+ tokenHash: hashToken(token, deps.tokenHashSecret),
90
+ expiresAt,
91
+ });
92
+ await deps.sendPasswordReset({ user: toAuthUser(user), token, expiresAt });
93
+ return { ok: true };
94
+ }
95
+ async function confirmPasswordReset(params) {
96
+ if (!deps.passwordResetTokenRepo || !userRepo.updatePassword) {
97
+ throw new Error('PASSWORD_RESET_UNSUPPORTED');
98
+ }
99
+ const parsed = parseOpaqueToken(params.token);
100
+ if (!parsed)
101
+ throw new Error('INVALID_PASSWORD_RESET_TOKEN');
102
+ const record = await deps.passwordResetTokenRepo.findById(parsed.id);
103
+ const now = getNow(deps);
104
+ if (!record ||
105
+ record.usedAt ||
106
+ record.revokedAt ||
107
+ isExpired(record.expiresAt, now) ||
108
+ !safeEqual(record.tokenHash, hashToken(params.token, deps.tokenHashSecret))) {
109
+ throw new Error('INVALID_PASSWORD_RESET_TOKEN');
110
+ }
111
+ const passwordHash = await bcrypt.hash(params.password, resolvePasswordHashRounds(deps.passwordHashRounds));
112
+ await userRepo.updatePassword(record.userId, passwordHash);
113
+ await deps.passwordResetTokenRepo.markUsed(record.id, { usedAt: now });
114
+ return { ok: true };
115
+ }
116
+ async function requestEmailVerification(params) {
117
+ if (!deps.emailVerificationTokenRepo || !deps.sendEmailVerification) {
118
+ throw new Error('EMAIL_VERIFICATION_UNSUPPORTED');
119
+ }
120
+ const user = await userRepo.findByEmail(params.email);
121
+ if (!user || user.emailVerifiedAt)
122
+ return { ok: true };
123
+ const now = getNow(deps);
124
+ const expiresAt = addMs(now, deps.emailVerificationTtlMs ?? DEFAULT_EMAIL_VERIFICATION_TTL_MS);
125
+ const { id, token } = createOpaqueToken(deps);
126
+ await deps.emailVerificationTokenRepo.create({
127
+ id,
128
+ userId: user.id,
129
+ tokenHash: hashToken(token, deps.tokenHashSecret),
130
+ expiresAt,
131
+ });
132
+ await deps.sendEmailVerification({ user: toAuthUser(user), token, expiresAt });
133
+ return { ok: true };
134
+ }
135
+ async function confirmEmailVerification(params) {
136
+ if (!deps.emailVerificationTokenRepo || !userRepo.markEmailVerified) {
137
+ throw new Error('EMAIL_VERIFICATION_UNSUPPORTED');
138
+ }
139
+ const parsed = parseOpaqueToken(params.token);
140
+ if (!parsed)
141
+ throw new Error('INVALID_EMAIL_VERIFICATION_TOKEN');
142
+ const record = await deps.emailVerificationTokenRepo.findById(parsed.id);
143
+ const now = getNow(deps);
144
+ if (!record ||
145
+ record.usedAt ||
146
+ record.revokedAt ||
147
+ isExpired(record.expiresAt, now) ||
148
+ !safeEqual(record.tokenHash, hashToken(params.token, deps.tokenHashSecret))) {
149
+ throw new Error('INVALID_EMAIL_VERIFICATION_TOKEN');
150
+ }
151
+ const updated = await userRepo.markEmailVerified(record.userId, now);
152
+ const user = updated ?? await userRepo.findById(record.userId);
153
+ if (!user)
154
+ throw new Error('USER_NOT_FOUND');
155
+ await deps.emailVerificationTokenRepo.markUsed(record.id, { usedAt: now });
156
+ return { ok: true, user: toAuthUser(user) };
157
+ }
158
+ async function signInWithOAuthProfile(profile) {
159
+ if (!deps.oauthAccountRepo)
160
+ throw new Error('OAUTH_UNSUPPORTED');
161
+ if (!profile.provider || !profile.providerAccountId)
162
+ throw new Error('INVALID_OAUTH_PROFILE');
163
+ const linkedAccount = await deps.oauthAccountRepo.findByProviderAccount(profile.provider, profile.providerAccountId);
164
+ if (linkedAccount) {
165
+ const existingUser = await userRepo.findById(linkedAccount.userId);
166
+ if (!existingUser)
167
+ throw new Error('USER_NOT_FOUND');
168
+ return { user: existingUser, ...(await issueTokens(existingUser, deps)) };
169
+ }
170
+ let user = null;
171
+ const canLinkByEmail = deps.linkOAuthByVerifiedEmail !== false && profile.email && profile.emailVerified === true;
172
+ if (canLinkByEmail && profile.email) {
173
+ user = await userRepo.findByEmail(profile.email);
174
+ }
175
+ if (!user) {
176
+ if (!profile.email)
177
+ throw new Error('OAUTH_EMAIL_REQUIRED');
178
+ const passwordHash = await bcrypt.hash(createSecret(), resolvePasswordHashRounds(deps.passwordHashRounds));
179
+ user = await userRepo.create({
180
+ email: profile.email,
181
+ passwordHash,
182
+ name: profile.name ?? null,
183
+ role: resolveRegisterRole(deps.defaultRegisterRole),
184
+ });
185
+ }
186
+ await deps.oauthAccountRepo.create({
187
+ provider: profile.provider,
188
+ providerAccountId: profile.providerAccountId,
189
+ userId: user.id,
190
+ email: profile.email ?? null,
191
+ });
192
+ const authUser = toAuthUser(user);
193
+ return { user: authUser, ...(await issueTokens(authUser, deps)) };
194
+ }
21
195
  return {
22
196
  async registerUser(params) {
23
197
  const exists = await userRepo.findByEmail(params.email);
@@ -32,7 +206,7 @@ export function makeAuthService(deps) {
32
206
  role,
33
207
  });
34
208
  const authUser = toAuthUser(user);
35
- return { user: authUser, ...issueTokens(authUser) };
209
+ return { user: authUser, ...(await issueTokens(authUser, deps)) };
36
210
  },
37
211
  async loginUser(params) {
38
212
  const user = await userRepo.findByEmail(params.email);
@@ -42,7 +216,7 @@ export function makeAuthService(deps) {
42
216
  if (!ok)
43
217
  throw new Error('INVALID_CREDENTIALS');
44
218
  const authUser = toAuthUser(user);
45
- return { user: authUser, ...issueTokens(authUser) };
219
+ return { user: authUser, ...(await issueTokens(authUser, deps)) };
46
220
  },
47
221
  async refreshSession(refreshToken) {
48
222
  const payload = verifyToken(refreshToken);
@@ -51,10 +225,47 @@ export function makeAuthService(deps) {
51
225
  const user = await userRepo.findById(payload.sub);
52
226
  if (!user)
53
227
  throw new Error('USER_NOT_FOUND');
54
- return issueTokens(user);
228
+ if (!deps.refreshTokenRepo)
229
+ return issueTokens(user, deps);
230
+ if (!payload.jti || !payload.fam)
231
+ throw new Error('INVALID_REFRESH_TOKEN');
232
+ const record = await deps.refreshTokenRepo.findById(payload.jti);
233
+ const now = getNow(deps);
234
+ if (!record || record.userId.toString() !== payload.sub.toString() || record.familyId !== payload.fam) {
235
+ throw new Error('INVALID_REFRESH_TOKEN');
236
+ }
237
+ if (record.revokedAt) {
238
+ await deps.refreshTokenRepo.revokeFamily?.(record.familyId, { revokedAt: now, reason: 'refresh-token-replay' });
239
+ throw new Error('REFRESH_TOKEN_REPLAYED');
240
+ }
241
+ if (isExpired(record.expiresAt, now) || !safeEqual(record.tokenHash, hashToken(refreshToken, deps.tokenHashSecret))) {
242
+ throw new Error('INVALID_REFRESH_TOKEN');
243
+ }
244
+ const nextTokens = await issueTokens(user, deps, record.familyId);
245
+ await deps.refreshTokenRepo.revoke(record.id, {
246
+ revokedAt: now,
247
+ replacedByTokenId: nextTokens.refreshTokenId ?? null,
248
+ reason: 'rotated',
249
+ });
250
+ return { accessToken: nextTokens.accessToken, refreshToken: nextTokens.refreshToken };
251
+ },
252
+ async revokeRefreshToken(refreshToken) {
253
+ const payload = verifyToken(refreshToken);
254
+ if (payload.typ !== 'refresh')
255
+ throw new Error('INVALID_REFRESH_TOKEN');
256
+ if (deps.refreshTokenRepo && payload.jti) {
257
+ await deps.refreshTokenRepo.revoke(payload.jti, { revokedAt: getNow(deps), reason: 'revoked' });
258
+ }
259
+ return { ok: true };
55
260
  },
56
261
  getMe(userId) {
57
262
  return userRepo.findById(userId);
58
263
  },
264
+ requestPasswordReset,
265
+ confirmPasswordReset,
266
+ requestEmailVerification,
267
+ confirmEmailVerification,
268
+ signInWithOAuthProfile,
269
+ loginWithOAuthProfile: signInWithOAuthProfile,
59
270
  };
60
271
  }
@@ -1,10 +1,126 @@
1
1
  import type { UserRepo } from '../../core/ports/user.repo.js';
2
2
  import type { Role, UserListItem } from '../user/user.types.js';
3
- export type AuthUserRepo = Pick<UserRepo, 'create' | 'findByEmail' | 'findById'>;
3
+ export type AuthUserRepo = Pick<UserRepo, 'create' | 'findByEmail' | 'findById'> & Pick<Partial<UserRepo>, 'updatePassword' | 'markEmailVerified'>;
4
+ export type RefreshTokenRecord = {
5
+ id: string;
6
+ userId: number | string;
7
+ tokenHash: string;
8
+ familyId: string;
9
+ expiresAt: Date | string;
10
+ revokedAt?: Date | string | null;
11
+ replacedByTokenId?: string | null;
12
+ };
13
+ export type RefreshTokenRepo = {
14
+ create(input: {
15
+ id: string;
16
+ userId: number | string;
17
+ tokenHash: string;
18
+ familyId: string;
19
+ expiresAt: Date;
20
+ }): Promise<RefreshTokenRecord | void>;
21
+ findById(id: string): Promise<RefreshTokenRecord | null>;
22
+ revoke(id: string, input?: {
23
+ revokedAt?: Date;
24
+ replacedByTokenId?: string | null;
25
+ reason?: string;
26
+ }): Promise<void>;
27
+ revokeFamily?(familyId: string, input?: {
28
+ revokedAt?: Date;
29
+ reason?: string;
30
+ }): Promise<void>;
31
+ };
32
+ export type PasswordResetTokenRecord = {
33
+ id: string;
34
+ userId: number | string;
35
+ tokenHash: string;
36
+ expiresAt: Date | string;
37
+ usedAt?: Date | string | null;
38
+ revokedAt?: Date | string | null;
39
+ };
40
+ export type PasswordResetTokenRepo = {
41
+ create(input: {
42
+ id: string;
43
+ userId: number | string;
44
+ tokenHash: string;
45
+ expiresAt: Date;
46
+ }): Promise<PasswordResetTokenRecord | void>;
47
+ findById(id: string): Promise<PasswordResetTokenRecord | null>;
48
+ markUsed(id: string, input?: {
49
+ usedAt?: Date;
50
+ }): Promise<void>;
51
+ revoke?(id: string, input?: {
52
+ revokedAt?: Date;
53
+ reason?: string;
54
+ }): Promise<void>;
55
+ };
56
+ export type EmailVerificationTokenRecord = {
57
+ id: string;
58
+ userId: number | string;
59
+ tokenHash: string;
60
+ expiresAt: Date | string;
61
+ usedAt?: Date | string | null;
62
+ revokedAt?: Date | string | null;
63
+ };
64
+ export type EmailVerificationTokenRepo = {
65
+ create(input: {
66
+ id: string;
67
+ userId: number | string;
68
+ tokenHash: string;
69
+ expiresAt: Date;
70
+ }): Promise<EmailVerificationTokenRecord | void>;
71
+ findById(id: string): Promise<EmailVerificationTokenRecord | null>;
72
+ markUsed(id: string, input?: {
73
+ usedAt?: Date;
74
+ }): Promise<void>;
75
+ revoke?(id: string, input?: {
76
+ revokedAt?: Date;
77
+ reason?: string;
78
+ }): Promise<void>;
79
+ };
80
+ export type OAuthProviderProfile = {
81
+ provider: string;
82
+ providerAccountId: string;
83
+ email?: string | null;
84
+ emailVerified?: boolean;
85
+ name?: string | null;
86
+ avatarUrl?: string | null;
87
+ };
88
+ export type OAuthAccount = {
89
+ provider: string;
90
+ providerAccountId: string;
91
+ userId: number | string;
92
+ email?: string | null;
93
+ };
94
+ export type OAuthAccountRepo = {
95
+ findByProviderAccount(provider: string, providerAccountId: string): Promise<OAuthAccount | null>;
96
+ findByUserAndProvider?(userId: number | string, provider: string): Promise<OAuthAccount | null>;
97
+ create(input: OAuthAccount): Promise<OAuthAccount | void>;
98
+ };
4
99
  export type AuthServiceDeps = {
5
100
  userRepo: AuthUserRepo;
6
101
  passwordHashRounds?: number;
7
102
  defaultRegisterRole?: Role;
103
+ refreshTokenRepo?: RefreshTokenRepo;
104
+ refreshTokenTtlMs?: number;
105
+ passwordResetTokenRepo?: PasswordResetTokenRepo;
106
+ passwordResetTtlMs?: number;
107
+ sendPasswordReset?: (input: {
108
+ user: AuthUser;
109
+ token: string;
110
+ expiresAt: Date;
111
+ }) => Promise<void> | void;
112
+ emailVerificationTokenRepo?: EmailVerificationTokenRepo;
113
+ emailVerificationTtlMs?: number;
114
+ sendEmailVerification?: (input: {
115
+ user: AuthUser;
116
+ token: string;
117
+ expiresAt: Date;
118
+ }) => Promise<void> | void;
119
+ oauthAccountRepo?: OAuthAccountRepo;
120
+ linkOAuthByVerifiedEmail?: boolean;
121
+ tokenHashSecret?: string;
122
+ idFactory?: () => string;
123
+ now?: () => Date;
8
124
  };
9
125
  export type AuthUser = UserListItem;
10
126
  export type AuthTokens = {
@@ -14,3 +130,16 @@ export type AuthTokens = {
14
130
  export type AuthResult = AuthTokens & {
15
131
  user: AuthUser;
16
132
  };
133
+ export type PasswordResetRequestInput = {
134
+ email: string;
135
+ };
136
+ export type PasswordResetConfirmInput = {
137
+ token: string;
138
+ password: string;
139
+ };
140
+ export type EmailVerificationRequestInput = {
141
+ email: string;
142
+ };
143
+ export type EmailVerificationConfirmInput = {
144
+ token: string;
145
+ };
@@ -4,6 +4,7 @@ export type UserListItem = {
4
4
  email: string;
5
5
  name: string | null;
6
6
  role: Role;
7
+ emailVerifiedAt?: Date | string | null;
7
8
  createdAt: Date | string;
8
9
  updatedAt: Date | string;
9
10
  profile?: {
package/dist/schemas.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { loginSchema, registerSchema } from './modules/auth/auth.schemas.js';
1
+ export { emailVerificationConfirmSchema, emailVerificationRequestSchema, loginSchema, passwordResetConfirmSchema, passwordResetRequestSchema, registerSchema, } from './modules/auth/auth.schemas.js';
2
2
  export { SortEnum, adminCreateUserSchema, adminUpdateUserSchema, listUsersQuerySchema, updateMeSchema, } from './modules/user/user.schemas.js';
package/dist/schemas.js CHANGED
@@ -1,2 +1,2 @@
1
- export { loginSchema, registerSchema } from './modules/auth/auth.schemas.js';
1
+ export { emailVerificationConfirmSchema, emailVerificationRequestSchema, loginSchema, passwordResetConfirmSchema, passwordResetRequestSchema, registerSchema, } from './modules/auth/auth.schemas.js';
2
2
  export { SortEnum, adminCreateUserSchema, adminUpdateUserSchema, listUsersQuerySchema, updateMeSchema, } from './modules/user/user.schemas.js';
@@ -2,6 +2,8 @@ export type JwtPayload = {
2
2
  sub: number | string;
3
3
  role: "USER" | "ADMIN";
4
4
  typ?: "refresh";
5
+ jti?: string;
6
+ fam?: string;
5
7
  };
6
8
  export declare function signAccessToken(payload: JwtPayload): string;
7
9
  export declare function signRefreshToken(payload: JwtPayload): string;
@@ -0,0 +1,120 @@
1
+ # Auth extensions
2
+
3
+ The core auth flow remains stateless and adapter-driven. Advanced auth features are enabled only when the corresponding repository and hook dependencies are passed to `makeAuthService()` or `createAuthRouter()`.
4
+
5
+ ## Persistent refresh token rotation
6
+
7
+ By default, refresh tokens are stateless JWTs. This is simple and works for small apps, but a stolen refresh token remains valid until expiry unless you rotate the signing secret.
8
+
9
+ Persistent refresh tokens add server-side state:
10
+
11
+ - Refresh JWTs include a token ID and token family ID.
12
+ - The repository stores a hash of the refresh token, never the raw token.
13
+ - `/auth/refresh` rotates the refresh token and revokes the previous token.
14
+ - Reusing a revoked refresh token is treated as replay and revokes the full token family when the repository supports `revokeFamily`.
15
+ - `/auth/logout` revokes the submitted refresh token.
16
+
17
+ ```ts
18
+ import { createAuthRouter } from "my-crud-lib/auth";
19
+ import {
20
+ makePrismaRefreshTokenRepo,
21
+ makePrismaUserRepo,
22
+ } from "my-crud-lib/adapter-prisma";
23
+
24
+ app.use(
25
+ "/auth",
26
+ createAuthRouter({
27
+ userRepo: makePrismaUserRepo(prisma),
28
+ refreshTokenRepo: makePrismaRefreshTokenRepo(prisma),
29
+ })
30
+ );
31
+ ```
32
+
33
+ ## Password reset
34
+
35
+ Password reset is app-owned for delivery. The library creates a one-time opaque token, stores only its hash, and calls your delivery hook.
36
+
37
+ Routes:
38
+
39
+ - `POST /auth/password-reset/request`
40
+ - `POST /auth/password-reset/confirm`
41
+
42
+ ```ts
43
+ import {
44
+ makePrismaPasswordResetTokenRepo,
45
+ makePrismaUserRepo,
46
+ } from "my-crud-lib/adapter-prisma";
47
+
48
+ createAuthRouter({
49
+ userRepo: makePrismaUserRepo(prisma),
50
+ passwordResetTokenRepo: makePrismaPasswordResetTokenRepo(prisma),
51
+ async sendPasswordReset({ user, token, expiresAt }) {
52
+ await emailProvider.sendPasswordReset(user.email, token, expiresAt);
53
+ },
54
+ });
55
+ ```
56
+
57
+ Security expectations:
58
+
59
+ - Return a generic success response for unknown emails.
60
+ - Keep reset token TTLs short.
61
+ - Send the token only through an out-of-band channel controlled by the application.
62
+ - Require HTTPS in production.
63
+
64
+ ## Email verification
65
+
66
+ Email verification uses the same one-time token pattern as password reset. Delivery stays app-owned.
67
+
68
+ Routes:
69
+
70
+ - `POST /auth/email-verification/request`
71
+ - `POST /auth/email-verification/confirm`
72
+
73
+ ```ts
74
+ import {
75
+ makePrismaEmailVerificationTokenRepo,
76
+ makePrismaUserRepo,
77
+ } from "my-crud-lib/adapter-prisma";
78
+
79
+ createAuthRouter({
80
+ userRepo: makePrismaUserRepo(prisma),
81
+ emailVerificationTokenRepo: makePrismaEmailVerificationTokenRepo(prisma),
82
+ async sendEmailVerification({ user, token, expiresAt }) {
83
+ await emailProvider.sendVerification(user.email, token, expiresAt);
84
+ },
85
+ });
86
+ ```
87
+
88
+ The bundled Prisma schema includes `User.emailVerifiedAt`. Existing Prisma users only need to add that column when they enable email verification. Apps should gate privileged behavior according to their own rules.
89
+
90
+ ## OAuth and social login
91
+
92
+ OAuth provider redirects, scopes, and provider SDKs remain outside the core package. The auth service exposes a provider-agnostic account-linking method:
93
+
94
+ ```ts
95
+ import {
96
+ makeAuthService,
97
+ type OAuthProviderProfile,
98
+ } from "my-crud-lib/auth";
99
+ import {
100
+ makePrismaOAuthAccountRepo,
101
+ makePrismaUserRepo,
102
+ } from "my-crud-lib/adapter-prisma";
103
+
104
+ const auth = makeAuthService({
105
+ userRepo: makePrismaUserRepo(prisma),
106
+ oauthAccountRepo: makePrismaOAuthAccountRepo(prisma),
107
+ });
108
+
109
+ const profile: OAuthProviderProfile = {
110
+ provider: "github",
111
+ providerAccountId: githubUser.id,
112
+ email: githubUser.email,
113
+ emailVerified: true,
114
+ name: githubUser.name,
115
+ };
116
+
117
+ const result = await auth.signInWithOAuthProfile(profile);
118
+ ```
119
+
120
+ If a provider account is already linked, the linked user is used. Otherwise, a verified email can be linked to an existing user. If no user exists, the service creates one with an unusable generated password hash and links the provider account.
@@ -0,0 +1,76 @@
1
+ # GitHub self-hosted runner
2
+
3
+ Questa configurazione sposta il calcolo delle pipeline da `ubuntu-latest` al server registrato come runner GitHub Actions.
4
+
5
+ Il runner non elimina GitHub Actions: GitHub resta l'orchestratore, ma i job vengono eseguiti su questa macchina.
6
+
7
+ ## Strategia consigliata
8
+
9
+ Per usare lo stesso server su piu progetti, registra il runner a livello di organizzazione GitHub e assegna il label custom `local-ci`.
10
+
11
+ Poi in ogni workflow usa:
12
+
13
+ ```yaml
14
+ runs-on: [self-hosted, linux, x64, local-ci]
15
+ ```
16
+
17
+ Se i repository sono sotto un account personale e non sotto un'organizzazione, GitHub non offre un runner unico per tutto l'account: va registrato un runner per ogni repository, oppure conviene spostare i progetti dentro una organizzazione.
18
+
19
+ ## Registrazione da interfaccia GitHub
20
+
21
+ Organizzazione:
22
+
23
+ 1. Vai su `Organization settings > Actions > Runners`.
24
+ 2. Crea un nuovo self-hosted runner Linux x64.
25
+ 3. Aggiungi il label `local-ci`.
26
+ 4. Se usi runner group, limita l'accesso ai repository che devono usare questo server.
27
+
28
+ Repository singolo:
29
+
30
+ 1. Vai su `Repository settings > Actions > Runners`.
31
+ 2. Crea un nuovo self-hosted runner Linux x64.
32
+ 3. Aggiungi il label `local-ci`.
33
+
34
+ ## Installazione sul server
35
+
36
+ Genera il token temporaneo dalla pagina GitHub del runner e poi esegui:
37
+
38
+ ```bash
39
+ GITHUB_URL="https://github.com/OWNER_OR_ORG" \
40
+ RUNNER_TOKEN="TOKEN_GENERATO_DA_GITHUB" \
41
+ RUNNER_NAME="server-ci-01" \
42
+ scripts/setup-github-runner.sh
43
+ ```
44
+
45
+ Per un repository singolo:
46
+
47
+ ```bash
48
+ GITHUB_URL="https://github.com/OWNER/REPOSITORY" \
49
+ RUNNER_TOKEN="TOKEN_GENERATO_DA_GITHUB" \
50
+ RUNNER_NAME="server-ci-01" \
51
+ scripts/setup-github-runner.sh
52
+ ```
53
+
54
+ Variabili utili:
55
+
56
+ - `RUNNER_LABELS`: label custom, default `local-ci`.
57
+ - `RUNNER_VERSION`: versione del runner, default `2.334.0`.
58
+ - `RUNNER_USER`: utente Linux del servizio, default `actions-runner`.
59
+ - `INSTALL_DIR`: directory di installazione, default `/opt/actions-runner/$RUNNER_NAME`.
60
+ - `RUNNER_GROUP`: runner group GitHub opzionale, utile nelle organizzazioni.
61
+
62
+ ## Requisiti del server
63
+
64
+ Per questo progetto servono Node.js e npm. Il server corrente ha Docker installato; se altri progetti usano container, assicurati che l'utente del runner abbia i permessi necessari.
65
+
66
+ Installa sul server cio che usano le pipeline dei vari repository, ad esempio:
67
+
68
+ ```bash
69
+ node --version
70
+ npm --version
71
+ docker --version
72
+ ```
73
+
74
+ ## Sicurezza
75
+
76
+ Evita self-hosted runner su repository pubblici che eseguono workflow da pull request non fidate: codice esterno potrebbe girare sul server. Per repo pubblici, usa approvazione manuale dei workflow, runner group ristretti e segreti con scope minimo.