• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

formcapture / form-backend / 16905974748

12 Aug 2025 10:20AM UTC coverage: 65.766% (-2.5%) from 68.25%
16905974748

push

github

web-flow
Merge pull request #164 from formcapture/feat/errorhandling

Feat: Improve error handling in app and backend

308 of 556 branches covered (55.4%)

Branch coverage included in aggregate %.

86 of 140 new or added lines in 11 files covered. (61.43%)

19 existing lines in 1 file now uncovered.

812 of 1147 relevant lines covered (70.79%)

6.75 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

77.72
/backend/src/processor/formConfig.ts
1
import { PostgrestClient } from '@supabase/postgrest-js';
2
import merge from 'lodash.merge';
6✔
3
import { Logger } from 'winston';
4

5
import { DatabaseError } from '../errors/GenericRequestError';
6✔
6
import { setupLogger } from '../logger';
6✔
7
import { isFormConfig } from '../typeguards/formConfig';
6✔
8
import { FormConfig } from '../types/formConfig';
9
import { FormConfigInternal } from '../types/formConfigInternal';
10
import { FormConfigPublic } from '../types/formConfigPublic';
11
import { JoinTable } from '../types/joinTable';
12
import { Opts } from '../types/opts';
13
import { Relationship } from '../types/relationship';
6✔
14
import { FormBackendErrorCode } from '../errors/FormBackendErrorCode';
6✔
15

