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.
@@ -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.98",
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/",