@stamhoofd/backend 2.87.1 → 2.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/package.json +10 -10
  2. package/src/endpoints/auth/CreateTokenEndpoint.ts +6 -6
  3. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +1 -1
  4. package/src/endpoints/auth/OpenIDConnectCallbackEndpoint.ts +1 -1
  5. package/src/endpoints/auth/OpenIDConnectStartEndpoint.ts +1 -1
  6. package/src/endpoints/auth/PollEmailVerificationEndpoint.ts +1 -1
  7. package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +1 -1
  8. package/src/endpoints/auth/SignupEndpoint.ts +1 -1
  9. package/src/endpoints/auth/VerifyEmailEndpoint.ts +1 -1
  10. package/src/endpoints/global/files/GetFileCache.ts +1 -1
  11. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +2 -2
  12. package/src/endpoints/global/organizations/GetOrganizationFromDomainEndpoint.test.ts +2 -2
  13. package/src/endpoints/global/organizations/GetOrganizationFromUriEndpoint.ts +0 -7
  14. package/src/endpoints/global/organizations/SearchOrganizationEndpoint.ts +1 -0
  15. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
  16. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +1 -1
  17. package/src/endpoints/organization/shared/ExchangePaymentEndpoint.ts +1 -1
  18. package/src/endpoints/organization/webshops/CheckWebshopDiscountCodesEndpoint.ts +1 -1
  19. package/src/endpoints/organization/webshops/GetOrderByPaymentEndpoint.ts +1 -1
  20. package/src/endpoints/organization/webshops/GetOrderEndpoint.ts +1 -1
  21. package/src/endpoints/organization/webshops/GetTicketsEndpoint.ts +1 -1
  22. package/src/endpoints/organization/webshops/retrieveUitpasSocialTariffPrice.ts +69 -0
  23. package/src/helpers/AuthenticatedStructures.ts +11 -0
  24. package/src/helpers/Context.ts +37 -8
  25. package/src/helpers/MemberUserSyncer.ts +2 -0
  26. package/src/helpers/UitpasNumberValidator.test.ts +23 -0
  27. package/src/helpers/UitpasNumberValidator.ts +157 -0
  28. package/src/helpers/UitpasTokenRepository.ts +146 -0
  29. package/src/sql-filters/groups.ts +1 -18
  30. package/tests/jest.global.setup.ts +5 -9
  31. package/tests/jest.setup.ts +7 -10
  32. package/.env.ci.json +0 -58
  33. package/.env.template.json +0 -72
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.87.1",
3
+ "version": "2.88.0",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -44,14 +44,14 @@
44
44
  "@simonbackx/simple-encoding": "2.22.0",
45
45
  "@simonbackx/simple-endpoints": "1.20.1",
46
46
  "@simonbackx/simple-logging": "^1.0.1",
47
- "@stamhoofd/backend-i18n": "2.87.1",
48
- "@stamhoofd/backend-middleware": "2.87.1",
49
- "@stamhoofd/email": "2.87.1",
50
- "@stamhoofd/models": "2.87.1",
51
- "@stamhoofd/queues": "2.87.1",
52
- "@stamhoofd/sql": "2.87.1",
53
- "@stamhoofd/structures": "2.87.1",
54
- "@stamhoofd/utility": "2.87.1",
47
+ "@stamhoofd/backend-i18n": "2.88.0",
48
+ "@stamhoofd/backend-middleware": "2.88.0",
49
+ "@stamhoofd/email": "2.88.0",
50
+ "@stamhoofd/models": "2.88.0",
51
+ "@stamhoofd/queues": "2.88.0",
52
+ "@stamhoofd/sql": "2.88.0",
53
+ "@stamhoofd/structures": "2.88.0",
54
+ "@stamhoofd/utility": "2.88.0",
55
55
  "archiver": "^7.0.1",
56
56
  "axios": "^1.8.2",
57
57
  "cookie": "^0.7.0",
@@ -69,5 +69,5 @@
69
69
  "publishConfig": {
70
70
  "access": "public"
71
71
  },
72
- "gitHead": "a9a8e07e38224fe34e805e400b1a46d5c286a10a"
72
+ "gitHead": "0406b1c0f731ff037988ddf71f3bc9a0046a64f3"
73
73
  }
@@ -34,7 +34,7 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
34
34
  // - check if not multiple attempts for the same username are started in parallel
35
35
  // - Limit the amount of failed attemps by IP (will only make it a bit harder)
36
36
  // - Detect attacks on random accounts (using email list + most used passwords) and temorary require CAPTCHA on all accounts
37
- const organization = await Context.setOptionalOrganizationScope();
37
+ const organization = await Context.setOptionalOrganizationScope({ willAuthenticate: false });
38
38
 