16
class FormConfigProcessor {
17

18
  #formConfig?: FormConfigInternal;
19

20
  #pgClient: PostgrestClient<any, any, any>;
21
  #pgUrl: string;
22
  #postgrestToken: string;
23
  #logger: Logger;
24

25
  constructor(
26
    opts: Opts,
27
    pgClient: PostgrestClient<any, any, any>,
28
    postgrestToken: string
29
  ) {
30
    this.#pgUrl = opts.POSTGREST_URL;
41✔
31
    this.#pgClient = pgClient;
41✔
32
    this.#postgrestToken = postgrestToken;
41✔
33
    this.#logger = setupLogger({ label: 'formConfigProcessor' });
41✔
34
  }
35

36
  /**
37
   * Create a new FormConfigProcessor instance with already
38
   * resolved formConfig.
39
   * In comparison to the direct instantiation of FormConfigProcessor,
40
   * this method resolves the formConfig and sets it on the processor.
41
   *
42
   * @param opts The opts.
43
   * @param formConfig The form config.
44
   * @param pgClient The pgClient.
45
   * @param postgrestToken The postgrest token.
46
   * @param order The order to use for the data source (optional).
47
   * @param orderBy The orderBy to use for the data source (optional).
48
   * @returns FormConfigProcessor instance with resolved formConfig.
49
   */
50
  static async createFormConfigProcessor(
51
    opts: Opts,
52
    formConfig: FormConfig,
53
    pgClient: PostgrestClient<any, any, any>,
54
    postgrestToken: string,
55
    order?: FormConfig['dataSource']['order'],
56
    orderBy?: FormConfig['dataSource']['orderBy'],
57
  ) {
58
    const formConfigProcessor = new FormConfigProcessor(opts, pgClient, postgrestToken);
1✔
59
    const formConfigInternal =
60
      await formConfigProcessor.processConfig(formConfig, order, orderBy) as FormConfigInternal;
1✔
61
    formConfigProcessor.setFormConfig(formConfigInternal);
1✔
62
    return formConfigProcessor;
1✔
63
  }
64

65
  /**
66
   * Check if user is allowed to read form.
67
   * Read form is allowed when one of following conditions hold true:
68
   * - user has write permission on form
69
   * - form is set to be readable by all (aka read === true)
70
   * - user has role that is included in the access.read
71
   * @param formConfig The form config to check access for.
72
   * @param userRoles List of roles that user has
73
   * @returns True, if user has permission to read form, false otherwise.
74
   */
75
  static allowsReadForm(formConfig: FormConfig, userRoles: string[]) {
76
    if (!formConfig) {
7!
77
      return false;
×
78
    }
79

80
    const read = formConfig.access.read;
7✔
81
    const hasWrite = FormConfigProcessor.allowsWriteForm(formConfig, userRoles);
7✔
82
    if (hasWrite || read === true) {
7✔
83
      return true;
2✔
84
    }
85
    if (Array.isArray(read)) {
5!
86
      const hasRead = userRoles.some(role => read.includes(role));
5✔
87
      if (hasRead) {
5✔
88
        return true;
2✔
89
      }
90
    }
91
    return false;
3✔
92
  }
93

94
  /**
95
   * Check if user is allowed to write form.
96
   * Write form is allowed when one of following conditions hold true:
97
   * - form is set to be writable by all (aka write === true)
98
   * - user has role that is included in the access.write
99
   * @param formConfig The form config to check access for.
100
   * @param userRoles List of roles that user has
101
   * @returns True, if user has permission to write form, false otherwise.
102
   */
103
  static allowsWriteForm(formConfig: FormConfig | FormConfigInternal, userRoles: string[]) {
104
    if (!formConfig) {
29!
105
      return false;
×
106
    }
107

108
    const write = formConfig.access.write;
29✔
109
    if (write === true) {
29✔
110
      return true;
12✔
111
    }
112
    if (Array.isArray(write)) {
17✔
113
      const hasWrite = userRoles.some(role => write.includes(role));
15✔
114
      if (hasWrite) {
15✔
115
        return true;
2✔
116
      }
117
    }
118
    return false;
15✔
119
  }
120

121
  static allowsTableView(formConfig: FormConfig) {
122
    if (!formConfig) {
4!
123
      return false;
×
124
    }
125

126
    return formConfig.views.table ?? false;
4!
127
  }
128

129
  static allowsItemView(formConfig: FormConfig) {
130
    if (!formConfig) {
4!
131
      return false;
×
132
    }
133

134
    return formConfig.views.item ?? false;
4!
135
  }
136

137

138
  static getPropsWithJoinTables(
139
    formConfig: FormConfigInternal | JoinTable,
140
    relationship?: JoinTable['relationship']
141
  ) {
142
    const propsWithJoinTables = formConfig.includedProperties
110✔
143
      .filter((prop: string) => formConfig.properties[prop].resolveTable);
202✔
144

145
    if (relationship) {
110!
146
      return propsWithJoinTables
110✔
147
        .filter((prop: string) => formConfig.dataSource.joinTables?.[prop]?.relationship === relationship);
56✔
148
    }
149

150
    return propsWithJoinTables;
×
151
  }
152

153
  setFormConfig(formConfig: FormConfigInternal) {
154
    this.#formConfig = formConfig;
17✔
155
  }
156

157
  getFormConfig() {
158
    return this.#formConfig;
×
159
  }
160

161
  /**
162
   * Process the form config to a form config internal.
163
   * @param formConfig The form config to process.
164
   * @param order
165
   * @param orderBy
166
   * @returns The form config internal.
167
   */
168
  async processConfig(
169
    formConfig: FormConfig | JoinTable,
170
    order?: FormConfig['dataSource']['order'],
171
    orderBy?: FormConfig['dataSource']['orderBy']
172
  ) {
173
    const schema = formConfig.dataSource?.schema || this.#pgClient.schemaName!;
36✔
174
    const tableName = formConfig.dataSource?.tableName;
36✔
175
    const tableDefinition = await this.#getTableDefinition(schema, tableName, formConfig);
36✔
176
    const properties = this.#mergeDefinitionAndProps(tableDefinition, formConfig.properties ?? {});
36✔
177
    this.#addRequiredFileProperties(properties);
36✔
178
    const includedProperties = this.#sanitizeIncludedProperties(formConfig.includedProperties, properties);
36✔
179
    let includedPropertiesTable = this.#sanitizeIncludedProperties(formConfig.includedPropertiesTable, properties);
36✔
180
    if (!formConfig.includedPropertiesTable || !formConfig.includedPropertiesTable.length) {
36!
181
      includedPropertiesTable = includedProperties;
36✔
182
    }
183
    const sanitizedOrder = this.#sanitizeOrder(order, formConfig.dataSource.order);
36✔
184
    const sanitizedOrderBy = this.#sanitizeOrderBy(
36✔
185
      orderBy, formConfig.dataSource.orderBy, formConfig.dataSource.idColumn, properties
186
    );
187
    const dataSource: FormConfigInternal['dataSource'] = {
36✔
188
      ...formConfig.dataSource,
189
      order: sanitizedOrder,
190
      orderBy: sanitizedOrderBy
191
    };
192

193
    let config: FormConfigInternal | JoinTable = {
36✔
194
      ...formConfig,
195
      dataSource,
196
      includedProperties,
197
      includedPropertiesTable,
198
      properties
199
    } as FormConfigInternal | JoinTable;
200

201
    if (isFormConfig(formConfig)) {
36!
202
      config.views = {
×
203
        item: formConfig.views.item ?? false,
×
204
        table: formConfig.views.table ?? false,
×
205
        pageSize: formConfig.views.pageSize ?? 10
×
206
      };
207
    }
208

209
    config.properties = await this.#resolveEnums(properties, config);
36✔
210

211
    config = await this.#processJoinTables(formConfig, config);
36✔
212
    config = await this.#processLookupTables(formConfig, config);
36✔
213

214
    return config;
36✔
215
  }
