Feat v0.9.1 server side subpath routing (#74)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m55s
CI/CD / test-sqlite (push) Successful in 2m58s
CI/CD / build-and-deploy (push) Successful in 1m12s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #74.
This commit is contained in:
2026-04-03 13:42:57 +00:00
committed by xcaliber
parent 5ad6d77c56
commit d03dfe502f
7 changed files with 461 additions and 12 deletions

View File

@@ -470,6 +470,16 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
return
}
// ── Early auth short-circuit ─────────────────────────────
// For all-authenticated packages, redirect before surface matching.
if rawSurfaces, ok := sr.Manifest["surfaces"].([]any); ok && len(rawSurfaces) > 0 {
profile := aggregateAccess(rawSurfaces)
if profile == "authenticated" && c.GetString("user_id") == "" {
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/login")
return
}
}
// ── Multi-surface resolution ─────────────────────────────
var surfacePath string
var surfaceParams map[string]string
@@ -611,6 +621,35 @@ func splitPath(p string) []string {
return parts
}
// aggregateAccess scans surfaces to determine the package's access profile.
// Returns "public" (all public), "authenticated" (all require auth), or "mixed".
func aggregateAccess(surfaces []any) string {
hasPublic := false
hasAuth := false
for _, raw := range surfaces {
s, ok := raw.(map[string]any)
if !ok {
continue
}
access, _ := s["access"].(string)
if access == "" {
access = "authenticated" // default
}
if access == "public" {
hasPublic = true
} else {
hasAuth = true
}
if hasPublic && hasAuth {
return "mixed"
}
}
if hasPublic {
return "public"
}
return "authenticated"
}
// evaluateAccess checks whether the current request meets an access requirement.
func evaluateAccess(c *gin.Context, access string) bool {
switch {
@@ -747,12 +786,14 @@ func (e *Engine) RegisterExtensionRoutes(
sGroup := base.Group("/s/:slug")
sGroup.Use(optAuth) // populate user context if token present; don't require it
// Root path: /s/:slug (no sub-path) — surface only
sGroup.GET("", surfaceHandler)
// Sub-paths: /s/:slug/* — dispatch between API and surface
sGroup.Any("/*path", func(c *gin.Context) {
// Dispatcher handles both root and sub-path requests.
// API requests (/s/:slug/api/*) go to extAPIHandler with JWT auth.
// Everything else goes to the surface handler with per-surface access checks.
dispatch := func(c *gin.Context) {
subPath := c.Param("path")
if subPath == "" {
subPath = "/"
}
// API requests: /s/:slug/api/*
if strings.HasPrefix(subPath, "/api/") || subPath == "/api" {
@@ -783,7 +824,17 @@ func (e *Engine) RegisterExtensionRoutes(
}
surfaceHandler(c)
}
// Root path: /s/:slug (Gin requires a separate registration since
// /*path doesn't match the bare group path)
sGroup.GET("", func(c *gin.Context) {
c.Params = append(c.Params, gin.Param{Key: "path", Value: "/"})
dispatch(c)
})
// Sub-paths: /s/:slug/*path — API and surface dispatch
sGroup.Any("/*path", dispatch)
}
// surfaceHandler returns the appropriate Gin handler for a surface.