velocious 1.0.459 → 1.0.461

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.
@@ -6,6 +6,10 @@ import {serializeFrontendModelTransportValue} from "./transport-serialization.js
6
6
 
7
7
  const EVENT_FILTER_KEYS = new Set(["joins", "key", "searches", "where"])
8
8
 
9
+ // Mirrors FRONTEND_MODELS_CHANNEL_NAME in ./websocket-publishers.js, duplicated here
10
+ // to avoid the configuration → logger → websocket-publishers import cycle.
11
+ const FRONTEND_MODELS_CHANNEL_NAME = "frontend-models"
12
+
9
13
  /**
10
14
  * Defines this typedef.
11
15
  * @typedef {{action?: string, id?: string | number, matchedEventFilterKeys?: string[], record?: import("./query.js").FrontendModelTransportValue, [key: string]: import("./query.js").FrontendModelTransportValue | string[] | undefined}} FrontendModelLifecycleBroadcastBody
@@ -360,6 +364,48 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
360
364
  return controller
361
365
  }
362
366
 
367
+ /**
368
+ * Resolves the subscriber's tenant for the broadcast record and runs `callback` inside that tenant
369
+ * context. Broadcast delivery runs in whatever ambient tenant context the publisher left behind. For
370
+ * multi-tenant records that ambient tenant may have been resolved without the subscriber's request
371
+ * (e.g. a relay endpoint or background job mutating the row), so it lacks the subscriber's per-record
372
+ * access flags and the per-event authorization query wrongly finds nothing. Re-resolving the tenant
373
+ * from the event record id plus the subscriber's request makes the authorization queries run against
374
+ * the subscriber's own tenant/ability scope. When no tenant resolves (non-multitenant configs), the
375
+ * callback runs directly so the ambient context is preserved.
376
+ * @template T
377
+ * @param {string | number} id - Event record id.
378
+ * @param {() => Promise<T>} callback - Authorized-query callback.
379
+ * @returns {Promise<T>} - Callback result.
380
+ */
381
+ async _withEventTenant(id, callback) {
382
+ const configuration = this.session.configuration
383
+
384
+ if (!configuration || typeof configuration.resolveTenant !== "function") {
385
+ return await callback()
386
+ }
387
+
388
+ // Mirror the subscribe-time tenant resolution (`WebsocketSession._resolveTenant`):
389
+ // pass `subscription: {channel, params}` so resolvers that derive scope from the
390
+ // subscription behave the same for broadcasts as they did at `channel-subscribe`.
391
+ // The synthetic request forwards the subscriber's params (e.g. authenticationToken),
392
+ // matching this channel's ability resolution above.
393
+ const tenant = await configuration.resolveTenant({
394
+ params: {...this.params, id, model: this._modelName()},
395
+ request: /** @type {import("../http-server/client/request.js").default} */ (this._syntheticRequest()),
396
+ response: new Response({configuration}),
397
+ subscription: {channel: FRONTEND_MODELS_CHANNEL_NAME, params: this.params}
398
+ })
399
+
400
+ // Always enter `runWithTenant`, even when no tenant resolved. Broadcast fan-out
401
+ // runs in the publisher's ambient tenant context; falling back to `callback()`
402
+ // there would authorize a cross-tenant record against the publisher's tenant and
403
+ // could leak it to a subscriber whose own resolver could not resolve it.
404
+ return await configuration.runWithTenant(tenant, async () => {
405
+ return await configuration.ensureConnections({name: "Frontend model websocket event tenant"}, callback)
406
+ })
407
+ }
408
+
363
409
  /**
364
410
  * Whether the broadcast record is within the subscriber's authenticated ability scope. Used to gate
365
411
  * unfiltered/unprojected create/update delivery so a scoped token never receives a record it cannot read.
@@ -368,15 +414,17 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
368
414
  * @returns {Promise<boolean>} True when the record is readable by this subscription.
369
415
  */
370
416
  async _eventIsAccessible(id, FrontendModelController) {
371
- const controller = this._frontendModelController(FrontendModelController)
417
+ return await this._withEventTenant(id, async () => {
418
+ const controller = this._frontendModelController(FrontendModelController)
372
419
 
373
- await controller.ensureFrontendModelClassInitialized()
420
+ await controller.ensureFrontendModelClassInitialized()
374
421
 
375
- const ModelClass = controller.frontendModelClass()
376
- const primaryKey = ModelClass.primaryKey()
377
- const query = controller.frontendModelAuthorizedQuery("find").where({[ModelClass.tableName()]: {[primaryKey]: id}})
422
+ const ModelClass = controller.frontendModelClass()
423
+ const primaryKey = ModelClass.primaryKey()
424
+ const query = controller.frontendModelAuthorizedQuery("find").where({[ModelClass.tableName()]: {[primaryKey]: id}})
378
425
 
379
- return Boolean(await query.first())
426
+ return Boolean(await query.first())
427
+ })
380
428
  }
381
429
 
382
430
  /**
@@ -413,30 +461,32 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
413
461
  * @returns {Promise<boolean>} Whether the record matches the filter.
414
462
  */
