@opensaas/stack-core 0.27.1 → 0.28.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.
@@ -4,7 +4,6 @@ import type {
4
4
  PluginContext,
5
5
  ListConfig,
6
6
  Hooks,
7
- OperationAccess,
8
7
  McpCustomTool,
9
8
  BaseFieldConfig,
10
9
  } from './types.js'
@@ -125,29 +124,6 @@ function mergeHooks(existing: Hooks | undefined, extension: Hooks | undefined):
125
124
  return Object.keys(merged).length > 0 ? (merged as Hooks) : undefined
126
125
  }
127
126
 
128
- /**
129
- * Merge access control from extension into existing access
130
- */
131
- function mergeAccess(
132
- existing: { operation?: OperationAccess } | undefined,
133
- extension: { operation?: OperationAccess } | undefined,
134
- ): { operation?: OperationAccess } | undefined {
135
- if (!extension) return existing
136
- if (!existing) return extension
137
-
138
- const merged: { operation?: OperationAccess } = {}
139
-
140
- // Merge operation access (use extension if provided, otherwise keep existing)
141
- if (existing.operation || extension.operation) {
142
- merged.operation = {
143
- ...existing.operation,
144
- ...extension.operation,
145
- }
146
- }
147
-
148
- return merged
149
- }
150
-
151
127
  /**
152
128
  * Execute plugins and transform config
153
129
  * Returns modified config with plugin data attached
@@ -197,6 +173,19 @@ export async function executePlugins(config: OpenSaasConfig): Promise<OpenSaasCo
197
173
  )
198
174
  }
199
175
 
176
+ // Operation-level access belongs to whoever owns the list (the
177
+ // application, or an earlier plugin that created it via addList).
178
+ // An extension of a pre-existing list must never define or override
179
+ // that access — see ADR-0013. Fields/hooks/relationships/mcp may
180
+ // still be merged in below.
181
+ if (extension.access?.operation) {
182
+ throw new Error(
183
+ `Plugin "${plugin.name}" tried to set operation-level access while extending list "${name}", ` +
184
+ `but access control belongs to the application (or whichever party created the list), never a ` +
185
+ `plugin extending it. Remove "access" from this extension — see ADR-0013.`,
186
+ )
187
+ }
188
+
200
189
  // Deep merge fields
201
190
  const mergedFields = {
202
191
  ...existing.fields,
@@ -206,9 +195,6 @@ export async function executePlugins(config: OpenSaasConfig): Promise<OpenSaasCo
206
195
  // Merge hooks
207
196
  const mergedHooks = mergeHooks(existing.hooks, extension.hooks)
208
197
 
209
- // Merge access control
210
- const mergedAccess = mergeAccess(existing.access, extension.access)
211
-
212
198
  // Merge MCP config
213
199
  const mergedMcp = extension.mcp
214
200
  ? {
@@ -221,7 +207,6 @@ export async function executePlugins(config: OpenSaasConfig): Promise<OpenSaasCo
221
207
  ...existing,
222
208
  fields: mergedFields,
223
209
  hooks: mergedHooks,
224
- access: mergedAccess,
225
210
  mcp: mergedMcp,
226
211
  }
227
212
  },
@@ -2437,8 +2437,21 @@ export type Plugin = {
2437
2437
  * Optional: Provide runtime services
2438
2438
  * Called when creating context to provide plugin-specific services
2439
2439
  * Return value is stored in context.plugins[pluginName]
2440
- */
2441
- runtime?: (context: import('../access/types.js').AccessContext) => unknown
2440
+ *
2441
+ * `sudo` returns an access-bypassing (but still hook-firing) `AccessContext`
2442
+ * for the same request — use `sudo().db` for reads/writes that must not
2443
+ * depend on the caller's own list access policy (e.g. an identity lookup
2444
+ * like "who is this session"). Deliberately NOT a method on `AccessContext`
2445
+ * itself — a self-referential `sudo(): AccessContext` field on that shared,
2446
+ * widely-instantiated interface tripped up TypeScript's structural checking
2447
+ * of unrelated generated Prisma types elsewhere (nullable JSON `CreateInput`
2448
+ * fields) in a downstream app; passing it as a plain second argument avoids
2449
+ * that recursion entirely.
2450
+ */
2451
+ runtime?: (
2452
+ context: import('../access/types.js').AccessContext,
2453
+ sudo: () => import('../access/types.js').AccessContext,
2454
+ ) => unknown
2442
2455
 