216

217
  /**
218
   * Post process the form config for an item.
219
   * @param userRoles The roles of the current user.
220
   * @returns The post processed form config.
221
   */
222
  postProcessItemConfig(userRoles: string[]) {
223
    if (!this.#formConfig) {
8!
224
      return;
×
225
    }
226

227
    const formConfig = this.#formConfig;
8✔
228
    const postProcessedConfig = this.#postProcessConfig(userRoles);
8✔
229
    if (!postProcessedConfig) {
8!
230
      return;
×
231
    }
232

233
    const filteredProperties = this.#filterByIncludedProperties(formConfig, formConfig.includedProperties);
8✔
234
    postProcessedConfig.properties = this.#postProcessProperties(filteredProperties);
8✔
235
    return postProcessedConfig;
8✔
236
  }
237

238
  /**
239
   * Post process the form config for an item.
240
   * @param userRoles The roles of the current user.
241
   * @returns The post processed form config.
242
   */
243
  postProcessTableConfig(userRoles: string[]) {
244
    if (!this.#formConfig) {
8!
245
      return;
×
246
    }
247

248
    const formConfig = this.#formConfig;
8✔
249
    const postProcessedConfig = this.#postProcessConfig(userRoles);
8✔
250
    if (!postProcessedConfig) {
8!
251
      return;
×
252
    }
253

254
    const filteredProperties = this.#filterByIncludedProperties(formConfig, formConfig.includedPropertiesTable);
8✔
255
    postProcessedConfig.properties = this.#postProcessProperties(filteredProperties);
8✔
256

257
    return postProcessedConfig;
8✔
258
  }
259

260
  /**
261
   * Process the form config internal to a form config public.
262
   * @param userRoles The user roles of the current user.
263
   * @returns The form config public.
264
   */
265
  #postProcessConfig(userRoles: string[]): FormConfigPublic | undefined {
266
    if (!this.#formConfig) {
16!
267
      return;
×
268
    }
269

270
    return {
16✔
271
      type: 'object',
272
      format: this.#formConfig.format,
273
      title: this.#formConfig.title,
274
      description: this.#formConfig.description,
275
      idColumn: this.#formConfig.dataSource.idColumn,
276
      editable: FormConfigProcessor.allowsWriteForm(this.#formConfig, userRoles),
277
      views: this.#formConfig.views,
278
      order: this.#formConfig.dataSource.order,
279
      orderBy: this.#formConfig.dataSource.orderBy
280
    };
281
  }
282

283
  /**
284
   * Remove keys of properties that should not be included in the public form config.
285
   * @param properties The properties to process.
286
   * @returns The post processed properties.
287
   */
288
  #postProcessProperties(properties: FormConfigInternal['properties']) {
289
    const propsToExclude = ['resolveTable', 'resolveAsEnum', 'resolveToColumn', 'resolveLookup'];
20✔
290
    return Object.keys(properties)
