@opensaas/stack-core 0.30.0 → 0.31.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 (127) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +410 -0
  3. package/dist/access/access-filter.d.ts.map +1 -1
  4. package/dist/access/access-filter.js +14 -1
  5. package/dist/access/access-filter.js.map +1 -1
  6. package/dist/access/access-filter.test.js +50 -0
  7. package/dist/access/access-filter.test.js.map +1 -1
  8. package/dist/access/field-access.js +6 -6
  9. package/dist/access/field-access.js.map +1 -1
  10. package/dist/access/multi-column-read-write.test.js +46 -12
  11. package/dist/access/multi-column-read-write.test.js.map +1 -1
  12. package/dist/access/relationship-count.d.ts +60 -0
  13. package/dist/access/relationship-count.d.ts.map +1 -0
  14. package/dist/access/relationship-count.js +233 -0
  15. package/dist/access/relationship-count.js.map +1 -0
  16. package/dist/access/relationship-count.test.d.ts +2 -0
  17. package/dist/access/relationship-count.test.d.ts.map +1 -0
  18. package/dist/access/relationship-count.test.js +185 -0
  19. package/dist/access/relationship-count.test.js.map +1 -0
  20. package/dist/access/relationship-label-filter.d.ts +45 -0
  21. package/dist/access/relationship-label-filter.d.ts.map +1 -0
  22. package/dist/access/relationship-label-filter.js +93 -0
  23. package/dist/access/relationship-label-filter.js.map +1 -0
  24. package/dist/access/relationship-label-filter.test.d.ts +2 -0
  25. package/dist/access/relationship-label-filter.test.d.ts.map +1 -0
  26. package/dist/access/relationship-label-filter.test.js +126 -0
  27. package/dist/access/relationship-label-filter.test.js.map +1 -0
  28. package/dist/config/index.d.ts +1 -1
  29. package/dist/config/index.d.ts.map +1 -1
  30. package/dist/config/nav-count.d.ts +34 -0
  31. package/dist/config/nav-count.d.ts.map +1 -0
  32. package/dist/config/nav-count.js +71 -0
  33. package/dist/config/nav-count.js.map +1 -0
  34. package/dist/config/types.d.ts +305 -4
  35. package/dist/config/types.d.ts.map +1 -1
  36. package/dist/context/hook-pipeline.d.ts.map +1 -1
  37. package/dist/context/hook-pipeline.js +11 -1
  38. package/dist/context/hook-pipeline.js.map +1 -1
  39. package/dist/context/index.d.ts +28 -0
  40. package/dist/context/index.d.ts.map +1 -1
  41. package/dist/context/index.js +205 -0
  42. package/dist/context/index.js.map +1 -1
  43. package/dist/context/nested-operations.d.ts.map +1 -1
  44. package/dist/context/nested-operations.js +7 -1
  45. package/dist/context/nested-operations.js.map +1 -1
  46. package/dist/extend.d.ts +1 -0
  47. package/dist/extend.d.ts.map +1 -1
  48. package/dist/fields/index.d.ts +1 -1
  49. package/dist/fields/index.d.ts.map +1 -1
  50. package/dist/fields/index.js +166 -0
  51. package/dist/fields/index.js.map +1 -1
  52. package/dist/filter/collect.d.ts +32 -0
  53. package/dist/filter/collect.d.ts.map +1 -0
  54. package/dist/filter/collect.js +60 -0
  55. package/dist/filter/collect.js.map +1 -0
  56. package/dist/filter/filter.test.d.ts +2 -0
  57. package/dist/filter/filter.test.d.ts.map +1 -0
  58. package/dist/filter/filter.test.js +348 -0
  59. package/dist/filter/filter.test.js.map +1 -0
  60. package/dist/filter/index.d.ts +7 -0
  61. package/dist/filter/index.d.ts.map +1 -0
  62. package/dist/filter/index.js +16 -0
  63. package/dist/filter/index.js.map +1 -0
  64. package/dist/filter/map.d.ts +18 -0
  65. package/dist/filter/map.d.ts.map +1 -0
  66. package/dist/filter/map.js +60 -0
  67. package/dist/filter/map.js.map +1 -0
  68. package/dist/filter/parse.d.ts +33 -0
  69. package/dist/filter/parse.d.ts.map +1 -0
  70. package/dist/filter/parse.js +103 -0
  71. package/dist/filter/parse.js.map +1 -0
  72. package/dist/filter/serialize.d.ts +25 -0
  73. package/dist/filter/serialize.d.ts.map +1 -0
  74. package/dist/filter/serialize.js +68 -0
  75. package/dist/filter/serialize.js.map +1 -0
  76. package/dist/filter/serialize.test.d.ts +2 -0
  77. package/dist/filter/serialize.test.d.ts.map +1 -0
  78. package/dist/filter/serialize.test.js +83 -0
  79. package/dist/filter/serialize.test.js.map +1 -0
  80. package/dist/filter/types.d.ts +122 -0
  81. package/dist/filter/types.d.ts.map +1 -0
  82. package/dist/filter/types.js +24 -0
  83. package/dist/filter/types.js.map +1 -0
  84. package/dist/hooks/index.d.ts +30 -0
  85. package/dist/hooks/index.d.ts.map +1 -1
  86. package/dist/hooks/index.js +79 -59
  87. package/dist/hooks/index.js.map +1 -1
  88. package/dist/index.d.ts +6 -1
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/index.js +19 -0
  91. package/dist/index.js.map +1 -1
  92. package/dist/internal.d.ts +1 -0
  93. package/dist/internal.d.ts.map +1 -1
  94. package/dist/internal.js +4 -0
  95. package/dist/internal.js.map +1 -1
  96. package/package.json +1 -1
  97. package/src/access/access-filter.test.ts +79 -0
  98. package/src/access/access-filter.ts +15 -4
  99. package/src/access/field-access.ts +6 -6
  100. package/src/access/multi-column-read-write.test.ts +68 -19
  101. package/src/access/relationship-count.test.ts +269 -0
  102. package/src/access/relationship-count.ts +311 -0
  103. package/src/access/relationship-label-filter.test.ts +177 -0
  104. package/src/access/relationship-label-filter.ts +140 -0
  105. package/src/config/index.ts +6 -0
  106. package/src/config/nav-count.ts +85 -0
  107. package/src/config/types.ts +319 -1
  108. package/src/context/hook-pipeline.ts +19 -0
  109. package/src/context/index.ts +285 -3
  110. package/src/context/nested-operations.ts +22 -0
  111. package/src/extend.ts +9 -0
  112. package/src/fields/index.ts +168 -0
  113. package/src/filter/collect.ts +72 -0
  114. package/src/filter/filter.test.ts +400 -0
  115. package/src/filter/index.ts +25 -0
  116. package/src/filter/map.ts +68 -0
  117. package/src/filter/parse.ts +106 -0
  118. package/src/filter/serialize.test.ts +115 -0
  119. package/src/filter/serialize.ts +75 -0
  120. package/src/filter/types.ts +120 -0
  121. package/src/hooks/index.ts +87 -57
  122. package/src/index.ts +54 -0
  123. package/src/internal.ts +5 -0
  124. package/tests/context.test.ts +690 -0
  125. package/tests/multi-column-validation-ordering.test.ts +305 -0
  126. package/tests/nav-count.test.ts +189 -0
  127. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { list } from '../config/index.js';
