@ftisindia/create-app 0.1.4 → 0.1.6

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.
Files changed (169) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +31 -0
  3. package/template/README.md +61 -0
  4. package/template/_gitignore +6 -0
  5. package/template/_package.json +6 -0
  6. package/template/docs/FORMS.md +169 -0
  7. package/template/docs/FORMS_CHECKLIST.md +61 -0
  8. package/template/docs/REPORTS.md +246 -0
  9. package/template/docs/REPORTS_CHECKLIST.md +97 -0
  10. package/template/prisma/migrations/20260612000000_add_form_builder/migration.sql +147 -0
  11. package/template/prisma/migrations/20260613000000_add_report_builder/migration.sql +129 -0
  12. package/template/prisma/schema.prisma +285 -0
  13. package/template/scripts/export-openapi.ts +85 -0
  14. package/template/scripts/gen-form.mjs +149 -0
  15. package/template/scripts/push-form.ts +124 -0
  16. package/template/src/app.module.ts +29 -8
  17. package/template/src/common/dto/membership-response.dto.ts +1 -0
  18. package/template/src/common/dto/role-summary.dto.ts +3 -3
  19. package/template/src/common/dto/user-summary.dto.ts +3 -3
  20. package/template/src/config/app.config.ts +6 -1
  21. package/template/src/config/env.validation.ts +45 -0
  22. package/template/src/config/forms.config.ts +12 -0
  23. package/template/src/config/index.ts +2 -0
  24. package/template/src/config/openapi.ts +12 -0
  25. package/template/src/config/reports-secret.ts +15 -0
  26. package/template/src/config/reports.config.ts +16 -0
  27. package/template/src/main.ts +16 -12
  28. package/template/src/modules/access-control/access-control.module.ts +2 -1
  29. package/template/src/modules/access-control/dto/access-control-response.dto.ts +3 -0
  30. package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +35 -0
  31. package/template/src/modules/access-control/presentation/current-access-control.controller.ts +40 -0
  32. package/template/src/modules/access-control/types/permission-key.ts +27 -0
  33. package/template/src/modules/access-control/types/route-permission-registry.ts +183 -0
  34. package/template/src/modules/audit/dto/audit-response.dto.ts +7 -3
  35. package/template/src/modules/auth/auth.module.ts +3 -1
  36. package/template/src/modules/auth/dto/auth-response.dto.ts +1 -1
  37. package/template/src/modules/forms/application/services/file-gc.service.ts +85 -0
  38. package/template/src/modules/forms/application/services/forms-definitions.service.ts +137 -0
  39. package/template/src/modules/forms/application/services/forms-error.mapper.ts +64 -0
  40. package/template/src/modules/forms/application/services/forms-export.service.ts +210 -0
  41. package/template/src/modules/forms/application/services/forms-files.service.ts +164 -0
  42. package/template/src/modules/forms/application/services/forms-public.service.ts +49 -0
  43. package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +53 -0
  44. package/template/src/modules/forms/application/services/forms-submissions.service.ts +103 -0
  45. package/template/src/modules/forms/application/services/handlers/authenticate.action.ts +37 -0
  46. package/template/src/modules/forms/application/services/handlers/logging-email.handler.ts +22 -0
  47. package/template/src/modules/forms/application/services/handlers/send-confirmation-email.action.ts +40 -0
  48. package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +41 -0
  49. package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +109 -0
  50. package/template/src/modules/forms/dto/create-form-definition.dto.ts +12 -0
  51. package/template/src/modules/forms/dto/data-source-response.dto.ts +19 -0
  52. package/template/src/modules/forms/dto/export-submissions-query.dto.ts +33 -0
  53. package/template/src/modules/forms/dto/file-upload-response.dto.ts +24 -0
  54. package/template/src/modules/forms/dto/form-definition-response.dto.ts +50 -0
  55. package/template/src/modules/forms/dto/form-render-response.dto.ts +17 -0
  56. package/template/src/modules/forms/dto/list-form-definitions-query.dto.ts +10 -0
  57. package/template/src/modules/forms/dto/list-submissions-query.dto.ts +10 -0
  58. package/template/src/modules/forms/dto/public-submit-form.dto.ts +24 -0
  59. package/template/src/modules/forms/dto/set-public-access.dto.ts +8 -0
  60. package/template/src/modules/forms/dto/submission-response.dto.ts +99 -0
  61. package/template/src/modules/forms/dto/submit-form.dto.ts +50 -0
  62. package/template/src/modules/forms/dto/update-form-definition.dto.ts +12 -0
  63. package/template/src/modules/forms/dto/upload-file-query.dto.ts +33 -0
  64. package/template/src/modules/forms/dto/validate-submission.dto.ts +22 -0
  65. package/template/src/modules/forms/examples/abstract-submission.form.json +80 -0
  66. package/template/src/modules/forms/examples/login.form.json +24 -0
  67. package/template/src/modules/forms/examples/registration.form.json +44 -0
  68. package/template/src/modules/forms/forms.module.ts +226 -0
  69. package/template/src/modules/forms/forms.tokens.ts +6 -0
  70. package/template/src/modules/forms/infrastructure/audit-sink.adapter.ts +30 -0
  71. package/template/src/modules/forms/infrastructure/casl-forms-authorization.ts +31 -0
  72. package/template/src/modules/forms/infrastructure/prisma-tx-runner.ts +17 -0
  73. package/template/src/modules/forms/infrastructure/registry/form-extension.decorators.ts +17 -0
  74. package/template/src/modules/forms/infrastructure/registry/registry-bootstrap.service.ts +82 -0
  75. package/template/src/modules/forms/infrastructure/request-forms-context.ts +60 -0
  76. package/template/src/modules/forms/infrastructure/schema-check/forms-schema-check.service.ts +76 -0
  77. package/template/src/modules/forms/infrastructure/storage/local-disk-storage.adapter.ts +43 -0
  78. package/template/src/modules/forms/infrastructure/stores/index.ts +5 -0
  79. package/template/src/modules/forms/infrastructure/stores/prisma-action-log.store.ts +37 -0
  80. package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +108 -0
  81. package/template/src/modules/forms/infrastructure/stores/prisma-form-definition.store.ts +147 -0
  82. package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +133 -0
  83. package/template/src/modules/forms/infrastructure/stores/prisma-submission.store.ts +164 -0
  84. package/template/src/modules/forms/presentation/forms-data-sources.controller.ts +58 -0
  85. package/template/src/modules/forms/presentation/forms-definitions.controller.ts +191 -0
  86. package/template/src/modules/forms/presentation/forms-files.controller.ts +79 -0
  87. package/template/src/modules/forms/presentation/forms-submissions.controller.ts +154 -0
  88. package/template/src/modules/forms/presentation/forms-upload.interceptor.ts +33 -0
  89. package/template/src/modules/forms/presentation/public-forms.controller.ts +51 -0
  90. package/template/src/modules/invitations/dto/invitation-response.dto.ts +4 -0
  91. package/template/src/modules/organisations/application/services/organisations.service.ts +67 -1
  92. package/template/src/modules/organisations/dto/organisation-response.dto.ts +52 -0
  93. package/template/src/modules/organisations/presentation/organisations.controller.ts +25 -3
  94. package/template/src/modules/reports/application/services/reports-actions.service.ts +54 -0
  95. package/template/src/modules/reports/application/services/reports-definitions.service.ts +66 -0
  96. package/template/src/modules/reports/application/services/reports-error.mapper.ts +97 -0
  97. package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +124 -0
  98. package/template/src/modules/reports/application/services/reports-exports.service.ts +74 -0
  99. package/template/src/modules/reports/application/services/reports-queries.service.ts +35 -0
  100. package/template/src/modules/reports/application/services/reports-settings-reader.service.ts +49 -0
  101. package/template/src/modules/reports/application/services/reports-views.service.ts +79 -0
  102. package/template/src/modules/reports/dto/action-result-response.dto.ts +21 -0
  103. package/template/src/modules/reports/dto/create-report-definition.dto.ts +86 -0
  104. package/template/src/modules/reports/dto/create-saved-view.dto.ts +26 -0
  105. package/template/src/modules/reports/dto/execute-action.dto.ts +71 -0
  106. package/template/src/modules/reports/dto/export-job-response.dto.ts +60 -0
  107. package/template/src/modules/reports/dto/export-request.dto.ts +34 -0
  108. package/template/src/modules/reports/dto/list-reports-query.dto.ts +10 -0
  109. package/template/src/modules/reports/dto/list-views-query.dto.ts +17 -0
  110. package/template/src/modules/reports/dto/prepare-action-response.dto.ts +14 -0
  111. package/template/src/modules/reports/dto/prepare-action.dto.ts +27 -0
  112. package/template/src/modules/reports/dto/query-response.dto.ts +64 -0
  113. package/template/src/modules/reports/dto/query-spec.dto.ts +120 -0
  114. package/template/src/modules/reports/dto/report-definition-response.dto.ts +64 -0
  115. package/template/src/modules/reports/dto/report-meta-query.dto.ts +16 -0
  116. package/template/src/modules/reports/dto/report-meta-response.dto.ts +113 -0
  117. package/template/src/modules/reports/dto/saved-view-response.dto.ts +66 -0
  118. package/template/src/modules/reports/dto/update-report-definition.dto.ts +9 -0
  119. package/template/src/modules/reports/dto/update-saved-view.dto.ts +27 -0
  120. package/template/src/modules/reports/examples/abstract-review-board.report.json +54 -0
  121. package/template/src/modules/reports/examples/org-members.report.json +55 -0
  122. package/template/src/modules/reports/infrastructure/audit-sink.adapter.ts +31 -0
  123. package/template/src/modules/reports/infrastructure/casl-reports-authorization.ts +39 -0
  124. package/template/src/modules/reports/infrastructure/forms-adapter/form-report-source.adapter.ts +292 -0
  125. package/template/src/modules/reports/infrastructure/forms-adapter/form-row-actions.ts +171 -0
  126. package/template/src/modules/reports/infrastructure/forms-adapter/forms-bridge-bootstrap.service.ts +32 -0
  127. package/template/src/modules/reports/infrastructure/prisma-catalog.adapter.ts +95 -0
  128. package/template/src/modules/reports/infrastructure/prisma-query-executor.ts +103 -0
  129. package/template/src/modules/reports/infrastructure/prisma-snapshot-runner.ts +47 -0
  130. package/template/src/modules/reports/infrastructure/prisma-tx-runner.ts +18 -0
  131. package/template/src/modules/reports/infrastructure/registry/registry-bootstrap.service.ts +61 -0
  132. package/template/src/modules/reports/infrastructure/registry/report-extension.decorators.ts +14 -0
  133. package/template/src/modules/reports/infrastructure/reports-job-queue.adapter.ts +28 -0
  134. package/template/src/modules/reports/infrastructure/request-reports-context.ts +42 -0
  135. package/template/src/modules/reports/infrastructure/schema-check/reports-schema-check.service.ts +116 -0
  136. package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +79 -0
  137. package/template/src/modules/reports/infrastructure/stores/index.ts +5 -0
  138. package/template/src/modules/reports/infrastructure/stores/prisma-bulk-action-run.store.ts +89 -0
  139. package/template/src/modules/reports/infrastructure/stores/prisma-export-job.store.ts +93 -0
  140. package/template/src/modules/reports/infrastructure/stores/prisma-report-definition.store.ts +171 -0
  141. package/template/src/modules/reports/infrastructure/stores/prisma-row-tag.store.ts +110 -0
  142. package/template/src/modules/reports/infrastructure/stores/prisma-saved-view.store.ts +144 -0
  143. package/template/src/modules/reports/presentation/reports-actions.controller.ts +83 -0
  144. package/template/src/modules/reports/presentation/reports-definitions.controller.ts +156 -0
  145. package/template/src/modules/reports/presentation/reports-export-jobs.controller.ts +61 -0
  146. package/template/src/modules/reports/presentation/reports-export.controller.ts +76 -0
  147. package/template/src/modules/reports/presentation/reports-query.controller.ts +52 -0
  148. package/template/src/modules/reports/presentation/reports-views.controller.ts +140 -0
  149. package/template/src/modules/reports/reports-forms.module.ts +33 -0
  150. package/template/src/modules/reports/reports.module.ts +335 -0
  151. package/template/src/modules/reports/reports.tokens.ts +11 -0
  152. package/template/src/modules/reports/sources/org-members.source.ts +112 -0
  153. package/template/src/modules/settings/types/setting-definitions.ts +94 -0
  154. package/template/test/forms-definitions.e2e-spec.ts +394 -0
  155. package/template/test/forms-export.e2e-spec.ts +390 -0
  156. package/template/test/forms-files.e2e-spec.ts +345 -0
  157. package/template/test/forms-outbox.e2e-spec.ts +309 -0
  158. package/template/test/forms-permission-sync.spec.ts +27 -0
  159. package/template/test/forms-public.e2e-spec.ts +269 -0
  160. package/template/test/forms-schema-check.e2e-spec.ts +65 -0
  161. package/template/test/forms-submissions.e2e-spec.ts +500 -0
  162. package/template/test/forms-webhooks.e2e-spec.ts +261 -0
  163. package/template/test/frontend-bootstrap.spec.ts +181 -0
  164. package/template/test/reports-advanced.e2e-spec.ts +368 -0
  165. package/template/test/reports-permission-sync.spec.ts +30 -0
  166. package/template/test/reports-query.e2e-spec.ts +350 -0
  167. package/template/test/reports-tiers.e2e-spec.ts +257 -0
  168. package/template/test/route-registry.validator.spec.ts +34 -0
  169. package/template/test/security.e2e-spec.ts +134 -2