20✔
291
      .reduce((acc, key) => {
292
        const property = properties[key];
12✔
293
        const processedProperty: FormConfigPublic['properties'] = Object.keys(property)
12✔
294
          .reduce((acc2, prop) => {
295
            if (propsToExclude.includes(prop)) {
14✔
296
              return {
8✔
297
                ...acc2
298
              };
299
            }
300
            return {
6✔
301
              ...acc2,
302
              [prop]: property[prop]
303
            };
304
          }, {});
305

306
        if (Object.hasOwn(processedProperty, 'items') && Object.hasOwn(processedProperty.items, 'properties')) {
12!
307
          processedProperty.items.properties = this.#postProcessProperties(property.items.properties);
×
308
        }
309

310
        if (Object.hasOwn(processedProperty, 'properties')) {
12✔
311
          processedProperty.properties = this.#postProcessProperties(property.properties);
4✔
312
        }
313

314
        return {
12✔
315
          ...acc,
316
          [key]: processedProperty
317
        };
318
      }, {});
319
  }
320

321
  #addRequiredFileProperties(properties: FormConfigInternal['properties']) {
322
    for (const key in properties) {
36✔
323
      if (Object.hasOwn(properties, key)) {
61!
324
        const property = properties[key];
61✔
325
        if (property.type !== 'string' || !property.media || property.media.binaryEncoding !== 'base64') {
61!
326
          continue;
61✔
327
        }
328
        if (!property.options) {
×
329
          property.options = {};
×
330
        }
331
        if (!property.options.max_upload_size) {
×
332
          // eslint-disable-next-line camelcase
333
          property.options.max_upload_size = 0;
×
334
        }
335
      }
336
    }
337
  }
338

339
  /**
340
   * Filter properties by included properties recursively.
341
   * @param formConfig The form config.
342
   * @param includedProperties The included properties.
343
   * @returns The filtered properties.
344
   */
345
  #filterByIncludedProperties(
346
    formConfig: FormConfigInternal | JoinTable,
347
    includedProperties: FormConfigInternal['includedProperties'] | FormConfigInternal['includedPropertiesTable'] = []
8✔
348
  ) {
349
    const properties = formConfig.properties;
18✔
350
    return Object.keys(properties)
18✔
351
      .filter(key => includedProperties.includes(key))
18✔
352
      .reduce((obj, key) => {
353
        const filteredProperty: FormConfigInternal['properties'] = {
10✔
354
          ...obj,
355
          [key]: properties[key]
356
        };
357

358
        if (properties[key].resolveTable) {
10✔
359
          const joinTable = formConfig.dataSource.joinTables?.[key];
2✔
360
          if (!joinTable) {
2!
NEW
361
            throw new DatabaseError(`Join table ${key} not found in joinTables`, {
×
362
              errorCode: FormBackendErrorCode.JOIN_TABLE_NOT_FOUND
363
            });
364
          }
365
          const joinTableIncludedProperties = joinTable.includedProperties;
2✔
366
          const filteredJoinTableProperties = this.#filterByIncludedProperties(joinTable, joinTableIncludedProperties);
2✔
367

368
          switch (joinTable.relationship) {
2!
369
            case Relationship.MANY_TO_MANY:
370
            case Relationship.ONE_TO_MANY:
371
              filteredProperty[key].items = {
×
372
                properties: filteredJoinTableProperties
373
              };
374
              break;
×
375
            case Relationship.MANY_TO_ONE:
376
            case Relationship.ONE_TO_ONE:
377
              filteredProperty[key].properties = filteredJoinTableProperties;
2✔
378
              break;
2✔
379
            default:
380
              break;
×
381
          }
382
        }
383
        return filteredProperty;
10✔
384
      }, {});
385
  }
386

387
  #sanitizeIncludedProperties(
388
    includedProperties: FormConfig['includedProperties'],
389
    properties: FormConfigInternal['properties']
390
  ) {
391
    const resolvedIncludedProperties =
392
      includedProperties && includedProperties.length ? includedProperties : Object.keys(properties);
72✔
393
    const sanitizedIncludedProperties = resolvedIncludedProperties.filter(prop => Object.hasOwn(properties, prop));
122✔
394
    return sanitizedIncludedProperties as FormConfigInternal['includedProperties'];
72✔
395
  }
396

397
  #sanitizeOrder(
398
    userDefined: string | undefined,
399
    configured: FormConfig['dataSource']['order']
