@btst/stack 5.0.0 → 5.1.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.
Files changed (113) hide show
  1. package/dist/api/index.d.cts +2 -2
  2. package/dist/api/index.d.mts +2 -2
  3. package/dist/api/index.d.ts +2 -2
  4. package/dist/client/index.d.cts +2 -2
  5. package/dist/client/index.d.mts +2 -2
  6. package/dist/client/index.d.ts +2 -2
  7. package/dist/context/index.d.cts +1 -1
  8. package/dist/context/index.d.mts +1 -1
  9. package/dist/context/index.d.ts +1 -1
  10. package/dist/index.d.cts +1 -1
  11. package/dist/index.d.mts +1 -1
  12. package/dist/index.d.ts +1 -1
  13. package/dist/packages/stack/src/plugins/cms/api/getters.cjs +142 -6
  14. package/dist/packages/stack/src/plugins/cms/api/getters.mjs +139 -8
  15. package/dist/packages/stack/src/plugins/cms/api/operations.cjs +67 -61
  16. package/dist/packages/stack/src/plugins/cms/api/operations.mjs +69 -63
  17. package/dist/packages/stack/src/plugins/cms/api/plugin.cjs +26 -8
  18. package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +27 -9
  19. package/dist/packages/stack/src/plugins/cms/api/query-key-defs.cjs +15 -1
  20. package/dist/packages/stack/src/plugins/cms/api/query-key-defs.mjs +14 -2
  21. package/dist/packages/stack/src/plugins/cms/client/hooks/cms-hooks.cjs +45 -24
  22. package/dist/packages/stack/src/plugins/cms/client/hooks/cms-hooks.mjs +45 -24
  23. package/dist/packages/stack/src/plugins/cms/schemas.cjs +22 -0
  24. package/dist/packages/stack/src/plugins/cms/schemas.mjs +20 -1
  25. package/dist/plugins/api/index.d.cts +2 -2
  26. package/dist/plugins/api/index.d.mts +2 -2
  27. package/dist/plugins/api/index.d.ts +2 -2
  28. package/dist/plugins/blog/api/index.d.cts +2 -2
  29. package/dist/plugins/blog/api/index.d.mts +2 -2
  30. package/dist/plugins/blog/api/index.d.ts +2 -2
  31. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  32. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  33. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  34. package/dist/plugins/blog/client/index.d.cts +2 -2
  35. package/dist/plugins/blog/client/index.d.mts +2 -2
  36. package/dist/plugins/blog/client/index.d.ts +2 -2
  37. package/dist/plugins/blog/query-keys.d.cts +2 -2
  38. package/dist/plugins/blog/query-keys.d.mts +2 -2
  39. package/dist/plugins/blog/query-keys.d.ts +2 -2
  40. package/dist/plugins/client/index.d.cts +2 -2
  41. package/dist/plugins/client/index.d.mts +2 -2
  42. package/dist/plugins/client/index.d.ts +2 -2
  43. package/dist/plugins/cms/api/index.cjs +1 -0
  44. package/dist/plugins/cms/api/index.d.cts +43 -11
  45. package/dist/plugins/cms/api/index.d.mts +43 -11
  46. package/dist/plugins/cms/api/index.d.ts +43 -11
  47. package/dist/plugins/cms/api/index.mjs +1 -1
  48. package/dist/plugins/cms/client/hooks/index.d.cts +90 -45
  49. package/dist/plugins/cms/client/hooks/index.d.mts +90 -45
  50. package/dist/plugins/cms/client/hooks/index.d.ts +90 -45
  51. package/dist/plugins/cms/client/index.d.cts +2 -1
  52. package/dist/plugins/cms/client/index.d.mts +2 -1
  53. package/dist/plugins/cms/client/index.d.ts +2 -1
  54. package/dist/plugins/cms/query-keys.cjs +69 -13
  55. package/dist/plugins/cms/query-keys.d.cts +89 -212
  56. package/dist/plugins/cms/query-keys.d.mts +89 -212
  57. package/dist/plugins/cms/query-keys.d.ts +89 -212
  58. package/dist/plugins/cms/query-keys.mjs +70 -14
  59. package/dist/plugins/kanban/client/index.d.cts +1 -1
  60. package/dist/plugins/kanban/client/index.d.mts +1 -1
  61. package/dist/plugins/kanban/client/index.d.ts +1 -1
  62. package/dist/plugins/route-docs/client/index.d.cts +1 -1
  63. package/dist/plugins/route-docs/client/index.d.mts +1 -1
  64. package/dist/plugins/route-docs/client/index.d.ts +1 -1
  65. package/dist/plugins/ui-builder/client/hooks/index.d.cts +3 -3
  66. package/dist/plugins/ui-builder/client/hooks/index.d.mts +3 -3
  67. package/dist/plugins/ui-builder/client/hooks/index.d.ts +3 -3
  68. package/dist/plugins/ui-builder/client/index.d.cts +3 -3
  69. package/dist/plugins/ui-builder/client/index.d.mts +3 -3
  70. package/dist/plugins/ui-builder/client/index.d.ts +3 -3
  71. package/dist/plugins/ui-builder/query-keys.d.cts +4 -3
  72. package/dist/plugins/ui-builder/query-keys.d.mts +4 -3
  73. package/dist/plugins/ui-builder/query-keys.d.ts +4 -3
  74. package/dist/shared/{stack.DMj68qY0.d.mts → stack.B2jlVVhD.d.mts} +5 -5
  75. package/dist/shared/{stack.Cvnj99cs.d.ts → stack.BB9tubJJ.d.ts} +9 -3
  76. package/dist/shared/{stack.Bt3WG2yb.d.cts → stack.BWduIMKG.d.cts} +35 -35
  77. package/dist/shared/stack.Bw09Lg2z.d.ts +1420 -0
  78. package/dist/shared/{stack.CkEz7vSt.d.mts → stack.C2Pvl-RS.d.mts} +9 -3
  79. package/dist/shared/{stack.C2l2wd_X.d.ts → stack.Ch9-ppmQ.d.ts} +35 -35
  80. package/dist/shared/{stack.Ht8mhDj5.d.cts → stack.D17yFglU.d.cts} +5 -5
  81. package/dist/shared/{stack.CiD115Ov.d.mts → stack.D6fPBfYe.d.mts} +35 -35
  82. package/dist/shared/stack.DFGj0gb7.d.mts +1420 -0
  83. package/dist/shared/{stack.pi0FxoEa.d.ts → stack.Dcfg4GNN.d.ts} +5 -5
  84. package/dist/shared/{stack.3rxARhgS.d.cts → stack.DrB3L7Ih.d.cts} +52 -3
  85. package/dist/shared/{stack.3rxARhgS.d.mts → stack.DrB3L7Ih.d.mts} +52 -3
  86. package/dist/shared/{stack.3rxARhgS.d.ts → stack.DrB3L7Ih.d.ts} +52 -3
  87. package/dist/shared/stack.NNrBKBkM.d.cts +1420 -0
  88. package/dist/shared/{stack.mRRyZBUg.d.cts → stack.Rb87lHqK.d.cts} +9 -3
  89. package/package.json +1 -1
  90. package/src/__tests__/cms-query-keys.test.ts +57 -0
  91. package/src/plugins/api/index.ts +1 -0
  92. package/src/plugins/api/operation.ts +16 -3
  93. package/src/plugins/cms/__tests__/authorization.test.ts +652 -187
  94. package/src/plugins/cms/__tests__/client-runtime.browser.test.tsx +655 -0
  95. package/src/plugins/cms/__tests__/content-read.typecheck.ts +351 -0
  96. package/src/plugins/cms/__tests__/getters.test.ts +286 -0
  97. package/src/plugins/cms/api/getters.ts +270 -12
  98. package/src/plugins/cms/api/index.ts +10 -0
  99. package/src/plugins/cms/api/operations.ts +163 -75
  100. package/src/plugins/cms/api/plugin.ts +50 -12
  101. package/src/plugins/cms/api/query-key-defs.ts +29 -2
  102. package/src/plugins/cms/client/hooks/cms-hooks.tsx +400 -91
  103. package/src/plugins/cms/client/index.ts +9 -0
  104. package/src/plugins/cms/query-keys.ts +171 -16
  105. package/src/plugins/cms/schemas.ts +30 -0
  106. package/src/plugins/cms/types.ts +49 -2
  107. package/src/plugins/media/__tests__/identity-partition.test.tsx +26 -16
  108. package/dist/shared/stack.CAmVUwjV.d.cts +0 -764
  109. package/dist/shared/stack.CQK02ujt.d.ts +0 -764
  110. package/dist/shared/stack.D9x1fR5a.d.mts +0 -764
  111. package/dist/shared/{stack.CKc_Bais.d.cts → stack.Csi-4Mgz.d.cts} +7 -7
  112. package/dist/shared/{stack.CKc_Bais.d.mts → stack.Csi-4Mgz.d.mts} +7 -7
  113. package/dist/shared/{stack.CKc_Bais.d.ts → stack.Csi-4Mgz.d.ts} +7 -7
