Skip to content

roadmap: high-impact application feature gaps — auth scaffold/generator, authorization policy layer, file attachments (Active Storage), channels docs + WebSocket parity #2962

Closed

Description

@bpamiri

Problem

The wave-2 framework review's feature-set assessment (FRAMEWORK-REVIEW-2026-06-09.md §5.2) identifies four High-impact application-layer parity gaps. Grouped into one roadmap issue to bound issue count; each section is an independent, separately-shippable feature. The framework already ships the lower-level primitives for several of these — the gap is the developer-facing scaffold/abstraction on top.

1. Authentication scaffold/generator (High / Medium effort)

No wheels generate auth producing a User model + secure password + registration/login/reset/verification flows on top of the existing vendor/wheels/auth/ strategies (Authenticator + Session/Token/JWT + JwtService). The primitives exist; the one-command scaffold does not.
Prior art: Rails 8 generate authentication; Laravel Fortify.

2. Authorization / policy layer (High / Medium effort)

No gates/policies/can() abstraction. AuthMiddleware answers "who are you" but not "may you do this". Apps must hand-roll authorization in controller filters.
Prior art: Laravel Gates + Policies; Pundit.

3. File attachments / Active Storage equivalent (High / High effort)

No hasOneAttached/hasManyAttached, no storage-service abstraction (local/S3/etc.), no variants, no signed URLs. Uploads are raw cffile.
Prior art: Rails Active Storage.

4. Channels documentation + WebSocket parity (High docs / Medium WS, Medium-High effort)

Working SSE channels ship with zero user-facing docs (.ai/wheels/channels/channels.md is internal only). SSE is server→client only; there is no client→server WebSocket path.
Prior art: Rails ActionCable; Phoenix Channels; Laravel Reverb.

Impact

These are the most-requested parity gaps versus Rails 8 / Laravel 12 for teams adopting Wheels for new apps. Auth scaffold and authorization in particular are table-stakes for typical CRUD apps; the absence forces every app to hand-roll them. Channels has working code with no docs — the lowest-effort, highest-leverage of the four (docs first, WebSocket later).

Suggested approach

Treat each section as its own design + implementation effort, sequenced by effort/leverage:

  1. Channels docs first (code exists; write user-facing guide pages under web/sites/guides/), then scope client→server WebSocket support.
  2. Authentication generator on top of vendor/wheels/auth/ — wheels generate auth producing model + migrations + controllers + views, calling super.config() (see the CSRF footgun, this campaign).
  3. Authorization policy layer — gates/policies/can(), composing with AuthMiddleware.
  4. File attachments — storage-service abstraction with local + S3 adapters, model macros, signed URLs, variants.

Each warrants its own design discussion; this issue is the roadmap anchor. Split into per-feature issues once each is scheduled.

Acceptance criteria

Source

Internal multi-agent framework review 2026-06-09, wave 2 (issues phase). Feature-set assessment §5.2, High-impact gaps: Authentication scaffold/generator, Authorization/policy layer, File attachments/Active Storage, Channels docs + WebSocket parity.


Tracking checklist (converted 2026-06-12 per the sequencing comment above)

