@opensaas/stack-core 0.27.0 → 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.
Files changed (35) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +18 -0
  3. package/CLAUDE.md +2 -0
  4. package/dist/access/access-filter.d.ts +1 -1
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +23 -5
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.d.ts +2 -0
  9. package/dist/access/access-filter.test.d.ts.map +1 -0
  10. package/dist/access/access-filter.test.js +116 -0
  11. package/dist/access/access-filter.test.js.map +1 -0
  12. package/dist/config/plugin-engine.d.ts.map +1 -1
  13. package/dist/config/plugin-engine.js +10 -21
  14. package/dist/config/plugin-engine.js.map +1 -1
  15. package/dist/config/types.d.ts +12 -2
  16. package/dist/config/types.d.ts.map +1 -1
  17. package/dist/context/index.d.ts.map +1 -1
  18. package/dist/context/index.js +15 -4
  19. package/dist/context/index.js.map +1 -1
  20. package/dist/query/relationship-options.d.ts.map +1 -1
  21. package/dist/query/relationship-options.js +7 -1
  22. package/dist/query/relationship-options.js.map +1 -1
  23. package/dist/query/relationship-options.test.js +29 -0
  24. package/dist/query/relationship-options.test.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/access/access-filter.test.ts +149 -0
  27. package/src/access/access-filter.ts +29 -10
  28. package/src/config/plugin-engine.ts +13 -28
  29. package/src/config/types.ts +15 -2
  30. package/src/context/index.ts +18 -1
  31. package/src/query/relationship-options.test.ts +37 -0
  32. package/src/query/relationship-options.ts +10 -2
  33. package/tests/plugin-engine.test.ts +66 -13
  34. package/tests/sudo.test.ts +49 -0
  35. package/tsconfig.tsbuildinfo +1 -1
@@ -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
  })