400
  ) {
401
    if (userDefined === 'asc' || userDefined === 'desc') {
36✔
402
      return userDefined;
1✔
403
    }
404
    if (configured === 'asc' || configured === 'desc') {
35✔
405
      return configured;
1✔
406
    }
407
    return 'asc';
34✔
408
  }
409

410
  #sanitizeOrderBy(
411
    userDefined: string | undefined,
412
    configured: FormConfig['dataSource']['orderBy'],
413
    fallback: FormConfig['dataSource']['idColumn'],
414
    properties: FormConfigInternal['properties']
415
  ) {
416
    if (userDefined && Object.hasOwn(properties, userDefined)) {
36✔
417
      return userDefined;
1✔
418
    }
419
    if (configured && Object.hasOwn(properties, configured)) {
35✔
420
      return configured;
1✔
421
    }
422
    return fallback;
34✔
423
  }
424

425
  async #getTableDefinition(schema: string, tableName: string, formConfig: FormConfig | JoinTable) {
426
    // TODO also get table definition of join tables
427
    if (!tableName) {
36!
428
      return;
×
429
    }
430
    const response = await fetch(this.#pgUrl, {
36✔
431
      headers: {
432
        Authorization: `Bearer ${this.#postgrestToken}`,
433
        'Accept-Profile': schema
434
      }
435
    });
436
    if (!response.ok) {
36!
437
      this.#logger.error(response.statusText);
×
438
      return;
×
439
    }
440
    const swaggerDoc = await response.json();
36✔
441
    const tableDefinition = swaggerDoc.definitions?.[tableName];
36✔
442

443
    if (!tableDefinition) {
36!
444
      this.#logger.error(`Table definition for table ${tableName} not found in swagger doc`);
×
445
      return;
×
446
    }
447

448
    if (formConfig.includedProperties) {
36✔
449
      // filter out only the properties that are included in the form config
450
      const includedProperties = formConfig.includedProperties;
4✔
451
      tableDefinition.properties = Object.keys(tableDefinition.properties)
4✔
452
        .filter(key => includedProperties.includes(key))
10✔
453
        .reduce((obj, key) => {
454
          return {
4✔
455
            ...obj,
456
            [key]: tableDefinition.properties[key]
457
          };
458
        }, {});
459
    }
460

461
    return tableDefinition;
36✔
462
  }
463

464
  async #resolveEnums(properties: FormConfigInternal['properties'], formConfig: FormConfigInternal | JoinTable) {
465
    const propertiesClone = structuredClone(properties);
36✔
466
    // TODO resolve enums after data retrieval, to also be able to include itemIds in the enum source where needed
467
    for (const key in propertiesClone) {
36✔
468
      if (Object.hasOwn(propertiesClone, key)) {
61!
469
        const property = propertiesClone[key];
61✔
470
        if (!property.resolveAsEnum) {
61✔
471
          continue;
59✔
472
        }
473
        // TODO if order by column matches key, order by display value
474
        const enums = await this.#requestEnumData(key, propertiesClone[key], formConfig);
2✔
475
        if (enums) {
2!
476
          property.enumSource = [{
2✔
477
            source: enums.map(e => {
478
              return {
2✔
479
                value: e[key as any],
480
                title: e[property.resolveToColumn]
481
              };
482
            }),
483
            title: '{{item.title}}',
484
            value: '{{item.value}}'
485
          }];
486
        }
487
      }
488
    }
489

490
    return propertiesClone;
36✔
491
  }
492

493
  async #requestEnumData(
494
    propertyName: string,
495
    props: FormConfigInternal['properties'][keyof FormConfigInternal['properties']],
496
    formConfig: FormConfigInternal | JoinTable
497
  ) {
498
    const schema = formConfig.dataSource.schema;
2✔
499
    const tableName = formConfig.dataSource.tableName;
2✔
500

501
    const columns = [propertyName, props.resolveToColumn];
2✔
502
    const selectStatement = columns.toString();
2✔
503

504
    // TODO add sorting and limit opts
505
    const data = await this.#pgClient
2✔
506
      .schema(schema || this.#pgClient.schemaName!)
4✔
507
      .from(tableName)
508
      .select(selectStatement);
509

510
    if (data.error || data.status !== 200) {
2!
511
      this.#logger.error(data.error);
×
512
      return;
×
513
    }
514

515
    return data.data;
2✔
516
  }
517

518
  #mergeDefinitionAndProps(
519
    // TODO add proper type as soon as we have postgrest model definitions
520
    tableDefinition: Record<string, any>,
521
    properties: FormConfigInternal['properties']
522
  ) {
523
    return merge(tableDefinition.properties, properties);
36✔
524
  }
