plutonium 0.62.2 → 0.63.0
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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +44 -0
- data/.claude/skills/plutonium-app/SKILL.md +3 -3
- data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
- data/.claude/skills/plutonium-auth/SKILL.md +36 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
- data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
- data/.claude/skills/plutonium-resource/SKILL.md +259 -12
- data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
- data/.claude/skills/plutonium-ui/SKILL.md +115 -14
- data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
- data/CHANGELOG.md +67 -0
- data/CLAUDE.md +87 -0
- data/Rakefile +34 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +685 -102
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/rodauth/_login_form.html.erb +13 -0
- data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
- data/docs/.vitepress/config.ts +81 -3
- data/docs/.vitepress/theme/blog.data.ts +44 -0
- data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
- data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
- data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
- data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
- data/docs/.vitepress/theme/index.ts +8 -4
- data/docs/blog/association-inputs-post-signed-ids.md +70 -0
- data/docs/blog/fix-the-model-not-the-policy.md +122 -0
- data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
- data/docs/blog/half-finished-forms-are-pii.md +76 -0
- data/docs/blog/index.md +13 -0
- data/docs/blog/interactions-are-presentation-objects.md +152 -0
- data/docs/blog/introducing-plutonium.md +253 -0
- data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
- data/docs/blog/plutonium-and-ai-agents.md +48 -0
- data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
- data/docs/blog/two-forms-one-dom-id.md +69 -0
- data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
- data/docs/getting-started/tutorial/04-authorization.md +12 -3
- data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/guides/authentication.md +73 -0
- data/docs/guides/authorization.md +2 -0
- data/docs/guides/creating-packages.md +5 -3
- data/docs/guides/custom-actions.md +74 -12
- data/docs/guides/customizing-ui.md +9 -2
- data/docs/guides/index.md +1 -0
- data/docs/guides/kanban.md +7 -5
- data/docs/guides/nested-resources.md +11 -1
- data/docs/guides/performance.md +104 -0
- data/docs/guides/user-invites.md +1 -1
- data/docs/guides/wizards.md +9 -1
- data/docs/index.md +3 -3
- data/docs/public/images/home/tour-actions.png +0 -0
- data/docs/public/images/home/tour-async.png +0 -0
- data/docs/public/images/home/tour-kanban.png +0 -0
- data/docs/public/images/home/tour-tenancy.png +0 -0
- data/docs/public/images/home/tour-wizard.png +0 -0
- data/docs/public/images/reference/async-progress-page.png +0 -0
- data/docs/public/images/reference/async-running-banner.png +0 -0
- data/docs/public/templates/experimental.rb +34 -0
- data/docs/public/templates/pluton8.rb +14 -0
- data/docs/reference/app/portals.md +15 -3
- data/docs/reference/auth/accounts.md +19 -0
- data/docs/reference/behavior/async-interactions.md +295 -0
- data/docs/reference/behavior/controllers.md +17 -4
- data/docs/reference/behavior/index.md +7 -1
- data/docs/reference/behavior/interactions.md +152 -22
- data/docs/reference/configuration.md +5 -0
- data/docs/reference/index.md +1 -0
- data/docs/reference/kanban/dsl.md +7 -4
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +26 -4
- data/docs/reference/positioning.md +568 -0
- data/docs/reference/resource/actions.md +97 -4
- data/docs/reference/resource/definition.md +181 -9
- data/docs/reference/tenancy/invites.md +1 -1
- data/docs/reference/tenancy/nested-resources.md +60 -2
- data/docs/reference/ui/assets.md +4 -0
- data/docs/reference/ui/components.md +57 -4
- data/docs/reference/ui/displays.md +20 -10
- data/docs/reference/ui/index.md +1 -1
- data/docs/reference/wizard/dsl.md +33 -0
- data/docs/reference/wizard/storage-config.md +1 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
- data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
- data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
- data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
- data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
- data/gemfiles/postgres.gemfile.lock +85 -85
- data/gemfiles/rails_7.gemfile.lock +322 -140
- data/gemfiles/rails_8.0.gemfile.lock +125 -115
- data/gemfiles/rails_8.1.gemfile.lock +126 -116
- data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
- data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
- data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
- data/lib/generators/pu/invites/install_generator.rb +3 -3
- data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
- data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
- data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
- data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
- data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
- data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
- data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
- data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
- data/lib/generators/pu/wizards/install_generator.rb +78 -0
- data/lib/plutonium/action/base.rb +71 -9
- data/lib/plutonium/action/interactive.rb +9 -0
- data/lib/plutonium/attachments.rb +254 -0
- data/lib/plutonium/configuration.rb +82 -1
- data/lib/plutonium/core/controller.rb +50 -7
- data/lib/plutonium/core/controllers/authorizable.rb +16 -0
- data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
- data/lib/plutonium/definition/base.rb +51 -0
- data/lib/plutonium/definition/display_layout.rb +112 -0
- data/lib/plutonium/definition/index_views.rb +8 -7
- data/lib/plutonium/definition/input_aliases.rb +38 -0
- data/lib/plutonium/definition/page_widths.rb +65 -0
- data/lib/plutonium/definition/positioning.rb +126 -0
- data/lib/plutonium/definition/sorting.rb +17 -2
- data/lib/plutonium/helpers/turbo_helper.rb +7 -0
- data/lib/plutonium/interaction/README.md +61 -24
- data/lib/plutonium/interaction/async/configuration.rb +38 -0
- data/lib/plutonium/interaction/async/context.rb +419 -0
- data/lib/plutonium/interaction/async/executor.rb +422 -0
- data/lib/plutonium/interaction/async/job.rb +80 -0
- data/lib/plutonium/interaction/async/reap_job.rb +81 -0
- data/lib/plutonium/interaction/async/run.rb +394 -0
- data/lib/plutonium/interaction/async/run_definition.rb +155 -0
- data/lib/plutonium/interaction/async/run_policy.rb +86 -0
- data/lib/plutonium/interaction/base.rb +34 -7
- data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
- data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
- data/lib/plutonium/interaction/response/redirect.rb +11 -3
- data/lib/plutonium/kanban/board.rb +14 -0
- data/lib/plutonium/kanban/column.rb +4 -2
- data/lib/plutonium/kanban/dsl.rb +4 -1
- data/lib/plutonium/kanban/grouping.rb +9 -22
- data/lib/plutonium/kanban/positioning.rb +5 -65
- data/lib/plutonium/positioning/config.rb +94 -0
- data/lib/plutonium/positioning/model.rb +128 -0
- data/lib/plutonium/positioning.rb +25 -86
- data/lib/plutonium/railtie.rb +1 -0
- data/lib/plutonium/resource/controller.rb +118 -38
- data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
- data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
- data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
- data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
- data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
- data/lib/plutonium/resource/controllers/presentable.rb +19 -13
- data/lib/plutonium/resource/controllers/queryable.rb +5 -1
- data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
- data/lib/plutonium/resource/policy.rb +33 -0
- data/lib/plutonium/resource/query_object.rb +36 -0
- data/lib/plutonium/routing/mapper_extensions.rb +100 -8
- data/lib/plutonium/routing/route_set_extensions.rb +15 -1
- data/lib/plutonium/routing/wizard_registration.rb +4 -0
- data/lib/plutonium/testing/resource_policy.rb +6 -2
- data/lib/plutonium/ui/action_button.rb +12 -7
- data/lib/plutonium/ui/actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/block.rb +21 -1
- data/lib/plutonium/ui/breadcrumbs.rb +187 -55
- data/lib/plutonium/ui/component/methods.rb +5 -0
- data/lib/plutonium/ui/component/positionable.rb +112 -0
- data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
- data/lib/plutonium/ui/component/section.rb +185 -0
- data/lib/plutonium/ui/display/base.rb +13 -1
- data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
- data/lib/plutonium/ui/display/components/section.rb +18 -0
- data/lib/plutonium/ui/display/resource.rb +141 -22
- data/lib/plutonium/ui/display/theme.rb +20 -1
- data/lib/plutonium/ui/export_button.rb +1 -1
- data/lib/plutonium/ui/form/base.rb +8 -7
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
- data/lib/plutonium/ui/form/components/section.rb +7 -62
- data/lib/plutonium/ui/form/components/uppy.rb +12 -1
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
- data/lib/plutonium/ui/form/query.rb +2 -4
- data/lib/plutonium/ui/form/resource.rb +92 -15
- data/lib/plutonium/ui/form/theme.rb +17 -0
- data/lib/plutonium/ui/form/wizard.rb +25 -1
- data/lib/plutonium/ui/grid/card.rb +79 -11
- data/lib/plutonium/ui/grid/resource.rb +47 -5
- data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
- data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
- data/lib/plutonium/ui/kanban/card.rb +2 -1
- data/lib/plutonium/ui/kanban/column.rb +12 -6
- data/lib/plutonium/ui/kanban/resource.rb +6 -7
- data/lib/plutonium/ui/layout/base.rb +10 -3
- data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +19 -0
- data/lib/plutonium/ui/page/edit.rb +4 -1
- data/lib/plutonium/ui/page/index.rb +69 -18
- data/lib/plutonium/ui/page/interactive_action.rb +5 -1
- data/lib/plutonium/ui/page/new.rb +4 -1
- data/lib/plutonium/ui/page/show.rb +27 -10
- data/lib/plutonium/ui/page/wizard.rb +10 -1
- data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
- data/lib/plutonium/ui/page_width.rb +58 -0
- data/lib/plutonium/ui/table/base.rb +34 -1
- data/lib/plutonium/ui/table/components/attachment.rb +1 -1
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
- data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
- data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
- data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/table/resource.rb +50 -5
- data/lib/plutonium/ui/table/theme.rb +59 -2
- data/lib/plutonium/ui/wizard/review.rb +4 -2
- data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachments.rb +32 -197
- data/lib/plutonium/wizard/base.rb +6 -1
- data/lib/plutonium/wizard/configuration.rb +12 -0
- data/lib/plutonium/wizard/controller.rb +14 -0
- data/lib/plutonium/wizard/driving.rb +99 -15
- data/lib/plutonium/wizard/dsl.rb +23 -0
- data/lib/plutonium/wizard/resume.rb +127 -49
- data/lib/plutonium/wizard/runner.rb +46 -2
- data/lib/plutonium/wizard/step_adapter.rb +1 -1
- data/lib/plutonium/wizard/sweep_job.rb +16 -0
- data/lib/plutonium.rb +21 -0
- data/lib/rodauth/features/session_isolation.rb +92 -0
- data/lib/rodauth/plugins.rb +1 -0
- data/package.json +2 -1
- data/plutonium.gemspec +29 -11
- data/src/css/components.css +89 -1
- data/src/css/slim_select.css +20 -0
- data/src/js/controllers/breadcrumbs_controller.js +112 -0
- data/src/js/controllers/bulk_actions_controller.js +10 -2
- data/src/js/controllers/kanban_controller.js +30 -21
- data/src/js/controllers/positioned_controller.js +452 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/resource_drop_down_controller.js +5 -0
- data/src/js/controllers/run_progress_controller.js +73 -0
- data/src/js/drag/sortable.js +186 -0
- data/yarn.lock +108 -63
- metadata +103 -16
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
- data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="pu-section">
|
|
3
|
+
<div class="pu-section-inner">
|
|
4
|
+
<h2 class="pu-section-title">More than CRUD.</h2>
|
|
5
|
+
<p class="ft-sub">
|
|
6
|
+
Five features other frameworks make you build yourself.
|
|
7
|
+
All declarative. All policy-aware.
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<div class="ft-grid" :style="gridStyle">
|
|
11
|
+
<template v-for="f in features" :key="f.id">
|
|
12
|
+
<button
|
|
13
|
+
class="ft-head"
|
|
14
|
+
:class="{ 'ft-head--on': selected === f.id }"
|
|
15
|
+
:aria-expanded="selected === f.id"
|
|
16
|
+
:aria-controls="selected === f.id ? `ft-panel-${f.id}` : undefined"
|
|
17
|
+
@click="selected = f.id"
|
|
18
|
+
>
|
|
19
|
+
<span class="ft-head-text">
|
|
20
|
+
<b>{{ f.name }}</b>
|
|
21
|
+
<small>{{ f.hook }}</small>
|
|
22
|
+
</span>
|
|
23
|
+
<IconChevronDown class="ft-chev" :size="16" :stroke-width="2" aria-hidden="true" />
|
|
24
|
+
</button>
|
|
25
|
+
|
|
26
|
+
<div v-if="selected === f.id" :id="`ft-panel-${f.id}`" class="ft-body">
|
|
27
|
+
<div class="ft-caption">{{ f.file }}</div>
|
|
28
|
+
<pre class="ft-code" v-html="f.code"></pre>
|
|
29
|
+
<img v-if="f.shot" :src="withBase(f.shot)" :alt="f.alt" class="ft-shot pu-zoomable" />
|
|
30
|
+
<div class="ft-foot">
|
|
31
|
+
<span class="ft-policy">
|
|
32
|
+
<IconShieldCheck :size="14" :stroke-width="2" aria-hidden="true" /> {{ f.policy }}
|
|
33
|
+
</span>
|
|
34
|
+
<a class="ft-link" :href="f.link">Read the guide →</a>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</section>
|
|
41
|
+
</template>
|
|
42
|
+
|
|
43
|
+
<script setup>
|
|
44
|
+
import { ref } from "vue"
|
|
45
|
+
import { withBase } from "vitepress"
|
|
46
|
+
import { IconChevronDown, IconShieldCheck } from "@tabler/icons-vue"
|
|
47
|
+
|
|
48
|
+
const features = [
|
|
49
|
+
{
|
|
50
|
+
id: "actions",
|
|
51
|
+
name: "Actions & interactions",
|
|
52
|
+
hook: "Business logic with auto-generated UI",
|
|
53
|
+
file: "packages/blogging/app/definitions/blogging/post_definition.rb",
|
|
54
|
+
code: `<span class="k">class</span> Blogging::PostDefinition <span class="k"><</span> Blogging::ResourceDefinition
|
|
55
|
+
<span class="m">scope</span> <span class="s">:published</span>
|
|
56
|
+
<span class="m">scope</span> <span class="s">:drafts</span>
|
|
57
|
+
<span class="m">scope</span> <span class="s">:archived</span>
|
|
58
|
+
|
|
59
|
+
<span class="m">action</span> <span class="s">:publish</span>, interaction: Blogging::PublishPost
|
|
60
|
+
<span class="m">action</span> <span class="s">:archive</span>, interaction: Blogging::ArchivePost
|
|
61
|
+
<span class="k">end</span>
|
|
62
|
+
|
|
63
|
+
<span class="c"># packages/blogging/app/interactions/blogging/publish_post.rb</span>
|
|
64
|
+
<span class="k">class</span> Blogging::PublishPost <span class="k"><</span> Blogging::ResourceInteraction
|
|
65
|
+
<span class="m">presents</span> label: <span class="s">"Publish Post"</span>, icon: Phlex::TablerIcons::Send
|
|
66
|
+
|
|
67
|
+
<span class="m">attribute</span> <span class="s">:resource</span>
|
|
68
|
+
|
|
69
|
+
<span class="k">def</span> <span class="f">execute</span>
|
|
70
|
+
resource.update!(status: <span class="s">:published</span>)
|
|
71
|
+
succeed(resource).with_message(<span class="s">"Post published successfully!"</span>)
|
|
72
|
+
<span class="k">end</span>
|
|
73
|
+
<span class="k">end</span>
|
|
74
|
+
|
|
75
|
+
<span class="c"># packages/blogging/app/policies/blogging/post_policy.rb</span>
|
|
76
|
+
<span class="k">class</span> Blogging::PostPolicy <span class="k"><</span> Blogging::ResourcePolicy
|
|
77
|
+
<span class="k">def</span> <span class="f">publish?</span> = record.draft?
|
|
78
|
+
<span class="k">def</span> <span class="f">archive?</span> = record.published?
|
|
79
|
+
<span class="k">end</span>`,
|
|
80
|
+
shot: "/images/home/tour-actions.png",
|
|
81
|
+
alt: "Blog posts table with the row actions menu open — Publish Post appears on the draft row only",
|
|
82
|
+
policy: "No publish? policy method, no button — it disappears, it doesn't disable.",
|
|
83
|
+
link: "/plutonium-core/guides/custom-actions",
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: "tenancy",
|
|
87
|
+
name: "Multi-tenancy & nesting",
|
|
88
|
+
hook: "Scoping, invites, nested resources",
|
|
89
|
+
file: "packages/org_portal/lib/engine.rb",
|
|
90
|
+
code: `<span class="k">module</span> OrgPortal
|
|
91
|
+
<span class="k">class</span> Engine <span class="k"><</span> Rails::Engine
|
|
92
|
+
<span class="k">include</span> Plutonium::Portal::Engine
|
|
93
|
+
|
|
94
|
+
config.after_initialize <span class="k">do</span>
|
|
95
|
+
<span class="m">scope_to_entity</span> ::Organization, strategy: <span class="s">:path</span>
|
|
96
|
+
<span class="k">end</span>
|
|
97
|
+
<span class="k">end</span>
|
|
98
|
+
<span class="k">end</span>
|
|
99
|
+
|
|
100
|
+
<span class="c"># → /org/1 — every count on this dashboard is scoped to organization 1</span>`,
|
|
101
|
+
shot: "/images/home/tour-tenancy.png",
|
|
102
|
+
alt: "Tenant-scoped portal dashboard where every resource card counts only the current organization's records",
|
|
103
|
+
policy: "Every query flows through the entity scope — no default_scope hacks.",
|
|
104
|
+
link: "/plutonium-core/guides/multi-tenancy",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: "wizards",
|
|
108
|
+
name: "Wizards",
|
|
109
|
+
hook: "Multi-step flows with branching & resume",
|
|
110
|
+
file: "app/wizards/onboard_organization_wizard.rb",
|
|
111
|
+
code: `<span class="k">class</span> OnboardOrganizationWizard <span class="k"><</span> Plutonium::Wizard::Base
|
|
112
|
+
<span class="m">presents</span> label: <span class="s">"Onboard an organization"</span>,
|
|
113
|
+
description: <span class="s">"Set up a workspace for your team — a few quick steps and you're in."</span>
|
|
114
|
+
|
|
115
|
+
<span class="m">step</span> <span class="s">:identity</span>, description: <span class="s">"Tell us who you are — this names the workspace."</span> <span class="k">do</span>
|
|
116
|
+
<span class="m">attribute</span> <span class="s">:name</span>, <span class="s">:string</span>
|
|
117
|
+
<span class="m">attribute</span> <span class="s">:plan</span>, <span class="s">:string</span>
|
|
118
|
+
<span class="m">attribute</span> <span class="s">:budget</span>, <span class="s">:decimal</span>
|
|
119
|
+
<span class="m">input</span> <span class="s">:plan</span>, as: <span class="s">:select</span>, choices: [[<span class="s">"Free"</span>, <span class="s">"free"</span>], [<span class="s">"Pro"</span>, <span class="s">"pro"</span>], [<span class="s">"Enterprise"</span>, <span class="s">"enterprise"</span>]]
|
|
120
|
+
<span class="m">input</span> <span class="s">:budget</span>, as: <span class="s">:currency</span>, unit: <span class="s">"$"</span>
|
|
121
|
+
<span class="m">validates</span> <span class="s">:name</span>, presence: <span class="k">true</span>
|
|
122
|
+
|
|
123
|
+
<span class="m">form_layout</span> <span class="k">do</span>
|
|
124
|
+
<span class="m">section</span> <span class="s">:basics</span>, <span class="s">:name</span>, <span class="s">:plan</span>, <span class="s">:budget</span>, label: <span class="s">"The basics"</span>
|
|
125
|
+
<span class="k">end</span>
|
|
126
|
+
<span class="k">end</span>
|
|
127
|
+
|
|
128
|
+
<span class="m">step</span> <span class="s">:details</span> <span class="k">do</span>
|
|
129
|
+
<span class="m">attribute</span> <span class="s">:note</span>, <span class="s">:string</span>
|
|
130
|
+
<span class="m">input</span> <span class="s">:note</span>, as: <span class="s">:textarea</span>
|
|
131
|
+
<span class="k">end</span>
|
|
132
|
+
|
|
133
|
+
<span class="m">step</span> <span class="s">:profile</span>, using: KitchenSink, fields: [<span class="s">:description</span>, <span class="s">:tier</span>]
|
|
134
|
+
|
|
135
|
+
<span class="m">step</span> <span class="s">:members</span> <span class="k">do</span>
|
|
136
|
+
<span class="m">structured_input</span> <span class="s">:invites</span>, repeat: <span class="n">5</span> <span class="k">do</span> |f|
|
|
137
|
+
f.<span class="m">input</span> <span class="s">:email</span>
|
|
138
|
+
f.<span class="m">input</span> <span class="s">:role</span>
|
|
139
|
+
<span class="k">end</span>
|
|
140
|
+
<span class="k">end</span>
|
|
141
|
+
|
|
142
|
+
<span class="m">review</span> label: <span class="s">"Review"</span>
|
|
143
|
+
|
|
144
|
+
<span class="k">def</span> <span class="f">execute</span>
|
|
145
|
+
org = Organization.create!(name: data.identity.name)
|
|
146
|
+
succeed(org).with_message(<span class="s">"Organization onboarded"</span>)
|
|
147
|
+
<span class="k">end</span>
|
|
148
|
+
<span class="k">end</span>`,
|
|
149
|
+
shot: "/images/home/tour-wizard.png",
|
|
150
|
+
alt: "Wizard step page — numbered stepper with Identity, Details, Profile, Members, and Review stops, and a typed step form",
|
|
151
|
+
policy: "Steps validate per-screen; the built-in review step gates the finish.",
|
|
152
|
+
link: "/plutonium-core/guides/wizards",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: "kanban",
|
|
156
|
+
name: "Kanban boards",
|
|
157
|
+
hook: "Drag-drop boards from one block",
|
|
158
|
+
file: "app/definitions/task_definition.rb",
|
|
159
|
+
code: `<span class="m">kanban</span> <span class="k">do</span>
|
|
160
|
+
<span class="m">card_fields</span> header: <span class="s">:title</span>, meta: [<span class="s">:status</span>]
|
|
161
|
+
|
|
162
|
+
<span class="m">column</span> <span class="s">:todo</span>, role: <span class="s">:backlog</span>,
|
|
163
|
+
scope: -> { where(status: <span class="s">"todo"</span>) },
|
|
164
|
+
on_enter: ->(task) { task.update!(status: <span class="s">"todo"</span>) }
|
|
165
|
+
|
|
166
|
+
<span class="m">column</span> <span class="s">:doing</span>, wip: <span class="n">3</span>,
|
|
167
|
+
scope: -> { where(status: <span class="s">"doing"</span>) },
|
|
168
|
+
on_enter: ->(task) { task.update!(status: <span class="s">"doing"</span>) }
|
|
169
|
+
|
|
170
|
+
<span class="m">column</span> <span class="s">:done</span>, role: <span class="s">:done</span>, accepts: [<span class="s">:doing</span>],
|
|
171
|
+
scope: -> { where(status: <span class="s">"done"</span>) },
|
|
172
|
+
on_enter: <span class="s">:mark_done!</span> <span class="k">do</span>
|
|
173
|
+
<span class="m">action</span> <span class="s">:archive_all</span>, interaction: ArchiveTasksInteraction, on: <span class="s">:all</span>, label: <span class="s">"Archive all"</span>
|
|
174
|
+
<span class="k">end</span>
|
|
175
|
+
<span class="k">end</span>`,
|
|
176
|
+
shot: "/images/home/tour-kanban.png",
|
|
177
|
+
alt: "Kanban board with Todo, Doing, and Done columns — WIP limit on Doing, quick-add on Todo, and an Archive all column action on Done",
|
|
178
|
+
policy: "Columns lock and drags are rejected server-side when kanban_move? says no.",
|
|
179
|
+
link: "/plutonium-core/guides/kanban",
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
id: "async",
|
|
183
|
+
name: "Async interactions",
|
|
184
|
+
hook: "Bulk work that outlives the request",
|
|
185
|
+
file: "packages/blogging/app/interactions/blogging/archive_posts.rb",
|
|
186
|
+
code: `<span class="k">class</span> Blogging::ArchivePosts <span class="k"><</span> Blogging::ResourceInteraction
|
|
187
|
+
<span class="m">presents</span> label: <span class="s">"Archive"</span>, icon: Phlex::TablerIcons::Archive
|
|
188
|
+
|
|
189
|
+
<span class="m">attribute</span> <span class="s">:resources</span> <span class="c"># bulk — perform_on runs once per record</span>
|
|
190
|
+
<span class="m">attribute</span> <span class="s">:reason</span>, <span class="s">:string</span>
|
|
191
|
+
|
|
192
|
+
<span class="m">async</span> <span class="k">do</span>
|
|
193
|
+
<span class="m">on_failure</span> <span class="s">:continue</span> <span class="c"># :halt (default) | :continue | :transactional</span>
|
|
194
|
+
|
|
195
|
+
<span class="k">def</span> <span class="f">perform_on</span>(post)
|
|
196
|
+
post.archive!(reason: options[<span class="s">"reason"</span>])
|
|
197
|
+
<span class="k">end</span>
|
|
198
|
+
<span class="k">end</span>
|
|
199
|
+
<span class="k">end</span>
|
|
200
|
+
|
|
201
|
+
<span class="c"># → persists a run, enqueues it, redirects to its live progress page</span>`,
|
|
202
|
+
shot: "/images/home/tour-async.png",
|
|
203
|
+
alt: "The run's progress page mid-flight — a Running badge, a progress bar at 27 of 50 targets, and the run's type, target type and initiator",
|
|
204
|
+
policy: "Permissions are re-derived when the job runs, never replayed from dispatch.",
|
|
205
|
+
link: "/plutonium-core/reference/behavior/async-interactions",
|
|
206
|
+
},
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
const selected = ref(features[0].id)
|
|
210
|
+
|
|
211
|
+
// One explicit row per feature head, with the last absorbing the panel's extra
|
|
212
|
+
// height. Derived from features.length — a hardcoded count silently drops any
|
|
213
|
+
// head past it into an implicit row below the grid.
|
|
214
|
+
const gridStyle = { gridTemplateRows: `repeat(${features.length - 1}, auto) 1fr` }
|
|
215
|
+
</script>
|
|
216
|
+
|
|
217
|
+
<style scoped>
|
|
218
|
+
.ft-sub { color: var(--pu-text-muted); font-size: 15px; margin: -16px 0 28px; }
|
|
219
|
+
|
|
220
|
+
.ft-grid {
|
|
221
|
+
display: grid;
|
|
222
|
+
grid-template-columns: 260px 1fr;
|
|
223
|
+
/* grid-template-rows is set inline from features.length — see gridStyle */
|
|
224
|
+
column-gap: 28px;
|
|
225
|
+
align-items: start;
|
|
226
|
+
}
|
|
227
|
+
.ft-head {
|
|
228
|
+
grid-column: 1;
|
|
229
|
+
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
|
230
|
+
width: 100%; text-align: left; cursor: pointer;
|
|
231
|
+
background: transparent; font-family: inherit;
|
|
232
|
+
border: 0; border-left: 3px solid transparent;
|
|
233
|
+
border-bottom: 1px solid var(--pu-border-soft);
|
|
234
|
+
padding: 14px 16px;
|
|
235
|
+
transition: border-color 0.15s ease, background 0.15s ease;
|
|
236
|
+
}
|
|
237
|
+
.ft-head:hover { background: var(--pu-bg-band); }
|
|
238
|
+
.ft-head--on { border-left-color: var(--pu-accent); background: var(--pu-bg-band); }
|
|
239
|
+
.ft-head-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
240
|
+
.ft-head b { font-size: 14.5px; font-weight: 600; color: var(--pu-text); }
|
|
241
|
+
.ft-head--on b { color: var(--pu-accent); }
|
|
242
|
+
.ft-head small { font-size: 12px; color: var(--pu-text-faint); }
|
|
243
|
+
.ft-chev { color: var(--pu-text-faint); flex-shrink: 0; display: none; }
|
|
244
|
+
|
|
245
|
+
.ft-body { grid-column: 2; grid-row: 1 / -1; min-width: 0; }
|
|
246
|
+
.ft-caption {
|
|
247
|
+
font-size: 11px; letter-spacing: 0.05em;
|
|
248
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
249
|
+
color: #8b949e; background: #161b22;
|
|
250
|
+
border: 1px solid #30363d; border-bottom: 0;
|
|
251
|
+
border-radius: 8px 8px 0 0; padding: 8px 14px;
|
|
252
|
+
overflow-wrap: anywhere;
|
|
253
|
+
}
|
|
254
|
+
.ft-code {
|
|
255
|
+
background: var(--pu-bg-dark); color: var(--pu-term-text);
|
|
256
|
+
border-radius: 0 0 8px 8px; margin: 0; padding: 14px 16px;
|
|
257
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
258
|
+
font-size: 12.5px; line-height: 1.6; overflow-x: auto; white-space: pre;
|
|
259
|
+
}
|
|
260
|
+
.ft-code :deep(.k) { color: #ff7b72; }
|
|
261
|
+
.ft-code :deep(.s) { color: #a5d6ff; }
|
|
262
|
+
.ft-code :deep(.m) { color: #d2a8ff; }
|
|
263
|
+
.ft-code :deep(.f) { color: #d2a8ff; }
|
|
264
|
+
.ft-code :deep(.n) { color: #79c0ff; }
|
|
265
|
+
.ft-code :deep(.c) { color: #8b949e; }
|
|
266
|
+
|
|
267
|
+
.ft-shot {
|
|
268
|
+
display: block; width: 100%; height: auto; margin-top: 14px;
|
|
269
|
+
border: 1px solid var(--pu-border-soft); border-radius: 8px;
|
|
270
|
+
}
|
|
271
|
+
.ft-foot {
|
|
272
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
273
|
+
gap: 12px; flex-wrap: wrap; margin-top: 12px;
|
|
274
|
+
}
|
|
275
|
+
.ft-policy {
|
|
276
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
277
|
+
font-size: 12.5px; color: var(--pu-text-muted);
|
|
278
|
+
}
|
|
279
|
+
.ft-policy svg { color: var(--pu-success-fg); flex-shrink: 0; }
|
|
280
|
+
.ft-link {
|
|
281
|
+
font-size: 13px; font-weight: 500; color: var(--pu-accent);
|
|
282
|
+
text-decoration: none; white-space: nowrap;
|
|
283
|
+
}
|
|
284
|
+
.ft-link:hover { text-decoration: underline; }
|
|
285
|
+
|
|
286
|
+
@media (max-width: 768px) {
|
|
287
|
+
.ft-grid { display: block; }
|
|
288
|
+
.ft-head { border: 1px solid var(--pu-border-soft); border-left-width: 3px; border-radius: 6px; margin-bottom: 8px; }
|
|
289
|
+
.ft-chev { display: block; transition: transform 0.15s ease; }
|
|
290
|
+
.ft-head--on .ft-chev { transform: rotate(180deg); }
|
|
291
|
+
.ft-body { margin: 4px 0 16px; }
|
|
292
|
+
}
|
|
293
|
+
</style>
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
Convention over configuration, extended to everything you keep rebuilding.
|
|
11
11
|
</p>
|
|
12
12
|
<p class="home-hero-pillars">
|
|
13
|
-
|
|
14
|
-
<b>Admin portals.</b> <b>Search, filters, bulk actions.</b>
|
|
13
|
+
<!-- keep the bolds on one source line: Vue drops whitespace-only nodes containing newlines -->
|
|
14
|
+
<b>CRUD.</b> <b>Auth.</b> <b>Authorization.</b> <b>Multi-tenancy.</b> <b>Wizards.</b> <b>Kanban.</b> <b>Admin portals.</b> <b>Search, filters, bulk actions.</b>
|
|
15
15
|
All generated. All customizable. All Rails.
|
|
16
16
|
</p>
|
|
17
17
|
<div class="home-hero-ctas">
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
<span class="dim"> create packages/admin_portal/...</span>
|
|
24
24
|
<span class="prompt">$</span> rails g pu:res:scaffold Post title:string body:text published_at:datetime --dest=main_app
|
|
25
25
|
<span class="dim"> create app/models/post.rb</span>
|
|
26
|
-
<span class="dim"> create app/
|
|
26
|
+
<span class="dim"> create app/definitions/post_definition.rb</span>
|
|
27
27
|
<span class="prompt">$</span> rails g pu:res:conn Post --dest=admin_portal
|
|
28
28
|
<span class="dim"> ✓ Connected Post to AdminPortal</span>
|
|
29
29
|
<span class="prompt">$</span> <span class="pu-term-cursor"></span></pre>
|
|
@@ -30,6 +30,14 @@ const cats = [
|
|
|
30
30
|
{ name: "Custom & bulk actions", desc: "Resource-scoped interactions",
|
|
31
31
|
link: "/plutonium-core/guides/custom-actions" },
|
|
32
32
|
]},
|
|
33
|
+
{ name: "Workflows", items: [
|
|
34
|
+
{ name: "Wizards", desc: "Multi-step flows with branching & resume",
|
|
35
|
+
link: "/plutonium-core/guides/wizards" },
|
|
36
|
+
{ name: "Kanban boards", desc: "Drag-drop boards from one block",
|
|
37
|
+
link: "/plutonium-core/guides/kanban" },
|
|
38
|
+
{ name: "Interactions", desc: "Business logic with auto-generated UI",
|
|
39
|
+
link: "/plutonium-core/guides/custom-actions" },
|
|
40
|
+
]},
|
|
33
41
|
{ name: "App structure", items: [
|
|
34
42
|
{ name: "Portals", desc: "Themed, mountable engines",
|
|
35
43
|
link: "/plutonium-core/reference/app/portals" },
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="pu-section pu-section--band">
|
|
3
|
+
<div class="pu-section-inner">
|
|
4
|
+
<h2 class="pu-section-title">Built on Rails. Wired for shipping.</h2>
|
|
5
|
+
|
|
6
|
+
<div class="wp-grid">
|
|
7
|
+
<div class="wp-card" v-for="p in pillars" :key="p.name">
|
|
8
|
+
<component :is="p.icon" class="wp-icon" :size="20" :stroke-width="1.75" />
|
|
9
|
+
<div class="wp-name">{{ p.name }}</div>
|
|
10
|
+
<div class="wp-desc">{{ p.desc }}</div>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<div class="wp-audiences">
|
|
15
|
+
<div class="wp-aud">
|
|
16
|
+
<div class="wp-aud-head">For Rails developers</div>
|
|
17
|
+
<p class="wp-aud-lede">The missing layer between Rails and the apps you keep building.</p>
|
|
18
|
+
<ul class="wp-aud-list">
|
|
19
|
+
<li><IconArrowRight class="wp-arr" :size="16" :stroke-width="2.25" aria-hidden="true" /><span>Convention extended to CRUD, policies, and portals</span></li>
|
|
20
|
+
<li><IconArrowRight class="wp-arr" :size="16" :stroke-width="2.25" aria-hidden="true" /><span>Generated code lives in your repo — edit anything</span></li>
|
|
21
|
+
</ul>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="wp-aud wp-aud--right">
|
|
24
|
+
<div class="wp-aud-head">For founders & teams</div>
|
|
25
|
+
<p class="wp-aud-lede">Skip the SaaS template debate. Plutonium turns Rails into a SaaS toolkit.</p>
|
|
26
|
+
<ul class="wp-aud-list">
|
|
27
|
+
<li><IconArrowRight class="wp-arr" :size="16" :stroke-width="2.25" aria-hidden="true" /><span>Admin panel, signup, and invites on day one</span></li>
|
|
28
|
+
<li><IconArrowRight class="wp-arr" :size="16" :stroke-width="2.25" aria-hidden="true" /><span>Multi-tenant scoping when you need it</span></li>
|
|
29
|
+
</ul>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</section>
|
|
34
|
+
</template>
|
|
35
|
+
|
|
36
|
+
<script setup>
|
|
37
|
+
import { IconRoute, IconCode, IconBuildingSkyscraper, IconRobot, IconArrowRight } from "@tabler/icons-vue"
|
|
38
|
+
|
|
39
|
+
const pillars = [
|
|
40
|
+
{ icon: IconRoute, name: "Convention over configuration",
|
|
41
|
+
desc: "Extended to resources, policies, portals, and tenancy — not just routes and views." },
|
|
42
|
+
{ icon: IconCode, name: "It's just Rails",
|
|
43
|
+
desc: 'Generated code lives in your repo. Edit it, override it, delete it. The “magic” is regular Ruby mixins you can read.' },
|
|
44
|
+
{ icon: IconBuildingSkyscraper, name: "Multi-tenant ready",
|
|
45
|
+
desc: "Path or domain tenancy. Scoped relations. Invites and memberships out of the box." },
|
|
46
|
+
{ icon: IconRobot, name: "AI-readable",
|
|
47
|
+
desc: "Predictable file layout and naming. Built-in skills teach AI assistants the patterns." },
|
|
48
|
+
]
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<style scoped>
|
|
52
|
+
.wp-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
|
|
53
|
+
.wp-card {
|
|
54
|
+
padding: 14px 16px; border: 1px solid var(--pu-border-soft); border-radius: 8px;
|
|
55
|
+
background: var(--pu-bg-light);
|
|
56
|
+
}
|
|
57
|
+
.wp-icon { color: var(--pu-accent); margin-bottom: 8px; display: block; }
|
|
58
|
+
.wp-name { font-weight: 600; font-size: 14px; color: var(--pu-text); margin-bottom: 4px; line-height: 1.25; }
|
|
59
|
+
.wp-desc { font-size: 12.5px; color: var(--pu-text-muted); line-height: 1.5; }
|
|
60
|
+
|
|
61
|
+
.wp-audiences {
|
|
62
|
+
display: grid; grid-template-columns: 1fr 1fr; gap: 28px;
|
|
63
|
+
margin-top: 28px; padding-top: 24px; border-top: 1px solid var(--pu-border);
|
|
64
|
+
}
|
|
65
|
+
.wp-aud--right { border-left: 1px solid var(--pu-border); padding-left: 28px; }
|
|
66
|
+
.wp-aud-head {
|
|
67
|
+
font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em;
|
|
68
|
+
color: var(--pu-accent); font-weight: 600; margin-bottom: 6px;
|
|
69
|
+
}
|
|
70
|
+
.wp-aud-lede {
|
|
71
|
+
font-size: 16px; line-height: 1.35; font-weight: 500; color: var(--pu-text);
|
|
72
|
+
margin: 0 0 10px; letter-spacing: -0.01em;
|
|
73
|
+
}
|
|
74
|
+
.wp-aud-list { list-style: none; padding: 0; margin: 0; font-size: 14px; line-height: 1.6; color: var(--pu-text-muted); }
|
|
75
|
+
.wp-aud-list li { display: flex; gap: 10px; align-items: flex-start; padding: 4px 0; }
|
|
76
|
+
.wp-arr { color: var(--pu-accent); flex-shrink: 0; margin-top: 3px; }
|
|
77
|
+
|
|
78
|
+
@media (max-width: 900px) { .wp-grid { grid-template-columns: repeat(2, 1fr); } }
|
|
79
|
+
@media (max-width: 768px) {
|
|
80
|
+
.wp-audiences { grid-template-columns: 1fr; }
|
|
81
|
+
.wp-aud--right { border-left: none; padding-left: 0; border-top: 1px solid var(--pu-border); padding-top: 20px; }
|
|
82
|
+
}
|
|
83
|
+
@media (max-width: 480px) { .wp-grid { grid-template-columns: 1fr; } }
|
|
84
|
+
</style>
|
|
@@ -6,24 +6,28 @@ import "./custom.css"
|
|
|
6
6
|
|
|
7
7
|
import HomeHero from "./components/HomeHero.vue"
|
|
8
8
|
import HomeStopWriting from "./components/HomeStopWriting.vue"
|
|
9
|
-
import HomePillars from "./components/HomePillars.vue"
|
|
10
9
|
import HomeWalkthrough from "./components/HomeWalkthrough.vue"
|
|
11
|
-
import
|
|
10
|
+
import HomeFeatureTour from "./components/HomeFeatureTour.vue"
|
|
11
|
+
import HomeWhyPlutonium from "./components/HomeWhyPlutonium.vue"
|
|
12
12
|
import HomeInTheBox from "./components/HomeInTheBox.vue"
|
|
13
13
|
import HomeCta from "./components/HomeCta.vue"
|
|
14
14
|
import SectionLanding from "./components/SectionLanding.vue"
|
|
15
|
+
import BlogIndex from "./components/BlogIndex.vue"
|
|
16
|
+
import BlogMeta from "./components/BlogMeta.vue"
|
|
15
17
|
|
|
16
18
|
export default {
|
|
17
19
|
extends: DefaultTheme,
|
|
18
20
|
enhanceApp({ app }) {
|
|
19
21
|
app.component("HomeHero", HomeHero)
|
|
20
22
|
app.component("HomeStopWriting", HomeStopWriting)
|
|
21
|
-
app.component("HomePillars", HomePillars)
|
|
22
23
|
app.component("HomeWalkthrough", HomeWalkthrough)
|
|
23
|
-
app.component("
|
|
24
|
+
app.component("HomeFeatureTour", HomeFeatureTour)
|
|
25
|
+
app.component("HomeWhyPlutonium", HomeWhyPlutonium)
|
|
24
26
|
app.component("HomeInTheBox", HomeInTheBox)
|
|
25
27
|
app.component("HomeCta", HomeCta)
|
|
26
28
|
app.component("SectionLanding", SectionLanding)
|
|
29
|
+
app.component("BlogIndex", BlogIndex)
|
|
30
|
+
app.component("BlogMeta", BlogMeta)
|
|
27
31
|
},
|
|
28
32
|
setup() {
|
|
29
33
|
const route = useRoute()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Plutonium association inputs post a signed id, not a database id"
|
|
3
|
+
titleTemplate: "Plutonium Blog"
|
|
4
|
+
date: 2026-09-28
|
|
5
|
+
description: A select whose values are primary keys is a form the user can edit. Signed Global IDs make the tampered version fail instead of succeed.
|
|
6
|
+
author: Stefan Froelich
|
|
7
|
+
tags: [security, forms, rails]
|
|
8
|
+
draft: true
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Plutonium association inputs post a signed id, not a database id
|
|
12
|
+
|
|
13
|
+
<BlogMeta />
|
|
14
|
+
|
|
15
|
+
Here is a form field every Rails app has:
|
|
16
|
+
|
|
17
|
+
```erb
|
|
18
|
+
<%= f.collection_select :author_id, User.all, :id, :name %>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
It renders `<option value="42">`. The user opens devtools, changes 42 to 7, and submits. Whether that matters depends entirely on whether the controller thought to check, which is a sentence that should make you uncomfortable, because "whether someone thought to check" is not a security model.
|
|
22
|
+
|
|
23
|
+
The usual defences are real but partial. Strong parameters decide *which* keys are allowed, not which values. A policy check on the parent record authorizes the edit, not the thing being pointed at. Scoping the collection you render controls what the select *offers*, and has no bearing on what the browser sends back.
|
|
24
|
+
|
|
25
|
+
## What Plutonium sends instead
|
|
26
|
+
|
|
27
|
+
Every association on a resource model gets Signed Global ID accessors, generated automatically:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
class Post < ResourceRecord
|
|
31
|
+
belongs_to :user
|
|
32
|
+
has_many :tags
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
post.user_sgid # => "BAh7..."
|
|
36
|
+
post.user_sgid = "BAh7..." # locates and assigns from the SGID
|
|
37
|
+
|
|
38
|
+
post.tag_sgids # => ["...", "..."]
|
|
39
|
+
post.tag_sgids = [sgid1, sgid2] # bulk replace
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The association input posts the SGID. A Global ID carries the class as well as the id, and signing it means the server can tell whether the value it received is one it issued.
|
|
43
|
+
|
|
44
|
+
Two consequences fall out.
|
|
45
|
+
|
|
46
|
+
**Tampering fails instead of succeeding.** Editing the option value produces a signature that does not verify, so the assignment raises rather than silently pointing at record 7. The failure mode moves from "wrong data, no error" to "error, no data," which is the direction you want.
|
|
47
|
+
|
|
48
|
+
**The class travels with the value.** `42` means nothing on its own; a Global ID says `gid://app/User/42`. A payload cannot smuggle a `Post` id into a field expecting a `User`, because the type is part of the signed value rather than an assumption the controller makes.
|
|
49
|
+
|
|
50
|
+
## What it is not
|
|
51
|
+
|
|
52
|
+
It is not authorization, and treating it as such is the mistake worth naming.
|
|
53
|
+
|
|
54
|
+
An SGID proves the server issued this reference. It says nothing about whether *this* user may point at *that* record. A signed id you were legitimately given yesterday is still legitimately signed today, after your access was revoked.
|
|
55
|
+
|
|
56
|
+
So the picker's options still come from `authorized_resource_scope`, which applies the target resource's own `relation_scope` and the current policy context. That is what decides which records a user may reference. The signature is a separate property: it stops the value being swapped for one that was never offered.
|
|
57
|
+
|
|
58
|
+
Two mechanisms, two questions. What may you see, and is this the thing you were shown. Conflating them gets you a system that is careful about exactly one of them.
|
|
59
|
+
|
|
60
|
+
## Why it can be automatic
|
|
61
|
+
|
|
62
|
+
The reason this is on by default rather than an opt-in helper is that it costs the developer nothing. The accessors are generated from the associations already declared on the model, so there is no new declaration, no field-level configuration, and no way to forget it on the one form written in a hurry.
|
|
63
|
+
|
|
64
|
+
That is the general shape of the useful kind of security default: not a feature you enable, but a thing that is already true because the framework could derive it from what you had already written. A defence you have to remember to apply is a defence with a failure rate equal to how often people are tired.
|
|
65
|
+
|
|
66
|
+
## When you will notice it
|
|
67
|
+
|
|
68
|
+
Mostly, never. The inputs render as ordinary selects with typeahead, and the values round-trip. You will notice on the day you look at a payload in the network tab and wonder why the author field contains `BAh7...` instead of a number.
|
|
69
|
+
|
|
70
|
+
The answer is that a number would have been a request, and this is a receipt.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Plutonium multi-tenancy: fix the model, not the policy"
|
|
3
|
+
titleTemplate: "Plutonium Blog"
|
|
4
|
+
date: 2026-08-27
|
|
5
|
+
description: A tenant leak is usually a modelling problem wearing a query costume. Patching it with a where clause in a policy fixes one read and leaves the others open.
|
|
6
|
+
author: Stefan Froelich
|
|
7
|
+
tags: [multi-tenancy, authorization, rails]
|
|
8
|
+
draft: true
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Plutonium multi-tenancy: fix the model, not the policy
|
|
12
|
+
|
|
13
|
+
<BlogMeta />
|
|
14
|
+
|
|
15
|
+
Most multi-tenancy bugs are not clever. They are a missing `where` on the one query somebody forgot, in the one place nobody looked.
|
|
16
|
+
|
|
17
|
+
That is worth sitting with, because it tells you what kind of problem it is. If the fix for a leak is "add the filter here too," then the filter is not a property of your data. It is a habit, applied by hand, at every read, forever, by everyone who joins the team. Habits have a failure rate.
|
|
18
|
+
|
|
19
|
+
Plutonium takes a different position: the relationship between a record and its tenant is a fact about your schema, and the framework should be able to *read* it rather than being told it repeatedly.
|
|
20
|
+
|
|
21
|
+
## What you declare
|
|
22
|
+
|
|
23
|
+
Once, on the portal:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
# packages/admin_portal/lib/engine.rb
|
|
27
|
+
module AdminPortal
|
|
28
|
+
class Engine < ::Rails::Engine
|
|
29
|
+
include Plutonium::Portal::Engine
|
|
30
|
+
|
|
31
|
+
scope_to_entity Organization, strategy: :path
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Then you design your models the way you would have anyway:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
class Project < ApplicationRecord
|
|
40
|
+
belongs_to :organization
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class Task < ApplicationRecord
|
|
44
|
+
belongs_to :project
|
|
45
|
+
has_one :organization, through: :project
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
That is the whole setup. Every query in that portal is scoped, every create is associated, every nested route is constrained, because `Model.associated_with(entity)` can see the path from a record to its tenant by reading your associations.
|
|
50
|
+
|
|
51
|
+
## How the path gets resolved
|
|
52
|
+
|
|
53
|
+
In a defined order, cheapest first:
|
|
54
|
+
|
|
55
|
+
1. **A custom scope** named `associated_with_<entity_name>`. Highest priority, full SQL control.
|
|
56
|
+
2. **A direct `belongs_to`** to the entity class. Plain `WHERE`, most efficient.
|
|
57
|
+
3. **`has_one` or `has_one :through`** to the entity class. A JOIN plus a WHERE, auto-detected by reflecting on the model's associations.
|
|
58
|
+
4. **A reverse `has_many`** from the entity. Works, requires a JOIN, and logs a warning because you can almost always do better.
|
|
59
|
+
|
|
60
|
+
If none apply, you get an error that names both classes:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
Could not resolve the association between 'Invoice' and 'Organization'
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
That error is the feature. It fires at the moment the relationship is undeclared, rather than letting the read succeed unscoped and become an incident later. A framework that guessed here, or silently returned everything, would be worse than one that stops.
|
|
67
|
+
|
|
68
|
+
## The wrong fix
|
|
69
|
+
|
|
70
|
+
The tempting response to that error is to reach for the policy:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
# Don't.
|
|
74
|
+
relation_scope do |relation|
|
|
75
|
+
skip_default_relation_scope!
|
|
76
|
+
relation.joins(:contract).where(contracts: {organization_id: entity_scope.id})
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
It works. It costs three things.
|
|
81
|
+
|
|
82
|
+
You needed `skip_default_relation_scope!` to get here, because an override is otherwise required to compose `default_relation_scope`, which calls the resolver that is already failing. So you switched off the framework's scoping in order to hand-write a replacement for it.
|
|
83
|
+
|
|
84
|
+
`default_relation_scope` does more than tenant filtering. On a nested route it scopes children through the parent association instead. Your entity join covers one of those two branches, so nested routes lose their scoping.
|
|
85
|
+
|
|
86
|
+
And it holds only where you wrote it. The next place that needs this relationship needs the join written again.
|
|
87
|
+
|
|
88
|
+
## The right fix
|
|
89
|
+
|
|
90
|
+
Put the resolution on the model, where the relationship actually lives:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
class Invoice < ApplicationRecord
|
|
94
|
+
belongs_to :contract
|
|
95
|
+
|
|
96
|
+
scope :associated_with_organization, ->(org) {
|
|
97
|
+
joins(:contract).where(contracts: {organization_id: org.id})
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Now `Invoice` scopes exactly the way `Project` does. The index, the show page, nested routes, the export, the typeahead and the authorization scope all behave identically, because they all go through the same resolver. You replaced one lookup, not the mechanism.
|
|
103
|
+
|
|
104
|
+
And the next person who adds a read path gets the scoping for free, without knowing this conversation happened.
|
|
105
|
+
|
|
106
|
+
## The general shape
|
|
107
|
+
|
|
108
|
+
This is convention-over-configuration doing the thing it is supposed to do, one layer above where Rails usually does it. The convention covers the common cases. The exception gets a named, obvious place to live. And critically, the exception lives on the **model**, so it applies everywhere the record is read, instead of everywhere somebody remembered to apply it.
|
|
109
|
+
|
|
110
|
+
The same instinct shows up in the sharp edges:
|
|
111
|
+
|
|
112
|
+
- **Two associations to the same tenant class** and Plutonium refuses to guess which one scopes the record. You override `scoped_entity_association` on the controller. A framework that picked one for you would be picking your security model by coin flip.
|
|
113
|
+
- **Debugging a scope** is `skip_default_relation_scope!`, an explicit, greppable opt-out. Not a `where` bypass that survives review because it looks like ordinary code.
|
|
114
|
+
- **A portal with no `scope_to_entity`** sees everything, which is the right way to build a super-admin view: an explicit absence of scoping, in one obvious place, rather than a special case threaded through policies.
|
|
115
|
+
|
|
116
|
+
## Where this stops
|
|
117
|
+
|
|
118
|
+
None of this protects you from a raw `Invoice.where(...)` in a script, or from a controller you hand-wrote that never asks the policy. It scopes the paths that go through Plutonium, which is most of them in a Plutonium app, and none of them outside it.
|
|
119
|
+
|
|
120
|
+
For cross-resource reads in your own code, the equivalent discipline is to use `authorized_resource_scope(OtherModel)` rather than `OtherModel.all`, because that applies the other resource's `relation_scope` and the current policy context instead of bypassing both.
|
|
121
|
+
|
|
122
|
+
The rule is short enough to remember: if scoping fails, the model is missing a relationship. Say what the relationship is. Do not describe the query to the policy.
|