@wbce-d9/api 12.6.0 → 12.7.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.
@@ -39,11 +39,16 @@ export class BaseOAuthDriver extends LocalAuthDriver {
39
39
  provider: this.getConfig()['provider'],
40
40
  providerPayload: { accessToken: tokenSet.access_token, userInfo },
41
41
  }, { database: getDatabase(), schema: this.schema, accountability: null });
42
- // Update user to update refresh_token and other properties that might have changed
43
- await this.getUserService().updateOne(userId, {
44
- ...updatedUserPayload,
45
- auth_data: userPayload.auth_data ?? null,
46
- });
42
+ // Update user to update refresh_token and other properties that might have changed.
43
+ // Keep the stored refresh_token if the provider didn't issue a new one (e.g. Google when consent was
44
+ // already granted): refresh() still checks it against the provider
45
+ const updateUserPayload = userPayload.auth_data
46
+ ? { ...updatedUserPayload, auth_data: userPayload.auth_data }
47
+ : updatedUserPayload;
48
+ // Skip empty updates, they crash (#127)
49
+ if (Object.keys(updateUserPayload).length > 0) {
50
+ await this.getUserService().updateOne(userId, updateUserPayload);
51
+ }
47
52
  return userId;
48
53
  }
49
54
  // --- NEW USER ----
@@ -131,10 +136,16 @@ export class BaseOAuthDriver extends LocalAuthDriver {
131
136
  throw this.handleError(e);
132
137
  }
133
138
  }
139
+ else if (this.getConfig()['requireRefreshToken'] === false) {
140
+ // Opt-in: the provider issued no refresh token (e.g. Google when consent was already granted),
141
+ // so the Directus session is the only source of truth for this user
142
+ logger.debug(`[${this.getClientName()}] No refresh token stored, continuing (REQUIRE_REFRESH_TOKEN=false)`);
143
+ }
134
144
  else {
135
- // If no auth connexion, throw user out
145
+ // If no auth connexion, throw user out. On login, the callback retries once with prompt=consent
146
+ // so the provider can issue a refresh_token
136
147
  logger.error(`No [${this.getClientName()}] Session found`);
137
- throw new InvalidCredentialsException();
148
+ throw new InvalidTokenException();
138
149
  }
139
150
  }
