@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +12 -0
- package/CLAUDE.md +2 -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 +3 -1
- 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/config/plugin-engine.ts +13 -28
- package/src/config/types.ts +15 -2
- package/src/context/index.ts +6 -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
|
@@ -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
|
},
|
package/src/config/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/context/index.ts
CHANGED
|
@@ -393,7 +393,12 @@ export function getContext<
|
|
|
393
393
|
for (const plugin of pluginsToExecute) {
|
|
394
394
|
if (plugin.runtime) {
|
|
395
395
|
try {
|
|
396
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
})
|