3
+ import { text, relationship, integer } from '../fields/index.js';
4
+ import { buildRelationshipCountSelect, resolveRelationshipCountFilters, isToManyRelationshipField, } from './relationship-count.js';
5
+ import { RELATIONSHIP_COUNT_FILTER_KEY } from '../filter/types.js';
6
+ /**
7
+ * Access-scoped to-many relationship counts (issue #732). Verifies:
8
+ * • the filtered `_count` select folds in the related list's query access,
9
+ * • a denied related list is omitted (its count renders as 0, never a leak),
10
+ * • count-filter markers resolve to access-scoped `{ id: { in } }` via a single
11
+ * secured read (never a per-row query), including the fully-denied case.
12
+ */
13
+ // User (published-only for anon via a filter), plus a Widget list that is fully
14
+ // closed by default (no query access) to prove the denied path.
15
+ function makeConfig() {
16
+ return {
17
+ db: { provider: 'sqlite', prismaClientConstructor: () => null },
18
+ lists: {
19
+ User: list({
20
+ fields: {
21
+ name: text(),
22
+ posts: relationship({ ref: 'Post.author', many: true }),
23
+ widgets: relationship({ ref: 'Widget', many: true }),
24
+ manager: relationship({ ref: 'User' }), // to-one — never counted
25
+ },
26
+ access: { operation: { query: () => true } },
27
+ }),
28
+ Post: list({
29
+ fields: { title: text(), views: integer(), author: relationship({ ref: 'User.posts' }) },
30
+ // Only published posts are visible → the count must be access-scoped.
31
+ access: { operation: { query: () => ({ status: { equals: 'published' } }) } },
32
+ }),
33
+ // Widget ships closed (no access block) → query denied by default.
34
+ Widget: list({ fields: { name: text() } }),
35
+ },
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
37
+ };
38
+ }
39
+ function makeContext(findMany) {
40
+ return {
41
+ session: null,
42
+ _isSudo: false,
43
+ _resolveOutputCounter: { depth: 0 },
44
+ db: findMany ? { user: { findMany } } : {},
45
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
46
+ };
47
+ }
48
+ describe('isToManyRelationshipField', () => {
49
+ const config = makeConfig();
50
+ const userFields = config.lists.User.fields;
51
+ it('is true only for a to-many relationship', () => {
52
+ expect(isToManyRelationshipField(userFields.posts)).toBe(true);
53
+ expect(isToManyRelationshipField(userFields.widgets)).toBe(true);
54
+ expect(isToManyRelationshipField(userFields.manager)).toBe(false);
55
+ expect(isToManyRelationshipField(userFields.name)).toBe(false);
56
+ });
57
+ });
58
+ describe('buildRelationshipCountSelect', () => {
59
+ it("folds the related list's query filter into each to-many count and omits denied/to-one", async () => {
60
+ const config = makeConfig();
61
+ const select = await buildRelationshipCountSelect(config.lists.User, { session: null, context: makeContext() }, config);
62
+ // `posts` → access-scoped where; `widgets` → denied (omitted); `manager` →
63
+ // to-one (never counted).
64
+ expect(select).toEqual({ posts: { where: { status: { equals: 'published' } } } });
65
+ });
66
+ it('uses a bare `true` when the related list is fully readable', async () => {
67
+ const config = {
68
+ db: { provider: 'sqlite', prismaClientConstructor: () => null },
69
+ lists: {
70
+ Tag: list({ fields: { name: text() }, access: { operation: { query: () => true } } }),
71
+ Post: list({ fields: { title: text(), tags: relationship({ ref: 'Tag', many: true }) } }),
72
+ },
73
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
74
+ };
75
+ const select = await buildRelationshipCountSelect(config.lists.Post, { session: null, context: makeContext() }, config);
76
+ expect(select).toEqual({ tags: true });
77
+ });
78
+ it('returns undefined when there are no countable to-many relationships', async () => {
79
+ const config = {
80
+ db: { provider: 'sqlite', prismaClientConstructor: () => null },
81
+ lists: { Post: list({ fields: { title: text() } }) },
82
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
83
+ };
84
+ const select = await buildRelationshipCountSelect(config.lists.Post, { session: null, context: makeContext() }, config);
85
+ expect(select).toBeUndefined();
86
+ });
87
+ });
88
+ describe('resolveRelationshipCountFilters', () => {
89
+ const marker = (operator, value) => ({
90
+ posts: { [RELATIONSHIP_COUNT_FILTER_KEY]: { operator, value } },
91
+ });
92
+ it('returns the where unchanged when there are no count markers', async () => {
93
+ const config = makeConfig();
94
+ const where = { name: { contains: 'ada' } };
95
+ const resolved = await resolveRelationshipCountFilters(where, config.lists.User, 'User', { session: null, context: makeContext() }, config);
96
+ expect(resolved).toBe(where);
97
+ });
98
+ it('resolves a count marker to an access-scoped { id: { in } } via a single secured read', async () => {
99
+ const config = makeConfig();
100
+ // Two users; the secured read returns each with its access-scoped `_count`.
101
+ const findMany = vi.fn(async () => [
102
+ { id: 'u1', _count: { posts: 3 } },
103
+ { id: 'u2', _count: { posts: 7 } },
104
+ ]);
105
+ const resolved = await resolveRelationshipCountFilters(marker('gt', 5), config.lists.User, 'User', { session: null, context: makeContext(findMany) }, config);
106
+ // Only u2 (7 > 5) matches; the read was access-scoped (published-only count).
107
+ expect(resolved).toEqual({ id: { in: ['u2'] } });
108
+ expect(findMany).toHaveBeenCalledTimes(1);
109
+ expect(findMany).toHaveBeenCalledWith({
110
+ include: { _count: { select: { posts: { where: { status: { equals: 'published' } } } } } },
111
+ });
112
+ });
113
+ it('includes zero-count rows for comparisons that admit zero (no complement bug)', async () => {
114
+ const config = makeConfig();
115
+ const findMany = vi.fn(async () => [
116
+ { id: 'u1', _count: { posts: 0 } },
117
+ { id: 'u2', _count: { posts: 4 } },
118
+ ]);
119
+ const resolved = await resolveRelationshipCountFilters(marker('lt', 1), // count < 1 → only the zero-count user
120
+ config.lists.User, 'User', { session: null, context: makeContext(findMany) }, config);
121
+ expect(resolved).toEqual({ id: { in: ['u1'] } });
122
+ });
123
+ it('resolves a denied related list without any query: count is always 0', async () => {
124
+ const config = makeConfig();
125
+ const findMany = vi.fn(async () => []);
126
+ // `widgets`' related list (Widget) is fully denied → every count is 0.
127
+ const where = { widgets: { [RELATIONSHIP_COUNT_FILTER_KEY]: { operator: 'gt', value: 5 } } };
128
+ const resolved = await resolveRelationshipCountFilters(where, config.lists.User, 'User', { session: null, context: makeContext(findMany) }, config);
129
+ // 0 is not > 5 → nothing matches, and no read was issued.
130
+ expect(resolved).toEqual({ id: { in: [] } });
131
+ expect(findMany).not.toHaveBeenCalled();
132
+ });
133
+ it('preserves sibling conditions, ANDing the resolved id constraint', async () => {
134
+ const config = makeConfig();
135
+ const findMany = vi.fn(async () => [{ id: 'u2', _count: { posts: 7 } }]);
136
+ const where = { AND: [{ name: { contains: 'ada' } }, marker('gte', 1)] };
137
+ const resolved = await resolveRelationshipCountFilters(where, config.lists.User, 'User', { session: null, context: makeContext(findMany) }, config);
138
+ expect(resolved).toEqual({ AND: [{ name: { contains: 'ada' } }, { id: { in: ['u2'] } }] });
139
+ });
140
+ it('reads with only the filtered `_count` include — no over-fetching `select` projection', async () => {
141
+ const config = makeConfig();
142
+ const findMany = vi.fn((_args) => Promise.resolve([{ id: 'u2', _count: { posts: 7 } }]));
143
+ await resolveRelationshipCountFilters(marker('gt', 5), config.lists.User, 'User', { session: null, context: makeContext(findMany) }, config);
144
+ // The secured read is issued with the access-scoped `_count` include and NO
145
+ // `select` — the secured findMany does not honour `select`, so forcing one
146
+ // would be an ignored no-op. Access-scoping lives entirely in `_count.where`.
147
+ expect(findMany).toHaveBeenCalledTimes(1);
148
+ const callArg = findMany.mock.calls[0][0];
149
+ expect(callArg).not.toHaveProperty('select');
150
+ expect(callArg).toEqual({
151
+ include: { _count: { select: { posts: { where: { status: { equals: 'published' } } } } } },
152
+ });
153
+ });
154
+ it('preserves a sibling condition co-present in the same member as the marker', async () => {
155
+ const config = makeConfig();
156
+ const findMany = vi.fn(async () => [{ id: 'u2', _count: { posts: 7 } }]);
157
+ // A single AND-member carrying BOTH a scalar condition and the count marker.
158
+ // The marker resolution must keep the co-present sibling, not replace the
159
+ // member wholesale (guards against a future filter-engine change that merges
160
+ // conditions into one member).
161
+ const where = {
162
+ AND: [
163
+ {
164
+ name: { contains: 'ada' },
165
+ posts: { [RELATIONSHIP_COUNT_FILTER_KEY]: { operator: 'gt', value: 5 } },
166
+ },
167
+ ],
168
+ };
169
+ const resolved = await resolveRelationshipCountFilters(where, config.lists.User, 'User', { session: null, context: makeContext(findMany) }, config);
170
+ expect(resolved).toEqual({ name: { contains: 'ada' }, id: { in: ['u2'] } });
171
+ });
172
+ it('ANDs the resolved id constraint with a co-present sibling id condition (no silent drop)', async () => {
173
+ const config = makeConfig();
174
+ const findMany = vi.fn(async () => [{ id: 'u2', _count: { posts: 7 } }]);
175
+ // Contrived: a member carrying its own `id` condition alongside the marker.
176
+ // Spreading would let one `id` overwrite the other; instead both are ANDed.
177
+ const where = {
178
+ id: { in: ['u1', 'u2'] },
179
+ posts: { [RELATIONSHIP_COUNT_FILTER_KEY]: { operator: 'gt', value: 5 } },
180
+ };
181
+ const resolved = await resolveRelationshipCountFilters(where, config.lists.User, 'User', { session: null, context: makeContext(findMany) }, config);
182
+ expect(resolved).toEqual({ AND: [{ id: { in: ['u1', 'u2'] } }, { id: { in: ['u2'] } }] });
183
+ });
184
+ });
185
+ //# sourceMappingURL=relationship-count.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relationship-count.test.js","sourceRoot":"","sources":["../../src/access/relationship-count.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAA;AACjD,OAAO,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAA;AACzC,OAAO,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAA;AAGhE,OAAO,EACL,4BAA4B,EAC5B,+BAA+B,EAC/B,yBAAyB,GAC1B,MAAM,yBAAyB,CAAA;AAChC,OAAO,EAAE,6BAA6B,EAAE,MAAM,oBAAoB,CAAA;AAElE;;;;;;GAMG;AAEH,gFAAgF;AAChF,gEAAgE;AAChE,SAAS,UAAU;IACjB,OAAO;QACL,EAAE,EAAE,EAAE,QAAQ,EAAE,QAAQ,EAAE,uBAAuB,EAAE,GAAG,EAAE,CAAC,IAAa,EAAE;QACxE,KAAK,EAAE;YACL,IAAI,EAAE,IAAI,CAAC;gBACT,MAAM,EAAE;oBACN,IAAI,EAAE,IAAI,EAAE;oBACZ,KAAK,EAAE,YAAY,CAAC,EAAE,GAAG,EAAE,aAAa,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;oBACvD,OAAO,EAAE,YAAY,CAAC,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;oBACpD,OAAO,EAAE,YAAY,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,CAAC,EAAE,yBAAyB;iBAClE;gBACD,MAAM,EAAE,EAAE,SAAS,EAAE,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,IAAI,EAAE,EAAE;aAC7C,CAAC;YACF,IAAI,EAAE,IAAI,CAAC;gBACT,MAAM,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,GAAG,EAAE,YAAY,EAAE,CAAC,EAAE;gBACxF,sEAAsE;gBACtE,MAAM,EAAE,EAAE,SAAS,EAAE,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,WAAW,EAAE,EAAE,CAAC,EAAE,EAAE;aAC9E,CAAC;YACF,mEAAmE;YACnE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC;SAC3C;QACD,8FAA8F;KACxF,CAAA;AACV,CAAC;AAED,SAAS,WAAW,CAClB,QAAqE;IAErE,OAAO;QACL,OAAO,EAAE,IAAI;QACb,OAAO,EAAE,KAAK;QACd,qBAAqB,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE;QACnC,EAAE,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE;QAC1C,+FAA+F;KACzF,CAAA;AACV,CAAC;AAED,QAAQ,CAAC,2BAA2B,EAAE,GAAG,EAAE;IACzC,MAAM,MAAM,GAAG,UAAU,EAAE,CAAA;IAC3B,MAAM,UAAU,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAA;IAC3C,EAAE,CAAC,yCAAyC,EAAE,GAAG,EAAE;QACjD,MAAM,CAAC,yBAAyB,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAC9D,MAAM,CAAC,yBAAyB,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAChE,MAAM,CAAC,yBAAyB,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACjE,MAAM,CAAC,yBAAyB,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IAChE,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA;AAEF,QAAQ,CAAC,8BAA8B,EAAE,GAAG,EAAE;IAC5C,EAAE,CAAC,uFAAuF,EAAE,KAAK,IAAI,EAAE;QACrG,MAAM,MAAM,GAAG,UAAU,EAAE,CAAA;QAC3B,MAAM,MAAM,GAAG,MAAM,4BAA4B,CAC/C,MAAM,CAAC,KAAK,CAAC,IAAI,EACjB,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,EAAE,EACzC,MAAM,CACP,CAAA;QACD,2EAA2E;QAC3E,0BAA0B;QAC1B,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,WAAW,EAAE,EAAE,EAAE,EAAE,CAAC,CAAA;IACnF,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,4DAA4D,EAAE,KAAK,IAAI,EAAE;QAC1E,MAAM,MAAM,GAAmB;YAC7B,EAAE,EAAE,EAAE,QAAQ,EAAE,QAAQ,EAAE,uBAAuB,EAAE,GAAG,EAAE,CAAC,IAAa,EAAE;YACxE,KAAK,EAAE;gBACL,GAAG,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,SAAS,EAAE,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,IAAI,EAAE,EAAE,EAAE,CAAC;gBACrF,IAAI,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,YAAY,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC;aAC1F;YACD,8FAA8F;SACxF,CAAA;QACR,MAAM,MAAM,GAAG,MAAM,4BAA4B,CAC/C,MAAM,CAAC,KAAK,CAAC,IAAI,EACjB,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,EAAE,EACzC,MAAM,CACP,CAAA;QACD,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAA;IACxC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,qEAAqE,EAAE,KAAK,IAAI,EAAE;QACnF,MAAM,MAAM,GAAmB;YAC7B,EAAE,EAAE,EAAE,QAAQ,EAAE,QAAQ,EAAE,uBAAuB,EAAE,GAAG,EAAE,CAAC,IAAa,EAAE;YACxE,KAAK,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,EAAE;YACpD,8FAA8F;SACxF,CAAA;QACR,MAAM,MAAM,GAAG,MAAM,4BAA4B,CAC/C,MAAM,CAAC,KAAK,CAAC,IAAI,EACjB,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,EAAE,EACzC,MAAM,CACP,CAAA;QACD,MAAM,CAAC,MAAM,CAAC,CAAC,aAAa,EAAE,CAAA;IAChC,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA;AAEF,QAAQ,CAAC,iCAAiC,EAAE,GAAG,EAAE;IAC/C,MAAM,MAAM,GAAG,CAAC,QAAgB,EAAE,KAAa,EAAE,EAAE,CAAC,CAAC;QACnD,KAAK,EAAE,EAAE,CAAC,6BAA6B,CAAC,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE;KAChE,CAAC,CAAA;IAEF,EAAE,CAAC,6DAA6D,EAAE,KAAK,IAAI,EAAE;QAC3E,MAAM,MAAM,GAAG,UAAU,EAAE,CAAA;QAC3B,MAAM,KAAK,GAAG,EAAE,IAAI,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,CAAA;QAC3C,MAAM,QAAQ,GAAG,MAAM,+BAA+B,CACpD,KAAK,EACL,MAAM,CAAC,KAAK,CAAC,IAAI,EACjB,MAAM,EACN,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,EAAE,EACzC,MAAM,CACP,CAAA;QACD,MAAM,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IAC9B,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,sFAAsF,EAAE,KAAK,IAAI,EAAE;QACpG,MAAM,MAAM,GAAG,UAAU,EAAE,CAAA;QAC3B,4EAA4E;QAC5E,MAAM,QAAQ,GAAG,EAAE,CAAC,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC;YACjC,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;YAClC,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;SACnC,CAAC,CAAA;QACF,MAAM,QAAQ,GAAG,MAAM,+BAA+B,CACpD,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,EACf,MAAM,CAAC,KAAK,CAAC,IAAI,EACjB,MAAM,EACN,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,CAAC,QAAQ,CAAC,EAAE,EACjD,MAAM,CACP,CAAA;QACD,8EAA8E;QAC9E,MAAM,CAAC,QAAQ,CAAC,CAAC,OAAO,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,CAAA;QAChD,MAAM,CAAC,QAAQ,CAAC,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAA;QACzC,MAAM,CAAC,QAAQ,CAAC,CAAC,oBAAoB,CAAC;YACpC,OAAO,EAAE,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,WAAW,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE;SAC3F,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,8EAA8E,EAAE,KAAK,IAAI,EAAE;QAC5F,MAAM,MAAM,GAAG,UAAU,EAAE,CAAA;QAC3B,MAAM,QAAQ,GAAG,EAAE,CAAC,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC;YACjC,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;YAClC,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;SACnC,CAAC,CAAA;QACF,MAAM,QAAQ,GAAG,MAAM,+BAA+B,CACpD,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,EAAE,uCAAuC;QACxD,MAAM,CAAC,KAAK,CAAC,IAAI,EACjB,MAAM,EACN,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,CAAC,QAAQ,CAAC,EAAE,EACjD,MAAM,CACP,CAAA;QACD,MAAM,CAAC,QAAQ,CAAC,CAAC,OAAO,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,CAAA;IAClD,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,qEAAqE,EAAE,KAAK,IAAI,EAAE;QACnF,MAAM,MAAM,GAAG,UAAU,EAAE,CAAA;QAC3B,MAAM,QAAQ,GAAG,EAAE,CAAC,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC,EAAE,CAAC,CAAA;QACtC,uEAAuE;QACvE,MAAM,KAAK,GAAG,EAAE,OAAO,EAAE,EAAE,CAAC,6BAA6B,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE,EAAE,CAAA;QAC5F,MAAM,QAAQ,GAAG,MAAM,+BAA+B,CACpD,KAAK,EACL,MAAM,CAAC,KAAK,CAAC,IAAI,EACjB,MAAM,EACN,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,CAAC,QAAQ,CAAC,EAAE,EACjD,MAAM,CACP,CAAA;QACD,0DAA0D;QAC1D,MAAM,CAAC,QAAQ,CAAC,CAAC,OAAO,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,CAAA;QAC5C,MAAM,CAAC,QAAQ,CAAC,CAAC,GAAG,CAAC,gBAAgB,EAAE,CAAA;IACzC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,iEAAiE,EAAE,KAAK,IAAI,EAAE;QAC/E,MAAM,MAAM,GAAG,UAAU,EAAE,CAAA;QAC3B,MAAM,QAAQ,GAAG,EAAE,CAAC,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAA;QACxE,MAAM,KAAK,GAAG,EAAE,GAAG,EAAE,CAAC,EAAE,IAAI,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAA;QACxE,MAAM,QAAQ,GAAG,MAAM,+BAA+B,CACpD,KAAK,EACL,MAAM,CAAC,KAAK,CAAC,IAAI,EACjB,MAAM,EACN,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,CAAC,QAAQ,CAAC,EAAE,EACjD,MAAM,CACP,CAAA;QACD,MAAM,CAAC,QAAQ,CAAC,CAAC,OAAO,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,IAAI,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,EAAE,CAAC,CAAA;IAC5F,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,sFAAsF,EAAE,KAAK,IAAI,EAAE;QACpG,MAAM,MAAM,GAAG,UAAU,EAAE,CAAA;QAC3B,MAAM,QAAQ,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC,KAAc,EAAE,EAAE,CACxC,OAAO,CAAC,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CACtD,CAAA;QACD,MAAM,+BAA+B,CACnC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,EACf,MAAM,CAAC,KAAK,CAAC,IAAI,EACjB,MAAM,EACN,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,CAAC,QAAQ,CAAC,EAAE,EACjD,MAAM,CACP,CAAA;QACD,4EAA4E;QAC5E,2EAA2E;QAC3E,8EAA8E;QAC9E,MAAM,CAAC,QAAQ,CAAC,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAA;QACzC,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;QACzC,MAAM,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAA;QAC5C,MAAM,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC;YACtB,OAAO,EAAE,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,WAAW,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE;SAC3F,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,2EAA2E,EAAE,KAAK,IAAI,EAAE;QACzF,MAAM,MAAM,GAAG,UAAU,EAAE,CAAA;QAC3B,MAAM,QAAQ,GAAG,EAAE,CAAC,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAA;QACxE,6EAA6E;QAC7E,0EAA0E;QAC1E,6EAA6E;QAC7E,+BAA+B;QAC/B,MAAM,KAAK,GAAG;YACZ,GAAG,EAAE;gBACH;oBACE,IAAI,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE;oBACzB,KAAK,EAAE,EAAE,CAAC,6BAA6B,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;iBACzE;aACF;SACF,CAAA;QACD,MAAM,QAAQ,GAAG,MAAM,+BAA+B,CACpD,KAAK,EACL,MAAM,CAAC,KAAK,CAAC,IAAI,EACjB,MAAM,EACN,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,CAAC,QAAQ,CAAC,EAAE,EACjD,MAAM,CACP,CAAA;QACD,MAAM,CAAC,QAAQ,CAAC,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,CAAA;IAC7E,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,yFAAyF,EAAE,KAAK,IAAI,EAAE;QACvG,MAAM,MAAM,GAAG,UAAU,EAAE,CAAA;QAC3B,MAAM,QAAQ,GAAG,EAAE,CAAC,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAA;QACxE,4EAA4E;QAC5E,4EAA4E;QAC5E,MAAM,KAAK,GAAG;YACZ,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE;YACxB,KAAK,EAAE,EAAE,CAAC,6BAA6B,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;SACzE,CAAA;QACD,MAAM,QAAQ,GAAG,MAAM,+BAA+B,CACpD,KAAK,EACL,MAAM,CAAC,KAAK,CAAC,IAAI,EACjB,MAAM,EACN,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,CAAC,QAAQ,CAAC,EAAE,EACjD,MAAM,CACP,CAAA;QACD,MAAM,CAAC,QAAQ,CAAC,CAAC,OAAO,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,EAAE,CAAC,CAAA;IAC3F,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}
@@ -0,0 +1,45 @@
1
+ import type { Session, AccessContext } from './types.js';
2
+ import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js';
3
+ /**
4
+ * Access-scoped to-one relationship label filters for the admin list view
5
+ * (issue #749).
6
+ *
7
+ * A to-one relationship's Filter spec (`author:Ada` → `{ author: { is: { name:
8
+ * { contains: 'Ada' } } } }`) is a pure mapper (see `relationship()` in
9
+ * `fields/index.ts`) — it has no way to consult the related list's access
10
+ * control, so the nested `is` clause it emits runs as an unscoped Prisma
11
+ * sub-filter against the related table. The parent list's own access filter
12
+ * still scopes which parent rows are visible, but the nested condition itself
13
+ * is evaluated with no reference to the related list's `query` access — a
14
+ * session could distinguish parent rows by a related field it is not itself
15
+ * allowed to read (e.g. binary-searching `author:A`, `author:Ad`, `author:Ada`
16
+ * against a `User` list it cannot query).
17
+ *
18
+ * This module is the single place the related list's operation-level `query`
19
+ * access is folded into that nested `is` clause, mirroring how
20
+ * `relationship-count.ts` folds it into `_count` selects and count-filter
21
+ * markers. A fully denied related list makes the member never match — the
22
+ * token cannot be used to confirm or rule out a value on a field the session
23
+ * cannot read (narrowing-only, matching the count resolver's denied path).
24
+ */
25
+ type LabelFilterArgs = {
26
+ session: Session | null;
27
+ context: AccessContext;
28
+ };
29
+ /**
30
+ * Whether a field is a to-one relationship — the only field kind whose Filter
31
+ * spec emits a nested `{ is: {...} } }` condition against the related list's
32
+ * label field.
33
+ */
34
+ export declare function isToOneRelationshipField(field: FieldConfig | undefined): boolean;
35
+ /**
36
+ * Replace any to-one relationship label-filter members in a filter `where`
37
+ * with access-scoped equivalents. Label-filter members only ever appear as
38
+ * top-level AND members (the relationship Filter spec does not declare
39
+ * `freeText`, so it never participates in the free-text OR), so this walks
40
+ * only the top level. Returns the `where` unchanged when it contains no such
41
+ * members, so lists without to-one relationship filters pay nothing.
42
+ */
43
+ export declare function resolveRelationshipLabelFilters(where: Record<string, unknown> | undefined, listConfig: ListConfig<any>, args: LabelFilterArgs, config: OpenSaasConfig): Promise<Record<string, unknown> | undefined>;
44
+ export {};
45
+ //# sourceMappingURL=relationship-label-filter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relationship-label-filter.d.ts","sourceRoot":"","sources":["../../src/access/relationship-label-filter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AACxD,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAIjF;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,KAAK,eAAe,GAAG;IACrB,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;IACvB,OAAO,EAAE,aAAa,CAAA;CACvB,CAAA;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,WAAW,GAAG,SAAS,GAAG,OAAO,CAQhF;AAsCD;;;;;;;GAOG;AACH,wBAAsB,+BAA+B,CACnD,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAE1C,UAAU,EAAE,UAAU,CAAC,GAAG,CAAC,EAC3B,IAAI,EAAE,eAAe,EACrB,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC,CAyC9C"}
@@ -0,0 +1,93 @@
1
+ import { checkAccess, getRelatedListConfig } from './engine.js';
2
+ import { mergeResolvedMember } from './relationship-count.js';
3
+ /**
4
+ * Whether a field is a to-one relationship — the only field kind whose Filter
5
+ * spec emits a nested `{ is: {...} } }` condition against the related list's
6
+ * label field.
7
+ */
8
+ export function isToOneRelationshipField(field) {
9
+ return (field?.type === 'relationship' &&
10
+ !('many' in field && field.many === true) &&
11
+ 'ref' in field &&
12
+ typeof field.ref === 'string' &&
13
+ field.ref.length > 0);
14
+ }
15
+ /** Narrow a filter member's field value to a `{ is: {...} } }` clause, if it is one. */
16
+ function readIsClause(value) {
17
+ if (!value || typeof value !== 'object')
18
+ return null;
19
+ const isValue = value.is;
20
+ if (!isValue || typeof isValue !== 'object')
21
+ return null;
22
+ return isValue;
23
+ }
24
+ /**
25
+ * Resolve one to-one relationship label-filter member into an access-scoped
26
+ * condition by ANDing the related list's `query` access filter into the nested
27
+ * `is` clause. Returns `{ id: { in: [] } }` (never matches) when the related
28
+ * list is fully denied, the original member unchanged when it is fully
29
+ * readable, and the `is` clause ANDed with the access filter otherwise.
30
+ */
31
+ async function resolveOneLabelFilter(field, fieldName, isClause, args, config) {
32
+ if (!('ref' in field) || typeof field.ref !== 'string') {
33
+ return { [fieldName]: { is: isClause } };
34
+ }
35
+ const related = getRelatedListConfig(field.ref, config);
36
+ if (!related)
37
+ return { [fieldName]: { is: isClause } };
38
+ const queryAccess = related.listConfig.access?.operation?.query;
39
+ const result = await checkAccess(queryAccess, { session: args.session, context: args.context });
40
+ if (result === false)
41
+ return { id: { in: [] } };
42
+ if (result === true)
43
+ return { [fieldName]: { is: isClause } };
44
+ return { [fieldName]: { is: { AND: [result, isClause] } } };
45
+ }
46
+ /**
47
+ * Replace any to-one relationship label-filter members in a filter `where`
48
+ * with access-scoped equivalents. Label-filter members only ever appear as
49
+ * top-level AND members (the relationship Filter spec does not declare
50
+ * `freeText`, so it never participates in the free-text OR), so this walks
51
+ * only the top level. Returns the `where` unchanged when it contains no such
52
+ * members, so lists without to-one relationship filters pay nothing.
53
+ */
54
+ export async function resolveRelationshipLabelFilters(where,
55
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
56
+ listConfig, args, config) {
57
+ if (!where)
58
+ return where;
59
+ const andValue = where.AND;
60
+ const members = Array.isArray(andValue)
61
+ ? andValue
62
+ : [where];
63
+ const findLabelFilter = (member) => {
64
+ for (const key of Object.keys(member)) {
65
+ const field = listConfig.fields[key];
66
+ if (!isToOneRelationshipField(field))
67
+ continue;
68
+ const isClause = readIsClause(member[key]);
69
+ if (!isClause)
70
+ continue;
71
+ return { field: key, isClause };
72
+ }
73
+ return null;
74
+ };
75
+ if (!members.some((member) => findLabelFilter(member) !== null)) {
76
+ return where;
77
+ }
78
+ const resolvedMembers = [];
79
+ for (const member of members) {
80
+ const found = findLabelFilter(member);
81
+ if (!found) {
82
+ resolvedMembers.push(member);
83
+ continue;
84
+ }
85
+ const field = listConfig.fields[found.field];
86
+ const resolved = await resolveOneLabelFilter(field, found.field, found.isClause, args, config);
87
+ const siblings = { ...member };
88
+ delete siblings[found.field];
89
+ resolvedMembers.push(mergeResolvedMember(siblings, resolved));
90
+ }
91
+ return resolvedMembers.length === 1 ? resolvedMembers[0] : { AND: resolvedMembers };
92
+ }
93
+ //# sourceMappingURL=relationship-label-filter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relationship-label-filter.js","sourceRoot":"","sources":["../../src/access/relationship-label-filter.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=relationship-label-filter.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relationship-label-filter.test.d.ts","sourceRoot":"","sources":["../../src/access/relationship-label-filter.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,126 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { list } from '../config/index.js';
3
+ import { text, relationship, integer } from '../fields/index.js';
4
+ import { resolveRelationshipLabelFilters, isToOneRelationshipField, } from './relationship-label-filter.js';
5
+ /**
6
+ * Access-scoped to-one relationship label filters (issue #749). Verifies that
7
+ * `author:Ada` → `{ author: { is: { name: { contains: 'Ada' } } } }` gets the
8
+ * related list's `query` access folded into the nested `is` clause, so a
9
+ * session can never distinguish parent rows by a related field it cannot
10
+ * itself read.
11
+ */
12
+ // Post is fully open; User (the `author` relation target) is scoped so only
13
+ // active users are queryable; Widget ships fully closed (no access block).
14
+ function makeConfig() {
15
+ return {
16
+ db: { provider: 'sqlite', prismaClientConstructor: () => null },
17
+ lists: {
18
+ User: list({
19
+ fields: { name: text(), active: integer() },
20
+ access: { operation: { query: () => ({ active: { equals: 1 } }) } },
21
+ }),
22
+ Post: list({
23
+ fields: {
24
+ title: text(),
25
+ views: integer(),
26
+ author: relationship({ ref: 'User.posts' }),
27
+ widget: relationship({ ref: 'Widget' }),
28
+ },
29
+ access: { operation: { query: () => true } },
30
+ }),
31
+ // Widget ships closed (no access block) → query denied by default.
32
+ Widget: list({ fields: { name: text() } }),
33
+ },
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
35
+ };
36
+ }
37
+ function makeContext() {
38
+ return {
39
+ session: null,
40
+ _isSudo: false,
41
+ _resolveOutputCounter: { depth: 0 },
42
+ db: {},
43
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
44
+ };
45
+ }
46
+ describe('isToOneRelationshipField', () => {
47
+ const config = makeConfig();
48
+ const postFields = config.lists.Post.fields;
49
+ it('is true only for a to-one relationship', () => {
50
+ expect(isToOneRelationshipField(postFields.author)).toBe(true);
51
+ expect(isToOneRelationshipField(postFields.widget)).toBe(true);
52
+ expect(isToOneRelationshipField(postFields.title)).toBe(false);
53
+ });
54
+ });
55
+ describe('resolveRelationshipLabelFilters', () => {
56
+ it('returns the where unchanged when there are no label-filter members', async () => {
57
+ const config = makeConfig();
58
+ const where = { title: { contains: 'hello' } };
59
+ const resolved = await resolveRelationshipLabelFilters(where, config.lists.Post, { session: null, context: makeContext() }, config);
60
+ expect(resolved).toBe(where);
61
+ });
62
+ it("ANDs the related list's access filter into the nested `is` clause", async () => {
63
+ const config = makeConfig();
64
+ const where = { author: { is: { name: { contains: 'Ada' } } } };
65
+ const resolved = await resolveRelationshipLabelFilters(where, config.lists.Post, { session: null, context: makeContext() }, config);
66
+ expect(resolved).toEqual({
67
+ author: { is: { AND: [{ active: { equals: 1 } }, { name: { contains: 'Ada' } }] } },
68
+ });
69
+ });
70
+ it('leaves the member unchanged when the related list is fully readable', async () => {
71
+ const config = {
72
+ db: { provider: 'sqlite', prismaClientConstructor: () => null },
73
+ lists: {
74
+ Tag: list({ fields: { name: text() }, access: { operation: { query: () => true } } }),
75
+ Post: list({ fields: { title: text(), tag: relationship({ ref: 'Tag' }) } }),
76
+ },
77
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
78
+ };
79
+ const where = { tag: { is: { name: { contains: 'news' } } } };
80
+ const resolved = await resolveRelationshipLabelFilters(where, config.lists.Post, { session: null, context: makeContext() }, config);
81
+ expect(resolved).toEqual(where);
82
+ });
83
+ it('resolves a fully denied related list to a never-matching member (no leak)', async () => {
84
+ const config = makeConfig();
85
+ const where = { widget: { is: { name: { contains: 'Gadget' } } } };
86
+ const resolved = await resolveRelationshipLabelFilters(where, config.lists.Post, { session: null, context: makeContext() }, config);
87
+ expect(resolved).toEqual({ id: { in: [] } });
88
+ });
89
+ it('preserves sibling conditions, ANDing the resolved id constraint for a denied member', async () => {
90
+ const config = makeConfig();
91
+ const where = {
92
+ AND: [{ title: { contains: 'hello' } }, { widget: { is: { name: { contains: 'Gadget' } } } }],
93
+ };
94
+ const resolved = await resolveRelationshipLabelFilters(where, config.lists.Post, { session: null, context: makeContext() }, config);
95
+ expect(resolved).toEqual({
96
+ AND: [{ title: { contains: 'hello' } }, { id: { in: [] } }],
97
+ });
98
+ });
99
+ it('preserves sibling conditions alongside the access-scoped nested `is` clause', async () => {
100
+ const config = makeConfig();
101
+ const where = {
102
+ AND: [{ title: { contains: 'hello' } }, { author: { is: { name: { contains: 'Ada' } } } }],
103
+ };
104
+ const resolved = await resolveRelationshipLabelFilters(where, config.lists.Post, { session: null, context: makeContext() }, config);
105
+ expect(resolved).toEqual({
106
+ AND: [
107
+ { title: { contains: 'hello' } },
108
+ { author: { is: { AND: [{ active: { equals: 1 } }, { name: { contains: 'Ada' } }] } } },
109
+ ],
110
+ });
111
+ });
112
+ it('composes with a to-many count marker member left untouched by this resolver', async () => {
113
+ const config = makeConfig();
114
+ const where = {
115
+ AND: [{ author: { is: { name: { contains: 'Ada' } } } }, { views: { gt: 10 } }],
116
+ };
117
+ const resolved = await resolveRelationshipLabelFilters(where, config.lists.Post, { session: null, context: makeContext() }, config);
118
+ expect(resolved).toEqual({
119
+ AND: [
120
+ { author: { is: { AND: [{ active: { equals: 1 } }, { name: { contains: 'Ada' } }] } } },
121
+ { views: { gt: 10 } },
122
+ ],
123
+ });
124
+ });
125
+ });
126
+ //# sourceMappingURL=relationship-label-filter.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relationship-label-filter.test.js","sourceRoot":"","sources":["../../src/access/relationship-label-filter.test.ts"],"names":[],"mappings":"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"}
@@ -62,5 +62,5 @@ export declare function config(userConfig: OpenSaasConfig): OpenSaasConfig | Pro
62
62
  * ```
63
63
  */
64
64
  export declare function list<TTypeInfo extends import('./types.js').TypeInfo>(config: ListConfigInput<TTypeInfo>): ListConfig<TTypeInfo>;
65
- export type { OpenSaasConfig, OutputConfig, ListConfig, ListConfigInput, ListAccessControl, FieldConfig, BaseFieldConfig, TextField, IntegerField, CheckboxField, TimestampField, PasswordField, SelectField, RelationshipField, PrismaRelationResult, MultiColumnPrismaResult, JsonField, VirtualField, TypeDescriptor, TypeInfo, OperationAccess, Hooks, FieldHooks, FieldsWithTypeInfo, DatabaseConfig, SessionConfig, UIConfig, ListUIConfig, ListViewUIConfig, ThemeConfig, ThemePreset, ThemeColors, ThemeFonts, ThemeShadows, McpConfig, McpToolsConfig, McpAuthConfig, ListMcpConfig, McpCustomTool, FileMetadata, ImageMetadata, ImageTransformationResult, Plugin, PluginContext, GeneratedFiles, ResolveInputHookArgs, ValidateHookArgs, BeforeOperationHookArgs, AfterOperationHookArgs, FieldResolveInputHookArgs, FieldValidateHookArgs, FieldBeforeOperationHookArgs, FieldAfterOperationHookArgs, FieldResolveOutputHookArgs, } from './types.js';
65
+ export type { OpenSaasConfig, OutputConfig, ListConfig, ListConfigInput, ListAccessControl, FieldConfig, BaseFieldConfig, TextField, IntegerField, CheckboxField, TimestampField, PasswordField, SelectField, RelationshipField, PrismaRelationResult, MultiColumnPrismaResult, JsonField, VirtualField, TypeDescriptor, TypeInfo, OperationAccess, Hooks, FieldHooks, FieldsWithTypeInfo, DatabaseConfig, SessionConfig, UIConfig, ListUIConfig, ListViewUIConfig, BulkAction, BulkActionContext, BulkActionResult, BulkActionVariant, ItemViewUIConfig, RelationshipItemViewConfig, ThemeConfig, ThemePreset, ThemeColors, ThemeFonts, ThemeShadows, McpConfig, McpToolsConfig, McpAuthConfig, ListMcpConfig, McpCustomTool, FileMetadata, ImageMetadata, ImageTransformationResult, Plugin, PluginContext, GeneratedFiles, ResolveInputHookArgs, ValidateHookArgs, BeforeOperationHookArgs, AfterOperationHookArgs, FieldResolveInputHookArgs, FieldValidateHookArgs, FieldBeforeOperationHookArgs, FieldAfterOperationHookArgs, FieldResolveOutputHookArgs, } from './types.js';
66
66
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/config/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EACd,UAAU,EACV,eAAe,EAGhB,MAAM,YAAY,CAAA;AA8BnB;;;;;;GAMG;AACH,wBAAgB,MAAM,CAAC,UAAU,EAAE,cAAc,GAAG,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAQ3F;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,wBAAgB,IAAI,CAAC,SAAS,SAAS,OAAO,YAAY,EAAE,QAAQ,EAClE,MAAM,EAAE,eAAe,CAAC,SAAS,CAAC,GACjC,UAAU,CAAC,SAAS,CAAC,CAUvB;AAGD,YAAY,EACV,cAAc,EACd,YAAY,EACZ,UAAU,EACV,eAAe,EACf,iBAAiB,EACjB,WAAW,EACX,eAAe,EACf,SAAS,EACT,YAAY,EACZ,aAAa,EACb,cAAc,EACd,aAAa,EACb,WAAW,EACX,iBAAiB,EACjB,oBAAoB,EACpB,uBAAuB,EACvB,SAAS,EACT,YAAY,EACZ,cAAc,EACd,QAAQ,EACR,eAAe,EACf,KAAK,EACL,UAAU,EACV,kBAAkB,EAClB,cAAc,EACd,aAAa,EACb,QAAQ,EACR,YAAY,EACZ,gBAAgB,EAChB,WAAW,EACX,WAAW,EACX,WAAW,EACX,UAAU,EACV,YAAY,EACZ,SAAS,EACT,cAAc,EACd,aAAa,EACb,aAAa,EACb,aAAa,EACb,YAAY,EACZ,aAAa,EACb,yBAAyB,EAEzB,MAAM,EACN,aAAa,EACb,cAAc,EAEd,oBAAoB,EACpB,gBAAgB,EAChB,uBAAuB,EACvB,sBAAsB,EAEtB,yBAAyB,EACzB,qBAAqB,EACrB,4BAA4B,EAC5B,2BAA2B,EAC3B,0BAA0B,GAC3B,MAAM,YAAY,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/config/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,cAAc,EACd,UAAU,EACV,eAAe,EAGhB,MAAM,YAAY,CAAA;AA8BnB;;;;;;GAMG;AACH,wBAAgB,MAAM,CAAC,UAAU,EAAE,cAAc,GAAG,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAQ3F;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,wBAAgB,IAAI,CAAC,SAAS,SAAS,OAAO,YAAY,EAAE,QAAQ,EAClE,MAAM,EAAE,eAAe,CAAC,SAAS,CAAC,GACjC,UAAU,CAAC,SAAS,CAAC,CAUvB;AAGD,YAAY,EACV,cAAc,EACd,YAAY,EACZ,UAAU,EACV,eAAe,EACf,iBAAiB,EACjB,WAAW,EACX,eAAe,EACf,SAAS,EACT,YAAY,EACZ,aAAa,EACb,cAAc,EACd,aAAa,EACb,WAAW,EACX,iBAAiB,EACjB,oBAAoB,EACpB,uBAAuB,EACvB,SAAS,EACT,YAAY,EACZ,cAAc,EACd,QAAQ,EACR,eAAe,EACf,KAAK,EACL,UAAU,EACV,kBAAkB,EAClB,cAAc,EACd,aAAa,EACb,QAAQ,EACR,YAAY,EACZ,gBAAgB,EAChB,UAAU,EACV,iBAAiB,EACjB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,0BAA0B,EAC1B,WAAW,EACX,WAAW,EACX,WAAW,EACX,UAAU,EACV,YAAY,EACZ,SAAS,EACT,cAAc,EACd,aAAa,EACb,aAAa,EACb,aAAa,EACb,YAAY,EACZ,aAAa,EACb,yBAAyB,EAEzB,MAAM,EACN,aAAa,EACb,cAAc,EAEd,oBAAoB,EACpB,gBAAgB,EAChB,uBAAuB,EACvB,sBAAsB,EAEtB,yBAAyB,EACzB,qBAAqB,EACrB,4BAA4B,EAC5B,2BAA2B,EAC3B,0BAA0B,GAC3B,MAAM,YAAY,CAAA"}
@@ -0,0 +1,34 @@
1
+ import type { AccessContext } from '../access/types.js';
2
+ import type { ListConfig, OpenSaasConfig } from './types.js';
3
+ /**
4
+ * Whether a list's query access is **statically denied** — provably no session
5
+ * can read any row without evaluating a session-dependent function.
6
+ *
7
+ * Mirrors the list view's `canDeleteList` static check (issue #733): query
8
+ * access is statically denied when it is absent (deny-by-default, matching the
9
+ * access engine) or the literal boolean `false`. A function — or `true` —
10
+ * cannot be decided up front, so the count is fetched through the secured
11
+ * context, which re-applies the real rule per row.
12
+ *
13
+ * A statically-denied list must render no nav count at all: a `0` there would
14
+ * imply "this list is empty" when the truth is "you may see none of it".
15
+ */
16
+ export declare function isListQueryStaticallyDenied(listConfig: ListConfig<any>): boolean;
17
+ /**
18
+ * Resolve the access-scoped nav counts for the lists that opt in via
19
+ * `ui.navCount` (issue #735).
20
+ *
21
+ * The returned map is keyed by list key and contains an entry **only** for a
22
+ * list that:
23
+ *
24
+ * 1. opts in (`ui.navCount === true`) — no count query runs for any other list,
25
+ * 2. is not a singleton (a single-record list has no meaningful count), and
26
+ * 3. does not have statically-denied query access (see
27
+ * {@link isListQueryStaticallyDenied}).
28
+ *
29
+ * Each count is read through the secured `context.db`, whose `count` applies the
30
+ * access filter, so the number reflects exactly what the current session may
31
+ * see (a denied row is not counted). Counts are fetched concurrently.
32
+ */
33
+ export declare function resolveNavCounts(context: AccessContext, config: OpenSaasConfig): Promise<Record<string, number>>;
34
+ //# sourceMappingURL=nav-count.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nav-count.d.ts","sourceRoot":"","sources":["../../src/config/nav-count.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAEvD,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAY,MAAM,YAAY,CAAA;AAWtE;;;;;;;;;;;;GAYG;AACH,wBAAgB,2BAA2B,CAEzC,UAAU,EAAE,UAAU,CAAC,GAAG,CAAC,GAC1B,OAAO,CAKT;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,aAAa,EACtB,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CA6BjC"}
@@ -0,0 +1,71 @@
1
+ import { getDbKey } from '../lib/case-utils.js';
2
+ /**
3
+ * Whether a list's query access is **statically denied** — provably no session
4
+ * can read any row without evaluating a session-dependent function.
5
+ *
6
+ * Mirrors the list view's `canDeleteList` static check (issue #733): query
7
+ * access is statically denied when it is absent (deny-by-default, matching the
8
+ * access engine) or the literal boolean `false`. A function — or `true` —
9
+ * cannot be decided up front, so the count is fetched through the secured
10
+ * context, which re-applies the real rule per row.
11
+ *
12
+ * A statically-denied list must render no nav count at all: a `0` there would
13
+ * imply "this list is empty" when the truth is "you may see none of it".
14
+ */
15
+ export function isListQueryStaticallyDenied(
16
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
17
+ listConfig) {
18
+ const queryAccess = listConfig.access?.operation?.query;
19
+ if (queryAccess === undefined)
20
+ return true;
21
+ if (typeof queryAccess === 'boolean')
22
+ return !queryAccess;
23
+ return false;
24
+ }
25
+ /**
26
+ * Resolve the access-scoped nav counts for the lists that opt in via
27
+ * `ui.navCount` (issue #735).
28
+ *
29
+ * The returned map is keyed by list key and contains an entry **only** for a
30
+ * list that:
31
+ *
32
+ * 1. opts in (`ui.navCount === true`) — no count query runs for any other list,
33
+ * 2. is not a singleton (a single-record list has no meaningful count), and
34
+ * 3. does not have statically-denied query access (see
35
+ * {@link isListQueryStaticallyDenied}).
36
+ *
37
+ * Each count is read through the secured `context.db`, whose `count` applies the
38
+ * access filter, so the number reflects exactly what the current session may
39
+ * see (a denied row is not counted). Counts are fetched concurrently.
40
+ */
41
+ export async function resolveNavCounts(context, config) {
42
+ const counts = {};
43
+ const lists = config.lists ?? {};
44
+ const optedIn = Object.keys(lists).filter((listKey) => {
45
+ const listConfig = lists[listKey];
46
+ if (!listConfig)
47
+ return false;
48
+ if (listConfig.ui?.navCount !== true)
49
+ return false;
50
+ if (listConfig.isSingleton)
51
+ return false;
52
+ return !isListQueryStaticallyDenied(listConfig);
53
+ });
54
+ await Promise.all(optedIn.map(async (listKey) => {
55
+ const delegate = context.db?.[getDbKey(listKey)];
56
+ if (!delegate?.count)
57
+ return;
58
+ // A single list's count failing (a DB hiccup, a throwing access filter or
59
+ // hook) must not blank the whole admin chrome — `AdminUI` awaits this
60
+ // before rendering the shell. Degrade like the sibling `ListView`: log and
61
+ // omit just that badge. Other lists' counts are unaffected.
62
+ try {
63
+ counts[listKey] = await delegate.count();
64
+ }
65
+ catch (error) {
66
+ console.error(`Failed to resolve nav count for ${listKey}:`, error);
67
+ }
68
+ }));
69
+ return counts;
70
+ }
71
+ //# sourceMappingURL=nav-count.js.map