415
463
  async _eventMatchesFilter({FrontendModelController, eventFilter, id}) {
416
- const controller = this._frontendModelController(FrontendModelController, {
417
- joins: eventFilter.joins,
418
- searches: eventFilter.searches,
419
- where: eventFilter.where
420
- })
464
+ return await this._withEventTenant(id, async () => {
465
+ const controller = this._frontendModelController(FrontendModelController, {
466
+ joins: eventFilter.joins,
467
+ searches: eventFilter.searches,
468
+ where: eventFilter.where
469
+ })
421
470
 
422
- await controller.ensureFrontendModelClassInitialized()
471
+ await controller.ensureFrontendModelClassInitialized()
423
472
 
424
- const ModelClass = controller.frontendModelClass()
425
- const primaryKey = ModelClass.primaryKey()
426
- const where = controller.frontendModelWhere()
427
- const joins = controller.frontendModelJoins()
428
- // Start from the subscriber's authorized scope so a filter can only ever match records the
429
- // subscription's ability permits to read.
430
- let query = controller.frontendModelAuthorizedQuery("find").where({[ModelClass.tableName()]: {[primaryKey]: id}})
473
+ const ModelClass = controller.frontendModelClass()
474
+ const primaryKey = ModelClass.primaryKey()
475
+ const where = controller.frontendModelWhere()
476
+ const joins = controller.frontendModelJoins()
477
+ // Start from the subscriber's authorized scope so a filter can only ever match records the
478
+ // subscription's ability permits to read.
479
+ let query = controller.frontendModelAuthorizedQuery("find").where({[ModelClass.tableName()]: {[primaryKey]: id}})
431
480
 
432
- if (where) controller.applyFrontendModelWhere({query, where})
433
- if (joins) controller.applyFrontendModelJoins({joins, query})
481
+ if (where) controller.applyFrontendModelWhere({query, where})
482
+ if (joins) controller.applyFrontendModelJoins({joins, query})
434
483
 
435
- for (const search of controller.frontendModelSearches()) {
436
- controller.applyFrontendModelSearch({query, search})
437
- }
484
+ for (const search of controller.frontendModelSearches()) {
485
+ controller.applyFrontendModelSearch({query, search})
486
+ }
438
487
 
439
- return Boolean(await query.first())
488
+ return Boolean(await query.first())
489
+ })
440
490
  }
441
491
 
442
492
  /**
@@ -446,51 +496,53 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
446
496
  * @returns {Promise<Record<string, import("./query.js").FrontendModelTransportValue> | null>} - Serialized projected record.
447
497
  */
448
498
  async _projectedRecordForEventId(id, FrontendModelController) {
449
- const controller = this._frontendModelController(FrontendModelController)
450
-
451
- await controller.ensureFrontendModelClassInitialized()
452
-
453
- const ModelClass = controller.frontendModelClass()
454
- const primaryKey = ModelClass.primaryKey()
455
- // Reload through the subscriber's authorized scope so projected records are only ever sent for
456
- // rows the subscription's ability permits to read.
457
- let query = controller.frontendModelAuthorizedQuery("find").where({[ModelClass.tableName()]: {[primaryKey]: id}})
458
- const preload = controller.frontendModelPreload()
459
-
460
- if (preload) query = query.preload(preload)
461
-
462
- for (const entry of controller.frontendModelWithCount()) {
463
- /**
464
- * Spec.
465
- * @type {Record<string, boolean | {relationship?: string, where?: Record<string, import("./query.js").FrontendModelTransportValue>}>} */
466
- const spec = {}
467
-
468
- spec[entry.attributeName] = {
469
- relationship: entry.relationshipName,
470
- where: entry.where ? /**
471
- * Narrows the runtime value to the documented type.
472
- * @type {Record<string, import("./query.js").FrontendModelTransportValue>} */ (entry.where) : undefined
499
+ return await this._withEventTenant(id, async () => {
500
+ const controller = this._frontendModelController(FrontendModelController)
501
+
502
+ await controller.ensureFrontendModelClassInitialized()
503
+
504
+ const ModelClass = controller.frontendModelClass()
505
+ const primaryKey = ModelClass.primaryKey()
506
+ // Reload through the subscriber's authorized scope so projected records are only ever sent for
507
+ // rows the subscription's ability permits to read.
508
+ let query = controller.frontendModelAuthorizedQuery("find").where({[ModelClass.tableName()]: {[primaryKey]: id}})
509
+ const preload = controller.frontendModelPreload()
510
+
511
+ if (preload) query = query.preload(preload)
512
+
513
+ for (const entry of controller.frontendModelWithCount()) {
514
+ /**
515
+ * Spec.
516
+ * @type {Record<string, boolean | {relationship?: string, where?: Record<string, import("./query.js").FrontendModelTransportValue>}>} */
517
+ const spec = {}
518
+
519
+ spec[entry.attributeName] = {
520
+ relationship: entry.relationshipName,
521
+ where: entry.where ? /**
522
+ * Narrows the runtime value to the documented type.
523
+ * @type {Record<string, import("./query.js").FrontendModelTransportValue>} */ (entry.where) : undefined
524
+ }
525
+ query.withCount(spec)
473
526
  }
474
- query.withCount(spec)
475
- }
476
527
 
477
- const queryData = controller.frontendModelQueryData()
528
+ const queryData = controller.frontendModelQueryData()
478
529
 
479
- if (queryData !== null) query.queryData(queryData)
530
+ if (queryData !== null) query.queryData(queryData)
480
531
 
481
- query = controller.applyFrontendModelTranslatedAttributePreloads({query})
532
+ query = controller.applyFrontendModelTranslatedAttributePreloads({query})
482
533
 
483
- const model = await query.first()
534
+ const model = await query.first()
484
535
 
485
- if (!model) return null
536
+ if (!model) return null
486
537
 
487
- if (this.params.abilities !== undefined) {
488
- await controller.frontendModelComputeAbilities([model])
489
- }
538
+ if (this.params.abilities !== undefined) {
539
+ await controller.frontendModelComputeAbilities([model])
540
+ }
490
541
 
491
- controller._frontendModelAbilityOverride = undefined
542
+ controller._frontendModelAbilityOverride = undefined
492
543
 
493
- return await controller.frontendModelResourceInstance().serialize(model, "find")
544
+ return await controller.frontendModelResourceInstance().serialize(model, "find")
545
+ })
494
546
  }
495
547
 
496
548
  /**