@@ -35,6 +35,66 @@ export const settingDefinitions = {
35
35
  defaultValue: '',
36
36
  parse: parseOptionalUrl,
37
37
  },
38
+ // Form builder (@ftisindia/form-builder) — org-variable policy lives here so
39
+ // it is typed, validated, and audited like every other org setting.
40
+ 'forms.allowedDangerousActions': {
41
+ defaultValue: [],
42
+ parse: parseStringArray('forms.allowedDangerousActions', 50),
43
+ },
44
+ 'forms.maxFileSizeMb': {
45
+ defaultValue: 10,
46
+ parse: parsePositiveInt('forms.maxFileSizeMb', 1024),
47
+ },
48
+ 'forms.enableRuleIteration': {
49
+ defaultValue: false,
50
+ parse: parseBoolean,
51
+ },
52
+ 'forms.virusScanRequired': {
53
+ defaultValue: false,
54
+ parse: parseBoolean,
55
+ },
56
+ 'forms.maxSubmissionsPerIpPerDay': {
57
+ defaultValue: 50,
58
+ parse: parsePositiveInt('forms.maxSubmissionsPerIpPerDay', 100000),
59
+ },
60
+ // SSRF gate for definition webhooks: empty list = webhooks disabled until
61
+ // an admin explicitly allows destination hosts (changes are audited).
62
+ 'forms.webhookAllowedHosts': {
63
+ defaultValue: [],
64
+ parse: parseStringArray('forms.webhookAllowedHosts', 50),
65
+ },
66
+ // Report builder (@ftisindia/report-builder) — runtime budgets and caps
67
+ // (report design §5.2/§8/§9). Typed, validated, audited like every setting.
68
+ 'reports.statementTimeoutMs': {
69
+ defaultValue: 5000,
70
+ parse: parsePositiveInt('reports.statementTimeoutMs', 60000),
71
+ },
72
+ 'reports.exportMaxSnapshotSeconds': {
73
+ defaultValue: 120,
74
+ parse: parsePositiveInt('reports.exportMaxSnapshotSeconds', 3600),
75
+ },
76
+ 'reports.maxRowsSync': {
77
+ defaultValue: 10000,
78
+ parse: parsePositiveInt('reports.maxRowsSync', 100000),
79
+ },
80
+ 'reports.countCap': {
81
+ defaultValue: 10000,
82
+ parse: parsePositiveInt('reports.countCap', 1000000),
83
+ },
84
+ 'reports.liveTierMaxRows': {
85
+ defaultValue: 50000,
86
+ parse: parsePositiveInt('reports.liveTierMaxRows', 10000000),
87
+ },
88
+ // 0 disables the short-TTL result cache (report design §5.7).
89
+ 'reports.resultCacheTtlMs': {
90
+ defaultValue: 0,
91
+ parse: parseNonNegativeInt('reports.resultCacheTtlMs', 300000),
92
+ },
93
+ // Curated tag vocabulary; empty = free-form tags (report design §7).
94
+ 'reports.tagVocabulary': {
95
+ defaultValue: [],
96
+ parse: parseStringArray('reports.tagVocabulary', 200),
97
+ },
38
98
  timezone: {
39
99
  defaultValue: 'UTC',
40
100
  parse: parseTimezone,
@@ -101,6 +161,40 @@ function parseOptionalUrl(value: unknown) {
101
161
  }
102
162
  }