140
151
  handleError = (e) => {
@@ -0,0 +1 @@
1
+ export {};
package/dist/env.js CHANGED
@@ -141,6 +141,7 @@ const allowedEnvironmentVars = [
141
141
  'AUTH_.+_FIRST_NAME_KEY',
142
142
  'AUTH_.+_LAST_NAME_KEY',
143
143
  'AUTH_.+_ALLOW_PUBLIC_REGISTRATION',
144
+ 'AUTH_.+_REQUIRE_REFRESH_TOKEN',
144
145
  'AUTH_.+_DEFAULT_ROLE_ID',
145
146
  'AUTH_.+_REGISTER_URL_ALLOW_LIST',
146
147
  'AUTH_.+_ICON',
package/dist/flows.d.ts CHANGED
@@ -15,8 +15,22 @@ declare class FlowManager {
15
15
  runOperationFlow(id: string, data: unknown, context: Record<string, unknown>): Promise<unknown>;
16
16
  runWebhookFlow(id: string, data: unknown, context: Record<string, unknown>): Promise<unknown>;
17
17
  private load;
18
+ /**
19
+ * Manual flows act on behalf of the triggering user: make sure that user can see the flow itself
20
+ * and read the collection / items passed in the payload before running it.
21
+ */
22
+ private checkManualTriggerAccess;
18
23
  private unload;
19
24
  private executeFlow;
25
+ /**
26
+ * Return a copy of the event data whose payload has its concealed fields masked, as they would be on read.
27
+ * The data is returned as is when there is nothing to conceal.
28
+ */
29
+ private concealEventPayload;
30
+ /**
31
+ * Options are resolved against `concealedKeyedData` for the log operation and for the returned options (stored in
32
+ * the flow revisions), and against `keyedData` for every other operation.
33
+ */
20
34
  private executeOperation;
21
35
  }
22
36
  export {};
package/dist/flows.js CHANGED
@@ -2,7 +2,7 @@ import * as sharedExceptions from '@wbce-d9/exceptions';
2
2
  import { Action } from '@wbce-d9/constants';
3
3
  import { applyOptionsData, isValidJSON, parseJSON, toArray } from '@wbce-d9/utils';
4
4
  import fastRedact from 'fast-redact';
5
- import { omit, pick } from 'lodash-es';
5
+ import { cloneDeep, omit, pick } from 'lodash-es';
6
6
  import { get } from 'micromustache';
7
7
  import { schedule, validate } from 'node-cron';
8
8
  import getDatabase from './database/index.js';
@@ -15,6 +15,7 @@ import { getMessenger } from './messenger.js';
15
15
  import { ActivityService } from './services/activity.js';
16
16
  import * as services from './services/index.js';
17
17
  import { FlowsService } from './services/flows.js';
18
+ import { PayloadService } from './services/payload.js';
18
19
  import { RevisionsService } from './services/revisions.js';
19
20
  import { constructFlowTree } from './utils/construct-flow-tree.js';
20
21
  import { getSchema } from './utils/get-schema.js';
@@ -197,13 +198,16 @@ class FlowManager {
197
198
  logger.warn(`Specified collection must be one of: ${enabledCollections.join(', ')}.`);
198
199
  throw new exceptions.ForbiddenException();
199
200
  }
200
- if (flow.options['async']) {
201
- this.executeFlow(flow, data, context);
202
- return undefined;
203
- }
204
- else {
205
- return this.executeFlow(flow, data, context);
206
- }
201
+ const run = () => {
202
+ if (flow.options['async']) {
203
+ this.executeFlow(flow, data, context);
204
+ return undefined;
205
+ }
206
+ else {
207
+ return this.executeFlow(flow, data, context);
208
+ }
209
+ };
210
+ return this.checkManualTriggerAccess(flow, targetCollection, data, context).then(run);
207
211
  };
208
212
  // Default return to $last for manual
209
213
  flow.options['return'] = '$last';
@@ -212,6 +216,34 @@ class FlowManager {
212
216
  }
213
217
  this.isLoaded = true;
214
218
  }
