@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +18 -0
- package/CLAUDE.md +2 -0
- package/dist/access/access-filter.d.ts +1 -1
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +23 -5
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.d.ts +2 -0
- package/dist/access/access-filter.test.d.ts.map +1 -0
- package/dist/access/access-filter.test.js +116 -0
- package/dist/access/access-filter.test.js.map +1 -0
- package/dist/config/plugin-engine.d.ts.map +1 -1
- package/dist/config/plugin-engine.js +10 -21
- package/dist/config/plugin-engine.js.map +1 -1
- package/dist/config/types.d.ts +12 -2
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +15 -4
- package/dist/context/index.js.map +1 -1
- package/dist/query/relationship-options.d.ts.map +1 -1
- package/dist/query/relationship-options.js +7 -1
- package/dist/query/relationship-options.js.map +1 -1
- package/dist/query/relationship-options.test.js +29 -0
- package/dist/query/relationship-options.test.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +149 -0
- package/src/access/access-filter.ts +29 -10
- package/src/config/plugin-engine.ts +13 -28
- package/src/config/types.ts +15 -2
- package/src/context/index.ts +18 -1
- package/src/query/relationship-options.test.ts +37 -0
- package/src/query/relationship-options.ts +10 -2
- package/tests/plugin-engine.test.ts +66 -13
- package/tests/sudo.test.ts +49 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -681,8 +681,8 @@ describe('Plugin Engine', () => {
|
|
|
681
681
|
})
|
|
682
682
|
})
|
|
683
683
|
|
|
684
|
-
describe('access control
|
|
685
|
-
test('
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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('
|
|
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:
|
|
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
|
-
|
|
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
|
|
package/tests/sudo.test.ts
CHANGED
|
@@ -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
|
})
|