103
163
 
164
+ function parseStringArray(key: string, maxItems: number) {
165
+ return (value: unknown) => {
166
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
167
+ throw new BadRequestException(`${key} must be an array of strings.`);
168
+ }
169
+
170
+ if (value.length > maxItems) {
171
+ throw new BadRequestException(`${key} must not contain more than ${maxItems} items.`);
172
+ }
173
+
174
+ return value.map((item) => (item as string).trim()).filter((item) => item.length > 0);
175
+ };
176
+ }
177
+
178
+ function parsePositiveInt(key: string, max: number) {
179
+ return (value: unknown) => {
180
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > max) {
181
+ throw new BadRequestException(`${key} must be an integer between 1 and ${max}.`);
182
+ }
183
+
184
+ return value;
185
+ };
186
+ }
187
+
188
+ function parseNonNegativeInt(key: string, max: number) {
189
+ return (value: unknown) => {
190
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > max) {
191
+ throw new BadRequestException(`${key} must be an integer between 0 and ${max}.`);
192
+ }
193
+
194
+ return value;
195
+ };
196
+ }
197
+
104
198
  function parseTimezone(value: unknown) {
105
199
  if (typeof value !== 'string') {
106
200
  throw new BadRequestException('timezone must be a string.');
@@ -0,0 +1,394 @@
1
+ import { INestApplication, ValidationPipe } from '@nestjs/common';
2
+ import { Test } from '@nestjs/testing';
3
+ import request from 'supertest';
4
+ import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
5
+ import { PrismaService } from '../src/database/prisma/prisma.service';
6
+
7
+ describe('Forms definitions lifecycle (e2e)', () => {
8
+ let app: INestApplication;
9
+ let prisma: PrismaService;
10
+
11
+ beforeAll(async () => {
12
+ process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
13
+ process.env.JWT_SECRET ||= 'test-jwt-secret-at-least-16-chars';
14
+ process.env.AUTH_GOOGLE_ENABLED ||= 'false';
15
+ process.env.AUTH_EMAIL_PASSWORD_ENABLED ||= 'true';
16
+ process.env.NODE_ENV = 'test';
17
+ process.env.FORMS_OUTBOX_ENABLED = 'false';
18
+ process.env.FORMS_FILE_STORAGE_DIR = './var/test-uploads';
19
+
20
+ const { AppModule } = await import('../src/app.module');
21
+ const moduleRef = await Test.createTestingModule({
22
+ imports: [AppModule],
23
+ }).compile();
24
+
25
+ app = moduleRef.createNestApplication();
26
+ app.useGlobalFilters(new HttpExceptionFilter());
27
+ app.useGlobalPipes(
28
+ new ValidationPipe({
29
+ forbidNonWhitelisted: true,
30
+ transform: true,
31
+ whitelist: true,
32
+ }),
33
+ );
34
+ await app.init();
35
+ prisma = app.get(PrismaService);
36
+ });
37
+
38
+ afterAll(async () => {
39
+ await app.close();
40
+ });
41
+
42
+ it('creates drafts with incrementing versions for the same key', async () => {
43
+ const { accessToken, orgId } = await createUserAndOrg('def-draft');
44
+ const key = uniqueFormKey('draft');
45
+
46
+ const first = await request(app.getHttpServer())
47
+ .post(`/organisations/${orgId}/forms`)
48
+ .set('Authorization', `Bearer ${accessToken}`)
49
+ .send({ definition: registrationDefinition(key) })
50
+ .expect(201);
51
+
52
+ expect(first.body.status).toBe('DRAFT');
53
+ expect(first.body.version).toBe(1);
54
+ expect(first.body.key).toBe(key);
55
+
56
+ const second = await request(app.getHttpServer())
57
+ .post(`/organisations/${orgId}/forms`)
58
+ .set('Authorization', `Bearer ${accessToken}`)
59
+ .send({ definition: registrationDefinition(key) })
60
+ .expect(201);
61
+
62
+ expect(second.body.status).toBe('DRAFT');
63
+ expect(second.body.version).toBe(2);
64
+ });
65
+
66
+ it('rejects definitions with an unknown field type at save time', async () => {
67
+ const { accessToken, orgId } = await createUserAndOrg('def-lint');
68
+ const definition = registrationDefinition(uniqueFormKey('lint'));
69
+ (definition.fields as Array<Record<string, unknown>>).push({
70
+ type: 'no-such-type',
71
+ name: 'mysteryField',
72
+ });
73
+
74
+ const response = await request(app.getHttpServer())
75
+ .post(`/organisations/${orgId}/forms`)
76
+ .set('Authorization', `Bearer ${accessToken}`)
77
+ .send({ definition })
78
+ .expect(422);
79
+
80
+ const issues = response.body.error.details.issues as Array<{ code: string }>;
81
+ expect(issues.map((issue) => issue.code)).toContain('UNKNOWN_FIELD_TYPE');
82
+ });
83
+
84
+ it('publishes, freezes published versions, auto-archives priors, and audits', async () => {
85
+ const { accessToken, orgId } = await createUserAndOrg('def-publish');
86
+ const key = uniqueFormKey('publish');
87
+
88
+ await request(app.getHttpServer())
89
+ .post(`/organisations/${orgId}/forms`)
90
+ .set('Authorization', `Bearer ${accessToken}`)
91
+ .send({ definition: registrationDefinition(key) })
92
+ .expect(201);
93
+
94
+ const published = await request(app.getHttpServer())
95
+ .post(`/organisations/${orgId}/forms/${key}/versions/1/publish`)
96
+ .set('Authorization', `Bearer ${accessToken}`)
97
+ .expect(200);
98
+ expect(published.body.status).toBe('PUBLISHED');
99
+
100
+ // Published versions are immutable.
101
+ await request(app.getHttpServer())
102
+ .patch(`/organisations/${orgId}/forms/${key}/versions/1`)
103
+ .set('Authorization', `Bearer ${accessToken}`)
104
+ .send({ definition: registrationDefinition(key) })
105
+ .expect(409);
106
+
107
+ // Publish version 2: version 1 must be auto-archived.
108
+ await request(app.getHttpServer())
109
+ .post(`/organisations/${orgId}/forms`)
110
+ .set('Authorization', `Bearer ${accessToken}`)
111
+ .send({ definition: registrationDefinition(key) })
112
+ .expect(201);
113
+ await request(app.getHttpServer())
114
+ .post(`/organisations/${orgId}/forms/${key}/versions/2/publish`)
115
+ .set('Authorization', `Bearer ${accessToken}`)
116
+ .expect(200);
117
+
118
+ const versionOne = await request(app.getHttpServer())
119
+ .get(`/organisations/${orgId}/forms/${key}/versions/1`)
120
+ .set('Authorization', `Bearer ${accessToken}`)
121
+ .expect(200);
122
+ expect(versionOne.body.status).toBe('ARCHIVED');
123
+
124
+ const auditRows = await prisma.auditLog.findMany({
125
+ where: { orgId, action: { in: ['forms.create', 'forms.publish'] } },
126
+ });
127
+ expect(auditRows.some((row) => row.action === 'forms.create')).toBe(true);
128
+ expect(auditRows.some((row) => row.action === 'forms.publish')).toBe(true);
129
+ });
130
+
131
+ it('gates dangerous-action wiring at attach time (permission and allowlist)', async () => {
132
+ const owner = await createUserAndOrg('def-dangerous');
133
+ const member = await createMemberInOrg(owner.orgId, 'def-dangerous-member', [
134
+ 'forms.read',
135
+ 'forms.create',
136
+ 'forms.update',
137
+ 'forms.publish',
138
+ ]);
139
+
140
+ // Member lacks forms.wireDangerous.
141
+ const memberAttempt = await request(app.getHttpServer())
142
+ .post(`/organisations/${owner.orgId}/forms`)
143
+ .set('Authorization', `Bearer ${member.accessToken}`)
144
+ .send({ definition: authenticateDefinition(uniqueFormKey('login-member')) })
145
+ .expect(422);
146
+ const memberIssues = memberAttempt.body.error.details.issues as Array<{ code: string }>;
147
+ expect(memberIssues.map((issue) => issue.code)).toContain('DANGEROUS_NOT_ALLOWED');
148
+
149
+ // Owner passes wireDangerous (manage all) but the org allowlist is empty.
150
+ const ownerAttempt = await request(app.getHttpServer())
151
+ .post(`/organisations/${owner.orgId}/forms`)
152
+ .set('Authorization', `Bearer ${owner.accessToken}`)
153
+ .send({ definition: authenticateDefinition(uniqueFormKey('login-owner')) })
154
+ .expect(422);
155
+ const ownerIssues = ownerAttempt.body.error.details.issues as Array<{ code: string }>;
156
+ expect(ownerIssues.map((issue) => issue.code)).toContain('DANGEROUS_NOT_IN_ALLOWLIST');
157
+ expect(ownerIssues.map((issue) => issue.code)).not.toContain('DANGEROUS_NOT_ALLOWED');
158
+
159
+ // With the org allowlist set, owner create + publish succeeds.
160
+ await setOrgSetting(owner.accessToken, owner.orgId, 'forms.allowedDangerousActions', [
161
+ 'authenticate',
162
+ ]);
163
+ const key = uniqueFormKey('login-allowed');
164
+ await request(app.getHttpServer())
165
+ .post(`/organisations/${owner.orgId}/forms`)
166
+ .set('Authorization', `Bearer ${owner.accessToken}`)
167
+ .send({ definition: authenticateDefinition(key) })
168
+ .expect(201);
169
+ const published = await request(app.getHttpServer())
170
+ .post(`/organisations/${owner.orgId}/forms/${key}/versions/1/publish`)
171
+ .set('Authorization', `Bearer ${owner.accessToken}`)
172
+ .expect(200);
173
+ expect(published.body.status).toBe('PUBLISHED');
174
+ });
175
+
176
+ it('gates public-access flips by forms.managePublicAccess and audits them', async () => {
177
+ const owner = await createUserAndOrg('def-public-access');
178
+ const member = await createMemberInOrg(owner.orgId, 'def-public-member', ['forms.read']);
179
+ const key = uniqueFormKey('flip');
180
+
181
+ await request(app.getHttpServer())
182
+ .post(`/organisations/${owner.orgId}/forms`)
183
+ .set('Authorization', `Bearer ${owner.accessToken}`)
184
+ .send({ definition: registrationDefinition(key) })
185
+ .expect(201);
186
+ await request(app.getHttpServer())
187
+ .post(`/organisations/${owner.orgId}/forms/${key}/versions/1/publish`)
188
+ .set('Authorization', `Bearer ${owner.accessToken}`)
189
+ .expect(200);
190
+
191
+ await request(app.getHttpServer())
192
+ .post(`/organisations/${owner.orgId}/forms/${key}/versions/1/public-access`)
193
+ .set('Authorization', `Bearer ${member.accessToken}`)
194
+ .send({ access: 'public' })
195
+ .expect(403);
196
+
197
+ await request(app.getHttpServer())
198
+ .post(`/organisations/${owner.orgId}/forms/${key}/versions/1/public-access`)
199
+ .set('Authorization', `Bearer ${owner.accessToken}`)
200
+ .send({ access: 'public' })
201
+ .expect(200);
202
+
203
+ const auditRow = await prisma.auditLog.findFirst({
204
+ where: { orgId: owner.orgId, action: 'forms.publicAccess.update' },
205
+ orderBy: { createdAt: 'desc' },
206
+ });
207
+ expect(auditRow).not.toBeNull();
208
+ const metadata = auditRow?.metadata as { previous?: string; next?: string };
209
+ expect(metadata.previous).toBe('private');
210
+ expect(metadata.next).toBe('public');
211
+ });
212
+
213
+ it('denies cross-org definition access (403 cross-tenant, 404 foreign key)', async () => {
214
+ const orgA = await createUserAndOrg('def-cross-a');
215
+ const orgB = await createUserAndOrg('def-cross-b');
216
+ const keyA = uniqueFormKey('cross');
217
+
218
+ await request(app.getHttpServer())
219
+ .post(`/organisations/${orgA.orgId}/forms`)
220
+ .set('Authorization', `Bearer ${orgA.accessToken}`)
221
+ .send({ definition: registrationDefinition(keyA) })
222
+ .expect(201);
223
+
224
+ // Org B's owner against org A's path: not a member, denied outright.
225
+ await request(app.getHttpServer())
226
+ .get(`/organisations/${orgA.orgId}/forms/${keyA}`)
227
+ .set('Authorization', `Bearer ${orgB.accessToken}`)
228
+ .expect(403);
229
+
230
+ // Org A's key under org B's own path: scoped lookup finds nothing.
231
+ await request(app.getHttpServer())
232
+ .get(`/organisations/${orgB.orgId}/forms/${keyA}`)
233
+ .set('Authorization', `Bearer ${orgB.accessToken}`)
234
+ .expect(404);
235
+ });
236
+
237
+ // ── Helpers ────────────────────────────────────────────────────────────────
238
+
239
+ function uniqueSuffix() {
240
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
241
+ }
242
+
243
+ function uniqueFormKey(label: string) {
244
+ return `${label}-${uniqueSuffix()}`.toLowerCase();
245
+ }
246
+
247
+ function registrationDefinition(key: string): Record<string, unknown> {
248
+ return {
249
+ key,
250
+ version: 1,
251
+ title: 'Event registration',
252
+ fields: [
253
+ {
254
+ type: 'text',
255
+ name: 'fullName',
256
+ label: 'Full name',
257
+ validators: { required: true, maxLength: 120 },
258
+ reportable: true,
259
+ },
260
+ {
261
+ type: 'email',
262
+ name: 'email',
263
+ label: 'Email address',
264
+ validators: { required: true },
265
+ reportable: true,
266
+ },
267
+ {
268
+ type: 'select',
269
+ name: 'ticketType',
270
+ label: 'Ticket type',
271
+ validators: { required: true, options: ['standard', 'student', 'vip'] },
272
+ reportable: true,
273
+ },
274
+ {
275
+ type: 'hidden',
276
+ name: 'registeredBy',
277
+ source: 'context',
278
+ path: 'user.id',
279
+ },
280
+ ],
281
+ actions: {
282
+ submit: ['validateAll', 'persist'],
283
+ saveDraft: ['persistDraft'],
284
+ },
285
+ };
286
+ }
287
+
288
+ function authenticateDefinition(key: string): Record<string, unknown> {
289
+ return {
290
+ key,
291
+ version: 1,
292
+ title: 'Sign in',
293
+ fields: [
294
+ { type: 'email', name: 'email', label: 'Email address', validators: { required: true } },
295
+ {
296
+ type: 'password',
297
+ name: 'password',
298
+ label: 'Password',
299
+ validators: { required: true, minLength: 8 },
300
+ },
301
+ ],
302
+ actions: {
303
+ submit: ['authenticate'],
304
+ },
305
+ };
306
+ }
307
+
308
+ async function setOrgSetting(accessToken: string, orgId: string, key: string, value: unknown) {
309
+ await request(app.getHttpServer())
310
+ .patch(`/organisations/${orgId}/settings`)
311
+ .set('Authorization', `Bearer ${accessToken}`)
312
+ .send({ key, value })
313
+ .expect(200);
314
+ }
315
+
316
+ async function createUserAndOrg(label: string) {
317
+ const user = await createUser(label);
318
+ const org = await createOrganisation(user.accessToken, label);
319
+
320
+ return {
321
+ ...user,
322
+ orgId: org.orgId,
323
+ membershipId: org.membershipId,
324
+ };
325
+ }
326
+
327
+ async function createUser(label: string) {
328
+ const suffix = uniqueSuffix();
329
+ const signup = await request(app.getHttpServer())
330
+ .post('/auth/signup')
331
+ .send({
332
+ email: `${label}-${suffix}@example.com`,
333
+ password: 'test-password-123',
334
+ displayName: label,
335
+ })
336
+ .expect(201);
337
+
338
+ return {
339
+ accessToken: signup.body.accessToken as string,
340
+ userId: signup.body.user.id as string,
341
+ };
342
+ }
343
+
344
+ async function createOrganisation(accessToken: string, label: string) {
345
+ const suffix = uniqueSuffix();
346
+ const org = await request(app.getHttpServer())
347
+ .post('/organisations')
348
+ .set('Authorization', `Bearer ${accessToken}`)
349
+ .send({
350
+ name: `${label} ${suffix}`,
351
+ slug: `${label}-${suffix}`,
352
+ })
353
+ .expect(201);
354
+
355
+ return {
356
+ orgId: org.body.organisation.id as string,
357
+ membershipId: org.body.membership.id as string,
358
+ };
359
+ }
360
+
361
+ async function createMemberInOrg(orgId: string, label: string, permissionKeys: string[]) {
362
+ const user = await createUser(label);
363
+ const permissions = await prisma.permission.findMany({
364
+ where: { key: { in: permissionKeys } },
365
+ select: { id: true },
366
+ });
367
+ expect(permissions).toHaveLength(permissionKeys.length);
368
+
369
+ const role = await prisma.role.create({
370
+ data: {
371
+ orgId,
372
+ name: `${label}-${uniqueSuffix()}`,
373
+ permissions: {
374
+ create: permissions.map((permission) => ({ permissionId: permission.id })),
375
+ },
376
+ },
377
+ select: { id: true },
378
+ });
379
+ const membership = await prisma.membership.create({
380
+ data: {
381
+ userId: user.userId,
382
+ orgId,
383
+ roleId: role.id,
384
+ },
385
+ select: { id: true, roleId: true },
386
+ });
387
+
388
+ return {
389
+ ...user,
390
+ membershipId: membership.id,
391
+ roleId: membership.roleId,
392
+ };
393
+ }
394
+ });