219
+ /**
220
+ * Manual flows act on behalf of the triggering user: make sure that user can see the flow itself
221
+ * and read the collection / items passed in the payload before running it.
222
+ */
223
+ async checkManualTriggerAccess(flow, collection, data, context) {
224
+ const accountability = context['accountability'];
225
+ if (!accountability)
226
+ throw new exceptions.ForbiddenException();
227
+ if (accountability.admin)
228
+ return;
229
+ const database = context['database'] ?? getDatabase();
230
+ const schema = context['schema'] ?? (await getSchema({ database }));
231
+ const flowsService = new FlowsService({ accountability, knex: database, schema });
232
+ await flowsService.readOne(flow.id, { fields: ['id'] });
233
+ const keys = data?.['body']?.keys;
234
+ if (keys === null || keys === undefined || (Array.isArray(keys) && keys.length === 0)) {
235
+ if (flow.options?.['requireSelection'] !== false)
236
+ throw new exceptions.ForbiddenException();
237
+ const canReadCollection = accountability.permissions?.some((permission) => permission.collection === collection && permission.action === 'read');
238
+ if (!canReadCollection)
239
+ throw new exceptions.ForbiddenException();
240
+ return;
241
+ }
242
+ if (!Array.isArray(keys))
243
+ throw new exceptions.ForbiddenException();
244
+ const authorizationService = new services.AuthorizationService({ accountability, knex: database, schema });
245
+ await authorizationService.checkAccess('read', collection, keys);
246
+ }
215
247
  async unload() {
216
248
  for (const trigger of this.triggerHandlers) {
217
249
  trigger.events.forEach((event) => {
@@ -242,13 +274,22 @@ class FlowManager {
242
274
  [ACCOUNTABILITY_KEY]: context?.['accountability'] ?? null,
243
275
  [ENV_KEY]: pick(env, env['FLOWS_ENV_ALLOW_LIST'] ? toArray(env['FLOWS_ENV_ALLOW_LIST']) : []),
244
276
  };
277
+ // Concealed fields of the event payload (e.g. passwords, tokens) must never end up in logs or revisions
278
+ const concealedData = await this.concealEventPayload(flow, data, database, schema);
279
+ const concealedKeyedData = {
280
+ ...keyedData,
281
+ [TRIGGER_KEY]: concealedData,
282
+ [LAST_KEY]: concealedData,
283
+ };
245
284
  let nextOperation = flow.operation;
246
285
  let lastOperationStatus = 'unknown';
247
286
  const steps = [];
248
287
  while (nextOperation !== null) {
249
- const { successor, data, status, options } = await this.executeOperation(nextOperation, keyedData, context);
288
+ const { successor, data, status, options } = await this.executeOperation(nextOperation, keyedData, concealedKeyedData, context);
250
289
  keyedData[nextOperation.key] = data;
251
290
  keyedData[LAST_KEY] = data;
291
+ concealedKeyedData[nextOperation.key] = data;
292
+ concealedKeyedData[LAST_KEY] = data;
252
293
  lastOperationStatus = status;
253
294
  steps.push({ operation: nextOperation.id, key: nextOperation.key, status, options });
254
295
  nextOperation = successor;
@@ -279,7 +320,7 @@ class FlowManager {
279
320
  item: flow.id,
280
321
  data: {
281
322
  steps: steps,
282
- data: redactLogs(omit(keyedData, '$accountability.permissions')), // Permissions is a ton of data, and is just a copy of what's in the directus_permissions table
323
+ data: redactLogs(omit(concealedKeyedData, '$accountability.permissions')), // Permissions is a ton of data, and is just a copy of what's in the directus_permissions table
283
324
  },
284
325
  });
285
326
  }
@@ -295,15 +336,35 @@ class FlowManager {
295
336
  }
296
337
  return undefined;
297
338
  }
298
- async executeOperation(operation, keyedData, context = {}) {
339
+ /**
340
+ * Return a copy of the event data whose payload has its concealed fields masked, as they would be on read.
341
+ * The data is returned as is when there is nothing to conceal.
342
+ */
343
+ async concealEventPayload(flow, data, knex, schema) {
344
+ if (flow.trigger !== 'event')
345
+ return data;
346
+ const { collection, payload } = data;
347
+ if (!schema.collections[collection] || !payload)
348
+ return data;
349
+ const payloadService = new PayloadService(collection, { knex, schema });
350
+ const concealedData = cloneDeep(data);
351
+ concealedData.payload = await payloadService.processValues('read', concealedData.payload);
352
+ return concealedData;
353
+ }
354
+ /**
355
+ * Options are resolved against `concealedKeyedData` for the log operation and for the returned options (stored in
356
+ * the flow revisions), and against `keyedData` for every other operation.
357
+ */
358
+ async executeOperation(operation, keyedData, concealedKeyedData, context = {}) {
299
359
  if (!(operation.type in this.operations)) {
300
360
  logger.warn(`Couldn't find operation ${operation.type}`);
301
361
  return { successor: null, status: 'unknown', data: null, options: null };
302
362
  }
303
363
  const handler = this.operations[operation.type];
304
364
  const options = applyOptionsData(operation.options, keyedData);
365
+ const concealedOptions = applyOptionsData(operation.options, concealedKeyedData);
305
366
  try {
306
- let result = await handler(options, {
367
+ let result = await handler(operation.type === 'log' ? concealedOptions : options, {
307
368
  services,
308
369
  exceptions: { ...exceptions, ...sharedExceptions },
309
370
  env,
@@ -321,7 +382,7 @@ class FlowManager {
321
382
  if (typeof result === 'object' && result !== null) {
322
383
  result = mapValuesDeep(result, (_, value) => (value === undefined ? null : value));
323
384
  }
324
- return { successor: operation.resolve, status: 'resolve', data: result ?? null, options };
385
+ return { successor: operation.resolve, status: 'resolve', data: result ?? null, options: concealedOptions };
325
386
  }
326
387
  catch (error) {
327
388
  let data;
@@ -343,7 +404,7 @@ class FlowManager {
343
404
  successor: operation.reject,
344
405
  status: 'reject',
345
406
  data,
346
- options,
407
+ options: concealedOptions,
347
408
  };
348
409
  }
349
410
  }
@@ -0,0 +1 @@
1
+ export {};
package/dist/logger.js CHANGED
@@ -6,17 +6,18 @@ import { URL } from 'url';
6
6
  import env from './env.js';
7
7
  import { REDACT_TEXT } from './constants.js';
8
8
  import { getConfigFromEnv } from './utils/get-config-from-env.js';
9
+ const REDACTED_PATHS = ['req.headers.authorization', 'req.headers.cookie', 'req.query.access_token'];
9
10
  const pinoOptions = {
10
11
  level: env['LOG_LEVEL'] || 'info',
11
12
  redact: {
12
- paths: ['req.headers.authorization', 'req.headers.cookie'],
13
+ paths: REDACTED_PATHS,
13
14
  censor: REDACT_TEXT,
14
15
  },
15
16
  };
16
17
  export const httpLoggerOptions = {
17
18
  level: env['LOG_LEVEL'] || 'info',
18
19
  redact: {
19
- paths: ['req.headers.authorization', 'req.headers.cookie'],
20
+ paths: REDACTED_PATHS,
20
21
  censor: REDACT_TEXT,
21
22
  },
22
23
  };
@@ -43,7 +44,7 @@ if (env['LOG_STYLE'] !== 'raw') {
43
44
  }
44
45
  if (env['LOG_STYLE'] === 'raw') {
45
46
  httpLoggerOptions.redact = {
46
- paths: ['req.headers.authorization', 'req.headers.cookie', 'res.headers'],
47
+ paths: [...REDACTED_PATHS, 'res.headers'],
47
48
  censor: (value, pathParts) => {
48
49
  const path = pathParts.join('.');
49
50
  if (path === 'res.headers') {
@@ -458,6 +458,18 @@ export class FieldsService {
458
458
  if (Object.keys(collectionMetaUpdates).length > 0) {
459
459
  await trx('directus_collections').update(collectionMetaUpdates).where({ collection });
460
460
  }
461
+ const permissions = await trx('directus_permissions')
462
+ .select('id', 'fields')
463
+ .where({ collection });
464
+ for (const permission of permissions) {
465
+ const permittedFields = permission.fields?.split(',') ?? [];
466
+ if (permittedFields.includes(field) === false)
467
+ continue;
468
+ const remainingFields = permittedFields.filter((permittedField) => permittedField !== field);
469
+ await trx('directus_permissions')
470
+ .update({ fields: remainingFields.length > 0 ? remainingFields.join(',') : null })
471
+ .where({ id: permission.id });
472
+ }
461
473
  // Cleanup directus_fields
462
474
  const metaRow = await trx
463
475
  .select('collection', 'field')
@@ -104,6 +104,9 @@ export class GraphQLService {
104
104
  return formattedResult;
105
105
  }
106
106
  getSchema(type = 'schema') {
107
+ if (type === 'sdl' && env['GRAPHQL_INTROSPECTION'] === false) {
108
+ throw new ForbiddenException();
109
+ }
107
110
  // eslint-disable-next-line @typescript-eslint/no-this-alias
108
111
  const self = this;
109
112
  const schemaComposer = new SchemaComposer();
@@ -3,7 +3,7 @@ import { format, isValid, parseISO } from 'date-fns';
3
3
  import flat from 'flat';
4
4
  import Joi from 'joi';
5
5
  import { clone, cloneDeep, isNil, isObject, isPlainObject, omit, pick } from 'lodash-es';
6
- import { v4 as uuid } from 'uuid';
6
+ import { v4 as uuid, validate as validateUUID } from 'uuid';
7
7
  import { parse as wktToGeoJSON } from 'wellknown';
8
8
  import { getHelpers } from '../database/helpers/index.js';
9
9
  import getDatabase from '../database/index.js';
@@ -35,13 +35,19 @@ export class PayloadService {
35
35
  if (action === 'create' || action === 'update') {
36
36
  return await generateHash(String(value));
37
37
  }
38
+ // Same as conceal
39
+ if (action === 'read')
40
+ return '**********';
38
41
  return value;
39
42
  },
40
43
  async uuid({ action, value }) {
41
44
  if (action === 'create' && !value) {
42
45
  return uuid();
43
46
  }
44
- return value;
47
+ if (action === 'create' && !validateUUID(String(value))) {
48
+ throw new InvalidPayloadException(`Invalid UUID "${value}"`);
49
+ }
50
+ return action === 'create' ? String(value).toLowerCase() : value;
45
51
  },
46
52
  async 'cast-boolean'({ action, value }) {
47
53
  if (action === 'read') {
@@ -62,7 +62,8 @@ class OASSpecsService {
62
62
  info: {
63
63
  title: 'Dynamic API Specification',
64
64
  description: 'This is a dynamically generated API specification for all endpoints existing on the current project.',
65
- version: version,
65
+ // Only admins see the real version, as on /server/info
66
+ version: this.accountability?.admin ? version : '0.0.0-hidden',
66
67
  },
67
68
  servers: [
68
69
  {
@@ -5,6 +5,7 @@ import { cloneDeep, isEmpty } from 'lodash-es';
5
5
  import { performance } from 'perf_hooks';
6
6
  import getDatabase from '../database/index.js';
7
7
  import env from '../env.js';
8
+ import logger from '../logger.js';
8
9
  import { RecordNotUniqueException } from '../exceptions/database/record-not-unique.js';
9
10
  import { ForbiddenException, InvalidPayloadException, UnprocessableEntityException } from '../exceptions/index.js';
10
11
  import isUrlAllowed from '../utils/is-url-allowed.js';
@@ -340,37 +341,42 @@ export class UsersService extends ItemsService {
340
341
  async requestPasswordReset(email, url, subject) {
341
342
  const STALL_TIME = 500;
342
343
  const timeStart = performance.now();
343
- const user = await this.getUserByEmail(email);
344
- if (user?.status !== 'active') {
345
- await stall(STALL_TIME, timeStart);
346
- throw new ForbiddenException();
344
+ try {
345
+ if (url && isUrlAllowed(url, env['PASSWORD_RESET_URL_ALLOW_LIST']) === false) {
346
+ throw new InvalidPayloadException(`Url "${url}" can't be used to reset passwords.`);
347
+ }
348
+ const user = await this.getUserByEmail(email);
349
+ if (user?.status !== 'active') {
350
+ throw new ForbiddenException();
351
+ }
352
+ const mailService = new MailService({
353
+ schema: this.schema,
354
+ knex: this.knex,
355
+ accountability: this.accountability,
356
+ });
357
+ const payload = { email: user.email, scope: 'password-reset', hash: getSimpleHash('' + user.password) };
358
+ const token = jwt.sign(payload, env['SECRET'], { expiresIn: '1d', issuer: 'directus' });
359
+ const acceptURL = url
360
+ ? new Url(url).setQuery('token', token).toString()
361
+ : new Url(env['PUBLIC_URL']).addPath('admin', 'reset-password').setQuery('token', token).toString();
362
+ const subjectLine = subject ? subject : 'Password Reset Request';
363
+ mailService
364
+ .send({
365
+ to: user.email,
366
+ subject: subjectLine,
367
+ template: {
368
+ name: 'password-reset',
369
+ data: {
370
+ url: acceptURL,
371
+ email: user.email,
372
+ },
373
+ },
374
+ })
375
+ .catch((err) => logger.warn(err, `[email] ${err}`));
347
376
  }
348
- if (url && isUrlAllowed(url, env['PASSWORD_RESET_URL_ALLOW_LIST']) === false) {
349
- throw new InvalidPayloadException(`Url "${url}" can't be used to reset passwords.`);
377
+ finally {
378
+ await stall(STALL_TIME, timeStart);
350
379
  }
351
- const mailService = new MailService({
352
- schema: this.schema,
353
- knex: this.knex,
354
- accountability: this.accountability,
355
- });
356
- const payload = { email: user.email, scope: 'password-reset', hash: getSimpleHash('' + user.password) };
357
- const token = jwt.sign(payload, env['SECRET'], { expiresIn: '1d', issuer: 'directus' });
358
- const acceptURL = url
359
- ? new Url(url).setQuery('token', token).toString()
360
- : new Url(env['PUBLIC_URL']).addPath('admin', 'reset-password').setQuery('token', token).toString();
361
- const subjectLine = subject ? subject : 'Password Reset Request';
362
- await mailService.send({
363
- to: user.email,
364
- subject: subjectLine,
365
- template: {
366
- name: 'password-reset',
367
- data: {
368
- url: acceptURL,
369
- email: user.email,
370
- },
371
- },
372
- });
373
- await stall(STALL_TIME, timeStart);
374
380
  }
375
381
  async resetPassword(token, password) {
376
382
  const { email, scope, hash } = jwt.verify(token, env['SECRET'], { issuer: 'directus' });
@@ -32,4 +32,4 @@ export declare function applyFilter(knex: Knex, schema: SchemaOverview, rootQuer
32
32
  };
33
33
  export declare function addWhereClauses(knex: Knex, schema: SchemaOverview, dbQuery: Knex.QueryBuilder, filter: Filter, collection: string, aliasMap: AliasMap, logical?: 'and' | 'or'): void;
34
34
  export declare function applySearch(schema: SchemaOverview, dbQuery: Knex.QueryBuilder, searchQuery: string, collection: string): Promise<void>;
35
- export declare function applyAggregate(dbQuery: Knex.QueryBuilder, aggregate: Aggregate, collection: string): void;
35
+ export declare function applyAggregate(schema: SchemaOverview, dbQuery: Knex.QueryBuilder, aggregate: Aggregate, collection: string): void;
@@ -33,7 +33,7 @@ export default function applyQuery(knex, collection, dbQuery, query, schema, opt
33
33
  dbQuery.groupBy(query.group.map((column) => getColumn(knex, collection, column, false, schema)));
34
34
  }
35
35
  if (query.aggregate) {
36
- applyAggregate(dbQuery, query.aggregate, collection);
36
+ applyAggregate(schema, dbQuery, query.aggregate, collection);
37
37
  }
38
38
  if (query.filter) {
39
39
  hasMultiRelationalFilter = applyFilter(knex, schema, dbQuery, query.filter, collection, aliasMap).hasMultiRelationalFilter;
@@ -166,6 +166,9 @@ export function applySort(knex, schema, rootQuery, rootSort, collection, aliasMa
166
166
  rootQuery.orderBy(sortRecords);
167
167
  return undefined;
168
168
  }
169
+ function isConcealedField(schema, collection, field) {
170
+ return schema.collections[collection]?.fields[stripFunction(field)]?.special.includes('conceal') ?? false;
171
+ }
169
172
  export function applyLimit(knex, rootQuery, limit) {
170
173
  if (typeof limit === 'number') {
171
174
  getHelpers(knex).schema.applyLimit(rootQuery, limit);
@@ -480,6 +483,8 @@ export async function applySearch(schema, dbQuery, searchQuery, collection) {
480
483
  const fields = Object.entries(schema.collections[collection].fields);
481
484
  dbQuery.andWhere(function () {
482
485
  fields.forEach(([name, field]) => {
486
+ if (field.special.includes('conceal'))
487
+ return;
483
488
  if (['text', 'string'].includes(field.type)) {
484
489
  this.orWhereRaw(`LOWER(??) LIKE ?`, [`${collection}.${name}`, `%${searchQuery.toLowerCase()}%`]);
485
490
  }
@@ -494,11 +499,14 @@ export async function applySearch(schema, dbQuery, searchQuery, collection) {
494
499
  });
495
500
  });
496
501
  }
497
- export function applyAggregate(dbQuery, aggregate, collection) {
502
+ export function applyAggregate(schema, dbQuery, aggregate, collection) {
498
503
  for (const [operation, fields] of Object.entries(aggregate)) {
499
504
  if (!fields)
500
505
  continue;
501
506
  for (const field of fields) {
507
+ if (!operation.startsWith('count') && isConcealedField(schema, collection, field)) {
508
+ throw new InvalidQueryException(`Field with "conceal" special does not allow the "${operation}" aggregate function`);
509
+ }
502
510
  if (operation === 'avg') {
503
511
  dbQuery.avg(`${collection}.${field}`, { as: `avg->${field}` });
504
512
  }
@@ -1,3 +1,3 @@
1
1
  declare const name: string, version: string;
2
- declare const mainVersion: any;
2
+ declare const mainVersion: string;
3
3
  export { name, version, mainVersion };
@@ -4,6 +4,18 @@ import { fileURLToPath } from 'node:url';
4
4
  const __dirname = dirname(fileURLToPath(import.meta.url));
5
5
  const apiPkgPath = resolve(__dirname, '../../package.json');
6
6
  const { name, version } = JSON.parse(readFileSync(apiPkgPath, 'utf8'));
7
- const mainPkgPath = resolve(dirname(apiPkgPath), '../../../../../../package.json');
8
- const mainVersion = existsSync(mainPkgPath) ? JSON.parse(readFileSync(mainPkgPath, 'utf8')).version : null;
7
+ function pkgVersion(path) {
8
+ try {
9
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')).version ?? null : null;
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ // d9 release version (the bundle, matching the Docker image tag): dev-checkout root, then the
16
+ // installed bundle (published/projen image), then the api version as a last resort.
17
+ const mainVersion = pkgVersion(resolve(dirname(apiPkgPath), '../../../../../../package.json')) ??
18
+ pkgVersion(resolve(process.cwd(), 'node_modules/@wbce-d9/directus9/package.json')) ??
19
+ pkgVersion(resolve(process.cwd(), 'node_modules/directus9/package.json')) ??
20
+ version;
9
21
  export { name, version, mainVersion };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbce-d9/api",
3
- "version": "12.6.0",
3
+ "version": "12.7.0",
4
4
  "description": "Directus is a real-time API and App dashboard for managing SQL database content",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -144,20 +144,20 @@
144
144
  "tmp-promise": "3.0.3",
145
145
  "tsx": "3.12.6",
146
146
  "uuid-validate": "0.0.3",
147
- "@wbce-d9/app": "11.0.1",
148
- "@wbce-d9/exceptions": "10.0.1",
147
+ "@wbce-d9/app": "11.2.0",
149
148
  "@wbce-d9/extensions-sdk": "10.0.5",
150
- "@wbce-d9/specs": "9.26.5",
151
- "@wbce-d9/constants": "10.0.0",
152
149
  "@wbce-d9/schema": "10.0.0",
150
+ "@wbce-d9/specs": "9.26.5",
153
151
  "@wbce-d9/storage-driver-azure": "10.0.4",
154
- "@wbce-d9/storage": "10.0.0",
155
152
  "@wbce-d9/storage-driver-cloudinary": "10.0.5",
153
+ "@wbce-d9/storage-driver-local": "10.0.4",
156
154
  "@wbce-d9/storage-driver-gcs": "10.0.4",
157
155
  "@wbce-d9/storage-driver-s3": "10.0.4",
158
156
  "@wbce-d9/update-check": "10.0.4",
157
+ "@wbce-d9/storage": "10.0.0",
159
158
  "@wbce-d9/utils": "10.0.4",
160
- "@wbce-d9/storage-driver-local": "10.0.4"
159
+ "@wbce-d9/constants": "10.0.0",
160
+ "@wbce-d9/exceptions": "10.0.1"
161
161
  },
162
162
  "devDependencies": {
163
163
  "@ngneat/falso": "6.4.0",