2443
2456
  /**
2444
2457
  * Optional: Type metadata for runtime services
@@ -393,7 +393,12 @@ export function getContext<
393
393
  for (const plugin of pluginsToExecute) {
394
394
  if (plugin.runtime) {
395
395
  try {
396
- context.plugins[plugin.name] = plugin.runtime(context)
396
+ // Passed as a plain second argument rather than a method on
397
+ // `context` itself — see the `sudo` param doc on `Plugin['runtime']`.
398
+ context.plugins[plugin.name] = plugin.runtime(
399
+ context,
400
+ () => sudo() as unknown as AccessContext<TPrisma>,
401
+ )
397
402
  } catch (error) {
398
403
  console.error(`Error executing runtime for plugin "${plugin.name}":`, error)
399
404
  // Continue with other plugins even if one fails
@@ -39,6 +39,13 @@ function makeConfig(): OpenSaasConfig {
39
39
  ui: { labelField: 'rank' },
40
40
  access: { operation: { query: () => true } },
41
41
  },
42
+ VirtualLabel: {
43
+ fields: {
44
+ displayName: { type: 'virtual', virtual: true },
45
+ },
46
+ ui: { labelField: 'displayName' },
47
+ access: { operation: { query: () => true } },
48
+ },
42
49
  },
43
50
  } as unknown as OpenSaasConfig
44
51
  }
@@ -99,6 +106,36 @@ describe('getRelationshipOptions', () => {
99
106
  expect(call.orderBy).toEqual({ rank: 'asc' })
100
107
  })
101
108
 
109
+ it('falls back to ordering by id when the label field is virtual (no backing column)', async () => {
110
+ const rows = [{ id: 'v1', displayName: 'Computed One' }]
111
+ const delegate = makeDelegate(rows)
112
+ const context = makeContext({ virtualLabel: delegate })
113
+ const config = makeConfig()
114
+
115
+ const result = await getRelationshipOptions(context, config, 'VirtualLabel', { search: 'One' })
116
+
117
+ const call = delegate.findMany.mock.calls[0][0] as Record<string, unknown>
118
+ // Virtual label fields have no backing column, so ordering by them would
119
+ // 500 in Prisma — order by id instead, and skip the text `contains` filter.
120
+ expect(call.orderBy).toEqual({ id: 'asc' })
121
+ expect(call.where).toBeUndefined()
122
+ expect(result).toEqual([{ id: 'v1', label: 'Computed One' }])
123
+ })
124
+
125
+ it('treats a field flagged virtual via type alone as non-orderable (orders by id)', async () => {
126
+ const config = makeConfig()
127
+ // Only `type: 'virtual'` is set here (no `virtual: true`) to lock in the
128
+ // discriminator against future refactors.
129
+ ;(config.lists.VirtualLabel.fields.displayName as { virtual?: boolean }).virtual = undefined
130
+ const delegate = makeDelegate([{ id: 'v1', displayName: 'Computed One' }])
131
+ const context = makeContext({ virtualLabel: delegate })
132
+
133
+ await getRelationshipOptions(context, config, 'VirtualLabel', { search: 'One' })
134
+
135
+ const call = delegate.findMany.mock.calls[0][0] as Record<string, unknown>
136
+ expect(call.orderBy).toEqual({ id: 'asc' })
137
+ })
138
+
102
139
  it('unions currently-selected ids even when beyond take / not matching search', async () => {
103
140
  // The bounded/search-scoped query only returns a1 (mimicking take:1 + search).
104
141
  const primaryDelegate = makeDelegate([authors[0]])
@@ -44,13 +44,21 @@ export async function getRelationshipOptions(
44
44
  })
45
45
 
46
46
  const { search, take = DEFAULT_TAKE, selectedIds = [] } = args
47
- const labelFieldConfig = relatedListConfig.fields[labelField] as { type?: string } | undefined
47
+ const labelFieldConfig = relatedListConfig.fields[labelField] as
48
+ { type?: string; virtual?: boolean } | undefined
48
49
  const where =
49
50
  search && labelFieldConfig?.type === 'text' ? { [labelField]: { contains: search } } : undefined
50
51
 
52
+ // Virtual/computed label fields (resolved at read time via `resolveOutput`)
53
+ // have no backing database column, so passing them into `orderBy` fails
54
+ // Prisma validation and 500s the request. Fall back to ordering by `id` —
55
+ // always a real, orderable column — whenever the label field is virtual.
56
+ const isVirtualLabel = labelFieldConfig?.type === 'virtual' || labelFieldConfig?.virtual === true
57
+ const orderBy: Record<string, 'asc'> = isVirtualLabel ? { id: 'asc' } : { [labelField]: 'asc' }
58
+
51
59
  const primary = await runQuery(context, relatedListKey, fragment, {
52
60
  where,
53
- orderBy: { [labelField]: 'asc' },
61
+ orderBy,
54
62
  take,
55
63
  })
56
64
 
@@ -681,8 +681,8 @@ describe('Plugin Engine', () => {
681
681
  })
682
682
  })
683
683
 
684
- describe('access control merging', () => {
685
- test('merges access control from plugins', async () => {
684
+ describe('access control guardrail (ADR-0013)', () => {
685
+ test('throws when an extension carries operation access for a pre-existing list', async () => {
686
686
  const plugin: Plugin = {
687
687
  name: 'test-plugin',
688
688
  init: async (context) => {
@@ -711,23 +711,19 @@ describe('Plugin Engine', () => {
711
711
  plugins: [plugin],
712
712
  }
713
713
 
714
- const result = await executePlugins(config)
715
-
716
- expect(result.lists.Post.access?.operation?.query).toBeDefined()
717
- expect(result.lists.Post.access?.operation?.create).toBeDefined()
718
- expect(result.lists.Post.access?.operation?.update).toBeDefined()
714
+ await expect(executePlugins(config)).rejects.toThrow(
715
+ 'Plugin "test-plugin" tried to set operation-level access while extending list "Post"',
716
+ )
719
717
  })
720
718
 
721
- test('plugin access control overrides existing', async () => {
722
- const pluginQuery = vi.fn(() => false)
723
-
719
+ test('extension attempting to override existing access throws instead of silently winning', async () => {
724
720
  const plugin: Plugin = {
725
721
  name: 'test-plugin',
726
722
  init: async (context) => {
727
723
  context.extendList('Post', {
728
724
  access: {
729
725
  operation: {
730
- query: pluginQuery,
726
+ query: () => false,
731
727
  },
732
728
  },
733
729
  })
@@ -750,10 +746,67 @@ describe('Plugin Engine', () => {
750
746
  plugins: [plugin],
751
747
  }
752
748
 
749
+ await expect(executePlugins(config)).rejects.toThrow('Post')
750
+
751
+ // The host's access is untouched — the throw happens before any mutation.
752
+ expect(config.lists.Post.access?.operation?.query).toBe(originalQuery)
753
+ })
754
+
755
+ test('extension providing only fields/hooks for a pre-existing list leaves its access untouched', async () => {
756
+ const originalQuery = vi.fn(() => true)
757
+
758
+ const plugin: Plugin = {
759
+ name: 'test-plugin',
760
+ init: async (context) => {
761
+ context.extendList('Post', {
762
+ fields: { views: integer() },
763
+ })
764
+ },
765
+ }
766
+
767
+ const config: OpenSaasConfig = {
768
+ lists: {
769
+ Post: {
770
+ fields: { title: text() },
771
+ access: {
772
+ operation: {
773
+ query: originalQuery,
774
+ },
775
+ },
776
+ },
777
+ },
778
+ plugins: [plugin],
779
+ }
780
+
781
+ const result = await executePlugins(config)
782
+
783
+ expect(result.lists.Post.fields.views).toBeDefined()
784
+ expect(result.lists.Post.access?.operation?.query).toBe(originalQuery)
785
+ })
786
+
787
+ test('a plugin creating a new list may still set its access via addList', async () => {
788
+ const plugin: Plugin = {
789
+ name: 'test-plugin',
790
+ init: async (context) => {
791
+ context.addList('Widget', {
792
+ fields: { name: text() },
793
+ access: {
794
+ operation: {
795
+ query: () => true,
796
+ },
797
+ },
798
+ })
799
+ },
800
+ }
801
+
802
+ const config: OpenSaasConfig = {
803
+ lists: {},
804
+ plugins: [plugin],
805
+ }
806
+
753
807
  const result = await executePlugins(config)
754
808
 
755
- // Plugin's access control should override original
756
- expect(result.lists.Post.access?.operation?.query).toBe(pluginQuery)
809
+ expect(result.lists.Widget.access?.operation?.query).toBeDefined()
757
810
  })
758
811
  })
759
812
 
@@ -3,6 +3,8 @@ import { getContext } from '../src/context/index.js'
3
3
  import { config, list } from '../src/config/index.js'
4
4
  import { text, integer, relationship } from '../src/fields/index.js'
5
5
  import type { PrismaClient } from '@prisma/client'
6
+ import type { Plugin } from '../src/config/types.js'
7
+ import type { AccessContext } from '../src/access/types.js'
6
8
 
7
9
  describe('Sudo Context', () => {
8
10
  // Mock Prisma client
@@ -624,4 +626,51 @@ describe('Sudo Context', () => {
624
626
  ).rejects.toThrow('Access denied')
625
627
  })
626
628
  })
629
+
630
+ describe('Plugin runtime sudo access', () => {
631
+ // A plugin's `runtime(context, sudo)` factory receives a `sudo` helper as a
632
+ // plain second argument — NOT a method on `AccessContext` itself. A
633
+ // self-referential `sudo(): AccessContext` field on that shared, widely
634
+ // instantiated interface was found to break TypeScript's structural
635
+ // checking of unrelated generated Prisma types in a downstream app
636
+ // (nullable JSON `CreateInput` fields); passing it as a separate argument
637
+ // avoids that recursion while still giving plugins (e.g. the auth
638
+ // plugin's getUser/getCurrentUser, see ADR-0013) an access-bypassing
639
+ // identity-lookup path.
640
+ it('passes a working sudo() as the second argument to plugin.runtime()', async () => {
641
+ let capturedSudo: (() => AccessContext<typeof mockPrisma>) | undefined
642
+
643
+ const plugin: Plugin = {
644
+ name: 'test-plugin',
645
+ init: async () => {},
646
+ runtime: (_context, sudo) => {
647
+ capturedSudo = sudo as () => AccessContext<typeof mockPrisma>
648
+ return {}
649
+ },
650
+ }
651
+
652
+ const pluginConfig = await config({
653
+ db: { provider: 'sqlite' },
654
+ plugins: [plugin],
655
+ lists: {
656
+ Post: list({
657
+ fields: { title: text({ validation: { isRequired: true } }) },
658
+ // Closed list: only sudo() should be able to read from it.
659
+ access: { operation: { query: () => false } },
660
+ }),
661
+ },
662
+ })
663
+
664
+ mockPrisma.post.findMany.mockResolvedValue([{ id: '1', title: 'Test Post' }])
665
+
666
+ getContext(pluginConfig, mockPrisma, null)
667
+
668
+ expect(capturedSudo).toBeTypeOf('function')
669
+
670
+ const sudoContext = capturedSudo!()
671
+ const sudoResult = await sudoContext.db.post.findMany()
672
+ expect(sudoResult).toHaveLength(1)
673
+ expect(sudoResult[0].title).toBe('Test Post')
674
+ })
675
+ })
627
676
  })