@appsemble/node-utils 0.36.10 → 0.37.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.
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.0/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.0/pipeline.svg)](https://gitlab.com/appsemble/appsemble/-/releases/0.37.0)
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.0/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.0",
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.0",
44
+ "@appsemble/types": "0.37.0",
45
+ "@appsemble/utils": "0.37.0",
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
  *
@@ -68,3 +68,19 @@ export declare function processResourceBody(ctx: Context | ParameterizedContext<
68
68
  id: string;
69
69
  name?: string;
70
70
  }[], isPatch?: boolean, resourceBody?: SerializedServerResourceBody): Promise<[Record<string, unknown> | Record<string, unknown>[], PreparedAsset[], string[]]>;
71
+ /**
72
+ * Validate that the references declared in the resource definition point at existing resources.
73
+ *
74
+ * Reference properties which hold no value are skipped. Expired resources are treated as
75
+ * non-existent. The referenced ids are collected per referenced resource type, so each type is
76
+ * checked using a single lookup, even when multiple resources are submitted at once.
77
+ *
78
+ * If a reference is broken, an HTTP 400 error naming the offending property path is thrown.
79
+ *
80
+ * @param ctx The Koa context used to throw back the error response.
81
+ * @param app The app the resources belong to.
82
+ * @param definition The definition of the resource type being created or updated.
83
+ * @param processedBody One or more resources as processed by `processResourceBody`.
84
+ * @param getAppResources The function used to look up resources of the referenced types.
85
+ */
86
+ 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
  /**
@@ -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
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: {
@@ -412,6 +433,7 @@ export interface Options {
412
433
  checkUserOrganizationPermissions: (params: CheckUserOrganizationPermissionsParams) => Promise<void>;
413
434
  checkAuthSubjectAppPermissions: (params: CheckAuthSubjectAppPermissionsParams) => Promise<void>;
414
435
  checkAppPermissions: (params: CheckAppPermissionsParams) => Promise<void>;
436
+ getAllowedGroups: (params: CheckAppPermissionsParams) => Promise<number[]>;
415
437
  reloadUser: (params: ReloadUserParams) => Promise<Record<string, any>>;
416
438
  parseQuery: (params: ParseQueryParams) => ParsedQuery;
417
439
  getAppResource: (params: GetAppResourceParams) => Promise<Resource | null>;
@@ -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({