@@ -3,6 +3,10 @@ import { defineDb, type DatabaseDefinition } from "@btst/db";
3
3
  import { zodToFormSchema } from "@workspace/ui/lib/schema-converter";
4
4
  import { describe, expect, it, vi } from "vitest";
5
5
  import { z } from "zod";
6
+ import { QueryClient } from "@tanstack/react-query";
7
+ import { createCMSQueryKeys, CMS_QUERY_KEYS } from "../api";
8
+ import { createApiClient } from "../../client";
9
+ import type { CMSApiRouter } from "../api";
6
10
  import { createBackendStack } from "../../../api";
7
11
  import { defineAuthorization } from "../../../authorization";
8
12
  import {
@@ -217,6 +221,318 @@ async function seedRecord(
217
221
  }
218
222
 
219
223
  describe("CMS authorization inventory", () => {
224
+ it("selects literal declared content fields without changing stored value semantics", async () => {
225
+ const backend = createBackendStack({
226
+ basePath: "/api",
227
+ plugins: {
228
+ cms: cmsBackendPlugin({
229
+ contentTypes: [
230
+ {
231
+ name: "Article",
232
+ slug: "article",
233
+ schema: z.object({
234
+ title: z.string(),
235
+ body: z.string(),
236
+ optional: z.string().optional(),
237
+ nil: z.string().nullable(),
238
+ nested: z.object({ label: z.string() }),
239
+ entries: z.array(z.string()),
240
+ 'headline,quoted". 标题': z.string(),
241
+ }),
242
+ },
243
+ ],
244
+ }),
245
+ },
246
+ adapter: memoryAdapter,
247
+ auth: createAuth(),
248
+ });
249
+ const record = await seedRecord(backend, { slug: "selected-content" });
250
+ await backend.adapter.update({
251
+ model: "contentItem",
252
+ where: [{ field: "id", value: record.id }],
253
+ update: {
254
+ data: JSON.stringify({
255
+ title: "Selected title",
256
+ body: "Searchable long body",
257
+ nil: null,
258
+ nested: { label: "Whole nested value" },
259
+ entries: ["one", "two"],
260
+ 'headline,quoted". 标题': "Literal property",
261
+ }),
262
+ },
263
+ });
264
+ const result = await backend.trusted.cms.listContentItems({
265
+ typeSlug: "article",
266
+ query: {
267
+ view: "content",
268
+ fields: [
269
+ "title",
270
+ "optional",
271
+ "nil",
272
+ "nested",
273
+ "entries",
274
+ 'headline,quoted". 标题',
275
+ ],
276
+ },
277
+ });
278
+ expect(result.items[0]).toEqual({
279
+ id: record.id,
280
+ slug: record.slug,
281
+ createdAt: record.createdAt.toISOString(),
282
+ updatedAt: record.updatedAt.toISOString(),
283
+ parsedData: {
284
+ title: "Selected title",
285
+ nil: null,
286
+ nested: { label: "Whole nested value" },
287
+ entries: ["one", "two"],
288
+ 'headline,quoted". 标题': "Literal property",
289
+ },
290
+ });
291
+ expect(result).toMatchObject({ total: 1, limit: 20, offset: 0 });
292
+ const fields = JSON.stringify(['headline,quoted". 标题', "title", "title"]);
293
+ for (const path of [
294
+ `/content/article/${record.id}`,
295
+ "/content/article?slug=selected-content",
296
+ "/content/article?search=Searchable",
297
+ ]) {
298
+ const response = await backend.handler(
299
+ request(
300
+ `${path}${path.includes("?") ? "&" : "?"}view=content&fields=${encodeURIComponent(fields)}`,
301
+ ),
302
+ );
303
+ expect(response.status).toBe(200);
304
+ const value = await response.json();
305
+ expect((value.items ? value.items[0] : value).parsedData).toEqual({
306
+ title: "Selected title",
307
+ 'headline,quoted". 标题': "Literal property",
308
+ });
309
+ if (value.items) expect(value.total).toBe(1);
310
+ }
311
+ });
312
+
313
+ it("rejects invalid field selections even when an existing collection is empty", async () => {
314
+ const backend = makeBackend({ auth: createAuth() });
315
+ for (const fields of [
316
+ "not-json",
317
+ "[]",
318
+ '["unknown"]',
319
+ '["title.nested"]',
320
+ "[1]",
321
+ "{}",
322
+ "null",
323
+ '"title"',
324
+ '["__proto__"]',
325
+ '["constructor"]',
326
+ ]) {
327
+ const response = await backend.handler(
328
+ request(
329
+ `/content/article?view=content&fields=${encodeURIComponent(fields)}`,
330
+ ),
331
+ );
332
+ expect(response.status, fields).toBe(400);
333
+ }
334
+ expect(
335
+ (
336
+ await backend.handler(
337
+ request("/content/article?fields=%5B%22title%22%5D"),
338
+ )
339
+ ).status,
340
+ ).toBe(400);
341
+ await expect(
342
+ backend.trusted.cms.listContentItems({
343
+ typeSlug: "article",
344
+ query: { fields: ["title"] },
345
+ }),
346
+ ).rejects.toMatchObject({ statusCode: 400 });
347
+ });
348
+
349
+ it("returns the content envelope only when requested on a collection read", async () => {
350
+ const backend = makeBackend();
351
+ const record = await seedRecord(backend, {
352
+ slug: "content-view",
353
+ authorId: "author-1",
354
+ });
355
+ const full = await backend.trusted.cms.listContentItems({
356
+ typeSlug: "article",
357
+ query: {},
358
+ });
359
+ const content = await backend.trusted.cms.listContentItems({
360
+ typeSlug: "article",
361
+ query: { view: "content" },
362
+ });
363
+ expect(full.items[0]).toHaveProperty("data");
364
+ expect(content).toEqual({
365
+ items: [
366
+ {
367
+ id: record.id,
368
+ slug: "content-view",
369
+ parsedData: { title: "content-view" },
370
+ createdAt: record.createdAt.toISOString(),
371
+ updatedAt: record.updatedAt.toISOString(),
372
+ },
373
+ ],
374
+ total: 1,
375
+ limit: 20,
376
+ offset: 0,
377
+ });
378
+ });
379
+
380
+ it("keeps content views on existing ID and slug HTTP reads with the same access checks", async () => {
381
+ const backend = makeBackend({ auth: createAuth() });
382
+ const record = await seedRecord(backend, {
383
+ slug: "read-content",
384
+ authorId: "owner",
385
+ });
386
+ for (const path of [
387
+ `/content/article/${record.id}?view=content`,
388
+ "/content/article?slug=read-content&view=content",
389
+ ]) {
390
+ const response = await backend.handler(request(path));
391
+ expect(response.status).toBe(200);
392
+ const value = await response.json();
393
+ const item = path.includes("?slug=") ? value.items[0] : value;
394
+ expect(Object.keys(item).sort()).toEqual([
395
+ "createdAt",
396
+ "id",
397
+ "parsedData",
398
+ "slug",
399
+ "updatedAt",
400
+ ]);
401
+ expect(item.parsedData).toEqual({ title: "read-content" });
402
+ }
403
+ expect(
404
+ (
405
+ await backend.handler(
406
+ request(`/content/article/${record.id}?view=invalid`),
407
+ )
408
+ ).status,
409
+ ).toBe(400);
410
+ expect(
411
+ (await backend.handler(request("/content/article?view=invalid"))).status,
412
+ ).toBe(400);
413
+ expect(
414
+ (await backend.handler(request("/content/article/missing?view=content")))
415
+ .status,
416
+ ).toBe(404);
417
+ expect(
418
+ (await backend.handler(request("/content/missing?view=content"))).status,
419
+ ).toBe(404);
420
+ const closed = makeBackend({
421
+ auth: createServerAuth({
422
+ authorization: defineAuthorization({
423
+ identity: z.object({ id: z.string() }),
424
+ permissions: [cmsPermissions] as const,
425
+ rules: ({ cms }) => [cms.contentType.read.allow()],
426
+ }),
427
+ getIdentity: () => ({ id: "reader" }),
428
+ }),
429
+ });
430
+ const privateRecord = await seedRecord(closed, { slug: "private-content" });
431
+ expect(
432
+ (
433
+ await closed.handler(
434
+ request(`/content/article/${privateRecord.id}?view=content`),
435
+ )
436
+ ).status,
437
+ ).toBe(403);
438
+ });
439
+
440
+ it("prefetches content views with matching client keys and serialized infinite pages", async () => {
441
+ const backend = makeBackend();
442
+ const record = await seedRecord(backend, { slug: "prefetch-content" });
443
+ const queryClient = new QueryClient();
444
+ await backend.raw.cms.prefetchForRoute("contentList", queryClient, {
445
+ typeSlug: "article",
446
+ view: "content",
447
+ fields: ["title", "title"],
448
+ limit: 5,
449
+ });
450
+ await backend.raw.cms.prefetchForRoute("editContent", queryClient, {
451
+ typeSlug: "article",
452
+ id: record.id,
453
+ view: "content",
454
+ fields: ["title", "title"],
455
+ });
456
+ const queries = createCMSQueryKeys(
457
+ createApiClient<CMSApiRouter>({
458
+ baseURL: "http://localhost",
459
+ basePath: "/api",
460
+ }),
461
+ );
462
+ const listKey = queries.cmsContent.list({
463
+ typeSlug: "article",
464
+ view: "content",
465
+ fields: ["title", "title"],
466
+ limit: 5,
467
+ }).queryKey;
468
+ expect(listKey).toEqual(
469
+ CMS_QUERY_KEYS.contentList({
470
+ typeSlug: "article",
471
+ view: "content",
472
+ fields: ["title", "title"],
473
+ limit: 5,
474
+ }),
475
+ );
476
+ expect(queryClient.getQueryData(listKey)).toEqual({
477
+ pages: [
478
+ {
479
+ items: [
480
+ {
481
+ id: record.id,
482
+ slug: "prefetch-content",
483
+ parsedData: { title: "prefetch-content" },
484
+ createdAt: record.createdAt.toISOString(),
485
+ updatedAt: record.updatedAt.toISOString(),
486
+ },
487
+ ],
488
+ total: 1,
489
+ limit: 5,
490
+ offset: 0,
491
+ },
492
+ ],
493
+ pageParams: [0],
494
+ });
495
+ const detail = queryClient.getQueryData(
496
+ queries.cmsContent.detail("article", record.id, {
497
+ view: "content",
498
+ fields: ["title"],
499
+ }).queryKey,
500
+ );
501
+ expect(detail).toHaveProperty("parsedData.title", "prefetch-content");
502
+ expect(detail).not.toHaveProperty("data");
503
+ expect(
504
+ queryClient.getQueryData(
505
+ CMS_QUERY_KEYS.contentDetail("article", record.id),
506
+ ),
507
+ ).toBeUndefined();
508
+ });
509
+
510
+ it.each(["", "&fields=%5B%22title%22%5D"])(
511
+ "still requires content-type metadata access for content-only ID, slug and collection reads (%s)",
512
+ async (selection) => {
513
+ const backend = makeBackend({
514
+ auth: createServerAuth({
515
+ authorization: defineAuthorization({
516
+ identity: z.object({ id: z.string() }),
517
+ permissions: [cmsPermissions] as const,
518
+ rules: ({ cms }) => [cms.record.read.allow()],
519
+ }),
520
+ getIdentity: () => ({ id: "reader" }),
521
+ }),
522
+ });
523
+ const record = await seedRecord(backend, { slug: "metadata-denied" });
524
+ for (const path of [
525
+ `/content/article/${record.id}?view=content`,
526
+ "/content/article?slug=metadata-denied&view=content",
527
+ "/content/article?view=content",
528
+ ]) {
529
+ expect(
530
+ (await backend.handler(request(`${path}${selection}`))).status,
531
+ ).toBe(403);
532
+ }
533
+ },
534
+ );
535
+
220
536
  it("records every removed lifecycle name and canonical replacement", () => {
221
537
  expect(CMS_LIFECYCLE_HOOK_MIGRATIONS).toEqual({
222
538
  onBeforeCreate: "onBeforeCreateContent",
@@ -1150,16 +1466,153 @@ describe("CMS operation-first authorization", () => {
1150
1466
  return findMany(query);
1151
1467
  });
1152
1468
 
1153
- const response = await backend.handler(
1154
- request(
1155
- `/content/resource/by-relation?field=categoryIds&targetId=${target.id}`,
1156
- ),
1157
- );
1158
- expect(response.status).toBe(403);
1469
+ for (const view of [undefined, "content"]) {
1470
+ const response = await backend.handler(
1471
+ request(
1472
+ `/content/resource/by-relation?field=categoryIds&targetId=${target.id}${view ? `&view=${view}&fields=%5B%22name%22%5D` : ""}`,
1473
+ ),
1474
+ );
1475
+ expect(response.status).toBe(403);
1476
+ }
1159
1477
  expect(relationReads).toBe(0);
1160
1478
  expect(events).toEqual([]);
1161
1479
  });
1162
1480
 
1481
+ it("pages every unique related record without fetching unrequested content", async () => {
1482
+ const backend = makeBackend({ auth: createAuth() });
1483
+ const target = await seedRecord(backend, {
1484
+ typeSlug: "category",
1485
+ slug: "large-category",
1486
+ });
1487
+ const types = await allContentTypes(backend);
1488
+ const noteType = types.find((type) => type.slug === "category-note")!;
1489
+ const articleType = types.find((type) => type.slug === "article")!;
1490
+ const createdAt = new Date("2026-01-01T00:00:00.000Z");
1491
+ for (let index = 0; index < 235; index++) {
1492
+ const isNote = index >= 110;
1493
+ const id = `${isNote ? "note" : "article"}-${String(isNote ? index - 110 : index).padStart(3, "0")}`;
1494
+ await backend.adapter.create<ContentItem>({
1495
+ model: "contentItem",
1496
+ forceAllowId: true,
1497
+ data: {
1498
+ id,
1499
+ contentTypeId: isNote ? noteType.id : articleType.id,
1500
+ slug: id,
1501
+ data: JSON.stringify({ message: id, unrequested: "Long body" }),
1502
+ createdAt,
1503
+ updatedAt: createdAt,
1504
+ } as ContentItem,
1505
+ });
1506
+ await backend.adapter.create<ContentRelation>({
1507
+ model: "contentRelation",
1508
+ data: {
1509
+ sourceId: id,
1510
+ targetId: target.id,
1511
+ fieldName: "categoryId",
1512
+ createdAt,
1513
+ },
1514
+ });
1515
+ }
1516
+ await backend.adapter.create<ContentRelation>({
1517
+ model: "contentRelation",
1518
+ data: {
1519
+ sourceId: "note-124",
1520
+ targetId: target.id,
1521
+ fieldName: "categoryId",
1522
+ createdAt,
1523
+ },
1524
+ });
1525
+ const findMany = backend.adapter.findMany.bind(backend.adapter);
1526
+ const findOne = backend.adapter.findOne.bind(backend.adapter);
1527
+ const fullReads: string[] = [];
1528
+ const individualSourceReads: string[] = [];
1529
+ vi.spyOn(backend.adapter, "findMany").mockImplementation(async (query) => {
1530
+ const rows = await findMany({ ...query, limit: query.limit ?? 100 });
1531
+ if (
1532
+ query.model === "contentItem" &&
1533
+ (!query.select || query.select.includes("data"))
1534
+ ) {
1535
+ fullReads.push(...(rows as ContentItem[]).map((item) => item.id));
1536
+ }
1537
+ return rows;
1538
+ });
1539
+ vi.spyOn(backend.adapter, "findOne").mockImplementation(async (query) => {
1540
+ const row = await findOne(query);
1541
+ if (
1542
+ query.model === "contentItem" &&
1543
+ (row as ContentItem | null)?.contentTypeId === noteType.id
1544
+ ) {
1545
+ individualSourceReads.push((row as ContentItem).id);
1546
+ }
1547
+ return row;
1548
+ });
1549
+ const expected = Array.from(
1550
+ { length: 125 },
1551
+ (_, index) => `note-${String(124 - index).padStart(3, "0")}`,
1552
+ );
1553
+ for (const path of [
1554
+ `/content/category-note/by-relation?field=categoryId&targetId=${target.id}`,
1555
+ `/content-types/category/inverse-relations/category-note?fieldName=categoryId&itemId=${target.id}`,
1556
+ ]) {
1557
+ expect(
1558
+ (await backend.handler(request(`${path}&view=editor`))).status,
1559
+ ).toBe(400);
1560
+ for (const fields of ['["unknown"]', "[]", '["message.nested"]']) {
1561
+ expect(
1562
+ (
1563
+ await backend.handler(
1564
+ request(
1565
+ `${path}&view=content&fields=${encodeURIComponent(fields)}`,
1566
+ ),
1567
+ )
1568
+ ).status,
1569
+ ).toBe(400);
1570
+ }
1571
+ for (const view of [undefined, "content", "projected"]) {
1572
+ const seen: string[] = [];
1573
+ for (let offset = 0; offset < 125; offset += 25) {
1574
+ fullReads.length = 0;
1575
+ const response = await backend.handler(
1576
+ request(
1577
+ `${path}&limit=25&offset=${offset}${view ? `&view=content${view === "projected" ? "&fields=%5B%22message%22%5D" : ""}` : ""}`,
1578
+ ),
1579
+ );
1580
+ expect(response.status).toBe(200);
1581
+ const page = await response.json();
1582
+ expect(page).toMatchObject({ total: 125, limit: 25, offset });
1583
+ expect(page.items.map((item: ContentItem) => item.id)).toEqual(
1584
+ expected.slice(offset, offset + 25),
1585
+ );
1586
+ if (view) {
1587
+ expect(page.items[0]).toEqual({
1588
+ id: expected[offset],
1589
+ slug: expected[offset],
1590
+ parsedData: {
1591
+ message: expected[offset],
1592
+ ...(view === "content" ? { unrequested: "Long body" } : {}),
1593
+ },
1594
+ createdAt: createdAt.toISOString(),
1595
+ updatedAt: createdAt.toISOString(),
1596
+ });
1597
+ } else {
1598
+ expect(page.items[0]).toMatchObject({
1599
+ data: expect.any(String),
1600
+ parsedData: { message: expected[offset] },
1601
+ contentType: { slug: "category-note" },
1602
+ createdAt: createdAt.toISOString(),
1603
+ });
1604
+ }
1605
+ expect([...fullReads].sort()).toEqual(
1606
+ [...expected.slice(offset, offset + 25)].sort(),
1607
+ );
1608
+ seen.push(...page.items.map((item: ContentItem) => item.id));
1609
+ }
1610
+ expect(seen).toEqual(expected);
1611
+ }
1612
+ }
1613
+ expect(individualSourceReads).toEqual([]);
1614
+ });
1615
+
1163
1616
  it("fails closed when relation-filter schemas or targets change after authorization", async () => {
1164
1617
  const relationFilterAuthorization = defineAuthorization({
1165
1618
  identity: z.object({ id: z.string(), role: z.literal("user") }),
@@ -1169,7 +1622,7 @@ describe("CMS operation-first authorization", () => {
1169
1622
  cms.record.read.allow(),
1170
1623
  ],
1171
1624
  });
1172
- const runRace = async (kind: "schema" | "target") => {
1625
+ const runRace = async (kind: "schema" | "target", view?: "content") => {
1173
1626
  const baseAuth = createServerAuth({
1174
1627
  authorization: relationFilterAuthorization,
1175
1628
  getIdentity: () => ({ id: "reader-1", role: "user" as const }),
@@ -1231,7 +1684,7 @@ describe("CMS operation-first authorization", () => {
1231
1684
  });
1232
1685
  const response = await backend.handler(
1233
1686
  request(
1234
- `/content/resource/by-relation?field=categoryIds&targetId=${target.id}`,
1687
+ `/content/resource/by-relation?field=categoryIds&targetId=${target.id}${view ? `&view=${view}` : ""}`,
1235
1688
  ),
1236
1689
  );
1237
1690
  expect(response.status).toBe(409);
@@ -1245,6 +1698,8 @@ describe("CMS operation-first authorization", () => {
1245
1698
 
1246
1699
  await runRace("schema");
1247
1700
  await runRace("target");
1701
+ await runRace("schema", "content");
1702
+ await runRace("target", "content");
1248
1703
  });
1249
1704
 
1250
1705
  it("authorizes every populated relation target before returning its record", async () => {
@@ -1397,191 +1852,201 @@ describe("CMS operation-first authorization", () => {
1397
1852
  expect(response.status).toBe(403);
1398
1853
  });
1399
1854
 
1400
- it("authorizes the target record and source collections before inverse counts", async () => {
1401
- const inverseCountAuthorization = defineAuthorization({
1402
- identity: z.object({
1403
- id: z.string(),
1404
- mode: z.enum([
1405
- "target-only",
1406
- "source-only",
1407
- "all",
1408
- "racing",
1409
- "schema-racing",
1410
- ]),
1411
- }),
1412
- permissions: [cmsPermissions] as const,
1413
- rules: ({ cms }) => [
1414
- cms.contentType.read.when(
1415
- ({ facts }) =>
1416
- facts.contentType === "category" ||
1417
- facts.contentType === "category-note",
1418
- ),
1419
- cms.record.read.when(({ identity, facts }) => {
1420
- const targetRecord =
1421
- facts.contentType === "category" && facts.scope === "record";
1422
- const sourceCollection =
1423
- facts.contentType === "category-note" &&
1424
- facts.scope === "collection";
1425
- if (identity?.mode === "target-only") return targetRecord;
1426
- if (identity?.mode === "source-only") return sourceCollection;
1427
- return (
1428
- (identity?.mode === "all" ||
1429
- identity?.mode === "racing" ||
1430
- identity?.mode === "schema-racing") &&
1431
- (targetRecord || sourceCollection)
1432
- );
1855
+ it.each([undefined, "content"] as const)(
1856
+ "authorizes the target record and source collections before inverse counts (%s view)",
1857
+ async (view) => {
1858
+ const inverseCountAuthorization = defineAuthorization({
1859
+ identity: z.object({
1860
+ id: z.string(),
1861
+ mode: z.enum([
1862
+ "target-only",
1863
+ "source-only",
1864
+ "all",
1865
+ "racing",
1866
+ "schema-racing",
1867
+ ]),
1433
1868
  }),
1434
- ],
1435
- });
1436
- const modes = [
1437
- "target-only",
1438
- "source-only",
1439
- "all",
1440
- "racing",
1441
- "schema-racing",
1442
- ] as const;
1443
- const baseAuth = createServerAuth({
1444
- authorization: inverseCountAuthorization,
1445
- getIdentity: ({ request: currentRequest }) => {
1446
- const mode = currentRequest.headers.get("x-mode");
1447
- return modes.includes(mode as (typeof modes)[number])
1448
- ? { id: "reader-1", mode: mode as (typeof modes)[number] }
1449
- : null;
1450
- },
1451
- });
1452
- let backend: ReturnType<typeof makeBackend>;
1453
- let targetId = "";
1454
- const authorizationCalls = new WeakMap<Request, number>();
1455
- const authorize = baseAuth.authorize.bind(baseAuth);
1456
- const racingAuth: typeof baseAuth = {
1457
- ...baseAuth,
1458
- async authorize(currentRequest, permissionRequest) {
1459
- const identity = await authorize(currentRequest, permissionRequest);
1460
- const count = (authorizationCalls.get(currentRequest) ?? 0) + 1;
1461
- authorizationCalls.set(currentRequest, count);
1462
- if (currentRequest.headers.get("x-mode") === "racing" && count === 4) {
1463
- await backend.adapter.update<ContentItem>({
1464
- model: "contentItem",
1465
- where: [{ field: "id", value: targetId }],
1466
- update: { authorId: "other-owner" },
1467
- });
1468
- }
1469
- if (
1470
- currentRequest.headers.get("x-mode") === "schema-racing" &&
1471
- count === 2
1472
- ) {
1473
- const sourceType = await backend.adapter.findOne<ContentType>({
1474
- model: "contentType",
1475
- where: [{ field: "slug", value: "category-note" }],
1476
- });
1477
- if (!sourceType)
1478
- throw new Error("Missing category-note content type");
1479
- await backend.adapter.update<ContentType>({
1480
- model: "contentType",
1481
- where: [{ field: "id", value: sourceType.id }],
1482
- update: {
1483
- jsonSchema: JSON.stringify(zodToFormSchema(articleSchema)),
1484
- updatedAt: new Date(),
1485
- },
1486
- });
1487
- }
1488
- return identity;
1489
- },
1490
- };
1491
- backend = makeBackend({ auth: racingAuth });
1492
- const target = await seedRecord(backend, {
1493
- typeSlug: "category",
1494
- slug: "counted-category",
1495
- authorId: "reader-1",
1496
- });
1497
- targetId = target.id;
1498
- const source = await seedRecord(backend, {
1499
- typeSlug: "category-note",
1500
- slug: "counted-note",
1501
- });
1502
- const wrongTypeTarget = await seedRecord(backend, {
1503
- typeSlug: "article",
1504
- slug: "wrong-target-type",
1505
- });
1506
- await backend.adapter.create<ContentRelation>({
1507
- model: "contentRelation",
1508
- data: {
1509
- sourceId: source.id,
1510
- targetId: target.id,
1511
- fieldName: "categoryId",
1512
- createdAt: new Date(),
1513
- },
1514
- });
1515
- const findMany = backend.adapter.findMany.bind(backend.adapter);
1516
- let relationReads = 0;
1517
- vi.spyOn(backend.adapter, "findMany").mockImplementation(async (query) => {
1518
- if (query.model === "contentRelation") relationReads += 1;
1519
- return findMany(query);
1520
- });
1521
- const countRequest = (mode: (typeof modes)[number]) =>
1522
- new Request(
1523
- `http://localhost/api/content-types/category/inverse-relations?itemId=${target.id}`,
1524
- { headers: { "x-mode": mode } },
1869
+ permissions: [cmsPermissions] as const,
1870
+ rules: ({ cms }) => [
1871
+ cms.contentType.read.when(
1872
+ ({ facts }) =>
1873
+ facts.contentType === "category" ||
1874
+ facts.contentType === "category-note",
1875
+ ),
1876
+ cms.record.read.when(({ identity, facts }) => {
1877
+ const targetRecord =
1878
+ facts.contentType === "category" && facts.scope === "record";
1879
+ const sourceCollection =
1880
+ facts.contentType === "category-note" &&
1881
+ facts.scope === "collection";
1882
+ if (identity?.mode === "target-only") return targetRecord;
1883
+ if (identity?.mode === "source-only") return sourceCollection;
1884
+ return (
1885
+ (identity?.mode === "all" ||
1886
+ identity?.mode === "racing" ||
1887
+ identity?.mode === "schema-racing") &&
1888
+ (targetRecord || sourceCollection)
1889
+ );
1890
+ }),
1891
+ ],
1892
+ });
1893
+ const modes = [
1894
+ "target-only",
1895
+ "source-only",
1896
+ "all",
1897
+ "racing",
1898
+ "schema-racing",
1899
+ ] as const;
1900
+ const baseAuth = createServerAuth({
1901
+ authorization: inverseCountAuthorization,
1902
+ getIdentity: ({ request: currentRequest }) => {
1903
+ const mode = currentRequest.headers.get("x-mode");
1904
+ return modes.includes(mode as (typeof modes)[number])
1905
+ ? { id: "reader-1", mode: mode as (typeof modes)[number] }
1906
+ : null;
1907
+ },
1908
+ });
1909
+ let backend: ReturnType<typeof makeBackend>;
1910
+ let targetId = "";
1911
+ const authorizationCalls = new WeakMap<Request, number>();
1912
+ const authorize = baseAuth.authorize.bind(baseAuth);
1913
+ const racingAuth: typeof baseAuth = {
1914
+ ...baseAuth,
1915
+ async authorize(currentRequest, permissionRequest) {
1916
+ const identity = await authorize(currentRequest, permissionRequest);
1917
+ const count = (authorizationCalls.get(currentRequest) ?? 0) + 1;
1918
+ authorizationCalls.set(currentRequest, count);
1919
+ if (
1920
+ currentRequest.headers.get("x-mode") === "racing" &&
1921
+ count === 4
1922
+ ) {
1923
+ await backend.adapter.update<ContentItem>({
1924
+ model: "contentItem",
1925
+ where: [{ field: "id", value: targetId }],
1926
+ update: { authorId: "other-owner" },
1927
+ });
1928
+ }
1929
+ if (
1930
+ currentRequest.headers.get("x-mode") === "schema-racing" &&
1931
+ count === 2
1932
+ ) {
1933
+ const sourceType = await backend.adapter.findOne<ContentType>({
1934
+ model: "contentType",
1935
+ where: [{ field: "slug", value: "category-note" }],
1936
+ });
1937
+ if (!sourceType)
1938
+ throw new Error("Missing category-note content type");
1939
+ await backend.adapter.update<ContentType>({
1940
+ model: "contentType",
1941
+ where: [{ field: "id", value: sourceType.id }],
1942
+ update: {
1943
+ jsonSchema: JSON.stringify(zodToFormSchema(articleSchema)),
1944
+ updatedAt: new Date(),
1945
+ },
1946
+ });
1947
+ }
1948
+ return identity;
1949
+ },
1950
+ };
1951
+ backend = makeBackend({ auth: racingAuth });
1952
+ const target = await seedRecord(backend, {
1953
+ typeSlug: "category",
1954
+ slug: "counted-category",
1955
+ authorId: "reader-1",
1956
+ });
1957
+ targetId = target.id;
1958
+ const source = await seedRecord(backend, {
1959
+ typeSlug: "category-note",
1960
+ slug: "counted-note",
1961
+ });
1962
+ const wrongTypeTarget = await seedRecord(backend, {
1963
+ typeSlug: "article",
1964
+ slug: "wrong-target-type",
1965
+ });
1966
+ await backend.adapter.create<ContentRelation>({
1967
+ model: "contentRelation",
1968
+ data: {
1969
+ sourceId: source.id,
1970
+ targetId: target.id,
1971
+ fieldName: "categoryId",
1972
+ createdAt: new Date(),
1973
+ },
1974
+ });
1975
+ const findMany = backend.adapter.findMany.bind(backend.adapter);
1976
+ let relationReads = 0;
1977
+ vi.spyOn(backend.adapter, "findMany").mockImplementation(
1978
+ async (query) => {
1979
+ if (query.model === "contentRelation") relationReads += 1;
1980
+ return findMany(query);
1981
+ },
1525
1982
  );
1526
- const listRequest = (
1527
- mode: (typeof modes)[number],
1528
- options?: { itemId?: string; fieldName?: string },
1529
- ) =>
1530
- new Request(
1531
- `http://localhost/api/content-types/category/inverse-relations/category-note?itemId=${options?.itemId ?? target.id}&fieldName=${options?.fieldName ?? "categoryId"}`,
1532
- { headers: { "x-mode": mode } },
1983
+ const countRequest = (mode: (typeof modes)[number]) =>
1984
+ new Request(
1985
+ `http://localhost/api/content-types/category/inverse-relations?itemId=${target.id}`,
1986
+ { headers: { "x-mode": mode } },
1987
+ );
1988
+ const listRequest = (
1989
+ mode: (typeof modes)[number],
1990
+ options?: { itemId?: string; fieldName?: string },
1991
+ ) =>
1992
+ new Request(
1993
+ `http://localhost/api/content-types/category/inverse-relations/category-note?itemId=${options?.itemId ?? target.id}&fieldName=${options?.fieldName ?? "categoryId"}${view ? `&view=${view}` : ""}`,
1994
+ { headers: { "x-mode": mode } },
1995
+ );
1996
+
1997
+ const listTargetDenied = await backend.handler(
1998
+ listRequest("source-only"),
1533
1999
  );
2000
+ expect(listTargetDenied.status).toBe(403);
2001
+ const wrongTargetType = await backend.handler(
2002
+ listRequest("all", { itemId: wrongTypeTarget.id }),
2003
+ );
2004
+ expect(wrongTargetType.status).toBe(404);
2005
+ const invalidField = await backend.handler(
2006
+ listRequest("all", { fieldName: "notACategoryRelation" }),
2007
+ );
2008
+ expect(invalidField.status).toBe(404);
2009
+ expect(await invalidField.json()).toMatchObject({
2010
+ code: "INVERSE_RELATION_NOT_FOUND",
2011
+ });
2012
+ expect(relationReads).toBe(0);
1534
2013
 
1535
- const listTargetDenied = await backend.handler(listRequest("source-only"));
1536
- expect(listTargetDenied.status).toBe(403);
1537
- const wrongTargetType = await backend.handler(
1538
- listRequest("all", { itemId: wrongTypeTarget.id }),
1539
- );
1540
- expect(wrongTargetType.status).toBe(404);
1541
- const invalidField = await backend.handler(
1542
- listRequest("all", { fieldName: "notACategoryRelation" }),
1543
- );
1544
- expect(invalidField.status).toBe(404);
1545
- expect(await invalidField.json()).toMatchObject({
1546
- code: "INVERSE_RELATION_NOT_FOUND",
1547
- });
1548
- expect(relationReads).toBe(0);
1549
-
1550
- const allowedList = await backend.handler(listRequest("all"));
1551
- expect(allowedList.status).toBe(200);
1552
- expect(await allowedList.json()).toMatchObject({
1553
- total: 1,
1554
- items: [{ id: source.id }],
1555
- });
1556
- expect(relationReads).toBe(1);
1557
-
1558
- const sourceDenied = await backend.handler(countRequest("target-only"));
1559
- expect(sourceDenied.status).toBe(403);
1560
- const targetDenied = await backend.handler(countRequest("source-only"));
1561
- expect(targetDenied.status).toBe(403);
1562
- expect(relationReads).toBe(1);
1563
-
1564
- const allowed = await backend.handler(countRequest("all"));
1565
- expect(allowed.status).toBe(200);
1566
- expect(await allowed.json()).toMatchObject({
1567
- inverseRelations: [{ sourceType: "category-note", count: 1 }],
1568
- });
1569
- expect(relationReads).toBe(2);
2014
+ const allowedList = await backend.handler(listRequest("all"));
2015
+ expect(allowedList.status).toBe(200);
2016
+ expect(await allowedList.json()).toMatchObject({
2017
+ total: 1,
2018
+ items: [{ id: source.id }],
2019
+ });
2020
+ expect(relationReads).toBe(1);
2021
+
2022
+ const sourceDenied = await backend.handler(countRequest("target-only"));
2023
+ expect(sourceDenied.status).toBe(403);
2024
+ const targetDenied = await backend.handler(countRequest("source-only"));
2025
+ expect(targetDenied.status).toBe(403);
2026
+ expect(relationReads).toBe(1);
2027
+
2028
+ const allowed = await backend.handler(countRequest("all"));
2029
+ expect(allowed.status).toBe(200);
2030
+ expect(await allowed.json()).toMatchObject({
2031
+ inverseRelations: [{ sourceType: "category-note", count: 1 }],
2032
+ });
2033
+ expect(relationReads).toBe(2);
1570
2034
 
1571
- const staleTarget = await backend.handler(countRequest("racing"));
1572
- expect(staleTarget.status).toBe(409);
1573
- expect(await staleTarget.json()).toMatchObject({
1574
- code: "RECORD_STATE_CHANGED",
1575
- });
1576
- expect(relationReads).toBe(2);
2035
+ const staleTarget = await backend.handler(countRequest("racing"));
2036
+ expect(staleTarget.status).toBe(409);
2037
+ expect(await staleTarget.json()).toMatchObject({
2038
+ code: "RECORD_STATE_CHANGED",
2039
+ });
2040
+ expect(relationReads).toBe(2);
1577
2041
 
1578
- const staleSchema = await backend.handler(listRequest("schema-racing"));
1579
- expect(staleSchema.status).toBe(409);
1580
- expect(await staleSchema.json()).toMatchObject({
1581
- code: "RELATION_SCHEMA_CHANGED",
1582
- });
1583
- expect(relationReads).toBe(2);
1584
- });
2042
+ const staleSchema = await backend.handler(listRequest("schema-racing"));
2043
+ expect(staleSchema.status).toBe(409);
2044
+ expect(await staleSchema.json()).toMatchObject({
2045
+ code: "RELATION_SCHEMA_CHANGED",
2046
+ });
2047
+ expect(relationReads).toBe(2);
2048
+ },
2049
+ );
1585
2050
 
1586
2051
  it("fails closed when inverse relation schemas change after authorization", async () => {
1587
2052
  const inverseAuthorization = defineAuthorization({