39
39
  switch (request.body.grantType) {
40
40
  case 'refresh_token': {
@@ -60,8 +60,11 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
60
60
  const token = await Token.createToken(oldToken.user);
61
61
 
62
62
  // In the rare event our response doesn't reach the client anymore, we don't want the client to sign out...
63
- // So we give them a second chance and create a new token BUT we expire our existing token in an hour (forever!)
64
- oldToken.refreshTokenValidUntil = new Date(Date.now() + 60 * 60 * 1000);
63
+ // So we allow a small rotation overlap period
64
+ const leeway = 60 * 1000;
65
+ oldToken.refreshTokenValidUntil = new Date(Math.min(oldToken.refreshTokenValidUntil.getTime(), Date.now() + leeway));
66
+
67
+ // Invalidate the corresponding access token
65
68
  oldToken.accessTokenValidUntil = new Date(Date.now() - 60 * 60 * 1000);
66
69
 
67
70
  // Do not delete the old one, only expire it fast so it will get deleted in the future
@@ -81,9 +84,6 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
81
84
  }
82
85
 
83
86
  case 'password': {
84
- // Increase timout for legacy
85
- request.request.request?.setTimeout(30 * 1000);
86
-
87
87
  if (STAMHOOFD.userMode === 'platform') {
88
88
  const platform = await Platform.getSharedPrivateStruct();
89
89
  const config = platform.config.loginMethods.get(LoginMethod.Password);
@@ -28,7 +28,7 @@ export class ForgotPasswordEndpoint extends Endpoint<Params, Query, Body, Respon
28
28
  }
29
29
 
30
30
  async handle(request: DecodedRequest<Params, Query, Body>) {
31
- const organization = await Context.setOptionalOrganizationScope();
31
+ const organization = await Context.setOptionalOrganizationScope({ willAuthenticate: false });
32
32
 
33
33
  if (STAMHOOFD.userMode === 'platform') {
34
34
  const platform = await Platform.getSharedPrivateStruct();
@@ -26,7 +26,7 @@ export class OpenIDConnectCallbackEndpoint extends Endpoint<Params, Query, Body,
26
26
  }
27
27
 
28
28
  async handle(request: DecodedRequest<Params, Query, Body>) {
29
- await Context.setUserOrganizationScope();
29
+ await Context.setUserOrganizationScope({ willAuthenticate: false });
30
30
  const ssoService = await SSOServiceWithSession.fromSession(request);
31
31
  return await ssoService.callback();
32
32
  }
@@ -28,7 +28,7 @@ export class OpenIDConnectStartEndpoint extends Endpoint<Params, Query, Body, Re
28
28
 
29
29
  async handle(request: DecodedRequest<Params, Query, Body>) {
30
30
  // Check webshop and/or organization
31
- await Context.setUserOrganizationScope();
31
+ await Context.setUserOrganizationScope({ willAuthenticate: false });
32
32
  const service = await SSOService.fromContext(request.query.provider);
33
33
  return await service.validateAndStartAuthCodeFlow(request.query);
34
34
  }
@@ -27,7 +27,7 @@ export class PollEmailVerificationEndpoint extends Endpoint<Params, Query, Body,
27
27
  }
28
28
 
29
29
  async handle(request: DecodedRequest<Params, Query, Body>) {
30
- const organization = await Context.setOptionalOrganizationScope();
30
+ const organization = await Context.setOptionalOrganizationScope({ willAuthenticate: false });
31
31
  const valid = await EmailVerificationCode.poll(organization?.id ?? null, request.body.token);
32
32
 
33
33
  return new Response(PollEmailVerificationResponse.create({
@@ -27,7 +27,7 @@ export class PollEmailVerificationEndpoint extends Endpoint<Params, Query, Body,
27
27
  }
28
28
 
29
29
  async handle(request: DecodedRequest<Params, Query, Body>) {
30
- const organization = await Context.setOptionalOrganizationScope();
30
+ const organization = await Context.setOptionalOrganizationScope({ willAuthenticate: false });
31
31
  const valid = await EmailVerificationCode.poll(organization?.id ?? null, request.body.token);
32
32
 
33
33
  if (valid) {
@@ -28,7 +28,7 @@ export class SignupEndpoint extends Endpoint<Params, Query, Body, ResponseBody>
28
28
  }
29
29
 
30
30
  async handle(request: DecodedRequest<Params, Query, Body>) {
31
- const organization = await Context.setUserOrganizationScope();
31
+ const organization = await Context.setUserOrganizationScope({ willAuthenticate: false });
32
32
 
33
33
  if (STAMHOOFD.userMode === 'platform') {
34
34
  const platform = await Platform.getShared();
@@ -28,7 +28,7 @@ export class VerifyEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
28
28
  }
29
29
 
30
30
  async handle(request: DecodedRequest<Params, Query, Body>) {
31
- const organization = await Context.setOptionalOrganizationScope();
31
+ const organization = await Context.setOptionalOrganizationScope({ willAuthenticate: false });
32
32
 
33
33
  const code = await EmailVerificationCode.verify(organization?.id ?? null, request.body.token, request.body.code);
34
34
 
@@ -45,7 +45,7 @@ export class GetFileCache extends Endpoint<Params, Query, Body, ResponseBody> {
45
45
  }
46
46
 
47
47
  async handle(request: DecodedRequest<Params, Query, Body>) {
48
- await Context.setOptionalOrganizationScope();
48
+ await Context.setOptionalOrganizationScope({ willAuthenticate: false });
49
49
 
50
50
  limiter.track(request.request.getIP(), 1);
51
51
 
@@ -101,7 +101,7 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
101
101
 
102
102
  // We risk creating a new member without being able to access it manually afterwards
103
103
  // Cache access to this member temporarily in memory
104
- await Context.auth.temporarilyGrantMemberAccess(member, PermissionLevel.Write);
104
+ await Context.auth.temporarilyGrantMemberAccess(member, PermissionLevel.Full);
105
105
 
106
106
  if (STAMHOOFD.userMode !== 'platform' && !member.organizationId) {
107
107
  throw new SimpleError({
@@ -925,7 +925,7 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
925
925
  console.log('checkSecurityCode: security code is correct - for ' + member.id);
926
926
 
927
927
  // Grant temporary access to this member without needing to enter the security code again
928
- await Context.auth.temporarilyGrantMemberAccess(member, PermissionLevel.Write);
928
+ await Context.auth.temporarilyGrantMemberAccess(member, PermissionLevel.Full);
929
929
 
930
930
  const log = new AuditLog();
931
931
 
@@ -1,5 +1,5 @@
1
1
  import { Request } from '@simonbackx/simple-endpoints';
2
- import { GroupFactory, OrganizationFactory } from '@stamhoofd/models';
2
+ import { OrganizationFactory } from '@stamhoofd/models';
3
3
  import { Organization } from '@stamhoofd/structures';
4
4
 
5
5
  import { testServer } from '../../../../tests/helpers/TestServer';
@@ -14,7 +14,7 @@ describe('Endpoint.GetOrganizationFromDomain', () => {
14
14
 
15
15
  const r = Request.buildJson('GET', '/v2/organization-from-domain');
16
16
  r.query = {
17
- domain: organization.uri + '.stamhoofd.dev',
17
+ domain: organization.uri + '.' + STAMHOOFD.domains.registration!['']!,
18
18
  };
19
19
 
20
20
  const response = await testServer.test(endpoint, r);
@@ -45,13 +45,6 @@ export class GetOrganizationFromUriEndpoint extends Endpoint<Params, Query, Body
45
45
  });
46
46
  }
47
47
 
48
- if (!organization.active) {
49
- throw new SimpleError({
50
- code: 'archived_organization',
51
- message: 'This organization has been archived',
52
- statusCode: 404,
53
- });
54
- }
55
48
  return new Response(await AuthenticatedStructures.organization(organization));
56
49
  }
57
50
  }
@@ -56,6 +56,7 @@ export class SearchOrganizationEndpoint extends Endpoint<Params, Query, Body, Re
56
56
  const whereMatch: SQLWhere = new SQLMatch(SQL.column(Organization.table, 'searchIndex'), scalarToSQLExpression(matchValue));
57
57
 
58
58
  let organizations = await Organization.select()
59
+ .where('active', true)
59
60
  .where(whereMatch)
60
61
  .orderBy(whereMatch, 'DESC')
61
62
  .limit(limit).fetch();
@@ -39,7 +39,7 @@ export class PatchOrganizationEndpoint extends Endpoint<Params, Query, Body, Res
39
39
  }
40
40
 
41
41
  async handle(request: DecodedRequest<Params, Query, Body>) {
42
- const organization = await Context.setOrganizationScope({ allowInactive: true });
42
+ const organization = await Context.setOrganizationScope();
43
43
  await Context.authenticate();
44
44
 
45
45
  if (!await Context.auth.hasSomeAccess(organization.id)) {
@@ -147,7 +147,7 @@ export class GetReceivableBalancesEndpoint extends Endpoint<Params, Query, Body,
147
147
  }
148
148
 
149
149
  static async buildDetailedData(requestQuery: LimitedFilteredRequest) {
150
- const organization = Context.organization ?? await Context.setOrganizationScope();
150
+ const organization = Context.organization ?? await Context.setOrganizationScope({ willAuthenticate: false });
151
151
  const { data, next } = await GetReceivableBalancesEndpoint.buildDataHelper(requestQuery);
152
152
 
153
153
  return new PaginatedResponse<DetailedReceivableBalance[], LimitedFilteredRequest>({
@@ -43,7 +43,7 @@ export class ExchangePaymentEndpoint extends Endpoint<Params, Query, Body, Respo
43
43
  }
44
44
 
45
45
  async handle(request: DecodedRequest<Params, Query, Body>) {
46
- const organization = await Context.setOptionalOrganizationScope();
46
+ const organization = await Context.setOptionalOrganizationScope({ willAuthenticate: false });
47
47
  if (!request.query.exchange) {
48
48
  await Context.optionalAuthenticate();
49
49
  }
@@ -28,7 +28,7 @@ export class CheckWebshopDiscountCodesEndpoint extends Endpoint<Params, Query, B
28
28
  }
29
29
 
30
30
  async handle(request: DecodedRequest<Params, Query, Body>) {
31
- const organization = await Context.setOrganizationScope();
31
+ const organization = await Context.setOrganizationScope({ willAuthenticate: false });
32
32
  const webshop = await Webshop.getByID(request.params.id);
33
33
  if (!webshop || webshop.organizationId !== organization.id) {
34
34
  throw new SimpleError({
@@ -26,7 +26,7 @@ export class GetOrderByPaymentEndpoint extends Endpoint<Params, Query, Body, Res
26
26
  }
27
27
 
28
28
  async handle(request: DecodedRequest<Params, Query, Body>) {
29
- const organization = await Context.setOrganizationScope();
29
+ const organization = await Context.setOrganizationScope({ willAuthenticate: false });
30
30
  const payment = await Payment.getByID(request.params.paymentId);
31
31
 
32
32
  if (!payment || payment.organizationId !== organization.id) {
@@ -24,7 +24,7 @@ export class GetOrderEndpoint extends Endpoint<Params, Query, Body, ResponseBody
24
24
  }
25
25
 
26
26
  async handle(request: DecodedRequest<Params, Query, Body>) {
27
- const organization = await Context.setOrganizationScope();
27
+ const organization = await Context.setOrganizationScope({ willAuthenticate: false });
28
28
  const order = await Order.getByID(request.params.orderId);
29
29
 
30
30
  if (!order || order.webshopId !== request.params.id || order.organizationId !== organization.id) {
@@ -42,7 +42,7 @@ export class GetTicketsEndpoint extends Endpoint<Params, Query, Body, ResponseBo
42
42
  }
43
43
 
44
44
  async handle(request: DecodedRequest<Params, Query, Body>) {
45
- const organization = await Context.setOrganizationScope();
45
+ const organization = await Context.setOrganizationScope({ willAuthenticate: false });
46
46
 
47
47
  if (request.query.secret) {
48
48
  const [ticket] = await Ticket.where({
@@ -0,0 +1,69 @@
1
+ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
+ import { SimpleError } from '@simonbackx/simple-errors';
3
+ import { UitpasPriceCheckRequest, UitpasPriceCheckResponse } from '@stamhoofd/structures';
4
+
5
+ import { UitpasNumberValidator } from '../../../helpers/UitpasNumberValidator';
6
+ import { Decoder } from '@simonbackx/simple-encoding';
7
+ type Params = Record<string, never>;
8
+ type Query = undefined;
9
+ type Body = UitpasPriceCheckRequest;
10
+ type ResponseBody = UitpasPriceCheckResponse;
11
+
12
+ export class retrieveUitpasSocialTariffPrice extends Endpoint<Params, Query, Body, ResponseBody> {
13
+ bodyDecoder = UitpasPriceCheckRequest as Decoder<UitpasPriceCheckRequest>;
14
+
15
+ protected doesMatch(request: Request): [true, Params] | [false] {
16
+ if (request.method !== 'POST') {
17
+ return [false];
18
+ }
19
+
20
+ const params = Endpoint.parseParameters(request.url, '/uitpas', {});
21
+
22
+ if (params) {
23
+ return [true, params as Params];
24
+ }
25
+ return [false];
26
+ }
27
+
28
+ async handle(request: DecodedRequest<Params, Query, Body>) {
29
+ if (request.body.uitpasEventId) {
30
+ // OFFICIAL FLOW
31
+ if (!request.body.uitpasNumber) {
32
+ // STATIC CHECK
33
+ // request shouldn't include a reduced price
34
+ }
35
+ else {
36
+ // OFFICIAL FLOW with an UiTPAS number
37
+ // request should include a reduced price (estimate by the frontend)
38
+ }
39
+ throw new SimpleError({
40
+ code: 'not_implemented',
41
+ message: 'Official flow not yet implemented',
42
+ human: 'De officiële flow voor het valideren van een UiTPAS-nummer wordt nog niet ondersteund.',
43
+ });
44
+ }
45
+ else {
46
+ // NON-OFFICIAL FLOW
47
+ // request should include UiTPAS-number, reduced price AND base price
48
+ if (!request.body.reducedPrice) {
49
+ throw new SimpleError({
50
+ code: 'missing_reduced_price',
51
+ message: 'Reduced price must be provided for non-official flow.',
52
+ human: $t('Je moet een verlaagd tarief opgeven voor de UiTPAS.'),
53
+ });
54
+ }
55
+ if (!request.body.uitpasNumber) {
56
+ throw new SimpleError({
57
+ code: 'missing_uitpas_number',
58
+ message: 'Uitpas number must be provided for non-official flow.',
59
+ human: $t('Je moet een UiTPAS-nummer opgeven.'),
60
+ });
61
+ }
62
+ await UitpasNumberValidator.checkUitpasNumber(request.body.uitpasNumber);
63
+ const uitpasPriceCheckResponse = UitpasPriceCheckResponse.create({
64
+ price: request.body.reducedPrice,
65
+ });
66
+ return new Response(uitpasPriceCheckResponse);
67
+ }
68
+ }
69
+ }
@@ -443,6 +443,17 @@ export class AuthenticatedStructures {
443
443
  }
444
444
  }
445
445
 
446
+ const membershipOrganizationId = Platform.shared.membershipOrganizationId;
447
+ if (Context.auth.hasSomePlatformAccess() && membershipOrganizationId) {
448
+ if (await Context.auth.hasSomeAccess(membershipOrganizationId)) {
449
+ const found = organizations.get(membershipOrganizationId);
450
+ if (!found) {
451
+ const organization = await Context.auth.getOrganization(membershipOrganizationId);
452
+ organizations.set(organization.id, organization);
453
+ }
454
+ }
455
+ }
456
+
446
457
  const memberBlobs: MemberWithRegistrationsBlob[] = [];
447
458
  for (const member of members) {
448
459
  for (const registration of member.registrations) {
@@ -1,5 +1,5 @@
1
1
  import { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
2
- import { SimpleError } from '@simonbackx/simple-errors';
2
+ import { isSimpleError, SimpleError } from '@simonbackx/simple-errors';
3
3
  import { I18n } from '@stamhoofd/backend-i18n';
4
4
  import { Organization, Platform, RateLimiter, Token, User } from '@stamhoofd/models';
5
5
  import { AsyncLocalStorage } from 'async_hooks';
@@ -149,12 +149,15 @@ export class ContextInstance {
149
149
  return this.#auth;
150
150
  }
151
151
 
152
- async setOptionalOrganizationScope() {
152
+ async setOptionalOrganizationScope(options?: { willAuthenticate?: boolean }) {
153
153
  try {
154
- return await this.setOrganizationScope();
154
+ return await this.setOrganizationScope(options);
155
155
  }
156
156
  catch (e) {
157
- return null;
157
+ if (isSimpleError(e) && e.hasCode('invalid_host')) {
158
+ return null;
159
+ }
160
+ throw e;
158
161
  }
159
162
  }
160
163
 
@@ -170,15 +173,21 @@ export class ContextInstance {
170
173
  /**
171
174
  * Require organization scope if userMode is not platform
172
175
  */
173
- async setUserOrganizationScope() {
176
+ async setUserOrganizationScope(options?: { willAuthenticate?: boolean }) {
174
177
  if (STAMHOOFD.userMode === 'platform') {
175
178
  return null;
176
179
  }
177
- return await this.setOrganizationScope();
180
+ return await this.setOrganizationScope(options);
178
181
  }
179
182
 
180
- async setOrganizationScope(options?: { allowInactive?: boolean }) {
181
- const organization = await Organization.fromApiHost(this.request.host, options);
183
+ async setOrganizationScope(options?: { willAuthenticate?: boolean }) {
184
+ if (!options) {
185
+ options = {};
186
+ }
187
+
188
+ const organization = await Organization.fromApiHost(this.request.host, {
189
+ allowInactive: options.willAuthenticate ?? true,
190
+ });
182
191
 
183
192
  this.organization = organization;
184
193
  this.i18n.switchToLocale({ country: organization.address.country });
@@ -192,6 +201,14 @@ export class ContextInstance {
192
201
  }
193
202
  catch (e) {
194
203
  if (e.code === 'not_authenticated') {
204
+ // Do not allow to optional authenticate to inactive organizations
205
+ if (this.organization && !this.organization.active) {
206
+ throw new SimpleError({
207
+ code: 'not_authenticated',
208
+ message: 'You need to authenticate to view inactive organizations',
209
+ statusCode: 401,
210
+ });
211
+ }
195
212
  return {};
196
213
  }
197
214
  throw e;
@@ -290,6 +307,18 @@ export class ContextInstance {
290
307
 
291
308
  this.#auth = new AdminPermissionChecker(user, await Platform.getSharedPrivateStruct(), this.organization);
292
309
 
310
+ if (this.organization && !this.organization.active) {
311
+ // For inactive organizations, you always need permissions to view them
312
+ if (!await Context.auth.hasFullAccess(this.organization.id)) {
313
+ throw new SimpleError({
314
+ code: 'archived',
315
+ message: 'Full access is required to view inactive organizations',
316
+ human: $t('Je moet een hoofdbeheerder zijn om inactieve verenigingen te bekijken'),
317
+ statusCode: 401,
318
+ });
319
+ }
320
+ }
321
+
293
322
  return { user, token };
294
323
  }
295
324
  }
@@ -156,6 +156,8 @@ export class MemberUserSyncerStatic {
156
156
  }
157
157
 
158
158
  async updateInheritedPermissions(user: User) {
159
+ // Fetch all members for this user
160
+
159
161
  const responsibilities = user.memberId ? (await this.getResponsibilitiesForMembers([user.memberId])) : [];
160
162
 
161
163
  // Check if the member has active registrations
@@ -0,0 +1,23 @@
1
+ import { STExpect } from '@stamhoofd/test-utils';
2
+ import { UitpasNumberValidator } from './UitpasNumberValidator';
3
+
4
+ describe.skip('UitpasNumberValidator', () => {
5
+ it('should validate a correct Uitpas number with kansentarief', async () => {
6
+ const validNumber = '0900000067513';
7
+ await expect(UitpasNumberValidator.checkUitpasNumber(validNumber)).resolves.toBeUndefined();
8
+ });
9
+
10
+ it('should throw an error for an invalid Uitpas number', async () => {
11
+ const invalidNumber = '1234567890123';
12
+ await expect(UitpasNumberValidator.checkUitpasNumber(invalidNumber)).rejects.toThrow(
13
+ STExpect.simpleError({ code: 'unsuccessful_but_expected_response_retrieving_pass_by_uitpas_number' }),
14
+ );
15
+ });
16
+
17
+ it('should throw an error for a Uitpas number with kansentarief expired', async () => {
18
+ const expiredNumber = '0900000058918';
19
+ await expect(UitpasNumberValidator.checkUitpasNumber(expiredNumber)).rejects.toThrow(
20
+ STExpect.simpleError({ code: 'uitpas_number_issue' }),
21
+ );
22
+ });
23
+ });
@@ -0,0 +1,157 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
2
+ import { UitpasTokenRepository } from './UitpasTokenRepository';
3
+ import { DataValidator } from '@stamhoofd/utility';
4
+
5
+ type UitpasNumberSuccessfulResponse = {
6
+ socialTariff: {
7
+ status: 'ACTIVE' | 'EXPIRED' | 'NONE';
8
+ };
9
+ messages?: Array<{
10
+ text: string;
11
+ }>;
12
+ };
13
+
14
+ type UitpasNumberErrorResponse = {
15
+ title: string; // e.g., "Invalid uitpas number"
16
+ endUserMessage?: {
17
+ nl: string;
18
+ };
19
+ };
20
+
21
+ function assertIsUitpasNumberSuccessfulResponse(
22
+ json: unknown,
23
+ ): asserts json is UitpasNumberSuccessfulResponse {
24
+ if (
25
+ typeof json !== 'object'
26
+ || json === null
27
+ || !('socialTariff' in json)
28
+ || typeof json.socialTariff !== 'object'
29
+ || json.socialTariff === null
30
+ || !('status' in json.socialTariff)
31
+ || typeof json.socialTariff.status !== 'string'
32
+ || (json.socialTariff.status !== 'ACTIVE' && json.socialTariff.status !== 'EXPIRED' && json.socialTariff.status !== 'NONE')
33
+ || ('messages' in json && (!Array.isArray(json.messages) || !json.messages.every(
34
+ (message: unknown) => typeof message === 'object' && message !== null && 'text' in message && typeof message.text === 'string')))
35
+ ) {
36
+ console.error('Invalid response when retrieving pass by UiTPAS number:', json);
37
+ throw new SimpleError({
38
+ code: 'invalid_response_retrieving_pass_by_uitpas_number',
39
+ message: `Invalid response when retrieving pass by UiTPAS number`,
40
+ human: $t(`Er is een fout opgetreden bij het ophalen van je UiTPAS. Kijk je het nummer even na?`),
41
+ });
42
+ }
43
+ }
44
+
45
+ function isUitpasNumberErrorResponse(
46
+ json: unknown,
47
+ ): json is UitpasNumberErrorResponse {
48
+ return typeof json === 'object'
49
+ && json !== null
50
+ && 'title' in json
51
+ && typeof json.title === 'string'
52
+ && (!('endUserMessage' in json)
53
+ || (typeof json.endUserMessage === 'object' && json.endUserMessage !== null && 'nl' in json.endUserMessage && typeof json.endUserMessage.nl === 'string')
54
+ );
55
+ }
56
+
57
+ export class UitpasNumberValidatorStatic {
58
+ async checkUitpasNumber(uitpasNumber: string) {
59
+ // static check (using regex)
60
+ if (!DataValidator.isUitpasNumberValid(uitpasNumber)) {
61
+ throw new SimpleError({
62
+ code: 'invalid_uitpas_number',
63
+ message: `Invalid UiTPAS number: ${uitpasNumber}`,
64
+ human: $t(
65
+ `Het opgegeven UiTPAS-nummer is ongeldig. Controleer het nummer en probeer het opnieuw.`,
66
+ ),
67
+ });
68
+ }
69
+ const access_token = await UitpasTokenRepository.getAccessTokenFor(); // for nothing, means for platform
70
+ const baseUrl = 'https://api-test.uitpas.be'; // TO DO: Use the URL from environment variables
71
+
72
+ const url = `${baseUrl}/passes/${uitpasNumber}`;
73
+ const myHeaders = new Headers();
74
+ myHeaders.append('Authorization', 'Bearer ' + access_token);
75
+ const requestOptions = {
76
+ method: 'GET',
77
+ headers: myHeaders,
78
+ };
79
+
80
+ const response = await fetch(url, requestOptions).catch(() => {
81
+ // Handle network errors
82
+ throw new SimpleError({
83
+ code: 'uitpas_unreachable_retrieving_pass_by_uitpas_number',
84
+ message: `Network issue when retrieving pass by UiTPAS number`,
85
+ human: $t(
86
+ `We konden UiTPAS niet bereiken om jouw UiTPAS-nummer te valideren. Probeer het later opnieuw.`,
87
+ ),
88
+ });
89
+ });
90
+ if (!response.ok) {
91
+ const json: unknown = await response.json().catch(() => { /* ignore */ });
92
+ let endUserMessage = '';
93
+
94
+ if (json) {
95
+ console.error(`UiTPAS API returned an error for UiTPAS number ${uitpasNumber}:`, json);
96
+ }
97
+ else {
98
+ console.error(`UiTPAS API returned an error for UiTPAS number ${uitpasNumber}:`, response.statusText);
99
+ }
100
+
101
+ if (isUitpasNumberErrorResponse(json)) {
102
+ endUserMessage = json.endUserMessage ? json.endUserMessage.nl : '';
103
+ }
104
+
105
+ if (endUserMessage) {
106
+ throw new SimpleError({
107
+ code: 'unsuccessful_but_expected_response_retrieving_pass_by_uitpas_number',
108
+ message: `Unsuccesful response with message when retrieving pass by UiTPAS number, message: ${endUserMessage}`,
109
+ human: endUserMessage,
110
+ });
111
+ }
112
+
113
+ throw new SimpleError({
114
+ code: 'unsuccessful_and_unexpected_response_retrieving_pass_by_uitpas_number',
115
+ message: `Unsuccesful response without message when retrieving pass by UiTPAS number`,
116
+ human: $t(`Er is een fout opgetreden bij het ophalen van je UiTPAS. Kijk je het nummer even na?`),
117
+ });
118
+ }
119
+
120
+ const json = await response.json().catch(() => {
121
+ // Handle JSON parsing errors
122
+ throw new SimpleError({
123
+ code: 'invalid_json_retrieving_pass_by_uitpas_number',
124
+ message: `Invalid json when retrieving pass by UiTPAS number`,
125
+ human: $t(
126
+ `Er is een fout opgetreden bij het communiceren met UiTPAS. Probeer het later opnieuw.`,
127
+ ),
128
+ });
129
+ });
130
+ assertIsUitpasNumberSuccessfulResponse(json);
131
+ if (json.messages) {
132
+ const humanMessage = json.messages[0].text; // only display the first message
133
+
134
+ // alternatively, join all messages
135
+ // const text = json.messages.map((message: any) => message.text).join(', ');
136
+
137
+ throw new SimpleError({
138
+ code: 'uitpas_number_issue',
139
+ message: `UiTPAS API returned an error: ${humanMessage}`,
140
+ human: humanMessage,
141
+ });
142
+ }
143
+ if (json.socialTariff.status !== 'ACTIVE') {
144
+ // THIS SHOULD NOT HAPPEN, as in that case json.messages should be present
145
+ throw new SimpleError({
146
+ code: 'non_active_social_tariff',
147
+ message: `UiTPAS social tariff is not ACTIVE but ${json.socialTariff.status}`,
148
+ human: $t(
149
+ `Het opgegeven UiTPAS-nummer heeft geen actief kansentarief. Neem contact op met de UiTPAS-organisatie voor meer informatie.`,
150
+ ),
151
+ });
152
+ }
153
+ // no errors -> the uitpas number is valid and social tariff is applicable
154
+ }
155
+ }
156
+
157
+ export const UitpasNumberValidator = new UitpasNumberValidatorStatic();
@@ -0,0 +1,146 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
2
+ import { UitpasClientCredential } from '@stamhoofd/models';
3
+ import { QueueHandler } from '@stamhoofd/queues';
4
+
5
+ type UitpasTokenResponse = {
6
+ access_token: string;
7
+ expires_in: number;
8
+ };
9
+
10
+ function assertIsUitpasTokenResponse(json: unknown): asserts json is UitpasTokenResponse {
11
+ if (
12
+ typeof json !== 'object'
13
+ || json === null
14
+ || !('access_token' in json)
15
+ || !('expires_in' in json)
16
+ || typeof json.access_token !== 'string'
17
+ || typeof json.expires_in !== 'number'
18
+ || json.expires_in <= 0
19
+ ) {
20
+ console.error('Invalid response when fetching UiTPAS token:', json);
21
+ throw new SimpleError({
22
+ code: 'invalid_response_fetching_uitpas_token',
23
+ message: `Invalid response when fetching UiTPAS token`,
24
+ human: $t(`Er is een fout opgetreden bij het communiceren met UiTPAS. Probeer het later opnieuw.`),
25
+ });
26
+ }
27
+ }
28
+
29
+ export class UitpasTokenRepository {
30
+ accessToken?: string;
31
+ expiresOn: Date = new Date(0); // Set to minimum time initially
32
+ uitpasClientCredential: UitpasClientCredential;
33
+
34
+ constructor(uitpasClientCredential: UitpasClientCredential) {
35
+ this.uitpasClientCredential = uitpasClientCredential;
36
+ }
37
+
38
+ /**
39
+ * organizationId (null means platform) -> UitpasTokenRepository
40
+ */
41
+ static knownTokens: Map<string | null, UitpasTokenRepository> = new Map();
42
+
43
+ private static async createRepoFromDb(organizationId: string | null): Promise<UitpasTokenRepository> {
44
+ // query db
45
+ let uitpasClientCredential = await UitpasClientCredential.select().where('organizationId', organizationId).first(false);
46
+ if (!uitpasClientCredential) {
47
+ // temporary solution, because platform client id and secret are not yet in the database
48
+ if (organizationId === null) {
49
+ if (!STAMHOOFD.UITPAS_API_CLIENT_ID || !STAMHOOFD.UITPAS_API_CLIENT_SECRET) {
50
+ throw new SimpleError({
51
+ code: 'uitpas_api_not_configured_for_platform',
52
+ message: 'UiTPAS api is not configured for the platform',
53
+ human: $t('UiTPAS is niet volledig geconfigureerd, contacteer de platformbeheerder.'),
54
+ });
55
+ }
56
+ uitpasClientCredential = new UitpasClientCredential();
57
+ uitpasClientCredential.clientId = STAMHOOFD.UITPAS_API_CLIENT_ID;
58
+ uitpasClientCredential.clientSecret = STAMHOOFD.UITPAS_API_CLIENT_SECRET;
59
+ uitpasClientCredential.organizationId = null; // null means platform
60
+ }
61
+ else {
62
+ throw new SimpleError({
63
+ code: 'uitpas_api_not_configured_for_this_organization',
64
+ message: `UiTPAS api not configured for organization with id ${organizationId}`,
65
+ human: $t(`De UiTPAS integratie is niet compleet, contacteer de beheerder.`),
66
+ });
67
+ }
68
+ }
69
+ const newRepo = new UitpasTokenRepository(uitpasClientCredential);
70
+ this.knownTokens.set(organizationId, newRepo);
71
+ return newRepo;
72
+ }
73
+
74
+ private async getNewAccessToken(): Promise<string> {
75
+ const url = 'https://account-test.uitid.be/realms/uitid/protocol/openid-connect/token';
76
+ const myHeaders = new Headers();
77
+ myHeaders.append('Content-Type', 'application/x-www-form-urlencoded');
78
+ const params = new URLSearchParams({
79
+ grant_type: 'client_credentials',
80
+ client_id: this.uitpasClientCredential.clientId,
81
+ client_secret: this.uitpasClientCredential.clientSecret,
82
+ });
83
+ const requestOptions: RequestInit = {
84
+ method: 'POST',
85
+ headers: myHeaders,
86
+ body: params.toString(),
87
+ };
88
+ const response = await fetch(url, requestOptions).catch(() => {
89
+ // Handle network errors
90
+ throw new SimpleError({
91
+ code: 'uitpas_unreachable_fetching_uitpas_token',
92
+ message: `Network issue when fetching UiTPAS token`,
93
+ human: $t(`We konden UiTPAS niet bereiken. Probeer het later opnieuw.`),
94
+ });
95
+ });
96
+ if (!response.ok) {
97
+ console.error(`Unsuccessful response when fetching UiTPAS token for organization with id ${this.uitpasClientCredential.organizationId}:`, response.statusText);
98
+ throw new SimpleError({
99
+ code: 'unsuccessful_response_fetching_uitpas_token',
100
+ message: `Unsuccesful response when fetching UiTPAS token`,
101
+ human: $t(`Er is een fout opgetreden bij het verbinden met UiTPAS. Probeer het later opnieuw.`),
102
+ });
103
+ }
104
+ const json: unknown = await response.json().catch(() => {
105
+ // Handle JSON parsing errors
106
+ throw new SimpleError({
107
+ code: 'invalid_json_fetching_uitpas_token',
108
+ message: `Invalid json when fetching UiTPAS token`,
109
+ human: $t(`Er is een fout opgetreden bij het communiceren met UiTPAS. Probeer het later opnieuw.`),
110
+ });
111
+ });
112
+ assertIsUitpasTokenResponse(json);
113
+ this.accessToken = json.access_token;
114
+ this.expiresOn = new Date((Date.now() + json.expires_in * 1000) - 10000); // Set expiration 10 seconds earlier to be safe
115
+ return this.accessToken;
116
+ }
117
+
118
+ /**
119
+ * Get the access token for the organization or platform.
120
+ * @param organizationId the organization ID for which to get the access token. If null, it means the platform.
121
+ * @param forceRefresh if true, the access token will be refreshed even if a previously stored token it is still valid
122
+ * @returns Promise<string> the access token for the organization or platform
123
+ * @throws SimpleError if the token cannot be obtained or the API is not configured
124
+ */
125
+ static async getAccessTokenFor(organizationId: string | null = null, forceRefresh: boolean = false): Promise<string> {
126
+ let repo = UitpasTokenRepository.knownTokens.get(organizationId);
127
+ if (repo && repo.accessToken && !forceRefresh && repo.expiresOn > new Date()) {
128
+ return repo.accessToken;
129
+ }
130
+
131
+ // Prevent multiple concurrent requests for the same organization, asking for an access token to the UiTPAS API.
132
+ // The queue can only run one at a time for the same organizationId
133
+ return await QueueHandler.schedule('uitpas/token-' + (organizationId ?? 'platform'), async () => {
134
+ // we re-search for the repo, as another call to this funcion might have added while we we're waiting in the queue
135
+ repo = UitpasTokenRepository.knownTokens.get(organizationId);
136
+ if (repo && repo.accessToken && !forceRefresh && repo.expiresOn > new Date()) {
137
+ return repo.accessToken;
138
+ }
139
+ if (!repo) {
140
+ repo = await UitpasTokenRepository.createRepoFromDb(organizationId);
141
+ }
142
+ // ask for a new access token
143
+ return repo.getNewAccessToken(); ;
144
+ });
145
+ }
146
+ }
@@ -1,4 +1,4 @@
1
- import { SQL, createColumnFilter, SQLModernFilterDefinitions, SQLValueType, baseModernSQLFilterCompilers, createSQLColumnFilterCompiler, createSQLExpressionFilterCompiler, SQLModernValueType, createWildcardColumnFilter, SQLJsonExtract } from '@stamhoofd/sql';
1
+ import { baseModernSQLFilterCompilers, createColumnFilter, createWildcardColumnFilter, SQL, SQLJsonExtract, SQLModernFilterDefinitions, SQLModernValueType } from '@stamhoofd/sql';
2
2
 
3
3
  export const groupFilterCompilers: SQLModernFilterDefinitions = {
4
4
  ...baseModernSQLFilterCompilers,
@@ -47,21 +47,4 @@ export const groupFilterCompilers: SQLModernFilterDefinitions = {
47
47
  }),
48
48
  }),
49
49
  ),
50
-
51
- /* name: createSQLExpressionFilterCompiler(
52
- SQL.jsonValue(SQL.column('groups', 'settings'), '$.value.name'),
53
- { isJSONValue: true, type: SQLValueType.JSONString },
54
- ),
55
- status: createSQLExpressionFilterCompiler(
56
- SQL.column('groups', 'status'),
57
- { isJSONValue: true, type: SQLValueType.JSONString },
58
- ),
59
- defaultAgeGroupId: createSQLColumnFilterCompiler(SQL.column('groups', 'defaultAgeGroupId'), { nullable: true }),
60
-
61
- bundleDiscountIds: createSQLExpressionFilterCompiler(
62
- SQL.jsonKeys(
63
- SQL.jsonValue(SQL.column('groups', 'settings'), '$.value.prices[0].bundleDiscounts'),
64
- ),
65
- { isJSONValue: true, isJSONObject: true },
66
- ), */
67
50
  };
@@ -1,14 +1,8 @@
1
- import backendEnv from '@stamhoofd/backend-env';
2
- backendEnv.load({ path: __dirname + '/../../.env.test.json' });
3
-
4
- import { Database, Migration } from '@simonbackx/simple-database';
5
- import * as jose from 'jose';
1
+ import { TestUtils } from '@stamhoofd/test-utils';
6
2
  import nock from 'nock';
7
3
  import path from 'path';
8
- import { GlobalHelper } from '../src/helpers/GlobalHelper';
9
4
  const emailPath = require.resolve('@stamhoofd/email');
10
5
  const modelsPath = require.resolve('@stamhoofd/models');
11
- import { TestUtils } from '@stamhoofd/test-utils';
12
6
 
13
7
  // Disable network requests
14
8
  nock.disableNetConnect();
@@ -22,6 +16,10 @@ if (new Date().getTimezoneOffset() !== 0) {
22
16
  }
23
17
 
24
18
  export default async () => {
19
+ await TestUtils.globalSetup();
20
+
21
+ const { Database, Migration } = await import('@simonbackx/simple-database');
22
+
25
23
  // External migrations
26
24
  await Migration.runAll(path.dirname(modelsPath) + '/migrations');
27
25
  await Migration.runAll(path.dirname(emailPath) + '/migrations');
@@ -30,6 +28,4 @@ export default async () => {
30
28
  await Migration.runAll(__dirname + '/src/migrations');
31
29
 
32
30
  await Database.end();
33
-
34
- await TestUtils.globalSetup();
35
31
  };
@@ -1,20 +1,17 @@
1
- import backendEnv from '@stamhoofd/backend-env';
2
- backendEnv.load({ path: __dirname + '/../../.env.test.json' });
3
-
4
1
  import { Column, Database } from '@simonbackx/simple-database';
5
2
  import { Request } from '@simonbackx/simple-endpoints';
3
+ import { SimpleError } from '@simonbackx/simple-errors';
6
4
  import { Email, EmailMocker } from '@stamhoofd/email';
5
+ import { QueueHandler } from '@stamhoofd/queues';
7
6
  import { Version } from '@stamhoofd/structures';
7
+ import { TestUtils } from '@stamhoofd/test-utils';
8
8
  import { sleep } from '@stamhoofd/utility';
9
+ import * as jose from 'jose';
9
10
  import nock from 'nock';
10
11
  import { GlobalHelper } from '../src/helpers/GlobalHelper';
11
- import * as jose from 'jose';
12
- import { TestUtils } from '@stamhoofd/test-utils';
13
- import './toMatchMap';
14
- import { PayconiqMocker } from './helpers/PayconiqMocker';
15
12
  import { BalanceItemService } from '../src/services/BalanceItemService';
16
- import { QueueHandler } from '@stamhoofd/queues';
17
- import { SimpleError } from '@simonbackx/simple-errors';
13
+ import { PayconiqMocker } from './helpers/PayconiqMocker';
14
+ import './toMatchMap';
18
15
 
19
16
  // Set version of saved structures
20
17
  Column.setJSONVersion(Version);
@@ -75,7 +72,7 @@ beforeAll(async () => {
75
72
  await GlobalHelper.load();
76
73
 
77
74
  // Override default $t handlers
78
- TestUtils.loadEnvironment();
75
+ await TestUtils.loadEnvironment();
79
76
  });
80
77
 
81
78
  afterAll(async () => {
package/.env.ci.json DELETED
@@ -1,58 +0,0 @@
1
- {
2
- "environment": "test",
3
- "domains": {
4
- "dashboard": "dashboard.stamhoofd",
5
- "marketing": {
6
- "": "www.be.stamhoofd",
7
- "BE": "www.be.stamhoofd",
8
- "NL": "www.nl.stamhoofd"
9
- },
10
- "webshop": {
11
- "": "shop.be.stamhoofd",
12
- "BE": "shop.be.stamhoofd",
13
- "NL": "shop.nl.stamhoofd"
14
- },
15
- "api": "api.stamhoofd",
16
- "rendererApi": "renderer.stamhoofd",
17
-
18
- "defaultTransactionalEmail": {
19
- "": "stamhoofd.be"
20
- },
21
-
22
- "defaultBroadcastEmail": {
23
- "": "stamhoofd.email"
24
- },
25
- "webshopCname": "shop.stamhoofd"
26
- },
27
-
28
- "PORT": 9091,
29
- "DB_HOST": "127.0.0.1",
30
- "DB_USER": "root",
31
- "DB_PASS": "root",
32
- "DB_DATABASE": "stamhoofd-tests",
33
-
34
- "SMTP_HOST": "0.0.0.0",
35
- "SMTP_USERNAME": "test",
36
- "SMTP_PASSWORD": "test",
37
- "SMTP_PORT": 587,
38
-
39
- "AWS_ACCESS_KEY_ID": "",
40
- "AWS_SECRET_ACCESS_KEY": "",
41
- "AWS_REGION": "",
42
-
43
- "SPACES_ENDPOINT": "anydomain.example",
44
- "SPACES_BUCKET": "example",
45
- "SPACES_KEY": "test",
46
- "SPACES_SECRET": "test",
47
-
48
- "INTERNAL_SECRET_KEY": "test",
49
-
50
- "STRIPE_SECRET_KEY": "sk_test_test",
51
- "STRIPE_ENDPOINT_SECRET": "sk_test",
52
- "translationNamespace": "digit",
53
- "platformName": "ravot",
54
- "userMode": "organization",
55
- "WHITELISTED_EMAIL_DESTINATIONS": [],
56
- "MEMBER_NUMBER_ALGORITHM": "Incremental",
57
- "MEMBER_NUMBER_ALGORITHM_LENGTH": 10
58
- }
@@ -1,72 +0,0 @@
1
- {
2
- "environment": "development",
3
- "domains": {
4
- "dashboard": "dashboard.stamhoofd",
5
- "registration": {
6
- "": "be.stamhoofd",
7
- "BE": "be.stamhoofd",
8
- "NL": "nl.stamhoofd"
9
- },
10
- "marketing": {
11
- "": "www.be.stamhoofd",
12
- "BE": "www.be.stamhoofd",
13
- "NL": "www.nl.stamhoofd"
14
- },
15
- "documentation": {
16
- "": "www.be.stamhoofd/docs",
17
- "BE": "www.be.stamhoofd/docs",
18
- "NL": "www.nl.stamhoofd/docs"
19
- },
20
- "webshop": {
21
- "": "shop.be.stamhoofd",
22
- "BE": "shop.be.stamhoofd",
23
- "NL": "shop.nl.stamhoofd"
24
- },
25
- "legacyWebshop": "shop.stamhoofd",
26
- "api": "api.stamhoofd",
27
- "rendererApi": "renderer.stamhoofd",
28
- "webshopCname": "shop.stamhoofd"
29
- },
30
- "translationNamespace": "stamhoofd",
31
- "platformName": "stamhoofd",
32
- "userMode": "organization",
33
-
34
- "PORT": 9091,
35
- "DB_HOST": "127.0.0.1",
36
- "DB_USER": "",
37
- "DB_PASS": "",
38
- "DB_DATABASE": "",
39
-
40
- "SMTP_HOST": "0.0.0.0",
41
- "SMTP_USERNAME": "username",
42
- "SMTP_PASSWORD": "password",
43
- "SMTP_PORT": 1025,
44
-
45
- "TRANSACTIONAL_SMTP_HOST": "0.0.0.0",
46
- "TRANSACTIONAL_SMTP_USERNAME": "username",
47
- "TRANSACTIONAL_SMTP_PASSWORD": "password",
48
- "TRANSACTIONAL_SMTP_PORT": 1025,
49
-
50
- "AWS_ACCESS_KEY_ID": "",
51
- "AWS_SECRET_ACCESS_KEY": "",
52
- "AWS_REGION": "",
53
-
54
- "SPACES_ENDPOINT": "",
55
- "SPACES_BUCKET": "",
56
- "SPACES_KEY": "",
57
- "SPACES_SECRET": "",
58
-
59
- "MOLLIE_CLIENT_ID": "",
60
- "MOLLIE_SECRET": "",
61
- "MOLLIE_API_KEY": "",
62
- "MOLLIE_ORGANIZATION_TOKEN": "",
63
-
64
- "LATEST_IOS_VERSION": 0,
65
- "LATEST_ANDROID_VERSION": 0,
66
-
67
- "NOLT_SSO_SECRET_KEY": "optional",
68
- "INTERNAL_SECRET_KEY": "",
69
- "CRONS_DISABLED": false,
70
- "WHITELISTED_EMAIL_DESTINATIONS": ["*"],
71
- "CACHE_PATH": "<fill in a safe path exlusive for Stamhoofd to store cached files>"
72
- }