spine-framework 0.3.92 → 0.3.94

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.
@@ -0,0 +1,13 @@
1
+ -- Migration 019: Add unique constraint on triggers(app_id, name)
2
+ --
3
+ -- The triggers table had no unique constraint, making idempotent upserts
4
+ -- impossible (re-runs would create duplicate rows). This adds a partial unique
5
+ -- index scoped to (app_id, name) so install-app can safely upsert seed triggers
6
+ -- using onConflict: 'app_id,name'.
7
+ --
8
+ -- NULL app_id rows (global triggers) are excluded from the uniqueness check
9
+ -- via the WHERE clause to avoid false conflicts between global triggers.
10
+
11
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_triggers_app_id_name
12
+ ON public.triggers(app_id, name)
13
+ WHERE app_id IS NOT NULL;
@@ -0,0 +1,117 @@
1
+ -- Migration 020: Add role-based filtering to get_account_apps()
2
+ --
3
+ -- Previously the RPC returned all apps for an account and the frontend
4
+ -- filtered by role client-side. This was fragile (race conditions around
5
+ -- stale roles) and leaked app metadata to users who couldn't access them.
6
+ --
7
+ -- New signature adds two optional params:
8
+ -- user_role_slugs text[] — the calling user's role slugs (e.g. ARRAY['support'])
9
+ -- is_system_admin boolean — true bypasses all role checks
10
+ --
11
+ -- Filtering logic:
12
+ -- 1. system_admin always sees everything
13
+ -- 2. Apps with no required_roles AND no min_role are visible to everyone
14
+ -- 3. Apps with required_roles: user must have at least one matching role
15
+ -- 4. Apps with only min_role (legacy): treated as required_roles = ARRAY[min_role]
16
+ --
17
+ -- Existing callers that omit the new params get the old behaviour (no filtering).
18
+
19
+ DROP FUNCTION IF EXISTS public.get_account_apps(uuid, boolean, boolean);
20
+ DROP FUNCTION IF EXISTS public.get_account_apps(uuid, boolean, boolean, text[], boolean);
21
+
22
+ CREATE OR REPLACE FUNCTION public.get_account_apps(
23
+ account_id uuid,
24
+ include_system boolean DEFAULT true,
25
+ include_inactive boolean DEFAULT false,
26
+ user_role_slugs text[] DEFAULT NULL,
27
+ is_system_admin boolean DEFAULT false
28
+ )
29
+ RETURNS TABLE(
30
+ id uuid,
31
+ slug text,
32
+ name text,
33
+ description text,
34
+ icon text,
35
+ color text,
36
+ version text,
37
+ app_type text,
38
+ source text,
39
+ owner_account_id uuid,
40
+ is_active boolean,
41
+ is_system boolean,
42
+ min_role text,
43
+ required_roles jsonb,
44
+ config jsonb,
45
+ nav_items jsonb,
46
+ route_prefix text,
47
+ renderer text,
48
+ metadata jsonb,
49
+ integration_deps jsonb,
50
+ created_at timestamptz
51
+ )
52
+ LANGUAGE plpgsql
53
+ SECURITY DEFINER
54
+ AS $$
55
+ BEGIN
56
+ RETURN QUERY
57
+ SELECT
58
+ a.id,
59
+ a.slug,
60
+ a.name,
61
+ a.description,
62
+ a.icon,
63
+ a.color,
64
+ a.version,
65
+ a.app_type,
66
+ a.source,
67
+ a.owner_account_id,
68
+ a.is_active,
69
+ a.is_system,
70
+ a.min_role,
71
+ a.required_roles,
72
+ a.config,
73
+ a.nav_items,
74
+ a.route_prefix,
75
+ a.renderer,
76
+ a.metadata,
77
+ a.integration_deps,
78
+ a.created_at
79
+ FROM public.apps a
80
+ WHERE
81
+ -- Active filter
82
+ (include_inactive OR a.is_active = true)
83
+ -- Ownership / visibility filter
84
+ AND (
85
+ a.is_system = true
86
+ OR a.owner_account_id = get_account_apps.account_id
87
+ OR a.owner_account_id IS NULL
88
+ )
89
+ -- Role filter (only applied when user_role_slugs is provided)
90
+ AND (
91
+ user_role_slugs IS NULL -- no filter requested (backward compat)
92
+ OR is_system_admin -- system_admin sees everything
93
+ OR ( -- no role requirement on this app
94
+ (a.required_roles IS NULL OR a.required_roles = '[]'::jsonb)
95
+ AND a.min_role IS NULL
96
+ )
97
+ OR ( -- user has a matching required_role
98
+ a.required_roles IS NOT NULL
99
+ AND a.required_roles != '[]'::jsonb
100
+ AND EXISTS (
101
+ SELECT 1
102
+ FROM jsonb_array_elements_text(a.required_roles) AS rr(role_slug)
103
+ WHERE rr.role_slug = ANY(user_role_slugs)
104
+ )
105
+ )
106
+ OR ( -- legacy min_role fallback
107
+ (a.required_roles IS NULL OR a.required_roles = '[]'::jsonb)
108
+ AND a.min_role IS NOT NULL
109
+ AND a.min_role = ANY(user_role_slugs)
110
+ )
111
+ )
112
+ ORDER BY
113
+ a.is_system DESC,
114
+ a.app_type,
115
+ a.name;
116
+ END;
117
+ $$;
@@ -84,11 +84,15 @@ export function AppsRegistryProvider({ children }: AppsRegistryProviderProps) {
84
84
  const [loading, setLoading] = useState(true)
85
85
  const [error, setError] = useState<string | null>(null)
86
86
  const abortRef = useRef<AbortController | null>(null)
87
+ // Track which user the current `apps` + `loading` state belongs to.
88
+ // When user changes, we know to treat state as stale until the new fetch lands.
89
+ const loadedForUserRef = useRef<string | null>(null)
87
90
 
88
91
  const fetchApps = useCallback(async (signal?: AbortSignal) => {
89
92
  if (!user) {
90
93
  setApps([])
91
94
  setLoading(false)
95
+ loadedForUserRef.current = null
92
96
  return
93
97
  }
94
98
 
@@ -108,6 +112,7 @@ export function AppsRegistryProvider({ children }: AppsRegistryProviderProps) {
108
112
  // Backend already filters by role — no client-side role check needed
109
113
  const allApps: AppRecord[] = data.data || data || []
110
114
  setApps(allApps)
115
+ loadedForUserRef.current = user.id
111
116
  } catch (err) {
112
117
  if (err instanceof DOMException && err.name === 'AbortError') return
113
118
  setError(err instanceof Error ? err.message : 'Failed to load apps')
@@ -131,6 +136,10 @@ export function AppsRegistryProvider({ children }: AppsRegistryProviderProps) {
131
136
  return () => controller.abort()
132
137
  }, [user?.id, user?.account_id, rolesKey, fetchApps])
133
138
 
139
+ // Derive effective loading: if user changed but we haven't fetched for them
140
+ // yet (effect hasn't fired), treat as loading to prevent 404 flash.
141
+ const effectiveLoading = loading || (!!user && loadedForUserRef.current !== user.id)
142
+
134
143
  const routableApps = apps.filter(
135
144
  app => app.route_prefix != null && app.renderer !== 'none'
136
145
  )
@@ -143,7 +152,7 @@ export function AppsRegistryProvider({ children }: AppsRegistryProviderProps) {
143
152
  }, [fetchApps])
144
153
 
145
154
  return (
146
- <AppsRegistryContext.Provider value={{ apps, routableApps, loading, error, refetch }}>
155
+ <AppsRegistryContext.Provider value={{ apps, routableApps, loading: effectiveLoading, error, refetch }}>
147
156
  {children}
148
157
  </AppsRegistryContext.Provider>
149
158
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework",
3
- "version": "0.3.92",
3
+ "version": "0.3.94",
4
4
  "description": "Multi-tenant, modular application platform for modern SaaS systems",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,6 +25,8 @@
25
25
  ".framework/migrations/016_invites.sql",
26
26
  ".framework/migrations/017_fix_get_account_apps.sql",
27
27
  ".framework/migrations/018_apps_required_roles.sql",
28
+ ".framework/migrations/019_triggers_unique_constraint.sql",
29
+ ".framework/migrations/020_get_account_apps_role_filter.sql",
28
30
  ".framework/public/",
29
31
  ".framework/scripts/",
30
32
  ".framework/seeds/",