525

526
  async #processJoinTables(formConfig: FormConfig | JoinTable, conf: FormConfigInternal | JoinTable) {
527
    const config = structuredClone(conf);
36✔
528

529
    const joinTables = formConfig.dataSource.joinTables ?? {};
36✔
530
    for (const key in joinTables) {
36✔
531
      if (Object.hasOwn(joinTables, key)) {
9!
532
        const joinTable = joinTables[key];
9✔
533
        const joinTableConfig = await this.processConfig(joinTable);
9✔
534
        if (!config.dataSource.joinTables) {
9!
535
          config.dataSource.joinTables = {};
×
536
        }
537
        config.dataSource.joinTables[key] = joinTableConfig;
9✔
538
      }
539
    }
540

541
    for (const prop in formConfig.properties) {
36✔
542
      if (!formConfig.properties[prop].resolveTable) {
17✔
543
        continue;
12✔
544
      }
545
      const joinTable = config.dataSource.joinTables?.[prop];
5✔
546
      if (!joinTable) {
5!
NEW
547
        throw new DatabaseError(`Join table ${prop} not found in joinTables`, {
×
548
          errorCode: FormBackendErrorCode.JOIN_TABLE_PROP_NOT_FOUND
549
        });
550
      }
551
      switch (joinTable.relationship) {
5!
552
        case Relationship.MANY_TO_MANY:
553
        case Relationship.ONE_TO_MANY:
554
          config.properties[prop].type = 'array';
2✔
555
          config.properties[prop].items = {
2✔
556
            ...formConfig.properties[prop].items,
557
            type: 'object',
558
            properties: joinTable.properties
559
          };
560
          break;
2✔
561
        case Relationship.MANY_TO_ONE:
562
        case Relationship.ONE_TO_ONE:
563
          config.properties[prop].type = 'object';
3✔
564
          config.properties[prop].properties = {
3✔
565
            ...formConfig.properties[prop].properties,
566
            ...joinTable.properties
567
          };
568
          break;
3✔
569
        default:
NEW
570
          throw new DatabaseError('Relationship not supported', {
×
571
            errorCode: FormBackendErrorCode.JOIN_TABLE_RELATIONSHIP_NOT_SUPPORTED
572
          });
573
      }
574
    }
575

576
    return config;
36✔
577
  }
578

579
  async #processLookupTables(formConfig: FormConfig | JoinTable, conf: FormConfigInternal | JoinTable) {
580
    const config = structuredClone(conf);
36✔
581

582
    const lookupTables = formConfig.dataSource.lookupTables ?? {};
36✔
583
    for (const key in lookupTables) {
36✔
584
      if (Object.hasOwn(lookupTables, key)) {
3!
585
        const lookupTable = lookupTables[key];
3✔
586
        const lookupTableConfig = await this.processConfig(lookupTable);
3✔
587
        if (!config.dataSource.lookupTables) {
3!
588
          config.dataSource.lookupTables = {};
×
589
        }
590
        config.dataSource.lookupTables[key] = lookupTableConfig;
3✔
591
      }
592
    }
593

594
    for (const prop in formConfig.properties) {
36✔
595
      if (!formConfig.properties[prop].resolveLookup) {
17✔
596
        continue;
16✔
597
      }
598
      const lookupTable = config.dataSource.lookupTables?.[prop];
1✔
599
      if (!lookupTable) {
1!
NEW
600
        throw new DatabaseError(`Lookup table ${prop} not found in lookupTables`, {
×
601
          errorCode: FormBackendErrorCode.LOOKUP_TABLE_PROPERTY_NOT_FOUND
602
        });
603
      }
604
      config.properties[prop] = {
1✔
605
        ...config.properties[prop],
606
        ...lookupTable.properties[lookupTable.includedProperties[0]]
607
      };
608
    }
609

610
    return config;
36✔
611
  }
612
}
613

614
export default FormConfigProcessor;
6✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc