From 3c9d7e1ed8bdba20db71019ea087dd20ef366608 Mon Sep 17 00:00:00 2001 From: conservationtimothy Date: Fri, 31 Jul 2026 15:26:42 +0100 Subject: [PATCH 1/3] Authorize secondary record fetches via the parent view config --- composables/useRecordCache.ts | 19 +++--- composables/useViewType.ts | 55 ++++++++++++++-- server/api/[table]/[recordId].get.ts | 17 ++++- server/api/[table]/records.post.ts | 17 ++++- server/database/dbOperations.ts | 64 +++++++++++++++++++ tests/unit/composables/useRecordCache.test.ts | 10 ++- tests/unit/composables/useViewType.test.ts | 37 ++++++++++- 7 files changed, 196 insertions(+), 23 deletions(-) diff --git a/composables/useRecordCache.ts b/composables/useRecordCache.ts index bb1f7f9d..def86f03 100644 --- a/composables/useRecordCache.ts +++ b/composables/useRecordCache.ts @@ -1,4 +1,4 @@ -import { resolveViewTypeForTable } from "@/composables/useViewType"; +import { resolveRecordPermissionQuery } from "@/composables/useViewType"; import { encodeDatasetNameForUrl } from "@/utils/identifierUtils"; import type { DataEntry } from "@/types"; @@ -73,13 +73,13 @@ export const useRecordCache = () => { return pending.get(cacheKey)!; } - // resolveViewTypeForTable returns undefined on purpose (cross-table read, non-view - // route, missing :tablename) — callers omit view_type and the server uses its default. - const viewType = resolveViewTypeForTable(route, table); + // Same-table view reads send view_type; companion reads send permission_table + + // view_type so the parent alerts/map view authorizes the secondary table. + const permissionQuery = resolveRecordPermissionQuery(route, table); const url = `/api/${encodeDatasetNameForUrl(table)}/${encodeURIComponent(recordId)}`; const request = ( - viewType - ? $fetch(url, { query: { view_type: viewType } }) + Object.keys(permissionQuery).length > 0 + ? $fetch(url, { query: permissionQuery }) : $fetch(url) ) .then((record) => { @@ -139,13 +139,14 @@ export const useRecordCache = () => { } try { - // Same as fetchRecord: undefined viewType → omit param (see useViewType.ts). - const viewType = resolveViewTypeForTable(route, table); + const permissionQuery = resolveRecordPermissionQuery(route, table); const batchPromises = batches.map((batch) => $fetch(`/api/${encodeDatasetNameForUrl(table)}/records`, { method: "POST", body: { ids: batch }, - ...(viewType ? { query: { view_type: viewType } } : {}), + ...(Object.keys(permissionQuery).length > 0 + ? { query: permissionQuery } + : {}), }), ); const batchResults = await Promise.all(batchPromises); diff --git a/composables/useViewType.ts b/composables/useViewType.ts index bb5170b7..a9a8ca3b 100644 --- a/composables/useViewType.ts +++ b/composables/useViewType.ts @@ -15,8 +15,8 @@ import { decodeDatasetNameFromUrl } from "@/utils/identifierUtils"; * routes derive `view_type` from the current page route and attach it to the * request. When `resolveViewTypeForTable` returns `undefined`, callers omit the * param; the server then falls back to its deterministic default (oldest view by - * `view_id`). Cross-table reads (e.g. alerts page fetching its Mapeo table) omit - * `view_type` — see guard #1 below. + * `view_id`). Cross-table companion reads use `resolveRecordPermissionQuery` + * instead so permissions come from the parent view. */ const VIEW_TYPE_BY_SEGMENT: Record = { map: "map", @@ -24,6 +24,11 @@ const VIEW_TYPE_BY_SEGMENT: Record = { alerts: "alerts", }; +export type RecordPermissionQuery = { + view_type?: ViewType; + permission_table?: string; +}; + /** * Decides whether a data request to the server should carry a `view_type`, and * which one, given the current route and the warehouse table being fetched. @@ -31,6 +36,10 @@ const VIEW_TYPE_BY_SEGMENT: Record = { * Returns a view type only when `table` is the route's own `:tablename` and the * route's first path segment is a known view prefix (`map`, `gallery`, `alerts`). * Otherwise returns `undefined` (callers omit the query param). + * + * @param {{ path: string; params: Record }} route - Current page route. + * @param {string} table - Warehouse table being fetched. + * @returns {ViewType | undefined} View type for same-table reads, else undefined. */ export function resolveViewTypeForTable( route: { path: string; params: Record }, @@ -40,8 +49,8 @@ export function resolveViewTypeForTable( typeof route.params.tablename === "string" ? decodeDatasetNameFromUrl(route.params.tablename) : undefined; - // Guard #1: cross-table reads (e.g. alerts page → Mapeo table) must not carry - // the route's view type. + // Guard #1: cross-table reads must not carry the route's view type alone — + // companion tables have no view row; use resolveRecordPermissionQuery instead. if (!primaryTable || decodeDatasetNameFromUrl(table) !== primaryTable) { return undefined; } @@ -49,3 +58,41 @@ export function resolveViewTypeForTable( const firstSegment = route.path.split("/").filter(Boolean)[0]; return firstSegment ? VIEW_TYPE_BY_SEGMENT[firstSegment] : undefined; } + +/** + * Builds query params for record/list fetches that may target a companion table. + * + * Same-table view reads send `view_type`. Cross-table reads on a view page send + * `permission_table` (route primary) + `view_type` so the server authorizes via + * the parent view while reading the companion warehouse table. + * + * @param {{ path: string; params: Record }} route - Current page route. + * @param {string} table - Warehouse table being fetched. + * @returns {RecordPermissionQuery} Query object (possibly empty). + */ +export function resolveRecordPermissionQuery( + route: { path: string; params: Record }, + table: string, +): RecordPermissionQuery { + const primaryTable = + typeof route.params.tablename === "string" + ? decodeDatasetNameFromUrl(route.params.tablename) + : undefined; + const firstSegment = route.path.split("/").filter(Boolean)[0]; + const routeViewType = firstSegment + ? VIEW_TYPE_BY_SEGMENT[firstSegment] + : undefined; + + if (!primaryTable || !routeViewType) { + return {}; + } + + if (decodeDatasetNameFromUrl(table) === primaryTable) { + return { view_type: routeViewType }; + } + + return { + view_type: routeViewType, + permission_table: primaryTable, + }; +} diff --git a/server/api/[table]/[recordId].get.ts b/server/api/[table]/[recordId].get.ts index b565b466..a8aab3e8 100644 --- a/server/api/[table]/[recordId].get.ts +++ b/server/api/[table]/[recordId].get.ts @@ -1,4 +1,7 @@ -import { fetchRecord, fetchTableConfig } from "@/server/database/dbOperations"; +import { + fetchRecord, + fetchTableConfigForDataAccess, +} from "@/server/database/dbOperations"; import { getRecordIdParam, getTableParam } from "@/server/utils/dbHelpers"; import { validatePermissions } from "@/utils/accessControls"; @@ -8,10 +11,18 @@ import type { ViewType } from "@/types"; export default defineEventHandler(async (event: H3Event) => { const table = getTableParam(event); const recordId = getRecordIdParam(event); - const viewType = getQuery(event).view_type as ViewType | undefined; + const query = getQuery(event); + const viewType = query.view_type as ViewType | undefined; + const permissionTable = + typeof query.permission_table === "string" + ? query.permission_table + : undefined; try { - const tableConfig = await fetchTableConfig(table, viewType); + const tableConfig = await fetchTableConfigForDataAccess(table, { + viewType, + permissionTable, + }); // Check visibility permissions const permission = tableConfig.ROUTE_LEVEL_PERMISSION ?? "member"; diff --git a/server/api/[table]/records.post.ts b/server/api/[table]/records.post.ts index 72a8770b..8d05495f 100644 --- a/server/api/[table]/records.post.ts +++ b/server/api/[table]/records.post.ts @@ -1,4 +1,7 @@ -import { fetchRecords, fetchTableConfig } from "@/server/database/dbOperations"; +import { + fetchRecords, + fetchTableConfigForDataAccess, +} from "@/server/database/dbOperations"; import { getTableParam } from "@/server/utils/dbHelpers"; import { validatePermissions } from "@/utils/accessControls"; @@ -9,7 +12,12 @@ const MAX_IDS = 500; /** NOTE: The endpoint does not guarantee that records are returned in the same order as requested IDs. Consumers must not rely on response ordering. */ export default defineEventHandler(async (event: H3Event) => { const table = getTableParam(event); - const viewType = getQuery(event).view_type as ViewType | undefined; + const query = getQuery(event); + const viewType = query.view_type as ViewType | undefined; + const permissionTable = + typeof query.permission_table === "string" + ? query.permission_table + : undefined; const body = await readBody(event); @@ -37,7 +45,10 @@ export default defineEventHandler(async (event: H3Event) => { } try { - const tableConfig = await fetchTableConfig(table, viewType); + const tableConfig = await fetchTableConfigForDataAccess(table, { + viewType, + permissionTable, + }); const permission = tableConfig.ROUTE_LEVEL_PERMISSION ?? "member"; await validatePermissions(event, permission); diff --git a/server/database/dbOperations.ts b/server/database/dbOperations.ts index d349ae26..d1c824f0 100644 --- a/server/database/dbOperations.ts +++ b/server/database/dbOperations.ts @@ -611,6 +611,70 @@ export const fetchTableConfig = async ( } }; +/** + * Loads view config used to authorize a warehouse table read. + * + * Same-table reads use `dataTable` (+ optional `viewType`). Cross-table companion + * reads (e.g. alerts secondary dataset) pass `permissionTable` = the parent view's + * primary dataset plus `viewType`; `dataTable` must be that view's primary or + * secondary table. + * + * @param {string} dataTable - Warehouse table being read. + * @param {{ viewType?: ViewType; permissionTable?: string | null }} [options] - Auth scope. + * @returns {Promise} View config for permission checks. + */ +export const fetchTableConfigForDataAccess = async ( + dataTable: string, + options: { + viewType?: ViewType; + permissionTable?: string | null; + } = {}, +): Promise => { + const normalizedDataTable = normalizeTableName(dataTable); + const permissionTable = options.permissionTable?.trim() + ? normalizeTableName(options.permissionTable) + : null; + + if (!permissionTable) { + return fetchTableConfig(normalizedDataTable, options.viewType); + } + + if (!options.viewType) { + throw Object.assign( + new Error("view_type is required when permission_table is set"), + { + statusCode: 400, + statusMessage: "view_type is required when permission_table is set", + }, + ); + } + + const { primaryTable, secondaryTable } = await fetchViewTables( + permissionTable, + options.viewType, + ); + + const allowed = new Set( + [primaryTable, secondaryTable].filter((name): name is string => + Boolean(name), + ), + ); + + if (!allowed.has(normalizedDataTable)) { + throw Object.assign( + new Error( + `Table "${normalizedDataTable}" is not part of view (${permissionTable}, ${options.viewType})`, + ), + { + statusCode: 403, + statusMessage: `Table "${normalizedDataTable}" is not part of the specified view`, + }, + ); + } + + return fetchTableConfig(permissionTable, options.viewType); +}; + /** * Keeps public_views in sync with view config: add table if permission is anyone, remove otherwise. * @param tableName - The table name to sync. diff --git a/tests/unit/composables/useRecordCache.test.ts b/tests/unit/composables/useRecordCache.test.ts index 1ffb0c85..e7ef80c9 100644 --- a/tests/unit/composables/useRecordCache.test.ts +++ b/tests/unit/composables/useRecordCache.test.ts @@ -274,7 +274,7 @@ describe("useRecordCache - view_type threading", () => { }); }); - it("fetchRecord omits view_type when reading a different table (e.g. an alerts page's Mapeo table)", async () => { + it("fetchRecord authorizes companion reads via the parent view (permission_table)", async () => { mockRoute = { path: "/alerts/primary_alerts", params: { tablename: "primary_alerts" }, @@ -284,8 +284,12 @@ describe("useRecordCache - view_type threading", () => { const { fetchRecord } = useRecordCache(); await fetchRecord("mapeo_secondary", "abc"); - // No view type for a cross-table read → no options object at all. - expect(mockFetch).toHaveBeenCalledWith("/api/mapeo_secondary/abc"); + expect(mockFetch).toHaveBeenCalledWith("/api/mapeo_secondary/abc", { + query: { + view_type: "alerts", + permission_table: "primary_alerts", + }, + }); }); it("fetchRecords sends the route's view_type for its own dataset", async () => { diff --git a/tests/unit/composables/useViewType.test.ts b/tests/unit/composables/useViewType.test.ts index 8d697299..ef4165c8 100644 --- a/tests/unit/composables/useViewType.test.ts +++ b/tests/unit/composables/useViewType.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect } from "vitest"; -import { resolveViewTypeForTable } from "@/composables/useViewType"; +import { + resolveRecordPermissionQuery, + resolveViewTypeForTable, +} from "@/composables/useViewType"; // resolveViewTypeForTable decides whether a data request should carry a // view_type, and which one. It encodes two deliberate guards that this suite @@ -69,3 +72,35 @@ describe("resolveViewTypeForTable", () => { ).toBeUndefined(); }); }); + +describe("resolveRecordPermissionQuery", () => { + it("sends view_type for the route's own dataset", () => { + expect( + resolveRecordPermissionQuery( + { path: "/alerts/springfield", params: { tablename: "springfield" } }, + "springfield", + ), + ).toEqual({ view_type: "alerts" }); + }); + + it("sends permission_table + view_type for companion table reads", () => { + expect( + resolveRecordPermissionQuery( + { path: "/alerts/springfield", params: { tablename: "springfield" } }, + "mapeo_data", + ), + ).toEqual({ + view_type: "alerts", + permission_table: "springfield", + }); + }); + + it("returns an empty query off view routes", () => { + expect( + resolveRecordPermissionQuery( + { path: "/dataset/springfield", params: { tablename: "springfield" } }, + "mapeo_data", + ), + ).toEqual({}); + }); +}); From 5a2958ae649c857e47a23eeb682b04c683b57789 Mon Sep 17 00:00:00 2001 From: conservationtimothy Date: Mon, 3 Aug 2026 19:06:05 +0100 Subject: [PATCH 2/3] Clarify secondary record fetches using primary dataset context --- composables/useRecordCache.ts | 16 +++--- composables/useViewType.ts | 44 +++++++-------- server/api/[table]/[recordId].get.ts | 12 ++--- server/api/[table]/records.post.ts | 12 ++--- server/database/dbOperations.ts | 53 ++++++++----------- tests/unit/composables/useRecordCache.test.ts | 4 +- tests/unit/composables/useViewType.test.ts | 14 ++--- 7 files changed, 70 insertions(+), 85 deletions(-) diff --git a/composables/useRecordCache.ts b/composables/useRecordCache.ts index def86f03..846baec5 100644 --- a/composables/useRecordCache.ts +++ b/composables/useRecordCache.ts @@ -1,4 +1,4 @@ -import { resolveRecordPermissionQuery } from "@/composables/useViewType"; +import { resolveRecordFetchQuery } from "@/composables/useViewType"; import { encodeDatasetNameForUrl } from "@/utils/identifierUtils"; import type { DataEntry } from "@/types"; @@ -73,13 +73,11 @@ export const useRecordCache = () => { return pending.get(cacheKey)!; } - // Same-table view reads send view_type; companion reads send permission_table + - // view_type so the parent alerts/map view authorizes the secondary table. - const permissionQuery = resolveRecordPermissionQuery(route, table); + const query = resolveRecordFetchQuery(route, table); const url = `/api/${encodeDatasetNameForUrl(table)}/${encodeURIComponent(recordId)}`; const request = ( - Object.keys(permissionQuery).length > 0 - ? $fetch(url, { query: permissionQuery }) + Object.keys(query).length > 0 + ? $fetch(url, { query }) : $fetch(url) ) .then((record) => { @@ -139,14 +137,12 @@ export const useRecordCache = () => { } try { - const permissionQuery = resolveRecordPermissionQuery(route, table); + const query = resolveRecordFetchQuery(route, table); const batchPromises = batches.map((batch) => $fetch(`/api/${encodeDatasetNameForUrl(table)}/records`, { method: "POST", body: { ids: batch }, - ...(Object.keys(permissionQuery).length > 0 - ? { query: permissionQuery } - : {}), + ...(Object.keys(query).length > 0 ? { query } : {}), }), ); const batchResults = await Promise.all(batchPromises); diff --git a/composables/useViewType.ts b/composables/useViewType.ts index a9a8ca3b..bf74bb3d 100644 --- a/composables/useViewType.ts +++ b/composables/useViewType.ts @@ -15,8 +15,8 @@ import { decodeDatasetNameFromUrl } from "@/utils/identifierUtils"; * routes derive `view_type` from the current page route and attach it to the * request. When `resolveViewTypeForTable` returns `undefined`, callers omit the * param; the server then falls back to its deterministic default (oldest view by - * `view_id`). Cross-table companion reads use `resolveRecordPermissionQuery` - * instead so permissions come from the parent view. + * `view_id`). Cross-table reads (e.g. alerts page fetching its Mapeo table) omit + * `view_type` — see guard #1 below. */ const VIEW_TYPE_BY_SEGMENT: Record = { map: "map", @@ -24,9 +24,9 @@ const VIEW_TYPE_BY_SEGMENT: Record = { alerts: "alerts", }; -export type RecordPermissionQuery = { +type RecordFetchQuery = { view_type?: ViewType; - permission_table?: string; + primary_dataset?: string; }; /** @@ -36,10 +36,6 @@ export type RecordPermissionQuery = { * Returns a view type only when `table` is the route's own `:tablename` and the * route's first path segment is a known view prefix (`map`, `gallery`, `alerts`). * Otherwise returns `undefined` (callers omit the query param). - * - * @param {{ path: string; params: Record }} route - Current page route. - * @param {string} table - Warehouse table being fetched. - * @returns {ViewType | undefined} View type for same-table reads, else undefined. */ export function resolveViewTypeForTable( route: { path: string; params: Record }, @@ -49,8 +45,8 @@ export function resolveViewTypeForTable( typeof route.params.tablename === "string" ? decodeDatasetNameFromUrl(route.params.tablename) : undefined; - // Guard #1: cross-table reads must not carry the route's view type alone — - // companion tables have no view row; use resolveRecordPermissionQuery instead. + // Guard #1: cross-table reads (e.g. alerts page → Mapeo table) must not carry + // the route's view type. if (!primaryTable || decodeDatasetNameFromUrl(table) !== primaryTable) { return undefined; } @@ -60,21 +56,21 @@ export function resolveViewTypeForTable( } /** - * Builds query params for record/list fetches that may target a companion table. + * Builds query params for record requests. * - * Same-table view reads send `view_type`. Cross-table reads on a view page send - * `permission_table` (route primary) + `view_type` so the server authorizes via - * the parent view while reading the companion warehouse table. + * Requests for the view's primary dataset send `view_type`. Requests for its + * secondary dataset also send `primary_dataset`, which identifies the view + * configuration that lists the requested secondary dataset. * * @param {{ path: string; params: Record }} route - Current page route. - * @param {string} table - Warehouse table being fetched. - * @returns {RecordPermissionQuery} Query object (possibly empty). + * @param {string} requestedDataset - Dataset being fetched. + * @returns {RecordFetchQuery} Query object (possibly empty). */ -export function resolveRecordPermissionQuery( +export const resolveRecordFetchQuery = ( route: { path: string; params: Record }, - table: string, -): RecordPermissionQuery { - const primaryTable = + requestedDataset: string, +): RecordFetchQuery => { + const primaryDataset = typeof route.params.tablename === "string" ? decodeDatasetNameFromUrl(route.params.tablename) : undefined; @@ -83,16 +79,16 @@ export function resolveRecordPermissionQuery( ? VIEW_TYPE_BY_SEGMENT[firstSegment] : undefined; - if (!primaryTable || !routeViewType) { + if (!primaryDataset || !routeViewType) { return {}; } - if (decodeDatasetNameFromUrl(table) === primaryTable) { + if (decodeDatasetNameFromUrl(requestedDataset) === primaryDataset) { return { view_type: routeViewType }; } return { view_type: routeViewType, - permission_table: primaryTable, + primary_dataset: primaryDataset, }; -} +}; diff --git a/server/api/[table]/[recordId].get.ts b/server/api/[table]/[recordId].get.ts index a8aab3e8..25f07b12 100644 --- a/server/api/[table]/[recordId].get.ts +++ b/server/api/[table]/[recordId].get.ts @@ -1,6 +1,6 @@ import { fetchRecord, - fetchTableConfigForDataAccess, + fetchViewConfigForDatasetRead, } from "@/server/database/dbOperations"; import { getRecordIdParam, getTableParam } from "@/server/utils/dbHelpers"; import { validatePermissions } from "@/utils/accessControls"; @@ -13,15 +13,15 @@ export default defineEventHandler(async (event: H3Event) => { const recordId = getRecordIdParam(event); const query = getQuery(event); const viewType = query.view_type as ViewType | undefined; - const permissionTable = - typeof query.permission_table === "string" - ? query.permission_table + const primaryDataset = + typeof query.primary_dataset === "string" + ? query.primary_dataset : undefined; try { - const tableConfig = await fetchTableConfigForDataAccess(table, { + const tableConfig = await fetchViewConfigForDatasetRead(table, { viewType, - permissionTable, + primaryDataset, }); // Check visibility permissions diff --git a/server/api/[table]/records.post.ts b/server/api/[table]/records.post.ts index 8d05495f..c4abac3b 100644 --- a/server/api/[table]/records.post.ts +++ b/server/api/[table]/records.post.ts @@ -1,6 +1,6 @@ import { fetchRecords, - fetchTableConfigForDataAccess, + fetchViewConfigForDatasetRead, } from "@/server/database/dbOperations"; import { getTableParam } from "@/server/utils/dbHelpers"; import { validatePermissions } from "@/utils/accessControls"; @@ -14,9 +14,9 @@ export default defineEventHandler(async (event: H3Event) => { const table = getTableParam(event); const query = getQuery(event); const viewType = query.view_type as ViewType | undefined; - const permissionTable = - typeof query.permission_table === "string" - ? query.permission_table + const primaryDataset = + typeof query.primary_dataset === "string" + ? query.primary_dataset : undefined; const body = await readBody(event); @@ -45,9 +45,9 @@ export default defineEventHandler(async (event: H3Event) => { } try { - const tableConfig = await fetchTableConfigForDataAccess(table, { + const tableConfig = await fetchViewConfigForDatasetRead(table, { viewType, - permissionTable, + primaryDataset, }); const permission = tableConfig.ROUTE_LEVEL_PERMISSION ?? "member"; await validatePermissions(event, permission); diff --git a/server/database/dbOperations.ts b/server/database/dbOperations.ts index d1c824f0..7a7f2fd1 100644 --- a/server/database/dbOperations.ts +++ b/server/database/dbOperations.ts @@ -612,67 +612,60 @@ export const fetchTableConfig = async ( }; /** - * Loads view config used to authorize a warehouse table read. + * Returns the view config for a dataset read. * - * Same-table reads use `dataTable` (+ optional `viewType`). Cross-table companion - * reads (e.g. alerts secondary dataset) pass `permissionTable` = the parent view's - * primary dataset plus `viewType`; `dataTable` must be that view's primary or - * secondary table. + * View configs are keyed by primary dataset and view type. A secondary dataset + * request therefore includes both, and must match the configured secondary + * dataset before using that config. * - * @param {string} dataTable - Warehouse table being read. - * @param {{ viewType?: ViewType; permissionTable?: string | null }} [options] - Auth scope. - * @returns {Promise} View config for permission checks. + * @param {string} requestedDataset - Warehouse dataset being read. + * @param {{ viewType?: ViewType; primaryDataset?: string | null }} [options] - View identity. + * @returns {Promise} Config for the requested dataset's view. */ -export const fetchTableConfigForDataAccess = async ( - dataTable: string, +export const fetchViewConfigForDatasetRead = async ( + requestedDataset: string, options: { viewType?: ViewType; - permissionTable?: string | null; + primaryDataset?: string | null; } = {}, ): Promise => { - const normalizedDataTable = normalizeTableName(dataTable); - const permissionTable = options.permissionTable?.trim() - ? normalizeTableName(options.permissionTable) + const normalizedRequestedDataset = normalizeTableName(requestedDataset); + const primaryDataset = options.primaryDataset?.trim() + ? normalizeTableName(options.primaryDataset) : null; - if (!permissionTable) { - return fetchTableConfig(normalizedDataTable, options.viewType); + if (!primaryDataset) { + return fetchTableConfig(normalizedRequestedDataset, options.viewType); } if (!options.viewType) { throw Object.assign( - new Error("view_type is required when permission_table is set"), + new Error("view_type is required when primary_dataset is set"), { statusCode: 400, - statusMessage: "view_type is required when permission_table is set", + statusMessage: "view_type is required when primary_dataset is set", }, ); } - const { primaryTable, secondaryTable } = await fetchViewTables( - permissionTable, + const { secondaryTable: secondaryDataset } = await fetchViewTables( + primaryDataset, options.viewType, ); - const allowed = new Set( - [primaryTable, secondaryTable].filter((name): name is string => - Boolean(name), - ), - ); - - if (!allowed.has(normalizedDataTable)) { + if (secondaryDataset !== normalizedRequestedDataset) { throw Object.assign( new Error( - `Table "${normalizedDataTable}" is not part of view (${permissionTable}, ${options.viewType})`, + `Dataset "${normalizedRequestedDataset}" is not the secondary dataset for view (${primaryDataset}, ${options.viewType})`, ), { statusCode: 403, - statusMessage: `Table "${normalizedDataTable}" is not part of the specified view`, + statusMessage: `Dataset "${normalizedRequestedDataset}" is not the configured secondary dataset`, }, ); } - return fetchTableConfig(permissionTable, options.viewType); + return fetchTableConfig(primaryDataset, options.viewType); }; /** diff --git a/tests/unit/composables/useRecordCache.test.ts b/tests/unit/composables/useRecordCache.test.ts index e7ef80c9..ad06934d 100644 --- a/tests/unit/composables/useRecordCache.test.ts +++ b/tests/unit/composables/useRecordCache.test.ts @@ -274,7 +274,7 @@ describe("useRecordCache - view_type threading", () => { }); }); - it("fetchRecord authorizes companion reads via the parent view (permission_table)", async () => { + it("fetchRecord identifies the view when reading its secondary dataset", async () => { mockRoute = { path: "/alerts/primary_alerts", params: { tablename: "primary_alerts" }, @@ -287,7 +287,7 @@ describe("useRecordCache - view_type threading", () => { expect(mockFetch).toHaveBeenCalledWith("/api/mapeo_secondary/abc", { query: { view_type: "alerts", - permission_table: "primary_alerts", + primary_dataset: "primary_alerts", }, }); }); diff --git a/tests/unit/composables/useViewType.test.ts b/tests/unit/composables/useViewType.test.ts index ef4165c8..8c52fadc 100644 --- a/tests/unit/composables/useViewType.test.ts +++ b/tests/unit/composables/useViewType.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { - resolveRecordPermissionQuery, + resolveRecordFetchQuery, resolveViewTypeForTable, } from "@/composables/useViewType"; @@ -73,31 +73,31 @@ describe("resolveViewTypeForTable", () => { }); }); -describe("resolveRecordPermissionQuery", () => { +describe("resolveRecordFetchQuery", () => { it("sends view_type for the route's own dataset", () => { expect( - resolveRecordPermissionQuery( + resolveRecordFetchQuery( { path: "/alerts/springfield", params: { tablename: "springfield" } }, "springfield", ), ).toEqual({ view_type: "alerts" }); }); - it("sends permission_table + view_type for companion table reads", () => { + it("identifies the view when fetching its secondary dataset", () => { expect( - resolveRecordPermissionQuery( + resolveRecordFetchQuery( { path: "/alerts/springfield", params: { tablename: "springfield" } }, "mapeo_data", ), ).toEqual({ view_type: "alerts", - permission_table: "springfield", + primary_dataset: "springfield", }); }); it("returns an empty query off view routes", () => { expect( - resolveRecordPermissionQuery( + resolveRecordFetchQuery( { path: "/dataset/springfield", params: { tablename: "springfield" } }, "mapeo_data", ), From 7f801f04daddb5e9f21603c6d6c3c3ee6782e6ce Mon Sep 17 00:00:00 2001 From: conservationtimothy Date: Tue, 4 Aug 2026 07:28:53 +0100 Subject: [PATCH 3/3] move view types --- composables/useViewType.ts | 7 +------ types/index.ts | 5 +++++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/composables/useViewType.ts b/composables/useViewType.ts index bf74bb3d..9b63b183 100644 --- a/composables/useViewType.ts +++ b/composables/useViewType.ts @@ -1,4 +1,4 @@ -import type { ViewType } from "@/types"; +import type { RecordFetchQuery, ViewType } from "@/types"; import { decodeDatasetNameFromUrl } from "@/utils/identifierUtils"; /** @@ -24,11 +24,6 @@ const VIEW_TYPE_BY_SEGMENT: Record = { alerts: "alerts", }; -type RecordFetchQuery = { - view_type?: ViewType; - primary_dataset?: string; -}; - /** * Decides whether a data request to the server should carry a `view_type`, and * which one, given the current route and the warehouse table being fetched. diff --git a/types/index.ts b/types/index.ts index bc061165..3b42ca6d 100644 --- a/types/index.ts +++ b/types/index.ts @@ -125,6 +125,11 @@ export interface Views { export type ViewType = "alerts" | "map" | "gallery"; +export type RecordFetchQuery = { + view_type?: ViewType; + primary_dataset?: string; +}; + export const VIEW_TYPES = [ "alerts", "map",