@appsemble/node-utils 0.36.10 → 0.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
- # ![](https://gitlab.com/appsemble/appsemble/-/raw/0.36.10/config/assets/logo.svg) Appsemble Node Utilities
1
+ # ![](https://gitlab.com/appsemble/appsemble/-/raw/0.37.1/config/assets/logo.svg) Appsemble Node Utilities
2
2
 
3
3
  > NodeJS utilities used by Appsemble internally.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@appsemble/node-utils)](https://www.npmjs.com/package/@appsemble/node-utils)
6
- [![GitLab CI](https://gitlab.com/appsemble/appsemble/badges/0.36.10/pipeline.svg)](https://gitlab.com/appsemble/appsemble/-/releases/0.36.10)
6
+ [![GitLab CI](https://gitlab.com/appsemble/appsemble/badges/0.37.1/pipeline.svg)](https://gitlab.com/appsemble/appsemble/-/releases/0.37.1)
7
7
  [![Prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://prettier.io)
8
8
 
9
9
  ## Table of Contents
@@ -26,5 +26,5 @@ compatibility is not guaranteed.
26
26
 
27
27
  ## License
28
28
 
29
- [LGPL-3.0-only](https://gitlab.com/appsemble/appsemble/-/blob/0.36.10/LICENSE.md) ©
29
+ [LGPL-3.0-only](https://gitlab.com/appsemble/appsemble/-/blob/0.37.1/LICENSE.md) ©
30
30
  [Appsemble](https://appsemble.com)
package/index.d.ts CHANGED
@@ -21,6 +21,7 @@ export * from './render.js';
21
21
  export * from './getAppsembleMessages.js';
22
22
  export * from './odata.js';
23
23
  export * from './resource.js';
24
+ export * from './resourceEtag.js';
24
25
  export * from './app.js';
25
26
  export * from './uploadValidation.js';
26
27
  export * from './mergeMessages.js';
@@ -48,3 +49,4 @@ export * from './createUser.js';
48
49
  export * from './deleteUser.js';
49
50
  export * from './updateSubscription.js';
50
51
  export * from './server/utils/ssrf.js';
52
+ export { appWideGroupId, getSingleGroupId } from './server/utils/resources.js';
package/index.js CHANGED
@@ -21,6 +21,7 @@ export * from './render.js';
21
21
  export * from './getAppsembleMessages.js';
22
22
  export * from './odata.js';
23
23
  export * from './resource.js';
24
+ export * from './resourceEtag.js';
24
25
  export * from './app.js';
25
26
  export * from './uploadValidation.js';
26
27
  export * from './mergeMessages.js';
@@ -48,4 +49,5 @@ export * from './createUser.js';
48
49
  export * from './deleteUser.js';
49
50
  export * from './updateSubscription.js';
50
51
  export * from './server/utils/ssrf.js';
52
+ export { appWideGroupId, getSingleGroupId } from './server/utils/resources.js';
51
53
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appsemble/node-utils",
3
- "version": "0.36.10",
3
+ "version": "0.37.1",
4
4
  "description": "NodeJS utilities used by Appsemble internally.",
5
5
  "keywords": [
6
6
  "app",
@@ -40,9 +40,9 @@
40
40
  "test": "vitest"
41
41
  },
42
42
  "dependencies": {
43
- "@appsemble/lang-sdk": "0.36.10",
44
- "@appsemble/types": "0.36.10",
45
- "@appsemble/utils": "0.36.10",
43
+ "@appsemble/lang-sdk": "0.37.1",
44
+ "@appsemble/types": "0.37.1",
45
+ "@appsemble/utils": "0.37.1",
46
46
  "@formatjs/fast-memoize": "^2.0.0",
47
47
  "@fortawesome/fontawesome-free": "^6.0.0",
48
48
  "@inquirer/prompts": "^8.0.0",
package/resource.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { type AppDefinition, type ResourceDefinition } from '@appsemble/lang-sdk';
2
- import { type Resource as ResourceType } from '@appsemble/types';
2
+ import { type App, type Resource as ResourceType } from '@appsemble/types';
3
3
  import { type PreValidatePropertyFunction } from 'jsonschema';
4
4
  import { type Context, type DefaultContext, type DefaultState, type ParameterizedContext } from 'koa';
5
5
  import { type JsonValue } from 'type-fest';
6
- import { type PreparedAsset, TempFile } from './index.js';
7
- export declare function stripResource({ $author, $created, $editor, $ephemeral, $group, $seed, $updated, ...data }: ResourceType): Record<string, unknown>;
6
+ import { type GetAppResourcesParams, type PreparedAsset, TempFile } from './index.js';
7
+ export declare function stripResource({ $author, $created, $editor, $ephemeral, $etag, $group, $seed, $updated, ...data }: ResourceType): Record<string, unknown>;
8
8
  /**
9
9
  * Works on a resource, which has been processed on the server by the streamParser
10
10
  *
@@ -17,6 +17,11 @@ export declare function serializeServerResource(data: any): JsonValue | {
17
17
  assets: TempFile[];
18
18
  };
19
19
  export type SerializedServerResourceBody = ReturnType<typeof serializeServerResource>;
20
+ export type SerializedMultipartBody = Extract<SerializedServerResourceBody, {
21
+ resource: JsonValue;
22
+ assets: TempFile[];
23
+ }>;
24
+ export declare function isSerializedMultipartBody(value: SerializedServerResourceBody): value is SerializedMultipartBody;
20
25
  /**
21
26
  * Get the resource definition of an app by name.
22
27
  *
@@ -68,3 +73,19 @@ export declare function processResourceBody(ctx: Context | ParameterizedContext<
68
73
  id: string;
69
74
  name?: string;
70
75
  }[], isPatch?: boolean, resourceBody?: SerializedServerResourceBody): Promise<[Record<string, unknown> | Record<string, unknown>[], PreparedAsset[], string[]]>;
76
+ /**
77
+ * Validate that the references declared in the resource definition point at existing resources.
78
+ *
79
+ * Reference properties which hold no value are skipped. Expired resources are treated as
80
+ * non-existent. The referenced ids are collected per referenced resource type, so each type is
81
+ * checked using a single lookup, even when multiple resources are submitted at once.
82
+ *
83
+ * If a reference is broken, an HTTP 400 error naming the offending property path is thrown.
84
+ *
85
+ * @param ctx The Koa context used to throw back the error response.
86
+ * @param app The app the resources belong to.
87
+ * @param definition The definition of the resource type being created or updated.
88
+ * @param processedBody One or more resources as processed by `processResourceBody`.
89
+ * @param getAppResources The function used to look up resources of the referenced types.
90
+ */
91
+ export declare function validateResourceReferences(ctx: Context, app: App, definition: ResourceDefinition, processedBody: Record<string, unknown> | Record<string, unknown>[], getAppResources: (params: GetAppResourcesParams) => Promise<ResourceType[]>): Promise<void>;
package/resource.js CHANGED
@@ -5,10 +5,10 @@ import { ValidationError, Validator } from 'jsonschema';
5
5
  import { partition } from 'lodash-es';
6
6
  import parseDuration from 'parse-duration';
7
7
  import { preProcessCSV } from './csv.js';
8
- import { handleValidatorResult, TempFile } from './index.js';
8
+ import { handleValidatorResult, TempFile, } from './index.js';
9
9
  import { throwKoaError } from './koa.js';
10
10
  import { AssetUploadValidationError, validateUploadedFile } from './uploadValidation.js';
11
- export function stripResource({ $author, $created, $editor, $ephemeral, $group, $seed, $updated, ...data }) {
11
+ export function stripResource({ $author, $created, $editor, $ephemeral, $etag, $group, $seed, $updated, ...data }) {
12
12
  return data;
13
13
  }
14
14
  /**
@@ -44,7 +44,7 @@ export function serializeServerResource(data) {
44
44
  assets,
45
45
  };
46
46
  }
47
- function isSerializedMultipartBody(value) {
47
+ export function isSerializedMultipartBody(value) {
48
48
  return (value != null &&
49
49
  typeof value === 'object' &&
50
50
  !Array.isArray(value) &&
@@ -232,9 +232,10 @@ export async function processResourceBody(ctx, definition, knownAssetIds = [], k
232
232
  }
233
233
  return num >= 0 && num < assets.length;
234
234
  };
235
+ const isPatchRequest = ctx.request?.method === 'PATCH' || isPatch;
235
236
  const patchedSchema = {
236
237
  ...definition.schema,
237
- required: ctx.request?.method === 'PATCH' || isPatch ? [] : definition.schema.required,
238
+ required: isPatchRequest ? [] : definition.schema.required,
238
239
  properties: {
239
240
  ...definition.schema.properties,
240
241
  id: { type: 'integer' },
@@ -266,7 +267,8 @@ export async function processResourceBody(ctx, definition, knownAssetIds = [], k
266
267
  base: '#',
267
268
  preValidateProperty,
268
269
  nestedErrors: true,
269
- rewrite(value, { format, oneOf }, options, { path }) {
270
+ rewrite(value, propertySchema, options, { path }) {
271
+ const { format, oneOf } = propertySchema;
270
272
  let propertyName;
271
273
  if (Array.isArray(resource) && path.length === 2 && typeof path[0] === 'number') {
272
274
  propertyName = path[1];
@@ -302,6 +304,14 @@ export async function processResourceBody(ctx, definition, knownAssetIds = [], k
302
304
  if (propertyName === '$clonable') {
303
305
  return definition.clonable;
304
306
  }
307
+ // Materialize schema property defaults for missing values. Patch requests skip this,
308
+ // because a missing property there means the stored value is kept.
309
+ const defaultValue = propertySchema.default;
310
+ if (!isPatchRequest && defaultValue !== undefined) {
311
+ return typeof defaultValue === 'object' && defaultValue != null
312
+ ? structuredClone(defaultValue)
313
+ : defaultValue;
314
+ }
305
315
  return;
306
316
  }
307
317
  if (format !== 'binary' && !oneOf?.some((s) => s.format === 'binary')) {
@@ -334,4 +344,82 @@ export async function processResourceBody(ctx, definition, knownAssetIds = [], k
334
344
  handleValidatorResult(ctx, result, 'Resource validation failed');
335
345
  return [resource, preparedAssets, knownAssetIds.filter((id) => !reusedAssets.has(id))];
336
346
  }
347
+ /**
348
+ * Validate that the references declared in the resource definition point at existing resources.
349
+ *
350
+ * Reference properties which hold no value are skipped. Expired resources are treated as
351
+ * non-existent. The referenced ids are collected per referenced resource type, so each type is
352
+ * checked using a single lookup, even when multiple resources are submitted at once.
353
+ *
354
+ * If a reference is broken, an HTTP 400 error naming the offending property path is thrown.
355
+ *
356
+ * @param ctx The Koa context used to throw back the error response.
357
+ * @param app The app the resources belong to.
358
+ * @param definition The definition of the resource type being created or updated.
359
+ * @param processedBody One or more resources as processed by `processResourceBody`.
360
+ * @param getAppResources The function used to look up resources of the referenced types.
361
+ */
362
+ export async function validateResourceReferences(ctx, app, definition, processedBody, getAppResources) {
363
+ const references = Object.entries(definition.references ?? {});
364
+ if (!references.length) {
365
+ return;
366
+ }
367
+ const resources = Array.isArray(processedBody) ? processedBody : [processedBody];
368
+ const referencedIds = new Map();
369
+ for (const [propertyName, reference] of references) {
370
+ for (const resource of resources) {
371
+ for (const value of [resource[propertyName]].flat()) {
372
+ if (value == null || value === '') {
373
+ continue;
374
+ }
375
+ const id = typeof value === 'string' ? Number(value) : value;
376
+ if (typeof id === 'number' && Number.isInteger(id)) {
377
+ if (!referencedIds.has(reference.resource)) {
378
+ referencedIds.set(reference.resource, new Set());
379
+ }
380
+ referencedIds.get(reference.resource).add(id);
381
+ }
382
+ }
383
+ }
384
+ }
385
+ const existingIds = new Map();
386
+ await Promise.all([...referencedIds].map(async ([type, ids]) => {
387
+ const existing = await getAppResources({
388
+ app,
389
+ context: ctx,
390
+ type,
391
+ findOptions: {
392
+ where: {
393
+ and: [
394
+ { type },
395
+ { or: [...ids].map((id) => ({ id })) },
396
+ { expires: { or: [{ gt: new Date() }, null] } },
397
+ ],
398
+ },
399
+ },
400
+ });
401
+ existingIds.set(type, new Set(existing.map((resource) => Number(resource.id))));
402
+ }));
403
+ const errors = [];
404
+ for (const [propertyName, reference] of references) {
405
+ for (const [index, resource] of resources.entries()) {
406
+ for (const value of [resource[propertyName]].flat()) {
407
+ if (value == null || value === '') {
408
+ continue;
409
+ }
410
+ const id = typeof value === 'string' ? Number(value) : value;
411
+ if (typeof id === 'number' && existingIds.get(reference.resource)?.has(id)) {
412
+ continue;
413
+ }
414
+ errors.push(new ValidationError(`does not reference an existing resource of type ${reference.resource}`, value, undefined, Array.isArray(processedBody) ? [index, propertyName] : [propertyName]));
415
+ }
416
+ }
417
+ }
418
+ if (errors.length) {
419
+ if (ctx.response === undefined) {
420
+ throw new Error('Resource validation failed', { cause: errors });
421
+ }
422
+ throwKoaError(ctx, 400, 'Resource validation failed', { errors });
423
+ }
424
+ }
337
425
  //# sourceMappingURL=resource.js.map
@@ -0,0 +1,19 @@
1
+ import { type Context } from 'koa';
2
+ import { AppsembleError } from './AppsembleError.js';
3
+ export declare function createResourceEtag(resource: Record<string, unknown>): string;
4
+ export declare function addResourceEtag<T extends Record<string, unknown>>(resource: T): T & {
5
+ $etag: string;
6
+ };
7
+ export declare function matchesResourceIfMatch(ifMatch: string | string[] | undefined, currentEtag: string): boolean;
8
+ export declare function setResourceEtagHeader(ctx: Context, resource: Record<string, unknown> | null | undefined): void;
9
+ export declare class ResourcePreconditionFailedError extends AppsembleError {
10
+ readonly data: {
11
+ code: 'RESOURCE_PRECONDITION_FAILED';
12
+ resourceId: number | string;
13
+ resourceType: string;
14
+ };
15
+ readonly error = "Precondition Failed";
16
+ readonly statusCode = 412;
17
+ constructor(resourceType: string, resourceId: number | string);
18
+ }
19
+ export declare function throwResourcePreconditionFailedKoaError(ctx: Context, resourceType: string, resourceId: number | string): never;
@@ -0,0 +1,117 @@
1
+ import { createHash } from 'node:crypto';
2
+ import sortKeys from 'sort-keys';
3
+ import { AppsembleError } from './AppsembleError.js';
4
+ import { throwKoaError } from './koa.js';
5
+ // Server-managed metadata keys that are echoed in resource responses but are not
6
+ // part of the resource's identity for ETag purposes. Stripped only at the top
7
+ // level; nested user data may legitimately contain keys with the same names.
8
+ const ignoredEtagFields = new Set(['$author', '$editor', '$etag', '$group', '$seed', '$ephemeral']);
9
+ function canonicalize(value) {
10
+ if (value instanceof Date) {
11
+ return value.toJSON();
12
+ }
13
+ if (Array.isArray(value)) {
14
+ return value.map(canonicalize);
15
+ }
16
+ if (value && typeof value === 'object') {
17
+ return sortKeys(Object.fromEntries(Object.entries(value).map(([key, entry]) => [
18
+ key,
19
+ canonicalize(entry),
20
+ ])), { deep: true });
21
+ }
22
+ return value;
23
+ }
24
+ export function createResourceEtag(resource) {
25
+ const stripped = {};
26
+ for (const [key, value] of Object.entries(resource)) {
27
+ if (!ignoredEtagFields.has(key)) {
28
+ stripped[key] = value;
29
+ }
30
+ }
31
+ const canonicalResource = JSON.stringify(canonicalize(stripped));
32
+ const hash = createHash('sha256').update(canonicalResource, 'utf8').digest('base64url');
33
+ return `"${hash}"`;
34
+ }
35
+ export function addResourceEtag(resource) {
36
+ return {
37
+ ...resource,
38
+ $etag: createResourceEtag(resource),
39
+ };
40
+ }
41
+ // RFC 7232 §3.1: If-Match uses the strong-comparison function, so weak
42
+ // validators (prefixed with `W/`) must not match. Tokens are split with a
43
+ // quoted-string-aware parser instead of a naive `,` split.
44
+ function parseIfMatchValue(ifMatch) {
45
+ if (ifMatch === '*') {
46
+ return ['*'];
47
+ }
48
+ const tokens = [];
49
+ let i = 0;
50
+ while (i < ifMatch.length) {
51
+ while (i < ifMatch.length &&
52
+ (ifMatch[i] === ' ' || ifMatch[i] === ',' || ifMatch[i] === '\t')) {
53
+ i += 1;
54
+ }
55
+ if (i >= ifMatch.length) {
56
+ break;
57
+ }
58
+ // Skip any leading weak prefix; strong comparison rejects it.
59
+ let weak = false;
60
+ if (ifMatch[i] === 'W' && ifMatch[i + 1] === '/') {
61
+ weak = true;
62
+ i += 2;
63
+ }
64
+ if (ifMatch[i] !== '"') {
65
+ // Malformed token; skip until next comma.
66
+ while (i < ifMatch.length && ifMatch[i] !== ',') {
67
+ i += 1;
68
+ }
69
+ continue;
70
+ }
71
+ const start = i;
72
+ i += 1;
73
+ while (i < ifMatch.length && ifMatch[i] !== '"') {
74
+ i += 1;
75
+ }
76
+ if (i >= ifMatch.length) {
77
+ break;
78
+ }
79
+ i += 1;
80
+ if (!weak) {
81
+ tokens.push(ifMatch.slice(start, i));
82
+ }
83
+ }
84
+ return tokens;
85
+ }
86
+ export function matchesResourceIfMatch(ifMatch, currentEtag) {
87
+ if (!ifMatch) {
88
+ return true;
89
+ }
90
+ const values = new Set((Array.isArray(ifMatch) ? ifMatch : [ifMatch]).flatMap(parseIfMatchValue));
91
+ return values.has('*') || values.has(currentEtag);
92
+ }
93
+ export function setResourceEtagHeader(ctx, resource) {
94
+ if (!resource) {
95
+ return;
96
+ }
97
+ const etag = typeof resource.$etag === 'string' ? resource.$etag : createResourceEtag(resource);
98
+ ctx.set('ETag', etag);
99
+ }
100
+ export class ResourcePreconditionFailedError extends AppsembleError {
101
+ constructor(resourceType, resourceId) {
102
+ super('This resource has changed since it was loaded. Fetch the latest version and try again.');
103
+ this.error = 'Precondition Failed';
104
+ this.statusCode = 412;
105
+ this.name = 'ResourcePreconditionFailedError';
106
+ this.data = {
107
+ code: 'RESOURCE_PRECONDITION_FAILED',
108
+ resourceId,
109
+ resourceType,
110
+ };
111
+ }
112
+ }
113
+ export function throwResourcePreconditionFailedKoaError(ctx, resourceType, resourceId) {
114
+ const error = new ResourcePreconditionFailedError(resourceType, resourceId);
115
+ throwKoaError(ctx, error.statusCode, error.message, error.data);
116
+ }
117
+ //# sourceMappingURL=resourceEtag.js.map
@@ -1,20 +1,27 @@
1
- import { generateResourceQuery } from '../../../../utils/resources.js';
1
+ import { generateResourceQuery, getGroupIdWhere } from '../../../../utils/resources.js';
2
2
  export function createCountAppResourcesController(options) {
3
3
  return async (ctx) => {
4
4
  const { pathParams: { appId, resourceType }, queryParams: { $own, selectedGroupId }, user: authSubject, } = ctx;
5
- const { checkAppPermissions, getApp, getAppResources } = options;
5
+ const { checkAppPermissions, getAllowedGroups, getApp, getAppResources } = options;
6
6
  const app = await getApp({
7
7
  context: ctx,
8
8
  query: { where: { id: appId }, attributes: ['demoMode', 'id'] },
9
9
  });
10
- await checkAppPermissions({
10
+ const permissions = [
11
+ $own ? `$resource:${resourceType}:own:query` : `$resource:${resourceType}:query`,
12
+ ];
13
+ // Across multiple selected groups the permission acts as a filter: only the
14
+ // groups the subject may query are counted. When no selected group is
15
+ // queryable, the strict permission check throws its usual 403.
16
+ const allowedGroups = await getAllowedGroups({
11
17
  context: ctx,
12
- permissions: [
13
- $own ? `$resource:${resourceType}:own:query` : `$resource:${resourceType}:query`,
14
- ],
18
+ permissions,
15
19
  app,
16
20
  groupId: selectedGroupId,
17
21
  });
22
+ if (!allowedGroups.length) {
23
+ await checkAppPermissions({ context: ctx, permissions, app, groupId: selectedGroupId });
24
+ }
18
25
  const { where } = generateResourceQuery(ctx, options);
19
26
  const findOptions = {
20
27
  attributes: ['id'],
@@ -23,7 +30,7 @@ export function createCountAppResourcesController(options) {
23
30
  where || {},
24
31
  {
25
32
  type: resourceType,
26
- GroupId: selectedGroupId ?? null,
33
+ GroupId: getGroupIdWhere(allowedGroups.length ? allowedGroups : selectedGroupId),
27
34
  expires: { or: [{ gt: new Date() }, null] },
28
35
  ...(app.demoMode ? { seed: false, ephemeral: true } : {}),
29
36
  ...($own ? { AuthorId: authSubject?.id } : {}),
@@ -3,7 +3,7 @@ import { copyFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { isDeepStrictEqual } from 'node:util';
6
- import { getResourceDefinition, processResourceBody, } from '../../../../../index.js';
6
+ import { getResourceDefinition, getSingleGroupId, processResourceBody, setResourceEtagHeader, validateResourceReferences, } from '../../../../../index.js';
7
7
  function clonePreparedAssets(preparedAssets) {
8
8
  return Promise.all(preparedAssets.map(async (asset) => {
9
9
  const path = join(tmpdir(), `${Date.now()}-${randomUUID()}`);
@@ -26,7 +26,8 @@ function clonePreparedAssets(preparedAssets) {
26
26
  export function createCreateAppResourceController(options) {
27
27
  return async (ctx) => {
28
28
  const { pathParams: { appId, resourceType }, query, queryParams: { selectedGroupId }, } = ctx;
29
- const { checkAppPermissions, createAppResourcesWithAssets, getApp, getAppAssets } = options;
29
+ const groupId = getSingleGroupId(selectedGroupId);
30
+ const { checkAppPermissions, createAppResourcesWithAssets, getApp, getAppAssets, getAppResources, } = options;
30
31
  const app = await getApp({
31
32
  context: ctx,
32
33
  query: { attributes: ['id', 'demoMode', 'definition'], where: { id: appId } },
@@ -35,7 +36,8 @@ export function createCreateAppResourceController(options) {
35
36
  context: ctx,
36
37
  permissions: [`$resource:${resourceType}:create`],
37
38
  app,
38
- groupId: selectedGroupId,
39
+ // The resource is created in a single group; authorize against that group.
40
+ groupId,
39
41
  });
40
42
  const resourceDefinition = getResourceDefinition(app.definition, resourceType, ctx);
41
43
  const appAssets = await getAppAssets({ app, context: ctx });
@@ -44,6 +46,7 @@ export function createCreateAppResourceController(options) {
44
46
  ctx.body = [];
45
47
  return;
46
48
  }
49
+ await validateResourceReferences(ctx, app, resourceDefinition, processedBody, getAppResources);
47
50
  const resources = Array.isArray(processedBody) ? processedBody : [processedBody];
48
51
  if (!(ctx.client && 'app' in ctx.client) && query?.seed === 'true') {
49
52
  const preparedSeedAssets = app.demoMode
@@ -77,7 +80,7 @@ export function createCreateAppResourceController(options) {
77
80
  }
78
81
  const createdSeedResources = await createAppResourcesWithAssets({
79
82
  app,
80
- groupId: selectedGroupId,
83
+ groupId,
81
84
  context: ctx,
82
85
  resources: preparedSeedResources,
83
86
  preparedAssets: preparedSeedAssets,
@@ -86,12 +89,15 @@ export function createCreateAppResourceController(options) {
86
89
  });
87
90
  if (!app.demoMode) {
88
91
  ctx.body = Array.isArray(processedBody) ? createdSeedResources : createdSeedResources[0];
92
+ if (!Array.isArray(ctx.body)) {
93
+ setResourceEtagHeader(ctx, ctx.body);
94
+ }
89
95
  return;
90
96
  }
91
97
  }
92
98
  const createdResources = await createAppResourcesWithAssets({
93
99
  app,
94
- groupId: selectedGroupId,
100
+ groupId,
95
101
  context: ctx,
96
102
  resources: resources.map((resource) => ({
97
103
  ...resource,
@@ -104,6 +110,9 @@ export function createCreateAppResourceController(options) {
104
110
  options,
105
111
  });
106
112
  ctx.body = Array.isArray(processedBody) ? createdResources : createdResources[0];
113
+ if (!Array.isArray(ctx.body)) {
114
+ setResourceEtagHeader(ctx, ctx.body);
115
+ }
107
116
  };
108
117
  }
109
118
  //# sourceMappingURL=createCreateAppResourceController.js.map
@@ -1,4 +1,5 @@
1
1
  import { assertKoaCondition } from '@appsemble/node-utils';
2
+ import { appWideGroupId, getGroupIdWhere } from '../../../../utils/resources.js';
2
3
  export function createDeleteAppResourceController(options) {
3
4
  return async (ctx) => {
4
5
  const { pathParams: { appId, resourceId, resourceType }, queryParams: { selectedGroupId }, user: authSubject, } = ctx;
@@ -11,7 +12,7 @@ export function createDeleteAppResourceController(options) {
11
12
  where: {
12
13
  id: resourceId,
13
14
  type: resourceType,
14
- GroupId: selectedGroupId ?? null,
15
+ GroupId: getGroupIdWhere(selectedGroupId),
15
16
  expires: { or: [{ gt: new Date() }, { eq: null }] },
16
17
  ...(app.demoMode ? { seed: false, ephemeral: true } : {}),
17
18
  },
@@ -24,6 +25,8 @@ export function createDeleteAppResourceController(options) {
24
25
  findOptions,
25
26
  });
26
27
  assertKoaCondition(resource != null, ctx, 404, 'Resource not found');
28
+ // The resource is searched across the selected groups; authorization is
29
+ // then scoped to the group the resource actually belongs to.
27
30
  await checkAppPermissions({
28
31
  context: ctx,
29
32
  permissions: [
@@ -32,7 +35,7 @@ export function createDeleteAppResourceController(options) {
32
35
  : `$resource:${resourceType}:delete`,
33
36
  ],
34
37
  app,
35
- groupId: selectedGroupId,
38
+ groupId: resource.$group?.id ?? appWideGroupId,
36
39
  });
37
40
  await deleteAppResource({
38
41
  app,
@@ -40,6 +43,8 @@ export function createDeleteAppResourceController(options) {
40
43
  id: resourceId,
41
44
  type: resourceType,
42
45
  options,
46
+ lockWhere: findOptions.where,
47
+ ifMatch: ctx.get('If-Match') || undefined,
43
48
  });
44
49
  ctx.status = 204;
45
50
  };
@@ -1,5 +1,6 @@
1
1
  import { defaultLocale, remap } from '@appsemble/lang-sdk';
2
- import { assertKoaCondition, getRemapperContext, getResourceDefinition, } from '@appsemble/node-utils';
2
+ import { assertKoaCondition, getRemapperContext, getResourceDefinition, setResourceEtagHeader, } from '@appsemble/node-utils';
3
+ import { appWideGroupId, getGroupIdWhere } from '../../../../utils/resources.js';
3
4
  export function createGetAppResourceByIdController(options) {
4
5
  return async (ctx) => {
5
6
  const { pathParams: { appId, resourceId, resourceType }, queryParams: { selectedGroupId, view }, user: authSubject, } = ctx;
@@ -12,7 +13,7 @@ export function createGetAppResourceByIdController(options) {
12
13
  where: {
13
14
  id: resourceId,
14
15
  type: resourceType,
15
- GroupId: selectedGroupId ?? null,
16
+ GroupId: getGroupIdWhere(selectedGroupId),
16
17
  expires: { or: [{ gt: new Date() }, null] },
17
18
  ...(app.demoMode ? { seed: false, ephemeral: true } : {}),
18
19
  },
@@ -25,6 +26,8 @@ export function createGetAppResourceByIdController(options) {
25
26
  findOptions,
26
27
  });
27
28
  assertKoaCondition(resource != null, ctx, 404, 'Resource not found');
29
+ // The resource is searched across the selected groups; authorization is
30
+ // then scoped to the group the resource actually belongs to.
28
31
  await checkAppPermissions({
29
32
  context: ctx,
30
33
  permissions: [
@@ -35,14 +38,19 @@ export function createGetAppResourceByIdController(options) {
35
38
  : `$resource:${resourceType}:get`,
36
39
  ],
37
40
  app,
38
- groupId: selectedGroupId,
41
+ groupId: resource.$group?.id ?? appWideGroupId,
39
42
  });
40
43
  if (view) {
41
44
  const context = await getRemapperContext(app, app.definition.defaultLanguage || defaultLocale, options, ctx);
42
45
  const resourceDefinition = getResourceDefinition(app.definition, resourceType, ctx, view);
46
+ // No ETag for view responses: the response body is a remapped projection
47
+ // that does not uniquely identify the raw resource representation, so a
48
+ // shared ETag across views would violate RFC 7232's representation-
49
+ // identity requirement and confuse conditional GETs/caches.
43
50
  ctx.body = remap(resourceDefinition.views?.[view].remap ?? null, resource, context);
44
51
  return;
45
52
  }
53
+ setResourceEtagHeader(ctx, resource);
46
54
  ctx.body = resource;
47
55
  };
48
56
  }
@@ -1,28 +1,35 @@
1
1
  import { defaultLocale, remap } from '@appsemble/lang-sdk';
2
2
  import { getRemapperContext, getResourceDefinition, } from '@appsemble/node-utils';
3
- import { generateResourceQuery } from '../../../../utils/resources.js';
3
+ import { generateResourceQuery, getGroupIdWhere } from '../../../../utils/resources.js';
4
4
  export function createQueryAppResourcesController(options) {
5
5
  return async (ctx) => {
6
6
  const { pathParams: { appId, resourceType }, queryParams: { $own, $select, $skip, $top, selectedGroupId, view }, user: authSubject, } = ctx;
7
- const { checkAppPermissions, getApp, getAppResources } = options;
7
+ const { checkAppPermissions, getAllowedGroups, getApp, getAppResources } = options;
8
8
  const app = await getApp({
9
9
  context: ctx,
10
10
  query: { attributes: ['definition', 'demoMode', 'id', 'template'], where: { id: appId } },
11
11
  });
12
12
  const resourceDefinition = getResourceDefinition(app.definition, resourceType, ctx, view);
13
13
  const { order, where } = generateResourceQuery(ctx, options, resourceDefinition);
14
- await checkAppPermissions({
14
+ const permissions = [
15
+ $own
16
+ ? `$resource:${resourceType}:own:query`
17
+ : view
18
+ ? `$resource:${resourceType}:query:${view}`
19
+ : `$resource:${resourceType}:query`,
20
+ ];
21
+ // Across multiple selected groups the permission acts as a filter: only the
22
+ // groups the subject may query are returned. When no selected group is
23
+ // queryable, the strict permission check throws its usual 403.
24
+ const allowedGroups = await getAllowedGroups({
15
25
  context: ctx,
16
- permissions: [
17
- $own
18
- ? `$resource:${resourceType}:own:query`
19
- : view
20
- ? `$resource:${resourceType}:query:${view}`
21
- : `$resource:${resourceType}:query`,
22
- ],
26
+ permissions,
23
27
  app,
24
28
  groupId: selectedGroupId,
25
29
  });
30
+ if (!allowedGroups.length) {
31
+ await checkAppPermissions({ context: ctx, permissions, app, groupId: selectedGroupId });
32
+ }
26
33
  const isSameOrigin = ctx?.headers?.origin === ctx?.headers?.host;
27
34
  const findOptions = {
28
35
  limit: $top,
@@ -33,7 +40,7 @@ export function createQueryAppResourcesController(options) {
33
40
  where,
34
41
  {
35
42
  type: resourceType,
36
- GroupId: selectedGroupId ?? null,
43
+ GroupId: getGroupIdWhere(allowedGroups.length ? allowedGroups : selectedGroupId),
37
44
  expires: { or: [{ gt: new Date() }, null] },
38
45
  ...(app.demoMode && !isSameOrigin ? { seed: false, ephemeral: true } : {}),
39
46
  ...($own ? { AuthorId: authSubject?.id } : {}),
@@ -1,8 +1,9 @@
1
- import { assertKoaCondition, getResourceDefinition, processResourceBody, } from '@appsemble/node-utils';
1
+ import { assertKoaCondition, getResourceDefinition, getSingleGroupId, processResourceBody, setResourceEtagHeader, validateResourceReferences, } from '@appsemble/node-utils';
2
2
  export function createUpdateAppResourceController(options) {
3
3
  return async (ctx) => {
4
4
  const { pathParams: { appId, resourceId, resourceType }, queryParams: { selectedGroupId }, user: authSubject, } = ctx;
5
- const { checkAppPermissions, getApp, getAppAssets, getAppResource, updateAppResource } = options;
5
+ const groupId = getSingleGroupId(selectedGroupId);
6
+ const { checkAppPermissions, getApp, getAppAssets, getAppResource, getAppResources, updateAppResource, } = options;
6
7
  const app = await getApp({
7
8
  context: ctx,
8
9
  query: { attributes: ['id', 'demoMode', 'definition', 'template'], where: { id: appId } },
@@ -11,7 +12,7 @@ export function createUpdateAppResourceController(options) {
11
12
  where: {
12
13
  id: resourceId,
13
14
  type: resourceType,
14
- GroupId: selectedGroupId ?? null,
15
+ GroupId: groupId,
15
16
  expires: { or: [{ gt: new Date() }, null] },
16
17
  ...(app.demoMode ? { seed: false, ephemeral: true } : {}),
17
18
  },
@@ -32,11 +33,13 @@ export function createUpdateAppResourceController(options) {
32
33
  : `$resource:${resourceType}:update`,
33
34
  ],
34
35
  app,
35
- groupId: selectedGroupId,
36
+ // The operation acts on a single group; authorize against that group only.
37
+ groupId,
36
38
  });
37
39
  const appAssets = await getAppAssets({ context: ctx, app });
38
40
  const resourceDefinition = getResourceDefinition(app.definition, resourceType, ctx);
39
41
  const [processedBody, preparedAssets, deletedAssetIds] = await processResourceBody(ctx, resourceDefinition, appAssets.filter((asset) => asset.resourceId === resourceId).map((asset) => asset.id), oldResource.expires, appAssets.map((asset) => ({ id: asset.id, name: asset.name })));
42
+ await validateResourceReferences(ctx, app, resourceDefinition, processedBody, getAppResources);
40
43
  const resources = Array.isArray(processedBody) ? processedBody : [processedBody];
41
44
  ctx.body = await updateAppResource({
42
45
  app,
@@ -48,7 +51,10 @@ export function createUpdateAppResourceController(options) {
48
51
  deletedAssetIds,
49
52
  resourceDefinition,
50
53
  options,
54
+ lockWhere: findOptions.where,
55
+ ifMatch: ctx.get('If-Match') || undefined,
51
56
  });
57
+ setResourceEtagHeader(ctx, ctx.body);
52
58
  };
53
59
  }
54
60
  //# sourceMappingURL=createUpdateAppResourceController.js.map
@@ -2,4 +2,4 @@ import { type Middleware } from 'koa';
2
2
  import { type Options } from '../../types.js';
3
3
  export declare const bulmaURL: string;
4
4
  export declare const faURL: string;
5
- export declare function createIndexHandler({ createSettings, getApp, getAppDetails, getAppMessages, getAppUrl, getCsp, getHost, }: Options): Middleware;
5
+ export declare function createIndexHandler(options: Options): Middleware;
@@ -1,4 +1,4 @@
1
- import { randomBytes } from 'node:crypto';
1
+ import { createHash, randomBytes } from 'node:crypto';
2
2
  import { getAppBlocks } from '@appsemble/lang-sdk';
3
3
  import { createThemeURL, defaultLocale, mergeThemes } from '@appsemble/utils';
4
4
  import { organizationBlocklist } from '../../../organizationBlocklist.js';
@@ -6,8 +6,50 @@ import { makeCSP, render } from '../../../render.js';
6
6
  import { bulmaVersion, faVersion } from '../../../versions.js';
7
7
  export const bulmaURL = `/bulma/${bulmaVersion}/bulma.min.css`;
8
8
  export const faURL = `/fa/${faVersion}/css/all.min.css`;
9
- export function createIndexHandler({ createSettings, getApp, getAppDetails, getAppMessages, getAppUrl, getCsp, getHost, }) {
9
+ function hash(value) {
10
+ return createHash('sha256').update(JSON.stringify(value)).digest('base64url');
11
+ }
12
+ function getAppUpdated(app) {
13
+ return app.$updated ?? '';
14
+ }
15
+ function getAppSnapshotId(app) {
16
+ return app.version ?? 'none';
17
+ }
18
+ function getSettingsCacheKey({ app, host, hostname, identifiableBlocks, languages, }) {
19
+ const blocks = identifiableBlocks
20
+ .map(({ type, version }) => ({ type, version }))
21
+ .sort((a, b) => a.type.localeCompare(b.type) || a.version.localeCompare(b.version));
22
+ return [
23
+ 'app-settings',
24
+ app.id,
25
+ getAppUpdated(app),
26
+ getAppSnapshotId(app),
27
+ hash({ host, hostname }),
28
+ hash(languages),
29
+ hash(blocks),
30
+ ].join(':');
31
+ }
32
+ function getMessagesCacheKey({ app }) {
33
+ return ['app-messages', app.id, getAppUpdated(app)].join(':');
34
+ }
35
+ async function getCachedValue(cache, key, fallback) {
36
+ if (!cache) {
37
+ return [await fallback(), 'disabled'];
38
+ }
39
+ const cached = await cache.get(key);
40
+ if (cached.status === 'hit') {
41
+ return [cached.value, 'hit'];
42
+ }
43
+ const value = await fallback();
44
+ if (cached.status === 'miss') {
45
+ const setStatus = await cache.set(key, value);
46
+ return [value, setStatus === 'error' ? 'error' : 'miss'];
47
+ }
48
+ return [value, cached.status];
49
+ }
50
+ export function createIndexHandler(options) {
10
51
  return async (ctx) => {
52
+ const { appServingCache, createSettings, getApp, getAppDetails, getAppMessages, getAppUrl, getCsp, getHost, } = options;
11
53
  const { hostname, path } = ctx;
12
54
  const host = getHost({ context: ctx });
13
55
  // Prevent mime-type sniffing,
@@ -40,21 +82,26 @@ export function createIndexHandler({ createSettings, getApp, getAppDetails, getA
40
82
  return;
41
83
  }
42
84
  const defaultLanguage = app.definition.defaultLanguage || defaultLocale;
43
- const appMessages = await getAppMessages({ app, context: ctx });
85
+ const [appMessages, messagesCacheStatus] = await getCachedValue(appServingCache, getMessagesCacheKey({ app }), () => getAppMessages({ app, context: ctx }));
86
+ ctx.set('X-Appsemble-Messages-Cache', messagesCacheStatus);
44
87
  const languages = [
45
88
  ...new Set([...appMessages.map(({ language }) => language), defaultLanguage]),
46
89
  ].sort();
47
90
  const identifiableBlocks = getAppBlocks(app.definition);
48
91
  const nonce = randomBytes(16).toString('base64');
49
- const [settingsHash, settings] = await createSettings({
50
- context: ctx,
51
- app,
52
- host,
53
- hostname,
54
- identifiableBlocks,
55
- languages,
56
- nonce,
92
+ const [{ settingsHash, settings }, settingsCacheStatus] = await getCachedValue(appServingCache, getSettingsCacheKey({ app, host, hostname, identifiableBlocks, languages }), async () => {
93
+ const [digest, script] = await createSettings({
94
+ context: ctx,
95
+ app,
96
+ host,
97
+ hostname,
98
+ identifiableBlocks,
99
+ languages,
100
+ nonce,
101
+ });
102
+ return { settingsHash: digest, settings: script };
57
103
  });
104
+ ctx.set('X-Appsemble-Settings-Cache', settingsCacheStatus);
58
105
  const csp = getCsp({ app, settingsHash, hostname, host, nonce });
59
106
  ctx.set('Content-Security-Policy', makeCSP(csp));
60
107
  const updated = app.$updated ? new Date(app.$updated) : new Date();
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { readFile } from 'node:fs/promises';
2
3
  export function createServiceWorkerHandler() {
3
4
  return async (ctx) => {
@@ -6,8 +7,18 @@ export function createServiceWorkerHandler() {
6
7
  const serviceWorker = await (production
7
8
  ? readFile(new URL('../../../../../dist/app/service-worker.js', import.meta.url), 'utf8')
8
9
  : ctx.fs.promises.readFile(filename, 'utf8'));
9
- ctx.body = serviceWorker;
10
10
  ctx.type = 'application/javascript';
11
+ // Browsers bypass their HTTP cache when checking for service worker updates, so a strong ETag
12
+ // lets them revalidate with a 304 instead of downloading the full script on every check.
13
+ ctx.set('etag', `"${createHash('sha256').update(serviceWorker).digest('base64url')}"`);
14
+ ctx.set('cache-control', 'no-cache');
15
+ ctx.status = 200;
16
+ if (ctx.fresh) {
17
+ ctx.status = 304;
18
+ }
19
+ else {
20
+ ctx.body = serviceWorker;
21
+ }
11
22
  };
12
23
  }
13
24
  //# sourceMappingURL=serviceWorkerHandler.js.map
package/server/types.d.ts CHANGED
@@ -112,7 +112,7 @@ declare module 'koas-parameters' {
112
112
  roles?: string;
113
113
  includeMessages: boolean;
114
114
  demo: boolean;
115
- selectedGroupId: number;
115
+ selectedGroupId: number[];
116
116
  $own: boolean;
117
117
  delimiter?: string;
118
118
  email: string;
@@ -249,13 +249,13 @@ export interface CheckUserOrganizationPermissionsParams {
249
249
  export interface CheckAuthSubjectAppPermissionsParams {
250
250
  context: ParameterizedContext<DefaultState, DefaultContextInterface, any>;
251
251
  app: App;
252
- groupId?: number;
252
+ groupId?: number | number[] | null;
253
253
  permissions: CustomAppPermission[];
254
254
  }
255
255
  export interface CheckAppPermissionsParams {
256
256
  context: ParameterizedContext<DefaultState, DefaultContextInterface, any>;
257
257
  app: App;
258
- groupId?: number;
258
+ groupId?: number | number[] | null;
259
259
  permissions: CustomAppPermission[];
260
260
  }
261
261
  export interface ReloadUserParams {
@@ -288,7 +288,7 @@ export interface CreateAppResourcesWithAssetsParams extends GetAppSubEntityParam
288
288
  preparedAssets: PreparedAsset[];
289
289
  resourceType: string;
290
290
  options: Options;
291
- groupId?: number;
291
+ groupId?: number | null;
292
292
  }
293
293
  export interface UpdateAppResourceParams extends GetAppSubEntityParams {
294
294
  id: number | string;
@@ -298,12 +298,33 @@ export interface UpdateAppResourceParams extends GetAppSubEntityParams {
298
298
  deletedAssetIds: string[];
299
299
  type: string;
300
300
  options: Options;
301
+ /**
302
+ * The same where clause used by the controller's pre-lock fetch. Re-applied
303
+ * inside the SELECT FOR UPDATE so the lock cannot widen the row set (e.g.
304
+ * picking up an expired row or one whose GroupId or seed flag changed
305
+ * between the unlocked read and the lock).
306
+ */
307
+ lockWhere: WhereOptions;
308
+ /**
309
+ * The `If-Match` header value parsed out of the request, if any.
310
+ */
311
+ ifMatch?: string;
301
312
  }
302
313
  export interface DeleteAppResourceParams extends GetAppSubEntityParams {
303
314
  id: number | string;
304
315
  type: string;
305
316
  whereOptions?: WhereOptions;
306
317
  options: Options;
318
+ /**
319
+ * Where-clause used by the controller's pre-lock fetch. Re-applied inside
320
+ * the lock so a concurrent writer cannot widen the row set between the read
321
+ * and the delete.
322
+ */
323
+ lockWhere?: WhereOptions;
324
+ /**
325
+ * The `If-Match` header value parsed out of the request, if any.
326
+ */
327
+ ifMatch?: string;
307
328
  }
308
329
  export interface CreateAppAssetParams extends GetAppSubEntityParams {
309
330
  payload: {
@@ -383,6 +404,15 @@ export interface ParsedQuery {
383
404
  where: WhereOptions;
384
405
  }
385
406
  export type ContentSecurityPolicy = Record<string, (string | false)[]>;
407
+ export type AppServingCacheStatus = 'disabled' | 'error' | 'hit' | 'miss';
408
+ export interface AppServingCacheResult<T> {
409
+ status: AppServingCacheStatus;
410
+ value?: T;
411
+ }
412
+ export interface AppServingCache {
413
+ get: <T>(key: string) => Promise<AppServingCacheResult<T>>;
414
+ set: <T>(key: string, value: T) => Promise<AppServingCacheStatus>;
415
+ }
386
416
  export interface Options {
387
417
  getSecurityEmail: () => string;
388
418
  getCurrentAppMember: (params: GetCurrentAppMemberParams) => Promise<AppMemberInfo | null>;
@@ -402,16 +432,18 @@ export interface Options {
402
432
  getBlockMessages: (params: GetBlockMessagesParams) => Promise<BlockMessages[]>;
403
433
  getBlockAsset: (params: GetBlockAssetParams) => Promise<ProjectAsset>;
404
434
  getBlocksAssetsPaths: (params: GetBlocksAssetsPathsParams) => Promise<string[]>;
405
- getTheme: (params: GetThemeParams) => Promise<Theme>;
435
+ getTheme: (params: GetThemeParams) => Promise<Theme | null>;
406
436
  createTheme: (params: CreateThemeParams) => Promise<Theme>;
407
437
  getHost: (params: GetHostParams) => string;
408
438
  getCsp: (params: GetCspParams) => ContentSecurityPolicy;
409
439
  createSettings: (params: CreateSettingsParams) => Promise<[digest: string, script: string]>;
440
+ appServingCache?: AppServingCache;
410
441
  applyAppServiceSecrets: (params: ApplyAppServiceSecretsParams) => Promise<RawAxiosRequestConfig<any>>;
411
442
  checkAppMemberAppPermissions: (params: CheckAppMemberAppPermissionsParams) => Promise<void>;
412
443
  checkUserOrganizationPermissions: (params: CheckUserOrganizationPermissionsParams) => Promise<void>;
413
444
  checkAuthSubjectAppPermissions: (params: CheckAuthSubjectAppPermissionsParams) => Promise<void>;
414
445
  checkAppPermissions: (params: CheckAppPermissionsParams) => Promise<void>;
446
+ getAllowedGroups: (params: CheckAppPermissionsParams) => Promise<number[]>;
415
447
  reloadUser: (params: ReloadUserParams) => Promise<Record<string, any>>;
416
448
  parseQuery: (params: ParseQueryParams) => ParsedQuery;
417
449
  getAppResource: (params: GetAppResourceParams) => Promise<Resource | null>;
@@ -1,7 +1,8 @@
1
1
  import { defaultLocale, remap, } from '@appsemble/lang-sdk';
2
2
  import { assertKoaCondition, createFormData, EmailQuotaExceededError, getContainerNamespace, getRemapperContext, getSSRFProtectedAgents, logger, parseServiceUrl, scaleDeployment, setLastRequestAnnotation, throwKoaError, version, waitForPodReadiness, } from '@appsemble/node-utils';
3
+ import { deserializeResource } from '@appsemble/utils';
3
4
  import axios from 'axios';
4
- import { get, mapValues, pick } from 'lodash-es';
5
+ import { get, pick } from 'lodash-es';
5
6
  /**
6
7
  * These response headers are forwarded when proxying requests.
7
8
  */
@@ -40,26 +41,6 @@ export async function handleNotify(ctx, app, action, options) {
40
41
  await sendNotifications({ app, to, title, body, link });
41
42
  ctx.status = 204;
42
43
  }
43
- function deserializeResource(data) {
44
- // Extract the resource and assets from the JSON object
45
- const { resource } = data;
46
- const assets = data.assets;
47
- // Function to replace asset placeholders with actual Blobs
48
- const replaceAssets = (value) => {
49
- if (Array.isArray(value)) {
50
- return value.map(replaceAssets);
51
- }
52
- if (typeof value === 'string' && /^\d+$/.test(value)) {
53
- return assets[Number(value)];
54
- }
55
- if (value && typeof value === 'object') {
56
- return mapValues(value, replaceAssets);
57
- }
58
- return value;
59
- };
60
- // Replace placeholders and return the deserialized resource
61
- return replaceAssets(resource);
62
- }
63
44
  async function handleRequestProxy(ctx, app, action, useBody, options) {
64
45
  const { method, query, request: { body, headers }, } = ctx;
65
46
  let data;
@@ -2,6 +2,30 @@ import { type ResourceDefinition } from '@appsemble/lang-sdk';
2
2
  import { type App } from '@appsemble/types';
3
3
  import { type Context } from 'koa';
4
4
  import { type Options, type OrderItem, type WhereOptions } from '../types.js';
5
+ /**
6
+ * The `selectedGroupId` value representing the app-wide (ungrouped) scope, as
7
+ * opposed to a concrete group id.
8
+ */
9
+ export declare const appWideGroupId = -1;
10
+ /**
11
+ * Collapse a `selectedGroupId` selection to a single group for operations that
12
+ * are inherently scoped to one group (create, update, reorder).
13
+ *
14
+ * @param selectedGroupId The selected group ids from the query parameters.
15
+ * @returns The first concrete group id, or null for the app-wide scope.
16
+ */
17
+ export declare function getSingleGroupId(selectedGroupId?: number[]): number | null;
18
+ /**
19
+ * Build a resource `GroupId` filter for operations that may span multiple
20
+ * groups (query, delete).
21
+ *
22
+ * The app-wide scope (`appWideGroupId`, or an empty selection) matches
23
+ * resources without a group; concrete ids match resources in those groups.
24
+ *
25
+ * @param selectedGroupId The selected group ids from the query parameters.
26
+ * @returns A value for a resource `GroupId` where clause.
27
+ */
28
+ export declare function getGroupIdWhere(selectedGroupId?: number[]): WhereOptions[string];
5
29
  export declare function generateResourceQuery(ctx: Context, { parseQuery }: Options, resourceDefinition?: ResourceDefinition): {
6
30
  order: OrderItem[];
7
31
  where: WhereOptions;
@@ -1,5 +1,39 @@
1
1
  import { throwKoaError } from '../../koa.js';
2
2
  import { logger } from '../../logger.js';
3
+ /**
4
+ * The `selectedGroupId` value representing the app-wide (ungrouped) scope, as
5
+ * opposed to a concrete group id.
6
+ */
7
+ export const appWideGroupId = -1;
8
+ /**
9
+ * Collapse a `selectedGroupId` selection to a single group for operations that
10
+ * are inherently scoped to one group (create, update, reorder).
11
+ *
12
+ * @param selectedGroupId The selected group ids from the query parameters.
13
+ * @returns The first concrete group id, or null for the app-wide scope.
14
+ */
15
+ export function getSingleGroupId(selectedGroupId = []) {
16
+ const [groupId] = selectedGroupId;
17
+ return groupId != null && groupId > 0 ? groupId : null;
18
+ }
19
+ /**
20
+ * Build a resource `GroupId` filter for operations that may span multiple
21
+ * groups (query, delete).
22
+ *
23
+ * The app-wide scope (`appWideGroupId`, or an empty selection) matches
24
+ * resources without a group; concrete ids match resources in those groups.
25
+ *
26
+ * @param selectedGroupId The selected group ids from the query parameters.
27
+ * @returns A value for a resource `GroupId` where clause.
28
+ */
29
+ export function getGroupIdWhere(selectedGroupId = []) {
30
+ const groupIds = selectedGroupId.filter((id) => id > 0);
31
+ const includeUngrouped = selectedGroupId.length === 0 || selectedGroupId.includes(appWideGroupId);
32
+ if (!groupIds.length) {
33
+ return null;
34
+ }
35
+ return includeUngrouped ? { or: [{ in: groupIds }, null] } : { in: groupIds };
36
+ }
3
37
  export function generateResourceQuery(ctx, { parseQuery }, resourceDefinition) {
4
38
  try {
5
39
  return parseQuery({