alepha 0.19.3 → 0.19.4
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/assets/swagger-ui/swagger-ui-bundle.js +1 -1
- package/dist/api/audits/index.d.ts +8 -8
- package/dist/api/invitations/index.d.ts +790 -0
- package/dist/api/invitations/index.d.ts.map +1 -0
- package/dist/api/invitations/index.js +665 -0
- package/dist/api/invitations/index.js.map +1 -0
- package/dist/api/jobs/index.browser.js +8 -9
- package/dist/api/jobs/index.browser.js.map +1 -1
- package/dist/api/jobs/index.d.ts +99 -43
- package/dist/api/jobs/index.d.ts.map +1 -1
- package/dist/api/jobs/index.js +257 -40
- package/dist/api/jobs/index.js.map +1 -1
- package/dist/api/keys/index.d.ts +5 -5
- package/dist/api/notifications/index.browser.js +0 -1
- package/dist/api/notifications/index.browser.js.map +1 -1
- package/dist/api/notifications/index.d.ts +3 -3
- package/dist/api/notifications/index.d.ts.map +1 -1
- package/dist/api/notifications/index.js +0 -1
- package/dist/api/notifications/index.js.map +1 -1
- package/dist/api/parameters/index.browser.js +112 -1
- package/dist/api/parameters/index.browser.js.map +1 -1
- package/dist/api/parameters/index.d.ts +90 -3
- package/dist/api/parameters/index.d.ts.map +1 -1
- package/dist/api/parameters/index.js +79 -12
- package/dist/api/parameters/index.js.map +1 -1
- package/dist/{billing → api/payments}/index.d.ts +67 -49
- package/dist/api/payments/index.d.ts.map +1 -0
- package/dist/{billing → api/payments}/index.js +108 -74
- package/dist/api/payments/index.js.map +1 -0
- package/dist/api/subscriptions/index.d.ts +1692 -0
- package/dist/api/subscriptions/index.d.ts.map +1 -0
- package/dist/api/subscriptions/index.js +1870 -0
- package/dist/api/subscriptions/index.js.map +1 -0
- package/dist/api/users/index.d.ts +18 -2
- package/dist/api/users/index.d.ts.map +1 -1
- package/dist/api/users/index.js +167 -34
- package/dist/api/users/index.js.map +1 -1
- package/dist/api/verifications/index.d.ts +13 -13
- package/dist/api/workflows/index.browser.js +246 -0
- package/dist/api/workflows/index.browser.js.map +1 -0
- package/dist/api/workflows/index.d.ts +1618 -0
- package/dist/api/workflows/index.d.ts.map +1 -0
- package/dist/api/workflows/index.js +1504 -0
- package/dist/api/workflows/index.js.map +1 -0
- package/dist/cli/core/index.d.ts +44 -28
- package/dist/cli/core/index.d.ts.map +1 -1
- package/dist/cli/core/index.js +16 -61
- package/dist/cli/core/index.js.map +1 -1
- package/dist/cli/vendor/index.d.ts +31 -8
- package/dist/cli/vendor/index.d.ts.map +1 -1
- package/dist/cli/vendor/index.js +79 -24
- package/dist/cli/vendor/index.js.map +1 -1
- package/dist/core/index.browser.js +21 -2
- package/dist/core/index.browser.js.map +1 -1
- package/dist/core/index.d.ts +33 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +21 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.native.js +21 -2
- package/dist/core/index.native.js.map +1 -1
- package/dist/core/index.workerd.js +21 -2
- package/dist/core/index.workerd.js.map +1 -1
- package/dist/email/smtp/index.js +24 -8
- package/dist/email/smtp/index.js.map +1 -1
- package/dist/orm/core/index.browser.js +0 -18
- package/dist/orm/core/index.browser.js.map +1 -1
- package/dist/orm/core/index.bun.js +0 -17
- package/dist/orm/core/index.bun.js.map +1 -1
- package/dist/orm/core/index.d.ts +1 -13
- package/dist/orm/core/index.d.ts.map +1 -1
- package/dist/orm/core/index.js +0 -17
- package/dist/orm/core/index.js.map +1 -1
- package/dist/orm/postgres/index.bun.js +3 -3
- package/dist/orm/postgres/index.bun.js.map +1 -1
- package/dist/orm/postgres/index.d.ts.map +1 -1
- package/dist/orm/postgres/index.js +3 -3
- package/dist/orm/postgres/index.js.map +1 -1
- package/dist/react/router/index.browser.js +25 -3
- package/dist/react/router/index.browser.js.map +1 -1
- package/dist/react/router/index.d.ts +16 -1
- package/dist/react/router/index.d.ts.map +1 -1
- package/dist/react/router/index.js +25 -3
- package/dist/react/router/index.js.map +1 -1
- package/dist/security/index.d.ts +28 -0
- package/dist/security/index.d.ts.map +1 -1
- package/dist/security/index.js +28 -0
- package/dist/security/index.js.map +1 -1
- package/package.json +37 -20
- package/src/api/invitations/__tests__/InvitationService.spec.ts +439 -0
- package/src/api/invitations/controllers/AdminInvitationController.ts +86 -0
- package/src/api/invitations/controllers/InvitationController.ts +84 -0
- package/src/api/invitations/entities/invitations.ts +33 -0
- package/src/api/invitations/index.ts +65 -0
- package/src/api/invitations/jobs/InvitationJobs.ts +37 -0
- package/src/api/invitations/providers/InvitationProvider.ts +45 -0
- package/src/api/invitations/schemas/createInvitationSchema.ts +12 -0
- package/src/api/invitations/schemas/invitationConfigAtom.ts +20 -0
- package/src/api/invitations/schemas/invitationQuerySchema.ts +15 -0
- package/src/api/invitations/schemas/invitationResourceSchema.ts +6 -0
- package/src/api/invitations/schemas/invitationWithResourceInfoSchema.ts +22 -0
- package/src/api/invitations/schemas/myInvitationsQuerySchema.ts +10 -0
- package/src/api/invitations/services/InvitationService.ts +556 -0
- package/src/api/jobs/__tests__/$job.spec.ts +876 -0
- package/src/api/jobs/controllers/AdminJobController.ts +44 -0
- package/src/api/jobs/entities/jobExecutionEntity.ts +0 -2
- package/src/api/jobs/index.ts +0 -3
- package/src/api/jobs/primitives/$job.ts +22 -11
- package/src/api/jobs/providers/JobProvider.ts +229 -19
- package/src/api/jobs/schemas/jobConfigAtom.ts +4 -0
- package/src/api/jobs/schemas/jobCronInfoSchema.ts +1 -0
- package/src/api/jobs/schemas/jobExecutionQuerySchema.ts +0 -1
- package/src/api/jobs/schemas/jobQueueDepthSchema.ts +1 -0
- package/src/api/jobs/schemas/jobRegistrationSchema.ts +1 -6
- package/src/api/jobs/services/JobService.ts +51 -12
- package/src/api/notifications/schemas/notificationQuerySchema.ts +0 -1
- package/src/api/parameters/__tests__/$parameter.spec.ts +327 -0
- package/src/api/parameters/controllers/AdminParameterController.ts +29 -3
- package/src/api/parameters/index.browser.ts +12 -0
- package/src/api/parameters/primitives/$parameter.ts +20 -3
- package/src/api/parameters/services/ParameterProvider.ts +48 -7
- package/src/{billing → api/payments}/__tests__/PaymentMethodService.spec.ts +32 -6
- package/src/api/payments/__tests__/PaymentService.spec.ts +279 -0
- package/src/{billing/controllers/AdminBillingController.ts → api/payments/controllers/AdminPaymentController.ts} +26 -21
- package/src/{billing/controllers/BillingController.ts → api/payments/controllers/PaymentController.ts} +23 -11
- package/src/{billing → api/payments}/entities/paymentIntents.ts +1 -0
- package/src/{billing/errors/BillingError.ts → api/payments/errors/PaymentError.ts} +1 -1
- package/src/{billing → api/payments}/index.ts +31 -25
- package/src/{billing/providers/MemoryBillingProvider.ts → api/payments/providers/MemoryPaymentProvider.ts} +4 -4
- package/src/{billing/providers/BillingProvider.ts → api/payments/providers/PaymentProvider.ts} +9 -2
- package/src/{billing → api/payments}/services/PaymentMethodService.ts +5 -5
- package/src/{billing/services/BillingService.ts → api/payments/services/PaymentService.ts} +94 -18
- package/src/api/subscriptions/__tests__/BillingService.spec.ts +218 -0
- package/src/api/subscriptions/__tests__/SubscriptionService.spec.ts +278 -0
- package/src/api/subscriptions/controllers/AdminSubscriptionController.ts +212 -0
- package/src/api/subscriptions/controllers/SubscriptionController.ts +189 -0
- package/src/api/subscriptions/entities/subscriptionEvents.ts +54 -0
- package/src/api/subscriptions/entities/subscriptions.ts +68 -0
- package/src/api/subscriptions/index.ts +144 -0
- package/src/api/subscriptions/jobs/SubscriptionJobs.ts +382 -0
- package/src/api/subscriptions/middleware/$requireLimit.ts +50 -0
- package/src/api/subscriptions/middleware/$requirePlan.ts +49 -0
- package/src/api/subscriptions/notifications/SubscriptionNotifications.ts +110 -0
- package/src/api/subscriptions/schemas/cancelSubscriptionSchema.ts +8 -0
- package/src/api/subscriptions/schemas/changePlanSchema.ts +9 -0
- package/src/api/subscriptions/schemas/createSubscriptionSchema.ts +11 -0
- package/src/api/subscriptions/schemas/entitlementsSchema.ts +21 -0
- package/src/api/subscriptions/schemas/mrrSchema.ts +13 -0
- package/src/api/subscriptions/schemas/planDefinitionSchema.ts +71 -0
- package/src/api/subscriptions/schemas/planResourceSchema.ts +25 -0
- package/src/api/subscriptions/schemas/subscriptionEventResourceSchema.ts +8 -0
- package/src/api/subscriptions/schemas/subscriptionQuerySchema.ts +19 -0
- package/src/api/subscriptions/schemas/subscriptionResourceSchema.ts +6 -0
- package/src/api/subscriptions/schemas/subscriptionSettingsSchema.ts +32 -0
- package/src/api/subscriptions/schemas/subscriptionStatsSchema.ts +23 -0
- package/src/api/subscriptions/services/BillingService.ts +437 -0
- package/src/api/subscriptions/services/SubscriptionConfig.ts +56 -0
- package/src/api/subscriptions/services/SubscriptionService.ts +867 -0
- package/src/api/subscriptions/services/UsageService.ts +118 -0
- package/src/api/users/__tests__/AdminUserController.spec.ts +80 -1
- package/src/api/users/__tests__/CredentialService.spec.ts +177 -0
- package/src/api/users/__tests__/EmailVerification.spec.ts +29 -18
- package/src/api/users/__tests__/PasswordReset.spec.ts +3 -0
- package/src/api/users/__tests__/RegistrationService.spec.ts +148 -1
- package/src/api/users/__tests__/SessionService.spec.ts +142 -1
- package/src/api/users/atoms/realmAuthSettingsAtom.ts +10 -1
- package/src/api/users/controllers/UserController.ts +3 -8
- package/src/api/users/notifications/UserNotifications.ts +23 -0
- package/src/api/users/schemas/loginSchema.ts +1 -1
- package/src/api/users/services/CredentialService.ts +51 -4
- package/src/api/users/services/RegistrationService.ts +38 -9
- package/src/api/users/services/SessionService.ts +62 -9
- package/src/api/users/services/UserService.ts +21 -12
- package/src/api/workflows/__tests__/$workflow.spec.ts +616 -0
- package/src/api/workflows/controllers/AdminWorkflowController.ts +191 -0
- package/src/api/workflows/entities/workflowExecutions.ts +74 -0
- package/src/api/workflows/entities/workflowStepExecutions.ts +74 -0
- package/src/api/workflows/entities/workflowStepLogs.ts +13 -0
- package/src/api/workflows/index.browser.ts +22 -0
- package/src/api/workflows/index.ts +124 -0
- package/src/api/workflows/jobs/WorkflowJobs.ts +77 -0
- package/src/api/workflows/primitives/$workflow.ts +202 -0
- package/src/api/workflows/providers/WorkflowProvider.ts +1284 -0
- package/src/api/workflows/schemas/workflowActivitySchema.ts +15 -0
- package/src/api/workflows/schemas/workflowConfigAtom.ts +51 -0
- package/src/api/workflows/schemas/workflowExecutionDetailSchema.ts +18 -0
- package/src/api/workflows/schemas/workflowExecutionQuerySchema.ts +26 -0
- package/src/api/workflows/schemas/workflowExecutionResourceSchema.ts +30 -0
- package/src/api/workflows/schemas/workflowRegistrationSchema.ts +26 -0
- package/src/api/workflows/schemas/workflowStatsSchema.ts +16 -0
- package/src/api/workflows/schemas/workflowStepExecutionResourceSchema.ts +15 -0
- package/src/api/workflows/services/WorkflowService.ts +382 -0
- package/src/cli/core/templates/webAppRouterTs.ts +5 -58
- package/src/cli/vendor/__tests__/VendorService.spec.ts +283 -178
- package/src/cli/vendor/services/VendorService.ts +126 -27
- package/src/core/__tests__/TypeProvider.spec.ts +4 -2
- package/src/core/providers/SchemaValidator.ts +1 -1
- package/src/core/providers/TypeProvider.ts +46 -3
- package/src/orm/__tests__/enums.spec.ts +22 -29
- package/src/orm/__tests__/orm-showcase-tests.ts +430 -0
- package/src/orm/__tests__/orm-showcase.spec.ts +167 -0
- package/src/orm/core/providers/DatabaseTypeProvider.ts +0 -29
- package/src/orm/postgres/services/PostgresModelBuilder.ts +3 -6
- package/src/react/router/__tests__/$page.browser.spec.tsx +157 -0
- package/src/react/router/providers/ReactBrowserProvider.ts +39 -0
- package/src/react/router/providers/ReactBrowserRouterProvider.ts +22 -0
- package/src/security/__tests__/$secure-combinations.spec.ts +945 -0
- package/src/security/primitives/$secure.ts +28 -0
- package/dist/billing/index.d.ts.map +0 -1
- package/dist/billing/index.js.map +0 -1
- package/src/billing/__tests__/BillingService.spec.ts +0 -136
- /package/src/{billing → api/payments}/entities/paymentMethods.ts +0 -0
- /package/src/{billing → api/payments}/entities/refunds.ts +0 -0
- /package/src/{billing → api/payments}/schemas/intentSchemas.ts +0 -0
- /package/src/{billing → api/payments}/schemas/paymentMethodSchemas.ts +0 -0
- /package/src/{billing → api/payments}/schemas/refundSchemas.ts +0 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { $inject } from "alepha";
|
|
2
|
+
import { CacheProvider } from "alepha/cache";
|
|
3
|
+
import { DateTimeProvider } from "alepha/datetime";
|
|
4
|
+
import { SubscriptionService } from "./SubscriptionService.ts";
|
|
5
|
+
|
|
6
|
+
// -----------------------------------------------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The result of a usage check or increment operation.
|
|
10
|
+
*/
|
|
11
|
+
export interface UsageResult {
|
|
12
|
+
/**
|
|
13
|
+
* Whether the operation is allowed given the current usage and limit.
|
|
14
|
+
*/
|
|
15
|
+
allowed: boolean;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Current usage count for the period.
|
|
19
|
+
*/
|
|
20
|
+
current: number;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The plan limit for the resource. -1 means unlimited.
|
|
24
|
+
*/
|
|
25
|
+
limit: number;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Remaining capacity. -1 means unlimited.
|
|
29
|
+
*/
|
|
30
|
+
remaining: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// -----------------------------------------------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Tracks and enforces per-organization resource usage limits.
|
|
37
|
+
*
|
|
38
|
+
* Usage counters are keyed by `organizationId:resource:YYYY-MM` and stored in the cache.
|
|
39
|
+
* Limits are resolved from the organization's current subscription plan.
|
|
40
|
+
*/
|
|
41
|
+
export class UsageService {
|
|
42
|
+
protected readonly cache = $inject(CacheProvider);
|
|
43
|
+
protected readonly dateTime = $inject(DateTimeProvider);
|
|
44
|
+
protected readonly subscriptionService = $inject(SubscriptionService);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Increment a resource counter for the current period and return the usage result.
|
|
48
|
+
*
|
|
49
|
+
* @param organizationId The organization to track usage for.
|
|
50
|
+
* @param resource The resource identifier (e.g., "api_calls", "seats").
|
|
51
|
+
* @param amount Amount to increment by (default: 1).
|
|
52
|
+
*/
|
|
53
|
+
public async increment(
|
|
54
|
+
organizationId: string,
|
|
55
|
+
resource: string,
|
|
56
|
+
amount = 1,
|
|
57
|
+
): Promise<UsageResult> {
|
|
58
|
+
const limit = await this.subscriptionService.limit(
|
|
59
|
+
organizationId,
|
|
60
|
+
resource,
|
|
61
|
+
);
|
|
62
|
+
const key = this.buildKey(organizationId, resource);
|
|
63
|
+
const current = await this.cache.incr("subscriptions:usage", key, amount);
|
|
64
|
+
const allowed = limit === -1 || current <= limit;
|
|
65
|
+
return {
|
|
66
|
+
allowed,
|
|
67
|
+
current,
|
|
68
|
+
limit,
|
|
69
|
+
remaining: limit === -1 ? -1 : Math.max(0, limit - current),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get the current usage for a resource without incrementing.
|
|
75
|
+
*
|
|
76
|
+
* @param organizationId The organization to query usage for.
|
|
77
|
+
* @param resource The resource identifier.
|
|
78
|
+
*/
|
|
79
|
+
public async getUsage(
|
|
80
|
+
organizationId: string,
|
|
81
|
+
resource: string,
|
|
82
|
+
): Promise<UsageResult> {
|
|
83
|
+
const limit = await this.subscriptionService.limit(
|
|
84
|
+
organizationId,
|
|
85
|
+
resource,
|
|
86
|
+
);
|
|
87
|
+
const key = this.buildKey(organizationId, resource);
|
|
88
|
+
const current = await this.cache.incr("subscriptions:usage", key, 0);
|
|
89
|
+
return {
|
|
90
|
+
allowed: limit === -1 || current <= limit,
|
|
91
|
+
current,
|
|
92
|
+
limit,
|
|
93
|
+
remaining: limit === -1 ? -1 : Math.max(0, limit - current),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Reset all usage counters for an organization.
|
|
99
|
+
*
|
|
100
|
+
* Used at the start of a new billing period.
|
|
101
|
+
*
|
|
102
|
+
* @param organizationId The organization whose counters to reset.
|
|
103
|
+
*/
|
|
104
|
+
public async resetForPeriod(organizationId: string): Promise<void> {
|
|
105
|
+
const pattern = this.buildKey(organizationId, "*");
|
|
106
|
+
await this.cache.invalidateKeys("subscriptions:usage", [pattern]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build the cache key for a usage counter.
|
|
111
|
+
*
|
|
112
|
+
* Format: `organizationId:resource:YYYY-MM`
|
|
113
|
+
*/
|
|
114
|
+
protected buildKey(organizationId: string, resource: string): string {
|
|
115
|
+
const period = this.dateTime.now().format("YYYY-MM");
|
|
116
|
+
return `${organizationId}:${resource}:${period}`;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -4,7 +4,12 @@ import { AlephaOrmPostgres } from "alepha/orm/postgres";
|
|
|
4
4
|
import { AlephaSecurity, type UserAccountToken } from "alepha/security";
|
|
5
5
|
import { BadRequestError } from "alepha/server";
|
|
6
6
|
import { describe, it } from "vitest";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
AdminUserController,
|
|
9
|
+
AlephaApiUsers,
|
|
10
|
+
SessionService,
|
|
11
|
+
UserService,
|
|
12
|
+
} from "../index.ts";
|
|
8
13
|
|
|
9
14
|
const adminUser: UserAccountToken = {
|
|
10
15
|
id: "00000000-0000-0000-0000-000000000001",
|
|
@@ -445,3 +450,77 @@ describe("alepha/api/users - AdminUserController CRUD", () => {
|
|
|
445
450
|
expect(userIds.indexOf(user2.id)).toBeLessThan(userIds.indexOf(user1.id));
|
|
446
451
|
});
|
|
447
452
|
});
|
|
453
|
+
|
|
454
|
+
describe("alepha/api/users - AdminUserController delete cleanup", () => {
|
|
455
|
+
it("should clean up sessions and identities when deleting a user", async ({
|
|
456
|
+
expect,
|
|
457
|
+
}) => {
|
|
458
|
+
const { alepha, controller } = await setup();
|
|
459
|
+
const sessionService = alepha.inject(SessionService);
|
|
460
|
+
|
|
461
|
+
// Arrange: create a user
|
|
462
|
+
const user = await controller.createUser(
|
|
463
|
+
{
|
|
464
|
+
body: {
|
|
465
|
+
username: "cleanupuser",
|
|
466
|
+
email: "cleanupuser@example.com",
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
asAdmin,
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
// Arrange: create identities for the user (credentials + google)
|
|
473
|
+
await sessionService.identities().create({
|
|
474
|
+
userId: user.id,
|
|
475
|
+
provider: "credentials",
|
|
476
|
+
providerUserId: user.id,
|
|
477
|
+
});
|
|
478
|
+
await sessionService.identities().create({
|
|
479
|
+
userId: user.id,
|
|
480
|
+
provider: "google",
|
|
481
|
+
providerUserId: "google-123",
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// Arrange: create sessions for the user
|
|
485
|
+
await sessionService.sessions().create({
|
|
486
|
+
userId: user.id,
|
|
487
|
+
refreshToken: "00000000-0000-0000-0000-000000000010",
|
|
488
|
+
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
489
|
+
});
|
|
490
|
+
await sessionService.sessions().create({
|
|
491
|
+
userId: user.id,
|
|
492
|
+
refreshToken: "00000000-0000-0000-0000-000000000011",
|
|
493
|
+
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// Sanity check: sessions and identities exist before deletion
|
|
497
|
+
const sessionsBefore = await sessionService
|
|
498
|
+
.sessions()
|
|
499
|
+
.findMany({ where: { userId: { eq: user.id } } });
|
|
500
|
+
expect(sessionsBefore.length).toBe(2);
|
|
501
|
+
|
|
502
|
+
const identitiesBefore = await sessionService
|
|
503
|
+
.identities()
|
|
504
|
+
.findMany({ where: { userId: { eq: user.id } } });
|
|
505
|
+
expect(identitiesBefore.length).toBe(2);
|
|
506
|
+
|
|
507
|
+
// Act: delete the user
|
|
508
|
+
const result = await controller.deleteUser(
|
|
509
|
+
{ params: { id: user.id } },
|
|
510
|
+
asAdmin,
|
|
511
|
+
);
|
|
512
|
+
expect(result.ok).toBe(true);
|
|
513
|
+
|
|
514
|
+
// Assert: sessions for that user should be gone
|
|
515
|
+
const sessionsAfter = await sessionService
|
|
516
|
+
.sessions()
|
|
517
|
+
.findMany({ where: { userId: { eq: user.id } } });
|
|
518
|
+
expect(sessionsAfter.length).toBe(0);
|
|
519
|
+
|
|
520
|
+
// Assert: identities for that user should be gone
|
|
521
|
+
const identitiesAfter = await sessionService
|
|
522
|
+
.identities()
|
|
523
|
+
.findMany({ where: { userId: { eq: user.id } } });
|
|
524
|
+
expect(identitiesAfter.length).toBe(0);
|
|
525
|
+
});
|
|
526
|
+
});
|
|
@@ -35,6 +35,9 @@ const setup = async () => {
|
|
|
35
35
|
features: {
|
|
36
36
|
notifications: true,
|
|
37
37
|
},
|
|
38
|
+
settings: {
|
|
39
|
+
resetPasswordAllowed: true,
|
|
40
|
+
},
|
|
38
41
|
});
|
|
39
42
|
|
|
40
43
|
const emailProvider = alepha.inject(MemoryEmailProvider);
|
|
@@ -499,6 +502,180 @@ describe("alepha/api/users - CredentialService", () => {
|
|
|
499
502
|
});
|
|
500
503
|
});
|
|
501
504
|
|
|
505
|
+
describe("resetPasswordAllowed=false", () => {
|
|
506
|
+
it("should silently return intent when resetPasswordAllowed is false", async ({
|
|
507
|
+
expect,
|
|
508
|
+
}) => {
|
|
509
|
+
const { alepha, credentialService, userService, cryptoProvider } =
|
|
510
|
+
await setup();
|
|
511
|
+
|
|
512
|
+
// Register a separate realm with resetPasswordAllowed disabled
|
|
513
|
+
const realmProvider = alepha.inject(RealmProvider);
|
|
514
|
+
realmProvider.register("no-reset", {
|
|
515
|
+
features: { notifications: true },
|
|
516
|
+
settings: { resetPasswordAllowed: false },
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// Create a user in the no-reset realm
|
|
520
|
+
const user = await credentialService.users("no-reset").create({
|
|
521
|
+
email: "noreset@example.com",
|
|
522
|
+
username: "noresetuser",
|
|
523
|
+
roles: ["user"],
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
const hashedPassword = await cryptoProvider.hashPassword("Password123!");
|
|
527
|
+
await credentialService.identities("no-reset").create({
|
|
528
|
+
userId: user.id,
|
|
529
|
+
provider: "credentials",
|
|
530
|
+
password: hashedPassword,
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// Request password reset - should return an intentId (security: doesn't reveal it's disabled)
|
|
534
|
+
const result = await credentialService.createPasswordResetIntent(
|
|
535
|
+
"noreset@example.com",
|
|
536
|
+
"no-reset",
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
expect(result.intentId).toBeDefined();
|
|
540
|
+
expect(result.expiresAt).toBeDefined();
|
|
541
|
+
|
|
542
|
+
// No email should be sent
|
|
543
|
+
const emailProvider = alepha.inject(MemoryEmailProvider);
|
|
544
|
+
expect(emailProvider.records.length).toBe(0);
|
|
545
|
+
|
|
546
|
+
// Completing the reset with the returned intentId should fail with 410
|
|
547
|
+
// because the intent was never actually stored in the cache
|
|
548
|
+
await expect(
|
|
549
|
+
credentialService.completePasswordReset({
|
|
550
|
+
intentId: result.intentId,
|
|
551
|
+
code: "123456",
|
|
552
|
+
newPassword: "NewPassword456!",
|
|
553
|
+
}),
|
|
554
|
+
).rejects.toThrow(HttpError);
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
describe("Password policy enforcement during reset", () => {
|
|
559
|
+
it("should reject password that violates realm policy", async ({
|
|
560
|
+
expect,
|
|
561
|
+
}) => {
|
|
562
|
+
const { alepha, credentialService, userService, cryptoProvider } =
|
|
563
|
+
await setup();
|
|
564
|
+
|
|
565
|
+
// Register a realm with strict password policy
|
|
566
|
+
const realmProvider = alepha.inject(RealmProvider);
|
|
567
|
+
realmProvider.register("strict-policy", {
|
|
568
|
+
features: { notifications: true },
|
|
569
|
+
settings: {
|
|
570
|
+
resetPasswordAllowed: true,
|
|
571
|
+
passwordPolicy: {
|
|
572
|
+
minLength: 8,
|
|
573
|
+
requireUppercase: true,
|
|
574
|
+
requireLowercase: true,
|
|
575
|
+
requireNumbers: true,
|
|
576
|
+
requireSpecialCharacters: false,
|
|
577
|
+
},
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// Create a user in the strict-policy realm
|
|
582
|
+
const user = await credentialService.users("strict-policy").create({
|
|
583
|
+
email: "strict@example.com",
|
|
584
|
+
username: "strictuser",
|
|
585
|
+
roles: ["user"],
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
const hashedPassword =
|
|
589
|
+
await cryptoProvider.hashPassword("OldPassword123!");
|
|
590
|
+
await credentialService.identities("strict-policy").create({
|
|
591
|
+
userId: user.id,
|
|
592
|
+
provider: "credentials",
|
|
593
|
+
password: hashedPassword,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
// Create intent
|
|
597
|
+
const intent = await credentialService.createPasswordResetIntent(
|
|
598
|
+
"strict@example.com",
|
|
599
|
+
"strict-policy",
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
// Extract code from email
|
|
603
|
+
const emailProvider = alepha.inject(MemoryEmailProvider);
|
|
604
|
+
await expect.poll(() => emailProvider.records.length).toBe(1);
|
|
605
|
+
const code = extractCode(emailProvider.records[0].body);
|
|
606
|
+
|
|
607
|
+
// Try to complete with a password that has no uppercase and no numbers
|
|
608
|
+
await expect(
|
|
609
|
+
credentialService.completePasswordReset({
|
|
610
|
+
intentId: intent.intentId,
|
|
611
|
+
code,
|
|
612
|
+
newPassword: "alllowercase",
|
|
613
|
+
}),
|
|
614
|
+
).rejects.toThrowError(BadRequestError);
|
|
615
|
+
|
|
616
|
+
// The verification code should NOT be consumed - can retry with a valid password
|
|
617
|
+
await credentialService.completePasswordReset({
|
|
618
|
+
intentId: intent.intentId,
|
|
619
|
+
code,
|
|
620
|
+
newPassword: "ValidRetry123",
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it("should accept password that meets realm policy", async ({ expect }) => {
|
|
625
|
+
const { alepha, credentialService, userService, cryptoProvider } =
|
|
626
|
+
await setup();
|
|
627
|
+
|
|
628
|
+
// Register a realm with strict password policy
|
|
629
|
+
const realmProvider = alepha.inject(RealmProvider);
|
|
630
|
+
realmProvider.register("strict-accept", {
|
|
631
|
+
features: { notifications: true },
|
|
632
|
+
settings: {
|
|
633
|
+
resetPasswordAllowed: true,
|
|
634
|
+
passwordPolicy: {
|
|
635
|
+
minLength: 8,
|
|
636
|
+
requireUppercase: true,
|
|
637
|
+
requireLowercase: true,
|
|
638
|
+
requireNumbers: true,
|
|
639
|
+
requireSpecialCharacters: false,
|
|
640
|
+
},
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// Create a user in the strict-accept realm
|
|
645
|
+
const user = await credentialService.users("strict-accept").create({
|
|
646
|
+
email: "accept@example.com",
|
|
647
|
+
username: "acceptuser",
|
|
648
|
+
roles: ["user"],
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
const hashedPassword =
|
|
652
|
+
await cryptoProvider.hashPassword("OldPassword123!");
|
|
653
|
+
await credentialService.identities("strict-accept").create({
|
|
654
|
+
userId: user.id,
|
|
655
|
+
provider: "credentials",
|
|
656
|
+
password: hashedPassword,
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// Create intent
|
|
660
|
+
const intent = await credentialService.createPasswordResetIntent(
|
|
661
|
+
"accept@example.com",
|
|
662
|
+
"strict-accept",
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
// Extract code from email
|
|
666
|
+
const emailProvider = alepha.inject(MemoryEmailProvider);
|
|
667
|
+
await expect.poll(() => emailProvider.records.length).toBe(1);
|
|
668
|
+
const code = extractCode(emailProvider.records[0].body);
|
|
669
|
+
|
|
670
|
+
// Complete with a password that meets the policy
|
|
671
|
+
await credentialService.completePasswordReset({
|
|
672
|
+
intentId: intent.intentId,
|
|
673
|
+
code,
|
|
674
|
+
newPassword: "ValidPass123",
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
|
|
502
679
|
describe("Legacy methods (backward compatibility)", () => {
|
|
503
680
|
it("requestPasswordReset should work as before", async ({ expect }) => {
|
|
504
681
|
const { credentialService, userService, cryptoProvider, emailProvider } =
|
|
@@ -3,7 +3,7 @@ import { AlephaApiVerification } from "alepha/api/verifications";
|
|
|
3
3
|
import { DateTimeProvider } from "alepha/datetime";
|
|
4
4
|
import { AlephaEmail, MemoryEmailProvider } from "alepha/email";
|
|
5
5
|
import { AlephaOrmPostgres } from "alepha/orm/postgres";
|
|
6
|
-
import { AlephaSecurity } from "alepha/security";
|
|
6
|
+
import { AlephaSecurity, currentUserAtom } from "alepha/security";
|
|
7
7
|
import { BadRequestError } from "alepha/server";
|
|
8
8
|
import { describe, it } from "vitest";
|
|
9
9
|
import {
|
|
@@ -264,7 +264,7 @@ describe("alepha/api/users - Email Verification", () => {
|
|
|
264
264
|
});
|
|
265
265
|
|
|
266
266
|
it("should check email verification status", async ({ expect }) => {
|
|
267
|
-
const { userService, actions } = await setup();
|
|
267
|
+
const { alepha, userService, actions } = await setup();
|
|
268
268
|
|
|
269
269
|
// Create a test user with unverified email
|
|
270
270
|
await userService.users().create({
|
|
@@ -282,23 +282,34 @@ describe("alepha/api/users - Email Verification", () => {
|
|
|
282
282
|
emailVerified: true,
|
|
283
283
|
});
|
|
284
284
|
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
285
|
+
// checkEmailVerification requires $secure() authentication
|
|
286
|
+
await alepha.context.run(async () => {
|
|
287
|
+
alepha.store.set(currentUserAtom, {
|
|
288
|
+
id: "admin-1",
|
|
289
|
+
name: "Admin",
|
|
290
|
+
email: "admin@example.com",
|
|
291
|
+
realm: "default",
|
|
292
|
+
roles: ["admin"],
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Check unverified email
|
|
296
|
+
const unverifiedResult = await actions.checkEmailVerification({
|
|
297
|
+
query: { email: "unverified@example.com" },
|
|
298
|
+
});
|
|
299
|
+
expect(unverifiedResult.verified).toBe(false);
|
|
300
|
+
|
|
301
|
+
// Check verified email
|
|
302
|
+
const verifiedResult = await actions.checkEmailVerification({
|
|
303
|
+
query: { email: "verified@example.com" },
|
|
304
|
+
});
|
|
305
|
+
expect(verifiedResult.verified).toBe(true);
|
|
306
|
+
|
|
307
|
+
// Check non-existent email
|
|
308
|
+
const nonExistentResult = await actions.checkEmailVerification({
|
|
309
|
+
query: { email: "nonexistent@example.com" },
|
|
310
|
+
});
|
|
311
|
+
expect(nonExistentResult.verified).toBe(false);
|
|
300
312
|
});
|
|
301
|
-
expect(nonExistentResult.verified).toBe(false);
|
|
302
313
|
});
|
|
303
314
|
|
|
304
315
|
it("should respect rate limiting on verification requests", async ({
|
|
@@ -245,8 +245,9 @@ describe("alepha/api/users - RegistrationService", () => {
|
|
|
245
245
|
} as never,
|
|
246
246
|
});
|
|
247
247
|
|
|
248
|
-
// Create existing user with phone
|
|
248
|
+
// Create existing user with phone in the same realm
|
|
249
249
|
await userService.users("phone-realm").create({
|
|
250
|
+
realm: "phone-realm",
|
|
250
251
|
username: "existinguser",
|
|
251
252
|
email: "existing@example.com",
|
|
252
253
|
phoneNumber: "+1234567890",
|
|
@@ -488,6 +489,7 @@ describe("alepha/api/users - RegistrationService", () => {
|
|
|
488
489
|
|
|
489
490
|
// Simulate another user registering with same email while verification pending
|
|
490
491
|
await userService.users("race-realm").create({
|
|
492
|
+
realm: "race-realm",
|
|
491
493
|
email: "race@example.com",
|
|
492
494
|
username: "racewinner",
|
|
493
495
|
roles: ["user"],
|
|
@@ -644,4 +646,149 @@ describe("alepha/api/users - RegistrationService", () => {
|
|
|
644
646
|
expect(session?.id).toBe(user.id);
|
|
645
647
|
});
|
|
646
648
|
});
|
|
649
|
+
|
|
650
|
+
describe("Password policy enforcement during registration", () => {
|
|
651
|
+
it("should reject registration when password violates realm policy", async ({
|
|
652
|
+
expect,
|
|
653
|
+
}) => {
|
|
654
|
+
const { registrationService, realmProvider } = await setup();
|
|
655
|
+
|
|
656
|
+
realmProvider.register("strict-policy-realm", {
|
|
657
|
+
settings: {
|
|
658
|
+
passwordPolicy: {
|
|
659
|
+
minLength: 10,
|
|
660
|
+
requireUppercase: true,
|
|
661
|
+
requireLowercase: false,
|
|
662
|
+
requireNumbers: false,
|
|
663
|
+
requireSpecialCharacters: false,
|
|
664
|
+
},
|
|
665
|
+
} as never,
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
await expect(
|
|
669
|
+
registrationService.createRegistrationIntent(
|
|
670
|
+
{
|
|
671
|
+
email: "weakpass@example.com",
|
|
672
|
+
password: "shortpw!",
|
|
673
|
+
},
|
|
674
|
+
"strict-policy-realm",
|
|
675
|
+
),
|
|
676
|
+
).rejects.toThrowError(BadRequestError);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it("should accept registration when password meets realm policy", async ({
|
|
680
|
+
expect,
|
|
681
|
+
}) => {
|
|
682
|
+
const { registrationService, realmProvider } = await setup();
|
|
683
|
+
|
|
684
|
+
realmProvider.register("strict-policy-realm", {
|
|
685
|
+
settings: {
|
|
686
|
+
passwordPolicy: {
|
|
687
|
+
minLength: 10,
|
|
688
|
+
requireUppercase: true,
|
|
689
|
+
requireLowercase: false,
|
|
690
|
+
requireNumbers: false,
|
|
691
|
+
requireSpecialCharacters: false,
|
|
692
|
+
},
|
|
693
|
+
} as never,
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
const result = await registrationService.createRegistrationIntent(
|
|
697
|
+
{
|
|
698
|
+
email: "strongpass@example.com",
|
|
699
|
+
password: "StrongPass123",
|
|
700
|
+
},
|
|
701
|
+
"strict-policy-realm",
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
expect(result.intentId).toBeDefined();
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
describe("Registration rate limiting", () => {
|
|
709
|
+
it("should rate limit registration attempts by IP", async ({ expect }) => {
|
|
710
|
+
const { alepha, registrationService } = await setup();
|
|
711
|
+
|
|
712
|
+
await alepha.fork(async () => {
|
|
713
|
+
alepha.store.set("alepha.http.request", { ip: "10.0.0.1" } as never);
|
|
714
|
+
|
|
715
|
+
// Make 10 registration attempts (they may fail for duplicate email, that's fine)
|
|
716
|
+
for (let i = 0; i < 10; i++) {
|
|
717
|
+
await registrationService
|
|
718
|
+
.createRegistrationIntent({
|
|
719
|
+
email: `ratelimit-${i}@example.com`,
|
|
720
|
+
password: "SecurePassword123!",
|
|
721
|
+
})
|
|
722
|
+
.catch(() => {});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// 11th attempt should be rate limited
|
|
726
|
+
await expect(
|
|
727
|
+
registrationService.createRegistrationIntent({
|
|
728
|
+
email: "ratelimit-overflow@example.com",
|
|
729
|
+
password: "SecurePassword123!",
|
|
730
|
+
}),
|
|
731
|
+
).rejects.toThrowError(BadRequestError);
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
describe("Default roles from realm settings", () => {
|
|
737
|
+
it("should assign defaultRoles from realm settings to new users", async ({
|
|
738
|
+
expect,
|
|
739
|
+
}) => {
|
|
740
|
+
const { registrationService, realmProvider } = await setup();
|
|
741
|
+
|
|
742
|
+
realmProvider.register("custom-roles-realm", {
|
|
743
|
+
settings: {
|
|
744
|
+
defaultRoles: ["member", "viewer"],
|
|
745
|
+
} as never,
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
const intent = await registrationService.createRegistrationIntent(
|
|
749
|
+
{
|
|
750
|
+
email: "roleuser@example.com",
|
|
751
|
+
password: "SecurePassword123!",
|
|
752
|
+
},
|
|
753
|
+
"custom-roles-realm",
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
const user = await registrationService.completeRegistration({
|
|
757
|
+
intentId: intent.intentId,
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
expect(user.roles).toEqual(["member", "viewer"]);
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
describe("Cross-realm email uniqueness", () => {
|
|
765
|
+
it("should allow same email in different realms", async ({ expect }) => {
|
|
766
|
+
const { registrationService, userService, realmProvider } = await setup();
|
|
767
|
+
|
|
768
|
+
realmProvider.register("realm-a");
|
|
769
|
+
realmProvider.register("realm-b");
|
|
770
|
+
|
|
771
|
+
// Create a user with this email in realm-a directly
|
|
772
|
+
await userService.users("realm-a").create({
|
|
773
|
+
realm: "realm-a",
|
|
774
|
+
email: "shared@example.com",
|
|
775
|
+
roles: ["user"],
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
// Register a new user with the same email in realm-b via registration flow
|
|
779
|
+
const intent = await registrationService.createRegistrationIntent(
|
|
780
|
+
{
|
|
781
|
+
email: "shared@example.com",
|
|
782
|
+
password: "SecurePassword123!",
|
|
783
|
+
},
|
|
784
|
+
"realm-b",
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
const user = await registrationService.completeRegistration({
|
|
788
|
+
intentId: intent.intentId,
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
expect(user.email).toBe("shared@example.com");
|
|
792
|
+
});
|
|
793
|
+
});
|
|
647
794
|
});
|