spine-framework 0.3.98 → 0.3.99
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/.framework/cli/commands/install-app.ts +4 -0
- package/.framework/functions/_shared/app-manifest.ts +4 -0
- package/.framework/functions/_shared/permissions.ts +4 -0
- package/.framework/migrations/001_schema.sql +2 -0
- package/.framework/migrations/021_app_domains.sql +112 -0
- package/.framework/src/contexts/AppContext.tsx +13 -2
- package/.framework/src/hooks/useApps.ts +12 -0
- package/package.json +2 -1
|
@@ -272,6 +272,8 @@ export function registerInstallAppCommands(program: Command) {
|
|
|
272
272
|
renderer: manifest.renderer ?? 'custom',
|
|
273
273
|
required_roles: manifest.required_roles ?? (manifest.min_role ? [manifest.min_role] : []),
|
|
274
274
|
min_role: manifest.required_roles?.[0] ?? manifest.min_role ?? null,
|
|
275
|
+
preview_domain: manifest.preview_domain ?? null,
|
|
276
|
+
prod_domain: manifest.prod_domain ?? null,
|
|
275
277
|
},
|
|
276
278
|
{ onConflict: 'slug' }
|
|
277
279
|
)
|
|
@@ -360,6 +362,8 @@ async function seedApp(appDir: string, appSlug: string) {
|
|
|
360
362
|
required_roles: manifest.required_roles ?? (manifest.min_role ? [manifest.min_role] : []),
|
|
361
363
|
// min_role: kept as legacy fallback — first entry of required_roles
|
|
362
364
|
min_role: manifest.required_roles?.[0] ?? manifest.min_role ?? null,
|
|
365
|
+
preview_domain: manifest.preview_domain ?? null,
|
|
366
|
+
prod_domain: manifest.prod_domain ?? null,
|
|
363
367
|
},
|
|
364
368
|
{ onConflict: 'slug' }
|
|
365
369
|
)
|
|
@@ -25,6 +25,8 @@ export interface AppManifest {
|
|
|
25
25
|
description?: string
|
|
26
26
|
version?: string
|
|
27
27
|
required_roles: string[] // Migrated from min_role (string) to array
|
|
28
|
+
route_prefix?: string
|
|
29
|
+
renderer?: 'generic' | 'custom' | 'none'
|
|
28
30
|
routes: string[]
|
|
29
31
|
nav_items: NavItem[]
|
|
30
32
|
features?: string[]
|
|
@@ -32,6 +34,8 @@ export interface AppManifest {
|
|
|
32
34
|
entry_point: string
|
|
33
35
|
is_public?: boolean
|
|
34
36
|
auth_required?: boolean
|
|
37
|
+
preview_domain?: string
|
|
38
|
+
prod_domain?: string
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
export interface NavItem {
|
|
@@ -434,6 +434,8 @@ class _PermissionEngineInternal {
|
|
|
434
434
|
route_prefix: record.route_prefix,
|
|
435
435
|
min_role: record.min_role,
|
|
436
436
|
required_roles: record.required_roles,
|
|
437
|
+
preview_domain: record.preview_domain,
|
|
438
|
+
prod_domain: record.prod_domain,
|
|
437
439
|
is_active: record.is_active,
|
|
438
440
|
is_system: record.is_system,
|
|
439
441
|
renderer: record.renderer,
|
|
@@ -863,6 +865,8 @@ class _PermissionEngineInternal {
|
|
|
863
865
|
route_prefix: record.route_prefix,
|
|
864
866
|
min_role: record.min_role,
|
|
865
867
|
required_roles: record.required_roles,
|
|
868
|
+
preview_domain: record.preview_domain,
|
|
869
|
+
prod_domain: record.prod_domain,
|
|
866
870
|
is_active: record.is_active,
|
|
867
871
|
is_system: record.is_system,
|
|
868
872
|
renderer: record.renderer,
|
|
@@ -60,6 +60,8 @@ CREATE TABLE IF NOT EXISTS public.apps (
|
|
|
60
60
|
account_id uuid, owner_account_id uuid,
|
|
61
61
|
created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now(),
|
|
62
62
|
route_prefix text,
|
|
63
|
+
preview_domain text,
|
|
64
|
+
prod_domain text,
|
|
63
65
|
renderer text NOT NULL DEFAULT 'generic',
|
|
64
66
|
CONSTRAINT chk_apps_renderer CHECK (renderer IN ('generic', 'custom', 'none'))
|
|
65
67
|
);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
-- Migration 021: Add preview/prod domain scoping to apps
|
|
2
|
+
--
|
|
3
|
+
-- Allows each app to declare which hostnames it should be served on
|
|
4
|
+
-- in preview and production environments. The frontend uses VITE_ENVIRONMENT
|
|
5
|
+
-- to pick the correct field and filters the routable app list by the
|
|
6
|
+
-- current hostname.
|
|
7
|
+
--
|
|
8
|
+
-- Apps with null preview_domain and prod_domain remain global (backward compatible).
|
|
9
|
+
|
|
10
|
+
ALTER TABLE public.apps
|
|
11
|
+
ADD COLUMN IF NOT EXISTS preview_domain text,
|
|
12
|
+
ADD COLUMN IF NOT EXISTS prod_domain text;
|
|
13
|
+
|
|
14
|
+
DROP FUNCTION IF EXISTS public.get_account_apps(uuid, boolean, boolean, text[], boolean);
|
|
15
|
+
|
|
16
|
+
CREATE OR REPLACE FUNCTION public.get_account_apps(
|
|
17
|
+
account_id uuid,
|
|
18
|
+
include_system boolean DEFAULT true,
|
|
19
|
+
include_inactive boolean DEFAULT false,
|
|
20
|
+
user_role_slugs text[] DEFAULT NULL,
|
|
21
|
+
is_system_admin boolean DEFAULT false
|
|
22
|
+
)
|
|
23
|
+
RETURNS TABLE(
|
|
24
|
+
id uuid,
|
|
25
|
+
slug text,
|
|
26
|
+
name text,
|
|
27
|
+
description text,
|
|
28
|
+
icon text,
|
|
29
|
+
color text,
|
|
30
|
+
version text,
|
|
31
|
+
app_type text,
|
|
32
|
+
source text,
|
|
33
|
+
owner_account_id uuid,
|
|
34
|
+
is_active boolean,
|
|
35
|
+
is_system boolean,
|
|
36
|
+
min_role text,
|
|
37
|
+
required_roles jsonb,
|
|
38
|
+
preview_domain text,
|
|
39
|
+
prod_domain text,
|
|
40
|
+
config jsonb,
|
|
41
|
+
nav_items jsonb,
|
|
42
|
+
route_prefix text,
|
|
43
|
+
renderer text,
|
|
44
|
+
metadata jsonb,
|
|
45
|
+
integration_deps jsonb,
|
|
46
|
+
created_at timestamptz
|
|
47
|
+
)
|
|
48
|
+
LANGUAGE plpgsql
|
|
49
|
+
SECURITY DEFINER
|
|
50
|
+
AS $$
|
|
51
|
+
BEGIN
|
|
52
|
+
RETURN QUERY
|
|
53
|
+
SELECT
|
|
54
|
+
a.id,
|
|
55
|
+
a.slug,
|
|
56
|
+
a.name,
|
|
57
|
+
a.description,
|
|
58
|
+
a.icon,
|
|
59
|
+
a.color,
|
|
60
|
+
a.version,
|
|
61
|
+
a.app_type,
|
|
62
|
+
a.source,
|
|
63
|
+
a.owner_account_id,
|
|
64
|
+
a.is_active,
|
|
65
|
+
a.is_system,
|
|
66
|
+
a.min_role,
|
|
67
|
+
a.required_roles,
|
|
68
|
+
a.preview_domain,
|
|
69
|
+
a.prod_domain,
|
|
70
|
+
a.config,
|
|
71
|
+
a.nav_items,
|
|
72
|
+
a.route_prefix,
|
|
73
|
+
a.renderer,
|
|
74
|
+
a.metadata,
|
|
75
|
+
a.integration_deps,
|
|
76
|
+
a.created_at
|
|
77
|
+
FROM public.apps a
|
|
78
|
+
WHERE
|
|
79
|
+
(include_inactive OR a.is_active = true)
|
|
80
|
+
AND (
|
|
81
|
+
a.is_system = true
|
|
82
|
+
OR a.owner_account_id = get_account_apps.account_id
|
|
83
|
+
OR a.owner_account_id IS NULL
|
|
84
|
+
)
|
|
85
|
+
AND (
|
|
86
|
+
user_role_slugs IS NULL
|
|
87
|
+
OR is_system_admin
|
|
88
|
+
OR (
|
|
89
|
+
(a.required_roles IS NULL OR a.required_roles = '[]'::jsonb)
|
|
90
|
+
AND a.min_role IS NULL
|
|
91
|
+
)
|
|
92
|
+
OR (
|
|
93
|
+
a.required_roles IS NOT NULL
|
|
94
|
+
AND a.required_roles != '[]'::jsonb
|
|
95
|
+
AND EXISTS (
|
|
96
|
+
SELECT 1
|
|
97
|
+
FROM jsonb_array_elements_text(a.required_roles) AS rr(role_slug)
|
|
98
|
+
WHERE rr.role_slug = ANY(user_role_slugs)
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
OR (
|
|
102
|
+
(a.required_roles IS NULL OR a.required_roles = '[]'::jsonb)
|
|
103
|
+
AND a.min_role IS NOT NULL
|
|
104
|
+
AND a.min_role = ANY(user_role_slugs)
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
ORDER BY
|
|
108
|
+
a.is_system DESC,
|
|
109
|
+
a.app_type,
|
|
110
|
+
a.name;
|
|
111
|
+
END;
|
|
112
|
+
$$;
|
|
@@ -73,7 +73,9 @@ interface AppsRegistryProviderProps {
|
|
|
73
73
|
*
|
|
74
74
|
* Role-based filtering is done server-side by the `get_account_apps` RPC
|
|
75
75
|
* (migration 020). The client trusts the backend response and only filters
|
|
76
|
-
* for routability (has a route_prefix and a renderer)
|
|
76
|
+
* for routability (has a route_prefix and a renderer) and the current
|
|
77
|
+
* environment's domain (preview_domain or prod_domain, selected by
|
|
78
|
+
* `VITE_ENVIRONMENT`).
|
|
77
79
|
*
|
|
78
80
|
* Uses an AbortController to cancel in-flight fetches when dependencies
|
|
79
81
|
* change, preventing interleaved responses from stale requests.
|
|
@@ -140,8 +142,17 @@ export function AppsRegistryProvider({ children }: AppsRegistryProviderProps) {
|
|
|
140
142
|
// yet (effect hasn't fired), treat as loading to prevent 404 flash.
|
|
141
143
|
const effectiveLoading = loading || (!!user && loadedForUserRef.current !== user.id)
|
|
142
144
|
|
|
145
|
+
const environment = (import.meta.env.VITE_ENVIRONMENT as string) || 'prod'
|
|
146
|
+
const currentHost = typeof window !== 'undefined' ? window.location.hostname : ''
|
|
147
|
+
|
|
148
|
+
const appMatchesDomain = (app: AppRecord): boolean => {
|
|
149
|
+
const domain = environment === 'preview' ? app.preview_domain : app.prod_domain
|
|
150
|
+
if (!domain) return true
|
|
151
|
+
return currentHost === domain.split(':')[0]
|
|
152
|
+
}
|
|
153
|
+
|
|
143
154
|
const routableApps = apps.filter(
|
|
144
|
-
app => app.route_prefix != null && app.renderer !== 'none'
|
|
155
|
+
app => app.route_prefix != null && app.renderer !== 'none' && appMatchesDomain(app)
|
|
145
156
|
)
|
|
146
157
|
|
|
147
158
|
const refetch = useCallback(() => {
|
|
@@ -20,6 +20,8 @@ export interface AppRecord {
|
|
|
20
20
|
is_system: boolean
|
|
21
21
|
route_prefix: string | null
|
|
22
22
|
renderer: 'generic' | 'custom' | 'none'
|
|
23
|
+
preview_domain?: string | null
|
|
24
|
+
prod_domain?: string | null
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
interface UseAppsResult {
|
|
@@ -65,10 +67,20 @@ export function useApps(): UseAppsResult {
|
|
|
65
67
|
|
|
66
68
|
const allApps: AppRecord[] = data.data || data || []
|
|
67
69
|
|
|
70
|
+
const environment = (import.meta.env.VITE_ENVIRONMENT as string) || 'prod'
|
|
71
|
+
const currentHost = typeof window !== 'undefined' ? window.location.hostname : ''
|
|
72
|
+
|
|
73
|
+
const appMatchesDomain = (app: AppRecord): boolean => {
|
|
74
|
+
const domain = environment === 'preview' ? app.preview_domain : app.prod_domain
|
|
75
|
+
if (!domain) return true
|
|
76
|
+
return currentHost === domain.split(':')[0]
|
|
77
|
+
}
|
|
78
|
+
|
|
68
79
|
// Filter by user's roles: include app if user has any of the required_roles,
|
|
69
80
|
// or if app has no role restrictions
|
|
70
81
|
const accessible = allApps.filter(app => {
|
|
71
82
|
if (!app.is_active) return false
|
|
83
|
+
if (!appMatchesDomain(app)) return false
|
|
72
84
|
|
|
73
85
|
// Support both new required_roles array and legacy min_role
|
|
74
86
|
const requiredRoles = app.required_roles || (app.min_role ? [app.min_role] : [])
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-framework",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.99",
|
|
4
4
|
"description": "Multi-tenant, modular application platform for modern SaaS systems",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
".framework/migrations/018_apps_required_roles.sql",
|
|
28
28
|
".framework/migrations/019_triggers_unique_constraint.sql",
|
|
29
29
|
".framework/migrations/020_get_account_apps_role_filter.sql",
|
|
30
|
+
".framework/migrations/021_app_domains.sql",
|
|
30
31
|
".framework/public/",
|
|
31
32
|
".framework/scripts/",
|
|
32
33
|
".framework/seeds/",
|