Activity

  1. wheels-bot commented on Jun 10, 2026

    @wheels-bot
    Contributor

    Wheels Bot — Triage

    Classified as framework-design (API/feature design question).
    Cross-framework research will follow shortly — Rails, Laravel, Django,
    and other modern frameworks will be consulted before any path forward
    is proposed.

    <!-- wheels-bot:triage:2962 -->
    <!-- wheels-bot:triage-class:framework-design -->

  2. wheels-bot commented on Jun 10, 2026

    @wheels-bot
    Contributor

    Wheels Bot — Cross-Framework Research (1/2)

    Question. Issue 2962 is a roadmap bundling four independent High-impact application-layer parity gaps: (1) wheels generate auth scaffold on vendor/wheels/auth/; (2) authorization / policy layer; (3) file attachments + storage-service abstraction; (4) Channels docs + WebSocket parity. The issue itself says "Split into per-feature issues once each is scheduled." Six parallel framework agents covered Rails 8, Laravel 12, Django 5.x, Phoenix 1.7, Spring Boot 3.x, and AdonisJS 6 as the +1. AdonisJS is the most relevant peer — it is the only other batteries-included MVC framework that ships first-party packages for all four features simultaneously, and it independently chose SSE-only (Transmit) over WebSocket, mirroring Wheels current design.

    Topic 1 — Authentication Generator

    Framework Surface
    Rails 8 bin/rails generate authentication — User + Session models, SessionsController, PasswordsController, Authentication concern, Current.user. Zero gem deps, DB-revocable sessions; no registration/2FA/OAuth.
    Laravel 12 Starter Kits + Fortify (php artisan fortify:install). Headless routes; closures-as-views; 2FA/email-verify/password-confirm. Own everything generated; no upgrade path.
    Django django.contrib.auth built-in app + createsuperuser. Library, not generator; LoginView/PasswordResetView; templates are stubs.
    Phoenix mix phx.gen.auth Accounts User users — context + User + UserToken + LiveViews + Notifier + UserAuth plug. Widely cited gold-standard.
    Spring Boot None. SecurityFilterChain + UserDetailsService + PasswordEncoder beans. Auto-config, no scaffold tradition.
    AdonisJS 6 node ace add @adonisjs/auth --guard=session — configure-as-scaffold. withAuthFinder mixin; auth.use("web").login(user).

    Dominant pattern. Modern frameworks ship a generator that emits real app code (Rails 8, Phoenix, Laravel starter kits, Adonis add). Django and Spring Boot are library-only. The shape is universal: model + sessions table + sessions controller + password-reset controller + plug/concern/middleware exposing current_user. Wheels already has the primitives (Authenticator, SessionStrategy, JwtService, AuthMiddleware) — the gap is the generator on top.

    Topic 2 — Authorization / Policy Layer

    Framework Surface
    Rails (Pundit) Community standard. authorize @post, policy_scope(Post). Plain Ruby; per-resource policy classes.
    Laravel 12 Built-in Gates + Policies. Gate::define, $user->can(...), @can, auto-discovery, [UsePolicy] attribute. before() returning true is allow-all gotcha.
    Django Built-in user.has_perm("app.codename") — table-level only. guardian for object-level; rules for predicates.
    Phoenix Bodyguard (community) or context-layer scoping. phx.gen.auth Scope (1.8) formalizes data-boundary.
    Spring Boot @PreAuthorize + SpEL. ACL module for object-level. SpEL is stringly-typed.
    AdonisJS 6 @adonisjs/bouncer first-party. Abilities (Gate-style fns) + Policy classes; ctx.bouncer.with(PostPolicy).authorize("edit", post).

    Dominant pattern. Per-resource policy classes with method-per-action (update/delete/create) plus a thin gate primitive for non-model checks. Laravel and Adonis ship first-party; Rails uses Pundit by convention; Phoenix uses Bodyguard or data-boundary. Django table-level permissions are weaker than the rest. The can() + authorize() pair is universal. Wheels AuthMiddleware answers "who you are"; this layer answers "may you do this."

    Topic 3 — File Attachments / Storage

    Framework Surface
    Rails Active Storage — has_one_attached :avatar. Polymorphic blob/attachment split; variants; signed URLs (5min TTL); direct uploads. Only framework with first-party model macro.
    Laravel 12 Storage facade (Flysystem). Disks (local/public/s3/sftp); Storage::disk("s3")->temporaryUrl(). Model attachment via community spatie/medialibrary.
    Django 5.x FileField/ImageField + STORAGES setting (4.2+). upload_to; per-field storage=callable. django-storages for S3.
    Phoenix None first-party; allow_upload/3 LiveView for transport only. Storage gap filled by Waffle.
    Spring Boot MultipartFile + @RequestPart. Cloud via Spring Cloud AWS S3Template. No model abstraction.
    AdonisJS 6 @adonisjs/drive storage. Disks (fs/s3/gcs/r2/spaces/supabase); file.moveToDisk(key, "s3"); signed URLs. Community adonisjs-attachment for model-bound.

    Dominant pattern. Splits into two layers: (a) storage-service / disk abstraction (local/s3/gcs/azure) — every framework ships this; (b) model-attachment macro (has_one_attached) — only Rails ships first-party. Storage abstraction is lower-effort, higher-leverage — ship that first, evaluate the macro later.

    Topic 4 — Channels / WebSocket

    Framework Surface
    Rails Action Cable (WebSocket). stream_for @post; Channel.broadcast_to(...); @rails/actioncable client. Postgres adapter caps at ~8KB; Redis needed.
    Laravel 12 Reverb (first-party WS server). install:broadcasting; Broadcast::channel(...); Echo client. Pusher wire-compatible.
    Django Channels (community, ASGI). WebsocketConsumer; channel_layers Redis; group_send. Sync ORM needs database_sync_to_async().
    Phoenix Channels (flagship). Per-channel Erlang process; join/3 auth; Phoenix.Presence CRDT. Gold-standard; PG2 clusters with zero infra.
    Spring Boot STOMP over WebSocket. @MessageMapping; SimpMessagingTemplate; simple broker or RabbitMQ relay.
    AdonisJS 6 Transmit — SSE-only by deliberate choice. transmit.broadcast(channel, payload); transmit.authorize<{id}>(...). Proxy-friendly, auto-reconnect, reuses HTTP auth; no client→server, no binary.

    Dominant pattern. Five of six frameworks ship WebSocket-based bidirectional channels with per-channel auth callbacks, broadcast helpers, and a JS client with auto-reconnect. Adonis is the outlier — and it is the most directly relevant prior art for Wheels, which already chose SSE in vendor/wheels/channels/. The Adonis team documented rationale (firewall/proxy traversal, free reconnect, reuse of HTTP auth) maps 1:1 onto why SSE is a defensible Wheels choice. The +1-framework divergence is non-trivial: WebSocket parity is real demand but ships a second protocol stack. Documenting the SSE choice + scoping a complementary WebSocket may be more honest than positioning WebSocket as a gap.

    (continued in part 2 — recommended path, CFML sketches, cross-engine notes, confidence, sources, marker)

  3. wheels-bot commented on Jun 10, 2026

    @wheels-bot
    Contributor

    Wheels Bot — Cross-Framework Research (2/2)

    Recommended path for Wheels

    Treat this issue as a roadmap and split it. The sequencing in the issue body is sound; here are the per-feature shapes for the resulting child issues.

    1. Channels docs — lowest effort, highest leverage; code already ships. Promote .ai/wheels/channels/channels.md into web/sites/guides/src/content/docs/v4-0-0/ with user-facing examples, deployment notes (memory vs database adapter, proxy-must-disable-compression-for-text/event-stream), and an explicit position on SSE vs WebSocket citing Adonis Transmit as prior art. Then the WebSocket question becomes "do we need bidirectional binary? if yes, scope a wheels.channels.WebSocketAdapter that composes with the existing channelAdapter setting."

    2. wheels generate auth on existing primitives. Phoenix-style scaffold layered on vendor/wheels/auth/:

    wheels generate auth                  // User model + migrations + controllers + views + routes
    wheels generate auth --model=Account  // override model name
    wheels generate auth --strategy=jwt   // pick strategy from vendor/wheels/auth/
    
    // Generated:
    //   app/models/User.cfc              extends "Model" — hasSecurePassword(), validations
    //   db/migrate/*_create_users.cfc    users + sessions tables
    //   app/controllers/Sessions.cfc     login/logout
    //   app/controllers/Passwords.cfc    reset request + edit
    //   app/controllers/Registrations.cfc (--no-registration to skip)
    //   app/views/sessions/{new,create}.cfm
    //   config/routes.cfm                resource("session"), resources("passwords")
    //   config/settings.cfm              set(authStrategy="session")

    Lives in: cli/lucli/services/Scaffold.cfc + cli/lucli/templates/auth/. Composes with existing vendor/wheels/auth/Authenticator.cfc strategies, AuthMiddleware, and DI container (service("authenticator")). All-named args; no mixin gotchas because generated files are user-owned.

    3. Authorization / policy layer — Laravel + Pundit hybrid. First-party policy objects + a gate primitive:

    // app/policies/PostPolicy.cfc
    component extends="wheels.Policy" {
        public boolean function update(required user, required post) {
            return arguments.user.isAdmin() || arguments.post.userId == arguments.user.id;
        }
        public boolean function destroy(required user, required post) {
            return arguments.user.isAdmin();
        }
    }
    
    // Controller
    authorize(action="update", record=post);          // throws Wheels.NotAuthorized -> 403
    if (can(action="update", record=post)) { ... }    // boolean predicate
    var visible = policyScope(model("Post"));         // chainable query builder filter
    
    // View
    <cfif can(action="update", record=post)>
        [linkTo to editPost, key=post.id]
    </cfif>
    
    // CLI generator
    wheels generate policy Post   // app/policies/PostPolicy.cfc with stubbed actions

    Lives in: vendor/wheels/auth/Policy.cfc (base) + controller/view mixins under vendor/wheels/controller/authorization.cfc. Composes with: AuthMiddleware (sets request.currentUser); policyScope returns a chainable query builder. All helpers MUST be public with $ prefix internally (CLAUDE.md anti-pattern 7 — private mixins are not integrated). Auto-discovery: model("Post") resolves to app/policies/PostPolicy.cfc. Default-deny when no policy file exists is safer than default-allow.

    4. File attachments — storage adapters first, model macro second. Ship the disk abstraction now, defer the hasOneAttached macro:

    // config/settings.cfm
    set(storageAdapter="local");
    set(storageDisks={
        local:  {root: "/storage/uploads", urlPrefix: "/uploads"},
        s3:     {bucket: "myapp-prod", region: "us-east-1"},
        public: {root: "/public/uploads", urlPrefix: "/uploads"}
    });
    
    // Service surface
    var disk = service("storage").use("s3");
    disk.put(key="avatars/42.jpg", contents=fileContent);
    disk.url(key="avatars/42.jpg");
    disk.signedUrl(key="avatars/42.jpg", expiresIn=300);
    disk.delete(key="avatars/42.jpg");
    
    // Phase-2 (separate issue): model macro
    component extends="Model" {
        function config() {
            hasOneAttached(name="avatar", disk="s3");
            hasManyAttached(name="documents", disk="s3");
        }
    }

    Lives in: vendor/wheels/storage/Disk.cfc, LocalAdapter.cfc, S3Adapter.cfc. Composes with DI container (service("storage")), cffile for local writes. S3 adapter via plain cfhttp signed requests — not JAR.

    Cross-engine notes

    • Auth generator: emits user-owned app code, so no mixin pitfalls. Use named args only (CLAUDE.md anti-pattern 1).
    • Policy layer: authorize()/can()/policyScope() mixins MUST be public with $ prefix (CLAUDE.md anti-pattern 7). Avoid obj.map() on lookup tables (cross-engine 1) — use mapInstance().
    • Storage: Left() len-guard idiom (cross-engine 8) for path-prefix trimming; avoid cfcache wrappers since attributeCollection = arguments fails on Adobe 2023/2025 (cross-engine 10). Plain cfhttp for S3 signing — no JVM-version-sensitive JARs.
    • Channels WebSocket (if pursued): WS support varies wildly — Lucee has cfwebsocket, Adobe deprecated it, BoxLang has no equivalent. A first-party WS layer would need an HTTP-upgrade-handler middleware path that is engine-portable, which is non-trivial. SSE has none of these problems — favor doubling down.

    Open questions

    • Generator template ownership. Phoenix-style "generated code you own" means Wheels cannot fix security bugs in deployed apps without a manual upgrade. Rails 8 picked this knowingly. Human decision.
    • Policy default-deny vs default-allow. Pundit defaults to false; Laravel before() returning true is allow-all. CLAUDE.md does not prescribe — needs a call.
    • Model attachment macro vs storage-only. Active Storage polymorphic blob/attachment split is non-trivial (two tables + variant cache + signed URL TTL). Storage adapters alone may cover 80% at 20% effort.
    • WebSocket parity. Is "no client to server" actually a real gap, or are users misreading SSE-only as a deficiency? User-research interviews would clarify before committing engineering time.
    • Per-feature issue split. The issue itself proposes splitting; each section above is the seed for a child issue.

    Confidence: low

    Auto-downgrade rule fired on two grounds: (a) the issue is broad enough that "what should the API be" for each of the four features is itself a multi-week design conversation; (b) all four proposals require new infrastructure (a generator subsystem, a wheels.Policy base + mixins + lookup convention, a wheels.storage adapter layer, channel-layer protocol decisions) — each warranting human discussion before code is written. The Topic 4 WS-vs-SSE split between Adonis and the other five frameworks is a non-trivial trade-off where the +1 picks the opposite side.

    Sources

    <!-- wheels-bot:research:2962 -->

  4. bpamiri commented on Jun 11, 2026

    @bpamiri
    CollaboratorAuthor

    Channels docs slice shipped — proposed sequencing for the remaining roadmap features

    The first acceptance criterion is now partially delivered by PR #3004: a user-facing Channels guide under digging-deeper/ in the v4.0 guides, documenting publish() / subscribeToChannel() / channelSSETag(), the memory and database adapters (post-#2940 retention/cleanup behavior), and the WheelsSSE JS client — including an honest statement of the WebSocket story (see below). This comment proposes per-feature sequencing for the rest of the roadmap, per the issue's own "split into per-feature issues once each is scheduled" guidance.

    What verification of the current code surfaced

    • All channels runtime code already ships and is spec-covered (channelSpec 25, sseSpec 22, DatabaseAdapterSpec 10, hardened in fix(sse): blank-line framing, DB cursor advance, channel pruning, bounded retention cleanup #2940). The gap really was docs-only.
    • One internal-doc inaccuracy fixed along the way: .ai/wheels/channels/channels.md claims the JS client is served at /wheels/assets/js/wheels-sse.js. It is not — no route in vendor/wheels/public/routes.cfm serves that path and the webroot is public/. The new guide instructs users to copy vendor/wheels/public/assets/js/wheels-sse.js into their app. A tiny follow-up worth scoping: ship the client at a real URL or via a generator snippet.

    Proposed sequencing (one child issue each)

    1. WebSocket parity — document-first, defer implementation pending demand evidence.
    The guide now states plainly: Wheels has no client→server WebSocket path; channels are SSE-only by design (proxy-friendly, free auto-reconnect, reuses HTTP auth — the same call AdonisJS made with Transmit). Client→server is a normal HTTP POST that calls publish(). Before committing to a WS stack, note the engine-portability wall: Lucee has cfwebsocket, Adobe deprecated theirs, BoxLang has none — a first-party WS layer needs an engine-portable HTTP-upgrade path, which is non-trivial. Recommend: open a child issue that gathers concrete user demand ("do you need bidirectional/binary, or did SSE-only read as a deficiency?") before any design work.

    2. wheels generate auth — next up; highest leverage per effort.
    CLI-only surface (cli/lucli/ templates + scaffolding) on top of the existing vendor/wheels/auth/ primitives (Authenticator, Session/Token/JWT strategies, AuthMiddleware). Phoenix/Rails-8 shape: User model + sessions/passwords controllers + views + routes + migrations. Hard requirements from this campaign's findings: generated controllers must call super.config() (the CSRF footgun), all-named args. Design calls needing a human: registration on/off flag, --strategy= selection, generated-code-you-own upgrade policy.

    3. Authorization / policy layer — after the auth generator (it gives policies a currentUser to authorize).
    Framework-core change: wheels.Policy base + authorize() / can() / policyScope() controller+view mixins. Cross-engine constraint: mixin helpers must be public with $-prefixed internals (private mixins are not integrated). Key design call: default-deny vs default-allow when no policy exists — Pundit denies, Laravel's before() foot-gun allows; recommend default-deny.

    4. File attachments — last; split into two phases.
    Phase 1: storage-disk abstraction (vendor/wheels/storage/ — local + S3 adapters, signed URLs; S3 via plain cfhttp request signing, no JARs). Phase 2 (separate issue, only if demand): Rails-style hasOneAttached model macro with the polymorphic blob/attachment tables. Storage adapters alone cover most of the demand at a fraction of the effort.

    If this sequencing looks right, I'll open the four child issues and convert this roadmap issue into a tracking checklist.

  5. added
    roadmapRoadmap / tracking issue grouping scheduled work
    on Jun 13, 2026
  6. bpamiri commented on Jul 8, 2026

    @bpamiri
    CollaboratorAuthor

    Status roll-up (2026-07-08) — all four deliverables shipped

    For the 4.0.6 cut, here's where this roadmap epic stands. Every tracked slice has landed:

    Deliverable Status Shipped in
    Channels docs ✅ #3004
    Auth scaffold/generator ✅ #3288 (PasswordHasher) + #3291 (wheels generate auth)
    Authorization/policy layer ✅ #3289
    File attachments — disk abstraction (phase 1) ✅ #3248 / #3157
    WebSocket parity ✅ decided + shipped as a package #3154 (GO as opt-in package) → wheels-dev/wheels-websockets v0.2.0 published; tracked in #3292

    wheels-websockets 0.2.0 (published to the registry — wheels packages add wheels-websockets): RustCFML native backend + Lucee backend over the official websocket extension. Verified live end-to-end on Lucee 6.2 and on Lucee 7.0.4.34 (with an extension master build); the only outstanding upstream item is a jakarta-compatible extension release (details in #3292). Ongoing package phases (P4 Adobe/BoxLang, demand-gated) stay tracked in #3292, not here.

    Remaining, deliberately scoped out of this epic and NOT 4.0.6-blocking:

    Recommend closing this epic as complete and tracking the two follow-ups above as their own issues when scheduled. Leaving it open for a maintainer close so the phase-2 decision is explicit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    roadmapRoadmap / tracking issue grouping scheduled work

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions