@ftisindia/create-app 0.1.4 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ftisindia/create-app",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "One-command scaffolder for the Phase 1 NestJS foundation starter.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,12 @@ AUTH_FACEBOOK_ENABLED=false
21
21
  AUTH_MOBILE_OTP_ENABLED=false
22
22
  AUTH_MAGIC_LINK_ENABLED=false
23
23
 
24
+ CORS_ENABLED=false
25
+ # Comma-separated exact browser origins, for example:
26
+ # CORS_ORIGINS=http://localhost:5173,https://app.example.com
27
+ CORS_ORIGINS=
28
+ CORS_CREDENTIALS=false
29
+
24
30
  RBAC_CACHE_TTL_SECONDS=60
25
31
  # Path is the only supported mode in this starter phase.
26
32
  ORG_CONTEXT_MODE=path
@@ -78,6 +78,7 @@ GET /auth/google
78
78
  GET /auth/google/callback
79
79
  GET /auth/me
80
80
  POST /organisations
81
+ GET /organisations/mine
81
82
  GET /organisations/:orgId/memberships/me
82
83
  GET /organisations/:orgId/memberships
83
84
  PATCH /organisations/:orgId/memberships/:membershipId/status
@@ -91,6 +92,7 @@ POST /organisations/:orgId/invitations/:invitationId/revoke
91
92
  POST /organisations/:orgId/invitations/:invitationId/resend
92
93
  POST /invitations/accept
93
94
  POST /invitations/decline
95
+ GET /organisations/:orgId/access-control/me
94
96
  GET /organisations/:orgId/access-control/permissions
95
97
  GET /organisations/:orgId/access-control/route-permissions
96
98
  GET /organisations/:orgId/access-control/roles
@@ -107,6 +109,14 @@ GET /organisations/:orgId/sample/status
107
109
  POST /organisations/:orgId/sample/echo
108
110
  ```
109
111
 
112
+ ## Frontend Bootstrap
113
+
114
+ After login, call `GET /organisations/mine` to populate the organisation picker,
115
+ store the selected `orgId`, then call
116
+ `GET /organisations/:orgId/access-control/me`. Use the returned
117
+ `permissionKeys` for route, menu, and button visibility in the frontend. Backend
118
+ guards remain the source of truth for access control.
119
+
110
120
  ## Architecture Rules
111
121
 
112
122
  - JWTs contain identity only. Organisation membership, roles, and permissions are read per request.
@@ -1,7 +1,12 @@
1
1
  import { registerAs } from '@nestjs/config';
2
- import { getEnv } from './env.validation';
2
+ import { getEnv, parseCorsOrigins } from './env.validation';
3
3
 
4
4
  export default registerAs('app', () => ({
5
5
  nodeEnv: getEnv().NODE_ENV,
6
6
  port: getEnv().PORT,
7
+ cors: {
8
+ enabled: getEnv().CORS_ENABLED,
9
+ origins: parseCorsOrigins(getEnv().CORS_ORIGINS),
10
+ credentials: getEnv().CORS_CREDENTIALS,
11
+ },
7
12
  }));
@@ -61,10 +61,23 @@ export const envSchema = z
61
61
  AUTH_FACEBOOK_ENABLED: booleanFromEnv.default(false),
62
62
  AUTH_MOBILE_OTP_ENABLED: booleanFromEnv.default(false),
63
63
  AUTH_MAGIC_LINK_ENABLED: booleanFromEnv.default(false),
64
+ CORS_ENABLED: booleanFromEnv.default(false),
65
+ CORS_ORIGINS: z.string().trim().optional().default(''),
66
+ CORS_CREDENTIALS: booleanFromEnv.default(false),
64
67
  RBAC_CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(60),
65
68
  ORG_CONTEXT_MODE: z.literal('path').default('path'),
66
69
  })
67
70
  .superRefine((env, ctx) => {
71
+ const corsOrigins = parseCorsOrigins(env.CORS_ORIGINS);
72
+
73
+ if (env.CORS_CREDENTIALS && corsOrigins.includes('*')) {
74
+ ctx.addIssue({
75
+ code: 'custom',
76
+ path: ['CORS_ORIGINS'],
77
+ message: 'CORS_ORIGINS cannot include * when CORS_CREDENTIALS=true',
78
+ });
79
+ }
80
+
68
81
  if (!env.AUTH_GOOGLE_ENABLED) {
69
82
  return;
70
83
  }
@@ -136,6 +149,13 @@ export function getEnv(): Env {
136
149
  return validatedEnv;
137
150
  }
138
151
 
152
+ export function parseCorsOrigins(value: string) {
153
+ return value
154
+ .split(',')
155
+ .map((origin) => origin.trim())
156
+ .filter(Boolean);
157
+ }
158
+
139
159
  function requireHttpsInProduction(ctx: z.RefinementCtx, key: string, value: string) {
140
160
  if (new URL(value).protocol === 'https:') {
141
161
  return;
@@ -8,6 +8,19 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
8
8
  async function bootstrap() {
9
9
  const app = await NestFactory.create(AppModule);
10
10
  const config = app.get(ConfigService);
11
+ const cors = config.get<{
12
+ enabled: boolean;
13
+ origins: string[];
14
+ credentials: boolean;
15
+ }>('app.cors');
16
+
17
+ if (cors?.enabled) {
18
+ app.enableCors({
19
+ origin: cors.origins,
20
+ credentials: cors.credentials,
21
+ });
22
+ }
23
+
11
24
  app.enableShutdownHooks();
12
25
  app.useGlobalFilters(new HttpExceptionFilter());
13
26
 
@@ -7,11 +7,12 @@ import { AccessControlService } from './application/services/access-control.serv
7
7
  import { PermissionGuard } from './application/services/permission.guard';
8
8
  import { RbacCacheService } from './application/services/rbac-cache.service';
9
9
  import { AccessControlController } from './presentation/access-control.controller';
10
+ import { CurrentAccessControlController } from './presentation/current-access-control.controller';
10
11
 
11
12
  @Global()
12
13
  @Module({
13
14
  imports: [AuthModule, DiscoveryModule],
14
- controllers: [AccessControlController],
15
+ controllers: [AccessControlController, CurrentAccessControlController],
15
16
  providers: [
16
17
  AbilityFactory,
17
18
  AccessControlService,
@@ -0,0 +1,31 @@
1
+ import { ApiProperty } from '@nestjs/swagger';
2
+ import { PermissionKey, permissionKeys } from '../types/permission-key';
3
+
4
+ export class CurrentAccessControlResponseDto {
5
+ @ApiProperty({
6
+ example: '2c67399d-670c-4025-a5fd-1ea9a211891e',
7
+ format: 'uuid',
8
+ })
9
+ orgId!: string;
10
+
11
+ @ApiProperty({
12
+ example: '0a57fb4a-95c6-4f7e-bd5a-f96dbe0599e3',
13
+ format: 'uuid',
14
+ })
15
+ membershipId!: string;
16
+
17
+ @ApiProperty({
18
+ example: 'f602c057-04f4-4ef8-8c84-1b7c62fbf8c5',
19
+ format: 'uuid',
20
+ })
21
+ roleId!: string;
22
+
23
+ @ApiProperty({ example: true })
24
+ isOwner!: boolean;
25
+
26
+ @ApiProperty({ example: true })
27
+ isBillingContact!: boolean;
28
+
29
+ @ApiProperty({ enum: permissionKeys, example: ['organisations.read', 'settings.read'] })
30
+ permissionKeys!: PermissionKey[];
31
+ }
@@ -0,0 +1,40 @@
1
+ import { Controller, Get, Param, UseGuards } from '@nestjs/common';
2
+ import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
3
+ import { ApiProtectedErrorResponses } from '../../../common/swagger/api-error-responses';
4
+ import { JwtAuthGuard } from '../../auth/infrastructure/passport/jwt-auth.guard';
5
+ import { CurrentUser } from '../../auth/presentation/current-user.decorator';
6
+ import { AuthenticatedUser } from '../../auth/types/authenticated-user';
7
+ import { OrgScopeGuard } from '../../request-context/presentation/org-scope.guard';
8
+ import { RbacCacheService } from '../application/services/rbac-cache.service';
9
+ import { CurrentAccessControlResponseDto } from '../dto/current-access-control-response.dto';
10
+
11
+ @ApiTags('Access control')
12
+ @ApiBearerAuth()
13
+ @ApiParam({ name: 'orgId', description: 'Organisation ID.', format: 'uuid' })
14
+ @ApiProtectedErrorResponses()
15
+ @Controller('organisations/:orgId/access-control')
16
+ @UseGuards(JwtAuthGuard, OrgScopeGuard)
17
+ export class CurrentAccessControlController {
18
+ constructor(private readonly rbacCache: RbacCacheService) {}
19
+
20
+ @Get('me')
21
+ @ApiOperation({
22
+ summary: "Return the current user's effective RBAC context for the organisation.",
23
+ })
24
+ @ApiOkResponse({
25
+ description: 'Effective access-control context.',
26
+ type: CurrentAccessControlResponseDto,
27
+ })
28
+ async me(@CurrentUser() user: AuthenticatedUser, @Param('orgId') orgId: string) {
29
+ const context = await this.rbacCache.getContext(user.id, orgId);
30
+
31
+ return {
32
+ orgId: context.orgId,
33
+ membershipId: context.membershipId,
34
+ roleId: context.roleId,
35
+ isOwner: context.isOwner,
36
+ isBillingContact: context.isBillingContact,
37
+ permissionKeys: context.permissionKeys,
38
+ };
39
+ }
40
+ }
@@ -1,5 +1,10 @@
1
1
  import { ConflictException, Injectable } from '@nestjs/common';
2
- import { Prisma } from '@prisma/client';
2
+ import { MembershipStatus, OrganisationStatus, Prisma } from '@prisma/client';
3
+ import {
4
+ PaginationQueryDto,
5
+ resolvePageLimit,
6
+ toPage,
7
+ } from '../../../../common/dto/pagination-query.dto';
3
8
  import { PrismaService } from '../../../../database/prisma/prisma.service';
4
9
  import { RequestContextService } from '../../../request-context/application/services/request-context.service';
5
10
  import { AuthenticatedUser } from '../../../auth/types/authenticated-user';
@@ -18,6 +23,67 @@ export class OrganisationsService {
18
23
  private readonly requestContext: RequestContextService,
19
24
  ) {}
20
25
 
26
+ async listMine(currentUser: AuthenticatedUser, query: PaginationQueryDto) {
27
+ const limit = resolvePageLimit(query.limit);
28
+
29
+ const rows = await this.prisma.membership.findMany({
30
+ where: {
31
+ userId: currentUser.id,
32
+ status: MembershipStatus.ACTIVE,
33
+ organisation: {
34
+ status: OrganisationStatus.ACTIVE,
35
+ },
36
+ },
37
+ select: {
38
+ id: true,
39
+ roleId: true,
40
+ isOwner: true,
41
+ isBillingContact: true,
42
+ organisation: {
43
+ select: {
44
+ id: true,
45
+ name: true,
46
+ slug: true,
47
+ status: true,
48
+ },
49
+ },
50
+ role: {
51
+ select: {
52
+ id: true,
53
+ name: true,
54
+ description: true,
55
+ isSystemSeeded: true,
56
+ },
57
+ },
58
+ },
59
+ orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }, { id: 'asc' }],
60
+ take: limit + 1,
61
+ ...(query.cursor
62
+ ? {
63
+ cursor: { id: query.cursor },
64
+ skip: 1,
65
+ }
66
+ : {}),
67
+ });
68
+
69
+ const page = toPage(rows, limit);
70
+
71
+ return {
72
+ items: page.items.map((membership) => ({
73
+ id: membership.organisation.id,
74
+ name: membership.organisation.name,
75
+ slug: membership.organisation.slug,
76
+ status: membership.organisation.status,
77
+ membershipId: membership.id,
78
+ roleId: membership.roleId,
79
+ role: membership.role,
80
+ isOwner: membership.isOwner,
81
+ isBillingContact: membership.isBillingContact,
82
+ })),
83
+ nextCursor: page.nextCursor,
84
+ };
85
+ }
86
+
21
87
  async createOrganisation(currentUser: AuthenticatedUser, dto: CreateOrganisationDto) {
22
88
  const name = dto.name.trim();
23
89
  const slug = dto.slug?.trim() || (await this.createAvailableSlug(name));
@@ -1,6 +1,7 @@
1
1
  import { ApiProperty } from '@nestjs/swagger';
2
2
  import { OrganisationStatus } from '@prisma/client';
3
3
  import { MembershipResponseDto } from '../../../common/dto/membership-response.dto';
4
+ import { RoleSummaryDto } from '../../../common/dto/role-summary.dto';
4
5
 
5
6
  export class OrganisationResponseDto {
6
7
  @ApiProperty({
@@ -60,3 +61,53 @@ export class CreateOrganisationResponseDto {
60
61
  @ApiProperty({ type: [SeededOrganisationSettingDto] })
61
62
  settings!: SeededOrganisationSettingDto[];
62
63
  }
64
+
65
+ export class MyOrganisationResponseDto {
66
+ @ApiProperty({
67
+ example: '2c67399d-670c-4025-a5fd-1ea9a211891e',
68
+ format: 'uuid',
69
+ })
70
+ id!: string;
71
+
72
+ @ApiProperty({ example: 'Acme Operations' })
73
+ name!: string;
74
+
75
+ @ApiProperty({ example: 'acme-operations' })
76
+ slug!: string;
77
+
78
+ @ApiProperty({ enum: OrganisationStatus, example: OrganisationStatus.ACTIVE })
79
+ status!: OrganisationStatus;
80
+
81
+ @ApiProperty({
82
+ example: '0a57fb4a-95c6-4f7e-bd5a-f96dbe0599e3',
83
+ format: 'uuid',
84
+ })
85
+ membershipId!: string;
86
+
87
+ @ApiProperty({
88
+ example: 'f602c057-04f4-4ef8-8c84-1b7c62fbf8c5',
89
+ format: 'uuid',
90
+ })
91
+ roleId!: string;
92
+
93
+ @ApiProperty({ type: RoleSummaryDto })
94
+ role!: RoleSummaryDto;
95
+
96
+ @ApiProperty({ example: true })
97
+ isOwner!: boolean;
98
+
99
+ @ApiProperty({ example: true })
100
+ isBillingContact!: boolean;
101
+ }
102
+
103
+ export class MyOrganisationListResponseDto {
104
+ @ApiProperty({ type: [MyOrganisationResponseDto] })
105
+ items!: MyOrganisationResponseDto[];
106
+
107
+ @ApiProperty({
108
+ example: '2c67399d-670c-4025-a5fd-1ea9a211891e',
109
+ format: 'uuid',
110
+ nullable: true,
111
+ })
112
+ nextCursor!: string | null;
113
+ }
@@ -1,12 +1,22 @@
1
- import { Body, Controller, Post, UseGuards } from '@nestjs/common';
2
- import { ApiBearerAuth, ApiCreatedResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
1
+ import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
2
+ import {
3
+ ApiBearerAuth,
4
+ ApiCreatedResponse,
5
+ ApiOkResponse,
6
+ ApiOperation,
7
+ ApiTags,
8
+ } from '@nestjs/swagger';
9
+ import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
3
10
  import { ApiErrorResponses } from '../../../common/swagger/api-error-responses';
4
11
  import { JwtAuthGuard } from '../../auth/infrastructure/passport/jwt-auth.guard';
5
12
  import { CurrentUser } from '../../auth/presentation/current-user.decorator';
6
13
  import { AuthenticatedUser } from '../../auth/types/authenticated-user';
7
14
  import { OrganisationsService } from '../application/services/organisations.service';
8
15
  import { CreateOrganisationDto } from '../dto/create-organisation.dto';
9
- import { CreateOrganisationResponseDto } from '../dto/organisation-response.dto';
16
+ import {
17
+ CreateOrganisationResponseDto,
18
+ MyOrganisationListResponseDto,
19
+ } from '../dto/organisation-response.dto';
10
20
 
11
21
  @ApiTags('Organisations')
12
22
  @ApiBearerAuth()
@@ -14,6 +24,18 @@ import { CreateOrganisationResponseDto } from '../dto/organisation-response.dto'
14
24
  export class OrganisationsController {
15
25
  constructor(private readonly organisationsService: OrganisationsService) {}
16
26
 
27
+ @Get('mine')
28
+ @UseGuards(JwtAuthGuard)
29
+ @ApiOperation({ summary: 'List active organisations for the current user.' })
30
+ @ApiOkResponse({
31
+ description: 'Current user organisations.',
32
+ type: MyOrganisationListResponseDto,
33
+ })
34
+ @ApiErrorResponses(400, 401)
35
+ mine(@CurrentUser() user: AuthenticatedUser, @Query() query: PaginationQueryDto) {
36
+ return this.organisationsService.listMine(user, query);
37
+ }
38
+
17
39
  @Post()
18
40
  @UseGuards(JwtAuthGuard)
19
41
  @ApiOperation({ summary: 'Create an organisation for the current user.' })
@@ -0,0 +1,181 @@
1
+ import { MembershipStatus, OrganisationStatus } from '@prisma/client';
2
+ import { validate, parseCorsOrigins } from '../src/config/env.validation';
3
+ import { CurrentAccessControlController } from '../src/modules/access-control/presentation/current-access-control.controller';
4
+ import { OrganisationsService } from '../src/modules/organisations/application/services/organisations.service';
5
+
6
+ describe('frontend bootstrap endpoints', () => {
7
+ it('returns the current effective access-control context without internal cache fields', async () => {
8
+ const controller = new CurrentAccessControlController({
9
+ getContext: jest.fn().mockResolvedValue({
10
+ cacheKey: 'rbac:user-1:org-1',
11
+ userId: 'user-1',
12
+ orgId: 'org-1',
13
+ membershipId: 'membership-1',
14
+ membershipStatus: MembershipStatus.ACTIVE,
15
+ roleId: 'role-1',
16
+ isOwner: false,
17
+ isBillingContact: true,
18
+ permissionKeys: ['organisations.read'],
19
+ }),
20
+ } as never);
21
+
22
+ await expect(
23
+ controller.me(
24
+ {
25
+ id: 'user-1',
26
+ email: 'user@example.com',
27
+ mobile: null,
28
+ displayName: 'User',
29
+ isActive: true,
30
+ },
31
+ 'org-1',
32
+ ),
33
+ ).resolves.toEqual({
34
+ orgId: 'org-1',
35
+ membershipId: 'membership-1',
36
+ roleId: 'role-1',
37
+ isOwner: false,
38
+ isBillingContact: true,
39
+ permissionKeys: ['organisations.read'],
40
+ });
41
+ });
42
+
43
+ it('queries only active current-user memberships in active organisations', async () => {
44
+ const prisma = {
45
+ membership: {
46
+ findMany: jest.fn().mockResolvedValue([
47
+ {
48
+ id: 'membership-1',
49
+ roleId: 'role-1',
50
+ isOwner: true,
51
+ isBillingContact: true,
52
+ organisation: {
53
+ id: 'org-1',
54
+ name: 'Acme Operations',
55
+ slug: 'acme',
56
+ status: OrganisationStatus.ACTIVE,
57
+ },
58
+ role: {
59
+ id: 'role-1',
60
+ name: 'Owner',
61
+ description: null,
62
+ isSystemSeeded: true,
63
+ },
64
+ },
65
+ {
66
+ id: 'membership-2',
67
+ roleId: 'role-2',
68
+ isOwner: false,
69
+ isBillingContact: false,
70
+ organisation: {
71
+ id: 'org-2',
72
+ name: 'Beta Operations',
73
+ slug: 'beta',
74
+ status: OrganisationStatus.ACTIVE,
75
+ },
76
+ role: {
77
+ id: 'role-2',
78
+ name: 'Viewer',
79
+ description: null,
80
+ isSystemSeeded: true,
81
+ },
82
+ },
83
+ ]),
84
+ },
85
+ };
86
+ const service = new OrganisationsService({} as never, prisma as never, {} as never);
87
+
88
+ await expect(
89
+ service.listMine(
90
+ {
91
+ id: 'user-1',
92
+ email: 'user@example.com',
93
+ mobile: null,
94
+ displayName: 'User',
95
+ isActive: true,
96
+ },
97
+ { limit: 1 },
98
+ ),
99
+ ).resolves.toEqual({
100
+ items: [
101
+ {
102
+ id: 'org-1',
103
+ name: 'Acme Operations',
104
+ slug: 'acme',
105
+ status: OrganisationStatus.ACTIVE,
106
+ membershipId: 'membership-1',
107
+ roleId: 'role-1',
108
+ role: {
109
+ id: 'role-1',
110
+ name: 'Owner',
111
+ description: null,
112
+ isSystemSeeded: true,
113
+ },
114
+ isOwner: true,
115
+ isBillingContact: true,
116
+ },
117
+ ],
118
+ nextCursor: 'membership-1',
119
+ });
120
+
121
+ expect(prisma.membership.findMany).toHaveBeenCalledWith(
122
+ expect.objectContaining({
123
+ where: {
124
+ userId: 'user-1',
125
+ status: MembershipStatus.ACTIVE,
126
+ organisation: {
127
+ status: OrganisationStatus.ACTIVE,
128
+ },
129
+ },
130
+ take: 2,
131
+ }),
132
+ );
133
+ });
134
+ });
135
+
136
+ describe('CORS environment config', () => {
137
+ it('is disabled by default', () => {
138
+ const env = validate(baseEnv());
139
+
140
+ expect(env.CORS_ENABLED).toBe(false);
141
+ expect(env.CORS_ORIGINS).toBe('');
142
+ expect(env.CORS_CREDENTIALS).toBe(false);
143
+ });
144
+
145
+ it('accepts enabled CORS with exact allowed origins', () => {
146
+ const env = validate({
147
+ ...baseEnv(),
148
+ CORS_ENABLED: 'true',
149
+ CORS_ORIGINS: 'http://localhost:5173, https://app.example.com',
150
+ CORS_CREDENTIALS: 'true',
151
+ });
152
+
153
+ expect(env.CORS_ENABLED).toBe(true);
154
+ expect(env.CORS_CREDENTIALS).toBe(true);
155
+ expect(parseCorsOrigins(env.CORS_ORIGINS)).toEqual([
156
+ 'http://localhost:5173',
157
+ 'https://app.example.com',
158
+ ]);
159
+ });
160
+
161
+ it('rejects wildcard origins when credentials are enabled', () => {
162
+ expect(() =>
163
+ validate({
164
+ ...baseEnv(),
165
+ CORS_ENABLED: 'true',
166
+ CORS_ORIGINS: '*',
167
+ CORS_CREDENTIALS: 'true',
168
+ }),
169
+ ).toThrow(/CORS_ORIGINS cannot include \*/u);
170
+ });
171
+ });
172
+
173
+ function baseEnv() {
174
+ return {
175
+ NODE_ENV: 'test',
176
+ PORT: '3000',
177
+ DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/app_test',
178
+ JWT_SECRET: 'test-jwt-secret-at-least-32-characters',
179
+ AUTH_GOOGLE_ENABLED: 'false',
180
+ };
181
+ }
@@ -1,9 +1,11 @@
1
1
  import { Controller, Get } from '@nestjs/common';
2
2
  import { MetadataScanner, Reflector } from '@nestjs/core';
3
3
  import { AccessControlController } from '../src/modules/access-control/presentation/access-control.controller';
4
+ import { CurrentAccessControlController } from '../src/modules/access-control/presentation/current-access-control.controller';
4
5
  import { RequirePermissions } from '../src/modules/access-control/presentation/permissions.decorator';
5
6
  import { RouteRegistryValidator } from '../src/modules/access-control/application/route-registry.validator';
6
7
  import { PermissionKey } from '../src/modules/access-control/types/permission-key';
8
+ import { routePermissionRegistry } from '../src/modules/access-control/types/route-permission-registry';
7
9
  import { AuditController } from '../src/modules/audit/presentation/audit.controller';
8
10
  import { AuthController } from '../src/modules/auth/presentation/auth.controller';
9
11
  import { InvitationsController } from '../src/modules/invitations/presentation/invitations.controller';
@@ -14,6 +16,7 @@ import { SettingsController } from '../src/modules/settings/presentation/setting
14
16
 
15
17
  const controllers = [
16
18
  AccessControlController,
19
+ CurrentAccessControlController,
17
20
  AuditController,
18
21
  AuthController,
19
22
  InvitationsController,
@@ -28,6 +31,15 @@ describe('RouteRegistryValidator', () => {
28
31
  expect(() => createValidator(controllers).onApplicationBootstrap()).not.toThrow();
29
32
  });
30
33
 
34
+ it('does not register the current access-control bootstrap route as permission-gated', () => {
35
+ expect(routePermissionRegistry).not.toContainEqual(
36
+ expect.objectContaining({
37
+ method: 'GET',
38
+ path: '/organisations/:orgId/access-control/me',
39
+ }),
40
+ );
41
+ });
42
+
31
43
  it('fails when a permission-gated route is missing from the registry', () => {
32
44
  class DriftController {
33
45
  probe() {
@@ -72,7 +72,101 @@ describe('Security invariants (e2e)', () => {
72
72
  .expect(403);
73
73
  });
74
74
 
75
+ it('requires authentication for frontend bootstrap endpoints', async () => {
76
+ const { orgId } = await createUserAndOrg('bootstrap-auth');
77
+
78
+ await request(app.getHttpServer()).get('/organisations/mine').expect(401);
79
+ await request(app.getHttpServer()).get(`/organisations/${orgId}/access-control/me`).expect(401);
80
+ });
81
+
82
+ it('lists only active current-user organisations with cursor pagination shape', async () => {
83
+ const first = await createUserAndOrg('mine-active');
84
+ const second = await createOrganisation(first.accessToken, 'mine-suspended');
85
+
86
+ await prisma.membership.update({
87
+ where: { id: second.membershipId },
88
+ data: { status: MembershipStatus.SUSPENDED },
89
+ });
90
+
91
+ const response = await request(app.getHttpServer())
92
+ .get('/organisations/mine')
93
+ .set('Authorization', `Bearer ${first.accessToken}`)
94
+ .expect(200);
95
+
96
+ expect(response.body).toEqual({
97
+ items: [
98
+ expect.objectContaining({
99
+ id: first.orgId,
100
+ membershipId: first.membershipId,
101
+ roleId: expect.any(String),
102
+ role: expect.objectContaining({ name: 'Owner' }),
103
+ isOwner: true,
104
+ isBillingContact: true,
105
+ }),
106
+ ],
107
+ nextCursor: null,
108
+ });
109
+ });
110
+
111
+ it('returns effective access-control context for an active member without roles.read', async () => {
112
+ const owner = await createUserAndOrg('access-me-owner');
113
+ const member = await createMemberInOrg(owner.orgId, 'access-me-member');
114
+
115
+ const response = await request(app.getHttpServer())
116
+ .get(`/organisations/${owner.orgId}/access-control/me`)
117
+ .set('Authorization', `Bearer ${member.accessToken}`)
118
+ .expect(200);
119
+
120
+ expect(response.body).toEqual({
121
+ orgId: owner.orgId,
122
+ membershipId: member.membershipId,
123
+ roleId: member.roleId,
124
+ isOwner: false,
125
+ isBillingContact: false,
126
+ permissionKeys: [],
127
+ });
128
+ });
129
+
130
+ it('denies access-control context to non-members', async () => {
131
+ const first = await createUserAndOrg('access-me-a');
132
+ const second = await createUserAndOrg('access-me-b');
133
+
134
+ await request(app.getHttpServer())
135
+ .get(`/organisations/${second.orgId}/access-control/me`)
136
+ .set('Authorization', `Bearer ${first.accessToken}`)
137
+ .expect(403);
138
+ });
139
+
140
+ it.each([MembershipStatus.SUSPENDED, MembershipStatus.REVOKED])(
141
+ 'denies access-control context for %s memberships',
142
+ async (status) => {
143
+ const owner = await createUserAndOrg(`access-me-${status.toLowerCase()}`);
144
+ const member = await createMemberInOrg(owner.orgId, `member-${status.toLowerCase()}`);
145
+
146
+ await prisma.membership.update({
147
+ where: { id: member.membershipId },
148
+ data: { status },
149
+ });
150
+
151
+ await request(app.getHttpServer())
152
+ .get(`/organisations/${owner.orgId}/access-control/me`)
153
+ .set('Authorization', `Bearer ${member.accessToken}`)
154
+ .expect(403);
155
+ },
156
+ );
157
+
75
158
  async function createUserAndOrg(label: string) {
159
+ const user = await createUser(label);
160
+ const org = await createOrganisation(user.accessToken, label);
161
+
162
+ return {
163
+ ...user,
164
+ orgId: org.orgId,
165
+ membershipId: org.membershipId,
166
+ };
167
+ }
168
+
169
+ async function createUser(label: string) {
76
170
  const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
77
171
  const signup = await request(app.getHttpServer())
78
172
  .post('/auth/signup')
@@ -83,7 +177,14 @@ describe('Security invariants (e2e)', () => {
83
177
  })
84
178
  .expect(201);
85
179
 
86
- const accessToken = signup.body.accessToken as string;
180
+ return {
181
+ accessToken: signup.body.accessToken as string,
182
+ userId: signup.body.user.id as string,
183
+ };
184
+ }
185
+
186
+ async function createOrganisation(accessToken: string, label: string) {
187
+ const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
87
188
  const org = await request(app.getHttpServer())
88
189
  .post('/organisations')
89
190
  .set('Authorization', `Bearer ${accessToken}`)
@@ -94,9 +195,40 @@ describe('Security invariants (e2e)', () => {
94
195
  .expect(201);
95
196
 
96
197
  return {
97
- accessToken,
98
198
  orgId: org.body.organisation.id as string,
99
199
  membershipId: org.body.membership.id as string,
100
200
  };
101
201
  }
202
+
203
+ async function createMemberInOrg(orgId: string, label: string) {
204
+ const user = await createUser(label);
205
+ const role = await prisma.role.findUniqueOrThrow({
206
+ where: {
207
+ orgId_name: {
208
+ orgId,
209
+ name: 'Viewer',
210
+ },
211
+ },
212
+ select: {
213
+ id: true,
214
+ },
215
+ });
216
+ const membership = await prisma.membership.create({
217
+ data: {
218
+ userId: user.userId,
219
+ orgId,
220
+ roleId: role.id,
221
+ },
222
+ select: {
223
+ id: true,
224
+ roleId: true,
225
+ },
226
+ });
227
+
228
+ return {
229
+ ...user,
230
+ membershipId: membership.id,
231
+ roleId: membership.roleId,
232
+ };
233
+ }
102
234
  });